diff --git a/.betterer.results b/.betterer.results index 57cc3ad8dce..c90b157dd4a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -203,7 +203,7 @@ exports[`no enzyme tests`] = { "public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx:809006195": [ [1, 35, 13, "RegExp match", "2409514259"] ], - "public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx:1798654441": [ + "public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx:3682134210": [ [1, 35, 13, "RegExp match", "2409514259"] ], "public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.test.tsx:3293585799": [ @@ -230,7 +230,7 @@ exports[`no enzyme tests`] = { "public/app/features/explore/LiveLogs.test.tsx:1667605379": [ [2, 17, 13, "RegExp match", "2409514259"] ], - "public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx:3948011811": [ + "public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx:492930613": [ [1, 17, 13, "RegExp match", "2409514259"] ], "public/app/features/explore/RunButton.test.tsx:138299098": [ @@ -290,7 +290,7 @@ exports[`no enzyme tests`] = { "public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx:2974837543": [ [1, 19, 13, "RegExp match", "2409514259"] ], - "public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx:132770839": [ + "public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx:3888529428": [ [1, 19, 13, "RegExp match", "2409514259"] ], "public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx:1089831034": [ diff --git a/.drone.yml b/.drone.yml index a3de2242549..b28736013e1 100644 --- a/.drone.yml +++ b/.drone.yml @@ -9,22 +9,32 @@ platform: os: linux services: [] steps: -- commands: - - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl - - chmod +x bin/grabpl - image: byrnedo/alpine-curl:0.1.8 - name: grabpl - commands: - echo $DRONE_RUNNER_NAME image: alpine:3.15 name: identify-runner - commands: - - make gen-go + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - - yarn install --immutable + depends_on: + - grabpl image: grafana/build-container:1.5.3 - name: initialize + name: gen-version +- commands: + - make gen-go + image: grafana/build-container:1.5.3 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - grabpl + image: grafana/build-container:1.5.3 + name: yarn-install - commands: - ./bin/grabpl verify-drone depends_on: @@ -41,20 +51,18 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - depends_on: - - initialize image: grafana/build-container:1.5.3 name: codespell - commands: - ./bin/grabpl shellcheck depends_on: - - initialize + - grabpl image: grafana/build-container:1.5.3 name: shellcheck - commands: - ./bin/grabpl lint-backend --edition oss depends_on: - - initialize + - wire-install environment: CGO_ENABLED: "1" image: grafana/build-container:1.5.3 @@ -65,7 +73,7 @@ steps: - yarn run i18n:compile - yarn run typecheck depends_on: - - initialize + - yarn-install environment: TEST_MAX_WORKERS: 50% image: grafana/build-container:1.5.3 @@ -73,19 +81,19 @@ steps: - commands: - ./bin/grabpl test-backend --edition oss depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend - commands: - ./bin/grabpl integration-tests --edition oss depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend-integration - commands: - yarn run ci:test-frontend depends_on: - - initialize + - yarn-install environment: TEST_MAX_WORKERS: 50% image: grafana/build-container:1.5.3 @@ -112,34 +120,45 @@ platform: os: linux services: [] steps: -- commands: - - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl - - chmod +x bin/grabpl - image: byrnedo/alpine-curl:0.1.8 - name: grabpl - commands: - echo $DRONE_RUNNER_NAME image: alpine:3.15 name: identify-runner - commands: - - make gen-go + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - - yarn install --immutable + depends_on: + - grabpl image: grafana/build-container:1.5.3 - name: initialize + name: gen-version +- commands: + - make gen-go + image: grafana/build-container:1.5.3 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - grabpl + image: grafana/build-container:1.5.3 + name: yarn-install - commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --variants linux-amd64,linux-amd64-musl,darwin-amd64,windows-amd64,armv6 depends_on: - - initialize - environment: {} + - gen-version + - wire-install image: grafana/build-container:1.5.3 name: build-backend - commands: - ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - - initialize + - gen-version + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 @@ -147,7 +166,8 @@ steps: - commands: - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - - initialize + - gen-version + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 @@ -155,7 +175,8 @@ steps: - commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss depends_on: - - initialize + - gen-version + - yarn-install environment: null image: grafana/build-container:1.5.3 name: build-plugins @@ -356,10 +377,14 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 + name: identify-runner - commands: - apt-get update - apt-get install -yq postgresql-client @@ -420,7 +445,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -429,11 +454,17 @@ steps: image: alpine:3.15 name: identify-runner - commands: - - make gen-go - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - - yarn install --immutable + depends_on: + - grabpl image: grafana/build-container:1.5.3 - name: initialize + name: gen-version +- commands: + - yarn install --immutable + depends_on: + - grabpl + image: grafana/build-container:1.5.3 + name: yarn-install - commands: - |- echo -e "unknwon @@ -444,14 +475,12 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - depends_on: - - initialize image: grafana/build-container:1.5.3 name: codespell - commands: - yarn run prettier:checkDocs depends_on: - - initialize + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 @@ -459,7 +488,8 @@ steps: - commands: - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - - initialize + - gen-version + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 @@ -503,7 +533,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -512,11 +542,17 @@ steps: image: alpine:3.15 name: identify-runner - commands: - - make gen-go - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - - yarn install --immutable + depends_on: + - grabpl image: grafana/build-container:1.5.3 - name: initialize + name: gen-version +- commands: + - yarn install --immutable + depends_on: + - grabpl + image: grafana/build-container:1.5.3 + name: yarn-install - commands: - |- echo -e "unknwon @@ -527,14 +563,12 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - depends_on: - - initialize image: grafana/build-container:1.5.3 name: codespell - commands: - yarn run prettier:checkDocs depends_on: - - initialize + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 @@ -542,7 +576,8 @@ steps: - commands: - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - - initialize + - gen-version + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 @@ -581,22 +616,32 @@ platform: os: linux services: [] steps: -- commands: - - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl - - chmod +x bin/grabpl - image: byrnedo/alpine-curl:0.1.8 - name: grabpl - commands: - echo $DRONE_RUNNER_NAME image: alpine:3.15 name: identify-runner - commands: - - make gen-go + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - - yarn install --immutable + depends_on: + - grabpl image: grafana/build-container:1.5.3 - name: initialize + name: gen-version +- commands: + - make gen-go + image: grafana/build-container:1.5.3 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - grabpl + image: grafana/build-container:1.5.3 + name: yarn-install - commands: - ./bin/grabpl verify-drone depends_on: @@ -613,20 +658,18 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - depends_on: - - initialize image: grafana/build-container:1.5.3 name: codespell - commands: - ./bin/grabpl shellcheck depends_on: - - initialize + - grabpl image: grafana/build-container:1.5.3 name: shellcheck - commands: - ./bin/grabpl lint-backend --edition oss depends_on: - - initialize + - wire-install environment: CGO_ENABLED: "1" image: grafana/build-container:1.5.3 @@ -637,7 +680,7 @@ steps: - yarn run i18n:compile - yarn run typecheck depends_on: - - initialize + - yarn-install environment: TEST_MAX_WORKERS: 50% image: grafana/build-container:1.5.3 @@ -645,19 +688,19 @@ steps: - commands: - ./bin/grabpl test-backend --edition oss depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend - commands: - ./bin/grabpl integration-tests --edition oss depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend-integration - commands: - yarn run ci:test-frontend depends_on: - - initialize + - yarn-install environment: TEST_MAX_WORKERS: 50% image: grafana/build-container:1.5.3 @@ -682,22 +725,32 @@ platform: os: linux services: [] steps: -- commands: - - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl - - chmod +x bin/grabpl - image: byrnedo/alpine-curl:0.1.8 - name: grabpl - commands: - echo $DRONE_RUNNER_NAME image: alpine:3.15 name: identify-runner - commands: - - make gen-go + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - - yarn install --immutable + depends_on: + - grabpl image: grafana/build-container:1.5.3 - name: initialize + name: gen-version +- commands: + - make gen-go + image: grafana/build-container:1.5.3 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - grabpl + image: grafana/build-container:1.5.3 + name: yarn-install - commands: - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" --depth=1 @@ -739,14 +792,15 @@ steps: - commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - - initialize - environment: {} + - gen-version + - wire-install image: grafana/build-container:1.5.3 name: build-backend - commands: - ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - - initialize + - gen-version + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 @@ -754,7 +808,8 @@ steps: - commands: - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - - initialize + - gen-version + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 @@ -762,7 +817,8 @@ steps: - commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss --sign --signing-admin depends_on: - - initialize + - gen-version + - yarn-install environment: GRAFANA_API_KEY: from_secret: grafana_api_key @@ -795,8 +851,6 @@ steps: - build-frontend - build-frontend-packages environment: - GITHUB_TOKEN: - from_secret: github_token GPG_KEY_PASSWORD: from_secret: gpg_key_password GPG_PRIV_KEY: @@ -899,10 +953,7 @@ steps: image: grafana/build-container:1.5.3 name: build-storybook - commands: - - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json - - gcloud auth activate-service-account --key-file=/tmp/gcpkey.json - - gsutil -m rm -r gs://$${PRERELEASE_BUCKET}/artifacts/storybook/canary || true - && gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${PRERELEASE_BUCKET}/artifacts/storybook/canary + - ./bin/grabpl store-storybook --deployment canary --src-bucket grafana-storybook depends_on: - build-storybook - end-to-end-tests-dashboards-suite @@ -1112,10 +1163,14 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 + name: identify-runner - commands: - apt-get update - apt-get install -yq postgresql-client @@ -1180,10 +1235,10 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 - name: initialize + name: windows-init - commands: - $$gcpKey = $$env:GCP_KEY - '[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) @@ -1199,7 +1254,7 @@ steps: - gsutil cp $$fname gs://grafana-downloads/oss/main/ - gsutil cp "$$fname.sha256" gs://grafana-downloads/oss/main/ depends_on: - - initialize + - windows-init environment: GCP_KEY: from_secret: gcp_key @@ -1267,7 +1322,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1275,10 +1330,6 @@ steps: - echo $DRONE_RUNNER_NAME image: alpine:3.15 name: identify-runner -- commands: - - make gen-go - image: grafana/build-container:1.5.3 - name: initialize - commands: - ./bin/grabpl store-packages --edition oss --gcp-key /tmp/gcpkey.json --build-id ${DRONE_BUILD_NUMBER} @@ -1349,47 +1400,53 @@ platform: os: linux services: [] steps: -- commands: - - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl - - chmod +x bin/grabpl - image: byrnedo/alpine-curl:0.1.8 - name: grabpl - commands: - echo $DRONE_RUNNER_NAME image: alpine:3.15 name: identify-runner - commands: - - make gen-go - - ./bin/grabpl verify-version ${DRONE_TAG} - - ./bin/grabpl gen-version ${DRONE_TAG} - - yarn install --immutable - image: grafana/build-container:1.5.3 - name: initialize + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl - commands: - - ./bin/grabpl build-backend --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} - ${DRONE_TAG} + - ./bin/grabpl gen-version ${DRONE_TAG} depends_on: - - initialize - environment: - GITHUB_TOKEN: - from_secret: github_token + - grabpl + image: grafana/build-container:1.5.3 + name: gen-version +- commands: + - make gen-go + image: grafana/build-container:1.5.3 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - grabpl + image: grafana/build-container:1.5.3 + name: yarn-install +- commands: + - ./bin/grabpl build-backend --jobs 8 --edition oss ${DRONE_TAG} + depends_on: + - gen-version + - wire-install image: grafana/build-container:1.5.3 name: build-backend - commands: - - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --edition - oss ${DRONE_TAG} + - ./bin/grabpl build-frontend --jobs 8 --edition oss ${DRONE_TAG} depends_on: - - initialize + - gen-version + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 name: build-frontend - commands: - - ./bin/grabpl build-frontend-packages --jobs 8 --github-token $${GITHUB_TOKEN} - --edition oss ${DRONE_TAG} + - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss ${DRONE_TAG} depends_on: - - initialize + - gen-version + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 @@ -1397,7 +1454,8 @@ steps: - commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss --sign --signing-admin depends_on: - - initialize + - gen-version + - yarn-install environment: GRAFANA_API_KEY: from_secret: grafana_api_key @@ -1423,16 +1481,13 @@ steps: image: grafana/build-container:1.5.3 name: ensure-cuetsified - commands: - - ./bin/grabpl package --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --sign - ${DRONE_TAG} + - ./bin/grabpl package --jobs 8 --edition oss --sign ${DRONE_TAG} depends_on: - build-plugins - build-backend - build-frontend - build-frontend-packages environment: - GITHUB_TOKEN: - from_secret: github_token GPG_KEY_PASSWORD: from_secret: gpg_key_password GPG_PRIV_KEY: @@ -1592,12 +1647,10 @@ steps: image: grafana/grafana-ci-deploy:1.3.1 name: upload-packages - commands: - - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json - - gcloud auth activate-service-account --key-file=/tmp/gcpkey.json - - gsutil -m rm -r gs://$${PRERELEASE_BUCKET}/artifacts/storybook/latest || true - && gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${PRERELEASE_BUCKET}/artifacts/storybook/latest - - gsutil -m rm -r gs://$${PRERELEASE_BUCKET}/artifacts/storybook/${DRONE_TAG} || - true && gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${PRERELEASE_BUCKET}/artifacts/storybook/${DRONE_TAG} + - ./bin/grabpl store-storybook --deployment latest --src-bucket grafana-prerelease + --src-dir artifacts/storybook + - ./bin/grabpl store-storybook --deployment ${DRONE_TAG} --src-bucket grafana-prerelease + --src-dir artifacts/storybook depends_on: - build-storybook - end-to-end-tests-dashboards-suite @@ -1653,23 +1706,38 @@ platform: os: linux services: [] steps: -- commands: - - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl - - chmod +x bin/grabpl - image: byrnedo/alpine-curl:0.1.8 - name: grabpl - commands: - echo $DRONE_RUNNER_NAME image: alpine:3.15 name: identify-runner - commands: - - make gen-go - - ./bin/grabpl verify-version ${DRONE_TAG} + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: - ./bin/grabpl gen-version ${DRONE_TAG} - - yarn install --immutable + depends_on: + - grabpl image: grafana/build-container:1.5.3 - name: initialize + name: gen-version +- commands: + - make gen-go + image: grafana/build-container:1.5.3 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - grabpl + image: grafana/build-container:1.5.3 + name: yarn-install +- commands: + - ./bin/grabpl shellcheck + depends_on: + - grabpl + image: grafana/build-container:1.5.3 + name: shellcheck - commands: - |- echo -e "unknwon @@ -1680,20 +1748,12 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - depends_on: - - initialize image: grafana/build-container:1.5.3 name: codespell -- commands: - - ./bin/grabpl shellcheck - depends_on: - - initialize - image: grafana/build-container:1.5.3 - name: shellcheck - commands: - ./bin/grabpl lint-backend --edition oss depends_on: - - initialize + - wire-install environment: CGO_ENABLED: "1" image: grafana/build-container:1.5.3 @@ -1704,7 +1764,7 @@ steps: - yarn run i18n:compile - yarn run typecheck depends_on: - - initialize + - yarn-install environment: TEST_MAX_WORKERS: 50% image: grafana/build-container:1.5.3 @@ -1712,19 +1772,19 @@ steps: - commands: - ./bin/grabpl test-backend --edition oss depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend - commands: - ./bin/grabpl integration-tests --edition oss depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend-integration - commands: - yarn run ci:test-frontend depends_on: - - initialize + - yarn-install environment: TEST_MAX_WORKERS: 50% image: grafana/build-container:1.5.3 @@ -1776,7 +1836,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1784,13 +1844,6 @@ steps: - echo $DRONE_RUNNER_NAME image: alpine:3.15 name: identify-runner -- commands: - - make gen-go - - ./bin/grabpl verify-version ${DRONE_TAG} - - ./bin/grabpl gen-version ${DRONE_TAG} - - yarn install --immutable - image: grafana/build-container:1.5.3 - name: initialize - commands: - apt-get update - apt-get install -yq postgresql-client @@ -1860,10 +1913,10 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 - name: initialize + name: windows-init - commands: - $$gcpKey = $$env:GCP_KEY - '[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) @@ -1878,7 +1931,7 @@ steps: - gsutil cp $$fname gs://%PRERELEASE_BUCKET%/artifacts/downloads/${DRONE_TAG}/oss/release/ - gsutil cp "$$fname.sha256" gs://%PRERELEASE_BUCKET%/artifacts/downloads/${DRONE_TAG}/oss/release/ depends_on: - - initialize + - windows-init environment: GCP_KEY: from_secret: gcp_key @@ -1919,7 +1972,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1945,41 +1998,52 @@ steps: - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json - mkdir bin - mv /tmp/grabpl bin/ - - make gen-go - - ./bin/grabpl verify-version ${DRONE_TAG} - - ./bin/grabpl gen-version ${DRONE_TAG} - - yarn install --immutable depends_on: - clone-enterprise environment: GITHUB_TOKEN: from_secret: github_token image: grafana/build-container:1.5.3 - name: initialize + name: init-enterprise - commands: - - ./bin/grabpl build-backend --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} - ${DRONE_TAG} + - make gen-go depends_on: - - initialize - environment: - GITHUB_TOKEN: - from_secret: github_token + - init-enterprise + image: grafana/build-container:1.5.3 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - init-enterprise + image: grafana/build-container:1.5.3 + name: yarn-install +- commands: + - ./bin/grabpl gen-version ${DRONE_TAG} + depends_on: + - init-enterprise + image: grafana/build-container:1.5.3 + name: gen-version +- commands: + - ./bin/grabpl build-backend --jobs 8 --edition enterprise ${DRONE_TAG} + depends_on: + - gen-version + - wire-install image: grafana/build-container:1.5.3 name: build-backend - commands: - - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --edition - enterprise ${DRONE_TAG} + - ./bin/grabpl build-frontend --jobs 8 --edition enterprise ${DRONE_TAG} depends_on: - - initialize + - gen-version + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 name: build-frontend - commands: - - ./bin/grabpl build-frontend-packages --jobs 8 --github-token $${GITHUB_TOKEN} - --edition enterprise ${DRONE_TAG} + - ./bin/grabpl build-frontend-packages --jobs 8 --edition enterprise ${DRONE_TAG} depends_on: - - initialize + - gen-version + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 @@ -1987,7 +2051,8 @@ steps: - commands: - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --sign --signing-admin depends_on: - - initialize + - gen-version + - yarn-install environment: GRAFANA_API_KEY: from_secret: grafana_api_key @@ -2013,18 +2078,14 @@ steps: image: grafana/build-container:1.5.3 name: ensure-cuetsified - commands: - - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} - ${DRONE_TAG} + - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 ${DRONE_TAG} depends_on: - - initialize - environment: - GITHUB_TOKEN: - from_secret: github_token + - gen-version + - wire-install image: grafana/build-container:1.5.3 name: build-backend-enterprise2 - commands: - - ./bin/grabpl package --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} - --sign ${DRONE_TAG} + - ./bin/grabpl package --jobs 8 --edition enterprise --sign ${DRONE_TAG} depends_on: - build-plugins - build-backend @@ -2032,8 +2093,6 @@ steps: - build-frontend-packages - build-backend-enterprise2 environment: - GITHUB_TOKEN: - from_secret: github_token GPG_KEY_PASSWORD: from_secret: gpg_key_password GPG_PRIV_KEY: @@ -2192,8 +2251,7 @@ steps: image: grafana/grafana-ci-deploy:1.3.1 name: store-npm-packages - commands: - - ./bin/grabpl package --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} - --sign ${DRONE_TAG} + - ./bin/grabpl package --jobs 8 --edition enterprise2 --sign ${DRONE_TAG} depends_on: - build-plugins - build-backend @@ -2201,8 +2259,6 @@ steps: - build-frontend-packages - build-backend-enterprise2 environment: - GITHUB_TOKEN: - from_secret: github_token GPG_KEY_PASSWORD: from_secret: gpg_key_password GPG_PRIV_KEY: @@ -2273,7 +2329,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2299,17 +2355,31 @@ steps: - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json - mkdir bin - mv /tmp/grabpl bin/ - - make gen-go - - ./bin/grabpl verify-version ${DRONE_TAG} - - ./bin/grabpl gen-version ${DRONE_TAG} - - yarn install --immutable depends_on: - clone-enterprise environment: GITHUB_TOKEN: from_secret: github_token image: grafana/build-container:1.5.3 - name: initialize + name: init-enterprise +- commands: + - make gen-go + depends_on: + - init-enterprise + image: grafana/build-container:1.5.3 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - init-enterprise + image: grafana/build-container:1.5.3 + name: yarn-install +- commands: + - ./bin/grabpl gen-version ${DRONE_TAG} + depends_on: + - init-enterprise + image: grafana/build-container:1.5.3 + name: gen-version - commands: - |- echo -e "unknwon @@ -2320,20 +2390,12 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - depends_on: - - initialize image: grafana/build-container:1.5.3 name: codespell -- commands: - - ./bin/grabpl shellcheck - depends_on: - - initialize - image: grafana/build-container:1.5.3 - name: shellcheck - commands: - ./bin/grabpl lint-backend --edition enterprise depends_on: - - initialize + - wire-install environment: CGO_ENABLED: "1" image: grafana/build-container:1.5.3 @@ -2344,7 +2406,7 @@ steps: - yarn run i18n:compile - yarn run typecheck depends_on: - - initialize + - yarn-install environment: TEST_MAX_WORKERS: 50% image: grafana/build-container:1.5.3 @@ -2352,19 +2414,19 @@ steps: - commands: - ./bin/grabpl test-backend --edition enterprise depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend - commands: - ./bin/grabpl integration-tests --edition enterprise depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend-integration - commands: - yarn run ci:test-frontend depends_on: - - initialize + - yarn-install environment: TEST_MAX_WORKERS: 50% image: grafana/build-container:1.5.3 @@ -2372,7 +2434,7 @@ steps: - commands: - ./bin/grabpl lint-backend --edition enterprise2 depends_on: - - initialize + - wire-install environment: CGO_ENABLED: "1" image: grafana/build-container:1.5.3 @@ -2380,13 +2442,13 @@ steps: - commands: - ./bin/grabpl test-backend --edition enterprise2 depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend-enterprise2 - commands: - ./bin/grabpl integration-tests --edition enterprise2 depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend-integration-enterprise2 trigger: @@ -2446,7 +2508,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2472,17 +2534,13 @@ steps: - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json - mkdir bin - mv /tmp/grabpl bin/ - - make gen-go - - ./bin/grabpl verify-version ${DRONE_TAG} - - ./bin/grabpl gen-version ${DRONE_TAG} - - yarn install --immutable depends_on: - clone-enterprise environment: GITHUB_TOKEN: from_secret: github_token image: grafana/build-container:1.5.3 - name: initialize + name: init-enterprise - commands: - apt-get update - apt-get install -yq postgresql-client @@ -2491,7 +2549,7 @@ steps: - go clean -testcache - ./bin/grabpl integration-tests --database postgres depends_on: - - initialize + - init-enterprise environment: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest @@ -2507,7 +2565,7 @@ steps: - go clean -testcache - ./bin/grabpl integration-tests --database mysql depends_on: - - initialize + - init-enterprise environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql @@ -2517,7 +2575,7 @@ steps: - dockerize -wait tcp://redis:6379/0 -timeout 120s - ./bin/grabpl integration-tests depends_on: - - initialize + - init-enterprise environment: REDIS_URL: redis://redis:6379/0 image: grafana/build-container:1.5.3 @@ -2526,7 +2584,7 @@ steps: - dockerize -wait tcp://memcached:11211 -timeout 120s - ./bin/grabpl integration-tests depends_on: - - initialize + - init-enterprise environment: MEMCACHED_HOSTS: memcached:11211 image: grafana/build-container:1.5.3 @@ -2574,7 +2632,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -2597,7 +2655,7 @@ steps: GITHUB_TOKEN: from_secret: github_token image: grafana/ci-wix:0.1.1 - name: initialize + name: windows-init - commands: - $$gcpKey = $$env:GCP_KEY - '[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) @@ -2612,7 +2670,7 @@ steps: - gsutil cp $$fname gs://%PRERELEASE_BUCKET%/artifacts/downloads/${DRONE_TAG}/enterprise/release/ - gsutil cp "$$fname.sha256" gs://%PRERELEASE_BUCKET%/artifacts/downloads/${DRONE_TAG}/enterprise/release/ depends_on: - - initialize + - windows-init environment: GCP_KEY: from_secret: gcp_key @@ -2649,7 +2707,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2727,7 +2785,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2788,7 +2846,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2867,7 +2925,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2929,17 +2987,19 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - - ./bin/grabpl artifacts publish --security --tag ${TAG} --src-bucket grafana-prerelease + - ./bin/grabpl artifacts publish --security --tag ${TAG} --src-bucket $${PRERELEASE_BUCKET} depends_on: - grabpl environment: GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: publish-artifacts trigger: @@ -2965,17 +3025,19 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - - ./bin/grabpl artifacts publish --tag ${TAG} --src-bucket grafana-prerelease + - ./bin/grabpl artifacts publish --tag ${TAG} --src-bucket $${PRERELEASE_BUCKET} depends_on: - grabpl environment: GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: publish-artifacts trigger: @@ -2999,27 +3061,16 @@ platform: os: linux services: [] steps: -- commands: - - echo $DRONE_RUNNER_NAME - image: alpine:3.15 - name: identify-runner -- commands: - - make gen-go - - ./bin/grabpl verify-version ${DRONE_TAG} - - ./bin/grabpl gen-version ${DRONE_TAG} - - yarn install --immutable - image: grafana/build-container:1.5.3 - name: initialize - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - ./bin/grabpl artifacts npm retrieve --tag v${TAG} depends_on: - - initialize + - grabpl environment: GCP_KEY: from_secret: gcp_key @@ -3062,7 +3113,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3123,33 +3174,44 @@ platform: os: linux services: [] steps: -- commands: - - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl - - chmod +x bin/grabpl - image: byrnedo/alpine-curl:0.1.8 - name: grabpl - commands: - echo $DRONE_RUNNER_NAME image: alpine:3.15 name: identify-runner - commands: - - make gen-go + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - - yarn install --immutable + depends_on: + - grabpl image: grafana/build-container:1.5.3 - name: initialize + name: gen-version +- commands: + - make gen-go + image: grafana/build-container:1.5.3 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - grabpl + image: grafana/build-container:1.5.3 + name: yarn-install - commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - - initialize - environment: {} + - gen-version + - wire-install image: grafana/build-container:1.5.3 name: build-backend - commands: - ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - - initialize + - gen-version + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 @@ -3157,7 +3219,8 @@ steps: - commands: - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - - initialize + - gen-version + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 @@ -3165,7 +3228,8 @@ steps: - commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss --sign --signing-admin depends_on: - - initialize + - gen-version + - yarn-install environment: GRAFANA_API_KEY: from_secret: grafana_api_key @@ -3198,8 +3262,6 @@ steps: - build-frontend - build-frontend-packages environment: - GITHUB_TOKEN: - from_secret: github_token GPG_KEY_PASSWORD: from_secret: gpg_key_password GPG_PRIV_KEY: @@ -3388,22 +3450,38 @@ platform: os: linux services: [] steps: -- commands: - - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl - - chmod +x bin/grabpl - image: byrnedo/alpine-curl:0.1.8 - name: grabpl - commands: - echo $DRONE_RUNNER_NAME image: alpine:3.15 name: identify-runner - commands: - - make gen-go + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - - yarn install --immutable + depends_on: + - grabpl image: grafana/build-container:1.5.3 - name: initialize + name: gen-version +- commands: + - make gen-go + image: grafana/build-container:1.5.3 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - grabpl + image: grafana/build-container:1.5.3 + name: yarn-install +- commands: + - ./bin/grabpl shellcheck + depends_on: + - grabpl + image: grafana/build-container:1.5.3 + name: shellcheck - commands: - |- echo -e "unknwon @@ -3414,20 +3492,12 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - depends_on: - - initialize image: grafana/build-container:1.5.3 name: codespell -- commands: - - ./bin/grabpl shellcheck - depends_on: - - initialize - image: grafana/build-container:1.5.3 - name: shellcheck - commands: - ./bin/grabpl lint-backend --edition oss depends_on: - - initialize + - wire-install environment: CGO_ENABLED: "1" image: grafana/build-container:1.5.3 @@ -3438,7 +3508,7 @@ steps: - yarn run i18n:compile - yarn run typecheck depends_on: - - initialize + - yarn-install environment: TEST_MAX_WORKERS: 50% image: grafana/build-container:1.5.3 @@ -3446,19 +3516,19 @@ steps: - commands: - ./bin/grabpl test-backend --edition oss depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend - commands: - ./bin/grabpl integration-tests --edition oss depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend-integration - commands: - yarn run ci:test-frontend depends_on: - - initialize + - yarn-install environment: TEST_MAX_WORKERS: 50% image: grafana/build-container:1.5.3 @@ -3504,7 +3574,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3512,12 +3582,6 @@ steps: - echo $DRONE_RUNNER_NAME image: alpine:3.15 name: identify-runner -- commands: - - make gen-go - - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - - yarn install --immutable - image: grafana/build-container:1.5.3 - name: initialize - commands: - apt-get update - apt-get install -yq postgresql-client @@ -3581,10 +3645,10 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 - name: initialize + name: windows-init - commands: - $$gcpKey = $$env:GCP_KEY - '[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) @@ -3594,7 +3658,7 @@ steps: - rm gcpkey.json - cp C:\App\nssm-2.24.zip . depends_on: - - initialize + - windows-init environment: GCP_KEY: from_secret: gcp_key @@ -3629,7 +3693,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3654,25 +3718,41 @@ steps: - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json - mkdir bin - mv /tmp/grabpl bin/ - - make gen-go - - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - - yarn install --immutable depends_on: - clone-enterprise environment: {} image: grafana/build-container:1.5.3 - name: initialize + name: init-enterprise +- commands: + - make gen-go + depends_on: + - init-enterprise + image: grafana/build-container:1.5.3 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - init-enterprise + image: grafana/build-container:1.5.3 + name: yarn-install +- commands: + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - init-enterprise + image: grafana/build-container:1.5.3 + name: gen-version - commands: - ./bin/grabpl build-backend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} depends_on: - - initialize - environment: {} + - gen-version + - wire-install image: grafana/build-container:1.5.3 name: build-backend - commands: - ./bin/grabpl build-frontend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} depends_on: - - initialize + - gen-version + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 @@ -3681,7 +3761,8 @@ steps: - ./bin/grabpl build-frontend-packages --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} depends_on: - - initialize + - gen-version + - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.3 @@ -3689,7 +3770,8 @@ steps: - commands: - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --sign --signing-admin depends_on: - - initialize + - gen-version + - yarn-install environment: GRAFANA_API_KEY: from_secret: grafana_api_key @@ -3718,8 +3800,8 @@ steps: - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} --variants linux-amd64 depends_on: - - initialize - environment: {} + - gen-version + - wire-install image: grafana/build-container:1.5.3 name: build-backend-enterprise2 - commands: @@ -3732,8 +3814,6 @@ steps: - build-frontend-packages - build-backend-enterprise2 environment: - GITHUB_TOKEN: - from_secret: github_token GPG_KEY_PASSWORD: from_secret: gpg_key_password GPG_PRIV_KEY: @@ -3905,8 +3985,6 @@ steps: - build-frontend-packages - build-backend-enterprise2 environment: - GITHUB_TOKEN: - from_secret: github_token GPG_KEY_PASSWORD: from_secret: gpg_key_password GPG_PRIV_KEY: @@ -3970,7 +4048,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3995,14 +4073,29 @@ steps: - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json - mkdir bin - mv /tmp/grabpl bin/ - - make gen-go - - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - - yarn install --immutable depends_on: - clone-enterprise environment: {} image: grafana/build-container:1.5.3 - name: initialize + name: init-enterprise +- commands: + - make gen-go + depends_on: + - init-enterprise + image: grafana/build-container:1.5.3 + name: wire-install +- commands: + - yarn install --immutable + depends_on: + - init-enterprise + image: grafana/build-container:1.5.3 + name: yarn-install +- commands: + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - init-enterprise + image: grafana/build-container:1.5.3 + name: gen-version - commands: - |- echo -e "unknwon @@ -4013,20 +4106,12 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - depends_on: - - initialize image: grafana/build-container:1.5.3 name: codespell -- commands: - - ./bin/grabpl shellcheck - depends_on: - - initialize - image: grafana/build-container:1.5.3 - name: shellcheck - commands: - ./bin/grabpl lint-backend --edition enterprise depends_on: - - initialize + - wire-install environment: CGO_ENABLED: "1" image: grafana/build-container:1.5.3 @@ -4037,7 +4122,7 @@ steps: - yarn run i18n:compile - yarn run typecheck depends_on: - - initialize + - yarn-install environment: TEST_MAX_WORKERS: 50% image: grafana/build-container:1.5.3 @@ -4045,19 +4130,19 @@ steps: - commands: - ./bin/grabpl test-backend --edition enterprise depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend - commands: - ./bin/grabpl integration-tests --edition enterprise depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend-integration - commands: - yarn run ci:test-frontend depends_on: - - initialize + - yarn-install environment: TEST_MAX_WORKERS: 50% image: grafana/build-container:1.5.3 @@ -4065,7 +4150,7 @@ steps: - commands: - ./bin/grabpl lint-backend --edition enterprise2 depends_on: - - initialize + - wire-install environment: CGO_ENABLED: "1" image: grafana/build-container:1.5.3 @@ -4073,13 +4158,13 @@ steps: - commands: - ./bin/grabpl test-backend --edition enterprise2 depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend-enterprise2 - commands: - ./bin/grabpl integration-tests --edition enterprise2 depends_on: - - initialize + - wire-install image: grafana/build-container:1.5.3 name: test-backend-integration-enterprise2 trigger: @@ -4133,7 +4218,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4158,14 +4243,11 @@ steps: - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json - mkdir bin - mv /tmp/grabpl bin/ - - make gen-go - - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - - yarn install --immutable depends_on: - clone-enterprise environment: {} image: grafana/build-container:1.5.3 - name: initialize + name: init-enterprise - commands: - apt-get update - apt-get install -yq postgresql-client @@ -4174,7 +4256,7 @@ steps: - go clean -testcache - ./bin/grabpl integration-tests --database postgres depends_on: - - initialize + - init-enterprise environment: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest @@ -4190,7 +4272,7 @@ steps: - go clean -testcache - ./bin/grabpl integration-tests --database mysql depends_on: - - initialize + - init-enterprise environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql @@ -4200,7 +4282,7 @@ steps: - dockerize -wait tcp://redis:6379/0 -timeout 120s - ./bin/grabpl integration-tests depends_on: - - initialize + - init-enterprise environment: REDIS_URL: redis://redis:6379/0 image: grafana/build-container:1.5.3 @@ -4209,7 +4291,7 @@ steps: - dockerize -wait tcp://memcached:11211 -timeout 120s - ./bin/grabpl integration-tests depends_on: - - initialize + - init-enterprise environment: MEMCACHED_HOSTS: memcached:11211 image: grafana/build-container:1.5.3 @@ -4251,7 +4333,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -4274,7 +4356,7 @@ steps: GITHUB_TOKEN: from_secret: github_token image: grafana/ci-wix:0.1.1 - name: initialize + name: windows-init - commands: - $$gcpKey = $$env:GCP_KEY - '[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) @@ -4284,7 +4366,7 @@ steps: - rm gcpkey.json - cp C:\App\nssm-2.24.zip . depends_on: - - initialize + - windows-init environment: GCP_KEY: from_secret: gcp_key @@ -4448,6 +4530,6 @@ kind: secret name: gcp_upload_artifacts_key --- kind: signature -hmac: 0fbba9c5099f1a3585f866b624f198058332624a4de32193a5326450a39cb8f3 +hmac: 803ec2a67b1758ce82ddda8ba1fb276fd23e4cfbfc3c5736bf2fa9e98edc2e6b ... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cc67825fdb8..fe6e7f3b57c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -28,6 +28,7 @@ go.sum @grafana/backend-platform .drone.yml @grafana/grafana-release-eng .drone.star @grafana/grafana-release-eng /scripts/drone/ @grafana/grafana-release-eng +/pkg/build/ @grafana/grafana-release-eng # Cloud Datasources backend code /pkg/tsdb/cloudwatch @grafana/cloud-datasources diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 55dc5c8da02..97042213f2c 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Actions - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: "grafana/grafana-github-actions" path: ./actions diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index f0aface7844..619ac66bd71 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -50,7 +50,7 @@ jobs: example: 7.4.3 or 7.4.3-beta1" exit 1 - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set intermedia variables id: intermedia @@ -69,7 +69,7 @@ jobs: exit 1 - name: Checkout Actions - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: "grafana/grafana-github-actions" path: ./actions diff --git a/.github/workflows/close-milestone.yml b/.github/workflows/close-milestone.yml index a495eab7a8b..e798f0cd4bf 100644 --- a/.github/workflows/close-milestone.yml +++ b/.github/workflows/close-milestone.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Actions - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: "grafana/grafana-github-actions" path: ./actions diff --git a/.github/workflows/cloud-data-sources-code-coverage.yml b/.github/workflows/cloud-data-sources-code-coverage.yml index a1af9904e89..f49cd30e3a2 100644 --- a/.github/workflows/cloud-data-sources-code-coverage.yml +++ b/.github/workflows/cloud-data-sources-code-coverage.yml @@ -10,7 +10,7 @@ on: - 'public/app/plugins/datasource/cloud-monitoring/**' branches-ignore: - dependabot/** - - backport-** + - backport-* jobs: workflow-call: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c1578b4840f..6fd5101abc8 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 7b5c492590f..ac5a03f9d5c 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Actions - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: "grafana/grafana-github-actions" path: ./actions diff --git a/.github/workflows/detect-breaking-changes-build.yml b/.github/workflows/detect-breaking-changes-build.yml index 87096eaf159..8e76bbe441b 100644 --- a/.github/workflows/detect-breaking-changes-build.yml +++ b/.github/workflows/detect-breaking-changes-build.yml @@ -11,7 +11,7 @@ jobs: working-directory: './pr' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: path: './pr' @@ -51,7 +51,7 @@ jobs: working-directory: './base' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: path: './base' ref: ${{ github.event.pull_request.base.ref }} @@ -92,7 +92,7 @@ jobs: GITHUB_STEP_NUMBER: 7 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Get built packages from pr uses: actions/download-artifact@v2 diff --git a/.github/workflows/detect-breaking-changes-report.yml b/.github/workflows/detect-breaking-changes-report.yml index fe5f2f1f490..fec1f33b93d 100644 --- a/.github/workflows/detect-breaking-changes-report.yml +++ b/.github/workflows/detect-breaking-changes-report.yml @@ -14,7 +14,7 @@ jobs: ARTIFACT_NAME: 'levitate' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: 'Download artifact' uses: actions/github-script@v6 diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index 9f1f1fe31c5..f0223cfdcca 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Actions - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: "grafana/grafana-github-actions" path: ./actions diff --git a/.github/workflows/metrics-collector.yml b/.github/workflows/metrics-collector.yml index 84dec4e204a..874ccc4c3e7 100644 --- a/.github/workflows/metrics-collector.yml +++ b/.github/workflows/metrics-collector.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Actions - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: "grafana/grafana-github-actions" path: ./actions diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 16e00f6d094..bf41c507509 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -20,7 +20,7 @@ jobs: if: github.event.pull_request.draft == false steps: - name: Checkout Actions - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: "grafana/grafana-github-actions" path: ./actions diff --git a/.github/workflows/pr-codeql-analysis-go.yml b/.github/workflows/pr-codeql-analysis-go.yml index 6e6bd524cc7..d80c62f4f5c 100644 --- a/.github/workflows/pr-codeql-analysis-go.yml +++ b/.github/workflows/pr-codeql-analysis-go.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. diff --git a/.github/workflows/pr-codeql-analysis-javascript.yml b/.github/workflows/pr-codeql-analysis-javascript.yml index 943bbfc1543..9fc371a61ad 100644 --- a/.github/workflows/pr-codeql-analysis-javascript.yml +++ b/.github/workflows/pr-codeql-analysis-javascript.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. diff --git a/.github/workflows/pr-codeql-analysis-python.yml b/.github/workflows/pr-codeql-analysis-python.yml index 4f887526812..d9fc18b4f68 100644 --- a/.github/workflows/pr-codeql-analysis-python.yml +++ b/.github/workflows/pr-codeql-analysis-python.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. diff --git a/.github/workflows/pr-commands.yml b/.github/workflows/pr-commands.yml index 7f47cfd8e92..ecd7361c550 100644 --- a/.github/workflows/pr-commands.yml +++ b/.github/workflows/pr-commands.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Actions - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: "grafana/grafana-github-actions" path: ./actions diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cc7d6bf4953..742dc7f9bac 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - run: git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.GH_BOT_ACCESS_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync - name: generate-packages-docs uses: actions/setup-node@v2.5.1 diff --git a/.github/workflows/remove-milestone.yml b/.github/workflows/remove-milestone.yml index ac2504a45f8..78305864e09 100644 --- a/.github/workflows/remove-milestone.yml +++ b/.github/workflows/remove-milestone.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Actions - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: "grafana/grafana-github-actions" path: ./actions diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index 9ea0da2388e..fe03bdeed01 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Actions - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: "grafana/grafana-github-actions" path: ./actions diff --git a/.yarnrc.yml b/.yarnrc.yml index 98678568a76..d14926ae5f9 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -9,28 +9,28 @@ packageExtensions: "@mdx-js/loader@1.6.22": peerDependencies: react: 17.0.1 - "@storybook/addon-docs@6.4.15": + "@storybook/addon-docs@6.4.21": peerDependencies: - "@storybook/manager-webpack5": 6.4.15 - "@storybook/addon-essentials@6.4.15": + "@storybook/manager-webpack5": 6.4.21 + "@storybook/addon-essentials@6.4.21": peerDependencies: - "@storybook/components": 6.4.15 - "@storybook/core-events": 6.4.15 - "@storybook/manager-webpack5": 6.4.15 - "@storybook/theming": 6.4.15 - "@storybook/core-server@6.4.15": + "@storybook/components": 6.4.21 + "@storybook/core-events": 6.4.21 + "@storybook/manager-webpack5": 6.4.21 + "@storybook/theming": 6.4.21 + "@storybook/core-server@6.4.21": peerDependencies: "@babel/core": ^7.0.0 - "@storybook/core@6.4.15": + "@storybook/core@6.4.21": peerDependencies: "@babel/core": ^7.0.0 - "@storybook/manager-webpack5": 6.4.15 - "@storybook/csf-tools@6.4.15": + "@storybook/manager-webpack5": 6.4.21 + "@storybook/csf-tools@6.4.21": peerDependencies: "@babel/core": ^7.0.0 - "@storybook/react@6.4.15": + "@storybook/react@6.4.21": peerDependencies: - "@storybook/manager-webpack5": 6.4.15 + "@storybook/manager-webpack5": 6.4.21 doctrine@3.0.0: dependencies: assert: 2.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index db42f22ca94..508cabd500c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,13 @@ When user is using Github OAuth, GitHub login is showed as both Grafana login an The meaning of the default data source has now changed from being a persisted property in a panel. Before when you selected the default data source for a panel and later changed the default data source to another data source it would change all panels who were configured to use the default data source. From now on the default data source is just the default for new panels and changing the default will not impact any currently saved dashboards. Issue [#45132](https://github.com/grafana/grafana/issues/45132) + + +# 8.4.6 (2022-04-12) + +- **Security:** Fixes CVE-2022-24812. For more information, see our [blog](https://grafana.com/blog/2022/04/12/grafana-enterprise-8.4.6-released-with-high-severity-security-fix/) + + # 8.4.5 (2022-03-31) diff --git a/conf/defaults.ini b/conf/defaults.ini index d0ab0c1a16c..904719d5620 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -230,6 +230,9 @@ application_insights_connection_string = # Optional. Specifies an Application Insights endpoint URL where the endpoint string is wrapped in backticks ``. application_insights_endpoint_url = +# Controls if the UI contains any links to user feedback forms +feedback_links_enabled = true + #################################### Security ############################ [security] # disable creation of admin user on first start of grafana @@ -1131,9 +1134,12 @@ license_path = # enable = feature1,feature2 enable = -# The new prometheus visual query builder +# The new prometheus visual query builder promQueryBuilder = true +# Experimental Explore to Dashboard workflow +explore2Dashboard = true + # feature1 = true # feature2 = false diff --git a/conf/sample.ini b/conf/sample.ini index f20d03f7461..fbe7b6b7f8d 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -230,6 +230,9 @@ # Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config ;rudderstack_config_url = +# Controls if the UI contains any links to user feedback forms +;feedback_links_enabled = true + #################################### Security #################################### [security] # disable creation of admin user on first start of grafana diff --git a/devenv/dev-dashboards/dashboards.go b/devenv/dev-dashboards/dashboards.go new file mode 100644 index 00000000000..4531431bc7a --- /dev/null +++ b/devenv/dev-dashboards/dashboards.go @@ -0,0 +1,6 @@ +package dev_dashboards + +import "embed" + +//go:embed *.json */*.json +var DevDashboardFS embed.FS diff --git a/devenv/dev-dashboards/dashboards_test.go b/devenv/dev-dashboards/dashboards_test.go new file mode 100644 index 00000000000..e330b53bb0f --- /dev/null +++ b/devenv/dev-dashboards/dashboards_test.go @@ -0,0 +1,84 @@ +package dev_dashboards + +import ( + "encoding/json" + "io" + "io/fs" + "path/filepath" + "strings" + "testing" + + "cuelang.org/go/cue/errors" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/coremodel/dashboard" + "github.com/grafana/grafana/pkg/cuectx" +) + +func TestDevenvDashboardValidity(t *testing.T) { + m, err := themaTestableDashboards() + require.NoError(t, err) + cm, err := dashboard.ProvideCoremodel(cuectx.ProvideThemaLibrary()) + require.NoError(t, err) + + for path, b := range m { + t.Run(path, func(t *testing.T) { + // The path arg here only matters for error output + cv, err := cuectx.JSONtoCUE(path, b) + require.NoError(t, err, "error while decoding dashboard JSON into a CUE value") + + _, err = cm.CurrentSchema().Validate(cv) + if err != nil { + // Testify trims errors to short length. We want the full text + errstr := errors.Details(err, nil) + t.Log(errstr) + if strings.Contains(errstr, "null") { + t.Log("validation failure appears to involve nulls - see if scripts/stripnulls.sh has any effect?") + } + t.FailNow() + } + }) + } +} + +func themaTestableDashboards() (map[string][]byte, error) { + m := make(map[string][]byte) + in := DevDashboardFS + + err := fs.WalkDir(in, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() || filepath.Ext(d.Name()) != ".json" { + return nil + } + + // nolint:gosec + f, err := in.Open(path) + if err != nil { + return err + } + defer f.Close() // nolint: errcheck + + b, err := io.ReadAll(f) + if err != nil { + return err + } + + jtree := make(map[string]interface{}) + json.Unmarshal(b, &jtree) + if oldschemav, has := jtree["schemaVersion"]; !has || !(oldschemav.(float64) > dashboard.HandoffSchemaVersion-1) { + return nil + } + + m[path] = b + return nil + }) + + if err != nil { + return nil, err + } + + return m, nil +} diff --git a/devenv/dev-dashboards/panel-graph/graph-ng-stacking2.json b/devenv/dev-dashboards/panel-graph/graph-ng-stacking2.json new file mode 100644 index 00000000000..410540966f0 --- /dev/null +++ b/devenv/dev-dashboards/panel-graph/graph-ng-stacking2.json @@ -0,0 +1,2930 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 0, + "y": 0 + }, + "id": 6, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "0,2,2,3,4,5" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "5,4,3,2,2,0" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,10,25,5,0" + } + ], + "title": "Pos Only", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 4, + "y": 0 + }, + "id": 7, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 6, + "refId": "A" + } + ], + "title": "Stack Normal", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "percent" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 8, + "y": 0 + }, + "id": 8, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 6, + "refId": "A" + } + ], + "title": "Stack 100%", + "type": "timeseries" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 12, + "y": 0 + }, + "id": 3, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "alias": "+100", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "hide": false, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "100,100,100,100,100,100,100,100" + }, + { + "alias": "+50", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "50,50,50,50,50,50,50,50" + }, + { + "alias": "+25", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "25,25,25,25,25,25,25,25" + }, + { + "alias": "-50", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "D", + "scenarioId": "csv_metric_values", + "stringInput": "-50,-50,-50,-50,-50,-50,-50,-50" + }, + { + "alias": "-100", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "E", + "scenarioId": "csv_metric_values", + "stringInput": "-100,-100,-100,-100,-100,-100,-100,-100" + } + ], + "title": "Pos & Neg", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 16, + "y": 0 + }, + "id": 4, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 3, + "refId": "A" + } + ], + "title": "Stack Normal", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "percent" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 20, + "y": 0 + }, + "id": 12, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 3, + "refId": "A" + } + ], + "title": "Stacked 100%", + "type": "timeseries" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "NegY" + }, + "properties": [ + { + "id": "custom.transform", + "value": "negative-Y" + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 0, + "y": 9 + }, + "id": 9, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "alias": "NegY", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,10,25,5,0" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "0,2,2,3,4,5" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "5,4,3,2,2,0" + }, + { + "alias": "Neg", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "D", + "scenarioId": "csv_metric_values", + "stringInput": "-15,-5,-15,-5,-15,-5" + } + ], + "title": "Pos & Neg & NegY", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "NegY" + }, + "properties": [ + { + "id": "custom.transform", + "value": "negative-Y" + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 4, + "y": 9 + }, + "id": 10, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 9, + "refId": "A" + } + ], + "title": "Stack Normal", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "percent" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "NegY" + }, + "properties": [ + { + "id": "custom.transform", + "value": "negative-Y" + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 8, + "y": 9 + }, + "id": 11, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 9, + "refId": "A" + } + ], + "title": "Stack 100%", + "type": "timeseries" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 12, + "y": 9 + }, + "id": 17, + "options": { + "barRadius": 0, + "barWidth": 1, + "groupWidth": 0.89, + "legend": { + "calcs": [ + "max" + ], + "displayMode": "hidden", + "placement": "right" + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "text": {}, + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "targets": [ + { + "csvContent": "Name,Stat1,Stat2,Stat3,Stat4,Stat5,Stat6,Stat7,Stat8\nA, 10, 15,8,3,4,12,14,1\nB, 19, 5,8,3,4,12,14,6\nC, 15, 5,8,3,4,10,4,6\n", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "Barchart", + "type": "barchart" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 16, + "y": 9 + }, + "id": 18, + "options": { + "barRadius": 0, + "barWidth": 1, + "groupWidth": 0.89, + "legend": { + "calcs": [ + "max" + ], + "displayMode": "hidden", + "placement": "right" + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "text": {}, + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 17, + "refId": "A" + } + ], + "title": "Stack Normal", + "type": "barchart" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 20, + "y": 9 + }, + "id": 19, + "options": { + "barRadius": 0, + "barWidth": 1, + "groupWidth": 0.89, + "legend": { + "calcs": [ + "max" + ], + "displayMode": "hidden", + "placement": "right" + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "percent", + "text": {}, + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 17, + "refId": "A" + } + ], + "title": "Stack 100%", + "type": "barchart" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 50, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "NegY" + }, + "properties": [ + { + "id": "custom.transform", + "value": "negative-Y" + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 0, + "y": 18 + }, + "id": 13, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 9, + "refId": "A" + } + ], + "title": "Neg & NegY Bars", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 50, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "NegY" + }, + "properties": [ + { + "id": "custom.transform", + "value": "negative-Y" + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 4, + "y": 18 + }, + "id": 14, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 9, + "refId": "A" + } + ], + "title": "Stack Normal", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 50, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "percent" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "NegY" + }, + "properties": [ + { + "id": "custom.transform", + "value": "negative-Y" + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 8, + "y": 18 + }, + "id": 15, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 9, + "refId": "A" + } + ], + "title": "Stack 100%", + "type": "timeseries" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 40, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(A|B)-series.*" + }, + "properties": [ + { + "id": "custom.drawStyle", + "value": "bars" + }, + { + "id": "custom.lineWidth", + "value": 0 + }, + { + "id": "custom.fillOpacity", + "value": 100 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 12, + "y": 18 + }, + "id": 21, + "maxDataPoints": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "18,25,43,11,5" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "41,29,20,23,27" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "169,109,170,150,116" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "D", + "scenarioId": "csv_metric_values", + "stringInput": "160,131,117,134,139" + } + ], + "title": "Mixed Styles", + "transformations": [], + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 40, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(A|B)-series.*" + }, + "properties": [ + { + "id": "custom.drawStyle", + "value": "bars" + }, + { + "id": "custom.lineWidth", + "value": 0 + }, + { + "id": "custom.fillOpacity", + "value": 100 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 16, + "y": 18 + }, + "id": 22, + "maxDataPoints": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 21, + "refId": "A" + } + ], + "title": "Stack Normal Auto-grouped", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 40, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "percent" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(A|B)-series.*" + }, + "properties": [ + { + "id": "custom.drawStyle", + "value": "bars" + }, + { + "id": "custom.lineWidth", + "value": 0 + }, + { + "id": "custom.fillOpacity", + "value": 100 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 20, + "y": 18 + }, + "id": 23, + "maxDataPoints": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 21, + "refId": "A" + } + ], + "title": "Stack 100% Auto-grouped", + "type": "timeseries" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 0, + "y": 27 + }, + "id": 24, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "alias": "+100", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "hide": false, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "100,100,100,100,100,null,100,100" + }, + { + "alias": "+50", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "50,50,50,50,50,null,50,50" + }, + { + "alias": "+25", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "25,25,25,25,25,null,25,25" + }, + { + "alias": "-50", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "D", + "scenarioId": "csv_metric_values", + "stringInput": "-50,-50,null,-50,-50,-50,-50,-50" + }, + { + "alias": "-100", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "E", + "scenarioId": "csv_metric_values", + "stringInput": "-100,-100,null,-100,-100,-100,-100,-100" + } + ], + "title": "Null through all", + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 4, + "y": 27 + }, + "id": 25, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 24, + "refId": "A" + } + ], + "title": "Stack Normal", + "type": "timeseries" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "NegY" + }, + "properties": [ + { + "id": "custom.transform", + "value": "negative-Y" + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 8, + "y": 27 + }, + "id": 2, + "maxDataPoints": 100, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "hide": false, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "50,50,50,null,50,50,50,50" + }, + { + "alias": "NegY", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "50,50,50,50,null,50,50" + } + ], + "title": "NegY nulls", + "type": "timeseries" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 12, + "y": 27 + }, + "id": 26, + "maxDataPoints": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,2,1,2,1" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "3,3,3,3,3" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "2,2,2,2,2" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "D", + "scenarioId": "csv_metric_values", + "stringInput": "4,4,4,4,4" + } + ], + "title": "1,2,3,4 Unstacked", + "transformations": [], + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(A|B)-series.*" + }, + "properties": [ + { + "id": "custom.stacking", + "value": { + "group": "B", + "mode": "normal" + } + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 16, + "y": 27 + }, + "id": 27, + "maxDataPoints": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 26, + "refId": "A" + } + ], + "title": "Stack Normal Groups A + B / C + D", + "transformations": [], + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "percent" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(A|B)-series.*" + }, + "properties": [ + { + "id": "custom.stacking", + "value": { + "group": "B", + "mode": "percent" + } + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 4, + "x": 20, + "y": 27 + }, + "id": 28, + "maxDataPoints": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 26, + "refId": "A" + } + ], + "title": "Stack 100% Groups A + B / C + D", + "transformations": [], + "type": "timeseries" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "custom.drawStyle", + "value": "bars" + }, + { + "id": "color", + "value": { + "mode": "palette-classic" + } + }, + { + "id": "custom.gradientMode", + "value": "none" + }, + { + "id": "custom.barAlignment", + "value": 1 + }, + { + "id": "custom.stacking", + "value": { + "group": "A", + "mode": "normal" + } + }, + { + "id": "custom.spanNulls", + "value": 3600000 + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 36 + }, + "id": 30, + "options": { + "legend": { + "calcs": [], + "displayMode": "hidden", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "csvContent": "\"Time\",\"Sum daily.cost_per_kwh\"\n1639612800000,\n1639872000000,0\n1639958400000,272\n1640044800000,272\n1640131200000,327\n1640217600000,290\n1640304000000,286\n1640390400000,256\n1640476800000,397\n1640563200000,295\n1640649600000,310\n1640736000000,224\n1640822400000,291\n1640908800000,265\n1640995200000,256\n1641081600000,399\n1641168000000,471\n1641254400000,325\n1641340800000,363\n1641427200000,375\n1641513600000,365\n1641600000000,492\n1641686400000,409\n1641772800000,371\n1641859200000,448\n1641945600000,372\n1642032000000,0\n1642118400000,0\n1642204800000,0\n1642291200000,0\n1642377600000,0\n1642464000000,0\n\n", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "hide": false, + "refId": "A", + "scenarioId": "csv_content" + }, + { + "csvContent": "\"Time\",\"Sum morning.cost_per_kwh\",\"Sum afternoon.cost_per_kwh\",\"Sum evening.cost_per_kwh\",\"Sum night.cost_per_kwh\",\"Sum late.cost_per_kwh\"\n1639612800000,0,0,0,0,0\n1639872000000,,,,,\n1639958400000,,,,,\n1640044800000,,,,,\n1640131200000,,,,,\n1640217600000,32,28,62,63,32\n1640304000000,,,,,\n1640390400000,,,,,\n1640476800000,,,,,\n1640563200000,,,,,\n1640649600000,,,,,\n1640736000000,,,,,\n1640822400000,47,53,68,96,45\n1640908800000,,,,,\n1640995200000,,,,,\n1641081600000,,,,,\n1641168000000,,,,,\n1641254400000,,,,,\n1641340800000,,,,,\n1641427200000,112,130,147,152,115\n1641513600000,,,,,\n1641600000000,,,,,\n1641686400000,,,,,\n1641772800000,,,,,\n1641859200000,,,,,\n1641945600000,,,,,\n1642032000000,0,0,0,0,0\n1642118400000,,,,,\n1642204800000,,,,,\n1642291200000,,,,,\n1642377600000,,,,,\n1642464000000,,,,,\n\n", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "hide": false, + "refId": "B", + "scenarioId": "csv_content" + } + ], + "title": "https://github.com/grafana/grafana/pull/44359", + "transformations": [], + "type": "timeseries" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "custom.drawStyle", + "value": "line" + }, + { + "id": "color", + "value": { + "mode": "palette-classic" + } + }, + { + "id": "custom.gradientMode", + "value": "none" + }, + { + "id": "custom.barAlignment", + "value": 1 + }, + { + "id": "custom.stacking", + "value": { + "group": "A", + "mode": "normal" + } + }, + { + "id": "custom.spanNulls", + "value": true + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 36 + }, + "id": 32, + "options": { + "legend": { + "calcs": [], + "displayMode": "hidden", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "csvContent": "\"Time\",\"Sum daily.cost_per_kwh\"\n1639612800000,\n1639872000000,0\n1639958400000,272\n1640044800000,272\n1640131200000,327\n1640217600000,290\n1640304000000,286\n1640390400000,256\n1640476800000,397\n1640563200000,295\n1640649600000,310\n1640736000000,224\n1640822400000,291\n1640908800000,265\n1640995200000,256\n1641081600000,399\n1641168000000,471\n1641254400000,325\n1641340800000,363\n1641427200000,375\n1641513600000,365\n1641600000000,492\n1641686400000,409\n1641772800000,371\n1641859200000,448\n1641945600000,372\n1642032000000,0\n1642118400000,0\n1642204800000,0\n1642291200000,0\n1642377600000,0\n1642464000000,0\n\n", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "hide": false, + "refId": "A", + "scenarioId": "csv_content" + }, + { + "csvContent": "\"Time\",\"Sum morning.cost_per_kwh\",\"Sum afternoon.cost_per_kwh\",\"Sum evening.cost_per_kwh\",\"Sum night.cost_per_kwh\",\"Sum late.cost_per_kwh\"\n1639612800000,0,0,0,0,0\n1639872000000,,,,,\n1639958400000,,,,,\n1640044800000,,,,,\n1640131200000,,,,,\n1640217600000,32,28,62,63,32\n1640304000000,,,,,\n1640390400000,,,,,\n1640476800000,,,,,\n1640563200000,,,,,\n1640649600000,,,,,\n1640736000000,,,,,\n1640822400000,47,53,68,96,45\n1640908800000,,,,,\n1640995200000,,,,,\n1641081600000,,,,,\n1641168000000,,,,,\n1641254400000,,,,,\n1641340800000,,,,,\n1641427200000,112,130,147,152,115\n1641513600000,,,,,\n1641600000000,,,,,\n1641686400000,,,,,\n1641772800000,,,,,\n1641859200000,,,,,\n1641945600000,,,,,\n1642032000000,0,0,0,0,0\n1642118400000,,,,,\n1642204800000,,,,,\n1642291200000,,,,,\n1642377600000,,,,,\n1642464000000,,,,,\n\n", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "hide": false, + "refId": "B", + "scenarioId": "csv_content" + } + ], + "title": "Connect null values = always", + "transformations": [], + "type": "timeseries" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "custom.drawStyle", + "value": "line" + }, + { + "id": "color", + "value": { + "mode": "palette-classic" + } + }, + { + "id": "custom.gradientMode", + "value": "none" + }, + { + "id": "custom.barAlignment", + "value": 1 + }, + { + "id": "custom.stacking", + "value": { + "group": "A", + "mode": "normal" + } + }, + { + "id": "custom.spanNulls", + "value": false + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 36 + }, + "id": 34, + "options": { + "legend": { + "calcs": [], + "displayMode": "hidden", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "csvContent": "\"Time\",\"Sum daily.cost_per_kwh\"\n1639612800000,\n1639872000000,0\n1639958400000,272\n1640044800000,272\n1640131200000,327\n1640217600000,290\n1640304000000,286\n1640390400000,256\n1640476800000,397\n1640563200000,295\n1640649600000,310\n1640736000000,224\n1640822400000,291\n1640908800000,265\n1640995200000,256\n1641081600000,399\n1641168000000,471\n1641254400000,325\n1641340800000,363\n1641427200000,375\n1641513600000,365\n1641600000000,492\n1641686400000,409\n1641772800000,371\n1641859200000,448\n1641945600000,372\n1642032000000,0\n1642118400000,0\n1642204800000,0\n1642291200000,0\n1642377600000,0\n1642464000000,0\n\n", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "hide": false, + "refId": "A", + "scenarioId": "csv_content" + }, + { + "csvContent": "\"Time\",\"Sum morning.cost_per_kwh\",\"Sum afternoon.cost_per_kwh\",\"Sum evening.cost_per_kwh\",\"Sum night.cost_per_kwh\",\"Sum late.cost_per_kwh\"\n1639612800000,0,0,0,0,0\n1639872000000,,,,,\n1639958400000,,,,,\n1640044800000,,,,,\n1640131200000,,,,,\n1640217600000,32,28,62,63,32\n1640304000000,,,,,\n1640390400000,,,,,\n1640476800000,,,,,\n1640563200000,,,,,\n1640649600000,,,,,\n1640736000000,,,,,\n1640822400000,47,53,68,96,45\n1640908800000,,,,,\n1640995200000,,,,,\n1641081600000,,,,,\n1641168000000,,,,,\n1641254400000,,,,,\n1641340800000,,,,,\n1641427200000,112,130,147,152,115\n1641513600000,,,,,\n1641600000000,,,,,\n1641686400000,,,,,\n1641772800000,,,,,\n1641859200000,,,,,\n1641945600000,,,,,\n1642032000000,0,0,0,0,0\n1642118400000,,,,,\n1642204800000,,,,,\n1642291200000,,,,,\n1642377600000,,,,,\n1642464000000,,,,,\n\n", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "hide": false, + "refId": "B", + "scenarioId": "csv_content" + } + ], + "title": "Connect null values = never", + "transformations": [], + "type": "timeseries" + } + ], + "refresh": false, + "schemaVersion": 36, + "style": "dark", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "templating": { + "list": [] + }, + "time": { + "from": "2021-12-14T04:26:06.000Z", + "to": "2022-01-19T08:44:05.000Z" + }, + "timepicker": {}, + "timezone": "", + "title": "TimeSeries & BarChart Stacking", + "uid": "1KxMUdE7k", + "version": 1, + "weekStart": "" +} diff --git a/devenv/dev-dashboards/panel-table/table_pagination.json b/devenv/dev-dashboards/panel-table/table_pagination.json new file mode 100644 index 00000000000..da1765cd937 --- /dev/null +++ b/devenv/dev-dashboards/panel-table/table_pagination.json @@ -0,0 +1,2683 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1110, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "displayMode": "auto", + "inspect": false + }, + "mappings": [ + { + "options": { + "rent": { + "color": "yellow", + "index": 0 + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "property_type" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background" + }, + { + "id": "mappings", + "value": [ + { + "options": { + "store": { + "color": "blue", + "index": 0 + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "title" + }, + "properties": [ + { + "id": "custom.width", + "value": 205 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Number of rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 66 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Description" + }, + "properties": [ + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "URL" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Show details", + "url": "${__value.raw}" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Surface" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background-solid" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "dark-red", + "value": null + }, + { + "color": "orange", + "value": 100 + }, + { + "color": "yellow", + "value": 200 + }, + { + "color": "green", + "value": 300 + }, + { + "color": "#EAB839", + "value": 500 + }, + { + "color": "#6ED0E0", + "value": 1000 + }, + { + "color": "#EF843C", + "value": 2000 + } + ] + } + }, + { + "id": "unit", + "value": "areaM2" + }, + { + "id": "custom.width", + "value": 93 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Type" + }, + "properties": [ + { + "id": "custom.width", + "value": 94 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "City" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 77 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Price" + }, + "properties": [ + { + "id": "unit", + "value": "currencyUSD" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "footer": { + "enablePagination": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Surface" + } + ] + }, + "pluginVersion": "9.0.0-pre", + "targets": [ + { + "csvContent": "\"id\",\"created_on\",\"operation\",\"property_type\",\"place_name\",\"place_with_parent_names\",\"country_name\",\"state_name\",\"geonames_id\",\"lat_lon\",\"lat\",\"lon\",\"price\",\"currency\",\"price_aprox_local_currency\",\"price_aprox_usd\",\"surface_total_in_m2\",\"surface_covered_in_m2\",\"price_usd_per_m2\",\"price_per_m2\",\"floor\",\"rooms\",\"expenses\",\"properati_url\",\"description\",\"title\",\"image_thumbnail\"\nf73c373e0588bf568164a057dd94203ac87d47b1,2014-08-07,rent,store,Catu,|Brasil|Bahia|Catu|,Brasil,Bahia,,,,,0,,0,0,,,,,,,,http://www.properati.com.br/mfye_alugar_outros_catu,\"Otimo Imovel com o melhor valor da regiao, confira as fotos - \",Exclusividade! - Loja Em Catu,https://thumbs4.properati.com/3/jA8isZw8Xju7z3-V2I-ED8x5D2o=/trim/198x0/smart/filters:strip_icc()/galeriadefotos.net.br/backup/upload/15989/1598966287bc7/zd533ba5e829b0543093a45e1d8398bd7.jpg\nd6d377927ab49671acdd38aa6f661a460601a88b,2015-01-20,rent,apartment,Pernambuco,|Brasil|Pernambuco|,Brasil,Pernambuco,,\"-8.126271,-34.903793\",-8.126271,-34.903793,1200,BRL,1184.32,383.55,,63,6.088095238095239,19.047619047619047,1,2,,http://www.properati.com.br/w71k_alugar_apartamentos_pernambuco,\"CONTATOS: Claro: 81./ Tim: 81./ Oi: 81./ Vivo: 81.Apartamento de 2 quartos mais Dependência completa de empregada, 1.º andar de edifício localizado na Rua Dr. Gaspar Regueira da Costa, 99, Boa Viagem.Este apartamento é constituído por: 1 Sala, 2 Quartos, 2 Banheiros, Cozinha, Área de serviço, Dependência completa de empregada e 1 lugar de estacionamento.O Valor do aluguer já inclui taxas.Agende já a sua visita.Nota: O Edifício irá ser pintado exteriormente, em breve.ISI-120-T2 #ref:ISI-120-T2 - ISI-120-T2 - idalécio Soares\",\"Apartamento de 2 quartos, DCE, 1.º Andar, 63 m2, Boa Viagem - Recife\",https://thumbs4.properati.com/0/B9BCwP1HK7NvZIYINGkFNnNfOZ4=/trim/198x0/smart/filters:strip_icc()/feedmedia.egorealestate.com/Zfeed/S5/C4123/P2270979/Tphoto/ID03a72200-0000-0500-0000-00000141a7a5.JPG\nc6826a9508436920fd59b46a202354f3e4823d22,2014-10-29,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.1546253,-45.7906242\",-23.1546253,-45.7906242,0,BRL,0,0,,3800,,,,,,http://www.properati.com.br/s41f_alugar_comercial_cacapava,\"Condomínio com 22 Módulos de Galpões; 3.800m2 a 12.600m2; 12m pé-direito médio; Piso 6 TON/m2; Ã�rea total de Terreno : 324.000 m2; Ã�rea Total construída: 133.200 m2; 293 vagas para automóveis; 103 vagas para caminhões; Refeitório para 256 pessoas por turno;Benefícios Fiscais no município de Caçapava conforme Lei Lei 5055/211; Isenção de Taxas;Isenção de IPTU; Redução de ISS; Reversão de ICMS.Valores locação = R$ 15,00 o m2Tx Cond. = R$ 1,50 o m2Valores poderão ser alterados sem prévio aviso.A Heron Imóveis oferece este(a) Comercial em Caçapava, no bairro Caçapava. Gostou desse(a) Comercial em Caçapava? Então veja mais informações! Acesse o nosso site clicando no botão Contate o anunciante ou ligue - ou ainda entre em contato conosco pelo email contato@ Obrigado!\",\"Comercial em Caçapava, no bairro Caçapava\",https://thumbs4.properati.com/1/J2n8nXVlvduy6EJjyLwPneEoc28=/trim/198x0/smart/filters:strip_icc()/www.heronimoveis.com.br/fotos/F00/35/23500-DSC06177.JPG\nba957ad3cb2d12d55d1f0466eeffe788a35a6f4f,2014-08-04,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7780086,-49.3450562\",-20.7780086,-49.3450562,950,BRL,751.1,243.25,,,,,,1,,http://www.properati.com.br/lxp9_alugar_casas_sao-jose-do-rio-preto,\"Ref.: CA95876 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Garden Village II..:Casa Condomnio Garden Village II, Casa Condominio, Dormitrios: 03, Tipo Apto.: 01, Wc. Social: 01, Sala de Visita: 01, Copa: 01, Cozinha: 01, Cozinha Planejada, Despensa, rea de Servio, Ar Condicionado, Alarme, Acabamento Em Gesso, Sanca, Campainha, Cerca Eletrica, Forro de Laje, Piso Dormitrios: Frio, Piso Salas: Frio, M2/ Terreno: 145, M2 rea Construida: 70, Garagem Descoberta: 01, Salo de Festa, Detalhes: R$ 950,00 + Iptu + Condomnio\", Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Garden Village II,https://thumbs4.properati.com/9/xcrfHjSnXdhGcOK1m1wg1jHJlsw=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/803/8860a8e8-6d62-49e6-a992-54ab8f360f35.jpg\n0999b33908c71af3b8be282ab35ab61b38a46353,2014-07-16,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,4300,BRL,4078.98,1321,,319,4.141065830721003,13.47962382445141,,4,,http://www.properati.com.br/ifuj_alugar_apartamentos_vila-olimpia,\"Apartamento na Vila Olí­mpia, com 4 quartos, sendo 2 suí­tes,Ampla sala com terraí§o, piso de madeira corrida, 1 dos quartos pode ser revertido para sala de tv ou escritório.Quartos com carpete de madeira, as suí­tes com armí¡rios embutidos, uma delas com ar condicionado.Armí¡rio corredor.Cozinha com armí¡rio, Piso de granito, na cozinha e lavanderia.  Referíªncia: AP34\",\"Apartamento no(a) Vila Olímpia , São Paulo - SP\",https://thumbs4.properati.com/8/nxrbzTDipvKnqdx1wDABEyIbSes=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/34/c789b808-bf30-460d-9376-16299ce5abbb.jpg\n011d5618639ce229b012022a32b22eff9abd213e,2014-11-19,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.9648026,-46.3855723\",-23.9648026,-46.3855723,1300,BRL,1323.12,428.5,,60,7.141666666666667,21.666666666666668,4,1,,http://www.properati.com.br/tl4s_alugar_apartamentos_sao-paulo,\"PACOTE R$ 1.300,00 OTIMO APARTAMENTO MOBILIADO COM TERRAÇO PROXIMO SHOPPING , PRAIA E COMERCIOS ACEITA FIADOR / SEG. FIANÇA A 3 Milenio oferece este(a) Apartamento em Sao Vicente, no bairro Gonzaguinha - 1 dormitórios. Gostou desse(a) Apartamento em Sao Vicente? Então veja mais informações! Acesse o nosso site clicando no botão Contate o anunciante ou ligue - ou ainda entre em contato conosco pelo email vendas@ Obrigado!\",\"Apartamento em Sao Vicente, no bairro Gonzaguinha - 1 dormitórios\",https://thumbs4.properati.com/4/x8BqTbd304ntTBY5pTf5Yi_nNMw=/trim/198x0/smart/filters:strip_icc()/www.imobiliariasdesantos.com.br/fotos/F00/74/757400-FOTOS%20HELANE%20001.jpg\n6f274a2d23ffa89cb0301f3277585beda7723ce8,2014-09-01,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8343086,-49.3805559\",-20.8343086,-49.3805559,5000,BRL,3953.34,1280.31,,,,,,,,http://www.properati.com.br/o23s_alugar_comercial_sao-jose-do-rio-preto_rua-nuno-alvares-pereira,\"Ref.: SA95668Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Pq. EstorilM2 rea Construida: 170, Detalhes: Salo de 170 M, Com Mezanino. J Com Acessibilidade. Tem Uma Linda Fachada e Estacionamento Para Clientes.\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Pq. Estoril,https://thumbs4.properati.com/8/7VqZVGV6Hl3paOrhYXv_ksowyEw=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1875/TG5yXGj9EJPaa5o0dWgL.jpg\ndb6f890847bcacc6ec3e78a0e6613132174e4c70,2014-12-05,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8178411,-49.387605\",-20.8178411,-49.387605,10000,BRL,7906.68,2560.62,,,,,,1,,http://www.properati.com.br/ue07_alugar_casas_sao-jose-do-rio-preto_rua-cila,\"Ref.: CO95026Tipo: Casa Comercial, Predio ComercialCidade: So Jos do Rio Preto - SPBairro: RedentoraSobrado, Dormitrios: 4, Tipo Apto.: 4, C/ A/e: 4, A/e C/ Interior: 4, Wc. Social: 1, Copa/cozinha: 1, Cozinha C/ A/e: 1, Despensa, Salas : 1, Sala P/ Ambientes : 4, M2 rea Construida: 330, Entrada Lateral: 1, Piso Dormitrios: Frio\",\"Ref.: CO95026 Tipo: Casa Comercial, Predio Comercial Cidade: So Jos do Rio Preto - SP Bairro: Redentora\",https://thumbs4.properati.com/2/DNREjVLgpGPzTlv8aIvhpd7KZkI=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2461/02ea33d3-3ebd-4139-a380-b60eb84c87d1.jpg\nf7d3243419c85cf439189481819f13cb22ab0498,2014-10-01,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8126181,-49.3404534\",-20.8126181,-49.3404534,3800,BRL,3004.52,973.03,,,,,,,,http://www.properati.com.br/qz8t_alugar_casas_sao-jose-do-rio-preto_avenida-miguel-damha,\"Ref.: CA95902Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Damha III..: Casa Condomnio Damha III, Casa Condominio, Dormitrios: 03, Suite: 01, Closet , Tipo Apto.: 02, C/ A/e: 02, Home, Copa/cozinha: 01, Cozinha Planejada, Piscina, Varanda, rea de Servio, Wc. de Empregada/servio, Forro de Laje, M2/ Terreno: 450, M2 rea Construida: 240, rea de Lazer, Garagem Descoberta: 02, Garagem Coberta: 02, Churrasqueira, Valor Condominio R$-: 420,00\",Ref.: CA95902 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Damha III,https://thumbs4.properati.com/8/cqu5q3tBJOddQ526fWf2puVwINI=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2060/6064cf7e-f8ed-44f3-8a80-b6576de27d62.jpg\ncbfc4c495b3578c995363825a85a821217fab4ec,2014-10-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,14000,BRL,11069.36,3584.87,,,,,,,,http://www.properati.com.br/ri3p_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95918Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Mini Dist. Industrial Adail Vetorasso..: Salo Comercial No Mini Distrito Industrial Adail Vetorasso, Salo Comercial, Wcs.: 02, Cozinha: 01, Escritrio, M2/ Terreno: 2.100, M2 rea Construida: 1.350, Detalhes: Cobertura Metlica.p Direito Com 7 Metros.corredor Lateral.\",Ref.: SA95918 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Mini Dist. Industrial Adail Vetorasso,https://thumbs4.properati.com/9/INPTa4YGuWfsekPfC8ez-rqd3PI=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2285/Mhd9hMU2ZwU9YXAC9Hzi.jpg\nb4dab15f9d4411d0847d1871e0a62e107b492fdc,2014-08-02,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.8773135,-46.7592613\",-23.8773135,-46.7592613,28000,BRL,22138.72,7169.74,,,,,,,,http://www.properati.com.br/lud3_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95869Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Eng. Schimidt..: Salo Comercial Em Engenheiro Schimidt, Salo Comercial, Obs: Salo Ideal Para Transportadora Com Frente Para 03 Marginal, Esquina Com Rua Projetada 01 e Rodovia W. Luiz, M2/ Terreno: 5.500, M2 rea Construida: 2.200\",Ref.: SA95869 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Eng. Schimidt,https://thumbs4.properati.com/3/oJm3M_UtBn2PVRsZ2O4n48IvhuA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/780/60d090bc-4a05-4929-88ae-aca5b9873a76.jpg\nd3769eb1b41c780c83f2d176adae8ad9a81b7ba3,2014-10-08,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.818728,-49.4212608\",-20.818728,-49.4212608,16000,BRL,12650.68,4096.99,,,,,,,,http://www.properati.com.br/r6d9_alugar_comercial_sao-jose-do-rio-preto_avenida-joao-batista-vetorasso,\"Ref.: SA95810Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial Waldemar VerdiM2/ Terreno: 5.000, M2 rea Construida: 1.400, Detalhes: Com Recepo e 8 Salas. P Direito Com 7 M.\",Salo,https://thumbs4.properati.com/6/GFkI3LbwZQAhB8USv5fTltHxz0E=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2179/QEWBOD1T8GCOjBi0H6WI.jpg\nf51aebdb554e9b5dfa16c3538d3376040f5f2b5c,2014-08-20,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-22.1423284,-51.3750141\",-22.1423284,-51.3750141,16000,BRL,12650.68,4096.99,,,,,,,,http://www.properati.com.br/np97_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95723Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Moyss Miguel HaddadM2 rea Construida: 322, Obs: Salo Ideal Para Loja, 217m Terreo e 105m Mezanino. Estacionamento Na Frente.imvel Novo.\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Moyss Miguel Haddad,https://thumbs4.properati.com/2/viy2bV22HhxBkY_449PiH9sLQPw=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1548/9242514f-75f0-4bdc-a5b1-68a958cbda17.jpg\n7266f270625a52c84adaf69ac08a53b397617247,2014-09-01,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,6000,BRL,4744,1536.37,,,,,,,,http://www.properati.com.br/o20x_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95742Tipo: Salo IndustrialCidade: So Jos do Rio Preto - SPBairro: Quinta das PaineirasWcs.: 02, Cozinha: 01, M2/ Terreno: 720, M2 rea Construida: 458,90, Obs: Salo Industrial Grande Com 458m, Com Boa Acessibilidade e tima Localizao, 02 Quadras da Avenida Potirendaba, Fcil Acesso e Entrada Para Caminhes.\",Tipo: Salo Industrial Cidade: So Jos do Rio Preto - SP Bairro: Quinta das Paineiras,https://thumbs4.properati.com/3/0rnMsn1G6rWTjCQ9lnrLn6WR7JA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1771/KET9NdQ0WNrtQlhGEVEq.jpg\nc1e10a8afe111ba5caa5034df1ecb56b25b772f9,2014-08-19,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-22.1423284,-51.3750141\",-22.1423284,-51.3750141,12000,BRL,9488,3072.74,,,,,,,,http://www.properati.com.br/muvz_alugar_casas_sao-jose-do-rio-preto,\"Ref.: CA95766Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Moyss Miguel HaddadWc. Social: 04, M2/ Terreno: 980, M2 rea Construida: 500, Obs: Entrada Lateral Com Acesso Para Caminho, Com 04 Wcs. Sendo 02 Wc P/ Deficiente.\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Moyss Miguel Haddad,https://thumbs4.properati.com/3/GvRImi73Q9koPTqVulTG5pg1J2s=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1519/T181YBWZd62P9tMfgs1F.jpg\nd156f7cc95141ef1fca4de81d6dc65b143c9988f,2014-08-19,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-22.1423284,-51.3750141\",-22.1423284,-51.3750141,12000,BRL,9488,3072.74,,,,,,,,http://www.properati.com.br/muwd_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95749Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Moyss Miguel HaddadM2 rea Construida: 280, Obs: Salo Ideal Para Loja, 217mterreo + 68m Mezanino.estacionamento Na Frente,\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Moyss Miguel Haddad,https://thumbs4.properati.com/5/A5TB5m6yLugDjIdQVsXy9br70YQ=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1533/eeec7b18-daa6-422b-aac1-82894f366e60.jpg\n90ed612641d0db18967ee9c07edc8b0647c97976,2014-08-19,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-22.1423284,-51.3750141\",-22.1423284,-51.3750141,12000,BRL,9488,3072.74,,,,,,,,http://www.properati.com.br/muwc_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95725Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Moyss Miguel HaddadM2 rea Construida: 280, Obs: Salo Ideal Para Loja, 217m Terreo, + 68m Mezanino.estacionamento Na Frente, Imvel Novo\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Moyss Miguel Haddad,https://thumbs4.properati.com/2/8rbyOC1iGXRWRZqh5Z6pcOsdsfQ=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1532/a142f7a0-ff97-4794-84d8-4144253429c3.jpg\nc69e1ca4e7cd3346bc510240893bf2c2c7437e8e,2014-08-19,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-22.1423284,-51.3750141\",-22.1423284,-51.3750141,12000,BRL,9488,3072.74,,,,,,,,http://www.properati.com.br/muvh_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95724Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Moyss Miguel HaddadM2/ Terreno: 280, Obs: Salo Ideal Para Loja, 217m Terreo e + 68m Mezanino.Estacionamento Na Frente,imvel Novo.\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Moyss Miguel Haddad,https://thumbs4.properati.com/1/3gyCoSFzrSme_NcQhKWIEnuoeaY=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1501/CeAfrlKKrQIRE9uzNxoa.jpg\n0c5e391bac4d007585efb02466681c760a758423,2014-09-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8379345,-49.3370139\",-20.8379345,-49.3370139,8000,BRL,6325.32,2048.49,,,,,,,,http://www.properati.com.br/q4jv_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95899Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Ch. Estancia Jockey Clube..: Salo Comercial , Salo Comercial, Wcs.: 04, Wcs. C/ A/e, Cozinha: 01, Obs: P Direito 9 Metros, M2 rea Construida: 1.000, Detalhes: Salo Novo, Sendo Os Wcs Piso Porcelanato, Cozinha Porcelanato e Piso Liso, Com Telhado Vulcanizado.\",Ref.: SA95899 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Ch. Estancia Jockey Clube,https://thumbs4.properati.com/8/OM-MkRbrVgdcQa163wlgXjWDHqU=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2007/8541c9f9-4477-448d-a193-d2b00dc5eed4.jpg\n01bdf823f7827d745574bb7a180a39e4db359463,2014-08-19,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,8000,BRL,6325.32,2048.49,,,,,,,,http://www.properati.com.br/muux_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95894Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Ch. Jockey Clube..: Salo Comercial Na Chcara Jockey Clube, Salo Comercial, M2/ Terreno: 5.000, M2 rea Construida: 1.150, Detalhes: Galpo Com Piso Cimento Polido, Estrutura Metlica, Entrada Lateral Para Caminho.p Direito Alto Com 6,/2 M. \",Ref.: SA95894 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Ch. Jockey Clube,https://thumbs4.properati.com/1/HZjM3Xdq2xWgpQ5gSrCVIASVnJA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1481/Tz9VYOVWXC1ZVU5fUHwL.jpg\n050a20b55332403b962575877b81d609a5988a15,2014-09-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8379345,-49.3370139\",-20.8379345,-49.3370139,8000,BRL,6325.32,2048.49,,,,,,,,http://www.properati.com.br/q4ju_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95900Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Ch. Estancia Jockey Clube..: Salo Comercial , Salo Comercial, Wcs.: 04, Wcs. C/ A/e, Cozinha: 01, Obs: P Direito 9 Metros, M2 rea Construida: 1.000, Detalhes: Salo Novo, Sendo Os Wcs Piso Porcelanato, Cozinha Porcelanato e Piso Liso, Com Telhado Vulcanizado.\",Ref.: SA95900 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Ch. Estancia Jockey Clube,https://thumbs4.properati.com/3/ZL10NlMLKOM5C49dt-MjpOTaaMo=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2006/9fbe5c67-00ba-4e7b-9904-08316f433dd4.jpg\nfeb178373d5eab7df3b6c546a5d1e6a192156c7f,2014-08-19,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8457948,-49.353464\",-20.8457948,-49.353464,8000,BRL,6325.32,2048.49,,,,,,,,http://www.properati.com.br/muuv_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95892Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Ch. Jockey Clube..: Salo Comercial Na Chcara Jockey Clube, Salo Comercial, M2/ Terreno: 5.000, M2 rea Construida: 1.150, Detalhes: Galpo Com Piso Cimento Polido, Estrutura Metlica, Entrada Lateral Para Caminho.p Direito Alto Com 6,/2 M.\",Ref.: SA95892 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Ch. Jockey Clube ,https://thumbs4.properati.com/5/xwV26hopilS3ZK6xLE-O3iy4rE4=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1479/vT0mPYs7U2dAsti8LWGQ.jpg\n882a8542b44e551782d3126a1d43d907cdba854b,2014-08-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8442587,-49.3291753\",-20.8442587,-49.3291753,7000,BRL,5534.66,1792.43,,,,,,,,http://www.properati.com.br/mt5y_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95817Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Marginal Eng. SchimidtWc. Social: 02, Cozinha, Mesanino, Escritrio, M2/ Terreno: 2.000, M2 rea Construida: 1.100, Pintura Nova, Piso Dormitrios: Cimentado;, Piso Salas: Polido;, Detalhes: Salo Novo, Com Estrutura Metlica e Banheiro C/ Acessibilidade.\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Marginal Eng. Schimidt,https://thumbs4.properati.com/4/FPT3HYl7BCrZMA5Okj6iNxUgPRA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1432/MaEwnQ1cL6HLcrscPFb8.jpg\n201e0ccc9fc63a78f68391bdd74ac5407e07da9b,2014-08-04,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,1000,BRL,790.66,256.06,,,,,,1,,http://www.properati.com.br/lxpd_alugar_casas_sao-jose-do-rio-preto,\"Ref.: CA95884Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Parque Liberdade III..:Casa Condomnio Parque da Liberdade III, Casa Condominio, Dormitrios: 03, C/ A/e: 02, Wc. Social: 01, Wcs. C/ A/e, Sala de Visita: 01, Cozinha: 01, Cozinha Planejada, Interfone, Acabamento Em Gesso, Sanca, Campainha, Forro de Laje, Obs: Imvel Semi Novo, Com Projeto da Arquiteta Kenia Miranda, o Imvel Tem Papel de Parede, Cortinas Nos Dormitrios e Sala, Luminrias Na Cozinha., M2/ Terreno: 143, Garagem Coberta: 02, Churrasqueira, Detalhes: R$ 1.000,00 + Iptu + Condominio\",Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Parque Liberdade III,https://thumbs4.properati.com/2/v319TrEqOc-5Hy9xmTQXh1KL2UE=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/807/7331be4a-3945-41cd-b761-4322696192f5.jpg\n05cf0728d4ba6016469b6b4a864542a1824dc25c,2014-08-15,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.7688712,-46.7100661\",-23.7688712,-46.7100661,2000,BRL,1581.32,512.12,,,,,,,,http://www.properati.com.br/mqtt_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95463Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Jardim So MarcosWc. Social: 2, Cozinha: 1, Mesanino, Recepo, M2/ Terreno: 406, M2 rea Construida: 110, Pintura Nova, Piso Salas: Piso Frio\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Jardim So Marcos,https://thumbs4.properati.com/9/EMUy3dbQiGwd8LmfHH92qsmCQ7Q=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1375/srbDQgpudCyI2lewxvjS.jpg\nf5942694fe76bfe705275acfe74148c588367f86,2014-09-01,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.7688712,-46.7100661\",-23.7688712,-46.7100661,4000,BRL,3162.64,1024.24,,,,,,8,,http://www.properati.com.br/o215_alugar_casas_sao-jose-do-rio-preto_rua-antonio-dias,\"Ref.: CO95681Tipo: Casa ComercialCidade: So Jos do Rio Preto - SPBairro: Jardim So MarcosWc. Social: 04, Sala(s): 08, M2/ Terreno: 280, Garagem Coberta: 02, Forro de Laje, Piso Salas: Frio, Iptu Valor: 58,00, Detalhes: timo Local Para Clincas.\",Tipo: Casa Comercial Cidade: So Jos do Rio Preto - SP Bairro: Jardim So Marcos,https://thumbs4.properati.com/4/F58YgAYlsu22NFL9RPPH5RURZJ8=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1779/sr0aS0NcXQGZ7HsOtyan.jpg\n1b093694ca05135b213162c0bd499e9adb708309,2014-12-10,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.6302071,-46.5818983\",-23.6302071,-46.5818983,4000,BRL,3162.64,1024.24,,,,,,,,http://www.properati.com.br/ufsq_alugar_comercial_sao-jose-do-rio-preto_avenida-marginal,\"Ref.: SA95942Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Pq. Res. Manor Daud..: Salo Comercial No Parque Residencial Manor Daud, Salo Comercial, Detalhes: Salo Com 340m, Entrada Para Caminho.frente Para Br153\",Ref.: SA95942 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Pq. Res. Manor Daud,https://thumbs4.properati.com/4/4e8bVPqS0g1ezrFKP9fcdzvSn0I=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2493/rJ3aboE1bZnwuqJHy4Lr.jpg\n50bd70c45a9a9b087c404cac6b67c491f64c211e,2014-10-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,2500,BRL,1976.65,640.15,,,,,,,,http://www.properati.com.br/ri3l_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95916Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Res. Caetano..: Salo Comercial No Residencial Caetano, Salo Comercial, Wcs.: 02, Cozinha: 01, M2 rea Construida: 350, Detalhes: Cozinha2 Wc Com Acessibilidade;1 Porta Na Frente1 Porta Na Lateral;barraco Novo.cobertura Com Estrutura Metlica. P Direito de 6 Metros.\",\"Ref.: SA95916 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Res. Caetano ..: Salo Comercial No Residencial Caetano, Salo Comercial, Wcs.: 02, Cozinha: 01, M2 rea Construida: 350, Detalhes: Cozinha 2 Wc Com Acessibilidade\",https://thumbs4.properati.com/9/l325GXwN3fD9xS0dglH5hmaB83A=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2281/FjKJBqBJFxhvJ4atJVuO.jpg\n50a4a96d3ec01b7b1032e5eec4a3496b38f35c4f,2014-10-09,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7780086,-49.3450562\",-20.7780086,-49.3450562,2500,BRL,1976.65,640.15,,,,,,,,http://www.properati.com.br/r9j7_alugar_casas_sao-jose-do-rio-preto_avenida-belvedere,\"Ref.: CA95904Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Village Damha Rio Preto I..: Casa Em Condomnio Village Damha Rio Preto I, Casa Condominio, Dormitrios: 03, Tipo Apto.: 01, C/ A/e: 03, Cozinha: 01, Cozinha Planejada, Varanda, rea de Servio, Wc. de Empregada/servio, Garagem Coberta: 02, Churrasqueira\",Ref.: CA95904 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Village Damha Rio Preto I,https://thumbs4.properati.com/1/CVM0xMvGlg_DX1BluR3vWpRkhMU=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2206/7f2a5d99-2465-48fb-859c-a52311ba0c18.jpg\nf5be311dcee4a66dcb26f463f76c49b6084de8ab,2014-11-11,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,1600,BRL,1411.74,457.2,,,,,2,1,,http://www.properati.com.br/t8nu_alugar_apartamentos_tatuape_rua-serra-de-braganca,\"APTO DE 40M/2 COM 01 DORMITRIO, SALA AMPLA COM SACADA, COZINHA PLANEJADA, WC SOCIAL, REA DE SERVIO, 01 VAGA DE GARAGEM LIVRE, LAZER COMPLETO COM PISCINA, PLAYGROUD, SALO DE FESTA, CHURRASQUEIRA, SAUNA, SALA DE FITINESS, SALA DE LEITURA, SALO DE JOGOS, EXCELENTE LOCALIZAO. O APTO ESTA COM FOGO, GELADEIRA, MICROONDAS, SUGGAR, BALCO ESTILO COZINHA AMERICANA, 02 BANQUETAS, 02 MESINHAS DE CANTO, 01 HOME-OFFICER, CAMA BOX COM COLCHO, VENTILADOR DE TETO, ARMRIO NO DORMITRIO.\",TOLEDO CONSULTORIA JURIDICA E IMOBILIARIA,https://thumbs4.properati.com/6/-9lTZIOGIGHwHaddpPoJu6s92hY=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/toledo/photos/2/6c5e21eb-1c7b-41d6-9257-b6e7994ea93c.jpg\na031b9b0ccce57678aab134025713cdb4ad7ed19,2014-10-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7630911,-49.3810839\",-20.7630911,-49.3810839,1500,BRL,1185.99,384.09,,,,,,,,http://www.properati.com.br/ri3h_alugar_comercial_sao-jose-do-rio-preto_rua-gumercindo-tomas-de-aquino,\"Ref.: SA95914Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial Centenrio da Emancipao..: Salo Comercial No Distrito Industrial Centenrio da Emancipao, Salo Comercial, Wcs.: 02, Cozinha: 01, M2/ Terreno: 200, M2 rea Construida: 100, Detalhes: Salo Em Construo, Com 2 Wc Com Acessibilidade e Cozinha.porta Frente e Lateral.estrutura Metlica, P Direito 5 Metros.\",Ref.: SA95914 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Dist. Industrial Centenrio da Emancipao,https://thumbs4.properati.com/5/cuTILpWk120n1EtpTTP7CuHqFTI=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2277/xOFbBdd0MAPLxuFIMMMc.jpg\n8198126233f30e45a7ec723640ecd09fc933f35f,2014-10-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7630911,-49.3810839\",-20.7630911,-49.3810839,1500,BRL,1185.99,384.09,,,,,,,,http://www.properati.com.br/ri3j_alugar_comercial_sao-jose-do-rio-preto_rua-gumercindo-tomas-de-aquino,\"Ref.: SA95915Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial Centenrio da Emancipao..: Salo Comercial No Distrito Industrial Centenrio da Emancipao, Salo Comercial, Wcs.: 02, Cozinha: 01, M2/ Terreno: 200, M2 rea Construida: 100, Detalhes: Salo Em Construo, Com 2 Wc Com Acessibilidade e Cozinha.porta Frente e Lateral.estrutura Metlica, P Direito 5 Metros.\",Ref.: SA95915 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Dist. Industrial Centenrio da Emancipao,https://thumbs4.properati.com/0/LWklCEKjfuTI94hwh7U-tpkiQxw=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2279/YBRlngu70KNBBuf3pXju.jpg\ncfe8aedbfe7496306c9ee598fdb0799d1a4d43a7,2014-08-14,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8178411,-49.387605\",-20.8178411,-49.387605,3000,BRL,2371.98,768.18,,,,,,,,http://www.properati.com.br/mpsw_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95860Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: RedentoraSalo Comercial, Terrea, Acabamento Em Gesso, Forro de Laje, Piso Salas: Frio, M2 rea Construida: 160\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Redentora,https://thumbs4.properati.com/1/w-odT7rMEUYcr3TVS4fo2ausAAI=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1278/Kp7YsojHwZdQDjK4JvMz.jpg\n9002dc7d32f4f097a4f9c789ad93b7f5304396cf,2014-08-14,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8178411,-49.387605\",-20.8178411,-49.387605,3000,BRL,2371.98,768.18,,,,,,,,http://www.properati.com.br/mpsz_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95862Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: RedentoraM2 rea Construida: 190, Garagem Descoberta: 02, Forro de Laje, Piso Salas: Frio\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Redentora,https://thumbs4.properati.com/7/xW33i6TXrwRgMFrpyW_syKKKlPs=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1281/UKC5P4P7lZPyQSY9l8xC.jpg\n2128d47314a494ddecda6b6e981c46afbd3fae51,2014-10-30,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,3000,BRL,2371.98,768.18,,,,,,,,http://www.properati.com.br/s9we_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95921Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Redentora..: Salo Comercial Na Redentora, Salo Comercial, M2/ Terreno: 150, Detalhes: tima Localizao!\",Ref.: SA95921 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Redentora,https://thumbs4.properati.com/6/HVrSVpwKgIJpnnFxGcMzyHIeUx4=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2347/abTB8PxHKedvADO9yyhF.jpg\n0329fa7a5536c99eee743612010897cafed7f340,2014-10-15,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8030634,-49.4324803\",-20.8030634,-49.4324803,3000,BRL,2371.98,768.18,,,,,,,,http://www.properati.com.br/re15_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95910Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Res. Palestra..: Salo Comercial No Residencial Palestra, Salo Comercial, Wc. Social: 02, Forro de Laje, Obs: timo Salo, Novo., M2/ Terreno: 250, M2 rea Construida: 200\",Ref.: SA95910 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Res. Palestra,https://thumbs4.properati.com/8/MBuRdEvx5x-V-zpeycnxFNg-n70=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2238/NOqLX7BtA7KEH0SUs5rV.jpg\nc822a8819d8874916877cc6b3c91194cbf5968ca,2014-10-09,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7780086,-49.3450562\",-20.7780086,-49.3450562,3000,BRL,2371.98,768.18,,,,,,,,http://www.properati.com.br/r9j6_alugar_casas_sao-jose-do-rio-preto_avenida-belvedere,\"Ref.: CA95903Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Village Damha Rio Preto I..: Casa Condomnio Village Damha Rio Preto I, Casa Condominio, Dormitrios: 03, Tipo Apto.: 03, C/ A/e: 03, Cozinha: 01, Cozinha Planejada, Fogo, Lavabo, Salas : 01, Sala P/ Ambientes : 02, Piscina, Ducha Externa, Varanda, rea de Servio, Wc. de Empregada/servio, Garagem Coberta: 02, Churrasqueira\",Ref.: CA95903 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Village Damha Rio Preto I,https://thumbs4.properati.com/2/595nD1yJ6OYSKSxIr0D1iMhNilQ=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2205/060de194-4e66-4f1e-b0cc-dd05e276539f.jpg\n2568b8aa05b9d3cb3e9e8ef74c4461c34d2d57b5,2014-10-05,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8350772,-49.369111\",-20.8350772,-49.369111,3000,BRL,2371.98,768.18,,,,,,,,http://www.properati.com.br/r1q8_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95905Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Quinta das Paineiras..: Salo Comercial No Quinta das Paineiras, Salo Comercial, Terrea, Cozinha: 01, Escritrio, M2 rea Construida: 225\",Ref.: SA95905 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Quinta das Paineiras,https://thumbs4.properati.com/6/2tu7W_u475v3n8Ubll7avmjLXPI=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2130/0y7ijJHaZqFbrC0LSLFy.jpg\n0dff14f277324eb9f04d59b6c26758ccdb744e27,2014-12-05,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8126181,-49.3404534\",-20.8126181,-49.3404534,3700,BRL,2925.47,947.43,,,,,,,,http://www.properati.com.br/udzy_alugar_casas_sao-jose-do-rio-preto_avenida-miguel-damha,\"Ref.: CA95938Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Damha III..: Linda Casa Tipo Sobrado No Condomnio Damha Iii, Casa Condominio, Sobrado, Dormitrios: 03, Suite Master: 01, C/ Hidro, Tipo Apto.: 02, C/ A/e, Ducha Dupla, Lavatrio Duplo, Cozinha: 01, Cozinha Planejada, Despensa, Escritrio, Lavabo, Salas : 01, Sala P/ Ambientes : 02, Varanda, rea de Servio, Lavanderia, Quarto de Empregada, Wc. de Empregada/servio, Forro de Laje, Garagem Descoberta: 02, Garagem Coberta: 02, Churrasqueira\",Ref.: CA95938 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Damha III,https://thumbs4.properati.com/0/FGZdTzG9TG6qqioIMRxZ7WLDQWY=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2452/bVU2c6naoVF7KCpphJqL.jpg\n6b3bff7cc70a79dfed41fc0ae514a7ad16a328b8,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,7000,BRL,6640.22,2150.47,,88,24.437159090909088,79.54545454545455,,,,http://www.properati.com.br/ifvc_alugar_comercial_vila-olimpia,\"\tEdifício comercial de excelente padrão, localizado em ponto nobre da Vila Olímpia, próximo da Av. dos Bandeirantes e Shopping Vila Olímpia.\t Prédio conta com 3 elevadores de alta velocidade, sistema de proteção contra incêndio, ar condicionado central, próximo a bancos. Referíªncia: CO65\",\"Comercio no(a) Vila Olímpia , São Paulo - SP\",\nc26e111d703af6f65be91df94c274bbd41585a40,2014-08-21,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.6357809,-46.5324419\",-23.6357809,-46.5324419,50000,BRL,39533.44,12803.11,,,,,,,,http://www.properati.com.br/nqbl_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95307Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Rodovia Assis ChateaubriandWcs.: 4, Escritrio, M2/ Terreno: 4.000, Detalhes: Barraco Com 4.000 M\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Rodovia Assis Chateaubriand,https://thumbs4.properati.com/0/lJ2ffPr7gtS2vlQuGTg_zDX6844=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1592/WQmB3JTS7Y2N72aUI5Ud.jpg\n2e8ffab1b55b5245e917b3597009338cb92b6586,2014-10-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7807632,-49.3985157\",-20.7807632,-49.3985157,50000,BRL,39533.44,12803.11,,,,,,,,http://www.properati.com.br/rgyw_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95804Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial Waldemar VerdiM2/ Terreno: 6.000, M2 rea Construida: 3.200\",Salo,https://thumbs4.properati.com/3/X38EXdJIkvCXPQCdWURfrgMMMWQ=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2270/OTptGk75zRPMzZ1pL1on.jpg\nc522d5f57ea8e14d18fa3ba746cb581cd379330a,2014-08-20,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,100000,BRL,79066.88,25606.22,,,,,,,,http://www.properati.com.br/np98_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95721Tipo: Salo IndustrialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial IWcs.: Vrios, M2/ Terreno: 13.000, M2 rea Construida: 8.500, Detalhes: tima Localizao\",Tipo: Salo Industrial Cidade: So Jos do Rio Preto - SP Bairro: Dist. Industrial I,https://thumbs4.properati.com/6/jARKWDWV0rGe-HzGUtUeE0X3knw=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1549/ZaCxToSHCaeCL7GxZ08g.jpg\n07383fafc72a9aa9f0286b841b782ca718485de8,2014-09-01,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8178198,-49.3468294\",-20.8178198,-49.3468294,3500,BRL,2767.31,896.21,,,,,,1,,http://www.properati.com.br/o246_alugar_casas_sao-jose-do-rio-preto_avenida-nadima-damha,\"Ref.: CA95827Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Damha II (Res. Jardins)Dormitrios: 03, Suite: 03, C/ A/e: 03, Sacada, Sala de Visita, Copa, Cozinha, Cozinha Planejada, Despensa, Piscina, rea de Servio, Lavanderia, Wc. de Empregada/servio, Garagem Descoberta: 02, Garagem Coberta: 02, Churrasqueira, Acabamento Em Gesso, Sanca, Forro de Laje, Piso Salas: Frio;, Documentao: Em Dia.\",Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Damha II (Res. Jardins),https://thumbs4.properati.com/6/Bq9TbjreWNnjd2VrvJ5oieGegeQ=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1889/db5caa0b-ec86-44f4-9c0f-ab26a6816b62.jpg\n179f70a47a9ce50e161ea483ffee741a8e1c7fb2,2014-08-14,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8178411,-49.387605\",-20.8178411,-49.387605,3500,BRL,2767.31,896.21,,,,,,,,http://www.properati.com.br/mpsx_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95861Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Redentora TerreaM2 rea Construida: 190, Acabamento Em Gesso, Forro de Laje, Pintura Nova, Piso Salas: Frio\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Redentora Terrea,https://thumbs4.properati.com/1/VQPzYdCL1X2L1IkzU17WHDuej9s=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1279/nGCLTL2QhwMJSHGtxR7y.jpg\ne48c41f6ad3dba93e0ac6038a5aa5cda39d651d0,2014-09-01,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-22.1423284,-51.3750141\",-22.1423284,-51.3750141,3500,BRL,2767.31,896.21,,,,,,,,http://www.properati.com.br/o2m6_alugar_casas_sao-jose-do-rio-preto,\"Ref.: CA95898Tipo: Casa Comercial, Casa ResidencialCidade: So Jos do Rio Preto - SPBairro: Moyss Miguel Haddad..: Casa Residencial No Moyss Miguel Haddad, Casa Residencial, Dormitrios: 03, Suite: 03, C/ Hidro: 01, C/ A/e: 03, Sacada, Wc. Social: 01, Wcs. C/ A/e, Sala de Visita: 01, Copa: 01, Cozinha: 01, Cozinha Planejada, Despensa, Escritrio, Salas : 01, Sala P/ Ambientes : 03, Piscina, Varanda, rea de Servio, Lavanderia, Quarto de Empregada, Wc. de Empregada/servio, Ar Split, Ventilador, Interfone, Acabamento Em Gesso, Sanca, Tv Cabo, Campainha, Quintal, Grade, Murado, Forro de Laje, Piso Salas: Frio, Obs: Imvel Prximo Ao Shopping Iguatemi, M2 rea Construida: 450, Garagem Descoberta: 04, Garagem Coberta: 02, Churrasqueira, Jardim de Inverno, Jardim Interno, Iptu Valor: 199,00\",\"Ref.: CA95898 Tipo: Casa Comercial, Casa Residencial Cidade: So Jos do Rio Preto - SP Bairro: Moyss Miguel Haddad\",https://thumbs4.properati.com/5/fZwdpPACH02ObP9aj7-UFvVm0lE=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1937/a7WmbhKqWo7JqFgAHcOD.jpg\n9ea36b0e269fbf2581eef1510f0148594919b330,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,3150,BRL,2988.09,967.71,,45,21.50466666666667,70,,,,http://www.properati.com.br/ifxf_alugar_comercial_chacara-santo-antonio,\"\tNovAmérica Inside Park, o novo marco imobiliário de São Paulo. É um novo espaço, um novo bairro, uma nova opção de vida. É a expansão urbana mais consciente e mais urbana. Um mundo para morar, trabalhar e viver em plena harmonia. Uma região onde a vida social e a vida familiar se equilibram. Um lugar que concretiza o prazer de viver em uma grande metrópole. 1 torre comercial, escritórios de vila e lojas, 5 condomínios residenciais independentes e 2 parques em mais de 70.000 m² de terreno.\t\tNovAmérica Office Park é um conceito totalmente novo de local de trabalho, que valoriza eficiência e a qualidade de vida de seus ocupantes. EDSA em parceria com Neusa Nakata tiveram a missão de conceber um produto inusitado, diferenciado e particular. O resultado é um cenário privilegiado para que você e seus clientes tenham bem-estar em horário comercial.\t\tA região do NovAmérica reúne as principais empresas nacionais e multinacionais, que chegaram por aqui atraídas pela infraestrutura e pelas importantes e fáceis vias de acesso. O que confirma que a região é o novo pólo econômico de São Paulo.\t\tConheça os diferenciais do NovAmérica Office Park:\t\tÚnico na Nações Unidas a integrar um complexo formado por 1 torre comercial, escritórios de vila, lojas e 5 condomínios residenciais independentes.\tConceito consagrado de bem-estar em horário comercial.\tAcesso ao parque com mais de 10 mil m² destinado à cidade.\tPotencial de clientes vindos dos 5 condomínios residenciais.\tA oportunidade de morar e trabalhar de forma integrada e independente.\tServiços by Facilities Office.\tArquitetura e decoração inspirados nas ultimas tendências mundiais.\tProjeto paisagístico assinado por um dos mais conceituados escritórios do mundo, a EDSA. Referíªncia: CO150\",\"Comercio no(a) Chácara Santo Antônio , São Paulo - SP\",https://thumbs4.properati.com/2/2S3OsO4umzIVXFHbHKTrb7dk-x8=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/150/c0d9b8fc-3da8-4a27-ac1f-4e1684d35312.jpg\na5c14fe842664d12fe4bdfad313d5af251121d89,2014-10-01,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5882535,-46.6167776\",-23.5882535,-46.6167776,1400,BRL,1106.91,358.48,,,,,,,,http://www.properati.com.br/qz98_alugar_comercial_sao-jose-do-rio-preto_rua-da-imprensa,Ref.: SA95836Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Vila DinizDetalhes: Salo Comercial Com Aproximadamente 50 M e 02 Banheiros Com Acessibilidade.,Ref.: SA95836 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Vila Diniz,https://thumbs4.properati.com/9/CRgjdJjcRp1Lz-pPZChI7eL8ZIY=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2075/fOuQlJsX61PBHGGwhPSh.jpg\nb64a9bd212d9e91821e5e892eeff464bbc0202cd,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,9600,BRL,9106.57,2949.21,,120,24.57675,80,,,,http://www.properati.com.br/ifwi_alugar_comercial_vila-olimpia,\"\tConjunto Comercial com 120m² de área útil, 3 vagas, 3 banheiros, 1 copa. Referíªncia: CO112\",\"Comercio no(a) Vila Olímpia , São Paulo - SP\",https://thumbs4.properati.com/0/3pwYR5bzXi_fuQfUVbzkPbFkst4=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/112/93f2b4dc-86f7-4544-803f-62dfec9dc967.jpg\n59a2898d29cb6860a70355c1b2028b9ab38d89d9,2015-01-13,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5456177,-46.563645\",-23.5456177,-46.563645,1801,BRL,1459.35,472.62,,,,,,2,,http://www.properati.com.br/vyso_alugar_apartamentos_tatuape,\"APTO DE 75M/2, COM 03 DORMITRIOS, SALA AMPLA COM SACADA, COZINHA PLANEJADA, WC SOCIAL, REA DE SERVIO COM WC, REPLETOS DE ARMRIOS, 01 VAGA DE GARAGEM FIXA, EXCELENTE ESTRUTURA DE LAZER COM PISCINA, PLAYGROUD, SALO DE FESTA, CHURRASQUEIRA, SALA DE GINASTICA, EXCELENTE LOCALIZAO, A 100 METROS DO METR.\",LOCAO TATUAPE CONDOMINIO ILHAS GREGAS,https://thumbs4.properati.com/6/-xzzzVWk3lxFITvW285d3YmP00c=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/toledo/photos/13/e7c88a20-dcac-49b2-ae42-e782447d3f07.jpg\n1925426e5575ecd98f1e63422a187081754e0f5c,2015-01-13,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5444872,-46.5657875\",-23.5444872,-46.5657875,1499,BRL,1214.64,393.37,,,,,2,1,,http://www.properati.com.br/vysp_alugar_apartamentos_tatuape_rua-serra-de-braganca,\"APTO DE 40M/2, COM 01 DORMITRIO, SALA AMPLA COM SACADA, COZINHA, WC SOCIAL, SEMI-MOBILIADO COM FOGO, SUGGAR, GELADEIRA, MICRO-ONDAS, ARMRIOS PLANEJADOS NA COZINHA, CAMA BOX, GUARDA ROUPA, VENTILADOR DE TETO, RACK DE TV, 02 BANQUETAS, 02 MESINHAS DE CANTO, EXCELENTE ESTRUTURA DE LAZER COM PISCINA, PLAYGROUD, SALO DE FESTA, CHURRASQUEIRA, SAUNA, SALA DE FITINESS, BRINQUEDOTECA, SALO DE JOGOS, SALA DE LEITURA. EXCELENTE LOCALIZAO, PRXIMO DO SUPERMERCADO PO DE ACAR, FCIL ACESSO PARA O METR.\",NEW CONCEPT 22 A,https://thumbs4.properati.com/5/SXF7T5Zfxal6lcQifCYOufBKjYA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/toledo/photos/14/0522b83e-c37e-4736-ac81-ca8540d2d806.jpg\nfda45d95b6fe8daf9215dcc80ad057531879eda4,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,4575,BRL,4339.84,1405.48,,120,11.712333333333333,38.125,,,,http://www.properati.com.br/ifxz_alugar_comercial_vila-olimpia,\"\tConjunto Comercial na Vila Olimpia, com 61m², podendo aumentar a área para 117m². Referíªncia: CO179\",\"Comercio no(a) Vila Olímpia , São Paulo - SP\",https://thumbs4.properati.com/5/cZY_8L_8bfKOV9ay-F-6DsnRZ-c=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/179/0df8ffa9-49a9-49c0-9968-37c13719d904.jpg\n7eb9fb8ad21ff1da490b75fd04643939fd72edf1,2015-01-13,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5508578,-46.5679013\",-23.5508578,-46.5679013,1550,BRL,1255.99,406.76,,,,,11,1,,http://www.properati.com.br/vysn_alugar_apartamentos_tatuape,\"APTO DE 57M/2, COM 02 DORMITRIOS, SALA AMPLA COM SACADA, WC SOCIAL, COZINHA PLANEJADA, AMPLA REA DE SERVIO COM WC, 01 VAGA DE GARAGEM, SALO DE FESTA, CHURRASQUEIRA, PLAYGROUD, QUADRA DE FUTEBOL, EXCELENTE LOCALIZAO, PRXIMO AO SHOPP/METR BOULEVAR TATUAP.\",Apartamento,https://thumbs4.properati.com/4/mwisK4iz0H0viKmBVOGr8B40c1o=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/toledo/photos/12/612ab301-0ac4-4c4c-9691-7278200c2b62.jpg\n59a2934bc5a4c3530613fae471a210ae63498d80,2014-09-02,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.826094,-49.376396\",-20.826094,-49.376396,1200,BRL,960.76,311.15,,,,,,1,,http://www.properati.com.br/ob16_alugar_apartamentos_sao-jose-do-rio-preto,\"Apartamento mobiliado, pronto para morar e com excelente localizao, na zona sul.2 dormitrios sendo 1 tipo apto todos com armrio, ventilador de teto e ar-condicionadosala com sacada para 2 ambientes com sofa e estantecozinha planejada com mesa com 4 cadeiras e fogo2 banheiros com box blindex, gabinete e chuveirorea de servio com armrio, varal e mquina de lavar1 vaga de garagem pilotisCondomnio: Porto eletrnico, portaria 24h, elevador, churrasqueira e piscina.\",Apartamento com 2 dormitrios em Rio Preto,https://thumbs4.properati.com/5/VXcYDJRN6VtNxvgVw-mxJOlL_sA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/premier/photos/111/91c65753-86b6-40fd-8673-7f8f107a8459.jpg\nd08581768058f5286ca4f209222d59a3f10af84d,2015-01-13,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-22.8877586,-47.081265\",-22.8877586,-47.081265,2401,BRL,1945.56,630.08,,,,,19,2,,http://www.properati.com.br/vysm_alugar_apartamentos_tatuape,\"APTO DE 93M/2, COM 03 DORMITRIOS, SENDO UMA SUTE, SALA AMPLA COM SACADA E DOIS AMBIENTES, WC SOCIAL, COZINHA, AMPLA REA DE SERVIO, REPLETO DE ARMRIOS, 02 VAGAS DE GARAGEM, LAZER COMPLETO COM PISCINA, PLAYGROUD, SALO DE FESTA, SALO DE JOGOS, SAUNA, CHURRASQUEIRA, EXCELENTE LOCALIZAO, PRXIMO DA PRAA SILVIO ROMERO.\",APTO LOCAO TATUAP,https://thumbs4.properati.com/3/d45ivnQIryA-xDQvwZrOc1pBPlE=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/toledo/photos/11/85e5aee8-90fa-4295-bcb5-dce1ed7e789c.jpg\n7739326a3915df1654866f5c27d42e40f0038365,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,2200,BRL,2086.92,675.86,,102,6.626078431372549,21.568627450980394,,,,http://www.properati.com.br/ifwp_alugar_comercial_vila-olimpia,\"\tConjunto Comercial com 46m², 2 banheiros, 3 salas, 1 vaga. Referíªncia: CO122\",\"Comercio no(a) Vila Olímpia , São Paulo - SP\",https://thumbs4.properati.com/6/QhZrO4ITAg_PJagrwPKR0ywe4GI=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/122/80e20053-aaba-4f3b-a8f7-9dc2cdcb44a7.jpg\n4d949c2fcb78e76cf0cb2376ae3a3acc3b27994a,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,2200,BRL,2086.92,675.86,,46,14.692608695652174,47.82608695652174,,,,http://www.properati.com.br/ifut_alugar_comercial_vila-olimpia,\"Conjunto Comercial na Rua Helena, com 46m², 1 vaga. Referíªncia: CO44\",\"Comercio no(a) Vila Olímpia , São Paulo - SP\",https://thumbs4.properati.com/7/rah-1WLiSRF_nrLl-U2mEY27Gus=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/44/3d3a55f5-dd0c-4969-bb57-19f9a554c1f2.jpg\nc98dc531eb6cb68bcec0e60d7dc042bd663caa4d,2014-08-02,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.815191,-49.389435\",-20.815191,-49.389435,5000,BRL,4003.24,1296.47,,,,,,,,http://www.properati.com.br/ludd_alugar_casas_sao-jose-do-rio-preto,\"Imvel com excelente localizao, com recepo, 1 sala com banheiro privativo, 3 salas amplas + 1 interna, todos os ambientes com ar condicionado, cozinha, despensa, garagem interna para vrios veculos. IMVEL SER ENTREGUE REFORMADO E COM AS ACESSIBILIDADES EXIGIDAS!\",Casa Comercial,https://thumbs4.properati.com/8/3TeTIOIgyjtraKZz08buZLIU8W8=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/premier/photos/80/6a0fcb3e-3e78-434f-9624-a5621798b2b2.jpg\ne85c67a2bc55f4834be8d4cf1fd96340ef4ce129,2014-08-04,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8379877,-49.385659\",-20.8379877,-49.385659,800,BRL,632.5,204.84,,,,,,1,,http://www.properati.com.br/lxpc_alugar_casas_higienopolis,\"Ref.: AP95874 Tipo: ApartamentoCidade: So Jos do Rio Preto - SPBairro: Higienpolis..: Apartamento Novo No Jardim Higienopolis, Apartamento, rea Util M2: 50, Dormitrios: 01, C/ A/e, Sacada, Wc. Social: 01, Cozinha C/ A/e, Lavanderia, Elevador, Piso Dormitrios: Porcelanato, Piso Salas: Porcelanato, Obs: Prdio Novo.apto Nunca Habitado., rea de Lazer, Garagem Coberta: 01, Churrasqueira, Espao Gourmet\",Tipo: Apartamento Cidade: So Jos do Rio Preto - SP Bairro: Higienpolis,https://thumbs4.properati.com/6/8tmxgf5PTdg11vI4rVdPwXy39gw=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/806/2077f853-c360-4cd7-b5c8-a33f76415c40.jpg\n33b22b30a043e5a868df61862bb97698b453df89,2014-12-05,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7910821,-49.3781115\",-20.7910821,-49.3781115,800,BRL,632.5,204.84,,,,,,1,,http://www.properati.com.br/ue05_alugar_apartamentos_sao-jose-do-rio-preto_rua-pedro-mulati,\"Ref.: AP95816Tipo: ApartamentoCidade: So Jos do Rio Preto - SPBairro: Jardim ConceioDormitrios: 02, C/ A/e, Wc. Social, Box Wcs., Sala de Estar, Cozinha, Piscina, rea de Servio, Garagem Coberta: 01, rea Util M2: 60, Condominio C/ rea de Lazer, Piscina Adulto, Playground, Valor Condominio R$-: 190,00\",Ref.: AP95816 Tipo: Apartamento Cidade: So Jos do Rio Preto - SP Bairro: Jardim Conceio,https://thumbs4.properati.com/2/4RA-EyT8LswOeQ6lcBbkzLlKjII=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2459/jWyxMNGlETMMxgj2uW9o.jpg\ne2235d97414fcc1a31865b1abe71c1cf85ae01fa,2014-11-17,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7780086,-49.3450562\",-20.7780086,-49.3450562,2700,BRL,2134.78,691.36,,,,,,,,http://www.properati.com.br/tdi5_alugar_casas_sao-jose-do-rio-preto_avenida-belvedere,\"Ref.: CA95926Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Village Damha Rio Preto I..: Casa Condomnio Village Damha Rio Preto, Casa Condominio, Terrea, Dormitrios: 3, Suite: 1, Tipo Apto.: 2, C/ A/e, Lavatrio Duplo: 1, Cozinha C/ A/e: 1, Cozinha Planejada, Escritrio, Lavabo, Salas : 1, Sala P/ Ambientes : 2, Varanda, rea de Servio, Wc. de Empregada/servio, Obs: Espao Para Colocar Piscina, M2 rea Construida: 180, Garagem Descoberta: 2, Garagem Coberta: 2, Churrasqueira, Detalhes: Com Armrios .\",Ref.: CA95926 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Village Damha Rio Preto I ,https://thumbs4.properati.com/3/ZZ3MVCiFvwDaZxqgfl5oQJ1Vp6c=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2390/5d2ae653-6549-4772-b486-cd9360af6ebd.jpg\n1334a26f10895f5a21b6db0adf96e2ff1dc83799,2014-09-16,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7780086,-49.3450562\",-20.7780086,-49.3450562,1250,BRL,988.31,320.07,,,,,,,,http://www.properati.com.br/q4jz_alugar_casas_sao-jose-do-rio-preto_avenida-belvedere,\"Ref.: CA95901Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Maria Stella..: Casa Condomnio Maria Stella, Casa Condominio, Terrea, Dormitrios: 3, Tipo Apto.: 1, Wc. Social: 1, Cozinha: 1, Cozinha Planejada, Salas : 01, Sala P/ Ambientes : 02, Varanda, rea de Servio, Lavanderia, Garagem Coberta: 02, Salo de Festa, Playground, Piscina Adulto, Quadras Poliesportivas: 1, Campo de Futebol: 1, Valor Condominio R$-: 260,00\",Ref.: CA95901 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Maria Stella,https://thumbs4.properati.com/3/nKZAghWN-90yQywuniXpPPLO9sk=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2011/e55aad60-d657-4c71-b732-96032067b76f.jpg\n40c34e74b932ff6e10ff49774a8f7fb6e982c1c2,2014-11-30,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8081017,-49.3505771\",-20.8081017,-49.3505771,4500,BRL,3558.01,1152.28,,,,,,,,http://www.properati.com.br/tz24_alugar_comercial_sao-jose-do-rio-preto_rua-valdomiro-nadotti,\"Ref.: SA95928Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Tres Maria..: Salo Comercial No Bairro Trs Maria, Salo Comercial, Detalhes: 160 Metros Mais 40 Metros de Mesanino.\",Ref.: SA95928 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Tres Maria,https://thumbs4.properati.com/3/Yg9eVpfZKwd2s12w6w1yQ4YYVhY=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2402/MTVaVFERWIxvf0IcXQaQ.jpg\n716428a7c9f46f98fd78e36a72109b0c0ec43753,2014-11-30,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8081017,-49.3505771\",-20.8081017,-49.3505771,4500,BRL,3558.01,1152.28,,,,,,,,http://www.properati.com.br/tz25_alugar_comercial_sao-jose-do-rio-preto_rua-valdomiro-nadotti,\"Ref.: SA95929Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Tres Maria..: Salo Comercial No Bairro Trs Maria, Salo Comercial, M2/ Terreno: 200, Detalhes: 140 Metros Mais 40 Metros de Mesanino.\",Ref.: SA95929 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Tres Maria,https://thumbs4.properati.com/9/a_rVYKLimoBl774T612e8o5Icfo=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2403/fhkihJbLNgNCmHo7eBRG.jpg\n2102860e4c561ee2afec1c15fb71f7c6cfafc28d,2014-08-19,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,9000,BRL,7116.02,2304.56,,,,,,,,http://www.properati.com.br/muuy_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95893Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Ch. Jockey Clube..: Salo Comercial Na Chcara Jockey Clube, Salo Comercial, M2/ Terreno: 5.000, M2 rea Construida: 1.200, Detalhes: Galpo Com Piso Cimento Polido, Estrutura Metlica, Escritrio, Cozinha, 02 Wc, Entrada Pela Frente e Lateral Para Caminho.p Direito Alto Com 6,/2 M.\",Ref.: SA95893 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Ch. Jockey Clube,https://thumbs4.properati.com/3/H-b0Ol05_NX0A5soGXBvD_dA3wk=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1482/gCB4KEDxOoIlG9HlGbCW.jpg\ndb16c4572d4f19875153b041d1dcb6b82115e6a7,2014-11-03,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8174527,-49.4306981\",-20.8174527,-49.4306981,18000,BRL,14232.04,4609.12,,,,,,,,http://www.properati.com.br/sjyy_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95923Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Parque Industrial..: Salo Comercial No Parque Industrial, Salo Comercial, M2/ Terreno: 3.000, M2 rea Construida: 1.780, Detalhes: 1.000m de Patio.escritrio Com Elevado Vrias Salas, Cozinha, Wc, Vestirio Mas/fem;04 Salas de Descanso Para Motorista Com Vestirio, 02 Chuveiros e 04 Wc;cozinha Externa Com Quiosque + 02 Wc Mas/fem;parte Operacional Com P Direito de 7 Metros, e A Lateral Toda Com Embarque e Desembarque;ptio Com Espao Para Carretas, Caminhes e Carros;portaria Na Entrada do Ptio.\",Ref.: SA95923 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Parque Industrial,https://thumbs4.properati.com/0/wtHJ8Xcu4pqyV7YSP06g_N1yrfg=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2356/rO0uXvqSM9ilLshNDz0f.jpg\nc8c81389391a237255bade4a0608212fa62e13b1,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,4200,BRL,3984.12,1290.28,,107,12.058691588785047,39.25233644859813,,,,http://www.properati.com.br/ifxy_alugar_comercial_vila-olimpia,\"\tConjunto Comercial na Vila Olimpia, com 56m² de área útil, excelente localização.  Referíªncia: CO178\",\"Comercio no(a) Vila Olímpia , São Paulo - SP\",https://thumbs4.properati.com/7/YjqSlFDSnOcayBrp2UtD_X2xaJM=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/178/fa4df6b6-bb36-40a4-a46b-7b1438c77d03.jpg\ne1f129a3f1296a7e7a3b60d5b96d61c49159900c,2014-11-19,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.9295411,-46.3418105\",-23.9295411,-46.3418105,1600,BRL,1628.47,527.39,,32,16.4809375,50,,1,,http://www.properati.com.br/tkvf_alugar_apartamentos_sao-paulo,\"A 3 Milenio oferece este(a) Apartamento em Sao Vicente, no bairro Morro dos Barbosas - 1 dormitórios. Gostou desse(a) Apartamento em Sao Vicente? Então veja mais informações! Acesse o nosso site clicando no botão Contate o anunciante ou ligue - ou ainda entre em contato conosco pelo email vendas@ Obrigado!\",\"Apartamento em Sao Vicente, no bairro Morro dos Barbosas - 1 dormitórios\",https://thumbs4.properati.com/5/FvlCdG4KWDtvmzsszDUttj3AuAY=/trim/198x0/smart/filters:strip_icc()/www.imobiliariasdesantos.com.br/fotos/F00/72/807200-DSC03573.JPG\na8821d1c24cccd45d37ef69cf2b29ef3be744fad,2014-11-17,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8456698,-49.4030718\",-20.8456698,-49.4030718,3200,BRL,2530.11,819.39,,,,,,1,,http://www.properati.com.br/tdi2_alugar_apartamentos_sao-jose-do-rio-preto_avenida-anisio-haddad,\"Ref.: AP95925Tipo: ApartamentoCidade: So Jos do Rio Preto - SPBairro: Cond. Green Fields..: Apartamento No Condomnio Green Fields, Apartamento, rea Util M2: 152, Dormitrios: 03, Suite Master: 01, Closet , Tipo Apto.: 02, C/ A/e: 02, Wc. Social: 02, Sala de Visita: 01, Cozinha Planejada, Garagem Coberta: 02\",Ref.: AP95925 Tipo: Apartamento Cidade: So Jos do Rio Preto - SP Bairro: Cond. Green Fields,https://thumbs4.properati.com/4/oRIx16Fpw4LPkRsB59wlBS_mxVo=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2387/ae2a6d87-5760-44ff-a47f-d237a5321c3c.jpg\n08918f4a93c54f4505b783a8e31ef11842c55e56,2014-10-01,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5882535,-46.6167776\",-23.5882535,-46.6167776,80000,BRL,63253.52,20484.98,,,,,,,,http://www.properati.com.br/qz9a_alugar_comercial_sao-jose-do-rio-preto_rua-da-imprensa,\"Ref.: SA95835Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Vila DinizM2/ Terreno: 12.000, Detalhes: - Salo Com 5.000 M e Bastante rea Para Estacionamento Aberto.Ideal Para Atacadista, Hipermercado e Distribuidora, Entre Outros.\",Ref.: SA95835 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Vila Diniz,https://thumbs4.properati.com/1/PkKXX6cNiwu9krqBtdWDinLG89A=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2077/vSsX8s9IzXdbNCXPId0W.jpg\n1ce358d0b5c35f146d70dff4089a8b3d287cb7b9,2014-10-08,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8085627,-49.3452813\",-20.8085627,-49.3452813,5500,BRL,4348.67,1408.34,,,,,,,,http://www.properati.com.br/r6de_alugar_comercial_sao-jose-do-rio-preto_avenida-sebastiao-tavares-da-silva,\"Ref.: SA95809Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Vista AlegreM2/ Terreno: 360, M2 rea Construida: 360, Detalhes: Salo Com Vrias Divisrias.\",Salo,https://thumbs4.properati.com/8/dhtnMWfIFZ8mMCFVDpxpvjoF0LA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2184/l63oynNjEecn8u1Dzf3W.jpg\nb6ed51b61bb57831cc00d904741b4f544cafc28f,2014-08-04,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8178198,-49.3468294\",-20.8178198,-49.3468294,5500,BRL,4348.67,1408.34,,,,,,1,,http://www.properati.com.br/lxoz_alugar_casas_sao-jose-do-rio-preto,\"Ref.: CA95886Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Damha II(Res. Jardins)..: Casa Condomnio Damha II, Casa Condominio, Sobrado, Dormitrios: 03, Suite: 01, C/ Hidro, Tipo Apto.: 02, C/ A/e: 03, Roupeiro, Home, Cozinha: 01, Cozinha Planejada, Despensa, Escritrio, Lavabo, Salas : 01, Sala P/ Ambientes : 03, Piscina, Varanda, rea de Servio, Quarto de Empregada, Wc. de Empregada/servio, Aquecedor Solar, M2 rea Construida: 350, rea de Lazer, Garagem Descoberta: 03, Garagem Coberta: 03, Churrasqueira\", Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Damha II,https://thumbs4.properati.com/1/gEw1FIkmFLn2_zGBjqnuUzcifYU=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/793/e9d2d588-8d92-4070-b7d6-5b55e4484038.jpg\n1273c64fb8b9402dfad98d3984dec2998d8bdb68,2014-08-15,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8126181,-49.3404534\",-20.8126181,-49.3404534,5500,BRL,4348.67,1408.34,,,,,,1,,http://www.properati.com.br/mqtl_alugar_casas_sao-jose-do-rio-preto_avenida-miguel-damha,\"Ref.: CA95731Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Damha IVDormitrios: 03, Suite: 01, Closet : 01, C/ Hidro, Tipo Apto.: 02, C/ A/e: 02, Wcs.: 05, Wcs. C/ A/e: 04, Box Wcs.: 04, Copa: 01, Cozinha: 01, Cozinha C/ A/e, Despensa, Escritrio, Lavabo, Salas : 01, Sala P/ Ambientes : 02, Piscina, Varanda, rea de Servio, Wc. de Empregada/servio, M2/ Terreno: 420, M2 rea Construida: 380, rea de Lazer, Garagem Descoberta: 02, Garagem Coberta: 02, Churrasqueira, Piso Dormitrios: Porcelanatoq, Piso Salas: Porcelanato, Obs: Imvel Muito Bem Conservado, timo Acabamento., Valor Condominio R$-: 300,00, Detalhes: Toda Reformada.\",Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Damha IV,https://thumbs4.properati.com/6/H5vuIYMzk8pMuWJ-OjcdH4GItq8=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1367/9717e95d-fe6f-4cbc-bb5d-21c2fe92d294.jpg\n6aa6bf8a204f123441efb9f97699ee24cf8f9932,2014-08-15,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8178198,-49.3468294\",-20.8178198,-49.3468294,5500,BRL,4348.67,1408.34,,,,,,1,,http://www.properati.com.br/mqtn_alugar_casas_sao-jose-do-rio-preto_avenida-nadima-damha,\"Ref.: CA95797Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Damha ISobrado, Dormitrios: 03, Suite: 01, Tipo Apto.: 02, Wcs.: 01, Cozinha Planejada, Escritrio, Lavabo, Salas : 01, Sala P/ Ambientes : 03, Piscina, rea de Servio, Wc. de Empregada/servio, rea de Lazer, Garagem Coberta: 03, Churrasqueira, Ar Condicionado, Ventilador, Interfone, Piso Dormitrios: Taco, Piso Salas: Frio\",Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Damha I,https://thumbs4.properati.com/0/NAjAu7cPHQwzwT2j3k1G4X7-A3U=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1369/7b6ca8dd-648a-4e18-b9f5-272073bfbf69.jpg\n8a977c0c4b9ef700024cb2ee3e59de53bcf599c8,2014-07-16,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,2000,BRL,1897.2,614.42,,70,8.77742857142857,28.571428571428573,,2,,http://www.properati.com.br/ifzl_alugar_apartamentos_vila-olimpia,\"\tApartamento na Vila Olimpia, sala para 2 ambientes, 2 dormitórios com armários embutidos, cozinha, lavanderia, quarto na área de serviço, 2 banheiros, 1 vaga. Referíªncia: AP237\",\"Apartamento no(a) Vila Olímpia , São Paulo - SP\",https://thumbs4.properati.com/9/waPGo7zWMk2XTzN-br6B0h4Nrgo=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/237/75daca8f-ee37-4813-97af-b4fa00920d4f.jpg\nb48b18eca01fc2732d6afe024904eb1e900cc52a,2014-07-16,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,2000,BRL,1897.2,614.42,,54,11.378148148148147,37.03703703703704,,2,,http://www.properati.com.br/ifzn_alugar_apartamentos_saude,\"\tÓtimo apartamento proximo ao Metrê Praça da Arvore, sala para 2 ambientes, 2 dormitórios com armários embutidos, cozinha com armários, banheiro social e de serviço, 1 vaga na garagem, quadra, piscina e play.\tCom pintura nova, pronto para morar!! Referíªncia: AP239\",\"Apartamento no(a) Saúde , São Paulo - SP\",https://thumbs4.properati.com/0/Zof1-heqqvAgC3xdyYRYiQHIX2I=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/239/450071da-5288-4f26-bbb4-66bcacaef572.jpg\n636cbd951ef34773973cfdef37746b911d07a24c,2014-07-16,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,4000,BRL,3794.41,1228.84,,200,6.1442,20,,4,,http://www.properati.com.br/ifxa_alugar_casas_saude,\"\tAluga sobrado em zona mista, residencial e ou serviços, a uma quadra da Av. do Cursino e a 1000m, do Shopping Plaza Sul, 4 dormitórios, sendo uma suite, ampla sala, mais sala de jantar, cozinha, edicula com 3 quartos, mais lavanderia, garagem para 3 veiculos, casa de esquina, com portões automaticos.  Referíªncia: CA145\",\"Casa no(a) Saúde , São Paulo - SP\",https://thumbs4.properati.com/3/0sie3N08lMquoZ655zNy1CeDUbU=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/145/9fef5234-2fa4-4974-b24c-2b3cf6c4c542.jpg\n0812a419c00dcbaaaa926ad4e8de47da7959f9d9,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,5700,BRL,5407.01,1751.09,,127,13.788110236220472,44.881889763779526,,,,http://www.properati.com.br/ifzp_alugar_comercial_saude,\"\tConjunto Comercial proximo do Metrô São Judas, com 109m² de área útil, dividido em 5 salas, com possibilidade de vão livre, 3 banheiro, 1 copa, 3 vagas, prédio 24 horas.  Referíªncia: CO241\",\"Comercio no(a) Saúde , São Paulo - SP\",https://thumbs4.properati.com/1/0kl3BRCM4riJX3LhCObIu4wuBxQ=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/241/25ac4ee5-f867-4808-b226-837aefd7ba2c.jpg\n570fece17687d05345022d1567c79dd80bd1dcb0,2014-12-19,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8312527,-49.360102\",-20.8312527,-49.360102,15000,BRL,11860.02,3840.93,,,,,,,,http://www.properati.com.br/viin_alugar_comercial_sao-jose-do-rio-preto_avenida-marginal-lineu-alcantara-gil,\"Ref.: SA95950Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial Campo Verde..: Salo Comercial No Distrito Industrial Campo Verde, Salo Comercial, M2/ Terreno: 5.000, M2 rea Construida: 3.000, Detalhes: tima Localizao Com Asfalto Para Rua, Estacionamento Para 60 Carros.barraco 5.000m2c/ Escritrio Isolado Ao Lado do Galpo C/ 6 Salas Grandes Von Livre Cond. Port.24h.\",Ref.: SA95950 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Dist. Industrial Campo Verde,https://thumbs4.properati.com/6/7lTVZ92lzm8ap5amDGM_eb1EHOU=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2528/yS1CaXFW8XYwJdPuhgGn.jpg\n2ee8e3818d61fdc4e01a5d89be181974d74e4380,2014-08-15,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8424141,-49.3549387\",-20.8424141,-49.3549387,15000,BRL,11860.02,3840.93,,,,,,,,http://www.properati.com.br/mqtc_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95109Tipo: Salo Comercial, Salo IndustrialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial Tancredo Neves .Terrea M2/ Terreno: 2.100, M2 rea Construida: 936, Detalhes: Estacionamento Na Frente,entrada Lateral Para Carga e Descarga,prox. do Carrefour\",\"Tipo: Salo Comercial, Salo Industrial Cidade: So Jos do Rio Preto - SP Bairro: Dist. Industrial Tancredo Neves .\",https://thumbs4.properati.com/9/KVlFV9eR-3HhzpzA5EtDKJICEgY=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1358/k6zhNAyNgH9P1excuuTM.jpg\n0f0577616ba85404705aaead7f35ff6e8b4a91da,2014-08-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5733308,-46.544242\",-23.5733308,-46.544242,15000,BRL,11860.02,3840.93,,,,,,,,http://www.properati.com.br/mt62_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: CO95825 Tipo: Predio Comercial, Salo Comercial, Salo Industrial Cidade: So Jos do Rio Preto - SP Bairro: Pq. IndustrialWc. Social: 02, Salas : 06, M2/ Terreno: 1.113, Documentao: Em Dia., Detalhes: Banheiro Feminino e Masculino, 06 Salas Amplas, Sendo de 420 M a Maior.o Salo Possui Ambientes Para Reunies, Sala de Refeitrio de 93 M e Armrio Embutido, Alarme, Campainha, Interfone, Cerca Eltrica e Estacionamento de 258 M Com 25 Vagas Cobertas.\",\"Tipo: Predio Comercial, Salo Comercial, Salo Industrial Cidade: So Jos do Rio Preto - SP Bairro: Pq. Industrial\",https://thumbs4.properati.com/5/ug_ZifuqqZGKLp9_hQAm2sSvkgE=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1436/Q14vgp5W0wZIU71vOSui.jpg\n8d6a13266a268bd8dd46f19df78ef71e3509ee5a,2014-10-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7700326,-49.3956468\",-20.7700326,-49.3956468,6500,BRL,5139.33,1664.4,,,,,,,,http://www.properati.com.br/ri3n_alugar_comercial_sao-jose-do-rio-preto_avenida-ada-marzochi-polachini,\"Ref.: SA95917Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Mini Dist. Industrial Adail Vetorasso..: Salo Comercial No Mini Destrito Industrial Adail Vetorasso, Salo Comercial, Wcs.: 02, Cozinha: 01, Escritrio, M2/ Terreno: 1.050, M2 rea Construida: 680, Detalhes: Barraco Novo, Com Pilar No Centro;porta Na Frente e Lateral;cobertura Metlica;p Direito 6 Metros.\",Ref.: SA95917 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Mini Dist. Industrial Adail Vetorasso,https://thumbs4.properati.com/2/3gLCbr6emuqSINA3fAYUW1zaIUM=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2283/grmYrDDM8HnJtcT4RzOD.jpg\ndac06563fc851c6596496ceaff68cbf177bb6dde,2014-10-01,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7863702,-49.3690389\",-20.7863702,-49.3690389,6500,BRL,5139.33,1664.4,,,,,,,,http://www.properati.com.br/qz9p_alugar_comercial_sao-jose-do-rio-preto_avenida-solon-da-silva-varginha,\"Ref.: SA95834Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Vila NovaesWc. Social: 03, Mesanino, M2/ Terreno: 530, M2 rea Construida: 480, Detalhes: Salo Comercial Com P Direito de 6m e Mezanino de 55 M.possui Aparelhagem de Ar Condicionado, Som Ambiente, Cmeras de Segurana e Estacionamento Para 18 Carros.local Ideal Para Lojas, Mercados, Igreja, Depsito, Entre Outros.\",Ref.: SA95834 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Vila Novaes,https://thumbs4.properati.com/8/qD9kCsVbiYyB0WGFw5WesI2Ny6s=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2092/d62f4d68-2ea5-4f83-bde9-fdc53bb504f2.jpg\n1ccba6f23edef69215ca9cfe74214a5276597714,2014-10-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,6500,BRL,5139.33,1664.4,,,,,,,,http://www.properati.com.br/ri3q_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95919Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial Centenrio da Emancipao..: Salo Comercial No Distrito Industrial Centenrio da Emancipao., Salo Comercial, Cozinha: 02, Mesanino, Escritrio, M2 rea Construida: 1.015, Detalhes: Escritrio, Cozinha, 02 Wc, Mezanino, 2 Salas, Entrada Lateral.salo 700 Metrosdiviso 315 Metros.\",Ref.: SA95919 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Dist. Industrial Centenrio da Emancipao,https://thumbs4.properati.com/0/FlEAiRaerT0pRZEeW7AhhCuJjJc=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2286/v22UnUeHl8YbhJaP8IaU.jpg\na9d04cd869b56cac7bcb3a54a27147996a973a3d,2015-01-20,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5418835,-46.5583192\",-23.5418835,-46.5583192,1900,BRL,1539.57,498.6,,,,,16,1,,http://www.properati.com.br/w730_alugar_apartamentos_tatuape,\"APTO DE 58M/2, COM 02 DORMITRIOS, SENDO UMA SUTE, SALA AMPLA COM SACADA, COZINHA, WC SOCIAL, REA DE SERVIO, COM ALGUMAS MOBILIAS, TUDO DE PRIMEIRA, 02 VAGAS DE GARAGEM, LAZER COMPLETE, EXCELENTE LOCALIZAO, A UMA QUADRA DO METR.\",MOBILIADO EXCLUSIVE,https://thumbs4.properati.com/2/KuQXATs4BGrSaZvfCzOSMN2pqWs=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/toledo/photos/19/30ad0c15-c9ef-48f7-a06e-2a0201bb7433.jpg\n5de8d0e3c8872b0af03bdc517a4a12021c48da59,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,24700,BRL,23430.5,7588.09,,289,26.25636678200692,85.46712802768167,,,,http://www.properati.com.br/ifvd_alugar_comercial_vila-olimpia,\"EDIFí�CIO COMERCIAL DE ALTí�SSIMO PADRí�O,  LOCALIZADO NO PONTO MAIS NOBRE DA REGIí�O, EM FRENTE AOSHOPPING VILA OLí�MPIA, ENTRE AV. DOS BANDEIRANTES E JUSCELINO KUBITSCHEK. OPRí�DIO CONTA COM 6 ELEVADORES DE ALTA VELOCIDADE, SENDO 1 PANORí�MICO, ARCONDICIONADO CENTRAL, FACHADA EM CORTINA DE VIDRO. EDIFí�CIO INTELIGENTE COMSOFISTICADO SISTEMA DE CONTROLE DE INCí�NDIO, CONSUMO DE ENERGIA. Referíªncia: CO66\",\"Comercio no(a) Vila Olímpia , São Paulo - SP\",\n705fdcf676aca2c9f51fcc0a332fcff39ee1706c,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,19500,BRL,18497.74,5990.59,,260,23.04073076923077,75,,,,http://www.properati.com.br/ifyk_alugar_comercial_vila-olimpia,\"\tConjunto Comercial com 260m² de área útil, 6 vagas, Ar condicionado Central. Referíªncia: CO200\",\"Comercio no(a) Vila Olímpia , São Paulo - SP\",https://thumbs4.properati.com/0/-wzyHwNVYSdVIvzDSj7dz-RtyfY=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/200/f2ef8797-b051-4b62-b4fe-e8fdb7523a26.jpg\n179ce00d44c6fafbac93e139f052ab4ce304100d,2014-08-04,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,4200,BRL,3320.8,1075.46,,,,,,,,http://www.properati.com.br/lxpa_alugar_casas_sao-jose-do-rio-preto,\"Ref.: CA95877 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Village La MontangneDormitrios: 3, Suite: 1, C/ Hidro: 1, Tipo Apto.: 2, C/ A/e: 3, Cozinha Planejada, Escritrio, Lavabo, Salas : 1, Sala P/ Ambientes : 3, Piscina, rea de Servio, M2/ Terreno: 360, M2 rea Construida: 250, Garagem Descoberta: 2, Garagem Coberta: 2\",Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Village La Montangne,https://thumbs4.properati.com/8/XTvYImGZcecKNC8xwzjnrrqWibg=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/804/ce485ed8-97e3-4af6-bc5e-3d347e06b31d.jpg\n868d3ff8ea898557e9819638705d2f823431d000,2015-01-13,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.521407,-46.5769202\",-23.521407,-46.5769202,1500,BRL,1215.45,393.63,,,,,13,1,,http://www.properati.com.br/vysr_alugar_apartamentos_tatuape,\"APTO DE 60M/2, COM 02 DORMITRIO, SALA AMPLA COM SACADA, WC SOCIAL, COZINHA, REA DE SERVIO, 01 VAGA DE GARAGEM, EXCELENTE ESTRUTURA DE LAZER COM PISCINA, PLAYGROUD, SALO DE FESTA, CHURRASQUEIRA, EXCELENTE LOCALIZAO, FCIL ACESSO AO SHOPPING METR/TATUAP.\",131 VITORIA,https://thumbs4.properati.com/0/yyioUci-ExAIuKlfIudXKNKaMAs=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/toledo/photos/16/ff90ae98-dbde-47dc-9f04-32cdb2d2d0d3.jpg\nb9de26b4b13ad0f58469677b72b5171c2b04ae1f,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,13500,BRL,12806.12,4147.33,,183,22.663005464480875,73.77049180327869,,,,http://www.properati.com.br/ifv7_alugar_comercial_vila-olimpia,CONJUNTO COMERCIAL - RECEPCAO - HD - 4 SALAS - 2 WC - COPA  Referíªncia: CO60,\"Comercio no(a) Vila Olímpia , São Paulo - SP\",\n416be9056b69e83819532b65a04c6d1f7135f1ca,2014-12-11,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,2400,BRL,1897.57,614.54,,,,,,,,http://www.properati.com.br/uict_alugar_comercial_jardim-maracana,\"Ref.: SA95945Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Jardim Maracan..: Salo Comercial No Jardim Maracan, Salo Comercial, Wc. Social: 02, Cozinha: 01, Forro de Laje, Piso Salas: Frio, M2/ Terreno: 225, M2 rea Construida: 130\",Ref.: SA95945 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Jardim Maracan,https://thumbs4.properati.com/5/3964ltfTag6YXQlyxEM19akYy1s=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2500/f25a8d90-6759-426c-a4ed-d8aefc6b19a4.jpg\nc1aff8edd38c3254c5775927802a8b8fae45a1d8,2014-08-05,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,2400,BRL,1897.57,614.54,,,,,,1,,http://www.properati.com.br/mel5_alugar_casas_bosque-da-saude,\"Ref.: CA95883Tipo: Casa ResidencialCidade: So Jos do Rio Preto - SPBairro: Bosque Da Saude..: Casa Residencial No Bosque da Saude, Casa Residencial, Dormitrios: 03, Tipo Apto.: 01, C/ A/e: 03, Wc. Social: 01, Copa: 01, Cozinha: 01, Despensa, Salas : 02, Varanda, Garagem Descoberta: 02, Garagem Coberta: 02\",Tipo: Casa Residencial Cidade: So Jos do Rio Preto - SP Bairro: Bosque Da Saude,https://thumbs4.properati.com/0/aAdm951DKitDcqkiJ8p68O6xdNA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/848/083f9a71-b22a-4897-bf14-72fbe585dea5.jpg\ne4d569301fb8a21047d67f193929ecf360e9405e,2015-01-11,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,750,BRL,600.6,194.51,,,,,,1,,http://www.properati.com.br/vxnv_alugar_apartamentos_jardim-satelite,\"Excelente apartamento em frente ao condomnio Quinta das Flores totalmente reformado com 2 dormitrios, sala, cozinha planejada, rea de servio, lavabo com gabinete, wc com box blindex e 1 vaga de garagem.Ideal para casal sem filhos e sem animais\",Excelente apartamento no Jardim Satlite,https://thumbs4.properati.com/1/RhUtbG6YnK8FCa_yOduiEf3yyj0=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/tyrreno/photos/1074/71219c81-463f-4710-a46d-5e7e2fee9362.jpg\n6b59e27767f7570db49b2175077b1c059fa20b88,2014-10-01,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5715766,-46.6697673\",-23.5715766,-46.6697673,3100,BRL,2451.06,793.79,,,,,,,,http://www.properati.com.br/qz9r_alugar_comercial_sao-jose-do-rio-preto_avenida-brasil,\"Ref.: SA95828Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Jardim MugnaniWc. Social: 02, Cozinha, M2/ Terreno: 350, M2 rea Construida: 210, Detalhes: - Salo Novo!\", Ref.: SA95828 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Jardim Mugnani,https://thumbs4.properati.com/5/ShU0-2rvOmZQkWG3Ml57SSATz_s=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2094/VmmbZZ9w1FykW0ldwpGM.jpg\n3931096ab81c0a74496858654375447e859df8f2,2014-12-05,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7910821,-49.3781115\",-20.7910821,-49.3781115,880,BRL,695.77,225.33,,,,,,1,,http://www.properati.com.br/ue04_alugar_apartamentos_sao-jose-do-rio-preto_rua-pedro-mulati,\"Ref.: AP95815Tipo: ApartamentoCidade: So Jos do Rio Preto - SPBairro: Jardim ConceioCobertura, Dormitrios: 02, C/ A/e, Wc. Social, Box Wcs., Sala de Estar, Cozinha, Piscina, rea de Servio, rea de Lazer, Garagem Coberta: 01, rea Util M2: 65, Playground\",Ref.: AP95815 Tipo: Apartamento Cidade: So Jos do Rio Preto - SP Bairro: Jardim Conceio,https://thumbs4.properati.com/3/0zqbkQ73LUnQeQMp0yntvzoZrq8=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2458/Dv0976z6fjHNbJf7oAX0.jpg\nc15e079e557ec6ad9d86baede07324e678686764,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,3800,BRL,3604.66,1167.39,,88,13.265795454545456,43.18181818181818,,,,http://www.properati.com.br/ifzr_alugar_comercial_saude,\"\tConjunto Comercial proximo do Metrô São Judas, com 76m², 2 banheiros, 2 vagas, 1 copa, predio 24 horas. Referíªncia: CO243\",\"Comercio no(a) Saúde , São Paulo - SP\",https://thumbs4.properati.com/4/h5_FesPCfkGnmz5ac8c3DvXy_cE=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/243/68c5314b-39b1-4ebd-af49-694d915540b0.jpg\n62cbd166c6e120af08202b4db367db83240ed7e1,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,8500,BRL,8063.11,2611.28,,300,8.704266666666667,28.333333333333332,,,,http://www.properati.com.br/ifw2_alugar_comercial_vila-olimpia,\"\t\t\tLindo(a) Comercio de 300 metros quadrados no bairro Vila Olímpia na cidade de São Paulo - SP.\t\t\t, 2 banheiro(s), 6 vagas(s) de garagem.\t \t\tCondições de Pagamento: \t \t \t \t\tCaracteristicas: Referíªncia: CO93\",\"Comercio no(a) Vila Olímpia , São Paulo - SP\",https://thumbs4.properati.com/1/gH6VwXVOHsfAy5JV8elaJg59lGA=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/93/4276fcba-994a-4296-9a35-65c580711457.jpg\n5a5bfb194fdbec1a633f5894a66c8bf45c8ae463,2015-01-06,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,1100,BRL,869.7,281.66,,,,,,,,http://www.properati.com.br/vv2s_alugar_apartamentos_sao-jose-do-rio-preto,\"Ref.: AP95935Tipo: ApartamentoCidade: So Jos do Rio Preto - SPBairro: Vila Itlia..: Apartamento Residencial Na Vila Itlia, Apartamento, rea Util M2: 63, Dormitrios: 02, Closet , Tipo Apto.: 01, Wc. Social: 01, Sala de Tv, Cozinha: 01, Cozinha C/ A/e, Garagem Coberta: 01, Detalhes: Prdio Novo.\",Ref.: AP95935 Tipo: Apartamento Cidade: So Jos do Rio Preto - SP Bairro: Vila Itlia,https://thumbs4.properati.com/5/XPnfndqmnEKY04smTHRwdeanGd8=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2535/atPPaTZcVegFbIYjH45d.jpg\n8f0adde258e0b9ad5897ba55288b52005ca749a8,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,3200,BRL,3035.52,983.07,,100,9.8307,32,,,,http://www.properati.com.br/ifxp_alugar_comercial_republica,\"\tLoja/Galpão com mezanino, pé direito duplo, localizada na Av. Amaral Guegel, proximo da Consolação, 2 banheiro e 2 vagas demarcadas. Referíªncia: CO169\",\"Comercio no(a) República , São Paulo - SP\",https://thumbs4.properati.com/6/XJyKobKfgKlfwiO0SB2Tt2i0Y9w=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/169/50b1ac29-6a0b-4488-aaa4-07bc131dd969.jpg\n480229dcb811d916f90a02ffd9c5b62e8a72ccbe,2014-10-15,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8168432,-49.3930989\",-20.8168432,-49.3930989,17000,BRL,13441.34,4353.05,,,,,,,,http://www.properati.com.br/re16_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: CO95289Tipo: Predio ComercialCidade: So Jos do Rio Preto - SPBairro: Vila dos BancariosM2 rea Construida: 800, Garagem Descoberta: 20, Detalhes: Predio Comercial Com 10m Salas de 80 M Cada, Fica Em Um Pavimento Com Elevador, 20 Vagas Para Estacionar .\",Loja,https://thumbs4.properati.com/4/Z-A_sLSPQOFzDbPb8MkkcY9IwMU=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2239/bsfsEYepcv9Cu4EHDvjU.jpg\n\n", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "No pagination, no footer", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "country_name": true, + "created_on": true, + "expenses": true, + "floor": true, + "geonames_id": true, + "id": true, + "image_thumbnail": true, + "lat": true, + "lat_lon": true, + "lon": true, + "operation": true, + "place_with_parent_names": true, + "price_aprox_local_currency": true, + "price_aprox_usd": true, + "state_name": true, + "surface_total_in_m2": true + }, + "indexByName": { + "country_name": 13, + "created_on": 9, + "currency": 19, + "description": 5, + "expenses": 24, + "floor": 7, + "geonames_id": 15, + "id": 0, + "image_thumbnail": 26, + "lat": 17, + "lat_lon": 16, + "lon": 18, + "operation": 8, + "place_name": 4, + "place_with_parent_names": 12, + "price": 11, + "price_aprox_local_currency": 20, + "price_aprox_usd": 21, + "price_per_m2": 23, + "price_usd_per_m2": 22, + "properati_url": 25, + "property_type": 2, + "rooms": 6, + "state_name": 14, + "surface_covered_in_m2": 3, + "surface_total_in_m2": 10, + "title": 1 + }, + "renameByName": { + "description": "Description", + "place_name": "City", + "price": "Price", + "properati_url": "URL", + "property_type": "Type", + "rooms": "Rooms", + "surface_covered_in_m2": "Surface", + "title": "Property name" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "displayMode": "auto", + "inspect": false + }, + "mappings": [ + { + "options": { + "rent": { + "color": "yellow", + "index": 0 + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "property_type" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background" + }, + { + "id": "mappings", + "value": [ + { + "options": { + "store": { + "color": "blue", + "index": 0 + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "title" + }, + "properties": [ + { + "id": "custom.width", + "value": 205 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Number of rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 66 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Description" + }, + "properties": [ + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "URL" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Show details", + "url": "${__value.raw}" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Surface" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background-solid" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "dark-red", + "value": null + }, + { + "color": "orange", + "value": 100 + }, + { + "color": "yellow", + "value": 200 + }, + { + "color": "green", + "value": 300 + }, + { + "color": "#EAB839", + "value": 500 + }, + { + "color": "#6ED0E0", + "value": 1000 + }, + { + "color": "#EF843C", + "value": 2000 + } + ] + } + }, + { + "id": "unit", + "value": "areaM2" + }, + { + "id": "custom.width", + "value": 93 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Type" + }, + "properties": [ + { + "id": "custom.width", + "value": 94 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "City" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 77 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Price" + }, + "properties": [ + { + "id": "unit", + "value": "currencyUSD" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 6, + "options": { + "footer": { + "enablePagination": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": true + }, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Surface" + } + ] + }, + "pluginVersion": "9.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "A" + } + ], + "title": "No pagination, footer", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "country_name": true, + "created_on": true, + "expenses": true, + "floor": true, + "geonames_id": true, + "id": true, + "image_thumbnail": true, + "lat": true, + "lat_lon": true, + "lon": true, + "operation": true, + "place_with_parent_names": true, + "price_aprox_local_currency": true, + "price_aprox_usd": true, + "state_name": true, + "surface_total_in_m2": true + }, + "indexByName": { + "country_name": 13, + "created_on": 9, + "currency": 19, + "description": 5, + "expenses": 24, + "floor": 7, + "geonames_id": 15, + "id": 0, + "image_thumbnail": 26, + "lat": 17, + "lat_lon": 16, + "lon": 18, + "operation": 8, + "place_name": 4, + "place_with_parent_names": 12, + "price": 11, + "price_aprox_local_currency": 20, + "price_aprox_usd": 21, + "price_per_m2": 23, + "price_usd_per_m2": 22, + "properati_url": 25, + "property_type": 2, + "rooms": 6, + "state_name": 14, + "surface_covered_in_m2": 3, + "surface_total_in_m2": 10, + "title": 1 + }, + "renameByName": { + "description": "Description", + "place_name": "City", + "price": "Price", + "properati_url": "URL", + "property_type": "Type", + "rooms": "Rooms", + "surface_covered_in_m2": "Surface", + "title": "Property name" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "displayMode": "auto", + "inspect": false + }, + "mappings": [ + { + "options": { + "rent": { + "color": "yellow", + "index": 0 + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "property_type" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background" + }, + { + "id": "mappings", + "value": [ + { + "options": { + "store": { + "color": "blue", + "index": 0 + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "title" + }, + "properties": [ + { + "id": "custom.width", + "value": 205 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Number of rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 66 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Description" + }, + "properties": [ + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "URL" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Show details", + "url": "${__value.raw}" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Surface" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background-solid" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "dark-red", + "value": null + }, + { + "color": "orange", + "value": 100 + }, + { + "color": "yellow", + "value": 200 + }, + { + "color": "green", + "value": 300 + }, + { + "color": "#EAB839", + "value": 500 + }, + { + "color": "#6ED0E0", + "value": 1000 + }, + { + "color": "#EF843C", + "value": 2000 + } + ] + } + }, + { + "id": "unit", + "value": "areaM2" + }, + { + "id": "custom.width", + "value": 93 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Type" + }, + "properties": [ + { + "id": "custom.width", + "value": 94 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "City" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 77 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Price" + }, + "properties": [ + { + "id": "unit", + "value": "currencyUSD" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 0 + }, + "id": 9, + "options": { + "footer": { + "enablePagination": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": false, + "sortBy": [ + { + "desc": true, + "displayName": "Surface" + } + ] + }, + "pluginVersion": "9.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "country_name": true, + "created_on": true, + "expenses": true, + "floor": true, + "geonames_id": true, + "id": true, + "image_thumbnail": true, + "lat": true, + "lat_lon": true, + "lon": true, + "operation": true, + "place_with_parent_names": true, + "price_aprox_local_currency": true, + "price_aprox_usd": true, + "state_name": true, + "surface_total_in_m2": true + }, + "indexByName": { + "country_name": 13, + "created_on": 9, + "currency": 19, + "description": 5, + "expenses": 24, + "floor": 7, + "geonames_id": 15, + "id": 0, + "image_thumbnail": 26, + "lat": 17, + "lat_lon": 16, + "lon": 18, + "operation": 8, + "place_name": 4, + "place_with_parent_names": 12, + "price": 11, + "price_aprox_local_currency": 20, + "price_aprox_usd": 21, + "price_per_m2": 23, + "price_usd_per_m2": 22, + "properati_url": 25, + "property_type": 2, + "rooms": 6, + "state_name": 14, + "surface_covered_in_m2": 3, + "surface_total_in_m2": 10, + "title": 1 + }, + "renameByName": { + "description": "Description", + "place_name": "City", + "price": "Price", + "properati_url": "URL", + "property_type": "Type", + "rooms": "Rooms", + "surface_covered_in_m2": "Surface", + "title": "Property name" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "displayMode": "auto", + "inspect": false + }, + "mappings": [ + { + "options": { + "rent": { + "color": "yellow", + "index": 0 + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "property_type" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background" + }, + { + "id": "mappings", + "value": [ + { + "options": { + "store": { + "color": "blue", + "index": 0 + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "title" + }, + "properties": [ + { + "id": "custom.width", + "value": 205 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Number of rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 66 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Description" + }, + "properties": [ + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "URL" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Show details", + "url": "${__value.raw}" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Surface" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background-solid" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "dark-red", + "value": null + }, + { + "color": "orange", + "value": 100 + }, + { + "color": "yellow", + "value": 200 + }, + { + "color": "green", + "value": 300 + }, + { + "color": "#EAB839", + "value": 500 + }, + { + "color": "#6ED0E0", + "value": 1000 + }, + { + "color": "#EF843C", + "value": 2000 + } + ] + } + }, + { + "id": "unit", + "value": "areaM2" + }, + { + "id": "custom.width", + "value": 93 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Type" + }, + "properties": [ + { + "id": "custom.width", + "value": 94 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "City" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 77 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Price" + }, + "properties": [ + { + "id": "unit", + "value": "currencyUSD" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 7, + "options": { + "footer": { + "enablePagination": true, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Surface" + } + ] + }, + "pluginVersion": "9.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "A" + } + ], + "title": "Pagination, no footer", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "country_name": true, + "created_on": true, + "expenses": true, + "floor": true, + "geonames_id": true, + "id": true, + "image_thumbnail": true, + "lat": true, + "lat_lon": true, + "lon": true, + "operation": true, + "place_with_parent_names": true, + "price_aprox_local_currency": true, + "price_aprox_usd": true, + "state_name": true, + "surface_total_in_m2": true + }, + "indexByName": { + "country_name": 13, + "created_on": 9, + "currency": 19, + "description": 5, + "expenses": 24, + "floor": 7, + "geonames_id": 15, + "id": 0, + "image_thumbnail": 26, + "lat": 17, + "lat_lon": 16, + "lon": 18, + "operation": 8, + "place_name": 4, + "place_with_parent_names": 12, + "price": 11, + "price_aprox_local_currency": 20, + "price_aprox_usd": 21, + "price_per_m2": 23, + "price_usd_per_m2": 22, + "properati_url": 25, + "property_type": 2, + "rooms": 6, + "state_name": 14, + "surface_covered_in_m2": 3, + "surface_total_in_m2": 10, + "title": 1 + }, + "renameByName": { + "description": "Description", + "place_name": "City", + "price": "Price", + "properati_url": "URL", + "property_type": "Type", + "rooms": "Rooms", + "surface_covered_in_m2": "Surface", + "title": "Property name" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "displayMode": "auto", + "inspect": false + }, + "mappings": [ + { + "options": { + "rent": { + "color": "yellow", + "index": 0 + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "property_type" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background" + }, + { + "id": "mappings", + "value": [ + { + "options": { + "store": { + "color": "blue", + "index": 0 + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "title" + }, + "properties": [ + { + "id": "custom.width", + "value": 205 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Number of rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 66 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Description" + }, + "properties": [ + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "URL" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Show details", + "url": "${__value.raw}" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Surface" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background-solid" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "dark-red", + "value": null + }, + { + "color": "orange", + "value": 100 + }, + { + "color": "yellow", + "value": 200 + }, + { + "color": "green", + "value": 300 + }, + { + "color": "#EAB839", + "value": 500 + }, + { + "color": "#6ED0E0", + "value": 1000 + }, + { + "color": "#EF843C", + "value": 2000 + } + ] + } + }, + { + "id": "unit", + "value": "areaM2" + }, + { + "id": "custom.width", + "value": 93 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Type" + }, + "properties": [ + { + "id": "custom.width", + "value": 94 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "City" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 77 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Price" + }, + "properties": [ + { + "id": "unit", + "value": "currencyUSD" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 8, + "options": { + "footer": { + "enablePagination": true, + "fields": "", + "reducer": [ + "sum" + ], + "show": true + }, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Surface" + } + ] + }, + "pluginVersion": "9.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "A" + } + ], + "title": "Pagination and footer", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "country_name": true, + "created_on": true, + "expenses": true, + "floor": true, + "geonames_id": true, + "id": true, + "image_thumbnail": true, + "lat": true, + "lat_lon": true, + "lon": true, + "operation": true, + "place_with_parent_names": true, + "price_aprox_local_currency": true, + "price_aprox_usd": true, + "state_name": true, + "surface_total_in_m2": true + }, + "indexByName": { + "country_name": 13, + "created_on": 9, + "currency": 19, + "description": 5, + "expenses": 24, + "floor": 7, + "geonames_id": 15, + "id": 0, + "image_thumbnail": 26, + "lat": 17, + "lat_lon": 16, + "lon": 18, + "operation": 8, + "place_name": 4, + "place_with_parent_names": 12, + "price": 11, + "price_aprox_local_currency": 20, + "price_aprox_usd": 21, + "price_per_m2": 23, + "price_usd_per_m2": 22, + "properati_url": 25, + "property_type": 2, + "rooms": 6, + "state_name": 14, + "surface_covered_in_m2": 3, + "surface_total_in_m2": 10, + "title": 1 + }, + "renameByName": { + "description": "Description", + "place_name": "City", + "price": "Price", + "properati_url": "URL", + "property_type": "Type", + "rooms": "Rooms", + "surface_covered_in_m2": "Surface", + "title": "Property name" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "displayMode": "auto", + "inspect": false + }, + "mappings": [ + { + "options": { + "rent": { + "color": "yellow", + "index": 0 + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "property_type" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background" + }, + { + "id": "mappings", + "value": [ + { + "options": { + "store": { + "color": "blue", + "index": 0 + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "title" + }, + "properties": [ + { + "id": "custom.width", + "value": 205 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Number of rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 66 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Description" + }, + "properties": [ + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "URL" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Show details", + "url": "${__value.raw}" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Surface" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background-solid" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "dark-red", + "value": null + }, + { + "color": "orange", + "value": 100 + }, + { + "color": "yellow", + "value": 200 + }, + { + "color": "green", + "value": 300 + }, + { + "color": "#EAB839", + "value": 500 + }, + { + "color": "#6ED0E0", + "value": 1000 + }, + { + "color": "#EF843C", + "value": 2000 + } + ] + } + }, + { + "id": "unit", + "value": "areaM2" + }, + { + "id": "custom.width", + "value": 93 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Type" + }, + "properties": [ + { + "id": "custom.width", + "value": 94 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "City" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 77 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Price" + }, + "properties": [ + { + "id": "unit", + "value": "currencyUSD" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 16 + }, + "id": 10, + "options": { + "footer": { + "enablePagination": true, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": false, + "sortBy": [ + { + "desc": true, + "displayName": "Surface" + } + ] + }, + "pluginVersion": "9.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "A" + } + ], + "title": "Pagination, no footer, no header", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "country_name": true, + "created_on": true, + "expenses": true, + "floor": true, + "geonames_id": true, + "id": true, + "image_thumbnail": true, + "lat": true, + "lat_lon": true, + "lon": true, + "operation": true, + "place_with_parent_names": true, + "price_aprox_local_currency": true, + "price_aprox_usd": true, + "state_name": true, + "surface_total_in_m2": true + }, + "indexByName": { + "country_name": 13, + "created_on": 9, + "currency": 19, + "description": 5, + "expenses": 24, + "floor": 7, + "geonames_id": 15, + "id": 0, + "image_thumbnail": 26, + "lat": 17, + "lat_lon": 16, + "lon": 18, + "operation": 8, + "place_name": 4, + "place_with_parent_names": 12, + "price": 11, + "price_aprox_local_currency": 20, + "price_aprox_usd": 21, + "price_per_m2": 23, + "price_usd_per_m2": 22, + "properati_url": 25, + "property_type": 2, + "rooms": 6, + "state_name": 14, + "surface_covered_in_m2": 3, + "surface_total_in_m2": 10, + "title": 1 + }, + "renameByName": { + "description": "Description", + "place_name": "City", + "price": "Price", + "properati_url": "URL", + "property_type": "Type", + "rooms": "Rooms", + "surface_covered_in_m2": "Surface", + "title": "Property name" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "displayMode": "auto", + "inspect": false + }, + "mappings": [ + { + "options": { + "rent": { + "color": "yellow", + "index": 0 + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "property_type" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background" + }, + { + "id": "mappings", + "value": [ + { + "options": { + "store": { + "color": "blue", + "index": 0 + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "title" + }, + "properties": [ + { + "id": "custom.width", + "value": 205 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Number of rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 66 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Description" + }, + "properties": [ + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "URL" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Show details", + "url": "${__value.raw}" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Surface" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background-solid" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "dark-red", + "value": null + }, + { + "color": "orange", + "value": 100 + }, + { + "color": "yellow", + "value": 200 + }, + { + "color": "green", + "value": 300 + }, + { + "color": "#EAB839", + "value": 500 + }, + { + "color": "#6ED0E0", + "value": 1000 + }, + { + "color": "#EF843C", + "value": 2000 + } + ] + } + }, + { + "id": "unit", + "value": "areaM2" + }, + { + "id": "custom.width", + "value": 93 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Type" + }, + "properties": [ + { + "id": "custom.width", + "value": 94 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "City" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 77 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Price" + }, + "properties": [ + { + "id": "unit", + "value": "currencyUSD" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 16 + }, + "id": 11, + "options": { + "footer": { + "enablePagination": true, + "fields": "", + "reducer": [ + "sum" + ], + "show": true + }, + "showHeader": false, + "sortBy": [ + { + "desc": true, + "displayName": "Surface" + } + ] + }, + "pluginVersion": "9.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "A" + } + ], + "title": "Pagination, footer, no header", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "country_name": true, + "created_on": true, + "expenses": true, + "floor": true, + "geonames_id": true, + "id": true, + "image_thumbnail": true, + "lat": true, + "lat_lon": true, + "lon": true, + "operation": true, + "place_with_parent_names": true, + "price_aprox_local_currency": true, + "price_aprox_usd": true, + "state_name": true, + "surface_total_in_m2": true + }, + "indexByName": { + "country_name": 13, + "created_on": 9, + "currency": 19, + "description": 5, + "expenses": 24, + "floor": 7, + "geonames_id": 15, + "id": 0, + "image_thumbnail": 26, + "lat": 17, + "lat_lon": 16, + "lon": 18, + "operation": 8, + "place_name": 4, + "place_with_parent_names": 12, + "price": 11, + "price_aprox_local_currency": 20, + "price_aprox_usd": 21, + "price_per_m2": 23, + "price_usd_per_m2": 22, + "properati_url": 25, + "property_type": 2, + "rooms": 6, + "state_name": 14, + "surface_covered_in_m2": 3, + "surface_total_in_m2": 10, + "title": 1 + }, + "renameByName": { + "description": "Description", + "place_name": "City", + "price": "Price", + "properati_url": "URL", + "property_type": "Type", + "rooms": "Rooms", + "surface_covered_in_m2": "Surface", + "title": "Property name" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "displayMode": "auto", + "inspect": false + }, + "mappings": [ + { + "options": { + "rent": { + "color": "yellow", + "index": 0 + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "property_type" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background" + }, + { + "id": "mappings", + "value": [ + { + "options": { + "store": { + "color": "blue", + "index": 0 + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "title" + }, + "properties": [ + { + "id": "custom.width", + "value": 205 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Number of rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 66 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Description" + }, + "properties": [ + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "URL" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Show details", + "url": "${__value.raw}" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Surface" + }, + "properties": [ + { + "id": "custom.displayMode", + "value": "color-background-solid" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "dark-red", + "value": null + }, + { + "color": "orange", + "value": 100 + }, + { + "color": "yellow", + "value": 200 + }, + { + "color": "green", + "value": 300 + }, + { + "color": "#EAB839", + "value": 500 + }, + { + "color": "#6ED0E0", + "value": 1000 + }, + { + "color": "#EF843C", + "value": 2000 + } + ] + } + }, + { + "id": "unit", + "value": "areaM2" + }, + { + "id": "custom.width", + "value": 93 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Type" + }, + "properties": [ + { + "id": "custom.width", + "value": 94 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "City" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Rooms" + }, + "properties": [ + { + "id": "custom.width", + "value": 77 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Price" + }, + "properties": [ + { + "id": "unit", + "value": "currencyUSD" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 16 + }, + "id": 12, + "options": { + "footer": { + "enablePagination": true, + "fields": "", + "reducer": [ + "sum" + ], + "show": true + }, + "showHeader": false, + "sortBy": [ + { + "desc": true, + "displayName": "Surface" + } + ] + }, + "pluginVersion": "9.0.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "country_name": true, + "created_on": true, + "expenses": true, + "floor": true, + "geonames_id": true, + "id": true, + "image_thumbnail": true, + "lat": true, + "lat_lon": true, + "lon": true, + "operation": true, + "place_with_parent_names": true, + "price_aprox_local_currency": true, + "price_aprox_usd": true, + "state_name": true, + "surface_total_in_m2": true + }, + "indexByName": { + "country_name": 13, + "created_on": 9, + "currency": 19, + "description": 5, + "expenses": 24, + "floor": 7, + "geonames_id": 15, + "id": 0, + "image_thumbnail": 26, + "lat": 17, + "lat_lon": 16, + "lon": 18, + "operation": 8, + "place_name": 4, + "place_with_parent_names": 12, + "price": 11, + "price_aprox_local_currency": 20, + "price_aprox_usd": 21, + "price_per_m2": 23, + "price_usd_per_m2": 22, + "properati_url": 25, + "property_type": 2, + "rooms": 6, + "state_name": 14, + "surface_covered_in_m2": 3, + "surface_total_in_m2": 10, + "title": 1 + }, + "renameByName": { + "description": "Description", + "place_name": "City", + "price": "Price", + "properati_url": "URL", + "property_type": "Type", + "rooms": "Rooms", + "surface_covered_in_m2": "Surface", + "title": "Property name" + } + } + } + ], + "type": "table" + } + ], + "schemaVersion": 32, + "style": "dark", + "tags": [ + "gdev", + "panel-tests" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Table panel - Pagination", + "uid": "1YGJxPUnz", + "version": 2, + "weekStart": "" +} \ No newline at end of file diff --git a/devenv/docker/blocks/alert_webhook_listener/Dockerfile b/devenv/docker/blocks/alert_webhook_listener/Dockerfile index a6bb87f15f5..84c2b31588e 100644 --- a/devenv/docker/blocks/alert_webhook_listener/Dockerfile +++ b/devenv/docker/blocks/alert_webhook_listener/Dockerfile @@ -2,6 +2,6 @@ FROM golang:latest ADD main.go / WORKDIR / -RUN go build -o main . +RUN go mod init alert_webhook_listener && go build -o main . EXPOSE 3010 ENTRYPOINT ["/main"] diff --git a/devenv/docker/blocks/alert_webhook_listener/docker-compose.yaml b/devenv/docker/blocks/alert_webhook_listener/docker-compose.yaml index 3e6c5389f6c..087ce5bac00 100644 --- a/devenv/docker/blocks/alert_webhook_listener/docker-compose.yaml +++ b/devenv/docker/blocks/alert_webhook_listener/docker-compose.yaml @@ -1,5 +1,4 @@ alert_webhook_listener: build: docker/blocks/alert_webhook_listener - network_mode: host ports: - "3010:3010" diff --git a/docs/sources/administration/_index.md b/docs/sources/administration/_index.md index 5c5b6606b18..deb198775b5 100644 --- a/docs/sources/administration/_index.md +++ b/docs/sources/administration/_index.md @@ -12,3 +12,4 @@ This section includes information for Grafana administrators, team administrator - [Configuration]({{< relref "configuration" >}}) - [Configure Docker image]({{< relref "configure-docker" >}}) - [Security]({{< relref "security" >}}) +- [Service accounts]({{< relref "service-accounts" >}}) diff --git a/docs/sources/administration/api-keys/_index.md b/docs/sources/administration/api-keys/_index.md new file mode 100644 index 00000000000..3ed45a97d02 --- /dev/null +++ b/docs/sources/administration/api-keys/_index.md @@ -0,0 +1,17 @@ +--- +title: 'API keys in Grafana' +menuTitle: 'API keys' +description: 'This section contains information about API keys in Grafana' +weight: 300 +keywords: + - API keys + - Service accounts +--- + +# API keys in Grafana + +API Keys can be used to interact with Grafana HTTP APIs. + +We recommend using service accounts instead of API keys if you are on Grafana 8.5+, for more information refer to [About service accounts]({{< relref "../service-accounts/about-service-accounts.md#">}}). + +{{< section >}} diff --git a/docs/sources/administration/api-keys/about-api-keys.md b/docs/sources/administration/api-keys/about-api-keys.md new file mode 100644 index 00000000000..67ecd4282fd --- /dev/null +++ b/docs/sources/administration/api-keys/about-api-keys.md @@ -0,0 +1,12 @@ +--- +title: About API keys in Grafana +menuTitle: About API keys +description: 'Learn about using API keys in Grafana' +weight: 30 +--- + +# About API keys in Grafana + +An API key is a randomly generated string that external systems use to interact with Grafana HTTP APIs. + +When you create an API key, you specify a **Role** that determines the permissions associated with the API key. Role permissions control that actions the API key can perform on Grafana resources. For more information about creating API keys, refer to [Create an API key]({{< relref "./create-api-key.md#">}}). diff --git a/docs/sources/administration/api-keys/create-api-key.md b/docs/sources/administration/api-keys/create-api-key.md new file mode 100644 index 00000000000..1f18da21e34 --- /dev/null +++ b/docs/sources/administration/api-keys/create-api-key.md @@ -0,0 +1,34 @@ +--- +title: Create an API key in Grafana +menuTitle: Create an API key +description: 'How to create an API key in Grafana' +weight: 50 +keywords: + - API keys + - Service accounts +--- + +# Create an API key in Grafana + +Create an API key when you want to manage your computed workload with a user. + +For more information about API keys, refer to [About API keys in Grafana]({{< relref "./about-api-keys.md">}}). + +This topic shows you how to create an API key using the Grafana UI. You can also create an API key using the Grafana HTTP API. For more information about creating API keys via the API, refer to [Create API key via API]({{< relref "../../http_api/create-api-tokens-for-org.md#how-to-create-a-new-organization-and-an-api-token">}}). + +## Before you begin: + +- Ensure you have permission to create and edit API keys. For more information about permissions, refer to [About users and permissions]({{< relref "../manage-users-and-permissions/about-users-and-permissions.md#">}}). + +**To create an API key:** + +1. Sign in to Grafana, hover your cursor over **Configuration** (the gear icon), and click **API Keys**. +1. Click **New API key**. +1. Enter a unique name for the key. +1. In the **Role** field, select one of the following access levels you want to assign to the key. + - **Admin**: Enables a user to use APIs at the broadest, most powerful administrative level. + - **Editor** or **Viewer** to limit the key's users to those levels of power. +1. In the **Time to live** field, specify how long you want the key to be valid. + - The maximum length of time is 30 days (one month). You enter a number and a letter. Valid letters include `s` for seconds,`m` for minutes, `h` for hours, `d `for days, `w` for weeks, and `M `for month. For example, `12h` is 12 hours and `1M` is 1 month (30 days). + - If you are unsure about how long an API key should be valid, we recommend that you choose a short duration, such as a few hours. This approach limits the risk of having API keys that are valid for a long time. +1. Click **Add**. diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md index 58cdcc98e3b..4a9106482d3 100644 --- a/docs/sources/administration/configuration.md +++ b/docs/sources/administration/configuration.md @@ -509,6 +509,10 @@ If you want to track Grafana usage via Azure Application Insights, then specify
+### enable_feedback_links + +If set to false will remove all feedback links from the UI. Defaults to true. + ## [security] ### disable_initial_admin_creation diff --git a/docs/sources/administration/service-accounts/_index.md b/docs/sources/administration/service-accounts/_index.md new file mode 100644 index 00000000000..9c2ead7f1a4 --- /dev/null +++ b/docs/sources/administration/service-accounts/_index.md @@ -0,0 +1,15 @@ +--- +title: 'Service accounts in Grafana' +menuTitle: 'Service accounts' +description: 'This page contains information about service accounts in Grafana' +weight: 300 +keywords: + - API keys + - Service accounts +--- + +# Service accounts in Grafana + +You can use service accounts to run automated or compute workloads. + +{{< section >}} diff --git a/docs/sources/administration/service-accounts/about-service-accounts.md b/docs/sources/administration/service-accounts/about-service-accounts.md new file mode 100644 index 00000000000..4eaa3f8e717 --- /dev/null +++ b/docs/sources/administration/service-accounts/about-service-accounts.md @@ -0,0 +1,50 @@ +--- +title: About service accounts +menuTitle: About service accounts +description: 'This page contains detailed information about service accounts in Grafana' +weight: 30 +--- + +# About service accounts in Grafana + +A service account can be used to run automated or compute workloads. Applications use service account tokens to authorize themselves as a service account. + +> **Note:** Service accounts are available in Grafana 8.5+ as a beta feature, to enable service accounts refer to [Enable service accounts]({{< relref "./enable-service-accounts.md#">}}) section. + +A common use case for creating a service account is to perform operations on automated or triggered tasks. You can use service accounts to: + +- Schedule reports for specific dashboards to be delivered on a daily/weekly/monthly basis +- Define alerts in your system to be used in Grafana +- Set up an external authentication provider to manage users and permissions across an organization +- Establish machine-to-machine communication +- Interact with Grafana without logging in as a user + +You can also use service accounts in combination with fine-grained access control to grant users specific scopes. + +You can associate a service account with multiple tokens. This is because a service account: + +- can be used by multiple team members and therefore can generate their own token each +- can be used across multiple tenants and each tenant can have its own token + +We recommend the you begin by creating one service account for each use case. + +> **Note:** Service accounts can only act in the organization they are created for. If you have the same task that is needed for multiple organizations, we recommend creating service accounts in each organization. + +--- + +## Service account tokens + +A service account token is a generated random string that are an alternative to using passwords for authentication with Grafana, to interact with the Grafana HTTP APIs. + +When you create a service account, you can associate one or more access tokens with it. You can use service access tokens the same way as API Keys, for example to access Grafana HTTP API programmatically. + +Service account access tokens inherit permissions from service account directly. + +### Service accounts benefits + +The added benefits of service accounts to API keys include: + +- Service accounts resemble Grafana users and can be enabled/disabled, granted specific permissions, and remain active until they are deleted or disabled. API keys are only valid until their expiry date. +- Service accounts can be associated with multiple tokens. +- Unlike API keys, service account tokens are not associated with a specific user, which means that applications can be authenticated even if a Grafana user is deleted. +- You can grant granular permissions to service accounts by leveraging [fine-grained access control]({{< relref "../../enterprise/access-control">}}). For more information about permissions, refer to [About users and permissions]({{< relref "../manage-users-and-permissions/about-users-and-permissions.md#">}}). diff --git a/docs/sources/administration/service-accounts/add-service-account-token.md b/docs/sources/administration/service-accounts/add-service-account-token.md new file mode 100644 index 00000000000..a44a1a8d0db --- /dev/null +++ b/docs/sources/administration/service-accounts/add-service-account-token.md @@ -0,0 +1,31 @@ +--- +title: 'Add a token to a service account in Grafana' +menuTitle: 'Add a token to a service account' +description: 'This topic shows you how to add a token to a service account' +weight: 60 +--- + +# Add a token to a service account in Grafana + +A service account token is a randomly generated string that external system use to authenticate into Grafana, and include specific permissions to interact with the Grafana HTTP APIs. +For more information about service accounts, refer to [About service accounts in Grafana]({{< relref "./about-service-accounts.md">}}). + +You can create a service account token using the Grafana UI or via the API. For more information about creating a service account token via the API, refer to [HTTP API Create service account token]({{< relref "../../http_api/serviceaccount.md#create-service-account-tokens">}}). + +## Before you begin + +- Ensure you have added the `service-accounts` feature toggle to Grafana. For more information about adding the `service-accounts` feature toggle, refer to [Enable service accounts]({{< relref "./enable-service-accounts.md#">}}). +- Ensure you have permission to create and edit service accounts. For more information about user roles, refer to [About users and permissions]({{< relref "../manage-users-and-permissions/about-users-and-permissions.md#">}}). +- [Create a service account in Grafana]({{< relref "./create-service-account.md#">}}). + +**To add a token to a service account:** + +1. Sign in to Grafana and hover your cursor over the organization icon in the sidebar. +1. Click **Service accounts**. +1. Click the service account to which you want to add a token. +1. Click **Add token**. +1. Enter a name for the token. +1. (recommended) Enter an expiry date and expiry date for the token or leave it on no expiry date option. + - The expiry date specifies how long you want the key to be valid. + - If you are unsure of an expiration date, we recommend that you set the token to expire after a short time, such as a few hours or less. This limits the risk associated with a token that is valid for a long time. +1. Click **Generate service account token**. diff --git a/docs/sources/administration/service-accounts/create-service-account.md b/docs/sources/administration/service-accounts/create-service-account.md new file mode 100644 index 00000000000..bc2909967ba --- /dev/null +++ b/docs/sources/administration/service-accounts/create-service-account.md @@ -0,0 +1,30 @@ +--- +title: Create a service account in Grafana +menuTitle: Create a service account +description: 'How to create a service account in Grafana' +weight: 50 +keywords: + - Service accounts +--- + +# Create a service account in Grafana + +A service account is a user account that you can use to run automated or compute workloads. For more information about how you can use service accounts, refer to [About service accounts]({{< relref "../service-accounts/about-service-accounts.md#">}}). + +For more information about creating service accounts via the API, refer to [Create service account via API]({{< relref "../../http_api/serviceaccount.md#create-service-account">}}). + +## Before you begin + +- Ensure you have added the feature toggle for service accounts `service-accounts`. For more information about adding the `service-account` feature toggle, refer to [Enable service accounts]({{< relref "./enable-service-accounts.md#">}}). +- Ensure you have permission to create and edit service accounts. For more information about user permissions, refer to [About users and permissions]({{< relref "../manage-users-and-permissions/about-users-and-permissions.md#">}}). + +**To create a service account:** + +1. Sign in to Grafana and hover your cursor over the organization icon in the sidebar. +1. Click **Service accounts**. +1. Click **New service account**. +1. Enter a **Display name**. +1. The display name must be unique as it determines the ID associated with the service account. + - We recommend that you use a consistent naming convention when you name service accounts. A consistent naming convention can help you scale and maintain service accounts in the future. + - You can change the display name at any time. +1. Click **Create service account**. diff --git a/docs/sources/administration/service-accounts/enable-service-accounts.md b/docs/sources/administration/service-accounts/enable-service-accounts.md new file mode 100644 index 00000000000..63d66210939 --- /dev/null +++ b/docs/sources/administration/service-accounts/enable-service-accounts.md @@ -0,0 +1,43 @@ +--- +title: 'Enable service accounts in Grafana' +menuTitle: 'Enable service accounts' +description: 'This topic shows you how to to enable the service accounts feature in Grafana' +weight: 40 +keywords: + - Feature toggle + - Service accounts +--- + +# Enable service accounts in Grafana + +Service accounts are available behind the `service-accounts` feature toggle available in Grafana 9.0+. + +You can enable service accounts by: + +- modifying the Grafana configuration file, or +- configuring an environment variable + +## Enable service accounts with configuration file + +This topic shows you how to enable service accounts by modifying the Grafana configuration file. + +1. Sign in to the Grafana server and locate the configuration file. For more information about finding the configuration file, refer to LINK. +1. Open the configuration file and locate the [feature toggles] section. In your [config file]({{< relref "../../administration/configuration.md#config-file-locations" >}}), add `service-accounts` as a [feature_toggle]({{< relref "../../administration/configuration.md#feature_toggle" >}}). + +``` +[feature_toggles] +# enable features, separated by spaces +enable = service-accounts +``` + +1. Save your changes, Grafana should recognize your changes; in case of any issues we recommend restarting the Grafana server. + +## Enable service accounts with an environment variable + +This topic shows you how to enable service accounts by setting environment variables before starting Grafana. + +> **Note:** Environment variables override any configuration file settings. + +You can use `GF_FEATURE_TOGGLES_ENABLE = service-accounts` environment variable. + +For more information regarding on how to setup environment variables refer to [Configuring with environment variables]({{< relref "../../administration/configuration.md#override-configuration-with-environment-variables" >}}). diff --git a/docs/sources/alerting/old-alerting/add-notification-template.md b/docs/sources/alerting/old-alerting/add-notification-template.md index e9c276b5833..bafab9a4f9e 100644 --- a/docs/sources/alerting/old-alerting/add-notification-template.md +++ b/docs/sources/alerting/old-alerting/add-notification-template.md @@ -9,7 +9,9 @@ aliases = ["/docs/grafana/latest/alerting/add-notification-template/"] You can provide detailed information to alert notification recipients by injecting alert query data into an alert notification. This topic explains how you can use alert query labels in alert notifications. -Labels that exist from the evaluation of the alert query can be used in the alert rule name and in the alert notification message fields. The alert label data is injected into the notification fields when the alert is in the alerting state. When there are multiple unique values for the same label, the values are comma-separated. +You can use labels generated during an alerting query evaluation to create alert notification messages. For multiple unique values for the same label, the values are comma-separated. + +When an alert fires, the alerting data series indicates the violation. For resolved alerts, all data series are included in the resolved notification. This topic explains how you can use alert query labels in alert notifications. diff --git a/docs/sources/auth/_index.md b/docs/sources/auth/_index.md index 719b5c758aa..16bdaacf067 100644 --- a/docs/sources/auth/_index.md +++ b/docs/sources/auth/_index.md @@ -12,14 +12,14 @@ Here is a table showing all supported authentication providers and the features See also, [Grafana Authentication]({{< relref "grafana.md" >}}). -| Provider | Support | Role mapping | Team sync
_(Enterprise only)_ | Active sync
_(Enterprise only)_ | -| ---------------------------------------------------------------- | :-----: | :----------: | :-------------------------------: | :---------------------------------: | -| [Auth Proxy]({{< relref "auth-proxy.md" >}}) | v2.1+ | - | v6.3+ | - | -| [Azure AD OAuth]({{< relref "azuread.md" >}}) | v6.7+ | v6.7+ | v6.7+ | - | -| [Generic OAuth]({{< relref "generic-oauth.md" >}}) | v4.0+ | v6.5+ | - | - | -| [GitHub OAuth]({{< relref "github.md" >}}) | v2.0+ | - | v6.3+ | - | -| [GitLab OAuth]({{< relref "gitlab.md" >}}) | v5.3+ | - | v6.4+ | - | -| [Google OAuth]({{< relref "google.md" >}}) | v2.0+ | - | - | - | -| [LDAP]({{< relref "ldap.md" >}}) | v2.1+ | v2.1+ | v5.3+ | v6.3+ | -| [Okta OAuth]({{< relref "okta.md" >}}) | v7.0+ | v7.0+ | v7.0+ | - | -| [SAML]({{< relref "../enterprise/saml.md" >}}) (Enterprise only) | v6.3+ | v7.0+ | v7.0+ | - | +| Provider | Support | Role mapping | Team sync
_(Enterprise only)_ | Active sync
_(Enterprise only)_ | +| -------------------------------------------------------------- | :-----: | :----------: | :-------------------------------: | :---------------------------------: | +| [Auth Proxy]({{< relref "auth-proxy.md" >}}) | v2.1+ | - | v6.3+ | - | +| [Azure AD OAuth]({{< relref "azuread.md" >}}) | v6.7+ | v6.7+ | v6.7+ | - | +| [Generic OAuth]({{< relref "generic-oauth.md" >}}) | v4.0+ | v6.5+ | - | - | +| [GitHub OAuth]({{< relref "github.md" >}}) | v2.0+ | - | v6.3+ | - | +| [GitLab OAuth]({{< relref "gitlab.md" >}}) | v5.3+ | - | v6.4+ | - | +| [Google OAuth]({{< relref "google.md" >}}) | v2.0+ | - | - | - | +| [LDAP]({{< relref "ldap.md" >}}) | v2.1+ | v2.1+ | v5.3+ | v6.3+ | +| [Okta OAuth]({{< relref "okta.md" >}}) | v7.0+ | v7.0+ | v7.0+ | - | +| [SAML]({{< relref "../enterprise/saml/" >}}) (Enterprise only) | v6.3+ | v7.0+ | v7.0+ | - | diff --git a/docs/sources/auth/overview.md b/docs/sources/auth/overview.md index 61e0dfe54f2..f9cba2e119b 100644 --- a/docs/sources/auth/overview.md +++ b/docs/sources/auth/overview.md @@ -8,20 +8,20 @@ weight = 1 Grafana provides many ways to authenticate users. Some authentication integrations also enable syncing user permissions and org memberships. -Here is a table showing all supported authentication providers and the features available for them. [Team sync]({{< relref "../enterprise/team-sync.md" >}}) and [active sync]({{< relref "../enterprise/enhanced_ldap.md#active-ldap-synchronization" >}}) are only available in Grafana Enterprise. +The following table shows all supported authentication providers and the features available for them. [Team sync]({{< relref "../enterprise/team-sync.md" >}}) and [active sync]({{< relref "../enterprise/enhanced_ldap.md#active-ldap-synchronization" >}}) are only available in Grafana Enterprise. -| Provider | Support | Role mapping | Team sync
_(Enterprise only)_ | Active sync
_(Enterprise only)_ | -| ---------------------------------------------------------------- | :-----: | :----------: | :-------------------------------: | :---------------------------------: | -| [Auth Proxy]({{< relref "auth-proxy.md" >}}) | v2.1+ | - | v6.3+ | - | -| [Azure AD OAuth]({{< relref "azuread.md" >}}) | v6.7+ | v6.7+ | v6.7+ | - | -| [Generic OAuth]({{< relref "generic-oauth.md" >}}) | v4.0+ | v6.5+ | - | - | -| [GitHub OAuth]({{< relref "github.md" >}}) | v2.0+ | - | v6.3+ | - | -| [GitLab OAuth]({{< relref "gitlab.md" >}}) | v5.3+ | - | v6.4+ | - | -| [Google OAuth]({{< relref "google.md" >}}) | v2.0+ | - | - | - | -| [JWT]({{< relref "jwt.md" >}}) | v8.0+ | - | - | - | -| [LDAP]({{< relref "ldap.md" >}}) | v2.1+ | v2.1+ | v5.3+ | v6.3+ | -| [Okta OAuth]({{< relref "okta.md" >}}) | v7.0+ | v7.0+ | v7.0+ | - | -| [SAML]({{< relref "../enterprise/saml.md" >}}) (Enterprise only) | v6.3+ | v7.0+ | v7.0+ | - | +| Provider | Support | Role mapping | Team sync
_(Enterprise only)_ | Active sync
_(Enterprise only)_ | +| -------------------------------------------------------------- | :-----: | :----------: | :-------------------------------: | :---------------------------------: | +| [Auth Proxy]({{< relref "auth-proxy.md" >}}) | v2.1+ | - | v6.3+ | - | +| [Azure AD OAuth]({{< relref "azuread.md" >}}) | v6.7+ | v6.7+ | v6.7+ | - | +| [Generic OAuth]({{< relref "generic-oauth.md" >}}) | v4.0+ | v6.5+ | - | - | +| [GitHub OAuth]({{< relref "github.md" >}}) | v2.0+ | - | v6.3+ | - | +| [GitLab OAuth]({{< relref "gitlab.md" >}}) | v5.3+ | - | v6.4+ | - | +| [Google OAuth]({{< relref "google.md" >}}) | v2.0+ | - | - | - | +| [JWT]({{< relref "jwt.md" >}}) | v8.0+ | - | - | - | +| [LDAP]({{< relref "ldap.md" >}}) | v2.1+ | v2.1+ | v5.3+ | v6.3+ | +| [Okta OAuth]({{< relref "okta.md" >}}) | v7.0+ | v7.0+ | v7.0+ | - | +| [SAML]({{< relref "../enterprise/saml/" >}}) (Enterprise only) | v6.3+ | v7.0+ | v7.0+ | - | ## Grafana Auth diff --git a/docs/sources/auth/saml.md b/docs/sources/auth/saml.md index 923715756a5..57157ad9ca4 100644 --- a/docs/sources/auth/saml.md +++ b/docs/sources/auth/saml.md @@ -10,4 +10,4 @@ weight = 1100 The SAML authentication integration allows your Grafana users to log in by using an external SAML Identity Provider (IdP). To enable this, Grafana becomes a Service Provider (SP) in the authentication flow, interacting with the IdP to exchange user information. -> SAML authentication integration is available in Grafana Cloud Pro and Advanced and in Grafana Enterprise. For more information, refer to [SAML authentication]({{< relref "../enterprise/saml.md" >}}) in [Grafana Enterprise]({{< relref "../enterprise" >}}). +> SAML authentication integration is available in Grafana Cloud Pro and Advanced and in Grafana Enterprise. For more information, refer to [SAML authentication]({{< relref "../enterprise/saml/" >}}) in [Grafana Enterprise]({{< relref "../enterprise" >}}). diff --git a/docs/sources/dashboards/dashboard-ui/dashboard-row.md b/docs/sources/dashboards/dashboard-ui/dashboard-row.md index b00e1f1c80c..52e637ed689 100644 --- a/docs/sources/dashboards/dashboard-ui/dashboard-row.md +++ b/docs/sources/dashboards/dashboard-ui/dashboard-row.md @@ -4,7 +4,7 @@ aliases = ["/docs/grafana/latest/features/dashboard/dashboards/"] weight = 80 +++ -# Dshboard rows +# Dashboard rows A dashboard row is a logical divider within a dashboard. It is used to group panels together. diff --git a/docs/sources/enterprise/_index.md b/docs/sources/enterprise/_index.md index bc7d1ac99a4..958578d0820 100644 --- a/docs/sources/enterprise/_index.md +++ b/docs/sources/enterprise/_index.md @@ -29,7 +29,7 @@ Supported auth providers: - [GitLab OAuth]({{< relref "../auth/gitlab.md#team-sync-enterprise-only" >}}) - [LDAP]({{< relref "enhanced_ldap.md#ldap-group-synchronization-for-teams" >}}) - [Okta]({{< relref "../auth/okta.md#team-sync-enterprise-only" >}}) -- [SAML]({{< relref "saml.md#configure-team-sync" >}}) +- [SAML]({{< relref "./saml/configure-saml.md#configure-team-sync" >}}) ### Enhanced LDAP integration @@ -37,7 +37,7 @@ With Grafana Enterprise [enhanced LDAP]({{< relref "enhanced_ldap.md" >}}), you ### SAML authentication -[SAML authentication]({{< relref "saml.md" >}}) enables your Grafana Enterprise users to authenticate with SAML. +[SAML authentication]({{< relref "./saml" >}}) enables your Grafana Enterprise users to authenticate with SAML. ## Enterprise features diff --git a/docs/sources/enterprise/access-control/_index.md b/docs/sources/enterprise/access-control/_index.md index 4c98feacf91..f9fa9d56690 100644 --- a/docs/sources/enterprise/access-control/_index.md +++ b/docs/sources/enterprise/access-control/_index.md @@ -36,6 +36,9 @@ Fine-grained access control is available for the following capabilities: - [Provision Grafana]({{< relref "../../administration/provisioning/_index.md" >}}) - [Manage reports]({{< relref "../reporting.md" >}}) - [View server information]({{< relref "../../administration/view-server/_index.md" >}}) +- [Manage teams]({{< relref "../../administration/manage-users-and-permissions/manage-teams/_index.md" >}}) +- [Manage dashboards and folders]({{< relref "../../dashboards/_index.md" >}}) +- [Manage annotations]({{< relref "../../visualizations/annotations.md" >}}) To learn about specific endpoints where you can use fine-grained access control, refer to [Permissions]({{< relref "./permissions.md" >}}) and to the relevant [API]({{< relref "../../http_api/_index.md" >}}) documentation. @@ -58,8 +61,13 @@ enable = accesscontrol You can use `GF_FEATURE_TOGGLES_ENABLE = accesscontrol` environment variable to override the config file configuration and enable fine-grained access control. -Refer to [Configuring with environment variables]({{< relref "../../administration/configuration.md#configure-with-environment-variables" >}}) for more information. +Refer to [Configuring with environment variables]({{< relref "../../administration/configuration.md#/#override-configuration-with-environment-variables" >}}) for more information. ### Verify if enabled You can verify if fine-grained access control is enabled or not by sending an HTTP request to the [Check endpoint]({{< relref "../../http_api/access_control.md#check-if-enabled" >}}). + +## Caveats + +If you have created a folder with unique identifier (uid) set to "general", you will not be able to manage its permissions with fine-grained access control. +Any [folder permissions]({{< relref "../../administration/manage-users-and-permissions/manage-dashboard-permissions/_index.md" >}}) set for this folder will be disregarded when fine-grained access control is enabled. diff --git a/docs/sources/enterprise/auditing.md b/docs/sources/enterprise/auditing.md index 01982586f82..ab8bd49db70 100644 --- a/docs/sources/enterprise/auditing.md +++ b/docs/sources/enterprise/auditing.md @@ -122,7 +122,7 @@ pattern of the `requestUri` field is given. \* Where `AUTH-MODULE` is the name of the authentication module: `grafana`, `saml`, `ldap`, etc. \ -\*\* Includes manual log out, token expired/revoked, and [SAML Single Logout]({{< relref "saml.md#single-logout" >}}). +\*\* Includes manual log out, token expired/revoked, and [SAML Single Logout]({{< relref "./saml/configure-saml.md#single-logout" >}}). #### User management diff --git a/docs/sources/enterprise/license/license-restrictions.md b/docs/sources/enterprise/license/license-restrictions.md index 2e4b068a450..080803aac4c 100644 --- a/docs/sources/enterprise/license/license-restrictions.md +++ b/docs/sources/enterprise/license/license-restrictions.md @@ -29,7 +29,7 @@ Your Grafana license includes a maximum number of _Viewer_ and _Editor/Admin_ ac - An _active user_ is a user who has signed in to Grafana within the last 30 days. This is a rolling window that is updated daily. - When you reach the number of maximum active viewers or editor/admins, only currently active users can sign in; new users and non-active users cannot sign in when you reach the limit. -- Grafana applies sign-in restrictions separately for viewers and editor/admins. If your Grafana license reaches its limit of active viewers but not its limit of active editor/ddmins, new editors and admins can still sign in. +- Grafana applies sign-in restrictions separately for viewers and editor/admins. If your Grafana license reaches its limit of active viewers but not its limit of active editor/admins, new editors and admins can still sign in. - The number of dashboards that a user can view or edit, and the number of organizations that they can access does not affect the active user count. A user with editor permissions for many dashboards across many different organizations counts as one editor. - A license limit banner appears to administrators when Grafana reaches its active user limit; editors and viewers do not see the banner. To change user roles to make better use of your licenses, refer to [Optimize your tiered license](#optimize-your-tiered-license). @@ -123,7 +123,7 @@ After you apply the token, Grafana Enterprise resets your license and updates th > If you are running Grafana Enterprise 8.2 or earlier, the license grants you the total number of licensed users _for each user type_. -For example, if your current license includes 60 viewers and 40 dditor/admins, the new license includes 100 viewers and 100 editor/admins. Grafana Enterprise 8.3 removes the distinction between viewers and editor/admins as shown on the **Utilization** panel. +For example, if your current license includes 60 viewers and 40 editor/admins, the new license includes 100 viewers and 100 editor/admins. Grafana Enterprise 8.3 removes the distinction between viewers and editor/admins as shown on the **Utilization** panel. Before you upgrade to Grafana 8.3, ensure that the total number of active users in Grafana does not exceed the number of users in your combined license. If it does, then new users cannot sign in to Grafana 8.3 until the active user count returns below the licensed limit. diff --git a/docs/sources/enterprise/saml/_index.md b/docs/sources/enterprise/saml/_index.md new file mode 100644 index 00000000000..3350533e54b --- /dev/null +++ b/docs/sources/enterprise/saml/_index.md @@ -0,0 +1,15 @@ +--- +title: 'SAML authentication' +description: 'Grafana SAML authentication' +keywords: ['grafana', 'saml', 'documentation', 'saml-auth', 'enterprise'] +aliases: ['/docs/grafana/latest/auth/saml/'] +weight: 10 +--- + +# SAML authentication + +SAML authentication integration enables your Grafana users to log in by using an external SAML 2.0 Identity Provider (IdP). To enable this, Grafana becomes a Service Provider (SP) in the authentication flow, interacting with the IdP to exchange user information. + +> Only available in Grafana Enterprise v6.3+. If you experience any issues with our implementation, contact our [Technical Support team](https://grafana.com/contact?plcmt=top-nav&cta=contactus) + +{{< section >}} diff --git a/docs/sources/enterprise/saml/about-saml.md b/docs/sources/enterprise/saml/about-saml.md new file mode 100644 index 00000000000..787c0552809 --- /dev/null +++ b/docs/sources/enterprise/saml/about-saml.md @@ -0,0 +1,74 @@ +--- +title: 'About SAML authentication in Grafana' +menuTitle: 'About SAML authentication' +description: 'SAML authentication' +keywords: ['grafana', 'saml', 'documentation', 'saml-auth', 'enterprise'] +aliases: ['/docs/grafana/latest/auth/saml/'] +weight: 20 +--- + +# About SAML authentication + +SAML authentication integration allows your Grafana users to log in by using an external SAML 2.0 Identity Provider (IdP). To enable this, Grafana becomes a Service Provider (SP) in the authentication flow, interacting with the IdP to exchange user information. + +The SAML single sign-on (SSO) standard is varied and flexible. Our implementation contains a subset of features needed to provide a smooth authentication experience into Grafana. + +> Only available in Grafana Enterprise v6.3+. If you encounter any problems with our implementation, please don't hesitate to contact us. + +## Supported SAML + +Grafana supports the following SAML 2.0 bindings: + +- From the Service Provider (SP) to the Identity Provider (IdP): + + - `HTTP-POST` binding + - `HTTP-Redirect` binding + +- From the Identity Provider (IdP) to the Service Provider (SP): + - `HTTP-POST` binding + +In terms of security: + +- Grafana supports signed and encrypted assertions. +- Grafana does not support signed or encrypted requests. + +In terms of initiation, Grafana supports: + +- SP-initiated requests +- IdP-initiated requests + +By default, SP-initiated requests are enabled. For instructions on how to enable IdP-initiated logins, see https://grafana.com/docs/grafana/latest/enterprise/saml/#idp-initiated-single-sign-on-sso. + +### Edit SAML options in the Grafana config file + +Once you have enabled saml, you can configure Grafana to use it for SAML authentication. Refer to [Configuration]({{< relref "../../administration/configuration.md" >}}) to get more information about how to configure Grafana. + +**Edit SAML options in Grafana config file:** + +1. In the `[auth.saml]` section in the Grafana configuration file, set [`enabled`]({{< relref ".././enterprise-configuration.md#enabled" >}}) to `true`. +1. Configure the [certificate and private key]({{< relref "#certificate-and-private-key" >}}). +1. On the Okta application page where you have been redirected after application created, navigate to the **Sign On** tab and find **Identity Provider metadata** link in the **Settings** section. +1. Set the [`idp_metadata_url`]({{< relref ".././enterprise-configuration.md#idp-metadata-url" >}}) to the URL obtained from the previous step. The URL should look like `https://.okta.com/app//sso/saml/metadata`. +1. Set the following options to the attribute names configured at the **step 10** of the SAML integration setup. You can find this attributes on the **General** tab of the application page (**ATTRIBUTE STATEMENTS** and **GROUP ATTRIBUTE STATEMENTS** in the **SAML Settings** section). + - [`assertion_attribute_login`]({{< relref ".././enterprise-configuration.md#assertion-attribute-login" >}}) + - [`assertion_attribute_email`]({{< relref ".././enterprise-configuration.md#assertion-attribute-email" >}}) + - [`assertion_attribute_name`]({{< relref ".././enterprise-configuration.md#assertion-attribute-name" >}}) + - [`assertion_attribute_groups`]({{< relref ".././enterprise-configuration.md#assertion-attribute-groups" >}}) +1. Save the configuration file and and then restart the Grafana server. + +When you are finished, the Grafana configuration might look like this example: + +```bash +[server] +root_url = https://grafana.example.com + +[auth.saml] +enabled = true +private_key_path = "/path/to/private_key.pem" +certificate_path = "/path/to/certificate.cert" +idp_metadata_url = "https://my-org.okta.com/app/my-application/sso/saml/metadata" +assertion_attribute_name = DisplayName +assertion_attribute_login = Login +assertion_attribute_email = Email +assertion_attribute_groups = Group +``` diff --git a/docs/sources/enterprise/saml.md b/docs/sources/enterprise/saml/configure-saml.md similarity index 59% rename from docs/sources/enterprise/saml.md rename to docs/sources/enterprise/saml/configure-saml.md index 903af76fc9c..bf697c51377 100644 --- a/docs/sources/enterprise/saml.md +++ b/docs/sources/enterprise/saml/configure-saml.md @@ -1,51 +1,21 @@ -+++ -title = "SAML Authentication" -description = "Grafana SAML Authentication" -keywords = ["grafana", "saml", "documentation", "saml-auth"] -aliases = ["/docs/grafana/latest/auth/saml/"] -weight = 900 -+++ +--- +title: 'Configure SAML authentication in Grafana' +menuTitle: 'Configure SAML' +description: 'This contains information on how to configure SAML authentication in Grafana' +keywords: ['grafana', 'saml', 'documentation', 'saml-configuration', 'enterprise'] +aliases: ['/docs/grafana/latest/auth/saml/'] +weight: 40 +--- -# SAML authentication +# Configure SAML authentication in Grafana -SAML authentication integration allows your Grafana users to log in by using an external SAML 2.0 Identity Provider (IdP). To enable this, Grafana becomes a Service Provider (SP) in the authentication flow, interacting with the IdP to exchange user information. - -The SAML single sign-on (SSO) standard is varied and flexible. Our implementation contains a subset of features needed to provide a smooth authentication experience into Grafana. - -> Only available in Grafana Enterprise v6.3+. If you encounter any problems with our implementation, please don't hesitate to contact us. - -## Supported SAML - -Grafana supports the following SAML 2.0 bindings: - -- From the Service Provider (SP) to the Identity Provider (IdP): - - - `HTTP-POST` binding - - `HTTP-Redirect` binding - -- From the Identity Provider (IdP) to the Service Provider (SP): - - `HTTP-POST` binding - -In terms of security: - -- Grafana supports signed and encrypted assertions. -- Grafana does not support signed or encrypted requests. - -In terms of initiation, Grafana supports: - -- SP-initiated requests -- IdP-initiated requests - -By default, SP-initiated requests are enabled. For instructions on how to enable IdP-initiated logins, see https://grafana.com/docs/grafana/latest/enterprise/saml/#idp-initiated-single-sign-on-sso. - -## Set up SAML authentication - -The table below describes all SAML configuration options. Continue reading below for details on specific options. Like any other Grafana configuration, you can apply these options as [environment variables]({{< relref "../administration/configuration.md#configure-with-environment-variables" >}}). +The table below describes all SAML configuration options. Continue reading below for details on specific options. Like any other Grafana configuration, you can apply these options as [environment variables]({{< relref "../../administration/configuration.md#configure-with-environment-variables" >}}). | Setting | Required | Description | Default | | ---------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | | `enabled` | No | Whether SAML authentication is allowed | `false` | | `single_logout` | No | Whether SAML Single Logout enabled | `false` | +| `allow_sign_up` | No | Whether to allow new Grafana user creation through SAML login. If set to `false`, then only existing Grafana users can log in with SAML. | `true` | | `allow_idp_initiated` | No | Whether SAML IdP-initiated login is allowed | `false` | | `certificate` or `certificate_path` | Yes | Base64-encoded string or Path for the SP X.509 certificate | | | `private_key` or `private_key_path` | Yes | Base64-encoded string or Path for the SP private key | | @@ -64,24 +34,7 @@ The table below describes all SAML configuration options. Continue reading below | `org_mapping` | No | List of comma- or space-separated Organization:OrgId:Role mappings. Organization can be `*` meaning "All users". Role is optional and can have the following values: `Viewer`, `Editor` or `Admin`. | | | `role_values_editor` | No | List of comma- or space-separated roles which will be mapped into the Editor role | | | `role_values_admin` | No | List of comma- or space-separated roles which will be mapped into the Admin role | | -| `role_values_grafana_admin` | No | List of comma- or space-separated roles which will be mapped into the Grafana Admin (Super Admin) role | | - -### Enable SAML authentication - -To use the SAML integration, in the `auth.saml` section of in the Grafana custom configuration file, set `enabled` to `true`. - -Refer to [Configuration]({{< relref "../administration/configuration.md" >}}) for more information about configuring Grafana. - -### Certificate and private key - -The SAML SSO standard uses asymmetric encryption to exchange information between the SP (Grafana) and the IdP. To perform such encryption, you need a public part and a private part. In this case, the X.509 certificate provides the public part, while the private key provides the private part. - -Grafana supports two ways of specifying both the `certificate` and `private_key`. - -- Without a suffix (`certificate` or `private_key`), the configuration assumes you've supplied the base64-encoded file contents. -- With the `_path` suffix (`certificate_path` or `private_key_path`), then Grafana treats the value entered as a file path and attempts to read the file from the file system. - -You can only use one form of each configuration option. Using multiple forms, such as both `certificate` and `certificate_path`, results in an error. +| `role_values_grafana_admin` | No | List of comma- or space-separated roles which will be mapped into the Grafana Admin (Super Admin) role | ### Signature algorithm @@ -142,28 +95,32 @@ For Grafana to map the user information, it looks at the individual attributes w Grafana provides configuration options that let you modify which keys to look at for these values. The data we need to create the user in Grafana is Name, Login handle, and email. +### Allow new user signups + +By default, new Grafana users using SAML authentication will have an account created for them automatically. To decouple authentication and account creation and ensure only users with existing accounts can log in with SAML, set the `allow_sign_up` option to false. + ### Configure team sync > Team sync support for SAML only available in Grafana v7.0+ -To use SAML Team sync, set [`assertion_attribute_groups`]({{< relref "./enterprise-configuration.md#assertion-attribute-groups" >}}) to the attribute name where you store user groups. Then Grafana will use attribute values extracted from SAML assertion to add user into the groups with the same name configured on the External group sync tab. +To use SAML Team sync, set [`assertion_attribute_groups`]({{< relref ".././enterprise-configuration.md#assertion-attribute-groups" >}}) to the attribute name where you store user groups. Then Grafana will use attribute values extracted from SAML assertion to add user into the groups with the same name configured on the External group sync tab. -[Learn more about Team Sync]({{< relref "../enterprise/team-sync.md" >}}) +[Learn more about Team Sync]({{< relref "../../enterprise/team-sync.md" >}}) ### Configure role sync > Only available in Grafana v7.0+ -Role sync allows you to map user roles from an identity provider to Grafana. To enable role sync, configure role attribute and possible values for the Editor, Admin, and Grafana Admin roles. For more information about user roles, refer to [About users and permissions]({{< relref "../administration/manage-users-and-permissions/about-users-and-permissions.md" >}}). +Role sync allows you to map user roles from an identity provider to Grafana. To enable role sync, configure role attribute and possible values for the Editor, Admin, and Grafana Admin roles. For more information about user roles, refer to [About users and permissions]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions.md" >}}). -1. In the configuration file, set [`assertion_attribute_role`]({{< relref "./enterprise-configuration.md#assertion-attribute-role" >}}) option to the attribute name where the role information will be extracted from. -1. Set the [`role_values_editor`]({{< relref "./enterprise-configuration.md#role-values-editor" >}}) option to the values mapped to the `Editor` role. -1. Set the [`role_values_admin`]({{< relref "./enterprise-configuration.md#role-values-admin" >}}) option to the values mapped to the organization `Admin` role. -1. Set the [`role_values_grafana_admin`]({{< relref "./enterprise-configuration.md#role-values-grafana-admin" >}}) option to the values mapped to the `Grafana Admin` role. +1. In the configuration file, set [`assertion_attribute_role`]({{< relref ".././enterprise-configuration.md#assertion-attribute-role" >}}) option to the attribute name where the role information will be extracted from. +1. Set the [`role_values_editor`]({{< relref ".././enterprise-configuration.md#role-values-editor" >}}) option to the values mapped to the `Editor` role. +1. Set the [`role_values_admin`]({{< relref ".././enterprise-configuration.md#role-values-admin" >}}) option to the values mapped to the organization `Admin` role. +1. Set the [`role_values_grafana_admin`]({{< relref ".././enterprise-configuration.md#role-values-grafana-admin" >}}) option to the values mapped to the `Grafana Admin` role. If a user role doesn't match any of configured values, then the `Viewer` role will be assigned. -Refer to [About users and permissions]({{< relref "../administration/manage-users-and-permissions/about-users-and-permissions.md" >}}) for more information about roles and permissions in Grafana. +Refer to [About users and permissions]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions.md" >}}) for more information about roles and permissions in Grafana. Example configuration: @@ -183,8 +140,8 @@ role_values_grafana_admin = superadmin Organization mapping allows you to assign users to particular organization in Grafana depending on attribute value obtained from identity provider. -1. In configuration file, set [`assertion_attribute_org`]({{< relref "./enterprise-configuration.md#assertion-attribute-org" >}}) to the attribute name you store organization info in. This attribute can be an array if you want a user to be in multiple organizations. -1. Set [`org_mapping`]({{< relref "./enterprise-configuration.md#org-mapping" >}}) option to the comma-separated list of `Organization:OrgId` pairs to map organization from IdP to Grafana organization specified by id. If you want users to have different roles in multiple organizations, you can set this option to a comma-separated list of `Organization:OrgId:Role` mappings. +1. In configuration file, set [`assertion_attribute_org`]({{< relref ".././enterprise-configuration.md#assertion-attribute-org" >}}) to the attribute name you store organization info in. This attribute can be an array if you want a user to be in multiple organizations. +1. Set [`org_mapping`]({{< relref ".././enterprise-configuration.md#org-mapping" >}}) option to the comma-separated list of `Organization:OrgId` pairs to map organization from IdP to Grafana organization specified by id. If you want users to have different roles in multiple organizations, you can set this option to a comma-separated list of `Organization:OrgId:Role` mappings. For example, use following configuration to assign users from `Engineering` organization to the Grafana organization with id `2` as Editor and users from `Sales` - to the org with id `3` as Admin, based on `Org` assertion attribute value: @@ -207,7 +164,7 @@ You can use `*` as an Organization if you want all your users to be in some orga > Only available in Grafana v7.0+ -With the [`allowed_organizations`]({{< relref "./enterprise-configuration.md#allowed-organizations" >}}) option you can specify a list of organizations where the user must be a member of at least one of them to be able to log in to Grafana. +With the [`allowed_organizations`]({{< relref ".././enterprise-configuration.md#allowed-organizations" >}}) option you can specify a list of organizations where the user must be a member of at least one of them to be able to log in to Grafana. ## Example SAML configuration @@ -232,79 +189,3 @@ role_values_grafana_admin = superadmin org_mapping = Engineering:2:Editor, Engineering:3:Viewer, Sales:3:Editor, *:1:Editor allowed_organizations = Engineering, Sales ``` - -## Set up SAML with Okta - -This guide will follow you through the steps of configuring SAML authentication in Grafana with [Okta](https://okta.com/). You need to be an admin in your Okta organization to access Admin Console and create SAML integration. You also need permissions to edit Grafana config file and restart Grafana server. - -### Configure the SAML integration in Okta - -To configure SAML integration with Okta, create integration inside the Okta organization first. - -1. Log in to the [Okta portal](https://login.okta.com/). -1. Go to the Admin Console in your Okta organization by clicking **Admin** in the upper-right corner. If you are in the Developer Console, then click **Developer Console** in the upper-left corner and then click **Classic UI** to switch over to the Admin Console. -1. In the Admin Console, navigate to **Applications** > **Applications**. -1. Click **Add Application**. -1. Click **Create New App** to start the Application Integration Wizard. -1. Choose **Web** as a platform. -1. Select **SAML 2.0** in the Sign on method section. -1. Click **Create**. -1. On the **General Settings** tab, enter a name for your Grafana integration. You can also upload a logo. -1. On the **Configure SAML** tab, enter the SAML information related to your Grafana instance: - - - In the **Single sign on URL** field, use the `/saml/acs` endpoint URL of your Grafana instance, for example, `https://grafana.example.com/saml/acs`. - - In the **Audience URI (SP Entity ID)** field, use the `/saml/metadata` endpoint URL, for example, `https://grafana.example.com/saml/metadata`. - - Leave the default values for **Name ID format** and **Application username**. - - In the **ATTRIBUTE STATEMENTS (OPTIONAL)** section, enter the SAML attributes to be shared with Grafana, for example: - - | Attribute name (in Grafana) | Value (in Okta profile) | - | --------------------------- | -------------------------------------- | - | Login | `user.login` | - | Email | `user.email` | - | DisplayName | `user.firstName + " " + user.lastName` | - - - In the **GROUP ATTRIBUTE STATEMENTS (OPTIONAL)** section, enter a group attribute name (for example, `Group`) and set filter to `Matches regex .*` to return all user groups. - -1. Click **Next**. -1. On the final Feedback tab, fill out the form and then click **Finish**. - -### Edit SAML options in the Grafana config file - -Once the application is created, configure Grafana to use it for SAML authentication. Refer to [Configuration]({{< relref "../administration/configuration.md" >}}) to get more information about how to configure Grafana. - -1. In the `[auth.saml]` section in the Grafana configuration file, set [`enabled`]({{< relref "./enterprise-configuration.md#enabled" >}}) to `true`. -1. Configure the [certificate and private key]({{< relref "#certificate-and-private-key" >}}). -1. On the Okta application page where you have been redirected after application created, navigate to the **Sign On** tab and find **Identity Provider metadata** link in the **Settings** section. -1. Set the [`idp_metadata_url`]({{< relref "./enterprise-configuration.md#idp-metadata-url" >}}) to the URL obtained from the previous step. The URL should look like `https://.okta.com/app//sso/saml/metadata`. -1. Set the following options to the attribute names configured at the **step 10** of the SAML integration setup. You can find this attributes on the **General** tab of the application page (**ATTRIBUTE STATEMENTS** and **GROUP ATTRIBUTE STATEMENTS** in the **SAML Settings** section). - - [`assertion_attribute_login`]({{< relref "./enterprise-configuration.md#assertion-attribute-login" >}}) - - [`assertion_attribute_email`]({{< relref "./enterprise-configuration.md#assertion-attribute-email" >}}) - - [`assertion_attribute_name`]({{< relref "./enterprise-configuration.md#assertion-attribute-name" >}}) - - [`assertion_attribute_groups`]({{< relref "./enterprise-configuration.md#assertion-attribute-groups" >}}) -1. Save the configuration file and and then restart the Grafana server. - -When you are finished, the Grafana configuration might look like this example: - -```bash -[server] -root_url = https://grafana.example.com - -[auth.saml] -enabled = true -private_key_path = "/path/to/private_key.pem" -certificate_path = "/path/to/certificate.cert" -idp_metadata_url = "https://my-org.okta.com/app/my-application/sso/saml/metadata" -assertion_attribute_name = DisplayName -assertion_attribute_login = Login -assertion_attribute_email = Email -assertion_attribute_groups = Group -``` - -## Troubleshoot SAML authentication - -To troubleshoot and get more log information, enable SAML debug logging in the configuration file. Refer to [Configuration]({{< relref "../administration/configuration.md#filters" >}}) for more information. - -```bash -[log] -filters = saml.auth:debug -``` diff --git a/docs/sources/enterprise/saml/enable-saml.md b/docs/sources/enterprise/saml/enable-saml.md new file mode 100644 index 00000000000..22067f4d12d --- /dev/null +++ b/docs/sources/enterprise/saml/enable-saml.md @@ -0,0 +1,65 @@ +--- +title: 'Enable SAML authentication in Grafana' +menuTitle: 'Enable SAML authentication' +description: 'This contains information to enable SAML authentication in Grafana' +keywords: ['grafana', 'saml', 'documentation', 'saml-auth', 'enterprise'] +aliases: ['/docs/grafana/latest/auth/saml/'] +weight: 30 +--- + +# Enable SAML authentication in Grafana + +To use the SAML integration, in the `auth.saml` section of in the Grafana custom configuration file, set `enabled` to `true`. + +Refer to [Configuration]({{< relref "../../administration/configuration.md" >}}) for more information about configuring Grafana. + +## Certificate and private key + +The SAML SSO standard uses asymmetric encryption to exchange information between the SP (Grafana) and the IdP. To perform such encryption, you need a public part and a private part. In this case, the X.509 certificate provides the public part, while the private key provides the private part. The private key needs to be issued in a [PKCS#8](https://en.wikipedia.org/wiki/PKCS_8) format. + +Grafana supports two ways of specifying both the `certificate` and `private_key`. + +- Without a suffix (`certificate` or `private_key`), the configuration assumes you've supplied the base64-encoded file contents. +- With the `_path` suffix (`certificate_path` or `private_key_path`), then Grafana treats the value entered as a file path and attempts to read the file from the file system. + +> **Note:** You can only use one form of each configuration option. Using multiple forms, such as both `certificate` and `certificate_path`, results in an error. + +--- + +### **Example** of how to generate SAML credentials: + +An example of how to generate a self-signed certificate and private key that's valid for one year: + +```sh +$ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes​ +``` + +Base64-encode the cert.pem and key.pem files: +(-w0 switch is not needed on Mac, only for Linux) + +```sh +$ base64 -w0 key.pem > key.pem.base64 +$ base64 -w0 cert.pem > cert.pem.base64 +``` + +The base64-encoded values (`key.pem.base64, cert.pem.base64` files) are then used for certificate and private_key. + +The keys you provide should look like: + +It should look like: + +``` +-----BEGIN PRIVATE KEY----- +... +... +-----END PRIVATE KEY----- +``` + +If you have a key that looks like: + +``` +-----BEGIN CERTIFICATE----- +... +... +-----END CERTIFICATE----- +``` diff --git a/docs/sources/enterprise/saml/setup-saml-with-okta.md b/docs/sources/enterprise/saml/setup-saml-with-okta.md new file mode 100644 index 00000000000..6daa4655f50 --- /dev/null +++ b/docs/sources/enterprise/saml/setup-saml-with-okta.md @@ -0,0 +1,45 @@ +--- +title: 'Setup SAML authentication with Okta in Grafana' +menuTitle: 'SAML authentication with Okta' +description: 'This is a guide to setup SAML authentication with Okta in Grafana' +keywords: ['grafana', 'saml', 'documentation', 'saml-auth', 'enterprise'] +weight: 30 +--- + +# Setup SAML with Okta + +Grafana supports user authentication through Okta, which is useful when you want your users to access Grafana using single sign on. This guide will follow you through the steps of configuring SAML authentication in Grafana with [Okta](https://okta.com/). You need to be an admin in your Okta organization to access Admin Console and create SAML integration. You also need permissions to edit Grafana config file and restart Grafana server. + +## Before you begin + +- To configure SAML integration with Okta, create integration inside the Okta organization first. [Add integration in Okta](https://help.okta.com/en/prod/Content/Topics/Apps/apps-overview-add-apps.htm) +- Ensure you have permission to administer SAML authentication. For more information about permissions, refer to [About users and permissions]({{< relref "../manage-users-and-permissions/about-users-and-permissions.md#">}}). + +**To setup SAML with Okta:** + +1. Log in to the [Okta portal](https://login.okta.com/). +1. Go to the Admin Console in your Okta organization by clicking **Admin** in the upper-right corner. If you are in the Developer Console, then click **Developer Console** in the upper-left corner and then click **Classic UI** to switch over to the Admin Console. +1. In the Admin Console, navigate to **Applications** > **Applications**. +1. Click **Add Application**. +1. Click **Create New App** to start the Application Integration Wizard. +1. Choose **Web** as a platform. +1. Select **SAML 2.0** in the Sign on method section. +1. Click **Create**. +1. On the **General Settings** tab, enter a name for your Grafana integration. You can also upload a logo. +1. On the **Configure SAML** tab, enter the SAML information related to your Grafana instance: + + - In the **Single sign on URL** field, use the `/saml/acs` endpoint URL of your Grafana instance, for example, `https://grafana.example.com/saml/acs`. + - In the **Audience URI (SP Entity ID)** field, use the `/saml/metadata` endpoint URL, for example, `https://grafana.example.com/saml/metadata`. + - Leave the default values for **Name ID format** and **Application username**. + - In the **ATTRIBUTE STATEMENTS (OPTIONAL)** section, enter the SAML attributes to be shared with Grafana, for example: + + | Attribute name (in Grafana) | Value (in Okta profile) | + | --------------------------- | -------------------------------------- | + | Login | `user.login` | + | Email | `user.email` | + | DisplayName | `user.firstName + " " + user.lastName` | + + - In the **GROUP ATTRIBUTE STATEMENTS (OPTIONAL)** section, enter a group attribute name (for example, `Group`) and set filter to `Matches regex .*` to return all user groups. + +1. Click **Next**. +1. On the final Feedback tab, fill out the form and then click **Finish**. diff --git a/docs/sources/enterprise/saml/troubleshoot-saml.md b/docs/sources/enterprise/saml/troubleshoot-saml.md new file mode 100644 index 00000000000..0c59e148046 --- /dev/null +++ b/docs/sources/enterprise/saml/troubleshoot-saml.md @@ -0,0 +1,31 @@ +--- +title: 'Troubleshoot SAML Authentication in Grafana' +menuTitle: 'Troubleshoot SAML Authentication' +description: 'This contains information on how to troubleshoot SAML authentication in Grafana' +keywords: ['grafana', 'saml', 'documentation', 'saml-auth', 'enterprise'] +aliases: ['/docs/grafana/latest/auth/saml/'] +weight: 50 +--- + +# Troubleshoot SAML authentication in Grafana + +To troubleshoot and get more log information, enable SAML debug logging in the configuration file. Refer to [Configuration]({{< relref "../../administration/configuration.md#filters" >}}) for more information. + +```bash +[log] +filters = saml.auth:debug +``` + +## Known issues + +### SAML authentication fails with error: + +- `asn1: structure error: tags don't match` + +We only support one private key format: PKCS#8. + +The keys may be in a different format (PKCS#1 or PKCS#12); in that case, it may be necessary to convert the private key format. + +```bash +$ openssl pkcs8 -topk8 -nocrypt -in -out private.pem +``` diff --git a/docs/sources/enterprise/team-sync.md b/docs/sources/enterprise/team-sync.md index 735cb260c6e..93888a7ed39 100644 --- a/docs/sources/enterprise/team-sync.md +++ b/docs/sources/enterprise/team-sync.md @@ -29,7 +29,7 @@ This mechanism allows Grafana to remove an existing synchronized user from a tea - [GitLab OAuth]({{< relref "../auth/gitlab.md#team-sync-enterprise-only" >}}) - [LDAP]({{< relref "enhanced_ldap.md#ldap-group-synchronization-for-teams" >}}) - [Okta]({{< relref "../auth/okta.md#team-sync-enterprise-only" >}}) -- [SAML]({{< relref "saml.md#configure-team-sync" >}}) +- [SAML]({{< relref "./saml/configure-saml.md#configure-team-sync" >}}) ## Synchronize a Grafana team with an external group diff --git a/docs/sources/explore/_index.md b/docs/sources/explore/_index.md index 610fb46f60d..b9552d5bc3b 100644 --- a/docs/sources/explore/_index.md +++ b/docs/sources/explore/_index.md @@ -55,16 +55,16 @@ In split view, timepickers for both panels can be linked (if you change one, the To close the newly created query, click on the Close Split button. -## Navigate between Explore and a dashboard - -To help accelerate workflows that involve regularly switching from Explore to a dashboard and vice-versa, Grafana provides you with the ability to return to the origin dashboard after navigating to Explore from the panel's dropdown. - -After you've navigated to Explore, you should notice a "Back" button in the Explore toolbar. Simply click it to return to the origin dashboard. To bring changes you make in Explore back to the dashboard, click the arrow next to the button to reveal a "Return to panel with changes" menu item. - -{{< figure src="/static/img/docs/explore/explore_return_dropdown-7-4.png" class="docs-image--no-shadow" max-width= "400px" caption="Screenshot of the expanded explore return dropdown" >}} - ## Share shortened link > **Note:** Available in Grafana 7.3 and later versions. The Share shortened link capability allows you to create smaller and simpler URLs of the format /goto/:uid instead of using longer URLs with query parameters. To create a shortened link to the executed query, click the **Share** option in the Explore toolbar. A shortened link that is never used will automatically get deleted after seven (7) days. + +## Available feature toggles + +### explore2Dashboard + +> **Note:** Available in Grafana 8.5.0 and later versions. + +Enabled by default, allows users to create panels in dashboards from within Explore. diff --git a/docs/sources/explore/logs-integration.md b/docs/sources/explore/logs-integration.md index 3e19c75196f..824faec1c20 100644 --- a/docs/sources/explore/logs-integration.md +++ b/docs/sources/explore/logs-integration.md @@ -97,6 +97,17 @@ You can change the order of received logs from the default descending order (new Each log row has an extendable area with its labels and detected fields, for more robust interaction. For all labels we have added the ability to filter for (positive filter) and filter out (negative filter) selected labels. Each field or label also has a stats icon to display ad-hoc statistics in relation to all displayed logs. +### Escaping newlines + +Explore automatically detects some incorrectly escaped sequences in log lines, such as newlines (`\n`, `\r`) or tabs (`\t`). When it detects such sequences, Explore provides an "Escape newlines" option. + +To automatically fix incorrectly escaped sequences that Explore has detected: + +1. Click "Escape newlines" to replace the sequences. +2. Manually review the replacements to confirm their correctness. + +Explore replaces these sequences. When it does so, the option will change from "Escape newlines" to "Remove escaping". Evaluate the changes as the parsing may not be accurate based on the input received. You can revert the replacements by clicking "Remove escaping". + #### Derived fields links By using Derived fields, you can turn any part of a log message into an internal or external link. The created link is visible as a button next to the Detected field in the Log details view. diff --git a/docs/sources/http_api/annotations.md b/docs/sources/http_api/annotations.md index e784f6b1841..a925a6d6b53 100644 --- a/docs/sources/http_api/annotations.md +++ b/docs/sources/http_api/annotations.md @@ -20,9 +20,9 @@ This is the API documentation for the new Grafana Annotations feature released i See note in the [introduction]({{< ref "#annotations-api" >}}) for an explanation. -| Action | Scope | -| ---------------- | -------------- | -| annotations:read | annotations:\* | +| Action | Scope | +| ---------------- | ----------------------- | +| annotations:read | annotations:type: | **Example Request**: diff --git a/docs/sources/http_api/serviceaccount.md b/docs/sources/http_api/serviceaccount.md new file mode 100644 index 00000000000..d169db92ad9 --- /dev/null +++ b/docs/sources/http_api/serviceaccount.md @@ -0,0 +1,338 @@ ++++ +title = "Service account HTTP API " +description = "Grafana service account HTTP API" +keywords = ["grafana", "http", "documentation", "api", "serviceaccount"] +aliases = ["/docs/grafana/latest/http_api/serviceaccount/"] ++++ + +# Service account API + +> If you are running Grafana Enterprise and have [Fine-grained access control]({{< relref "../enterprise/access-control/_index.md" >}}) enabled, for some endpoints you would need to have relevant permissions. +> Refer to specific resources to understand what permissions are required. + +## Search service accounts with Paging + +`GET /api/serviceaccounts/search?perpage=10&page=1&query=myserviceaccount` + +#### Required permissions + +See note in the [introduction]({{< ref "#user-api" >}}) for an explanation. + +| Action | Scope | +| -------------------- | ------------------------- | +| serviceaccounts:read | global:serviceaccounts:\* | + +**Example Request**: + +```http +GET /api/serviceaccounts/search?perpage=10&page=1&query=mygraf HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` + +Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. The `totalCount` field in the response can be used for pagination of the user list E.g. if `totalCount` is equal to 100 users and the `perpage` parameter is set to 10 then there are 10 pages of users. The `query` parameter is optional and it will return results where the query value is contained in one of the `name`. Query values with spaces need to be URL encoded e.g. `query=Jane%20Doe`. + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json +{ + "totalCount": 2, + "serviceAccounts": [ + { + "id": 1, + "name": "grafana", + "login": "sa-grafana", + "orgId": 1, + "isDisabled": false, + "role": "Viewer", + "tokens": 0, + "avatarUrl": "/avatar/85ec38023d90823d3e5b43ef35646af9", + "accessControl": { + "serviceaccounts:delete": true, + "serviceaccounts:read": true, + "serviceaccounts:write": true + } + }, + { + "id": 2, + "name": "test", + "login": "sa-test", + "orgId": 1, + "isDisabled": false, + "role": "Viewer", + "tokens": 0, + "avatarUrl": "/avatar/8ea890a677d6a223c591a1beea6ea9d2", + "accessControl": { + "serviceaccounts:delete": true, + "serviceaccounts:read": true, + "serviceaccounts:write": true + } + } + ], + "page": 1, + "perPage": 10 +} +``` + +## Create service account + +`POST /api/serviceaccounts` + +#### Required permissions + +See note in the [introduction]({{< ref "#serviceaccount-api" >}}) for an explanation. + +| Action | Scope | +| --------------------- | ------------------ | +| serviceaccounts:write | serviceaccounts:\* | + +**Example Request**: + +```http +POST /api/serviceaccounts HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= + +{ + "name": "grafana", + "role": "Admin", +} +``` + +Requires basic authentication and that the authenticated user is a Grafana Admin. + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "id": 1, + "name": "test", + "login": "sa-test", + "orgId": 1, + "isDisabled": false, + "createdAt": "2022-03-21T14:35:33Z", + "updatedAt": "2022-03-21T14:35:33Z", + "avatarUrl": "/avatar/8ea890a677d6a223c591a1beea6ea9d2", + "role": "Viewer", + "teams": [] +} +``` + +## Get single serviceaccount by Id + +`GET /api/serviceaccounts/:id` + +#### Required permissions + +See note in the [introduction]({{< ref "#serviceaccount-api" >}}) for an explanation. + +| Action | Scope | +| -------------------- | ------------------ | +| serviceaccounts:read | serviceaccounts:\* | + +**Example Request**: + +```http +GET /api/serviceaccounts/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` + +Requires basic authentication and that the authenticated user is a Grafana Admin. + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "id": 1, + "name": "test", + "login": "sa-test", + "orgId": 1, + "isDisabled": false, + "createdAt": "2022-03-21T14:35:33Z", + "updatedAt": "2022-03-21T14:35:33Z", + "avatarUrl": "/avatar/8ea890a677d6a223c591a1beea6ea9d2", + "role": "Viewer", + "teams": [] +} +``` + +## Update service account + +`PATCH /api/serviceaccounts/:id` + +#### Required permissions + +See note in the [introduction]({{< ref "#serviceaccount-api" >}}) for an explanation. + +| Action | Scope | +| --------------------- | ------------------ | +| serviceaccounts:write | serviceaccounts:\* | + +**Example Request**: + +```http +PUT /api/serviceaccounts/2 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= + +{ + "name": "test", + "role": "Editor" +} +``` + +Requires basic authentication and that the authenticated user is a Grafana Admin. + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "id": 2, + "name": "test", + "login": "sa-grafana", + "orgId": 1, + "isDisabled": false, + "createdAt": "2022-03-21T14:35:44Z", + "updatedAt": "2022-03-21T14:35:44Z", + "avatarUrl": "/avatar/8ea890a677d6a223c591a1beea6ea9d2", + "role": "Editor", + "teams": [] +} +``` + +--- + +## Service account tokens + +## Get service account tokens + +`GET /api/serviceaccounts/:id/tokens` + +#### Required permissions + +See note in the [introduction]({{< ref "#serviceaccount-api" >}}) for an explanation. + +| Action | Scope | +| -------------------- | ------------------ | +| serviceaccounts:read | serviceaccounts:\* | + +**Example Request**: + +```http +GET /api/serviceaccounts/2/tokens HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` + +Requires basic authentication and that the authenticated user is a Grafana Admin. + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +[ + { + "id": 1, + "name": "grafana", + "role": "Viewer", + "created": "2022-03-23T10:31:02Z", + "expiration": null, + "secondsUntilExpiration": 0, + "hasExpired": false + } +] +``` + +## Create service account tokens + +`POST /api/serviceaccounts/:id/tokens` + +#### Required permissions + +See note in the [introduction]({{< ref "#serviceaccount-api" >}}) for an explanation. + +| Action | Scope | +| --------------------- | ------------------ | +| serviceaccounts:write | serviceaccounts:\* | + +**Example Request**: + +```http +POST /api/serviceaccounts/2/tokens HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= + +{ + "name": "grafana", + "role": "Viewer" +} +``` + +Requires basic authentication and that the authenticated user is a Grafana Admin. + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "id": 7, + "name": "grafana", + "key": "eyJrIjoiVjFxTHZ6dGdPSjg5Um92MjN1RlhjMkNqYkZUbm9jYkwiLCJuIjoiZ3JhZmFuYSIsImlkIjoxfQ==" +} +``` + +## Delete service account tokens + +`DELETE /api/serviceaccounts/:id/tokens/:tokenId` + +#### Required permissions + +See note in the [introduction]({{< ref "#serviceaccount-api" >}}) for an explanation. + +| Action | Scope | +| --------------------- | ------------------ | +| serviceaccounts:write | serviceaccounts:\* | + +**Example Request**: + +```http +DELETE /api/serviceaccounts/2/tokens/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` + +Requires basic authentication and that the authenticated user is a Grafana Admin. + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "message": "API key deleted" +} +``` diff --git a/docs/sources/installation/docker.md b/docs/sources/installation/docker.md index fbb80fa3eed..c5206ec0ee7 100755 --- a/docs/sources/installation/docker.md +++ b/docs/sources/installation/docker.md @@ -16,6 +16,8 @@ You can install and run Grafana using the official Docker images. Our docker ima Each edition is available in two variants: Alpine and Ubuntu. See below. +For documentation regarding the configuration of a docker image, refer to [configure a Grafana Docker image](https://grafana.com/docs/grafana/latest/administration/configure-docker/). + This topic also contains important information about [migrating from earlier Docker image versions](#migrate-from-previous-docker-containers-versions). > **Note:** You can use [Grafana Cloud](https://grafana.com/products/cloud/features/#cloud-logs) to avoid the overhead of installing, maintaining, and scaling your observability stack. The free forever plan includes Grafana, 10K Prometheus series, 50 GB logs, and more.[Create a free account to get started](https://grafana.com/auth/sign-up/create-user?pg=docs-grafana-install&plcmt=in-text). diff --git a/docs/sources/release-notes/_index.md b/docs/sources/release-notes/_index.md index 0c8f2f85b44..df1de760538 100644 --- a/docs/sources/release-notes/_index.md +++ b/docs/sources/release-notes/_index.md @@ -9,6 +9,7 @@ Here you can find detailed release notes that list everything that is included i about deprecations, breaking changes as well as changes that relate to plugin development. - [Release notes for 8.5.0-beta1]({{< relref "release-notes-8-5-0-beta1" >}}) +- [Release notes for 8.4.6]({{< relref "release-notes-8-4-6" >}}) - [Release notes for 8.4.5]({{< relref "release-notes-8-4-5" >}}) - [Release notes for 8.4.4]({{< relref "release-notes-8-4-4" >}}) - [Release notes for 8.4.3]({{< relref "release-notes-8-4-3" >}}) diff --git a/docs/sources/release-notes/release-notes-8-4-6.md b/docs/sources/release-notes/release-notes-8-4-6.md new file mode 100644 index 00000000000..05a0b6f4bc4 --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-4-6.md @@ -0,0 +1,10 @@ ++++ +title = "Release notes for Grafana 8.4.6" +hide_menu = true ++++ + + + +# Release notes for Grafana 8.4.6 + +- **Security:** Fixes CVE-2022-24812. For more information, see our [blog](https://grafana.com/blog/2022/04/12/grafana-enterprise-8.4.6-released-with-high-severity-security-fix/) diff --git a/docs/sources/visualizations/node-graph.md b/docs/sources/visualizations/node-graph.md index 2545d108325..e0fcc69fed5 100644 --- a/docs/sources/visualizations/node-graph.md +++ b/docs/sources/visualizations/node-graph.md @@ -88,14 +88,14 @@ Required fields: Optional fields: -| Field name | Type | Description | -| ------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| title | string | Name of the node visible in just under the node. | -| subTitle | string | Additional, name, type or other identifier that will be shown right under the title. | -| mainStat | string/number | First stat shown inside the node itself. Can be either string in which case the value will be shown as it is or it can be a number in which case any unit associated with that field will be also shown. | -| secondaryStat | string/number | Same as mainStat but shown right under it inside the node. | -| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. | -| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. | +| Field name | Type | Description | +| ------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| title | string | Name of the node visible in just under the node. | +| subtitle | string | Additional, name, type or other identifier shown under the title. | +| mainstat | string/number | First stat shown inside the node itself. It can either be a string showing the value as is or a number. If it is a number, any unit associated with that field is also shown. | +| secondarystat | string/number | Same as mainStat, but shown under it inside the node. | +| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. | +| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. | ### Edge parameters @@ -109,8 +109,8 @@ Required fields: Optional fields: -| Field name | Type | Description | -| ------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| mainStat | string/number | First stat shown in the overlay when hovering over the edge. Can be either string in which case the value will be shown as it is or it can be a number in which case any unit associated with that field will be also shown | -| secondaryStat | string/number | Same as mainStat but shown right under it. | -| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the edge. Use `config.displayName` for more human readable label. | +| Field name | Type | Description | +| ------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| mainstat | string/number | First stat shown in the overlay when hovering over the edge. It can be a string showing the value as is or it can be a number. If it is a number, any unit associated with that field is also shown | +| secondarystat | string/number | Same as mainStat, but shown right under it. | +| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the edge. Use `config.displayName` for more human readable label. | diff --git a/go.mod b/go.mod index d0646277b24..c5e28247313 100644 --- a/go.mod +++ b/go.mod @@ -14,14 +14,14 @@ replace k8s.io/client-go => k8s.io/client-go v0.22.1 replace github.com/russellhaering/goxmldsig@v1.1.0 => github.com/russellhaering/goxmldsig v1.1.1 require ( - cloud.google.com/go/storage v1.18.2 - cuelang.org/go v0.4.0 + cloud.google.com/go/storage v1.21.0 + cuelang.org/go v0.4.1 github.com/Azure/azure-sdk-for-go v59.3.0+incompatible github.com/Azure/go-autorest/autorest v0.11.22 github.com/BurntSushi/toml v0.3.1 github.com/Masterminds/semver v1.5.0 github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f - github.com/aws/aws-sdk-go v1.42.8 + github.com/aws/aws-sdk-go v1.43.31 github.com/beevik/etree v1.1.0 github.com/benbjohnson/clock v1.1.0 github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b @@ -29,9 +29,9 @@ require ( github.com/cortexproject/cortex v1.10.1-0.20211014125347-85c378182d0d github.com/crewjam/saml v0.4.6-0.20210521115923-29c6295245bd github.com/davecgh/go-spew v1.1.1 - github.com/denisenkom/go-mssqldb v0.11.0 + github.com/denisenkom/go-mssqldb v0.12.0 github.com/dop251/goja v0.0.0-20210804101310-32956a348b49 - github.com/fatih/color v1.10.0 + github.com/fatih/color v1.13.0 github.com/gchaincl/sqlhooks v1.3.0 github.com/getsentry/sentry-go v0.10.0 github.com/go-kit/kit v0.11.0 // indirect @@ -53,10 +53,10 @@ require ( github.com/grafana/cuetsy v0.0.0-20211119211437-8c25464cc9bf github.com/grafana/grafana-aws-sdk v0.10.1 github.com/grafana/grafana-azure-sdk-go v1.1.0 - github.com/grafana/grafana-plugin-sdk-go v0.129.0 + github.com/grafana/grafana-plugin-sdk-go v0.131.0 github.com/grafana/loki v1.6.2-0.20211015002020-7832783b1caa github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 - github.com/hashicorp/go-hclog v0.16.1 + github.com/hashicorp/go-hclog v1.0.0 github.com/hashicorp/go-plugin v1.4.3 github.com/hashicorp/go-version v1.3.0 github.com/influxdata/influxdb-client-go/v2 v2.6.0 @@ -69,7 +69,7 @@ require ( github.com/linkedin/goavro/v2 v2.10.0 github.com/m3db/prometheus_remote_client_golang v0.4.4 github.com/magefile/mage v1.12.1 - github.com/mattn/go-isatty v0.0.12 + github.com/mattn/go-isatty v0.0.14 github.com/mattn/go-sqlite3 v1.14.7 github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f @@ -86,35 +86,35 @@ require ( github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 github.com/robfig/cron/v3 v3.0.1 github.com/russellhaering/goxmldsig v1.1.1 - github.com/stretchr/testify v1.7.0 + github.com/stretchr/testify v1.7.1 github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f github.com/uber/jaeger-client-go v2.29.1+incompatible github.com/unknwon/com v1.0.1 github.com/urfave/cli/v2 v2.3.0 github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae - github.com/weaveworks/common v0.0.0-20210913144402-035033b78a78 + github.com/weaveworks/common v0.0.0-20210913144402-035033b78a78 // indirect github.com/xorcare/pointer v1.1.0 github.com/yudai/gojsondiff v1.0.0 go.opentelemetry.io/collector v0.31.0 go.opentelemetry.io/collector/model v0.31.0 - go.opentelemetry.io/otel v1.2.0 + go.opentelemetry.io/otel v1.6.3 go.opentelemetry.io/otel/exporters/jaeger v1.0.0 go.opentelemetry.io/otel/sdk v1.0.0 - go.opentelemetry.io/otel/trace v1.2.0 - golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 + go.opentelemetry.io/otel/trace v1.6.3 + golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 golang.org/x/exp v0.0.0-20210220032938-85be41e4509f - golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 - golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 + golang.org/x/net v0.0.0-20220401154927-543a649e0bdd // indirect + golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a golang.org/x/sync v0.0.0-20210220032951-036812b2e83c - golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 + golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 golang.org/x/tools v0.1.9 gonum.org/v1/gonum v0.11.0 - google.golang.org/api v0.60.0 - google.golang.org/grpc v1.42.0 - google.golang.org/protobuf v1.27.1 + google.golang.org/api v0.74.0 + google.golang.org/grpc v1.45.0 + google.golang.org/protobuf v1.28.0 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect - gopkg.in/ini.v1 v1.62.0 + gopkg.in/ini.v1 v1.66.2 gopkg.in/ldap.v3 v3.1.0 gopkg.in/mail.v2 v2.3.1 gopkg.in/square/go-jose.v2 v2.5.1 @@ -126,7 +126,6 @@ require ( ) require ( - cloud.google.com/go v0.97.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1 // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect @@ -141,7 +140,6 @@ require ( github.com/alecthomas/units v0.0.0-20210912230133-d1bdfacee922 // indirect github.com/andybalholm/brotli v1.0.3 github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40 // indirect - github.com/armon/go-metrics v0.3.8 // indirect github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect @@ -151,7 +149,6 @@ require ( github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/cheekybits/genny v1.0.0 // indirect github.com/cockroachdb/apd/v2 v2.0.2 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/deepmap/oapi-codegen v1.8.2 // indirect github.com/dennwc/varint v1.0.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -174,7 +171,7 @@ require ( github.com/go-openapi/validate v0.20.2 // indirect github.com/gogo/googleapis v1.4.1 // indirect github.com/gogo/status v1.1.0 // indirect - github.com/golang-jwt/jwt/v4 v4.2.0 // indirect + github.com/golang-jwt/jwt/v4 v4.4.1 // indirect github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect @@ -182,17 +179,15 @@ require ( github.com/gomodule/redigo v2.0.0+incompatible // indirect github.com/google/btree v1.0.1 // indirect github.com/google/flatbuffers v2.0.0+incompatible // indirect - github.com/googleapis/gax-go/v2 v2.1.1 + github.com/googleapis/gax-go/v2 v2.2.0 github.com/gorilla/mux v1.8.0 // indirect github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect - github.com/hashicorp/go-immutable-radix v1.3.0 // indirect github.com/hashicorp/go-msgpack v0.5.5 // indirect github.com/hashicorp/go-multierror v1.1.0 // indirect github.com/hashicorp/go-sockaddr v1.0.2 // indirect github.com/hashicorp/golang-lru v0.5.4 - github.com/hashicorp/memberlist v0.2.4 // indirect github.com/hashicorp/yamux v0.0.0-20210826001029-26ff87cf9493 // indirect github.com/igm/sockjs-go/v3 v3.0.1 // indirect github.com/jessevdk/go-flags v1.5.0 // indirect @@ -202,11 +197,9 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattetti/filebuffer v1.0.1 // indirect - github.com/mattn/go-colorable v0.1.8 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect github.com/miekg/dns v1.1.43 // indirect github.com/mitchellh/go-testing-interface v1.14.0 // indirect - github.com/mitchellh/mapstructure v1.4.2 // indirect github.com/mna/redisc v1.3.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -224,13 +217,11 @@ require ( github.com/protocolbuffers/txtpbfmt v0.0.0-20201118171849-f6a6b3f636fc // indirect github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect github.com/rs/cors v1.8.0 // indirect - github.com/russross/blackfriday/v2 v2.0.1 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/segmentio/encoding v0.3.2 github.com/sercand/kuberesolver v2.4.0+incompatible // indirect github.com/sergi/go-diff v1.0.0 // indirect github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect - github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 // indirect github.com/sirupsen/logrus v1.8.1 // indirect github.com/stretchr/objx v0.2.0 // indirect @@ -241,42 +232,53 @@ require ( go.mongodb.org/mongo-driver v1.7.0 // indirect go.opencensus.io v0.23.0 // indirect go.uber.org/atomic v1.9.0 - go.uber.org/goleak v1.1.11-0.20210813005559-691160354723 // indirect - golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect + go.uber.org/goleak v1.1.11 // indirect + golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 + google.golang.org/genproto v0.0.0-20220401170504-314d38edb7de gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect ) require ( - cloud.google.com/go/kms v1.1.0 + cloud.google.com/go/kms v1.4.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.13.2 github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.4.0 github.com/Azure/go-autorest/autorest/adal v0.9.17 github.com/golang-migrate/migrate/v4 v4.7.0 github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f - gocloud.dev v0.24.0 + github.com/grafana/thema v0.0.0-20220413232647-fc54c169b508 + gocloud.dev v0.25.0 ) require ( + cloud.google.com/go v0.100.2 // indirect + github.com/armon/go-metrics v0.3.10 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/memberlist v0.3.0 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mitchellh/mapstructure v1.4.3 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect +) + +require ( + cloud.google.com/go/compute v1.5.0 // indirect + cloud.google.com/go/iam v0.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v0.22.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.2.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 // indirect - github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2 // indirect - github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect - github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 // indirect github.com/containerd/containerd v1.5.9 // indirect github.com/elazarl/goproxy v0.0.0-20220115173737-adb46da277ac // indirect - github.com/envoyproxy/go-control-plane v0.10.1 // indirect - github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect - github.com/getkin/kin-openapi v0.91.0 // indirect + github.com/getkin/kin-openapi v0.94.0 // indirect github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 // indirect + github.com/go-logr/logr v1.2.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt v3.2.1+incompatible // indirect github.com/imdario/mergo v0.3.12 // indirect - github.com/klauspost/compress v1.13.6 // indirect + github.com/klauspost/compress v1.15.1 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/opencontainers/image-spec v1.0.2 // indirect github.com/pierrec/lz4/v4 v4.1.8 // indirect @@ -290,6 +292,7 @@ replace github.com/apache/thrift => github.com/apache/thrift v0.14.1 replace github.com/hashicorp/consul => github.com/hashicorp/consul v1.10.2 -// TODO: remove once gocloud.dev releases 0.25.x -// `fileblob` implementation has buggy key ordering in 0.24.0 -replace gocloud.dev v0.24.0 => github.com/google/go-cloud v0.24.1-0.20220209172924-99801bbb523a +// Thema's thema CLI requires cobra, which eventually works its way down to go-hclog@v1.0.0. +// Upgrading affects backend plugins: https://github.com/grafana/grafana/pull/47653#discussion_r850508593 +// No harm to Thema because it's only a dependency in its main package. +replace github.com/hashicorp/go-hclog => github.com/hashicorp/go-hclog v0.16.1 diff --git a/go.sum b/go.sum index 707fb9c42b0..291c9de9a53 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,12 @@ cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWc cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= -cloud.google.com/go v0.97.0 h1:3DXvAyifywvq64LfkKaMOmkWPS1CikIQdMe2lY9vxU8= cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= +cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM= +cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= +cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U= +cloud.google.com/go v0.100.2 h1:t9Iw5QH5v4XtlEQaCtUY7x6sCABps8sW0acw7e2WQ6Y= +cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= @@ -41,29 +45,41 @@ cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM7 cloud.google.com/go/bigtable v1.1.0/go.mod h1:B6ByKcIdYmhoyDzmOnQxyOhN6r05qnewYIxxG6L0/b4= cloud.google.com/go/bigtable v1.2.0/go.mod h1:JcVAOl45lrTmQfLj7T6TxyMzIN/3FGGcFm+2xVAli2o= cloud.google.com/go/bigtable v1.3.0/go.mod h1:z5EyKrPE8OQmeg4h5MNdKvuSnI9CCT49Ki3f23aBzio= +cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= +cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw= +cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= +cloud.google.com/go/compute v1.5.0 h1:b1zWmYuuHz7gO9kDcM/EpHGr06UgsYNRpNJzI2kFiLM= +cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= +cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c= +cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw= +cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc= +cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= cloud.google.com/go/kms v1.0.0/go.mod h1:nhUehi+w7zht2XrUfvTRNpxrfayBHqP4lu2NSywui/0= -cloud.google.com/go/kms v1.1.0 h1:1yc4rLqCkVDS9Zvc7m+3mJ47kw0Uo5Q5+sMjcmUVUeM= cloud.google.com/go/kms v1.1.0/go.mod h1:WdbppnCDMDpOvoYBMn1+gNmOeEoZYqAv+HeuKARGCXI= +cloud.google.com/go/kms v1.4.0 h1:iElbfoE61VeLhnZcGOltqL8HIly8Nhbe5t6JlH9GXjo= +cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA= cloud.google.com/go/monitoring v1.1.0/go.mod h1:L81pzz7HKn14QCMaCs6NTQkdBnE87TElyanS95vIcl4= +cloud.google.com/go/monitoring v1.4.0/go.mod h1:y6xnxfwI3hTFWOdkOaD7nfJVlwuC3/mS/5kvtT131p4= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/pubsub v1.17.1/go.mod h1:4qDxMr1WsM9+aQAz36ltDwCIM+R0QdlseyFjBuNvnss= -cloud.google.com/go/secretmanager v1.0.0/go.mod h1:+Qkm5qxIJ5mk74xxIXA+87fseaY1JLYBcFPQoc/GQxg= +cloud.google.com/go/pubsub v1.19.0/go.mod h1:/O9kmSe9bb9KRnIAWkzmqhPjHo6LtzGOBYd/kr06XSs= +cloud.google.com/go/secretmanager v1.3.0/go.mod h1:+oLTkouyiYiabAQNugCeTS3PAArGiMJuBqvJnJsyH+U= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.3.0/go.mod h1:9IAwXhoyBJ7z9LcAwkj0/7NnPzYaPeZxxVp3zm+5IqA= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -cloud.google.com/go/storage v1.18.2 h1:5NQw6tOn3eMm0oE8vTkfjau18kjL79FlMjy/CHTpmoY= -cloud.google.com/go/storage v1.18.2/go.mod h1:AiIj7BWXyhO5gGVmYJ+S8tbkCx3yb0IMjua8Aw4naVM= +cloud.google.com/go/storage v1.21.0 h1:HwnT2u2D309SFDHQII6m18HlrCi3jAXhUMTLOWXYH14= +cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA= cloud.google.com/go/trace v1.0.0/go.mod h1:4iErSByzxkyHWzzlAj63/Gmjz0NH1ASqhJguHpGcr6A= +cloud.google.com/go/trace v1.2.0/go.mod h1:Wc8y/uYyOhPy12KEnXG9XGrvfMz5F5SrYecQlbW1rwM= code.cloudfoundry.org/clock v1.0.0/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= collectd.org v0.3.0/go.mod h1:A/8DzQBkF6abtvrT2j/AU/4tiBgJWYyh0y/oB/4MlWE= contrib.go.opencensus.io/exporter/aws v0.0.0-20200617204711-c478e41e60e9/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA= @@ -71,8 +87,9 @@ contrib.go.opencensus.io/exporter/ocagent v0.6.0/go.mod h1:zmKjrJcdo0aYcVS7bmEeS contrib.go.opencensus.io/exporter/prometheus v0.3.0/go.mod h1:rpCPVQKhiyH8oomWgm34ZmgIdZa8OVYO5WAIygPbBBE= contrib.go.opencensus.io/exporter/stackdriver v0.13.10/go.mod h1:I5htMbyta491eUxufwwZPQdcKvvgzMB4O9ni41YnIM8= contrib.go.opencensus.io/integrations/ocsql v0.1.7/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE= -cuelang.org/go v0.4.0 h1:GLJblw6m2WGGCA3k1v6Wbk9gTOt2qto48ahO2MmSd6I= cuelang.org/go v0.4.0/go.mod h1:tz/edkPi+T37AZcb5GlPY+WJkL6KiDlDVupKwL3vvjs= +cuelang.org/go v0.4.1 h1:rxG2cyZzdAymrNICI0L/wagK2EitoR8Xsiiq2D7qkaQ= +cuelang.org/go v0.4.1/go.mod h1:P09/R4UfAEzLkV9DXxwlxQnIZbkaT4uIhiEgs6Vsz2Q= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= @@ -172,7 +189,6 @@ github.com/Azure/go-autorest/autorest/azure/auth v0.5.8/go.mod h1:kxyKZTSfKh8OVF github.com/Azure/go-autorest/autorest/azure/auth v0.5.9/go.mod h1:hg3/1yw0Bq87O3KvvnJoAh34/0zbP7SFizX/qN5JvjU= github.com/Azure/go-autorest/autorest/azure/cli v0.3.1/go.mod h1:ZG5p860J94/0kI9mNJVoIoLgXcirM2gF5i2kWloofxw= github.com/Azure/go-autorest/autorest/azure/cli v0.4.2/go.mod h1:7qkJkT+j6b+hIpzMOwPChJhTqS8VbsqqgULzMNRugoM= -github.com/Azure/go-autorest/autorest/azure/cli v0.4.4/go.mod h1:yAQ2b6eP/CmLPnmLvxtT1ALIY3OR1oFcCqVBi8vHiTc= github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= github.com/Azure/go-autorest/autorest/date v0.2.0/go.mod h1:vcORJHLJEh643/Ioh9+vPmf1Ij9AEBM5FuBIXLmIy0g= github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw= @@ -215,7 +231,7 @@ github.com/FZambia/eagle v0.0.1 h1:FN1yTkPihMb5nE8SrlRjoCf7T9H9bTKJFQOm6ach2YU= github.com/FZambia/eagle v0.0.1/go.mod h1:xq6u/JeNZ5/8mrAQ76MMhzNTodASh9FavQlCgg4j48w= github.com/FZambia/sentinel v1.1.0 h1:qrCBfxc8SvJihYNjBWgwUI93ZCvFe/PJIPTHKmlp8a8= github.com/FZambia/sentinel v1.1.0/go.mod h1:ytL1Am/RLlAoAXG6Kj5LNuw/TRRQrv2rt2FT26vP5gI= -github.com/GoogleCloudPlatform/cloudsql-proxy v1.27.0/go.mod h1:bn9iHmAjogMoIPkqBGyJ9R1m9cXGCjBE/cuhBs3oEsQ= +github.com/GoogleCloudPlatform/cloudsql-proxy v1.29.0/go.mod h1:spvB9eLJH9dutlbPSRmHvSXXHOwGRyeXh1jVdquA2G8= github.com/HdrHistogram/hdrhistogram-go v0.9.0/go.mod h1:nxrse8/Tzg2tg3DZcZjm6qEclQKK70g0KxO61gFFZD4= github.com/HdrHistogram/hdrhistogram-go v1.0.1/go.mod h1:BWJ+nMSHY3L41Zj7CA3uXnloDp7xxV0YvstAE7nKTaM= github.com/HdrHistogram/hdrhistogram-go v1.1.0 h1:6dpdDPTRoo78HxAJ6T1HfMiKSnqhgRRqzCuPshRkQ7I= @@ -327,8 +343,9 @@ github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQh github.com/armon/go-metrics v0.3.0/go.mod h1:zXjbSimjXTd7vOpY8B0/2LpvNvDoXBuplAD+gJD3GYs= github.com/armon/go-metrics v0.3.3/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-metrics v0.3.6/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= -github.com/armon/go-metrics v0.3.8 h1:oOxq3KPj0WhCuy50EhzwiyMyG2ovRQZpZLXQuOh2a/M= github.com/armon/go-metrics v0.3.8/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= +github.com/armon/go-metrics v0.3.10 h1:FR+drcQStOe+32sYyJYyZ7FIdgoGGBnwLl+flodp8Uo= +github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= @@ -369,49 +386,51 @@ github.com/aws/aws-sdk-go v1.38.60/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2z github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.40.11/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/aws/aws-sdk-go v1.40.37/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= -github.com/aws/aws-sdk-go v1.42.8 h1:Tj2RP4Fas1mYchwbmw0qWLJIEATAseyp5iTa1D+LWYQ= -github.com/aws/aws-sdk-go v1.42.8/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= +github.com/aws/aws-sdk-go v1.43.31 h1:yJZIr8nMV1hXjAvvOLUFqZRJcHV7udPQBfhJqawDzI0= +github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4= -github.com/aws/aws-sdk-go-v2 v1.11.0 h1:HxyD62DyNhCfiFGUHqJ/xITD6rAjJ7Dm/2nLxLmO4Ag= -github.com/aws/aws-sdk-go-v2 v1.11.0/go.mod h1:SQfA+m2ltnu1cA0soUkj4dRSsmITiVQUJvBIZjzfPyQ= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.0.0 h1:yVUAwvJC/0WNPbyl0nA3j1L6CW1CN8wBubCRqtG7JLI= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.0.0/go.mod h1:Xn6sxgRuIDflLRJFj5Ev7UxABIkNbccFPV/p8itDReM= -github.com/aws/aws-sdk-go-v2/config v1.10.1 h1:z/ViqIjW6ZeuLWgTWMTSyZzaVWo/1cWeVf1Uu+RF01E= -github.com/aws/aws-sdk-go-v2/config v1.10.1/go.mod h1:auIv5pIIn3jIBHNRcVQcsczn6Pfa6Dyv80Fai0ueoJU= -github.com/aws/aws-sdk-go-v2/credentials v1.6.1 h1:A39JYth2fFCx+omN/gib/jIppx3rRnt2r7UKPq7Mh5Y= -github.com/aws/aws-sdk-go-v2/credentials v1.6.1/go.mod h1:QyvQk1IYTqBWSi1T6UgT/W8DMxBVa5pVuLFSRLLhGf8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.8.0 h1:OpZjuUy8Jt3CA1WgJgBC5Bz+uOjE5Ppx4NFTRaooUuA= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.8.0/go.mod h1:5E1J3/TTYy6z909QNR0QnXGBpfESYGDqd3O0zqONghU= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.7.1 h1:p9Dys1g2YdaqMalnp6AwCA+tpMMdJNGw5YYKP/u3sUk= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.7.1/go.mod h1:wN/mvkow08GauDwJ70jnzJ1e+hE+Q3Q7TwpYLXOe9oI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.0 h1:zY8cNmbBXt3pzjgWgdIbzpQ6qxoCwt+Nx9JbrAf2mbY= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.0/go.mod h1:NO3Q5ZTTQtO2xIg2+xTXYDiT7knSejfeDm7WGDaOo0U= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.0.0 h1:Z3aR/OXBnkYK9zXkNkfitHX6SmUBzSsx8VMHbH4Lvhw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.0.0/go.mod h1:anlUzBoEWglcUxUQwZA7HQOEVEnQALVZsizAapB2hq8= -github.com/aws/aws-sdk-go-v2/internal/ini v1.3.0 h1:c10Z7fWxtJCoyc8rv06jdh9xrKnu7bAJiRaKWvTb2mU= -github.com/aws/aws-sdk-go-v2/internal/ini v1.3.0/go.mod h1:6oXGy4GLpypD3uCh8wcqztigGgmhLToMfjavgh+VySg= +github.com/aws/aws-sdk-go-v2 v1.16.2 h1:fqlCk6Iy3bnCumtrLz9r3mJ/2gUT0pJ0wLFVIdWh+JA= +github.com/aws/aws-sdk-go-v2 v1.16.2/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.1 h1:SdK4Ppk5IzLs64ZMvr6MrSficMtjY2oS0WOORXTlxwU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.1/go.mod h1:n8Bs1ElDD2wJ9kCRTczA83gYbBmjSwZp3umc6zF4EeM= +github.com/aws/aws-sdk-go-v2/config v1.15.3 h1:5AlQD0jhVXlGzwo+VORKiUuogkG7pQcLJNzIzK7eodw= +github.com/aws/aws-sdk-go-v2/config v1.15.3/go.mod h1:9YL3v07Xc/ohTsxFXzan9ZpFpdTOFl4X65BAKYaz8jg= +github.com/aws/aws-sdk-go-v2/credentials v1.11.2 h1:RQQ5fzclAKJyY5TvF+fkjJEwzK4hnxQCLOu5JXzDmQo= +github.com/aws/aws-sdk-go-v2/credentials v1.11.2/go.mod h1:j8YsY9TXTm31k4eFhspiQicfXPLZ0gYXA50i4gxPE8g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.3 h1:LWPg5zjHV9oz/myQr4wMs0gi4CjnDN/ILmyZUFYXZsU= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.3/go.mod h1:uk1vhHHERfSVCUnqSqz8O48LBYDSC+k6brng09jcMOk= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.3 h1:ir7iEq78s4txFGgwcLqD6q9IIPzTQNRJXulJd9h/zQo= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.3/go.mod h1:0dHuD2HZZSiwfJSy1FO5bX1hQ1TxVV1QXXjpn3XUE44= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9 h1:onz/VaaxZ7Z4V+WIN9Txly9XLTmoOh1oJ8XcAC3pako= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9/go.mod h1:AnVH5pvai0pAF4lXRq0bmhbes1u9R8wTE+g+183bZNM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.3 h1:9stUQR/u2KXU6HkFJYlqnZEjBnbgrVbG6I5HN09xZh0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.3/go.mod h1:ssOhaLpRlh88H3UmEcsBoVKq309quMvm3Ds8e9d4eJM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.10 h1:by9P+oy3P/CwggN4ClnW2D4oL91QV7pBzBICi1chZvQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.10/go.mod h1:8DcYQcz0+ZJaSxANlHIsbbi6S+zMwjwdDqwW3r9AzaE= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.5.0/go.mod h1:acH3+MQoiMzozT/ivU+DbRg7Ooo2298RdRaWcOv+4vM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.5.0 h1:lPLbw4Gn59uoKqvOfSnkJr54XWk5Ak1NK20ZEiSWb3U= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.5.0/go.mod h1:80NaCIH9YU3rzTTs/J/ECATjXuRqzo/wB6ukO6MZ0XY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.5.0 h1:qGZWS/WgiFY+Zgad2u0gwBHpJxz6Ne401JE7iQI1nKs= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.5.0/go.mod h1:Mq6AEc+oEjCUlBuLiK5YwW4shSOAKCQ3tXN0sQeYoBA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.9.0 h1:0BOlTqnNnrEO04oYKzDxMMe68t107pmIotn18HtVonY= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.9.0/go.mod h1:xKCZ4YFSF2s4Hnb/J0TLeOsKuGzICzcElaOKNGrVnx4= -github.com/aws/aws-sdk-go-v2/service/kms v1.10.0/go.mod h1:ZkHWL8m5Nw1g9yMXqpCjnIJtSDToAmNbXXZ9gj0bO7s= -github.com/aws/aws-sdk-go-v2/service/s3 v1.19.0 h1:5mRAms4TjSTOGYsqKYte5kHr1PzpMJSyLThjF3J+hw0= -github.com/aws/aws-sdk-go-v2/service/s3 v1.19.0/go.mod h1:Gwz3aVctJe6mUY9T//bcALArPUaFmNAy2rTB9qN4No8= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.10.0/go.mod h1:qAgsrzF3Z2vvV01j79fs7D75ofCMQe81/OKBJx0rjFY= -github.com/aws/aws-sdk-go-v2/service/sns v1.11.0/go.mod h1:LIPf3BTbSY5UeVli+x/1y2Qw1w8T9DYyp7p18Qt8Zc8= -github.com/aws/aws-sdk-go-v2/service/sqs v1.12.0/go.mod h1:TDqDmQnsbgL2ZMIGUf3z9xTzCMqFX7FP1geAgIlYqvA= -github.com/aws/aws-sdk-go-v2/service/ssm v1.15.0/go.mod h1:kJa2uHklY03rKsNSbEsToeUgWJ1PambXBtRNacorRhg= -github.com/aws/aws-sdk-go-v2/service/sso v1.6.0 h1:JDgKIUZOmLFu/Rv6zXLrVTWCmzA0jcTdvsT8iFIKrAI= -github.com/aws/aws-sdk-go-v2/service/sso v1.6.0/go.mod h1:Q/l0ON1annSU+mc0JybDy1Gy6dnJxIcWjphO6qJPzvM= -github.com/aws/aws-sdk-go-v2/service/sts v1.10.0 h1:1jh8J+JjYRp+QWKOsaZt7rGUgoyrqiiVwIm+w0ymeUw= -github.com/aws/aws-sdk-go-v2/service/sts v1.10.0/go.mod h1:jLKCFqS+1T4i7HDqCP9GM4Uk75YW1cS0o82LdxpMyOE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.1 h1:T4pFel53bkHjL2mMo+4DKE6r6AuoZnM0fg7k1/ratr4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.1/go.mod h1:GeUru+8VzrTXV/83XyMJ80KpH8xO89VPoUileyNQ+tc= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.3 h1:I0dcwWitE752hVSMrsLCxqNQ+UdEp3nACx2bYNMQq+k= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.3/go.mod h1:Seb8KNmD6kVTjwRjVEgOT5hPin6sq+v4C2ycJQDwuH8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.3 h1:Gh1Gpyh01Yvn7ilO/b/hr01WgNpaszfbKMUgqM186xQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.3/go.mod h1:wlY6SVjuwvh3TVRpTqdy4I1JpBFLX4UGeKZdWntaocw= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.3 h1:BKjwCJPnANbkwQ8vzSbaZDKawwagDubrH/z/c0X+kbQ= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.3/go.mod h1:Bm/v2IaN6rZ+Op7zX+bOUMdL4fsrYZiD0dsjLhNKwZc= +github.com/aws/aws-sdk-go-v2/service/kms v1.16.3/go.mod h1:QuiHPBqlOFCi4LqdSskYYAWpQlx3PKmohy+rE2F+o5g= +github.com/aws/aws-sdk-go-v2/service/s3 v1.26.3 h1:rMPtwA7zzkSQZhhz9U3/SoIDz/NZ7Q+iRn4EIO8rSyU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.26.3/go.mod h1:g1qvDuRsJY+XghsV6zg00Z4KJ7DtFFCx8fJD2a491Ak= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.4/go.mod h1:PJc8s+lxyU8rrre0/4a0pn2wgwiDvOEzoOjcJUBr67o= +github.com/aws/aws-sdk-go-v2/service/sns v1.17.4/go.mod h1:kElt+uCcXxcqFyc+bQqZPFD9DME/eC6oHBXvFzQ9Bcw= +github.com/aws/aws-sdk-go-v2/service/sqs v1.18.3/go.mod h1:skmQo0UPvsjsuYYSYMVmrPc1HWCbHUJyrCEp+ZaLzqM= +github.com/aws/aws-sdk-go-v2/service/ssm v1.24.1/go.mod h1:NR/xoKjdbRJ+qx0pMR4mI+N/H1I1ynHwXnO6FowXJc0= +github.com/aws/aws-sdk-go-v2/service/sso v1.11.3 h1:frW4ikGcxfAEDfmQqWgMLp+F1n4nRo9sF39OcIb5BkQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.11.3/go.mod h1:7UQ/e69kU7LDPtY40OyoHYgRmgfGM4mgsLYtcObdveU= +github.com/aws/aws-sdk-go-v2/service/sts v1.16.3 h1:cJGRyzCSVwZC7zZZ1xbx9m32UnrKydRYhOvcD1NYP9Q= +github.com/aws/aws-sdk-go-v2/service/sts v1.16.3/go.mod h1:bfBj0iVmsUyUg4weDB4NxktD9rDGeKSVWnjTnwbx9b8= github.com/aws/smithy-go v1.5.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= -github.com/aws/smithy-go v1.9.0 h1:c7FUdEqrQA1/UVKKCNDFQPNKGp4FQg3YW4Ck5SLTG58= -github.com/aws/smithy-go v1.9.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= +github.com/aws/smithy-go v1.11.2 h1:eG/N+CcUMAvsdffgMvjMKwfyDzIkjM6pfxMJ8Mzc6mE= +github.com/aws/smithy-go v1.11.2/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM= github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc= github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= @@ -472,7 +491,6 @@ github.com/cenkalti/backoff/v4 v4.1.0/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInq github.com/cenkalti/backoff/v4 v4.1.1 h1:G2HAfAmvm/GcKan2oOQpBXOd2tT2G57ZnZGWa1PxPBQ= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/census-instrumentation/opencensus-proto v0.3.0 h1:t/LhUZLVitR1Ow2YOnduCsavhwFUklBMoGVYUCqmCqk= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/centrifugal/centrifuge v0.19.0 h1:YHws0dRpgsBiI73tRl1wwaB13gzuaI1AM4IFcQQQqcw= github.com/centrifugal/centrifuge v0.19.0/go.mod h1:O2elf8q3Qkie3z97wkqVqxB52pnOpPsfFUa7L88Lpy0= @@ -513,14 +531,14 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/cncf/udpa/go v0.0.0-20200313221541-5f7e5dd04533/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 h1:hzAQntlaYRkVSFEfj9OTWlVV1H155FMD8BTKktLv0QI= github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 h1:zH8ljVhhq7yC0MIeUL/IviMtY8hx2mK8cN9wEYb8ggw= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 h1:KwaoQzs/WeUxxJqiJsZ4euOly1Az/IgZXXSxlD/UBNk= +github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/apd/v2 v2.0.1/go.mod h1:DDxRlzC2lo3/vSlmSoS7JkqbbrARPuFOGr0B9pvN3Gw= @@ -638,6 +656,7 @@ github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7 github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= @@ -662,8 +681,9 @@ github.com/couchbase/goutils v0.0.0-20180530154633-e865a1461c8a/go.mod h1:BQwMFl github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= +github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -832,8 +852,8 @@ github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= -github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= 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/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= @@ -868,8 +888,9 @@ github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= github.com/getkin/kin-openapi v0.53.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4= github.com/getkin/kin-openapi v0.61.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4= -github.com/getkin/kin-openapi v0.91.0 h1:mOSAljTAQONM0YVtI3+LvIQaa0zPwa3SH6UuiyEnbYQ= github.com/getkin/kin-openapi v0.91.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= +github.com/getkin/kin-openapi v0.94.0 h1:bAxg2vxgnHHHoeefVdmGbR+oxtJlcv5HsJJa3qmAHuo= +github.com/getkin/kin-openapi v0.94.0/go.mod h1:LWZfzOd7PRy8GJ1dJ6mCU6tNdSfOwRac1BUPam4aw6Q= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/getsentry/sentry-go v0.10.0 h1:6gwY+66NHKqyZrdi6O2jGdo7wGdo9b3B69E01NFgT5g= github.com/getsentry/sentry-go v0.10.0/go.mod h1:kELm/9iCblqUYh+ZRML7PNdCvEuw24wBvJPYyi86cws= @@ -921,8 +942,12 @@ github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNV github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= -github.com/go-logr/logr v0.4.0 h1:K7/B1jt6fIBQVd4Owv2MqGQClcgf0R266+7C/QjRcLc= github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= github.com/go-ole/go-ole v1.2.1/go.mod h1:7FAglXiTm7HKlQRDeOQ6ZNUHidzCWXuZWq/1dTyBNF8= github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= @@ -1152,9 +1177,9 @@ github.com/gogo/status v1.1.0/go.mod h1:BFv9nrluPLmrS0EmGVvLaPNmRosr9KapBYd5/hpY github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang-jwt/jwt/v4 v4.2.0 h1:besgBTC8w8HjP6NzQdxwKH9Z5oQMZ24ThTrHp3cZ8eU= github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ= +github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-migrate/migrate/v4 v4.7.0 h1:gONcHxHApDTKXDyLH/H97gEHmpu1zcnnbAaq2zgrPrs= 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= @@ -1224,8 +1249,6 @@ github.com/google/flatbuffers v1.11.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv github.com/google/flatbuffers v1.12.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v2.0.0+incompatible h1:dicJ2oXwypfwUGnB2/TYWYEKiuk9eYQlQO/AnOHl5mI= github.com/google/flatbuffers v2.0.0+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/go-cloud v0.24.1-0.20220209172924-99801bbb523a h1:Kw18HR30Firrm4cB1BzcVVaFrrdDlAtnrCKgHxemgMI= -github.com/google/go-cloud v0.24.1-0.20220209172924-99801bbb523a/go.mod h1:TqAL9Q5Q8jFUfIDNbDiFbU3w0z3MQ7q4r1wm57tBhmI= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -1246,7 +1269,7 @@ github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135/go.mod h1:od github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6AwLM6miiXUQwP1S+I9icmhk= -github.com/google/go-replayers/httpreplay v1.0.0/go.mod h1:LJhKoTwS5Wy5Ld/peq8dFFG5OfJyHEz7ft+DsTUv25M= +github.com/google/go-replayers/httpreplay v1.1.1/go.mod h1:gN9GeLIs7l6NUoVaSSnv2RiqK1NiwAmD0MrKeC9IIks= github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -1257,8 +1280,9 @@ github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible h1:x github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ= github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= +github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= +github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190723021845-34ac40c74b70/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -1285,6 +1309,7 @@ github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210827144239-02619b876842/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/tcpproxy v0.0.0-20180808230851-dfa16c61dad2/go.mod h1:DavVbd41y+b7ukKDmlnPR4nGYmkWXR6vHUkjQNiHPBs= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -1301,8 +1326,9 @@ github.com/googleapis/gax-go v2.0.2+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= -github.com/googleapis/gax-go/v2 v2.1.1 h1:dp3bWCh+PPO1zjRRiCSczJav13sBvG4UhNyVTa1KqdU= github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= +github.com/googleapis/gax-go/v2 v2.2.0 h1:s7jOdKSaksJVOxE0Y/S32otcfiP+UQ0cL8/GTKaONwE= +github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= github.com/googleapis/gnostic v0.0.0-20170426233943-68f4ded48ba9/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/googleapis/gnostic v0.1.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= @@ -1366,13 +1392,16 @@ github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58/go.m github.com/grafana/grafana-plugin-sdk-go v0.94.0/go.mod h1:3VXz4nCv6wH5SfgB3mlW39s+c+LetqSCjFj7xxPC5+M= github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk= github.com/grafana/grafana-plugin-sdk-go v0.125.0/go.mod h1:9YiJ5GUxIsIEUC0qR9+BJVP5M7mCSP6uc6Ne62YKkgc= -github.com/grafana/grafana-plugin-sdk-go v0.129.0 h1:apmA8x59QvW5Wov+FhAfM0IiNGjMi8V2Ou7xyMk1leE= github.com/grafana/grafana-plugin-sdk-go v0.129.0/go.mod h1:4edtosZepfQF9jkQwRywJsNSJzXTHmzbmcVcAl8MEQc= +github.com/grafana/grafana-plugin-sdk-go v0.131.0 h1:8M+Qfch4WNi3PPpRhWtmcLFTCq8zlIjnxrc8iRigAY0= +github.com/grafana/grafana-plugin-sdk-go v0.131.0/go.mod h1:jmrxelOJKrIK0yrsIzcotS8pbqPZozbmJgGy7k3hK1k= github.com/grafana/loki v1.6.2-0.20211015002020-7832783b1caa h1:+pXjAxavVR2FKKNsuuCXGCWEj8XGc1Af6SPiyBpzU2A= github.com/grafana/loki v1.6.2-0.20211015002020-7832783b1caa/go.mod h1:0O8o/juxNSKN/e+DzWDTRkl7Zm8CkZcz0NDqEdojlrk= github.com/grafana/saml v0.0.0-20211007135653-aed1b2edd86b h1:YiSGp34F4V0G08HHx1cJBf2GVgwYAkXQjzuVs1t8jYk= github.com/grafana/saml v0.0.0-20211007135653-aed1b2edd86b/go.mod h1:q83kyQoMD0vhy+RzFLlbw0UgHJ6TAihQpuXvdFmm4s4= github.com/grafana/sqlds/v2 v2.3.2/go.mod h1:34uyqPBWsEvg4V/xxh6V4uIqwu1qLfOfsmScll/ukrk= +github.com/grafana/thema v0.0.0-20220413232647-fc54c169b508 h1:6k0scTj6kRDjn/qLigsLc9OTiMMaWkRUxjKBFowWEWg= +github.com/grafana/thema v0.0.0-20220413232647-fc54c169b508/go.mod h1:KuqTKX9lfM87uu9vt9DS/q+REqSrAm2xYMnBBvlmevA= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= @@ -1411,8 +1440,9 @@ github.com/hashicorp/consul/api v1.7.0/go.mod h1:1NSuaUUkFaJzMasbfq/11wKYWSR67Xn github.com/hashicorp/consul/api v1.8.1/go.mod h1:sDjTOq0yUyv5G4h+BqSea7Fn6BU+XbolEz1952UB+mk= github.com/hashicorp/consul/api v1.9.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= github.com/hashicorp/consul/api v1.10.0/go.mod h1:sDjTOq0yUyv5G4h+BqSea7Fn6BU+XbolEz1952UB+mk= -github.com/hashicorp/consul/api v1.10.1 h1:MwZJp86nlnL+6+W1Zly4JUuVn9YHhMggBirMpHGD7kw= github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= +github.com/hashicorp/consul/api v1.11.0 h1:Hw/G8TtRvOElqxVIhBzXciiSTbapq8hZ2XKZsXk5ZCE= +github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/consul/sdk v0.4.0/go.mod h1:fY08Y9z5SvJqevyZNy6WWPXiG3KwBPAvlcdx16zZ0fM= @@ -1426,24 +1456,19 @@ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv github.com/hashicorp/go-bexpr v0.1.2/go.mod h1:ANbpTX1oAql27TZkKVeW8p1w8NTdnyzPe/0qqPCKohU= github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-connlimit v0.3.0/go.mod h1:OUj9FGL1tPIhl/2RCfzYHrIiWj+VVPGNyVPnUX8AqS0= github.com/hashicorp/go-discover v0.0.0-20200501174627-ad1e96bde088/go.mod h1:vZu6Opqf49xX5lsFAu7iFNewkcVF1sn/wyapZh5ytlg= -github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= -github.com/hashicorp/go-hclog v0.8.0/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= -github.com/hashicorp/go-hclog v0.9.1/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= -github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= -github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-hclog v0.12.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v0.16.1 h1:IVQwpTGNRRIHafnTs2dQLIk4ENtneRIEEJWOVDqz99o= github.com/hashicorp/go-hclog v0.16.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.1.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.2.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-immutable-radix v1.3.0 h1:8exGP7ego3OmkfksihtSouGMZ+hQrhxx+FVELeXpVPE= github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-kms-wrapping/entropy v0.1.0/go.mod h1:d1g9WGtAunDNpek8jUIEJnBlbgKS1N2Q61QkHiZyR1g= github.com/hashicorp/go-memdb v1.3.1/go.mod h1:Mluclgwib3R93Hk5fxEfiRhB+6Dar64wWh71LpNSe3g= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= @@ -1499,8 +1524,9 @@ github.com/hashicorp/memberlist v0.1.5/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p github.com/hashicorp/memberlist v0.2.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/memberlist v0.2.3/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= -github.com/hashicorp/memberlist v0.2.4 h1:OOhYzSvFnkFQXm1ysE8RjXTHsqSRDyP4emusC9K7DYg= github.com/hashicorp/memberlist v0.2.4/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/memberlist v0.3.0 h1:8+567mCcFDnS5ADl7lrpxPMWiFCElyUEeW0gtj34fMA= +github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/net-rpc-msgpackrpc v0.0.0-20151116020338-a14192a58a69/go.mod h1:/z+jUGRBlwVpUZfjute9jWaF6/HuhjuFQuL1YXzVD1Q= github.com/hashicorp/raft v1.1.1/go.mod h1:vPAJM8Asw6u8LxC3eJCUZmRP/E4QmUGE1R7g7k8sG/8= github.com/hashicorp/raft v1.2.0/go.mod h1:vPAJM8Asw6u8LxC3eJCUZmRP/E4QmUGE1R7g7k8sG/8= @@ -1513,8 +1539,9 @@ github.com/hashicorp/serf v0.8.3/go.mod h1:UpNcs7fFbpKIyZaUuSW6EPiH+eZC7OuyFD+wc github.com/hashicorp/serf v0.8.5/go.mod h1:UpNcs7fFbpKIyZaUuSW6EPiH+eZC7OuyFD+wc1oal+k= github.com/hashicorp/serf v0.9.0/go.mod h1:YL0HO+FifKOW2u1ke99DGVu1zhcpZzNwrLIqBC7vbYU= github.com/hashicorp/serf v0.9.3/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= -github.com/hashicorp/serf v0.9.5 h1:EBWvyu9tcRszt3Bxp3KNssBMP1KuHWyO51lz9+786iM= github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= +github.com/hashicorp/serf v0.9.6 h1:uuEX1kLR6aoda1TBttmJQKDLZE1Ob7KN0NPdE7EtCDc= +github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoIospckxBxk6Q= github.com/hashicorp/vault/api v1.0.5-0.20200717191844-f687267c8086/go.mod h1:R3Umvhlxi2TN7Ex2hzOowyeNb+SfbVWI973N+ctaFMk= github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M= @@ -1599,10 +1626,48 @@ github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7Ua github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g= github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw= github.com/j-keck/arping v0.0.0-20160618110441-2cf9dc699c56/go.mod h1:ymszkNOg6tORTn+6F6j+Jc8TOr5osrynvN6ivFWZ2GA= +github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ= +github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= +github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= +github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= +github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= +github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= +github.com/jackc/pgconn v1.11.0/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= +github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.2.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= +github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= +github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= +github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= +github.com/jackc/pgtype v1.10.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= github.com/jackc/pgx v3.2.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= github.com/jackc/pgx v3.3.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= github.com/jackc/pgx v3.6.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= +github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= +github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= +github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= +github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= +github.com/jackc/pgx/v4 v4.15.0/go.mod h1:D/zyOyXiaM1TmVWnOM18p0xdDtdakRBa0RsVGI3U3bw= +github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jaegertracing/jaeger v1.24.0/go.mod h1:mqdtFDA447va5j0UewDaAWyNlGreGQyhGxXVhbF58gQ= github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da/go.mod h1:ks+b9deReOc7jgqp+e7LuFiCBH6Rm5hL32cLcEAArb4= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= @@ -1693,8 +1758,8 @@ github.com/klauspost/compress v1.11.12/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdY github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.13.1/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= -github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= -github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.15.1 h1:y9FcTHGyrebwfP0ZZqFiaxTaiDnUrGkJkI+f583BL1A= +github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/cpuid v0.0.0-20170728055534-ae7887de9fa5/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= @@ -1743,10 +1808,11 @@ github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgx github.com/leodido/ragel-machinery v0.0.0-20181214104525-299bdde78165/go.mod h1:WZxr2/6a/Ar9bMDc2rN/LJrE/hF6bXE4LPyDSIxwAfg= github.com/leoluk/perflib_exporter v0.1.0/go.mod h1:rpV0lYj7lemdTm31t7zpCqYqPnw7xs86f+BaaNBVYFM= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= @@ -1795,25 +1861,31 @@ github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxq github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM= github.com/mattetti/filebuffer v1.0.1/go.mod h1:YdMURNDOttIiruleeVr6f56OrMc+MydEnTcXwtkxNVs= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-ieproxy v0.0.0-20190610004146-91bb50d98149/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc= github.com/mattn/go-ieproxy v0.0.0-20190702010315-6dee0af9227d/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc= github.com/mattn/go-ieproxy v0.0.0-20191113090002-7c0f6868bffe/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E= github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E= +github.com/mattn/go-ieproxy v0.0.3/go.mod h1:6ZpRmhBaYuBX1U2za+9rC9iCGLsSp2tftelZne7CPko= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= @@ -1890,8 +1962,8 @@ github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/mitchellh/mapstructure v1.4.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.1-0.20210112042008-8ebf2d61a8b4/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.4.2 h1:6h7AQ0yhTcIsmFmnAwQls75jp2Gzs4iB8W7pjMO+rqo= -github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= +github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= github.com/mitchellh/pointerstructure v1.0.0/go.mod h1:k4XwG94++jLVsSiTxo7qdIfXA9pj9EAeo0QsNNJOLZ8= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= @@ -2069,6 +2141,7 @@ github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUr github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/peterh/liner v1.0.1-0.20180619022028-8c1271fcf47f/go.mod h1:xIteQHvHuaLYG9IFj6mSxM0fCKrs34IrEQUhOYuGPHc= @@ -2266,13 +2339,16 @@ github.com/rs/cors v1.8.0 h1:P2KMzcFwrPoSjkF1WLRPsp3UMLyql8L4v9hQpVeK5so= github.com/rs/cors v1.8.0/go.mod h1:EBwu+T5AvHOcXwvZIkQFjUN6s8Czyqw12GL/Y0tUyRM= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.4.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= github.com/russellhaering/goxmldsig v1.1.0/go.mod h1:QK8GhXPB3+AfuCrfo0oRISa9NfzeCpWmxeGnqEpDF9o= github.com/russellhaering/goxmldsig v1.1.1 h1:vI0r2osGF1A9PLvsGdPUAGwEIrKa4Pj5sesSBsebIxM= github.com/russellhaering/goxmldsig v1.1.1/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= -github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= @@ -2280,6 +2356,7 @@ github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFo github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= github.com/safchain/ethtool v0.0.0-20200218184317-f459e2d13664/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= +github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/samuel/go-zookeeper v0.0.0-20180130194729-c4fab1ac1bec/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= github.com/samuel/go-zookeeper v0.0.0-20190810000440-0ceca61e4d75/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= @@ -2322,7 +2399,6 @@ github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFR github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 h1:bUGsEnyNbVPw06Bs80sCeARAlK8lhwqGyi6UT8ymuGk= github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= -github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/vfsgen v0.0.0-20180825020608-02ddb050ef6b/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= @@ -2368,6 +2444,7 @@ github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= @@ -2375,6 +2452,7 @@ github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHN github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= +github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= @@ -2387,6 +2465,7 @@ github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DM github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= +github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM= github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/streadway/amqp v0.0.0-20180528204448-e5adc2ada8b8/go.mod h1:1WNBiOZtZQLpVAyu0iTduoJL9hEsMloAK5XWrtW0xdY= @@ -2409,8 +2488,9 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= @@ -2563,6 +2643,7 @@ github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da/go.mod h1:E1AXubJB github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= @@ -2587,9 +2668,12 @@ go.etcd.io/etcd v0.5.0-alpha.5.0.20200910180754-dd1b699fc489/go.mod h1:yVHk9ub3C go.etcd.io/etcd v3.3.25+incompatible/go.mod h1:yaeTdrJi5lOmYerz05bd8+V7KubZs8YSFZfzsF9A6aI= go.etcd.io/etcd/api/v3 v3.5.0-alpha.0/go.mod h1:mPcW6aZJukV6Aa81LSKpBjQXTWlXB5r74ymPoSWa3Sw= go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= +go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= +go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.0-alpha.0/go.mod h1:kdV+xzCJ3luEBSIeQyB/OEKkWKd8Zkux4sbDeANrosU= go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= +go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= go.etcd.io/etcd/client/v3 v3.5.0-alpha.0/go.mod h1:wKt7jgDgf/OfKiYmCq5WFGxOFAkVMLxiiXgLDFhECr8= go.etcd.io/etcd/client/v3 v3.5.0-alpha.0.0.20210225194612-fa82d11a958a/go.mod h1:wKt7jgDgf/OfKiYmCq5WFGxOFAkVMLxiiXgLDFhECr8= go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0= @@ -2634,8 +2718,8 @@ go.opentelemetry.io/contrib/zpages v0.0.0-20210722161726-7668016acb73/go.mod h1: go.opentelemetry.io/otel v0.11.0/go.mod h1:G8UCk+KooF2HLkgo8RHX9epABH/aRGYET7gQOqBVdB0= go.opentelemetry.io/otel v1.0.0-RC1/go.mod h1:x9tRa9HK4hSSq7jf2TKbqFbtt58/TGk0f9XiEYISI1I= go.opentelemetry.io/otel v1.0.0/go.mod h1:AjRVh9A5/5DE7S+mZtTR6t8vpKKryam+0lREnfmS4cg= -go.opentelemetry.io/otel v1.2.0 h1:YOQDvxO1FayUcT9MIhJhgMyNO1WqoduiyvQHzGN0kUQ= -go.opentelemetry.io/otel v1.2.0/go.mod h1:aT17Fk0Z1Nor9e0uisf98LrntPGMnk4frBO9+dkf69I= +go.opentelemetry.io/otel v1.6.3 h1:FLOfo8f9JzFVFVyU+MSRJc2HdEAXQgm7pIv2uFKRSZE= +go.opentelemetry.io/otel v1.6.3/go.mod h1:7BgNga5fNlF/iZjG06hM3yofffp0ofKCDwSXx1GC4dI= go.opentelemetry.io/otel/exporters/jaeger v1.0.0 h1:cLhx8llHw02h5JTqGqaRbYn+QVKHmrzD9vEbKnSPk5U= go.opentelemetry.io/otel/exporters/jaeger v1.0.0/go.mod h1:q10N1AolE1JjqKrFJK2tYw0iZpmX+HBaXBtuCzRnBGQ= go.opentelemetry.io/otel/internal/metric v0.21.0/go.mod h1:iOfAaY2YycsXfYD4kaRSbLx2LKmfpKObWBEv9QK5zFo= @@ -2646,8 +2730,8 @@ go.opentelemetry.io/otel/sdk v1.0.0 h1:BNPMYUONPNbLneMttKSjQhOTlFLOD9U22HNG1KrIN go.opentelemetry.io/otel/sdk v1.0.0/go.mod h1:PCrDHlSy5x1kjezSdL37PhbFUMjrsLRshJ2zCzeXwbM= go.opentelemetry.io/otel/trace v1.0.0-RC1/go.mod h1:86UHmyHWFEtWjfWPSbu0+d0Pf9Q6e1U+3ViBOc+NXAg= go.opentelemetry.io/otel/trace v1.0.0/go.mod h1:PXTWqayeFUlJV1YDNhsJYB184+IvAH814St6o6ajzIs= -go.opentelemetry.io/otel/trace v1.2.0 h1:Ys3iqbqZhcf28hHzrm5WAquMkDHNZTUkw7KHbuNjej0= -go.opentelemetry.io/otel/trace v1.2.0/go.mod h1:N5FLswTubnxKxOJHM7XZC074qpeEdLy3CgAVsdMucK0= +go.opentelemetry.io/otel/trace v1.6.3 h1:IqN4L+5b0mPNjdXIiZ90Ni4Bl5BRkDQywePLWemd9bc= +go.opentelemetry.io/otel/trace v1.6.3/go.mod h1:GNJQusJlUgZl9/TQBPKU/Y/ty+0iVB5fjhKeJGZPGFs= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.starlark.net v0.0.0-20200901195727-6e684ef5eeee/go.mod h1:f0znQkUKRrkk36XxWbGjMqQM8wGv/xHBVE2qc3B5oFU= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -2663,14 +2747,14 @@ go.uber.org/automaxprocs v1.2.0/go.mod h1:YfO3fm683kQpzETxlTGZhGIVmXAhaw3gxeBADb go.uber.org/automaxprocs v1.4.0/go.mod h1:/mTEdr7LvHhs0v7mjdxDreTz1OG5zdZGqgOnhWiR/+Q= go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= -go.uber.org/goleak v1.1.11-0.20210813005559-691160354723 h1:sHOAIxRGBp443oHZIPB+HsUGaksVCXVQENPxwTfQdH4= -go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= @@ -2680,10 +2764,12 @@ go.uber.org/zap v1.14.1/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc= go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= -go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= go4.org/intern v0.0.0-20210108033219-3eb7198706b2/go.mod h1:vLqJ+12kCw61iCWsPto0EOHhBS+o4rO5VIucbc9g2Cc= go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222175341-b30ae309168e/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222180813-1025295fd063/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= +gocloud.dev v0.25.0 h1:Y7vDq8xj7SyM848KXf32Krda2e6jQ4CLh/mTeCSqXtk= +gocloud.dev v0.25.0/go.mod h1:7HegHVCYZrMiU3IE1qtnzf/vRrDwLYnRNR3EhWX8x9Y= golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180505025534-4ec37c66abab/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180608092829-8ac0e0d97ce4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -2695,6 +2781,7 @@ golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190418165655-df01cb2cc480/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -2725,6 +2812,7 @@ golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= @@ -2732,10 +2820,12 @@ golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI= -golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 h1:tkVvjkPTB7pnW3jnid7kNyAMPVWllTNOf/qKDze4p9o= +golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -2885,8 +2975,12 @@ golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0= -golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220401154927-543a649e0bdd h1:zYlwaUHTmxuf6H7hwO2dgwqozQmH7zf4x+/qql4oVWc= +golang.org/x/net v0.0.0-20220401154927-543a649e0bdd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -2909,8 +3003,10 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM= +golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -3079,19 +3175,29 @@ golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211110154304-99a53858aa08/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8pY/4yfcXrddB8qAbU0= +golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f h1:rlezHXNlxYWvBCzNses9Dlc7nGFaNMJeqLolcmQSSZY= +golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -3115,8 +3221,9 @@ golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 h1:GZokNIeuVkl3aZHJchRrr13WCsols02MLUcz1U9is6M= golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs= +golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -3140,6 +3247,7 @@ golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190425222832-ad9eeb80039a/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -3152,6 +3260,7 @@ golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190813034749-528a2984e271/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190918214516-5a1a30219888/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -3226,6 +3335,8 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.9 h1:j9KsMiaP1c3B0OTQGth0/k+miLGTgLsAFUCrF2vLcF8= golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -3286,8 +3397,18 @@ google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqiv google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E= google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= -google.golang.org/api v0.60.0 h1:eq/zs5WPH4J9undYM9IP1O7dSr7Yh8Y0GtSCpzGzIUk= -google.golang.org/api v0.60.0/go.mod h1:d7rl65NZAkEQ90JFzqBjcRq1TVeG5ZoGV3sSpEnnVb4= +google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= +google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw= +google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= +google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM= +google.golang.org/api v0.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M= +google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= +google.golang.org/api v0.68.0/go.mod h1:sOM8pTpwgflXRhz+oC8H2Dr+UcbMqkPPWNJo88Q7TH8= +google.golang.org/api v0.69.0/go.mod h1:boanBiw+h5c3s+tBPgEzLDRHfFLWV0qXxRHz3ws7C80= +google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= +google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= +google.golang.org/api v0.74.0 h1:ExR2D+5TYIrMphWgs5JCgwRhEDlPDXXrLwHHMgPHTXE= +google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -3384,13 +3505,30 @@ google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEc google.golang.org/genproto v0.0.0-20210921142501-181ce0d877f6/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211018162055-cf77aa76bad2/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211019152133-63b7e35f4404/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 h1:b9mVrqYfq3P4bCdaLg1qtBnPzUYgglsIdjZkL/fQVOE= google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220204002441-d6cc3cc0770e/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= +google.golang.org/genproto v0.0.0-20220401170504-314d38edb7de h1:9Ti5SG2U4cAcluryUo/sFay3TQKoxiFMfaT0pbizU7k= +google.golang.org/genproto v0.0.0-20220401170504-314d38edb7de/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= @@ -3430,9 +3568,12 @@ google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k= -google.golang.org/grpc v1.42.0 h1:XT2/MFpuPFsEX2fWh3YQtHkZ+WYZFQRfaUgLZYj/p6A= google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.45.0 h1:NEpgUqV3Z+ZjkqMsxMg11IaDrXY4RY6CQukSGK0uI1M= +google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v0.0.0-20200910201057-6591123024b3/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= @@ -3447,8 +3588,9 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= @@ -3477,6 +3619,7 @@ gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8 gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= gopkg.in/gorethink/gorethink.v3 v3.0.5/go.mod h1:+3yIIHJUGMBK+wyPH+iN5TP+88ikFDfZdqTlK3Y9q8I= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/inf.v0 v0.9.0/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= @@ -3484,8 +3627,9 @@ gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.66.2 h1:XfR1dOYubytKy4Shzc2LHrrGhU0lDCfDGG1yLPmpgsI= +gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/jcmturner/aescts.v1 v1.0.1/go.mod h1:nsR8qBOg+OucoIW+WMhB3GspUQXq9XorLnQb9XtvcOo= gopkg.in/jcmturner/dnsutils.v1 v1.0.1/go.mod h1:m3v+5svpVOhtFAP/wSz+yzh4Mc0Fg7eRhxkJMWSIz9Q= gopkg.in/jcmturner/goidentity.v3 v3.0.0/go.mod h1:oG2kH0IvSYNIu80dVAyu/yoefjq1mNfM5bm88whjWx4= diff --git a/latest.json b/latest.json index f1c9e890e90..9a36ea00bc2 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "8.4.5", + "stable": "8.4.6", "testing": "8.5.0-beta1" } diff --git a/package.json b/package.json index c1f8a365b26..44727529032 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "*pkg/**/*.go": [ "gofmt -w -s" ], - "*.{star}": [ + "*.star": [ "make drone" ] }, @@ -89,9 +89,9 @@ "@babel/preset-env": "7.16.11", "@babel/preset-react": "7.16.7", "@babel/preset-typescript": "7.16.7", - "@betterer/betterer": "5.1.7", - "@betterer/cli": "5.1.7", - "@betterer/regexp": "5.1.7", + "@betterer/betterer": "5.3.0", + "@betterer/cli": "5.3.0", + "@betterer/regexp": "5.3.0", "@emotion/eslint-plugin": "11.7.0", "@grafana/api-documenter": "7.11.2", "@grafana/e2e": "workspace:*", @@ -142,7 +142,7 @@ "@types/papaparse": "5.3.2", "@types/pluralize": "^0.0.29", "@types/prismjs": "1.26.0", - "@types/rc-time-picker": "^3", + "@types/rc-time-picker": "3.4.1", "@types/react": "17.0.42", "@types/react-beautiful-dnd": "13.1.2", "@types/react-dom": "17.0.14", @@ -202,7 +202,7 @@ "jest-canvas-mock": "2.3.1", "jest-date-mock": "1.0.8", "jest-fail-on-console": "2.3.0", - "jest-junit": "13.0.0", + "jest-junit": "13.1.0", "jest-matcher-utils": "27.5.1", "jest-mock-console": "1.2.3", "lerna": "^4.0.0", @@ -219,7 +219,7 @@ "prettier": "2.6.0", "raw-loader": "4.0.2", "react-refresh": "0.11.0", - "react-select-event": "^5.1.0", + "react-select-event": "5.5.0", "react-test-renderer": "17.0.2", "redux-mock-store": "1.5.4", "rimraf": "3.0.2", @@ -237,7 +237,7 @@ "ts-node": "10.7.0", "typescript": "4.4.4", "wait-on": "6.0.1", - "webpack": "5.70.0", + "webpack": "5.72.0", "webpack-bundle-analyzer": "4.5.0", "webpack-cli": "4.9.2", "webpack-dev-server": "4.7.4", @@ -257,7 +257,7 @@ "@grafana/slate-react": "0.22.10-grafana", "@grafana/ui": "workspace:*", "@jaegertracing/jaeger-ui-components": "workspace:*", - "@kusto/monaco-kusto": "4.1.6", + "@kusto/monaco-kusto": "5.0.0", "@lezer/common": "0.15.12", "@lezer/lr": "0.15.8", "@lingui/core": "3.13.2", @@ -325,7 +325,7 @@ "lru-cache": "7.7.1", "memoize-one": "6.0.0", "minisearch": "5.0.0-beta1", - "moment": "2.29.1", + "moment": "2.29.2", "moment-timezone": "0.5.34", "monaco-editor": "^0.31.1", "monaco-promql": "^1.7.2", @@ -396,7 +396,7 @@ "@rushstack/node-core-library": "3.45.1", "@rushstack/rig-package": "0.3.8", "@rushstack/ts-command-line": "4.10.7", - "@storybook/react/webpack": "5.70.0", + "@storybook/react/webpack": "5.72.0", "node-fetch": "2.6.7" }, "workspaces": { diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index f67ee56785b..aeabae904ef 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -29,7 +29,7 @@ "eventemitter3": "4.0.7", "lodash": "4.17.21", "marked": "4.0.12", - "moment": "2.29.1", + "moment": "2.29.2", "moment-timezone": "0.5.34", "ol": "6.14.1", "papaparse": "5.3.2", diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index bb003374d92..8231bb4c252 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -180,4 +180,5 @@ export interface GrafanaConfig { geomapDisableCustomBaseLayer?: boolean; unifiedAlertingEnabled: boolean; angularSupportEnabled: boolean; + feedbackLinksEnabled: boolean; } diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index 94276a9114d..1396140c41e 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -468,7 +468,7 @@ export interface DataQueryError { error?: string; }; message?: string; - status?: string; + status?: number; statusText?: string; refId?: string; type?: DataQueryErrorType; @@ -481,7 +481,6 @@ export interface DataQueryRequest { intervalMs: number; maxDataPoints?: number; range: TimeRange; - reverse?: boolean; scopedVars: ScopedVars; targets: TQuery[]; timezone: string; diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index dc9564e190e..23e997e4dd6 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -41,6 +41,7 @@ export interface FeatureToggles { showFeatureFlagsInUI?: boolean; disable_http_request_histogram?: boolean; validatedQueries?: boolean; + publicDashboards?: boolean; lokiLive?: boolean; swaggerUi?: boolean; featureHighlights?: boolean; diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index a9cfee1a4e1..e706dde235f 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -30,6 +30,7 @@ export interface NavModelItem extends NavLinkDTO { highlightId?: string; tabSuffix?: ComponentType<{ className?: string }>; hideFromNavbar?: boolean; + showIconInNavbar?: boolean; } export enum NavSection { diff --git a/packages/grafana-data/src/utils/location.test.ts b/packages/grafana-data/src/utils/location.test.ts index 174a505601d..b56660bbd01 100644 --- a/packages/grafana-data/src/utils/location.test.ts +++ b/packages/grafana-data/src/utils/location.test.ts @@ -3,7 +3,7 @@ import { locationUtil } from './location'; describe('locationUtil', () => { const { location } = window; - beforeAll(() => { + beforeEach(() => { // @ts-ignore delete window.location; @@ -21,63 +21,97 @@ describe('locationUtil', () => { }; }); - afterAll(() => { + afterEach(() => { window.location = location; }); - describe('strip base when appSubUrl configured', () => { - beforeEach(() => { - locationUtil.initialize({ - config: { appSubUrl: '/subUrl' } as any, - getVariablesUrlParams: (() => {}) as any, - getTimeRangeForUrl: (() => {}) as any, + describe('stripBaseFromUrl', () => { + describe('when appSubUrl configured', () => { + beforeEach(() => { + locationUtil.initialize({ + config: { appSubUrl: '/subUrl' } as any, + getVariablesUrlParams: (() => {}) as any, + getTimeRangeForUrl: (() => {}) as any, + }); }); - }); - test('relative url', () => { - const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/thisShouldRemain/'); - expect(urlWithoutMaster).toBe('/thisShouldRemain/'); - }); - test('relative url with multiple subUrl in path', () => { - const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/thisShouldRemain/subUrl/'); - expect(urlWithoutMaster).toBe('/thisShouldRemain/subUrl/'); - }); - test('relative url with subdirectory subUrl', () => { - const urlWithoutMaster = locationUtil.stripBaseFromUrl('/thisShouldRemain/subUrl/'); - expect(urlWithoutMaster).toBe('/thisShouldRemain/subUrl/'); - }); - test('absolute url', () => { - const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/subUrl/thisShouldRemain/'); - expect(urlWithoutMaster).toBe('/thisShouldRemain/'); - }); - test('absolute url with multiple subUrl in path', () => { - const urlWithoutMaster = locationUtil.stripBaseFromUrl( - 'http://www.domain.com:9877/subUrl/thisShouldRemain/subUrl/' - ); - expect(urlWithoutMaster).toBe('/thisShouldRemain/subUrl/'); - }); - test('absolute url with subdirectory subUrl', () => { - const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/thisShouldRemain/subUrl/'); - expect(urlWithoutMaster).toBe('http://www.domain.com:9877/thisShouldRemain/subUrl/'); - }); - }); - - describe('strip base when appSubUrl not configured', () => { - beforeEach(() => { - locationUtil.initialize({ - config: {} as any, - getVariablesUrlParams: (() => {}) as any, - getTimeRangeForUrl: (() => {}) as any, + test('relative url', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/thisShouldRemain/'); + expect(urlWithoutMaster).toBe('/thisShouldRemain/'); + }); + test('relative url with multiple subUrl in path', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/thisShouldRemain/subUrl/'); + expect(urlWithoutMaster).toBe('/thisShouldRemain/subUrl/'); + }); + test('relative url with subdirectory subUrl', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('/thisShouldRemain/subUrl/'); + expect(urlWithoutMaster).toBe('/thisShouldRemain/subUrl/'); + }); + test('absolute url', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/subUrl/thisShouldRemain/'); + expect(urlWithoutMaster).toBe('/thisShouldRemain/'); + }); + test('absolute url with multiple subUrl in path', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl( + 'http://www.domain.com:9877/subUrl/thisShouldRemain/subUrl/' + ); + expect(urlWithoutMaster).toBe('/thisShouldRemain/subUrl/'); + }); + test('absolute url with subdirectory subUrl', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/thisShouldRemain/subUrl/'); + expect(urlWithoutMaster).toBe('http://www.domain.com:9877/thisShouldRemain/subUrl/'); }); }); - test('relative url', () => { - const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/grafana/'); - expect(urlWithoutMaster).toBe('/subUrl/grafana/'); + describe('when appSubUrl not configured', () => { + beforeEach(() => { + locationUtil.initialize({ + config: {} as any, + getVariablesUrlParams: (() => {}) as any, + getTimeRangeForUrl: (() => {}) as any, + }); + }); + + test('relative url', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/grafana/'); + expect(urlWithoutMaster).toBe('/subUrl/grafana/'); + }); + + test('absolute url', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/subUrl/grafana/'); + expect(urlWithoutMaster).toBe('/subUrl/grafana/'); + }); }); - test('absolute url', () => { - const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/subUrl/grafana/'); - expect(urlWithoutMaster).toBe('/subUrl/grafana/'); + describe('when origin does not have a port in it', () => { + beforeEach(() => { + window.location = { + ...location, + hash: '#hash', + host: 'www.domain.com', + hostname: 'www.domain.com', + href: 'http://www.domain.com/path/b?search=a&b=c&d#hash', + origin: 'http://www.domain.com', + pathname: '/path/b', + port: '', + protocol: 'http:', + search: '?search=a&b=c&d', + }; + }); + + test('relative url', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/grafana/'); + expect(urlWithoutMaster).toBe('/subUrl/grafana/'); + }); + + test('URL with same host, different port', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/subUrl/grafana/'); + expect(urlWithoutMaster).toBe('http://www.domain.com:9877/subUrl/grafana/'); + }); + + test('URL of a completely different origin', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.another-domain.com/subUrl/grafana/'); + expect(urlWithoutMaster).toBe('http://www.another-domain.com/subUrl/grafana/'); + }); }); }); diff --git a/packages/grafana-data/src/utils/location.ts b/packages/grafana-data/src/utils/location.ts index 83f481067bb..7df8796b41b 100644 --- a/packages/grafana-data/src/utils/location.ts +++ b/packages/grafana-data/src/utils/location.ts @@ -7,22 +7,43 @@ let grafanaConfig: GrafanaConfig = { appSubUrl: '' } as any; let getTimeRangeUrlParams: () => RawTimeRange; let getVariablesUrlParams: (scopedVars?: ScopedVars) => UrlQueryMap; +const maybeParseUrl = (input: string): URL | undefined => { + try { + return new URL(input); + } catch { + return undefined; + } +}; + /** * * @param url * @internal */ -const stripBaseFromUrl = (url: string): string => { +const stripBaseFromUrl = (urlOrPath: string): string => { + // Will only return a URL object if the input is actually a valid URL + const parsedUrl = maybeParseUrl(urlOrPath); + if (parsedUrl) { + // If the input is a URL, and for a different origin that we're on, just bail + // and return it. There's no need to strip anything from it + if (parsedUrl.origin !== window.location.origin) { + return urlOrPath; + } + } + const appSubUrl = grafanaConfig.appSubUrl ?? ''; const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0; - const isAbsoluteUrl = url.startsWith('http'); + const isAbsoluteUrl = urlOrPath.startsWith('http'); + let segmentToStrip = appSubUrl; - if (!url.startsWith('/') || isAbsoluteUrl) { + if (!urlOrPath.startsWith('/') || isAbsoluteUrl) { segmentToStrip = `${window.location.origin}${appSubUrl}`; } - return url.length > 0 && url.indexOf(segmentToStrip) === 0 ? url.slice(segmentToStrip.length - stripExtraChars) : url; + return urlOrPath.length > 0 && urlOrPath.indexOf(segmentToStrip) === 0 + ? urlOrPath.slice(segmentToStrip.length - stripExtraChars) + : urlOrPath; }; /** diff --git a/packages/grafana-data/src/utils/nodeGraph.ts b/packages/grafana-data/src/utils/nodeGraph.ts index 03a1c48eda9..caccc59e5ef 100644 --- a/packages/grafana-data/src/utils/nodeGraph.ts +++ b/packages/grafana-data/src/utils/nodeGraph.ts @@ -1,9 +1,9 @@ export enum NodeGraphDataFrameFieldNames { id = 'id', title = 'title', - subTitle = 'subTitle', - mainStat = 'mainStat', - secondaryStat = 'secondaryStat', + subTitle = 'subtitle', + mainStat = 'mainstat', + secondaryStat = 'secondarystat', source = 'source', target = 'target', detail = 'detail__', diff --git a/packages/grafana-data/tsconfig.json b/packages/grafana-data/tsconfig.json index 39d54db4906..d56edaaf559 100644 --- a/packages/grafana-data/tsconfig.json +++ b/packages/grafana-data/tsconfig.json @@ -5,9 +5,7 @@ "rootDirs": ["."], "paths": { "@grafana/data": ["."] - }, - "incremental": true, - "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + } }, "exclude": ["dist", "node_modules"], "extends": "@grafana/tsconfig", diff --git a/packages/grafana-e2e-selectors/tsconfig.json b/packages/grafana-e2e-selectors/tsconfig.json index e2299dee4fe..8a293c016ad 100644 --- a/packages/grafana-e2e-selectors/tsconfig.json +++ b/packages/grafana-e2e-selectors/tsconfig.json @@ -2,9 +2,7 @@ "compilerOptions": { "declarationDir": "dist", "outDir": "compiled", - "rootDirs": ["."], - "incremental": true, - "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + "rootDirs": ["."] }, "exclude": ["dist", "node_modules"], "extends": "@grafana/tsconfig", diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index 9f892d541c6..6fb70e16064 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -41,7 +41,7 @@ "rollup-plugin-copy": "3.4.0", "rollup-plugin-sourcemaps": "0.6.3", "rollup-plugin-terser": "7.0.2", - "webpack": "5.70.0" + "webpack": "5.72.0" }, "types": "src/index.ts", "dependencies": { diff --git a/packages/grafana-e2e/tsconfig.json b/packages/grafana-e2e/tsconfig.json index a3cb5c5b14f..9936ed1fd49 100644 --- a/packages/grafana-e2e/tsconfig.json +++ b/packages/grafana-e2e/tsconfig.json @@ -3,9 +3,7 @@ "declarationDir": "dist", "outDir": "compiled", "rootDirs": ["."], - "types": ["cypress"], - "incremental": true, - "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + "types": ["cypress"] }, "exclude": ["dist", "node_modules"], "extends": "@grafana/tsconfig", diff --git a/packages/grafana-runtime/src/components/PanelDataErrorView.tsx b/packages/grafana-runtime/src/components/PanelDataErrorView.tsx index 25e577d6712..2e3907c9264 100644 --- a/packages/grafana-runtime/src/components/PanelDataErrorView.tsx +++ b/packages/grafana-runtime/src/components/PanelDataErrorView.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { PanelData } from '@grafana/data'; +import { FieldConfigSource, PanelData } from '@grafana/data'; /** * Describes the properties that can be passed to the PanelDataErrorView. @@ -10,6 +10,7 @@ export interface PanelDataErrorViewProps { message?: string; panelId: number; data: PanelData; + fieldConfig?: FieldConfigSource; needsTimeField?: boolean; needsNumberField?: boolean; needsStringField?: boolean; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 6a0e015626b..d5076e5ae6a 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -36,6 +36,7 @@ export class GrafanaBootConfig implements GrafanaConfig { externalUserMngLinkName = ''; externalUserMngInfo = ''; allowOrgCreate = false; + feedbackLinksEnabled = true; disableLoginForm = false; defaultDatasource = ''; // UID alertingEnabled = false; @@ -119,7 +120,6 @@ export class GrafanaBootConfig implements GrafanaConfig { this.theme2 = createTheme({ colors: { mode } }); this.theme = this.theme2.v1; this.bootData = options.bootData; - this.buildInfo = options.buildInfo; const defaults = { datasources: {}, @@ -131,7 +131,7 @@ export class GrafanaBootConfig implements GrafanaConfig { appUrl: '', appSubUrl: '', buildInfo: { - version: 'v1.0', + version: '1.0', commit: '1', env: 'production', }, @@ -142,6 +142,8 @@ export class GrafanaBootConfig implements GrafanaConfig { merge(this, defaults, options); + this.buildInfo = options.buildInfo || defaults.buildInfo; + if (this.dateFormats) { systemDateFormats.update(this.dateFormats); } diff --git a/packages/grafana-runtime/tsconfig.json b/packages/grafana-runtime/tsconfig.json index 444da147886..984ef9a09ed 100644 --- a/packages/grafana-runtime/tsconfig.json +++ b/packages/grafana-runtime/tsconfig.json @@ -3,9 +3,7 @@ "baseUrl": ".", "declarationDir": "dist", "outDir": "compiled", - "rootDirs": ["."], - "incremental": true, - "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + "rootDirs": ["."] }, "exclude": ["dist", "node_modules"], "extends": "@grafana/tsconfig", diff --git a/packages/grafana-schema/src/scuemata/dashboard/dashboard.cue b/packages/grafana-schema/src/scuemata/dashboard/dashboard.cue index cb7e767ca01..9040f0a28c3 100644 --- a/packages/grafana-schema/src/scuemata/dashboard/dashboard.cue +++ b/packages/grafana-schema/src/scuemata/dashboard/dashboard.cue @@ -1,434 +1,441 @@ package dashboard import ( - "list" + "list" - "github.com/grafana/grafana/cue/scuemata" + "github.com/grafana/grafana/cue/scuemata" ) Family: scuemata.#Family & { lineages: [ [ - { // 0.0 - // Unique numeric identifier for the dashboard. - // TODO must isolate or remove identifiers local to a Grafana instance...? - id?: number - // Unique dashboard identifier that can be generated by anyone. string (8-40) - uid?: string - // Title of dashboard. - title?: string - // Description of dashboard. - description?: string + {// 0.0 + // Unique numeric identifier for the dashboard. + // TODO must isolate or remove identifiers local to a Grafana instance...? + id?: number + // Unique dashboard identifier that can be generated by anyone. string (8-40) + uid?: string + // Title of dashboard. + title?: string + // Description of dashboard. + description?: string - gnetId?: string - // Tags associated with dashboard. - tags?: [...string] - // Theme of dashboard. - style: *"light" | "dark" - // Timezone of dashboard, - timezone?: *"browser" | "utc" | "" - // Whether a dashboard is editable or not. - editable: bool | *true - // 0 for no shared crosshair or tooltip (default). - // 1 for shared crosshair. - // 2 for shared crosshair AND shared tooltip. - graphTooltip: >=0 & <=2 | *0 - // Time range for dashboard, e.g. last 6 hours, last 7 days, etc - time?: { - from: string | *"now-6h" - to: string | *"now" - } - // Timepicker metadata. - timepicker?: { - // Whether timepicker is collapsed or not. - collapse: bool | *false - // Whether timepicker is enabled or not. - enable: bool | *true - // Whether timepicker is visible or not. - hidden: bool | *false - // Selectable intervals for auto-refresh. - refresh_intervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] - } - // Templating. - templating?: list: [...{...}] - // Annotations. - annotations?: list: [...{ - builtIn: number | *0 - // Datasource to use for annotation. - datasource: string - // Whether annotation is enabled. - enable: bool | *true - // Whether to hide annotation. - hide?: bool | *false - // Annotation icon color. - iconColor?: string - // Name of annotation. - name?: string - type: string | *"dashboard" - // Query for annotation data. - rawQuery?: string - showIn: number | *0 - }] - // Auto-refresh interval. - refresh?: string | false - // Version of the JSON schema, incremented each time a Grafana update brings - // changes to said schema. - // FIXME this is the old schema numbering system, and will be replaced by scuemata - schemaVersion: number | *30 - // Version of the dashboard, incremented each time the dashboard is updated. - version?: number - panels?: [...(#Panel | #GraphPanel | #RowPanel)] + gnetId?: string + // Tags associated with dashboard. + tags?: [...string] + // Theme of dashboard. + style: *"light" | "dark" + // Timezone of dashboard, + timezone?: *"browser" | "utc" | "" + // Whether a dashboard is editable or not. + editable: bool | *true + // 0 for no shared crosshair or tooltip (default). + // 1 for shared crosshair. + // 2 for shared crosshair AND shared tooltip. + graphTooltip: >=0 & <=2 | *0 + // Time range for dashboard, e.g. last 6 hours, last 7 days, etc + time?: { + from: string | *"now-6h" + to: string | *"now" + } + // Timepicker metadata. + timepicker?: { + // Whether timepicker is collapsed or not. + collapse: bool | *false + // Whether timepicker is enabled or not. + enable: bool | *true + // Whether timepicker is visible or not. + hidden: bool | *false + // Selectable intervals for auto-refresh. + refresh_intervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + } + // Templating. + templating?: list: [...{...}] + // Annotations. + annotations?: list: [...{ + builtIn: number | *0 + // Datasource to use for annotation. + datasource: { + type?: string + uid?: string + } + // Whether annotation is enabled. + enable: bool | *true + // Whether to hide annotation. + hide?: bool | *false + // Annotation icon color. + iconColor?: string + // Name of annotation. + name?: string + type: string | *"dashboard" + // Query for annotation data. + rawQuery?: string + showIn: number | *0 + }] + // Auto-refresh interval. + refresh?: string | false + // Version of the JSON schema, incremented each time a Grafana update brings + // changes to said schema. + // FIXME this is the old schema numbering system, and will be replaced by scuemata + schemaVersion: number | *30 + // Version of the dashboard, incremented each time the dashboard is updated. + version?: number + panels?: [...(#Panel | #GraphPanel | #RowPanel)] - // TODO docs - #FieldColorModeId: "thresholds" | "palette-classic" | "palette-saturated" | "continuous-GrYlRd" | "fixed" @cuetsy(kind="enum") + // TODO docs + #FieldColorModeId: "thresholds" | "palette-classic" | "palette-saturated" | "continuous-GrYlRd" | "fixed" @cuetsy(kind="enum") - // TODO docs - #FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type") + // TODO docs + #FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type") - // TODO docs - #FieldColor: { - // The main color scheme mode - mode: #FieldColorModeId | string - // Stores the fixed color value if mode is fixed - fixedColor?: string - // Some visualizations need to know how to assign a series color from by value color schemes - seriesBy?: #FieldColorSeriesByMode - } @cuetsy(kind="interface") + // TODO docs + #FieldColor: { + // The main color scheme mode + mode: #FieldColorModeId | string + // Stores the fixed color value if mode is fixed + fixedColor?: string + // Some visualizations need to know how to assign a series color from by value color schemes + seriesBy?: #FieldColorSeriesByMode + } @cuetsy(kind="interface") - // TODO docs - #Threshold: { - // TODO docs - // FIXME the corresponding typescript field is required/non-optional, but nulls currently appear here when serializing -Infinity to JSON - value?: number - // TODO docs - color: string - // TODO docs - // TODO are the values here enumerable into a disjunction? - // Some seem to be listed in typescript comment - state?: string - } @cuetsy(kind="interface") + // TODO docs + #Threshold: { + // TODO docs + // FIXME the corresponding typescript field is required/non-optional, but nulls currently appear here when serializing -Infinity to JSON + value?: number + // TODO docs + color: string + // TODO docs + // TODO are the values here enumerable into a disjunction? + // Some seem to be listed in typescript comment + state?: string + } @cuetsy(kind="interface") - #ThresholdsMode: "absolute" | "percentage" @cuetsy(kind="enum") + #ThresholdsMode: "absolute" | "percentage" @cuetsy(kind="enum") - #ThresholdsConfig: { - mode: #ThresholdsMode + #ThresholdsConfig: { + mode: #ThresholdsMode - // Must be sorted by 'value', first value is always -Infinity - steps: [...#Threshold] - } @cuetsy(kind="interface") + // Must be sorted by 'value', first value is always -Infinity + steps: [...#Threshold] + } @cuetsy(kind="interface") - // TODO docs - // FIXME this is extremely underspecfied; wasn't obvious which typescript types corresponded to it - #Transformation: { - id: string - options: {...} - } + // TODO docs + // FIXME this is extremely underspecfied; wasn't obvious which typescript types corresponded to it + #Transformation: { + id: string + options: {...} + } - // Schema for panel targets is specified by datasource - // plugins. We use a placeholder definition, which the Go - // schema loader either left open/as-is with the Base - // variant of the Dashboard and Panel families, or filled - // with types derived from plugins in the Instance variant. - // When working directly from CUE, importers can extend this - // type directly to achieve the same effect. - #Target: {...} + // Schema for panel targets is specified by datasource + // plugins. We use a placeholder definition, which the Go + // schema loader either left open/as-is with the Base + // variant of the Dashboard and Panel families, or filled + // with types derived from plugins in the Instance variant. + // When working directly from CUE, importers can extend this + // type directly to achieve the same effect. + #Target: {...} - // Dashboard panels. Panels are canonically defined inline - // because they share a version timeline with the dashboard - // schema; they do not evolve independently. - #Panel: { - // The panel plugin type id. - type: !="" + // Dashboard panels. Panels are canonically defined inline + // because they share a version timeline with the dashboard + // schema; they do not evolve independently. + #Panel: { + // The panel plugin type id. + type: !="" - // TODO docs - id?: number + // TODO docs + id?: number - // FIXME this almost certainly has to be changed in favor of scuemata versions - pluginVersion?: string + // FIXME this almost certainly has to be changed in favor of scuemata versions + pluginVersion?: string - // TODO docs - tags?: [...string] + // TODO docs + tags?: [...string] - // Internal - the exact major and minor versions of the panel plugin - // schema. Hidden and therefore not a part of the data model, but - // expected to be filled with panel plugin schema versions so that it's - // possible to figure out which schema version matched on a successful - // unification. - // _pv: { maj: int, min: int } - // The major and minor versions of the panel plugin for this schema. - // TODO 2-tuple list instead of struct? - // panelSchema?: { maj: number, min: number } - panelSchema?: [number, number] + // Internal - the exact major and minor versions of the panel plugin + // schema. Hidden and therefore not a part of the data model, but + // expected to be filled with panel plugin schema versions so that it's + // possible to figure out which schema version matched on a successful + // unification. + // _pv: { maj: int, min: int } + // The major and minor versions of the panel plugin for this schema. + // TODO 2-tuple list instead of struct? + // panelSchema?: { maj: number, min: number } + panelSchema?: [number, number] - // TODO docs - targets?: [...#Target] + // TODO docs + targets?: [...#Target] - // Panel title. - title?: string - // Description. - description?: string - // Whether to display the panel without a background. - transparent: bool | *false - // The datasource used in all targets. - datasource?: { - type?: string - uid?: string - } - // Grid position. - gridPos?: { - // Panel - h: number & >0 | *9 - // Panel - w: number & >0 & <=24 | *12 - // Panel x - x: number & >=0 & <24 | *0 - // Panel y - y: number & >=0 | *0 - // true if fixed - static?: bool - } - // Panel links. - // FIXME this is temporarily specified as a closed list so - // that validation will pass when no links are present, but - // to force a failure as soon as it's checked against there - // being anything in the list so it can be fixed in - // accordance with that object - links?: [] + // Panel title. + title?: string + // Description. + description?: string + // Whether to display the panel without a background. + transparent: bool | *false + // The datasource used in all targets. + datasource?: { + type?: string + uid?: string + } + // Grid position. + gridPos?: { + // Panel + h: number & >0 | *9 + // Panel + w: number & >0 & <=24 | *12 + // Panel x + x: number & >=0 & <24 | *0 + // Panel y + y: number & >=0 | *0 + // true if fixed + static?: bool + } + // Panel links. + // FIXME this is temporarily specified as a closed list so + // that validation will pass when no links are present, but + // to force a failure as soon as it's checked against there + // being anything in the list so it can be fixed in + // accordance with that object + links?: [] - // Name of template variable to repeat for. - repeat?: string - // Direction to repeat in if 'repeat' is set. - // "h" for horizontal, "v" for vertical. - repeatDirection: *"h" | "v" + // Name of template variable to repeat for. + repeat?: string + // Direction to repeat in if 'repeat' is set. + // "h" for horizontal, "v" for vertical. + repeatDirection: *"h" | "v" - // TODO docs - maxDataPoints?: number + // TODO docs + maxDataPoints?: number - // TODO docs - thresholds?: [...] + // TODO docs + thresholds?: [...] - // TODO docs - timeRegions?: [...] + // TODO docs + timeRegions?: [...] - transformations: [...#Transformation] + transformations: [...#Transformation] - // TODO docs - // TODO tighter constraint - interval?: string + // TODO docs + // TODO tighter constraint + interval?: string - // TODO docs - // TODO tighter constraint - timeFrom?: string + // TODO docs + // TODO tighter constraint + timeFrom?: string - // TODO docs - // TODO tighter constraint - timeShift?: string + // TODO docs + // TODO tighter constraint + timeShift?: string - // options is specified by the PanelOptions field in panel - // plugin schemas. - options: {} + // options is specified by the PanelOptions field in panel + // plugin schemas. + options: {} - fieldConfig: { - defaults: { - // The display value for this field. This supports template variables blank is auto - displayName?: string + fieldConfig: { + defaults: { + // The display value for this field. This supports template variables blank is auto + displayName?: string - // This can be used by data sources that return and explicit naming structure for values and labels - // When this property is configured, this value is used rather than the default naming strategy. - displayNameFromDS?: string + // This can be used by data sources that return and explicit naming structure for values and labels + // When this property is configured, this value is used rather than the default naming strategy. + displayNameFromDS?: string - // Human readable field metadata - description?: string + // Human readable field metadata + description?: string - // An explict path to the field in the datasource. When the frame meta includes a path, - // This will default to `${frame.meta.path}/${field.name} - // - // When defined, this value can be used as an identifier within the datasource scope, and - // may be used to update the results - path?: string + // An explict path to the field in the datasource. When the frame meta includes a path, + // This will default to `${frame.meta.path}/${field.name} + // + // When defined, this value can be used as an identifier within the datasource scope, and + // may be used to update the results + path?: string - // True if data source can write a value to the path. Auth/authz are supported separately - writeable?: bool + // True if data source can write a value to the path. Auth/authz are supported separately + writeable?: bool - // True if data source field supports ad-hoc filters - filterable?: bool + // True if data source field supports ad-hoc filters + filterable?: bool - // Numeric Options - unit?: string + // Numeric Options + unit?: string - // Significant digits (for display) - decimals?: number + // Significant digits (for display) + decimals?: number - min?: number - max?: number + min?: number + max?: number - // Convert input values into a display string - // - // TODO this one corresponds to a complex type with - // generics on the typescript side. Ouch. Will - // either need special care, or we'll just need to - // accept a very loosely specified schema. It's very - // unlikely we'll be able to translate cue to - // typescript generics in the general case, though - // this particular one *may* be able to work. - mappings?: [...{...}] + // Convert input values into a display string + // + // TODO this one corresponds to a complex type with + // generics on the typescript side. Ouch. Will + // either need special care, or we'll just need to + // accept a very loosely specified schema. It's very + // unlikely we'll be able to translate cue to + // typescript generics in the general case, though + // this particular one *may* be able to work. + mappings?: [...{...}] - // Map numeric values to states - thresholds?: #ThresholdsConfig + // Map numeric values to states + thresholds?: #ThresholdsConfig - // // Map values to a display color - color?: #FieldColor + // // Map values to a display color + color?: #FieldColor - // // Used when reducing field values - // nullValueMode?: NullValueMode + // // Used when reducing field values + // nullValueMode?: NullValueMode - // // The behavior when clicking on a result - links?: [...] + // // The behavior when clicking on a result + links?: [...] - // Alternative to empty string - noValue?: string + // Alternative to empty string + noValue?: string - // custom is specified by the PanelFieldConfig field - // in panel plugin schemas. - custom?: {} - } - overrides: [...{ - matcher: { - id: string | *"" - options?: _ - } - properties: [...{ - id: string | *"" - value?: _ - }] - }] - } - // Embed the disjunction of all injected panel schema, if any were injected. - if len(compose._panelSchemas) > 0 { - or(compose._panelSchemas) // TODO try to stick graph in here - } + // custom is specified by the PanelFieldConfig field + // in panel plugin schemas. + custom?: {} + } + overrides: [...{ + matcher: { + id: string | *"" + options?: _ + } + properties: [...{ + id: string | *"" + value?: _ + }] + }] + } + // Embed the disjunction of all injected panel schema, if any were injected. + if len(compose._panelSchemas) > 0 { + or(compose._panelSchemas)// TODO try to stick graph in here + } - // Make the plugin-composed subtrees open if the panel is - // of unknown types. This is important in every possible case: - // - Base (this file only): no real dashboard json - // containing any panels would ever validate - // - Dist (this file + core plugin schema): dashboard json containing - // panels with any third-party panel plugins would fail to validate, - // as well as any core plugins lacking a models.cue. The latter case - // is not normally expected, but this is not the appropriate place - // to enforce the invariant, anyway. - // - Instance (this file + core + third-party plugin schema): dashboard - // json containing panels with a third-party plugin that exists but - // is not currently installed would fail to validate. - if !list.Contains(compose._panelTypes, type) { - options: {...} - fieldConfig: defaults: custom: {...} - ... - } - } + // Make the plugin-composed subtrees open if the panel is + // of unknown types. This is important in every possible case: + // - Base (this file only): no real dashboard json + // containing any panels would ever validate + // - Dist (this file + core plugin schema): dashboard json containing + // panels with any third-party panel plugins would fail to validate, + // as well as any core plugins lacking a models.cue. The latter case + // is not normally expected, but this is not the appropriate place + // to enforce the invariant, anyway. + // - Instance (this file + core + third-party plugin schema): dashboard + // json containing panels with a third-party plugin that exists but + // is not currently installed would fail to validate. + if !list.Contains(compose._panelTypes, type) { + options: {...} + fieldConfig: defaults: custom: {...} + ... + } + } - // Row panel - #RowPanel: { - type: "row" - collapsed: bool | *false - title?: string + // Row panel + #RowPanel: { + type: "row" + collapsed: bool | *false + title?: string - // Name of default datasource. - datasource?: string + // Name of default datasource. + datasource: { + type?: string + uid?: string + } - gridPos?: { - // Panel - h: number & >0 | *9 - // Panel - w: number & >0 & <=24 | *12 - // Panel x - x: number & >=0 & <24 | *0 - // Panel y - y: number & >=0 | *0 - // true if fixed - static?: bool - } - id: number - panels: [...(#Panel | #GraphPanel)] - // Name of template variable to repeat for. - repeat?: string - } - // Support for legacy graph panels. - #GraphPanel: { - ... - type: "graph" - thresholds: [...{...}] - timeRegions?: [...{...}] - seriesOverrides: [...{...}] - aliasColors?: [string]: string - bars: bool | *false - dashes: bool | *false - dashLength: number | *10 - fill?: number - fillGradient?: number - hiddenSeries: bool | *false - legend: {...} - lines: bool | *false - linewidth?: number - nullPointMode: *"null" | "connected" | "null as zero" - percentage: bool | *false - points: bool | *false - pointradius?: number - renderer: string - spaceLength: number | *10 - stack: bool | *false - steppedLine: bool | *false - tooltip?: { - shared?: bool - sort: number | *0 - value_type: *"individual" | "cumulative" - } - } - } - ] + gridPos?: { + // Panel + h: number & >0 | *9 + // Panel + w: number & >0 & <=24 | *12 + // Panel x + x: number & >=0 & <24 | *0 + // Panel y + y: number & >=0 | *0 + // true if fixed + static?: bool + } + id: number + panels: [...(#Panel | #GraphPanel)] + // Name of template variable to repeat for. + repeat?: string + } + + // Support for legacy graph panels. + #GraphPanel: { + ... + type: "graph" + thresholds: [...{...}] + timeRegions?: [...{...}] + seriesOverrides: [...{...}] + aliasColors?: [string]: string + bars: bool | *false + dashes: bool | *false + dashLength: number | *10 + fill?: number + fillGradient?: number + hiddenSeries: bool | *false + legend: {...} + lines: bool | *false + linewidth?: number + nullPointMode: *"null" | "connected" | "null as zero" + percentage: bool | *false + points: bool | *false + pointradius?: number + renderer: string + spaceLength: number | *10 + stack: bool | *false + steppedLine: bool | *false + tooltip?: { + shared?: bool + sort: number | *0 + value_type: *"individual" | "cumulative" + } + } + }, + ], ] - compose: { - // Scuemata families for all panel types that should be composed into the - // dashboard schema. - Panel: [string]: scuemata.#PanelFamily + compose: { + // Scuemata families for all panel types that should be composed into the + // dashboard schema. + Panel: [string]: scuemata.#PanelFamily - // _panelTypes: [for typ, _ in Panel {typ}] - _panelTypes: [for typ, _ in Panel {typ}, "graph", "row"] - _panelSchemas: [for typ, scue in Panel { - for lv, lin in scue.lineages { - for sv, sch in lin { - (_mapPanel & {arg: { - type: typ - v: [lv, sv] // TODO add optionality for exact, at least, at most, any - model: sch // TODO Does this need to be close()d? - }}).out - } - } - }, { type: string }] - _mapPanel: { - arg: { - type: string & !="" - v: [number, number] - model: {...} - } - // Until CUE introduces the must() constraint, we have to enforce - // that the input model is as expected by checking for unification - // in this hidden property (see https://github.com/cue-lang/cue/issues/575). - // If we unified arg.model with the scuemata.#PanelSchema - // meta-schema directly, the struct openness (PanelOptions: {...}) - // would be applied to the actual schema instance in the arg. Here, - // where we're actually putting those in the dashboard schema, want - // those to be closed, or at least preserve closed-ness. - _checkSchema: scuemata.#PanelSchema & arg.model - out: { - type: arg.type - panelSchema: arg.v // TODO add optionality for exact, at least, at most, any - options: arg.model.PanelOptions - fieldConfig: defaults: custom: {} - if arg.model.PanelFieldConfig != _|_ { - fieldConfig: defaults: custom: arg.model.PanelFieldConfig - } - } - } - } + // _panelTypes: [for typ, _ in Panel {typ}] + _panelTypes: [ for typ, _ in Panel {typ}, "graph", "row"] + _panelSchemas: [ for typ, scue in Panel { + for lv, lin in scue.lineages { + for sv, sch in lin { + (_mapPanel & {arg: { + type: typ + v: [lv, sv] // TODO add optionality for exact, at least, at most, any + model: sch // TODO Does this need to be close()d? + }}).out + } + } + }, {type: string}] + _mapPanel: { + arg: { + type: string & !="" + v: [number, number] + model: {...} + } + // Until CUE introduces the must() constraint, we have to enforce + // that the input model is as expected by checking for unification + // in this hidden property (see https://github.com/cue-lang/cue/issues/575). + // If we unified arg.model with the scuemata.#PanelSchema + // meta-schema directly, the struct openness (PanelOptions: {...}) + // would be applied to the actual schema instance in the arg. Here, + // where we're actually putting those in the dashboard schema, want + // those to be closed, or at least preserve closed-ness. + _checkSchema: scuemata.#PanelSchema & arg.model + out: { + type: arg.type + panelSchema: arg.v // TODO add optionality for exact, at least, at most, any + options: arg.model.PanelOptions + fieldConfig: defaults: custom: {} + if arg.model.PanelFieldConfig != _|_ { + fieldConfig: defaults: custom: arg.model.PanelFieldConfig + } + } + } + } } diff --git a/packages/grafana-schema/tsconfig.json b/packages/grafana-schema/tsconfig.json index 35b2c110cf6..7bad1e46a47 100644 --- a/packages/grafana-schema/tsconfig.json +++ b/packages/grafana-schema/tsconfig.json @@ -2,9 +2,7 @@ "compilerOptions": { "declarationDir": "dist", "outDir": "compiled", - "rootDirs": ["."], - "incremental": true, - "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + "rootDirs": ["."] }, "exclude": ["dist", "node_modules"], "extends": "@grafana/tsconfig", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index d0163669a65..a36db9237a8 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -58,7 +58,7 @@ "jquery": "3.6.0", "lodash": "4.17.21", "memoize-one": "6.0.0", - "moment": "2.29.1", + "moment": "2.29.2", "monaco-editor": "^0.31.1", "ol": "6.14.1", "prismjs": "1.27.0", @@ -98,22 +98,21 @@ "@grafana/tsconfig": "^1.2.0-rc1", "@mdx-js/react": "1.6.22", "@rollup/plugin-commonjs": "21.0.2", - "@rollup/plugin-image": "2.1.1", "@rollup/plugin-node-resolve": "13.1.3", - "@storybook/addon-a11y": "6.4.15", - "@storybook/addon-actions": "6.4.15", - "@storybook/addon-docs": "6.4.15", - "@storybook/addon-essentials": "6.4.15", + "@storybook/addon-a11y": "6.4.21", + "@storybook/addon-actions": "6.4.21", + "@storybook/addon-docs": "6.4.21", + "@storybook/addon-essentials": "6.4.21", "@storybook/addon-knobs": "6.4.0", - "@storybook/addon-storysource": "6.4.15", - "@storybook/addons": "6.4.15", - "@storybook/api": "6.4.15", - "@storybook/builder-webpack5": "6.4.15", - "@storybook/components": "6.4.15", - "@storybook/core-events": "6.4.15", - "@storybook/manager-webpack5": "6.4.15", - "@storybook/react": "6.4.15", - "@storybook/theming": "6.4.15", + "@storybook/addon-storysource": "6.4.21", + "@storybook/addons": "6.4.21", + "@storybook/api": "6.4.21", + "@storybook/builder-webpack5": "6.4.21", + "@storybook/components": "6.4.21", + "@storybook/core-events": "6.4.21", + "@storybook/manager-webpack5": "6.4.21", + "@storybook/react": "6.4.21", + "@storybook/theming": "6.4.21", "@swc/helpers": "0.3.8", "@testing-library/dom": "8.13.0", "@testing-library/jest-dom": "5.16.2", @@ -168,6 +167,7 @@ "rimraf": "3.0.2", "rollup": "2.70.1", "rollup-plugin-sourcemaps": "0.6.3", + "rollup-plugin-svg-import": "^1.6.0", "rollup-plugin-terser": "7.0.2", "sass-loader": "12.6.0", "storybook-dark-mode": "1.0.9", @@ -175,7 +175,7 @@ "terser-webpack-plugin": "5.3.1", "ts-loader": "8.0.11", "typescript": "4.4.4", - "webpack": "5.70.0", + "webpack": "5.72.0", "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 d40c5776084..71f712df14f 100644 --- a/packages/grafana-ui/rollup.config.ts +++ b/packages/grafana-ui/rollup.config.ts @@ -1,6 +1,6 @@ import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; -import image from '@rollup/plugin-image'; +import svg from 'rollup-plugin-svg-import'; import { terser } from 'rollup-plugin-terser'; const pkg = require('./package.json'); @@ -41,7 +41,7 @@ const buildCjsPackage = ({ env }) => { include: /node_modules/, }), resolve(), - image(), + svg({ stringify: true }), env === 'production' && terser(), ], }; diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx index 24fe7a67f77..c58c7b8c3a1 100644 --- a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx +++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx @@ -60,7 +60,7 @@ export const CollapsableSection: FC = ({ {loading ? ( ) : ( - + )}
diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index 9758bfcb61a..e02a291226a 100755 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -82,7 +82,7 @@ function sameProps(prevProps: any, nextProps: any, propsToDiff: Array { constructor(props: GraphNGProps) { super(props); - this.state = this.prepState(props); + let state = this.prepState(props); + state.alignedData = state.config!.prepData!([state.alignedFrame]) as AlignedData; + this.state = state; this.plotInstance = React.createRef(); } @@ -131,7 +133,6 @@ export class GraphNG extends React.Component { state = { alignedFrame, - alignedData: config!.prepData!([alignedFrame]) as AlignedData, config, }; @@ -229,12 +230,13 @@ export class GraphNG extends React.Component { if (shouldReconfig) { newState.config = this.props.prepConfig(newState.alignedFrame, this.props.frames, this.getTimeRange); - newState.alignedData = newState.config.prepData!([newState.alignedFrame]) as AlignedData; pluginLog('GraphNG', false, 'config recreated', newState.config); } - } - newState && this.setState(newState); + newState.alignedData = newState.config!.prepData!([newState.alignedFrame]) as AlignedData; + + this.setState(newState); + } } } @@ -255,7 +257,7 @@ export class GraphNG extends React.Component { {(vizWidth: number, vizHeight: number) => ( ) { - const { children, className, root: portalRoot = document.body, forwardedRef } = props; + const { children, className, root, forwardedRef } = props; const theme = useTheme2(); const node = useRef(null); + const portalRoot = root ?? getPortalContainer(); + if (!node.current) { node.current = document.createElement('div'); if (className) { @@ -25,6 +27,7 @@ export function Portal(props: PropsWithChildren) { if (node.current) { portalRoot.appendChild(node.current); } + return () => { if (node.current) { portalRoot.removeChild(node.current); @@ -35,7 +38,18 @@ export function Portal(props: PropsWithChildren) { return ReactDOM.createPortal(
{children}
, node.current); } +/** @internal */ +export function getPortalContainer() { + return window.document.getElementById('grafana-portal-container') ?? document.body; +} + +/** @internal */ +export function PortalContainer() { + return
; +} + export const RefForwardingPortal = React.forwardRef((props, ref) => { return ; }); + RefForwardingPortal.displayName = 'RefForwardingPortal'; diff --git a/packages/grafana-ui/src/components/Select/Select.tsx b/packages/grafana-ui/src/components/Select/Select.tsx index b6219969017..ad94830f156 100644 --- a/packages/grafana-ui/src/components/Select/Select.tsx +++ b/packages/grafana-ui/src/components/Select/Select.tsx @@ -13,7 +13,7 @@ export function MultiSelect(props: MultiSelectCommonProps) { return ; } -interface AsyncSelectProps extends Omit, 'options'>, SelectAsyncProps { +export interface AsyncSelectProps extends Omit, 'options'>, SelectAsyncProps { // AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options value?: SelectableValue | null; invalid?: boolean; diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx index bd313b231e7..b74dc5258d0 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx @@ -128,6 +128,7 @@ export function SelectBase({ onInputChange, onKeyDown, onOpenMenu, + onFocus, openMenuOnFocus = false, options = [], placeholder = 'Choose', @@ -235,6 +236,7 @@ export function SelectBase({ onKeyDown, onMenuClose: onCloseMenu, onMenuOpen: onOpenMenu, + onFocus, formatOptionLabel, openMenuOnFocus, options, diff --git a/packages/grafana-ui/src/components/Select/types.ts b/packages/grafana-ui/src/components/Select/types.ts index 4b93366dc86..12606c768c0 100644 --- a/packages/grafana-ui/src/components/Select/types.ts +++ b/packages/grafana-ui/src/components/Select/types.ts @@ -61,6 +61,7 @@ export interface SelectCommonProps { onInputChange?: (value: string, actionMeta: InputActionMeta) => void; onKeyDown?: (event: React.KeyboardEvent) => void; onOpenMenu?: () => void; + onFocus?: () => void; openMenuOnFocus?: boolean; options?: Array>; placeholder?: string; diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index dcd271f5d44..c956b401e0e 100755 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -20,7 +20,7 @@ import { import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; import { UPlotChart } from '../uPlot/Plot'; import { Themeable2 } from '../../types'; -import { preparePlotData } from '../uPlot/utils'; +import { preparePlotData2, getStackingGroups } from '../uPlot/utils'; import { preparePlotFrame } from './utils'; import { isEqual } from 'lodash'; @@ -51,7 +51,7 @@ export class Sparkline extends PureComponent { const alignedDataFrame = preparePlotFrame(props.sparkline, props.config); this.state = { - data: preparePlotData([alignedDataFrame]), + data: preparePlotData2(alignedDataFrame, getStackingGroups(alignedDataFrame)), alignedDataFrame, configBuilder: this.prepareConfig(alignedDataFrame), }; @@ -65,7 +65,7 @@ export class Sparkline extends PureComponent { return { ...state, - data: preparePlotData([frame]), + data: preparePlotData2(frame, getStackingGroups(frame)), alignedDataFrame: frame, }; } diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index 1366744fd91..1097fad630f 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -304,9 +304,9 @@ export const Table: FC = memo((props: Props) => { totalColumnsWidth={totalColumnsWidth} /> )} - {paginationEl}
+ {paginationEl}
); }); diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index c70f0c9e199..3682b5da17e 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -184,6 +184,9 @@ export const getTableStyles = (theme: GrafanaTheme2) => { div:not(:only-child):first-child { flex-grow: 0.6; } + position: absolute; + bottom: 0; + left: 0; `, paginationSummary: css` color: ${theme.colors.text.secondary}; @@ -191,11 +194,16 @@ export const getTableStyles = (theme: GrafanaTheme2) => { margin-left: auto; `, - tableContentWrapper: (totalColumnsWidth: number) => css` - width: ${totalColumnsWidth ?? '100%'}; - display: flex; - flex-direction: column; - `, + tableContentWrapper: (totalColumnsWidth: number) => { + const width = totalColumnsWidth !== undefined ? `${totalColumnsWidth}px` : '100%'; + + return css` + label: tableContentWrapper; + width: ${width}; + display: flex; + flex-direction: column; + `; + }, row: css` label: row; border-bottom: 1px solid ${borderColor}; diff --git a/packages/grafana-ui/src/components/Tabs/Tab.tsx b/packages/grafana-ui/src/components/Tabs/Tab.tsx index 562eb2669ee..d45f33cc436 100644 --- a/packages/grafana-ui/src/components/Tabs/Tab.tsx +++ b/packages/grafana-ui/src/components/Tabs/Tab.tsx @@ -38,18 +38,20 @@ export const Tab = React.forwardRef( const linkClass = cx(tabsStyles.link, active ? tabsStyles.activeStyle : tabsStyles.notActive); return ( -
  • +
  • + ); } ); diff --git a/packages/grafana-ui/src/components/Tabs/TabsBar.tsx b/packages/grafana-ui/src/components/Tabs/TabsBar.tsx index b5eb2a07206..13fccb9223f 100644 --- a/packages/grafana-ui/src/components/Tabs/TabsBar.tsx +++ b/packages/grafana-ui/src/components/Tabs/TabsBar.tsx @@ -32,7 +32,9 @@ export const TabsBar = React.forwardRef(({ children, clas return (
    -
      {children}
    +
    + {children} +
    ); }); diff --git a/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx b/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx index 2b0813d2ee4..e3d54e93ce5 100644 --- a/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx +++ b/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx @@ -19,7 +19,7 @@ export class UnthemedTimeSeries extends React.Component { prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { const { eventBus, sync } = this.context as PanelContext; - const { theme, timeZone, legend, renderers, tweakAxis, tweakScale } = this.props; + const { theme, timeZone, renderers, tweakAxis, tweakScale } = this.props; return preparePlotConfigBuilder({ frame: alignedFrame, @@ -29,7 +29,6 @@ export class UnthemedTimeSeries extends React.Component { eventBus, sync, allFrames, - legend, renderers, tweakScale, tweakAxis, diff --git a/packages/grafana-ui/src/components/TimeSeries/utils.ts b/packages/grafana-ui/src/components/TimeSeries/utils.ts index d812a8dc93d..ed9e084759e 100644 --- a/packages/grafana-ui/src/components/TimeSeries/utils.ts +++ b/packages/grafana-ui/src/components/TimeSeries/utils.ts @@ -23,10 +23,9 @@ import { VisibilityMode, ScaleDirection, ScaleOrientation, - VizLegendOptions, StackingMode, } from '@grafana/schema'; -import { collectStackingGroups, INTERNAL_NEGATIVE_Y_PREFIX, orderIdsByCalcs, preparePlotData } from '../uPlot/utils'; +import { getStackingGroups, preparePlotData2 } from '../uPlot/utils'; import uPlot from 'uplot'; import { buildScaleKey } from '../GraphNG/utils'; @@ -40,7 +39,6 @@ const defaultConfig: GraphFieldConfig = { export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync?: () => DashboardCursorSync; - legend?: VizLegendOptions; }> = ({ frame, theme, @@ -50,13 +48,12 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync, allFrames, renderers, - legend, tweakScale = (opts) => opts, tweakAxis = (opts) => opts, }) => { const builder = new UPlotConfigBuilder(timeZone); - builder.setPrepData((prepData) => preparePlotData(prepData, undefined, legend)); + builder.setPrepData((frames) => preparePlotData2(frames[0], builder.getStackingGroups())); // X is the first field in the aligned frame const xField = frame.fields[0]; @@ -122,8 +119,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ let customRenderedFields = renderers?.flatMap((r) => Object.values(r.fieldMap).filter((name) => r.indicesOnly.indexOf(name) === -1)) ?? []; - const stackingGroups: Map = new Map(); - let indexByName: Map | undefined; for (let i = 1; i < frame.fields.length; i++) { @@ -328,20 +323,11 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ }); } } - collectStackingGroups(field, stackingGroups, seriesIndex); } - if (stackingGroups.size !== 0) { - for (const [group, seriesIds] of stackingGroups.entries()) { - const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame }); - for (let j = seriesIdxs.length - 1; j > 0; j--) { - builder.addBand({ - series: [seriesIdxs[j], seriesIdxs[j - 1]], - dir: group.startsWith(INTERNAL_NEGATIVE_Y_PREFIX) ? 1 : -1, - }); - } - } - } + let stackingGroups = getStackingGroups(frame); + + builder.setStackingGroups(stackingGroups); // hook up custom/composite renderers renderers?.forEach((r) => { diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 3d04933efe2..f4733b6ff7d 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -6,7 +6,7 @@ export { Tooltip } from './Tooltip/Tooltip'; export { PopoverContent } from './Tooltip/types'; export { PopoverController } from './Tooltip/PopoverController'; export { Popover } from './Tooltip/Popover'; -export { Portal } from './Portal/Portal'; +export { Portal, getPortalContainer, PortalContainer } from './Portal/Portal'; export { CustomScrollbar, ScrollbarPosition } from './CustomScrollbar/CustomScrollbar'; export { TabbedContainer, TabConfig } from './TabbedContainer/TabbedContainer'; diff --git a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx index 26640d5b532..7505c6cb26f 100644 --- a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx @@ -6,7 +6,7 @@ import { GraphFieldConfig, GraphDrawStyle } from '@grafana/schema'; import uPlot from 'uplot'; import createMockRaf from 'mock-raf'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; -import { preparePlotData } from './utils'; +import { preparePlotData2, getStackingGroups } from './utils'; import { SeriesProps } from './config/UPlotSeriesBuilder'; const mockRaf = createMockRaf(); @@ -55,7 +55,7 @@ const mockData = () => { const config = new UPlotConfigBuilder(); config.addSeries({} as SeriesProps); - return { data: [data], timeRange, config }; + return { data: data, timeRange, config }; }; describe('UPlotChart', () => { @@ -75,7 +75,7 @@ describe('UPlotChart', () => { const { unmount } = render( { const { rerender } = render( { expect(uPlot).toBeCalledTimes(1); - data[0].fields[1].values.set(0, 1); + data.fields[1].values.set(0, 1); rerender( { it('skips uPlot intialization for width and height equal 0', async () => { const { data, timeRange, config } = mockData(); const { queryAllByTestId } = render( - + ); expect(queryAllByTestId('uplot-main-div')).toHaveLength(1); @@ -136,7 +142,7 @@ describe('UPlotChart', () => { const { rerender } = render( { nextConfig.addSeries({} as SeriesProps); rerender( - + ); expect(destroyMock).toBeCalledTimes(1); @@ -162,7 +174,7 @@ describe('UPlotChart', () => { const { rerender } = render( { // we wait 1 frame for plugins initialisation logic to finish rerender( AlignedData | FacetedData; +type PreDataStacked = (frames: DataFrame[], stackingGroups: StackingGroup[]) => AlignedData | FacetedData; export class UPlotConfigBuilder { private series: UPlotSeriesBuilder[] = []; private axes: Record = {}; private scales: UPlotScaleBuilder[] = []; private bands: Band[] = []; + private stackingGroups: StackingGroup[] = []; private cursor: Cursor | undefined; private select: uPlot.Select | undefined; private hasLeftAxis = false; @@ -143,6 +145,14 @@ export class UPlotConfigBuilder { this.bands.push(band); } + setStackingGroups(groups: StackingGroup[]) { + this.stackingGroups = groups; + } + + getStackingGroups() { + return this.stackingGroups; + } + setTooltipInterpolator(interpolator: PlotTooltipInterpolator) { this.tooltipInterpolator = interpolator; } @@ -151,10 +161,10 @@ export class UPlotConfigBuilder { return this.tooltipInterpolator; } - setPrepData(prepData: PrepData) { + setPrepData(prepData: PreDataStacked) { this.prepData = (frames) => { this.frames = frames; - return prepData(frames); + return prepData(frames, this.getStackingGroups()); }; } @@ -221,6 +231,14 @@ export class UPlotConfigBuilder { config.tzDate = this.tzDate; config.padding = this.padding; + if (this.stackingGroups.length) { + this.stackingGroups.forEach((group) => { + getStackingBands(group).forEach((band) => { + this.addBand(band); + }); + }); + } + if (this.bands.length) { config.bands = this.bands; } diff --git a/packages/grafana-ui/src/components/uPlot/utils.test.ts b/packages/grafana-ui/src/components/uPlot/utils.test.ts index 8cf0e7c1445..3ccb288de24 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.test.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.test.ts @@ -1,6 +1,7 @@ -import { orderIdsByCalcs, preparePlotData, timeFormatToTemplate } from './utils'; +import { getStackingGroups, preparePlotData2, timeFormatToTemplate } from './utils'; import { FieldType, MutableDataFrame } from '@grafana/data'; -import { GraphTransform, StackingMode } from '@grafana/schema'; +import { BarAlignment, GraphDrawStyle, GraphTransform, LineInterpolation, StackingMode } from '@grafana/schema'; +import Units from 'ol/proj/Units'; describe('timeFormatToTemplate', () => { it.each` @@ -16,7 +17,7 @@ describe('timeFormatToTemplate', () => { }); }); -describe('preparePlotData', () => { +describe('preparePlotData2', () => { const df = new MutableDataFrame({ fields: [ { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] }, @@ -27,7 +28,7 @@ describe('preparePlotData', () => { }); it('creates array from DataFrame', () => { - expect(preparePlotData([df])).toMatchInlineSnapshot(` + expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` Array [ Array [ 9997, @@ -63,7 +64,7 @@ describe('preparePlotData', () => { { name: 'c', values: [20, 20, 20], config: { custom: { transform: GraphTransform.NegativeY } } }, ], }); - expect(preparePlotData([df])).toMatchInlineSnapshot(` + expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` Array [ Array [ 9997, @@ -104,7 +105,7 @@ describe('preparePlotData', () => { { name: 'i', values: [20, undefined, 20, 20], config: { custom: { transform: GraphTransform.NegativeY } } }, ], }); - expect(preparePlotData([df])).toMatchInlineSnapshot(` + expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` Array [ Array [ 9997, @@ -178,7 +179,7 @@ describe('preparePlotData', () => { { name: 'c', values: [20, 20, 20] }, ], }); - expect(preparePlotData([df])).toMatchInlineSnapshot(` + expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` Array [ Array [ 9997, @@ -226,7 +227,7 @@ describe('preparePlotData', () => { }, ], }); - expect(preparePlotData([df])).toMatchInlineSnapshot(` + expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` Array [ Array [ 9997, @@ -273,7 +274,7 @@ describe('preparePlotData', () => { }, ], }); - expect(preparePlotData([df])).toMatchInlineSnapshot(` + expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` Array [ Array [ 9997, @@ -329,7 +330,7 @@ describe('preparePlotData', () => { }, ], }); - expect(preparePlotData([df])).toMatchInlineSnapshot(` + expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` Array [ Array [ 9997, @@ -397,7 +398,7 @@ describe('preparePlotData', () => { ], }); - expect(preparePlotData([df])).toMatchInlineSnapshot(` + expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` Array [ Array [ 9997, @@ -472,7 +473,7 @@ describe('preparePlotData', () => { ], }); - expect(preparePlotData([df])).toMatchInlineSnapshot(` + expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(` Array [ Array [ 9997, @@ -507,239 +508,330 @@ describe('preparePlotData', () => { ] `); }); - - describe('ignores nullish-only stacks', () => { - test('single stacking group', () => { - const df = new MutableDataFrame({ - fields: [ - { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] }, - { - name: 'a', - values: [-10, null, 10], - config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, - }, - { - name: 'b', - values: [10, null, null], - config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, - }, - { - name: 'c', - values: [20, undefined, 20], - config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, - }, - ], - }); - - expect(preparePlotData([df])).toMatchInlineSnapshot(` - Array [ - Array [ - 9997, - 9998, - 9999, - ], - Array [ - -10, - null, - 10, - ], - Array [ - 0, - null, - 10, - ], - Array [ - 20, - null, - 30, - ], - ] - `); - }); - test('multiple stacking groups', () => { - const df = new MutableDataFrame({ - fields: [ - { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] }, - { - name: 'a', - values: [-10, undefined, 10], - config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, - }, - { - name: 'b', - values: [10, undefined, 10], - config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, - }, - { - name: 'c', - values: [20, undefined, 20], - config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, - }, - { - name: 'd', - values: [1, 2, null], - config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackB' } } }, - }, - { - name: 'e', - values: [1, 2, null], - config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackB' } } }, - }, - { - name: 'f', - values: [1, 2, null], - config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackB' } } }, - }, - ], - }); - - expect(preparePlotData([df])).toMatchInlineSnapshot(` - Array [ - Array [ - 9997, - 9998, - 9999, - ], - Array [ - -10, - null, - 10, - ], - Array [ - 0, - null, - 20, - ], - Array [ - 20, - null, - 40, - ], - Array [ - 1, - 2, - null, - ], - Array [ - 2, - 4, - null, - ], - Array [ - 3, - 6, - null, - ], - ] - `); - }); - }); - describe('with legend sorted', () => { - it('should affect when single group', () => { - const df = new MutableDataFrame({ - fields: [ - { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] }, - { - name: 'a', - values: [-10, 20, 10], - state: { calcs: { max: 20 } }, - config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, - }, - { - name: 'b', - values: [10, 10, 10], - state: { calcs: { max: 10 } }, - config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, - }, - { - name: 'c', - values: [20, 20, 20], - state: { calcs: { max: 20 } }, - config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, - }, - ], - }); - - expect(preparePlotData([df], undefined, { sortBy: 'Max', sortDesc: false } as any)).toMatchInlineSnapshot(` - Array [ - Array [ - 9997, - 9998, - 9999, - ], - Array [ - 0, - 30, - 20, - ], - Array [ - 10, - 10, - 10, - ], - Array [ - 20, - 50, - 40, - ], - ] - `); - expect(preparePlotData([df], undefined, { sortBy: 'Max', sortDesc: true } as any)).toMatchInlineSnapshot(` - Array [ - Array [ - 9997, - 9998, - 9999, - ], - Array [ - -10, - 20, - 10, - ], - Array [ - 20, - 50, - 40, - ], - Array [ - 10, - 40, - 30, - ], - ] - `); - }); - }); }); }); -describe('orderIdsByCalcs', () => { - const ids = [1, 2, 3, 4]; - const frame = new MutableDataFrame({ - fields: [ - { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] }, - { name: 'a', values: [-10, 20, 10], state: { calcs: { min: -10 } } }, - { name: 'b', values: [20, 20, 20], state: { calcs: { min: 20 } } }, - { name: 'c', values: [10, 10, 10], state: { calcs: { min: 10 } } }, - { name: 'd', values: [30, 30, 30] }, - ], +describe('auto stacking groups', () => { + test('split on stacking mode', () => { + const df = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [0, 1, 2] }, + { + name: 'b', + values: [1, 2, 3], + config: { custom: { stacking: { mode: StackingMode.Percent } } }, + }, + { + name: 'c', + values: [4, 5, 6], + config: { custom: { stacking: { mode: StackingMode.Normal } } }, + }, + ], + }); + + expect(getStackingGroups(df)).toMatchInlineSnapshot(` + Array [ + Object { + "dir": 1, + "series": Array [ + 1, + ], + }, + Object { + "dir": 1, + "series": Array [ + 2, + ], + }, + ] + `); }); - it.each([ - { legend: undefined }, - { legend: { sortBy: 'Min' } }, - { legend: { sortDesc: false } }, - { legend: {} }, - { sortBy: 'Mik', sortDesc: true }, - ])('should return without ordering if legend option is %o', (legend: any) => { - const result = orderIdsByCalcs({ ids, frame, legend }); - expect(result).toEqual([1, 2, 3, 4]); + test('split pos/neg', () => { + // since we expect most series to be Pos, we try to bail early when scanning all values + // as soon as we find a value >= 0, it's assumed Pos, else Neg + + const df = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [0, 1, 2] }, + { + name: 'a', + values: [-1, null, -3], + config: { custom: { stacking: { mode: StackingMode.Normal } } }, + }, + { + name: 'b', + values: [1, 2, 3], + config: { custom: { stacking: { mode: StackingMode.Normal } } }, + }, + { + name: 'c', + values: [0, 0, 0], + config: { custom: { stacking: { mode: StackingMode.Normal } } }, + }, + ], + }); + + expect(getStackingGroups(df)).toMatchInlineSnapshot(` + Array [ + Object { + "dir": -1, + "series": Array [ + 1, + 3, + ], + }, + Object { + "dir": 1, + "series": Array [ + 2, + ], + }, + ] + `); }); - it('should order the ids based on the frame stat', () => { - const resultDesc = orderIdsByCalcs({ ids, frame, legend: { sortBy: 'Min', sortDesc: true } as any }); - expect(resultDesc).toEqual([4, 2, 3, 1]); - const resultAsc = orderIdsByCalcs({ ids, frame, legend: { sortBy: 'Min', sortDesc: false } as any }); - expect(resultAsc).toEqual([1, 3, 2, 4]); + test('split pos/neg with NegY', () => { + const df = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [0, 1, 2] }, + { + name: 'a', + values: [-1, null, -3], + config: { custom: { stacking: { mode: StackingMode.Normal }, transform: GraphTransform.NegativeY } }, + }, + { + name: 'b', + values: [1, 2, 3], + config: { custom: { stacking: { mode: StackingMode.Normal } } }, + }, + { + name: 'c', + values: [0, 0, 0], + config: { custom: { stacking: { mode: StackingMode.Normal } } }, + }, + ], + }); + + expect(getStackingGroups(df)).toMatchInlineSnapshot(` + Array [ + Object { + "dir": 1, + "series": Array [ + 1, + 2, + ], + }, + Object { + "dir": -1, + "series": Array [ + 3, + ], + }, + ] + `); + }); + + test('split on drawStyle, lineInterpolation, barAlignment', () => { + const df = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [0, 1, 2] }, + { + name: 'a', + values: [1, 2, 3], + config: { + custom: { + drawStyle: GraphDrawStyle.Bars, + barAlignment: BarAlignment.After, + stacking: { mode: StackingMode.Normal }, + }, + }, + }, + { + name: 'b', + values: [1, 2, 3], + config: { + custom: { + drawStyle: GraphDrawStyle.Bars, + barAlignment: BarAlignment.Before, + stacking: { mode: StackingMode.Normal }, + }, + }, + }, + { + name: 'c', + values: [1, 2, 3], + config: { + custom: { + drawStyle: GraphDrawStyle.Line, + lineInterpolation: LineInterpolation.Linear, + stacking: { mode: StackingMode.Normal }, + }, + }, + }, + { + name: 'd', + values: [1, 2, 3], + config: { + custom: { + drawStyle: GraphDrawStyle.Line, + lineInterpolation: LineInterpolation.Smooth, + stacking: { mode: StackingMode.Normal }, + }, + }, + }, + { + name: 'e', + values: [1, 2, 3], + config: { custom: { drawStyle: GraphDrawStyle.Points, stacking: { mode: StackingMode.Normal } } }, + }, + ], + }); + + expect(getStackingGroups(df)).toMatchInlineSnapshot(` + Array [ + Object { + "dir": 1, + "series": Array [ + 1, + ], + }, + Object { + "dir": 1, + "series": Array [ + 2, + ], + }, + Object { + "dir": 1, + "series": Array [ + 3, + ], + }, + Object { + "dir": 1, + "series": Array [ + 4, + ], + }, + Object { + "dir": 1, + "series": Array [ + 5, + ], + }, + ] + `); + }); + + test('split on axis & units (scaleKey)', () => { + const df = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [0, 1, 2] }, + { + name: 'a', + values: [1, 2, 3], + config: { custom: { stacking: { mode: StackingMode.Normal } }, unit: Units.FEET }, + }, + { + name: 'b', + values: [1, 2, 3], + config: { custom: { stacking: { mode: StackingMode.Normal } }, unit: Units.DEGREES }, + }, + ], + }); + + expect(getStackingGroups(df)).toMatchInlineSnapshot(` + Array [ + Object { + "dir": 1, + "series": Array [ + 1, + ], + }, + Object { + "dir": 1, + "series": Array [ + 2, + ], + }, + ] + `); + }); + + test('split on explicit stacking group & mode & pos/neg w/NegY', () => { + const df = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [0, 1, 2] }, + { + name: 'a', + values: [1, 2, 3], + config: { custom: { stacking: { mode: StackingMode.Normal, group: 'A' } } }, + }, + { + name: 'b', + values: [1, 2, 3], + config: { custom: { stacking: { mode: StackingMode.Normal, group: 'A' } } }, + }, + { + name: 'c', + values: [1, 2, 3], + config: { custom: { stacking: { mode: StackingMode.Percent, group: 'A' } } }, + }, + { + name: 'd', + values: [1, 2, 3], + config: { custom: { stacking: { mode: StackingMode.Normal, group: 'B' } } }, + }, + { + name: 'e', + values: [1, 2, 3], + config: { custom: { stacking: { mode: StackingMode.Percent, group: 'B' } } }, + }, + { + name: 'e', + values: [1, 2, 3], + config: { + custom: { stacking: { mode: StackingMode.Percent, group: 'B' }, transform: GraphTransform.NegativeY }, + }, + }, + ], + }); + + expect(getStackingGroups(df)).toMatchInlineSnapshot(` + Array [ + Object { + "dir": 1, + "series": Array [ + 1, + 2, + ], + }, + Object { + "dir": 1, + "series": Array [ + 3, + ], + }, + Object { + "dir": 1, + "series": Array [ + 4, + ], + }, + Object { + "dir": 1, + "series": Array [ + 5, + ], + }, + Object { + "dir": -1, + "series": Array [ + 6, + ], + }, + ] + `); }); }); diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index 96ceaf2eb5d..4bdaadc8b8d 100755 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -1,12 +1,11 @@ -import { DataFrame, ensureTimeField, Field, FieldType } from '@grafana/data'; -import { GraphFieldConfig, GraphTransform, StackingMode, VizLegendOptions } from '@grafana/schema'; -import { orderBy } from 'lodash'; +import { DataFrame, ensureTimeField, FieldType } from '@grafana/data'; +import { BarAlignment, GraphDrawStyle, GraphTransform, LineInterpolation, StackingMode } from '@grafana/schema'; import uPlot, { AlignedData, Options, PaddingSide } from 'uplot'; import { attachDebugger } from '../../utils'; import { createLogger } from '../../utils/logger'; +import { buildScaleKey } from '../GraphNG/utils'; const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g; -export const INTERNAL_NEGATIVE_Y_PREFIX = '__internalNegY'; export function timeFormatToTemplate(f: string) { return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, (match) => `{${match}}`); @@ -41,115 +40,225 @@ interface StackMeta { } /** @internal */ -export function preparePlotData( - frames: DataFrame[], - onStackMeta?: (meta: StackMeta) => void, - legend?: VizLegendOptions -): AlignedData { - const frame = frames[0]; - const result: any[] = []; - const stackingGroups: Map = new Map(); - let seriesIndex = 0; - - for (let i = 0; i < frame.fields.length; i++) { - const f = frame.fields[i]; - - if (f.type === FieldType.time) { - result.push(ensureTimeField(f).values.toArray()); - seriesIndex++; - continue; - } - - collectStackingGroups(f, stackingGroups, seriesIndex); - const customConfig: GraphFieldConfig = f.config.custom || {}; - - const values = f.values.toArray(); - - if (customConfig.transform === GraphTransform.NegativeY) { - result.push(values.map((v) => (v == null ? v : v * -1))); - } else if (customConfig.transform === GraphTransform.Constant) { - result.push(new Array(values.length).fill(values[0])); - } else { - result.push(values); - } - seriesIndex++; - } - - // Stacking - if (stackingGroups.size !== 0) { - const byPct = frame.fields[1].config.custom?.stacking?.mode === StackingMode.Percent; - const dataLength = result[0].length; - const alignedTotals = Array(stackingGroups.size); - alignedTotals[0] = null; - - // array or stacking groups - for (const [_, seriesIds] of stackingGroups.entries()) { - const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame }); - const noValueStack = Array(dataLength).fill(true); - const groupTotals = byPct ? Array(dataLength).fill(0) : null; - - if (byPct) { - for (let j = 0; j < seriesIdxs.length; j++) { - const currentlyStacking = result[seriesIdxs[j]]; - - for (let k = 0; k < dataLength; k++) { - const v = currentlyStacking[k]; - groupTotals![k] += v == null ? 0 : +v; - } - } - } - - const acc = Array(dataLength).fill(0); - - for (let j = 0; j < seriesIdxs.length; j++) { - let seriesIdx = seriesIdxs[j]; - - alignedTotals[seriesIdx] = groupTotals; - - const currentlyStacking = result[seriesIdx]; - - for (let k = 0; k < dataLength; k++) { - const v = currentlyStacking[k]; - if (v != null && noValueStack[k]) { - noValueStack[k] = false; - } - acc[k] += v == null ? 0 : v / (byPct ? groupTotals![k] : 1); - } - - result[seriesIdx] = acc.slice().map((v, i) => (noValueStack[i] ? null : v)); - } - } - - onStackMeta && - onStackMeta({ - totals: alignedTotals as AlignedData, - }); - } - - return result as AlignedData; +export interface StackingGroup { + series: number[]; + dir: StackDirection; } -export function collectStackingGroups(f: Field, groups: Map, seriesIdx: number) { - const customConfig = f.config.custom; - if (!customConfig) { - return; - } - if ( - customConfig.stacking?.mode !== StackingMode.None && - customConfig.stacking?.group && - !customConfig.hideFrom?.viz - ) { - const group = - customConfig.transform === GraphTransform.NegativeY - ? `${INTERNAL_NEGATIVE_Y_PREFIX}-${customConfig.stacking.group}` - : customConfig.stacking.group; +/** @internal */ +const enum StackDirection { + Pos = 1, + Neg = -1, +} - if (!groups.has(group)) { - groups.set(group, [seriesIdx]); - } else { - groups.set(group, groups.get(group)!.concat(seriesIdx)); +// generates bands between adjacent group series +/** @internal */ +export function getStackingBands(group: StackingGroup) { + let bands: uPlot.Band[] = []; + let { series, dir } = group; + let lastIdx = series.length - 1; + + let rSeries = series.slice().reverse(); + + rSeries.forEach((si, i) => { + if (i !== lastIdx) { + let nextIdx = rSeries[i + 1]; + bands.push({ + series: [si, nextIdx], + // fill direction is inverted from stack direction + dir: (-1 * dir) as 1 | -1, + }); } + }); + + return bands; +} + +// expects an AlignedFrame +/** @internal */ +export function getStackingGroups(frame: DataFrame) { + let groups: Map = new Map(); + + frame.fields.forEach(({ config, values }, i) => { + // skip x or time field + if (i === 0) { + return; + } + + let { custom } = config; + + if (custom == null) { + return; + } + + // TODO: currently all AlignedFrame fields end up in uplot series & data, even custom.hideFrom?.viz + // ideally hideFrom.viz fields would be excluded so we can remove this + if (custom.hideFrom?.viz) { + return; + } + + let { stacking } = custom; + + if (stacking == null) { + return; + } + + let { mode: stackingMode, group: stackingGroup } = stacking; + + // not stacking + if (stackingMode === StackingMode.None) { + return; + } + + // will this be stacked up or down after any transforms applied + let vals = values.toArray(); + let transform = custom.transform; + let stackDir = + transform === GraphTransform.Constant + ? vals[0] > 0 + ? StackDirection.Pos + : StackDirection.Neg + : transform === GraphTransform.NegativeY + ? vals.some((v) => v > 0) + ? StackDirection.Neg + : StackDirection.Pos + : vals.some((v) => v > 0) + ? StackDirection.Pos + : StackDirection.Neg; + + let drawStyle = custom.drawStyle as GraphDrawStyle; + let drawStyle2 = + drawStyle === GraphDrawStyle.Bars + ? (custom.barAlignment as BarAlignment) + : drawStyle === GraphDrawStyle.Line + ? (custom.lineInterpolation as LineInterpolation) + : null; + + let stackKey = `${stackDir}|${stackingMode}|${stackingGroup}|${buildScaleKey(config)}|${drawStyle}|${drawStyle2}`; + + let group = groups.get(stackKey); + + if (group == null) { + group = { + series: [], + dir: stackDir, + }; + + groups.set(stackKey, group); + } + + group.series.push(i); + }); + + return [...groups.values()]; +} + +/** @internal */ +export function preparePlotData2( + frame: DataFrame, + stackingGroups: StackingGroup[], + onStackMeta?: (meta: StackMeta) => void +) { + let data = Array(frame.fields.length) as AlignedData; + + let dataLen = frame.length; + let zeroArr = Array(dataLen).fill(0); + let accums = Array.from({ length: stackingGroups.length }, () => zeroArr.slice()); + + frame.fields.forEach((field, i) => { + let vals = field.values.toArray(); + + if (i === 0) { + if (field.type === FieldType.time) { + data[i] = ensureTimeField(field).values.toArray(); + } else { + data[i] = vals; + } + return; + } + + let { custom } = field.config; + + if (!custom || custom.hideFrom?.viz) { + data[i] = vals; + return; + } + + // apply transforms + if (custom.transform === GraphTransform.Constant) { + vals = Array(vals.length).fill(vals[0]); + } else { + vals = vals.slice(); + + if (custom.transform === GraphTransform.NegativeY) { + for (let i = 0; i < vals.length; i++) { + if (vals[i] != null) { + vals[i] *= -1; + } + } + } + } + + let stackingMode = custom.stacking?.mode; + + if (!stackingMode || stackingMode === StackingMode.None) { + data[i] = vals; + } else { + let stackIdx = stackingGroups.findIndex((group) => group.series.indexOf(i) > -1); + + let accum = accums[stackIdx]; + let stacked = (data[i] = Array(dataLen)); + + for (let i = 0; i < dataLen; i++) { + let v = vals[i]; + + if (v != null) { + stacked[i] = accum[i] += v; + } else { + stacked[i] = v; // we may want to coerce to 0 here + } + } + } + }); + + if (onStackMeta) { + let accumsBySeriesIdx = data.map((vals, i) => { + let stackIdx = stackingGroups.findIndex((group) => group.series.indexOf(i) > -1); + return stackIdx !== -1 ? accums[stackIdx] : vals; + }); + + onStackMeta({ + totals: accumsBySeriesIdx as AlignedData, + }); } + + // re-compute by percent + frame.fields.forEach((field, i) => { + if (i === 0 || field.config.custom?.hideFrom?.viz) { + return; + } + + let stackingMode = field.config.custom?.stacking?.mode; + + if (stackingMode === StackingMode.Percent) { + let stackIdx = stackingGroups.findIndex((group) => group.series.indexOf(i) > -1); + let accum = accums[stackIdx]; + let group = stackingGroups[stackIdx]; + + let stacked = data[i]; + + for (let i = 0; i < dataLen; i++) { + let v = stacked[i]; + + if (v != null) { + // v / accum will always be pos, so properly (re)sign by group stacking dir + stacked[i] = group.dir * (v / accum[i]); + } + } + } + }); + + return data; } /** @@ -215,23 +324,3 @@ export const pluginLogger = createLogger('uPlot'); export const pluginLog = pluginLogger.logger; // pluginLogger.enable(); attachDebugger('graphng', undefined, pluginLogger); - -type OrderIdsByCalcsOptions = { - legend?: VizLegendOptions; - ids: number[]; - frame: DataFrame; -}; -export function orderIdsByCalcs({ legend, ids, frame }: OrderIdsByCalcsOptions) { - if (!legend?.sortBy || legend.sortDesc == null) { - return ids; - } - const orderedIds = orderBy( - ids, - (id) => { - return frame.fields[id].state?.calcs?.[legend.sortBy!.toLowerCase()]; - }, - legend.sortDesc ? 'desc' : 'asc' - ); - - return orderedIds; -} diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts index de315cb978e..870cf8b2d82 100644 --- a/packages/grafana-ui/src/types/icon.ts +++ b/packages/grafana-ui/src/types/icon.ts @@ -69,6 +69,7 @@ export const getAvailableIcons = () => 'envelope', 'exchange-alt', 'exclamation-triangle', + 'exclamation-circle', 'external-link-alt', 'eye', 'eye-slash', diff --git a/packages/grafana-ui/src/utils/standardEditors.tsx b/packages/grafana-ui/src/utils/standardEditors.tsx index 8ddbb3cf528..065c1812113 100644 --- a/packages/grafana-ui/src/utils/standardEditors.tsx +++ b/packages/grafana-ui/src/utils/standardEditors.tsx @@ -134,7 +134,7 @@ export const getStandardFieldConfigs = () => { const noValue: FieldConfigPropertyItem = { id: 'noValue', path: 'noValue', - name: 'No Value', + name: 'No value', description: 'What to show when there is no value', editor: standardEditorsRegistry.get('text').editor as any, diff --git a/packages/grafana-ui/tsconfig.json b/packages/grafana-ui/tsconfig.json index f8ba5270586..d89cdc42449 100644 --- a/packages/grafana-ui/tsconfig.json +++ b/packages/grafana-ui/tsconfig.json @@ -3,9 +3,7 @@ "baseUrl": "./", "declarationDir": "dist", "outDir": "compiled", - "rootDirs": [".", "stories"], - "incremental": true, - "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + "rootDirs": [".", "stories"] }, "exclude": ["dist", "node_modules"], "extends": "@grafana/tsconfig", diff --git a/packages/jaeger-ui-components/package.json b/packages/jaeger-ui-components/package.json index b69f93aad83..1ede766e773 100644 --- a/packages/jaeger-ui-components/package.json +++ b/packages/jaeger-ui-components/package.json @@ -39,7 +39,7 @@ "lodash": "4.17.21", "lru-memoize": "^1.1.0", "memoize-one": "6.0.0", - "moment": "2.29.1", + "moment": "2.29.2", "moment-timezone": "0.5.34", "prop-types": "15.8.1", "react": "17.0.2", diff --git a/pkg/api/accesscontrol.go b/pkg/api/accesscontrol.go index 6f08cfd6382..e1c3eed6acd 100644 --- a/pkg/api/accesscontrol.go +++ b/pkg/api/accesscontrol.go @@ -5,6 +5,7 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/setting" ) @@ -149,6 +150,23 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{string(models.ROLE_VIEWER)}, } + apikeyReaderRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ + Version: 1, + Name: "fixed:apikeys:reader", + DisplayName: "APIKeys reader", + Description: "Gives access to read api keys.", + Group: "API Keys", + Permissions: []ac.Permission{ + { + Action: ac.ActionAPIKeyRead, + Scope: ac.ScopeAPIKeysAll, + }, + }, + }, + Grants: []string{string(models.ROLE_ADMIN)}, + } + apikeyWriterRole := ac.RoleRegistration{ Role: ac.RoleDTO{ Version: 1, @@ -410,7 +428,7 @@ func (hs *HTTPServer) declareFixedRoles() error { orgMaintainerRole, teamsCreatorRole, teamsWriterRole, datasourcesExplorerRole, annotationsReaderRole, dashboardAnnotationsWriterRole, annotationsWriterRole, dashboardsCreatorRole, dashboardsReaderRole, dashboardsWriterRole, - foldersCreatorRole, foldersReaderRole, foldersWriterRole, apikeyWriterRole, + foldersCreatorRole, foldersReaderRole, foldersWriterRole, apikeyReaderRole, apikeyWriterRole, ) } @@ -462,6 +480,12 @@ var teamsEditAccessEvaluator = ac.EvalAll( ), ) +// apiKeyAccessEvaluator is used to protect the "Configuration > API keys" page access +var apiKeyAccessEvaluator = ac.EvalPermission(ac.ActionAPIKeyRead) + +// serviceAccountAccessEvaluator is used to protect the "Configuration > Service accounts" page access +var serviceAccountAccessEvaluator = ac.EvalPermission(serviceaccounts.ActionRead) + // Metadata helpers // getAccessControlMetadata returns the accesscontrol metadata associated with a given resource func (hs *HTTPServer) getAccessControlMetadata(c *models.ReqContext, diff --git a/pkg/api/admin.go b/pkg/api/admin.go index 453db021900..2ebe625d398 100644 --- a/pkg/api/admin.go +++ b/pkg/api/admin.go @@ -25,7 +25,7 @@ func (hs *HTTPServer) AdminGetStats(c *models.ReqContext) response.Response { return response.Error(500, "Failed to get admin stats from database", err) } - return response.JSON(200, statsQuery.Result) + return response.JSON(http.StatusOK, statsQuery.Result) } func (hs *HTTPServer) getAuthorizedSettings(ctx context.Context, user *models.SignedInUser, bag setting.SettingsBag) (setting.SettingsBag, error) { diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index c9fa2407533..78393beb864 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -58,7 +58,7 @@ func (hs *HTTPServer) AdminCreateUser(c *models.ReqContext) response.Response { Id: user.Id, } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } func (hs *HTTPServer) AdminUpdateUserPassword(c *models.ReqContext) response.Response { diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index b2d331eb213..1ab6c368b38 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -1,14 +1,12 @@ package api import ( - "context" "fmt" "testing" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" @@ -49,11 +47,6 @@ func TestAdminAPIEndpoint(t *testing.T) { mock := mockstore.NewSQLStoreMock() adminLogoutUserScenario(t, "Should not be allowed when calling POST on", "/api/admin/users/1/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) { - bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error { - cmd.Result = &models.User{Id: testUserID} - return nil - }) - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 400, sc.resp.Code) }, mock) @@ -182,8 +175,6 @@ func TestAdminAPIEndpoint(t *testing.T) { } adminCreateUserScenario(t, "Should create the user", "/api/admin/users", "/api/admin/users", createCmd, func(sc *scenarioContext) { - bus.ClearBusHandlers() - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) @@ -202,8 +193,6 @@ func TestAdminAPIEndpoint(t *testing.T) { } adminCreateUserScenario(t, "Should create the user", "/api/admin/users", "/api/admin/users", createCmd, func(sc *scenarioContext) { - bus.ClearBusHandlers() - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) @@ -222,8 +211,6 @@ func TestAdminAPIEndpoint(t *testing.T) { } adminCreateUserScenario(t, "Should create the user", "/api/admin/users", "/api/admin/users", createCmd, func(sc *scenarioContext) { - bus.ClearBusHandlers() - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 400, sc.resp.Code) @@ -241,8 +228,6 @@ func TestAdminAPIEndpoint(t *testing.T) { } adminCreateUserScenario(t, "Should return an error", "/api/admin/users", "/api/admin/users", createCmd, func(sc *scenarioContext) { - bus.ClearBusHandlers() - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 412, sc.resp.Code) @@ -256,8 +241,6 @@ func TestAdminAPIEndpoint(t *testing.T) { func putAdminScenario(t *testing.T, desc string, url string, routePattern string, role models.RoleType, cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - hs := &HTTPServer{ Cfg: setting.NewCfg(), SQLStore: sqlStore, @@ -284,10 +267,7 @@ func putAdminScenario(t *testing.T, desc string, url string, routePattern string func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - hs := HTTPServer{ - Bus: bus.GetBus(), AuthTokenService: auth.NewFakeUserAuthTokenService(), SQLStore: sqlStore, } @@ -312,12 +292,9 @@ func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd models.RevokeAuthTokenCmd, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() hs := HTTPServer{ - Bus: bus.GetBus(), AuthTokenService: fakeAuthTokenService, SQLStore: sqlStore, } @@ -343,12 +320,9 @@ func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, rou func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() hs := HTTPServer{ - Bus: bus.GetBus(), AuthTokenService: fakeAuthTokenService, SQLStore: sqlStore, } @@ -372,14 +346,11 @@ func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, route func adminDisableUserScenario(t *testing.T, desc string, action string, url string, routePattern string, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() authInfoService := &logintest.AuthInfoServiceFake{} hs := HTTPServer{ - Bus: bus.GetBus(), SQLStore: mockstore.NewSQLStoreMock(), AuthTokenService: fakeAuthTokenService, authInfoService: authInfoService, @@ -410,8 +381,6 @@ func adminDeleteUserScenario(t *testing.T, desc string, url string, routePattern SQLStore: mockstore.NewSQLStoreMock(), } t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - sc := setupScenarioContext(t, url) sc.sqlStore = hs.SQLStore sc.authInfoService = &logintest.AuthInfoServiceFake{} @@ -430,10 +399,7 @@ func adminDeleteUserScenario(t *testing.T, desc string, url string, routePattern func adminCreateUserScenario(t *testing.T, desc string, url string, routePattern string, cmd dtos.AdminCreateUserForm, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - hs := HTTPServer{ - Bus: bus.GetBus(), Login: loginservice.LoginServiceMock{ ExpectedUserForm: cmd, NoExistingOrgId: nonExistingOrgID, diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 4b58ace4bce..4fa1589a9c7 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/guardian" @@ -55,7 +54,7 @@ func (hs *HTTPServer) GetAlertStatesForDashboard(c *models.ReqContext) response. return response.Error(500, "Failed to fetch alert states", err) } - return response.JSON(200, query.Result) + return response.JSON(http.StatusOK, query.Result) } // GET /api/alerts @@ -107,7 +106,7 @@ func (hs *HTTPServer) GetAlerts(c *models.ReqContext) response.Response { // if we didn't find any dashboards, return empty result if len(dashboardIDs) == 0 { - return response.JSON(200, []*models.AlertListItemDTO{}) + return response.JSON(http.StatusOK, []*models.AlertListItemDTO{}) } } @@ -133,7 +132,7 @@ func (hs *HTTPServer) GetAlerts(c *models.ReqContext) response.Response { alert.Url = models.GetDashboardUrl(alert.DashboardUid, alert.DashboardSlug) } - return response.JSON(200, query.Result) + return response.JSON(http.StatusOK, query.Result) } // POST /api/alerts/test @@ -177,7 +176,7 @@ func (hs *HTTPServer) AlertTest(c *models.ReqContext) response.Response { dtoRes.TimeMs = fmt.Sprintf("%1.3fms", res.GetDurationMs()) - return response.JSON(200, dtoRes) + return response.JSON(http.StatusOK, dtoRes) } // GET /api/alerts/:id @@ -192,17 +191,17 @@ func (hs *HTTPServer) GetAlert(c *models.ReqContext) response.Response { return response.Error(500, "List alerts failed", err) } - return response.JSON(200, &query.Result) + return response.JSON(http.StatusOK, &query.Result) } func (hs *HTTPServer) GetAlertNotifiers(ngalertEnabled bool) func(*models.ReqContext) response.Response { return func(_ *models.ReqContext) response.Response { if ngalertEnabled { - return response.JSON(200, notifier.GetAvailableNotifiers()) + return response.JSON(http.StatusOK, notifier.GetAvailableNotifiers()) } // TODO(codesome): This wont be required in 8.0 since ngalert // will be enabled by default with no disabling. This is to be removed later. - return response.JSON(200, alerting.GetNotifiers()) + return response.JSON(http.StatusOK, alerting.GetNotifiers()) } } @@ -218,7 +217,7 @@ func (hs *HTTPServer) GetAlertNotificationLookup(c *models.ReqContext) response. result = append(result, dtos.NewAlertNotificationLookup(notification)) } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } func (hs *HTTPServer) GetAlertNotifications(c *models.ReqContext) response.Response { @@ -233,7 +232,7 @@ func (hs *HTTPServer) GetAlertNotifications(c *models.ReqContext) response.Respo result = append(result, dtos.NewAlertNotification(notification)) } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } func (hs *HTTPServer) getAlertNotificationsInternal(c *models.ReqContext) ([]*models.AlertNotification, error) { @@ -268,7 +267,7 @@ func (hs *HTTPServer) GetAlertNotificationByID(c *models.ReqContext) response.Re return response.Error(404, "Alert notification not found", nil) } - return response.JSON(200, dtos.NewAlertNotification(query.Result)) + return response.JSON(http.StatusOK, dtos.NewAlertNotification(query.Result)) } func (hs *HTTPServer) GetAlertNotificationByUID(c *models.ReqContext) response.Response { @@ -289,7 +288,7 @@ func (hs *HTTPServer) GetAlertNotificationByUID(c *models.ReqContext) response.R return response.Error(404, "Alert notification not found", nil) } - return response.JSON(200, dtos.NewAlertNotification(query.Result)) + return response.JSON(http.StatusOK, dtos.NewAlertNotification(query.Result)) } func (hs *HTTPServer) CreateAlertNotification(c *models.ReqContext) response.Response { @@ -310,7 +309,7 @@ func (hs *HTTPServer) CreateAlertNotification(c *models.ReqContext) response.Res return response.Error(500, "Failed to create alert notification", err) } - return response.JSON(200, dtos.NewAlertNotification(cmd.Result)) + return response.JSON(http.StatusOK, dtos.NewAlertNotification(cmd.Result)) } func (hs *HTTPServer) UpdateAlertNotification(c *models.ReqContext) response.Response { @@ -345,7 +344,7 @@ func (hs *HTTPServer) UpdateAlertNotification(c *models.ReqContext) response.Res return response.Error(500, "Failed to get alert notification", err) } - return response.JSON(200, dtos.NewAlertNotification(query.Result)) + return response.JSON(http.StatusOK, dtos.NewAlertNotification(query.Result)) } func (hs *HTTPServer) UpdateAlertNotificationByUID(c *models.ReqContext) response.Response { @@ -377,7 +376,7 @@ func (hs *HTTPServer) UpdateAlertNotificationByUID(c *models.ReqContext) respons return response.Error(500, "Failed to get alert notification", err) } - return response.JSON(200, dtos.NewAlertNotification(query.Result)) + return response.JSON(http.StatusOK, dtos.NewAlertNotification(query.Result)) } func (hs *HTTPServer) fillWithSecureSettingsData(ctx context.Context, cmd *models.UpdateAlertNotificationCommand) error { @@ -470,7 +469,7 @@ func (hs *HTTPServer) DeleteAlertNotificationByUID(c *models.ReqContext) respons return response.Error(500, "Failed to delete alert notification", err) } - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": "Notification deleted", "id": cmd.DeletedAlertNotificationId, }) @@ -491,7 +490,7 @@ func (hs *HTTPServer) NotificationTest(c *models.ReqContext) response.Response { SecureSettings: dto.SecureSettings, } - if err := bus.Dispatch(c.Req.Context(), cmd); err != nil { + if err := hs.AlertNotificationService.HandleNotificationTestCommand(c.Req.Context(), cmd); err != nil { if errors.Is(err, models.ErrSmtpNotEnabled) { return response.Error(412, err.Error(), err) } @@ -537,11 +536,11 @@ func (hs *HTTPServer) PauseAlert(c *models.ReqContext) response.Response { if query.Result.State != models.AlertStatePaused && !dto.Paused { result["state"] = "un-paused" result["message"] = "Alert is already un-paused" - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } else if query.Result.State == models.AlertStatePaused && dto.Paused { result["state"] = models.AlertStatePaused result["message"] = "Alert is already paused" - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } cmd := models.PauseAlertCommand{ @@ -554,7 +553,7 @@ func (hs *HTTPServer) PauseAlert(c *models.ReqContext) response.Response { return response.Error(500, "", err) } - var resp models.AlertStateType = models.AlertStateUnknown + resp := models.AlertStateUnknown pausedState := "un-paused" if cmd.Paused { resp = models.AlertStatePaused @@ -563,7 +562,7 @@ func (hs *HTTPServer) PauseAlert(c *models.ReqContext) response.Response { result["state"] = resp result["message"] = "Alert " + pausedState - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } // POST /api/admin/pause-all-alerts @@ -580,7 +579,7 @@ func (hs *HTTPServer) PauseAllAlerts(c *models.ReqContext) response.Response { return response.Error(500, "Failed to pause alerts", err) } - var resp models.AlertStateType = models.AlertStatePending + resp := models.AlertStatePending pausedState := "un paused" if updateCmd.Paused { resp = models.AlertStatePaused @@ -593,5 +592,5 @@ func (hs *HTTPServer) PauseAllAlerts(c *models.ReqContext) response.Response { "alertsAffected": updateCmd.ResultCount, } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } diff --git a/pkg/api/alerting_test.go b/pkg/api/alerting_test.go index 1f09ca06a19..12deadcc9d5 100644 --- a/pkg/api/alerting_test.go +++ b/pkg/api/alerting_test.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/search" @@ -124,18 +123,12 @@ func TestAlertingAPIEndpoint(t *testing.T) { } func callPauseAlert(sc *scenarioContext) { - bus.AddHandler("test", func(ctx context.Context, cmd *models.PauseAlertCommand) error { - return nil - }) - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() } func postAlertScenario(t *testing.T, hs *HTTPServer, desc string, url string, routePattern string, role models.RoleType, cmd dtos.PauseAlertCommand, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - defer bus.ClearBusHandlers() - sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 5221332c741..195c931fe76 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -20,17 +20,18 @@ import ( func (hs *HTTPServer) GetAnnotations(c *models.ReqContext) response.Response { query := &annotations.ItemQuery{ - From: c.QueryInt64("from"), - To: c.QueryInt64("to"), - OrgId: c.OrgId, - UserId: c.QueryInt64("userId"), - AlertId: c.QueryInt64("alertId"), - DashboardId: c.QueryInt64("dashboardId"), - PanelId: c.QueryInt64("panelId"), - Limit: c.QueryInt64("limit"), - Tags: c.QueryStrings("tags"), - Type: c.Query("type"), - MatchAny: c.QueryBool("matchAny"), + From: c.QueryInt64("from"), + To: c.QueryInt64("to"), + OrgId: c.OrgId, + UserId: c.QueryInt64("userId"), + AlertId: c.QueryInt64("alertId"), + DashboardId: c.QueryInt64("dashboardId"), + PanelId: c.QueryInt64("panelId"), + Limit: c.QueryInt64("limit"), + Tags: c.QueryStrings("tags"), + Type: c.Query("type"), + MatchAny: c.QueryBool("matchAny"), + SignedInUser: c.SignedInUser, } repo := annotations.GetRepository() @@ -46,7 +47,7 @@ func (hs *HTTPServer) GetAnnotations(c *models.ReqContext) response.Response { } } - return response.JSON(200, items) + return response.JSON(http.StatusOK, items) } type AnnotationError struct { @@ -110,7 +111,7 @@ func (hs *HTTPServer) PostAnnotation(c *models.ReqContext) response.Response { startID := item.Id - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": "Annotation added", "id": startID, }) @@ -173,7 +174,7 @@ func (hs *HTTPServer) PostGraphiteAnnotation(c *models.ReqContext) response.Resp return response.Error(500, "Failed to save Graphite annotation", err) } - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": "Graphite annotation added", "id": item.Id, }) @@ -192,7 +193,7 @@ func (hs *HTTPServer) UpdateAnnotation(c *models.ReqContext) response.Response { repo := annotations.GetRepository() - annotation, resp := findAnnotationByID(c.Req.Context(), repo, annotationID, c.OrgId) + annotation, resp := findAnnotationByID(c.Req.Context(), repo, annotationID, c.SignedInUser) if resp != nil { return resp } @@ -239,7 +240,7 @@ func (hs *HTTPServer) PatchAnnotation(c *models.ReqContext) response.Response { repo := annotations.GetRepository() - annotation, resp := findAnnotationByID(c.Req.Context(), repo, annotationID, c.OrgId) + annotation, resp := findAnnotationByID(c.Req.Context(), repo, annotationID, c.SignedInUser) if resp != nil { return resp } @@ -310,7 +311,7 @@ func (hs *HTTPServer) MassDeleteAnnotations(c *models.ReqContext) response.Respo var dashboardId int64 if cmd.AnnotationId != 0 { - annotation, respErr := findAnnotationByID(c.Req.Context(), repo, cmd.AnnotationId, c.OrgId) + annotation, respErr := findAnnotationByID(c.Req.Context(), repo, cmd.AnnotationId, c.SignedInUser) if respErr != nil { return respErr } @@ -358,7 +359,7 @@ func (hs *HTTPServer) DeleteAnnotationByID(c *models.ReqContext) response.Respon repo := annotations.GetRepository() - annotation, resp := findAnnotationByID(c.Req.Context(), repo, annotationID, c.OrgId) + annotation, resp := findAnnotationByID(c.Req.Context(), repo, annotationID, c.SignedInUser) if resp != nil { return resp } @@ -400,8 +401,13 @@ func canSaveOrganizationAnnotation(c *models.ReqContext) bool { return c.SignedInUser.HasRole(models.ROLE_EDITOR) } -func findAnnotationByID(ctx context.Context, repo annotations.Repository, annotationID int64, orgID int64) (*annotations.ItemDTO, response.Response) { - items, err := repo.Find(ctx, &annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgID}) +func findAnnotationByID(ctx context.Context, repo annotations.Repository, annotationID int64, user *models.SignedInUser) (*annotations.ItemDTO, response.Response) { + query := &annotations.ItemQuery{ + AnnotationId: annotationID, + OrgId: user.OrgId, + SignedInUser: user, + } + items, err := repo.Find(ctx, query) if err != nil { return nil, response.Error(500, "Failed to find annotation", err) @@ -427,7 +433,7 @@ func (hs *HTTPServer) GetAnnotationTags(c *models.ReqContext) response.Response return response.Error(500, "Failed to find annotation tags", err) } - return response.JSON(200, annotations.GetAnnotationTagsResponse{Result: result}) + return response.JSON(http.StatusOK, annotations.GetAnnotationTagsResponse{Result: result}) } // AnnotationTypeScopeResolver provides an AttributeScopeResolver able to @@ -446,9 +452,21 @@ func AnnotationTypeScopeResolver() (string, accesscontrol.AttributeScopeResolveF return "", accesscontrol.ErrInvalidScope } - annotation, resp := findAnnotationByID(ctx, annotations.GetRepository(), int64(annotationId), orgID) + // tempUser is used to resolve annotation type. + // The annotation doesn't get returned to the real user, so real user's permissions don't matter here. + tempUser := &models.SignedInUser{ + OrgId: orgID, + Permissions: map[int64]map[string][]string{ + orgID: { + accesscontrol.ActionDashboardsRead: {accesscontrol.ScopeDashboardsAll}, + accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsAll}, + }, + }, + } + + annotation, resp := findAnnotationByID(ctx, annotations.GetRepository(), int64(annotationId), tempUser) if resp != nil { - return "", err + return "", errors.New("could not resolve annotation type") } if annotation.GetType() == annotations.Organization { diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index 30b0af8b746..300a751454a 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -13,7 +13,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" @@ -293,8 +292,6 @@ var fakeAnnoRepo *fakeAnnotationsRepo func postAnnotationScenario(t *testing.T, desc string, url string, routePattern string, role models.RoleType, cmd dtos.PostAnnotationsCmd, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - hs := setupSimpleHTTPServer(nil) store := sqlstore.InitTestDB(t) store.Cfg = hs.Cfg @@ -324,8 +321,6 @@ func postAnnotationScenario(t *testing.T, desc string, url string, routePattern func putAnnotationScenario(t *testing.T, desc string, url string, routePattern string, role models.RoleType, cmd dtos.UpdateAnnotationsCmd, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - hs := setupSimpleHTTPServer(nil) store := sqlstore.InitTestDB(t) store.Cfg = hs.Cfg @@ -354,8 +349,6 @@ func putAnnotationScenario(t *testing.T, desc string, url string, routePattern s func patchAnnotationScenario(t *testing.T, desc string, url string, routePattern string, role models.RoleType, cmd dtos.PatchAnnotationsCmd, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - defer bus.ClearBusHandlers() - hs := setupSimpleHTTPServer(nil) store := sqlstore.InitTestDB(t) store.Cfg = hs.Cfg @@ -385,8 +378,6 @@ func patchAnnotationScenario(t *testing.T, desc string, url string, routePattern func deleteAnnotationsScenario(t *testing.T, desc string, url string, routePattern string, role models.RoleType, cmd dtos.MassDeleteAnnotationsCmd, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - defer bus.ClearBusHandlers() - hs := setupSimpleHTTPServer(nil) store := sqlstore.InitTestDB(t) store.Cfg = hs.Cfg diff --git a/pkg/api/api.go b/pkg/api/api.go index 12c7dbd4a8c..a7de6111fab 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/web" ) @@ -62,9 +63,9 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/org/teams", authorize(reqCanAccessTeams, ac.EvalPermission(ac.ActionTeamsRead)), hs.Index) r.Get("/org/teams/edit/*", authorize(reqCanAccessTeams, teamsEditAccessEvaluator), hs.Index) r.Get("/org/teams/new", authorize(reqCanAccessTeams, ac.EvalPermission(ac.ActionTeamsCreate)), hs.Index) - r.Get("/org/serviceaccounts", middleware.ReqOrgAdmin, hs.Index) - r.Get("/org/serviceaccounts/:serviceAccountId", middleware.ReqOrgAdmin, hs.Index) - r.Get("/org/apikeys/", reqOrgAdmin, hs.Index) + r.Get("/org/serviceaccounts", authorize(reqOrgAdmin, ac.EvalPermission(serviceaccounts.ActionRead)), hs.Index) + r.Get("/org/serviceaccounts/:serviceAccountId", authorize(reqOrgAdmin, ac.EvalPermission(serviceaccounts.ActionRead)), hs.Index) + r.Get("/org/apikeys/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyRead)), hs.Index) r.Get("/dashboard/import/", reqSignedIn, hs.Index) r.Get("/configuration", reqGrafanaAdmin, hs.Index) r.Get("/admin", reqGrafanaAdmin, hs.Index) @@ -279,7 +280,7 @@ func (hs *HTTPServer) registerRoutes() { keysRoute.Get("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyRead, ac.ScopeAPIKeysAll)), routing.Wrap(hs.GetAPIKeys)) keysRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyCreate)), quota("api_key"), routing.Wrap(hs.AddAPIKey)) keysRoute.Delete("/:id", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyDelete, apikeyIDScope)), routing.Wrap(hs.DeleteAPIKey)) - }, reqOrgAdmin) + }) // Preferences apiRoute.Group("/preferences", func(prefRoute routing.RouteRegister) { @@ -322,7 +323,9 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings) apiRoute.Any("/datasources/proxy/:id/*", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequest) + apiRoute.Any("/datasources/proxy/uid/:uid/*", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequestWithUID) apiRoute.Any("/datasources/proxy/:id", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequest) + apiRoute.Any("/datasources/proxy/uid/:uid", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequestWithUID) apiRoute.Any("/datasources/:id/resources", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), hs.CallDatasourceResource) apiRoute.Any("/datasources/:id/resources/*", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), hs.CallDatasourceResource) apiRoute.Any("/datasources/:id/health", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), routing.Wrap(hs.CheckDatasourceHealth)) @@ -446,7 +449,7 @@ func (hs *HTTPServer) registerRoutes() { orgRoute.Get("/lookup", routing.Wrap(hs.GetAlertNotificationLookup)) }) - apiRoute.Get("/annotations", authorize(reqSignedIn, ac.EvalPermission(ac.ActionAnnotationsRead, ac.ScopeAnnotationsAll)), routing.Wrap(hs.GetAnnotations)) + apiRoute.Get("/annotations", authorize(reqSignedIn, ac.EvalPermission(ac.ActionAnnotationsRead)), routing.Wrap(hs.GetAnnotations)) apiRoute.Post("/annotations/mass-delete", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAnnotationsDelete)), routing.Wrap(hs.MassDeleteAnnotations)) apiRoute.Group("/annotations", func(annotationsRoute routing.RouteRegister) { diff --git a/pkg/api/apikey.go b/pkg/api/apikey.go index a282ee2f257..9d1aab24bf3 100644 --- a/pkg/api/apikey.go +++ b/pkg/api/apikey.go @@ -36,7 +36,7 @@ func (hs *HTTPServer) GetAPIKeys(c *models.ReqContext) response.Response { } } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } // DeleteAPIKey deletes an API key @@ -70,6 +70,9 @@ func (hs *HTTPServer) AddAPIKey(c *models.ReqContext) response.Response { if !cmd.Role.IsValid() { return response.Error(400, "Invalid role specified", nil) } + if !c.OrgRole.Includes(cmd.Role) { + return response.Error(http.StatusForbidden, "Cannot assign a role higher than user's role", nil) + } if hs.Cfg.ApiKeyMaxSecondsToLive != -1 { if cmd.SecondsToLive == 0 { @@ -104,5 +107,5 @@ func (hs *HTTPServer) AddAPIKey(c *models.ReqContext) response.Response { Key: newKeyInfo.ClientSecret, } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go index 49b95f0e93f..c855edde892 100644 --- a/pkg/api/app_routes.go +++ b/pkg/api/app_routes.go @@ -27,10 +27,10 @@ func (hs *HTTPServer) initAppPluginRoutes(r *web.Mux) { Renegotiation: tls.RenegotiateFreelyAsClient, }, Proxy: http.ProxyFromEnvironment, - Dial: (&net.Dialer{ + DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, - }).Dial, + }).DialContext, TLSHandshakeTimeout: 10 * time.Second, } @@ -65,6 +65,7 @@ func AppPluginRoute(route *plugins.Route, appID string, hs *HTTPServer) web.Hand proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID, hs.Cfg, hs.PluginSettings, hs.SecretsService) proxy.Transport = pluginProxyTransport + proxy.ServeHTTP(c.Resp, c.Req) } } diff --git a/pkg/api/comments.go b/pkg/api/comments.go index d84d4a8cd8f..7f32d64ac08 100644 --- a/pkg/api/comments.go +++ b/pkg/api/comments.go @@ -23,7 +23,7 @@ func (hs *HTTPServer) commentsGet(c *models.ReqContext) response.Response { } return response.Error(http.StatusInternalServerError, "internal error", err) } - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "comments": items, }) } @@ -43,7 +43,7 @@ func (hs *HTTPServer) commentsCreate(c *models.ReqContext) response.Response { } return response.Error(http.StatusInternalServerError, "internal error", err) } - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "comment": comment, }) } diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index b256c1454d7..18986610b82 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -13,7 +13,6 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/fs" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/remotecache" @@ -53,8 +52,6 @@ func loggedInUserScenario(t *testing.T, desc string, url string, routePattern st func loggedInUserScenarioWithRole(t *testing.T, desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - sc := setupScenarioContext(t, url) sc.sqlStore = sqlStore sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { @@ -82,8 +79,6 @@ func loggedInUserScenarioWithRole(t *testing.T, desc string, method string, url func anonymousUserScenario(t *testing.T, desc string, method string, url string, routePattern string, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - defer bus.ClearBusHandlers() - sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { sc.context = c @@ -197,7 +192,9 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa tracer, err := tracing.InitializeTracerForTest() require.NoError(t, err) authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginservice.LoginServiceMock{}, sqlStore) - ctxHdlr := contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy) + loginService := &logintest.LoginServiceFake{} + authenticator := &logintest.AuthenticatorFake{} + ctxHdlr := contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, authenticator) return ctxHdlr } @@ -238,7 +235,6 @@ func setupAccessControlScenarioContext(t *testing.T, cfg *setting.Cfg, url strin store := sqlstore.InitTestDB(t) hs := &HTTPServer{ Cfg: cfg, - Bus: bus.GetBus(), Live: newTestLive(t, store), Features: features, QuotaService: "a.QuotaService{Cfg: cfg}, @@ -329,7 +325,6 @@ func setupSimpleHTTPServer(features *featuremgmt.FeatureManager) *HTTPServer { return &HTTPServer{ Cfg: cfg, Features: features, - Bus: bus.GetBus(), AccessControl: accesscontrolmock.New().WithDisabled(), } } @@ -380,7 +375,6 @@ func setupHTTPServerWithCfgDb(t *testing.T, useFakeAccessControl, enableAccessCo hs := &HTTPServer{ Cfg: cfg, Features: features, - Bus: bus.GetBus(), Live: newTestLive(t, db), QuotaService: "a.QuotaService{Cfg: cfg}, RouteRegister: routeRegister, @@ -407,7 +401,7 @@ func setupHTTPServerWithCfgDb(t *testing.T, useFakeAccessControl, enableAccessCo // Perform role registration err := hs.declareFixedRoles() require.NoError(t, err) - err = ac.RegisterFixedRoles() + err = ac.RegisterFixedRoles(context.Background()) require.NoError(t, err) teamPermissionService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, routeRegister, db, ac, database.ProvideService(db)) require.NoError(t, err) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index cb71e7eec9a..791a30172ce 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -72,7 +72,7 @@ func (hs *HTTPServer) TrimDashboard(c *models.ReqContext) response.Response { } c.TimeRequest(metrics.MApiDashboardGet) - return response.JSON(200, dto) + return response.JSON(http.StatusOK, dto) } func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { @@ -196,7 +196,7 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { } c.TimeRequest(metrics.MApiDashboardGet) - return response.JSON(200, dto) + return response.JSON(http.StatusOK, dto) } func (hs *HTTPServer) getAnnotationPermissionsByScope(c *models.ReqContext, actions *dtos.AnnotationActions, scope string) { @@ -282,7 +282,7 @@ func (hs *HTTPServer) deleteDashboard(c *models.ReqContext) response.Response { hs.log.Error("Failed to broadcast delete info", "dashboard", dash.Uid, "error", err) } } - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "title": dash.Title, "message": fmt.Sprintf("Dashboard %s deleted", dash.Title), "id": dash.Id, @@ -396,7 +396,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa } c.TimeRequest(metrics.MApiDashboardSave) - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "status": "success", "slug": dashboard.Slug, "version": dashboard.Version, @@ -417,7 +417,7 @@ func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) response.Response { if prefsQuery.Result.HomeDashboardId == 0 && len(homePage) > 0 { homePageRedirect := dtos.DashboardRedirect{RedirectUri: homePage} - return response.JSON(200, &homePageRedirect) + return response.JSON(http.StatusOK, &homePageRedirect) } if prefsQuery.Result.HomeDashboardId != 0 { @@ -426,7 +426,7 @@ func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) response.Response { if err == nil { url := models.GetDashboardUrl(slugQuery.Result.Uid, slugQuery.Result.Slug) dashRedirect := dtos.DashboardRedirect{RedirectUri: url} - return response.JSON(200, &dashRedirect) + return response.JSON(http.StatusOK, &dashRedirect) } hs.log.Warn("Failed to get slug from database", "err", err) } @@ -453,15 +453,16 @@ func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) response.Response { dash.Meta.IsHome = true dash.Meta.CanEdit = c.SignedInUser.HasRole(models.ROLE_EDITOR) dash.Meta.FolderTitle = "General" + dash.Dashboard = simplejson.New() jsonParser := json.NewDecoder(file) - if err := jsonParser.Decode(&dash.Dashboard); err != nil { + if err := jsonParser.Decode(dash.Dashboard); err != nil { return response.Error(500, "Failed to load home dashboard", err) } hs.addGettingStartedPanelToHomeDashboard(c, dash.Dashboard) - return response.JSON(200, &dash) + return response.JSON(http.StatusOK, &dash) } func (hs *HTTPServer) addGettingStartedPanelToHomeDashboard(c *models.ReqContext, dash *simplejson.Json) { @@ -529,7 +530,7 @@ func (hs *HTTPServer) GetDashboardVersions(c *models.ReqContext) response.Respon } } - return response.JSON(200, query.Result) + return response.JSON(http.StatusOK, query.Result) } // GetDashboardVersion returns the dashboard version with the given ID. @@ -572,7 +573,7 @@ func (hs *HTTPServer) GetDashboardVersion(c *models.ReqContext) response.Respons CreatedBy: creator, } - return response.JSON(200, dashVersionMeta) + return response.JSON(http.StatusOK, dashVersionMeta) } // POST /api/dashboards/calculate-diff performs diffs on two dashboards @@ -647,10 +648,10 @@ func (hs *HTTPServer) CalculateDashboardDiff(c *models.ReqContext) response.Resp } if options.DiffType == dashdiffs.DiffDelta { - return response.Respond(200, result.Delta).SetHeader("Content-Type", "application/json") + return response.Respond(http.StatusOK, result.Delta).SetHeader("Content-Type", "application/json") } - return response.Respond(200, result.Delta).SetHeader("Content-Type", "text/html") + return response.Respond(http.StatusOK, result.Delta).SetHeader("Content-Type", "text/html") } // RestoreDashboardVersion restores a dashboard to the given version. @@ -702,5 +703,5 @@ func (hs *HTTPServer) GetDashboardTags(c *models.ReqContext) { return } - c.JSON(200, query.Result) + c.JSON(http.StatusOK, query.Result) } diff --git a/pkg/api/dashboard_permission.go b/pkg/api/dashboard_permission.go index 34ea0b797f8..0415fe0262e 100644 --- a/pkg/api/dashboard_permission.go +++ b/pkg/api/dashboard_permission.go @@ -56,7 +56,7 @@ func (hs *HTTPServer) GetDashboardPermissionList(c *models.ReqContext) response. filteredAcls = append(filteredAcls, perm) } - return response.JSON(200, filteredAcls) + return response.JSON(http.StatusOK, filteredAcls) } func (hs *HTTPServer) UpdateDashboardPermissions(c *models.ReqContext) response.Response { diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 1d5c45cc5f7..93fa34cd385 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -24,7 +24,7 @@ var client = &http.Client{ } func GetSharingOptions(c *models.ReqContext) { - c.JSON(200, util.DynMap{ + c.JSON(http.StatusOK, util.DynMap{ "externalSnapshotURL": setting.ExternalSnapshotUrl, "externalSnapshotName": setting.ExternalSnapshotName, "externalEnabled": setting.ExternalEnabled, @@ -138,7 +138,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res return nil } - c.JSON(200, util.DynMap{ + c.JSON(http.StatusOK, util.DynMap{ "key": cmd.Key, "deleteKey": cmd.DeleteKey, "url": url, @@ -181,7 +181,7 @@ func (hs *HTTPServer) GetDashboardSnapshot(c *models.ReqContext) response.Respon metrics.MApiDashboardSnapshotGet.Inc() - return response.JSON(200, dto).SetHeader("Cache-Control", "public, max-age=3600") + return response.JSON(http.StatusOK, dto).SetHeader("Cache-Control", "public, max-age=3600") } func deleteExternalDashboardSnapshot(externalUrl string) error { @@ -242,7 +242,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r return response.Error(500, "Failed to delete dashboard snapshot", err) } - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.", "id": query.Result.Id, }) @@ -290,7 +290,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res return response.Error(500, "Failed to delete dashboard snapshot", err) } - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.", "id": query.Result.Id, }) @@ -333,5 +333,5 @@ func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Re } } - return response.JSON(200, dtos) + return response.JSON(http.StatusOK, dtos) } diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index a6d1bcef985..d34d285c46d 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -1,7 +1,6 @@ package api import ( - "context" "fmt" "net/http" "net/http/httptest" @@ -9,7 +8,6 @@ import ( "testing" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" @@ -52,12 +50,6 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { sqlmock.ExpectedDashboardAclInfoList = aclMockResp sqlmock.ExpectedTeamsByUser = []*models.TeamDTO{} - // we need it here for now for the guadian service to work - bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardAclInfoListQuery) error { - query.Result = aclMockResp - return nil - }) - return mockSnapshotResult } diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 54b77fbb520..0a3735e284a 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -16,7 +16,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/models" @@ -46,7 +45,7 @@ func TestGetHomeDashboard(t *testing.T) { cfg.StaticRootPath = "../../public/" hs := &HTTPServer{ - Cfg: cfg, Bus: bus.New(), + Cfg: cfg, pluginStore: &fakePluginStore{}, SQLStore: mockstore.NewSQLStoreMock(), } @@ -96,7 +95,7 @@ func newTestLive(t *testing.T, store *sqlstore.SQLStore) *live.GrafanaLive { nil, &usagestats.UsageStatsMock{T: t}, nil, - features, nil) + features, accesscontrolmock.New()) require.NoError(t, err) return gLive } @@ -957,31 +956,17 @@ func (hs *HTTPServer) callGetDashboard(sc *scenarioContext) { } func (hs *HTTPServer) callGetDashboardVersion(sc *scenarioContext) { - bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardVersionQuery) error { - query.Result = &models.DashboardVersion{} - return nil - }) - sc.handlerFunc = hs.GetDashboardVersion sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() } func (hs *HTTPServer) callGetDashboardVersions(sc *scenarioContext) { - bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardVersionsQuery) error { - query.Result = []*models.DashboardVersionDTO{} - return nil - }) - sc.handlerFunc = hs.GetDashboardVersions sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() } func (hs *HTTPServer) callDeleteDashboardByUID(t *testing.T, sc *scenarioContext, mockDashboard *dashboards.FakeDashboardService) { - bus.AddHandler("test", func(ctx context.Context, cmd *models.DeleteDashboardCommand) error { - return nil - }) - hs.dashboardService = mockDashboard sc.handlerFunc = hs.DeleteDashboardByUID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() @@ -1003,11 +988,8 @@ func callPostDashboardShouldReturnSuccess(sc *scenarioContext) { func postDashboardScenario(t *testing.T, desc string, url string, routePattern string, cmd models.SaveDashboardCommand, dashboardService dashboards.DashboardService, folderService dashboards.FolderService, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - cfg := setting.NewCfg() hs := HTTPServer{ - Bus: bus.GetBus(), Cfg: cfg, ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), Live: newTestLive(t, sqlstore.InitTestDB(t)), @@ -1040,12 +1022,9 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s func postDiffScenario(t *testing.T, desc string, url string, routePattern string, cmd dtos.CalculateDiffOptions, role models.RoleType, fn scenarioFunc, sqlmock sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - defer bus.ClearBusHandlers() - cfg := setting.NewCfg() hs := HTTPServer{ Cfg: cfg, - Bus: bus.GetBus(), ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), Live: newTestLive(t, sqlstore.InitTestDB(t)), QuotaService: "a.QuotaService{Cfg: cfg}, @@ -1076,13 +1055,10 @@ func postDiffScenario(t *testing.T, desc string, url string, routePattern string func restoreDashboardVersionScenario(t *testing.T, desc string, url string, routePattern string, mock *dashboards.FakeDashboardService, cmd dtos.RestoreDashboardVersionCommand, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - defer bus.ClearBusHandlers() - cfg := setting.NewCfg() mockSQLStore := mockstore.NewSQLStoreMock() hs := HTTPServer{ Cfg: cfg, - Bus: bus.GetBus(), ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), Live: newTestLive(t, sqlstore.InitTestDB(t)), QuotaService: "a.QuotaService{Cfg: cfg}, @@ -1115,8 +1091,8 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout } func (sc *scenarioContext) ToJSON() *simplejson.Json { - var result *simplejson.Json - err := json.NewDecoder(sc.resp.Body).Decode(&result) + result := simplejson.New() + err := json.NewDecoder(sc.resp.Body).Decode(result) require.NoError(sc.t, err) return result } diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go index 5a39872d20e..af006072019 100644 --- a/pkg/api/dataproxy.go +++ b/pkg/api/dataproxy.go @@ -5,3 +5,7 @@ import "github.com/grafana/grafana/pkg/models" func (hs *HTTPServer) ProxyDataSourceRequest(c *models.ReqContext) { hs.DataProxy.ProxyDataSourceRequest(c) } + +func (hs *HTTPServer) ProxyDataSourceRequestWithUID(c *models.ReqContext) { + hs.DataProxy.ProxyDatasourceRequestWithUID(c) +} diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 71e18314d72..61a25517107 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -68,7 +68,7 @@ func (hs *HTTPServer) GetDataSources(c *models.ReqContext) response.Response { sort.Sort(result) - return response.JSON(200, &result) + return response.JSON(http.StatusOK, &result) } // GET /api/datasources/:id @@ -102,7 +102,7 @@ func (hs *HTTPServer) GetDataSourceById(c *models.ReqContext) response.Response // Add accesscontrol metadata dto.AccessControl = hs.getAccessControlMetadata(c, c.OrgId, datasources.ScopePrefix, dto.UID) - return response.JSON(200, &dto) + return response.JSON(http.StatusOK, &dto) } // DELETE /api/datasources/:id @@ -161,7 +161,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *models.ReqContext) response.Response // Add accesscontrol metadata dto.AccessControl = hs.getAccessControlMetadata(c, c.OrgId, datasources.ScopePrefix, dto.UID) - return response.JSON(200, &dto) + return response.JSON(http.StatusOK, &dto) } // DELETE /api/datasources/uid/:uid @@ -193,7 +193,7 @@ func (hs *HTTPServer) DeleteDataSourceByUID(c *models.ReqContext) response.Respo hs.Live.HandleDatasourceDelete(c.OrgId, ds.Uid) - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": "Data source deleted", "id": ds.Id, }) @@ -227,7 +227,7 @@ func (hs *HTTPServer) DeleteDataSourceByName(c *models.ReqContext) response.Resp hs.Live.HandleDatasourceDelete(c.OrgId, getCmd.Result.Uid) - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": "Data source deleted", "id": getCmd.Result.Id, }) @@ -266,7 +266,7 @@ func (hs *HTTPServer) AddDataSource(c *models.ReqContext) response.Response { } ds := convertModelToDtos(cmd.Result) - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": "Datasource added", "id": cmd.Result.Id, "name": cmd.Result.Name, @@ -331,7 +331,7 @@ func (hs *HTTPServer) UpdateDataSource(c *models.ReqContext) response.Response { hs.Live.HandleDatasourceUpdate(c.OrgId, datasourceDTO.UID) - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": "Datasource updated", "id": cmd.Id, "name": cmd.Name, @@ -409,7 +409,7 @@ func (hs *HTTPServer) GetDataSourceByName(c *models.ReqContext) response.Respons } dto := convertModelToDtos(filtered[0]) - return response.JSON(200, &dto) + return response.JSON(http.StatusOK, &dto) } // Get /api/datasources/id/:name @@ -428,7 +428,7 @@ func (hs *HTTPServer) GetDataSourceIdByName(c *models.ReqContext) response.Respo Id: ds.Id, } - return response.JSON(200, &dtos) + return response.JSON(http.StatusOK, &dtos) } // /api/datasources/:id/resources/* diff --git a/pkg/api/docs/definitions/datasources.go b/pkg/api/docs/definitions/datasources.go index c5eb1d54752..e911644d0bb 100644 --- a/pkg/api/docs/definitions/datasources.go +++ b/pkg/api/docs/definitions/datasources.go @@ -167,6 +167,20 @@ import ( // 404: notFoundError // 500: internalServerError +// swagger:route GET /datasources/proxy/uid/{datasource_uid}/{datasource_proxy_route} datasources datasourceProxyGETByUIDcalls +// +// Data source proxy GET calls. +// +// Proxies all calls to the actual data source. +// +// Responses: +// 200: +// 400: badRequestError +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError + // swagger:route POST /datasources/proxy/{datasource_id}/{datasource_proxy_route} datasources datasourceProxyPOSTcalls // // Data source proxy POST calls. @@ -182,6 +196,21 @@ import ( // 404: notFoundError // 500: internalServerError +// swagger:route POST /datasources/proxy/uid/{datasource_uid}/{datasource_proxy_route} datasources datasourceProxyPOSTByUIDcalls +// +// Data source proxy POST calls. +// +// Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined +// +// Responses: +// 201: +// 202: +// 400: badRequestError +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError + // swagger:route DELETE /datasources/proxy/{datasource_id}/{datasource_proxy_route} datasources datasourceProxyDELETEcalls // // Data source proxy DELETE calls. @@ -196,6 +225,20 @@ import ( // 404: notFoundError // 500: internalServerError +// swagger:route DELETE /datasources/proxy/uid/{datasource_uid}/{datasource_proxy_route} datasources datasourceProxyDELETEByUIDcalls +// +// Data source proxy DELETE calls. +// +// Proxies all calls to the actual data source. +// +// Responses: +// 202: +// 400: badRequestError +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError + // swagger:route POST /tsdb/query datasources queryDatasource // // Query metrics. @@ -227,7 +270,7 @@ type DatasourceID struct { DatasourceID string `json:"datasource_id"` } -// swagger:parameters deleteDatasourceByUID getDatasourceByUID +// swagger:parameters deleteDatasourceByUID getDatasourceByUID datasourceProxyGETByUIDcalls datasourceProxyPOSTByUIDcalls datasourceProxyDELETEByUIDcalls type DatasourceUID struct { // in:path // required:true @@ -241,7 +284,8 @@ type DatasourceName struct { DatasourceName string `json:"datasource_name"` } -// swagger:parameters datasourceProxyGETcalls datasourceProxyPOSTcalls datasourceProxyDELETEcalls +// swagger:parameters datasourceProxyGETcalls datasourceProxyPOSTcalls datasourceProxyDELETEcalls datasourceProxyGETByUIDcalls +// swagger:parameters datasourceProxyPOSTByUIDcalls datasourceProxyDELETEByUIDcalls type DatasourceProxyRouteParam struct { // in:path // required:true diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 141a7b2f026..caba326a2a8 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -56,22 +56,23 @@ const ( ) type NavLink struct { - Id string `json:"id,omitempty"` - Text string `json:"text"` - Description string `json:"description,omitempty"` - Section string `json:"section,omitempty"` - SubTitle string `json:"subTitle,omitempty"` - Icon string `json:"icon,omitempty"` - Img string `json:"img,omitempty"` - Url string `json:"url,omitempty"` - Target string `json:"target,omitempty"` - SortWeight int64 `json:"sortWeight,omitempty"` - Divider bool `json:"divider,omitempty"` - HideFromMenu bool `json:"hideFromMenu,omitempty"` - HideFromTabs bool `json:"hideFromTabs,omitempty"` - Children []*NavLink `json:"children,omitempty"` - HighlightText string `json:"highlightText,omitempty"` - HighlightID string `json:"highlightId,omitempty"` + Id string `json:"id,omitempty"` + Text string `json:"text"` + Description string `json:"description,omitempty"` + Section string `json:"section,omitempty"` + SubTitle string `json:"subTitle,omitempty"` + Icon string `json:"icon,omitempty"` + Img string `json:"img,omitempty"` + Url string `json:"url,omitempty"` + Target string `json:"target,omitempty"` + SortWeight int64 `json:"sortWeight,omitempty"` + Divider bool `json:"divider,omitempty"` + HideFromMenu bool `json:"hideFromMenu,omitempty"` + HideFromTabs bool `json:"hideFromTabs,omitempty"` + ShowIconInNavbar bool `json:"showIconInNavbar,omitempty"` + Children []*NavLink `json:"children,omitempty"` + HighlightText string `json:"highlightText,omitempty"` + HighlightID string `json:"highlightId,omitempty"` } // NavIDCfg is the id for org configuration navigation node diff --git a/pkg/api/dtos/prefs.go b/pkg/api/dtos/prefs.go index 96a83a94dcb..e8f929e6da4 100644 --- a/pkg/api/dtos/prefs.go +++ b/pkg/api/dtos/prefs.go @@ -3,11 +3,12 @@ package dtos import "github.com/grafana/grafana/pkg/models" type Prefs struct { - Theme string `json:"theme"` - HomeDashboardID int64 `json:"homeDashboardId"` - Timezone string `json:"timezone"` - WeekStart string `json:"weekStart"` - Navbar models.NavbarPreference `json:"navbar,omitempty"` + Theme string `json:"theme"` + HomeDashboardID int64 `json:"homeDashboardId"` + Timezone string `json:"timezone"` + WeekStart string `json:"weekStart"` + Navbar models.NavbarPreference `json:"navbar,omitempty"` + QueryHistory models.QueryHistoryPreference `json:"queryHistory,omitempty"` } // swagger:model @@ -18,9 +19,10 @@ type UpdatePrefsCmd struct { // Default:0 HomeDashboardID int64 `json:"homeDashboardId"` // Enum: utc,browser - Timezone string `json:"timezone"` - WeekStart string `json:"weekStart"` - Navbar *models.NavbarPreference `json:"navbar,omitempty"` + Timezone string `json:"timezone"` + WeekStart string `json:"weekStart"` + Navbar *models.NavbarPreference `json:"navbar,omitempty"` + QueryHistory *models.QueryHistoryPreference `json:"queryHistory,omitempty"` } // swagger:model @@ -31,7 +33,8 @@ type PatchPrefsCmd struct { // Default:0 HomeDashboardID *int64 `json:"homeDashboardId,omitempty"` // Enum: utc,browser - Timezone *string `json:"timezone,omitempty"` - WeekStart *string `json:"weekStart,omitempty"` - Navbar *models.NavbarPreference `json:"navbar,omitempty"` + Timezone *string `json:"timezone,omitempty"` + WeekStart *string `json:"weekStart,omitempty"` + Navbar *models.NavbarPreference `json:"navbar,omitempty"` + QueryHistory *models.QueryHistoryPreference `json:"queryHistory,omitempty"` } diff --git a/pkg/api/folder.go b/pkg/api/folder.go index faee784f8a8..3a14be06d33 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -34,7 +34,7 @@ func (hs *HTTPServer) GetFolders(c *models.ReqContext) response.Response { }) } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } func (hs *HTTPServer) GetFolderByUID(c *models.ReqContext) response.Response { @@ -44,7 +44,7 @@ func (hs *HTTPServer) GetFolderByUID(c *models.ReqContext) response.Response { } g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser) - return response.JSON(200, hs.toFolderDto(c.Req.Context(), g, folder)) + return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, folder)) } func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response { @@ -58,7 +58,7 @@ func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response { } g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser) - return response.JSON(200, hs.toFolderDto(c.Req.Context(), g, folder)) + return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, folder)) } func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response { @@ -72,7 +72,7 @@ func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response { } g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser) - return response.JSON(200, hs.toFolderDto(c.Req.Context(), g, folder)) + return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, folder)) } func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response { @@ -86,7 +86,7 @@ func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response { } g := guardian.New(c.Req.Context(), cmd.Result.Id, c.OrgId, c.SignedInUser) - return response.JSON(200, hs.toFolderDto(c.Req.Context(), g, cmd.Result)) + return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, cmd.Result)) } func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // temporarily adding this function to HTTPServer, will be removed from HTTPServer when librarypanels featuretoggle is removed @@ -103,7 +103,7 @@ func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // return apierrors.ToFolderErrorResponse(err) } - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "title": f.Title, "message": fmt.Sprintf("Folder %s deleted", f.Title), "id": f.Id, diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go index 548201c7ba2..830c71b7afd 100644 --- a/pkg/api/folder_permission.go +++ b/pkg/api/folder_permission.go @@ -55,7 +55,7 @@ func (hs *HTTPServer) GetFolderPermissionList(c *models.ReqContext) response.Res filteredAcls = append(filteredAcls, perm) } - return response.JSON(200, filteredAcls) + return response.JSON(http.StatusOK, filteredAcls) } func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Response { @@ -141,7 +141,7 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res return response.Error(500, "Failed to create permission", err) } - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": "Folder permissions updated", "id": folder.Id, "title": folder.Title, diff --git a/pkg/api/folder_permission_test.go b/pkg/api/folder_permission_test.go index 18b45c069cb..9198f15e55b 100644 --- a/pkg/api/folder_permission_test.go +++ b/pkg/api/folder_permission_test.go @@ -15,7 +15,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" service "github.com/grafana/grafana/pkg/services/dashboards/manager" @@ -355,8 +354,6 @@ func callUpdateFolderPermissions(t *testing.T, sc *scenarioContext) { func updateFolderPermissionScenario(t *testing.T, ctx updatePermissionContext, hs *HTTPServer) { t.Run(fmt.Sprintf("%s %s", ctx.desc, ctx.url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - sc := setupScenarioContext(t, ctx.url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index e61f232c1f3..d62b4ad68c5 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -138,10 +137,7 @@ func callCreateFolder(sc *scenarioContext) { func createFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService dashboards.FolderService, cmd models.CreateFolderCommand, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - hs := HTTPServer{ - Bus: bus.GetBus(), Cfg: setting.NewCfg(), folderService: folderService, Features: featuremgmt.WithFeatures(), @@ -170,8 +166,6 @@ func callUpdateFolder(sc *scenarioContext) { func updateFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService dashboards.FolderService, cmd models.UpdateFolderCommand, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - defer bus.ClearBusHandlers() - hs := HTTPServer{ Cfg: setting.NewCfg(), folderService: folderService, diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index cc609bb2dda..2c341d53979 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -2,6 +2,7 @@ package api import ( "context" + "net/http" "strconv" "github.com/grafana/grafana/pkg/models" @@ -21,7 +22,7 @@ func (hs *HTTPServer) GetFrontendSettings(c *models.ReqContext) { return } - c.JSON(200, settings) + c.JSON(http.StatusOK, settings) } // getFrontendSettingsMap returns a json object with all the settings needed for front end initialisation. @@ -113,6 +114,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "rudderstackDataPlaneUrl": setting.RudderstackDataPlaneUrl, "rudderstackSdkUrl": setting.RudderstackSdkUrl, "rudderstackConfigUrl": setting.RudderstackConfigUrl, + "feedbackLinksEnabled": hs.Cfg.FeedbackLinksEnabled, "applicationInsightsConnectionString": hs.Cfg.ApplicationInsightsConnectionString, "applicationInsightsEndpointUrl": hs.Cfg.ApplicationInsightsEndpointUrl, "disableLoginForm": setting.DisableLoginForm, diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index 707145f7378..26c3a939090 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -7,7 +7,6 @@ import ( "path/filepath" "testing" - "github.com/grafana/grafana/pkg/bus" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing" @@ -45,7 +44,6 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt. hs := &HTTPServer{ Cfg: cfg, Features: features, - Bus: bus.GetBus(), License: &licensing.OSSLicensingService{Cfg: cfg}, RenderService: &rendering.RenderingService{ Cfg: cfg, diff --git a/pkg/api/grafana_com_proxy.go b/pkg/api/grafana_com_proxy.go index 343a5ba9ed0..db974883d49 100644 --- a/pkg/api/grafana_com_proxy.go +++ b/pkg/api/grafana_com_proxy.go @@ -7,22 +7,24 @@ import ( "net/url" "time" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/util/proxyutil" "github.com/grafana/grafana/pkg/web" ) var grafanaComProxyTransport = &http.Transport{ Proxy: http.ProxyFromEnvironment, - Dial: (&net.Dialer{ + DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, - }).Dial, + }).DialContext, TLSHandshakeTimeout: 10 * time.Second, } -func ReverseProxyGnetReq(proxyPath string, version string) *httputil.ReverseProxy { +func ReverseProxyGnetReq(logger log.Logger, proxyPath string, version string) *httputil.ReverseProxy { url, _ := url.Parse(setting.GrafanaComUrl) director := func(req *http.Request) { @@ -41,13 +43,12 @@ func ReverseProxyGnetReq(proxyPath string, version string) *httputil.ReverseProx req.Header.Add("grafana-version", version) } - return &httputil.ReverseProxy{Director: director} + return proxyutil.NewReverseProxy(logger, director) } func (hs *HTTPServer) ProxyGnetRequest(c *models.ReqContext) { proxyPath := web.Params(c.Req)["*"] - proxy := ReverseProxyGnetReq(proxyPath, hs.Cfg.BuildVersion) + proxy := ReverseProxyGnetReq(c.Logger, proxyPath, hs.Cfg.BuildVersion) proxy.Transport = grafanaComProxyTransport proxy.ServeHTTP(c.Resp, c.Req) - c.Resp.Header().Del("Set-Cookie") } diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 6d026fc5f26..a2ff9d8e6d9 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -16,7 +16,6 @@ import ( "github.com/grafana/grafana/pkg/api/avatar" "github.com/grafana/grafana/pkg/api/routing" httpstatic "github.com/grafana/grafana/pkg/api/static" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" @@ -85,7 +84,6 @@ type HTTPServer struct { PluginContextProvider *plugincontext.Provider RouteRegister routing.RouteRegister - Bus bus.Bus RenderService rendering.Service Cfg *setting.Cfg Features *featuremgmt.FeatureManager @@ -155,7 +153,7 @@ type ServerOptions struct { Listener net.Listener } -func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routing.RouteRegister, bus bus.Bus, +func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routing.RouteRegister, renderService rendering.Service, licensing models.Licensing, hooksService *hooks.HooksService, cacheService *localcache.CacheService, sqlStore *sqlstore.SQLStore, alertEngine *alerting.AlertEngine, pluginRequestValidator models.PluginRequestValidator, pluginStaticRouteResolver plugins.StaticRouteResolver, @@ -188,7 +186,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi hs := &HTTPServer{ Cfg: cfg, RouteRegister: routeRegister, - Bus: bus, RenderService: renderService, License: licensing, HooksService: hooksService, @@ -507,7 +504,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { m.Use(hs.pluginMetricsEndpoint) m.Use(hs.ContextHandler.Middleware) - m.Use(middleware.OrgRedirect(hs.Cfg)) + m.Use(middleware.OrgRedirect(hs.Cfg, hs.SQLStore)) m.Use(acmiddleware.LoadPermissionsMiddleware(hs.AccessControl)) // needs to be after context handler diff --git a/pkg/api/index.go b/pkg/api/index.go index 20c21a3c7c6..d7d8f5e133a 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -2,6 +2,7 @@ package api import ( "fmt" + "net/http" "path" "sort" "strings" @@ -149,8 +150,11 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) } func enableServiceAccount(hs *HTTPServer, c *models.ReqContext) bool { - return (c.OrgRole == models.ROLE_ADMIN || (hs.Cfg.EditorsCanAdmin && c.OrgRole == models.ROLE_EDITOR)) && - hs.Features.IsEnabled(featuremgmt.FlagServiceAccounts) + if !hs.Features.IsEnabled(featuremgmt.FlagServiceAccounts) { + return false + } + hasAccess := ac.HasAccess(hs.AccessControl, c) + return hasAccess(ac.ReqOrgAdmin, serviceAccountAccessEvaluator) } func (hs *HTTPServer) ReqCanAdminTeams(c *models.ReqContext) bool { @@ -230,7 +234,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto uaVisibleForOrg := hs.Cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg if setting.AlertingEnabled != nil && *setting.AlertingEnabled || uaVisibleForOrg { - navTree = append(navTree, hs.buildAlertNavLinks(c, uaVisibleForOrg)...) + navTree = append(navTree, hs.buildAlertNavLinks(c, uaVisibleForOrg, hasEditPerm)...) } appLinks, err := hs.getAppLinks(c) @@ -291,7 +295,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto }) } - if c.OrgRole == models.ROLE_ADMIN { + if hasAccess(ac.ReqOrgAdmin, apiKeyAccessEvaluator) { configNodes = append(configNodes, &dtos.NavLink{ Text: "API keys", Id: "apikeys", @@ -307,7 +311,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto Id: "serviceaccounts", Description: "Manage service accounts", // TODO: change icon to "key-skeleton-alt" when it's available - Icon: "key-skeleton-alt", + Icon: "keyhole-circle", Url: hs.Cfg.AppSubURL + "/org/serviceaccounts", }) } @@ -460,23 +464,23 @@ func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, }) dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ - Text: "New dashboard", Icon: "plus", Url: hs.Cfg.AppSubURL + "/dashboard/new", HideFromTabs: true, Id: "new-dashboard", + Text: "New dashboard", Icon: "plus", Url: hs.Cfg.AppSubURL + "/dashboard/new", HideFromTabs: true, Id: "new-dashboard", ShowIconInNavbar: true, }) if c.OrgRole == models.ROLE_ADMIN || c.OrgRole == models.ROLE_EDITOR { dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ Text: "New folder", SubTitle: "Create a new folder to organize your dashboards", Id: "new-folder", - Icon: "plus", Url: hs.Cfg.AppSubURL + "/dashboards/folder/new", HideFromTabs: true, + Icon: "plus", Url: hs.Cfg.AppSubURL + "/dashboards/folder/new", HideFromTabs: true, ShowIconInNavbar: true, }) } dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "import", Icon: "plus", - Url: hs.Cfg.AppSubURL + "/dashboard/import", HideFromTabs: true, + Url: hs.Cfg.AppSubURL + "/dashboard/import", HideFromTabs: true, ShowIconInNavbar: true, }) } return dashboardChildNavs } -func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext, uaVisibleForOrg bool) []*dtos.NavLink { +func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext, uaVisibleForOrg bool, hasEditPerm bool) []*dtos.NavLink { hasAccess := ac.HasAccess(hs.AccessControl, c) var alertChildNavs []*dtos.NavLink @@ -513,6 +517,19 @@ func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext, uaVisibleForOrg b }) } + if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { + if uaVisibleForOrg && hasEditPerm && hasAccess(ac.ReqSignedIn, ac.EvalPermission(ac.ActionAlertingRuleCreate)) { + alertChildNavs = append(alertChildNavs, &dtos.NavLink{ + Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, + }) + + alertChildNavs = append(alertChildNavs, &dtos.NavLink{ + Text: "Alert rule", SubTitle: "Create an alert rule", Id: "alert", + Icon: "plus", Url: hs.Cfg.AppSubURL + "/alerting/new", HideFromTabs: true, ShowIconInNavbar: true, + }) + } + } + if len(alertChildNavs) > 0 { return []*dtos.NavLink{ { @@ -734,7 +751,7 @@ func (hs *HTTPServer) Index(c *models.ReqContext) { c.Handle(hs.Cfg, 500, "Failed to get settings", err) return } - c.HTML(200, "index", data) + c.HTML(http.StatusOK, "index", data) } func (hs *HTTPServer) NotFoundHandler(c *models.ReqContext) { diff --git a/pkg/api/ldap_debug.go b/pkg/api/ldap_debug.go index a477bceedd6..97b1e5142fe 100644 --- a/pkg/api/ldap_debug.go +++ b/pkg/api/ldap_debug.go @@ -314,7 +314,7 @@ func (hs *HTTPServer) GetUserFromLDAP(c *models.ReqContext) response.Response { return response.Error(http.StatusBadRequest, "Unable to find the teams for this user", err) } - return response.JSON(200, u) + return response.JSON(http.StatusOK, u) } // splitName receives the full name of a user and splits it into two parts: A name and a surname. diff --git a/pkg/api/ldap_debug_test.go b/pkg/api/ldap_debug_test.go index f9d796018bc..f7ed3473906 100644 --- a/pkg/api/ldap_debug_test.go +++ b/pkg/api/ldap_debug_test.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -146,13 +147,11 @@ func TestGetUserFromLDAPAPIEndpoint_OrgNotfound(t *testing.T) { require.Equal(t, http.StatusBadRequest, sc.resp.Code) - expected := ` - { - "error": "unable to find organization with ID '2'", - "message": "An organization was not found - Please verify your LDAP configuration" - } - ` - assert.JSONEq(t, expected, sc.resp.Body.String()) + var res map[string]interface{} + err := json.Unmarshal(sc.resp.Body.Bytes(), &res) + assert.NoError(t, err) + assert.Equal(t, "unable to find organization with ID '2'", res["error"]) + assert.Equal(t, "An organization was not found - Please verify your LDAP configuration", res["message"]) } func TestGetUserFromLDAPAPIEndpoint(t *testing.T) { @@ -470,14 +469,11 @@ func TestPostSyncUserWithLDAPAPIEndpoint_WhenGrafanaAdmin(t *testing.T) { }, &sqlstoremock) assert.Equal(t, http.StatusBadRequest, sc.resp.Code) - expected := ` - { - "error": "did not find a user", - "message": "Refusing to sync grafana super admin \"ldap-daniel\" - it would be disabled" - } - ` - - assert.JSONEq(t, expected, sc.resp.Body.String()) + var res map[string]interface{} + err := json.Unmarshal(sc.resp.Body.Bytes(), &res) + assert.NoError(t, err) + assert.Equal(t, "did not find a user", res["error"]) + assert.Equal(t, "Refusing to sync grafana super admin \"ldap-daniel\" - it would be disabled", res["message"]) } func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotInLDAP(t *testing.T) { diff --git a/pkg/api/login.go b/pkg/api/login.go index 49a33fa5744..b158afcbef8 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -85,7 +85,7 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) { urlParams := c.Req.URL.Query() if _, disableAutoLogin := urlParams["disableAutoLogin"]; disableAutoLogin { hs.log.Debug("Auto login manually disabled") - c.HTML(200, getViewIndex(), viewData) + c.HTML(http.StatusOK, getViewIndex(), viewData) return } @@ -109,7 +109,7 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) { // to login again via OAuth and enter to a redirect loop cookies.DeleteCookie(c.Resp, loginErrorCookieName, hs.CookieOptionsFromCfg) viewData.Settings["loginError"] = loginError - c.HTML(200, getViewIndex(), viewData) + c.HTML(http.StatusOK, getViewIndex(), viewData) return } @@ -144,7 +144,7 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) { return } - c.HTML(200, getViewIndex(), viewData) + c.HTML(http.StatusOK, getViewIndex(), viewData) } func (hs *HTTPServer) tryOAuthAutoLogin(c *models.ReqContext) bool { @@ -167,7 +167,7 @@ func (hs *HTTPServer) tryOAuthAutoLogin(c *models.ReqContext) bool { func (hs *HTTPServer) LoginAPIPing(c *models.ReqContext) response.Response { if c.IsSignedIn || c.IsAnonymous { - return response.JSON(200, "Logged in") + return response.JSON(http.StatusOK, "Logged in") } return response.Error(401, "Unauthorized", nil) diff --git a/pkg/api/login_oauth_test.go b/pkg/api/login_oauth_test.go index a9af0b79544..cc0c41f745d 100644 --- a/pkg/api/login_oauth_test.go +++ b/pkg/api/login_oauth_test.go @@ -13,7 +13,6 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/licensing" @@ -34,7 +33,6 @@ func setupOAuthTest(t *testing.T, cfg *setting.Cfg) *web.Mux { hs := &HTTPServer{ Cfg: cfg, - Bus: bus.GetBus(), License: &licensing.OSSLicensingService{Cfg: cfg}, SQLStore: sqlStore, SocialService: social.ProvideService(cfg), diff --git a/pkg/api/metrics_test.go b/pkg/api/metrics_test.go index 634ce0ee74e..5a32d5cfbba 100644 --- a/pkg/api/metrics_test.go +++ b/pkg/api/metrics_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "fmt" "net/http" "strconv" @@ -241,15 +242,14 @@ func TestAPIEndpoint_Metrics_QueryMetricsFromDashboard(t *testing.T) { strings.NewReader(queryDatasourceInput), t, ) + assert.Equal(t, http.StatusBadRequest, response.Code) - assert.JSONEq( - t, - fmt.Sprintf( - "{\"error\":\"%[1]s\",\"message\":\"%[1]s\"}", - models.ErrDashboardOrPanelIdentifierNotSet, - ), - response.Body.String(), - ) + + var res map[string]interface{} + err := json.Unmarshal(response.Body.Bytes(), &res) + assert.NoError(t, err) + assert.Equal(t, models.ErrDashboardOrPanelIdentifierNotSet.Error(), res["error"]) + assert.Equal(t, models.ErrDashboardOrPanelIdentifierNotSet.Error(), res["message"]) }) t.Run("Cannot query without a valid orgid", func(t *testing.T) { @@ -261,14 +261,10 @@ func TestAPIEndpoint_Metrics_QueryMetricsFromDashboard(t *testing.T) { t, ) assert.Equal(t, http.StatusBadRequest, response.Code) - assert.JSONEq( - t, - fmt.Sprintf( - "{\"error\":\"%[1]s\",\"message\":\"%[1]s\"}", - models.ErrDashboardOrPanelIdentifierNotSet, - ), - response.Body.String(), - ) + var res map[string]interface{} + assert.NoError(t, json.Unmarshal(response.Body.Bytes(), &res)) + assert.Equal(t, models.ErrDashboardOrPanelIdentifierNotSet.Error(), res["error"]) + assert.Equal(t, models.ErrDashboardOrPanelIdentifierNotSet.Error(), res["message"]) }) t.Run("Cannot query without a valid dashboard or panel ID", func(t *testing.T) { @@ -280,14 +276,11 @@ func TestAPIEndpoint_Metrics_QueryMetricsFromDashboard(t *testing.T) { t, ) assert.Equal(t, http.StatusBadRequest, response.Code) - assert.JSONEq( - t, - fmt.Sprintf( - "{\"error\":\"%[1]s\",\"message\":\"%[1]s\"}", - models.ErrDashboardOrPanelIdentifierNotSet, - ), - response.Body.String(), - ) + + var res map[string]interface{} + assert.NoError(t, json.Unmarshal(response.Body.Bytes(), &res)) + assert.Equal(t, models.ErrDashboardOrPanelIdentifierNotSet.Error(), res["error"]) + assert.Equal(t, models.ErrDashboardOrPanelIdentifierNotSet.Error(), res["message"]) }) t.Run("Cannot query when ValidatedQueries is disabled", func(t *testing.T) { diff --git a/pkg/api/org.go b/pkg/api/org.go index 21abb8fe5f0..d6cd5b3799b 100644 --- a/pkg/api/org.go +++ b/pkg/api/org.go @@ -54,7 +54,7 @@ func (hs *HTTPServer) GetOrgByName(c *models.ReqContext) response.Response { }, } - return response.JSON(200, &result) + return response.JSON(http.StatusOK, &result) } func (hs *HTTPServer) getOrgHelper(ctx context.Context, orgID int64) response.Response { @@ -81,7 +81,7 @@ func (hs *HTTPServer) getOrgHelper(ctx context.Context, orgID int64) response.Re }, } - return response.JSON(200, &result) + return response.JSON(http.StatusOK, &result) } // POST /api/orgs @@ -105,7 +105,7 @@ func (hs *HTTPServer) CreateOrg(c *models.ReqContext) response.Response { metrics.MApiOrgCreate.Inc() - return response.JSON(200, &util.DynMap{ + return response.JSON(http.StatusOK, &util.DynMap{ "orgId": cmd.Result.Id, "message": "Organization created", }) @@ -226,5 +226,5 @@ func (hs *HTTPServer) SearchOrgs(c *models.ReqContext) response.Response { return response.Error(500, "Failed to search orgs", err) } - return response.JSON(200, query.Result) + return response.JSON(http.StatusOK, query.Result) } diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go index 957053455dd..312971cdedc 100644 --- a/pkg/api/org_invite.go +++ b/pkg/api/org_invite.go @@ -28,7 +28,7 @@ func (hs *HTTPServer) GetPendingOrgInvites(c *models.ReqContext) response.Respon invite.Url = setting.ToAbsUrl("invite/" + invite.Code) } - return response.JSON(200, query.Result) + return response.JSON(http.StatusOK, query.Result) } func (hs *HTTPServer) AddOrgInvite(c *models.ReqContext) response.Response { @@ -131,7 +131,7 @@ func (hs *HTTPServer) inviteExistingUserToOrg(c *models.ReqContext, user *models } } - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": fmt.Sprintf("Existing Grafana user %s added to org %s", user.NameOrFallback(), c.OrgName), "userId": user.Id, }) @@ -162,7 +162,7 @@ func (hs *HTTPServer) GetInviteInfoByCode(c *models.ReqContext) response.Respons return response.Error(404, "Invite not found", nil) } - return response.JSON(200, dtos.InviteInfo{ + return response.JSON(http.StatusOK, dtos.InviteInfo{ Email: invite.Email, Name: invite.Name, Username: invite.Email, @@ -225,7 +225,7 @@ func (hs *HTTPServer) CompleteInvite(c *models.ReqContext) response.Response { metrics.MApiUserSignUpCompleted.Inc() metrics.MApiUserSignUpInvite.Inc() - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": "User created and logged in", "id": user.Id, }) diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index dcd45bbe523..b10aae80c45 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -64,7 +64,7 @@ func (hs *HTTPServer) addOrgUserHelper(ctx context.Context, cmd models.AddOrgUse return response.Error(500, "Could not add user to organization", err) } - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": "User added to organization", "userId": cmd.UserId, }) @@ -83,7 +83,7 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrg(c *models.ReqContext) response.Re return response.Error(500, "Failed to get users for current organization", err) } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } // GET /api/org/users/lookup @@ -109,7 +109,7 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrgLookup(c *models.ReqContext) respo }) } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } // GET /api/orgs/:orgId/users @@ -130,7 +130,7 @@ func (hs *HTTPServer) GetOrgUsers(c *models.ReqContext) response.Response { return response.Error(500, "Failed to get users for organization", err) } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } func (hs *HTTPServer) getOrgUsersHelper(c *models.ReqContext, query *models.GetOrgUsersQuery, signedInUser *models.SignedInUser) ([]*models.OrgUserDTO, error) { @@ -201,7 +201,7 @@ func (hs *HTTPServer) SearchOrgUsersWithPaging(c *models.ReqContext) response.Re query.Result.Page = page query.Result.PerPage = perPage - return response.JSON(200, query.Result) + return response.JSON(http.StatusOK, query.Result) } // PATCH /api/org/users/:userId diff --git a/pkg/api/password.go b/pkg/api/password.go index 487c1e1c33d..4454a486001 100644 --- a/pkg/api/password.go +++ b/pkg/api/password.go @@ -29,7 +29,7 @@ func (hs *HTTPServer) SendResetPasswordEmail(c *models.ReqContext) response.Resp if err := hs.SQLStore.GetUserByLogin(c.Req.Context(), &userQuery); err != nil { c.Logger.Info("Requested password reset for user that was not found", "user", userQuery.LoginOrEmail) - return response.Error(200, "Email sent", err) + return response.Error(http.StatusOK, "Email sent", err) } emailCmd := models.SendResetPasswordEmailCommand{User: userQuery.Result} diff --git a/pkg/api/playlist.go b/pkg/api/playlist.go index 5e496c0fd0e..d30f8c58b23 100644 --- a/pkg/api/playlist.go +++ b/pkg/api/playlist.go @@ -54,7 +54,7 @@ func (hs *HTTPServer) SearchPlaylists(c *models.ReqContext) response.Response { return response.Error(500, "Search failed", err) } - return response.JSON(200, searchQuery.Result) + return response.JSON(http.StatusOK, searchQuery.Result) } func (hs *HTTPServer) GetPlaylist(c *models.ReqContext) response.Response { @@ -78,7 +78,7 @@ func (hs *HTTPServer) GetPlaylist(c *models.ReqContext) response.Response { Items: playlistDTOs, } - return response.JSON(200, dto) + return response.JSON(http.StatusOK, dto) } func (hs *HTTPServer) LoadPlaylistItemDTOs(ctx context.Context, id int64) ([]models.PlaylistItemDTO, error) { @@ -125,7 +125,7 @@ func (hs *HTTPServer) GetPlaylistItems(c *models.ReqContext) response.Response { return response.Error(500, "Could not load playlist items", err) } - return response.JSON(200, playlistDTOs) + return response.JSON(http.StatusOK, playlistDTOs) } func (hs *HTTPServer) GetPlaylistDashboards(c *models.ReqContext) response.Response { @@ -139,7 +139,7 @@ func (hs *HTTPServer) GetPlaylistDashboards(c *models.ReqContext) response.Respo return response.Error(500, "Could not load dashboards", err) } - return response.JSON(200, playlists) + return response.JSON(http.StatusOK, playlists) } func (hs *HTTPServer) DeletePlaylist(c *models.ReqContext) response.Response { @@ -153,7 +153,7 @@ func (hs *HTTPServer) DeletePlaylist(c *models.ReqContext) response.Response { return response.Error(500, "Failed to delete playlist", err) } - return response.JSON(200, "") + return response.JSON(http.StatusOK, "") } func (hs *HTTPServer) CreatePlaylist(c *models.ReqContext) response.Response { @@ -167,7 +167,7 @@ func (hs *HTTPServer) CreatePlaylist(c *models.ReqContext) response.Response { return response.Error(500, "Failed to create playlist", err) } - return response.JSON(200, cmd.Result) + return response.JSON(http.StatusOK, cmd.Result) } func (hs *HTTPServer) UpdatePlaylist(c *models.ReqContext) response.Response { @@ -192,5 +192,5 @@ func (hs *HTTPServer) UpdatePlaylist(c *models.ReqContext) response.Response { } cmd.Result.Items = playlistDTOs - return response.JSON(200, cmd.Result) + return response.JSON(http.StatusOK, cmd.Result) } diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index fc12967768b..239249cf620 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -5,9 +5,7 @@ import ( "errors" "fmt" "io/ioutil" - "log" "net/http" - "net/http/httputil" "net/url" "strconv" "strings" @@ -48,35 +46,10 @@ type DataSourceProxy struct { secretsService secrets.Service } -type handleResponseTransport struct { - transport http.RoundTripper -} - -func (t *handleResponseTransport) RoundTrip(req *http.Request) (*http.Response, error) { - res, err := t.transport.RoundTrip(req) - if err != nil { - return nil, err - } - res.Header.Del("Set-Cookie") - proxyutil.SetProxyResponseHeaders(res.Header) - return res, nil -} - type httpClient interface { Do(req *http.Request) (*http.Response, error) } -type logWrapper struct { - logger glog.Logger -} - -// Write writes log messages as bytes from proxy -func (lw *logWrapper) Write(p []byte) (n int, err error) { - withoutNewline := strings.TrimSuffix(string(p), "\n") - lw.logger.Error("Data proxy error", "error", withoutNewline) - return len(p), nil -} - // NewDataSourceProxy creates a new Datasource proxy func NewDataSourceProxy(ds *models.DataSource, pluginRoutes []*plugins.Route, ctx *models.ReqContext, proxyPath string, cfg *setting.Cfg, clientProvider httpclient.Provider, @@ -115,8 +88,14 @@ func (proxy *DataSourceProxy) HandleRequest() { return } - proxyErrorLogger := logger.New("userId", proxy.ctx.UserId, "orgId", proxy.ctx.OrgId, "uname", proxy.ctx.Login, - "path", proxy.ctx.Req.URL.Path, "remote_addr", proxy.ctx.RemoteAddr(), "referer", proxy.ctx.Req.Referer()) + proxyErrorLogger := logger.New( + "userId", proxy.ctx.UserId, + "orgId", proxy.ctx.OrgId, + "uname", proxy.ctx.Login, + "path", proxy.ctx.Req.URL.Path, + "remote_addr", proxy.ctx.RemoteAddr(), + "referer", proxy.ctx.Req.Referer(), + ) transport, err := proxy.dataSourcesService.GetHTTPTransport(proxy.ds, proxy.clientProvider) if err != nil { @@ -124,36 +103,36 @@ func (proxy *DataSourceProxy) HandleRequest() { return } - reverseProxy := &httputil.ReverseProxy{ - Director: proxy.director, - FlushInterval: time.Millisecond * 200, - ErrorLog: log.New(&logWrapper{logger: proxyErrorLogger}, "", 0), - Transport: &handleResponseTransport{ - transport: transport, - }, - ModifyResponse: func(resp *http.Response) error { - if resp.StatusCode == 401 { - // The data source rejected the request as unauthorized, convert to 400 (bad request) - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read data source response body: %w", err) - } - _ = resp.Body.Close() - - proxyErrorLogger.Info("Authentication to data source failed", "body", string(body), "statusCode", - resp.StatusCode) - msg := "Authentication to data source failed" - *resp = http.Response{ - StatusCode: 400, - Status: "Bad Request", - Body: ioutil.NopCloser(strings.NewReader(msg)), - ContentLength: int64(len(msg)), - } + modifyResponse := func(resp *http.Response) error { + if resp.StatusCode == 401 { + // The data source rejected the request as unauthorized, convert to 400 (bad request) + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read data source response body: %w", err) } - return nil - }, + _ = resp.Body.Close() + + proxyErrorLogger.Info("Authentication to data source failed", "body", string(body), "statusCode", + resp.StatusCode) + msg := "Authentication to data source failed" + *resp = http.Response{ + StatusCode: 400, + Status: "Bad Request", + Body: ioutil.NopCloser(strings.NewReader(msg)), + ContentLength: int64(len(msg)), + Header: http.Header{}, + } + } + return nil } + reverseProxy := proxyutil.NewReverseProxy( + proxyErrorLogger, + proxy.director, + proxyutil.WithTransport(transport), + proxyutil.WithModifyResponse(modifyResponse), + ) + proxy.logRequest() ctx, span := proxy.tracer.Start(proxy.ctx.Req.Context(), "datasource reverse proxy") defer span.End() @@ -236,14 +215,8 @@ func (proxy *DataSourceProxy) director(req *http.Request) { } proxyutil.ClearCookieHeader(req, keepCookieNames) - proxyutil.PrepareProxyRequest(req) - req.Header.Set("User-Agent", fmt.Sprintf("Grafana/%s", setting.BuildVersion)) - // Clear Origin and Referer to avoid CORS issues - req.Header.Del("Origin") - req.Header.Del("Referer") - jsonData := make(map[string]interface{}) if proxy.ds.JsonData != nil { jsonData, err = proxy.ds.JsonData.Map() diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 370cf09883f..d0784e839eb 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -13,7 +13,6 @@ import ( "time" "github.com/grafana/grafana/pkg/api/datasource" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/tracing" @@ -129,7 +128,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -142,7 +141,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic url", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/common/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) proxy.matchedRoute = routes[3] @@ -154,7 +153,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path with no url", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) proxy.matchedRoute = routes[4] @@ -165,7 +164,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic body", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/body", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) proxy.matchedRoute = routes[5] @@ -179,7 +178,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("Validating request", func(t *testing.T) { t.Run("plugin route with valid role", func(t *testing.T) { ctx, _ := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) err = proxy.validateRequest() @@ -188,7 +187,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is editor", func(t *testing.T) { ctx, _ := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) err = proxy.validateRequest() @@ -198,7 +197,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is admin", func(t *testing.T) { ctx, _ := setUp() ctx.SignedInUser.OrgRole = models.ROLE_ADMIN - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) err = proxy.validateRequest() @@ -287,7 +286,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }, } - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) @@ -303,7 +302,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { req, err := http.NewRequest("GET", "http://localhost/asd", nil) require.NoError(t, err) client = newFakeHTTPClient(t, json2) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken2", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[1], dsInfo, cfg) @@ -320,7 +319,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { require.NoError(t, err) client = newFakeHTTPClient(t, []byte{}) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) @@ -342,7 +341,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{BuildVersion: "5.3.0"}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -368,7 +367,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -392,7 +391,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -420,7 +419,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} var pluginRoutes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, pluginRoutes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -443,7 +442,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -456,25 +455,10 @@ func TestDataSourceProxy_routeRule(t *testing.T) { assert.Equal(t, "http://host/root/path/to/folder/", req.URL.String()) - assert.Empty(t, req.Header.Get("Origin")) - assert.Empty(t, req.Header.Get("Referer")) assert.Equal(t, "stillthere", req.Header.Get("X-Canary")) }) t.Run("When proxying a datasource that has OAuth token pass-through enabled", func(t *testing.T) { - bus.AddHandler("test", func(ctx context.Context, query *models.GetAuthInfoQuery) error { - query.Result = &models.UserAuth{ - Id: 1, - UserId: 1, - AuthModule: "generic_oauth", - OAuthAccessToken: "testtoken", - OAuthRefreshToken: "testrefreshtoken", - OAuthTokenType: "Bearer", - OAuthExpiry: time.Now().AddDate(0, 0, 1), - } - return nil - }) - ds := &models.DataSource{ Type: "custom-datasource", Url: "http://host/root/", @@ -507,7 +491,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &mockAuthToken, dsService, tracer, secretsService) require.NoError(t, err) req, err = http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -641,7 +625,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { ctx, ds := setUp(t) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -659,7 +643,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { }) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -673,7 +657,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { ctx, ds := setUp(t) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -695,7 +679,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { }) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -720,7 +704,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { ctx.Req = httptest.NewRequest("GET", "/api/datasources/proxy/1/path/%2Ftest%2Ftest%2F?query=%2Ftest%2Ftest%2F", nil) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -744,7 +728,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { ctx.Req = httptest.NewRequest("GET", "/api/datasources/proxy/1/path/%2Ftest%2Ftest%2F?query=%2Ftest%2Ftest%2F", nil) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -769,7 +753,7 @@ func TestNewDataSourceProxy_InvalidURL(t *testing.T) { require.NoError(t, err) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.Error(t, err) assert.True(t, strings.HasPrefix(err.Error(), `validation of data source URL "://host/root" failed`)) @@ -790,7 +774,7 @@ func TestNewDataSourceProxy_ProtocolLessURL(t *testing.T) { var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -833,7 +817,7 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) { var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) p, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) if tc.err == nil { require.NoError(t, err) @@ -860,7 +844,7 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *models.ReqContext, cfg *sett var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -984,7 +968,7 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, cfg *se require.NoError(t, err) var routes []*plugins.Route - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -1027,7 +1011,7 @@ func Test_PathCheck(t *testing.T) { } ctx, _ := setUp() secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(&models.DataSource{}, routes, ctx, "b", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index ca0d3092e3a..3735a34aa1a 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -1,7 +1,9 @@ package pluginproxy import ( + "bytes" "encoding/json" + "io/ioutil" "net/http" "net/http/httputil" "net/url" @@ -22,7 +24,18 @@ type templateData struct { // NewApiPluginProxy create a plugin proxy func NewApiPluginProxy(ctx *models.ReqContext, proxyPath string, route *plugins.Route, - appID string, cfg *setting.Cfg, pluginSettingsService pluginsettings.Service, secretsService secrets.Service) *httputil.ReverseProxy { + appID string, cfg *setting.Cfg, pluginSettingsService pluginsettings.Service, + secretsService secrets.Service) *httputil.ReverseProxy { + appProxyLogger := logger.New( + "userId", ctx.UserId, + "orgId", ctx.OrgId, + "uname", ctx.Login, + "app", appID, + "path", ctx.Req.URL.Path, + "remote_addr", ctx.RemoteAddr(), + "referer", ctx.Req.Referer(), + ) + director := func(req *http.Request) { query := pluginsettings.GetByPluginIDArgs{OrgID: ctx.OrgId, PluginID: appID} ps, err := pluginSettingsService.GetPluginSettingByPluginID(ctx.Req.Context(), &query) @@ -61,8 +74,6 @@ func NewApiPluginProxy(ctx *models.ReqContext, proxyPath string, route *plugins. req.Header.Del("Cookie") req.Header.Del("Set-Cookie") - proxyutil.PrepareProxyRequest(req) - // Create a HTTP header with the context in it. ctxJSON, err := json.Marshal(ctx.SignedInUser) if err != nil { @@ -80,15 +91,35 @@ func NewApiPluginProxy(ctx *models.ReqContext, proxyPath string, route *plugins. } if err := setBodyContent(req, route, data); err != nil { - logger.Error("Failed to set plugin route body content", "error", err) + appProxyLogger.Error("Failed to set plugin route body content", "error", err) } } - return &httputil.ReverseProxy{Director: director, ModifyResponse: modifyResponse} + logAppPluginProxyRequest(appID, cfg, ctx) + + return proxyutil.NewReverseProxy(appProxyLogger, director) } -func modifyResponse(resp *http.Response) error { - proxyutil.SetProxyResponseHeaders(resp.Header) +func logAppPluginProxyRequest(appID string, cfg *setting.Cfg, c *models.ReqContext) { + if !cfg.DataProxyLogging { + return + } - return nil + var body string + if c.Req.Body != nil { + buffer, err := ioutil.ReadAll(c.Req.Body) + if err == nil { + c.Req.Body = ioutil.NopCloser(bytes.NewBuffer(buffer)) + body = string(buffer) + } + } + + logger.Info("Proxying incoming request", + "userid", c.UserId, + "orgid", c.OrgId, + "username", c.Login, + "app", appID, + "uri", c.Req.RequestURI, + "method", c.Req.Method, + "body", body) } diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 25a1dcbfe60..227a6d6fba1 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -111,7 +111,7 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response { } sort.Sort(result) - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } func (hs *HTTPServer) GetPluginSettingByID(c *models.ReqContext) response.Response { @@ -163,7 +163,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *models.ReqContext) response.Respon dto.HasUpdate = true } - return response.JSON(200, dto) + return response.JSON(http.StatusOK, dto) } func (hs *HTTPServer) UpdatePluginSetting(c *models.ReqContext) response.Response { @@ -217,7 +217,7 @@ func (hs *HTTPServer) GetPluginMarkdown(c *models.ReqContext) response.Response } } - resp := response.Respond(200, content) + resp := response.Respond(http.StatusOK, content) resp.SetHeader("Content-Type", "text/plain; charset=utf-8") return resp } @@ -343,7 +343,7 @@ func (hs *HTTPServer) CheckHealth(c *models.ReqContext) response.Response { return response.JSON(503, payload) } - return response.JSON(200, payload) + return response.JSON(http.StatusOK, payload) } // CallResource passes a resource call from a plugin to the backend plugin. @@ -354,7 +354,7 @@ func (hs *HTTPServer) CallResource(c *models.ReqContext) { } func (hs *HTTPServer) GetPluginErrorsList(_ *models.ReqContext) response.Response { - return response.JSON(200, hs.pluginErrorResolver.PluginErrors()) + return response.JSON(http.StatusOK, hs.pluginErrorResolver.PluginErrors()) } func (hs *HTTPServer) InstallPlugin(c *models.ReqContext) response.Response { diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index 8c8674fd6c4..3504d51e4cf 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -15,7 +15,6 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" @@ -218,8 +217,6 @@ func callGetPluginAsset(sc *scenarioContext) { func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern string, pluginStore plugins.Store, logger log.Logger, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - defer bus.ClearBusHandlers() - hs := HTTPServer{ Cfg: setting.NewCfg(), pluginStore: pluginStore, diff --git a/pkg/api/preferences.go b/pkg/api/preferences.go index 7d6de66a44f..3792bee5a90 100644 --- a/pkg/api/preferences.go +++ b/pkg/api/preferences.go @@ -53,9 +53,10 @@ func (hs *HTTPServer) getPreferencesFor(ctx context.Context, orgID, userID, team if prefsQuery.Result.JsonData != nil { dto.Navbar = prefsQuery.Result.JsonData.Navbar + dto.QueryHistory = prefsQuery.Result.JsonData.QueryHistory } - return response.JSON(200, &dto) + return response.JSON(http.StatusOK, &dto) } // PUT /api/user/preferences @@ -110,6 +111,7 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te WeekStart: dtoCmd.WeekStart, HomeDashboardId: dtoCmd.HomeDashboardID, Navbar: dtoCmd.Navbar, + QueryHistory: dtoCmd.QueryHistory, } if err := hs.SQLStore.PatchPreferences(ctx, &patchCmd); err != nil { diff --git a/pkg/api/quota.go b/pkg/api/quota.go index cdd8c9762d1..79d304dbf9e 100644 --- a/pkg/api/quota.go +++ b/pkg/api/quota.go @@ -32,7 +32,7 @@ func (hs *HTTPServer) getOrgQuotasHelper(c *models.ReqContext, orgID int64) resp return response.Error(500, "Failed to get org quotas", err) } - return response.JSON(200, query.Result) + return response.JSON(http.StatusOK, query.Result) } func (hs *HTTPServer) UpdateOrgQuota(c *models.ReqContext) response.Response { @@ -76,7 +76,7 @@ func (hs *HTTPServer) GetUserQuotas(c *models.ReqContext) response.Response { return response.Error(500, "Failed to get org quotas", err) } - return response.JSON(200, query.Result) + return response.JSON(http.StatusOK, query.Result) } func (hs *HTTPServer) UpdateUserQuota(c *models.ReqContext) response.Response { diff --git a/pkg/api/response/response.go b/pkg/api/response/response.go index 57d751a407c..e08ed4eb974 100644 --- a/pkg/api/response/response.go +++ b/pkg/api/response/response.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" jsoniter "github.com/json-iterator/go" @@ -73,7 +74,15 @@ func (r *NormalResponse) ErrMessage() string { func (r *NormalResponse) WriteTo(ctx *models.ReqContext) { if r.err != nil { - ctx.Logger.Error(r.errMessage, "error", r.err, "remote_addr", ctx.RemoteAddr()) + v := map[string]interface{}{} + traceID := tracing.TraceIDFromContext(ctx.Req.Context(), false) + if err := json.Unmarshal(r.body.Bytes(), &v); err == nil { + v["traceID"] = traceID + if b, err := json.Marshal(v); err == nil { + r.body = bytes.NewBuffer(b) + } + } + ctx.Logger.Error(r.errMessage, "error", r.err, "remote_addr", ctx.RemoteAddr(), "traceID", traceID) } header := ctx.Resp.Header() @@ -171,7 +180,7 @@ func JSONStreaming(status int, body interface{}) StreamingResponse { func Success(message string) *NormalResponse { resp := make(map[string]interface{}) resp["message"] = message - return JSON(200, resp) + return JSON(http.StatusOK, resp) } // Error creates an error response. diff --git a/pkg/api/search.go b/pkg/api/search.go index 2f1a2cbf407..a981250d4d4 100644 --- a/pkg/api/search.go +++ b/pkg/api/search.go @@ -67,7 +67,7 @@ func (hs *HTTPServer) Search(c *models.ReqContext) response.Response { } c.TimeRequest(metrics.MApiDashboardSearch) - return response.JSON(200, searchQuery.Result) + return response.JSON(http.StatusOK, searchQuery.Result) } func (hs *HTTPServer) ListSortOptions(c *models.ReqContext) response.Response { diff --git a/pkg/api/short_url.go b/pkg/api/short_url.go index 180aeedfd73..6e9d1614d1f 100644 --- a/pkg/api/short_url.go +++ b/pkg/api/short_url.go @@ -47,7 +47,7 @@ func (hs *HTTPServer) createShortURL(c *models.ReqContext) response.Response { URL: url, } - return response.JSON(200, dto) + return response.JSON(http.StatusOK, dto) } func (hs *HTTPServer) redirectFromShortURL(c *models.ReqContext) { diff --git a/pkg/api/short_url_test.go b/pkg/api/short_url_test.go index 752004738f2..bb592a9d30a 100644 --- a/pkg/api/short_url_test.go +++ b/pkg/api/short_url_test.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/shorturls" @@ -54,8 +53,6 @@ func callCreateShortURL(sc *scenarioContext) { func createShortURLScenario(t *testing.T, desc string, url string, routePattern string, cmd dtos.CreateShortURLCmd, shortURLService shorturls.Service, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - defer bus.ClearBusHandlers() - hs := HTTPServer{ Cfg: setting.NewCfg(), ShortURLService: shortURLService, diff --git a/pkg/api/signup.go b/pkg/api/signup.go index f3568d03daf..69591220c93 100644 --- a/pkg/api/signup.go +++ b/pkg/api/signup.go @@ -18,7 +18,7 @@ import ( // GET /api/user/signup/options func GetSignUpOptions(c *models.ReqContext) response.Response { - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "verifyEmailEnabled": setting.VerifyEmailEnabled, "autoAssignOrg": setting.AutoAssignOrg, }) @@ -64,7 +64,7 @@ func (hs *HTTPServer) SignUp(c *models.ReqContext) response.Response { metrics.MApiUserSignUpStarted.Inc() - return response.JSON(200, util.DynMap{"status": "SignUpCreated"}) + return response.JSON(http.StatusOK, util.DynMap{"status": "SignUpCreated"}) } func (hs *HTTPServer) SignUpStep2(c *models.ReqContext) response.Response { @@ -135,7 +135,7 @@ func (hs *HTTPServer) SignUpStep2(c *models.ReqContext) response.Response { metrics.MApiUserSignUpCompleted.Inc() - return response.JSON(200, apiResponse) + return response.JSON(http.StatusOK, apiResponse) } func (hs *HTTPServer) verifyUserSignUpEmail(ctx context.Context, email string, code string) (bool, response.Response) { diff --git a/pkg/api/swagger.go b/pkg/api/swagger.go index 37d88541e72..7c96b576b9f 100644 --- a/pkg/api/swagger.go +++ b/pkg/api/swagger.go @@ -1,7 +1,11 @@ package api -import "github.com/grafana/grafana/pkg/models" +import ( + "net/http" + + "github.com/grafana/grafana/pkg/models" +) func swaggerUI(c *models.ReqContext) { - c.HTML(200, "swagger", nil) + c.HTML(http.StatusOK, "swagger", nil) } diff --git a/pkg/api/team.go b/pkg/api/team.go index 77416f5bce8..79c0eac9982 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -44,7 +44,7 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response { c.Logger.Warn("Could not add creator to team because is not a real user") } } - return response.JSON(200, &util.DynMap{ + return response.JSON(http.StatusOK, &util.DynMap{ "teamId": team.Id, "message": "Team created", }) @@ -151,7 +151,7 @@ func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response { query.Result.Page = page query.Result.PerPage = perPage - return response.JSON(200, query.Result) + return response.JSON(http.StatusOK, query.Result) } // UserFilter returns the user ID used in a filter when querying a team @@ -198,7 +198,7 @@ func (hs *HTTPServer) GetTeamByID(c *models.ReqContext) response.Response { query.Result.AccessControl = hs.getAccessControlMetadata(c, c.OrgId, "teams:id:", strconv.FormatInt(query.Result.Id, 10)) query.Result.AvatarUrl = dtos.GetGravatarUrlWithDefault(query.Result.Email, query.Result.Name) - return response.JSON(200, &query.Result) + return response.JSON(http.StatusOK, &query.Result) } // GET /api/teams/:teamId/preferences diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index e39028872e8..a8c44cde66a 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -54,7 +54,7 @@ func (hs *HTTPServer) GetTeamMembers(c *models.ReqContext) response.Response { filteredMembers = append(filteredMembers, member) } - return response.JSON(200, filteredMembers) + return response.JSON(http.StatusOK, filteredMembers) } // POST /api/teams/:teamId/members @@ -89,7 +89,7 @@ func (hs *HTTPServer) AddTeamMember(c *models.ReqContext) response.Response { return response.Error(500, "Failed to add Member to Team", err) } - return response.JSON(200, &util.DynMap{ + return response.JSON(http.StatusOK, &util.DynMap{ "message": "Member added to Team", }) } diff --git a/pkg/api/user.go b/pkg/api/user.go index 95b69d0b462..8b0c59debf7 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -49,7 +49,7 @@ func (hs *HTTPServer) getUserUserProfile(c *models.ReqContext, userID int64) res query.Result.AccessControl = hs.getAccessControlMetadata(c, c.OrgId, "global.users:id:", strconv.FormatInt(userID, 10)) query.Result.AvatarUrl = dtos.GetGravatarUrl(query.Result.Email) - return response.JSON(200, query.Result) + return response.JSON(http.StatusOK, query.Result) } // GET /api/users/lookup @@ -73,7 +73,7 @@ func (hs *HTTPServer) GetUserByLoginOrEmail(c *models.ReqContext) response.Respo UpdatedAt: user.Updated, CreatedAt: user.Created, } - return response.JSON(200, &result) + return response.JSON(http.StatusOK, &result) } // POST /api/user @@ -176,7 +176,7 @@ func (hs *HTTPServer) getUserTeamList(ctx context.Context, orgID int64, userID i for _, team := range query.Result { team.AvatarUrl = dtos.GetGravatarUrlWithDefault(team.Email, team.Name) } - return response.JSON(200, query.Result) + return response.JSON(http.StatusOK, query.Result) } // GET /api/users/:id/orgs @@ -195,7 +195,7 @@ func (hs *HTTPServer) getUserOrgList(ctx context.Context, userID int64) response return response.Error(500, "Failed to get user organizations", err) } - return response.JSON(200, query.Result) + return response.JSON(http.StatusOK, query.Result) } func (hs *HTTPServer) validateUsingOrg(ctx context.Context, userID int64, orgID int64) bool { @@ -321,7 +321,7 @@ func (hs *HTTPServer) SetHelpFlag(c *models.ReqContext) response.Response { return response.Error(500, "Failed to update help flag", err) } - return response.JSON(200, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1}) + return response.JSON(http.StatusOK, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1}) } func (hs *HTTPServer) ClearHelpFlags(c *models.ReqContext) response.Response { @@ -334,7 +334,7 @@ func (hs *HTTPServer) ClearHelpFlags(c *models.ReqContext) response.Response { return response.Error(500, "Failed to update help flag", err) } - return response.JSON(200, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1}) + return response.JSON(http.StatusOK, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1}) } func GetAuthProviderLabel(authModule string) string { diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index bc4df9b7ec9..4e59ac05d8c 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -13,7 +13,6 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" @@ -48,7 +47,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { loggedInUserScenario(t, "When calling GET on", "api/users/1", "api/users/:id", func(sc *scenarioContext) { fakeNow := time.Date(2019, 2, 11, 17, 30, 40, 0, time.UTC) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) - authInfoStore := authinfostore.ProvideAuthInfoStore(sqlStore, bus.New(), secretsService) + authInfoStore := authinfostore.ProvideAuthInfoStore(sqlStore, secretsService) srv := authinfoservice.ProvideAuthInfoService(&authinfoservice.OSSUserProtectionImpl{}, authInfoStore) hs.authInfoService = srv @@ -107,25 +106,6 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { }, mock) loggedInUserScenario(t, "When calling GET on", "/api/users/lookup", "/api/users/lookup", func(sc *scenarioContext) { - fakeNow := time.Date(2019, 2, 11, 17, 30, 40, 0, time.UTC) - bus.AddHandler("test", func(ctx context.Context, query *models.GetUserByLoginQuery) error { - require.Equal(t, "danlee", query.LoginOrEmail) - - query.Result = &models.User{ - Id: int64(1), - Email: "daniel@grafana.com", - Name: "Daniel", - Login: "danlee", - Theme: "light", - IsAdmin: true, - OrgId: int64(2), - IsDisabled: false, - Updated: fakeNow, - Created: fakeNow, - } - - return nil - }) createUserCmd := models.CreateUserCommand{ Email: fmt.Sprint("admin", "@test.com"), Name: "admin", diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go index 53b040bd531..edb5c34da41 100644 --- a/pkg/api/user_token.go +++ b/pkg/api/user_token.go @@ -43,7 +43,7 @@ func (hs *HTTPServer) logoutUserFromAllDevicesInternal(ctx context.Context, user return response.Error(500, "Failed to logout user", err) } - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": "User logged out", }) } @@ -112,7 +112,7 @@ func (hs *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int }) } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) response.Response { @@ -144,7 +144,7 @@ func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID i return response.Error(500, "Failed to revoke user auth token", err) } - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "message": "User auth token revoked", }) } diff --git a/pkg/api/user_token_test.go b/pkg/api/user_token_test.go index 37766385f13..8131f9f28a5 100644 --- a/pkg/api/user_token_test.go +++ b/pkg/api/user_token_test.go @@ -8,7 +8,6 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -79,11 +78,6 @@ func TestUserTokenAPIEndpoint(t *testing.T) { token := &models.UserToken{Id: 2} mock := mockstore.NewSQLStoreMock() revokeUserAuthTokenInternalScenario(t, "Should not be successful", cmd, testUserID, token, func(sc *scenarioContext) { - bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error { - cmd.Result = &models.User{Id: testUserID} - return nil - }) - sc.userAuthTokenService.GetUserTokenProvider = func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) { return token, nil } @@ -153,12 +147,9 @@ func TestUserTokenAPIEndpoint(t *testing.T) { func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd models.RevokeAuthTokenCmd, userId int64, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() hs := HTTPServer{ - Bus: bus.GetBus(), AuthTokenService: fakeAuthTokenService, SQLStore: sqlStore, } @@ -183,12 +174,9 @@ func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePat func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, userId int64, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() hs := HTTPServer{ - Bus: bus.GetBus(), AuthTokenService: fakeAuthTokenService, SQLStore: sqlStore, } @@ -212,10 +200,7 @@ func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePatte func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId int64, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(desc, func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - hs := HTTPServer{ - Bus: bus.GetBus(), AuthTokenService: auth.NewFakeUserAuthTokenService(), SQLStore: sqlStore, } @@ -239,12 +224,9 @@ func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.RevokeAuthTokenCmd, userId int64, token *models.UserToken, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(desc, func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() hs := HTTPServer{ - Bus: bus.GetBus(), AuthTokenService: fakeAuthTokenService, SQLStore: sqlStore, } @@ -267,12 +249,9 @@ func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.R func getUserAuthTokensInternalScenario(t *testing.T, desc string, token *models.UserToken, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(desc, func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() hs := HTTPServer{ - Bus: bus.GetBus(), AuthTokenService: fakeAuthTokenService, SQLStore: sqlStore, } diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index e3f207ef814..4ca374e93b0 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -21,41 +21,17 @@ type Msg interface{} // ErrHandlerNotFound defines an error if a handler is not found var ErrHandlerNotFound = errors.New("handler not found") -// TransactionManager defines a transaction interface -type TransactionManager interface { - InTransaction(ctx context.Context, fn func(ctx context.Context) error) error -} - // Bus type defines the bus interface structure type Bus interface { - Dispatch(ctx context.Context, msg Msg) error - Publish(ctx context.Context, msg Msg) error - - // InTransaction starts a transaction and store it in the context. - // The caller can then pass a function with multiple DispatchCtx calls that - // all will be executed in the same transaction. InTransaction will rollback if the - // callback returns an error. - InTransaction(ctx context.Context, fn func(ctx context.Context) error) error - - AddHandler(handler HandlerFunc) - AddEventListener(handler HandlerFunc) - - // SetTransactionManager allows the user to replace the internal - // noop TransactionManager that is responsible for managing - // transactions in `InTransaction` - SetTransactionManager(tm TransactionManager) } // InProcBus defines the bus structure type InProcBus struct { logger log.Logger - handlers map[string]HandlerFunc - handlersWithCtx map[string]HandlerFunc listeners map[string][]HandlerFunc listenersWithCtx map[string][]HandlerFunc - txMng TransactionManager tracer tracing.Tracer } @@ -64,11 +40,6 @@ func ProvideBus(tracer tracing.Tracer) *InProcBus { return globalBus } -// InTransaction defines an in transaction function -func (b *InProcBus) InTransaction(ctx context.Context, fn func(ctx context.Context) error) error { - return b.txMng.InTransaction(ctx, fn) -} - // temp stuff, not sure how to handle bus instance, and init yet var globalBus = New() @@ -76,61 +47,13 @@ var globalBus = New() func New() *InProcBus { bus := &InProcBus{ logger: log.New("bus"), - handlers: make(map[string]HandlerFunc), - handlersWithCtx: make(map[string]HandlerFunc), listeners: make(map[string][]HandlerFunc), listenersWithCtx: make(map[string][]HandlerFunc), - txMng: &noopTransactionManager{}, } bus.tracer = tracing.InitializeForBus() return bus } -// Want to get rid of global bus -func GetBus() Bus { - return globalBus -} - -// SetTransactionManager function assign a transaction manager to the bus. -func (b *InProcBus) SetTransactionManager(tm TransactionManager) { - b.txMng = tm -} - -// DispatchCtx function dispatch a message to the bus context. -func (b *InProcBus) Dispatch(ctx context.Context, msg Msg) error { - var msgName = reflect.TypeOf(msg).Elem().Name() - - ctx, span := b.tracer.Start(ctx, "bus - "+msgName) - defer span.End() - - span.SetAttributes("msg", msgName, attribute.Key("msg").String(msgName)) - - withCtx := true - var handler = b.handlersWithCtx[msgName] - if handler == nil { - withCtx = false - handler = b.handlers[msgName] - if handler == nil { - return ErrHandlerNotFound - } - } - - var params = []reflect.Value{} - if withCtx { - params = append(params, reflect.ValueOf(ctx)) - } else if setting.Env == setting.Dev { - b.logger.Warn("DispatchCtx called with message handler registered using AddHandler and should be changed to use AddHandler", "msgName", msgName) - } - params = append(params, reflect.ValueOf(msg)) - - ret := reflect.ValueOf(handler).Call(params) - err := ret[0].Interface() - if err == nil { - return nil - } - return err.(error) -} - // PublishCtx function publish a message to the bus listener. func (b *InProcBus) Publish(ctx context.Context, msg Msg) error { var msgName = reflect.TypeOf(msg).Elem().Name() @@ -176,17 +99,6 @@ func callListeners(listeners []HandlerFunc, params []reflect.Value) error { return nil } -func (b *InProcBus) AddHandler(handler HandlerFunc) { - handlerType := reflect.TypeOf(handler) - queryTypeName := handlerType.In(1).Elem().Name() - b.handlersWithCtx[queryTypeName] = handler -} - -// GetHandlerCtx returns the handler function for the given struct name. -func (b *InProcBus) GetHandlerCtx(name string) HandlerFunc { - return b.handlersWithCtx[name] -} - func (b *InProcBus) AddEventListener(handler HandlerFunc) { handlerType := reflect.TypeOf(handler) eventName := handlerType.In(1).Elem().Name() @@ -197,36 +109,12 @@ func (b *InProcBus) AddEventListener(handler HandlerFunc) { b.listenersWithCtx[eventName] = append(b.listenersWithCtx[eventName], handler) } -// AddHandler attaches a handler function to the global bus context. -// Package level function. -func AddHandler(implName string, handler HandlerFunc) { - globalBus.AddHandler(handler) -} - // AddEventListenerCtx attaches a handler function to the event listener. // Package level function. func AddEventListener(handler HandlerFunc) { globalBus.AddEventListener(handler) } -func Dispatch(ctx context.Context, msg Msg) error { - return globalBus.Dispatch(ctx, msg) -} - func Publish(ctx context.Context, msg Msg) error { return globalBus.Publish(ctx, msg) } - -func GetHandlerCtx(name string) HandlerFunc { - return globalBus.GetHandlerCtx(name) -} - -func ClearBusHandlers() { - globalBus = New() -} - -type noopTransactionManager struct{} - -func (*noopTransactionManager) InTransaction(ctx context.Context, fn func(ctx context.Context) error) error { - return fn(ctx) -} diff --git a/pkg/bus/bus_test.go b/pkg/bus/bus_test.go index 05789ef1f8b..3bb6a6bb86e 100644 --- a/pkg/bus/bus_test.go +++ b/pkg/bus/bus_test.go @@ -2,7 +2,6 @@ package bus import ( "context" - "errors" "testing" "github.com/grafana/grafana/pkg/infra/tracing" @@ -14,140 +13,6 @@ type testQuery struct { Resp string } -func TestDispatch(t *testing.T) { - bus := New() - tracer, err := tracing.InitializeTracerForTest() - require.NoError(t, err) - bus.tracer = tracer - - var invoked bool - - bus.AddHandler(func(ctx context.Context, query *testQuery) error { - invoked = true - return nil - }) - - require.NoError(t, err) - err = bus.Dispatch(context.Background(), &testQuery{}) - require.NoError(t, err) - - require.True(t, invoked, "expected handler to be called") -} - -func TestDispatch_NoRegisteredHandler(t *testing.T) { - bus := New() - tracer, err := tracing.InitializeTracerForTest() - require.NoError(t, err) - bus.tracer = tracer - - err = bus.Dispatch(context.Background(), &testQuery{}) - require.Equal(t, err, ErrHandlerNotFound, - "expected bus to return HandlerNotFound since no handler is registered") -} - -func TestDispatch_ContextHandler(t *testing.T) { - bus := New() - tracer, err := tracing.InitializeTracerForTest() - require.NoError(t, err) - bus.tracer = tracer - - var invoked bool - - bus.AddHandler(func(ctx context.Context, query *testQuery) error { - invoked = true - return nil - }) - - err = bus.Dispatch(context.Background(), &testQuery{}) - require.NoError(t, err) - - require.True(t, invoked, "expected handler to be called") -} - -func TestDispatchCtx(t *testing.T) { - bus := New() - tracer, err := tracing.InitializeTracerForTest() - require.NoError(t, err) - bus.tracer = tracer - - var invoked bool - - bus.AddHandler(func(ctx context.Context, query *testQuery) error { - invoked = true - return nil - }) - - err = bus.Dispatch(context.Background(), &testQuery{}) - require.NoError(t, err) - - require.True(t, invoked, "expected handler to be called") -} - -func TestDispatchCtx_NoContextHandler(t *testing.T) { - bus := New() - tracer, err := tracing.InitializeTracerForTest() - require.NoError(t, err) - bus.tracer = tracer - - var invoked bool - - bus.AddHandler(func(ctx context.Context, query *testQuery) error { - invoked = true - return nil - }) - - err = bus.Dispatch(context.Background(), &testQuery{}) - require.NoError(t, err) - - require.True(t, invoked, "expected handler to be called") -} - -func TestDispatchCtx_NoRegisteredHandler(t *testing.T) { - bus := New() - tracer, err := tracing.InitializeTracerForTest() - require.NoError(t, err) - bus.tracer = tracer - - err = bus.Dispatch(context.Background(), &testQuery{}) - require.Equal(t, err, ErrHandlerNotFound, - "expected bus to return HandlerNotFound since no handler is registered") -} - -func TestQuery(t *testing.T) { - bus := New() - tracer, err := tracing.InitializeTracerForTest() - require.NoError(t, err) - bus.tracer = tracer - - want := "hello from handler" - - bus.AddHandler(func(ctx context.Context, q *testQuery) error { - q.Resp = want - return nil - }) - - q := &testQuery{} - - err = bus.Dispatch(context.Background(), q) - require.NoError(t, err, "unable to dispatch query") - - require.Equal(t, want, q.Resp) -} - -func TestQuery_HandlerReturnsError(t *testing.T) { - bus := New() - tracer, err := tracing.InitializeTracerForTest() - require.NoError(t, err) - bus.tracer = tracer - - bus.AddHandler(func(ctx context.Context, query *testQuery) error { - return errors.New("handler error") - }) - - err = bus.Dispatch(context.Background(), &testQuery{}) - require.Error(t, err, "expected error but got none") -} - func TestEventPublish(t *testing.T) { bus := New() tracer, err := tracing.InitializeTracerForTest() diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go index 82bcb8ffac3..37354ab98ae 100644 --- a/pkg/cmd/grafana-cli/commands/commands.go +++ b/pkg/cmd/grafana-cli/commands/commands.go @@ -4,7 +4,6 @@ import ( "strings" "github.com/fatih/color" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/cmd/grafana-cli/commands/datamigrations" "github.com/grafana/grafana/pkg/cmd/grafana-cli/commands/secretsmigrations" "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" @@ -56,7 +55,7 @@ func runDbCommand(command func(commandLine utils.CommandLine, sqlStore *sqlstore return errutil.Wrap("failed to initialize tracer service", err) } - sqlStore, err := sqlstore.ProvideService(cfg, nil, bus.GetBus(), &migrations.OSSMigrations{}, tracer) + sqlStore, err := sqlstore.ProvideService(cfg, nil, &migrations.OSSMigrations{}, tracer) if err != nil { return errutil.Wrap("failed to initialize SQL store", err) } diff --git a/pkg/components/imguploader/s3uploader.go b/pkg/components/imguploader/s3uploader.go index 9cb0a7f7c57..7228ac85a75 100644 --- a/pkg/components/imguploader/s3uploader.go +++ b/pkg/components/imguploader/s3uploader.go @@ -114,6 +114,8 @@ func webIdentityProvider(sess client.ConfigProvider) credentials.Provider { roleARN := os.Getenv("AWS_ROLE_ARN") tokenFilepath := os.Getenv("AWS_WEB_IDENTITY_TOKEN_FILE") roleSessionName := os.Getenv("AWS_ROLE_SESSION_NAME") + + // nolint:staticcheck return stscreds.NewWebIdentityRoleProvider(svc, roleARN, roleSessionName, tokenFilepath) } diff --git a/pkg/coremodel/dashboard/coremodel.go b/pkg/coremodel/dashboard/coremodel.go new file mode 100644 index 00000000000..ed36c36dc7b --- /dev/null +++ b/pkg/coremodel/dashboard/coremodel.go @@ -0,0 +1,40 @@ +package dashboard + +import ( + "github.com/grafana/thema" +) + +// Coremodel contains the foundational schema declaration for dashboards. +type Coremodel struct { + lin thema.Lineage +} + +// Lineage returns the canonical dashboard Lineage. +func (c *Coremodel) Lineage() thema.Lineage { + return c.lin +} + +func (c *Coremodel) CurrentSchema() thema.Schema { + sch, err := c.lin.Schema(currentVersion) + if err != nil { + // Only reachable if our own schema currentVersion does not exist, which + // can really only happen transitionally during development + panic(err) + } + return sch +} + +func (c *Coremodel) GoType() interface{} { + return &model{} +} + +func ProvideCoremodel(lib thema.Library) (*Coremodel, error) { + lin, err := Lineage(lib) + if err != nil { + return nil, err + } + + return &Coremodel{ + lin: lin, + }, nil +} diff --git a/pkg/coremodel/dashboard/lineage.cue b/pkg/coremodel/dashboard/lineage.cue new file mode 100644 index 00000000000..bd1c1db4f2e --- /dev/null +++ b/pkg/coremodel/dashboard/lineage.cue @@ -0,0 +1,351 @@ +package dashboard + +import "github.com/grafana/thema" + +thema.#Lineage +name: "dashboard" +seqs: [ + { + schemas: [ + {// 0.0 + // Unique numeric identifier for the dashboard. + // TODO must isolate or remove identifiers local to a Grafana instance...? + id?: int64 + // Unique dashboard identifier that can be generated by anyone. string (8-40) + uid?: string + // Title of dashboard. + title?: string + // Description of dashboard. + description?: string + + gnetId?: string + // Tags associated with dashboard. + tags?: [...string] + // Theme of dashboard. + style: *"light" | "dark" + // Timezone of dashboard, + timezone?: *"browser" | "utc" | "" + // Whether a dashboard is editable or not. + editable: bool | *true + // 0 for no shared crosshair or tooltip (default). + // 1 for shared crosshair. + // 2 for shared crosshair AND shared tooltip. + graphTooltip: uint8 & >=0 & <=2 | *0 + // Time range for dashboard, e.g. last 6 hours, last 7 days, etc + time?: { + from: string | *"now-6h" + to: string | *"now" + } + // Timepicker metadata. + timepicker?: { + // Whether timepicker is collapsed or not. + collapse: bool | *false + // Whether timepicker is enabled or not. + enable: bool | *true + // Whether timepicker is visible or not. + hidden: bool | *false + // Selectable intervals for auto-refresh. + refresh_intervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + } + // Templating. + templating?: list: [...{...}] + // Annotations. + annotations?: list: [...{ + builtIn: uint8 | *0 + // Datasource to use for annotation. + datasource: { + type?: string + uid?: string + } + // Whether annotation is enabled. + enable: bool | *true + // Whether to hide annotation. + hide?: bool | *false + // Annotation icon color. + iconColor?: string + // Name of annotation. + name?: string + type: string | *"dashboard" + // Query for annotation data. + rawQuery?: string + showIn: uint8 | *0 + }] + // Auto-refresh interval. + refresh?: string | false + // Version of the JSON schema, incremented each time a Grafana update brings + // changes to said schema. + // FIXME this is the old schema numbering system, and will be replaced by Thema's themaVersion + schemaVersion: uint16 | *33 + // Version of the dashboard, incremented each time the dashboard is updated. + version?: uint32 + panels?: [...(#Panel | #GraphPanel | #HeatmapPanel | #RowPanel)] + + // TODO docs + #FieldColorModeId: "thresholds" | "palette-classic" | "palette-saturated" | "continuous-GrYlRd" | "fixed" @cuetsy(kind="enum") + + // TODO docs + #FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type") + + // TODO docs + #FieldColor: { + // The main color scheme mode + mode: #FieldColorModeId | string + // Stores the fixed color value if mode is fixed + fixedColor?: string + // Some visualizations need to know how to assign a series color from by value color schemes + seriesBy?: #FieldColorSeriesByMode + } @cuetsy(kind="interface") + + // TODO docs + #Threshold: { + // TODO docs + // FIXME the corresponding typescript field is required/non-optional, but nulls currently appear here when serializing -Infinity to JSON + value?: number + // TODO docs + color: string + // TODO docs + // TODO are the values here enumerable into a disjunction? + // Some seem to be listed in typescript comment + state?: string + } @cuetsy(kind="interface") + + #ThresholdsMode: "absolute" | "percentage" @cuetsy(kind="enum") + + #ThresholdsConfig: { + mode: #ThresholdsMode + + // Must be sorted by 'value', first value is always -Infinity + steps: [...#Threshold] + } @cuetsy(kind="interface") + + // TODO docs + // FIXME this is extremely underspecfied; wasn't obvious which typescript types corresponded to it + #Transformation: { + id: string + options: {...} + } + + // Schema for panel targets is specified by datasource + // plugins. We use a placeholder definition, which the Go + // schema loader either left open/as-is with the Base + // variant of the Dashboard and Panel families, or filled + // with types derived from plugins in the Instance variant. + // When working directly from CUE, importers can extend this + // type directly to achieve the same effect. + #Target: {...} + + // Dashboard panels. Panels are canonically defined inline + // because they share a version timeline with the dashboard + // schema; they do not evolve independently. + #Panel: { + // The panel plugin type id. + type: !="" + + // TODO docs + id?: uint32 + + // FIXME this almost certainly has to be changed in favor of scuemata versions + pluginVersion?: string + + // TODO docs + tags?: [...string] + + // Internal - the exact major and minor versions of the panel plugin + // schema. Hidden and therefore not a part of the data model, but + // expected to be filled with panel plugin schema versions so that it's + // possible to figure out which schema version matched on a successful + // unification. + // _pv: { maj: int, min: int } + // The major and minor versions of the panel plugin for this schema. + // TODO 2-tuple list instead of struct? + // panelSchema?: { maj: number, min: number } + panelSchema?: [number, number] + + // TODO docs + targets?: [...#Target] + + // Panel title. + title?: string + // Description. + description?: string + // Whether to display the panel without a background. + transparent: bool | *false + // The datasource used in all targets. + datasource?: { + type?: string + uid?: string + } + // Grid position. + gridPos?: { + // Panel + h: uint32 & >0 | *9 + // Panel + w: uint32 & >0 & <=24 | *12 + // Panel x + x: uint32 & >=0 & <24 | *0 + // Panel y + y: uint32 & >=0 | *0 + // true if fixed + static?: bool + } + // Panel links. + // FIXME this is temporarily specified as a closed list so + // that validation will pass when no links are present, but + // to force a failure as soon as it's checked against there + // being anything in the list so it can be fixed in + // accordance with that object + links?: [] + + // Name of template variable to repeat for. + repeat?: string + // Direction to repeat in if 'repeat' is set. + // "h" for horizontal, "v" for vertical. + repeatDirection: *"h" | "v" + + // TODO docs + maxDataPoints?: number + + // TODO docs + thresholds?: [...] + + // TODO docs + timeRegions?: [...] + + transformations: [...#Transformation] + + // TODO docs + // TODO tighter constraint + interval?: string + + // TODO docs + // TODO tighter constraint + timeFrom?: string + + // TODO docs + // TODO tighter constraint + timeShift?: string + + // options is specified by the PanelOptions field in panel + // plugin schemas. + options: {...} + + fieldConfig: { + defaults: { + // The display value for this field. This supports template variables blank is auto + displayName?: string + + // This can be used by data sources that return and explicit naming structure for values and labels + // When this property is configured, this value is used rather than the default naming strategy. + displayNameFromDS?: string + + // Human readable field metadata + description?: string + + // An explict path to the field in the datasource. When the frame meta includes a path, + // This will default to `${frame.meta.path}/${field.name} + // + // When defined, this value can be used as an identifier within the datasource scope, and + // may be used to update the results + path?: string + + // True if data source can write a value to the path. Auth/authz are supported separately + writeable?: bool + + // True if data source field supports ad-hoc filters + filterable?: bool + + // Numeric Options + unit?: string + + // Significant digits (for display) + decimals?: number + + min?: number + max?: number + + // Convert input values into a display string + // + // TODO this one corresponds to a complex type with + // generics on the typescript side. Ouch. Will + // either need special care, or we'll just need to + // accept a very loosely specified schema. It's very + // unlikely we'll be able to translate cue to + // typescript generics in the general case, though + // this particular one *may* be able to work. + mappings?: [...{...}] + + // Map numeric values to states + thresholds?: #ThresholdsConfig + + // // Map values to a display color + color?: #FieldColor + + // // Used when reducing field values + // nullValueMode?: NullValueMode + + // // The behavior when clicking on a result + links?: [...] + + // Alternative to empty string + noValue?: string + + // custom is specified by the PanelFieldConfig field + // in panel plugin schemas. + custom?: {...} + } + overrides: [...{ + matcher: { + id: string | *"" + options?: _ + } + properties: [...{ + id: string | *"" + value?: _ + }] + }] + } + } + + // Row panel + #RowPanel: { + type: "row" + collapsed: bool | *false + title?: string + + // Name of default datasource. + datasource?: { + type?: string + uid?: string + } + + gridPos?: { + // Panel + h: uint32 & >0 | *9 + // Panel + w: uint32 & >0 & <=24 | *12 + // Panel x + x: uint32 & >=0 & <24 | *0 + // Panel y + y: uint32 & >=0 | *0 + // true if fixed + static?: bool + } + id: uint32 + panels: [...(#Panel | #GraphPanel | #HeatmapPanel)] + // Name of template variable to repeat for. + repeat?: string + } + + // Support for legacy graph and heatmap panels. + #GraphPanel: { + type: "graph" + ... + } + #HeatmapPanel: { + type: "heatmap" + ... + } + }, + ] + }, +] diff --git a/pkg/coremodel/dashboard/schema.go b/pkg/coremodel/dashboard/schema.go new file mode 100644 index 00000000000..c7b7abc084a --- /dev/null +++ b/pkg/coremodel/dashboard/schema.go @@ -0,0 +1,98 @@ +package dashboard + +import ( + "embed" + "path/filepath" + + "github.com/grafana/thema" + + "github.com/grafana/grafana/pkg/cuectx" +) + +var ( + //go:embed lineage.cue + cueFS embed.FS + + // TODO: this should be generated by Thema. + currentVersion = thema.SV(0, 0) +) + +// HandoffSchemaVersion is the minimum schemaVersion for dashboards at which the +// Thema-based dashboard schema is known to be valid. +// +// schemaVersion is the original version numbering system for dashboards. If a +// dashboard is below this schemaVersion, it is necessary for the frontend +// typescript dashboard migration logic to first run and get it past this +// number, after which Thema can take over. +const HandoffSchemaVersion = 36 + +// Lineage returns the Thema lineage representing Grafana dashboards. The +// lineage is the canonical specification of the current datasource schema, all +// prior schema versions, and the mappings that allow migration between schema +// versions. +// +// This is the base variant of the schema, which does not include any composed +// plugin schemas. +func Lineage(lib thema.Library, opts ...thema.BindOption) (thema.Lineage, error) { + return cuectx.LoadGrafanaInstancesWithThema(filepath.Join("pkg", "coremodel", "dashboard"), cueFS, lib, opts...) +} + +// Model is a dummy struct stand-in for dashboards. +// +// It exists solely to trick compgen into accepting the dashboard coremodel as valid. +type Model struct{} + +// model is a hacky Go struct representing a dashboard. +// +// This exists solely because the coremodel framework enforces that there is a Go struct to which +// all valid Thema schema instances can be assigned, per Thema's assignability checker. See +// https://github.com/grafana/thema/blob/main/docs/invariants.md#go-assignability for rules. +// +// DO NOT RELY ON THIS FOR ANYTHING REAL. It is unclear whether we will ever attempt to have a correct, complete +// Go struct representation of dashboards, let alone compress it into a single struct. +type model struct { + Title string `json:"title"` + Description string `json:"description"` + GnetId string `json:"gnetId"` + Tags []string `json:"tags"` + Style string `json:"style"` + Timezone string `json:"timezone"` + Editable bool `json:"editable"` + GraphTooltip uint8 `json:"graphTooltip"` + Time struct { + From string `json:"from"` + To string `json:"to"` + } `json:"time"` + Timepicker struct { + Collapse bool `json:"collapse"` + Enable bool `json:"enable"` + Hidden bool `json:"hidden"` + RefreshIntervals []string `json:"refresh_intervals"` + } `json:"timepicker"` + Templating struct { + List []interface{} `json:"list"` + } `json:"templating"` + Annotations struct { + List []struct { + Name string `json:"name"` + Type string `json:"type"` + BuiltIn uint8 `json:"builtIn"` + Datasource string `json:"datasource"` + Enable bool `json:"enable"` + Hide bool `json:"hide,omitempty"` + IconColor string `json:"iconColor"` + RawQuery string `json:"rawQuery,omitempty"` + ShowIn int `json:"showIn"` + } `json:"list"` + } `json:"annotations"` + Refresh interface{} `json:"refresh"` // (bool|string) + SchemaVersion int `json:"schemaVersion"` + Panels []interface{} `json:"panels"` + + // // + + Uid string `json:"uid"` + // OrgId int64 `json:"orgId"` + Id int64 `json:"id,omitempty"` + Version int `json:"version"` +} diff --git a/pkg/cuectx/ctx.go b/pkg/cuectx/ctx.go new file mode 100644 index 00000000000..bddbe1ad1a0 --- /dev/null +++ b/pkg/cuectx/ctx.go @@ -0,0 +1,116 @@ +// Package cuectx provides a single, central CUE context (runtime) and Thema +// library that can be used uniformly across Grafana, and related helper +// functions for loading Thema lineages. + +package cuectx + +import ( + "io" + "io/fs" + "path/filepath" + "testing/fstest" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "github.com/grafana/thema" + "github.com/grafana/thema/kernel" + "github.com/grafana/thema/load" +) + +var ctx *cue.Context = cuecontext.New() +var lib thema.Library = thema.NewLibrary(ctx) + +// ProvideCUEContext is a wire service provider of a central cue.Context. +func ProvideCUEContext() *cue.Context { + return ctx +} + +// ProvideThemaLibrary is a wire service provider of a central thema.Library. +func ProvideThemaLibrary() thema.Library { + return lib +} + +// JSONtoCUE attempts to decode the given []byte into a cue.Value, relying on +// the central Grafana cue.Context provided in this package. +// +// The provided path argument determines the name given to the input bytes if +// later CUE operations (e.g. Thema validation) produce errors related to the +// returned cue.Value. +// +// This is a convenience function for one-off JSON decoding. It's wasteful to +// call it repeatedly. Most use cases use cases should probably prefer making +// their own Thema/CUE decoders. +func JSONtoCUE(path string, b []byte) (cue.Value, error) { + return kernel.NewJSONDecoder(path)(ctx, b) +} + +// LoadGrafanaInstancesWithThema loads CUE files containing a lineage +// representing some Grafana core model schema. It is expected to be used when +// implementing a thema.LineageFactory. +// +// This function primarily juggles paths to make CUE's loader happy. Provide the +// path from the grafana root to the directory containing the lineage.cue. The +// lineage.cue file must be the sole contents of the provided fs.FS. +// +// More details on underlying behavior can be found in the docs for github.com/grafana/thema/load.InstancesWithThema. +func LoadGrafanaInstancesWithThema( + path string, + cueFS fs.FS, + lib thema.Library, + opts ...thema.BindOption, +) (thema.Lineage, error) { + prefix := filepath.FromSlash(path) + fs, err := prefixWithGrafanaCUE(prefix, cueFS) + if err != nil { + return nil, err + } + inst, err := load.InstancesWithThema(fs, prefix) + + // Need to trick loading by creating the embedded file and + // making it look like a module in the root dir. + if err != nil { + return nil, err + } + + val := lib.Context().BuildInstance(inst) + + lin, err := thema.BindLineage(val, lib, opts...) + if err != nil { + return nil, err + } + + return lin, nil +} + +func prefixWithGrafanaCUE(prefix string, inputfs fs.FS) (fs.FS, error) { + m := fstest.MapFS{ + filepath.Join("cue.mod", "module.cue"): &fstest.MapFile{Data: []byte(`module: "github.com/grafana/grafana"`)}, + } + + prefix = filepath.FromSlash(prefix) + err := fs.WalkDir(inputfs, ".", (func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + f, err := inputfs.Open(path) + if err != nil { + return err + } + defer f.Close() // nolint: errcheck + + b, err := io.ReadAll(f) + if err != nil { + return err + } + + m[filepath.Join(prefix, path)] = &fstest.MapFile{Data: b} + return nil + })) + + return m, err +} diff --git a/pkg/expr/service_test.go b/pkg/expr/service_test.go index 0449a98a4cc..6741174baad 100644 --- a/pkg/expr/service_test.go +++ b/pkg/expr/service_test.go @@ -10,8 +10,6 @@ import ( "github.com/google/go-cmp/cmp" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -38,11 +36,6 @@ func TestService(t *testing.T) { secretsService: secretsService, } - bus.AddHandler("test", func(_ context.Context, query *models.GetDataSourceQuery) error { - query.Result = &models.DataSource{Uid: "1", OrgId: 1, Type: "test", JsonData: simplejson.New()} - return nil - }) - queries := []Query{ { RefID: "A", diff --git a/pkg/expr/transform.go b/pkg/expr/transform.go index c0d12b495f0..fb327613fe9 100644 --- a/pkg/expr/transform.go +++ b/pkg/expr/transform.go @@ -1,6 +1,7 @@ package expr import ( + "context" "encoding/json" "fmt" "time" @@ -8,7 +9,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/models" "github.com/prometheus/client_golang/prometheus" - "golang.org/x/net/context" ) var ( diff --git a/pkg/framework/coremodel/helpers.go b/pkg/framework/coremodel/helpers.go new file mode 100644 index 00000000000..d3f88a856d5 --- /dev/null +++ b/pkg/framework/coremodel/helpers.go @@ -0,0 +1 @@ +package coremodel diff --git a/pkg/framework/coremodel/interface.go b/pkg/framework/coremodel/interface.go new file mode 100644 index 00000000000..07957dd3b44 --- /dev/null +++ b/pkg/framework/coremodel/interface.go @@ -0,0 +1,25 @@ +package coremodel + +import ( + "github.com/grafana/thema" +) + +// Interface is the primary coremodel interface that must be implemented by all +// Grafana coremodels. A coremodel is the foundational, canonical schema for +// some known-at-compile-time Grafana object. +// +// Currently, all Coremodels are expressed as Thema lineages. +type Interface interface { + // Lineage should return the canonical Thema lineage for the coremodel. + Lineage() thema.Lineage + + // CurrentSchema should return the schema of the version that the Grafana backend + // is currently written against. (While Grafana can accept data from all + // older versions of the Thema schema, backend Go code is written against a + // single version for simplicity) + CurrentSchema() thema.Schema + + // GoType should return a pointer to the Go struct type that corresponds to + // the Current() schema. + GoType() interface{} +} diff --git a/pkg/framework/coremodel/registry.go b/pkg/framework/coremodel/registry.go new file mode 100644 index 00000000000..0fe44809d2f --- /dev/null +++ b/pkg/framework/coremodel/registry.go @@ -0,0 +1,78 @@ +package coremodel + +import ( + "errors" + "fmt" + "sync" + + "github.com/grafana/thema" +) + +var ( + // ErrModelAlreadyRegistered is returned when trying to register duplicate model to Registry. + ErrModelAlreadyRegistered = errors.New("error registering duplicate model") +) + +// Registry is a registry of coremodel instances. +type Registry struct { + lock sync.RWMutex + models []Interface + modelIdx map[string]Interface +} + +// NewRegistry returns a new Registry with the provided coremodel instances. +func NewRegistry(models ...Interface) (*Registry, error) { + r := &Registry{ + models: make([]Interface, 0, len(models)), + modelIdx: make(map[string]Interface, len(models)), + } + + if err := r.addModels(models); err != nil { + return nil, err + } + + return r, nil +} + +// Register adds coremodels to the Registry. +func (r *Registry) Register(models ...Interface) error { + return r.addModels(models) +} + +// List returns all coremodels registered in this Registry. +func (r *Registry) List() []Interface { + r.lock.RLock() + defer r.lock.RUnlock() + + return r.models +} + +func (r *Registry) addModels(models []Interface) error { + r.lock.Lock() + defer r.lock.Unlock() + + // Update model index and return an error if trying to register a duplicate. + for _, m := range models { + k := m.Lineage().Name() + + // Ensure assignability first. TODO will this blow up for dashboards? + if err := thema.AssignableTo(m.CurrentSchema(), m.GoType()); err != nil { + return fmt.Errorf("%s schema version %v not assignable to provided Go type: %w", k, m.CurrentSchema().Version(), err) + } + + if _, ok := r.modelIdx[k]; ok { + return ErrModelAlreadyRegistered + } + + r.modelIdx[k] = m + } + + // Remake model list. + // TODO: this can be more performant (proper resizing, maybe single loop with index building, etc.). + r.models = r.models[:0] + for _, m := range r.modelIdx { + r.models = append(r.models, m) + } + + return nil +} diff --git a/pkg/framework/coremodel/staticregistry/provide.go b/pkg/framework/coremodel/staticregistry/provide.go new file mode 100644 index 00000000000..09a12bd3c77 --- /dev/null +++ b/pkg/framework/coremodel/staticregistry/provide.go @@ -0,0 +1,17 @@ +package staticregistry + +import ( + "github.com/grafana/grafana/pkg/coremodel/dashboard" + "github.com/grafana/grafana/pkg/framework/coremodel" +) + +// ProvideRegistry provides a simple static Registry for coremodels. +// Coremodels have to be manually added. +// TODO dynamism +func ProvideRegistry( + dashboard *dashboard.Coremodel, +) (*coremodel.Registry, error) { + return coremodel.NewRegistry( + dashboard, + ) +} diff --git a/pkg/infra/filestorage/api.go b/pkg/infra/filestorage/api.go index 3a4a5c285ba..93515f2c30e 100644 --- a/pkg/infra/filestorage/api.go +++ b/pkg/infra/filestorage/api.go @@ -51,9 +51,12 @@ type Paging struct { } type UpsertFileCommand struct { - Path string - MimeType string - Contents *[]byte + Path string + MimeType string + + // Contents of an existing file won't be modified if cmd.Contents is nil + Contents []byte + // Properties of an existing file won't be modified if cmd.Properties is nil Properties map[string]string } diff --git a/pkg/infra/filestorage/cdk_blob_filestorage.go b/pkg/infra/filestorage/cdk_blob_filestorage.go index 27a5f4aac03..53f165fac9a 100644 --- a/pkg/infra/filestorage/cdk_blob_filestorage.go +++ b/pkg/infra/filestorage/cdk_blob_filestorage.go @@ -98,7 +98,7 @@ func (c cdkBlobStorage) Upsert(ctx context.Context, command *UpsertFileCommand) if command.Contents == nil { contents = make([]byte, 0) } else { - contents = *command.Contents + contents = command.Contents } metadata = make(map[string]string) @@ -115,7 +115,7 @@ func (c cdkBlobStorage) Upsert(ctx context.Context, command *UpsertFileCommand) contents = existing.Contents if command.Contents != nil { - contents = *command.Contents + contents = command.Contents } if command.Properties != nil { diff --git a/pkg/infra/filestorage/db_filestorage.go b/pkg/infra/filestorage/db_filestorage.go index 06cfba9c1f7..ae4db90b713 100644 --- a/pkg/infra/filestorage/db_filestorage.go +++ b/pkg/infra/filestorage/db_filestorage.go @@ -141,7 +141,7 @@ func (s dbFileStorage) Upsert(ctx context.Context, cmd *UpsertFileCommand) error if exists { existing.Updated = now if cmd.Contents != nil { - contents := *cmd.Contents + contents := cmd.Contents existing.Contents = contents existing.MimeType = cmd.MimeType existing.Size = int64(len(contents)) @@ -154,7 +154,7 @@ func (s dbFileStorage) Upsert(ctx context.Context, cmd *UpsertFileCommand) error } else { contentsToInsert := make([]byte, 0) if cmd.Contents != nil { - contentsToInsert = *cmd.Contents + contentsToInsert = cmd.Contents } file := &file{ diff --git a/pkg/infra/filestorage/fs_integration_test.go b/pkg/infra/filestorage/fs_integration_test.go index 5b9268497ff..1a756545a2c 100644 --- a/pkg/infra/filestorage/fs_integration_test.go +++ b/pkg/infra/filestorage/fs_integration_test.go @@ -152,7 +152,7 @@ func runTests(createCases func() []fsTestCase, t *testing.T) { func TestFsStorage(t *testing.T) { //skipTest := true - emptyFileBytes := make([]byte, 0) + emptyContents := make([]byte, 0) pngImage, _ := base64.StdEncoding.DecodeString(pngImageBase64) pngImageSize := int64(len(pngImage)) @@ -164,21 +164,21 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder1/folder2/file.jpg", - Contents: &[]byte{}, + Contents: emptyContents, Properties: map[string]string{"prop1": "val1", "prop2": "val"}, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder1/file-inner.jpg", - Contents: &[]byte{}, + Contents: emptyContents, Properties: map[string]string{"prop1": "val1", "prop2": "val"}, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder1/file-inner2.jpg", - Contents: &[]byte{}, + Contents: emptyContents, }, }, queryListFiles{ @@ -261,25 +261,25 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/ab/a.jpg", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/ab/a/a.jpg", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/ac/a.jpg", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/aba/a.jpg", - Contents: &[]byte{}, + Contents: emptyContents, }, }, queryListFiles{ @@ -307,14 +307,14 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder1/folder2/file.jpg", - Contents: &[]byte{}, + Contents: emptyContents, Properties: map[string]string{"prop1": "val1", "prop2": "val"}, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder1/file-inner.jpg", - Contents: &[]byte{}, + Contents: emptyContents, Properties: map[string]string{"prop1": "val1"}, }, }, @@ -370,13 +370,13 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder1/folder2/file.jpg", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder1/file-inner.jpg", - Contents: &[]byte{}, + Contents: emptyContents, }, }, queryListFiles{ @@ -411,19 +411,19 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder1/a", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder1/b", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder2/c", - Contents: &[]byte{}, + Contents: emptyContents, }, }, queryListFiles{ @@ -533,25 +533,25 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder1/folder2/file.jpg", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder1/file-inner.jpg", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folderX/folderZ/file.txt", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folderA/folderB/file.txt", - Contents: &[]byte{}, + Contents: emptyContents, }, }, queryListFolders{ @@ -585,25 +585,25 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder1/folder2/file.jpg", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder1/file-inner.jpg", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folderX/folderZ/file.txt", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folderA/folderB/file.txt", - Contents: &[]byte{}, + Contents: emptyContents, }, }, queryListFolders{ @@ -673,7 +673,7 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder/a.png", - Contents: &pngImage, + Contents: pngImage, Properties: map[string]string{"prop1": "val1", "prop2": "val"}, }, }, @@ -698,7 +698,7 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/Folder/A.png", - Contents: &emptyFileBytes, + Contents: emptyContents, }, }, queryGet{ @@ -718,7 +718,7 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/a.png", - Contents: &pngImage, + Contents: pngImage, Properties: map[string]string{"a": "av", "b": "bv"}, }, }, @@ -754,7 +754,7 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/aB.png", - Contents: &emptyFileBytes, + Contents: emptyContents, Properties: map[string]string{"a": "av", "b": "bv"}, }, }, @@ -791,7 +791,7 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/FILE.png", - Contents: &emptyFileBytes, + Contents: emptyContents, Properties: map[string]string{"a": "av", "b": "bv"}, }, }, @@ -803,13 +803,13 @@ func TestFsStorage(t *testing.T) { fName("FILE.png"), fProperties(map[string]string{"a": "av", "b": "bv"}), fSize(0), - fContents(emptyFileBytes), + fContents(emptyContents), ), }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/file.png", - Contents: &pngImage, + Contents: pngImage, }, }, queryGet{ @@ -832,7 +832,7 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/FILE.png", - Contents: &emptyFileBytes, + Contents: emptyContents, Properties: map[string]string{"a": "av", "b": "bv"}, }, }, @@ -855,7 +855,7 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/file.png", - Contents: &emptyFileBytes, + Contents: emptyContents, Properties: map[string]string{"a": "av", "b": "bv"}, }, }, @@ -888,7 +888,7 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/aB/cD/eF/file.jpg", - Contents: &[]byte{}, + Contents: emptyContents, }, }, queryListFolders{ @@ -1007,7 +1007,7 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/a/b/c/file.jpg", - Contents: &[]byte{}, + Contents: emptyContents, }, }, queryListFolders{ @@ -1119,7 +1119,7 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/folder/dashboards/myNewFolder/file.jpg", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdDeleteFolder{ @@ -1164,43 +1164,43 @@ func TestFsStorage(t *testing.T) { cmdUpsert{ cmd: UpsertFileCommand{ Path: "/s3/folder/dashboard.json", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/s3/folder/nested/dashboard.json", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/gitA/dashboard1.json", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/gitA/dashboard2.json", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/gitB/nested/dashboard.json", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/gitB/nested2/dashboard2.json", - Contents: &[]byte{}, + Contents: emptyContents, }, }, cmdUpsert{ cmd: UpsertFileCommand{ Path: "/gitC/nestedC/dashboardC.json", - Contents: &[]byte{}, + Contents: emptyContents, }, }, queryListFiles{ diff --git a/pkg/infra/log/level/level.go b/pkg/infra/log/level/level.go index cc3ef214491..5b1308d5a17 100644 --- a/pkg/infra/log/level/level.go +++ b/pkg/infra/log/level/level.go @@ -196,6 +196,14 @@ var ( debugValue = &levelValue{level: levelDebug, name: "dbug"} ) +func SetLevelKeyAndValuesToGokitLog() { + key = "level" + errorValue = &levelValue{level: levelError, name: "error"} + warnValue = &levelValue{level: levelWarn, name: "warn"} + infoValue = &levelValue{level: levelInfo, name: "info"} + debugValue = &levelValue{level: levelDebug, name: "debug"} +} + type level byte const ( diff --git a/pkg/infra/log/log.go b/pkg/infra/log/log.go index 21c11705e6a..efe85bbf84e 100644 --- a/pkg/infra/log/log.go +++ b/pkg/infra/log/log.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "sync" "time" @@ -30,6 +31,7 @@ var ( loggersToReload []ReloadableHandler root *logManager now = time.Now + logTimeFormat = "2006-01-02T15:04:05.99-0700" ) const ( @@ -53,9 +55,10 @@ func init() { // logManager manage loggers type logManager struct { *ConcreteLogger - loggersByName map[string]*ConcreteLogger - logFilters []logWithFilters - mutex sync.RWMutex + loggersByName map[string]*ConcreteLogger + logFilters []logWithFilters + mutex sync.RWMutex + gokitLogActivated bool } func newManager(logger gokitlog.Logger) *logManager { @@ -69,6 +72,12 @@ func (lm *logManager) initialize(loggers []logWithFilters) { lm.mutex.Lock() defer lm.mutex.Unlock() + if lm.gokitLogActivated { + level.SetLevelKeyAndValuesToGokitLog() + term.SetTimeFormatGokitLog() + logTimeFormat = time.RFC3339Nano + } + defaultLoggers := make([]gokitlog.Logger, len(loggers)) for index, logger := range loggers { defaultLoggers[index] = level.NewFilter(logger.val, logger.maxLevel) @@ -181,7 +190,7 @@ func (cl *ConcreteLogger) Info(msg string, args ...interface{}) { } func (cl *ConcreteLogger) log(msg string, logLevel level.Value, args ...interface{}) error { - logger := gokitlog.With(&cl.SwapLogger, "t", gokitlog.TimestampFormat(now, "2006-01-02T15:04:05.99-0700")) + logger := gokitlog.With(&cl.SwapLogger, "t", gokitlog.TimestampFormat(now, logTimeFormat)) args = append([]interface{}{level.Key(), logLevel, "msg", msg}, args...) return logger.Log(args...) @@ -436,9 +445,57 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) error { configLoggers = append(configLoggers, handler) } + var err error + isOldLoggerActivated, err := isOldLoggerActivated(cfg) + root.gokitLogActivated = !isOldLoggerActivated + + if err != nil { + return err + } if len(configLoggers) > 0 { root.initialize(configLoggers) } return nil } + +// This would be removed eventually, no need to make a fancy design. +// For the sake of important cycle I just copied the function +func isOldLoggerActivated(cfg *ini.File) (bool, error) { + section := cfg.Section("feature_toggles") + toggles, err := readFeatureTogglesFromInitFile(section) + if err != nil { + return false, err + } + return toggles["oldlog"], nil +} + +func readFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]bool, error) { + featureToggles := make(map[string]bool, 10) + + // parse the comma separated list in `enable`. + featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "") + for _, feature := range util.SplitString(featuresTogglesStr) { + featureToggles[feature] = true + } + + // read all other settings under [feature_toggles]. If a toggle is + // present in both the value in `enable` is overridden. + for _, v := range featureTogglesSection.Keys() { + if v.Name() == "enable" { + continue + } + + b, err := strconv.ParseBool(v.Value()) + if err != nil { + return featureToggles, err + } + + featureToggles[v.Name()] = b + } + return featureToggles, nil +} + +func valueAsString(section *ini.Section, keyName string, defaultValue string) string { + return section.Key(keyName).MustString(defaultValue) +} diff --git a/pkg/infra/log/term/terminal_logger.go b/pkg/infra/log/term/terminal_logger.go index 860ef27c7b6..b9aeac0e8a4 100644 --- a/pkg/infra/log/term/terminal_logger.go +++ b/pkg/infra/log/term/terminal_logger.go @@ -14,14 +14,21 @@ import ( "github.com/grafana/grafana/pkg/infra/log/level" ) -const ( +var ( timeFormat = "2006-01-02T15:04:05-0700" termTimeFormat = "01-02|15:04:05" - floatFormat = 'f' - termMsgJust = 40 - errorKey = "LOG15_ERROR" ) +const ( + floatFormat = 'f' + termMsgJust = 40 + errorKey = "LOG15_ERROR" +) + +func SetTimeFormatGokitLog() { + timeFormat = time.RFC3339Nano +} + type terminalLogger struct { w io.Writer } @@ -45,7 +52,10 @@ func (l terminalLogger) Log(keyvals ...interface{}) error { r := getRecord(keyvals) b := &bytes.Buffer{} - lvl := strings.ToUpper(r.level.String()) + + // To make the log output more readable, we make all log levels 5 characters long + lvl := fmt.Sprintf("%-5s", strings.ToUpper(r.level.String())) + if r.color > 0 { fmt.Fprintf(b, "\x1b[%dm%s\x1b[0m[%s] %s ", r.color, lvl, r.time.Format(termTimeFormat), r.msg) // lgtm[go/log-injection] } else { @@ -93,10 +103,16 @@ func getRecord(keyvals ...interface{}) *record { if k == "t" { t, ok := v.(fmt.Stringer) if ok { - time, err := time.Parse("2006-01-02T15:04:05.999999999-0700", t.String()) + parsedTime, err := time.Parse("2006-01-02T15:04:05.999999999-0700", t.String()) if err == nil { - r.time = time + r.time = parsedTime continue + } else { + parsedTime, err := time.Parse(time.RFC3339Nano, t.String()) + if err == nil { + r.time = parsedTime + continue + } } } diff --git a/pkg/infra/tracing/opentelemetry_tracing.go b/pkg/infra/tracing/opentelemetry_tracing.go index abe7fcb2fe2..61b62a850e0 100644 --- a/pkg/infra/tracing/opentelemetry_tracing.go +++ b/pkg/infra/tracing/opentelemetry_tracing.go @@ -126,6 +126,7 @@ func (ots *Opentelemetry) Start(ctx context.Context, spanName string, opts ...tr opentelemetrySpan := OpentelemetrySpan{ span: span, } + ctx = context.WithValue(ctx, traceKey{}, traceValue{span.SpanContext().TraceID().String(), span.SpanContext().IsSampled()}) return ctx, opentelemetrySpan } diff --git a/pkg/infra/tracing/tracing.go b/pkg/infra/tracing/tracing.go index 95df49b4727..c13e353e94e 100644 --- a/pkg/infra/tracing/tracing.go +++ b/pkg/infra/tracing/tracing.go @@ -14,6 +14,7 @@ import ( opentracing "github.com/opentracing/opentracing-go" "github.com/opentracing/opentracing-go/ext" ol "github.com/opentracing/opentracing-go/log" + "github.com/uber/jaeger-client-go" jaegercfg "github.com/uber/jaeger-client-go/config" "github.com/uber/jaeger-client-go/zipkin" "go.opentelemetry.io/otel/attribute" @@ -52,6 +53,21 @@ func ProvideService(cfg *setting.Cfg) (Tracer, error) { return ots, ots.initOpentelemetryTracer() } +type traceKey struct{} +type traceValue struct { + ID string + IsSampled bool +} + +func TraceIDFromContext(c context.Context, requireSampled bool) string { + v := c.Value(traceKey{}) + // Return traceID if a) it is present and b) it is sampled when requireSampled param is true + if trace, ok := v.(traceValue); ok && (!requireSampled || trace.IsSampled) { + return trace.ID + } + return "" +} + type Opentracing struct { enabled bool address string @@ -172,6 +188,9 @@ func (ts *Opentracing) Run(ctx context.Context) error { func (ts *Opentracing) Start(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, Span) { span, ctx := opentracing.StartSpanFromContext(ctx, spanName) opentracingSpan := OpentracingSpan{span: span} + if sctx, ok := span.Context().(jaeger.SpanContext); ok { + ctx = context.WithValue(ctx, traceKey{}, traceValue{sctx.TraceID().String(), sctx.IsSampled()}) + } return ctx, opentracingSpan } diff --git a/pkg/infra/usagestats/mock.go b/pkg/infra/usagestats/mock.go index bead572f455..0510233fbb8 100644 --- a/pkg/infra/usagestats/mock.go +++ b/pkg/infra/usagestats/mock.go @@ -2,6 +2,7 @@ package usagestats import ( "context" + "strings" "testing" "github.com/stretchr/testify/require" @@ -29,8 +30,8 @@ func (usm *UsageStatsMock) GetUsageReport(ctx context.Context) (Report, error) { return Report{Metrics: all}, nil } -func (usm *UsageStatsMock) ShouldBeReported(_ context.Context, _ string) bool { - return true +func (usm *UsageStatsMock) ShouldBeReported(_ context.Context, s string) bool { + return !strings.HasPrefix(s, "unknown") } func (usm *UsageStatsMock) RegisterSendReportCallback(_ SendReportCallbackFunc) {} diff --git a/pkg/infra/usagestats/service/api_test.go b/pkg/infra/usagestats/service/api_test.go index d95cc1dbd4a..450bfb83c0d 100644 --- a/pkg/infra/usagestats/service/api_test.go +++ b/pkg/infra/usagestats/service/api_test.go @@ -73,11 +73,11 @@ func getUsageStats(t *testing.T, server *web.Mux) (*models.SystemStats, *httptes recorder := httptest.NewRecorder() server.ServeHTTP(recorder, req) - var usageStats *models.SystemStats + var usageStats models.SystemStats if recorder.Code == http.StatusOK { require.NoError(t, json.NewDecoder(recorder.Body).Decode(&usageStats)) } - return usageStats, recorder + return &usageStats, recorder } func setupTestServer(t *testing.T, user *models.SignedInUser, service *UsageStats) *web.Mux { diff --git a/pkg/infra/usagestats/service/service.go b/pkg/infra/usagestats/service/service.go index ceb8a321fbc..c31385d7d5a 100644 --- a/pkg/infra/usagestats/service/service.go +++ b/pkg/infra/usagestats/service/service.go @@ -2,48 +2,35 @@ package service import ( "context" - "fmt" "time" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/usagestats" - "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" ) type UsageStats struct { Cfg *setting.Cfg - SQLStore sqlstore.Store - pluginStore plugins.Store - SocialService social.Service kvStore *kvstore.NamespacedKVStore RouteRegister routing.RouteRegister + pluginStore plugins.Store log log.Logger - oauthProviders map[string]bool - externalMetrics []usagestats.MetricsFunc - concurrentUserStatsCache memoConcurrentUserStats - startTime time.Time - sendReportCallbacks []usagestats.SendReportCallbackFunc + externalMetrics []usagestats.MetricsFunc + sendReportCallbacks []usagestats.SendReportCallbackFunc } -func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore plugins.Store, - socialService social.Service, kvStore kvstore.KVStore, routeRegister routing.RouteRegister, -) *UsageStats { +func ProvideService(cfg *setting.Cfg, pluginStore plugins.Store, kvStore kvstore.KVStore, routeRegister routing.RouteRegister) *UsageStats { s := &UsageStats{ - Cfg: cfg, - SQLStore: sqlStore, - oauthProviders: socialService.GetOAuthProviders(), - RouteRegister: routeRegister, - pluginStore: pluginStore, - kvStore: kvstore.WithNamespace(kvStore, 0, "infra.usagestats"), - log: log.New("infra.usagestats"), - startTime: time.Now(), + Cfg: cfg, + RouteRegister: routeRegister, + pluginStore: pluginStore, + kvStore: kvstore.WithNamespace(kvStore, 0, "infra.usagestats"), + log: log.New("infra.usagestats"), } s.registerAPIEndpoints() @@ -52,8 +39,6 @@ func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore p } func (uss *UsageStats) Run(ctx context.Context) error { - uss.updateTotalStats(ctx) - // try to load last sent time from kv store lastSent := time.Now() if val, ok, err := uss.kvStore.Get(ctx, "last_sent"); err != nil { @@ -74,10 +59,8 @@ func (uss *UsageStats) Run(ctx context.Context) error { } sendReportTicker := time.NewTicker(nextSendInterval) - updateStatsTicker := time.NewTicker(time.Minute * 30) defer sendReportTicker.Stop() - defer updateStatsTicker.Stop() for { select { @@ -99,54 +82,21 @@ func (uss *UsageStats) Run(ctx context.Context) error { for _, callback := range uss.sendReportCallbacks { callback() } - case <-updateStatsTicker.C: - uss.updateTotalStats(ctx) case <-ctx.Done(): return ctx.Err() } } } -type memoConcurrentUserStats struct { - stats *concurrentUsersStats - - memoized time.Time -} - -const concurrentUserStatsCacheLifetime = time.Hour - -func (uss *UsageStats) GetConcurrentUsersStats(ctx context.Context) (*concurrentUsersStats, error) { - memoizationPeriod := time.Now().Add(-concurrentUserStatsCacheLifetime) - if !uss.concurrentUserStatsCache.memoized.Before(memoizationPeriod) { - return uss.concurrentUserStatsCache.stats, nil - } - - uss.concurrentUserStatsCache.stats = &concurrentUsersStats{} - err := uss.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - // Retrieves concurrent users stats as a histogram. Buckets are accumulative and upper bound is inclusive. - rawSQL := ` -SELECT - COUNT(CASE WHEN tokens <= 3 THEN 1 END) AS bucket_le_3, - COUNT(CASE WHEN tokens <= 6 THEN 1 END) AS bucket_le_6, - COUNT(CASE WHEN tokens <= 9 THEN 1 END) AS bucket_le_9, - COUNT(CASE WHEN tokens <= 12 THEN 1 END) AS bucket_le_12, - COUNT(CASE WHEN tokens <= 15 THEN 1 END) AS bucket_le_15, - COUNT(1) AS bucket_le_inf -FROM (select count(1) as tokens from user_auth_token group by user_id) uat;` - _, err := sess.SQL(rawSQL).Get(uss.concurrentUserStatsCache.stats) - if err != nil { - return err - } - return nil - }) - if err != nil { - return nil, fmt.Errorf("failed to get concurrent users stats from database: %w", err) - } - - uss.concurrentUserStatsCache.memoized = time.Now() - return uss.concurrentUserStatsCache.stats, nil -} - func (uss *UsageStats) RegisterSendReportCallback(c usagestats.SendReportCallbackFunc) { uss.sendReportCallbacks = append(uss.sendReportCallbacks, c) } + +func (uss *UsageStats) ShouldBeReported(ctx context.Context, dsType string) bool { + ds, exists := uss.pluginStore.Plugin(ctx, dsType) + if !exists { + return false + } + + return ds.Signature.IsValid() || ds.Signature.IsInternal() +} diff --git a/pkg/infra/usagestats/service/types.go b/pkg/infra/usagestats/service/types.go deleted file mode 100644 index c9e2a4d8361..00000000000 --- a/pkg/infra/usagestats/service/types.go +++ /dev/null @@ -1,10 +0,0 @@ -package service - -type concurrentUsersStats struct { - BucketLE3 int32 `xorm:"bucket_le_3"` - BucketLE6 int32 `xorm:"bucket_le_6"` - BucketLE9 int32 `xorm:"bucket_le_9"` - BucketLE12 int32 `xorm:"bucket_le_12"` - BucketLE15 int32 `xorm:"bucket_le_15"` - BucketLEInf int32 `xorm:"bucket_le_inf"` -} diff --git a/pkg/infra/usagestats/service/usage_stats.go b/pkg/infra/usagestats/service/usage_stats.go index b67e1db0aa9..95df118a6c0 100644 --- a/pkg/infra/usagestats/service/usage_stats.go +++ b/pkg/infra/usagestats/service/usage_stats.go @@ -4,17 +4,14 @@ import ( "bytes" "context" "encoding/json" - "fmt" "net/http" "runtime" "strings" "time" "github.com/google/uuid" - "github.com/grafana/grafana/pkg/infra/metrics" + "github.com/grafana/grafana/pkg/infra/usagestats" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/plugins" ) var usageStatsURL = "https://stats.grafana.org/grafana-usage-report" @@ -38,64 +35,7 @@ func (uss *UsageStats) GetUsageReport(ctx context.Context) (usagestats.Report, e UsageStatsId: uss.GetUsageStatsId(ctx), } - statsQuery := models.GetSystemStatsQuery{} - if err := uss.SQLStore.GetSystemStats(ctx, &statsQuery); err != nil { - uss.log.Error("Failed to get system stats", "error", err) - return report, err - } - - metrics["stats.dashboards.count"] = statsQuery.Result.Dashboards - metrics["stats.users.count"] = statsQuery.Result.Users - metrics["stats.admins.count"] = statsQuery.Result.Admins - metrics["stats.editors.count"] = statsQuery.Result.Editors - metrics["stats.viewers.count"] = statsQuery.Result.Viewers - metrics["stats.orgs.count"] = statsQuery.Result.Orgs - metrics["stats.playlist.count"] = statsQuery.Result.Playlists - metrics["stats.plugins.apps.count"] = uss.appCount(ctx) - metrics["stats.plugins.panels.count"] = uss.panelCount(ctx) - metrics["stats.plugins.datasources.count"] = uss.dataSourceCount(ctx) - metrics["stats.alerts.count"] = statsQuery.Result.Alerts - metrics["stats.active_users.count"] = statsQuery.Result.ActiveUsers - metrics["stats.active_admins.count"] = statsQuery.Result.ActiveAdmins - metrics["stats.active_editors.count"] = statsQuery.Result.ActiveEditors - metrics["stats.active_viewers.count"] = statsQuery.Result.ActiveViewers - metrics["stats.active_sessions.count"] = statsQuery.Result.ActiveSessions - metrics["stats.monthly_active_users.count"] = statsQuery.Result.MonthlyActiveUsers - metrics["stats.daily_active_users.count"] = statsQuery.Result.DailyActiveUsers - metrics["stats.daily_active_admins.count"] = statsQuery.Result.DailyActiveAdmins - metrics["stats.daily_active_editors.count"] = statsQuery.Result.DailyActiveEditors - metrics["stats.daily_active_viewers.count"] = statsQuery.Result.DailyActiveViewers - metrics["stats.daily_active_sessions.count"] = statsQuery.Result.DailyActiveSessions - metrics["stats.datasources.count"] = statsQuery.Result.Datasources - metrics["stats.stars.count"] = statsQuery.Result.Stars - metrics["stats.folders.count"] = statsQuery.Result.Folders - metrics["stats.dashboard_permissions.count"] = statsQuery.Result.DashboardPermissions - metrics["stats.folder_permissions.count"] = statsQuery.Result.FolderPermissions - metrics["stats.provisioned_dashboards.count"] = statsQuery.Result.ProvisionedDashboards - metrics["stats.snapshots.count"] = statsQuery.Result.Snapshots - metrics["stats.teams.count"] = statsQuery.Result.Teams - metrics["stats.total_auth_token.count"] = statsQuery.Result.AuthTokens - metrics["stats.dashboard_versions.count"] = statsQuery.Result.DashboardVersions - metrics["stats.annotations.count"] = statsQuery.Result.Annotations - metrics["stats.alert_rules.count"] = statsQuery.Result.AlertRules - metrics["stats.library_panels.count"] = statsQuery.Result.LibraryPanels - metrics["stats.library_variables.count"] = statsQuery.Result.LibraryVariables - metrics["stats.dashboards_viewers_can_edit.count"] = statsQuery.Result.DashboardsViewersCanEdit - metrics["stats.dashboards_viewers_can_admin.count"] = statsQuery.Result.DashboardsViewersCanAdmin - metrics["stats.folders_viewers_can_edit.count"] = statsQuery.Result.FoldersViewersCanEdit - metrics["stats.folders_viewers_can_admin.count"] = statsQuery.Result.FoldersViewersCanAdmin - metrics["stats.api_keys.count"] = statsQuery.Result.APIKeys - - ossEditionCount := 1 - enterpriseEditionCount := 0 - if uss.Cfg.IsEnterprise { - enterpriseEditionCount = 1 - ossEditionCount = 0 - } - metrics["stats.edition.oss.count"] = ossEditionCount - metrics["stats.edition.enterprise.count"] = enterpriseEditionCount - - uss.registerExternalMetrics(ctx, metrics) + uss.gatherMetrics(ctx, metrics) // must run after registration of external metrics if v, ok := metrics["stats.valid_license.count"]; ok { @@ -104,136 +44,10 @@ func (uss *UsageStats) GetUsageReport(ctx context.Context) (usagestats.Report, e metrics["stats.valid_license.count"] = 0 } - userCount := statsQuery.Result.Users - avgAuthTokensPerUser := statsQuery.Result.AuthTokens - if userCount != 0 { - avgAuthTokensPerUser /= userCount - } - - metrics["stats.avg_auth_token_per_user.count"] = avgAuthTokensPerUser - - dsStats := models.GetDataSourceStatsQuery{} - if err := uss.SQLStore.GetDataSourceStats(ctx, &dsStats); err != nil { - uss.log.Error("Failed to get datasource stats", "error", err) - return report, err - } - - // send counters for each data source - // but ignore any custom data sources - // as sending that name could be sensitive information - dsOtherCount := 0 - for _, dsStat := range dsStats.Result { - if uss.ShouldBeReported(ctx, dsStat.Type) { - metrics["stats.ds."+dsStat.Type+".count"] = dsStat.Count - } else { - dsOtherCount += dsStat.Count - } - } - metrics["stats.ds.other.count"] = dsOtherCount - - esDataSourcesQuery := models.GetDataSourcesByTypeQuery{Type: models.DS_ES} - if err := uss.SQLStore.GetDataSourcesByType(ctx, &esDataSourcesQuery); err != nil { - uss.log.Error("Failed to get elasticsearch json data", "error", err) - return report, err - } - - for _, data := range esDataSourcesQuery.Result { - esVersion, err := data.JsonData.Get("esVersion").Int() - if err != nil { - continue - } - - statName := fmt.Sprintf("stats.ds.elasticsearch.v%d.count", esVersion) - - count, _ := metrics[statName].(int64) - - metrics[statName] = count + 1 - } - - metrics["stats.packaging."+uss.Cfg.Packaging+".count"] = 1 - metrics["stats.distributor."+uss.Cfg.ReportingDistributor+".count"] = 1 - - // fetch datasource access stats - dsAccessStats := models.GetDataSourceAccessStatsQuery{} - if err := uss.SQLStore.GetDataSourceAccessStats(ctx, &dsAccessStats); err != nil { - uss.log.Error("Failed to get datasource access stats", "error", err) - return report, err - } - - // send access counters for each data source - // but ignore any custom data sources - // as sending that name could be sensitive information - dsAccessOtherCount := make(map[string]int64) - for _, dsAccessStat := range dsAccessStats.Result { - if dsAccessStat.Access == "" { - continue - } - - access := strings.ToLower(dsAccessStat.Access) - - if uss.ShouldBeReported(ctx, dsAccessStat.Type) { - metrics["stats.ds_access."+dsAccessStat.Type+"."+access+".count"] = dsAccessStat.Count - } else { - old := dsAccessOtherCount[access] - dsAccessOtherCount[access] = old + dsAccessStat.Count - } - } - - for access, count := range dsAccessOtherCount { - metrics["stats.ds_access.other."+access+".count"] = count - } - - // get stats about alert notifier usage - anStats := models.GetAlertNotifierUsageStatsQuery{} - if err := uss.SQLStore.GetAlertNotifiersUsageStats(ctx, &anStats); err != nil { - uss.log.Error("Failed to get alert notification stats", "error", err) - return report, err - } - - for _, stats := range anStats.Result { - metrics["stats.alert_notifiers."+stats.Type+".count"] = stats.Count - } - - // Add stats about auth configuration - authTypes := map[string]bool{} - authTypes["anonymous"] = uss.Cfg.AnonymousEnabled - authTypes["basic_auth"] = uss.Cfg.BasicAuthEnabled - authTypes["ldap"] = uss.Cfg.LDAPEnabled - authTypes["auth_proxy"] = uss.Cfg.AuthProxyEnabled - - for provider, enabled := range uss.oauthProviders { - authTypes["oauth_"+provider] = enabled - } - - for authType, enabled := range authTypes { - enabledValue := 0 - if enabled { - enabledValue = 1 - } - metrics["stats.auth_enabled."+authType+".count"] = enabledValue - } - - // Get concurrent users stats as histogram - concurrentUsersStats, err := uss.GetConcurrentUsersStats(ctx) - if err != nil { - uss.log.Error("Failed to get concurrent users stats", "error", err) - return report, err - } - - // Histogram is cumulative and metric name has a postfix of le_"" - metrics["stats.auth_token_per_user_le_3"] = concurrentUsersStats.BucketLE3 - metrics["stats.auth_token_per_user_le_6"] = concurrentUsersStats.BucketLE6 - metrics["stats.auth_token_per_user_le_9"] = concurrentUsersStats.BucketLE9 - metrics["stats.auth_token_per_user_le_12"] = concurrentUsersStats.BucketLE12 - metrics["stats.auth_token_per_user_le_15"] = concurrentUsersStats.BucketLE15 - metrics["stats.auth_token_per_user_le_inf"] = concurrentUsersStats.BucketLEInf - - metrics["stats.uptime"] = int64(time.Since(uss.startTime).Seconds()) - return report, nil } -func (uss *UsageStats) registerExternalMetrics(ctx context.Context, metrics map[string]interface{}) { +func (uss *UsageStats) gatherMetrics(ctx context.Context, metrics map[string]interface{}) { for _, fn := range uss.externalMetrics { fnMetrics, err := fn(ctx) if err != nil { @@ -290,55 +104,6 @@ var sendUsageStats = func(uss *UsageStats, data *bytes.Buffer) { }() } -func (uss *UsageStats) updateTotalStats(ctx context.Context) { - if !uss.Cfg.MetricsEndpointEnabled || uss.Cfg.MetricsEndpointDisableTotalStats { - return - } - - statsQuery := models.GetSystemStatsQuery{} - if err := uss.SQLStore.GetSystemStats(ctx, &statsQuery); err != nil { - uss.log.Error("Failed to get system stats", "error", err) - return - } - - metrics.MStatTotalDashboards.Set(float64(statsQuery.Result.Dashboards)) - metrics.MStatTotalFolders.Set(float64(statsQuery.Result.Folders)) - metrics.MStatTotalUsers.Set(float64(statsQuery.Result.Users)) - metrics.MStatActiveUsers.Set(float64(statsQuery.Result.ActiveUsers)) - metrics.MStatTotalPlaylists.Set(float64(statsQuery.Result.Playlists)) - metrics.MStatTotalOrgs.Set(float64(statsQuery.Result.Orgs)) - metrics.StatsTotalViewers.Set(float64(statsQuery.Result.Viewers)) - metrics.StatsTotalActiveViewers.Set(float64(statsQuery.Result.ActiveViewers)) - metrics.StatsTotalEditors.Set(float64(statsQuery.Result.Editors)) - metrics.StatsTotalActiveEditors.Set(float64(statsQuery.Result.ActiveEditors)) - metrics.StatsTotalAdmins.Set(float64(statsQuery.Result.Admins)) - metrics.StatsTotalActiveAdmins.Set(float64(statsQuery.Result.ActiveAdmins)) - metrics.StatsTotalDashboardVersions.Set(float64(statsQuery.Result.DashboardVersions)) - metrics.StatsTotalAnnotations.Set(float64(statsQuery.Result.Annotations)) - metrics.StatsTotalAlertRules.Set(float64(statsQuery.Result.AlertRules)) - metrics.StatsTotalLibraryPanels.Set(float64(statsQuery.Result.LibraryPanels)) - metrics.StatsTotalLibraryVariables.Set(float64(statsQuery.Result.LibraryVariables)) - - dsStats := models.GetDataSourceStatsQuery{} - if err := uss.SQLStore.GetDataSourceStats(ctx, &dsStats); err != nil { - uss.log.Error("Failed to get datasource stats", "error", err) - return - } - - for _, dsStat := range dsStats.Result { - metrics.StatsTotalDataSources.WithLabelValues(dsStat.Type).Set(float64(dsStat.Count)) - } -} - -func (uss *UsageStats) ShouldBeReported(ctx context.Context, dsType string) bool { - ds, exists := uss.pluginStore.Plugin(ctx, dsType) - if !exists { - return false - } - - return ds.Signature.IsValid() || ds.Signature.IsInternal() -} - func (uss *UsageStats) GetUsageStatsId(ctx context.Context) string { anonId, ok, err := uss.kvStore.Get(ctx, "anonymous_id") if err != nil { @@ -366,15 +131,3 @@ func (uss *UsageStats) GetUsageStatsId(ctx context.Context) string { return anonId } - -func (uss *UsageStats) appCount(ctx context.Context) int { - return len(uss.pluginStore.Plugins(ctx, plugins.App)) -} - -func (uss *UsageStats) panelCount(ctx context.Context) int { - return len(uss.pluginStore.Plugins(ctx, plugins.Panel)) -} - -func (uss *UsageStats) dataSourceCount(ctx context.Context) int { - return len(uss.pluginStore.Plugins(ctx, plugins.DataSource)) -} diff --git a/pkg/infra/usagestats/service/usage_stats_test.go b/pkg/infra/usagestats/service/usage_stats_test.go index 13acd1155aa..7c36eead42e 100644 --- a/pkg/infra/usagestats/service/usage_stats_test.go +++ b/pkg/infra/usagestats/service/usage_stats_test.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "encoding/json" "errors" "io/ioutil" "net/http" @@ -11,18 +12,16 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/kvstore" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/usagestats" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) // This is to ensure that the interface contract is held by the implementation @@ -37,411 +36,163 @@ func Test_InterfaceContractValidity(t *testing.T) { } func TestMetrics(t *testing.T) { - t.Run("When sending usage stats", func(t *testing.T) { - sqlStore := mockstore.NewSQLStoreMock() - uss := createService(t, setting.Cfg{}, sqlStore, false) + const metricName = "stats.test_metric.count" - sqlStore.ExpectedSystemStats = &models.SystemStats{ - Dashboards: 1, - Datasources: 2, - Users: 3, - Admins: 31, - Editors: 32, - Viewers: 33, - ActiveUsers: 4, - ActiveAdmins: 21, - ActiveEditors: 22, - ActiveViewers: 23, - ActiveSessions: 24, - DailyActiveUsers: 25, - DailyActiveAdmins: 26, - DailyActiveEditors: 27, - DailyActiveViewers: 28, - DailyActiveSessions: 29, - Orgs: 5, - Playlists: 6, - Alerts: 7, - Stars: 8, - Folders: 9, - DashboardPermissions: 10, - FolderPermissions: 11, - ProvisionedDashboards: 12, - Snapshots: 13, - Teams: 14, - AuthTokens: 15, - DashboardVersions: 16, - Annotations: 17, - AlertRules: 18, - LibraryPanels: 19, - LibraryVariables: 20, - DashboardsViewersCanAdmin: 3, - DashboardsViewersCanEdit: 2, - FoldersViewersCanAdmin: 1, - FoldersViewersCanEdit: 5, - APIKeys: 2, + sqlStore := mockstore.NewSQLStoreMock() + uss := createService(t, setting.Cfg{}, sqlStore, false) + + uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) { + return map[string]interface{}{metricName: 1}, nil + }) + + err := uss.sendUsageStats(context.Background()) + require.NoError(t, err) + + t.Run("Given reporting not enabled and sending usage stats", func(t *testing.T) { + origSendUsageStats := sendUsageStats + t.Cleanup(func() { + sendUsageStats = origSendUsageStats + }) + statsSent := false + sendUsageStats = func(uss *UsageStats, b *bytes.Buffer) { + statsSent = true } - setupSomeDataSourcePlugins(t, uss) + uss.Cfg.ReportingEnabled = false + err := uss.sendUsageStats(context.Background()) + require.NoError(t, err) - sqlStore.ExpectedDataSourceStats = []*models.DataSourceStats{ - { - Type: models.DS_ES, - Count: 9, - }, - { - Type: models.DS_PROMETHEUS, - Count: 10, - }, - { - Type: "unknown_ds", - Count: 11, - }, - { - Type: "unknown_ds2", - Count: 12, - }, + require.False(t, statsSent) + }) + + t.Run("Given reporting enabled, stats should be gathered and sent to HTTP endpoint", func(t *testing.T) { + origCfg := uss.Cfg + t.Cleanup(func() { + uss.Cfg = origCfg + }) + uss.Cfg = &setting.Cfg{ + ReportingEnabled: true, + BuildVersion: "5.0.0", + AnonymousEnabled: true, + BasicAuthEnabled: true, + LDAPEnabled: true, + AuthProxyEnabled: true, + Packaging: "deb", + ReportingDistributor: "hosted-grafana", } - sqlStore.ExpectedDataSources = []*models.DataSource{ - { - JsonData: simplejson.NewFromAny(map[string]interface{}{ - "esVersion": 2, - }), - }, - { - JsonData: simplejson.NewFromAny(map[string]interface{}{ - "esVersion": 2, - }), - }, - { - JsonData: simplejson.NewFromAny(map[string]interface{}{ - "esVersion": 70, - }), - }, - } - - sqlStore.ExpectedDataSourcesAccessStats = []*models.DataSourceAccessStats{ - { - Type: models.DS_ES, - Access: "direct", - Count: 1, - }, - { - Type: models.DS_ES, - Access: "proxy", - Count: 2, - }, - { - Type: models.DS_PROMETHEUS, - Access: "proxy", - Count: 3, - }, - { - Type: "unknown_ds", - Access: "proxy", - Count: 4, - }, - { - Type: "unknown_ds2", - Access: "", - Count: 5, - }, - { - Type: "unknown_ds3", - Access: "direct", - Count: 6, - }, - { - Type: "unknown_ds4", - Access: "direct", - Count: 7, - }, - { - Type: "unknown_ds5", - Access: "proxy", - Count: 8, - }, - } - - sqlStore.ExpectedNotifierUsageStats = []*models.NotifierUsageStats{ - { - Type: "slack", - Count: 1, - }, - { - Type: "webhook", - Count: 2, - }, - } - - uss.SQLStore = sqlStore - - createConcurrentTokens(t, uss.SQLStore) - - uss.oauthProviders = map[string]bool{ - "github": true, - "gitlab": true, - "azuread": true, - "google": true, - "generic_oauth": true, - "grafana_com": true, - } + ch := make(chan httpResp) + ticker := time.NewTicker(2 * time.Second) + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + buf, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Logf("Fake HTTP handler received an error: %s", err.Error()) + ch <- httpResp{ + err: err, + } + return + } + require.NoError(t, err, "Failed to read response body, err=%v", err) + t.Logf("Fake HTTP handler received a response") + ch <- httpResp{ + responseBuffer: bytes.NewBuffer(buf), + req: r, + } + })) + t.Cleanup(ts.Close) + t.Cleanup(func() { + close(ch) + }) + usageStatsURL = ts.URL err := uss.sendUsageStats(context.Background()) require.NoError(t, err) - t.Run("Given reporting not enabled and sending usage stats", func(t *testing.T) { - origSendUsageStats := sendUsageStats - t.Cleanup(func() { - sendUsageStats = origSendUsageStats - }) - statsSent := false - sendUsageStats = func(uss *UsageStats, b *bytes.Buffer) { - statsSent = true - } + // Wait for fake HTTP server to receive a request + var resp httpResp + select { + case resp = <-ch: + require.NoError(t, resp.err, "Fake server experienced an error") + case <-ticker.C: + t.Fatalf("Timed out waiting for HTTP request") + } - uss.Cfg.ReportingEnabled = false - err := uss.sendUsageStats(context.Background()) - require.NoError(t, err) + t.Logf("Received response from fake HTTP server: %+v\n", resp) - require.False(t, statsSent) - }) + assert.NotNil(t, resp.req) - t.Run("Given reporting enabled, stats should be gathered and sent to HTTP endpoint", func(t *testing.T) { - origCfg := uss.Cfg - t.Cleanup(func() { - uss.Cfg = origCfg - }) - uss.Cfg = &setting.Cfg{ - ReportingEnabled: true, - BuildVersion: "5.0.0", - AnonymousEnabled: true, - BasicAuthEnabled: true, - LDAPEnabled: true, - AuthProxyEnabled: true, - Packaging: "deb", - ReportingDistributor: "hosted-grafana", - } + assert.Equal(t, http.MethodPost, resp.req.Method) + assert.Equal(t, "application/json", resp.req.Header.Get("Content-Type")) - ch := make(chan httpResp) - ticker := time.NewTicker(2 * time.Second) - ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - buf, err := ioutil.ReadAll(r.Body) - if err != nil { - t.Logf("Fake HTTP handler received an error: %s", err.Error()) - ch <- httpResp{ - err: err, - } - return - } - require.NoError(t, err, "Failed to read response body, err=%v", err) - t.Logf("Fake HTTP handler received a response") - ch <- httpResp{ - responseBuffer: bytes.NewBuffer(buf), - req: r, - } - })) - t.Cleanup(ts.Close) - t.Cleanup(func() { - close(ch) - }) - usageStatsURL = ts.URL + require.NotNil(t, resp.responseBuffer) - err := uss.sendUsageStats(context.Background()) - require.NoError(t, err) + j := make(map[string]interface{}) + err = json.Unmarshal(resp.responseBuffer.Bytes(), &j) + require.NoError(t, err) - // Wait for fake HTTP server to receive a request - var resp httpResp - select { - case resp = <-ch: - require.NoError(t, resp.err, "Fake server experienced an error") - case <-ticker.C: - t.Fatalf("Timed out waiting for HTTP request") - } + assert.Equal(t, "5_0_0", j["version"]) + assert.Equal(t, runtime.GOOS, j["os"]) + assert.Equal(t, runtime.GOARCH, j["arch"]) - t.Logf("Received response from fake HTTP server: %+v\n", resp) + usageId := uss.GetUsageStatsId(context.Background()) + assert.NotEmpty(t, usageId) - assert.NotNil(t, resp.req) + metrics, ok := j["metrics"].(map[string]interface{}) + require.True(t, ok) + assert.EqualValues(t, 1, metrics[metricName]) + }) +} - assert.Equal(t, http.MethodPost, resp.req.Method) - assert.Equal(t, "application/json", resp.req.Header.Get("Content-Type")) +func TestGetUsageReport_IncludesMetrics(t *testing.T) { + sqlStore := mockstore.NewSQLStoreMock() + uss := createService(t, setting.Cfg{}, sqlStore, true) + metricName := "stats.test_metric.count" - require.NotNil(t, resp.responseBuffer) - - j, err := simplejson.NewFromReader(resp.responseBuffer) - require.NoError(t, err) - - assert.Equal(t, "5_0_0", j.Get("version").MustString()) - assert.Equal(t, runtime.GOOS, j.Get("os").MustString()) - assert.Equal(t, runtime.GOARCH, j.Get("arch").MustString()) - - usageId := uss.GetUsageStatsId(context.Background()) - assert.NotEmpty(t, usageId) - - metrics := j.Get("metrics") - assert.Equal(t, 15, metrics.Get("stats.total_auth_token.count").MustInt()) - assert.Equal(t, 2, metrics.Get("stats.api_keys.count").MustInt()) - assert.Equal(t, 5, metrics.Get("stats.avg_auth_token_per_user.count").MustInt()) - assert.Equal(t, 16, metrics.Get("stats.dashboard_versions.count").MustInt()) - assert.Equal(t, 17, metrics.Get("stats.annotations.count").MustInt()) - assert.Equal(t, 18, metrics.Get("stats.alert_rules.count").MustInt()) - assert.Equal(t, 19, metrics.Get("stats.library_panels.count").MustInt()) - assert.Equal(t, 20, metrics.Get("stats.library_variables.count").MustInt()) - assert.Equal(t, 0, metrics.Get("stats.live_users.count").MustInt()) - assert.Equal(t, 0, metrics.Get("stats.live_clients.count").MustInt()) - - assert.Equal(t, 9, metrics.Get("stats.ds."+models.DS_ES+".count").MustInt()) - assert.Equal(t, 10, metrics.Get("stats.ds."+models.DS_PROMETHEUS+".count").MustInt()) - - assert.Equal(t, 11+12, metrics.Get("stats.ds.other.count").MustInt()) - - assert.Equal(t, 1, metrics.Get("stats.ds_access."+models.DS_ES+".direct.count").MustInt()) - assert.Equal(t, 2, metrics.Get("stats.ds_access."+models.DS_ES+".proxy.count").MustInt()) - assert.Equal(t, 3, metrics.Get("stats.ds_access."+models.DS_PROMETHEUS+".proxy.count").MustInt()) - assert.Equal(t, 6+7, metrics.Get("stats.ds_access.other.direct.count").MustInt()) - assert.Equal(t, 4+8, metrics.Get("stats.ds_access.other.proxy.count").MustInt()) - - assert.Equal(t, 1, metrics.Get("stats.alert_notifiers.slack.count").MustInt()) - assert.Equal(t, 2, metrics.Get("stats.alert_notifiers.webhook.count").MustInt()) - - assert.Equal(t, 1, metrics.Get("stats.auth_enabled.anonymous.count").MustInt()) - assert.Equal(t, 1, metrics.Get("stats.auth_enabled.basic_auth.count").MustInt()) - assert.Equal(t, 1, metrics.Get("stats.auth_enabled.ldap.count").MustInt()) - assert.Equal(t, 1, metrics.Get("stats.auth_enabled.auth_proxy.count").MustInt()) - assert.Equal(t, 1, metrics.Get("stats.auth_enabled.oauth_github.count").MustInt()) - assert.Equal(t, 1, metrics.Get("stats.auth_enabled.oauth_gitlab.count").MustInt()) - assert.Equal(t, 1, metrics.Get("stats.auth_enabled.oauth_google.count").MustInt()) - assert.Equal(t, 1, metrics.Get("stats.auth_enabled.oauth_azuread.count").MustInt()) - assert.Equal(t, 1, metrics.Get("stats.auth_enabled.oauth_generic_oauth.count").MustInt()) - assert.Equal(t, 1, metrics.Get("stats.auth_enabled.oauth_grafana_com.count").MustInt()) - - assert.Equal(t, 1, metrics.Get("stats.packaging.deb.count").MustInt()) - assert.Equal(t, 1, metrics.Get("stats.distributor.hosted-grafana.count").MustInt()) - - assert.LessOrEqual(t, 60, metrics.Get("stats.uptime").MustInt()) - assert.Greater(t, 70, metrics.Get("stats.uptime").MustInt()) - }) + uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) { + return map[string]interface{}{metricName: 1}, nil }) - t.Run("When updating total stats", func(t *testing.T) { - sqlStore := mockstore.NewSQLStoreMock() - uss := createService(t, setting.Cfg{}, sqlStore, false) - uss.Cfg.MetricsEndpointEnabled = true - uss.Cfg.MetricsEndpointDisableTotalStats = false + report, err := uss.GetUsageReport(context.Background()) + require.NoError(t, err, "Expected no error") - sqlStore.ExpectedSystemStats = &models.SystemStats{} + metric := report.Metrics[metricName] + assert.Equal(t, 1, metric) +} - t.Run("When metrics is disabled and total stats is enabled, stats should not be updated", func(t *testing.T) { - uss.Cfg.MetricsEndpointEnabled = false - uss.Cfg.MetricsEndpointDisableTotalStats = false - uss.updateTotalStats(context.Background()) - }) +func TestRegisterMetrics(t *testing.T) { + const goodMetricName = "stats.test_external_metric.count" - t.Run("When metrics is enabled and total stats is disabled, stats should not be updated", func(t *testing.T) { - uss.Cfg.MetricsEndpointEnabled = true - uss.Cfg.MetricsEndpointDisableTotalStats = true + sqlStore := mockstore.NewSQLStoreMock() + uss := createService(t, setting.Cfg{}, sqlStore, false) + metrics := map[string]interface{}{"stats.test_metric.count": 1, "stats.test_metric_second.count": 2} - uss.updateTotalStats(context.Background()) - }) - - t.Run("When metrics is disabled and total stats is disabled, stats should not be updated", func(t *testing.T) { - uss.Cfg.MetricsEndpointEnabled = false - uss.Cfg.MetricsEndpointDisableTotalStats = true - - uss.updateTotalStats(context.Background()) - }) - - t.Run("When metrics is enabled and total stats is enabled, stats should be updated", func(t *testing.T) { - uss.Cfg.MetricsEndpointEnabled = true - uss.Cfg.MetricsEndpointDisableTotalStats = false - - uss.updateTotalStats(context.Background()) - }) + uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) { + return map[string]interface{}{goodMetricName: 1}, nil }) - t.Run("When registering a metric", func(t *testing.T) { - sqlStore := mockstore.NewSQLStoreMock() - uss := createService(t, setting.Cfg{}, sqlStore, false) - metricName := "stats.test_metric.count" + { + extMetrics, err := uss.externalMetrics[0](context.Background()) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{goodMetricName: 1}, extMetrics) + } - t.Run("Adds a new metric to the external metrics", func(t *testing.T) { - uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) { - return map[string]interface{}{metricName: 1}, nil - }) + uss.gatherMetrics(context.Background(), metrics) + assert.Equal(t, 1, metrics[goodMetricName]) - metrics, err := uss.externalMetrics[0](context.Background()) - require.NoError(t, err) - assert.Equal(t, map[string]interface{}{metricName: 1}, metrics) - }) - }) - - t.Run("When getting usage report", func(t *testing.T) { - sqlStore := mockstore.NewSQLStoreMock() - uss := createService(t, setting.Cfg{}, sqlStore, true) - metricName := "stats.test_metric.count" - - createConcurrentTokens(t, uss.SQLStore) - - t.Run("Should include metrics for concurrent users", func(t *testing.T) { - report, err := uss.GetUsageReport(context.Background()) - require.NoError(t, err) - - assert.Equal(t, int32(1), report.Metrics["stats.auth_token_per_user_le_3"]) - assert.Equal(t, int32(2), report.Metrics["stats.auth_token_per_user_le_6"]) - assert.Equal(t, int32(3), report.Metrics["stats.auth_token_per_user_le_9"]) - assert.Equal(t, int32(4), report.Metrics["stats.auth_token_per_user_le_12"]) - assert.Equal(t, int32(5), report.Metrics["stats.auth_token_per_user_le_15"]) - assert.Equal(t, int32(6), report.Metrics["stats.auth_token_per_user_le_inf"]) - }) - - t.Run("Should include external metrics", func(t *testing.T) { - uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) { - return map[string]interface{}{metricName: 1}, nil - }) - - report, err := uss.GetUsageReport(context.Background()) - require.NoError(t, err, "Expected no error") - - metric := report.Metrics[metricName] - assert.Equal(t, 1, metric) - }) - }) - - t.Run("When registering external metrics", func(t *testing.T) { - sqlStore := mockstore.NewSQLStoreMock() - uss := createService(t, setting.Cfg{}, sqlStore, false) - metrics := map[string]interface{}{"stats.test_metric.count": 1, "stats.test_metric_second.count": 2} - extMetricName := "stats.test_external_metric.count" + t.Run("do not add metrics that return an error when fetched", func(t *testing.T) { + const badMetricName = "stats.test_external_metric_error.count" uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) { - return map[string]interface{}{extMetricName: 1}, nil + return map[string]interface{}{badMetricName: 1}, errors.New("some error") }) + uss.gatherMetrics(context.Background(), metrics) - uss.registerExternalMetrics(context.Background(), metrics) + extErrorMetric := metrics[badMetricName] + extMetric := metrics[goodMetricName] - assert.Equal(t, 1, metrics[extMetricName]) - - t.Run("When loading a metric results to an error", func(t *testing.T) { - uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) { - return map[string]interface{}{extMetricName: 1}, nil - }) - extErrorMetricName := "stats.test_external_metric_error.count" - - t.Run("Should not add it to metrics", func(t *testing.T) { - uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) { - return map[string]interface{}{extErrorMetricName: 1}, errors.New("some error") - }) - - uss.registerExternalMetrics(context.Background(), metrics) - - extErrorMetric := metrics[extErrorMetricName] - extMetric := metrics[extMetricName] - - require.Nil(t, extErrorMetric, "Invalid metric should not be added") - assert.Equal(t, 1, extMetric) - assert.Len(t, metrics, 3, "Expected only one available metric") - }) - }) + require.Nil(t, extErrorMetric, "Invalid metric should not be added") + assert.Equal(t, 1, extMetric) + assert.Len(t, metrics, 3, "Expected only one available metric") }) } @@ -470,27 +221,6 @@ func (pr fakePluginStore) Plugins(_ context.Context, pluginTypes ...plugins.Type return result } -func setupSomeDataSourcePlugins(t *testing.T, uss *UsageStats) { - t.Helper() - - uss.pluginStore = &fakePluginStore{ - plugins: map[string]plugins.PluginDTO{ - models.DS_ES: { - Signature: "internal", - }, - models.DS_PROMETHEUS: { - Signature: "internal", - }, - models.DS_GRAPHITE: { - Signature: "internal", - }, - models.DS_MYSQL: { - Signature: "internal", - }, - }, - } -} - type httpResp struct { req *http.Request responseBuffer *bytes.Buffer @@ -502,14 +232,11 @@ func createService(t *testing.T, cfg setting.Cfg, sqlStore sqlstore.Store, withD if withDB { sqlStore = sqlstore.InitTestDB(t) } - return &UsageStats{ - Cfg: &cfg, - SQLStore: sqlStore, - externalMetrics: make([]usagestats.MetricsFunc, 0), - pluginStore: &fakePluginStore{}, - kvStore: kvstore.WithNamespace(kvstore.ProvideService(sqlStore), 0, "infra.usagestats"), - log: log.New("infra.usagestats"), - startTime: time.Now().Add(-1 * time.Minute), - RouteRegister: routing.NewRouteRegister(), - } + + return ProvideService( + &cfg, + &fakePluginStore{}, + kvstore.ProvideService(sqlStore), + routing.NewRouteRegister(), + ) } diff --git a/pkg/infra/usagestats/statscollector/concurrent_users.go b/pkg/infra/usagestats/statscollector/concurrent_users.go new file mode 100644 index 00000000000..55d23881ebc --- /dev/null +++ b/pkg/infra/usagestats/statscollector/concurrent_users.go @@ -0,0 +1,58 @@ +package statscollector + +import ( + "context" + "fmt" + "time" + + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +const concurrentUserStatsCacheLifetime = time.Hour + +type concurrentUsersStats struct { + BucketLE3 int32 `xorm:"bucket_le_3"` + BucketLE6 int32 `xorm:"bucket_le_6"` + BucketLE9 int32 `xorm:"bucket_le_9"` + BucketLE12 int32 `xorm:"bucket_le_12"` + BucketLE15 int32 `xorm:"bucket_le_15"` + BucketLEInf int32 `xorm:"bucket_le_inf"` +} + +type memoConcurrentUserStats struct { + stats *concurrentUsersStats + + memoized time.Time +} + +func (s *Service) concurrentUsers(ctx context.Context) (*concurrentUsersStats, error) { + memoizationPeriod := time.Now().Add(-concurrentUserStatsCacheLifetime) + if !s.concurrentUserStatsCache.memoized.Before(memoizationPeriod) { + return s.concurrentUserStatsCache.stats, nil + } + + s.concurrentUserStatsCache.stats = &concurrentUsersStats{} + err := s.sqlstore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + // Retrieves concurrent users stats as a histogram. Buckets are accumulative and upper bound is inclusive. + rawSQL := ` +SELECT + COUNT(CASE WHEN tokens <= 3 THEN 1 END) AS bucket_le_3, + COUNT(CASE WHEN tokens <= 6 THEN 1 END) AS bucket_le_6, + COUNT(CASE WHEN tokens <= 9 THEN 1 END) AS bucket_le_9, + COUNT(CASE WHEN tokens <= 12 THEN 1 END) AS bucket_le_12, + COUNT(CASE WHEN tokens <= 15 THEN 1 END) AS bucket_le_15, + COUNT(1) AS bucket_le_inf +FROM (select count(1) as tokens from user_auth_token group by user_id) uat;` + _, err := sess.SQL(rawSQL).Get(s.concurrentUserStatsCache.stats) + if err != nil { + return err + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to get concurrent users stats from database: %w", err) + } + + s.concurrentUserStatsCache.memoized = time.Now() + return s.concurrentUserStatsCache.stats, nil +} diff --git a/pkg/infra/usagestats/service/usage_stats_service_test.go b/pkg/infra/usagestats/statscollector/concurrent_users_test.go similarity index 57% rename from pkg/infra/usagestats/service/usage_stats_service_test.go rename to pkg/infra/usagestats/statscollector/concurrent_users_test.go index 5960c0fb8c6..dfa027d0b85 100644 --- a/pkg/infra/usagestats/service/usage_stats_service_test.go +++ b/pkg/infra/usagestats/statscollector/concurrent_users_test.go @@ -1,4 +1,4 @@ -package service +package statscollector import ( "context" @@ -8,21 +8,35 @@ import ( "testing" "time" - "github.com/grafana/grafana/pkg/infra/kvstore" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/util" ) -func TestUsageStatsService_GetConcurrentUsersStats(t *testing.T) { +func TestConcurrentUsersMetrics(t *testing.T) { sqlStore := sqlstore.InitTestDB(t) - uss := &UsageStats{ - SQLStore: sqlStore, - kvStore: kvstore.WithNamespace(kvstore.ProvideService(sqlStore), 0, "infra.usagestats"), - log: log.New("infra.usagestats"), - } + s := createService(t, setting.NewCfg(), sqlStore) + + createConcurrentTokens(t, sqlStore) + + stats, err := s.collect(context.Background()) + require.NoError(t, err) + + assert.Equal(t, int32(1), stats["stats.auth_token_per_user_le_3"]) + assert.Equal(t, int32(2), stats["stats.auth_token_per_user_le_6"]) + assert.Equal(t, int32(3), stats["stats.auth_token_per_user_le_9"]) + assert.Equal(t, int32(4), stats["stats.auth_token_per_user_le_12"]) + assert.Equal(t, int32(5), stats["stats.auth_token_per_user_le_15"]) + assert.Equal(t, int32(6), stats["stats.auth_token_per_user_le_inf"]) +} + +func TestConcurrentUsersStats(t *testing.T) { + sqlStore := sqlstore.InitTestDB(t) + s := createService(t, setting.NewCfg(), sqlStore) createConcurrentTokens(t, sqlStore) @@ -31,10 +45,10 @@ func TestUsageStatsService_GetConcurrentUsersStats(t *testing.T) { cancel() }) - actualResult, err := uss.GetConcurrentUsersStats(ctx) + actualResult, err := s.concurrentUsers(ctx) require.NoError(t, err) - expectedCachedResult := &concurrentUsersStats{ + expectedResult := &concurrentUsersStats{ BucketLE3: 1, BucketLE6: 2, BucketLE9: 3, @@ -42,14 +56,39 @@ func TestUsageStatsService_GetConcurrentUsersStats(t *testing.T) { BucketLE15: 5, BucketLEInf: 6, } - assert.Equal(t, expectedCachedResult, actualResult) + assert.Equal(t, expectedResult, actualResult) createToken(t, 8, sqlStore) require.NoError(t, err) - actualResult, err = uss.GetConcurrentUsersStats(ctx) + // Cached, new token is not reflected yet. + actualResult, err = s.concurrentUsers(ctx) require.NoError(t, err) - assert.Equal(t, expectedCachedResult, actualResult) + assert.Equal(t, expectedResult, actualResult) + + // Bust cache + s.concurrentUserStatsCache = memoConcurrentUserStats{} + + expectedResult = &concurrentUsersStats{ + BucketLE3: 2, + BucketLE6: 3, + BucketLE9: 4, + BucketLE12: 5, + BucketLE15: 6, + BucketLEInf: 7, + } + actualResult, err = s.concurrentUsers(ctx) + require.NoError(t, err) + assert.Equal(t, expectedResult, actualResult) +} + +func createConcurrentTokens(t *testing.T, sqlStore sqlstore.Store) { + t.Helper() + for u := 1; u <= 6; u++ { + for tkn := 1; tkn <= u*3; tkn++ { + createToken(t, u, sqlStore) + } + } } func createToken(t *testing.T, uID int, sqlStore sqlstore.Store) { @@ -83,15 +122,6 @@ func createToken(t *testing.T, uID int, sqlStore sqlstore.Store) { require.NoError(t, err) } -func createConcurrentTokens(t *testing.T, sqlStore sqlstore.Store) { - t.Helper() - for u := 1; u <= 6; u++ { - for tkn := 1; tkn <= u*3; tkn++ { - createToken(t, u, sqlStore) - } - } -} - type userAuthToken struct { UserID int64 `xorm:"user_id"` AuthToken string diff --git a/pkg/infra/usagestats/statscollector/service.go b/pkg/infra/usagestats/statscollector/service.go new file mode 100644 index 00000000000..b96abfb4e68 --- /dev/null +++ b/pkg/infra/usagestats/statscollector/service.go @@ -0,0 +1,321 @@ +package statscollector + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/grafana/grafana/pkg/services/featuremgmt" + + "github.com/grafana/grafana/pkg/infra/usagestats" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/metrics" + "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" +) + +type Service struct { + cfg *setting.Cfg + sqlstore sqlstore.Store + plugins plugins.Store + social social.Service + usageStats usagestats.Service + features *featuremgmt.FeatureManager + + log log.Logger + + startTime time.Time + concurrentUserStatsCache memoConcurrentUserStats +} + +func ProvideService( + usagestats usagestats.Service, + cfg *setting.Cfg, + store sqlstore.Store, + social social.Service, + plugins plugins.Store, + features *featuremgmt.FeatureManager, +) *Service { + s := &Service{ + cfg: cfg, + sqlstore: store, + plugins: plugins, + social: social, + usageStats: usagestats, + features: features, + + startTime: time.Now(), + log: log.New("infra.usagestats.collector"), + } + + usagestats.RegisterMetricsFunc(s.collect) + + return s +} + +func (s *Service) Run(ctx context.Context) error { + s.updateTotalStats(ctx) + updateStatsTicker := time.NewTicker(time.Minute * 30) + defer updateStatsTicker.Stop() + + for { + select { + case <-updateStatsTicker.C: + s.updateTotalStats(ctx) + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (s *Service) collect(ctx context.Context) (map[string]interface{}, error) { + m := map[string]interface{}{} + + statsQuery := models.GetSystemStatsQuery{} + if err := s.sqlstore.GetSystemStats(ctx, &statsQuery); err != nil { + s.log.Error("Failed to get system stats", "error", err) + return nil, err + } + + m["stats.dashboards.count"] = statsQuery.Result.Dashboards + m["stats.users.count"] = statsQuery.Result.Users + m["stats.admins.count"] = statsQuery.Result.Admins + m["stats.editors.count"] = statsQuery.Result.Editors + m["stats.viewers.count"] = statsQuery.Result.Viewers + m["stats.orgs.count"] = statsQuery.Result.Orgs + m["stats.playlist.count"] = statsQuery.Result.Playlists + m["stats.plugins.apps.count"] = s.appCount(ctx) + m["stats.plugins.panels.count"] = s.panelCount(ctx) + m["stats.plugins.datasources.count"] = s.dataSourceCount(ctx) + m["stats.alerts.count"] = statsQuery.Result.Alerts + m["stats.active_users.count"] = statsQuery.Result.ActiveUsers + m["stats.active_admins.count"] = statsQuery.Result.ActiveAdmins + m["stats.active_editors.count"] = statsQuery.Result.ActiveEditors + m["stats.active_viewers.count"] = statsQuery.Result.ActiveViewers + m["stats.active_sessions.count"] = statsQuery.Result.ActiveSessions + m["stats.monthly_active_users.count"] = statsQuery.Result.MonthlyActiveUsers + m["stats.daily_active_users.count"] = statsQuery.Result.DailyActiveUsers + m["stats.daily_active_admins.count"] = statsQuery.Result.DailyActiveAdmins + m["stats.daily_active_editors.count"] = statsQuery.Result.DailyActiveEditors + m["stats.daily_active_viewers.count"] = statsQuery.Result.DailyActiveViewers + m["stats.daily_active_sessions.count"] = statsQuery.Result.DailyActiveSessions + m["stats.datasources.count"] = statsQuery.Result.Datasources + m["stats.stars.count"] = statsQuery.Result.Stars + m["stats.folders.count"] = statsQuery.Result.Folders + m["stats.dashboard_permissions.count"] = statsQuery.Result.DashboardPermissions + m["stats.folder_permissions.count"] = statsQuery.Result.FolderPermissions + m["stats.provisioned_dashboards.count"] = statsQuery.Result.ProvisionedDashboards + m["stats.snapshots.count"] = statsQuery.Result.Snapshots + m["stats.teams.count"] = statsQuery.Result.Teams + m["stats.total_auth_token.count"] = statsQuery.Result.AuthTokens + m["stats.dashboard_versions.count"] = statsQuery.Result.DashboardVersions + m["stats.annotations.count"] = statsQuery.Result.Annotations + m["stats.alert_rules.count"] = statsQuery.Result.AlertRules + m["stats.library_panels.count"] = statsQuery.Result.LibraryPanels + m["stats.library_variables.count"] = statsQuery.Result.LibraryVariables + m["stats.dashboards_viewers_can_edit.count"] = statsQuery.Result.DashboardsViewersCanEdit + m["stats.dashboards_viewers_can_admin.count"] = statsQuery.Result.DashboardsViewersCanAdmin + m["stats.folders_viewers_can_edit.count"] = statsQuery.Result.FoldersViewersCanEdit + m["stats.folders_viewers_can_admin.count"] = statsQuery.Result.FoldersViewersCanAdmin + m["stats.api_keys.count"] = statsQuery.Result.APIKeys + + ossEditionCount := 1 + enterpriseEditionCount := 0 + if s.cfg.IsEnterprise { + enterpriseEditionCount = 1 + ossEditionCount = 0 + } + m["stats.edition.oss.count"] = ossEditionCount + m["stats.edition.enterprise.count"] = enterpriseEditionCount + + userCount := statsQuery.Result.Users + avgAuthTokensPerUser := statsQuery.Result.AuthTokens + if userCount != 0 { + avgAuthTokensPerUser /= userCount + } + + m["stats.avg_auth_token_per_user.count"] = avgAuthTokensPerUser + + dsStats := models.GetDataSourceStatsQuery{} + if err := s.sqlstore.GetDataSourceStats(ctx, &dsStats); err != nil { + s.log.Error("Failed to get datasource stats", "error", err) + return nil, err + } + + // send counters for each data source + // but ignore any custom data sources + // as sending that name could be sensitive information + dsOtherCount := 0 + for _, dsStat := range dsStats.Result { + if s.usageStats.ShouldBeReported(ctx, dsStat.Type) { + m["stats.ds."+dsStat.Type+".count"] = dsStat.Count + } else { + dsOtherCount += dsStat.Count + } + } + m["stats.ds.other.count"] = dsOtherCount + + esDataSourcesQuery := models.GetDataSourcesByTypeQuery{Type: models.DS_ES} + if err := s.sqlstore.GetDataSourcesByType(ctx, &esDataSourcesQuery); err != nil { + s.log.Error("Failed to get elasticsearch json data", "error", err) + return nil, err + } + + for _, data := range esDataSourcesQuery.Result { + esVersion, err := data.JsonData.Get("esVersion").Int() + if err != nil { + continue + } + + statName := fmt.Sprintf("stats.ds.elasticsearch.v%d.count", esVersion) + + count, _ := m[statName].(int64) + + m[statName] = count + 1 + } + + m["stats.packaging."+s.cfg.Packaging+".count"] = 1 + m["stats.distributor."+s.cfg.ReportingDistributor+".count"] = 1 + + // fetch datasource access stats + dsAccessStats := models.GetDataSourceAccessStatsQuery{} + if err := s.sqlstore.GetDataSourceAccessStats(ctx, &dsAccessStats); err != nil { + s.log.Error("Failed to get datasource access stats", "error", err) + return nil, err + } + + // send access counters for each data source + // but ignore any custom data sources + // as sending that name could be sensitive information + dsAccessOtherCount := make(map[string]int64) + for _, dsAccessStat := range dsAccessStats.Result { + if dsAccessStat.Access == "" { + continue + } + + access := strings.ToLower(dsAccessStat.Access) + + if s.usageStats.ShouldBeReported(ctx, dsAccessStat.Type) { + m["stats.ds_access."+dsAccessStat.Type+"."+access+".count"] = dsAccessStat.Count + } else { + old := dsAccessOtherCount[access] + dsAccessOtherCount[access] = old + dsAccessStat.Count + } + } + + for access, count := range dsAccessOtherCount { + m["stats.ds_access.other."+access+".count"] = count + } + + // get stats about alert notifier usage + anStats := models.GetAlertNotifierUsageStatsQuery{} + if err := s.sqlstore.GetAlertNotifiersUsageStats(ctx, &anStats); err != nil { + s.log.Error("Failed to get alert notification stats", "error", err) + return nil, err + } + + for _, stats := range anStats.Result { + m["stats.alert_notifiers."+stats.Type+".count"] = stats.Count + } + + // Add stats about auth configuration + authTypes := map[string]bool{} + authTypes["anonymous"] = s.cfg.AnonymousEnabled + authTypes["basic_auth"] = s.cfg.BasicAuthEnabled + authTypes["ldap"] = s.cfg.LDAPEnabled + authTypes["auth_proxy"] = s.cfg.AuthProxyEnabled + + for provider, enabled := range s.social.GetOAuthProviders() { + authTypes["oauth_"+provider] = enabled + } + + for authType, enabled := range authTypes { + enabledValue := 0 + if enabled { + enabledValue = 1 + } + m["stats.auth_enabled."+authType+".count"] = enabledValue + } + + // Get concurrent users stats as histogram + concurrentUsersStats, err := s.concurrentUsers(ctx) + if err != nil { + s.log.Error("Failed to get concurrent users stats", "error", err) + return nil, err + } + + // Histogram is cumulative and metric name has a postfix of le_"" + m["stats.auth_token_per_user_le_3"] = concurrentUsersStats.BucketLE3 + m["stats.auth_token_per_user_le_6"] = concurrentUsersStats.BucketLE6 + m["stats.auth_token_per_user_le_9"] = concurrentUsersStats.BucketLE9 + m["stats.auth_token_per_user_le_12"] = concurrentUsersStats.BucketLE12 + m["stats.auth_token_per_user_le_15"] = concurrentUsersStats.BucketLE15 + m["stats.auth_token_per_user_le_inf"] = concurrentUsersStats.BucketLEInf + + m["stats.uptime"] = int64(time.Since(s.startTime).Seconds()) + + featureUsageStats := s.features.GetUsageStats(ctx) + for k, v := range featureUsageStats { + m[k] = v + } + + return m, nil +} + +func (s *Service) updateTotalStats(ctx context.Context) bool { + if !s.cfg.MetricsEndpointEnabled || s.cfg.MetricsEndpointDisableTotalStats { + return false + } + + statsQuery := models.GetSystemStatsQuery{} + if err := s.sqlstore.GetSystemStats(ctx, &statsQuery); err != nil { + s.log.Error("Failed to get system stats", "error", err) + return false + } + + metrics.MStatTotalDashboards.Set(float64(statsQuery.Result.Dashboards)) + metrics.MStatTotalFolders.Set(float64(statsQuery.Result.Folders)) + metrics.MStatTotalUsers.Set(float64(statsQuery.Result.Users)) + metrics.MStatActiveUsers.Set(float64(statsQuery.Result.ActiveUsers)) + metrics.MStatTotalPlaylists.Set(float64(statsQuery.Result.Playlists)) + metrics.MStatTotalOrgs.Set(float64(statsQuery.Result.Orgs)) + metrics.StatsTotalViewers.Set(float64(statsQuery.Result.Viewers)) + metrics.StatsTotalActiveViewers.Set(float64(statsQuery.Result.ActiveViewers)) + metrics.StatsTotalEditors.Set(float64(statsQuery.Result.Editors)) + metrics.StatsTotalActiveEditors.Set(float64(statsQuery.Result.ActiveEditors)) + metrics.StatsTotalAdmins.Set(float64(statsQuery.Result.Admins)) + metrics.StatsTotalActiveAdmins.Set(float64(statsQuery.Result.ActiveAdmins)) + metrics.StatsTotalDashboardVersions.Set(float64(statsQuery.Result.DashboardVersions)) + metrics.StatsTotalAnnotations.Set(float64(statsQuery.Result.Annotations)) + metrics.StatsTotalAlertRules.Set(float64(statsQuery.Result.AlertRules)) + metrics.StatsTotalLibraryPanels.Set(float64(statsQuery.Result.LibraryPanels)) + metrics.StatsTotalLibraryVariables.Set(float64(statsQuery.Result.LibraryVariables)) + + dsStats := models.GetDataSourceStatsQuery{} + if err := s.sqlstore.GetDataSourceStats(ctx, &dsStats); err != nil { + s.log.Error("Failed to get datasource stats", "error", err) + return true + } + + for _, dsStat := range dsStats.Result { + metrics.StatsTotalDataSources.WithLabelValues(dsStat.Type).Set(float64(dsStat.Count)) + } + return true +} + +func (s *Service) appCount(ctx context.Context) int { + return len(s.plugins.Plugins(ctx, plugins.App)) +} + +func (s *Service) panelCount(ctx context.Context) int { + return len(s.plugins.Plugins(ctx, plugins.Panel)) +} + +func (s *Service) dataSourceCount(ctx context.Context) int { + return len(s.plugins.Plugins(ctx, plugins.DataSource)) +} diff --git a/pkg/infra/usagestats/statscollector/service_test.go b/pkg/infra/usagestats/statscollector/service_test.go new file mode 100644 index 00000000000..6ff76c947bd --- /dev/null +++ b/pkg/infra/usagestats/statscollector/service_test.go @@ -0,0 +1,362 @@ +package statscollector + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/grafana/grafana/pkg/services/featuremgmt" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/usagestats" + "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" + "github.com/grafana/grafana/pkg/setting" +) + +func TestTotalStatsUpdate(t *testing.T) { + sqlStore := mockstore.NewSQLStoreMock() + s := createService(t, setting.NewCfg(), sqlStore) + s.cfg.MetricsEndpointEnabled = true + s.cfg.MetricsEndpointDisableTotalStats = false + + sqlStore.ExpectedSystemStats = &models.SystemStats{} + + tests := []struct { + MetricsEndpointEnabled bool + MetricsEndpointDisableTotalStats bool + ExpectedUpdate bool + }{ + { + MetricsEndpointEnabled: false, + MetricsEndpointDisableTotalStats: false, + ExpectedUpdate: false, + }, + { + MetricsEndpointEnabled: false, + MetricsEndpointDisableTotalStats: true, + ExpectedUpdate: false, + }, + { + MetricsEndpointEnabled: true, + MetricsEndpointDisableTotalStats: true, + ExpectedUpdate: false, + }, + { + MetricsEndpointEnabled: true, + MetricsEndpointDisableTotalStats: false, + ExpectedUpdate: true, + }, + } + + for _, tc := range tests { + tc := tc + + t.Run(fmt.Sprintf( + "metricsEnabled(%v) * totalStatsDisabled(%v) = %v", + tc.MetricsEndpointEnabled, + tc.MetricsEndpointDisableTotalStats, + tc.ExpectedUpdate, + ), func(t *testing.T) { + s.cfg.MetricsEndpointEnabled = tc.MetricsEndpointEnabled + s.cfg.MetricsEndpointDisableTotalStats = tc.MetricsEndpointDisableTotalStats + + assert.Equal(t, tc.ExpectedUpdate, s.updateTotalStats(context.Background())) + }) + } +} + +func TestFeatureUsageStats(t *testing.T) { + store := mockstore.NewSQLStoreMock() + mockSystemStats(store) + s := createService(t, setting.NewCfg(), store) + + m, err := s.collect(context.Background()) + require.NoError(t, err, "Expected no error") + + assert.Equal(t, 1, m["stats.features.feature_1.count"]) + assert.Equal(t, 1, m["stats.features.feature_2.count"]) +} + +func TestCollectingUsageStats(t *testing.T) { + sqlStore := mockstore.NewSQLStoreMock() + s := createService(t, &setting.Cfg{ + ReportingEnabled: true, + BuildVersion: "5.0.0", + AnonymousEnabled: true, + BasicAuthEnabled: true, + LDAPEnabled: true, + AuthProxyEnabled: true, + Packaging: "deb", + ReportingDistributor: "hosted-grafana", + }, sqlStore) + + s.startTime = time.Now().Add(-1 * time.Minute) + + mockSystemStats(sqlStore) + setupSomeDataSourcePlugins(t, s) + + sqlStore.ExpectedDataSourceStats = []*models.DataSourceStats{ + { + Type: models.DS_ES, + Count: 9, + }, + { + Type: models.DS_PROMETHEUS, + Count: 10, + }, + { + Type: "unknown_ds", + Count: 11, + }, + { + Type: "unknown_ds2", + Count: 12, + }, + } + + sqlStore.ExpectedDataSources = []*models.DataSource{ + { + JsonData: simplejson.NewFromAny(map[string]interface{}{ + "esVersion": 2, + }), + }, + { + JsonData: simplejson.NewFromAny(map[string]interface{}{ + "esVersion": 2, + }), + }, + { + JsonData: simplejson.NewFromAny(map[string]interface{}{ + "esVersion": 70, + }), + }, + } + + sqlStore.ExpectedDataSourcesAccessStats = []*models.DataSourceAccessStats{ + { + Type: models.DS_ES, + Access: "direct", + Count: 1, + }, + { + Type: models.DS_ES, + Access: "proxy", + Count: 2, + }, + { + Type: models.DS_PROMETHEUS, + Access: "proxy", + Count: 3, + }, + { + Type: "unknown_ds", + Access: "proxy", + Count: 4, + }, + { + Type: "unknown_ds2", + Access: "", + Count: 5, + }, + { + Type: "unknown_ds3", + Access: "direct", + Count: 6, + }, + { + Type: "unknown_ds4", + Access: "direct", + Count: 7, + }, + { + Type: "unknown_ds5", + Access: "proxy", + Count: 8, + }, + } + + sqlStore.ExpectedNotifierUsageStats = []*models.NotifierUsageStats{ + { + Type: "slack", + Count: 1, + }, + { + Type: "webhook", + Count: 2, + }, + } + + createConcurrentTokens(t, sqlStore) + + s.social = &mockSocial{ + OAuthProviders: map[string]bool{ + "github": true, + "gitlab": true, + "azuread": true, + "google": true, + "generic_oauth": true, + "grafana_com": true, + }, + } + + metrics, err := s.collect(context.Background()) + require.NoError(t, err) + + assert.EqualValues(t, 15, metrics["stats.total_auth_token.count"]) + assert.EqualValues(t, 2, metrics["stats.api_keys.count"]) + assert.EqualValues(t, 5, metrics["stats.avg_auth_token_per_user.count"]) + assert.EqualValues(t, 16, metrics["stats.dashboard_versions.count"]) + assert.EqualValues(t, 17, metrics["stats.annotations.count"]) + assert.EqualValues(t, 18, metrics["stats.alert_rules.count"]) + assert.EqualValues(t, 19, metrics["stats.library_panels.count"]) + assert.EqualValues(t, 20, metrics["stats.library_variables.count"]) + + assert.EqualValues(t, 9, metrics["stats.ds."+models.DS_ES+".count"]) + assert.EqualValues(t, 10, metrics["stats.ds."+models.DS_PROMETHEUS+".count"]) + + assert.EqualValues(t, 11+12, metrics["stats.ds.other.count"]) + + assert.EqualValues(t, 1, metrics["stats.ds_access."+models.DS_ES+".direct.count"]) + assert.EqualValues(t, 2, metrics["stats.ds_access."+models.DS_ES+".proxy.count"]) + assert.EqualValues(t, 3, metrics["stats.ds_access."+models.DS_PROMETHEUS+".proxy.count"]) + assert.EqualValues(t, 6+7, metrics["stats.ds_access.other.direct.count"]) + assert.EqualValues(t, 4+8, metrics["stats.ds_access.other.proxy.count"]) + + assert.EqualValues(t, 1, metrics["stats.alert_notifiers.slack.count"]) + assert.EqualValues(t, 2, metrics["stats.alert_notifiers.webhook.count"]) + + assert.EqualValues(t, 1, metrics["stats.auth_enabled.anonymous.count"]) + assert.EqualValues(t, 1, metrics["stats.auth_enabled.basic_auth.count"]) + assert.EqualValues(t, 1, metrics["stats.auth_enabled.ldap.count"]) + assert.EqualValues(t, 1, metrics["stats.auth_enabled.auth_proxy.count"]) + assert.EqualValues(t, 1, metrics["stats.auth_enabled.oauth_github.count"]) + assert.EqualValues(t, 1, metrics["stats.auth_enabled.oauth_gitlab.count"]) + assert.EqualValues(t, 1, metrics["stats.auth_enabled.oauth_google.count"]) + assert.EqualValues(t, 1, metrics["stats.auth_enabled.oauth_azuread.count"]) + assert.EqualValues(t, 1, metrics["stats.auth_enabled.oauth_generic_oauth.count"]) + assert.EqualValues(t, 1, metrics["stats.auth_enabled.oauth_grafana_com.count"]) + + assert.EqualValues(t, 1, metrics["stats.packaging.deb.count"]) + assert.EqualValues(t, 1, metrics["stats.distributor.hosted-grafana.count"]) + + assert.InDelta(t, int64(65), metrics["stats.uptime"], 6) +} + +func mockSystemStats(sqlStore *mockstore.SQLStoreMock) { + sqlStore.ExpectedSystemStats = &models.SystemStats{ + Dashboards: 1, + Datasources: 2, + Users: 3, + Admins: 31, + Editors: 32, + Viewers: 33, + ActiveUsers: 4, + ActiveAdmins: 21, + ActiveEditors: 22, + ActiveViewers: 23, + ActiveSessions: 24, + DailyActiveUsers: 25, + DailyActiveAdmins: 26, + DailyActiveEditors: 27, + DailyActiveViewers: 28, + DailyActiveSessions: 29, + Orgs: 5, + Playlists: 6, + Alerts: 7, + Stars: 8, + Folders: 9, + DashboardPermissions: 10, + FolderPermissions: 11, + ProvisionedDashboards: 12, + Snapshots: 13, + Teams: 14, + AuthTokens: 15, + DashboardVersions: 16, + Annotations: 17, + AlertRules: 18, + LibraryPanels: 19, + LibraryVariables: 20, + DashboardsViewersCanAdmin: 3, + DashboardsViewersCanEdit: 2, + FoldersViewersCanAdmin: 1, + FoldersViewersCanEdit: 5, + APIKeys: 2, + } +} + +type mockSocial struct { + social.Service + + OAuthProviders map[string]bool +} + +func (m *mockSocial) GetOAuthProviders() map[string]bool { + return m.OAuthProviders +} + +type fakePluginStore struct { + plugins.Store + + plugins map[string]plugins.PluginDTO +} + +func (pr fakePluginStore) Plugin(_ context.Context, pluginID string) (plugins.PluginDTO, bool) { + p, exists := pr.plugins[pluginID] + + return p, exists +} + +func setupSomeDataSourcePlugins(t *testing.T, s *Service) { + t.Helper() + + s.plugins = &fakePluginStore{ + plugins: map[string]plugins.PluginDTO{ + models.DS_ES: { + Signature: "internal", + }, + models.DS_PROMETHEUS: { + Signature: "internal", + }, + models.DS_GRAPHITE: { + Signature: "internal", + }, + models.DS_MYSQL: { + Signature: "internal", + }, + }, + } +} + +func (pr fakePluginStore) Plugins(_ context.Context, pluginTypes ...plugins.Type) []plugins.PluginDTO { + var result []plugins.PluginDTO + for _, v := range pr.plugins { + for _, t := range pluginTypes { + if v.Type == t { + result = append(result, v) + } + } + } + + return result +} + +func createService(t testing.TB, cfg *setting.Cfg, store sqlstore.Store) *Service { + t.Helper() + + return ProvideService( + &usagestats.UsageStatsMock{}, + cfg, + store, + &mockSocial{}, + &fakePluginStore{}, + featuremgmt.WithFeatures("feature1", "feature2"), + ) +} diff --git a/pkg/login/auth.go b/pkg/login/auth.go index ae76ee3bec5..d75b1cd8ad2 100644 --- a/pkg/login/auth.go +++ b/pkg/login/auth.go @@ -4,7 +4,6 @@ import ( "context" "errors" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ldap" @@ -41,7 +40,6 @@ func ProvideService(store sqlstore.Store, loginService login.Service) *Authentic store: store, loginService: loginService, } - bus.AddHandler("auth", a.AuthenticateUser) return a } diff --git a/pkg/middleware/logger.go b/pkg/middleware/logger.go index 15174880807..c2f127319cb 100644 --- a/pkg/middleware/logger.go +++ b/pkg/middleware/logger.go @@ -19,10 +19,10 @@ import ( "net/http" "time" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" - cw "github.com/weaveworks/common/tracing" ) func Logger(cfg *setting.Cfg) web.Handler { @@ -58,8 +58,8 @@ func Logger(cfg *setting.Cfg) web.Handler { "referer", req.Referer(), } - traceID, exist := cw.ExtractTraceID(ctx.Req.Context()) - if exist { + traceID := tracing.TraceIDFromContext(ctx.Req.Context(), false) + if traceID != "" { logParams = append(logParams, "traceID", traceID) } diff --git a/pkg/middleware/middleware_basic_auth_test.go b/pkg/middleware/middleware_basic_auth_test.go index 7588d2172e1..5836a0d315b 100644 --- a/pkg/middleware/middleware_basic_auth_test.go +++ b/pkg/middleware/middleware_basic_auth_test.go @@ -1,11 +1,9 @@ package middleware import ( - "context" "encoding/json" "testing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/contexthandler" @@ -42,22 +40,8 @@ func TestMiddlewareBasicAuth(t *testing.T) { middlewareScenario(t, "Handle auth", func(t *testing.T, sc *scenarioContext) { const password = "MyPass" - const salt = "Salt" const orgID int64 = 2 - bus.AddHandler("grafana-auth", func(ctx context.Context, query *models.LoginUserQuery) error { - t.Log("Handling LoginUserQuery") - encoded, err := util.EncodePassword(password, salt) - if err != nil { - return err - } - query.User = &models.User{ - Password: encoded, - Salt: salt, - } - return nil - }) - sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{OrgId: orgID, UserId: id} authHeader := util.GetBasicAuthHeader("myUser", password) @@ -88,6 +72,7 @@ func TestMiddlewareBasicAuth(t *testing.T) { }, configure) middlewareScenario(t, "Should return error if user is not found", func(t *testing.T, sc *scenarioContext) { + sc.mockSQLStore.ExpectedError = models.ErrUserNotFound sc.fakeReq("GET", "/") sc.req.SetBasicAuth("user", "password") sc.exec() @@ -100,10 +85,7 @@ func TestMiddlewareBasicAuth(t *testing.T) { }, configure) middlewareScenario(t, "Should return error if user & password do not match", func(t *testing.T, sc *scenarioContext) { - bus.AddHandler("user-query", func(ctx context.Context, loginUserQuery *models.GetUserByLoginQuery) error { - return nil - }) - + sc.mockSQLStore.ExpectedError = models.ErrUserNotFound sc.fakeReq("GET", "/") sc.req.SetBasicAuth("killa", "gorilla") sc.exec() diff --git a/pkg/middleware/middleware_jwt_auth_test.go b/pkg/middleware/middleware_jwt_auth_test.go index 02d4075201d..31403464d69 100644 --- a/pkg/middleware/middleware_jwt_auth_test.go +++ b/pkg/middleware/middleware_jwt_auth_test.go @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/assert" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/setting" @@ -109,14 +108,6 @@ func TestMiddlewareJWTAuth(t *testing.T) { }, nil } sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{UserId: id, OrgId: orgID, Email: myEmail} - bus.AddHandler("upsert-user", func(ctx context.Context, command *models.UpsertUserCommand) error { - command.Result = &models.User{ - Id: id, - Name: command.ExternalUser.Name, - Email: command.ExternalUser.Email, - } - return nil - }) sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec() assert.Equal(t, verifiedToken, token) diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index fe645b87cd2..a4fbd7c7629 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -14,7 +14,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/fs" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/remotecache" @@ -25,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/login/loginservice" + "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" @@ -111,7 +111,7 @@ func TestMiddlewareContext(t *testing.T) { NavTree: []*dtos.NavLink{}, } t.Log("Calling HTML", "data", data) - c.HTML(200, "index-template", data) + c.HTML(http.StatusOK, "index-template", data) t.Log("Returned HTML with code 200") } sc.fakeReq("GET", "/").exec() @@ -569,8 +569,6 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func( t.Helper() t.Run(desc, func(t *testing.T) { - t.Cleanup(bus.ClearBusHandlers) - logger := log.New("test") loginMaxLifetime, err := gtime.ParseDuration("30d") @@ -602,7 +600,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func( sc.sqlStore = ctxHdlr.SQLStore sc.contextHandler = ctxHdlr sc.m.Use(ctxHdlr.Middleware) - sc.m.Use(OrgRedirect(sc.cfg)) + sc.m.Use(OrgRedirect(sc.cfg, sc.mockSQLStore)) sc.userAuthTokenService = ctxHdlr.AuthTokenService.(*auth.FakeUserAuthTokenService) sc.jwtAuthService = ctxHdlr.JWTAuthService.(*models.FakeJWTService) @@ -618,7 +616,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func( t.Log("Returning JSON OK") resp := make(map[string]interface{}) resp["message"] = "OK" - c.JSON(200, resp) + c.JSON(http.StatusOK, resp) } } @@ -644,8 +642,9 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg, mockSQLStore *mockstore.S authJWTSvc := models.NewFakeJWTService() tracer, err := tracing.InitializeTracerForTest() authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginService, mockSQLStore) + authenticator := &logintest.AuthenticatorFake{ExpectedUser: &models.User{}} require.NoError(t, err) - return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy) + return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy, loginService, authenticator) } type fakeRenderService struct { diff --git a/pkg/middleware/org_redirect.go b/pkg/middleware/org_redirect.go index e6f8b638f52..ecf0d0800b0 100644 --- a/pkg/middleware/org_redirect.go +++ b/pkg/middleware/org_redirect.go @@ -6,16 +6,16 @@ import ( "strconv" "strings" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) // OrgRedirect changes org and redirects users if the // querystring `orgId` doesn't match the active org. -func OrgRedirect(cfg *setting.Cfg) web.Handler { +func OrgRedirect(cfg *setting.Cfg, store sqlstore.Store) web.Handler { return func(res http.ResponseWriter, req *http.Request, c *web.Context) { orgIdValue := req.URL.Query().Get("orgId") orgId, err := strconv.ParseInt(orgIdValue, 10, 64) @@ -34,7 +34,7 @@ func OrgRedirect(cfg *setting.Cfg) web.Handler { } cmd := models.SetUsingOrgCommand{UserId: ctx.UserId, OrgId: orgId} - if err := bus.Dispatch(ctx.Req.Context(), &cmd); err != nil { + if err := store.SetUsingOrg(ctx.Req.Context(), &cmd); err != nil { if ctx.IsApiRequest() { ctx.JsonApiErr(404, "Not found", nil) } else { diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index 5917e9a5377..f943d12bd0a 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -5,7 +5,6 @@ import ( "fmt" "testing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/stretchr/testify/require" ) @@ -47,10 +46,6 @@ func TestOrgRedirectMiddleware(t *testing.T) { middlewareScenario(t, tc.desc, func(t *testing.T, sc *scenarioContext) { sc.withTokenSessionCookie("token") sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{OrgId: 1, UserId: 12} - bus.AddHandler("test", func(ctx context.Context, query *models.SetUsingOrgCommand) error { - return nil - }) - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { return &models.UserToken{ UserId: 0, @@ -68,9 +63,7 @@ func TestOrgRedirectMiddleware(t *testing.T) { middlewareScenario(t, "when setting an invalid org for user", func(t *testing.T, sc *scenarioContext) { sc.withTokenSessionCookie("token") - bus.AddHandler("test", func(ctx context.Context, query *models.SetUsingOrgCommand) error { - return fmt.Errorf("") - }) + sc.mockSQLStore.ExpectedSetUsingOrgError = fmt.Errorf("") sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{OrgId: 1, UserId: 12} sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { diff --git a/pkg/middleware/rate_limit_test.go b/pkg/middleware/rate_limit_test.go index 641e385018a..88fb7e021c5 100644 --- a/pkg/middleware/rate_limit_test.go +++ b/pkg/middleware/rate_limit_test.go @@ -25,7 +25,7 @@ func rateLimiterScenario(t *testing.T, desc string, rps int, burst int, fn rateL defaultHandler := func(c *models.ReqContext) { resp := make(map[string]interface{}) resp["message"] = "OK" - c.JSON(200, resp) + c.JSON(http.StatusOK, resp) } currentTime := time.Now() diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index 51b1c6c1c4d..251558056dc 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" @@ -48,8 +47,6 @@ func panicHandler(c *models.ReqContext) { func recoveryScenario(t *testing.T, desc string, url string, fn scenarioFunc) { t.Run(desc, func(t *testing.T) { - defer bus.ClearBusHandlers() - cfg := setting.NewCfg() cfg.ErrTemplateName = "error-template" sc := &scenarioContext{ @@ -73,7 +70,7 @@ func recoveryScenario(t *testing.T, desc string, url string, fn scenarioFunc) { contextHandler := getContextHandler(t, nil, nil, nil) sc.m.Use(contextHandler.Middleware) // mock out gc goroutine - sc.m.Use(OrgRedirect(cfg)) + sc.m.Use(OrgRedirect(cfg, sc.mockSQLStore)) sc.defaultHandler = func(c *models.ReqContext) { sc.context = c diff --git a/pkg/middleware/request_metrics.go b/pkg/middleware/request_metrics.go index d4923f233a6..6f9b2618993 100644 --- a/pkg/middleware/request_metrics.go +++ b/pkg/middleware/request_metrics.go @@ -7,10 +7,10 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/metrics" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/web" "github.com/prometheus/client_golang/prometheus" - cw "github.com/weaveworks/common/tracing" ) var ( @@ -80,7 +80,7 @@ func RequestMetrics(features featuremgmt.FeatureToggles) web.Handler { // since they dont make much sense. We should remove them later. histogram := httpRequestDurationHistogram. WithLabelValues(handler, code, req.Method) - if traceID, ok := cw.ExtractSampledTraceID(c.Req.Context()); ok { + if traceID := tracing.TraceIDFromContext(c.Req.Context(), true); traceID != "" { // Need to type-convert the Observer to an // ExemplarObserver. This will always work for a // HistogramVec. diff --git a/pkg/models/context.go b/pkg/models/context.go index dca3b2e5310..f31ea1197d6 100644 --- a/pkg/models/context.go +++ b/pkg/models/context.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" "github.com/prometheus/client_golang/prometheus" @@ -51,9 +52,11 @@ func (ctx *ReqContext) IsApiRequest() bool { func (ctx *ReqContext) JsonApiErr(status int, message string, err error) { resp := make(map[string]interface{}) + traceID := tracing.TraceIDFromContext(ctx.Req.Context(), false) if err != nil { - ctx.Logger.Error(message, "error", err) + resp["traceID"] = traceID + ctx.Logger.Error(message, "error", err, "traceID", traceID) if setting.Env != setting.Prod { resp["error"] = err.Error() } diff --git a/pkg/models/preferences.go b/pkg/models/preferences.go index 1fbfb3f0992..f5a20048c27 100644 --- a/pkg/models/preferences.go +++ b/pkg/models/preferences.go @@ -49,8 +49,13 @@ type NavbarPreference struct { SavedItems []NavLink `json:"savedItems"` } +type QueryHistoryPreference struct { + HomeTab string `json:"homeTab"` +} + type PreferencesJsonData struct { - Navbar NavbarPreference `json:"navbar"` + Navbar NavbarPreference `json:"navbar"` + QueryHistory QueryHistoryPreference `json:"queryHistory"` } // --------------------- @@ -78,11 +83,12 @@ type SavePreferencesCommand struct { OrgId int64 TeamId int64 - HomeDashboardId int64 `json:"homeDashboardId,omitempty"` - Timezone string `json:"timezone,omitempty"` - WeekStart string `json:"weekStart,omitempty"` - Theme string `json:"theme,omitempty"` - Navbar *NavbarPreference `json:"navbar,omitempty"` + HomeDashboardId int64 `json:"homeDashboardId,omitempty"` + Timezone string `json:"timezone,omitempty"` + WeekStart string `json:"weekStart,omitempty"` + Theme string `json:"theme,omitempty"` + Navbar *NavbarPreference `json:"navbar,omitempty"` + QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"` } type PatchPreferencesCommand struct { @@ -90,9 +96,10 @@ type PatchPreferencesCommand struct { OrgId int64 TeamId int64 - HomeDashboardId *int64 `json:"homeDashboardId,omitempty"` - Timezone *string `json:"timezone,omitempty"` - WeekStart *string `json:"weekStart,omitempty"` - Theme *string `json:"theme,omitempty"` - Navbar *NavbarPreference `json:"navbar,omitempty"` + HomeDashboardId *int64 `json:"homeDashboardId,omitempty"` + Timezone *string `json:"timezone,omitempty"` + WeekStart *string `json:"weekStart,omitempty"` + Theme *string `json:"theme,omitempty"` + Navbar *NavbarPreference `json:"navbar,omitempty"` + QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"` } diff --git a/pkg/models/user.go b/pkg/models/user.go index 4d4b2bace22..b2419637364 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -182,6 +182,7 @@ type SignedInUser struct { OrgCount int IsGrafanaAdmin bool IsAnonymous bool + IsDisabled bool HelpFlags1 HelpFlags1 LastSeenAt time.Time Teams []int64 diff --git a/pkg/plugins/manager/signature/manifest.go b/pkg/plugins/manager/signature/manifest.go index 43e474af440..a7dcb19516d 100644 --- a/pkg/plugins/manager/signature/manifest.go +++ b/pkg/plugins/manager/signature/manifest.go @@ -82,7 +82,7 @@ func readPluginManifest(body []byte) (*pluginManifest, error) { } // Convert to a well typed object - manifest := &pluginManifest{} + var manifest pluginManifest err := json.Unmarshal(block.Plaintext, &manifest) if err != nil { return nil, errutil.Wrap("Error parsing manifest JSON", err) @@ -99,7 +99,7 @@ func readPluginManifest(body []byte) (*pluginManifest, error) { return nil, errutil.Wrap("failed to check signature", err) } - return manifest, nil + return &manifest, nil } func Calculate(mlog log.Logger, plugin *plugins.Plugin) (plugins.Signature, error) { diff --git a/pkg/plugins/plugincontext/plugincontext.go b/pkg/plugins/plugincontext/plugincontext.go index 865a42905fa..652417534b7 100644 --- a/pkg/plugins/plugincontext/plugincontext.go +++ b/pkg/plugins/plugincontext/plugincontext.go @@ -8,7 +8,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" @@ -20,11 +19,10 @@ import ( "github.com/grafana/grafana/pkg/util/errutil" ) -func ProvideService(bus bus.Bus, cacheService *localcache.CacheService, pluginStore plugins.Store, +func ProvideService(cacheService *localcache.CacheService, pluginStore plugins.Store, dataSourceCache datasources.CacheService, secretsService secrets.Service, pluginSettingsService pluginsettings.Service) *Provider { return &Provider{ - bus: bus, cacheService: cacheService, pluginStore: pluginStore, dataSourceCache: dataSourceCache, @@ -35,7 +33,6 @@ func ProvideService(bus bus.Bus, cacheService *localcache.CacheService, pluginSt } type Provider struct { - bus bus.Bus cacheService *localcache.CacheService pluginStore plugins.Store dataSourceCache datasources.CacheService diff --git a/pkg/schema/load/load_test.go b/pkg/schema/load/load_test.go index e7d3c1e310c..d9f96849cc0 100644 --- a/pkg/schema/load/load_test.go +++ b/pkg/schema/load/load_test.go @@ -19,6 +19,7 @@ import ( "cuelang.org/go/cue/errors" "cuelang.org/go/cue/load" cuejson "cuelang.org/go/pkg/encoding/json" + "github.com/grafana/grafana/pkg/coremodel/dashboard" "github.com/grafana/grafana/pkg/schema" "github.com/laher/mergefs" "github.com/stretchr/testify/require" @@ -36,7 +37,6 @@ type testfunc func(*testing.T, schema.VersionedCueSchema, []byte, fs.FileInfo, s // TODO: remove validdir once we can test directly with devenv folder var doTestAgainstDevenv = func(sch schema.VersionedCueSchema, validdir string, fn testfunc) func(t *testing.T) { return func(t *testing.T) { - t.Parallel() require.NoError(t, filepath.Walk(validdir, func(path string, d fs.FileInfo, err error) error { require.NoError(t, err) @@ -49,7 +49,6 @@ var doTestAgainstDevenv = func(sch schema.VersionedCueSchema, validdir string, f b, err := os.Open(path) require.NoError(t, err, "failed to open dashboard file") - // Only try to validate dashboards with schemaVersion >= 30 jtree := make(map[string]interface{}) byt, err := io.ReadAll(b) if err != nil { @@ -60,9 +59,9 @@ var doTestAgainstDevenv = func(sch schema.VersionedCueSchema, validdir string, f t.Logf("no schemaVersion in %s", path) return nil } else { - if !(oldschemav.(float64) > 32) { + if !(oldschemav.(float64) > dashboard.HandoffSchemaVersion-1) { if testing.Verbose() { - t.Logf("schemaVersion is %v, older than 33, skipping %s", oldschemav, path) + t.Logf("schemaVersion is %v, older than %v, skipping %s", oldschemav, dashboard.HandoffSchemaVersion-1, path) } return nil } diff --git a/pkg/server/backgroundsvcs/background_services.go b/pkg/server/backgroundsvcs/background_services.go index 8521761435e..529e7abde09 100644 --- a/pkg/server/backgroundsvcs/background_services.go +++ b/pkg/server/backgroundsvcs/background_services.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/infra/tracing" uss "github.com/grafana/grafana/pkg/infra/usagestats/service" + "github.com/grafana/grafana/pkg/infra/usagestats/statscollector" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins/manager" "github.com/grafana/grafana/pkg/registry" @@ -32,9 +33,10 @@ func ProvideBackgroundServiceRegistry( pushGateway *pushhttp.Gateway, notifications *notifications.NotificationService, pm *manager.PluginManager, rendering *rendering.RenderingService, tokenService models.UserTokenBackgroundService, tracing tracing.Tracer, provisioning *provisioning.ProvisioningServiceImpl, alerting *alerting.AlertEngine, usageStats *uss.UsageStats, - grafanaUpdateChecker *updatechecker.GrafanaService, pluginsUpdateChecker *updatechecker.PluginsService, - metrics *metrics.InternalMetricsService, secretsService *secretsManager.SecretsService, - remoteCache *remotecache.RemoteCache, thumbnailsService thumbs.Service, StorageService store.StorageService, + statsCollector *statscollector.Service, grafanaUpdateChecker *updatechecker.GrafanaService, + pluginsUpdateChecker *updatechecker.PluginsService, metrics *metrics.InternalMetricsService, + secretsService *secretsManager.SecretsService, remoteCache *remotecache.RemoteCache, + thumbnailsService thumbs.Service, StorageService store.StorageService, // Need to make sure these are initialized, is there a better place to put them? _ *dashboardsnapshots.Service, _ *alerting.AlertNotificationService, _ serviceaccounts.Service, _ *guardian.Provider, @@ -56,11 +58,13 @@ func ProvideBackgroundServiceRegistry( pluginsUpdateChecker, metrics, usageStats, + statsCollector, tracing, remoteCache, secretsService, StorageService, - thumbnailsService) + thumbnailsService, + ) } // BackgroundServiceRegistry provides background services. diff --git a/pkg/server/server.go b/pkg/server/server.go index b1e8df32f94..d3c5ab9c88e 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -121,7 +121,7 @@ func (s *Server) init() error { login.ProvideService(s.HTTPServer.SQLStore, s.HTTPServer.Login) social.ProvideService(s.cfg) - if err := s.roleRegistry.RegisterFixedRoles(); err != nil { + if err := s.roleRegistry.RegisterFixedRoles(s.context); err != nil { return err } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 566aa24de28..7c22a7dc356 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -5,12 +5,17 @@ package server import ( "github.com/google/wire" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/api/avatar" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/coremodel/dashboard" + "github.com/grafana/grafana/pkg/cuectx" "github.com/grafana/grafana/pkg/expr" + cmreg "github.com/grafana/grafana/pkg/framework/coremodel/staticregistry" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/httpclient/httpclientprovider" "github.com/grafana/grafana/pkg/infra/kvstore" @@ -21,6 +26,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/infra/usagestats" uss "github.com/grafana/grafana/pkg/infra/usagestats/service" + "github.com/grafana/grafana/pkg/infra/usagestats/statscollector" loginpkg "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models" @@ -63,13 +69,13 @@ import ( plugindashboardsservice "github.com/grafana/grafana/pkg/services/plugindashboards/service" "github.com/grafana/grafana/pkg/services/pluginsettings" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service" + "github.com/grafana/grafana/pkg/services/preference/prefimpl" "github.com/grafana/grafana/pkg/services/query" "github.com/grafana/grafana/pkg/services/queryhistory" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/schemaloader" "github.com/grafana/grafana/pkg/services/search" - "github.com/grafana/grafana/pkg/services/searchV2" "github.com/grafana/grafana/pkg/services/secrets" secretsDatabase "github.com/grafana/grafana/pkg/services/secrets/database" @@ -233,8 +239,14 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(alerting.DashAlertExtractor), new(*alerting.DashAlertExtractorService)), comments.ProvideService, guardian.ProvideService, + prefimpl.ProvideService, avatar.ProvideAvatarCacheServer, authproxy.ProvideAuthProxy, + statscollector.ProvideService, + dashboard.ProvideCoremodel, + cmreg.ProvideRegistry, + cuectx.ProvideCUEContext, + cuectx.ProvideThemaLibrary, ) var wireSet = wire.NewSet( diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 81b21fad048..84903cdb4bd 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -31,6 +31,7 @@ import ( "github.com/grafana/grafana/pkg/services/searchusers" "github.com/grafana/grafana/pkg/services/searchusers/filters" "github.com/grafana/grafana/pkg/services/sqlstore/migrations" + "github.com/grafana/grafana/pkg/services/thumbs" "github.com/grafana/grafana/pkg/services/validations" "github.com/grafana/grafana/pkg/setting" ) @@ -46,6 +47,8 @@ var wireExtsBasicSet = wire.NewSet( ossaccesscontrol.ProvideService, wire.Bind(new(accesscontrol.RoleRegistry), new(*ossaccesscontrol.OSSAccessControlService)), wire.Bind(new(accesscontrol.AccessControl), new(*ossaccesscontrol.OSSAccessControlService)), + thumbs.ProvideCrawlerAuthSetupService, + wire.Bind(new(thumbs.CrawlerAuthSetupService), new(*thumbs.OSSCrawlerAuthSetupService)), validations.ProvideValidator, wire.Bind(new(models.PluginRequestValidator), new(*validations.OSSPluginRequestValidator)), provisioning.ProvideService, diff --git a/pkg/services/accesscontrol/filter.go b/pkg/services/accesscontrol/filter.go index 2fc40de2796..67eb5c49eaa 100644 --- a/pkg/services/accesscontrol/filter.go +++ b/pkg/services/accesscontrol/filter.go @@ -43,7 +43,7 @@ func Filter(user *models.SignedInUser, sqlID, prefix string, actions ...string) wildcards := 0 result := make(map[interface{}]int) for _, a := range actions { - ids, hasWildcard := parseScopes(prefix, user.Permissions[user.OrgId][a]) + ids, hasWildcard := ParseScopes(prefix, user.Permissions[user.OrgId][a]) if hasWildcard { wildcards += 1 continue @@ -84,7 +84,7 @@ func Filter(user *models.SignedInUser, sqlID, prefix string, actions ...string) return SQLFilter{query.String(), ids}, nil } -func parseScopes(prefix string, scopes []string) (ids map[interface{}]struct{}, hasWildcard bool) { +func ParseScopes(prefix string, scopes []string) (ids map[interface{}]struct{}, hasWildcard bool) { ids = make(map[interface{}]struct{}) rootPrefix, attributePrefix, ok := extractPrefixes(prefix) diff --git a/pkg/services/accesscontrol/mock/mock.go b/pkg/services/accesscontrol/mock/mock.go index 870ebcadbc9..c9278d4c9e7 100644 --- a/pkg/services/accesscontrol/mock/mock.go +++ b/pkg/services/accesscontrol/mock/mock.go @@ -10,7 +10,7 @@ import ( type fullAccessControl interface { accesscontrol.AccessControl GetUserBuiltInRoles(user *models.SignedInUser) []string - RegisterFixedRoles() error + RegisterFixedRoles(context.Context) error } type Calls struct { @@ -165,7 +165,7 @@ func (m *Mock) GetUserBuiltInRoles(user *models.SignedInUser) []string { // RegisterFixedRoles registers all roles declared to AccessControl // This mock returns no error unless an override is provided. -func (m *Mock) RegisterFixedRoles() error { +func (m *Mock) RegisterFixedRoles(ctx context.Context) error { m.Calls.RegisterFixedRoles = append(m.Calls.RegisterFixedRoles, []struct{}{}) // Use override if provided if m.RegisterFixedRolesFunc != nil { diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index d3df60664a5..0d4a2fc6f36 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -2,8 +2,12 @@ package accesscontrol import ( "encoding/json" + "fmt" "strings" "time" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/annotations" ) // RoleRegistration stores a role and its assignments to built-in roles @@ -29,15 +33,15 @@ type Role struct { Created time.Time `json:"created"` } -func (r Role) Global() bool { +func (r *Role) Global() bool { return r.OrgID == GlobalOrgID } -func (r Role) IsFixed() bool { +func (r *Role) IsFixed() bool { return strings.HasPrefix(r.Name, FixedRolePrefix) } -func (r Role) GetDisplayName() string { +func (r *Role) GetDisplayName() string { if r.IsFixed() && r.DisplayName == "" { r.DisplayName = fallbackDisplayName(r.Name) } @@ -75,11 +79,27 @@ type RoleDTO struct { Created time.Time `json:"created"` } -func (r RoleDTO) Role() Role { +func (r *RoleDTO) LogID() string { + var org string + + if r.Global() { + org = "Global" + } else { + org = fmt.Sprintf("OrgId:%v", r.OrgID) + } + + if r.UID != "" { + return fmt.Sprintf("[%s RoleUID:%v]", org, r.UID) + } + return fmt.Sprintf("[%s Role:%v]", org, r.Name) +} + +func (r *RoleDTO) Role() Role { return Role{ ID: r.ID, OrgID: r.OrgID, UID: r.UID, + Version: r.Version, Name: r.Name, DisplayName: r.DisplayName, Group: r.Group, @@ -90,15 +110,15 @@ func (r RoleDTO) Role() Role { } } -func (r RoleDTO) Global() bool { +func (r *RoleDTO) Global() bool { return r.OrgID == GlobalOrgID } -func (r RoleDTO) IsFixed() bool { +func (r *RoleDTO) IsFixed() bool { return strings.HasPrefix(r.Name, FixedRolePrefix) } -func (r RoleDTO) GetDisplayName() string { +func (r *RoleDTO) GetDisplayName() string { if r.IsFixed() && r.DisplayName == "" { r.DisplayName = fallbackDisplayName(r.Name) } @@ -242,6 +262,9 @@ type SetResourcePermissionCommand struct { const ( GlobalOrgID = 0 + FixedRolePrefix = "fixed:" + RoleGrafanaAdmin = "Grafana Admin" + GeneralFolderUID = "general" // Permission actions @@ -379,10 +402,21 @@ var ( ScopeAnnotationsProvider = NewScopeProvider(ScopeAnnotationsRoot) ScopeAnnotationsAll = ScopeAnnotationsProvider.GetResourceAllScope() ScopeAnnotationsID = Scope(ScopeAnnotationsRoot, "id", Parameter(":annotationId")) - ScopeAnnotationsTypeDashboard = ScopeAnnotationsProvider.GetResourceScopeType("dashboard") - ScopeAnnotationsTypeOrganization = ScopeAnnotationsProvider.GetResourceScopeType("organization") + ScopeAnnotationsTypeDashboard = ScopeAnnotationsProvider.GetResourceScopeType(annotations.Dashboard.String()) + ScopeAnnotationsTypeOrganization = ScopeAnnotationsProvider.GetResourceScopeType(annotations.Organization.String()) ) -const RoleGrafanaAdmin = "Grafana Admin" +func BuiltInRolesWithParents(builtInRoles []string) map[string]struct{} { + res := map[string]struct{}{} -const FixedRolePrefix = "fixed:" + for _, br := range builtInRoles { + res[br] = struct{}{} + if br != RoleGrafanaAdmin { + for _, parent := range models.RoleType(br).Parents() { + res[string(parent)] = struct{}{} + } + } + } + + return res +} diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go index 36ed825e626..15fe9dce61c 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go @@ -33,43 +33,6 @@ func ProvideService(features featuremgmt.FeatureToggles, usageStats usagestats.S return s, errDeclareRoles } -func macroRoles() map[string]*accesscontrol.RoleDTO { - return map[string]*accesscontrol.RoleDTO{ - string(models.ROLE_ADMIN): { - Name: "fixed:builtins:admin", - DisplayName: string(models.ROLE_ADMIN), - Description: "Admin role", - Group: "Basic", - Version: 1, - Permissions: []accesscontrol.Permission{}, - }, - string(models.ROLE_EDITOR): { - Name: "fixed:builtins:editor", - DisplayName: string(models.ROLE_EDITOR), - Description: "Editor role", - Group: "Basic", - Version: 1, - Permissions: []accesscontrol.Permission{}, - }, - string(models.ROLE_VIEWER): { - Name: "fixed:builtins:viewer", - DisplayName: string(models.ROLE_VIEWER), - Description: "Viewer role", - Group: "Basic", - Version: 1, - Permissions: []accesscontrol.Permission{}, - }, - accesscontrol.RoleGrafanaAdmin: { - Name: "fixed:builtins:grafana_admin", - DisplayName: accesscontrol.RoleGrafanaAdmin, - Description: "Grafana Admin role", - Group: "Basic", - Version: 1, - Permissions: []accesscontrol.Permission{}, - }, - } -} - // ProvideOSSAccessControl creates an oss implementation of access control without usage stats registration func ProvideOSSAccessControl(features featuremgmt.FeatureToggles, provider accesscontrol.PermissionsProvider) *OSSAccessControlService { s := &OSSAccessControlService{ @@ -77,7 +40,7 @@ func ProvideOSSAccessControl(features featuremgmt.FeatureToggles, provider acces provider: provider, log: log.New("accesscontrol"), scopeResolver: accesscontrol.NewScopeResolver(), - roles: macroRoles(), + roles: accesscontrol.BuildMacroRoleDefinitions(), } return s @@ -211,7 +174,7 @@ func (ac *OSSAccessControlService) GetUserBuiltInRoles(user *models.SignedInUser } // RegisterFixedRoles registers all declared roles in RAM -func (ac *OSSAccessControlService) RegisterFixedRoles() error { +func (ac *OSSAccessControlService) RegisterFixedRoles(ctx context.Context) error { // If accesscontrol is disabled no need to register roles if ac.IsDisabled() { return nil @@ -225,18 +188,7 @@ func (ac *OSSAccessControlService) RegisterFixedRoles() error { // RegisterFixedRole saves a fixed role and assigns it to built-in roles func (ac *OSSAccessControlService) registerFixedRole(role accesscontrol.RoleDTO, builtInRoles []string) { - // Inheritance - brs := map[string]struct{}{} - for _, builtInRole := range builtInRoles { - brs[builtInRole] = struct{}{} - if builtInRole != accesscontrol.RoleGrafanaAdmin { - for _, parent := range models.RoleType(builtInRole).Parents() { - brs[string(parent)] = struct{}{} - } - } - } - - for br := range brs { + for br := range accesscontrol.BuiltInRolesWithParents(builtInRoles) { if macroRole, ok := ac.roles[br]; ok { macroRole.Permissions = append(macroRole.Permissions, role.Permissions...) } else { diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go index f26fb33788c..df799ad1a3d 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go @@ -26,9 +26,9 @@ func setupTestEnv(t testing.TB) *OSSAccessControlService { registrations: accesscontrol.RegistrationList{}, scopeResolver: accesscontrol.NewScopeResolver(), provider: database.ProvideService(sqlstore.InitTestDB(t)), - roles: macroRoles(), + roles: accesscontrol.BuildMacroRoleDefinitions(), } - require.NoError(t, ac.RegisterFixedRoles()) + require.NoError(t, ac.RegisterFixedRoles(context.Background())) return ac } @@ -94,7 +94,7 @@ func TestEvaluatingPermissions(t *testing.T) { err := accesscontrol.DeclareFixedRoles(ac) require.NoError(t, err) - errRegisterRoles := ac.RegisterFixedRoles() + errRegisterRoles := ac.RegisterFixedRoles(context.Background()) require.NoError(t, errRegisterRoles) user := &models.SignedInUser{ @@ -341,7 +341,7 @@ func TestOSSAccessControlService_RegisterFixedRoles(t *testing.T) { ac.registrations.Append(tt.registrations...) // Test - err := ac.RegisterFixedRoles() + err := ac.RegisterFixedRoles(context.Background()) if tt.wantErr { require.Error(t, err) return @@ -350,19 +350,8 @@ func TestOSSAccessControlService_RegisterFixedRoles(t *testing.T) { // Check for _, registration := range tt.registrations { - // Prepare list of builtin roles to check - brAndParents := map[string]struct{}{} - for _, br := range registration.Grants { - brAndParents[br] = struct{}{} - if br != accesscontrol.RoleGrafanaAdmin { - for _, parent := range models.RoleType(br).Parents() { - brAndParents[string(parent)] = struct{}{} - } - } - } - // Check builtin roles (parents included) have been granted with the permissions - for br := range brAndParents { + for br := range accesscontrol.BuiltInRolesWithParents(registration.Grants) { builtinRole, ok := ac.roles[br] assert.True(t, ok) for _, expectedPermission := range registration.Role.Permissions { @@ -418,7 +407,7 @@ func TestOSSAccessControlService_GetUserPermissions(t *testing.T) { err := ac.DeclareFixedRoles(registration) require.NoError(t, err) - err = ac.RegisterFixedRoles() + err = ac.RegisterFixedRoles(context.Background()) require.NoError(t, err) // Test @@ -499,7 +488,7 @@ func TestOSSAccessControlService_Evaluate(t *testing.T) { err := ac.DeclareFixedRoles(registration) require.NoError(t, err) - err = ac.RegisterFixedRoles() + err = ac.RegisterFixedRoles(context.Background()) require.NoError(t, err) // Test diff --git a/pkg/services/accesscontrol/roles.go b/pkg/services/accesscontrol/roles.go index 7561c836151..ac5082c3303 100644 --- a/pkg/services/accesscontrol/roles.go +++ b/pkg/services/accesscontrol/roles.go @@ -1,6 +1,7 @@ package accesscontrol import ( + "context" "fmt" "strings" "sync" @@ -10,7 +11,7 @@ import ( type RoleRegistry interface { // RegisterFixedRoles registers all roles declared to AccessControl - RegisterFixedRoles() error + RegisterFixedRoles(ctx context.Context) error } // Roles definition @@ -276,3 +277,48 @@ func (m *RegistrationList) Range(f func(registration RoleRegistration) bool) { } } } + +func BuildMacroRoleDefinitions() map[string]*RoleDTO { + return map[string]*RoleDTO{ + string(models.ROLE_ADMIN): { + Name: "grafana:builtins:admin", + UID: "grafana_builtins_admin", + OrgID: GlobalOrgID, + Version: 1, + DisplayName: string(models.ROLE_ADMIN), + Description: "Admin role", + Group: "Basic", + Permissions: []Permission{}, + }, + string(models.ROLE_EDITOR): { + Name: "grafana:builtins:editor", + UID: "grafana_builtins_editor", + OrgID: GlobalOrgID, + Version: 1, + DisplayName: string(models.ROLE_EDITOR), + Description: "Editor role", + Group: "Basic", + Permissions: []Permission{}, + }, + string(models.ROLE_VIEWER): { + Name: "grafana:builtins:viewer", + UID: "grafana_builtins_viewer", + OrgID: GlobalOrgID, + Version: 1, + DisplayName: string(models.ROLE_VIEWER), + Description: "Viewer role", + Group: "Basic", + Permissions: []Permission{}, + }, + RoleGrafanaAdmin: { + Name: "grafana:builtins:grafana_admin", + UID: "grafana_builtins_grafana_admin", + OrgID: GlobalOrgID, + Version: 1, + DisplayName: RoleGrafanaAdmin, + Description: "Grafana Admin role", + Group: "Basic", + Permissions: []Permission{}, + }, + } +} diff --git a/pkg/services/alerting/conditions/query.go b/pkg/services/alerting/conditions/query.go index 9aee49e5b1b..4eb86714dd6 100644 --- a/pkg/services/alerting/conditions/query.go +++ b/pkg/services/alerting/conditions/query.go @@ -56,7 +56,11 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext, requestHandler lega emptySeriesCount := 0 evalMatchCount := 0 + + // matches represents all the series that violate the alert condition var matches []*alerting.EvalMatch + // allMatches capture all evaluation matches irregardless on whether the condition is met or not + var allMatches []*alerting.EvalMatch for _, series := range seriesList { reducedValue := c.Reducer.Reduce(series) @@ -72,14 +76,17 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext, requestHandler lega }) } + em := alerting.EvalMatch{ + Metric: series.Name, + Value: reducedValue, + Tags: series.Tags, + } + + allMatches = append(allMatches, &em) + if evalMatch { evalMatchCount++ - - matches = append(matches, &alerting.EvalMatch{ - Metric: series.Name, - Value: reducedValue, - Tags: series.Tags, - }) + matches = append(matches, &em) } } @@ -105,6 +112,7 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext, requestHandler lega NoDataFound: emptySeriesCount == len(seriesList), Operator: c.Operator, EvalMatches: matches, + AllMatches: allMatches, }, nil } diff --git a/pkg/services/alerting/engine.go b/pkg/services/alerting/engine.go index cb39ff67448..7fda9c47e85 100644 --- a/pkg/services/alerting/engine.go +++ b/pkg/services/alerting/engine.go @@ -10,7 +10,6 @@ import ( "go.opentelemetry.io/otel/attribute" "golang.org/x/sync/errgroup" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/infra/usagestats" @@ -30,6 +29,7 @@ type AlertStore interface { GetDashboardUIDById(context.Context, *models.GetDashboardRefByIdQuery) error SetAlertNotificationStateToCompleteCommand(context.Context, *models.SetAlertNotificationStateToCompleteCommand) error SetAlertNotificationStateToPendingCommand(context.Context, *models.SetAlertNotificationStateToPendingCommand) error + GetAlertNotificationUidWithId(context.Context, *models.GetAlertNotificationUidQuery) error GetAlertNotificationsWithUidToSend(context.Context, *models.GetAlertNotificationsWithUidToSendQuery) error GetOrCreateAlertNotificationState(context.Context, *models.GetOrCreateNotificationStateQuery) error SetAlertState(context.Context, *models.SetAlertStateCommand) error @@ -40,7 +40,6 @@ type AlertStore interface { // are sent. type AlertEngine struct { RenderService rendering.Service - Bus bus.Bus RequestValidator models.PluginRequestValidator DataService legacydata.RequestHandler Cfg *setting.Cfg @@ -64,14 +63,13 @@ func (e *AlertEngine) IsDisabled() bool { } // ProvideAlertEngine returns a new AlertEngine. -func ProvideAlertEngine(renderer rendering.Service, bus bus.Bus, requestValidator models.PluginRequestValidator, +func ProvideAlertEngine(renderer rendering.Service, requestValidator models.PluginRequestValidator, dataService legacydata.RequestHandler, usageStatsService usagestats.Service, encryptionService encryption.Internal, notificationService *notifications.NotificationService, tracer tracing.Tracer, sqlStore AlertStore, cfg *setting.Cfg, dashAlertExtractor DashAlertExtractor) *AlertEngine { e := &AlertEngine{ Cfg: cfg, RenderService: renderer, - Bus: bus, RequestValidator: requestValidator, DataService: dataService, usageStatsService: usageStatsService, diff --git a/pkg/services/alerting/engine_integration_test.go b/pkg/services/alerting/engine_integration_test.go index 05725068215..06df00a9d4d 100644 --- a/pkg/services/alerting/engine_integration_test.go +++ b/pkg/services/alerting/engine_integration_test.go @@ -24,7 +24,7 @@ func TestEngineTimeouts(t *testing.T) { usMock := &usagestats.UsageStatsMock{T: t} tracer, err := tracing.InitializeTracerForTest() require.NoError(t, err) - engine := ProvideAlertEngine(nil, nil, nil, nil, usMock, ossencryption.ProvideService(), nil, tracer, nil, setting.NewCfg(), nil) + engine := ProvideAlertEngine(nil, nil, nil, usMock, ossencryption.ProvideService(), nil, tracer, nil, setting.NewCfg(), nil) setting.AlertingNotificationTimeout = 30 * time.Second setting.AlertingMaxAttempts = 3 engine.resultHandler = &FakeResultHandler{} diff --git a/pkg/services/alerting/engine_test.go b/pkg/services/alerting/engine_test.go index 6e7fb674d68..e64f8a14dff 100644 --- a/pkg/services/alerting/engine_test.go +++ b/pkg/services/alerting/engine_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/infra/usagestats" @@ -65,6 +64,10 @@ func (a *AlertStoreMock) GetAllAlertQueryHandler(c context.Context, cmd *models. return nil } +func (a *AlertStoreMock) GetAlertNotificationUidWithId(c context.Context, query *models.GetAlertNotificationUidQuery) error { + return nil +} + func (a *AlertStoreMock) GetAlertNotificationsWithUidToSend(c context.Context, cmd *models.GetAlertNotificationsWithUidToSendQuery) error { if a.getAlertNotificationsWithUidToSend != nil { return a.getAlertNotificationsWithUidToSend(c, cmd) @@ -96,13 +99,12 @@ func (a *AlertStoreMock) SetAlertState(_ context.Context, _ *models.SetAlertStat } func TestEngineProcessJob(t *testing.T) { - bus := bus.New() usMock := &usagestats.UsageStatsMock{T: t} tracer, err := tracing.InitializeTracerForTest() require.NoError(t, err) store := &AlertStoreMock{} - engine := ProvideAlertEngine(nil, bus, nil, nil, usMock, ossencryption.ProvideService(), nil, tracer, store, setting.NewCfg(), nil) + engine := ProvideAlertEngine(nil, nil, nil, usMock, ossencryption.ProvideService(), nil, tracer, store, setting.NewCfg(), nil) setting.AlertingEvaluationTimeout = 30 * time.Second setting.AlertingNotificationTimeout = 30 * time.Second setting.AlertingMaxAttempts = 3 diff --git a/pkg/services/alerting/eval_context.go b/pkg/services/alerting/eval_context.go index de0d2d155ab..7eb3caad471 100644 --- a/pkg/services/alerting/eval_context.go +++ b/pkg/services/alerting/eval_context.go @@ -17,6 +17,7 @@ type EvalContext struct { IsTestRun bool IsDebug bool EvalMatches []*EvalMatch + AllMatches []*EvalMatch Logs []*ResultLogEntry Error error ConditionEvals string @@ -47,6 +48,7 @@ func NewEvalContext(alertCtx context.Context, rule *Rule, requestValidator model Rule: rule, Logs: make([]*ResultLogEntry, 0), EvalMatches: make([]*EvalMatch, 0), + AllMatches: make([]*EvalMatch, 0), Log: log.New("alerting.evalContext"), PrevAlertState: rule.State, RequestValidator: requestValidator, @@ -188,11 +190,13 @@ func getNewStateInternal(c *EvalContext) models.AlertStateType { // evaluateNotificationTemplateFields will treat the alert evaluation rule's name and message fields as // templates, and evaluate the templates using data from the alert evaluation's tags func (c *EvalContext) evaluateNotificationTemplateFields() error { - if len(c.EvalMatches) < 1 { + matches := c.getTemplateMatches() + if len(matches) < 1 { + // if there are no series to parse the templates with, return return nil } - templateDataMap, err := buildTemplateDataMap(c.EvalMatches) + templateDataMap, err := buildTemplateDataMap(matches) if err != nil { return err } @@ -212,6 +216,18 @@ func (c *EvalContext) evaluateNotificationTemplateFields() error { return nil } +// getTemplateMatches returns the values we should use to parse the templates +func (c *EvalContext) getTemplateMatches() []*EvalMatch { + // EvalMatches represent series violating the rule threshold, + // if we have any, this means the alert is firing and we should use this data to parse the templates. + if len(c.EvalMatches) > 0 { + return c.EvalMatches + } + + // If we don't have any alerting values, use all values to parse the templates. + return c.AllMatches +} + func evaluateTemplate(s string, m map[string]string) (string, error) { for k, v := range m { re, err := regexp.Compile(fmt.Sprintf(`\${%s}`, regexp.QuoteMeta(k))) diff --git a/pkg/services/alerting/eval_context_test.go b/pkg/services/alerting/eval_context_test.go index 30c3114ed15..6ac5da6fab0 100644 --- a/pkg/services/alerting/eval_context_test.go +++ b/pkg/services/alerting/eval_context_test.go @@ -340,3 +340,66 @@ func TestEvaluateTemplate(t *testing.T) { }) } } + +func TestEvaluateNotificationTemplateFields(t *testing.T) { + tests := []struct { + name string + evalMatches []*EvalMatch + allMatches []*EvalMatch + expectedName string + expectedMessage string + }{ + { + "with evaluation matches", + []*EvalMatch{{ + Tags: map[string]string{"value1": "test1", "value2": "test2"}, + }}, + []*EvalMatch{{ + Tags: map[string]string{"value1": "test1", "value2": "test2"}, + }}, + "Rule name: test1", + "Rule message: test2", + }, + { + "missing key", + []*EvalMatch{{ + Tags: map[string]string{"value1": "test1", "value3": "test2"}, + }}, + []*EvalMatch{{ + Tags: map[string]string{"value1": "test1", "value3": "test2"}, + }}, + "Rule name: test1", + "Rule message: ${value2}", + }, + { + "no evaluation matches, with series", + []*EvalMatch{}, + []*EvalMatch{{ + Tags: map[string]string{"value1": "test1", "value2": "test2"}, + }}, + "Rule name: test1", + "Rule message: test2", + }, + { + "no evaluation matches, no series", + []*EvalMatch{}, + []*EvalMatch{}, + "Rule name: ${value1}", + "Rule message: ${value2}", + }, + } + + for _, test := range tests { + t.Run(test.name, func(tt *testing.T) { + evalContext := NewEvalContext(context.Background(), &Rule{Name: "Rule name: ${value1}", Message: "Rule message: ${value2}", Conditions: []Condition{&conditionStub{firing: true}}}, &validations.OSSPluginRequestValidator{}, nil) + evalContext.EvalMatches = test.evalMatches + evalContext.AllMatches = test.allMatches + + err := evalContext.evaluateNotificationTemplateFields() + + require.NoError(tt, err) + require.Equal(tt, test.expectedName, evalContext.Rule.Name) + require.Equal(tt, test.expectedMessage, evalContext.Rule.Message) + }) + } +} diff --git a/pkg/services/alerting/eval_handler.go b/pkg/services/alerting/eval_handler.go index 88a609a714b..f516d0718e6 100644 --- a/pkg/services/alerting/eval_handler.go +++ b/pkg/services/alerting/eval_handler.go @@ -68,6 +68,7 @@ func (e *DefaultEvalHandler) Eval(context *EvalContext) { } context.EvalMatches = append(context.EvalMatches, cr.EvalMatches...) + context.AllMatches = append(context.AllMatches, cr.AllMatches...) } context.ConditionEvals = conditionEvals + " = " + strconv.FormatBool(firing) diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go index 3238b5b60ac..460489b0a3d 100644 --- a/pkg/services/alerting/extractor.go +++ b/pkg/services/alerting/extractor.go @@ -22,13 +22,15 @@ type DashAlertExtractor interface { type DashAlertExtractorService struct { datasourcePermissionsService permissions.DatasourcePermissionsService datasourceService datasources.DataSourceService + alertStore AlertStore log log.Logger } -func ProvideDashAlertExtractorService(datasourcePermissionsService permissions.DatasourcePermissionsService, datasourceService datasources.DataSourceService) *DashAlertExtractorService { +func ProvideDashAlertExtractorService(datasourcePermissionsService permissions.DatasourcePermissionsService, datasourceService datasources.DataSourceService, alertStore AlertStore) *DashAlertExtractorService { return &DashAlertExtractorService{ datasourcePermissionsService: datasourcePermissionsService, datasourceService: datasourceService, + alertStore: alertStore, log: log.New("alerting.extractor"), } } @@ -231,7 +233,7 @@ func (e *DashAlertExtractorService) getAlertFromPanels(ctx context.Context, json alert.Settings = jsonAlert // validate - _, err = NewRuleFromDBAlert(ctx, alert, logTranslationFailures) + _, err = NewRuleFromDBAlert(ctx, e.alertStore, alert, logTranslationFailures) if err != nil { return nil, err } diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go index 90c7499d9e3..9b2dba8b0fc 100644 --- a/pkg/services/alerting/extractor_test.go +++ b/pkg/services/alerting/extractor_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources/permissions" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -36,7 +37,8 @@ func TestAlertRuleExtraction(t *testing.T) { } dsService := &fakeDatasourceService{ExpectedDatasource: defaultDs} - extractor := ProvideDashAlertExtractorService(dsPermissions, dsService) + store := mockstore.NewSQLStoreMock() + extractor := ProvideDashAlertExtractorService(dsPermissions, dsService, store) t.Run("Parsing alert rules from dashboard json", func(t *testing.T) { dashJSON, err := simplejson.NewJson(json) @@ -312,7 +314,7 @@ func TestFilterPermissionsErrors(t *testing.T) { dsPermissions := permissions.NewMockDatasourcePermissionService() dsService := &fakeDatasourceService{ExpectedDatasource: defaultDs} - extractor := ProvideDashAlertExtractorService(dsPermissions, dsService) + extractor := ProvideDashAlertExtractorService(dsPermissions, dsService, nil) tc := []struct { name string diff --git a/pkg/services/alerting/interfaces.go b/pkg/services/alerting/interfaces.go index 5d40116021b..b5f2fce922d 100644 --- a/pkg/services/alerting/interfaces.go +++ b/pkg/services/alerting/interfaces.go @@ -56,6 +56,7 @@ type ConditionResult struct { NoDataFound bool Operator string EvalMatches []*EvalMatch + AllMatches []*EvalMatch } // Condition is responsible for evaluating an alert condition. diff --git a/pkg/services/alerting/notifier_test.go b/pkg/services/alerting/notifier_test.go index 558697083c8..4e1ce660709 100644 --- a/pkg/services/alerting/notifier_test.go +++ b/pkg/services/alerting/notifier_test.go @@ -13,7 +13,6 @@ import ( "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" - "github.com/grafana/grafana/pkg/bus" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/components/imguploader" @@ -26,6 +25,7 @@ func TestNotificationService(t *testing.T) { evalCtx := NewEvalContext(context.Background(), testRule, &validations.OSSPluginRequestValidator{}, store) testRuleTemplated := &Rule{Name: "Test latency ${quantile}", Message: "Something is bad on instance ${instance}"} + evalCtxWithMatch := NewEvalContext(context.Background(), testRuleTemplated, &validations.OSSPluginRequestValidator{}, store) evalCtxWithMatch.EvalMatches = []*EvalMatch{{ Tags: map[string]string{ @@ -205,14 +205,6 @@ func notificationServiceScenario(t *testing.T, name string, evalCtx *EvalContext return nil } - bus.AddHandler("test", func(ctx context.Context, cmd *models.SetAlertNotificationStateToPendingCommand) error { - return nil - }) - - bus.AddHandler("test", func(ctx context.Context, cmd *models.SetAlertNotificationStateToCompleteCommand) error { - return nil - }) - setting.AlertingNotificationTimeout = 30 * time.Second scenarioCtx := &scenarioContext{ diff --git a/pkg/services/alerting/notifiers/hipchat.go b/pkg/services/alerting/notifiers/hipchat.go index 7a21903fb6c..78a141cea7e 100644 --- a/pkg/services/alerting/notifiers/hipchat.go +++ b/pkg/services/alerting/notifiers/hipchat.go @@ -55,9 +55,7 @@ const ( // for the HipChatNotifier func NewHipChatNotifier(model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) { url := model.Settings.Get("url").MustString() - if strings.HasSuffix(url, "/") { - url = url[:len(url)-1] - } + url = strings.TrimSuffix(url, "/") if url == "" { return nil, alerting.ValidationError{Reason: "Could not find url property in settings"} } diff --git a/pkg/services/alerting/reader.go b/pkg/services/alerting/reader.go index 6d4cc8dd80f..ebd41626164 100644 --- a/pkg/services/alerting/reader.go +++ b/pkg/services/alerting/reader.go @@ -38,7 +38,7 @@ func (arr *defaultRuleReader) fetch(ctx context.Context) []*Rule { res := make([]*Rule, 0) for _, ruleDef := range cmd.Result { - if model, err := NewRuleFromDBAlert(ctx, ruleDef, false); err != nil { + if model, err := NewRuleFromDBAlert(ctx, arr.sqlStore, ruleDef, false); err != nil { arr.log.Error("Could not build alert model for rule", "ruleId", ruleDef.Id, "error", err) } else { res = append(res, model) diff --git a/pkg/services/alerting/rule.go b/pkg/services/alerting/rule.go index 05ffc47805d..7dd920d2bfa 100644 --- a/pkg/services/alerting/rule.go +++ b/pkg/services/alerting/rule.go @@ -9,7 +9,6 @@ import ( "strconv" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" ) @@ -145,7 +144,7 @@ func getForValue(rawFor string) (time.Duration, error) { // NewRuleFromDBAlert maps a db version of // alert to an in-memory version. -func NewRuleFromDBAlert(ctx context.Context, ruleDef *models.Alert, logTranslationFailures bool) (*Rule, error) { +func NewRuleFromDBAlert(ctx context.Context, store AlertStore, ruleDef *models.Alert, logTranslationFailures bool) (*Rule, error) { model := &Rule{} model.ID = ruleDef.Id model.OrgID = ruleDef.OrgId @@ -170,7 +169,7 @@ func NewRuleFromDBAlert(ctx context.Context, ruleDef *models.Alert, logTranslati for _, v := range ruleDef.Settings.Get("notifications").MustArray() { jsonModel := simplejson.NewFromAny(v) if id, err := jsonModel.Get("id").Int64(); err == nil { - uid, err := translateNotificationIDToUID(ctx, id, ruleDef.OrgId) + uid, err := translateNotificationIDToUID(ctx, store, id, ruleDef.OrgId) if err != nil { if !errors.Is(err, models.ErrAlertNotificationFailedTranslateUniqueID) { logger.Error("Failed to translate notification id to uid", "error", err.Error(), "dashboardId", model.DashboardID, "alert", model.Name, "panelId", model.PanelID, "notificationId", id) @@ -211,8 +210,8 @@ func NewRuleFromDBAlert(ctx context.Context, ruleDef *models.Alert, logTranslati return model, nil } -func translateNotificationIDToUID(ctx context.Context, id int64, orgID int64) (string, error) { - notificationUID, err := getAlertNotificationUIDByIDAndOrgID(ctx, id, orgID) +func translateNotificationIDToUID(ctx context.Context, store AlertStore, id int64, orgID int64) (string, error) { + notificationUID, err := getAlertNotificationUIDByIDAndOrgID(ctx, store, id, orgID) if err != nil { return "", err } @@ -220,13 +219,13 @@ func translateNotificationIDToUID(ctx context.Context, id int64, orgID int64) (s return notificationUID, nil } -func getAlertNotificationUIDByIDAndOrgID(ctx context.Context, notificationID int64, orgID int64) (string, error) { +func getAlertNotificationUIDByIDAndOrgID(ctx context.Context, store AlertStore, notificationID int64, orgID int64) (string, error) { query := &models.GetAlertNotificationUidQuery{ OrgId: orgID, Id: notificationID, } - if err := bus.Dispatch(ctx, query); err != nil { + if err := store.GetAlertNotificationUidWithId(ctx, query); err != nil { return "", err } diff --git a/pkg/services/alerting/rule_test.go b/pkg/services/alerting/rule_test.go index 55c603a4c2b..506b7cb03b6 100644 --- a/pkg/services/alerting/rule_test.go +++ b/pkg/services/alerting/rule_test.go @@ -130,7 +130,7 @@ func TestAlertRuleModel(t *testing.T) { Settings: alertJSON, } - alertRule, err := NewRuleFromDBAlert(context.Background(), alert, false) + alertRule, err := NewRuleFromDBAlert(context.Background(), sqlStore, alert, false) require.Nil(t, err) require.Len(t, alertRule.Conditions, 1) @@ -169,7 +169,7 @@ func TestAlertRuleModel(t *testing.T) { Settings: alertJSON, } - alertRule, err := NewRuleFromDBAlert(context.Background(), alert, false) + alertRule, err := NewRuleFromDBAlert(context.Background(), sqlStore, alert, false) require.Nil(t, err) require.NotContains(t, alertRule.Notifications, "999") require.Contains(t, alertRule.Notifications, "notifier2") @@ -200,7 +200,7 @@ func TestAlertRuleModel(t *testing.T) { Settings: alertJSON, } - alertRule, err := NewRuleFromDBAlert(context.Background(), alert, false) + alertRule, err := NewRuleFromDBAlert(context.Background(), sqlStore, alert, false) require.Nil(t, err) require.EqualValues(t, alertRule.Frequency, 60) }) @@ -238,7 +238,7 @@ func TestAlertRuleModel(t *testing.T) { Settings: alertJSON, } - _, err := NewRuleFromDBAlert(context.Background(), alert, false) + _, err := NewRuleFromDBAlert(context.Background(), sqlStore, alert, false) require.NotNil(t, err) require.EqualValues(t, err.Error(), "alert validation error: Neither id nor uid is specified in 'notifications' block, type assertion to string failed AlertId: 1 PanelId: 1 DashboardId: 1") }) diff --git a/pkg/services/alerting/service.go b/pkg/services/alerting/service.go index 5031b4cb9cc..34e683561fc 100644 --- a/pkg/services/alerting/service.go +++ b/pkg/services/alerting/service.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/encryption" "github.com/grafana/grafana/pkg/services/notifications" @@ -13,35 +12,19 @@ import ( ) type AlertNotificationService struct { - Bus bus.Bus SQLStore *sqlstore.SQLStore EncryptionService encryption.Internal NotificationService *notifications.NotificationService } -func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, encryptionService encryption.Internal, +func ProvideService(store *sqlstore.SQLStore, encryptionService encryption.Internal, notificationService *notifications.NotificationService) *AlertNotificationService { s := &AlertNotificationService{ - Bus: bus, SQLStore: store, EncryptionService: encryptionService, NotificationService: notificationService, } - s.Bus.AddHandler(s.GetAlertNotifications) - s.Bus.AddHandler(s.CreateAlertNotificationCommand) - s.Bus.AddHandler(s.UpdateAlertNotification) - s.Bus.AddHandler(s.DeleteAlertNotification) - s.Bus.AddHandler(s.GetAllAlertNotifications) - s.Bus.AddHandler(s.GetOrCreateAlertNotificationState) - s.Bus.AddHandler(s.SetAlertNotificationStateToCompleteCommand) - s.Bus.AddHandler(s.SetAlertNotificationStateToPendingCommand) - s.Bus.AddHandler(s.GetAlertNotificationsWithUid) - s.Bus.AddHandler(s.UpdateAlertNotificationWithUid) - s.Bus.AddHandler(s.DeleteAlertNotificationWithUid) - s.Bus.AddHandler(s.GetAlertNotificationsWithUidToSend) - s.Bus.AddHandler(s.HandleNotificationTestCommand) - return s } diff --git a/pkg/services/alerting/service_test.go b/pkg/services/alerting/service_test.go index 020003ffacd..edd58a5da30 100644 --- a/pkg/services/alerting/service_test.go +++ b/pkg/services/alerting/service_test.go @@ -4,7 +4,6 @@ import ( "context" "testing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/encryption/ossencryption" @@ -20,7 +19,7 @@ func TestService(t *testing.T) { nType := "test" registerTestNotifier(nType) - s := ProvideService(bus.New(), sqlStore, ossencryption.ProvideService(), nil) + s := ProvideService(sqlStore, ossencryption.ProvideService(), nil) origSecret := setting.SecretKey setting.SecretKey = "alert_notification_service_test" diff --git a/pkg/services/alerting/test_rule.go b/pkg/services/alerting/test_rule.go index b8c4e2e5edf..011990faad7 100644 --- a/pkg/services/alerting/test_rule.go +++ b/pkg/services/alerting/test_rule.go @@ -25,7 +25,7 @@ func (e *AlertEngine) AlertTest(orgID int64, dashboard *simplejson.Json, panelID if alert.PanelId != panelID { continue } - rule, err := NewRuleFromDBAlert(context.Background(), alert, true) + rule, err := NewRuleFromDBAlert(context.Background(), e.sqlStore, alert, true) if err != nil { return nil, err } diff --git a/pkg/services/annotations/annotations.go b/pkg/services/annotations/annotations.go index 9b79c1cdeec..5dea39e5ccc 100644 --- a/pkg/services/annotations/annotations.go +++ b/pkg/services/annotations/annotations.go @@ -5,6 +5,7 @@ import ( "errors" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" ) @@ -37,6 +38,7 @@ type ItemQuery struct { Tags []string `json:"tags"` Type string `json:"type"` MatchAny bool `json:"matchAny"` + SignedInUser *models.SignedInUser Limit int64 `json:"limit"` } @@ -152,6 +154,17 @@ const ( Dashboard ) +func (a annotationType) String() string { + switch a { + case Organization: + return "organization" + case Dashboard: + return "dashboard" + default: + return "" + } +} + func (annotation *ItemDTO) GetType() annotationType { if annotation.DashboardId != 0 { return Dashboard diff --git a/pkg/services/comments/commentmodel/models.go b/pkg/services/comments/commentmodel/models.go index 64526d124f2..bfaa1713591 100644 --- a/pkg/services/comments/commentmodel/models.go +++ b/pkg/services/comments/commentmodel/models.go @@ -56,9 +56,9 @@ func (s Settings) Value() (driver.Value, error) { func (s *Settings) Scan(value interface{}) error { switch v := value.(type) { case string: - return json.Unmarshal([]byte(v), &s) + return json.Unmarshal([]byte(v), s) case []uint8: - return json.Unmarshal(v, &s) + return json.Unmarshal(v, s) default: return fmt.Errorf("type assertion on scan failed: got %T", value) } diff --git a/pkg/services/comments/commentmodel/permissions.go b/pkg/services/comments/commentmodel/permissions.go index bdfed834f44..b1bcbee82d1 100644 --- a/pkg/services/comments/commentmodel/permissions.go +++ b/pkg/services/comments/commentmodel/permissions.go @@ -4,21 +4,22 @@ import ( "context" "strconv" - "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/services/sqlstore" ) type PermissionChecker struct { - sqlStore *sqlstore.SQLStore - features featuremgmt.FeatureToggles + sqlStore *sqlstore.SQLStore + features featuremgmt.FeatureToggles + accessControl accesscontrol.AccessControl } -func NewPermissionChecker(sqlStore *sqlstore.SQLStore, features featuremgmt.FeatureToggles) *PermissionChecker { - return &PermissionChecker{sqlStore: sqlStore, features: features} +func NewPermissionChecker(sqlStore *sqlstore.SQLStore, features featuremgmt.FeatureToggles, accessControl accesscontrol.AccessControl) *PermissionChecker { + return &PermissionChecker{sqlStore: sqlStore, features: features, accessControl: accessControl} } func (c *PermissionChecker) getDashboardByUid(ctx context.Context, orgID int64, uid string) (*models.Dashboard, error) { @@ -62,7 +63,7 @@ func (c *PermissionChecker) CheckReadPermissions(ctx context.Context, orgId int6 if err != nil { return false, nil } - items, err := repo.Find(ctx, &annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgId}) + items, err := repo.Find(ctx, &annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgId, SignedInUser: signedInUser}) if err != nil || len(items) != 1 { return false, nil } @@ -104,12 +105,18 @@ func (c *PermissionChecker) CheckWritePermissions(ctx context.Context, orgId int if !c.features.IsEnabled(featuremgmt.FlagAnnotationComments) { return false, nil } + if c.features.IsEnabled(featuremgmt.FlagAccesscontrol) { + evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsWrite, accesscontrol.ScopeAnnotationsTypeDashboard) + if canEdit, err := c.accessControl.Evaluate(ctx, signedInUser, evaluator); err != nil || !canEdit { + return canEdit, err + } + } repo := annotations.GetRepository() annotationID, err := strconv.ParseInt(objectID, 10, 64) if err != nil { return false, nil } - items, err := repo.Find(ctx, &annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgId}) + items, err := repo.Find(ctx, &annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgId, SignedInUser: signedInUser}) if err != nil || len(items) != 1 { return false, nil } diff --git a/pkg/services/comments/service.go b/pkg/services/comments/service.go index c2797fdaa87..a899af106ef 100644 --- a/pkg/services/comments/service.go +++ b/pkg/services/comments/service.go @@ -3,6 +3,7 @@ package comments import ( "context" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/comments/commentmodel" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/live" @@ -18,7 +19,7 @@ type Service struct { permissions *commentmodel.PermissionChecker } -func ProvideService(cfg *setting.Cfg, store *sqlstore.SQLStore, live *live.GrafanaLive, features featuremgmt.FeatureToggles) *Service { +func ProvideService(cfg *setting.Cfg, store *sqlstore.SQLStore, live *live.GrafanaLive, features featuremgmt.FeatureToggles, accessControl accesscontrol.AccessControl) *Service { s := &Service{ cfg: cfg, live: live, @@ -26,7 +27,7 @@ func ProvideService(cfg *setting.Cfg, store *sqlstore.SQLStore, live *live.Grafa storage: &sqlStorage{ sql: store, }, - permissions: commentmodel.NewPermissionChecker(store, features), + permissions: commentmodel.NewPermissionChecker(store, features, accessControl), } return s } diff --git a/pkg/services/contexthandler/auth_jwt.go b/pkg/services/contexthandler/auth_jwt.go index d29b4b07d65..cda763f2c37 100644 --- a/pkg/services/contexthandler/auth_jwt.go +++ b/pkg/services/contexthandler/auth_jwt.go @@ -3,7 +3,6 @@ package contexthandler import ( "errors" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/models" ) @@ -67,7 +66,7 @@ func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64) SignupAllowed: h.Cfg.JWTAuthAutoSignUp, ExternalUser: extUser, } - if err := bus.Dispatch(ctx.Req.Context(), upsert); err != nil { + if err := h.loginService.UpsertUser(ctx.Req.Context(), upsert); err != nil { ctx.Logger.Error("Failed to upsert JWT user", "error", err) return false } diff --git a/pkg/services/contexthandler/auth_proxy_test.go b/pkg/services/contexthandler/auth_proxy_test.go index 82e6245c428..48fea6227bb 100644 --- a/pkg/services/contexthandler/auth_proxy_test.go +++ b/pkg/services/contexthandler/auth_proxy_test.go @@ -84,8 +84,9 @@ func getContextHandler(t *testing.T) *ContextHandler { loginService := loginservice.LoginServiceMock{ExpectedUser: &models.User{Id: userID}} authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginService, &FakeGetSignUserStore{}) + authenticator := &fakeAuthenticator{} - return ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy) + return ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, authenticator) } type FakeGetSignUserStore struct { @@ -103,3 +104,9 @@ func (f *FakeGetSignUserStore) GetSignedInUser(ctx context.Context, query *model } return nil } + +type fakeAuthenticator struct{} + +func (fa *fakeAuthenticator) AuthenticateUser(c context.Context, query *models.LoginUserQuery) error { + return nil +} diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index 99111ac9194..d339ea5f09b 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -4,17 +4,18 @@ package contexthandler import ( "context" "errors" + "net/http" "net/url" "strconv" "strings" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/apikeygen" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/network" "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/infra/tracing" + loginpkg "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/middleware/cookies" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" @@ -24,19 +25,19 @@ import ( "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" - cw "github.com/weaveworks/common/tracing" ) const ( InvalidUsernamePassword = "invalid username or password" - InvalidAPIKey = "invalid API key" + /* #nosec */ + InvalidAPIKey = "invalid API key" ) const ServiceName = "ContextHandler" func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtService models.JWTService, remoteCache *remotecache.RemoteCache, renderService rendering.Service, sqlStore sqlstore.Store, - tracer tracing.Tracer, authProxy *authproxy.AuthProxy) *ContextHandler { + tracer tracing.Tracer, authProxy *authproxy.AuthProxy, loginService login.Service, authenticator loginpkg.Authenticator) *ContextHandler { return &ContextHandler{ Cfg: cfg, AuthTokenService: tokenService, @@ -46,6 +47,8 @@ func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtS SQLStore: sqlStore, tracer: tracer, authProxy: authProxy, + authenticator: authenticator, + loginService: loginService, } } @@ -59,6 +62,8 @@ type ContextHandler struct { SQLStore sqlstore.Store tracer tracing.Tracer authProxy *authproxy.AuthProxy + authenticator loginpkg.Authenticator + loginService login.Service // GetTime returns the current time. // Stubbable by tests. GetTime func() time.Time @@ -92,8 +97,8 @@ func (h *ContextHandler) Middleware(mContext *web.Context) { mContext.Req = mContext.Req.WithContext(context.WithValue(mContext.Req.Context(), reqContextKey{}, reqContext)) mContext.Map(mContext.Req) - traceID, exists := cw.ExtractTraceID(mContext.Req.Context()) - if exists { + traceID := tracing.TraceIDFromContext(mContext.Req.Context(), false) + if traceID != "" { reqContext.Logger = reqContext.Logger.New("traceID", traceID) } @@ -250,20 +255,26 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo //There is a service account attached to the API key //Use service account linked to API key as the signed in user - query := models.GetSignedInUserQuery{UserId: *apikey.ServiceAccountId, OrgId: apikey.OrgId} - if err := h.SQLStore.GetSignedInUserWithCacheCtx(reqContext.Req.Context(), &query); err != nil { + querySignedInUser := models.GetSignedInUserQuery{UserId: *apikey.ServiceAccountId, OrgId: apikey.OrgId} + if err := h.SQLStore.GetSignedInUserWithCacheCtx(reqContext.Req.Context(), &querySignedInUser); err != nil { reqContext.Logger.Error( "Failed to link API key to service account in", - "id", query.UserId, - "org", query.OrgId, + "id", querySignedInUser.UserId, + "org", querySignedInUser.OrgId, "err", err, ) - reqContext.JsonApiErr(500, "Unable to link API key to service account", err) + reqContext.JsonApiErr(http.StatusInternalServerError, "Unable to link API key to service account", err) + return true + } + + // disabled service accounts are not allowed to access the API + if querySignedInUser.Result.IsDisabled { + reqContext.JsonApiErr(http.StatusUnauthorized, "Service account is disabled", nil) return true } reqContext.IsSignedIn = true - reqContext.SignedInUser = query.Result + reqContext.SignedInUser = querySignedInUser.Result return true } @@ -291,7 +302,7 @@ func (h *ContextHandler) initContextWithBasicAuth(reqContext *models.ReqContext, Password: password, Cfg: h.Cfg, } - if err := bus.Dispatch(reqContext.Req.Context(), &authQuery); err != nil { + if err := h.authenticator.AuthenticateUser(reqContext.Req.Context(), &authQuery); err != nil { reqContext.Logger.Debug( "Failed to authorize the user", "username", username, diff --git a/pkg/services/dashboards/manager/dashboard_service_test.go b/pkg/services/dashboards/manager/dashboard_service_test.go index 7e303d70471..b1a11805674 100644 --- a/pkg/services/dashboards/manager/dashboard_service_test.go +++ b/pkg/services/dashboards/manager/dashboard_service_test.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "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" @@ -22,8 +21,6 @@ import ( func TestDashboardService(t *testing.T) { t.Run("Dashboard service tests", func(t *testing.T) { - bus.ClearBusHandlers() - fakeStore := m.FakeDashboardStore{} defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ diff --git a/pkg/services/dashboardsnapshots/dashboardsnapshots.go b/pkg/services/dashboardsnapshots/dashboardsnapshots.go index 990e60bb818..51587cfa2dd 100644 --- a/pkg/services/dashboardsnapshots/dashboardsnapshots.go +++ b/pkg/services/dashboardsnapshots/dashboardsnapshots.go @@ -3,7 +3,6 @@ package dashboardsnapshots import ( "context" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/secrets" @@ -11,24 +10,16 @@ import ( ) type Service struct { - Bus bus.Bus SQLStore sqlstore.Store SecretsService secrets.Service } -func ProvideService(bus bus.Bus, store sqlstore.Store, secretsService secrets.Service) *Service { +func ProvideService(store sqlstore.Store, secretsService secrets.Service) *Service { s := &Service{ - Bus: bus, SQLStore: store, SecretsService: secretsService, } - s.Bus.AddHandler(s.CreateDashboardSnapshot) - s.Bus.AddHandler(s.GetDashboardSnapshot) - s.Bus.AddHandler(s.DeleteDashboardSnapshot) - s.Bus.AddHandler(s.SearchDashboardSnapshots) - s.Bus.AddHandler(s.DeleteExpiredSnapshots) - return s } diff --git a/pkg/services/datasourceproxy/datasourceproxy.go b/pkg/services/datasourceproxy/datasourceproxy.go index fc101016b87..75d5f506ea1 100644 --- a/pkg/services/datasourceproxy/datasourceproxy.go +++ b/pkg/services/datasourceproxy/datasourceproxy.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -59,24 +60,47 @@ func (p *DataSourceProxyService) ProxyDataSourceRequest(c *models.ReqContext) { p.ProxyDatasourceRequestWithID(c, id) } +func (p *DataSourceProxyService) ProxyDatasourceRequestWithUID(c *models.ReqContext) { + c.TimeRequest(metrics.MDataSourceProxyReqTimer) + + dsUID := web.Params(c.Req)[":uid"] + if !util.IsValidShortUID(dsUID) { + c.JsonApiErr(http.StatusBadRequest, "UID is invalid", nil) + return + } + + ds, err := p.DataSourceCache.GetDatasourceByUID(c.Req.Context(), dsUID, c.SignedInUser, c.SkipCache) + if err != nil { + toAPIError(c, err) + return + } + p.proxyDatasourceRequest(c, ds) +} + func (p *DataSourceProxyService) ProxyDatasourceRequestWithID(c *models.ReqContext, dsID int64) { c.TimeRequest(metrics.MDataSourceProxyReqTimer) ds, err := p.DataSourceCache.GetDatasource(c.Req.Context(), dsID, c.SignedInUser, c.SkipCache) if err != nil { - if errors.Is(err, models.ErrDataSourceAccessDenied) { - c.JsonApiErr(http.StatusForbidden, "Access denied to datasource", err) - return - } - if errors.Is(err, models.ErrDataSourceNotFound) { - c.JsonApiErr(http.StatusNotFound, "Unable to find datasource", err) - return - } - c.JsonApiErr(http.StatusInternalServerError, "Unable to load datasource meta data", err) + toAPIError(c, err) + } + p.proxyDatasourceRequest(c, ds) +} + +func toAPIError(c *models.ReqContext, err error) { + if errors.Is(err, models.ErrDataSourceAccessDenied) { + c.JsonApiErr(http.StatusForbidden, "Access denied to datasource", err) return } + if errors.Is(err, models.ErrDataSourceNotFound) { + c.JsonApiErr(http.StatusNotFound, "Unable to find datasource", err) + return + } + c.JsonApiErr(http.StatusInternalServerError, "Unable to load datasource meta data", err) +} - err = p.PluginRequestValidator.Validate(ds.Url, c.Req) +func (p *DataSourceProxyService) proxyDatasourceRequest(c *models.ReqContext, ds *models.DataSource) { + err := p.PluginRequestValidator.Validate(ds.Url, c.Req) if err != nil { c.JsonApiErr(http.StatusForbidden, "Access denied", err) return @@ -103,7 +127,7 @@ func (p *DataSourceProxyService) ProxyDatasourceRequestWithID(c *models.ReqConte proxy.HandleRequest() } -var proxyPathRegexp = regexp.MustCompile(`^\/api\/datasources\/proxy\/[\d]+\/?`) +var proxyPathRegexp = regexp.MustCompile(`^\/api\/datasources\/proxy\/([\d]+|uid\/[\w]+)\/?`) func extractProxyPath(originalRawPath string) string { return proxyPathRegexp.ReplaceAllString(originalRawPath, "") diff --git a/pkg/services/datasourceproxy/datasourceproxy_test.go b/pkg/services/datasourceproxy/datasourceproxy_test.go index 4533fbf74d9..6567ba579c5 100644 --- a/pkg/services/datasourceproxy/datasourceproxy_test.go +++ b/pkg/services/datasourceproxy/datasourceproxy_test.go @@ -24,6 +24,18 @@ func TestDataProxy(t *testing.T) { "/api/datasources/proxy/54/api/services/afsd%2Fafsd/operations", "api/services/afsd%2Fafsd/operations", }, + { + "/api/datasources/proxy/uid/26MI0wZ7k", + "", + }, + { + "/api/datasources/proxy/uid/26MI0wZ7k/some/thing", + "some/thing", + }, + { + "/api/datasources/proxy/uid/26MI0wZ7k/api/services/afsd%2Fafsd/operations", + "api/services/afsd%2Fafsd/operations", + }, } for _, tc := range testCases { t.Run("Given raw path, should extract expected proxy path", func(t *testing.T) { diff --git a/pkg/services/datasources/service/datasource_service.go b/pkg/services/datasources/service/datasource_service.go index 52ffe634707..914f5ed0b26 100644 --- a/pkg/services/datasources/service/datasource_service.go +++ b/pkg/services/datasources/service/datasource_service.go @@ -16,7 +16,6 @@ import ( "github.com/grafana/grafana-azure-sdk-go/azhttpclient" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/models" @@ -29,7 +28,6 @@ import ( ) type Service struct { - Bus bus.Bus SQLStore *sqlstore.SQLStore SecretsService secrets.Service cfg *setting.Cfg @@ -61,11 +59,10 @@ type cachedDecryptedJSON struct { } func ProvideService( - bus bus.Bus, store *sqlstore.SQLStore, secretsService secrets.Service, cfg *setting.Cfg, features featuremgmt.FeatureToggles, + store *sqlstore.SQLStore, secretsService secrets.Service, cfg *setting.Cfg, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, permissionsServices accesscontrol.PermissionsServices, ) *Service { s := &Service{ - Bus: bus, SQLStore: store, SecretsService: secretsService, ptc: proxyTransportCache{ @@ -79,14 +76,6 @@ func ProvideService( permissionsService: permissionsServices.GetDataSourceService(), } - s.Bus.AddHandler(s.GetDataSources) - s.Bus.AddHandler(s.GetDataSourcesByType) - s.Bus.AddHandler(s.GetDataSource) - s.Bus.AddHandler(s.AddDataSource) - s.Bus.AddHandler(s.DeleteDataSource) - s.Bus.AddHandler(s.UpdateDataSource) - s.Bus.AddHandler(s.GetDefaultDataSource) - ac.RegisterAttributeScopeResolver(NewNameScopeResolver(store)) ac.RegisterAttributeScopeResolver(NewIDScopeResolver(store)) diff --git a/pkg/services/datasources/service/datasource_service_test.go b/pkg/services/datasources/service/datasource_service_test.go index 72a85504867..511b0f8c9b2 100644 --- a/pkg/services/datasources/service/datasource_service_test.go +++ b/pkg/services/datasources/service/datasource_service_test.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana-azure-sdk-go/azsettings" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/models" @@ -39,7 +38,7 @@ func TestService(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) - s := ProvideService(bus.New(), sqlStore, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + s := ProvideService(sqlStore, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) var ds *models.DataSource @@ -239,7 +238,7 @@ func TestService_GetHttpTransport(t *testing.T) { } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) rt1, err := dsService.GetHTTPTransport(&ds, provider) require.NoError(t, err) @@ -272,7 +271,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("tlsAuthWithCACert", true) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) tlsCaCert, err := secretsService.Encrypt(context.Background(), []byte(caCert), secrets.WithoutScope()) require.NoError(t, err) @@ -322,7 +321,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("tlsAuth", true) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) tlsClientCert, err := secretsService.Encrypt(context.Background(), []byte(clientCert), secrets.WithoutScope()) require.NoError(t, err) @@ -365,7 +364,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("serverName", "server-name") secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) tlsCaCert, err := secretsService.Encrypt(context.Background(), []byte(caCert), secrets.WithoutScope()) require.NoError(t, err) @@ -402,7 +401,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("tlsSkipVerify", true) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Id: 1, @@ -433,7 +432,7 @@ func TestService_GetHttpTransport(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) encryptedData, err := secretsService.Encrypt(context.Background(), []byte(`Bearer xf5yhfkpsnmgo`), secrets.WithoutScope()) require.NoError(t, err) @@ -492,7 +491,7 @@ func TestService_GetHttpTransport(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Id: 1, @@ -525,7 +524,7 @@ func TestService_GetHttpTransport(t *testing.T) { require.NoError(t, err) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Type: models.DS_ES, @@ -560,7 +559,7 @@ func TestService_getTimeout(t *testing.T) { } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) for _, tc := range testCases { ds := &models.DataSource{ @@ -575,7 +574,7 @@ func TestService_DecryptedValue(t *testing.T) { t.Run("When datasource hasn't been updated, encrypted JSON should be fetched from cache", func(t *testing.T) { secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) encryptedJsonData, err := secretsService.EncryptJsonData( context.Background(), @@ -629,7 +628,7 @@ func TestService_DecryptedValue(t *testing.T) { SecureJsonData: encryptedJsonData, } - dsService := ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) // Populate cache password, ok := dsService.DecryptedValue(&ds, "password") @@ -680,7 +679,7 @@ func TestService_HTTPClientOptions(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock()) opts, err := dsService.httpClientOptions(&ds) require.NoError(t, err) @@ -697,7 +696,7 @@ func TestService_HTTPClientOptions(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock()) opts, err := dsService.httpClientOptions(&ds) require.NoError(t, err) @@ -716,7 +715,7 @@ func TestService_HTTPClientOptions(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock()) _, err := dsService.httpClientOptions(&ds) assert.Error(t, err) @@ -734,7 +733,7 @@ func TestService_HTTPClientOptions(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock()) opts, err := dsService.httpClientOptions(&ds) require.NoError(t, err) @@ -752,7 +751,7 @@ func TestService_HTTPClientOptions(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock()) opts, err := dsService.httpClientOptions(&ds) require.NoError(t, err) @@ -774,7 +773,7 @@ func TestService_HTTPClientOptions(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock()) _, err := dsService.httpClientOptions(&ds) assert.Error(t, err) @@ -794,7 +793,7 @@ func TestService_HTTPClientOptions(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) opts, err := dsService.httpClientOptions(&ds) require.NoError(t, err) diff --git a/pkg/services/featuremgmt/manager.go b/pkg/services/featuremgmt/manager.go index 1b795d0348d..8593e6635cf 100644 --- a/pkg/services/featuremgmt/manager.go +++ b/pkg/services/featuremgmt/manager.go @@ -3,6 +3,7 @@ package featuremgmt import ( "context" "fmt" + "net/http" "reflect" "github.com/grafana/grafana/pkg/infra/log" @@ -157,7 +158,7 @@ func (fm *FeatureManager) HandleGetSettings(c *models.ReqContext) { res["info"] = vv - response.JSON(200, res).WriteTo(c) + response.JSON(http.StatusOK, res).WriteTo(c) } // WithFeatures is used to define feature toggles for testing. diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index af7f1baf424..60b46d46b6a 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -143,6 +143,12 @@ var ( State: FeatureStateAlpha, RequiresDevMode: true, }, + { + Name: "publicDashboards", + Description: "enables public access to dashboards", + State: FeatureStateAlpha, + RequiresDevMode: true, + }, { Name: "lokiLive", Description: "support websocket streaming for loki (early prototype)", diff --git a/pkg/services/featuremgmt/strcase/snake.go b/pkg/services/featuremgmt/strcase/snake.go new file mode 100644 index 00000000000..012fdb181d2 --- /dev/null +++ b/pkg/services/featuremgmt/strcase/snake.go @@ -0,0 +1,118 @@ +// https://github.com/iancoleman/strcase/blob/master/snake.go + +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 Ian Coleman + * Copyright (c) 2018 Ma_124, + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, Subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or Substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package strcase + +import ( + "strings" +) + +// ToSnake converts a string to snake_case +func ToSnake(s string) string { + return ToDelimited(s, '_') +} + +func ToSnakeWithIgnore(s string, ignore string) string { + return ToScreamingDelimited(s, '_', ignore, false) +} + +// ToScreamingSnake converts a string to SCREAMING_SNAKE_CASE +func ToScreamingSnake(s string) string { + return ToScreamingDelimited(s, '_', "", true) +} + +// ToKebab converts a string to kebab-case +func ToKebab(s string) string { + return ToDelimited(s, '-') +} + +// ToScreamingKebab converts a string to SCREAMING-KEBAB-CASE +func ToScreamingKebab(s string) string { + return ToScreamingDelimited(s, '-', "", true) +} + +// ToDelimited converts a string to delimited.snake.case +// (in this case `delimiter = '.'`) +func ToDelimited(s string, delimiter uint8) string { + return ToScreamingDelimited(s, delimiter, "", false) +} + +// ToScreamingDelimited converts a string to SCREAMING.DELIMITED.SNAKE.CASE +// (in this case `delimiter = '.'; screaming = true`) +// or delimited.snake.case +// (in this case `delimiter = '.'; screaming = false`) +//nolint: gocyclo +func ToScreamingDelimited(s string, delimiter uint8, ignore string, screaming bool) string { + s = strings.TrimSpace(s) + n := strings.Builder{} + n.Grow(len(s) + 2) // nominal 2 bytes of extra space for inserted delimiters + for i, v := range []byte(s) { + vIsCap := v >= 'A' && v <= 'Z' + vIsLow := v >= 'a' && v <= 'z' + if vIsLow && screaming { + v += 'A' + v -= 'a' + } else if vIsCap && !screaming { + v += 'a' + v -= 'A' + } + + // treat acronyms as words, eg for JSONData -> JSON is a whole word + if i+1 < len(s) { + next := s[i+1] + vIsNum := v >= '0' && v <= '9' + nextIsCap := next >= 'A' && next <= 'Z' + nextIsLow := next >= 'a' && next <= 'z' + nextIsNum := next >= '0' && next <= '9' + // add underscore if next letter case type is changed + if (vIsCap && (nextIsLow || nextIsNum)) || (vIsLow && (nextIsCap || nextIsNum)) || (vIsNum && (nextIsCap || nextIsLow)) { + prevIgnore := ignore != "" && i > 0 && strings.ContainsAny(string(s[i-1]), ignore) + if !prevIgnore { + if vIsCap && nextIsLow { + if prevIsCap := i > 0 && s[i-1] >= 'A' && s[i-1] <= 'Z'; prevIsCap { + n.WriteByte(delimiter) + } + } + n.WriteByte(v) + if vIsLow || vIsNum || nextIsNum { + n.WriteByte(delimiter) + } + continue + } + } + } + + if (v == ' ' || v == '_' || v == '-' || v == '.') && !strings.ContainsAny(string(v), ignore) { + // replace space/underscore/hyphen/dot with delimiter + n.WriteByte(delimiter) + } else { + n.WriteByte(v) + } + } + + return n.String() +} diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 078ecbda7e7..7548ed76643 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -107,6 +107,10 @@ const ( // only execute the query saved in a panel FlagValidatedQueries = "validatedQueries" + // FlagPublicDashboards + // enables public access to dashboards + FlagPublicDashboards = "publicDashboards" + // FlagLokiLive // support websocket streaming for loki (early prototype) FlagLokiLive = "lokiLive" diff --git a/pkg/services/featuremgmt/usage_stats.go b/pkg/services/featuremgmt/usage_stats.go new file mode 100644 index 00000000000..f33e77834a6 --- /dev/null +++ b/pkg/services/featuremgmt/usage_stats.go @@ -0,0 +1,21 @@ +package featuremgmt + +import ( + "context" + "fmt" + + "github.com/grafana/grafana/pkg/services/featuremgmt/strcase" +) + +func (fm *FeatureManager) GetUsageStats(ctx context.Context) map[string]interface{} { + enabled := fm.GetEnabled(ctx) + stats := make(map[string]interface{}, len(enabled)) + for featureName := range enabled { + stats[asMetricName(featureName)] = 1 + } + return stats +} + +func asMetricName(featureName string) string { + return fmt.Sprintf("stats.features.%s.count", strcase.ToSnake(featureName)) +} diff --git a/pkg/services/featuremgmt/usage_stats_test.go b/pkg/services/featuremgmt/usage_stats_test.go new file mode 100644 index 00000000000..1bc5420e4ff --- /dev/null +++ b/pkg/services/featuremgmt/usage_stats_test.go @@ -0,0 +1,34 @@ +package featuremgmt + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFeatureUsageStats(t *testing.T) { + featureManagerWithAllFeatures := WithFeatures( + "trimDefaults", + "httpclientprovider_azure_auth", + "service-accounts", + "database_metrics", + "dashboardPreviews", + "live-config", + "showFeatureFlagsInUI", + "UPPER_SNAKE_CASE", + "feature.with.a.dot", + ) + + require.Equal(t, map[string]interface{}{ + "stats.features.trim_defaults.count": 1, + "stats.features.httpclientprovider_azure_auth.count": 1, + "stats.features.service_accounts.count": 1, + "stats.features.database_metrics.count": 1, + "stats.features.dashboard_previews.count": 1, + "stats.features.live_config.count": 1, + "stats.features.show_feature_flags_in_ui.count": 1, + "stats.features.upper_snake_case.count": 1, + "stats.features.feature_with_a_dot.count": 1, + }, featureManagerWithAllFeatures.GetUsageStats(context.Background())) +} diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go index 8babbe60046..f14c876b8e4 100644 --- a/pkg/services/libraryelements/api.go +++ b/pkg/services/libraryelements/api.go @@ -35,7 +35,7 @@ func (l *LibraryElementService) createHandler(c *models.ReqContext) response.Res return toLibraryElementError(err, "Failed to create library element") } - return response.JSON(200, LibraryElementResponse{Result: element}) + return response.JSON(http.StatusOK, LibraryElementResponse{Result: element}) } // deleteHandler handles DELETE /api/library-elements/:uid. @@ -45,7 +45,7 @@ func (l *LibraryElementService) deleteHandler(c *models.ReqContext) response.Res return toLibraryElementError(err, "Failed to delete library element") } - return response.JSON(200, DeleteLibraryElementResponse{ + return response.JSON(http.StatusOK, DeleteLibraryElementResponse{ Message: "Library element deleted", ID: id, }) @@ -58,7 +58,7 @@ func (l *LibraryElementService) getHandler(c *models.ReqContext) response.Respon return toLibraryElementError(err, "Failed to get library element") } - return response.JSON(200, LibraryElementResponse{Result: element}) + return response.JSON(http.StatusOK, LibraryElementResponse{Result: element}) } // getAllHandler handles GET /api/library-elements/. @@ -78,7 +78,7 @@ func (l *LibraryElementService) getAllHandler(c *models.ReqContext) response.Res return toLibraryElementError(err, "Failed to get library elements") } - return response.JSON(200, LibraryElementSearchResponse{Result: elementsResult}) + return response.JSON(http.StatusOK, LibraryElementSearchResponse{Result: elementsResult}) } // patchHandler handles PATCH /api/library-elements/:uid @@ -93,7 +93,7 @@ func (l *LibraryElementService) patchHandler(c *models.ReqContext) response.Resp return toLibraryElementError(err, "Failed to update library element") } - return response.JSON(200, LibraryElementResponse{Result: element}) + return response.JSON(http.StatusOK, LibraryElementResponse{Result: element}) } // getConnectionsHandler handles GET /api/library-panels/:uid/connections/. @@ -103,7 +103,7 @@ func (l *LibraryElementService) getConnectionsHandler(c *models.ReqContext) resp return toLibraryElementError(err, "Failed to get connections") } - return response.JSON(200, LibraryElementConnectionsResponse{Result: connections}) + return response.JSON(http.StatusOK, LibraryElementConnectionsResponse{Result: connections}) } // getByNameHandler handles GET /api/library-elements/name/:name/. @@ -113,7 +113,7 @@ func (l *LibraryElementService) getByNameHandler(c *models.ReqContext) response. return toLibraryElementError(err, "Failed to get library element") } - return response.JSON(200, LibraryElementArrayResponse{Result: elements}) + return response.JSON(http.StatusOK, LibraryElementArrayResponse{Result: elements}) } func toLibraryElementError(err error, message string) response.Response { diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 2358b8f6342..a4bc6ed6cfa 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -201,7 +201,7 @@ func createDashboard(t *testing.T, sqlStore *sqlstore.SQLStore, user models.Sign } dashboardStore := database.ProvideDashboardStore(sqlStore) - dashAlertExtractor := alerting.ProvideDashAlertExtractorService(nil, nil) + dashAlertExtractor := alerting.ProvideDashAlertExtractorService(nil, nil, nil) service := dashboardservice.ProvideDashboardService( setting.NewCfg(), dashboardStore, dashAlertExtractor, featuremgmt.WithFeatures(), acmock.NewPermissionsServicesMock(), diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 0d13913c966..26f28a798c2 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -1422,7 +1422,7 @@ func createDashboard(t *testing.T, sqlStore *sqlstore.SQLStore, user *models.Sig } dashboardStore := database.ProvideDashboardStore(sqlStore) - dashAlertService := alerting.ProvideDashAlertExtractorService(nil, nil) + dashAlertService := alerting.ProvideDashAlertExtractorService(nil, nil, nil) service := dashboardservice.ProvideDashboardService( setting.NewCfg(), dashboardStore, dashAlertService, featuremgmt.WithFeatures(), acmock.NewPermissionsServicesMock(), diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index d03b6453076..1dba33d32b3 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "os" + "strconv" "strings" "sync" "time" @@ -16,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/usagestats" @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/plugincontext" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/comments/commentmodel" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -70,7 +71,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r pluginStore plugins.Store, cacheService *localcache.CacheService, dataSourceCache datasources.CacheService, sqlStore *sqlstore.SQLStore, secretsService secrets.Service, usageStatsService usagestats.Service, queryDataService *query.Service, toggles featuremgmt.FeatureToggles, - bus bus.Bus) (*GrafanaLive, error) { + accessControl accesscontrol.AccessControl) (*GrafanaLive, error) { g := &GrafanaLive{ Cfg: cfg, Features: toggles, @@ -82,7 +83,6 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r SQLStore: sqlStore, SecretsService: secretsService, queryDataService: queryDataService, - bus: bus, channels: make(map[string]models.ChannelHandler), GrafanaScope: CoreGrafanaScope{ Features: make(map[string]models.ChannelHandlerFactory), @@ -242,7 +242,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r g.GrafanaScope.Dashboards = dash g.GrafanaScope.Features["dashboard"] = dash g.GrafanaScope.Features["broadcast"] = features.NewBroadcastRunner(g.storage) - g.GrafanaScope.Features["comment"] = features.NewCommentHandler(commentmodel.NewPermissionChecker(g.SQLStore, g.Features)) + g.GrafanaScope.Features["comment"] = features.NewCommentHandler(commentmodel.NewPermissionChecker(g.SQLStore, g.Features, accessControl)) g.surveyCaller = survey.NewCaller(managedStreamRunner, node) err = g.surveyCaller.SetupHandlers() @@ -407,7 +407,6 @@ type GrafanaLive struct { SecretsService secrets.Service pluginStore plugins.Store queryDataService *query.Service - bus bus.Bus node *centrifuge.Node surveyCaller *survey.Caller @@ -1043,14 +1042,14 @@ func (g *GrafanaLive) HandleListHTTP(c *models.ReqContext) response.Response { info := streamChannelListResponse{ Channels: channels, } - return response.JSONStreaming(200, info) + return response.JSONStreaming(http.StatusOK, info) } // HandleInfoHTTP special http response for func (g *GrafanaLive) HandleInfoHTTP(ctx *models.ReqContext) response.Response { path := web.Params(ctx.Req)["*"] if path == "grafana/dashboards/gitops" { - return response.JSON(200, util.DynMap{ + return response.JSON(http.StatusOK, util.DynMap{ "active": g.GrafanaScope.Dashboards.HasGitOpsObserver(ctx.SignedInUser.OrgId), }) } @@ -1367,6 +1366,11 @@ func handleLog(msg centrifuge.LogEntry) { func (g *GrafanaLive) sampleLiveStats() { numClients := g.node.Hub().NumClients() numUsers := g.node.Hub().NumUsers() + numChannels := g.node.Hub().NumChannels() + var numNodes int + if info, err := g.node.Info(); err == nil { + numNodes = len(info.Nodes) + } g.usageStats.sampleCount++ g.usageStats.numClientsSum += numClients @@ -1376,16 +1380,16 @@ func (g *GrafanaLive) sampleLiveStats() { g.usageStats.numClientsMax = numClients } - if numClients < g.usageStats.numClientsMin { - g.usageStats.numClientsMin = numClients - } - if numUsers > g.usageStats.numUsersMax { g.usageStats.numUsersMax = numUsers } - if numUsers < g.usageStats.numUsersMin { - g.usageStats.numUsersMin = numUsers + if numNodes > g.usageStats.numNodesMax { + g.usageStats.numNodesMax = numNodes + } + + if numChannels > g.usageStats.numChannelsMax { + g.usageStats.numChannelsMax = numChannels } } @@ -1393,38 +1397,65 @@ func (g *GrafanaLive) resetLiveStats() { g.usageStats = usageStats{} } +func getHistogramMetric(val int, bounds []int, metricPrefix string) string { + for _, bound := range bounds { + if val <= bound { + return metricPrefix + "le_" + strconv.Itoa(bound) + } + } + return metricPrefix + "le_inf" +} + +func (g *GrafanaLive) collectLiveStats(_ context.Context) (map[string]interface{}, error) { + liveUsersAvg := 0 + liveClientsAvg := 0 + + if g.usageStats.sampleCount > 0 { + liveUsersAvg = g.usageStats.numUsersSum / g.usageStats.sampleCount + liveClientsAvg = g.usageStats.numClientsSum / g.usageStats.sampleCount + } + + var liveEnabled int + if g.Cfg.LiveMaxConnections != 0 { + liveEnabled = 1 + } + + var liveHAEnabled int + if g.Cfg.LiveHAEngine != "" { + liveHAEnabled = 1 + } + + metrics := map[string]interface{}{ + "stats.live_enabled.count": liveEnabled, + "stats.live_ha_enabled.count": liveHAEnabled, + "stats.live_samples.count": g.usageStats.sampleCount, + "stats.live_users_max.count": g.usageStats.numUsersMax, + "stats.live_users_avg.count": liveUsersAvg, + "stats.live_clients_max.count": g.usageStats.numClientsMax, + "stats.live_clients_avg.count": liveClientsAvg, + "stats.live_channels_max.count": g.usageStats.numChannelsMax, + "stats.live_nodes_max.count": g.usageStats.numNodesMax, + } + + metrics[getHistogramMetric(g.usageStats.numClientsMax, []int{0, 10, 100, 1000, 10000, 100000}, "stats.live_clients_")] = 1 + metrics[getHistogramMetric(g.usageStats.numUsersMax, []int{0, 10, 100, 1000, 10000, 100000}, "stats.live_users_")] = 1 + metrics[getHistogramMetric(g.usageStats.numChannelsMax, []int{0, 10, 100, 1000, 10000, 100000}, "stats.live_channels_")] = 1 + metrics[getHistogramMetric(g.usageStats.numNodesMax, []int{1, 3, 9}, "stats.live_nodes_")] = 1 + + return metrics, nil +} + func (g *GrafanaLive) registerUsageMetrics() { g.usageStatsService.RegisterSendReportCallback(g.resetLiveStats) - - g.usageStatsService.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) { - liveUsersAvg := 0 - liveClientsAvg := 0 - - if g.usageStats.sampleCount > 0 { - liveUsersAvg = g.usageStats.numUsersSum / g.usageStats.sampleCount - liveClientsAvg = g.usageStats.numClientsSum / g.usageStats.sampleCount - } - - metrics := map[string]interface{}{ - "stats.live_samples.count": g.usageStats.sampleCount, - "stats.live_users_max.count": g.usageStats.numUsersMax, - "stats.live_users_min.count": g.usageStats.numUsersMin, - "stats.live_users_avg.count": liveUsersAvg, - "stats.live_clients_max.count": g.usageStats.numClientsMax, - "stats.live_clients_min.count": g.usageStats.numClientsMin, - "stats.live_clients_avg.count": liveClientsAvg, - } - - return metrics, nil - }) + g.usageStatsService.RegisterMetricsFunc(g.collectLiveStats) } type usageStats struct { - numClientsMax int - numClientsMin int - numClientsSum int - numUsersMax int - numUsersMin int - numUsersSum int - sampleCount int + numClientsMax int + numClientsSum int + numUsersMax int + numUsersSum int + sampleCount int + numNodesMax int + numChannelsMax int } diff --git a/pkg/services/live/live_test.go b/pkg/services/live/live_test.go index 9c738c0aa30..d4dff995fcd 100644 --- a/pkg/services/live/live_test.go +++ b/pkg/services/live/live_test.go @@ -157,3 +157,45 @@ func TestCheckOrigin(t *testing.T) { }) } } + +func Test_getHistogramMetric(t *testing.T) { + type args struct { + val int + bounds []int + metricPrefix string + } + + tests := []struct { + name string + args args + want string + }{ + { + "zero", + args{0, []int{0, 10, 100, 1000, 10000, 100000}, "live_users_"}, + "live_users_le_0", + }, + { + "equal_to_bound", + args{10, []int{0, 10, 100, 1000, 10000, 100000}, "live_users_"}, + "live_users_le_10", + }, + { + "in_the_middle", + args{30000, []int{0, 10, 100, 1000, 10000, 100000}, "live_users_"}, + "live_users_le_100000", + }, + { + "more_than_upper_bound", + args{300000, []int{0, 10, 100, 1000, 10000, 100000}, "live_users_"}, + "live_users_le_inf", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getHistogramMetric(tt.args.val, tt.args.bounds, tt.args.metricPrefix); got != tt.want { + t.Errorf("getHistogramMetric() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/services/login/authinfoservice/database/database.go b/pkg/services/login/authinfoservice/database/database.go index 5fa3ee4a89e..efd4f2cadac 100644 --- a/pkg/services/login/authinfoservice/database/database.go +++ b/pkg/services/login/authinfoservice/database/database.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/secrets" @@ -16,30 +15,19 @@ var GetTime = time.Now type AuthInfoStore struct { sqlStore sqlstore.Store - bus bus.Bus secretsService secrets.Service logger log.Logger } -func ProvideAuthInfoStore(sqlStore sqlstore.Store, bus bus.Bus, secretsService secrets.Service) *AuthInfoStore { +func ProvideAuthInfoStore(sqlStore sqlstore.Store, secretsService secrets.Service) *AuthInfoStore { store := &AuthInfoStore{ sqlStore: sqlStore, - bus: bus, secretsService: secretsService, logger: log.New("login.authinfo.store"), } - store.registerBusHandlers() return store } -func (s *AuthInfoStore) registerBusHandlers() { - s.bus.AddHandler(s.GetExternalUserInfoByLogin) - s.bus.AddHandler(s.GetAuthInfo) - s.bus.AddHandler(s.SetAuthInfo) - s.bus.AddHandler(s.UpdateAuthInfo) - s.bus.AddHandler(s.DeleteAuthInfo) -} - func (s *AuthInfoStore) GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error { userQuery := models.GetUserByLoginQuery{LoginOrEmail: query.LoginOrEmail} err := s.sqlStore.GetUserByLogin(ctx, &userQuery) diff --git a/pkg/services/login/authinfoservice/user_auth_test.go b/pkg/services/login/authinfoservice/user_auth_test.go index 1795714292a..f719366adb7 100644 --- a/pkg/services/login/authinfoservice/user_auth_test.go +++ b/pkg/services/login/authinfoservice/user_auth_test.go @@ -6,7 +6,6 @@ import ( "testing" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" secretstore "github.com/grafana/grafana/pkg/services/secrets/database" @@ -20,7 +19,7 @@ import ( func TestUserAuth(t *testing.T) { sqlStore := sqlstore.InitTestDB(t) secretsService := secretsManager.SetupTestService(t, secretstore.ProvideSecretsStore(sqlStore)) - authInfoStore := database.ProvideAuthInfoStore(sqlStore, bus.New(), secretsService) + authInfoStore := database.ProvideAuthInfoStore(sqlStore, secretsService) srv := ProvideAuthInfoService(&OSSUserProtectionImpl{}, authInfoStore) t.Run("Given 5 users", func(t *testing.T) { diff --git a/pkg/services/login/login.go b/pkg/services/login/login.go index c698ee55dbe..18b5381e552 100644 --- a/pkg/services/login/login.go +++ b/pkg/services/login/login.go @@ -11,6 +11,7 @@ var ( ErrInvalidCredentials = errors.New("invalid username or password") ErrUsersQuotaReached = errors.New("users quota reached") ErrGettingUserQuota = errors.New("error getting user quota") + ErrSignupNotAllowed = errors.New("system administrator has disabled signup") ) type TeamSyncFunc func(user *models.User, externalUser *models.ExternalUserInfo) error diff --git a/pkg/services/login/loginservice/loginservice.go b/pkg/services/login/loginservice/loginservice.go index db5ccf002cb..ea9338602b7 100644 --- a/pkg/services/login/loginservice/loginservice.go +++ b/pkg/services/login/loginservice/loginservice.go @@ -4,7 +4,6 @@ import ( "context" "errors" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/login" @@ -16,20 +15,17 @@ var ( logger = log.New("login.ext_user") ) -func ProvideService(sqlStore sqlstore.Store, bus bus.Bus, quotaService *quota.QuotaService, authInfoService login.AuthInfoService) *Implementation { +func ProvideService(sqlStore sqlstore.Store, quotaService *quota.QuotaService, authInfoService login.AuthInfoService) *Implementation { s := &Implementation{ SQLStore: sqlStore, - Bus: bus, QuotaService: quotaService, AuthInfoService: authInfoService, } - bus.AddHandler(s.UpsertUser) return s } type Implementation struct { SQLStore sqlstore.Store - Bus bus.Bus AuthInfoService login.AuthInfoService QuotaService *quota.QuotaService TeamSync login.TeamSyncFunc @@ -57,7 +53,7 @@ func (ls *Implementation) UpsertUser(ctx context.Context, cmd *models.UpsertUser } if !cmd.SignupAllowed { cmd.ReqContext.Logger.Warn("Not allowing login, user not found in internal user database and allow signup = false", "authmode", extUser.AuthModule) - return login.ErrInvalidCredentials + return login.ErrSignupNotAllowed } limitReached, err := ls.QuotaService.QuotaReached(cmd.ReqContext, "user") diff --git a/pkg/services/login/loginservice/loginservice_test.go b/pkg/services/login/loginservice/loginservice_test.go index 4bace3556f9..1e687de9e00 100644 --- a/pkg/services/login/loginservice/loginservice_test.go +++ b/pkg/services/login/loginservice/loginservice_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/go-kit/log" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log/level" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/login/logintest" @@ -28,7 +27,6 @@ func Test_syncOrgRoles_doesNotBreakWhenTryingToRemoveLastOrgAdmin(t *testing.T) } login := Implementation{ - Bus: bus.New(), QuotaService: "a.QuotaService{}, AuthInfoService: authInfoMock, SQLStore: store, @@ -53,7 +51,6 @@ func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) { } login := Implementation{ - Bus: bus.New(), QuotaService: "a.QuotaService{}, AuthInfoService: authInfoMock, SQLStore: store, @@ -67,7 +64,6 @@ func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) { func Test_teamSync(t *testing.T) { authInfoMock := &logintest.AuthInfoServiceFake{} login := Implementation{ - Bus: bus.New(), QuotaService: "a.QuotaService{}, AuthInfoService: authInfoMock, } @@ -80,8 +76,6 @@ func Test_teamSync(t *testing.T) { Login: "test_user", } authInfoMock.ExpectedUser = expectedUser - bus.ClearBusHandlers() - t.Cleanup(func() { bus.ClearBusHandlers() }) var actualUser *models.User var actualExternalUser *models.ExternalUserInfo diff --git a/pkg/services/login/logintest/logintest.go b/pkg/services/login/logintest/logintest.go index 6197e8d5ce9..020714632e8 100644 --- a/pkg/services/login/logintest/logintest.go +++ b/pkg/services/login/logintest/logintest.go @@ -49,3 +49,13 @@ func (a *AuthInfoServiceFake) GetExternalUserInfoByLogin(ctx context.Context, qu query.Result = a.ExpectedExternalUser return a.ExpectedError } + +type AuthenticatorFake struct { + ExpectedUser *models.User + ExpectedError error +} + +func (a *AuthenticatorFake) AuthenticateUser(c context.Context, query *models.LoginUserQuery) error { + query.User = a.ExpectedUser + return a.ExpectedError +} diff --git a/pkg/services/ngalert/CHANGELOG.md b/pkg/services/ngalert/CHANGELOG.md new file mode 100644 index 00000000000..15d9aadcf60 --- /dev/null +++ b/pkg/services/ngalert/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +**Please note that this is not a direct replacement of Grafana's "add to changelog" label. It is _mostly_ for internal consumption of the Alerting team that owns this part of Grafana.** + +## Scope Glossary + +### `[ADMIN]` +The ADMIN scope denotes a change that affect the structure and layout of this repository. This includes updates to the following: + +- CODEOWNERS +- README +- DotFiles (.gitignore, .git-attributes, etc) + +Anything that a developer working on this repo should be aware of from a standards and practice perspective. + +### `[BUGFIX]` + +The BUGFIX scope denotes a change that fixes an issue with the project in question. A BUGFIX should align the behaviour of the service with the current expected behaviour of the service. If a BUGFIX introduces new unexpected behaviour to ameliorate the issue, a corresponding FEATURE or ENHANCEMENT scope should also be added to the changelog. + +### `[CHANGE]` + +The CHANGE scope denotes a change that changes the expected behavior of the project while not adding new functionality or fixing an underling issue. This commonly occurs when renaming things to make them more consistent or to accommodate updated versions of vendored dependencies. + +### `[FEATURE]` + +The FEATURE scope denotes a change that adds new functionality to the project/service. + +### `[ENHANCEMENT]` + +The ENHANCEMENT scope denotes a change that improves upon the current functionality of the project/service. Generally, an enhancement is something that improves upon something that is already present. Either by making it simpler, more powerful, or more performant. For Example: + +An optimization on a particular process in a service that makes it more performant +Simpler syntax for setting a configuration value, like allowing 1m instead of 60 for a duration setting. + +## Order + +Scopes must have an order to ensure consistency and ease of search, this helps us identify which section do we need to look for what. The order must be: + +1. `[CHANGE]` +2. `[FEATURE]` +3. `[BUGFIX]` +4. `[ENHANCEMENT]` +5. `[ADMIN]` + + +## Grafana Alerting - main / unreleased + +- [CHANGE] Prometheus Compatible API: Use float-like values for `api/prometheus/grafana/api/v1/alerts` and `api/prometheus/grafana/api/v1/rules` instead of the evaluation string #47216 +- [BUGFIX] (Legacy) Templates: Parse notification templates using all the matches of the alert rule when going from `Alerting` to `OK` in legacy alerting #47355 +- [BUGFIX] Scheduler: Fix state manager to support OK option of `AlertRule.ExecErrState` #47670 +- [ENHANCEMENT] Templates: Enable the use of classic condition values in templates #46971 diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index 46f62d054db..0b6123b6603 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -77,6 +77,7 @@ type API struct { SecretsService secrets.Service AccessControl accesscontrol.AccessControl Policies *provisioning.NotificationPolicyService + ContactPointService *provisioning.ContactPointService } // RegisterAPIEndpoints registers API handlers @@ -90,13 +91,13 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { api.RegisterAlertmanagerApiEndpoints(NewForkedAM( api.DatasourceCache, NewLotexAM(proxy, logger), - &AlertmanagerSrv{store: api.AlertingStore, mam: api.MultiOrgAlertmanager, secrets: api.SecretsService, log: logger, ac: api.AccessControl}, + &AlertmanagerSrv{crypto: api.MultiOrgAlertmanager.Crypto, log: logger, ac: api.AccessControl, mam: api.MultiOrgAlertmanager}, ), m) // Register endpoints for proxying to Prometheus-compatible backends. api.RegisterPrometheusApiEndpoints(NewForkedProm( api.DatasourceCache, NewLotexProm(proxy, logger), - &PrometheusSrv{log: logger, manager: api.StateManager, store: api.RuleStore}, + &PrometheusSrv{log: logger, manager: api.StateManager, store: api.RuleStore, ac: api.AccessControl}, ), m) // Register endpoints for proxying to Cortex Ruler-compatible backends. api.RegisterRulerApiEndpoints(NewForkedRuler( @@ -132,8 +133,9 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { if api.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagAlertProvisioning) { api.RegisterProvisioningApiEndpoints(NewForkedProvisioningApi(&ProvisioningSrv{ - log: logger, - policies: api.Policies, + log: logger, + policies: api.Policies, + contactPointService: api.ContactPointService, }), m) } } diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index 047a637c32c..ee22a4145ab 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -2,7 +2,6 @@ package api import ( "context" - "encoding/base64" "errors" "fmt" "net/http" @@ -15,10 +14,8 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" - ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/store" - "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -29,11 +26,10 @@ const ( ) type AlertmanagerSrv struct { - mam *notifier.MultiOrgAlertmanager - secrets secrets.Service - store AlertingStore - log log.Logger - ac accesscontrol.AccessControl + log log.Logger + ac accesscontrol.AccessControl + mam *notifier.MultiOrgAlertmanager + crypto notifier.Crypto } type UnknownReceiverError struct { @@ -44,79 +40,6 @@ func (e UnknownReceiverError) Error() string { return fmt.Sprintf("unknown receiver: %s", e.UID) } -func (srv AlertmanagerSrv) loadSecureSettings(ctx context.Context, orgId int64, receivers []*apimodels.PostableApiReceiver) error { - // Get the last known working configuration - query := ngmodels.GetLatestAlertmanagerConfigurationQuery{OrgID: orgId} - if err := srv.store.GetLatestAlertmanagerConfiguration(ctx, &query); err != nil { - // If we don't have a configuration there's nothing for us to know and we should just continue saving the new one - if !errors.Is(err, store.ErrNoAlertmanagerConfiguration) { - return fmt.Errorf("failed to get latest configuration: %w", err) - } - } - - currentReceiverMap := make(map[string]*apimodels.PostableGrafanaReceiver) - if query.Result != nil { - currentConfig, err := notifier.Load([]byte(query.Result.AlertmanagerConfiguration)) - if err != nil { - return fmt.Errorf("failed to load latest configuration: %w", err) - } - currentReceiverMap = currentConfig.GetGrafanaReceiverMap() - } - - // Copy the previously known secure settings - for i, r := range receivers { - for j, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers { - if gr.UID == "" { // new receiver - continue - } - - cgmr, ok := currentReceiverMap[gr.UID] - if !ok { - // it tries to update a receiver that didn't previously exist - return UnknownReceiverError{UID: gr.UID} - } - - // frontend sends only the secure settings that have to be updated - // therefore we have to copy from the last configuration only those secure settings not included in the request - for key := range cgmr.SecureSettings { - _, ok := gr.SecureSettings[key] - if !ok { - decryptedValue, err := srv.getDecryptedSecret(cgmr, key) - if err != nil { - return fmt.Errorf("failed to decrypt stored secure setting: %s: %w", key, err) - } - - if receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings == nil { - receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings = make(map[string]string, len(cgmr.SecureSettings)) - } - - receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings[key] = decryptedValue - } - } - } - } - return nil -} - -func (srv AlertmanagerSrv) getDecryptedSecret(r *apimodels.PostableGrafanaReceiver, key string) (string, error) { - storedValue, ok := r.SecureSettings[key] - if !ok { - return "", nil - } - - decodeValue, err := base64.StdEncoding.DecodeString(storedValue) - if err != nil { - return "", err - } - - decryptedValue, err := srv.secrets.Decrypt(context.Background(), decodeValue) - if err != nil { - return "", err - } - - return string(decryptedValue), nil -} - func (srv AlertmanagerSrv) RouteGetAMStatus(c *models.ReqContext) response.Response { am, errResp := srv.AlertmanagerFor(c.OrgId) if errResp != nil { @@ -190,59 +113,14 @@ func (srv AlertmanagerSrv) RouteDeleteSilence(c *models.ReqContext) response.Res } func (srv AlertmanagerSrv) RouteGetAlertingConfig(c *models.ReqContext) response.Response { - query := ngmodels.GetLatestAlertmanagerConfigurationQuery{OrgID: c.OrgId} - if err := srv.store.GetLatestAlertmanagerConfiguration(c.Req.Context(), &query); err != nil { + config, err := srv.mam.GetAlertmanagerConfiguration(c.Req.Context(), c.OrgId) + if err != nil { if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { return ErrResp(http.StatusNotFound, err, "") } - return ErrResp(http.StatusInternalServerError, err, "failed to get latest configuration") + return ErrResp(http.StatusInternalServerError, err, err.Error()) } - - cfg, err := notifier.Load([]byte(query.Result.AlertmanagerConfiguration)) - if err != nil { - return ErrResp(http.StatusInternalServerError, err, "failed to unmarshal alertmanager configuration") - } - - result := apimodels.GettableUserConfig{ - TemplateFiles: cfg.TemplateFiles, - AlertmanagerConfig: apimodels.GettableApiAlertingConfig{ - Config: cfg.AlertmanagerConfig.Config, - }, - } - for _, recv := range cfg.AlertmanagerConfig.Receivers { - receivers := make([]*apimodels.GettableGrafanaReceiver, 0, len(recv.PostableGrafanaReceivers.GrafanaManagedReceivers)) - for _, pr := range recv.PostableGrafanaReceivers.GrafanaManagedReceivers { - secureFields := make(map[string]bool, len(pr.SecureSettings)) - for k := range pr.SecureSettings { - decryptedValue, err := srv.getDecryptedSecret(pr, k) - if err != nil { - return ErrResp(http.StatusInternalServerError, err, "failed to decrypt stored secure setting: %s", k) - } - if decryptedValue == "" { - continue - } - secureFields[k] = true - } - gr := apimodels.GettableGrafanaReceiver{ - UID: pr.UID, - Name: pr.Name, - Type: pr.Type, - DisableResolveMessage: pr.DisableResolveMessage, - Settings: pr.Settings, - SecureFields: secureFields, - } - receivers = append(receivers, &gr) - } - gettableApiReceiver := apimodels.GettableApiReceiver{ - GettableGrafanaReceivers: apimodels.GettableGrafanaReceivers{ - GrafanaManagedReceivers: receivers, - }, - } - gettableApiReceiver.Name = recv.Name - result.AlertmanagerConfig.Receivers = append(result.AlertmanagerConfig.Receivers, &gettableApiReceiver) - } - - return response.JSON(http.StatusOK, result) + return response.JSON(http.StatusOK, config) } func (srv AlertmanagerSrv) RouteGetAMAlertGroups(c *models.ReqContext) response.Response { @@ -332,41 +210,26 @@ func (srv AlertmanagerSrv) RouteGetSilences(c *models.ReqContext) response.Respo } func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *models.ReqContext, body apimodels.PostableUserConfig) response.Response { - // Get the last known working configuration - query := ngmodels.GetLatestAlertmanagerConfigurationQuery{OrgID: c.OrgId} - if err := srv.store.GetLatestAlertmanagerConfiguration(c.Req.Context(), &query); err != nil { - // If we don't have a configuration there's nothing for us to know and we should just continue saving the new one - if !errors.Is(err, store.ErrNoAlertmanagerConfiguration) { - return ErrResp(http.StatusInternalServerError, err, "failed to get latest configuration") - } + err := srv.mam.ApplyAlertmanagerConfiguration(c.Req.Context(), c.OrgId, body) + if err == nil { + return response.JSON(http.StatusAccepted, util.DynMap{"message": "configuration created"}) + } + var unknownReceiverError notifier.UnknownReceiverError + if errors.As(err, &unknownReceiverError) { + return ErrResp(http.StatusBadRequest, unknownReceiverError, "") + } + var configRejectedError notifier.AlertmanagerConfigRejectedError + if errors.As(err, &configRejectedError) { + return ErrResp(http.StatusBadRequest, configRejectedError, "") + } + if errors.Is(err, notifier.ErrNoAlertmanagerForOrg) { + return response.Error(http.StatusNotFound, err.Error(), err) + } + if errors.Is(err, notifier.ErrAlertmanagerNotReady) { + return response.Error(http.StatusConflict, err.Error(), err) } - if err := srv.loadSecureSettings(c.Req.Context(), c.OrgId, body.AlertmanagerConfig.Receivers); err != nil { - var unknownReceiverError UnknownReceiverError - if errors.As(err, &unknownReceiverError) { - return ErrResp(http.StatusBadRequest, err, "") - } - return ErrResp(http.StatusInternalServerError, err, "") - } - - if err := body.ProcessConfig(srv.secrets.Encrypt); err != nil { - return ErrResp(http.StatusInternalServerError, err, "failed to post process Alertmanager configuration") - } - - am, errResp := srv.AlertmanagerFor(c.OrgId) - if errResp != nil { - // It's okay if the alertmanager isn't ready yet, we're changing its config anyway. - if !errors.Is(errResp.Err(), notifier.ErrAlertmanagerNotReady) { - return errResp - } - } - - if err := am.SaveAndApplyConfig(c.Req.Context(), &body); err != nil { - srv.log.Error("unable to save and apply alertmanager configuration", "err", err) - return ErrResp(http.StatusBadRequest, err, "failed to save and apply Alertmanager configuration") - } - - return response.JSON(http.StatusAccepted, util.DynMap{"message": "configuration created"}) + return ErrResp(http.StatusInternalServerError, err, "") } func (srv AlertmanagerSrv) RoutePostAMAlerts(_ *models.ReqContext, _ apimodels.PostableAlerts) response.Response { @@ -374,7 +237,7 @@ func (srv AlertmanagerSrv) RoutePostAMAlerts(_ *models.ReqContext, _ apimodels.P } func (srv AlertmanagerSrv) RoutePostTestReceivers(c *models.ReqContext, body apimodels.TestReceiversConfigBodyParams) response.Response { - if err := srv.loadSecureSettings(c.Req.Context(), c.OrgId, body.Receivers); err != nil { + if err := srv.crypto.LoadSecureSettings(c.Req.Context(), c.OrgId, body.Receivers); err != nil { var unknownReceiverError UnknownReceiverError if errors.As(err, &unknownReceiverError) { return ErrResp(http.StatusBadRequest, err, "") @@ -382,7 +245,7 @@ func (srv AlertmanagerSrv) RoutePostTestReceivers(c *models.ReqContext, body api return ErrResp(http.StatusInternalServerError, err, "") } - if err := body.ProcessConfig(srv.secrets.Encrypt); err != nil { + if err := body.ProcessConfig(srv.crypto.Encrypt); err != nil { return ErrResp(http.StatusInternalServerError, err, "failed to post process Alertmanager configuration") } @@ -512,7 +375,6 @@ func (srv AlertmanagerSrv) AlertmanagerFor(orgID int64) (Alertmanager, *response if errors.Is(err, notifier.ErrNoAlertmanagerForOrg) { return nil, response.Error(http.StatusNotFound, err.Error(), err) } - if errors.Is(err, notifier.ErrAlertmanagerNotReady) { return am, response.Error(http.StatusConflict, err.Error(), err) } diff --git a/pkg/services/ngalert/api/api_alertmanager_test.go b/pkg/services/ngalert/api/api_alertmanager_test.go index 13943adc9ed..eec2b513e00 100644 --- a/pkg/services/ngalert/api/api_alertmanager_test.go +++ b/pkg/services/ngalert/api/api_alertmanager_test.go @@ -346,15 +346,19 @@ func createSut(t *testing.T, accessControl accesscontrol.AccessControl) Alertman t.Helper() mam := createMultiOrgAlertmanager(t) - store := newFakeAlertingStore(t) - store.Setup(1) - store.Setup(2) - store.Setup(3) + configs := map[int64]*ngmodels.AlertConfiguration{ + 1: {AlertmanagerConfiguration: validConfig, OrgID: 1}, + 2: {AlertmanagerConfiguration: validConfig, OrgID: 2}, + 3: {AlertmanagerConfiguration: brokenConfig, OrgID: 3}, + } + configStore := notifier.NewFakeConfigStore(t, configs) secrets := fakes.NewFakeSecretsService() if accessControl == nil { accessControl = acMock.New().WithDisabled() } - return AlertmanagerSrv{mam: mam, store: store, secrets: secrets, ac: accessControl} + log := log.NewNopLogger() + crypto := notifier.NewCrypto(secrets, &configStore, log) + return AlertmanagerSrv{mam: mam, crypto: crypto, ac: accessControl, log: log} } func createAmConfigRequest(t *testing.T) apimodels.PostableUserConfig { @@ -392,7 +396,7 @@ func createMultiOrgAlertmanager(t *testing.T) *notifier.MultiOrgAlertmanager { }, // do not poll in tests. } - mam, err := notifier.NewMultiOrgAlertmanager(cfg, &configStore, &orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger")) + mam, err := notifier.NewMultiOrgAlertmanager(cfg, &configStore, &orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService) require.NoError(t, err) err = mam.LoadAndSyncAlertmanagersForOrgs(context.Background()) require.NoError(t, err) diff --git a/pkg/services/ngalert/api/api_prometheus.go b/pkg/services/ngalert/api/api_prometheus.go index 37b84ac21bb..c7ee7b93b97 100644 --- a/pkg/services/ngalert/api/api_prometheus.go +++ b/pkg/services/ngalert/api/api_prometheus.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -26,6 +27,7 @@ type PrometheusSrv struct { log log.Logger manager state.AlertInstanceManager store store.RuleStore + ac accesscontrol.AccessControl } const queryIncludeInternalLabels = "includeInternalLabels" @@ -101,6 +103,15 @@ func getPanelIDFromRequest(r *http.Request) (int64, error) { } func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Response { + dashboardUID := c.Query("dashboard_uid") + panelID, err := getPanelIDFromRequest(c.Req) + if err != nil { + return ErrResp(http.StatusBadRequest, err, "invalid panel_id") + } + if dashboardUID == "" && panelID != 0 { + return ErrResp(http.StatusBadRequest, errors.New("panel_id must be set with dashboard_uid"), "") + } + ruleResponse := apimodels.RuleResponse{ DiscoveryBase: apimodels.DiscoveryBase{ Status: "success", @@ -130,65 +141,44 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res namespaceUIDs = append(namespaceUIDs, k) } - dashboardUID := c.Query("dashboard_uid") - panelID, err := getPanelIDFromRequest(c.Req) - if err != nil { - return ErrResp(http.StatusBadRequest, err, "invalid panel_id") - } - if dashboardUID == "" && panelID != 0 { - return ErrResp(http.StatusBadRequest, errors.New("panel_id must be set with dashboard_uid"), "") - } - - ruleGroupQuery := ngmodels.ListOrgRuleGroupsQuery{ + alertRuleQuery := ngmodels.ListAlertRulesQuery{ OrgID: c.SignedInUser.OrgId, NamespaceUIDs: namespaceUIDs, DashboardUID: dashboardUID, PanelID: panelID, } - if err := srv.store.GetOrgRuleGroups(c.Req.Context(), &ruleGroupQuery); err != nil { - ruleResponse.DiscoveryBase.Status = "error" - ruleResponse.DiscoveryBase.Error = fmt.Sprintf("failure getting rule groups: %s", err.Error()) - ruleResponse.DiscoveryBase.ErrorType = apiv1.ErrServer - return response.JSON(http.StatusInternalServerError, ruleResponse) - } - - alertRuleQuery := ngmodels.ListAlertRulesQuery{ - OrgID: c.SignedInUser.OrgId, - DashboardUID: dashboardUID, - PanelID: panelID, - } if err := srv.store.GetOrgAlertRules(c.Req.Context(), &alertRuleQuery); err != nil { ruleResponse.DiscoveryBase.Status = "error" ruleResponse.DiscoveryBase.Error = fmt.Sprintf("failure getting rules: %s", err.Error()) ruleResponse.DiscoveryBase.ErrorType = apiv1.ErrServer return response.JSON(http.StatusInternalServerError, ruleResponse) } + hasAccess := func(evaluator accesscontrol.Evaluator) bool { + return accesscontrol.HasAccess(srv.ac, c)(accesscontrol.ReqSignedIn, evaluator) + } groupMap := make(map[string]*apimodels.RuleGroup) - for _, r := range ruleGroupQuery.Result { - if len(r) < 3 { - continue - } - groupId, namespaceUID, namespace := r[0], r[1], r[2] - - newGroup := &apimodels.RuleGroup{ - Name: groupId, - File: namespace, // file is what Prometheus uses for provisioning, we replace it with namespace. - LastEvaluation: time.Time{}, - EvaluationTime: 0, // TODO: see if we are able to pass this along with evaluation results - } - - groupMap[groupId+"-"+namespaceUID] = newGroup - - ruleResponse.Data.RuleGroups = append(ruleResponse.Data.RuleGroups, newGroup) - } - for _, rule := range alertRuleQuery.Result { - newGroup, ok := groupMap[rule.RuleGroup+"-"+rule.NamespaceUID] - if !ok { + if !authorizeDatasourceAccessForRule(rule, hasAccess) { continue } + groupKey := rule.RuleGroup + "-" + rule.NamespaceUID + newGroup, ok := groupMap[groupKey] + if !ok { + folder := namespaceMap[rule.NamespaceUID] + if folder == nil { + srv.log.Warn("query returned rules that belong to folder the user does not have access to. The rule will not be added to the response", "folder_uid", rule.NamespaceUID, "rule_uid", rule.UID) + continue + } + newGroup = &apimodels.RuleGroup{ + Name: rule.RuleGroup, + File: folder.Title, // file is what Prometheus uses for provisioning, we replace it with namespace. + } + groupMap[groupKey] = newGroup + ruleResponse.Data.RuleGroups = append(ruleResponse.Data.RuleGroups, newGroup) + } + alertingRule := apimodels.AlertingRule{ State: "inactive", Name: rule.Title, @@ -222,7 +212,6 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res if alertState.LastEvaluationTime.After(newRule.LastEvaluation) { newRule.LastEvaluation = alertState.LastEvaluationTime - newGroup.LastEvaluation = alertState.LastEvaluationTime } newRule.EvaluationTime = alertState.EvaluationDuration.Seconds() @@ -252,7 +241,10 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res alertingRule.Rule = newRule newGroup.Rules = append(newGroup.Rules, alertingRule) newGroup.Interval = float64(rule.IntervalSeconds) + newGroup.EvaluationTime = newRule.EvaluationTime + newGroup.LastEvaluation = newRule.LastEvaluation } + return response.JSON(http.StatusOK, ruleResponse) } diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 74ec3dc9809..5558ee35c30 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -4,21 +4,25 @@ import ( "context" "encoding/json" "fmt" + "math/rand" "net/http" "testing" "time" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" - - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/stretchr/testify/require" ) func Test_FormatValues(t *testing.T) { @@ -83,7 +87,7 @@ func TestRouteGetAlertStatuses(t *testing.T) { orgID := int64(1) t.Run("with no alerts", func(t *testing.T) { - _, _, api := setupAPI(t) + _, _, _, api := setupAPI(t) req, err := http.NewRequest("GET", "/api/v1/alerts", nil) require.NoError(t, err) c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &models.SignedInUser{OrgId: orgID}} @@ -101,7 +105,7 @@ func TestRouteGetAlertStatuses(t *testing.T) { }) t.Run("with two alerts", func(t *testing.T) { - _, fakeAIM, api := setupAPI(t) + _, fakeAIM, _, api := setupAPI(t) fakeAIM.GenerateAlertInstances(1, util.GenerateShortUID(), 2) req, err := http.NewRequest("GET", "/api/v1/alerts", nil) require.NoError(t, err) @@ -143,7 +147,7 @@ func TestRouteGetAlertStatuses(t *testing.T) { }) t.Run("with two firing alerts", func(t *testing.T) { - _, fakeAIM, api := setupAPI(t) + _, fakeAIM, _, api := setupAPI(t) fakeAIM.GenerateAlertInstances(1, util.GenerateShortUID(), 2, withAlertingState()) req, err := http.NewRequest("GET", "/api/v1/alerts", nil) require.NoError(t, err) @@ -185,7 +189,7 @@ func TestRouteGetAlertStatuses(t *testing.T) { }) t.Run("with the inclusion of internal labels", func(t *testing.T) { - _, fakeAIM, api := setupAPI(t) + _, fakeAIM, _, api := setupAPI(t) fakeAIM.GenerateAlertInstances(orgID, util.GenerateShortUID(), 2) req, err := http.NewRequest("GET", "/api/v1/alerts?includeInternalLabels=true", nil) require.NoError(t, err) @@ -251,10 +255,10 @@ func TestRouteGetRuleStatuses(t *testing.T) { req, err := http.NewRequest("GET", "/api/v1/rules", nil) require.NoError(t, err) - c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &models.SignedInUser{OrgId: orgID}} + c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &models.SignedInUser{OrgId: orgID}, IsSignedIn: true} t.Run("with no rules", func(t *testing.T) { - _, _, api := setupAPI(t) + _, _, _, api := setupAPI(t) r := api.RouteGetRuleStatuses(c) require.JSONEq(t, ` @@ -268,18 +272,19 @@ func TestRouteGetRuleStatuses(t *testing.T) { }) t.Run("with a rule that only has one query", func(t *testing.T) { - fakeStore, fakeAIM, api := setupAPI(t) + fakeStore, fakeAIM, _, api := setupAPI(t) generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery()) + folder := fakeStore.Folders[orgID][0] r := api.RouteGetRuleStatuses(c) require.Equal(t, http.StatusOK, r.Status()) - require.JSONEq(t, ` + require.JSONEq(t, fmt.Sprintf(` { "status": "success", "data": { "groups": [{ "name": "rule-group", - "file": "namespaceUID", + "file": "%s", "rules": [{ "state": "inactive", "name": "AlwaysFiring", @@ -306,30 +311,31 @@ func TestRouteGetRuleStatuses(t *testing.T) { }], "interval": 60, "lastEvaluation": "2022-03-10T14:01:00Z", - "evaluationTime": 0 + "evaluationTime": 60 }] } } -`, string(r.Body())) +`, folder.Title), string(r.Body())) }) t.Run("with the inclusion of internal Labels", func(t *testing.T) { - fakeStore, fakeAIM, api := setupAPI(t) + fakeStore, fakeAIM, _, api := setupAPI(t) generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery()) + folder := fakeStore.Folders[orgID][0] req, err := http.NewRequest("GET", "/api/v1/rules?includeInternalLabels=true", nil) require.NoError(t, err) - c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &models.SignedInUser{OrgId: orgID}} + c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &models.SignedInUser{OrgId: orgID}, IsSignedIn: true} r := api.RouteGetRuleStatuses(c) require.Equal(t, http.StatusOK, r.Status()) - require.JSONEq(t, ` + require.JSONEq(t, fmt.Sprintf(` { "status": "success", "data": { "groups": [{ "name": "rule-group", - "file": "namespaceUID", + "file": "%s", "rules": [{ "state": "inactive", "name": "AlwaysFiring", @@ -359,26 +365,27 @@ func TestRouteGetRuleStatuses(t *testing.T) { }], "interval": 60, "lastEvaluation": "2022-03-10T14:01:00Z", - "evaluationTime": 0 + "evaluationTime": 60 }] } } -`, string(r.Body())) +`, folder.Title), string(r.Body())) }) t.Run("with a rule that has multiple queries", func(t *testing.T) { - fakeStore, fakeAIM, api := setupAPI(t) + fakeStore, fakeAIM, _, api := setupAPI(t) generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withExpressionsMultiQuery()) + folder := fakeStore.Folders[orgID][0] r := api.RouteGetRuleStatuses(c) require.Equal(t, http.StatusOK, r.Status()) - require.JSONEq(t, ` + require.JSONEq(t, fmt.Sprintf(` { "status": "success", "data": { "groups": [{ "name": "rule-group", - "file": "namespaceUID", + "file": "%s", "rules": [{ "state": "inactive", "name": "AlwaysFiring", @@ -405,24 +412,65 @@ func TestRouteGetRuleStatuses(t *testing.T) { }], "interval": 60, "lastEvaluation": "2022-03-10T14:01:00Z", - "evaluationTime": 0 + "evaluationTime": 60 }] } } -`, string(r.Body())) +`, folder.Title), string(r.Body())) + }) + + t.Run("when fine-grained access is enabled", func(t *testing.T) { + t.Run("should return only rules if the user can query all data sources", func(t *testing.T) { + ruleStore := store.NewFakeRuleStore(t) + fakeAIM := NewFakeAlertInstanceManager(t) + + rules := ngmodels.GenerateAlertRules(rand.Intn(4)+2, ngmodels.AlertRuleGen(withOrgID(orgID))) + ruleStore.PutRule(context.Background(), rules...) + ruleStore.PutRule(context.Background(), ngmodels.GenerateAlertRules(rand.Intn(4)+2, ngmodels.AlertRuleGen(withOrgID(orgID)))...) + + acMock := acmock.New().WithPermissions(createPermissionsForRules(rules)) + + api := PrometheusSrv{ + log: log.NewNopLogger(), + manager: fakeAIM, + store: ruleStore, + ac: acMock, + } + + response := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, response.Status()) + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(response.Body(), result)) + for _, group := range result.Data.RuleGroups { + grouploop: + for _, rule := range group.Rules { + for i, expected := range rules { + if rule.Name == expected.Title && group.Name == expected.RuleGroup { + rules = append(rules[:i], rules[i+1:]...) + continue grouploop + } + } + assert.Failf(t, "rule %s in a group %s was not found in expected", rule.Name, group.Name) + } + } + assert.Emptyf(t, rules, "not all expected rules were returned") + }) }) } -func setupAPI(t *testing.T) (*store.FakeRuleStore, *fakeAlertInstanceManager, PrometheusSrv) { +func setupAPI(t *testing.T) (*store.FakeRuleStore, *fakeAlertInstanceManager, *acmock.Mock, PrometheusSrv) { fakeStore := store.NewFakeRuleStore(t) fakeAIM := NewFakeAlertInstanceManager(t) + acMock := acmock.New().WithDisabled() + api := PrometheusSrv{ log: log.NewNopLogger(), manager: fakeAIM, store: fakeStore, + ac: acMock, } - return fakeStore, fakeAIM, api + return fakeStore, fakeAIM, acMock, api } func generateRuleAndInstanceWithQuery(t *testing.T, orgID int64, fakeAIM *fakeAlertInstanceManager, fakeStore *store.FakeRuleStore, query func(r *ngmodels.AlertRule)) { diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go index 679e432107c..2b6e632c58e 100644 --- a/pkg/services/ngalert/api/api_provisioning.go +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -9,20 +9,29 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" - domain "github.com/grafana/grafana/pkg/services/ngalert/models" + alerting_models "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/web" ) type ProvisioningSrv struct { - log log.Logger - policies NotificationPolicyService + log log.Logger + policies NotificationPolicyService + contactPointService ContactPointService +} + +type ContactPointService interface { + GetContactPoints(ctx context.Context, orgID int64) ([]apimodels.EmbeddedContactPoint, error) + CreateContactPoint(ctx context.Context, orgID int64, contactPoint apimodels.EmbeddedContactPoint, p alerting_models.Provenance) (apimodels.EmbeddedContactPoint, error) + UpdateContactPoint(ctx context.Context, orgID int64, contactPoint apimodels.EmbeddedContactPoint, p alerting_models.Provenance) error + DeleteContactPoint(ctx context.Context, orgID int64, uid string) error } type NotificationPolicyService interface { GetPolicyTree(ctx context.Context, orgID int64) (provisioning.EmbeddedRoutingTree, error) - UpdatePolicyTree(ctx context.Context, orgID int64, tree apimodels.Route, p domain.Provenance) error + UpdatePolicyTree(ctx context.Context, orgID int64, tree apimodels.Route, p alerting_models.Provenance) error } func (srv *ProvisioningSrv) RouteGetPolicyTree(c *models.ReqContext) response.Response { @@ -39,7 +48,7 @@ func (srv *ProvisioningSrv) RouteGetPolicyTree(c *models.ReqContext) response.Re func (srv *ProvisioningSrv) RoutePostPolicyTree(c *models.ReqContext, tree apimodels.Route) response.Response { // TODO: lift validation out of definitions.Rotue.UnmarshalJSON and friends into a dedicated validator. - err := srv.policies.UpdatePolicyTree(c.Req.Context(), c.OrgId, tree, domain.ProvenanceApi) + err := srv.policies.UpdatePolicyTree(c.Req.Context(), c.OrgId, tree, alerting_models.ProvenanceAPI) if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { return ErrResp(http.StatusNotFound, err, "") } @@ -49,3 +58,37 @@ func (srv *ProvisioningSrv) RoutePostPolicyTree(c *models.ReqContext, tree apimo return response.JSON(http.StatusAccepted, util.DynMap{"message": "policies updated"}) } + +func (srv *ProvisioningSrv) RouteGetContactPoints(c *models.ReqContext) response.Response { + cps, err := srv.contactPointService.GetContactPoints(c.Req.Context(), c.OrgId) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "") + } + return response.JSON(http.StatusOK, cps) +} + +func (srv *ProvisioningSrv) RoutePostContactPoint(c *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response { + // TODO: provenance is hardcoded for now, change it later to make it more flexible + contactPoint, err := srv.contactPointService.CreateContactPoint(c.Req.Context(), c.OrgId, cp, alerting_models.ProvenanceAPI) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "") + } + return response.JSON(http.StatusAccepted, contactPoint) +} + +func (srv *ProvisioningSrv) RoutePutContactPoint(c *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response { + err := srv.contactPointService.UpdateContactPoint(c.Req.Context(), c.OrgId, cp, alerting_models.ProvenanceAPI) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "") + } + return response.JSON(http.StatusAccepted, util.DynMap{"message": "contactpoint updated"}) +} + +func (srv *ProvisioningSrv) RouteDeleteContactPoint(c *models.ReqContext) response.Response { + cpID := web.Params(c.Req)[":ID"] + err := srv.contactPointService.DeleteContactPoint(c.Req.Context(), c.OrgId, cpID) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "") + } + return response.JSON(http.StatusAccepted, util.DynMap{"message": "contactpoint deleted"}) +} diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 14852ac0f34..963597e7e9c 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -128,17 +128,25 @@ func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *models.ReqContext) response. return toNamespaceErrorResponse(err) } - q := ngmodels.ListNamespaceAlertRulesQuery{ + q := ngmodels.GetAlertRulesQuery{ OrgID: c.SignedInUser.OrgId, NamespaceUID: namespace.Uid, } - if err := srv.store.GetNamespaceAlertRules(c.Req.Context(), &q); err != nil { + if err := srv.store.GetAlertRules(c.Req.Context(), &q); err != nil { return ErrResp(http.StatusInternalServerError, err, "failed to update rule group") } result := apimodels.NamespaceConfigResponse{} ruleGroupConfigs := make(map[string]apimodels.GettableRuleGroupConfig) + + hasAccess := func(evaluator accesscontrol.Evaluator) bool { + return accesscontrol.HasAccess(srv.ac, c)(accesscontrol.ReqSignedIn, evaluator) + } + for _, r := range q.Result { + if !authorizeDatasourceAccessForRule(r, hasAccess) { + continue + } ruleGroupConfig, ok := ruleGroupConfigs[r.RuleGroup] if !ok { ruleGroupInterval := model.Duration(time.Duration(r.IntervalSeconds) * time.Second) @@ -181,7 +189,15 @@ func (srv RulerSrv) RouteGetRulegGroupConfig(c *models.ReqContext) response.Resp var ruleGroupInterval model.Duration ruleNodes := make([]apimodels.GettableExtendedRuleNode, 0, len(q.Result)) + + hasAccess := func(evaluator accesscontrol.Evaluator) bool { + return accesscontrol.HasAccess(srv.ac, c)(accesscontrol.ReqSignedIn, evaluator) + } + for _, r := range q.Result { + if !authorizeDatasourceAccessForRule(r, hasAccess) { + continue + } ruleGroupInterval = model.Duration(time.Duration(r.IntervalSeconds) * time.Second) ruleNodes = append(ruleNodes, toGettableExtendedRuleNode(*r, namespace.Id)) } @@ -234,7 +250,15 @@ func (srv RulerSrv) RouteGetRulesConfig(c *models.ReqContext) response.Response } configs := make(map[string]map[string]apimodels.GettableRuleGroupConfig) + + hasAccess := func(evaluator accesscontrol.Evaluator) bool { + return accesscontrol.HasAccess(srv.ac, c)(accesscontrol.ReqSignedIn, evaluator) + } + for _, r := range q.Result { + if !authorizeDatasourceAccessForRule(r, hasAccess) { + continue + } folder, ok := namespaceMap[r.NamespaceUID] if !ok { srv.log.Error("namespace not visible to the user", "user", c.SignedInUser.UserId, "namespace", r.NamespaceUID, "rule", r.UID) @@ -331,23 +355,25 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, namespace *mod logger.Debug("updating database with the authorized changes", "add", len(authorizedChanges.New), "update", len(authorizedChanges.New), "delete", len(authorizedChanges.Delete)) if len(authorizedChanges.Update) > 0 || len(authorizedChanges.New) > 0 { - upsert := make([]store.UpsertRule, 0, len(authorizedChanges.Update)+len(authorizedChanges.New)) + updates := make([]store.UpdateRule, 0, len(authorizedChanges.Update)) + inserts := make([]ngmodels.AlertRule, 0, len(authorizedChanges.New)) for _, update := range authorizedChanges.Update { logger.Debug("updating rule", "rule_uid", update.New.UID, "diff", update.Diff.String()) - upsert = append(upsert, store.UpsertRule{ + updates = append(updates, store.UpdateRule{ Existing: update.Existing, New: *update.New, }) } for _, rule := range authorizedChanges.New { - upsert = append(upsert, store.UpsertRule{ - Existing: nil, - New: *rule, - }) + inserts = append(inserts, *rule) } - err = srv.store.UpsertAlertRules(tranCtx, upsert) + err = srv.store.InsertAlertRules(tranCtx, inserts) if err != nil { - return fmt.Errorf("failed to add or update rules: %w", err) + return fmt.Errorf("failed to add rules: %w", err) + } + err = srv.store.UpdateAlertRules(tranCtx, updates) + if err != nil { + return fmt.Errorf("failed to update rules: %w", err) } } diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go index a23afee1774..9a40a93dfbd 100644 --- a/pkg/services/ngalert/api/api_ruler_test.go +++ b/pkg/services/ngalert/api/api_ruler_test.go @@ -2,12 +2,14 @@ package api import ( "context" + "encoding/json" "errors" "math/rand" "net/http" "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -16,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" acMock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/datasources" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -264,19 +267,6 @@ func TestCalculateChanges(t *testing.T) { } func TestRouteDeleteAlertRules(t *testing.T) { - createService := func(ac *acMock.Mock, store *store.FakeRuleStore, scheduler schedule.ScheduleService) *RulerSrv { - return &RulerSrv{ - xactManager: store, - store: store, - DatasourceCache: nil, - QuotaService: nil, - scheduleService: scheduler, - log: log.New("test"), - cfg: nil, - ac: ac, - } - } - getRecordedCommand := func(ruleStore *store.FakeRuleStore) []store.GenericRecordedQuery { results := ruleStore.GetRecordedCommands(func(cmd interface{}) (interface{}, bool) { c, ok := cmd.(store.GenericRecordedQuery) @@ -421,15 +411,7 @@ func TestRouteDeleteAlertRules(t *testing.T) { scheduler := &schedule.FakeScheduleService{} scheduler.On("DeleteAlertRule", mock.Anything) - var permissions []*accesscontrol.Permission - for _, rule := range rulesInFolder { - for _, query := range rule.Data { - permissions = append(permissions, &accesscontrol.Permission{ - Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID), - }) - } - } - ac := acMock.New().WithPermissions(permissions) + ac := acMock.New().WithPermissions(createPermissionsForRules(rulesInFolder)) request := createRequestContext(orgID, "None", map[string]string{ ":Namespace": folder.Title, }) @@ -454,15 +436,7 @@ func TestRouteDeleteAlertRules(t *testing.T) { scheduler := &schedule.FakeScheduleService{} scheduler.On("DeleteAlertRule", mock.Anything) - var permissions []*accesscontrol.Permission - for _, rule := range authorizedRulesInFolder { - for _, query := range rule.Data { - permissions = append(permissions, &accesscontrol.Permission{ - Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID), - }) - } - } - ac := acMock.New().WithPermissions(permissions) + ac := acMock.New().WithPermissions(createPermissionsForRules(authorizedRulesInFolder)) request := createRequestContext(orgID, "None", map[string]string{ ":Namespace": folder.Title, }) @@ -489,15 +463,7 @@ func TestRouteDeleteAlertRules(t *testing.T) { scheduler := &schedule.FakeScheduleService{} scheduler.On("DeleteAlertRule", mock.Anything) - var permissions []*accesscontrol.Permission - for _, rule := range authorizedRulesInGroup { - for _, query := range rule.Data { - permissions = append(permissions, &accesscontrol.Permission{ - Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID), - }) - } - } - ac := acMock.New().WithPermissions(permissions) + ac := acMock.New().WithPermissions(createPermissionsForRules(authorizedRulesInGroup)) request := createRequestContext(orgID, "None", map[string]string{ ":Namespace": folder.Title, ":Groupname": groupName, @@ -510,11 +476,101 @@ func TestRouteDeleteAlertRules(t *testing.T) { }) } +func TestRouteGetNamespaceRulesConfig(t *testing.T) { + t.Run("fine-grained access is enabled", func(t *testing.T) { + t.Run("should return rules for which user has access to data source", func(t *testing.T) { + orgID := rand.Int63() + folder := randFolder() + ruleStore := store.NewFakeRuleStore(t) + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + expectedRules := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) + ruleStore.PutRule(context.Background(), expectedRules...) + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...) + ac := acMock.New().WithPermissions(createPermissionsForRules(expectedRules)) + + response := createService(ac, ruleStore, nil).RouteGetNamespaceRulesConfig(createRequestContext(orgID, "", map[string]string{ + ":Namespace": folder.Title, + })) + + require.Equal(t, http.StatusAccepted, response.Status()) + result := &apimodels.NamespaceConfigResponse{} + require.NoError(t, json.Unmarshal(response.Body(), result)) + require.NotNil(t, result) + for namespace, groups := range *result { + require.Equal(t, folder.Title, namespace) + for _, group := range groups { + grouploop: + for _, actualRule := range group.Rules { + for i, expected := range expectedRules { + if actualRule.GrafanaManagedAlert.UID == expected.UID { + expectedRules = append(expectedRules[:i], expectedRules[i+1:]...) + continue grouploop + } + } + assert.Failf(t, "rule in a group was not found in expected", "rule %s group %s", actualRule.GrafanaManagedAlert.Title, group.Name) + } + } + } + assert.Emptyf(t, expectedRules, "not all expected rules were returned") + }) + }) + t.Run("fine-grained access is disabled", func(t *testing.T) { + t.Run("should return all rules from folder", func(t *testing.T) { + orgID := rand.Int63() + folder := randFolder() + ruleStore := store.NewFakeRuleStore(t) + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + expectedRules := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) + ruleStore.PutRule(context.Background(), expectedRules...) + ac := acMock.New().WithDisabled() + + response := createService(ac, ruleStore, nil).RouteGetNamespaceRulesConfig(createRequestContext(orgID, "", map[string]string{ + ":Namespace": folder.Title, + })) + + require.Equal(t, http.StatusAccepted, response.Status()) + result := &apimodels.NamespaceConfigResponse{} + require.NoError(t, json.Unmarshal(response.Body(), result)) + require.NotNil(t, result) + for namespace, groups := range *result { + require.Equal(t, folder.Title, namespace) + for _, group := range groups { + grouploop: + for _, actualRule := range group.Rules { + for i, expected := range expectedRules { + if actualRule.GrafanaManagedAlert.UID == expected.UID { + expectedRules = append(expectedRules[:i], expectedRules[i+1:]...) + continue grouploop + } + } + assert.Failf(t, "rule in a group was not found in expected", "rule %s group %s", actualRule.GrafanaManagedAlert.Title, group.Name) + } + } + } + assert.Emptyf(t, expectedRules, "not all expected rules were returned") + }) + }) +} + +func createService(ac *acMock.Mock, store *store.FakeRuleStore, scheduler schedule.ScheduleService) *RulerSrv { + return &RulerSrv{ + xactManager: store, + store: store, + DatasourceCache: nil, + QuotaService: nil, + scheduleService: scheduler, + log: log.New("test"), + cfg: nil, + ac: ac, + } +} + func createRequestContext(orgID int64, role models2.RoleType, params map[string]string) *models2.ReqContext { ctx := web.Context{Req: &http.Request{}} ctx.Req = web.SetURLParams(ctx.Req, params) return &models2.ReqContext{ + IsSignedIn: true, SignedInUser: &models2.SignedInUser{ OrgRole: role, OrgId: orgID, @@ -523,6 +579,18 @@ func createRequestContext(orgID int64, role models2.RoleType, params map[string] } } +func createPermissionsForRules(rules []*models.AlertRule) []*accesscontrol.Permission { + var permissions []*accesscontrol.Permission + for _, rule := range rules { + for _, query := range rule.Data { + permissions = append(permissions, &accesscontrol.Permission{ + Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID), + }) + } + } + return permissions +} + func withOrgID(orgId int64) func(rule *models.AlertRule) { return func(rule *models.AlertRule) { rule.OrgID = orgId diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 317c7b21037..6394ec50ac3 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -181,10 +181,14 @@ func (api *API) authorize(method, path string) web.Handler { return middleware.ReqOrgAdmin // Grafana-only Provisioning Read Paths - case http.MethodGet + "/api/provisioning/policies": + case http.MethodGet + "/api/provisioning/policies", + http.MethodGet + "/api/provisioning/contact-points": return middleware.ReqSignedIn - case http.MethodPost + "/api/provisioning/policies": + case http.MethodPost + "/api/provisioning/policies", + http.MethodPost + "/api/provisioning/contact-points", + http.MethodPut + "/api/provisioning/contact-points", + http.MethodDelete + "/api/provisioning/contact-points/{ID}": return middleware.ReqEditorRole } diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go index b39597a9fb8..0f6579760e6 100644 --- a/pkg/services/ngalert/api/authorization_test.go +++ b/pkg/services/ngalert/api/authorization_test.go @@ -47,7 +47,7 @@ func TestAuthorize(t *testing.T) { } paths[p] = methods } - require.Len(t, paths, 30) + require.Len(t, paths, 32) ac := acmock.New() api := &API{AccessControl: ac} diff --git a/pkg/services/ngalert/api/forked_provisioning.go b/pkg/services/ngalert/api/forked_provisioning.go index 74213e3c783..a808d9eca3e 100644 --- a/pkg/services/ngalert/api/forked_provisioning.go +++ b/pkg/services/ngalert/api/forked_provisioning.go @@ -26,3 +26,19 @@ func (f *ForkedProvisioningApi) forkRouteGetPolicyTree(ctx *models.ReqContext) r func (f *ForkedProvisioningApi) forkRoutePostPolicyTree(ctx *models.ReqContext, route apimodels.Route) response.Response { return f.svc.RoutePostPolicyTree(ctx, route) } + +func (f *ForkedProvisioningApi) forkRouteGetContactpoints(ctx *models.ReqContext) response.Response { + return f.svc.RouteGetContactPoints(ctx) +} + +func (f *ForkedProvisioningApi) forkRoutePostContactpoints(ctx *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response { + return f.svc.RoutePostContactPoint(ctx, cp) +} + +func (f *ForkedProvisioningApi) forkRoutePutContactpoints(ctx *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response { + return f.svc.RoutePutContactPoint(ctx, cp) +} + +func (f *ForkedProvisioningApi) forkRouteDeleteContactpoints(ctx *models.ReqContext) response.Response { + return f.svc.RouteDeleteContactPoint(ctx) +} diff --git a/pkg/services/ngalert/api/generated_base_api_provisioning.go b/pkg/services/ngalert/api/generated_base_api_provisioning.go index 2d5e72ae571..f310cdaddd5 100644 --- a/pkg/services/ngalert/api/generated_base_api_provisioning.go +++ b/pkg/services/ngalert/api/generated_base_api_provisioning.go @@ -20,14 +20,34 @@ import ( ) type ProvisioningApiForkingService interface { + RouteDeleteContactpoints(*models.ReqContext) response.Response + RouteGetContactpoints(*models.ReqContext) response.Response RouteGetPolicyTree(*models.ReqContext) response.Response + RoutePostContactpoints(*models.ReqContext) response.Response RoutePostPolicyTree(*models.ReqContext) response.Response + RoutePutContactpoints(*models.ReqContext) response.Response +} + +func (f *ForkedProvisioningApi) RouteDeleteContactpoints(ctx *models.ReqContext) response.Response { + return f.forkRouteDeleteContactpoints(ctx) +} + +func (f *ForkedProvisioningApi) RouteGetContactpoints(ctx *models.ReqContext) response.Response { + return f.forkRouteGetContactpoints(ctx) } func (f *ForkedProvisioningApi) RouteGetPolicyTree(ctx *models.ReqContext) response.Response { return f.forkRouteGetPolicyTree(ctx) } +func (f *ForkedProvisioningApi) RoutePostContactpoints(ctx *models.ReqContext) response.Response { + conf := apimodels.EmbeddedContactPoint{} + if err := web.Bind(ctx.Req, &conf); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + return f.forkRoutePostContactpoints(ctx, conf) +} + func (f *ForkedProvisioningApi) RoutePostPolicyTree(ctx *models.ReqContext) response.Response { conf := apimodels.Route{} if err := web.Bind(ctx.Req, &conf); err != nil { @@ -36,8 +56,36 @@ func (f *ForkedProvisioningApi) RoutePostPolicyTree(ctx *models.ReqContext) resp return f.forkRoutePostPolicyTree(ctx, conf) } +func (f *ForkedProvisioningApi) RoutePutContactpoints(ctx *models.ReqContext) response.Response { + conf := apimodels.EmbeddedContactPoint{} + if err := web.Bind(ctx.Req, &conf); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + return f.forkRoutePutContactpoints(ctx, conf) +} + func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingService, m *metrics.API) { api.RouteRegister.Group("", func(group routing.RouteRegister) { + group.Delete( + toMacaronPath("/api/provisioning/contact-points/{ID}"), + api.authorize(http.MethodDelete, "/api/provisioning/contact-points/{ID}"), + metrics.Instrument( + http.MethodDelete, + "/api/provisioning/contact-points/{ID}", + srv.RouteDeleteContactpoints, + m, + ), + ) + group.Get( + toMacaronPath("/api/provisioning/contact-points"), + api.authorize(http.MethodGet, "/api/provisioning/contact-points"), + metrics.Instrument( + http.MethodGet, + "/api/provisioning/contact-points", + srv.RouteGetContactpoints, + m, + ), + ) group.Get( toMacaronPath("/api/provisioning/policies"), api.authorize(http.MethodGet, "/api/provisioning/policies"), @@ -48,6 +96,16 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi m, ), ) + group.Post( + toMacaronPath("/api/provisioning/contact-points"), + api.authorize(http.MethodPost, "/api/provisioning/contact-points"), + metrics.Instrument( + http.MethodPost, + "/api/provisioning/contact-points", + srv.RoutePostContactpoints, + m, + ), + ) group.Post( toMacaronPath("/api/provisioning/policies"), api.authorize(http.MethodPost, "/api/provisioning/policies"), @@ -58,5 +116,15 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi m, ), ) + group.Put( + toMacaronPath("/api/provisioning/contact-points"), + api.authorize(http.MethodPut, "/api/provisioning/contact-points"), + metrics.Instrument( + http.MethodPut, + "/api/provisioning/contact-points", + srv.RoutePutContactpoints, + m, + ), + ) }, middleware.ReqSignedIn) } diff --git a/pkg/services/ngalert/api/testing.go b/pkg/services/ngalert/api/testing.go index 26344d66b64..68504135781 100644 --- a/pkg/services/ngalert/api/testing.go +++ b/pkg/services/ngalert/api/testing.go @@ -1,43 +1,17 @@ package api import ( - "context" "fmt" "sync" "testing" "time" "github.com/grafana/grafana/pkg/services/ngalert/eval" - "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" - "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana-plugin-sdk-go/data" ) -type FakeAlertingStore struct { - orgsWithConfig map[int64]bool -} - -func newFakeAlertingStore(t *testing.T) FakeAlertingStore { - t.Helper() - - return FakeAlertingStore{ - orgsWithConfig: map[int64]bool{}, - } -} - -func (f FakeAlertingStore) Setup(orgID int64) { - f.orgsWithConfig[orgID] = true -} - -func (f FakeAlertingStore) GetLatestAlertmanagerConfiguration(_ context.Context, query *models.GetLatestAlertmanagerConfigurationQuery) error { - if _, ok := f.orgsWithConfig[query.OrgID]; ok { - return nil - } - return store.ErrNoAlertmanagerConfiguration -} - type fakeAlertInstanceManager struct { mtx sync.Mutex // orgID -> RuleID -> States diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index 94eb137d7b7..ad75f5215b3 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -18,6 +18,7 @@ import ( "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/util" ) @@ -712,6 +713,8 @@ type Route struct { GroupWait *model.Duration `yaml:"group_wait,omitempty" json:"group_wait,omitempty"` GroupInterval *model.Duration `yaml:"group_interval,omitempty" json:"group_interval,omitempty"` RepeatInterval *model.Duration `yaml:"repeat_interval,omitempty" json:"repeat_interval,omitempty"` + + Provenance models.Provenance `yaml:"provenance,omitempty" json:"provenance,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface for Route. This is a copy of alertmanager's upstream except it removes validation on the label key. diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go new file mode 100644 index 00000000000..3d86af76928 --- /dev/null +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go @@ -0,0 +1,157 @@ +package definitions + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" +) + +// swagger:route GET /api/provisioning/contact-points provisioning RouteGetContactpoints +// +// Get all the contact points. +// +// Responses: +// 200: Route +// 400: ValidationError + +// swagger:route POST /api/provisioning/contact-points provisioning RoutePostContactpoints +// +// Create a contact point. +// +// Consumes: +// - application/json +// +// Responses: +// 202: Accepted +// 400: ValidationError + +// swagger:route PUT /api/provisioning/contact-points provisioning RoutePutContactpoints +// +// Update an existing contact point. +// +// Consumes: +// - application/json +// +// Responses: +// 202: Accepted +// 400: ValidationError + +// swagger:route DELETE /api/provisioning/contact-points/{ID} provisioning RouteDeleteContactpoints +// +// Delete a contact point. +// +// Consumes: +// - application/json +// +// Responses: +// 202: Accepted +// 400: ValidationError + +// swagger:parameters RoutePostContactpoints RoutePutContactpoints +type ContactPointPayload struct { + // in:body + Body EmbeddedContactPoint +} + +// EmbeddedContactPoint is the contact point type that is used +// by grafanas embedded alertmanager implementation. +type EmbeddedContactPoint struct { + // UID is the unique identifier of the contact point. This will be + // automatically set be the Grafana. + UID string `json:"uid"` + // Name is used as grouping key in the UI. Contact points with the + // same name will be grouped in the UI. + Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required"` + Settings *simplejson.Json `json:"settings" binding:"required"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Provenance string `json:"provanance"` +} + +const RedactedValue = "[REDACTED]" + +func (e *EmbeddedContactPoint) Valid(decryptFunc channels.GetDecryptedValueFn) error { + if e.Type == "" { + return fmt.Errorf("type should not be an empty string") + } + if e.Settings == nil { + return fmt.Errorf("settings should not be empty") + } + factory, exists := channels.Factory(e.Type) + if !exists { + return fmt.Errorf("unknown type '%s'", e.Type) + } + cfg, _ := channels.NewFactoryConfig(&channels.NotificationChannelConfig{ + Settings: e.Settings, + Type: e.Type, + }, nil, decryptFunc, nil) + if _, err := factory(cfg); err != nil { + return err + } + return nil +} + +func (e *EmbeddedContactPoint) SecretKeys() ([]string, error) { + switch e.Type { + case "alertmanager": + return []string{"basicAuthPassword"}, nil + case "dingding": + return []string{}, nil + case "discord": + return []string{}, nil + case "email": + return []string{}, nil + case "googlechat": + return []string{}, nil + case "kafka": + return []string{}, nil + case "line": + return []string{"token"}, nil + case "opsgenie": + return []string{"apiKey"}, nil + case "pagerduty": + return []string{"integrationKey"}, nil + case "pushover": + return []string{"userKey", "apiToken"}, nil + case "sensugo": + return []string{"apiKey"}, nil + case "slack": + return []string{"url", "token"}, nil + case "teams": + return []string{}, nil + case "telegram": + return []string{"bottoken"}, nil + case "threema": + return []string{"api_secret"}, nil + case "victorops": + return []string{}, nil + case "webhook": + return []string{}, nil + case "wecom": + return []string{"url"}, nil + } + return nil, fmt.Errorf("no secrets configured for type '%s'", e.Type) +} + +func (e *EmbeddedContactPoint) ExtractSecrets() (map[string]string, error) { + secrets := map[string]string{} + secretKeys, err := e.SecretKeys() + if err != nil { + return nil, err + } + for _, secretKey := range secretKeys { + secretValue := e.Settings.Get(secretKey).MustString() + e.Settings.Del(secretKey) + secrets[secretKey] = secretValue + } + return secrets, nil +} + +func (e *EmbeddedContactPoint) ResourceID() string { + return e.UID +} + +func (e *EmbeddedContactPoint) ResourceType() string { + return "contactPoint" +} diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_policies.go similarity index 100% rename from pkg/services/ngalert/api/tooling/definitions/provisioning.go rename to pkg/services/ngalert/api/tooling/definitions/provisioning_policies.go diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index dd87745df0d..f9373171bf5 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -558,6 +558,36 @@ "type": "object", "x-go-package": "github.com/prometheus/alertmanager/config" }, + "EmbeddedContactPoint": { + "description": "EmbeddedContactPoint is the contact point type that is used\nby grafanas embedded alertmanager implementation.", + "properties": { + "disableResolveMessage": { + "type": "boolean", + "x-go-name": "DisableResolveMessage" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "provanance": { + "type": "string", + "x-go-name": "Provenance" + }, + "settings": { + "$ref": "#/definitions/Json" + }, + "type": { + "type": "string", + "x-go-name": "Type" + }, + "uid": { + "type": "string", + "x-go-name": "UID" + } + }, + "type": "object", + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, "ErrorType": { "title": "ErrorType models the different API error types.", "type": "string", @@ -3034,6 +3064,7 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "alertGroup": { + "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -3055,17 +3086,14 @@ "labels", "receiver" ], - "type": "object", - "x-go-name": "AlertGroup", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" + "type": "object" }, "alertGroups": { + "description": "AlertGroups alert groups", "items": { "$ref": "#/definitions/alertGroup" }, - "type": "array", - "x-go-name": "AlertGroups", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" + "type": "array" }, "alertStatus": { "description": "AlertStatus alert status", @@ -3255,6 +3283,7 @@ "type": "array" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -3306,17 +3335,14 @@ "status", "updatedAt" ], - "type": "object", - "x-go-name": "GettableSilence", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" + "type": "object" }, "gettableSilences": { + "description": "GettableSilences gettable silences", "items": { "$ref": "#/definitions/gettableSilence" }, - "type": "array", - "x-go-name": "GettableSilences", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" + "type": "array" }, "labelSet": { "additionalProperties": { @@ -3445,7 +3471,6 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "postableSilence": { - "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -3485,7 +3510,9 @@ "matchers", "startsAt" ], - "type": "object" + "type": "object", + "x-go-name": "PostableSilence", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "receiver": { "properties": { @@ -4733,6 +4760,112 @@ ] } }, + "/api/provisioning/contact-points": { + "get": { + "operationId": "RouteGetContactpoints", + "responses": { + "200": { + "description": "Route", + "schema": { + "$ref": "#/definitions/Route" + } + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + }, + "summary": "Get all the contact points.", + "tags": [ + "provisioning" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "operationId": "RoutePostContactpoints", + "parameters": [ + { + "in": "body", + "name": "Body", + "schema": { + "$ref": "#/definitions/EmbeddedContactPoint" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + }, + "summary": "Create a contact point.", + "tags": [ + "provisioning" + ] + }, + "put": { + "consumes": [ + "application/json" + ], + "operationId": "RoutePutContactpoints", + "parameters": [ + { + "in": "body", + "name": "Body", + "schema": { + "$ref": "#/definitions/EmbeddedContactPoint" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + }, + "summary": "Update an existing contact point.", + "tags": [ + "provisioning" + ] + } + }, + "/api/provisioning/contact-points/{ID}": { + "delete": { + "consumes": [ + "application/json" + ], + "operationId": "RouteDeleteContactpoints", + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + }, + "summary": "Delete a contact point.", + "tags": [ + "provisioning" + ] + } + }, "/api/provisioning/policies": { "get": { "operationId": "RouteGetPolicyTree", diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index fff24ed4f08..188a5c4b885 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1136,6 +1136,112 @@ } } }, + "/api/provisioning/contact-points": { + "get": { + "tags": [ + "provisioning" + ], + "summary": "Get all the contact points.", + "operationId": "RouteGetContactpoints", + "responses": { + "200": { + "description": "Route", + "schema": { + "$ref": "#/definitions/Route" + } + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "tags": [ + "provisioning" + ], + "summary": "Update an existing contact point.", + "operationId": "RoutePutContactpoints", + "parameters": [ + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/EmbeddedContactPoint" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "tags": [ + "provisioning" + ], + "summary": "Create a contact point.", + "operationId": "RoutePostContactpoints", + "parameters": [ + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/EmbeddedContactPoint" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + } + }, + "/api/provisioning/contact-points/{ID}": { + "delete": { + "consumes": [ + "application/json" + ], + "tags": [ + "provisioning" + ], + "summary": "Delete a contact point.", + "operationId": "RouteDeleteContactpoints", + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + } + }, "/api/provisioning/policies": { "get": { "tags": [ @@ -2369,6 +2475,36 @@ }, "x-go-package": "github.com/prometheus/alertmanager/config" }, + "EmbeddedContactPoint": { + "description": "EmbeddedContactPoint is the contact point type that is used\nby grafanas embedded alertmanager implementation.", + "type": "object", + "properties": { + "disableResolveMessage": { + "type": "boolean", + "x-go-name": "DisableResolveMessage" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "provanance": { + "type": "string", + "x-go-name": "Provenance" + }, + "settings": { + "$ref": "#/definitions/Json" + }, + "type": { + "type": "string", + "x-go-name": "Type" + }, + "uid": { + "type": "string", + "x-go-name": "UID" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, "ErrorType": { "type": "string", "title": "ErrorType models the different API error types.", @@ -4848,6 +4984,7 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "alertGroup": { + "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -4870,17 +5007,14 @@ "$ref": "#/definitions/receiver" } }, - "x-go-name": "AlertGroup", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/alertGroup" }, "alertGroups": { + "description": "AlertGroups alert groups", "type": "array", "items": { "$ref": "#/definitions/alertGroup" }, - "x-go-name": "AlertGroups", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/alertGroups" }, "alertStatus": { @@ -5073,6 +5207,7 @@ "$ref": "#/definitions/gettableAlerts" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -5125,17 +5260,14 @@ "x-go-name": "UpdatedAt" } }, - "x-go-name": "GettableSilence", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/gettableSilence" }, "gettableSilences": { + "description": "GettableSilences gettable silences", "type": "array", "items": { "$ref": "#/definitions/gettableSilence" }, - "x-go-name": "GettableSilences", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/gettableSilences" }, "labelSet": { @@ -5265,7 +5397,6 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "postableSilence": { - "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", @@ -5306,6 +5437,8 @@ "x-go-name": "StartsAt" } }, + "x-go-name": "PostableSilence", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/postableSilence" }, "receiver": { diff --git a/pkg/services/ngalert/models/provisioning.go b/pkg/services/ngalert/models/provisioning.go index b3ad9612ef5..c3655edfc5b 100644 --- a/pkg/services/ngalert/models/provisioning.go +++ b/pkg/services/ngalert/models/provisioning.go @@ -3,8 +3,10 @@ package models type Provenance string const ( + // ProvenanceNone reflects the provenance when no provenance is stored + // for the requested object in the database. ProvenanceNone Provenance = "" - ProvenanceApi Provenance = "api" + ProvenanceAPI Provenance = "api" ProvenanceFile Provenance = "file" ) diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index fe070adf47a..49158bc52b5 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -99,7 +99,7 @@ func (ng *AlertNG) init() error { decryptFn := ng.SecretsService.GetDecryptedValue multiOrgMetrics := ng.Metrics.GetMultiOrgAlertmanagerMetrics() - ng.MultiOrgAlertmanager, err = notifier.NewMultiOrgAlertmanager(ng.Cfg, store, store, ng.KVStore, decryptFn, multiOrgMetrics, ng.NotificationService, log.New("ngalert.multiorg.alertmanager")) + ng.MultiOrgAlertmanager, err = notifier.NewMultiOrgAlertmanager(ng.Cfg, store, store, ng.KVStore, decryptFn, multiOrgMetrics, ng.NotificationService, log.New("ngalert.multiorg.alertmanager"), ng.SecretsService) if err != nil { return err } @@ -139,6 +139,7 @@ func (ng *AlertNG) init() error { // Provisioning policyService := provisioning.NewNotificationPolicyService(store, store, store, ng.Log) + contactPointService := provisioning.NewContactPointService(store, ng.SecretsService, store, store, ng.Log) api := api.API{ Cfg: ng.Cfg, @@ -158,6 +159,7 @@ func (ng *AlertNG) init() error { StateManager: ng.stateManager, AccessControl: ng.accesscontrol, Policies: policyService, + ContactPointService: contactPointService, } api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics()) diff --git a/pkg/services/ngalert/notifier/alertmanager_config.go b/pkg/services/ngalert/notifier/alertmanager_config.go new file mode 100644 index 00000000000..7efd6561589 --- /dev/null +++ b/pkg/services/ngalert/notifier/alertmanager_config.go @@ -0,0 +1,119 @@ +package notifier + +import ( + "context" + "errors" + "fmt" + + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/store" +) + +type UnknownReceiverError struct { + UID string +} + +func (e UnknownReceiverError) Error() string { + return fmt.Sprintf("unknown receiver: %s", e.UID) +} + +type AlertmanagerConfigRejectedError struct { + Inner error +} + +func (e AlertmanagerConfigRejectedError) Error() string { + return fmt.Sprintf("failed to save and apply Alertmanager configuration: %s", e.Inner.Error()) +} + +type configurationStore interface { + GetLatestAlertmanagerConfiguration(ctx context.Context, query *models.GetLatestAlertmanagerConfigurationQuery) error +} + +func (moa *MultiOrgAlertmanager) GetAlertmanagerConfiguration(ctx context.Context, org int64) (definitions.GettableUserConfig, error) { + query := models.GetLatestAlertmanagerConfigurationQuery{OrgID: org} + err := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, &query) + if err != nil { + return definitions.GettableUserConfig{}, fmt.Errorf("failed to get latest configuration: %w", err) + } + cfg, err := Load([]byte(query.Result.AlertmanagerConfiguration)) + if err != nil { + return definitions.GettableUserConfig{}, fmt.Errorf("failed to unmarshal alertmanager configuration: %w", err) + } + + result := definitions.GettableUserConfig{ + TemplateFiles: cfg.TemplateFiles, + AlertmanagerConfig: definitions.GettableApiAlertingConfig{ + Config: cfg.AlertmanagerConfig.Config, + }, + } + + for _, recv := range cfg.AlertmanagerConfig.Receivers { + receivers := make([]*definitions.GettableGrafanaReceiver, 0, len(recv.PostableGrafanaReceivers.GrafanaManagedReceivers)) + for _, pr := range recv.PostableGrafanaReceivers.GrafanaManagedReceivers { + secureFields := make(map[string]bool, len(pr.SecureSettings)) + for k := range pr.SecureSettings { + decryptedValue, err := moa.Crypto.getDecryptedSecret(pr, k) + if err != nil { + return definitions.GettableUserConfig{}, fmt.Errorf("failed to decrypt stored secure setting: %w", err) + } + if decryptedValue == "" { + continue + } + secureFields[k] = true + } + gr := definitions.GettableGrafanaReceiver{ + UID: pr.UID, + Name: pr.Name, + Type: pr.Type, + DisableResolveMessage: pr.DisableResolveMessage, + Settings: pr.Settings, + SecureFields: secureFields, + } + receivers = append(receivers, &gr) + } + gettableApiReceiver := definitions.GettableApiReceiver{ + GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{ + GrafanaManagedReceivers: receivers, + }, + } + gettableApiReceiver.Name = recv.Name + result.AlertmanagerConfig.Receivers = append(result.AlertmanagerConfig.Receivers, &gettableApiReceiver) + } + + return result, nil +} + +func (moa *MultiOrgAlertmanager) ApplyAlertmanagerConfiguration(ctx context.Context, org int64, config definitions.PostableUserConfig) error { + // Get the last known working configuration + query := models.GetLatestAlertmanagerConfigurationQuery{OrgID: org} + if err := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, &query); err != nil { + // If we don't have a configuration there's nothing for us to know and we should just continue saving the new one + if !errors.Is(err, store.ErrNoAlertmanagerConfiguration) { + return fmt.Errorf("failed to get latest configuration %w", err) + } + } + + if err := moa.Crypto.LoadSecureSettings(ctx, org, config.AlertmanagerConfig.Receivers); err != nil { + return err + } + + if err := config.ProcessConfig(moa.Crypto.Encrypt); err != nil { + return fmt.Errorf("failed to post process Alertmanager configuration: %w", err) + } + + am, err := moa.AlertmanagerFor(org) + if err != nil { + // It's okay if the alertmanager isn't ready yet, we're changing its config anyway. + if !errors.Is(err, ErrAlertmanagerNotReady) { + return err + } + } + + if err := am.SaveAndApplyConfig(ctx, &config); err != nil { + moa.logger.Error("unable to save and apply alertmanager configuration", "err", err) + return AlertmanagerConfigRejectedError{err} + } + + return nil +} diff --git a/pkg/services/ngalert/notifier/alertmanager_test.go b/pkg/services/ngalert/notifier/alertmanager_test.go index 94ec8a365fb..5e2f10e7420 100644 --- a/pkg/services/ngalert/notifier/alertmanager_test.go +++ b/pkg/services/ngalert/notifier/alertmanager_test.go @@ -386,10 +386,10 @@ func TestSilenceCleanup(t *testing.T) { makeSilence("", "tests", dt(now.Add(5*time.Hour)), dt(now.Add(6*time.Hour)), matchers), // Active now makeSilence("", "tests", dt(now.Add(-5*time.Hour)), dt(now.Add(6*time.Hour)), matchers), - // Expiring soon - makeSilence("", "tests", dt(now.Add(-5*time.Hour)), dt(now.Add(2*time.Second)), matchers), + // Expiring soon. + makeSilence("", "tests", dt(now.Add(-5*time.Hour)), dt(now.Add(5*time.Second)), matchers), // Expiring *very* soon - makeSilence("", "tests", dt(now.Add(-5*time.Hour)), dt(now.Add(20*time.Millisecond)), matchers), + makeSilence("", "tests", dt(now.Add(-5*time.Hour)), dt(now.Add(2*time.Second)), matchers), } for _, s := range silences { @@ -403,12 +403,12 @@ func TestSilenceCleanup(t *testing.T) { found, err := am.ListSilences(nil) require.NoError(err) return len(found) == 3 - }, 1500*time.Millisecond, 150*time.Millisecond) + }, 3*time.Second, 150*time.Millisecond) // Wait again for another silence to expire. require.Eventually(func() bool { found, err := am.ListSilences(nil) require.NoError(err) return len(found) == 2 - }, 2*time.Second, 150*time.Millisecond) + }, 6*time.Second, 150*time.Millisecond) } diff --git a/pkg/services/ngalert/notifier/crypto.go b/pkg/services/ngalert/notifier/crypto.go new file mode 100644 index 00000000000..0d5e06a55f1 --- /dev/null +++ b/pkg/services/ngalert/notifier/crypto.go @@ -0,0 +1,118 @@ +package notifier + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/secrets" +) + +// Crypto allows decryption of Alertmanager Configuration and encryption of arbitrary payloads. +type Crypto interface { + LoadSecureSettings(ctx context.Context, orgId int64, receivers []*definitions.PostableApiReceiver) error + Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error) + + getDecryptedSecret(r *definitions.PostableGrafanaReceiver, key string) (string, error) +} + +// alertmanagerCrypto implements decryption of Alertmanager configuration and encryption of arbitrary payloads based on Grafana's encryptions. +type alertmanagerCrypto struct { + secrets secrets.Service + configs configurationStore + log log.Logger +} + +func NewCrypto(secrets secrets.Service, configs configurationStore, log log.Logger) Crypto { + return &alertmanagerCrypto{ + secrets: secrets, + configs: configs, + log: log, + } +} + +// LoadSecureSettings adds the corresponding unencrypted secrets stored to the list of input receivers. +func (c *alertmanagerCrypto) LoadSecureSettings(ctx context.Context, orgId int64, receivers []*definitions.PostableApiReceiver) error { + // Get the last known working configuration. + query := models.GetLatestAlertmanagerConfigurationQuery{OrgID: orgId} + if err := c.configs.GetLatestAlertmanagerConfiguration(ctx, &query); err != nil { + // If we don't have a configuration there's nothing for us to know and we should just continue saving the new one. + if !errors.Is(err, store.ErrNoAlertmanagerConfiguration) { + return fmt.Errorf("failed to get latest configuration: %w", err) + } + } + + currentReceiverMap := make(map[string]*definitions.PostableGrafanaReceiver) + if query.Result != nil { + currentConfig, err := Load([]byte(query.Result.AlertmanagerConfiguration)) + // If the current config is un-loadable, treat it as if it never existed. Providing a new, valid config should be able to "fix" this state. + if err != nil { + c.log.Warn("Last known alertmanager configuration was invalid. Overwriting...") + } else { + currentReceiverMap = currentConfig.GetGrafanaReceiverMap() + } + } + + // Copy the previously known secure settings. + for i, r := range receivers { + for j, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers { + if gr.UID == "" { // new receiver + continue + } + + cgmr, ok := currentReceiverMap[gr.UID] + if !ok { + // It tries to update a receiver that didn't previously exist + return UnknownReceiverError{UID: gr.UID} + } + + // Frontend sends only the secure settings that have to be updated + // Therefore we have to copy from the last configuration only those secure settings not included in the request + for key := range cgmr.SecureSettings { + _, ok := gr.SecureSettings[key] + if !ok { + decryptedValue, err := c.getDecryptedSecret(cgmr, key) + if err != nil { + return fmt.Errorf("failed to decrypt stored secure setting: %s: %w", key, err) + } + + if receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings == nil { + receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings = make(map[string]string, len(cgmr.SecureSettings)) + } + + receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings[key] = decryptedValue + } + } + } + } + return nil +} + +func (c *alertmanagerCrypto) getDecryptedSecret(r *definitions.PostableGrafanaReceiver, key string) (string, error) { + storedValue, ok := r.SecureSettings[key] + if !ok { + return "", nil + } + + decodeValue, err := base64.StdEncoding.DecodeString(storedValue) + if err != nil { + return "", err + } + + decryptedValue, err := c.secrets.Decrypt(context.Background(), decodeValue) + if err != nil { + return "", err + } + + return string(decryptedValue), nil +} + +// Encrypt delegates encryption to secrets.Service. +func (c *alertmanagerCrypto) Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error) { + return c.secrets.Encrypt(ctx, payload, opt) +} diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager.go b/pkg/services/ngalert/notifier/multiorg_alertmanager.go index 3c8a2f1d987..d34677c3e44 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" "github.com/grafana/grafana/pkg/services/notifications" + "github.com/grafana/grafana/pkg/services/secrets" "github.com/prometheus/alertmanager/cluster" "github.com/prometheus/client_golang/prometheus" @@ -29,6 +30,8 @@ var ( ) type MultiOrgAlertmanager struct { + Crypto Crypto + alertmanagersMtx sync.RWMutex alertmanagers map[int64]*Alertmanager @@ -51,9 +54,11 @@ type MultiOrgAlertmanager struct { func NewMultiOrgAlertmanager(cfg *setting.Cfg, configStore store.AlertingStore, orgStore store.OrgStore, kvStore kvstore.KVStore, decryptFn channels.GetDecryptedValueFn, m *metrics.MultiOrgAlertmanager, - ns notifications.Service, l log.Logger, + ns notifications.Service, l log.Logger, s secrets.Service, ) (*MultiOrgAlertmanager, error) { moa := &MultiOrgAlertmanager{ + Crypto: NewCrypto(s, configStore, l), + logger: l, settings: cfg, alertmanagers: map[int64]*Alertmanager{}, diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go b/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go index 6dd2f020be0..56899182532 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go @@ -44,7 +44,7 @@ func TestMultiOrgAlertmanager_SyncAlertmanagersForOrgs(t *testing.T) { DisabledOrgs: map[int64]struct{}{5: {}}, }, // do not poll in tests. } - mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger")) + mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService) require.NoError(t, err) ctx := context.Background() @@ -168,7 +168,7 @@ func TestMultiOrgAlertmanager_SyncAlertmanagersForOrgsWithFailures(t *testing.T) DefaultConfiguration: setting.GetAlertmanagerDefaultConfiguration(), }, // do not poll in tests. } - mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger")) + mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService) require.NoError(t, err) ctx := context.Background() @@ -218,7 +218,7 @@ func TestMultiOrgAlertmanager_AlertmanagerFor(t *testing.T) { decryptFn := secretsService.GetDecryptedValue reg := prometheus.NewPedanticRegistry() m := metrics.NewNGAlert(reg) - mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger")) + mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService) require.NoError(t, err) ctx := context.Background() diff --git a/pkg/services/ngalert/provisioning/contactpoints.go b/pkg/services/ngalert/provisioning/contactpoints.go new file mode 100644 index 00000000000..00b1b60aba7 --- /dev/null +++ b/pkg/services/ngalert/provisioning/contactpoints.go @@ -0,0 +1,402 @@ +package provisioning + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "sort" + + "github.com/grafana/grafana/pkg/infra/log" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/secrets" + "github.com/grafana/grafana/pkg/util" + "github.com/prometheus/alertmanager/config" +) + +type ContactPointService struct { + amStore AMConfigStore + encryptionService secrets.Service + provenanceStore ProvisioningStore + xact TransactionManager + log log.Logger +} + +func NewContactPointService(store store.AlertingStore, encryptionService secrets.Service, + provenanceStore ProvisioningStore, xact TransactionManager, log log.Logger) *ContactPointService { + return &ContactPointService{ + amStore: store, + encryptionService: encryptionService, + provenanceStore: provenanceStore, + xact: xact, + log: log, + } +} + +func (ecp *ContactPointService) GetContactPoints(ctx context.Context, orgID int64) ([]apimodels.EmbeddedContactPoint, error) { + cfg, _, err := ecp.getCurrentConfig(ctx, orgID) + if err != nil { + return nil, err + } + provenances, err := ecp.provenanceStore.GetProvenances(ctx, orgID, "contactPoint") + if err != nil { + return nil, err + } + contactPoints := []apimodels.EmbeddedContactPoint{} + for _, contactPoint := range cfg.GetGrafanaReceiverMap() { + embeddedContactPoint := apimodels.EmbeddedContactPoint{ + UID: contactPoint.UID, + Type: contactPoint.Type, + Name: contactPoint.Name, + DisableResolveMessage: contactPoint.DisableResolveMessage, + Settings: contactPoint.Settings, + } + if val, exists := provenances[embeddedContactPoint.UID]; exists && val != "" { + embeddedContactPoint.Provenance = string(val) + } + for k, v := range contactPoint.SecureSettings { + decryptedValue, err := ecp.decryptValue(v) + if err != nil { + ecp.log.Warn("decrypting value failed", "err", err.Error()) + continue + } + if decryptedValue == "" { + continue + } + embeddedContactPoint.Settings.Set(k, apimodels.RedactedValue) + } + contactPoints = append(contactPoints, embeddedContactPoint) + } + sort.SliceStable(contactPoints, func(i, j int) bool { + return contactPoints[i].Name < contactPoints[j].Name + }) + return contactPoints, nil +} + +// internal only +func (ecp *ContactPointService) getContactPointDecrypted(ctx context.Context, orgID int64, uid string) (apimodels.EmbeddedContactPoint, error) { + cfg, _, err := ecp.getCurrentConfig(ctx, orgID) + if err != nil { + return apimodels.EmbeddedContactPoint{}, err + } + for _, receiver := range cfg.GetGrafanaReceiverMap() { + if receiver.UID != uid { + continue + } + embeddedContactPoint := apimodels.EmbeddedContactPoint{ + UID: receiver.UID, + Type: receiver.Type, + Name: receiver.Name, + DisableResolveMessage: receiver.DisableResolveMessage, + Settings: receiver.Settings, + } + for k, v := range receiver.SecureSettings { + decryptedValue, err := ecp.decryptValue(v) + if err != nil { + ecp.log.Warn("decrypting value failed", "err", err.Error()) + continue + } + if decryptedValue == "" { + continue + } + embeddedContactPoint.Settings.Set(k, decryptedValue) + } + return embeddedContactPoint, nil + } + return apimodels.EmbeddedContactPoint{}, fmt.Errorf("contact point with uid '%s' not found", uid) +} + +func (ecp *ContactPointService) CreateContactPoint(ctx context.Context, orgID int64, + contactPoint apimodels.EmbeddedContactPoint, provenance models.Provenance) (apimodels.EmbeddedContactPoint, error) { + if err := contactPoint.Valid(ecp.encryptionService.GetDecryptedValue); err != nil { + return apimodels.EmbeddedContactPoint{}, fmt.Errorf("contact point is not valid: %w", err) + } + + cfg, fetchedHash, err := ecp.getCurrentConfig(ctx, orgID) + if err != nil { + return apimodels.EmbeddedContactPoint{}, err + } + + extractedSecrets, err := contactPoint.ExtractSecrets() + if err != nil { + return apimodels.EmbeddedContactPoint{}, err + } + + for k, v := range extractedSecrets { + encryptedValue, err := ecp.encryptValue(v) + if err != nil { + return apimodels.EmbeddedContactPoint{}, err + } + extractedSecrets[k] = encryptedValue + } + + contactPoint.UID = util.GenerateShortUID() + grafanaReceiver := &apimodels.PostableGrafanaReceiver{ + UID: contactPoint.UID, + Name: contactPoint.Name, + Type: contactPoint.Type, + DisableResolveMessage: contactPoint.DisableResolveMessage, + Settings: contactPoint.Settings, + SecureSettings: extractedSecrets, + } + + receiverFound := false + for _, receiver := range cfg.AlertmanagerConfig.Receivers { + if receiver.Name == contactPoint.Name { + receiver.PostableGrafanaReceivers.GrafanaManagedReceivers = append(receiver.PostableGrafanaReceivers.GrafanaManagedReceivers, grafanaReceiver) + receiverFound = true + } + } + + if !receiverFound { + cfg.AlertmanagerConfig.Receivers = append(cfg.AlertmanagerConfig.Receivers, &apimodels.PostableApiReceiver{ + Receiver: config.Receiver{ + Name: grafanaReceiver.Name, + }, + PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{ + GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{grafanaReceiver}, + }, + }) + } + + data, err := json.Marshal(cfg) + if err != nil { + return apimodels.EmbeddedContactPoint{}, err + } + + err = ecp.xact.InTransaction(ctx, func(ctx context.Context) error { + err = ecp.amStore.UpdateAlertmanagerConfiguration(ctx, &models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: string(data), + FetchedConfigurationHash: fetchedHash, + ConfigurationVersion: "v1", + Default: false, + OrgID: orgID, + }) + if err != nil { + return err + } + adapter := provenanceOrgAdapter{ + inner: &contactPoint, + orgID: orgID, + } + err = ecp.provenanceStore.SetProvenance(ctx, adapter, provenance) + if err != nil { + return err + } + contactPoint.Provenance = string(provenance) + return nil + }) + if err != nil { + return apimodels.EmbeddedContactPoint{}, err + } + for k := range extractedSecrets { + contactPoint.Settings.Set(k, apimodels.RedactedValue) + } + return contactPoint, nil +} + +func (ecp *ContactPointService) UpdateContactPoint(ctx context.Context, orgID int64, contactPoint apimodels.EmbeddedContactPoint, provenance models.Provenance) error { + // set all redacted values with the latest known value from the store + rawContactPoint, err := ecp.getContactPointDecrypted(ctx, orgID, contactPoint.UID) + if err != nil { + return err + } + secretKeys, err := contactPoint.SecretKeys() + if err != nil { + return err + } + for _, secretKey := range secretKeys { + secretValue := contactPoint.Settings.Get(secretKey).MustString() + if secretValue == apimodels.RedactedValue { + contactPoint.Settings.Set(secretKey, rawContactPoint.Settings.Get(secretKey).MustString()) + } + } + // validate merged values + if err := contactPoint.Valid(ecp.encryptionService.GetDecryptedValue); err != nil { + return err + } + // check that provenance is not changed in a invalid way + storedProvenance, err := ecp.provenanceStore.GetProvenance(ctx, provenanceOrgAdapter{ + inner: &contactPoint, + orgID: orgID, + }) + if err != nil { + return err + } + if storedProvenance != provenance && storedProvenance != models.ProvenanceNone { + return fmt.Errorf("cannot changed provenance from '%s' to '%s'", storedProvenance, provenance) + } + // transform to internal model + extractedSecrets, err := contactPoint.ExtractSecrets() + if err != nil { + return err + } + for k, v := range extractedSecrets { + encryptedValue, err := ecp.encryptValue(v) + if err != nil { + return err + } + extractedSecrets[k] = encryptedValue + } + mergedReceiver := &apimodels.PostableGrafanaReceiver{ + UID: contactPoint.UID, + Name: contactPoint.Name, + Type: contactPoint.Type, + DisableResolveMessage: contactPoint.DisableResolveMessage, + Settings: contactPoint.Settings, + SecureSettings: extractedSecrets, + } + // save to store + cfg, fetchedHash, err := ecp.getCurrentConfig(ctx, orgID) + if err != nil { + return err + } + for _, receiver := range cfg.AlertmanagerConfig.Receivers { + if receiver.Name == contactPoint.Name { + receiverNotFound := true + for i, grafanaReceiver := range receiver.GrafanaManagedReceivers { + if grafanaReceiver.UID == mergedReceiver.UID { + receiverNotFound = false + receiver.GrafanaManagedReceivers[i] = mergedReceiver + break + } + } + if receiverNotFound { + return fmt.Errorf("contact point with uid '%s' not found", mergedReceiver.UID) + } + } + } + data, err := json.Marshal(cfg) + if err != nil { + return err + } + return ecp.xact.InTransaction(ctx, func(ctx context.Context) error { + err = ecp.amStore.UpdateAlertmanagerConfiguration(ctx, &models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: string(data), + FetchedConfigurationHash: fetchedHash, + ConfigurationVersion: "v1", + Default: false, + OrgID: orgID, + }) + if err != nil { + return err + } + adapter := provenanceOrgAdapter{ + inner: &contactPoint, + orgID: orgID, + } + err = ecp.provenanceStore.SetProvenance(ctx, adapter, provenance) + if err != nil { + return err + } + contactPoint.Provenance = string(provenance) + return nil + }) +} + +func (ecp *ContactPointService) DeleteContactPoint(ctx context.Context, orgID int64, uid string) error { + cfg, fetchedHash, err := ecp.getCurrentConfig(ctx, orgID) + if err != nil { + return err + } + // Indicates if the full contact point is removed or just one of the + // configurations, as a contactpoint can consist of any number of + // configurations. + fullRemoval := false + // Name of the contact point that will be removed, might be used if a + // full removal is done to check if it's referenced in any route. + name := "" + for i, receiver := range cfg.AlertmanagerConfig.Receivers { + for j, grafanaReceiver := range receiver.GrafanaManagedReceivers { + if grafanaReceiver.UID == uid { + name = grafanaReceiver.Name + receiver.GrafanaManagedReceivers = append(receiver.GrafanaManagedReceivers[:j], receiver.GrafanaManagedReceivers[j+1:]...) + // if this was the last receiver we removed, we remove the whole receiver + if len(receiver.GrafanaManagedReceivers) == 0 { + fullRemoval = true + cfg.AlertmanagerConfig.Receivers = append(cfg.AlertmanagerConfig.Receivers[:i], cfg.AlertmanagerConfig.Receivers[i+1:]...) + } + break + } + } + } + if fullRemoval && isContactPointInUse(name, []*apimodels.Route{cfg.AlertmanagerConfig.Route}) { + return fmt.Errorf("contact point '%s' is currently used by a notification policy", name) + } + data, err := json.Marshal(cfg) + if err != nil { + return err + } + return ecp.xact.InTransaction(ctx, func(ctx context.Context) error { + err := ecp.provenanceStore.DeleteProvenance(ctx, provenanceOrgAdapter{ + inner: &apimodels.EmbeddedContactPoint{ + UID: uid, + }, + orgID: orgID, + }) + if err != nil { + return err + } + return ecp.amStore.UpdateAlertmanagerConfiguration(ctx, &models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: string(data), + FetchedConfigurationHash: fetchedHash, + ConfigurationVersion: "v1", + Default: false, + OrgID: orgID, + }) + }) +} + +func (ecp *ContactPointService) getCurrentConfig(ctx context.Context, orgID int64) (*apimodels.PostableUserConfig, string, error) { + query := &models.GetLatestAlertmanagerConfigurationQuery{ + OrgID: orgID, + } + err := ecp.amStore.GetLatestAlertmanagerConfiguration(ctx, query) + if err != nil { + return nil, "", err + } + cfg, err := DeserializeAlertmanagerConfig([]byte(query.Result.AlertmanagerConfiguration)) + if err != nil { + return nil, "", err + } + return cfg, query.Result.ConfigurationHash, nil +} + +func isContactPointInUse(name string, routes []*apimodels.Route) bool { + if len(routes) == 0 { + return false + } + for _, route := range routes { + if route.Receiver == name { + return true + } + if isContactPointInUse(name, route.Routes) { + return true + } + } + return false +} + +func (ecp *ContactPointService) decryptValue(value string) (string, error) { + decodeValue, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return "", err + } + + decryptedValue, err := ecp.encryptionService.Decrypt(context.Background(), decodeValue) + if err != nil { + return "", err + } + + return string(decryptedValue), nil +} + +func (ecp *ContactPointService) encryptValue(value string) (string, error) { + encryptedData, err := ecp.encryptionService.Encrypt(context.Background(), []byte(value), secrets.WithoutScope()) + if err != nil { + return "", fmt.Errorf("failed to encrypt secure settings: %w", err) + } + return base64.StdEncoding.EncodeToString(encryptedData), nil +} diff --git a/pkg/services/ngalert/provisioning/contactpoints_test.go b/pkg/services/ngalert/provisioning/contactpoints_test.go new file mode 100644 index 00000000000..cb70fe05baa --- /dev/null +++ b/pkg/services/ngalert/provisioning/contactpoints_test.go @@ -0,0 +1,195 @@ +package provisioning + +import ( + "context" + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/secrets" + "github.com/grafana/grafana/pkg/services/secrets/database" + "github.com/grafana/grafana/pkg/services/secrets/manager" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/stretchr/testify/require" +) + +func TestContactPointService(t *testing.T) { + sqlStore := sqlstore.InitTestDB(t) + secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) + t.Run("service gets contact points from AM config", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + + cps, err := sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + + require.Len(t, cps, 1) + require.Equal(t, "email receiver", cps[0].Name) + }) + + t.Run("service stitches contact point into org's AM config", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + newCp := createTestContactPoint() + + _, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) + require.NoError(t, err) + + cps, err := sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + require.Len(t, cps, 2) + require.Equal(t, "test-contact-point", cps[1].Name) + require.Equal(t, "slack", cps[1].Type) + }) + + t.Run("default provenance of contact points is none", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + + cps, err := sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + + require.Equal(t, models.ProvenanceNone, models.Provenance(cps[0].Provenance)) + }) + + t.Run("it's possible to update provenance from none to API", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + newCp := createTestContactPoint() + + newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceNone) + require.NoError(t, err) + + cps, err := sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, newCp.UID, cps[1].UID) + require.Equal(t, models.ProvenanceNone, models.Provenance(cps[1].Provenance)) + + err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) + require.NoError(t, err) + + cps, err = sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, newCp.UID, cps[1].UID) + require.Equal(t, models.ProvenanceAPI, models.Provenance(cps[1].Provenance)) + }) + + t.Run("it's possible to update provenance from none to File", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + newCp := createTestContactPoint() + + newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceNone) + require.NoError(t, err) + + cps, err := sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, newCp.UID, cps[1].UID) + require.Equal(t, models.ProvenanceNone, models.Provenance(cps[1].Provenance)) + + err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceFile) + require.NoError(t, err) + + cps, err = sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, newCp.UID, cps[1].UID) + require.Equal(t, models.ProvenanceFile, models.Provenance(cps[1].Provenance)) + }) + + t.Run("it's not possible to update provenance from File to API", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + newCp := createTestContactPoint() + + newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceFile) + require.NoError(t, err) + + cps, err := sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, newCp.UID, cps[1].UID) + require.Equal(t, models.ProvenanceFile, models.Provenance(cps[1].Provenance)) + + err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) + require.Error(t, err) + }) + + t.Run("it's not possible to update provenance from API to File", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + newCp := createTestContactPoint() + + newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) + require.NoError(t, err) + + cps, err := sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, newCp.UID, cps[1].UID) + require.Equal(t, models.ProvenanceAPI, models.Provenance(cps[1].Provenance)) + + err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceFile) + require.Error(t, err) + }) + + t.Run("service respects concurrency token when updating", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + newCp := createTestContactPoint() + q := models.GetLatestAlertmanagerConfigurationQuery{ + OrgID: 1, + } + err := sut.amStore.GetLatestAlertmanagerConfiguration(context.Background(), &q) + require.NoError(t, err) + expectedConcurrencyToken := q.Result.ConfigurationHash + + _, err = sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) + require.NoError(t, err) + + fake := sut.amStore.(*fakeAMConfigStore) + intercepted := fake.lastSaveCommand + require.Equal(t, expectedConcurrencyToken, intercepted.FetchedConfigurationHash) + }) +} + +func TestContactPointInUse(t *testing.T) { + result := isContactPointInUse("test", []*definitions.Route{ + { + Receiver: "not-test", + Routes: []*definitions.Route{ + { + Receiver: "not-test", + }, + { + Receiver: "test", + }, + }, + }, + }) + require.True(t, result) + result = isContactPointInUse("test", []*definitions.Route{ + { + Receiver: "not-test", + Routes: []*definitions.Route{ + { + Receiver: "not-test", + }, + { + Receiver: "not-test", + }, + }, + }, + }) + require.False(t, result) +} + +func createContactPointServiceSut(secretService secrets.Service) *ContactPointService { + return &ContactPointService{ + amStore: newFakeAMConfigStore(), + provenanceStore: newFakeProvisioningStore(), + xact: newNopTransactionManager(), + encryptionService: secretService, + log: log.NewNopLogger(), + } +} + +func createTestContactPoint() definitions.EmbeddedContactPoint { + settings, _ := simplejson.NewJson([]byte(`{"recipient":"value_recipient","token":"value_token"}`)) + return definitions.EmbeddedContactPoint{ + Name: "test-contact-point", + Type: "slack", + Settings: settings, + } +} diff --git a/pkg/services/ngalert/provisioning/notification_policies_test.go b/pkg/services/ngalert/provisioning/notification_policies_test.go index 3df52d4bc81..2a27b6b3554 100644 --- a/pkg/services/ngalert/provisioning/notification_policies_test.go +++ b/pkg/services/ngalert/provisioning/notification_policies_test.go @@ -45,12 +45,12 @@ func TestNotificationPolicyService(t *testing.T) { sut := createNotificationPolicyServiceSut() newRoute := createTestRoutingTree() - err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceApi) + err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceAPI) require.NoError(t, err) updated, err := sut.GetPolicyTree(context.Background(), 1) require.NoError(t, err) - require.Equal(t, models.ProvenanceApi, updated.Provenance) + require.Equal(t, models.ProvenanceAPI, updated.Provenance) }) t.Run("service respects concurrency token when updating", func(t *testing.T) { @@ -63,7 +63,7 @@ func TestNotificationPolicyService(t *testing.T) { require.NoError(t, err) expectedConcurrencyToken := q.Result.ConfigurationHash - err = sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceApi) + err = sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceAPI) require.NoError(t, err) fake := sut.GetAMConfigStore().(*fakeAMConfigStore) diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go index 2a550db327f..eb428055a86 100644 --- a/pkg/services/ngalert/provisioning/persist.go +++ b/pkg/services/ngalert/provisioning/persist.go @@ -15,7 +15,9 @@ type AMConfigStore interface { // ProvisioningStore is a store of provisioning data for arbitrary objects. type ProvisioningStore interface { GetProvenance(ctx context.Context, o models.Provisionable) (models.Provenance, error) + GetProvenances(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error + DeleteProvenance(ctx context.Context, o models.Provisionable) error } // TransactionManager represents the ability to issue and close transactions through contexts. diff --git a/pkg/services/ngalert/provisioning/testing.go b/pkg/services/ngalert/provisioning/testing.go index 12c54cc02c7..aba31dfd513 100644 --- a/pkg/services/ngalert/provisioning/testing.go +++ b/pkg/services/ngalert/provisioning/testing.go @@ -4,6 +4,7 @@ import ( "context" "crypto/md5" "fmt" + "strings" "github.com/grafana/grafana/pkg/services/ngalert/models" ) @@ -99,19 +100,39 @@ func newFakeProvisioningStore() *fakeProvisioningStore { func (f *fakeProvisioningStore) GetProvenance(ctx context.Context, o models.Provisionable) (models.Provenance, error) { if val, ok := f.records[o.ResourceOrgID()]; ok { - if prov, ok := val[o.ResourceID()]; ok { + if prov, ok := val[o.ResourceID()+o.ResourceType()]; ok { return prov, nil } } return models.ProvenanceNone, nil } +func (f *fakeProvisioningStore) GetProvenances(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) { + results := make(map[string]models.Provenance) + if val, ok := f.records[orgID]; ok { + for k, v := range val { + if strings.HasSuffix(k, resourceType) { + results[strings.TrimSuffix(k, resourceType)] = v + } + } + } + return results, nil +} + func (f *fakeProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error { orgID := o.ResourceOrgID() if _, ok := f.records[orgID]; !ok { f.records[orgID] = map[string]models.Provenance{} } - f.records[orgID][o.ResourceID()] = p + _ = f.DeleteProvenance(ctx, o) // delete old entries first + f.records[orgID][o.ResourceID()+o.ResourceType()] = p + return nil +} + +func (f *fakeProvisioningStore) DeleteProvenance(ctx context.Context, o models.Provisionable) error { + if val, ok := f.records[o.ResourceOrgID()]; ok { + delete(val, o.ResourceID()+o.ResourceType()) + } return nil } diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go index 4824f05d95f..7f5f8da4cac 100644 --- a/pkg/services/ngalert/schedule/schedule_unit_test.go +++ b/pkg/services/ngalert/schedule/schedule_unit_test.go @@ -1015,7 +1015,7 @@ func setupScheduler(t *testing.T, rs store.RuleStore, is store.InstanceStore, ac m := metrics.NewNGAlert(registry) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) decryptFn := secretsService.GetDecryptedValue - moa, err := notifier.NewMultiOrgAlertmanager(&setting.Cfg{}, ¬ifier.FakeConfigStore{}, ¬ifier.FakeOrgStore{}, ¬ifier.FakeKVStore{}, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger")) + moa, err := notifier.NewMultiOrgAlertmanager(&setting.Cfg{}, ¬ifier.FakeConfigStore{}, ¬ifier.FakeOrgStore{}, ¬ifier.FakeKVStore{}, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService) require.NoError(t, err) schedCfg := SchedulerCfg{ diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index df34f4b4f3d..7ef5a293282 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -1397,6 +1397,152 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + { + desc: "normal -> normal when result is Error and ExecErrState is OK", + alertRule: &models.AlertRule{ + OrgID: 1, + Title: "test_title", + UID: "test_alert_rule_uid_2", + NamespaceUID: "test_namespace_uid", + Data: []models.AlertQuery{{ + RefID: "A", + DatasourceUID: "datasource_uid_1", + }}, + Annotations: map[string]string{"annotation": "test"}, + Labels: map[string]string{"label": "test"}, + IntervalSeconds: 10, + For: 1 * time.Minute, + ExecErrState: models.OkErrState, + }, + evalResults: []eval.Results{ + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Normal, + EvaluatedAt: evaluationTime, + EvaluationDuration: evaluationDuration, + }, + }, + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + Error: expr.QueryError{ + RefID: "A", + Err: errors.New("this is an error"), + }, + State: eval.Error, + EvaluatedAt: evaluationTime.Add(10 * time.Second), + EvaluationDuration: evaluationDuration, + }, + }, + }, + expectedAnnotations: 0, + expectedStates: map[string]*state.State{ + `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { + AlertRuleUID: "test_alert_rule_uid_2", + OrgID: 1, + CacheId: `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`, + Labels: data.Labels{ + "__alert_rule_namespace_uid__": "test_namespace_uid", + "__alert_rule_uid__": "test_alert_rule_uid_2", + "alertname": "test_title", + "label": "test", + "instance_label": "test", + }, + State: eval.Normal, + Error: nil, + Results: []state.Evaluation{ + { + EvaluationTime: evaluationTime, + EvaluationState: eval.Normal, + Values: make(map[string]*float64), + }, + { + EvaluationTime: evaluationTime.Add(10 * time.Second), + EvaluationState: eval.Error, + Values: make(map[string]*float64), + }, + }, + LastEvaluationTime: evaluationTime.Add(10 * time.Second), + EvaluationDuration: evaluationDuration, + Annotations: map[string]string{"annotation": "test"}, + }, + }, + }, + { + desc: "alerting -> normal when result is Error and ExecErrState is OK", + alertRule: &models.AlertRule{ + OrgID: 1, + Title: "test_title", + UID: "test_alert_rule_uid_2", + NamespaceUID: "test_namespace_uid", + Data: []models.AlertQuery{{ + RefID: "A", + DatasourceUID: "datasource_uid_1", + }}, + Annotations: map[string]string{"annotation": "test"}, + Labels: map[string]string{"label": "test"}, + IntervalSeconds: 10, + For: 1 * time.Minute, + ExecErrState: models.OkErrState, + }, + evalResults: []eval.Results{ + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Alerting, + EvaluatedAt: evaluationTime, + EvaluationDuration: evaluationDuration, + }, + }, + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + Error: expr.QueryError{ + RefID: "A", + Err: errors.New("this is an error"), + }, + State: eval.Error, + EvaluatedAt: evaluationTime.Add(10 * time.Second), + EvaluationDuration: evaluationDuration, + }, + }, + }, + expectedAnnotations: 2, + expectedStates: map[string]*state.State{ + `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { + AlertRuleUID: "test_alert_rule_uid_2", + OrgID: 1, + CacheId: `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`, + Labels: data.Labels{ + "__alert_rule_namespace_uid__": "test_namespace_uid", + "__alert_rule_uid__": "test_alert_rule_uid_2", + "alertname": "test_title", + "label": "test", + "instance_label": "test", + }, + State: eval.Normal, + Error: nil, + Results: []state.Evaluation{ + { + EvaluationTime: evaluationTime, + EvaluationState: eval.Alerting, + Values: make(map[string]*float64), + }, + { + EvaluationTime: evaluationTime.Add(10 * time.Second), + EvaluationState: eval.Error, + Values: make(map[string]*float64), + }, + }, + StartsAt: evaluationTime.Add(10 * time.Second), + EndsAt: evaluationTime.Add(10 * time.Second), + LastEvaluationTime: evaluationTime.Add(10 * time.Second), + EvaluationDuration: evaluationDuration, + Annotations: map[string]string{"annotation": "test"}, + }, + }, + }, { desc: "normal -> alerting -> error -> alerting - it should clear the error", alertRule: &models.AlertRule{ diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index 63f3b9d5930..cf5c5b849d2 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -2,6 +2,7 @@ package state import ( "errors" + "fmt" "strings" "time" @@ -51,8 +52,7 @@ func NewEvaluationValues(m map[string]eval.NumberValueCapture) map[string]*float } func (a *State) resultNormal(_ *ngModels.AlertRule, result eval.Result) { - a.Error = result.Error // should be nil since state is not error - + a.Error = nil // should be nil since state is not error if a.State != eval.Normal { a.EndsAt = result.EvaluatedAt a.StartsAt = result.EvaluatedAt @@ -88,9 +88,10 @@ func (a *State) resultError(alertRule *ngModels.AlertRule, result eval.Result) { a.Error = result.Error execErrState := eval.Error - if alertRule.ExecErrState == ngModels.AlertingErrState { + switch alertRule.ExecErrState { + case ngModels.AlertingErrState: execErrState = eval.Alerting - } else if alertRule.ExecErrState == ngModels.ErrorErrState { + case ngModels.ErrorErrState: // If the evaluation failed because a query returned an error then // update the state with the Datasource UID as a label and the error // message as an annotation so other code can use this metadata to @@ -107,6 +108,11 @@ func (a *State) resultError(alertRule *ngModels.AlertRule, result eval.Result) { a.Annotations["Error"] = queryError.Error() } execErrState = eval.Error + case ngModels.OkErrState: + a.resultNormal(alertRule, result) + return + default: + a.Error = fmt.Errorf("cannot map error to a state because option [%s] is not supported. evaluation error: %w", alertRule.ExecErrState, a.Error) } switch a.State { diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 06beea3a1d2..ff1df1709ce 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -27,24 +27,23 @@ type UpdateRuleGroupCmd struct { RuleGroupConfig apimodels.PostableRuleGroupConfig } -type UpsertRule struct { +type UpdateRule struct { Existing *ngmodels.AlertRule New ngmodels.AlertRule } -// Store is the interface for persisting alert rules and instances +// RuleStore is the interface for persisting alert rules and instances type RuleStore interface { DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error DeleteAlertInstancesByRuleUID(ctx context.Context, orgID int64, ruleUID string) error GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAlertRuleByUIDQuery) error GetAlertRulesForScheduling(ctx context.Context, query *ngmodels.ListAlertRulesQuery) error GetOrgAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) error - GetNamespaceAlertRules(ctx context.Context, query *ngmodels.ListNamespaceAlertRulesQuery) error GetAlertRules(ctx context.Context, query *ngmodels.GetAlertRulesQuery) error GetUserVisibleNamespaces(context.Context, int64, *models.SignedInUser) (map[string]*models.Folder, error) GetNamespaceByTitle(context.Context, string, int64, *models.SignedInUser, bool) (*models.Folder, error) - GetOrgRuleGroups(ctx context.Context, query *ngmodels.ListOrgRuleGroupsQuery) error - UpsertAlertRules(ctx context.Context, rule []UpsertRule) error + InsertAlertRules(ctx context.Context, rule []ngmodels.AlertRule) error + UpdateAlertRules(ctx context.Context, rule []UpdateRule) error } func getAlertRuleByUID(sess *sqlstore.DBSession, alertRuleUID string, orgID int64) (*ngmodels.AlertRule, error) { @@ -109,52 +108,87 @@ func (st DBstore) GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAler }) } -// UpsertAlertRules is a handler for creating/updating alert rules. -func (st DBstore) UpsertAlertRules(ctx context.Context, rules []UpsertRule) error { +// InsertAlertRules is a handler for creating/updating alert rules. +func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRule) error { + return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + newRules := make([]ngmodels.AlertRule, 0, len(rules)) + ruleVersions := make([]ngmodels.AlertRuleVersion, 0, len(rules)) + for i := range rules { + r := rules[i] + uid, err := GenerateNewAlertRuleUID(sess, r.OrgID, r.Title) + if err != nil { + return fmt.Errorf("failed to generate UID for alert rule %q: %w", r.Title, err) + } + r.UID = uid + r.Version = 1 + if err := st.validateAlertRule(r); err != nil { + return err + } + if err := (&r).PreSave(TimeNow); err != nil { + return err + } + newRules = append(newRules, r) + ruleVersions = append(ruleVersions, ngmodels.AlertRuleVersion{ + RuleOrgID: r.OrgID, + RuleUID: r.UID, + RuleNamespaceUID: r.NamespaceUID, + RuleGroup: r.RuleGroup, + ParentVersion: 0, + Version: r.Version, + Created: r.Updated, + Condition: r.Condition, + Title: r.Title, + Data: r.Data, + IntervalSeconds: r.IntervalSeconds, + NoDataState: r.NoDataState, + ExecErrState: r.ExecErrState, + For: r.For, + Annotations: r.Annotations, + Labels: r.Labels, + }) + } + if len(newRules) > 0 { + if _, err := sess.Insert(&newRules); err != nil { + if st.SQLStore.Dialect.IsUniqueConstraintViolation(err) { + return ngmodels.ErrAlertRuleUniqueConstraintViolation + } + return fmt.Errorf("failed to create new rules: %w", err) + } + } + + if len(ruleVersions) > 0 { + if _, err := sess.Insert(&ruleVersions); err != nil { + return fmt.Errorf("failed to create new rule versions: %w", err) + } + } + + return nil + }) +} + +// UpdateAlertRules is a handler for creating/updating alert rules. +func (st DBstore) UpdateAlertRules(ctx context.Context, rules []UpdateRule) error { return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { newRules := make([]ngmodels.AlertRule, 0, len(rules)) ruleVersions := make([]ngmodels.AlertRuleVersion, 0, len(rules)) for _, r := range rules { var parentVersion int64 - switch r.Existing { - case nil: // new rule - uid, err := GenerateNewAlertRuleUID(sess, r.New.OrgID, r.New.Title) - if err != nil { - return fmt.Errorf("failed to generate UID for alert rule %q: %w", r.New.Title, err) - } - r.New.UID = uid - r.New.Version = 1 - - if err := st.validateAlertRule(r.New); err != nil { - return err - } - - if err := (&r.New).PreSave(TimeNow); err != nil { - return err - } - newRules = append(newRules, r.New) - default: - r.New.ID = r.Existing.ID - r.New.Version = r.Existing.Version + 1 - - if err := st.validateAlertRule(r.New); err != nil { - return err - } - - if err := (&r.New).PreSave(TimeNow); err != nil { - return err - } - - // no way to update multiple rules at once - if _, err := sess.ID(r.Existing.ID).AllCols().Update(r.New); err != nil { - if st.SQLStore.Dialect.IsUniqueConstraintViolation(err) { - return ngmodels.ErrAlertRuleUniqueConstraintViolation - } - return fmt.Errorf("failed to update rule [%s] %s: %w", r.New.UID, r.New.Title, err) - } - parentVersion = r.Existing.Version + r.New.ID = r.Existing.ID + r.New.Version = r.Existing.Version + 1 + if err := st.validateAlertRule(r.New); err != nil { + return err } - + if err := (&r.New).PreSave(TimeNow); err != nil { + return err + } + // no way to update multiple rules at once + if _, err := sess.ID(r.Existing.ID).AllCols().Update(r.New); err != nil { + if st.SQLStore.Dialect.IsUniqueConstraintViolation(err) { + return ngmodels.ErrAlertRuleUniqueConstraintViolation + } + return fmt.Errorf("failed to update rule [%s] %s: %w", r.New.UID, r.New.Title, err) + } + parentVersion = r.Existing.Version ruleVersions = append(ruleVersions, ngmodels.AlertRuleVersion{ RuleOrgID: r.New.OrgID, RuleUID: r.New.UID, @@ -174,7 +208,6 @@ func (st DBstore) UpsertAlertRules(ctx context.Context, rules []UpsertRule) erro Labels: r.New.Labels, }) } - if len(newRules) > 0 { if _, err := sess.Insert(&newRules); err != nil { if st.SQLStore.Dialect.IsUniqueConstraintViolation(err) { @@ -183,13 +216,11 @@ func (st DBstore) UpsertAlertRules(ctx context.Context, rules []UpsertRule) erro return fmt.Errorf("failed to create new rules: %w", err) } } - if len(ruleVersions) > 0 { if _, err := sess.Insert(&ruleVersions); err != nil { return fmt.Errorf("failed to create new rule versions: %w", err) } } - return nil }) } @@ -230,21 +261,6 @@ func (st DBstore) GetOrgAlertRules(ctx context.Context, query *ngmodels.ListAler }) } -// GetNamespaceAlertRules is a handler for retrieving namespace alert rules of specific organisation. -func (st DBstore) GetNamespaceAlertRules(ctx context.Context, query *ngmodels.ListNamespaceAlertRulesQuery) error { - return st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - alertRules := make([]*ngmodels.AlertRule, 0) - // TODO rewrite using group by namespace_uid, rule_group - q := "SELECT * FROM alert_rule WHERE org_id = ? and namespace_uid = ?" - if err := sess.SQL(q, query.OrgID, query.NamespaceUID).Find(&alertRules); err != nil { - return err - } - - query.Result = alertRules - return nil - }) -} - // GetAlertRules is a handler for retrieving rule group alert rules of specific organisation. func (st DBstore) GetAlertRules(ctx context.Context, query *ngmodels.GetAlertRulesQuery) error { return st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { @@ -405,50 +421,3 @@ func (st DBstore) validateAlertRule(alertRule ngmodels.AlertRule) error { return nil } - -func (st DBstore) GetOrgRuleGroups(ctx context.Context, query *ngmodels.ListOrgRuleGroupsQuery) error { - return st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - var ruleGroups [][]string - q := ` -SELECT DISTINCT - rule_group, - namespace_uid, - ( - SELECT title - FROM dashboard - WHERE - org_id = alert_rule.org_id AND - uid = alert_rule.namespace_uid - ) AS namespace_title -FROM alert_rule -WHERE org_id = ?` - params := []interface{}{query.OrgID} - - if len(query.NamespaceUIDs) > 0 { - placeholders := make([]string, 0, len(query.NamespaceUIDs)) - for _, folderUID := range query.NamespaceUIDs { - params = append(params, folderUID) - placeholders = append(placeholders, "?") - } - q = fmt.Sprintf(" %s AND namespace_uid IN (%s)", q, strings.Join(placeholders, ",")) - } - - if query.DashboardUID != "" { - q = fmt.Sprintf("%s and dashboard_uid = ?", q) - params = append(params, query.DashboardUID) - if query.PanelID != 0 { - q = fmt.Sprintf("%s and panel_id = ?", q) - params = append(params, query.PanelID) - } - } - - q = fmt.Sprintf(" %s ORDER BY namespace_title", q) - - if err := sess.SQL(q, params...).Find(&ruleGroups); err != nil { - return err - } - - query.Result = ruleGroups - return nil - }) -} diff --git a/pkg/services/ngalert/store/provisioning_store.go b/pkg/services/ngalert/store/provisioning_store.go index 56c5d157606..6710b702a84 100644 --- a/pkg/services/ngalert/store/provisioning_store.go +++ b/pkg/services/ngalert/store/provisioning_store.go @@ -45,6 +45,23 @@ func (st DBstore) GetProvenance(ctx context.Context, o models.Provisionable) (mo return provenance, nil } +// GetProvenance gets the provenance status for a provisionable object. +func (st DBstore) GetProvenances(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) { + resultMap := make(map[string]models.Provenance) + err := st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + filter := "record_type = ? AND org_id = ?" + rawData, err := sess.Table(provenanceRecord{}).Where(filter, resourceType, orgID).Desc("id").Cols("record_key", "provenance").QueryString() + if err != nil { + return fmt.Errorf("failed to query for existing provenance status: %w", err) + } + for _, data := range rawData { + resultMap[data["record_key"]] = models.Provenance(data["provenance"]) + } + return nil + }) + return resultMap, err +} + // SetProvenance changes the provenance status for a provisionable object. func (st DBstore) SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error { recordType := o.ResourceType() @@ -76,3 +93,15 @@ func (st DBstore) SetProvenance(ctx context.Context, o models.Provisionable, p m return nil }) } + +// DeleteProvenance deletes the provenance record from the table +func (st DBstore) DeleteProvenance(ctx context.Context, o models.Provisionable) error { + return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + _, err := sess.Delete(provenanceRecord{ + RecordKey: o.ResourceID(), + RecordType: o.ResourceType(), + OrgID: o.ResourceOrgID(), + }) + return err + }) +} diff --git a/pkg/services/ngalert/store/provisioning_store_test.go b/pkg/services/ngalert/store/provisioning_store_test.go index 815b799f080..886ca7f6884 100644 --- a/pkg/services/ngalert/store/provisioning_store_test.go +++ b/pkg/services/ngalert/store/provisioning_store_test.go @@ -73,16 +73,58 @@ func TestProvisioningStore(t *testing.T) { err = store.SetProvenance(context.Background(), &ruleOrg3, models.ProvenanceFile) require.NoError(t, err) - err = store.SetProvenance(context.Background(), &ruleOrg2, models.ProvenanceApi) + err = store.SetProvenance(context.Background(), &ruleOrg2, models.ProvenanceAPI) require.NoError(t, err) p, err := store.GetProvenance(context.Background(), &ruleOrg2) require.NoError(t, err) - require.Equal(t, models.ProvenanceApi, p) + require.Equal(t, models.ProvenanceAPI, p) p, err = store.GetProvenance(context.Background(), &ruleOrg3) require.NoError(t, err) require.Equal(t, models.ProvenanceFile, p) }) + + t.Run("Store should return all provenances by type", func(t *testing.T) { + const orgID = 123 + ruleOrg1 := models.AlertRule{ + UID: "789", + OrgID: orgID, + } + ruleOrg2 := models.AlertRule{ + UID: "790", + OrgID: orgID, + } + err := store.SetProvenance(context.Background(), &ruleOrg1, models.ProvenanceFile) + require.NoError(t, err) + err = store.SetProvenance(context.Background(), &ruleOrg2, models.ProvenanceAPI) + require.NoError(t, err) + + p, err := store.GetProvenances(context.Background(), orgID, ruleOrg1.ResourceType()) + require.NoError(t, err) + require.Len(t, p, 2) + require.Equal(t, models.ProvenanceFile, p[ruleOrg1.UID]) + require.Equal(t, models.ProvenanceAPI, p[ruleOrg2.UID]) + }) + + t.Run("Store should delete provenance correctly", func(t *testing.T) { + const orgID = 1234 + ruleOrg := models.AlertRule{ + UID: "7834539", + OrgID: orgID, + } + err := store.SetProvenance(context.Background(), &ruleOrg, models.ProvenanceFile) + require.NoError(t, err) + p, err := store.GetProvenance(context.Background(), &ruleOrg) + require.NoError(t, err) + require.Equal(t, models.ProvenanceFile, p) + + err = store.DeleteProvenance(context.Background(), &ruleOrg) + require.NoError(t, err) + + p, err = store.GetProvenance(context.Background(), &ruleOrg) + require.NoError(t, err) + require.Equal(t, models.ProvenanceNone, p) + }) } func createProvisioningStoreSut(_ *ngalert.AlertNG, db *store.DBstore) provisioning.ProvisioningStore { diff --git a/pkg/services/ngalert/store/testing.go b/pkg/services/ngalert/store/testing.go index 527626c3e98..0c69fd5f522 100644 --- a/pkg/services/ngalert/store/testing.go +++ b/pkg/services/ngalert/store/testing.go @@ -178,12 +178,6 @@ func (f *FakeRuleStore) GetOrgAlertRules(_ context.Context, q *models.ListAlertR q.Result = rules return nil } -func (f *FakeRuleStore) GetNamespaceAlertRules(_ context.Context, q *models.ListNamespaceAlertRulesQuery) error { - f.mtx.Lock() - defer f.mtx.Unlock() - f.RecordedOps = append(f.RecordedOps, *q) - return nil -} func (f *FakeRuleStore) GetAlertRules(_ context.Context, q *models.GetAlertRulesQuery) error { f.mtx.Lock() defer f.mtx.Unlock() @@ -219,8 +213,8 @@ func (f *FakeRuleStore) GetUserVisibleNamespaces(_ context.Context, orgID int64, return namespacesMap, nil } - for _, rule := range f.Rules[orgID] { - namespacesMap[rule.NamespaceUID] = &models2.Folder{} + for _, folder := range f.Folders[orgID] { + namespacesMap[folder.Uid] = folder } return namespacesMap, nil } @@ -233,36 +227,18 @@ func (f *FakeRuleStore) GetNamespaceByTitle(_ context.Context, title string, org } return nil, fmt.Errorf("not found") } -func (f *FakeRuleStore) GetOrgRuleGroups(_ context.Context, q *models.ListOrgRuleGroupsQuery) error { + +func (f *FakeRuleStore) UpdateAlertRules(_ context.Context, q []UpdateRule) error { f.mtx.Lock() defer f.mtx.Unlock() - f.RecordedOps = append(f.RecordedOps, *q) - if err := f.Hook(*q); err != nil { + f.RecordedOps = append(f.RecordedOps, q) + if err := f.Hook(q); err != nil { return err } - - // If we have namespaces, we want to try and retrieve the list of rules stored. - if len(q.NamespaceUIDs) != 0 { - rules, ok := f.Rules[q.OrgID] - if !ok { - return nil - } - - var ruleGroups [][]string - for _, rule := range rules { - for _, namespace := range q.NamespaceUIDs { - if rule.NamespaceUID == namespace { // if they match, they should go in. - ruleGroups = append(ruleGroups, []string{rule.RuleGroup, rule.NamespaceUID, rule.NamespaceUID}) - } - } - } - - q.Result = ruleGroups - } return nil } -func (f *FakeRuleStore) UpsertAlertRules(_ context.Context, q []UpsertRule) error { +func (f *FakeRuleStore) InsertAlertRules(_ context.Context, q []models.AlertRule) error { f.mtx.Lock() defer f.mtx.Unlock() f.RecordedOps = append(f.RecordedOps, q) diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index 35ef0930872..19de8f046a2 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -81,35 +81,34 @@ func CreateTestAlertRule(t *testing.T, ctx context.Context, dbstore *store.DBsto func CreateTestAlertRuleWithLabels(t *testing.T, ctx context.Context, dbstore *store.DBstore, intervalSeconds int64, orgID int64, labels map[string]string) *models.AlertRule { ruleGroup := fmt.Sprintf("ruleGroup-%s", util.GenerateShortUID()) - err := dbstore.UpsertAlertRules(ctx, []store.UpsertRule{ + err := dbstore.InsertAlertRules(ctx, []models.AlertRule{ { - New: models.AlertRule{ - ID: 0, - OrgID: orgID, - Title: fmt.Sprintf("an alert definition %s", util.GenerateShortUID()), - Condition: "A", - Data: []models.AlertQuery{ - { - Model: json.RawMessage(`{ + + ID: 0, + OrgID: orgID, + Title: fmt.Sprintf("an alert definition %s", util.GenerateShortUID()), + Condition: "A", + Data: []models.AlertQuery{ + { + Model: json.RawMessage(`{ "datasourceUid": "-100", "type":"math", "expression":"2 + 2 > 1" }`), - RelativeTimeRange: models.RelativeTimeRange{ - From: models.Duration(5 * time.Hour), - To: models.Duration(3 * time.Hour), - }, - RefID: "A", + RelativeTimeRange: models.RelativeTimeRange{ + From: models.Duration(5 * time.Hour), + To: models.Duration(3 * time.Hour), }, + RefID: "A", }, - Labels: labels, - Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, - IntervalSeconds: intervalSeconds, - NamespaceUID: "namespace", - RuleGroup: ruleGroup, - NoDataState: models.NoData, - ExecErrState: models.AlertingErrState, }, + Labels: labels, + Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, + IntervalSeconds: intervalSeconds, + NamespaceUID: "namespace", + RuleGroup: ruleGroup, + NoDataState: models.NoData, + ExecErrState: models.AlertingErrState, }, }) require.NoError(t, err) diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go index 221b3223891..7c86ebb8348 100644 --- a/pkg/services/notifications/notifications.go +++ b/pkg/services/notifications/notifications.go @@ -45,13 +45,6 @@ func ProvideService(bus bus.Bus, cfg *setting.Cfg, mailer Mailer, store TempUser store: store, } - ns.Bus.AddHandler(ns.SendResetPasswordEmail) - ns.Bus.AddHandler(ns.ValidateResetPasswordCode) - ns.Bus.AddHandler(ns.SendEmailCommandHandler) - - ns.Bus.AddHandler(ns.SendEmailCommandHandlerSync) - ns.Bus.AddHandler(ns.SendWebhookSync) - ns.Bus.AddEventListener(ns.signUpStartedHandler) ns.Bus.AddEventListener(ns.signUpCompletedHandler) diff --git a/pkg/services/notifications/notifications_test.go b/pkg/services/notifications/notifications_test.go index bd07a4dd291..0244c7df558 100644 --- a/pkg/services/notifications/notifications_test.go +++ b/pkg/services/notifications/notifications_test.go @@ -35,7 +35,7 @@ func TestSendEmailSync(t *testing.T) { bus := bus.New() t.Run("When sending emails synchronously", func(t *testing.T) { - _, mailer := createSut(t, bus) + ns, mailer := createSut(t, bus) cmd := &models.SendEmailCommandSync{ SendEmailCommand: models.SendEmailCommand{ Subject: "subject", @@ -44,8 +44,7 @@ func TestSendEmailSync(t *testing.T) { Template: "welcome_on_signup", }, } - - err := bus.Dispatch(context.Background(), cmd) + err := ns.SendEmailCommandHandlerSync(context.Background(), cmd) require.NoError(t, err) require.NotEmpty(t, mailer.Sent) @@ -55,7 +54,7 @@ func TestSendEmailSync(t *testing.T) { }) t.Run("When using Single Email mode with multiple recipients", func(t *testing.T) { - _, mailer := createSut(t, bus) + ns, mailer := createSut(t, bus) cmd := &models.SendEmailCommandSync{ SendEmailCommand: models.SendEmailCommand{ Subject: "subject", @@ -65,14 +64,14 @@ func TestSendEmailSync(t *testing.T) { }, } - err := bus.Dispatch(context.Background(), cmd) + err := ns.SendEmailCommandHandlerSync(context.Background(), cmd) require.NoError(t, err) require.Len(t, mailer.Sent, 1) }) t.Run("When using Multi Email mode with multiple recipients", func(t *testing.T) { - _, mailer := createSut(t, bus) + ns, mailer := createSut(t, bus) cmd := &models.SendEmailCommandSync{ SendEmailCommand: models.SendEmailCommand{ Subject: "subject", @@ -82,14 +81,14 @@ func TestSendEmailSync(t *testing.T) { }, } - err := bus.Dispatch(context.Background(), cmd) + err := ns.SendEmailCommandHandlerSync(context.Background(), cmd) require.NoError(t, err) require.Len(t, mailer.Sent, 3) }) t.Run("When attaching files to emails", func(t *testing.T) { - _, mailer := createSut(t, bus) + ns, mailer := createSut(t, bus) cmd := &models.SendEmailCommandSync{ SendEmailCommand: models.SendEmailCommand{ Subject: "subject", @@ -105,7 +104,7 @@ func TestSendEmailSync(t *testing.T) { }, } - err := bus.Dispatch(context.Background(), cmd) + err := ns.SendEmailCommandHandlerSync(context.Background(), cmd) require.NoError(t, err) require.NotEmpty(t, mailer.Sent) @@ -119,7 +118,7 @@ func TestSendEmailSync(t *testing.T) { t.Run("When SMTP disabled in configuration", func(t *testing.T) { cfg := createSmtpConfig() cfg.Smtp.Enabled = false - _, mailer, err := createSutWithConfig(t, bus, cfg) + ns, mailer, err := createSutWithConfig(t, bus, cfg) require.NoError(t, err) cmd := &models.SendEmailCommandSync{ SendEmailCommand: models.SendEmailCommand{ @@ -130,7 +129,7 @@ func TestSendEmailSync(t *testing.T) { }, } - err = bus.Dispatch(context.Background(), cmd) + err = ns.SendEmailCommandHandlerSync(context.Background(), cmd) require.ErrorIs(t, err, models.ErrSmtpNotEnabled) require.Empty(t, mailer.Sent) @@ -139,7 +138,7 @@ func TestSendEmailSync(t *testing.T) { t.Run("When invalid content type in configuration", func(t *testing.T) { cfg := createSmtpConfig() cfg.Smtp.ContentTypes = append(cfg.Smtp.ContentTypes, "multipart/form-data") - _, mailer, err := createSutWithConfig(t, bus, cfg) + ns, mailer, err := createSutWithConfig(t, bus, cfg) require.NoError(t, err) cmd := &models.SendEmailCommandSync{ SendEmailCommand: models.SendEmailCommand{ @@ -150,14 +149,14 @@ func TestSendEmailSync(t *testing.T) { }, } - err = bus.Dispatch(context.Background(), cmd) + err = ns.SendEmailCommandHandlerSync(context.Background(), cmd) require.Error(t, err) require.Empty(t, mailer.Sent) }) t.Run("When SMTP dialer is disconnected", func(t *testing.T) { - _ = createDisconnectedSut(t, bus) + ns := createDisconnectedSut(t, bus) cmd := &models.SendEmailCommandSync{ SendEmailCommand: models.SendEmailCommand{ Subject: "subject", @@ -167,7 +166,7 @@ func TestSendEmailSync(t *testing.T) { }, } - err := bus.Dispatch(context.Background(), cmd) + err := ns.SendEmailCommandHandlerSync(context.Background(), cmd) require.Error(t, err) }) @@ -210,7 +209,7 @@ func TestSendEmailAsync(t *testing.T) { t.Run("When invalid content type in configuration", func(t *testing.T) { cfg := createSmtpConfig() cfg.Smtp.ContentTypes = append(cfg.Smtp.ContentTypes, "multipart/form-data") - _, mailer, err := createSutWithConfig(t, bus, cfg) + ns, mailer, err := createSutWithConfig(t, bus, cfg) require.NoError(t, err) cmd := &models.SendEmailCommand{ Subject: "subject", @@ -219,14 +218,14 @@ func TestSendEmailAsync(t *testing.T) { Template: "welcome_on_signup", } - err = bus.Dispatch(context.Background(), cmd) + err = ns.SendEmailCommandHandler(context.Background(), cmd) require.Error(t, err) require.Empty(t, mailer.Sent) }) t.Run("When SMTP dialer is disconnected", func(t *testing.T) { - _ = createDisconnectedSut(t, bus) + ns := createDisconnectedSut(t, bus) cmd := &models.SendEmailCommand{ Subject: "subject", To: []string{"1@grafana.com", "2@grafana.com", "3@grafana.com"}, @@ -234,7 +233,7 @@ func TestSendEmailAsync(t *testing.T) { Template: "welcome_on_signup", } - err := bus.Dispatch(context.Background(), cmd) + err := ns.SendEmailCommandHandler(context.Background(), cmd) // The async version should not surface connection errors via Bus. It should only log them. require.NoError(t, err) diff --git a/pkg/services/notifications/webhook.go b/pkg/services/notifications/webhook.go index 2ea0ea19048..9c790c3c549 100644 --- a/pkg/services/notifications/webhook.go +++ b/pkg/services/notifications/webhook.go @@ -11,8 +11,6 @@ import ( "net/http" "time" - "golang.org/x/net/context/ctxhttp" - "github.com/grafana/grafana/pkg/util" ) @@ -52,7 +50,7 @@ func (ns *NotificationService) sendWebRequestSync(ctx context.Context, webhook * return fmt.Errorf("webhook only supports HTTP methods PUT or POST") } - request, err := http.NewRequest(webhook.HttpMethod, webhook.Url, bytes.NewReader([]byte(webhook.Body))) + request, err := http.NewRequestWithContext(ctx, webhook.HttpMethod, webhook.Url, bytes.NewReader([]byte(webhook.Body))) if err != nil { return err } @@ -72,7 +70,7 @@ func (ns *NotificationService) sendWebRequestSync(ctx context.Context, webhook * request.Header.Set(k, v) } - resp, err := ctxhttp.Do(ctx, netClient, request) + resp, err := netClient.Do(request) if err != nil { return err } diff --git a/pkg/services/plugindashboards/service/dashboard_updater_test.go b/pkg/services/plugindashboards/service/dashboard_updater_test.go index 5648bb613be..05a0e33630e 100644 --- a/pkg/services/plugindashboards/service/dashboard_updater_test.go +++ b/pkg/services/plugindashboards/service/dashboard_updater_test.go @@ -569,8 +569,6 @@ func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenar sCtx.dashboardService, ) - t.Cleanup(bus.ClearBusHandlers) - t.Run(desc, func(t *testing.T) { f(sCtx) }) diff --git a/pkg/services/preference/model.go b/pkg/services/preference/model.go new file mode 100644 index 00000000000..ae8b5ca665b --- /dev/null +++ b/pkg/services/preference/model.go @@ -0,0 +1,100 @@ +package pref + +import ( + "bytes" + "encoding/json" + "errors" + "time" +) + +var ErrPrefNotFound = errors.New("preference not found") + +type Preference struct { + ID int64 `xorm:"pk autoincr 'id'"` + OrgID int64 `xorm:"org_id"` + UserID int64 `xorm:"user_id"` + TeamID int64 `xorm:"team_id"` + Teams []int64 `xorm:"extends"` + Version int + HomeDashboardID int64 `xorm:"home_dashboard_id"` + Timezone string + WeekStart string + Theme string + Created time.Time + Updated time.Time + JSONData *PreferenceJSONData `xorm:"json_data"` +} + +type GetPreferenceWithDefaultsQuery struct { + Teams []int64 + OrgID int64 + UserID int64 +} + +type GetPreferenceQuery struct { + OrgID int64 + UserID int64 + TeamID int64 +} + +type SavePreferenceCommand struct { + UserID int64 + OrgID int64 + TeamID int64 + + HomeDashboardID int64 `json:"homeDashboardId,omitempty"` + Timezone string `json:"timezone,omitempty"` + WeekStart string `json:"weekStart,omitempty"` + Theme string `json:"theme,omitempty"` + Navbar *NavbarPreference `json:"navbar,omitempty"` + QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"` +} + +type PatchPreferenceCommand struct { + UserID int64 + OrgID int64 + TeamID int64 + + HomeDashboardID *int64 `json:"homeDashboardId,omitempty"` + Timezone *string `json:"timezone,omitempty"` + WeekStart *string `json:"weekStart,omitempty"` + Theme *string `json:"theme,omitempty"` + Navbar *NavbarPreference `json:"navbar,omitempty"` + QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"` +} + +type NavLink struct { + ID string `json:"id,omitempty"` + Text string `json:"text,omitempty"` + Url string `json:"url,omitempty"` + Target string `json:"target,omitempty"` +} + +type NavbarPreference struct { + SavedItems []NavLink `json:"savedItems"` +} + +type PreferenceJSONData struct { + Navbar NavbarPreference `json:"navbar"` + QueryHistory QueryHistoryPreference `json:"queryHistory"` +} + +type QueryHistoryPreference struct { + HomeTab string `json:"homeTab"` +} + +func (j *PreferenceJSONData) FromDB(data []byte) error { + dec := json.NewDecoder(bytes.NewBuffer(data)) + dec.UseNumber() + return dec.Decode(j) +} + +func (j *PreferenceJSONData) ToDB() ([]byte, error) { + if j == nil { + return nil, nil + } + + return json.Marshal(j) +} + +func (p Preference) TableName() string { return "preferences" } diff --git a/pkg/services/preference/pref.go b/pkg/services/preference/pref.go new file mode 100644 index 00000000000..5cf17789a25 --- /dev/null +++ b/pkg/services/preference/pref.go @@ -0,0 +1,13 @@ +package pref + +import ( + "context" +) + +type Service interface { + GetWithDefaults(context.Context, *GetPreferenceWithDefaultsQuery) (*Preference, error) + Get(context.Context, *GetPreferenceQuery) (*Preference, error) + Save(context.Context, *SavePreferenceCommand) error + Patch(ctx context.Context, cmd *PatchPreferenceCommand) error + GetDefaults() *Preference +} diff --git a/pkg/services/preference/prefimpl/pref.go b/pkg/services/preference/prefimpl/pref.go new file mode 100644 index 00000000000..c43fb7d98c5 --- /dev/null +++ b/pkg/services/preference/prefimpl/pref.go @@ -0,0 +1,203 @@ +package prefimpl + +import ( + "context" + "errors" + "time" + + pref "github.com/grafana/grafana/pkg/services/preference" + "github.com/grafana/grafana/pkg/services/sqlstore/db" + "github.com/grafana/grafana/pkg/setting" +) + +type Service struct { + store store + cfg *setting.Cfg +} + +func ProvideService(db db.DB, cfg *setting.Cfg) *Service { + return &Service{ + store: &sqlStore{ + db: db, + }, + cfg: cfg, + } +} + +func (s *Service) GetWithDefaults(ctx context.Context, query *pref.GetPreferenceWithDefaultsQuery) (*pref.Preference, error) { + listQuery := &pref.Preference{ + Teams: query.Teams, + OrgID: query.OrgID, + UserID: query.UserID, + } + prefs, err := s.store.List(ctx, listQuery) + if err != nil { + return nil, err + } + + res := s.GetDefaults() + for _, p := range prefs { + if p.Theme != "" { + res.Theme = p.Theme + } + if p.Timezone != "" { + res.Timezone = p.Timezone + } + if p.WeekStart != "" { + res.WeekStart = p.WeekStart + } + if p.HomeDashboardID != 0 { + res.HomeDashboardID = p.HomeDashboardID + } + if p.JSONData != nil { + res.JSONData = p.JSONData + } + } + + return res, err +} + +func (s *Service) Get(ctx context.Context, query *pref.GetPreferenceQuery) (*pref.Preference, error) { + getPref := &pref.Preference{ + OrgID: query.OrgID, + UserID: query.UserID, + TeamID: query.TeamID, + } + prefs, err := s.store.Get(ctx, getPref) + if err != nil && !errors.Is(err, pref.ErrPrefNotFound) { + return nil, err + } + return prefs, nil +} + +func (s *Service) Save(ctx context.Context, cmd *pref.SavePreferenceCommand) error { + preference, err := s.store.Get(ctx, &pref.Preference{ + OrgID: cmd.OrgID, + UserID: cmd.UserID, + TeamID: cmd.TeamID, + }) + if err != nil { + if errors.Is(err, pref.ErrPrefNotFound) { + preference := &pref.Preference{ + UserID: cmd.UserID, + OrgID: cmd.OrgID, + TeamID: cmd.TeamID, + HomeDashboardID: cmd.HomeDashboardID, + Timezone: cmd.Timezone, + WeekStart: cmd.WeekStart, + Theme: cmd.Theme, + Created: time.Now(), + Updated: time.Now(), + } + _, err = s.store.Insert(ctx, preference) + if err != nil { + return err + } + } + return err + } + preference.Timezone = cmd.Timezone + preference.WeekStart = cmd.WeekStart + preference.Theme = cmd.Theme + preference.Updated = time.Now() + preference.Version += 1 + preference.JSONData = &pref.PreferenceJSONData{} + + if cmd.Navbar != nil { + preference.JSONData.Navbar = *cmd.Navbar + } + if cmd.QueryHistory != nil { + preference.JSONData.QueryHistory = *cmd.QueryHistory + } + return s.store.Update(ctx, preference) +} + +func (s *Service) Patch(ctx context.Context, cmd *pref.PatchPreferenceCommand) error { + var exists bool + preference, err := s.store.Get(ctx, &pref.Preference{ + OrgID: cmd.OrgID, + UserID: cmd.UserID, + TeamID: cmd.TeamID, + }) + if err != nil && !errors.Is(err, pref.ErrPrefNotFound) { + return err + } + + if errors.Is(err, pref.ErrPrefNotFound) { + preference = &pref.Preference{ + UserID: cmd.UserID, + OrgID: cmd.OrgID, + TeamID: cmd.TeamID, + Created: time.Now(), + JSONData: &pref.PreferenceJSONData{}, + } + } else { + exists = true + } + + if cmd.Navbar != nil { + if preference.JSONData == nil { + preference.JSONData = &pref.PreferenceJSONData{} + } + if cmd.Navbar.SavedItems != nil { + preference.JSONData.Navbar.SavedItems = cmd.Navbar.SavedItems + } + } + + if cmd.QueryHistory != nil { + if preference.JSONData == nil { + preference.JSONData = &pref.PreferenceJSONData{} + } + if cmd.QueryHistory.HomeTab != "" { + preference.JSONData.QueryHistory.HomeTab = cmd.QueryHistory.HomeTab + } + } + + if cmd.HomeDashboardID != nil { + preference.HomeDashboardID = *cmd.HomeDashboardID + } + + if cmd.Timezone != nil { + preference.Timezone = *cmd.Timezone + } + + if cmd.WeekStart != nil { + preference.WeekStart = *cmd.WeekStart + } + + if cmd.Theme != nil { + preference.Theme = *cmd.Theme + } + + preference.Updated = time.Now() + preference.Version += 1 + + // Wrap this in an if statement to maintain backwards compatibility + if cmd.Navbar != nil { + if preference.JSONData == nil { + preference.JSONData = &pref.PreferenceJSONData{} + } + if cmd.Navbar.SavedItems != nil { + preference.JSONData.Navbar.SavedItems = cmd.Navbar.SavedItems + } + } + + if exists { + err = s.store.Update(ctx, preference) + } else { + _, err = s.store.Insert(ctx, preference) + } + return err +} + +func (s *Service) GetDefaults() *pref.Preference { + defaults := &pref.Preference{ + Theme: s.cfg.DefaultTheme, + Timezone: s.cfg.DateFormats.DefaultTimezone, + WeekStart: s.cfg.DateFormats.DefaultWeekStart, + HomeDashboardID: 0, + JSONData: &pref.PreferenceJSONData{}, + } + + return defaults +} diff --git a/pkg/services/preference/prefimpl/pref_test.go b/pkg/services/preference/prefimpl/pref_test.go new file mode 100644 index 00000000000..b1e439b6830 --- /dev/null +++ b/pkg/services/preference/prefimpl/pref_test.go @@ -0,0 +1,446 @@ +package prefimpl + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + pref "github.com/grafana/grafana/pkg/services/preference" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" +) + +func TestPreferencesService(t *testing.T) { + prefStoreFake := newPreferenceStoreFake() + prefService := &Service{ + store: prefStoreFake, + } + + emptyNavbarPreferences := pref.NavbarPreference{} + userNavbarPreferences := pref.NavbarPreference{ + SavedItems: []pref.NavLink{{ + ID: "explore", + Text: "Explore", + Url: "/explore", + }}, + } + orgNavbarPreferences := pref.NavbarPreference{ + SavedItems: []pref.NavLink{{ + ID: "alerting", + Text: "Alerting", + Url: "/alerting", + }}, + } + team1NavbarPreferences := pref.NavbarPreference{ + SavedItems: []pref.NavLink{{ + ID: "dashboards", + Text: "Dashboards", + Url: "/dashboards", + }}, + } + team2NavbarPreferences := pref.NavbarPreference{ + SavedItems: []pref.NavLink{{ + ID: "home", + Text: "Home", + Url: "/home", + }}, + } + + emptyQueryPreference := pref.QueryHistoryPreference{} + + queryPreference := pref.QueryHistoryPreference{ + HomeTab: "hometab", + } + + queryPreference2 := pref.QueryHistoryPreference{ + HomeTab: "hometab", + } + + emptyPreferencesJsonData := pref.PreferenceJSONData{ + Navbar: emptyNavbarPreferences, + } + userPreferencesJsonData := pref.PreferenceJSONData{ + Navbar: userNavbarPreferences, + QueryHistory: queryPreference, + } + orgPreferencesJsonData := pref.PreferenceJSONData{ + Navbar: orgNavbarPreferences, + } + team2PreferencesJsonData := pref.PreferenceJSONData{ + Navbar: team2NavbarPreferences, + } + team1PreferencesJsonData := pref.PreferenceJSONData{ + Navbar: team1NavbarPreferences, + } + + t.Run("GetDefaults should return defaults", func(t *testing.T) { + prefService.cfg = setting.NewCfg() + prefService.cfg.DefaultTheme = "light" + prefService.cfg.DateFormats.DefaultTimezone = "UTC" + + preferences := prefService.GetDefaults() + expected := &pref.Preference{ + Theme: "light", + Timezone: "UTC", + HomeDashboardID: 0, + JSONData: &pref.PreferenceJSONData{}, + } + if diff := cmp.Diff(expected, preferences); diff != "" { + t.Fatalf("Result mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("GetDefaults with no saved preferences should return defaults", func(t *testing.T) { + prefStoreFake.ExpectedPreference = &pref.Preference{ + Theme: "light", + Timezone: "UTC", + } + query := &pref.GetPreferenceWithDefaultsQuery{} + preferences, err := prefService.GetWithDefaults(context.Background(), query) + require.NoError(t, err) + expected := &pref.Preference{ + Theme: "light", + Timezone: "UTC", + HomeDashboardID: 0, + JSONData: &emptyPreferencesJsonData, + } + if diff := cmp.Diff(expected, preferences); diff != "" { + t.Fatalf("Result mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("GetWithDefaults with saved org and user home dashboard should return user home dashboard", func(t *testing.T) { + prefStoreFake.ExpectedPreference = &pref.Preference{} + prefStoreFake.ExpectedListPreferences = []*pref.Preference{ + { + OrgID: 1, + HomeDashboardID: 1, + Theme: "dark", + Timezone: "UTC", + }, + { + OrgID: 1, + UserID: 1, + HomeDashboardID: 4, + Theme: "light", + WeekStart: "1", + }, + } + query := &pref.GetPreferenceWithDefaultsQuery{OrgID: 1, UserID: 1} + preferences, err := prefService.GetWithDefaults(context.Background(), query) + require.NoError(t, err) + expected := &pref.Preference{ + Theme: "light", + Timezone: "UTC", + WeekStart: "1", + HomeDashboardID: 4, + JSONData: &pref.PreferenceJSONData{}, + } + if diff := cmp.Diff(expected, preferences); diff != "" { + t.Fatalf("Result mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("GetWithDefaults with saved org and other user home dashboard should return org home dashboard", func(t *testing.T) { + prefStoreFake.ExpectedPreference = &pref.Preference{} + prefStoreFake.ExpectedListPreferences = []*pref.Preference{ + { + OrgID: 1, + HomeDashboardID: 1, + Theme: "dark", + Timezone: "UTC", + WeekStart: "1", + }, + { + OrgID: 1, + UserID: 1, + HomeDashboardID: 4, + Theme: "light", + Timezone: "browser", + WeekStart: "2", + }, + } + prefService.GetDefaults().HomeDashboardID = 1 + query := &pref.GetPreferenceWithDefaultsQuery{OrgID: 1, UserID: 2} + preferences, err := prefService.GetWithDefaults(context.Background(), query) + require.NoError(t, err) + expected := &pref.Preference{ + Theme: "light", + Timezone: "browser", + WeekStart: "2", + HomeDashboardID: 4, + JSONData: &pref.PreferenceJSONData{}, + } + if diff := cmp.Diff(expected, preferences); diff != "" { + t.Fatalf("Result mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("GetPreferencesWithDefaults with saved org and user json data should return user json data", func(t *testing.T) { + prefStoreFake.ExpectedPreference = &pref.Preference{} + prefStoreFake.ExpectedListPreferences = []*pref.Preference{ + { + OrgID: 1, + JSONData: &orgPreferencesJsonData, + }, + { + OrgID: 1, + UserID: 1, + JSONData: &userPreferencesJsonData, + }, + } + query := &pref.GetPreferenceWithDefaultsQuery{OrgID: 1, UserID: 1} + preference, err := prefService.GetWithDefaults(context.Background(), query) + require.NoError(t, err) + require.Equal(t, &pref.Preference{ + Theme: "light", + JSONData: &userPreferencesJsonData, + Timezone: "UTC", + }, preference) + }) + + t.Run("GetWithDefaults with saved org and teams json data should return last team json data", func(t *testing.T) { + prefStoreFake.ExpectedPreference = &pref.Preference{} + prefStoreFake.ExpectedListPreferences = []*pref.Preference{ + { + OrgID: 1, + JSONData: &orgPreferencesJsonData, + }, + { + OrgID: 1, + TeamID: 2, + JSONData: &team1PreferencesJsonData, + }, + { + OrgID: 1, + TeamID: 3, + JSONData: &team2PreferencesJsonData, + }, + } + query := &pref.GetPreferenceWithDefaultsQuery{ + OrgID: 1, Teams: []int64{2, 3}, + } + preference, err := prefService.GetWithDefaults(context.Background(), query) + require.NoError(t, err) + require.Equal(t, &pref.Preference{ + Timezone: "UTC", + Theme: "light", + JSONData: &team2PreferencesJsonData, + }, preference) + }) + + t.Run("GetWithDefaults with saved org and teams home dashboard should return last team home dashboard", func(t *testing.T) { + prefStoreFake.ExpectedPreference = &pref.Preference{ + Theme: "dark", + Timezone: "UTC", + } + prefStoreFake.ExpectedListPreferences = []*pref.Preference{ + { + OrgID: 1, + HomeDashboardID: 1, + Theme: "light", + Timezone: "browser", + WeekStart: "1", + }, + { + OrgID: 1, + UserID: 1, + HomeDashboardID: 4, + Theme: "light", + Timezone: "browser", + WeekStart: "2", + }, + } + + query := &pref.GetPreferenceWithDefaultsQuery{OrgID: 1, Teams: []int64{2, 3}} + preferences, err := prefService.GetWithDefaults(context.Background(), query) + require.NoError(t, err) + expected := &pref.Preference{ + Theme: "light", + Timezone: "browser", + WeekStart: "2", + HomeDashboardID: 4, + JSONData: &pref.PreferenceJSONData{}, + } + if diff := cmp.Diff(expected, preferences); diff != "" { + t.Fatalf("Result mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("SavePreferences for a user should store correct values", func(t *testing.T) { + prefStoreFake.ExpectedPreference = &pref.Preference{ + ID: 1, + OrgID: 1, + UserID: 3, + TeamID: 6, + HomeDashboardID: 5, + Timezone: "browser", + WeekStart: "1", + Theme: "dark", + } + err := prefService.Save(context.Background(), + &pref.SavePreferenceCommand{ + Theme: "dark", + Timezone: "browser", + HomeDashboardID: 5, + WeekStart: "1"}, + ) + require.NoError(t, err) + }) + + t.Run("SavePreferences for a user should store correct values, when preference not found", func(t *testing.T) { + prefStoreFake.ExpectedGetError = pref.ErrPrefNotFound + prefStoreFake.ExpectedPreference = &pref.Preference{ + ID: 1, + OrgID: 1, + UserID: 3, + TeamID: 6, + HomeDashboardID: 5, + Timezone: "browser", + WeekStart: "1", + Theme: "dark", + } + err := prefService.Save(context.Background(), + &pref.SavePreferenceCommand{ + Theme: "dark", + Timezone: "browser", + HomeDashboardID: 5, + WeekStart: "1", + }, + ) + require.NoError(t, err) + prefStoreFake.ExpectedGetError = nil + }) + + t.Run("SavePreferences for a user should store correct values with nav and query history", func(t *testing.T) { + prefStoreFake.ExpectedPreference = &pref.Preference{ + ID: 1, + OrgID: 1, + UserID: 3, + TeamID: 6, + HomeDashboardID: 5, + Timezone: "browser", + WeekStart: "1", + Theme: "dark", + JSONData: &userPreferencesJsonData, + } + err := prefService.Save(context.Background(), + &pref.SavePreferenceCommand{ + Theme: "dark", + Timezone: "browser", + HomeDashboardID: 5, + WeekStart: "1", + Navbar: &userNavbarPreferences, + QueryHistory: &emptyQueryPreference, + }, + ) + require.NoError(t, err) + }) + + t.Run("Get for a user should store correct values", func(t *testing.T) { + prefStoreFake.ExpectedPreference = &pref.Preference{ + HomeDashboardID: 5, + Timezone: "browser", + WeekStart: "1", + Theme: "dark", + } + preference, err := prefService.Get(context.Background(), &pref.GetPreferenceQuery{}) + require.NoError(t, err) + + expected := &pref.Preference{ + ID: preference.ID, + Version: preference.Version, + HomeDashboardID: 5, + Timezone: "browser", + WeekStart: "1", + Theme: "dark", + Created: preference.Created, + Updated: preference.Updated, + } + if diff := cmp.Diff(expected, preference); diff != "" { + t.Fatalf("Result mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("Patch for a user should store correct values", func(t *testing.T) { + darkTheme := "dark" + prefStoreFake.ExpectedPreference = &pref.Preference{ + HomeDashboardID: 5, + Timezone: "browser", + WeekStart: "1", + Theme: "dark", + JSONData: &userPreferencesJsonData, + } + err := prefService.Patch(context.Background(), + &pref.PatchPreferenceCommand{ + Theme: &darkTheme, + Navbar: &userNavbarPreferences, + QueryHistory: &queryPreference2, + }) + require.NoError(t, err) + }) + + t.Run("Patch for a user should store correct values, without navbar and query history", func(t *testing.T) { + darkTheme := "dark" + prefStoreFake.ExpectedPreference = &pref.Preference{ + HomeDashboardID: 5, + Timezone: "browser", + WeekStart: "1", + Theme: "dark", + } + err := prefService.Patch(context.Background(), + &pref.PatchPreferenceCommand{ + Theme: &darkTheme, + Navbar: &userNavbarPreferences, + QueryHistory: &queryPreference2, + }) + require.NoError(t, err) + }) + + t.Run("Patch for a user should store correct values, when preference not found", func(t *testing.T) { + timezone := "browser" + weekStart := "1" + homeDashboardID := int64(5) + prefStoreFake.ExpectedGetError = pref.ErrPrefNotFound + prefStoreFake.ExpectedPreference = nil + + err := prefService.Patch(context.Background(), + &pref.PatchPreferenceCommand{ + HomeDashboardID: &homeDashboardID, + Timezone: &timezone, + WeekStart: &weekStart, + Navbar: &emptyNavbarPreferences, + QueryHistory: &emptyQueryPreference, + }) + require.NoError(t, err) + prefStoreFake.ExpectedGetError = nil + }) +} + +type FakePreferenceStore struct { + ExpectedPreference *pref.Preference + ExpectedListPreferences []*pref.Preference + ExpectedID int64 + ExpectedError error + ExpectedGetError error +} + +func newPreferenceStoreFake() *FakePreferenceStore { + return &FakePreferenceStore{} +} + +func (f *FakePreferenceStore) List(ctx context.Context, query *pref.Preference) ([]*pref.Preference, error) { + return f.ExpectedListPreferences, f.ExpectedError +} + +func (f *FakePreferenceStore) Get(ctx context.Context, query *pref.Preference) (*pref.Preference, error) { + return f.ExpectedPreference, f.ExpectedGetError +} + +func (f *FakePreferenceStore) Insert(ctx context.Context, cmd *pref.Preference) (int64, error) { + return f.ExpectedID, f.ExpectedError +} + +func (f *FakePreferenceStore) Update(ctx context.Context, cmd *pref.Preference) error { + return f.ExpectedError +} diff --git a/pkg/services/preference/prefimpl/store.go b/pkg/services/preference/prefimpl/store.go new file mode 100644 index 00000000000..139b723363b --- /dev/null +++ b/pkg/services/preference/prefimpl/store.go @@ -0,0 +1,88 @@ +package prefimpl + +import ( + "context" + "strings" + + pref "github.com/grafana/grafana/pkg/services/preference" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/db" +) + +type store interface { + Get(context.Context, *pref.Preference) (*pref.Preference, error) + List(context.Context, *pref.Preference) ([]*pref.Preference, error) + Insert(context.Context, *pref.Preference) (int64, error) + Update(context.Context, *pref.Preference) error +} + +type sqlStore struct { + db db.DB +} + +func (s *sqlStore) Get(ctx context.Context, query *pref.Preference) (*pref.Preference, error) { + var prefs pref.Preference + err := s.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + exist, err := sess.Where("org_id=? AND user_id=? AND team_id=?", query.OrgID, query.UserID, query.TeamID).Get(&prefs) + if err != nil { + return err + } + if !exist { + return pref.ErrPrefNotFound + } + return nil + }) + if err != nil { + return nil, err + } + return &prefs, nil +} + +func (s *sqlStore) List(ctx context.Context, query *pref.Preference) ([]*pref.Preference, error) { + prefs := make([]*pref.Preference, 0) + params := make([]interface{}, 0) + filter := "" + + if len(query.Teams) > 0 { + filter = "(org_id=? AND team_id IN (?" + strings.Repeat(",?", len(query.Teams)-1) + ")) OR " + params = append(params, query.OrgID) + for _, v := range query.Teams { + params = append(params, v) + } + } + + filter += "(org_id=? AND user_id=? AND team_id=0) OR (org_id=? AND team_id=0 AND user_id=0)" + params = append(params, query.OrgID) + params = append(params, query.UserID) + params = append(params, query.OrgID) + + err := s.db.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + err := dbSession.Where(filter, params...). + OrderBy("user_id ASC, team_id ASC"). + Find(&prefs) + + if err != nil { + return err + } + + return nil + }) + return prefs, err +} + +func (s *sqlStore) Update(ctx context.Context, cmd *pref.Preference) error { + return s.db.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + _, err := sess.ID(cmd.ID).AllCols().Update(cmd) + return err + }) +} + +func (s *sqlStore) Insert(ctx context.Context, cmd *pref.Preference) (int64, error) { + var ID int64 + var err error + err = s.db.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + ID, err = sess.Insert(cmd) + return err + }) + return ID, err +} diff --git a/pkg/services/preference/prefimpl/store_test.go b/pkg/services/preference/prefimpl/store_test.go new file mode 100644 index 00000000000..773b6dea57b --- /dev/null +++ b/pkg/services/preference/prefimpl/store_test.go @@ -0,0 +1,170 @@ +//go:build integration +// +build integration + +package prefimpl + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/grafana/grafana/pkg/models" + pref "github.com/grafana/grafana/pkg/services/preference" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/stretchr/testify/require" +) + +func TestPreferencesDataAccess(t *testing.T) { + ss := sqlstore.InitTestDB(t) + prefStore := sqlStore{db: ss} + orgNavbarPreferences := pref.NavbarPreference{ + SavedItems: []pref.NavLink{{ + ID: "alerting", + Text: "Alerting", + Url: "/alerting", + }}, + } + + t.Run("Get with saved org and user home dashboard returns not found", func(t *testing.T) { + query := &pref.Preference{OrgID: 1, UserID: 1, TeamID: 2} + prefs, err := prefStore.Get(context.Background(), query) + require.EqualError(t, err, pref.ErrPrefNotFound.Error()) + require.Nil(t, prefs) + }) + + t.Run("Get with saved org and user home dashboard should return user home dashboard", func(t *testing.T) { + _, err := prefStore.Insert(context.Background(), + &pref.Preference{ + OrgID: 1, + UserID: 1, + HomeDashboardID: 4, + TeamID: 2, + Created: time.Now(), + Updated: time.Now(), + }) + require.NoError(t, err) + + query := &pref.Preference{OrgID: 1, UserID: 1, TeamID: 2} + prefs, err := prefStore.Get(context.Background(), query) + require.NoError(t, err) + require.Equal(t, int64(4), prefs.HomeDashboardID) + }) + + t.Run("List with saved org and user home dashboard should return user home dashboard", func(t *testing.T) { + _, err := prefStore.Insert(context.Background(), + &pref.Preference{ + OrgID: 1, + UserID: 1, + TeamID: 3, + HomeDashboardID: 1, + Created: time.Now(), + Updated: time.Now(), + }) + require.NoError(t, err) + + query := &pref.Preference{OrgID: 1, UserID: 1, Teams: []int64{2}} + prefs, err := prefStore.List(context.Background(), query) + require.NoError(t, err) + require.Equal(t, int64(4), prefs[0].HomeDashboardID) + }) + + t.Run("List with saved org and other user home dashboard should return org home dashboard", func(t *testing.T) { + _, err := prefStore.Insert(context.Background(), + &pref.Preference{ + OrgID: 1, + UserID: 2, + TeamID: 3, + HomeDashboardID: 1, + Created: time.Now(), + Updated: time.Now(), + }) + require.NoError(t, err) + + query := &pref.Preference{OrgID: 1, UserID: 1, Teams: []int64{3}} + prefs, err := prefStore.List(context.Background(), query) + require.NoError(t, err) + require.Equal(t, int64(1), prefs[0].HomeDashboardID) + require.Equal(t, int64(1), prefs[1].HomeDashboardID) + }) + + t.Run("List with saved org and teams home dashboard should return last team home dashboard", func(t *testing.T) { + query := &pref.Preference{ + OrgID: 1, Teams: []int64{2, 3}, + } + prefs, err := prefStore.List(context.Background(), query) + require.NoError(t, err) + require.Equal(t, int64(4), prefs[0].HomeDashboardID) + require.Equal(t, int64(1), prefs[1].HomeDashboardID) + require.Equal(t, int64(1), prefs[2].HomeDashboardID) + }) + + t.Run("List with saved org and other teams home dashboard should return org home dashboard", func(t *testing.T) { + _, err := prefStore.Insert(context.Background(), &pref.Preference{OrgID: 1, HomeDashboardID: 1, Created: time.Now(), Updated: time.Now()}) + require.NoError(t, err) + _, err = prefStore.Insert(context.Background(), &pref.Preference{OrgID: 1, TeamID: 2, HomeDashboardID: 2, Created: time.Now(), Updated: time.Now()}) + require.NoError(t, err) + _, err = prefStore.Insert(context.Background(), &pref.Preference{OrgID: 1, TeamID: 3, HomeDashboardID: 3, Created: time.Now(), Updated: time.Now()}) + require.NoError(t, err) + + query := &pref.Preference{OrgID: 1} + prefs, err := prefStore.List(context.Background(), query) + require.NoError(t, err) + require.Equal(t, int64(1), prefs[0].HomeDashboardID) + }) + + t.Run("Update for a user should only modify a single value", func(t *testing.T) { + ss := sqlstore.InitTestDB(t) + prefStore := sqlStore{db: ss} + id, err := prefStore.Insert(context.Background(), &pref.Preference{ + UserID: models.SignedInUser{}.UserId, + Theme: "dark", + Timezone: "browser", + HomeDashboardID: 5, + WeekStart: "1", + JSONData: &pref.PreferenceJSONData{Navbar: orgNavbarPreferences}, + Created: time.Now(), + Updated: time.Now(), + }) + require.NoError(t, err) + + err = prefStore.Update(context.Background(), &pref.Preference{ + ID: id, + Theme: "dark", + HomeDashboardID: 5, + Timezone: "browser", + WeekStart: "1", + Created: time.Now(), + Updated: time.Now(), + JSONData: &pref.PreferenceJSONData{}, + }) + require.NoError(t, err) + query := &pref.Preference{} + prefs, err := prefStore.List(context.Background(), query) + require.NoError(t, err) + expected := &pref.Preference{ + ID: prefs[0].ID, + Version: prefs[0].Version, + HomeDashboardID: 5, + Timezone: "browser", + WeekStart: "1", + Theme: "dark", + JSONData: prefs[0].JSONData, + Created: prefs[0].Created, + Updated: prefs[0].Updated, + } + if diff := cmp.Diff(expected, prefs[0]); diff != "" { + t.Fatalf("Result mismatch (-want +got):\n%s", diff) + } + }) + t.Run("insert preference that does not exist", func(t *testing.T) { + _, err := prefStore.Insert(context.Background(), + &pref.Preference{ + UserID: models.SignedInUser{}.UserId, + Created: time.Now(), + Updated: time.Now(), + JSONData: &pref.PreferenceJSONData{}, + }) + require.NoError(t, err) + }) +} diff --git a/pkg/services/preference/preftest/fake.go b/pkg/services/preference/preftest/fake.go new file mode 100644 index 00000000000..f52acea2047 --- /dev/null +++ b/pkg/services/preference/preftest/fake.go @@ -0,0 +1,32 @@ +package preftest + +import ( + "context" + + pref "github.com/grafana/grafana/pkg/services/preference" +) + +type FakePreferenceService struct { + ExpectedPreference *pref.Preference + ExpectedError error +} + +func NewPreferenceServiceFake() *FakePreferenceService { + return &FakePreferenceService{} +} + +func (f *FakePreferenceService) GetWithDefaults(ctx context.Context, query *pref.GetPreferenceWithDefaultsQuery) (*pref.Preference, error) { + return f.ExpectedPreference, f.ExpectedError +} + +func (f *FakePreferenceService) Get(ctx context.Context, query *pref.GetPreferenceQuery) (*pref.Preference, error) { + return f.ExpectedPreference, f.ExpectedError +} + +func (f *FakePreferenceService) Save(ctx context.Context, cmd *pref.SavePreferenceCommand) error { + return f.ExpectedError +} + +func (f *FakePreferenceService) GetDefaults() *pref.Preference { + return f.ExpectedPreference +} diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go index 60752f263ac..881af5dd2cf 100644 --- a/pkg/services/provisioning/dashboards/dashboard.go +++ b/pkg/services/provisioning/dashboards/dashboard.go @@ -23,7 +23,7 @@ type DashboardProvisioner interface { } // DashboardProvisionerFactory creates DashboardProvisioners based on input -type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, utils.OrgStore) (DashboardProvisioner, error) +type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, utils.OrgStore, utils.DashboardStore) (DashboardProvisioner, error) // Provisioner is responsible for syncing dashboard from disk to Grafana's database. type Provisioner struct { @@ -35,7 +35,7 @@ type Provisioner struct { } // New returns a new DashboardProvisioner -func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, orgStore utils.OrgStore) (DashboardProvisioner, error) { +func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, orgStore utils.OrgStore, dashboardStore utils.DashboardStore) (DashboardProvisioner, error) { logger := log.New("provisioning.dashboard") cfgReader := &configReader{path: configDirectory, log: logger, orgStore: orgStore} configs, err := cfgReader.readConfig(ctx) @@ -43,7 +43,7 @@ func New(ctx context.Context, configDirectory string, provisioner dashboards.Das return nil, errutil.Wrap("Failed to read dashboards config", err) } - fileReaders, err := getFileReaders(configs, logger, provisioner) + fileReaders, err := getFileReaders(configs, logger, provisioner, dashboardStore) if err != nil { return nil, errutil.Wrap("Failed to initialize file readers", err) } @@ -123,14 +123,14 @@ func (provider *Provisioner) GetAllowUIUpdatesFromConfig(name string) bool { } func getFileReaders( - configs []*config, logger log.Logger, service dashboards.DashboardProvisioningService, + configs []*config, logger log.Logger, service dashboards.DashboardProvisioningService, store utils.DashboardStore, ) ([]*FileReader, error) { var readers []*FileReader for _, config := range configs { switch config.Type { case "file": - fileReader, err := NewDashboardFileReader(config, logger.New("type", config.Type, "name", config.Name), service) + fileReader, err := NewDashboardFileReader(config, logger.New("type", config.Type, "name", config.Name), service, store) if err != nil { return nil, errutil.Wrapf(err, "Failed to create file reader for config %v", config.Name) } diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index 36dfdf7c541..827a941a87a 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -11,12 +11,12 @@ import ( "sync" "time" - "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/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/provisioning/utils" "github.com/grafana/grafana/pkg/util" ) @@ -33,6 +33,7 @@ type FileReader struct { Path string log log.Logger dashboardProvisioningService dashboards.DashboardProvisioningService + dashboardStore utils.DashboardStore FoldersFromFilesStructure bool mux sync.RWMutex @@ -41,7 +42,7 @@ type FileReader struct { } // NewDashboardFileReader returns a new filereader based on `config` -func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.DashboardProvisioningService) (*FileReader, error) { +func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.DashboardProvisioningService, dashboardStore utils.DashboardStore) (*FileReader, error) { var path string path, ok := cfg.Options["path"].(string) if !ok { @@ -63,6 +64,7 @@ func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.Dash Path: path, log: log, dashboardProvisioningService: service, + dashboardStore: dashboardStore, FoldersFromFilesStructure: foldersFromFilesStructure, usageTracker: newUsageTracker(), }, nil @@ -298,7 +300,7 @@ func (fr *FileReader) getOrCreateFolderID(ctx context.Context, cfg *config, serv } cmd := &models.GetDashboardQuery{Slug: models.SlugifyTitle(folderName), OrgId: cfg.OrgID} - err := bus.Dispatch(ctx, cmd) + err := fr.dashboardStore.GetDashboard(ctx, cmd) if err != nil && !errors.Is(err, models.ErrDashboardNotFound) { return 0, err diff --git a/pkg/services/provisioning/dashboards/file_reader_linux_test.go b/pkg/services/provisioning/dashboards/file_reader_symlink_test.go similarity index 91% rename from pkg/services/provisioning/dashboards/file_reader_linux_test.go rename to pkg/services/provisioning/dashboards/file_reader_symlink_test.go index c8f288d52f4..91b356e251a 100644 --- a/pkg/services/provisioning/dashboards/file_reader_linux_test.go +++ b/pkg/services/provisioning/dashboards/file_reader_symlink_test.go @@ -1,5 +1,5 @@ -//go:build linux -// +build linux +//go:build linux || darwin +// +build linux darwin package dashboards @@ -25,7 +25,7 @@ func TestProvisionedSymlinkedFolder(t *testing.T) { Options: map[string]interface{}{"path": symlinkedFolder}, } - reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil) + reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil) if err != nil { t.Error("expected err to be nil") } diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go index 8e6b01f55d1..627ed6041b8 100644 --- a/pkg/services/provisioning/dashboards/file_reader_test.go +++ b/pkg/services/provisioning/dashboards/file_reader_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" @@ -42,7 +41,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) { t.Run("using path parameter", func(t *testing.T) { cfg := setup() cfg.Options["path"] = defaultDashboards - reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil) + reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil) require.NoError(t, err) require.NotEqual(t, reader.Path, "") }) @@ -50,7 +49,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) { t.Run("using folder as options", func(t *testing.T) { cfg := setup() cfg.Options["folder"] = defaultDashboards - reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil) + reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil) require.NoError(t, err) require.NotEqual(t, reader.Path, "") }) @@ -59,7 +58,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) { cfg := setup() cfg.Options["path"] = foldersFromFilesStructure cfg.Options["foldersFromFilesStructure"] = true - reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil) + reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil) require.NoError(t, err) require.NotEqual(t, reader.Path, "") }) @@ -72,7 +71,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) { } cfg.Options["folder"] = fullPath - reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil) + reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil) require.NoError(t, err) require.Equal(t, reader.Path, fullPath) @@ -82,7 +81,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) { t.Run("using relative path", func(t *testing.T) { cfg := setup() cfg.Options["folder"] = defaultDashboards - reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil) + reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil) require.NoError(t, err) resolvedPath := reader.resolvedPath() @@ -96,9 +95,8 @@ func TestDashboardFileReader(t *testing.T) { fakeService := &dashboards.FakeDashboardProvisioning{} defer fakeService.AssertExpectations(t) + fakeStore := &fakeDashboardStore{} setup := func() { - bus.ClearBusHandlers() - bus.AddHandler("test", mockGetDashboardQuery) cfg = &config{ Name: configName, Type: "file", @@ -118,7 +116,7 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{Id: 1}, nil).Once() fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{Id: 2}, nil).Times(2) - reader, err := NewDashboardFileReader(cfg, logger, nil) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -138,7 +136,7 @@ func TestDashboardFileReader(t *testing.T) { inserted++ }) - reader, err := NewDashboardFileReader(cfg, logger, nil) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -175,7 +173,7 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once() - reader, err := NewDashboardFileReader(cfg, logger, nil) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -203,7 +201,7 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once() fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once() - reader, err := NewDashboardFileReader(cfg, logger, nil) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -238,7 +236,7 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once() - reader, err := NewDashboardFileReader(cfg, logger, nil) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -266,7 +264,7 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once() fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once() - reader, err := NewDashboardFileReader(cfg, logger, nil) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -281,7 +279,7 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("GetProvisionedDashboardData", configName).Return(nil, nil).Once() fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once() - reader, err := NewDashboardFileReader(cfg, logger, nil) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -298,7 +296,7 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2) fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(3) - reader, err := NewDashboardFileReader(cfg, logger, nil) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -315,7 +313,7 @@ func TestDashboardFileReader(t *testing.T) { Folder: "", } - _, err := NewDashboardFileReader(cfg, logger, nil) + _, err := NewDashboardFileReader(cfg, logger, nil, nil) require.NotNil(t, err) }) @@ -323,7 +321,7 @@ func TestDashboardFileReader(t *testing.T) { setup() cfg.Options["path"] = brokenDashboards - _, err := NewDashboardFileReader(cfg, logger, nil) + _, err := NewDashboardFileReader(cfg, logger, nil, nil) require.NoError(t, err) }) @@ -336,14 +334,14 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2) fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2) - reader1, err := NewDashboardFileReader(cfg1, logger, nil) + reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore) reader1.dashboardProvisioningService = fakeService require.NoError(t, err) err = reader1.walkDisk(context.Background()) require.NoError(t, err) - reader2, err := NewDashboardFileReader(cfg2, logger, nil) + reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore) reader2.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -363,7 +361,7 @@ func TestDashboardFileReader(t *testing.T) { "folder": defaultDashboards, }, } - r, err := NewDashboardFileReader(cfg, logger, nil) + r, err := NewDashboardFileReader(cfg, logger, nil, nil) require.NoError(t, err) _, err = r.getOrCreateFolderID(context.Background(), cfg, fakeService, cfg.Folder) @@ -383,7 +381,7 @@ func TestDashboardFileReader(t *testing.T) { } fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{Id: 1}, nil).Once() - r, err := NewDashboardFileReader(cfg, logger, nil) + r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) require.NoError(t, err) _, err = r.getOrCreateFolderID(context.Background(), cfg, fakeService, cfg.Folder) @@ -403,7 +401,7 @@ func TestDashboardFileReader(t *testing.T) { }, } - r, err := NewDashboardFileReader(cfg, logger, nil) + r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) require.NoError(t, err) _, err = r.getOrCreateFolderID(context.Background(), cfg, fakeService, cfg.Folder) @@ -458,7 +456,7 @@ func TestDashboardFileReader(t *testing.T) { cfg.DisableDeletion = true - reader, err := NewDashboardFileReader(cfg, logger, nil) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -473,7 +471,7 @@ func TestDashboardFileReader(t *testing.T) { fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once() fakeService.On("DeleteProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() - reader, err := NewDashboardFileReader(cfg, logger, nil) + reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) reader.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -512,6 +510,8 @@ func (ffi FakeFileInfo) Sys() interface{} { return nil } -func mockGetDashboardQuery(_ context.Context, _ *models.GetDashboardQuery) error { +type fakeDashboardStore struct{} + +func (fds *fakeDashboardStore) GetDashboard(_ context.Context, _ *models.GetDashboardQuery) error { return models.ErrDashboardNotFound } diff --git a/pkg/services/provisioning/dashboards/validator_test.go b/pkg/services/provisioning/dashboards/validator_test.go index 1af1e7c05ff..b3e39e3072d 100644 --- a/pkg/services/provisioning/dashboards/validator_test.go +++ b/pkg/services/provisioning/dashboards/validator_test.go @@ -5,7 +5,6 @@ import ( "sort" "testing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" @@ -19,11 +18,9 @@ const ( ) func TestDuplicatesValidator(t *testing.T) { - bus.ClearBusHandlers() fakeService := &dashboards.FakeDashboardProvisioning{} defer fakeService.AssertExpectations(t) - bus.AddHandler("test", mockGetDashboardQuery) cfg := &config{ Name: "Default", Type: "file", @@ -36,7 +33,8 @@ func TestDuplicatesValidator(t *testing.T) { t.Run("Duplicates validator should collect info about duplicate UIDs and titles within folders", func(t *testing.T) { const folderName = "duplicates-validator-folder" - r, err := NewDashboardFileReader(cfg, logger, nil) + fakeStore := &fakeDashboardStore{} + r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) require.NoError(t, err) fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(6) fakeService.On("GetProvisionedDashboardData", mock.Anything).Return([]*models.DashboardProvisioning{}, nil).Times(4) @@ -55,11 +53,11 @@ func TestDuplicatesValidator(t *testing.T) { Options: map[string]interface{}{"path": dashboardContainingUID}, } - reader1, err := NewDashboardFileReader(cfg1, logger, nil) + reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore) reader1.dashboardProvisioningService = fakeService require.NoError(t, err) - reader2, err := NewDashboardFileReader(cfg2, logger, nil) + reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore) reader2.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -91,7 +89,8 @@ func TestDuplicatesValidator(t *testing.T) { t.Run("Duplicates validator should not collect info about duplicate UIDs and titles within folders for different orgs", func(t *testing.T) { const folderName = "duplicates-validator-folder" - r, err := NewDashboardFileReader(cfg, logger, nil) + fakeStore := &fakeDashboardStore{} + r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) require.NoError(t, err) folderID, err := r.getOrCreateFolderID(context.Background(), cfg, fakeService, folderName) require.NoError(t, err) @@ -107,11 +106,11 @@ func TestDuplicatesValidator(t *testing.T) { Options: map[string]interface{}{"path": dashboardContainingUID}, } - reader1, err := NewDashboardFileReader(cfg1, logger, nil) + reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore) reader1.dashboardProvisioningService = fakeService require.NoError(t, err) - reader2, err := NewDashboardFileReader(cfg2, logger, nil) + reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore) reader2.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -154,6 +153,7 @@ func TestDuplicatesValidator(t *testing.T) { fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(5) fakeService.On("GetProvisionedDashboardData", mock.Anything).Return([]*models.DashboardProvisioning{}, nil).Times(3) fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(5) + fakeStore := &fakeDashboardStore{} cfg1 := &config{ Name: "first", Type: "file", OrgID: 1, Folder: "duplicates-validator-folder", @@ -167,15 +167,15 @@ func TestDuplicatesValidator(t *testing.T) { Name: "third", Type: "file", OrgID: 2, Folder: "duplicates-validator-folder", Options: map[string]interface{}{"path": twoDashboardsWithUID}, } - reader1, err := NewDashboardFileReader(cfg1, logger, nil) + reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore) reader1.dashboardProvisioningService = fakeService require.NoError(t, err) - reader2, err := NewDashboardFileReader(cfg2, logger, nil) + reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore) reader2.dashboardProvisioningService = fakeService require.NoError(t, err) - reader3, err := NewDashboardFileReader(cfg3, logger, nil) + reader3, err := NewDashboardFileReader(cfg3, logger, nil, fakeStore) reader3.dashboardProvisioningService = fakeService require.NoError(t, err) @@ -192,7 +192,7 @@ func TestDuplicatesValidator(t *testing.T) { duplicates := duplicateValidator.getDuplicates() - r, err := NewDashboardFileReader(cfg, logger, nil) + r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore) require.NoError(t, err) folderID, err := r.getOrCreateFolderID(context.Background(), cfg, fakeService, cfg1.Folder) require.NoError(t, err) @@ -209,7 +209,7 @@ func TestDuplicatesValidator(t *testing.T) { sort.Strings(titleUsageReaders) require.Equal(t, []string{"first"}, titleUsageReaders) - r, err = NewDashboardFileReader(cfg3, logger, nil) + r, err = NewDashboardFileReader(cfg3, logger, nil, fakeStore) require.NoError(t, err) folderID, err = r.getOrCreateFolderID(context.Background(), cfg3, fakeService, cfg3.Folder) require.NoError(t, err) diff --git a/pkg/services/provisioning/notifiers/alert_notifications.go b/pkg/services/provisioning/notifiers/alert_notifications.go index aac68649cda..de1621a7177 100644 --- a/pkg/services/provisioning/notifiers/alert_notifications.go +++ b/pkg/services/provisioning/notifiers/alert_notifications.go @@ -1,11 +1,12 @@ package notifiers import ( + "context" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/encryption" "github.com/grafana/grafana/pkg/services/notifications" - "golang.org/x/net/context" ) type Manager interface { diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index 4fb9202524f..091d3329aed 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -187,7 +187,7 @@ func (ps *ProvisioningServiceImpl) ProvisionNotifications(ctx context.Context) e func (ps *ProvisioningServiceImpl) ProvisionDashboards(ctx context.Context) error { dashboardPath := filepath.Join(ps.Cfg.ProvisioningPath, "dashboards") - dashProvisioner, err := ps.newDashboardProvisioner(ctx, dashboardPath, ps.dashboardService, ps.SQLStore) + dashProvisioner, err := ps.newDashboardProvisioner(ctx, dashboardPath, ps.dashboardService, ps.SQLStore, ps.SQLStore) if err != nil { return errutil.Wrap("Failed to create provisioner", err) } diff --git a/pkg/services/provisioning/provisioning_test.go b/pkg/services/provisioning/provisioning_test.go index 4493d6569dd..ad2547e3ed6 100644 --- a/pkg/services/provisioning/provisioning_test.go +++ b/pkg/services/provisioning/provisioning_test.go @@ -93,7 +93,7 @@ func setup() *serviceTestStruct { } serviceTest.service = newProvisioningServiceImpl( - func(context.Context, string, dashboardstore.DashboardProvisioningService, utils.OrgStore) (dashboards.DashboardProvisioner, error) { + func(context.Context, string, dashboardstore.DashboardProvisioningService, utils.OrgStore, utils.DashboardStore) (dashboards.DashboardProvisioner, error) { return serviceTest.mock, nil }, nil, diff --git a/pkg/services/provisioning/utils/utils.go b/pkg/services/provisioning/utils/utils.go index 18f74408b52..315f4fb251e 100644 --- a/pkg/services/provisioning/utils/utils.go +++ b/pkg/services/provisioning/utils/utils.go @@ -12,6 +12,10 @@ type OrgStore interface { GetOrgById(context.Context, *models.GetOrgByIdQuery) error } +type DashboardStore interface { + GetDashboard(context.Context, *models.GetDashboardQuery) error +} + func CheckOrgExists(ctx context.Context, store OrgStore, orgID int64) error { query := models.GetOrgByIdQuery{Id: orgID} if err := store.GetOrgById(ctx, &query); err != nil { diff --git a/pkg/services/queryhistory/api.go b/pkg/services/queryhistory/api.go index dc2245407f4..fd719cf7b9e 100644 --- a/pkg/services/queryhistory/api.go +++ b/pkg/services/queryhistory/api.go @@ -19,6 +19,8 @@ func (s *QueryHistoryService) registerAPIEndpoints() { entities.Post("/star/:uid", middleware.ReqSignedIn, routing.Wrap(s.starHandler)) entities.Delete("/star/:uid", middleware.ReqSignedIn, routing.Wrap(s.unstarHandler)) entities.Patch("/:uid", middleware.ReqSignedIn, routing.Wrap(s.patchCommentHandler)) + // Remove migrate endpoint in Grafana v10 as breaking change + entities.Post("/migrate", middleware.ReqSignedIn, routing.Wrap(s.migrateHandler)) }) } @@ -117,3 +119,17 @@ func (s *QueryHistoryService) unstarHandler(c *models.ReqContext) response.Respo return response.JSON(http.StatusOK, QueryHistoryResponse{Result: query}) } + +func (s *QueryHistoryService) migrateHandler(c *models.ReqContext) response.Response { + cmd := MigrateQueriesToQueryHistoryCommand{} + if err := web.Bind(c.Req, &cmd); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + + totalCount, starredCount, err := s.MigrateQueriesToQueryHistory(c.Req.Context(), c.SignedInUser, cmd) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to migrate query history", err) + } + + return response.JSON(http.StatusOK, QueryHistoryMigrationResponse{Message: "Query history successfully migrated", TotalCount: totalCount, StarredCount: starredCount}) +} diff --git a/pkg/services/queryhistory/database.go b/pkg/services/queryhistory/database.go index edb16de741f..3f718a6b69a 100644 --- a/pkg/services/queryhistory/database.go +++ b/pkg/services/queryhistory/database.go @@ -3,6 +3,7 @@ package queryhistory import ( "context" "errors" + "fmt" "time" "github.com/grafana/grafana/pkg/models" @@ -265,3 +266,61 @@ func (s QueryHistoryService) unstarQuery(ctx context.Context, user *models.Signe return dto, nil } + +func (s QueryHistoryService) migrateQueries(ctx context.Context, user *models.SignedInUser, cmd MigrateQueriesToQueryHistoryCommand) (int, int, error) { + queryHistories := make([]*QueryHistory, 0, len(cmd.Queries)) + starredQueries := make([]*QueryHistoryStar, 0) + + err := s.SQLStore.WithTransactionalDbSession(ctx, func(session *sqlstore.DBSession) error { + for _, query := range cmd.Queries { + uid := util.GenerateShortUID() + queryHistories = append(queryHistories, &QueryHistory{ + OrgID: user.OrgId, + UID: uid, + Queries: query.Queries, + DatasourceUID: query.DatasourceUID, + CreatedBy: user.UserId, + CreatedAt: query.CreatedAt, + Comment: query.Comment, + }) + + if query.Starred { + starredQueries = append(starredQueries, &QueryHistoryStar{ + UserID: user.UserId, + QueryUID: uid, + }) + } + } + + batchSize := 50 + var err error + for i := 0; i < len(queryHistories); i += batchSize { + j := i + batchSize + if j > len(queryHistories) { + j = len(queryHistories) + } + _, err = session.InsertMulti(queryHistories[i:j]) + if err != nil { + return err + } + } + + for i := 0; i < len(starredQueries); i += batchSize { + j := i + batchSize + if j > len(starredQueries) { + j = len(starredQueries) + } + _, err = session.InsertMulti(starredQueries[i:j]) + if err != nil { + return err + } + } + return err + }) + + if err != nil { + return 0, 0, fmt.Errorf("failed to migrate query history: %w", err) + } + + return len(queryHistories), len(starredQueries), nil +} diff --git a/pkg/services/queryhistory/models.go b/pkg/services/queryhistory/models.go index 7dfc9de69a0..19d7d77ad5c 100644 --- a/pkg/services/queryhistory/models.go +++ b/pkg/services/queryhistory/models.go @@ -78,3 +78,21 @@ type DeleteQueryFromQueryHistoryResponse struct { ID int64 `json:"id"` Message string `json:"message"` } + +type MigrateQueriesToQueryHistoryCommand struct { + Queries []QueryToMigrate `json:"queries"` +} + +type QueryToMigrate struct { + DatasourceUID string `json:"datasourceUid"` + Queries *simplejson.Json `json:"queries"` + CreatedAt int64 `json:"createdAt"` + Comment string `json:"comment"` + Starred bool `json:"starred"` +} + +type QueryHistoryMigrationResponse struct { + Message string `json:"message"` + TotalCount int `json:"totalCount"` + StarredCount int `json:"starredCount"` +} diff --git a/pkg/services/queryhistory/queryhistory.go b/pkg/services/queryhistory/queryhistory.go index bd8c88ab826..c3c1290cb00 100644 --- a/pkg/services/queryhistory/queryhistory.go +++ b/pkg/services/queryhistory/queryhistory.go @@ -33,6 +33,7 @@ type Service interface { PatchQueryCommentInQueryHistory(ctx context.Context, user *models.SignedInUser, UID string, cmd PatchQueryCommentInQueryHistoryCommand) (QueryHistoryDTO, error) StarQueryInQueryHistory(ctx context.Context, user *models.SignedInUser, UID string) (QueryHistoryDTO, error) UnstarQueryInQueryHistory(ctx context.Context, user *models.SignedInUser, UID string) (QueryHistoryDTO, error) + MigrateQueriesToQueryHistory(ctx context.Context, user *models.SignedInUser, cmd MigrateQueriesToQueryHistoryCommand) (int, int, error) } type QueryHistoryService struct { @@ -65,3 +66,7 @@ func (s QueryHistoryService) StarQueryInQueryHistory(ctx context.Context, user * func (s QueryHistoryService) UnstarQueryInQueryHistory(ctx context.Context, user *models.SignedInUser, UID string) (QueryHistoryDTO, error) { return s.unstarQuery(ctx, user, UID) } + +func (s QueryHistoryService) MigrateQueriesToQueryHistory(ctx context.Context, user *models.SignedInUser, cmd MigrateQueriesToQueryHistoryCommand) (int, int, error) { + return s.migrateQueries(ctx, user, cmd) +} diff --git a/pkg/services/queryhistory/queryhistory_migrate_test.go b/pkg/services/queryhistory/queryhistory_migrate_test.go new file mode 100644 index 00000000000..eb896dc9b33 --- /dev/null +++ b/pkg/services/queryhistory/queryhistory_migrate_test.go @@ -0,0 +1,120 @@ +//go:build integration +// +build integration + +package queryhistory + +import ( + "encoding/json" + "testing" + "time" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/stretchr/testify/require" +) + +func TestMigrateQueriesToQueryHistory(t *testing.T) { + testScenario(t, "When users tries to migrate 1 query in query history it should succeed", + func(t *testing.T, sc scenarioContext) { + command := MigrateQueriesToQueryHistoryCommand{ + Queries: []QueryToMigrate{ + { + DatasourceUID: "NCzh67i", + Queries: simplejson.NewFromAny(map[string]interface{}{ + "expr": "test", + }), + Comment: "", + Starred: false, + CreatedAt: time.Now().Unix(), + }, + }, + } + sc.reqContext.Req.Body = mockRequestBody(command) + resp := sc.service.migrateHandler(sc.reqContext) + var response QueryHistoryMigrationResponse + err := json.Unmarshal(resp.Body(), &response) + require.NoError(t, err) + require.Equal(t, 200, resp.Status()) + require.Equal(t, "Query history successfully migrated", response.Message) + require.Equal(t, 1, response.TotalCount) + require.Equal(t, 0, response.StarredCount) + }) + + testScenario(t, "When users tries to migrate multiple queries in query history it should succeed", + func(t *testing.T, sc scenarioContext) { + command := MigrateQueriesToQueryHistoryCommand{ + Queries: []QueryToMigrate{ + { + DatasourceUID: "NCzh67i", + Queries: simplejson.NewFromAny(map[string]interface{}{ + "expr": "test1", + }), + Comment: "", + Starred: false, + CreatedAt: time.Now().Unix(), + }, + { + DatasourceUID: "NCzh67i", + Queries: simplejson.NewFromAny(map[string]interface{}{ + "expr": "test2", + }), + Comment: "", + Starred: false, + CreatedAt: time.Now().Unix() - int64(100), + }, + { + DatasourceUID: "ABch68f", + Queries: simplejson.NewFromAny(map[string]interface{}{ + "expr": "test3", + }), + Comment: "", + Starred: false, + CreatedAt: time.Now().Unix() - int64(1000), + }, + }, + } + sc.reqContext.Req.Body = mockRequestBody(command) + resp := sc.service.migrateHandler(sc.reqContext) + var response QueryHistoryMigrationResponse + err := json.Unmarshal(resp.Body(), &response) + require.NoError(t, err) + require.Equal(t, 200, resp.Status()) + require.Equal(t, "Query history successfully migrated", response.Message) + require.Equal(t, 3, response.TotalCount) + require.Equal(t, 0, response.StarredCount) + }) + + testScenario(t, "When users tries to migrate starred and not starred query in query history it should succeed", + func(t *testing.T, sc scenarioContext) { + command := MigrateQueriesToQueryHistoryCommand{ + Queries: []QueryToMigrate{ + { + DatasourceUID: "NCzh67i", + Queries: simplejson.NewFromAny(map[string]interface{}{ + "expr": "test1", + }), + Comment: "", + Starred: true, + CreatedAt: time.Now().Unix(), + }, + { + DatasourceUID: "NCzh67i", + Queries: simplejson.NewFromAny(map[string]interface{}{ + "expr": "test2", + }), + Comment: "", + Starred: false, + CreatedAt: time.Now().Unix() - int64(100), + }, + }, + } + sc.reqContext.Req.Body = mockRequestBody(command) + resp := sc.service.migrateHandler(sc.reqContext) + var response QueryHistoryMigrationResponse + err := json.Unmarshal(resp.Body(), &response) + require.NoError(t, err) + require.Equal(t, 200, resp.Status()) + require.Equal(t, "Query history successfully migrated", response.Message) + require.Equal(t, 2, response.TotalCount) + require.Equal(t, 1, response.StarredCount) + }) +} diff --git a/pkg/services/queryhistory/queryhistory_search_test.go b/pkg/services/queryhistory/queryhistory_search_test.go index f459cd4fa7e..9d3db5ca328 100644 --- a/pkg/services/queryhistory/queryhistory_search_test.go +++ b/pkg/services/queryhistory/queryhistory_search_test.go @@ -111,7 +111,7 @@ func TestGetQueriesFromQueryHistory(t *testing.T) { err := json.Unmarshal(resp.Body(), &response) require.NoError(t, err) require.Equal(t, 200, resp.Status()) - require.Equal(t, 1, response.Result.TotalCount) + require.Equal(t, 2, response.Result.TotalCount) require.Equal(t, true, response.Result.QueryHistory[0].Starred) }) } diff --git a/pkg/services/queryhistory/queryhistory_test.go b/pkg/services/queryhistory/queryhistory_test.go index fe9dfecedeb..b88c3161309 100644 --- a/pkg/services/queryhistory/queryhistory_test.go +++ b/pkg/services/queryhistory/queryhistory_test.go @@ -115,6 +115,12 @@ func testScenarioWithMultipleQueriesInQueryHistory(t *testing.T, desc string, fn resp1 := sc.service.createHandler(sc.reqContext) sc.initialResult = validateAndUnMarshalResponse(t, resp1) + // Add comment + cmd := PatchQueryCommentInQueryHistoryCommand{Comment: "test comment 2"} + sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID}) + sc.reqContext.Req.Body = mockRequestBody(cmd) + sc.service.patchCommentHandler(sc.reqContext) + time.Sleep(1 * time.Second) command2 := CreateQueryInQueryHistoryCommand{ DatasourceUID: testDsUID1, diff --git a/pkg/services/queryhistory/writers.go b/pkg/services/queryhistory/writers.go index 20f94ab1705..034a055ecfb 100644 --- a/pkg/services/queryhistory/writers.go +++ b/pkg/services/queryhistory/writers.go @@ -23,12 +23,12 @@ func writeStarredSQL(query SearchInQueryHistoryQuery, sqlStore *sqlstore.SQLStor } func writeFiltersSQL(query SearchInQueryHistoryQuery, user *models.SignedInUser, sqlStore *sqlstore.SQLStore, builder *sqlstore.SQLBuilder) { - params := []interface{}{user.OrgId, user.UserId, "%" + query.SearchString + "%"} + params := []interface{}{user.OrgId, user.UserId, "%" + query.SearchString + "%", "%" + query.SearchString + "%"} for _, uid := range query.DatasourceUIDs { params = append(params, uid) } var sql bytes.Buffer - sql.WriteString(" WHERE query_history.org_id = ? AND query_history.created_by = ? AND query_history.queries " + sqlStore.Dialect.LikeStr() + " ? AND query_history.datasource_uid IN (? " + strings.Repeat(",?", len(query.DatasourceUIDs)-1) + ") ") + sql.WriteString(" WHERE query_history.org_id = ? AND query_history.created_by = ? AND (query_history.queries " + sqlStore.Dialect.LikeStr() + " ? OR query_history.comment " + sqlStore.Dialect.LikeStr() + " ?) AND query_history.datasource_uid IN (? " + strings.Repeat(",?", len(query.DatasourceUIDs)-1) + ") ") builder.Write(sql.String(), params...) } diff --git a/pkg/services/rendering/http_mode.go b/pkg/services/rendering/http_mode.go index e8820b9e1f9..b086a36b787 100644 --- a/pkg/services/rendering/http_mode.go +++ b/pkg/services/rendering/http_mode.go @@ -46,7 +46,8 @@ func (rs *RenderingService) renderViaHTTP(ctx context.Context, renderKey string, } queryParams := rendererURL.Query() - queryParams.Add("url", rs.getURL(opts.Path)) + url := rs.getURL(opts.Path) + queryParams.Add("url", url) queryParams.Add("renderKey", renderKey) queryParams.Add("width", strconv.Itoa(opts.Width)) queryParams.Add("height", strconv.Itoa(opts.Height)) @@ -74,7 +75,7 @@ func (rs *RenderingService) renderViaHTTP(ctx context.Context, renderKey string, } }() - err = rs.readFileResponse(reqContext, resp, filePath) + err = rs.readFileResponse(reqContext, resp, filePath, url) if err != nil { return nil, err } @@ -94,7 +95,8 @@ func (rs *RenderingService) renderCSVViaHTTP(ctx context.Context, renderKey stri } queryParams := rendererURL.Query() - queryParams.Add("url", rs.getURL(opts.Path)) + url := rs.getURL(opts.Path) + queryParams.Add("url", url) queryParams.Add("renderKey", renderKey) queryParams.Add("domain", rs.domain) queryParams.Add("timezone", isoTimeOffsetToPosixTz(opts.Timezone)) @@ -125,7 +127,7 @@ func (rs *RenderingService) renderCSVViaHTTP(ctx context.Context, renderKey stri } downloadFileName := params["filename"] - err = rs.readFileResponse(reqContext, resp, filePath) + err = rs.readFileResponse(reqContext, resp, filePath, url) if err != nil { return nil, err } @@ -156,7 +158,7 @@ func (rs *RenderingService) doRequest(ctx context.Context, url *url.URL, headers return resp, nil } -func (rs *RenderingService) readFileResponse(ctx context.Context, resp *http.Response, filePath string) error { +func (rs *RenderingService) readFileResponse(ctx context.Context, resp *http.Response, filePath string, url string) error { // check for timeout first if errors.Is(ctx.Err(), context.DeadlineExceeded) { rs.log.Info("Rendering timed out") @@ -165,7 +167,7 @@ func (rs *RenderingService) readFileResponse(ctx context.Context, resp *http.Res // if we didn't get a 200 response, something went wrong. if resp.StatusCode != http.StatusOK { - rs.log.Error("Remote rendering request failed", "error", resp.Status) + rs.log.Error("Remote rendering request failed", "error", resp.Status, "url", url) return fmt.Errorf("remote rendering request failed, status code: %d, status: %s", resp.StatusCode, resp.Status) } diff --git a/pkg/services/search/service.go b/pkg/services/search/service.go index d8b3eb2e95a..809e5ba8c11 100644 --- a/pkg/services/search/service.go +++ b/pkg/services/search/service.go @@ -7,21 +7,18 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" ) -func ProvideService(cfg *setting.Cfg, bus bus.Bus, sqlstore *sqlstore.SQLStore) *SearchService { +func ProvideService(cfg *setting.Cfg, sqlstore *sqlstore.SQLStore) *SearchService { s := &SearchService{ Cfg: cfg, - Bus: bus, sortOptions: map[string]models.SortOption{ SortAlphaAsc.Name: SortAlphaAsc, SortAlphaDesc.Name: SortAlphaDesc, }, sqlstore: sqlstore, } - s.Bus.AddHandler(s.SearchHandler) return s } @@ -48,7 +45,6 @@ type Service interface { } type SearchService struct { - Bus bus.Bus Cfg *setting.Cfg sortOptions map[string]models.SortOption sqlstore sqlstore.Store diff --git a/pkg/services/searchV2/extract/dashboard_test.go b/pkg/services/searchV2/extract/dashboard_test.go index 34270bec827..0bc3a17090b 100644 --- a/pkg/services/searchV2/extract/dashboard_test.go +++ b/pkg/services/searchV2/extract/dashboard_test.go @@ -49,7 +49,7 @@ func TestReadDashboard(t *testing.T) { require.NoError(t, err) update := false - savedPath := "testdata/" + input + "-info.json" + savedPath := filepath.Join("testdata/", input+"-info.json") saved, err := os.ReadFile(savedPath) if err != nil { update = true diff --git a/pkg/services/searchusers/searchusers.go b/pkg/services/searchusers/searchusers.go index 79dc1b59196..fbed2051dae 100644 --- a/pkg/services/searchusers/searchusers.go +++ b/pkg/services/searchusers/searchusers.go @@ -1,6 +1,8 @@ package searchusers import ( + "net/http" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" @@ -27,7 +29,7 @@ func (s *OSSService) SearchUsers(c *models.ReqContext) response.Response { return response.Error(500, "Failed to fetch users", err) } - return response.JSON(200, query.Result.Users) + return response.JSON(http.StatusOK, query.Result.Users) } func (s *OSSService) SearchUsersWithPaging(c *models.ReqContext) response.Response { @@ -36,7 +38,7 @@ func (s *OSSService) SearchUsersWithPaging(c *models.ReqContext) response.Respon return response.Error(500, "Failed to fetch users", err) } - return response.JSON(200, query.Result) + return response.JSON(http.StatusOK, query.Result) } func (s *OSSService) SearchUser(c *models.ReqContext) (*models.SearchUsersQuery, error) { diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index 90d4b4f2817..5acc84f090c 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -93,7 +93,7 @@ func (api *ServiceAccountsAPI) CreateServiceAccount(c *models.ReqContext) respon serviceAccount, err := api.store.CreateServiceAccount(c.Req.Context(), c.OrgId, cmd.Name) switch { case errors.Is(err, &database.ErrSAInvalidName{}): - return response.Error(http.StatusBadRequest, "Invalid service account name", err) + return response.Error(http.StatusBadRequest, "Failed due to %s", err) case err != nil: return response.Error(http.StatusInternalServerError, "Failed to create service account", err) } @@ -179,7 +179,7 @@ func (api *ServiceAccountsAPI) updateServiceAccount(c *models.ReqContext) respon return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err) } - cmd := &serviceaccounts.UpdateServiceAccountForm{} + var cmd serviceaccounts.UpdateServiceAccountForm if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "Bad request data", err) } @@ -187,8 +187,11 @@ func (api *ServiceAccountsAPI) updateServiceAccount(c *models.ReqContext) respon if cmd.Role != nil && !cmd.Role.IsValid() { return response.Error(http.StatusBadRequest, "Invalid role specified", nil) } + if cmd.Role != nil && !c.OrgRole.Includes(*cmd.Role) { + return response.Error(http.StatusForbidden, "Cannot assign a role higher than user's role", nil) + } - resp, err := api.store.UpdateServiceAccount(c.Req.Context(), c.OrgId, scopeID, cmd) + resp, err := api.store.UpdateServiceAccount(c.Req.Context(), c.OrgId, scopeID, &cmd) if err != nil { switch { case errors.Is(err, serviceaccounts.ErrServiceAccountNotFound): diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go index a5ed51dcde1..c017b3dee0d 100644 --- a/pkg/services/serviceaccounts/api/api_test.go +++ b/pkg/services/serviceaccounts/api/api_test.go @@ -227,7 +227,7 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, m := web.New() signedUser := &models.SignedInUser{ OrgId: 1, - OrgRole: models.ROLE_ADMIN, + OrgRole: models.ROLE_VIEWER, } m.Use(func(c *web.Context) { @@ -344,13 +344,14 @@ func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) { Id int } - role := models.ROLE_ADMIN + viewerRole := models.ROLE_VIEWER + editorRole := models.ROLE_EDITOR var invalidRole models.RoleType = "InvalidRole" testCases := []testUpdateSATestCase{ { desc: "should be ok to update serviceaccount with permissions", - user: &tests.TestUser{Login: "servicetest1@admin", IsServiceAccount: true, Role: "Editor", Name: "Unaltered"}, - body: &serviceaccounts.UpdateServiceAccountForm{Name: newString("New Name"), Role: &role}, + user: &tests.TestUser{Login: "servicetest1@admin", IsServiceAccount: true, Role: "Viewer", Name: "Unaltered"}, + body: &serviceaccounts.UpdateServiceAccountForm{Name: newString("New Name"), Role: &viewerRole}, acmock: tests.SetupMockAccesscontrol( t, func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { @@ -360,6 +361,19 @@ func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) { ), expectedCode: http.StatusOK, }, + { + desc: "should be forbidden to set role higher than user's role", + user: &tests.TestUser{Login: "servicetest2@admin", IsServiceAccount: true, Role: "Viewer", Name: "Unaltered 2"}, + body: &serviceaccounts.UpdateServiceAccountForm{Name: newString("New Name 2"), Role: &editorRole}, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: serviceaccounts.ScopeAll}}, nil + }, + false, + ), + expectedCode: http.StatusForbidden, + }, { desc: "bad request when invalid role", user: &tests.TestUser{Login: "servicetest3@admin", IsServiceAccount: true, Role: "Invalid", Name: "Unaltered"}, @@ -375,7 +389,7 @@ func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) { }, { desc: "should be forbidden to update serviceaccount if no permissions", - user: &tests.TestUser{Login: "servicetest2@admin", IsServiceAccount: true}, + user: &tests.TestUser{Login: "servicetest4@admin", IsServiceAccount: true}, body: nil, acmock: tests.SetupMockAccesscontrol( t, diff --git a/pkg/services/serviceaccounts/api/token.go b/pkg/services/serviceaccounts/api/token.go index 787d06fdadb..dec1d498280 100644 --- a/pkg/services/serviceaccounts/api/token.go +++ b/pkg/services/serviceaccounts/api/token.go @@ -17,13 +17,12 @@ import ( const failedToDeleteMsg = "Failed to delete API key" type TokenDTO struct { - Id int64 `json:"id"` - Name string `json:"name"` - Role models.RoleType `json:"role"` - Created *time.Time `json:"created"` - Expiration *time.Time `json:"expiration"` - SecondsUntilExpiration *float64 `json:"secondsUntilExpiration"` - HasExpired bool `json:"hasExpired"` + Id int64 `json:"id"` + Name string `json:"name"` + Created *time.Time `json:"created"` + Expiration *time.Time `json:"expiration"` + SecondsUntilExpiration *float64 `json:"secondsUntilExpiration"` + HasExpired bool `json:"hasExpired"` } func hasExpired(expiration *int64) bool { @@ -60,7 +59,6 @@ func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Respo result[i] = &TokenDTO{ Id: t.Id, Name: t.Name, - Role: t.Role, Created: &t.Created, Expiration: expiration, SecondsUntilExpiration: &secondsUntilExpiration, @@ -91,7 +89,7 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon } } - cmd := models.AddApiKeyCommand{} + cmd := serviceaccounts.AddServiceAccountTokenCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "Bad request data", err) } @@ -99,10 +97,6 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon // Force affected service account to be the one referenced in the URL cmd.OrgId = c.OrgId - if !cmd.Role.IsValid() { - return response.Error(http.StatusBadRequest, "Invalid role specified", nil) - } - if api.cfg.ApiKeyMaxSecondsToLive != -1 { if cmd.SecondsToLive == 0 { return response.Error(http.StatusBadRequest, "Number of seconds before expiration should be set", nil) diff --git a/pkg/services/serviceaccounts/api/token_test.go b/pkg/services/serviceaccounts/api/token_test.go index 38eb95de0bb..7f04cc8d1bb 100644 --- a/pkg/services/serviceaccounts/api/token_test.go +++ b/pkg/services/serviceaccounts/api/token_test.go @@ -34,9 +34,8 @@ func createTokenforSA(t *testing.T, store serviceaccounts.Store, keyName string, key, err := apikeygen.New(orgID, keyName) require.NoError(t, err) - cmd := models.AddApiKeyCommand{ + cmd := serviceaccounts.AddServiceAccountTokenCommand{ Name: keyName, - Role: "Viewer", OrgId: orgID, Key: key.HashedKey, SecondsToLive: secondsToLive, diff --git a/pkg/services/serviceaccounts/database/database.go b/pkg/services/serviceaccounts/database/database.go index 61647755700..aa11773c867 100644 --- a/pkg/services/serviceaccounts/database/database.go +++ b/pkg/services/serviceaccounts/database/database.go @@ -157,7 +157,7 @@ func (s *ServiceAccountsStoreImpl) ListTokens(ctx context.Context, orgID int64, sess = dbSession. Join("inner", quotedUser, quotedUser+".id = api_key.service_account_id"). Where(quotedUser+".org_id=? AND "+quotedUser+".id=?", orgID, serviceAccountID). - Asc("name") + Asc("api_key.name") return sess.Find(&result) }) @@ -231,6 +231,47 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, o return serviceAccount, nil } +func (s *ServiceAccountsStoreImpl) RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error) { + serviceAccount := &struct { + Id int64 + }{} + + err := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + sess := dbSession.Table("user") + + whereConditions := []string{ + fmt.Sprintf("%s.name = ?", + s.sqlStore.Dialect.Quote("user")), + fmt.Sprintf("%s.org_id = ?", + s.sqlStore.Dialect.Quote("user")), + fmt.Sprintf("%s.is_service_account = %s", + s.sqlStore.Dialect.Quote("user"), + s.sqlStore.Dialect.BooleanStr(true)), + } + whereParams := []interface{}{name, orgID} + + sess.Where(strings.Join(whereConditions, " AND "), whereParams...) + + sess.Cols( + "user.id", + ) + + if ok, err := sess.Get(serviceAccount); err != nil { + return err + } else if !ok { + return serviceaccounts.ErrServiceAccountNotFound + } + + return nil + }) + + if err != nil { + return 0, err + } + + return serviceAccount.Id, nil +} + func (s *ServiceAccountsStoreImpl) UpdateServiceAccount(ctx context.Context, orgID, serviceAccountID int64, saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountProfileDTO, error) { diff --git a/pkg/services/serviceaccounts/database/database_test.go b/pkg/services/serviceaccounts/database/database_test.go index d09aa46de4e..44c388034dd 100644 --- a/pkg/services/serviceaccounts/database/database_test.go +++ b/pkg/services/serviceaccounts/database/database_test.go @@ -15,17 +15,24 @@ import ( func TestStore_CreateServiceAccount(t *testing.T) { _, store := setupTestDatabase(t) t.Run("create service account", func(t *testing.T) { - saDTO, err := store.CreateServiceAccount(context.Background(), 1, "new Service Account") + serviceAccountName := "new Service Account" + serviceAccountOrgId := int64(1) + + saDTO, err := store.CreateServiceAccount(context.Background(), serviceAccountOrgId, serviceAccountName) require.NoError(t, err) assert.Equal(t, "sa-new-service-account", saDTO.Login) - assert.Equal(t, "new Service Account", saDTO.Name) + assert.Equal(t, serviceAccountName, saDTO.Name) assert.Equal(t, 0, int(saDTO.Tokens)) - retrieved, err := store.RetrieveServiceAccount(context.Background(), 1, saDTO.Id) + retrieved, err := store.RetrieveServiceAccount(context.Background(), serviceAccountOrgId, saDTO.Id) require.NoError(t, err) assert.Equal(t, "sa-new-service-account", retrieved.Login) - assert.Equal(t, "new Service Account", retrieved.Name) - assert.Equal(t, 1, int(retrieved.OrgId)) + assert.Equal(t, serviceAccountName, retrieved.Name) + assert.Equal(t, serviceAccountOrgId, retrieved.OrgId) + + retrievedId, err := store.RetrieveServiceAccountIdByName(context.Background(), serviceAccountOrgId, serviceAccountName) + require.NoError(t, err) + assert.Equal(t, saDTO.Id, retrievedId) }) } diff --git a/pkg/services/serviceaccounts/database/errors.go b/pkg/services/serviceaccounts/database/errors.go index 3cd555480a6..8001b2bd889 100644 --- a/pkg/services/serviceaccounts/database/errors.go +++ b/pkg/services/serviceaccounts/database/errors.go @@ -17,14 +17,14 @@ func (e *ErrSAInvalidName) Unwrap() error { return models.ErrUserAlreadyExists } -type ErrMisingSAToken struct { +type ErrMissingSAToken struct { } -func (e *ErrMisingSAToken) Error() string { +func (e *ErrMissingSAToken) Error() string { return "service account token not found" } -func (e *ErrMisingSAToken) Unwrap() error { +func (e *ErrMissingSAToken) Unwrap() error { return models.ErrApiKeyNotFound } @@ -44,7 +44,7 @@ type ErrDuplicateSAToken struct { } func (e *ErrDuplicateSAToken) Error() string { - return fmt.Sprintf("service account token %s already exists", e.name) + return fmt.Sprintf("service account token %s already exists in the organization", e.name) } func (e *ErrDuplicateSAToken) Unwrap() error { diff --git a/pkg/services/serviceaccounts/database/stats_test.go b/pkg/services/serviceaccounts/database/stats_test.go index 067bf756d9d..55405509c4b 100644 --- a/pkg/services/serviceaccounts/database/stats_test.go +++ b/pkg/services/serviceaccounts/database/stats_test.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/components/apikeygen" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -20,9 +21,8 @@ func TestStore_UsageStats(t *testing.T) { key, err := apikeygen.New(sa.OrgId, keyName) require.NoError(t, err) - cmd := models.AddApiKeyCommand{ + cmd := serviceaccounts.AddServiceAccountTokenCommand{ Name: keyName, - Role: "Viewer", OrgId: sa.OrgId, Key: key.HashedKey, SecondsToLive: 0, diff --git a/pkg/services/serviceaccounts/database/token_store.go b/pkg/services/serviceaccounts/database/token_store.go index e69317e55c0..4a4b04f9585 100644 --- a/pkg/services/serviceaccounts/database/token_store.go +++ b/pkg/services/serviceaccounts/database/token_store.go @@ -5,10 +5,11 @@ import ( "time" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/sqlstore" ) -func (s *ServiceAccountsStoreImpl) AddServiceAccountToken(ctx context.Context, saID int64, cmd *models.AddApiKeyCommand) error { +func (s *ServiceAccountsStoreImpl) AddServiceAccountToken(ctx context.Context, saID int64, cmd *serviceaccounts.AddServiceAccountTokenCommand) error { return s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { key := models.ApiKey{OrgId: cmd.OrgId, Name: cmd.Name} exists, _ := sess.Get(&key) @@ -28,7 +29,7 @@ func (s *ServiceAccountsStoreImpl) AddServiceAccountToken(ctx context.Context, s t := models.ApiKey{ OrgId: cmd.OrgId, Name: cmd.Name, - Role: cmd.Role, + Role: models.ROLE_VIEWER, Key: cmd.Key, Created: updated, Updated: updated, @@ -56,7 +57,7 @@ func (s *ServiceAccountsStoreImpl) DeleteServiceAccountToken(ctx context.Context if err != nil { return err } else if n == 0 { - return &ErrMisingSAToken{} + return &ErrMissingSAToken{} } return nil }) diff --git a/pkg/services/serviceaccounts/database/token_store_test.go b/pkg/services/serviceaccounts/database/token_store_test.go index 2e24caa9c37..1560e19ffcd 100644 --- a/pkg/services/serviceaccounts/database/token_store_test.go +++ b/pkg/services/serviceaccounts/database/token_store_test.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/components/apikeygen" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/stretchr/testify/require" ) @@ -28,9 +29,8 @@ func TestStore_AddServiceAccountToken(t *testing.T) { key, err := apikeygen.New(user.OrgId, keyName) require.NoError(t, err) - cmd := models.AddApiKeyCommand{ + cmd := serviceaccounts.AddServiceAccountTokenCommand{ Name: keyName, - Role: "Viewer", OrgId: user.OrgId, Key: key.HashedKey, SecondsToLive: tc.secondsToLive, @@ -79,9 +79,8 @@ func TestStore_DeleteServiceAccountToken(t *testing.T) { key, err := apikeygen.New(user.OrgId, keyName) require.NoError(t, err) - cmd := models.AddApiKeyCommand{ + cmd := serviceaccounts.AddServiceAccountTokenCommand{ Name: keyName, - Role: "Viewer", OrgId: user.OrgId, Key: key.HashedKey, SecondsToLive: 0, diff --git a/pkg/services/serviceaccounts/manager/roles.go b/pkg/services/serviceaccounts/manager/roles.go index 656f27881ec..7c323043a95 100644 --- a/pkg/services/serviceaccounts/manager/roles.go +++ b/pkg/services/serviceaccounts/manager/roles.go @@ -1,12 +1,30 @@ package manager import ( + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/serviceaccounts" ) func RegisterRoles(ac accesscontrol.AccessControl) error { - role := accesscontrol.RoleRegistration{ + saReader := accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Version: 1, + Name: "fixed:serviceaccounts:reader", + DisplayName: "Service accounts reader", + Description: "Read service accounts and service account tokens.", + Group: "Service accounts", + Permissions: []accesscontrol.Permission{ + { + Action: serviceaccounts.ActionRead, + Scope: serviceaccounts.ScopeAll, + }, + }, + }, + Grants: []string{string(models.ROLE_ADMIN)}, + } + + saWriter := accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ Version: 4, Name: "fixed:serviceaccounts:writer", @@ -31,10 +49,10 @@ func RegisterRoles(ac accesscontrol.AccessControl) error { }, }, }, - Grants: []string{"Admin"}, + Grants: []string{string(models.ROLE_ADMIN)}, } - if err := ac.DeclareFixedRoles(role); err != nil { + if err := ac.DeclareFixedRoles(saReader, saWriter); err != nil { return err } diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index 9d8967488b5..71aba8ae775 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -68,3 +68,11 @@ func (sa *ServiceAccountsService) DeleteServiceAccount(ctx context.Context, orgI } return sa.store.DeleteServiceAccount(ctx, orgID, serviceAccountID) } + +func (sa *ServiceAccountsService) RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error) { + if !sa.features.IsEnabled(featuremgmt.FlagServiceAccounts) { + sa.log.Debug(ServiceAccountFeatureToggleNotFound) + return 0, nil + } + return sa.store.RetrieveServiceAccountIdByName(ctx, orgID, name) +} diff --git a/pkg/services/serviceaccounts/models.go b/pkg/services/serviceaccounts/models.go index f350f259a90..ffc354ce7f0 100644 --- a/pkg/services/serviceaccounts/models.go +++ b/pkg/services/serviceaccounts/models.go @@ -40,6 +40,15 @@ type ServiceAccountDTO struct { AvatarUrl string `json:"avatarUrl"` AccessControl map[string]bool `json:"accessControl,omitempty"` } + +type AddServiceAccountTokenCommand struct { + Name string `json:"name" binding:"Required"` + OrgId int64 `json:"-"` + Key string `json:"-"` + SecondsToLive int64 `json:"secondsToLive"` + Result *models.ApiKey `json:"-"` +} + type SearchServiceAccountsResult struct { TotalCount int64 `json:"totalCount"` ServiceAccounts []*ServiceAccountDTO `json:"serviceAccounts"` diff --git a/pkg/services/serviceaccounts/serviceaccounts.go b/pkg/services/serviceaccounts/serviceaccounts.go index e7b186e8397..632aa42a928 100644 --- a/pkg/services/serviceaccounts/serviceaccounts.go +++ b/pkg/services/serviceaccounts/serviceaccounts.go @@ -10,6 +10,7 @@ import ( type Service interface { CreateServiceAccount(ctx context.Context, orgID int64, name string) (*ServiceAccountDTO, error) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error + RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error) } type Store interface { @@ -19,11 +20,12 @@ type Store interface { UpdateServiceAccount(ctx context.Context, orgID, serviceAccountID int64, saForm *UpdateServiceAccountForm) (*ServiceAccountProfileDTO, error) RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*ServiceAccountProfileDTO, error) + RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error UpgradeServiceAccounts(ctx context.Context) error ConvertToServiceAccounts(ctx context.Context, keys []int64) error ListTokens(ctx context.Context, orgID int64, serviceAccount int64) ([]*models.ApiKey, error) DeleteServiceAccountToken(ctx context.Context, orgID, serviceAccountID, tokenID int64) error - AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *models.AddApiKeyCommand) error + AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *AddServiceAccountTokenCommand) error GetUsageMetrics(ctx context.Context) (map[string]interface{}, error) } diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index 6974ed1de14..67c30d20a10 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -38,6 +38,10 @@ func SetupUserServiceAccount(t *testing.T, sqlStore *sqlstore.SQLStore, testUser // create mock for serviceaccountservice type ServiceAccountMock struct{} +func (s *ServiceAccountMock) RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error) { + return 0, nil +} + func (s *ServiceAccountMock) CreateServiceAccount(ctx context.Context, orgID int64, name string) (*serviceaccounts.ServiceAccountDTO, error) { return nil, nil } @@ -65,24 +69,31 @@ func SetupMockAccesscontrol(t *testing.T, // this is a way to see // that the Mock implements the store interface var _ serviceaccounts.Store = new(ServiceAccountsStoreMock) +var _ serviceaccounts.Service = new(ServiceAccountMock) type Calls struct { - CreateServiceAccount []interface{} - RetrieveServiceAccount []interface{} - DeleteServiceAccount []interface{} - UpgradeServiceAccounts []interface{} - ConvertServiceAccounts []interface{} - ListTokens []interface{} - DeleteServiceAccountToken []interface{} - UpdateServiceAccount []interface{} - AddServiceAccountToken []interface{} - SearchOrgServiceAccounts []interface{} + CreateServiceAccount []interface{} + RetrieveServiceAccount []interface{} + DeleteServiceAccount []interface{} + UpgradeServiceAccounts []interface{} + ConvertServiceAccounts []interface{} + ListTokens []interface{} + DeleteServiceAccountToken []interface{} + UpdateServiceAccount []interface{} + AddServiceAccountToken []interface{} + SearchOrgServiceAccounts []interface{} + RetrieveServiceAccountIdByName []interface{} } type ServiceAccountsStoreMock struct { Calls Calls } +func (s *ServiceAccountsStoreMock) RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error) { + s.Calls.RetrieveServiceAccountIdByName = append(s.Calls.RetrieveServiceAccountIdByName, []interface{}{ctx, orgID, name}) + return 0, nil +} + func (s *ServiceAccountsStoreMock) CreateServiceAccount(ctx context.Context, orgID int64, name string) (*serviceaccounts.ServiceAccountDTO, error) { // now we can test that the mock has these calls when we call the function s.Calls.CreateServiceAccount = append(s.Calls.CreateServiceAccount, []interface{}{ctx, orgID, name}) @@ -140,7 +151,7 @@ func (s *ServiceAccountsStoreMock) DeleteServiceAccountToken(ctx context.Context return nil } -func (s *ServiceAccountsStoreMock) AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *models.AddApiKeyCommand) error { +func (s *ServiceAccountsStoreMock) AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *serviceaccounts.AddServiceAccountTokenCommand) error { s.Calls.AddServiceAccountToken = append(s.Calls.AddServiceAccountToken, []interface{}{ctx, cmd}) return nil } diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index 7e79cb6d24c..d4b07cb64db 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -7,23 +7,12 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" ) // timeNow makes it possible to test usage of time var timeNow = time.Now -func (ss *SQLStore) addAlertQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.HandleAlertsQuery) - bus.AddHandler("sql", ss.GetAlertById) - bus.AddHandler("sql", ss.GetAllAlertQueryHandler) - bus.AddHandler("sql", ss.SetAlertState) - bus.AddHandler("sql", ss.GetAlertStatesForDashboard) - bus.AddHandler("sql", ss.PauseAlert) - bus.AddHandler("sql", ss.PauseAllAlerts) -} - func (ss *SQLStore) GetAlertById(ctx context.Context, query *models.GetAlertByIdQuery) error { return ss.WithDbSession(ctx, func(sess *DBSession) error { alert := models.Alert{} diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go index 0d6bd261415..c8c515e81ee 100644 --- a/pkg/services/sqlstore/alert_notification.go +++ b/pkg/services/sqlstore/alert_notification.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/util" ) @@ -79,10 +78,6 @@ func (ss *SQLStore) GetAlertNotifications(ctx context.Context, query *models.Get return getAlertNotificationInternal(ctx, query, newSession(ctx)) } -func (ss *SQLStore) addAlertNotificationUidByIdHandler() { - bus.AddHandler("sql", ss.GetAlertNotificationUidWithId) -} - func (ss *SQLStore) GetAlertNotificationUidWithId(ctx context.Context, query *models.GetAlertNotificationUidQuery) error { cacheKey := newAlertNotificationUidCacheKey(query.OrgId, query.Id) diff --git a/pkg/services/sqlstore/alert_notification_test.go b/pkg/services/sqlstore/alert_notification_test.go index 6fae4142bab..6d2b7a3d9b7 100644 --- a/pkg/services/sqlstore/alert_notification_test.go +++ b/pkg/services/sqlstore/alert_notification_test.go @@ -10,7 +10,6 @@ import ( "testing" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" @@ -19,13 +18,7 @@ import ( func TestAlertNotificationSQLAccess(t *testing.T) { var sqlStore *SQLStore - setup := func() { - sqlStore = InitTestDB(t) - // Set up bus handlers - bus.AddHandler("deleteAlertNotification", func(ctx context.Context, cmd *models.DeleteAlertNotificationCommand) error { - return sqlStore.DeleteAlertNotification(ctx, cmd) - }) - } + setup := func() { sqlStore = InitTestDB(t) } t.Run("Alert notification state", func(t *testing.T) { setup() diff --git a/pkg/services/sqlstore/annotation.go b/pkg/services/sqlstore/annotation.go index 633bc3bb10d..83f3e0f319f 100644 --- a/pkg/services/sqlstore/annotation.go +++ b/pkg/services/sqlstore/annotation.go @@ -9,7 +9,11 @@ import ( "time" "github.com/grafana/grafana/pkg/models" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/sqlstore/permissions" + "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" ) // Update the item so that EpochEnd >= Epoch @@ -33,6 +37,10 @@ type SQLAnnotationRepo struct { sql *SQLStore } +func NewSQLAnnotationRepo(sql *SQLStore) SQLAnnotationRepo { + return SQLAnnotationRepo{sql: sql} +} + func (r *SQLAnnotationRepo) Save(item *annotations.Item) error { return inTransaction(func(sess *DBSession) error { tags := models.ParseTagPairs(item.Tags) @@ -221,6 +229,15 @@ func (r *SQLAnnotationRepo) Find(ctx context.Context, query *annotations.ItemQue } } + if r.sql.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagAccesscontrol) { + acFilter, acArgs, err := getAccessControlFilter(query.SignedInUser) + if err != nil { + return err + } + sql.WriteString(fmt.Sprintf(" AND (%s)", acFilter)) + params = append(params, acArgs...) + } + if query.Limit == 0 { query.Limit = 100 } @@ -239,6 +256,37 @@ func (r *SQLAnnotationRepo) Find(ctx context.Context, query *annotations.ItemQue return items, err } +func getAccessControlFilter(user *models.SignedInUser) (string, []interface{}, error) { + if user == nil || user.Permissions[user.OrgId] == nil { + return "", nil, errors.New("missing permissions") + } + scopes, has := user.Permissions[user.OrgId][ac.ActionAnnotationsRead] + if !has { + return "", nil, errors.New("missing permissions") + } + types, hasWildcardScope := ac.ParseScopes(ac.ScopeAnnotationsProvider.GetResourceScopeType(""), scopes) + if hasWildcardScope { + types = map[interface{}]struct{}{annotations.Dashboard.String(): {}, annotations.Organization.String(): {}} + } + + var filters []string + var params []interface{} + for t := range types { + // annotation read permission with scope annotations:type:organization allows listing annotations that are not associated with a dashboard + if t == annotations.Organization.String() { + filters = append(filters, "a.dashboard_id = 0") + } + // annotation read permission with scope annotations:type:dashboard allows listing annotations from dashboards which the user can view + if t == annotations.Dashboard.String() { + dashboardFilter, dashboardParams := permissions.NewAccessControlDashboardPermissionFilter(user, models.PERMISSION_VIEW, searchstore.TypeDashboard).Where() + filter := fmt.Sprintf("a.dashboard_id IN(SELECT id FROM dashboard WHERE %s)", dashboardFilter) + filters = append(filters, filter) + params = dashboardParams + } + } + return strings.Join(filters, " OR "), params, nil +} + func (r *SQLAnnotationRepo) Delete(ctx context.Context, params *annotations.DeleteParams) error { return r.sql.WithTransactionalDbSession(ctx, func(sess *DBSession) error { var ( diff --git a/pkg/services/sqlstore/annotation_test.go b/pkg/services/sqlstore/annotation_test.go index 4c508b7f0fc..6b66861d0cb 100644 --- a/pkg/services/sqlstore/annotation_test.go +++ b/pkg/services/sqlstore/annotation_test.go @@ -1,27 +1,32 @@ //go:build integration // +build integration -package sqlstore +package sqlstore_test import ( "context" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "fmt" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" + dashboardstore "github.com/grafana/grafana/pkg/services/dashboards/database" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/sqlstore" ) func TestAnnotations(t *testing.T) { - mockTimeNow() - defer resetTimeNow() - - repo := SQLAnnotationRepo{} - repo.sql = InitTestDB(t) + sql := sqlstore.InitTestDB(t) + repo := sqlstore.NewSQLAnnotationRepo(sql) t.Run("Testing annotation create, read, update and delete", func(t *testing.T) { t.Cleanup(func() { - err := repo.sql.WithDbSession(context.Background(), func(dbSession *DBSession) error { + err := sql.WithDbSession(context.Background(), func(dbSession *sqlstore.DBSession) error { _, err := dbSession.Exec("DELETE FROM annotation WHERE 1=1") if err != nil { return err @@ -331,3 +336,136 @@ func TestAnnotations(t *testing.T) { }) }) } + +func TestAnnotationListingWithFGAC(t *testing.T) { + sql := sqlstore.InitTestDB(t) + sql.Cfg.IsFeatureToggleEnabled = func(key string) bool { + return key == featuremgmt.FlagAccesscontrol + } + repo := sqlstore.NewSQLAnnotationRepo(sql) + dashboardStore := dashboardstore.ProvideDashboardStore(sql) + + testDashboard1 := models.SaveDashboardCommand{ + UserId: 1, + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "Dashboard 1", + }), + } + dashboard, err := dashboardStore.SaveDashboard(testDashboard1) + require.NoError(t, err) + dash1UID := dashboard.Uid + + testDashboard2 := models.SaveDashboardCommand{ + UserId: 1, + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "Dashboard 2", + }), + } + _, err = dashboardStore.SaveDashboard(testDashboard2) + require.NoError(t, err) + + dash1Annotation := &annotations.Item{ + OrgId: 1, + DashboardId: 1, + Epoch: 10, + } + err = repo.Save(dash1Annotation) + require.NoError(t, err) + + dash2Annotation := &annotations.Item{ + OrgId: 1, + DashboardId: 2, + Epoch: 10, + } + err = repo.Save(dash2Annotation) + require.NoError(t, err) + + organizationAnnotation := &annotations.Item{ + OrgId: 1, + Epoch: 10, + } + err = repo.Save(organizationAnnotation) + require.NoError(t, err) + + user := &models.SignedInUser{ + UserId: 1, + OrgId: 1, + } + + type testStruct struct { + description string + permissions map[string][]string + expectedAnnotationIds []int64 + expectedError bool + } + + testCases := []testStruct{ + { + description: "Should find all annotations when has permissions to list all annotations and read all dashboards", + permissions: map[string][]string{ + accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsAll}, + accesscontrol.ActionDashboardsRead: {accesscontrol.ScopeDashboardsAll}, + }, + expectedAnnotationIds: []int64{dash1Annotation.Id, dash2Annotation.Id, organizationAnnotation.Id}, + }, + { + description: "Should find all dashboard annotations", + permissions: map[string][]string{ + accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard}, + accesscontrol.ActionDashboardsRead: {accesscontrol.ScopeDashboardsAll}, + }, + expectedAnnotationIds: []int64{dash1Annotation.Id, dash2Annotation.Id}, + }, + { + description: "Should find only annotations from dashboards that user can read", + permissions: map[string][]string{ + accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard}, + accesscontrol.ActionDashboardsRead: {fmt.Sprintf("dashboards:uid:%s", dash1UID)}, + }, + expectedAnnotationIds: []int64{dash1Annotation.Id}, + }, + { + description: "Should find no annotations if user can't view dashboards or organization annotations", + permissions: map[string][]string{ + accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard}, + }, + expectedAnnotationIds: []int64{}, + }, + { + description: "Should find only organization annotations", + permissions: map[string][]string{ + accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeOrganization}, + accesscontrol.ActionDashboardsRead: {accesscontrol.ScopeDashboardsAll}, + }, + expectedAnnotationIds: []int64{organizationAnnotation.Id}, + }, + { + description: "Should error if user doesn't have annotation read permissions", + permissions: map[string][]string{ + accesscontrol.ActionDashboardsRead: {accesscontrol.ScopeDashboardsAll}, + }, + expectedError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + user.Permissions = map[int64]map[string][]string{1: tc.permissions} + results, err := repo.Find(context.Background(), &annotations.ItemQuery{ + OrgId: 1, + SignedInUser: user, + }) + if tc.expectedError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Len(t, results, len(tc.expectedAnnotationIds)) + for _, r := range results { + assert.Contains(t, tc.expectedAnnotationIds, r.Id) + } + }) + } +} diff --git a/pkg/services/sqlstore/apikey.go b/pkg/services/sqlstore/apikey.go index eb31a56efaf..876d41382d2 100644 --- a/pkg/services/sqlstore/apikey.go +++ b/pkg/services/sqlstore/apikey.go @@ -6,18 +6,9 @@ import ( "xorm.io/xorm" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" ) -func (ss *SQLStore) addAPIKeysQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.GetAPIKeys) - bus.AddHandler("sql", ss.GetApiKeyById) - bus.AddHandler("sql", ss.GetApiKeyByName) - bus.AddHandler("sql", ss.DeleteApiKey) - bus.AddHandler("sql", ss.AddAPIKey) -} - // GetAPIKeys queries the database based // on input on GetApiKeysQuery func (ss *SQLStore) GetAPIKeys(ctx context.Context, query *models.GetApiKeysQuery) error { diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index c53c577a886..4017e8d8872 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -6,7 +6,6 @@ import ( "github.com/prometheus/client_golang/prometheus" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" @@ -26,18 +25,6 @@ func init() { prometheus.MustRegister(shadowSearchCounter) } -func (ss *SQLStore) addDashboardQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.GetDashboard) - bus.AddHandler("sql", ss.GetDashboardUIDById) - bus.AddHandler("sql", ss.GetDashboardTags) - bus.AddHandler("sql", ss.SearchDashboards) - bus.AddHandler("sql", ss.GetDashboards) - bus.AddHandler("sql", ss.HasEditPermissionInFolders) - bus.AddHandler("sql", ss.GetDashboardPermissionsForUser) - bus.AddHandler("sql", ss.GetDashboardSlugById) - bus.AddHandler("sql", ss.HasAdminPermissionInFolders) -} - var generateNewUid func() string = util.GenerateShortUID func (ss *SQLStore) GetDashboard(ctx context.Context, query *models.GetDashboardQuery) error { diff --git a/pkg/services/sqlstore/dashboard_acl.go b/pkg/services/sqlstore/dashboard_acl.go index bcc04b2a17a..df4a4fcf129 100644 --- a/pkg/services/sqlstore/dashboard_acl.go +++ b/pkg/services/sqlstore/dashboard_acl.go @@ -3,14 +3,9 @@ package sqlstore import ( "context" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" ) -func (ss *SQLStore) addDashboardACLQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.GetDashboardAclInfoList) -} - // GetDashboardAclInfoList returns a list of permissions for a dashboard. They can be fetched from three // different places. // 1) Permissions for the dashboard diff --git a/pkg/services/sqlstore/dashboard_version.go b/pkg/services/sqlstore/dashboard_version.go index 3c6c6832ef8..e8bd87b6461 100644 --- a/pkg/services/sqlstore/dashboard_version.go +++ b/pkg/services/sqlstore/dashboard_version.go @@ -4,17 +4,10 @@ import ( "context" "strings" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" ) -func (ss *SQLStore) addDashboardVersionQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.GetDashboardVersion) - bus.AddHandler("sql", ss.GetDashboardVersions) - bus.AddHandler("sql", ss.DeleteExpiredVersions) -} - // GetDashboardVersion gets the dashboard version for the given dashboard ID and version number. func (ss *SQLStore) GetDashboardVersion(ctx context.Context, query *models.GetDashboardVersionQuery) error { return ss.WithDbSession(ctx, func(sess *DBSession) error { diff --git a/pkg/services/sqlstore/database_wrapper.go b/pkg/services/sqlstore/database_wrapper.go index 81079412cd3..400f823a55e 100644 --- a/pkg/services/sqlstore/database_wrapper.go +++ b/pkg/services/sqlstore/database_wrapper.go @@ -16,7 +16,6 @@ import ( "github.com/lib/pq" "github.com/mattn/go-sqlite3" "github.com/prometheus/client_golang/prometheus" - cw "github.com/weaveworks/common/tracing" "xorm.io/core" ) @@ -83,7 +82,7 @@ func (h *databaseQueryWrapper) instrument(ctx context.Context, status string, qu elapsed := time.Since(begin) histogram := databaseQueryHistogram.WithLabelValues(status) - if traceID, ok := cw.ExtractSampledTraceID(ctx); ok { + if traceID := tracing.TraceIDFromContext(ctx, true); traceID != "" { // Need to type-convert the Observer to an // ExemplarObserver. This will always work for a // HistogramVec. diff --git a/pkg/services/sqlstore/login_attempt.go b/pkg/services/sqlstore/login_attempt.go index fbbf85d786b..dd93a249552 100644 --- a/pkg/services/sqlstore/login_attempt.go +++ b/pkg/services/sqlstore/login_attempt.go @@ -5,18 +5,11 @@ import ( "strconv" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" ) var getTimeNow = time.Now -func (ss *SQLStore) addLoginAttemptQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.CreateLoginAttempt) - bus.AddHandler("sql", ss.DeleteOldLoginAttempts) - bus.AddHandler("sql", ss.GetUserLoginAttemptCount) -} - func (ss *SQLStore) CreateLoginAttempt(ctx context.Context, cmd *models.CreateLoginAttemptCommand) error { return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { loginAttempt := models.LoginAttempt{ diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index 2094f0100da..f66b16b723e 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -44,7 +44,8 @@ type SQLStoreMock struct { ExpectedUserStars map[int64]bool ExpectedLoginAttempts int64 - ExpectedError error + ExpectedError error + ExpectedSetUsingOrgError error } func NewSQLStoreMock() *SQLStoreMock { @@ -178,7 +179,7 @@ func (m *SQLStoreMock) UpdateUserLastSeenAt(ctx context.Context, cmd *models.Upd } func (m *SQLStoreMock) SetUsingOrg(ctx context.Context, cmd *models.SetUsingOrgCommand) error { - return m.ExpectedError + return m.ExpectedSetUsingOrgError } func (m *SQLStoreMock) GetUserProfile(ctx context.Context, query *models.GetUserProfileQuery) error { diff --git a/pkg/services/sqlstore/org.go b/pkg/services/sqlstore/org.go index 6cac1cea15f..1cdd72a938a 100644 --- a/pkg/services/sqlstore/org.go +++ b/pkg/services/sqlstore/org.go @@ -5,7 +5,6 @@ import ( "fmt" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" @@ -15,16 +14,6 @@ import ( // MainOrgName is the name of the main organization. const MainOrgName = "Main Org." -func (ss *SQLStore) addOrgQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.GetOrgById) - bus.AddHandler("sql", CreateOrg) - bus.AddHandler("sql", ss.UpdateOrg) - bus.AddHandler("sql", ss.UpdateOrgAddress) - bus.AddHandler("sql", ss.GetOrgByNameHandler) - bus.AddHandler("sql", ss.SearchOrgs) - bus.AddHandler("sql", ss.DeleteOrg) -} - func (ss *SQLStore) SearchOrgs(ctx context.Context, query *models.SearchOrgsQuery) error { return ss.WithDbSession(ctx, func(dbSession *DBSession) error { query.Result = make([]*models.OrgDTO, 0) diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index 24f1a9e8119..60d7e5ca8f7 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -6,20 +6,12 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/util" ) -func (ss *SQLStore) addOrgUsersQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.AddOrgUser) - bus.AddHandler("sql", ss.RemoveOrgUser) - bus.AddHandler("sql", ss.GetOrgUsers) - bus.AddHandler("sql", ss.UpdateOrgUser) -} - func (ss *SQLStore) AddOrgUser(ctx context.Context, cmd *models.AddOrgUserCommand) error { return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { // check if user exists diff --git a/pkg/services/sqlstore/playlist.go b/pkg/services/sqlstore/playlist.go index b0d8e96652d..49f3aa311c8 100644 --- a/pkg/services/sqlstore/playlist.go +++ b/pkg/services/sqlstore/playlist.go @@ -3,19 +3,9 @@ package sqlstore import ( "context" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" ) -func (ss *SQLStore) addPlaylistQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.CreatePlaylist) - bus.AddHandler("sql", ss.UpdatePlaylist) - bus.AddHandler("sql", ss.DeletePlaylist) - bus.AddHandler("sql", ss.SearchPlaylists) - bus.AddHandler("sql", ss.GetPlaylist) - bus.AddHandler("sql", ss.GetPlaylistItem) -} - func (ss *SQLStore) CreatePlaylist(ctx context.Context, cmd *models.CreatePlaylistCommand) error { return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { playlist := models.Playlist{ diff --git a/pkg/services/sqlstore/preferences.go b/pkg/services/sqlstore/preferences.go index 1fccba8adc3..6d9a7fe84ef 100644 --- a/pkg/services/sqlstore/preferences.go +++ b/pkg/services/sqlstore/preferences.go @@ -5,17 +5,9 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" ) -func (ss *SQLStore) addPreferencesQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.GetPreferences) - bus.AddHandler("sql", ss.GetPreferencesWithDefaults) - bus.AddHandler("sql", ss.SavePreferences) - bus.AddHandler("sql", ss.PatchPreferences) -} - func (ss *SQLStore) GetPreferencesWithDefaults(ctx context.Context, query *models.GetPreferencesWithDefaultsQuery) error { return ss.WithDbSession(ctx, func(dbSession *DBSession) error { params := make([]interface{}, 0) @@ -117,6 +109,11 @@ func (ss *SQLStore) SavePreferences(ctx context.Context, cmd *models.SavePrefere if cmd.Navbar != nil { prefs.JsonData.Navbar = *cmd.Navbar } + + if cmd.QueryHistory != nil { + prefs.JsonData.QueryHistory = *cmd.QueryHistory + } + _, err = sess.Insert(&prefs) return err } @@ -129,6 +126,16 @@ func (ss *SQLStore) SavePreferences(ctx context.Context, cmd *models.SavePrefere prefs.JsonData.Navbar.SavedItems = cmd.Navbar.SavedItems } } + + if cmd.QueryHistory != nil { + if prefs.JsonData == nil { + prefs.JsonData = &models.PreferencesJsonData{} + } + if cmd.QueryHistory.HomeTab != "" { + prefs.JsonData.QueryHistory.HomeTab = cmd.QueryHistory.HomeTab + } + } + prefs.HomeDashboardId = cmd.HomeDashboardId prefs.Timezone = cmd.Timezone prefs.WeekStart = cmd.WeekStart @@ -167,6 +174,15 @@ func (ss *SQLStore) PatchPreferences(ctx context.Context, cmd *models.PatchPrefe } } + if cmd.QueryHistory != nil { + if prefs.JsonData == nil { + prefs.JsonData = &models.PreferencesJsonData{} + } + if cmd.QueryHistory.HomeTab != "" { + prefs.JsonData.QueryHistory.HomeTab = cmd.QueryHistory.HomeTab + } + } + if cmd.HomeDashboardId != nil { prefs.HomeDashboardId = *cmd.HomeDashboardId } diff --git a/pkg/services/sqlstore/quota.go b/pkg/services/sqlstore/quota.go index abc0c39b98c..3ed4428fa71 100644 --- a/pkg/services/sqlstore/quota.go +++ b/pkg/services/sqlstore/quota.go @@ -5,7 +5,6 @@ import ( "fmt" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" ) @@ -15,16 +14,6 @@ const ( dashboardTarget = "dashboard" ) -func (ss *SQLStore) addQuotaQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.GetOrgQuotaByTarget) - bus.AddHandler("sql", ss.GetOrgQuotas) - bus.AddHandler("sql", ss.UpdateOrgQuota) - bus.AddHandler("sql", ss.GetUserQuotaByTarget) - bus.AddHandler("sql", ss.GetUserQuotas) - bus.AddHandler("sql", ss.UpdateUserQuota) - bus.AddHandler("sql", ss.GetGlobalQuotaByTarget) -} - type targetCount struct { Count int64 } diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 792ce846329..7baa450cb99 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -15,7 +15,6 @@ import ( _ "github.com/lib/pq" "xorm.io/xorm" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/fs" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" @@ -44,7 +43,6 @@ type ContextSessionKey struct{} type SQLStore struct { Cfg *setting.Cfg - Bus bus.Bus CacheService *localcache.CacheService dbCfg DatabaseConfig @@ -56,12 +54,12 @@ type SQLStore struct { tracer tracing.Tracer } -func ProvideService(cfg *setting.Cfg, cacheService *localcache.CacheService, bus bus.Bus, migrations registry.DatabaseMigrator, tracer tracing.Tracer) (*SQLStore, error) { +func ProvideService(cfg *setting.Cfg, cacheService *localcache.CacheService, migrations registry.DatabaseMigrator, tracer tracing.Tracer) (*SQLStore, error) { // This change will make xorm use an empty default schema for postgres and // by that mimic the functionality of how it was functioning before // xorm's changes above. xorm.DefaultPostgresSchema = "" - s, err := newSQLStore(cfg, cacheService, bus, nil, migrations, tracer) + s, err := newSQLStore(cfg, cacheService, nil, migrations, tracer) if err != nil { return nil, err } @@ -81,11 +79,10 @@ func ProvideServiceForTests(migrations registry.DatabaseMigrator) (*SQLStore, er return initTestDB(migrations, InitTestDBOpt{EnsureDefaultOrgAndUser: true}) } -func newSQLStore(cfg *setting.Cfg, cacheService *localcache.CacheService, b bus.Bus, engine *xorm.Engine, +func newSQLStore(cfg *setting.Cfg, cacheService *localcache.CacheService, engine *xorm.Engine, migrations registry.DatabaseMigrator, tracer tracing.Tracer, opts ...InitTestDBOpt) (*SQLStore, error) { ss := &SQLStore{ Cfg: cfg, - Bus: b, CacheService: cacheService, log: log.New("sqlstore"), skipEnsureDefaultOrgAndUser: false, @@ -111,28 +108,6 @@ func newSQLStore(cfg *setting.Cfg, cacheService *localcache.CacheService, b bus. // Init repo instances annotations.SetRepository(&SQLAnnotationRepo{sql: ss}) annotations.SetAnnotationCleaner(&AnnotationCleanupService{batchSize: ss.Cfg.AnnotationCleanupJobBatchSize, log: log.New("annotationcleaner")}) - ss.Bus.SetTransactionManager(ss) - - // Register handlers - ss.addStatsQueryAndCommandHandlers() - ss.addUserQueryAndCommandHandlers() - ss.addAlertNotificationUidByIdHandler() - ss.addPreferencesQueryAndCommandHandlers() - ss.addDashboardQueryAndCommandHandlers() - ss.addDashboardACLQueryAndCommandHandlers() - ss.addQuotaQueryAndCommandHandlers() - ss.addOrgUsersQueryAndCommandHandlers() - ss.addStarQueryAndCommandHandlers() - ss.addAlertQueryAndCommandHandlers() - ss.addTempUserQueryAndCommandHandlers() - ss.addDashboardVersionQueryAndCommandHandlers() - ss.addAPIKeysQueryAndCommandHandlers() - ss.addPlaylistQueryAndCommandHandlers() - ss.addLoginAttemptQueryAndCommandHandlers() - ss.addTeamQueryAndCommandHandlers() - ss.addOrgQueryAndCommandHandlers() - - bus.AddHandler("sql", ss.GetDBHealthQuery) // if err := ss.Reset(); err != nil { // return nil, err @@ -565,7 +540,7 @@ func initTestDB(migration registry.DatabaseMigrator, opts ...InitTestDBOpt) (*SQ if err != nil { return nil, err } - testSQLStore, err = newSQLStore(cfg, localcache.New(5*time.Minute, 10*time.Minute), bus.GetBus(), engine, migration, tracer, opts...) + testSQLStore, err = newSQLStore(cfg, localcache.New(5*time.Minute, 10*time.Minute), engine, migration, tracer, opts...) if err != nil { return nil, err } diff --git a/pkg/services/sqlstore/star.go b/pkg/services/sqlstore/star.go index fef91f9e9b4..847fdfec6e0 100644 --- a/pkg/services/sqlstore/star.go +++ b/pkg/services/sqlstore/star.go @@ -3,17 +3,9 @@ package sqlstore import ( "context" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" ) -func (ss *SQLStore) addStarQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.StarDashboard) - bus.AddHandler("sql", ss.UnstarDashboard) - bus.AddHandler("sql", ss.GetUserStars) - bus.AddHandler("sql", ss.IsStarredByUserCtx) -} - func (ss *SQLStore) IsStarredByUserCtx(ctx context.Context, query *models.IsStarredByUserQuery) error { return ss.WithDbSession(ctx, func(sess *DBSession) error { rawSQL := "SELECT 1 from star where user_id=? and dashboard_id=?" diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go index 43777190fe3..e1e1500994e 100644 --- a/pkg/services/sqlstore/stats.go +++ b/pkg/services/sqlstore/stats.go @@ -5,20 +5,10 @@ import ( "strconv" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) -func (ss *SQLStore) addStatsQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.GetAdminStats) - bus.AddHandler("sql", ss.GetSystemUserCountStats) - bus.AddHandler("sql", ss.GetAlertNotifiersUsageStats) - bus.AddHandler("sql", ss.GetDataSourceAccessStats) - bus.AddHandler("sql", ss.GetDataSourceStats) - bus.AddHandler("sql", ss.GetSystemStats) -} - const activeUserTimeLimit = time.Hour * 24 * 30 const dailyActiveUserTimeLimit = time.Hour * 24 diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index abedc2110b3..46c88f8f0bc 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -7,25 +7,11 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/featuremgmt" ) -func (ss *SQLStore) addTeamQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.UpdateTeam) - bus.AddHandler("sql", ss.DeleteTeam) - bus.AddHandler("sql", ss.SearchTeams) - bus.AddHandler("sql", ss.GetTeamById) - bus.AddHandler("sql", ss.GetTeamsByUser) - - bus.AddHandler("sql", ss.UpdateTeamMember) - bus.AddHandler("sql", ss.RemoveTeamMember) - bus.AddHandler("sql", ss.GetTeamMembers) - bus.AddHandler("sql", IsAdminOfTeams) -} - type TeamStore interface { UpdateTeam(ctx context.Context, cmd *models.UpdateTeamCommand) error DeleteTeam(ctx context.Context, cmd *models.DeleteTeamCommand) error @@ -35,7 +21,6 @@ type TeamStore interface { RemoveTeamMember(ctx context.Context, cmd *models.RemoveTeamMemberCommand) error GetTeamMembers(ctx context.Context, cmd *models.GetTeamMembersQuery) error GetUserTeamMemberships(ctx context.Context, orgID, userID int64, external bool) ([]*models.TeamMemberDTO, error) - AddOrUpdateTeamMember(userID, orgID, teamID int64, isExternal bool, permission models.PermissionType) error } func getFilteredUsers(signedInUser *models.SignedInUser, hiddenUsers map[string]struct{}) []string { diff --git a/pkg/services/sqlstore/temp_user.go b/pkg/services/sqlstore/temp_user.go index 97f32f77a41..80115ade956 100644 --- a/pkg/services/sqlstore/temp_user.go +++ b/pkg/services/sqlstore/temp_user.go @@ -4,19 +4,9 @@ import ( "context" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" ) -func (ss *SQLStore) addTempUserQueryAndCommandHandlers() { - bus.AddHandler("sql", ss.CreateTempUser) - bus.AddHandler("sql", ss.GetTempUsersQuery) - bus.AddHandler("sql", ss.UpdateTempUserStatus) - bus.AddHandler("sql", ss.GetTempUserByCode) - bus.AddHandler("sql", ss.UpdateTempUserWithEmailSent) - bus.AddHandler("sql", ss.ExpireOldUserInvites) -} - func (ss *SQLStore) UpdateTempUserStatus(ctx context.Context, cmd *models.UpdateTempUserStatusCommand) error { return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { var rawSQL = "UPDATE temp_user SET status=? WHERE code=?" diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index c9ce4d9b098..2a029912a37 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -16,25 +15,6 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func (ss *SQLStore) addUserQueryAndCommandHandlers() { - ss.Bus.AddHandler(ss.GetSignedInUserWithCacheCtx) - - bus.AddHandler("sql", ss.GetUserById) - bus.AddHandler("sql", ss.UpdateUser) - bus.AddHandler("sql", ss.ChangeUserPassword) - bus.AddHandler("sql", ss.GetUserByLogin) - bus.AddHandler("sql", ss.GetUserByEmail) - bus.AddHandler("sql", ss.SetUsingOrg) - bus.AddHandler("sql", ss.UpdateUserLastSeenAt) - bus.AddHandler("sql", ss.GetUserProfile) - bus.AddHandler("sql", ss.SearchUsers) - bus.AddHandler("sql", ss.GetUserOrgList) - bus.AddHandler("sql", ss.DisableUser) - bus.AddHandler("sql", ss.BatchDisableUsers) - bus.AddHandler("sql", ss.DeleteUser) - bus.AddHandler("sql", ss.SetUserHelpFlag) -} - func getOrgIdForNewUser(sess *DBSession, cmd models.CreateUserCommand) (int64, error) { if cmd.SkipOrgSetup { return -1, nil @@ -509,7 +489,7 @@ func (ss *SQLStore) GetUserOrgList(ctx context.Context, query *models.GetUserOrg query.Result = make([]*models.UserOrgDTO, 0) sess := dbSess.Table("org_user") sess.Join("INNER", "org", "org_user.org_id=org.id") - sess.Join("INNER", x.Dialect().Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", x.Dialect().Quote("user"))) + sess.Join("INNER", ss.Dialect.Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", ss.Dialect.Quote("user"))) sess.Where("org_user.user_id=?", query.UserId) sess.Where(notServiceAccountFilter(ss)) sess.Cols("org.name", "org_user.role", "org_user.org_id") @@ -555,6 +535,7 @@ func (ss *SQLStore) GetSignedInUser(ctx context.Context, query *models.GetSigned u.email as email, u.login as login, u.name as name, + u.is_disabled as is_disabled, u.help_flags1 as help_flags1, u.last_seen_at as last_seen_at, (SELECT COUNT(*) FROM org_user where org_user.user_id = u.id) as org_count, diff --git a/pkg/services/sqlstore/user_test.go b/pkg/services/sqlstore/user_test.go index 76182fbc2cd..e2340de0ab6 100644 --- a/pkg/services/sqlstore/user_test.go +++ b/pkg/services/sqlstore/user_test.go @@ -255,7 +255,7 @@ func TestUserDataAccess(t *testing.T) { require.Nil(t, err) query1 := &models.GetOrgUsersQuery{OrgId: users[0].OrgId} - err = GetOrgUsersForTest(query1) + err = ss.GetOrgUsersForTest(context.Background(), query1) require.Nil(t, err) require.Len(t, query1.Result, 1) @@ -341,7 +341,7 @@ func TestUserDataAccess(t *testing.T) { // delete connected org users and permissions query2 := &models.GetOrgUsersQuery{OrgId: users[0].OrgId} - err = GetOrgUsersForTest(query2) + err = ss.GetOrgUsersForTest(context.Background(), query2) require.Nil(t, err) require.Len(t, query2.Result, 1) @@ -507,15 +507,17 @@ func TestUserDataAccess(t *testing.T) { }) } -func GetOrgUsersForTest(query *models.GetOrgUsersQuery) error { - query.Result = make([]*models.OrgUserDTO, 0) - sess := x.Table("org_user") - sess.Join("LEFT ", x.Dialect().Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", x.Dialect().Quote("user"))) - sess.Where("org_user.org_id=?", query.OrgId) - sess.Cols("org_user.org_id", "org_user.user_id", "user.email", "user.login", "org_user.role") +func (ss *SQLStore) GetOrgUsersForTest(ctx context.Context, query *models.GetOrgUsersQuery) error { + return ss.WithDbSession(ctx, func(dbSess *DBSession) error { + query.Result = make([]*models.OrgUserDTO, 0) + sess := dbSess.Table("org_user") + sess.Join("LEFT ", ss.Dialect.Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", ss.Dialect.Quote("user"))) + sess.Where("org_user.org_id=?", query.OrgId) + sess.Cols("org_user.org_id", "org_user.user_id", "user.email", "user.login", "org_user.role") - err := sess.Find(&query.Result) - return err + err := sess.Find(&query.Result) + return err + }) } func createFiveTestUsers(t *testing.T, sqlStore *SQLStore, fn func(i int) *models.CreateUserCommand) []models.User { diff --git a/pkg/services/store/http.go b/pkg/services/store/http.go index 5d23128a8cf..7334049d395 100644 --- a/pkg/services/store/http.go +++ b/pkg/services/store/http.go @@ -1,6 +1,8 @@ package store import ( + "net/http" + "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/web" @@ -28,7 +30,7 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response { action := "Upload" scope, path := getPathAndScope(c) - return response.JSON(200, map[string]string{ + return response.JSON(http.StatusOK, map[string]string{ "action": action, "scope": scope, "path": path, @@ -39,7 +41,7 @@ func (s *httpStorage) Read(c *models.ReqContext) response.Response { action := "Read" scope, path := getPathAndScope(c) - return response.JSON(200, map[string]string{ + return response.JSON(http.StatusOK, map[string]string{ "action": action, "scope": scope, "path": path, @@ -50,7 +52,7 @@ func (s *httpStorage) Delete(c *models.ReqContext) response.Response { action := "Delete" scope, path := getPathAndScope(c) - return response.JSON(200, map[string]string{ + return response.JSON(http.StatusOK, map[string]string{ "action": action, "scope": scope, "path": path, @@ -67,5 +69,5 @@ func (s *httpStorage) List(c *models.ReqContext) response.Response { if frame == nil { return response.Error(404, "not found", nil) } - return response.JSONStreaming(200, frame) + return response.JSONStreaming(http.StatusOK, frame) } diff --git a/pkg/services/store/storage_disk.go b/pkg/services/store/storage_disk.go index e7dde055629..143a80b5e28 100644 --- a/pkg/services/store/storage_disk.go +++ b/pkg/services/store/storage_disk.go @@ -82,7 +82,7 @@ func (s *rootStorageDisk) Write(ctx context.Context, cmd *WriteValueRequest) (*W } err := s.store.Upsert(ctx, &filestorage.UpsertFileCommand{ Path: path, - Contents: &byteAray, + Contents: byteAray, }) if err != nil { return nil, err diff --git a/pkg/services/thumbs/crawler.go b/pkg/services/thumbs/crawler.go index 74dcc5051a6..c641455728c 100644 --- a/pkg/services/thumbs/crawler.go +++ b/pkg/services/thumbs/crawler.go @@ -26,6 +26,7 @@ type simpleCrawler struct { thumbnailRepo thumbnailRepo mode CrawlerMode thumbnailKind models.ThumbnailKind + auth CrawlerAuth opts rendering.Opts status crawlStatus statusMutex sync.RWMutex @@ -67,8 +68,8 @@ func (r *simpleCrawler) next(ctx context.Context) (*models.DashboardWithStaleThu authOpts := rendering.AuthOpts{ OrgID: v.OrgId, - UserID: r.opts.AuthOpts.UserID, - OrgRole: r.opts.AuthOpts.OrgRole, + UserID: r.auth.GetUserId(v.OrgId), + OrgRole: r.auth.GetOrgRole(), } if renderingSession, ok := r.renderingSessionByOrgId[v.OrgId]; ok { @@ -112,7 +113,7 @@ func (d byOrgId) Len() int { return len(d) } func (d byOrgId) Less(i, j int) bool { return d[i].OrgId > d[j].OrgId } func (d byOrgId) Swap(i, j int) { d[i], d[j] = d[j], d[i] } -func (r *simpleCrawler) Run(ctx context.Context, authOpts rendering.AuthOpts, mode CrawlerMode, theme models.Theme, thumbnailKind models.ThumbnailKind) error { +func (r *simpleCrawler) Run(ctx context.Context, auth CrawlerAuth, mode CrawlerMode, theme models.Theme, thumbnailKind models.ThumbnailKind) error { res, err := r.renderService.HasCapability(rendering.ScalingDownImages) if err != nil { return err @@ -150,8 +151,8 @@ func (r *simpleCrawler) Run(ctx context.Context, authOpts rendering.AuthOpts, mo r.mode = mode r.thumbnailKind = thumbnailKind + r.auth = auth r.opts = rendering.Opts{ - AuthOpts: authOpts, TimeoutOpts: rendering.TimeoutOpts{ Timeout: 20 * time.Second, RequestTimeoutMultiplier: 3, diff --git a/pkg/services/thumbs/crawler_auth.go b/pkg/services/thumbs/crawler_auth.go new file mode 100644 index 00000000000..b8dd6ba334c --- /dev/null +++ b/pkg/services/thumbs/crawler_auth.go @@ -0,0 +1,41 @@ +package thumbs + +import ( + "context" + + "github.com/grafana/grafana/pkg/models" +) + +type CrawlerAuthSetupService interface { + Setup(ctx context.Context) (CrawlerAuth, error) +} + +func ProvideCrawlerAuthSetupService() *OSSCrawlerAuthSetupService { + return &OSSCrawlerAuthSetupService{} +} + +type OSSCrawlerAuthSetupService struct{} + +type CrawlerAuth interface { + GetUserId(orgId int64) int64 + GetOrgRole() models.RoleType +} + +type staticCrawlerAuth struct { + userId int64 + orgRole models.RoleType +} + +func (o *staticCrawlerAuth) GetOrgRole() models.RoleType { + return o.orgRole +} + +func (o *staticCrawlerAuth) GetUserId(orgId int64) int64 { + return o.userId +} + +func (o *OSSCrawlerAuthSetupService) Setup(ctx context.Context) (CrawlerAuth, error) { + // userId:0 and ROLE_ADMIN grants the crawler process permissions to view all dashboards in all folders & orgs + // the process doesn't and shouldn't actually need to edit/modify any resources from the UI + return &staticCrawlerAuth{userId: 0, orgRole: models.ROLE_ADMIN}, nil +} diff --git a/pkg/services/thumbs/dummy.go b/pkg/services/thumbs/dummy.go index 6c984d412ad..769b70443a8 100644 --- a/pkg/services/thumbs/dummy.go +++ b/pkg/services/thumbs/dummy.go @@ -2,6 +2,7 @@ package thumbs import ( "context" + "net/http" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" @@ -39,19 +40,19 @@ func (ds *dummyService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) func (ds *dummyService) StartCrawler(c *models.ReqContext) response.Response { result := make(map[string]string) result["error"] = "Not enabled" - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } func (ds *dummyService) StopCrawler(c *models.ReqContext) response.Response { result := make(map[string]string) result["error"] = "Not enabled" - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } func (ds *dummyService) CrawlerStatus(c *models.ReqContext) response.Response { result := make(map[string]string) result["error"] = "Not enabled" - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } func (ds *dummyService) Run(ctx context.Context) error { diff --git a/pkg/services/thumbs/models.go b/pkg/services/thumbs/models.go index a7c128d195c..78d30746c74 100644 --- a/pkg/services/thumbs/models.go +++ b/pkg/services/thumbs/models.go @@ -5,7 +5,6 @@ import ( "time" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/rendering" ) type CrawlerMode string @@ -66,7 +65,7 @@ type dashboardPreviewsSetupConfig struct { type dashRenderer interface { // Run Assumes you have already authenticated as admin. - Run(ctx context.Context, authOpts rendering.AuthOpts, mode CrawlerMode, theme models.Theme, kind models.ThumbnailKind) error + Run(ctx context.Context, auth CrawlerAuth, mode CrawlerMode, theme models.Theme, kind models.ThumbnailKind) error // Assumes you have already authenticated as admin. Stop() (crawlStatus, error) diff --git a/pkg/services/thumbs/service.go b/pkg/services/thumbs/service.go index 6979de50572..45cba2828aa 100644 --- a/pkg/services/thumbs/service.go +++ b/pkg/services/thumbs/service.go @@ -6,11 +6,13 @@ import ( "fmt" "io" "io/ioutil" + "net/http" "time" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/serverlock" + "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/guardian" @@ -48,6 +50,7 @@ type thumbService struct { store sqlstore.Store crawlLockServiceActionName string log log.Logger + usageStatsService usagestats.Service } type crawlerScheduleOptions struct { @@ -56,23 +59,25 @@ type crawlerScheduleOptions struct { maxCrawlDuration time.Duration crawlerMode CrawlerMode thumbnailKind models.ThumbnailKind - auth rendering.AuthOpts themes []models.Theme + auth CrawlerAuth } -func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, lockService *serverlock.ServerLockService, renderService rendering.Service, gl *live.GrafanaLive, store *sqlstore.SQLStore) Service { +func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, lockService *serverlock.ServerLockService, renderService rendering.Service, gl *live.GrafanaLive, store *sqlstore.SQLStore, authSetupService CrawlerAuthSetupService, usageStatsService usagestats.Service) Service { if !features.IsEnabled(featuremgmt.FlagDashboardPreviews) { return &dummyService{} } + logger := log.New("thumbnails_service") thumbnailRepo := newThumbnailRepo(store) - authOpts := rendering.AuthOpts{ - OrgID: 0, - UserID: 0, - OrgRole: models.ROLE_ADMIN, + crawlerAuth, err := authSetupService.Setup(context.Background()) + if err != nil { + logger.Error("Failed to setup auth for the dashboard previews crawler", "err", err) + return &dummyService{} } - return &thumbService{ + t := &thumbService{ + usageStatsService: usageStatsService, renderingService: renderService, renderer: newSimpleCrawler(renderService, gl, thumbnailRepo), thumbnailRepo: thumbnailRepo, @@ -80,7 +85,7 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, lockS features: features, lockService: lockService, crawlLockServiceActionName: "dashboard-crawler", - log: log.New("thumbnails_service"), + log: logger, scheduleOptions: crawlerScheduleOptions{ tickerInterval: time.Hour, @@ -89,9 +94,30 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, lockS crawlerMode: CrawlerModeThumbs, thumbnailKind: models.ThumbnailKindDefault, themes: []models.Theme{models.ThemeDark, models.ThemeLight}, - auth: authOpts, + auth: crawlerAuth, }, } + + t.registerUsageStats() + return t +} + +func (hs *thumbService) registerUsageStats() { + hs.usageStatsService.RegisterMetricsFunc(func(ctx context.Context) (map[string]interface{}, error) { + s := hs.getDashboardPreviewsSetupSettings(ctx) + + stats := make(map[string]interface{}) + + if s.SystemRequirements.Met { + stats["stats.dashboard_previews.system_req_met.count"] = 1 + } + + if s.ThumbnailsExist { + stats["stats.dashboard_previews.thumbnails_exist.count"] = 1 + } + + return stats, nil + }) } func (hs *thumbService) Enabled() bool { @@ -167,7 +193,7 @@ func (hs *thumbService) UpdateThumbnailState(c *models.ReqContext) { } hs.log.Info("Updated dashboard thumbnail state", "dashboardUid", req.UID, "theme", req.Theme, "newState", body.State) - c.JSON(200, map[string]string{"success": "true"}) + c.JSON(http.StatusOK, map[string]string{"success": "true"}) } func (hs *thumbService) GetImage(c *models.ReqContext) { @@ -201,8 +227,12 @@ func (hs *thumbService) GetImage(c *models.ReqContext) { } func (hs *thumbService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig { + return hs.getDashboardPreviewsSetupSettings(c.Req.Context()) +} + +func (hs *thumbService) getDashboardPreviewsSetupSettings(ctx context.Context) dashboardPreviewsSetupConfig { systemRequirements := hs.getSystemRequirements() - thumbnailsExist, err := hs.thumbnailRepo.doThumbnailsExist(c.Req.Context()) + thumbnailsExist, err := hs.thumbnailRepo.doThumbnailsExist(ctx) if err != nil { return dashboardPreviewsSetupConfig{ @@ -291,7 +321,7 @@ func (hs *thumbService) SetImage(c *models.ReqContext) { return } - c.JSON(200, map[string]int{"OK": len(fileBytes)}) + c.JSON(http.StatusOK, map[string]int{"OK": len(fileBytes)}) } func (hs *thumbService) StartCrawler(c *models.ReqContext) response.Response { @@ -319,7 +349,7 @@ func (hs *thumbService) StartCrawler(c *models.ReqContext) response.Response { return response.Error(500, "error starting", err) } - return response.JSON(200, status) + return response.JSON(http.StatusOK, status) } func (hs *thumbService) StopCrawler(c *models.ReqContext) response.Response { @@ -327,7 +357,7 @@ func (hs *thumbService) StopCrawler(c *models.ReqContext) response.Response { if err != nil { return response.Error(500, "error starting", err) } - return response.JSON(200, msg) + return response.JSON(http.StatusOK, msg) } func (hs *thumbService) CrawlerStatus(c *models.ReqContext) response.Response { @@ -335,7 +365,7 @@ func (hs *thumbService) CrawlerStatus(c *models.ReqContext) response.Response { if err != nil { return response.Error(500, "error starting", err) } - return response.JSON(200, msg) + return response.JSON(http.StatusOK, msg) } // Ideally this service would not require first looking up the full dashboard just to bet the id! @@ -377,7 +407,7 @@ func (hs *thumbService) runOnDemandCrawl(parentCtx context.Context, theme models // wait for at least a minute after the last completed run interval := time.Minute err := hs.lockService.LockAndExecute(crawlerCtx, hs.crawlLockServiceActionName, interval, func(ctx context.Context) { - if err := hs.renderer.Run(crawlerCtx, authOpts, mode, theme, kind); err != nil { + if err := hs.renderer.Run(crawlerCtx, hs.scheduleOptions.auth, mode, theme, kind); err != nil { hs.log.Error("On demand crawl error", "mode", mode, "theme", theme, "kind", kind, "userId", authOpts.UserID, "orgId", authOpts.OrgID, "orgRole", authOpts.OrgRole) } }) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 10834bf4556..58d695f41fc 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -393,6 +393,7 @@ type Cfg struct { ReportingEnabled bool ApplicationInsightsConnectionString string ApplicationInsightsEndpointUrl string + FeedbackLinksEnabled bool // LDAP LDAPEnabled bool @@ -938,13 +939,17 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { RudderstackDataPlaneUrl = analytics.Key("rudderstack_data_plane_url").String() RudderstackSdkUrl = analytics.Key("rudderstack_sdk_url").String() RudderstackConfigUrl = analytics.Key("rudderstack_config_url").String() + cfg.ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) cfg.ReportingDistributor = analytics.Key("reporting_distributor").MustString("grafana-labs") + if len(cfg.ReportingDistributor) >= 100 { cfg.ReportingDistributor = cfg.ReportingDistributor[:100] } + cfg.ApplicationInsightsConnectionString = analytics.Key("application_insights_connection_string").String() cfg.ApplicationInsightsEndpointUrl = analytics.Key("application_insights_endpoint_url").String() + cfg.FeedbackLinksEnabled = analytics.Key("feedback_links_enabled").MustBool(true) if err := readAlertingSettings(iniFile); err != nil { return err diff --git a/pkg/tests/api/alerting/api_admin_configuration_test.go b/pkg/tests/api/alerting/api_admin_configuration_test.go index 777ea9f05aa..3dca58e0d16 100644 --- a/pkg/tests/api/alerting/api_admin_configuration_test.go +++ b/pkg/tests/api/alerting/api_admin_configuration_test.go @@ -12,7 +12,6 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" @@ -35,8 +34,6 @@ func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) { }) grafanaListedAddr, s := testinfra.StartGrafana(t, dir, path) - // override bus to get the GetSignedInUserQuery handler - s.Bus = bus.GetBus() // Create a user to make authenticated requests userID := createUser(t, s, models.CreateUserCommand{ @@ -69,7 +66,10 @@ func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) { resp := getRequest(t, alertsURL, http.StatusNotFound) // nolint b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - require.JSONEq(t, `{"message": "no admin configuration available"}`, string(b)) + var res map[string]interface{} + err = json.Unmarshal(b, &res) + require.NoError(t, err) + require.Equal(t, "no admin configuration available", res["message"]) } // An invalid alertmanager choice should return an error. diff --git a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go index d51d3752b60..7405c32083c 100644 --- a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go @@ -3,12 +3,12 @@ package alerting import ( "encoding/json" "fmt" + "io/ioutil" "net/http" "regexp" "testing" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" @@ -34,9 +34,6 @@ func TestAlertmanagerConfigurationIsTransactional(t *testing.T) { // editor from main organisation requests configuration alertConfigURL := fmt.Sprintf("http://editor:editor@%s/api/alertmanager/grafana/config/api/v1/alerts", grafanaListedAddr) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() - // create user under main organisation userID := createUser(t, store, models.CreateUserCommand{ DefaultOrgRole: string(models.ROLE_EDITOR), @@ -93,9 +90,13 @@ func TestAlertmanagerConfigurationIsTransactional(t *testing.T) { } ` resp := postRequest(t, alertConfigURL, payload, http.StatusBadRequest) // nolint - require.JSONEq(t, `{"message": "failed to save and apply Alertmanager configuration: failed to build integration map: the receiver is invalid: failed to validate receiver \"slack.receiver\" of type \"slack\": token must be specified when using the Slack chat API"}`, getBody(t, resp.Body)) - + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + var res map[string]interface{} + require.NoError(t, json.Unmarshal(b, &res)) + require.Equal(t, `failed to save and apply Alertmanager configuration: failed to build integration map: the receiver is invalid: failed to validate receiver "slack.receiver" of type "slack": token must be specified when using the Slack chat API`, res["message"]) resp = getRequest(t, alertConfigURL, http.StatusOK) // nolint + require.JSONEq(t, defaultAlertmanagerConfigJSON, getBody(t, resp.Body)) } @@ -145,9 +146,6 @@ func TestAlertmanagerConfigurationPersistSecrets(t *testing.T) { grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) alertConfigURL := fmt.Sprintf("http://editor:editor@%s/api/alertmanager/grafana/config/api/v1/alerts", grafanaListedAddr) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() - createUser(t, store, models.CreateUserCommand{ DefaultOrgRole: string(models.ROLE_EDITOR), Password: "editor", @@ -217,7 +215,10 @@ func TestAlertmanagerConfigurationPersistSecrets(t *testing.T) { ` resp := postRequest(t, alertConfigURL, payload, http.StatusBadRequest) // nolint - require.JSONEq(t, `{"message": "unknown receiver: invalid"}`, getBody(t, resp.Body)) + s := getBody(t, resp.Body) + var res map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(s), &res)) + require.Equal(t, "unknown receiver: invalid", res["message"]) } // The secure settings must be present diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 1505ade9311..1ed1cde55c8 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -16,7 +16,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" @@ -40,8 +39,6 @@ func TestAMConfigAccess(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() // Create a users to make authenticated requests createUser(t, store, models.CreateUserCommand{ @@ -403,8 +400,6 @@ func TestAlertAndGroupsQuery(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() // unauthenticated request to get the alerts should fail { @@ -442,7 +437,10 @@ func TestAlertAndGroupsQuery(t *testing.T) { b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, http.StatusUnauthorized, resp.StatusCode) - require.JSONEq(t, `{"message": "invalid username or password"}`, string(b)) + + var res map[string]interface{} + require.NoError(t, json.Unmarshal(b, &res)) + require.Equal(t, "invalid username or password", res["message"]) } // When there are no alerts available, it returns an empty list. @@ -574,8 +572,6 @@ func TestRulerAccess(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() // Create the namespace we'll save our alerts to. _, err = createFolder(t, store, 0, "default") @@ -704,8 +700,6 @@ func TestDeleteFolderWithRules(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() // Create the namespace we'll save our alerts to. namespaceUID, err := createFolder(t, store, 0, "default") @@ -866,8 +860,6 @@ func TestAlertRuleCRUD(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() createUser(t, store, models.CreateUserCommand{ DefaultOrgRole: string(models.ROLE_EDITOR), @@ -908,7 +900,7 @@ func TestAlertRuleCRUD(t *testing.T) { Data: []ngmodels.AlertQuery{}, }, }, - expectedResponse: `{"message": "invalid rule specification at index [0]: invalid alert rule: no queries or expressions are found"}`, + expectedResponse: `{"message": "invalid rule specification at index [0]: invalid alert rule: no queries or expressions are found", "traceID":"00000000000000000000000000000000"}`, }, { desc: "alert rule with empty title", @@ -938,7 +930,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message": "invalid rule specification at index [0]: alert rule title cannot be empty"}`, + expectedResponse: `{"message": "invalid rule specification at index [0]: alert rule title cannot be empty", "traceID":"00000000000000000000000000000000"}`, }, { desc: "alert rule with too long name", @@ -968,7 +960,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message": "invalid rule specification at index [0]: alert rule title is too long. Max length is 190"}`, + expectedResponse: `{"message": "invalid rule specification at index [0]: alert rule title is too long. Max length is 190", "traceID":"00000000000000000000000000000000"}`, }, { desc: "alert rule with too long rulegroup", @@ -998,7 +990,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message": "rule group name is too long. Max length is 190"}`, + expectedResponse: `{"message": "rule group name is too long. Max length is 190", "traceID":"00000000000000000000000000000000"}`, }, { desc: "alert rule with invalid interval", @@ -1029,7 +1021,8 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message": "rule evaluation interval (1 second) should be positive number that is multiple of the base interval of 10 seconds"}`, + expectedResponse: `{"message": "rule evaluation interval (1 second) should be positive ` + + `number that is multiple of the base interval of 10 seconds", "traceID":"00000000000000000000000000000000"}`, }, { desc: "alert rule with unknown datasource", @@ -1059,7 +1052,8 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message": "invalid rule specification at index [0]: failed to validate condition of alert rule AlwaysFiring: invalid query A: data source not found: unknown"}`, + expectedResponse: `{"message": "invalid rule specification at index [0]: failed to validate condition of alert rule AlwaysFiring:` + + ` invalid query A: data source not found: unknown", "traceID":"00000000000000000000000000000000"}`, }, { desc: "alert rule with invalid condition", @@ -1089,7 +1083,8 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message": "invalid rule specification at index [0]: failed to validate condition of alert rule AlwaysFiring: condition B not found in any query or expression: it should be one of: [A]"}`, + expectedResponse: `{"message": "invalid rule specification at index [0]: failed to validate condition of alert rule AlwaysFiring: ` + + `condition B not found in any query or expression: it should be one of: [A]", "traceID":"00000000000000000000000000000000"}`, }, } @@ -1379,7 +1374,9 @@ func TestAlertRuleCRUD(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusNotFound, resp.StatusCode) - require.JSONEq(t, `{"message": "failed to update rule group: failed to update rule with UID unknown because could not find alert rule"}`, string(b)) + var res map[string]interface{} + assert.NoError(t, json.Unmarshal(b, &res)) + require.Equal(t, "failed to update rule group: failed to update rule with UID unknown because could not find alert rule", res["message"]) // let's make sure that rule definitions are not affected by the failed POST request. u = fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) @@ -1498,7 +1495,9 @@ func TestAlertRuleCRUD(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - require.JSONEq(t, fmt.Sprintf(`{"message": "rule [1] has UID %s that is already assigned to another rule at index 0"}`, ruleUID), string(b)) + var res map[string]interface{} + require.NoError(t, json.Unmarshal(b, &res)) + require.Equal(t, fmt.Sprintf("rule [1] has UID %s that is already assigned to another rule at index 0", ruleUID), res["message"]) // let's make sure that rule definitions are not affected by the failed POST request. u = fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) @@ -1904,7 +1903,9 @@ func TestAlertRuleCRUD(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusAccepted, resp.StatusCode) - require.JSONEq(t, `{"message":"rules deleted"}`, string(b)) + var res map[string]interface{} + require.NoError(t, json.Unmarshal(b, &res)) + require.Equal(t, "rules deleted", res["message"]) }) t.Run("succeed if the rule group name does exist", func(t *testing.T) { @@ -2001,8 +2002,6 @@ func TestQuota(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() // Create the namespace we'll save our alerts to. _, err = createFolder(t, store, 0, "default") @@ -2116,7 +2115,9 @@ func TestQuota(t *testing.T) { b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, http.StatusForbidden, resp.StatusCode) - require.JSONEq(t, `{"message": "quota has been exceeded"}`, string(b)) + var res map[string]interface{} + require.NoError(t, json.Unmarshal(b, &res)) + require.Equal(t, "quota has been exceeded", res["message"]) }) t.Run("when quota limit exceed updating existing rule should succeed", func(t *testing.T) { @@ -2248,8 +2249,6 @@ func TestEval(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() createUser(t, store, models.CreateUserCommand{ DefaultOrgRole: string(models.ROLE_EDITOR), @@ -2415,7 +2414,8 @@ func TestEval(t *testing.T) { } `, expectedStatusCode: http.StatusBadRequest, - expectedResponse: `{"message": "invalid condition: condition B not found in any query or expression: it should be one of: [A]"}`, + expectedResponse: `{"message": "invalid condition: condition B not found in any query or expression: it should be one of: [A]",` + + `"traceID": "00000000000000000000000000000000"}`, }, { desc: "unknown query datasource", @@ -2440,7 +2440,7 @@ func TestEval(t *testing.T) { } `, expectedStatusCode: http.StatusBadRequest, - expectedResponse: `{"message": "invalid condition: invalid query A: data source not found: unknown"}`, + expectedResponse: `{"message": "invalid condition: invalid query A: data source not found: unknown", "traceID": "00000000000000000000000000000000"}`, }, } @@ -2596,7 +2596,8 @@ func TestEval(t *testing.T) { } `, expectedStatusCode: http.StatusBadRequest, - expectedResponse: `{"message": "invalid queries or expressions: invalid query A: data source not found: unknown"}`, + expectedResponse: `{"message": "invalid queries or expressions: invalid query A: data source not found: unknown",` + + `"traceID": "00000000000000000000000000000000"}`, }, } diff --git a/pkg/tests/api/alerting/api_available_channel_test.go b/pkg/tests/api/alerting/api_available_channel_test.go index 3a53f2a1bfa..c54d1a1ae6b 100644 --- a/pkg/tests/api/alerting/api_available_channel_test.go +++ b/pkg/tests/api/alerting/api_available_channel_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" @@ -28,7 +27,6 @@ func TestAvailableChannels(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) - store.Bus = bus.GetBus() // Create a user to make authenticated requests createUser(t, store, models.CreateUserCommand{ diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index 686de9827a3..495656e115e 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -20,7 +20,6 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" @@ -43,7 +42,6 @@ func TestTestReceivers(t *testing.T) { }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - env.SQLStore.Bus = bus.GetBus() createUser(t, env.SQLStore, models.CreateUserCommand{ DefaultOrgRole: string(models.ROLE_EDITOR), @@ -63,7 +61,7 @@ func TestTestReceivers(t *testing.T) { b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - require.JSONEq(t, `{}`, string(b)) + require.JSONEq(t, `{"traceID":"00000000000000000000000000000000"}`, string(b)) }) t.Run("assert working receiver returns OK", func(t *testing.T) { @@ -77,7 +75,6 @@ func TestTestReceivers(t *testing.T) { }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - env.SQLStore.Bus = bus.GetBus() createUser(t, env.SQLStore, models.CreateUserCommand{ DefaultOrgRole: string(models.ROLE_EDITOR), @@ -162,7 +159,6 @@ func TestTestReceivers(t *testing.T) { }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - env.SQLStore.Bus = bus.GetBus() createUser(t, env.SQLStore, models.CreateUserCommand{ DefaultOrgRole: string(models.ROLE_EDITOR), @@ -242,7 +238,6 @@ func TestTestReceivers(t *testing.T) { }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - env.SQLStore.Bus = bus.GetBus() createUser(t, env.SQLStore, models.CreateUserCommand{ DefaultOrgRole: string(models.ROLE_EDITOR), @@ -332,7 +327,6 @@ func TestTestReceivers(t *testing.T) { }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - env.SQLStore.Bus = bus.GetBus() createUser(t, env.SQLStore, models.CreateUserCommand{ DefaultOrgRole: string(models.ROLE_EDITOR), @@ -448,7 +442,6 @@ func TestTestReceiversAlertCustomization(t *testing.T) { }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - env.SQLStore.Bus = bus.GetBus() createUser(t, env.SQLStore, models.CreateUserCommand{ DefaultOrgRole: string(models.ROLE_EDITOR), @@ -544,7 +537,6 @@ func TestTestReceiversAlertCustomization(t *testing.T) { }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - env.SQLStore.Bus = bus.GetBus() createUser(t, env.SQLStore, models.CreateUserCommand{ DefaultOrgRole: string(models.ROLE_EDITOR), @@ -635,7 +627,6 @@ func TestTestReceiversAlertCustomization(t *testing.T) { }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - env.SQLStore.Bus = bus.GetBus() createUser(t, env.SQLStore, models.CreateUserCommand{ DefaultOrgRole: string(models.ROLE_EDITOR), @@ -727,7 +718,6 @@ func TestNotificationChannels(t *testing.T) { }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - env.SQLStore.Bus = bus.GetBus() mockChannel := newMockNotificationChannel(t, grafanaListedAddr) amConfig := getAlertmanagerConfig(mockChannel.server.Addr) diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index 8a834a19292..28a4aec99b5 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -14,7 +14,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" dashboardsstore "github.com/grafana/grafana/pkg/services/dashboards/database" @@ -35,8 +34,6 @@ func TestPrometheusRules(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() // Create the namespace under default organisation (orgID = 1) where we'll save our alerts to. _, err = createFolder(t, store, 0, "default") @@ -209,7 +206,9 @@ func TestPrometheusRules(t *testing.T) { require.NoError(t, err) assert.Equal(t, 400, resp.StatusCode) - require.JSONEq(t, `{"message": "invalid rule specification at index [0]: both annotations __dashboardUid__ and __panelId__ must be specified"}`, string(b)) + var res map[string]interface{} + require.NoError(t, json.Unmarshal(b, &res)) + require.Equal(t, "invalid rule specification at index [0]: both annotations __dashboardUid__ and __panelId__ must be specified", res["message"]) } // Now, let's see how this looks like. @@ -330,8 +329,6 @@ func TestPrometheusRulesFilterByDashboard(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() // Create the namespace under default organisation (orgID = 1) where we'll save our alerts to. dashboardUID, err := createFolder(t, store, 0, "default") @@ -592,7 +589,9 @@ func TestPrometheusRulesFilterByDashboard(t *testing.T) { require.Equal(t, http.StatusBadRequest, resp.StatusCode) b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - require.JSONEq(t, `{"message": "invalid panel_id: strconv.ParseInt: parsing \"invalid\": invalid syntax"}`, string(b)) + var res map[string]interface{} + require.NoError(t, json.Unmarshal(b, &res)) + require.Equal(t, `invalid panel_id: strconv.ParseInt: parsing "invalid": invalid syntax`, res["message"]) } // Now, let's check a panel_id without dashboard_uid returns a 400 Bad Request response @@ -608,7 +607,9 @@ func TestPrometheusRulesFilterByDashboard(t *testing.T) { require.Equal(t, http.StatusBadRequest, resp.StatusCode) b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - require.JSONEq(t, `{"message": "panel_id must be set with dashboard_uid"}`, string(b)) + var res map[string]interface{} + require.NoError(t, json.Unmarshal(b, &res)) + require.Equal(t, "panel_id must be set with dashboard_uid", res["message"]) } } @@ -625,8 +626,6 @@ func TestPrometheusRulesPermissions(t *testing.T) { grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) dashboardsStore := dashboardsstore.ProvideDashboardStore(store) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() // Create a user to make authenticated requests createUser(t, store, models.CreateUserCommand{ diff --git a/pkg/tests/api/alerting/api_provisioning_test.go b/pkg/tests/api/alerting/api_provisioning_test.go index 1cdcef6055b..38428d00ca5 100644 --- a/pkg/tests/api/alerting/api_provisioning_test.go +++ b/pkg/tests/api/alerting/api_provisioning_test.go @@ -7,7 +7,6 @@ import ( "net/http" "testing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -28,8 +27,6 @@ func TestProvisioning(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() // Create a users to make authenticated requests createUser(t, store, models.CreateUserCommand{ @@ -129,6 +126,98 @@ func TestProvisioning(t *testing.T) { require.Equal(t, 202, resp.StatusCode) }) + t.Run("admin POST should succeed", func(t *testing.T) { + req := createTestRequest("POST", url, "admin", body) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 202, resp.StatusCode) + }) + }) + t.Run("when provisioning contactpoints", func(t *testing.T) { + url := fmt.Sprintf("http://%s/api/provisioning/contact-points", grafanaListedAddr) + body := ` + { + "name": "my-contact-point", + "type": "slack", + "settings": { + "recipient": "value_recipient", + "token": "value_token" + } + }` + + t.Run("un-authenticated GET should 401", func(t *testing.T) { + req := createTestRequest("GET", url, "", "") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 401, resp.StatusCode) + }) + + t.Run("viewer GET should succeed", func(t *testing.T) { + req := createTestRequest("GET", url, "viewer", "") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 200, resp.StatusCode) + }) + + t.Run("editor GET should succeed", func(t *testing.T) { + req := createTestRequest("GET", url, "editor", "") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 200, resp.StatusCode) + }) + + t.Run("admin GET should succeed", func(t *testing.T) { + req := createTestRequest("GET", url, "admin", "") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 200, resp.StatusCode) + }) + + t.Run("un-authenticated POST should 401", func(t *testing.T) { + req := createTestRequest("POST", url, "", body) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 401, resp.StatusCode) + }) + + t.Run("viewer POST should 403", func(t *testing.T) { + req := createTestRequest("POST", url, "viewer", body) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 403, resp.StatusCode) + }) + + t.Run("editor POST should succeed", func(t *testing.T) { + req := createTestRequest("POST", url, "editor", body) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 202, resp.StatusCode) + }) + t.Run("admin POST should succeed", func(t *testing.T) { req := createTestRequest("POST", url, "admin", body) diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index 3e59d152ddd..b9556e26d2e 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -14,7 +14,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" dashboardsstore "github.com/grafana/grafana/pkg/services/dashboards/database" @@ -37,8 +36,6 @@ func TestAlertRulePermissions(t *testing.T) { grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) dashboardsStore := dashboardsstore.ProvideDashboardStore(store) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() // Create a user to make authenticated requests createUser(t, store, models.CreateUserCommand{ @@ -345,8 +342,6 @@ func TestAlertRuleConflictingTitle(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() // Create the namespace we'll save our alerts to. _, err = createFolder(t, store, 0, "folder1") @@ -361,41 +356,8 @@ func TestAlertRuleConflictingTitle(t *testing.T) { Login: "admin", }) - interval, err := model.ParseDuration("1m") - require.NoError(t, err) + rules := newTestingRuleConfig(t) - rules := apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Rules: []apimodels.PostableExtendedRuleNode{ - { - ApiRuleNode: &apimodels.ApiRuleNode{ - For: interval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - // this rule does not explicitly set no data and error states - // therefore it should get the default values - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: "AlwaysFiring", - Condition: "A", - Data: []ngmodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: ngmodels.RelativeTimeRange{ - From: ngmodels.Duration(time.Duration(5) * time.Hour), - To: ngmodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: "-100", - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - }, - }, - }, - } buf := bytes.Buffer{} enc := json.NewEncoder(&buf) err = enc.Encode(&rules) @@ -415,7 +377,22 @@ func TestAlertRuleConflictingTitle(t *testing.T) { assert.Equal(t, http.StatusAccepted, resp.StatusCode) require.JSONEq(t, `{"message":"rule group updated successfully"}`, string(b)) + // fetch the created rules, so we can get the uid's and trigger + // and update by reusing the uid's + resp, err = http.Get(u + "/" + rules.Name) + require.NoError(t, err) + + var createdRuleGroup apimodels.GettableRuleGroupConfig + data, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + err = json.Unmarshal(data, &createdRuleGroup) + require.NoError(t, err) + require.Len(t, createdRuleGroup.Rules, 2) + t.Run("trying to create alert with same title under same folder should fail", func(t *testing.T) { + rules := newTestingRuleConfig(t) + buf := bytes.Buffer{} enc := json.NewEncoder(&buf) err = enc.Encode(&rules) @@ -433,10 +410,42 @@ func TestAlertRuleConflictingTitle(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) - require.JSONEq(t, `{"message": "failed to update rule group: failed to add or update rules: a conflicting alert rule is found: rule title under the same organisation and folder should be unique"}`, string(b)) + + var res map[string]interface{} + require.NoError(t, json.Unmarshal(b, &res)) + require.Equal(t, "failed to update rule group: failed to add rules: a conflicting alert rule is found: rule title under the same organisation and folder should be unique", res["message"]) + }) + + t.Run("trying to update an alert to the title of an existing alert in the same folder should fail", func(t *testing.T) { + rules := newTestingRuleConfig(t) + rules.Rules[0].GrafanaManagedAlert.UID = createdRuleGroup.Rules[0].GrafanaManagedAlert.UID + rules.Rules[1].GrafanaManagedAlert.UID = createdRuleGroup.Rules[1].GrafanaManagedAlert.UID + rules.Rules[1].GrafanaManagedAlert.Title = "AlwaysFiring" + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + err = enc.Encode(&rules) + require.NoError(t, err) + + u := fmt.Sprintf("http://admin:admin@%s/api/ruler/grafana/api/v1/rules/folder1", grafanaListedAddr) + // nolint:gosec + resp, err := http.Post(u, "application/json", &buf) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + + var res map[string]interface{} + require.NoError(t, json.Unmarshal(b, &res)) + require.Equal(t, "failed to update rule group: failed to update rules: a conflicting alert rule is found: rule title under the same organisation and folder should be unique", res["message"]) }) t.Run("trying to create alert with same title under another folder should succeed", func(t *testing.T) { + rules := newTestingRuleConfig(t) buf := bytes.Buffer{} enc := json.NewEncoder(&buf) err = enc.Encode(&rules) @@ -469,8 +478,6 @@ func TestRulerRulesFilterByDashboard(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) - // override bus to get the GetSignedInUserQuery handler - store.Bus = bus.GetBus() // Create the namespace under default organisation (orgID = 1) where we'll save our alerts to. dashboardUID, err := createFolder(t, store, 0, "default") @@ -788,7 +795,9 @@ func TestRulerRulesFilterByDashboard(t *testing.T) { require.Equal(t, http.StatusBadRequest, resp.StatusCode) b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - require.JSONEq(t, `{"message": "invalid panel_id: strconv.ParseInt: parsing \"invalid\": invalid syntax"}`, string(b)) + var res map[string]interface{} + require.NoError(t, json.Unmarshal(b, &res)) + require.Equal(t, `invalid panel_id: strconv.ParseInt: parsing "invalid": invalid syntax`, res["message"]) } // Now, let's check a panel_id without dashboard_uid returns a 400 Bad Request response @@ -804,6 +813,76 @@ func TestRulerRulesFilterByDashboard(t *testing.T) { require.Equal(t, http.StatusBadRequest, resp.StatusCode) b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - require.JSONEq(t, `{"message": "panel_id must be set with dashboard_uid"}`, string(b)) + var res map[string]interface{} + require.NoError(t, json.Unmarshal(b, &res)) + require.Equal(t, "panel_id must be set with dashboard_uid", res["message"]) + } +} + +func newTestingRuleConfig(t *testing.T) apimodels.PostableRuleGroupConfig { + interval, err := model.ParseDuration("1m") + require.NoError(t, err) + + firstRule := apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: interval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + // this rule does not explicitly set no data and error states + // therefore it should get the default values + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "AlwaysFiring", + Condition: "A", + Data: []ngmodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: ngmodels.RelativeTimeRange{ + From: ngmodels.Duration(time.Duration(5) * time.Hour), + To: ngmodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: "-100", + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + } + secondRule := apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: interval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + // this rule does not explicitly set no data and error states + // therefore it should get the default values + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "AlwaysFiring2", + Condition: "A", + Data: []ngmodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: ngmodels.RelativeTimeRange{ + From: ngmodels.Duration(time.Duration(5) * time.Hour), + To: ngmodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: "-100", + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + } + + return apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Rules: []apimodels.PostableExtendedRuleNode{ + firstRule, + secondRule, + }, } } diff --git a/pkg/tests/api/plugins/api_plugins_test.go b/pkg/tests/api/plugins/api_plugins_test.go index 04073f43e1a..bf44d6202f4 100644 --- a/pkg/tests/api/plugins/api_plugins_test.go +++ b/pkg/tests/api/plugins/api_plugins_test.go @@ -10,7 +10,6 @@ import ( "path/filepath" "testing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/tests/testinfra" @@ -42,8 +41,6 @@ func TestPlugins(t *testing.T) { } t.Run("Install", func(t *testing.T) { - store.Bus = bus.GetBus() - createUser(t, store, models.CreateUserCommand{Login: usernameNonAdmin, Password: defaultPassword, IsAdmin: false}) createUser(t, store, models.CreateUserCommand{Login: usernameAdmin, Password: defaultPassword, IsAdmin: true}) diff --git a/pkg/tsdb/azuremonitor/deprecated/applicationinsights-datasource.go b/pkg/tsdb/azuremonitor/deprecated/applicationinsights-datasource.go index c75b79ea252..4520522531e 100644 --- a/pkg/tsdb/azuremonitor/deprecated/applicationinsights-datasource.go +++ b/pkg/tsdb/azuremonitor/deprecated/applicationinsights-datasource.go @@ -14,13 +14,13 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "go.opentelemetry.io/otel/attribute" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/grafana/grafana/pkg/util/errutil" - "go.opentelemetry.io/otel/attribute" - "golang.org/x/net/context/ctxhttp" ) // ApplicationInsightsDatasource calls the application insights query API. @@ -158,7 +158,7 @@ func (e *ApplicationInsightsDatasource) executeQuery(ctx context.Context, query tracer.Inject(ctx, req.Header, span) azlog.Debug("ApplicationInsights", "Request URL", req.URL.String()) - res, err := ctxhttp.Do(ctx, client, req) + res, err := client.Do(req) if err != nil { dataResponse.Error = err return dataResponse, nil @@ -200,7 +200,7 @@ func (e *ApplicationInsightsDatasource) executeQuery(ctx context.Context, query func (e *ApplicationInsightsDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) { appInsightsAppID := dsInfo.Settings.AppInsightsAppId - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { azlog.Debug("Failed to create request", "error", err) return nil, errutil.Wrap("Failed to create request", err) diff --git a/pkg/tsdb/azuremonitor/deprecated/insights-analytics-datasource.go b/pkg/tsdb/azuremonitor/deprecated/insights-analytics-datasource.go index 27a6c866e67..00d29951fca 100644 --- a/pkg/tsdb/azuremonitor/deprecated/insights-analytics-datasource.go +++ b/pkg/tsdb/azuremonitor/deprecated/insights-analytics-datasource.go @@ -12,14 +12,14 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "go.opentelemetry.io/otel/attribute" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/loganalytics" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/macros" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/grafana/grafana/pkg/util/errutil" - "go.opentelemetry.io/otel/attribute" - "golang.org/x/net/context/ctxhttp" ) type InsightsAnalyticsDatasource struct { @@ -121,7 +121,7 @@ func (e *InsightsAnalyticsDatasource) executeQuery(ctx context.Context, query *I } azlog.Debug("ApplicationInsights", "Request URL", req.URL.String()) - res, err := ctxhttp.Do(ctx, client, req) + res, err := client.Do(req) if err != nil { return dataResponseError(err) } @@ -180,7 +180,7 @@ func (e *InsightsAnalyticsDatasource) executeQuery(ctx context.Context, query *I func (e *InsightsAnalyticsDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) { appInsightsAppID := dsInfo.Settings.AppInsightsAppId - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { azlog.Debug("Failed to create request", "error", err) return nil, errutil.Wrap("Failed to create request", err) diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go index cc96a88ebac..6370b36392d 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go @@ -15,14 +15,14 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "go.opentelemetry.io/otel/attribute" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/macros" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/grafana/grafana/pkg/util/errutil" - "go.opentelemetry.io/otel/attribute" - "golang.org/x/net/context/ctxhttp" ) // AzureLogAnalyticsDatasource calls the Azure Log Analytics API's @@ -173,7 +173,7 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A tracer.Inject(ctx, req.Header, span) azlog.Debug("AzureLogAnalytics", "Request ApiURL", req.URL.String()) - res, err := ctxhttp.Do(ctx, client, req) + res, err := client.Do(req) if err != nil { return dataResponseErrorWithExecuted(err) } @@ -224,7 +224,7 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A } func (e *AzureLogAnalyticsDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) { - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { azlog.Debug("Failed to create request", "error", err) return nil, errutil.Wrap("failed to create request", err) diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go index 3def772d76b..84279f4de67 100644 --- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go +++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go @@ -14,6 +14,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "go.opentelemetry.io/otel/attribute" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" @@ -21,8 +23,6 @@ import ( azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/grafana/grafana/pkg/util/errutil" - "go.opentelemetry.io/otel/attribute" - "golang.org/x/net/context/ctxhttp" ) // AzureMonitorDatasource calls the Azure Monitor API - one of the four API's supported @@ -179,7 +179,7 @@ func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *types. azlog.Debug("AzureMonitor", "Request ApiURL", req.URL.String()) azlog.Debug("AzureMonitor", "Target", query.Target) - res, err := ctxhttp.Do(ctx, cli, req) + res, err := cli.Do(req) if err != nil { dataResponse.Error = err return dataResponse @@ -212,12 +212,11 @@ func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *types. } func (e *AzureMonitorDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) { - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { azlog.Debug("Failed to create request", "error", err) return nil, errutil.Wrap("Failed to create request", err) } - req.URL.Path = "/subscriptions" req.Header.Set("Content-Type", "application/json") return req, nil diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go index 41453545713..89faac286c0 100644 --- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go +++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go @@ -163,7 +163,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { } azureMonitorQuery := &types.AzureMonitorQuery{ - URL: "12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana/providers/microsoft.insights/metrics", + URL: "/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana/providers/microsoft.insights/metrics", UrlComponents: map[string]string{ "metricDefinition": "Microsoft.Compute/virtualMachines", "resourceGroup": "grafanastaging", @@ -587,7 +587,7 @@ func TestAzureMonitorCreateRequest(t *testing.T) { }{ { name: "creates a request", - expectedURL: "http://ds/subscriptions", + expectedURL: "http://ds/", expectedHeaders: http.Header{ "Content-Type": []string{"application/json"}, }, diff --git a/pkg/tsdb/azuremonitor/metrics/url-builder.go b/pkg/tsdb/azuremonitor/metrics/url-builder.go index 4745f3bcdf0..8c3fad8fd37 100644 --- a/pkg/tsdb/azuremonitor/metrics/url-builder.go +++ b/pkg/tsdb/azuremonitor/metrics/url-builder.go @@ -18,6 +18,35 @@ type urlBuilder struct { ResourceName string } +func (params *urlBuilder) buildMetricsURLFromLegacyQuery() string { + subscription := params.Subscription + + if params.Subscription == "" { + subscription = params.DefaultSubscription + } + + metricDefinitionArray := strings.Split(params.MetricDefinition, "/") + resourceNameArray := strings.Split(params.ResourceName, "/") + provider := metricDefinitionArray[0] + metricDefinitionArray = metricDefinitionArray[1:] + + urlArray := []string{ + "/subscriptions", + subscription, + "resourceGroups", + params.ResourceGroup, + "providers", + provider, + } + + for i, metricDefinition := range metricDefinitionArray { + urlArray = append(urlArray, metricDefinition, resourceNameArray[i]) + } + + resourceURI := strings.Join(urlArray, "/") + return resourceURI +} + // BuildMetricsURL checks the metric definition property to see which form of the url // should be returned func (params *urlBuilder) BuildMetricsURL() string { @@ -25,31 +54,7 @@ func (params *urlBuilder) BuildMetricsURL() string { // Prior to Grafana 9, we had a legacy query object rather than a resourceURI, so we manually create the resource URI if resourceURI == "" { - subscription := params.Subscription - - if params.Subscription == "" { - subscription = params.DefaultSubscription - } - - metricDefinitionArray := strings.Split(params.MetricDefinition, "/") - resourceNameArray := strings.Split(params.ResourceName, "/") - provider := metricDefinitionArray[0] - metricDefinitionArray = metricDefinitionArray[1:] - - urlArray := []string{ - subscription, - "resourceGroups", - params.ResourceGroup, - "providers", - provider, - } - - for i := range metricDefinitionArray { - urlArray = append(urlArray, metricDefinitionArray[i]) - urlArray = append(urlArray, resourceNameArray[i]) - } - - resourceURI = strings.Join(urlArray[:], "/") + resourceURI = params.buildMetricsURLFromLegacyQuery() } return fmt.Sprintf("%s/providers/microsoft.insights/metrics", resourceURI) diff --git a/pkg/tsdb/azuremonitor/metrics/url-builder_test.go b/pkg/tsdb/azuremonitor/metrics/url-builder_test.go index 458cf7b491c..1abde71f094 100644 --- a/pkg/tsdb/azuremonitor/metrics/url-builder_test.go +++ b/pkg/tsdb/azuremonitor/metrics/url-builder_test.go @@ -3,23 +3,23 @@ package metrics import ( "testing" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" ) func TestURLBuilder(t *testing.T) { t.Run("AzureMonitor URL Builder", func(t *testing.T) { t.Run("when only resource uri is provided it returns resource/uri/providers/microsoft.insights/metrics", func(t *testing.T) { ub := &urlBuilder{ - ResourceURI: "resource/uri", + ResourceURI: "/subscriptions/sub/resource/uri", } url := ub.BuildMetricsURL() - require.Equal(t, url, "resource/uri/providers/microsoft.insights/metrics") + assert.Equal(t, "/subscriptions/sub/resource/uri/providers/microsoft.insights/metrics", url) }) t.Run("when resource uri and legacy fields are provided the legacy fields are ignored", func(t *testing.T) { ub := &urlBuilder{ - ResourceURI: "resource/uri", + ResourceURI: "/subscriptions/sub/resource/uri", DefaultSubscription: "default-sub", ResourceGroup: "rg", MetricDefinition: "Microsoft.NetApp/netAppAccounts/capacityPools/volumes", @@ -27,7 +27,7 @@ func TestURLBuilder(t *testing.T) { } url := ub.BuildMetricsURL() - require.Equal(t, url, "resource/uri/providers/microsoft.insights/metrics") + assert.Equal(t, "/subscriptions/sub/resource/uri/providers/microsoft.insights/metrics", url) }) t.Run("Legacy URL Builder params", func(t *testing.T) { @@ -40,7 +40,7 @@ func TestURLBuilder(t *testing.T) { } url := ub.BuildMetricsURL() - require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics") + assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics", url) }) t.Run("when metric definition is in the short form and a subscription is defined", func(t *testing.T) { @@ -53,7 +53,7 @@ func TestURLBuilder(t *testing.T) { } url := ub.BuildMetricsURL() - require.Equal(t, url, "specified-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics") + assert.Equal(t, "/subscriptions/specified-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics", url) }) t.Run("when metric definition is Microsoft.Storage/storageAccounts/blobServices", func(t *testing.T) { @@ -65,7 +65,7 @@ func TestURLBuilder(t *testing.T) { } url := ub.BuildMetricsURL() - require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/providers/microsoft.insights/metrics") + assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/providers/microsoft.insights/metrics", url) }) t.Run("when metric definition is Microsoft.Storage/storageAccounts/fileServices", func(t *testing.T) { @@ -77,7 +77,7 @@ func TestURLBuilder(t *testing.T) { } url := ub.BuildMetricsURL() - require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/providers/microsoft.insights/metrics") + assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/providers/microsoft.insights/metrics", url) }) t.Run("when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes", func(t *testing.T) { @@ -89,7 +89,7 @@ func TestURLBuilder(t *testing.T) { } url := ub.BuildMetricsURL() - require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/providers/microsoft.insights/metrics") + assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/providers/microsoft.insights/metrics", url) }) }) }) diff --git a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go index f99a837a6bc..ad26abfaa5e 100644 --- a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go +++ b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "go.opentelemetry.io/otel/attribute" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/tracing" @@ -23,8 +24,6 @@ import ( "github.com/grafana/grafana/pkg/tsdb/azuremonitor/macros" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/grafana/grafana/pkg/util/errutil" - "go.opentelemetry.io/otel/attribute" - "golang.org/x/net/context/ctxhttp" ) // AzureResourceGraphResponse is the json response object from the Azure Resource Graph Analytics API. @@ -180,7 +179,7 @@ func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query * tracer.Inject(ctx, req.Header, span) azlog.Debug("AzureResourceGraph", "Request ApiURL", req.URL.String()) - res, err := ctxhttp.Do(ctx, client, req) + res, err := client.Do(req) if err != nil { return dataResponseErrorWithExecuted(err) } @@ -227,7 +226,7 @@ func AddConfigLinks(frame data.Frame, dl string) data.Frame { } func (e *AzureResourceGraphDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, reqBody []byte, url string) (*http.Request, error) { - req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(reqBody)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(reqBody)) if err != nil { azlog.Debug("Failed to create request", "error", err) return nil, errutil.Wrap("failed to create request", err) diff --git a/pkg/tsdb/azuremonitor/types/types.go b/pkg/tsdb/azuremonitor/types/types.go index f0d40823c7b..1a9b4d60271 100644 --- a/pkg/tsdb/azuremonitor/types/types.go +++ b/pkg/tsdb/azuremonitor/types/types.go @@ -121,9 +121,12 @@ type AzureMonitorJSONQuery struct { // Legecy "resource" fields from before the resource picker provided just a single ResourceURI // These are used for pre-resource picker queries to reconstruct a resource URI + // Deprecated MetricDefinition string `json:"metricDefinition"` - ResourceGroup string `json:"resourceGroup"` - ResourceName string `json:"resourceName"` + // Deprecated + ResourceGroup string `json:"resourceGroup"` + // Deprecated + ResourceName string `json:"resourceName"` AllowedTimeGrainsMs []int64 `json:"allowedTimeGrainsMs"` Dimension string `json:"dimension"` // old model diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index 223a0869c8d..520d52d60a7 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -32,7 +32,7 @@ func TestQuery_Metrics(t *testing.T) { var cwClient fakeCWClient NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI { - return cwClient + return &cwClient } t.Run("Custom metrics", func(t *testing.T) { @@ -403,7 +403,7 @@ func TestQuery_GetDimensionKeys(t *testing.T) { var client fakeCWClient NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI { - return client + return &client } metrics := []*cloudwatch.Metric{ @@ -527,7 +527,7 @@ func TestQuery_ListMetricsPagination(t *testing.T) { var client fakeCWClient NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI { - return client + return &client } metrics := []*cloudwatch.Metric{ diff --git a/pkg/tsdb/cloudwatch/metrics.go b/pkg/tsdb/cloudwatch/metrics.go index 6208d472b5d..79f2c371f92 100644 --- a/pkg/tsdb/cloudwatch/metrics.go +++ b/pkg/tsdb/cloudwatch/metrics.go @@ -355,6 +355,7 @@ var metricsMap = map[string][]string{ "AWS/MediaPackage": {"ActiveInput", "EgressBytes", "EgressRequestCount", "EgressResponseTime", "IngressBytes", "IngressResponseTime"}, "AWS/MediaStore": {"RequestCount", "4xxErrorCount", "5xxErrorCount", "BytesUploaded", "BytesDownloaded", "TotalTime", "TurnaroundTime"}, "AWS/MediaTailor": {"AdDecisionServer.Ads", "AdDecisionServer.Duration", "AdDecisionServer.Errors", "AdDecisionServer.FillRate", "AdDecisionServer.Timeouts", "AdNotReady", "Avails.Duration", "Avails.FillRate", "Avails.FilledDuration", "GetManifest.Errors", "Origin.Errors", "Origin.Timeouts"}, + "AWS/MemoryDB": {"ActiveDefragHits", "AuthenticationFailures", "BytesUsedForMemoryDB", "CommandAuthorizationFailures", "CPUUtilization", "CurrConnections", "CurrItems", "DatabaseMemoryUsagePercentage", "DB0AverageTTL", "EngineCPUUtilization", "EvalBasedCmds", "Evictions", "FreeableMemory", "GeoSpatialBasedCmds", "GetTypeCmds", "HashBasedCmds", "HyperLogLogBasedCmds", "IsPrimary", "KeyAuthorizationFailures", "KeyBasedCmds", "KeyspaceHits", "KeyspaceMisses", "KeysTracked", "ListBasedCmds", "MaxReplicationThroughput", "MemoryFragmentationRatio", "NetworkBandwidthInAllowanceExceeded", "NetworkBandwidthOutAllowanceExceeded", "NetworkBytesIn", "NetworkBytesOut", "NetworkConntrackAllowanceExceeded", "NetworkPacketsIn", "NetworkPacketsOut", "NetworkPacketsPerSecondAllowanceExceeded", "NewConnections", "PrimaryLinkHealthStatus", "PubSubBasedCmds", "Reclaimed", "ReplicationBytes", "ReplicationDelayedWriteCommands", "ReplicationLag", "SetBasedCmds", "SetTypeCmds", "SortedSetBasedCmds", "StringBasedCmds", "StreamBasedCmds", "SwapUsage"}, "AWS/NATGateway": {"ActiveConnectionCount", "BytesInFromDestination", "BytesInFromSource", "BytesOutToDestination", "BytesOutToSource", "ConnectionAttemptCount", "ConnectionEstablishedCount", "ErrorPortAllocation", "IdleTimeoutCount", "PacketsDropCount", "PacketsInFromDestination", "PacketsInFromSource", "PacketsOutToDestination", "PacketsOutToSource"}, "AWS/Neptune": {"CPUUtilization", "ClusterReplicaLag", "ClusterReplicaLagMaximum", "ClusterReplicaLagMinimum", "EngineUptime", "FreeLocalStorage", "FreeableMemory", "GremlinErrors", "GremlinHttp1xx", "GremlinHttp2xx", "GremlinHttp4xx", "GremlinHttp5xx", "GremlinRequests", "GremlinRequestsPerSec", "GremlinWebSocketAvailableConnections", "GremlinWebSocketClientErrors", "GremlinWebSocketServerErrors", "GremlinWebSocketSuccess", "Http100", "Http101", "Http1xx", "Http200", "Http2xx", "Http400", "Http403", "Http405", "Http413", "Http429", "Http4xx", "Http500", "Http501", "Http5xx", "LoaderErrors", "LoaderRequests", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "SparqlErrors", "SparqlHttp1xx", "SparqlHttp2xx", "SparqlHttp4xx", "SparqlHttp5xx", "SparqlRequests", "SparqlRequestsPerSec", "StatusErrors", "StatusRequests", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs"}, "AWS/NetworkELB": {"ActiveFlowCount", "ActiveFlowCount_TLS", "ClientTLSNegotiationErrorCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "NewFlowCount_TLS", "ProcessedBytes", "ProcessedBytes_TLS", "TCP_Client_Reset_Count", "TCP_ELB_Reset_Count", "TCP_Target_Reset_Count", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"}, @@ -468,6 +469,7 @@ var dimensionsMap = map[string][]string{ "AWS/MediaPackage": {"Channel", "No Dimension", "OriginEndpoint", "StatusCodeRange"}, "AWS/MediaStore": {"ContainerName", "ObjectGroupName", "RequestType"}, "AWS/MediaTailor": {"ConfigurationName"}, + "AWS/MemoryDB": {"ClusterName", "NodeName"}, "AWS/NATGateway": {"NatGatewayId"}, "AWS/Neptune": {"DBClusterIdentifier", "DatabaseClass", "EngineName", "Role"}, "AWS/NetworkELB": {"AvailabilityZone", "LoadBalancer", "TargetGroup"}, diff --git a/pkg/tsdb/cloudwatch/time_series_query_test.go b/pkg/tsdb/cloudwatch/time_series_query_test.go index 7c55c4d2459..72c12e6dc17 100644 --- a/pkg/tsdb/cloudwatch/time_series_query_test.go +++ b/pkg/tsdb/cloudwatch/time_series_query_test.go @@ -30,7 +30,7 @@ func TestTimeSeriesQuery(t *testing.T) { var cwClient fakeCWClient NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI { - return cwClient + return &cwClient } t.Run("Custom metrics", func(t *testing.T) { diff --git a/pkg/tsdb/cloudwatch/utils_test.go b/pkg/tsdb/cloudwatch/utils_test.go index e6bf1156535..9f1213ed435 100644 --- a/pkg/tsdb/cloudwatch/utils_test.go +++ b/pkg/tsdb/cloudwatch/utils_test.go @@ -67,11 +67,11 @@ type fakeCWClient struct { MetricsPerPage int } -func (c fakeCWClient) GetMetricDataWithContext(aws.Context, *cloudwatch.GetMetricDataInput, ...request.Option) (*cloudwatch.GetMetricDataOutput, error) { +func (c *fakeCWClient) GetMetricDataWithContext(aws.Context, *cloudwatch.GetMetricDataInput, ...request.Option) (*cloudwatch.GetMetricDataOutput, error) { return &c.GetMetricDataOutput, nil } -func (c fakeCWClient) ListMetricsPages(input *cloudwatch.ListMetricsInput, fn func(*cloudwatch.ListMetricsOutput, bool) bool) error { +func (c *fakeCWClient) ListMetricsPages(input *cloudwatch.ListMetricsInput, fn func(*cloudwatch.ListMetricsOutput, bool) bool) error { if c.MetricsPerPage == 0 { c.MetricsPerPage = 1000 } diff --git a/pkg/tsdb/elasticsearch/client/client.go b/pkg/tsdb/elasticsearch/client/client.go index 2371ddf0e54..3118693b2b7 100644 --- a/pkg/tsdb/elasticsearch/client/client.go +++ b/pkg/tsdb/elasticsearch/client/client.go @@ -20,8 +20,6 @@ import ( "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/tsdb/intervalv2" - - "golang.org/x/net/context/ctxhttp" ) type DatasourceInfo struct { @@ -161,9 +159,9 @@ func (c *baseClientImpl) executeRequest(method, uriPath, uriQuery string, body [ var req *http.Request if method == http.MethodPost { - req, err = http.NewRequest(http.MethodPost, u.String(), bytes.NewBuffer(body)) + req, err = http.NewRequestWithContext(c.ctx, http.MethodPost, u.String(), bytes.NewBuffer(body)) } else { - req, err = http.NewRequest(http.MethodGet, u.String(), nil) + req, err = http.NewRequestWithContext(c.ctx, http.MethodGet, u.String(), nil) } if err != nil { return nil, err @@ -193,7 +191,7 @@ func (c *baseClientImpl) executeRequest(method, uriPath, uriQuery string, body [ clientLog.Debug("Executed request", "took", elapsed) }() //nolint:bodyclose - resp, err := ctxhttp.Do(c.ctx, httpClient, req) + resp, err := httpClient.Do(req) if err != nil { return nil, err } diff --git a/pkg/tsdb/elasticsearch/elasticsearch.go b/pkg/tsdb/elasticsearch/elasticsearch.go index 48cb5da2964..2f2b2ca8d8f 100644 --- a/pkg/tsdb/elasticsearch/elasticsearch.go +++ b/pkg/tsdb/elasticsearch/elasticsearch.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strconv" "github.com/Masterminds/semver" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -95,8 +96,17 @@ func newInstanceSettings() datasource.InstanceFactoryFunc { timeInterval = "" } - maxConcurrentShardRequests, ok := jsonData["maxConcurrentShardRequests"].(float64) - if !ok { + var maxConcurrentShardRequests float64 + + switch v := jsonData["maxConcurrentShardRequests"].(type) { + case float64: + maxConcurrentShardRequests = v + case string: + maxConcurrentShardRequests, err = strconv.ParseFloat(v, 64) + if err != nil { + maxConcurrentShardRequests = 256 + } + default: maxConcurrentShardRequests = 256 } diff --git a/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go index bc3b61e9e9b..2844ac5ad70 100644 --- a/pkg/tsdb/graphite/graphite.go +++ b/pkg/tsdb/graphite/graphite.go @@ -15,7 +15,6 @@ import ( "time" "go.opentelemetry.io/otel/attribute" - "golang.org/x/net/context/ctxhttp" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" @@ -148,7 +147,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) s.logger.Debug("Graphite request", "params", formData) } - graphiteReq, err := s.createRequest(dsInfo, formData) + graphiteReq, err := s.createRequest(ctx, dsInfo, formData) if err != nil { return &result, err } @@ -163,7 +162,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) defer span.End() s.tracer.Inject(ctx, graphiteReq.Header, span) - res, err := ctxhttp.Do(ctx, dsInfo.HTTPClient, graphiteReq) + res, err := dsInfo.HTTPClient.Do(graphiteReq) if err != nil { return &result, err } @@ -252,14 +251,14 @@ func (s *Service) toDataFrames(response *http.Response) (frames data.Frames, err return frames, nil } -func (s *Service) createRequest(dsInfo *datasourceInfo, data url.Values) (*http.Request, error) { +func (s *Service) createRequest(ctx context.Context, dsInfo *datasourceInfo, data url.Values) (*http.Request, error) { u, err := url.Parse(dsInfo.URL) if err != nil { return nil, err } u.Path = path.Join(u.Path, "render") - req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(data.Encode())) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(data.Encode())) if err != nil { s.logger.Info("Failed to create request", "error", err) return nil, fmt.Errorf("failed to create request: %w", err) diff --git a/pkg/tsdb/legacydata/service/service_test.go b/pkg/tsdb/legacydata/service/service_test.go index b309f97b982..7e895da093a 100644 --- a/pkg/tsdb/legacydata/service/service_test.go +++ b/pkg/tsdb/legacydata/service/service_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" @@ -40,7 +39,7 @@ func TestHandleRequest(t *testing.T) { return backend.NewQueryDataResponse(), nil } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) s := ProvideService(client, nil, dsService) ds := &models.DataSource{Id: 12, Type: "unregisteredType", JsonData: simplejson.New()} diff --git a/pkg/tsdb/loki/api.go b/pkg/tsdb/loki/api.go index 92ffb697049..08e3b1eb8c5 100644 --- a/pkg/tsdb/loki/api.go +++ b/pkg/tsdb/loki/api.go @@ -51,7 +51,13 @@ func makeRequest(ctx context.Context, lokiDsUrl string, query lokiQuery) (*http. // is ignored, so it would be nicer to not send it in such cases, // but we cannot detect that situation, so we always send it. // it should not break anything. - qs.Set("step", query.Step.String()) + // NOTE2: we do this at millisecond precision for two reasons: + // a. Loki cannot do steps with better precision anyway, + // so the microsecond & nanosecond part can be ignored. + // b. having it always be number+'ms' makes it more robust and + // precise, as Loki does not support step with float number + // and time-specifier, like "1.5s" + qs.Set("step", fmt.Sprintf("%dms", query.Step.Milliseconds())) lokiUrl.Path = "/loki/api/v1/query_range" } case QueryTypeInstant: diff --git a/pkg/tsdb/loki/frame.go b/pkg/tsdb/loki/frame.go index 35314e22e65..5593c59ae57 100644 --- a/pkg/tsdb/loki/frame.go +++ b/pkg/tsdb/loki/frame.go @@ -2,6 +2,7 @@ package loki import ( "fmt" + "hash/fnv" "sort" "strings" "time" @@ -11,14 +12,42 @@ import ( // we adjust the dataframes to be the way frontend & alerting // wants them. -func adjustFrame(frame *data.Frame, query *lokiQuery) *data.Frame { +func adjustFrame(frame *data.Frame, query *lokiQuery) error { + fields := frame.Fields + + if len(fields) < 2 { + return fmt.Errorf("missing fields in frame") + } + + // metric-fields have "timefield, valuefield" + // logs-fields have "labelsfield, timefield, ..." + + secondField := fields[1] + + if secondField.Type() == data.FieldTypeFloat64 { + return adjustMetricFrame(frame, query) + } else { + return adjustLogsFrame(frame, query) + } +} + +func adjustMetricFrame(frame *data.Frame, query *lokiQuery) error { + fields := frame.Fields + // we check if the fields are of correct type + if len(fields) != 2 { + return fmt.Errorf("invalid fields in metric frame") + } + + timeField := fields[0] + valueField := fields[1] + + if (timeField.Type() != data.FieldTypeTime) || (valueField.Type() != data.FieldTypeFloat64) { + return fmt.Errorf("invalid fields in metric frame") + } + labels := getFrameLabels(frame) - timeFields, nonTimeFields := partitionFields(frame) - - isMetricFrame := nonTimeFields[0].Type() != data.FieldTypeString - - isMetricRange := isMetricFrame && query.QueryType == QueryTypeRange + isMetricRange := query.QueryType == QueryTypeRange name := formatName(labels, query) frame.Name = name @@ -33,48 +62,109 @@ func adjustFrame(frame *data.Frame, query *lokiQuery) *data.Frame { frame.Meta.ExecutedQueryString = "Expr: " + query.Expr } - for _, field := range timeFields { - field.Name = "time" - - if isMetricRange { - if field.Config == nil { - field.Config = &data.FieldConfig{} - } - field.Config.Interval = float64(query.Step.Milliseconds()) + if isMetricRange { + if timeField.Config == nil { + timeField.Config = &data.FieldConfig{} } + timeField.Config.Interval = float64(query.Step.Milliseconds()) } - for _, field := range nonTimeFields { - field.Name = "value" - if field.Config == nil { - field.Config = &data.FieldConfig{} - } - field.Config.DisplayNameFromDS = name + if valueField.Config == nil { + valueField.Config = &data.FieldConfig{} + } + valueField.Config.DisplayNameFromDS = name + + return nil +} + +func adjustLogsFrame(frame *data.Frame, query *lokiQuery) error { + // we check if the fields are of correct type and length + fields := frame.Fields + if len(fields) != 3 { + return fmt.Errorf("invalid fields in logs frame") } - // for streams-dataframes, we need to send to the browser the nanosecond-precision timestamp too. + labelsField := fields[0] + timeField := fields[1] + lineField := fields[2] + + if (timeField.Type() != data.FieldTypeTime) || (lineField.Type() != data.FieldTypeString) || (labelsField.Type() != data.FieldTypeString) { + return fmt.Errorf("invalid fields in metric frame") + } + + if (timeField.Len() != lineField.Len()) || (timeField.Len() != labelsField.Len()) { + return fmt.Errorf("invalid fields in metric frame") + } + + if frame.Meta == nil { + frame.Meta = &data.FrameMeta{} + } + + frame.Meta.ExecutedQueryString = "Expr: " + query.Expr + + // we need to send to the browser the nanosecond-precision timestamp too. // usually timestamps become javascript-date-objects in the browser automatically, which only // have millisecond-precision. // so we send a separate timestamp-as-string field too. - if !isMetricFrame { - stringTimeField := makeStringTimeField(timeFields[0]) - frame.Fields = append(frame.Fields, stringTimeField) - } + stringTimeField := makeStringTimeField(timeField) - return frame + idField, err := makeIdField(stringTimeField, lineField, labelsField, frame.RefID) + if err != nil { + return err + } + frame.Fields = append(frame.Fields, stringTimeField, idField) + return nil } -func makeStringTimeField(field *data.Field) *data.Field { - length := field.Len() +func makeStringTimeField(timeField *data.Field) *data.Field { + length := timeField.Len() stringTimestamps := make([]string, length) for i := 0; i < length; i++ { - if v, ok := field.ConcreteAt(i); ok { - nsNumber := v.(time.Time).UnixNano() - stringTimestamps[i] = fmt.Sprintf("%d", nsNumber) - } + nsNumber := timeField.At(i).(time.Time).UnixNano() + stringTimestamps[i] = fmt.Sprintf("%d", nsNumber) } - return data.NewField("tsNs", field.Labels.Copy(), stringTimestamps) + return data.NewField("tsNs", timeField.Labels.Copy(), stringTimestamps) +} + +func calculateCheckSum(time string, line string, labels string) (string, error) { + input := []byte(line + "_" + labels) + hash := fnv.New32() + _, err := hash.Write(input) + if err != nil { + return "", err + } + return fmt.Sprintf("%s_%x", time, hash.Sum32()), nil +} + +func makeIdField(stringTimeField *data.Field, lineField *data.Field, labelsField *data.Field, refId string) (*data.Field, error) { + length := stringTimeField.Len() + + ids := make([]string, length) + + checksums := make(map[string]int) + + for i := 0; i < length; i++ { + time := stringTimeField.At(i).(string) + line := lineField.At(i).(string) + labels := labelsField.At(i).(string) + + sum, err := calculateCheckSum(time, line, labels) + if err != nil { + return nil, err + } + + sumCount := checksums[sum] + idSuffix := "" + if sumCount > 0 { + // we had this checksum already, we need to do something to make it unique + idSuffix = fmt.Sprintf("_%d", sumCount) + } + checksums[sum] = sumCount + 1 + + ids[i] = sum + idSuffix + "_" + refId + } + return data.NewField("id", nil, ids), nil } func formatNamePrometheusStyle(labels map[string]string) string { @@ -119,18 +209,3 @@ func getFrameLabels(frame *data.Frame) map[string]string { return labels } - -func partitionFields(frame *data.Frame) ([]*data.Field, []*data.Field) { - var timeFields []*data.Field - var nonTimeFields []*data.Field - - for _, field := range frame.Fields { - if field.Type() == data.FieldTypeTime { - timeFields = append(timeFields, field) - } else { - nonTimeFields = append(nonTimeFields, field) - } - } - - return timeFields, nonTimeFields -} diff --git a/pkg/tsdb/loki/frame_test.go b/pkg/tsdb/loki/frame_test.go index ee2d0a74637..0e4bf3ebcfc 100644 --- a/pkg/tsdb/loki/frame_test.go +++ b/pkg/tsdb/loki/frame_test.go @@ -37,7 +37,56 @@ func TestFormatName(t *testing.T) { } func TestAdjustFrame(t *testing.T) { - t.Run("response should be parsed normally", func(t *testing.T) { + t.Run("logs-frame metadata should be set correctly", func(t *testing.T) { + frame := data.NewFrame("", + data.NewField("labels", nil, []string{ + `{"level":"info"}`, + `{"level":"error"}`, + `{"level":"error"}`, + `{"level":"info"}`, + }), + data.NewField("time", nil, []time.Time{ + time.Date(2022, 1, 2, 3, 4, 5, 6, time.UTC), + time.Date(2022, 1, 2, 3, 5, 5, 6, time.UTC), + time.Date(2022, 1, 2, 3, 5, 5, 6, time.UTC), + time.Date(2022, 1, 2, 3, 6, 5, 6, time.UTC), + }), + data.NewField("line", nil, []string{"line1", "line2", "line2", "line3"}), + ) + + frame.RefID = "A" + + query := &lokiQuery{ + Expr: `{type="important"}`, + QueryType: QueryTypeRange, + } + + err := adjustFrame(frame, query) + require.NoError(t, err) + + fields := frame.Fields + + require.Equal(t, 5, len(fields)) + tsNsField := fields[3] + require.Equal(t, "tsNs", tsNsField.Name) + require.Equal(t, data.FieldTypeString, tsNsField.Type()) + require.Equal(t, 4, tsNsField.Len()) + require.Equal(t, "1641092645000000006", tsNsField.At(0)) + require.Equal(t, "1641092705000000006", tsNsField.At(1)) + require.Equal(t, "1641092705000000006", tsNsField.At(2)) + require.Equal(t, "1641092765000000006", tsNsField.At(3)) + + idField := fields[4] + require.Equal(t, "id", idField.Name) + require.Equal(t, data.FieldTypeString, idField.Type()) + require.Equal(t, 4, idField.Len()) + require.Equal(t, "1641092645000000006_a36f4e1b_A", idField.At(0)) + require.Equal(t, "1641092705000000006_1d77c9ca_A", idField.At(1)) + require.Equal(t, "1641092705000000006_1d77c9ca_1_A", idField.At(2)) + require.Equal(t, "1641092765000000006_948c1a7d_A", idField.At(3)) + }) + + t.Run("logs-frame id and string-time fields should be created", func(t *testing.T) { field1 := data.NewField("", nil, make([]time.Time, 0)) field2 := data.NewField("", nil, make([]float64, 0)) field2.Labels = data.Labels{"app": "Application", "tag2": "tag2"} @@ -52,7 +101,8 @@ func TestAdjustFrame(t *testing.T) { Step: time.Second * 42, } - adjustFrame(frame, query) + err := adjustFrame(frame, query) + require.NoError(t, err) require.Equal(t, frame.Name, "legend Application") require.Equal(t, frame.Meta.ExecutedQueryString, "Expr: up(ALERTS)\nStep: 42s") @@ -72,7 +122,8 @@ func TestAdjustFrame(t *testing.T) { frame := data.NewFrame("test", field1, field2) frame.SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMany}) - adjustFrame(frame, query) + err := adjustFrame(frame, query) + require.NoError(t, err) // to keep the test simple, we assume the // first field is the time-field diff --git a/pkg/tsdb/loki/framing_test.go b/pkg/tsdb/loki/framing_test.go index 78d8df2cfec..696cc5d3fb6 100644 --- a/pkg/tsdb/loki/framing_test.go +++ b/pkg/tsdb/loki/framing_test.go @@ -59,7 +59,7 @@ func TestSuccessResponse(t *testing.T) { bytes, err := os.ReadFile(responseFileName) require.NoError(t, err) - frames, err := runQuery(context.Background(), makeMockedAPI(200, "application/json", bytes), &test.query) + frames, err := runQuery(context.Background(), makeMockedAPI(http.StatusOK, "application/json", bytes), &test.query) require.NoError(t, err) dr := &backend.DataResponse{ diff --git a/pkg/tsdb/loki/loki_bench_test.go b/pkg/tsdb/loki/loki_bench_test.go index 331b8b533f1..de0b6d50a17 100644 --- a/pkg/tsdb/loki/loki_bench_test.go +++ b/pkg/tsdb/loki/loki_bench_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math/rand" + "net/http" "strings" "testing" ) @@ -16,7 +17,7 @@ func BenchmarkMatrixJson(b *testing.B) { b.ResetTimer() for n := 0; n < b.N; n++ { - _, _ = runQuery(context.Background(), makeMockedAPI(200, "application/json", bytes), &lokiQuery{}) + _, _ = runQuery(context.Background(), makeMockedAPI(http.StatusOK, "application/json", bytes), &lokiQuery{}) } } diff --git a/pkg/tsdb/loki/parse_response.go b/pkg/tsdb/loki/parse_response.go index 48eae74861e..73ed248c4d5 100644 --- a/pkg/tsdb/loki/parse_response.go +++ b/pkg/tsdb/loki/parse_response.go @@ -2,11 +2,13 @@ package loki import ( "fmt" + "sort" "time" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/loki/pkg/loghttp" "github.com/grafana/loki/pkg/logqlmodel/stats" + jsoniter "github.com/json-iterator/go" ) func parseResponse(value *loghttp.QueryResponse, query *lokiQuery) (data.Frames, error) { @@ -17,7 +19,10 @@ func parseResponse(value *loghttp.QueryResponse, query *lokiQuery) (data.Frames, } for _, frame := range frames { - adjustFrame(frame, query) + err = adjustFrame(frame, query) + if err != nil { + return nil, err + } } return frames, nil @@ -31,7 +36,7 @@ func lokiResponseToDataFrames(value *loghttp.QueryResponse, query *lokiQuery) (d case loghttp.Vector: return lokiVectorToDataFrames(res, query, stats), nil case loghttp.Streams: - return lokiStreamsToDataFrames(res, query, stats), nil + return lokiStreamsToDataFrames(res, query, stats) default: return nil, fmt.Errorf("resultType %T not supported{", res) } @@ -54,8 +59,8 @@ func lokiMatrixToDataFrames(matrix loghttp.Matrix, query *lokiQuery, stats []dat values = append(values, float64(k.Value)) } - timeField := data.NewField("", nil, timeVector) - valueField := data.NewField("", tags, values) + timeField := data.NewField("time", nil, timeVector) + valueField := data.NewField("value", tags, values) frame := data.NewFrame("", timeField, valueField) frame.SetMeta(&data.FrameMeta{ @@ -79,8 +84,8 @@ func lokiVectorToDataFrames(vector loghttp.Vector, query *lokiQuery, stats []dat for k, v := range v.Metric { tags[string(k)] = string(v) } - timeField := data.NewField("", nil, timeVector) - valueField := data.NewField("", tags, values) + timeField := data.NewField("time", nil, timeVector) + valueField := data.NewField("value", tags, values) frame := data.NewFrame("", timeField, valueField) frame.SetMeta(&data.FrameMeta{ @@ -93,35 +98,61 @@ func lokiVectorToDataFrames(vector loghttp.Vector, query *lokiQuery, stats []dat return frames } -func lokiStreamsToDataFrames(streams loghttp.Streams, query *lokiQuery, stats []data.QueryStat) data.Frames { - frames := data.Frames{} +// we serialize the labels as an ordered list of pairs +func labelsToString(labels data.Labels) (string, error) { + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + sort.Strings(keys) + + labelArray := make([][2]string, 0, len(labels)) + + for _, k := range keys { + pair := [2]string{k, labels[k]} + labelArray = append(labelArray, pair) + } + + bytes, err := jsoniter.Marshal(labelArray) + if err != nil { + return "", err + } + + return string(bytes), nil +} + +func lokiStreamsToDataFrames(streams loghttp.Streams, query *lokiQuery, stats []data.QueryStat) (data.Frames, error) { + var timeVector []time.Time + var values []string + var labelsVector []string for _, v := range streams { - tags := make(map[string]string, len(v.Labels)) - timeVector := make([]time.Time, 0, len(v.Entries)) - values := make([]string, 0, len(v.Entries)) - - for k, v := range v.Labels { - tags[k] = v + labelsText, err := labelsToString(v.Labels.Map()) + if err != nil { + return nil, err } for _, k := range v.Entries { timeVector = append(timeVector, k.Timestamp.UTC()) values = append(values, k.Line) + labelsVector = append(labelsVector, labelsText) } - - timeField := data.NewField("", nil, timeVector) - valueField := data.NewField("", tags, values) - - frame := data.NewFrame("", timeField, valueField) - frame.SetMeta(&data.FrameMeta{ - Stats: stats, - }) - - frames = append(frames, frame) } - return frames + timeField := data.NewField("ts", nil, timeVector) + valueField := data.NewField("line", nil, values) + labelsField := data.NewField("labels", nil, labelsVector) + labelsField.Config = &data.FieldConfig{ + // we should have a native json-field-type + Custom: map[string]interface{}{"json": true}, + } + + frame := data.NewFrame("", labelsField, timeField, valueField) + frame.SetMeta(&data.FrameMeta{ + Stats: stats, + }) + + return data.Frames{frame}, nil } func parseStats(result stats.Result) []data.QueryStat { diff --git a/pkg/tsdb/loki/testdata/streams_simple.golden.txt b/pkg/tsdb/loki/testdata/streams_simple.golden.txt index 76204b3a22f..dff97cb0e40 100644 --- a/pkg/tsdb/loki/testdata/streams_simple.golden.txt +++ b/pkg/tsdb/loki/testdata/streams_simple.golden.txt @@ -111,143 +111,21 @@ Frame[0] { ], "executedQueryString": "Expr: query1" } -Name: {level="error", location="moon"} -Dimensions: 3 Fields by 1 Rows -+----------------------------------------+------------------------------------+---------------------+ -| Name: time | Name: value | Name: tsNs | -| Labels: | Labels: level=error, location=moon | Labels: | -| Type: []time.Time | Type: []string | Type: []string | -+----------------------------------------+------------------------------------+---------------------+ -| 2022-02-16 16:50:44.81075712 +0000 UTC | log line error 1 | 1645030244810757120 | -+----------------------------------------+------------------------------------+---------------------+ - - - -Frame[1] { - "stats": [ - { - "displayName": "Summary: bytes processed per second", - "unit": "Bps", - "value": 3507022 - }, - { - "displayName": "Summary: lines processed per second", - "value": 24818 - }, - { - "displayName": "Summary: total bytes processed", - "unit": "decbytes", - "value": 7772 - }, - { - "displayName": "Summary: total lines processed", - "value": 55 - }, - { - "displayName": "Summary: exec time", - "unit": "s", - "value": 0.002216125 - }, - { - "displayName": "Store: total chunks ref", - "value": 2 - }, - { - "displayName": "Store: total chunks downloaded", - "value": 3 - }, - { - "displayName": "Store: chunks download time", - "unit": "s", - "value": 0.000390958 - }, - { - "displayName": "Store: head chunk bytes", - "unit": "decbytes", - "value": 4 - }, - { - "displayName": "Store: head chunk lines", - "value": 5 - }, - { - "displayName": "Store: decompressed bytes", - "unit": "decbytes", - "value": 7772 - }, - { - "displayName": "Store: decompressed lines", - "value": 55 - }, - { - "displayName": "Store: compressed bytes", - "unit": "decbytes", - "value": 31432 - }, - { - "displayName": "Store: total duplicates", - "value": 6 - }, - { - "displayName": "Ingester: total reached", - "value": 7 - }, - { - "displayName": "Ingester: total chunks matched", - "value": 8 - }, - { - "displayName": "Ingester: total batches", - "value": 9 - }, - { - "displayName": "Ingester: total lines sent", - "value": 10 - }, - { - "displayName": "Ingester: head chunk bytes", - "unit": "decbytes", - "value": 11 - }, - { - "displayName": "Ingester: head chunk lines", - "value": 12 - }, - { - "displayName": "Ingester: decompressed bytes", - "unit": "decbytes", - "value": 13 - }, - { - "displayName": "Ingester: decompressed lines", - "value": 14 - }, - { - "displayName": "Ingester: compressed bytes", - "unit": "decbytes", - "value": 15 - }, - { - "displayName": "Ingester: total duplicates", - "value": 16 - } - ], - "executedQueryString": "Expr: query1" -} -Name: {level="info", location="moon"} -Dimensions: 3 Fields by 4 Rows -+-----------------------------------------+-----------------------------------+---------------------+ -| Name: time | Name: value | Name: tsNs | -| Labels: | Labels: level=info, location=moon | Labels: | -| Type: []time.Time | Type: []string | Type: []string | -+-----------------------------------------+-----------------------------------+---------------------+ -| 2022-02-16 16:50:47.02773504 +0000 UTC | log line info 1 | 1645030247027735040 | -| 2022-02-16 16:50:46.277587968 +0000 UTC | log line info 2 | 1645030246277587968 | -| 2022-02-16 16:50:45.539423744 +0000 UTC | log line info 3 | 1645030245539423744 | -| 2022-02-16 16:50:44.091700992 +0000 UTC | log line info 4 | 1645030244091700992 | -+-----------------------------------------+-----------------------------------+---------------------+ +Name: +Dimensions: 5 Fields by 6 Rows ++-------------------------------------------+-----------------------------------------+------------------+---------------------+---------------------------------+ +| Name: labels | Name: ts | Name: line | Name: tsNs | Name: id | +| Labels: | Labels: | Labels: | Labels: | Labels: | +| Type: []string | Type: []time.Time | Type: []string | Type: []string | Type: []string | ++-------------------------------------------+-----------------------------------------+------------------+---------------------+---------------------------------+ +| [["code","one\","],["location","moon🌙"]] | 2022-02-16 16:50:44.81075712 +0000 UTC | log line error 1 | 1645030244810757120 | 1645030244810757120_f5088206_ | +| [["code","\",two"],["location","moon🌙"]] | 2022-02-16 16:50:47.02773504 +0000 UTC | log line info 1 | 1645030247027735040 | 1645030247027735040_46b225a4_ | +| [["code","\",two"],["location","moon🌙"]] | 2022-02-16 16:50:46.277587968 +0000 UTC | log line info 2 | 1645030246277587968 | 1645030246277587968_c2a9f32b_ | +| [["code","\",two"],["location","moon🌙"]] | 2022-02-16 16:50:46.277587968 +0000 UTC | log line info 2 | 1645030246277587968 | 1645030246277587968_c2a9f32b_1_ | +| [["code","\",two"],["location","moon🌙"]] | 2022-02-16 16:50:45.539423744 +0000 UTC | log line info 3 | 1645030245539423744 | 1645030245539423744_86502e6_ | +| [["code","\",two"],["location","moon🌙"]] | 2022-02-16 16:50:44.091700992 +0000 UTC | log line info 4 | 1645030244091700992 | 1645030244091700992_c6f5b60d_ | ++-------------------------------------------+-----------------------------------------+------------------+---------------------+---------------------------------+ ====== TEST DATA RESPONSE (arrow base64) ====== -FRAME=QVJST1cxAAD/////AAkAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAMQGAAADAAAAbAAAACgAAAAEAAAAlPf//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAC09///CAAAACwAAAAgAAAAe2xldmVsPSJlcnJvciIsIGxvY2F0aW9uPSJtb29uIn0AAAAABAAAAG5hbWUAAAAA9Pf//wgAAAA8BgAAMgYAAHsic3RhdHMiOlt7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogYnl0ZXMgcHJvY2Vzc2VkIHBlciBzZWNvbmQiLCJ1bml0IjoiQnBzIiwidmFsdWUiOjM1MDcwMjJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBsaW5lcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInZhbHVlIjoyNDgxOH0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IHRvdGFsIGJ5dGVzIHByb2Nlc3NlZCIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo3NzcyfSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgbGluZXMgcHJvY2Vzc2VkIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogZXhlYyB0aW1lIiwidW5pdCI6InMiLCJ2YWx1ZSI6MC4wMDIyMTYxMjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIHJlZiIsInZhbHVlIjoyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGNodW5rcyBkb3dubG9hZGVkIiwidmFsdWUiOjN9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogY2h1bmtzIGRvd25sb2FkIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMDM5MDk1OH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjR9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjo1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGRlY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo3NzcyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGRlY29tcHJlc3NlZCBsaW5lcyIsInZhbHVlIjo1NX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjMxNDMyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGR1cGxpY2F0ZXMiLCJ2YWx1ZSI6Nn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCByZWFjaGVkIiwidmFsdWUiOjd9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgY2h1bmtzIG1hdGNoZWQiLCJ2YWx1ZSI6OH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBiYXRjaGVzIiwidmFsdWUiOjl9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgbGluZXMgc2VudCIsInZhbHVlIjoxMH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBoZWFkIGNodW5rIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjExfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgbGluZXMiLCJ2YWx1ZSI6MTJ9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjEzfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGRlY29tcHJlc3NlZCBsaW5lcyIsInZhbHVlIjoxNH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjE1fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGR1cGxpY2F0ZXMiLCJ2YWx1ZSI6MTZ9XSwiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHF1ZXJ5MSJ9AAAEAAAAbWV0YQAAAAADAAAAmAEAAIQAAAAEAAAAhv7//xQAAABgAAAAYAAAAAAAAAVcAAAAAgAAACwAAAAEAAAAeP7//wgAAAAQAAAABAAAAHRzTnMAAAAABAAAAG5hbWUAAAAAnP7//wgAAAAMAAAAAgAAAHt9AAAGAAAAbGFiZWxzAAAAAAAABP///wQAAAB0c05zAAAAAAL///8UAAAA4AAAAOQAAAAAAAAF4AAAAAMAAABwAAAALAAAAAQAAAD4/v//CAAAABAAAAAFAAAAdmFsdWUAAAAEAAAAbmFtZQAAAAAc////CAAAACwAAAAjAAAAeyJsZXZlbCI6ImVycm9yIiwibG9jYXRpb24iOiJtb29uIn0ABgAAAGxhYmVscwAAXP///wgAAABIAAAAPAAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJ7bGV2ZWw9XCJlcnJvclwiLCBsb2NhdGlvbj1cIm1vb25cIn0ifQAAAAAGAAAAY29uZmlnAAAAAAAABAAEAAQAAAAFAAAAdmFsdWUAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAAAApMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAAB0aW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAHRpbWUAAAAAAAAAAP////8IAQAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAQAAAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAmAAAAAEAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAEAAAAAAAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAgAAAAAAAAAKAAAAAAAAAATAAAAAAAAAAAAAAADAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAABS4ukpS1BYAAAAAEAAAAGxvZyBsaW5lIGVycm9yIDEAAAAAEwAAADE2NDUwMzAyNDQ4MTA3NTcxMjAAAAAAABAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA4AAAAAAAEAAEAAAAQCQAAAAAAABABAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAxAYAAAMAAABsAAAAKAAAAAQAAACU9///CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAALT3//8IAAAALAAAACAAAAB7bGV2ZWw9ImVycm9yIiwgbG9jYXRpb249Im1vb24ifQAAAAAEAAAAbmFtZQAAAAD09///CAAAADwGAAAyBgAAeyJzdGF0cyI6W3siZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBieXRlcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInVuaXQiOiJCcHMiLCJ2YWx1ZSI6MzUwNzAyMn0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGxpbmVzIHByb2Nlc3NlZCBwZXIgc2Vjb25kIiwidmFsdWUiOjI0ODE4fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgYnl0ZXMgcHJvY2Vzc2VkIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiB0b3RhbCBsaW5lcyBwcm9jZXNzZWQiLCJ2YWx1ZSI6NTV9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBleGVjIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMjIxNjEyNX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBjaHVua3MgcmVmIiwidmFsdWUiOjJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIGRvd25sb2FkZWQiLCJ2YWx1ZSI6M30seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjaHVua3MgZG93bmxvYWQgdGltZSIsInVuaXQiOiJzIiwidmFsdWUiOjAuMDAwMzkwOTU4fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6NH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGxpbmVzIiwidmFsdWUiOjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MzE0MzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjo2fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIHJlYWNoZWQiLCJ2YWx1ZSI6N30seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBjaHVua3MgbWF0Y2hlZCIsInZhbHVlIjo4fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGJhdGNoZXMiLCJ2YWx1ZSI6OX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBsaW5lcyBzZW50IiwidmFsdWUiOjEwfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTF9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjoxMn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBkZWNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTN9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjE0fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTV9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjoxNn1dLCJleGVjdXRlZFF1ZXJ5U3RyaW5nIjoiRXhwcjogcXVlcnkxIn0AAAQAAABtZXRhAAAAAAMAAACYAQAAhAAAAAQAAACG/v//FAAAAGAAAABgAAAAAAAABVwAAAACAAAALAAAAAQAAAB4/v//CAAAABAAAAAEAAAAdHNOcwAAAAAEAAAAbmFtZQAAAACc/v//CAAAAAwAAAACAAAAe30AAAYAAABsYWJlbHMAAAAAAAAE////BAAAAHRzTnMAAAAAAv///xQAAADgAAAA5AAAAAAAAAXgAAAAAwAAAHAAAAAsAAAABAAAAPj+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAABz///8IAAAALAAAACMAAAB7ImxldmVsIjoiZXJyb3IiLCJsb2NhdGlvbiI6Im1vb24ifQAGAAAAbGFiZWxzAABc////CAAAAEgAAAA8AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImVycm9yXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAAAAAYAAABjb25maWcAAAAAAAAEAAQABAAAAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAAAoCQAAQVJST1cx -FRAME=QVJST1cxAAD/////+AgAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAMAGAAADAAAAaAAAACgAAAAEAAAAnPf//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAC89///CAAAACgAAAAfAAAAe2xldmVsPSJpbmZvIiwgbG9jYXRpb249Im1vb24ifQAEAAAAbmFtZQAAAAD49///CAAAADwGAAAyBgAAeyJzdGF0cyI6W3siZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBieXRlcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInVuaXQiOiJCcHMiLCJ2YWx1ZSI6MzUwNzAyMn0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGxpbmVzIHByb2Nlc3NlZCBwZXIgc2Vjb25kIiwidmFsdWUiOjI0ODE4fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgYnl0ZXMgcHJvY2Vzc2VkIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiB0b3RhbCBsaW5lcyBwcm9jZXNzZWQiLCJ2YWx1ZSI6NTV9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBleGVjIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMjIxNjEyNX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBjaHVua3MgcmVmIiwidmFsdWUiOjJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIGRvd25sb2FkZWQiLCJ2YWx1ZSI6M30seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjaHVua3MgZG93bmxvYWQgdGltZSIsInVuaXQiOiJzIiwidmFsdWUiOjAuMDAwMzkwOTU4fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6NH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGxpbmVzIiwidmFsdWUiOjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MzE0MzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjo2fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIHJlYWNoZWQiLCJ2YWx1ZSI6N30seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBjaHVua3MgbWF0Y2hlZCIsInZhbHVlIjo4fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGJhdGNoZXMiLCJ2YWx1ZSI6OX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBsaW5lcyBzZW50IiwidmFsdWUiOjEwfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTF9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjoxMn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBkZWNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTN9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjE0fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTV9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjoxNn1dLCJleGVjdXRlZFF1ZXJ5U3RyaW5nIjoiRXhwcjogcXVlcnkxIn0AAAQAAABtZXRhAAAAAAMAAACUAQAAhAAAAAQAAACK/v//FAAAAGAAAABgAAAAAAAABVwAAAACAAAALAAAAAQAAAB8/v//CAAAABAAAAAEAAAAdHNOcwAAAAAEAAAAbmFtZQAAAACg/v//CAAAAAwAAAACAAAAe30AAAYAAABsYWJlbHMAAAAAAAAI////BAAAAHRzTnMAAAAABv///xQAAADcAAAA4AAAAAAAAAXcAAAAAwAAAHAAAAAsAAAABAAAAPz+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAACD///8IAAAALAAAACIAAAB7ImxldmVsIjoiaW5mbyIsImxvY2F0aW9uIjoibW9vbiJ9AAAGAAAAbGFiZWxzAABg////CAAAAEQAAAA7AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImluZm9cIiwgbG9jYXRpb249XCJtb29uXCJ9In0ABgAAAGNvbmZpZwAAAAAAAAQABAAEAAAABQAAAHZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAAB0aW1lAAAAAAAAAAD/////CAEAABQAAAAAAAAADAAWABQAEwAMAAQADAAAAOAAAAAAAAAAFAAAAAAAAAMEAAoAGAAMAAgABAAKAAAAFAAAAJgAAAAEAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAUAAAAAAAAADgAAAAAAAAAPAAAAAAAAAB4AAAAAAAAAAAAAAAAAAAAeAAAAAAAAAAUAAAAAAAAAJAAAAAAAAAATAAAAAAAAAAAAAAAAwAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAB63D5LUtQWACQmEktS1BYApibmSlLUFgAn3I9KUtQWAAAAAA8AAAAeAAAALQAAADwAAAAAAAAAbG9nIGxpbmUgaW5mbyAxbG9nIGxpbmUgaW5mbyAybG9nIGxpbmUgaW5mbyAzbG9nIGxpbmUgaW5mbyA0AAAAAAAAAAATAAAAJgAAADkAAABMAAAAAAAAADE2NDUwMzAyNDcwMjc3MzUwNDAxNjQ1MDMwMjQ2Mjc3NTg3OTY4MTY0NTAzMDI0NTUzOTQyMzc0NDE2NDUwMzAyNDQwOTE3MDA5OTIAAAAAEAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADgAAAAAAAQAAQAAAAgJAAAAAAAAEAEAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAADABgAAAwAAAGgAAAAoAAAABAAAAJz3//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAAvPf//wgAAAAoAAAAHwAAAHtsZXZlbD0iaW5mbyIsIGxvY2F0aW9uPSJtb29uIn0ABAAAAG5hbWUAAAAA+Pf//wgAAAA8BgAAMgYAAHsic3RhdHMiOlt7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogYnl0ZXMgcHJvY2Vzc2VkIHBlciBzZWNvbmQiLCJ1bml0IjoiQnBzIiwidmFsdWUiOjM1MDcwMjJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBsaW5lcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInZhbHVlIjoyNDgxOH0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IHRvdGFsIGJ5dGVzIHByb2Nlc3NlZCIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo3NzcyfSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgbGluZXMgcHJvY2Vzc2VkIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogZXhlYyB0aW1lIiwidW5pdCI6InMiLCJ2YWx1ZSI6MC4wMDIyMTYxMjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIHJlZiIsInZhbHVlIjoyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGNodW5rcyBkb3dubG9hZGVkIiwidmFsdWUiOjN9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogY2h1bmtzIGRvd25sb2FkIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMDM5MDk1OH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjR9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjo1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGRlY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo3NzcyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGRlY29tcHJlc3NlZCBsaW5lcyIsInZhbHVlIjo1NX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjMxNDMyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGR1cGxpY2F0ZXMiLCJ2YWx1ZSI6Nn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCByZWFjaGVkIiwidmFsdWUiOjd9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgY2h1bmtzIG1hdGNoZWQiLCJ2YWx1ZSI6OH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBiYXRjaGVzIiwidmFsdWUiOjl9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgbGluZXMgc2VudCIsInZhbHVlIjoxMH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBoZWFkIGNodW5rIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjExfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgbGluZXMiLCJ2YWx1ZSI6MTJ9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjEzfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGRlY29tcHJlc3NlZCBsaW5lcyIsInZhbHVlIjoxNH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjE1fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGR1cGxpY2F0ZXMiLCJ2YWx1ZSI6MTZ9XSwiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHF1ZXJ5MSJ9AAAEAAAAbWV0YQAAAAADAAAAlAEAAIQAAAAEAAAAiv7//xQAAABgAAAAYAAAAAAAAAVcAAAAAgAAACwAAAAEAAAAfP7//wgAAAAQAAAABAAAAHRzTnMAAAAABAAAAG5hbWUAAAAAoP7//wgAAAAMAAAAAgAAAHt9AAAGAAAAbGFiZWxzAAAAAAAACP///wQAAAB0c05zAAAAAAb///8UAAAA3AAAAOAAAAAAAAAF3AAAAAMAAABwAAAALAAAAAQAAAD8/v//CAAAABAAAAAFAAAAdmFsdWUAAAAEAAAAbmFtZQAAAAAg////CAAAACwAAAAiAAAAeyJsZXZlbCI6ImluZm8iLCJsb2NhdGlvbiI6Im1vb24ifQAABgAAAGxhYmVscwAAYP///wgAAABEAAAAOwAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJ7bGV2ZWw9XCJpbmZvXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAYAAABjb25maWcAAAAAAAAEAAQABAAAAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAAAgCQAAQVJST1cx +FRAME=QVJST1cxAAD/////IAkAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAKQGAAADAAAATAAAACgAAAAEAAAAfPf//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAACc9///CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAALz3//8IAAAAPAYAADIGAAB7InN0YXRzIjpbeyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGJ5dGVzIHByb2Nlc3NlZCBwZXIgc2Vjb25kIiwidW5pdCI6IkJwcyIsInZhbHVlIjozNTA3MDIyfSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogbGluZXMgcHJvY2Vzc2VkIHBlciBzZWNvbmQiLCJ2YWx1ZSI6MjQ4MTh9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiB0b3RhbCBieXRlcyBwcm9jZXNzZWQiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6Nzc3Mn0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IHRvdGFsIGxpbmVzIHByb2Nlc3NlZCIsInZhbHVlIjo1NX0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGV4ZWMgdGltZSIsInVuaXQiOiJzIiwidmFsdWUiOjAuMDAyMjE2MTI1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGNodW5rcyByZWYiLCJ2YWx1ZSI6Mn0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBjaHVua3MgZG93bmxvYWRlZCIsInZhbHVlIjozfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGNodW5rcyBkb3dubG9hZCB0aW1lIiwidW5pdCI6InMiLCJ2YWx1ZSI6MC4wMDAzOTA5NTh9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogaGVhZCBjaHVuayBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo0fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGhlYWQgY2h1bmsgbGluZXMiLCJ2YWx1ZSI6NX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBkZWNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6Nzc3Mn0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBkZWNvbXByZXNzZWQgbGluZXMiLCJ2YWx1ZSI6NTV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjozMTQzMn0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBkdXBsaWNhdGVzIiwidmFsdWUiOjZ9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgcmVhY2hlZCIsInZhbHVlIjo3fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGNodW5rcyBtYXRjaGVkIiwidmFsdWUiOjh9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgYmF0Y2hlcyIsInZhbHVlIjo5fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGxpbmVzIHNlbnQiLCJ2YWx1ZSI6MTB9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogaGVhZCBjaHVuayBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjoxMX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBoZWFkIGNodW5rIGxpbmVzIiwidmFsdWUiOjEyfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGRlY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjoxM30seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBkZWNvbXByZXNzZWQgbGluZXMiLCJ2YWx1ZSI6MTR9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjoxNX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBkdXBsaWNhdGVzIiwidmFsdWUiOjE2fV0sImV4ZWN1dGVkUXVlcnlTdHJpbmciOiJFeHByOiBxdWVyeTEifQAABAAAAG1ldGEAAAAABQAAAKgBAAA0AQAA2AAAAFgAAAAEAAAAfv7//xQAAAA4AAAAOAAAAAAAAAU0AAAAAQAAAAQAAABE/v//CAAAAAwAAAACAAAAaWQAAAQAAABuYW1lAAAAAAAAAAAk/v//AgAAAGlkAADO/v//FAAAAGAAAABgAAAAAAAABVwAAAACAAAALAAAAAQAAACY/v//CAAAABAAAAAEAAAAdHNOcwAAAAAEAAAAbmFtZQAAAAC8/v//CAAAAAwAAAACAAAAe30AAAYAAABsYWJlbHMAAAAAAACc/v//BAAAAHRzTnMAAAAASv///xQAAAA8AAAAPAAAAAAAAAU4AAAAAQAAAAQAAAAQ////CAAAABAAAAAEAAAAbGluZQAAAAAEAAAAbmFtZQAAAAAAAAAA9P7//wQAAABsaW5lAAAAAKL///8UAAAAOAAAAEAAAAAAAAAKQAAAAAEAAAAEAAAAaP///wgAAAAMAAAAAgAAAHRzAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAIAAAB0cwAAAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAIAAAACEAAAAAAAABYAAAAACAAAANAAAAAQAAADc////CAAAABAAAAAGAAAAbGFiZWxzAAAEAAAAbmFtZQAAAAAIAAwACAAEAAgAAAAIAAAAJAAAABgAAAB7ImN1c3RvbSI6eyJqc29uIjp0cnVlfX0AAAAABgAAAGNvbmZpZwAAAAAAAAQABAAEAAAABgAAAGxhYmVscwAA/////4gBAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAABAAwAAAAAAABQAAAAAAAADBAAKABgADAAIAAQACgAAABQAAAD4AAAABgAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAAAAAAAAAIAAAAAAAAAACAQAAAAAAACgBAAAAAAAAAAAAAAAAAAAoAQAAAAAAADAAAAAAAAAAWAEAAAAAAAAAAAAAAAAAAFgBAAAAAAAAHAAAAAAAAAB4AQAAAAAAAFsAAAAAAAAA2AEAAAAAAAAAAAAAAAAAANgBAAAAAAAAHAAAAAAAAAD4AQAAAAAAAHIAAAAAAAAAcAIAAAAAAAAAAAAAAAAAAHACAAAAAAAAHAAAAAAAAACQAgAAAAAAAK8AAAAAAAAAAAAAAAUAAAAGAAAAAAAAAAAAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAAAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAArAAAAVgAAAIEAAACsAAAA1wAAAAIBAAAAAAAAW1siY29kZSIsIm9uZVwiLCJdLFsibG9jYXRpb24iLCJtb29u8J+MmSJdXVtbImNvZGUiLCJcIix0d28iXSxbImxvY2F0aW9uIiwibW9vbvCfjJkiXV1bWyJjb2RlIiwiXCIsdHdvIl0sWyJsb2NhdGlvbiIsIm1vb27wn4yZIl1dW1siY29kZSIsIlwiLHR3byJdLFsibG9jYXRpb24iLCJtb29u8J+MmSJdXVtbImNvZGUiLCJcIix0d28iXSxbImxvY2F0aW9uIiwibW9vbvCfjJkiXV1bWyJjb2RlIiwiXCIsdHdvIl0sWyJsb2NhdGlvbiIsIm1vb27wn4yZIl1dAAAAAAAAABS4ukpS1BYAetw+S1LUFgAkJhJLUtQWACQmEktS1BYApibmSlLUFgAn3I9KUtQWAAAAABAAAAAfAAAALgAAAD0AAABMAAAAWwAAAAAAAABsb2cgbGluZSBlcnJvciAxbG9nIGxpbmUgaW5mbyAxbG9nIGxpbmUgaW5mbyAybG9nIGxpbmUgaW5mbyAybG9nIGxpbmUgaW5mbyAzbG9nIGxpbmUgaW5mbyA0AAAAAAAAAAAAEwAAACYAAAA5AAAATAAAAF8AAAByAAAAAAAAADE2NDUwMzAyNDQ4MTA3NTcxMjAxNjQ1MDMwMjQ3MDI3NzM1MDQwMTY0NTAzMDI0NjI3NzU4Nzk2ODE2NDUwMzAyNDYyNzc1ODc5NjgxNjQ1MDMwMjQ1NTM5NDIzNzQ0MTY0NTAzMDI0NDA5MTcwMDk5MgAAAAAAAAAAAAAdAAAAOgAAAFcAAAB2AAAAkgAAAK8AAAAAAAAAMTY0NTAzMDI0NDgxMDc1NzEyMF9mNTA4ODIwNl8xNjQ1MDMwMjQ3MDI3NzM1MDQwXzQ2YjIyNWE0XzE2NDUwMzAyNDYyNzc1ODc5NjhfYzJhOWYzMmJfMTY0NTAzMDI0NjI3NzU4Nzk2OF9jMmE5ZjMyYl8xXzE2NDUwMzAyNDU1Mzk0MjM3NDRfODY1MDJlNl8xNjQ1MDMwMjQ0MDkxNzAwOTkyX2M2ZjViNjBkXwAQAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAPAAAAAAABAABAAAAMAkAAAAAAACQAQAAAAAAAEADAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAACkBgAAAwAAAEwAAAAoAAAABAAAAHz3//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAAnPf//wgAAAAMAAAAAAAAAAAAAAAEAAAAbmFtZQAAAAC89///CAAAADwGAAAyBgAAeyJzdGF0cyI6W3siZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBieXRlcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInVuaXQiOiJCcHMiLCJ2YWx1ZSI6MzUwNzAyMn0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGxpbmVzIHByb2Nlc3NlZCBwZXIgc2Vjb25kIiwidmFsdWUiOjI0ODE4fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgYnl0ZXMgcHJvY2Vzc2VkIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiB0b3RhbCBsaW5lcyBwcm9jZXNzZWQiLCJ2YWx1ZSI6NTV9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBleGVjIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMjIxNjEyNX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBjaHVua3MgcmVmIiwidmFsdWUiOjJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIGRvd25sb2FkZWQiLCJ2YWx1ZSI6M30seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjaHVua3MgZG93bmxvYWQgdGltZSIsInVuaXQiOiJzIiwidmFsdWUiOjAuMDAwMzkwOTU4fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6NH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGxpbmVzIiwidmFsdWUiOjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MzE0MzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjo2fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIHJlYWNoZWQiLCJ2YWx1ZSI6N30seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBjaHVua3MgbWF0Y2hlZCIsInZhbHVlIjo4fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGJhdGNoZXMiLCJ2YWx1ZSI6OX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBsaW5lcyBzZW50IiwidmFsdWUiOjEwfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTF9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjoxMn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBkZWNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTN9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjE0fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTV9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjoxNn1dLCJleGVjdXRlZFF1ZXJ5U3RyaW5nIjoiRXhwcjogcXVlcnkxIn0AAAQAAABtZXRhAAAAAAUAAACoAQAANAEAANgAAABYAAAABAAAAH7+//8UAAAAOAAAADgAAAAAAAAFNAAAAAEAAAAEAAAARP7//wgAAAAMAAAAAgAAAGlkAAAEAAAAbmFtZQAAAAAAAAAAJP7//wIAAABpZAAAzv7//xQAAABgAAAAYAAAAAAAAAVcAAAAAgAAACwAAAAEAAAAmP7//wgAAAAQAAAABAAAAHRzTnMAAAAABAAAAG5hbWUAAAAAvP7//wgAAAAMAAAAAgAAAHt9AAAGAAAAbGFiZWxzAAAAAAAAnP7//wQAAAB0c05zAAAAAEr///8UAAAAPAAAADwAAAAAAAAFOAAAAAEAAAAEAAAAEP///wgAAAAQAAAABAAAAGxpbmUAAAAABAAAAG5hbWUAAAAAAAAAAPT+//8EAAAAbGluZQAAAACi////FAAAADgAAABAAAAAAAAACkAAAAABAAAABAAAAGj///8IAAAADAAAAAIAAAB0cwAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwACAAAAdHMAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAACAAAAAhAAAAAAAAAWAAAAAAgAAADQAAAAEAAAA3P///wgAAAAQAAAABgAAAGxhYmVscwAABAAAAG5hbWUAAAAACAAMAAgABAAIAAAACAAAACQAAAAYAAAAeyJjdXN0b20iOnsianNvbiI6dHJ1ZX19AAAAAAYAAABjb25maWcAAAAAAAAEAAQABAAAAAYAAABsYWJlbHMAAFAJAABBUlJPVzE= diff --git a/pkg/tsdb/loki/testdata/streams_simple.json b/pkg/tsdb/loki/testdata/streams_simple.json index 8754d444ad0..71c0d4ab44b 100644 --- a/pkg/tsdb/loki/testdata/streams_simple.json +++ b/pkg/tsdb/loki/testdata/streams_simple.json @@ -5,8 +5,8 @@ "result": [ { "stream": { - "level": "error", - "location": "moon" + "code": "one\",", + "location": "moon🌙" }, "values": [ [ @@ -17,8 +17,8 @@ }, { "stream": { - "level": "info", - "location": "moon" + "code": "\",two", + "location": "moon🌙" }, "values": [ [ @@ -29,6 +29,10 @@ "1645030246277587968", "log line info 2" ], + [ + "1645030246277587968", + "log line info 2" + ], [ "1645030245539423744", "log line info 3" diff --git a/pkg/tsdb/opentsdb/opentsdb.go b/pkg/tsdb/opentsdb/opentsdb.go index 8dc65d0ff5c..6143fae3dbf 100644 --- a/pkg/tsdb/opentsdb/opentsdb.go +++ b/pkg/tsdb/opentsdb/opentsdb.go @@ -16,11 +16,11 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/setting" - "golang.org/x/net/context/ctxhttp" ) type Service struct { @@ -86,12 +86,12 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return nil, err } - request, err := s.createRequest(dsInfo, tsdbQuery) + request, err := s.createRequest(ctx, dsInfo, tsdbQuery) if err != nil { return &backend.QueryDataResponse{}, err } - res, err := ctxhttp.Do(ctx, dsInfo.HTTPClient, request) + res, err := dsInfo.HTTPClient.Do(request) if err != nil { return &backend.QueryDataResponse{}, err } @@ -104,7 +104,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return result, nil } -func (s *Service) createRequest(dsInfo *datasourceInfo, data OpenTsdbQuery) (*http.Request, error) { +func (s *Service) createRequest(ctx context.Context, dsInfo *datasourceInfo, data OpenTsdbQuery) (*http.Request, error) { u, err := url.Parse(dsInfo.URL) if err != nil { return nil, err @@ -117,7 +117,7 @@ func (s *Service) createRequest(dsInfo *datasourceInfo, data OpenTsdbQuery) (*ht return nil, fmt.Errorf("failed to create request: %w", err) } - req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(string(postData))) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(string(postData))) if err != nil { s.logger.Info("Failed to create request", "error", err) return nil, fmt.Errorf("failed to create request: %w", err) diff --git a/pkg/tsdb/opentsdb/opentsdb_test.go b/pkg/tsdb/opentsdb/opentsdb_test.go index 7ef63ee7e54..19ba68bd5d1 100644 --- a/pkg/tsdb/opentsdb/opentsdb_test.go +++ b/pkg/tsdb/opentsdb/opentsdb_test.go @@ -1,6 +1,7 @@ package opentsdb import ( + "context" "io/ioutil" "net/http" "strings" @@ -10,9 +11,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/infra/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" ) func TestOpenTsdbExecutor(t *testing.T) { @@ -21,7 +23,7 @@ func TestOpenTsdbExecutor(t *testing.T) { } t.Run("create request", func(t *testing.T) { - req, err := service.createRequest(&datasourceInfo{}, OpenTsdbQuery{}) + req, err := service.createRequest(context.Background(), &datasourceInfo{}, OpenTsdbQuery{}) require.NoError(t, err) assert.Equal(t, "POST", req.Method) diff --git a/pkg/tsdb/postgres/postgres.go b/pkg/tsdb/postgres/postgres.go index 527102831b6..37e69c09ebc 100644 --- a/pkg/tsdb/postgres/postgres.go +++ b/pkg/tsdb/postgres/postgres.go @@ -140,7 +140,7 @@ func (s *Service) generateConnectionString(dsInfo sqleng.DataSourceInfo) (string } } else { if index == v6Index+1 { - host = dsInfo.URL[0:index] + host = dsInfo.URL[1 : index-1] var err error port, err = strconv.Atoi(dsInfo.URL[index+1:]) if err != nil { @@ -149,7 +149,7 @@ func (s *Service) generateConnectionString(dsInfo sqleng.DataSourceInfo) (string logger.Debug("Generating ipv6 connection string with network host/port pair", "host", host, "port", port) } else { - host = dsInfo.URL + host = dsInfo.URL[1 : len(dsInfo.URL)-1] logger.Debug("Generating ipv6 connection string with network host", "host", host) } } diff --git a/pkg/tsdb/postgres/postgres_test.go b/pkg/tsdb/postgres/postgres_test.go index eb7278d47ff..45b9a1d030c 100644 --- a/pkg/tsdb/postgres/postgres_test.go +++ b/pkg/tsdb/postgres/postgres_test.go @@ -74,7 +74,7 @@ func TestGenerateConnectionString(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='[::1]' dbname='database' sslmode='verify-full'", + expConnStr: "user='user' password='password' host='::1' dbname='database' sslmode='verify-full'", }, { desc: "Ipv6/port host", @@ -83,7 +83,7 @@ func TestGenerateConnectionString(t *testing.T) { password: "password", database: "database", tlsSettings: tlsSettings{Mode: "verify-full"}, - expConnStr: "user='user' password='password' host='[::1]' dbname='database' port=1234 sslmode='verify-full'", + expConnStr: "user='user' password='password' host='::1' dbname='database' port=1234 sslmode='verify-full'", }, { desc: "Invalid port", diff --git a/pkg/util/proxyutil/reverse_proxy.go b/pkg/util/proxyutil/reverse_proxy.go new file mode 100644 index 00000000000..3ce5d3b4011 --- /dev/null +++ b/pkg/util/proxyutil/reverse_proxy.go @@ -0,0 +1,146 @@ +package proxyutil + +import ( + "context" + "errors" + "log" + "net/http" + "net/http/httputil" + "strings" + "time" + + glog "github.com/grafana/grafana/pkg/infra/log" +) + +// StatusClientClosedRequest A non-standard status code introduced by nginx +// for the case when a client closes the connection while nginx is processing +// the request. +// https://httpstatus.in/499/ +const StatusClientClosedRequest = 499 + +// ReverseProxyOption reverse proxy option to configure a httputil.ReverseProxy. +type ReverseProxyOption func(*httputil.ReverseProxy) + +// NewReverseProxy creates a new httputil.ReverseProxy with sane default configuration. +func NewReverseProxy(logger glog.Logger, director func(*http.Request), opts ...ReverseProxyOption) *httputil.ReverseProxy { + if logger == nil { + panic("logger cannot be nil") + } + + if director == nil { + panic("director cannot be nil") + } + + p := &httputil.ReverseProxy{ + FlushInterval: time.Millisecond * 200, + ErrorHandler: errorHandler(logger), + ErrorLog: log.New(&logWrapper{logger: logger}, "", 0), + Director: director, + } + + for _, opt := range opts { + opt(p) + } + + origDirector := p.Director + p.Director = wrapDirector(origDirector) + + if p.ModifyResponse == nil { + // nolint:bodyclose + p.ModifyResponse = modifyResponse(logger) + } else { + modResponse := p.ModifyResponse + p.ModifyResponse = func(resp *http.Response) error { + if err := modResponse(resp); err != nil { + return err + } + + // nolint:bodyclose + return modifyResponse(logger)(resp) + } + } + + return p +} + +// wrapDirector wraps a director and adds additional functionality. +func wrapDirector(d func(*http.Request)) func(req *http.Request) { + return func(req *http.Request) { + d(req) + PrepareProxyRequest(req) + + // Clear Origin and Referer to avoid CORS issues + req.Header.Del("Origin") + req.Header.Del("Referer") + } +} + +// modifyResponse enforces certain constraints on http.Response. +func modifyResponse(logger glog.Logger) func(resp *http.Response) error { + return func(resp *http.Response) error { + resp.Header.Del("Set-Cookie") + SetProxyResponseHeaders(resp.Header) + return nil + } +} + +type timeoutError interface { + error + Timeout() bool +} + +// errorHandler handles any errors happening while proxying a request and enforces +// certain HTTP status based on the kind of error. +// If client cancel/close the request we return 499 StatusClientClosedRequest. +// If timeout happens while communicating with upstream server we return http.StatusGatewayTimeout. +// If any other error we return http.StatusBadGateway. +func errorHandler(logger glog.Logger) func(http.ResponseWriter, *http.Request, error) { + return func(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, context.Canceled) { + logger.Debug("Proxy request cancelled by client") + w.WriteHeader(StatusClientClosedRequest) + return + } + + // nolint:errorlint + if timeoutErr, ok := err.(timeoutError); ok && timeoutErr.Timeout() { + logger.Error("Proxy request timed out", "err", err) + w.WriteHeader(http.StatusGatewayTimeout) + return + } + + logger.Error("Proxy request failed", "err", err) + w.WriteHeader(http.StatusBadGateway) + } +} + +type logWrapper struct { + logger glog.Logger +} + +// Write writes log messages as bytes from proxy. +func (lw *logWrapper) Write(p []byte) (n int, err error) { + withoutNewline := strings.TrimSuffix(string(p), "\n") + lw.logger.Error("Proxy request error", "error", withoutNewline) + return len(p), nil +} + +func WithTransport(transport http.RoundTripper) ReverseProxyOption { + if transport == nil { + panic("transport cannot be nil") + } + + return ReverseProxyOption(func(rp *httputil.ReverseProxy) { + rp.Transport = transport + }) +} + +func WithModifyResponse(fn func(*http.Response) error) ReverseProxyOption { + if fn == nil { + panic("fn cannot be nil") + } + + return ReverseProxyOption(func(rp *httputil.ReverseProxy) { + rp.ModifyResponse = fn + }) +} diff --git a/pkg/util/proxyutil/reverse_proxy_test.go b/pkg/util/proxyutil/reverse_proxy_test.go new file mode 100644 index 00000000000..a886b928d8e --- /dev/null +++ b/pkg/util/proxyutil/reverse_proxy_test.go @@ -0,0 +1,157 @@ +package proxyutil + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/stretchr/testify/require" +) + +func TestReverseProxy(t *testing.T) { + t.Run("When proxying a request should enforce request and response constraints", func(t *testing.T) { + var actualReq *http.Request + upstream := newUpstreamServer(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + actualReq = req + http.SetCookie(w, &http.Cookie{Name: "test"}) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(upstream.Close) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, upstream.URL, nil) + req.Header.Set("X-Forwarded-Host", "forwarded.host.com") + req.Header.Set("X-Forwarded-Port", "8080") + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("Origin", "test.com") + req.Header.Set("Referer", "https://test.com/api") + req.RemoteAddr = "10.0.0.1" + + rp := NewReverseProxy(log.New("test"), func(req *http.Request) { + req.Header.Set("X-KEY", "value") + }) + require.NotNil(t, rp) + require.NotNil(t, rp.ModifyResponse) + rp.ServeHTTP(rec, req) + + require.NotNil(t, actualReq) + require.Empty(t, actualReq.Header.Get("X-Forwarded-Host")) + require.Empty(t, actualReq.Header.Get("X-Forwarded-Port")) + require.Empty(t, actualReq.Header.Get("X-Forwarded-Proto")) + require.Equal(t, "10.0.0.1", actualReq.Header.Get("X-Forwarded-For")) + require.Empty(t, actualReq.Header.Get("Origin")) + require.Empty(t, actualReq.Header.Get("Referer")) + require.Equal(t, "value", actualReq.Header.Get("X-KEY")) + resp := rec.Result() + require.Empty(t, resp.Cookies()) + require.Equal(t, "sandbox", resp.Header.Get("Content-Security-Policy")) + require.NoError(t, resp.Body.Close()) + }) + + t.Run("When proxying a request using WithModifyResponse should call it before default ModifyResponse func", func(t *testing.T) { + var actualReq *http.Request + upstream := newUpstreamServer(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + actualReq = req + http.SetCookie(w, &http.Cookie{Name: "test"}) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(upstream.Close) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, upstream.URL, nil) + rp := NewReverseProxy( + log.New("test"), + func(req *http.Request) { + req.Header.Set("X-KEY", "value") + }, + WithModifyResponse(func(r *http.Response) error { + r.Header.Set("X-KEY2", "value2") + return nil + }), + ) + require.NotNil(t, rp) + require.NotNil(t, rp.ModifyResponse) + rp.ServeHTTP(rec, req) + + require.NotNil(t, actualReq) + require.Equal(t, "value", actualReq.Header.Get("X-KEY")) + resp := rec.Result() + require.Empty(t, resp.Cookies()) + require.Equal(t, "sandbox", resp.Header.Get("Content-Security-Policy")) + require.Equal(t, "value2", resp.Header.Get("X-KEY2")) + require.NoError(t, resp.Body.Close()) + }) + + t.Run("Error handling should convert status codes depending on what kind of error it is", func(t *testing.T) { + timedOutTransport := http.DefaultTransport.(*http.Transport) + timedOutTransport.ResponseHeaderTimeout = time.Nanosecond + + testCases := []struct { + desc string + transport http.RoundTripper + expectedStatusCode int + }{ + { + desc: "Cancelled request should return 499 Client closed request", + transport: &cancelledRoundTripper{}, + expectedStatusCode: StatusClientClosedRequest, + }, + { + desc: "Timed out request should return 504 Gateway timeout", + transport: timedOutTransport, + expectedStatusCode: http.StatusGatewayTimeout, + }, + { + desc: "Failed request should return 502 Bad gateway", + transport: &failingRoundTripper{}, + expectedStatusCode: http.StatusBadGateway, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + upstream := newUpstreamServer(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(upstream.Close) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, upstream.URL, nil) + + rp := NewReverseProxy( + log.New("test"), + func(req *http.Request) {}, + WithTransport(tc.transport), + ) + require.NotNil(t, rp) + require.NotNil(t, rp.Transport) + require.Same(t, tc.transport, rp.Transport) + rp.ServeHTTP(rec, req) + + resp := rec.Result() + require.Equal(t, tc.expectedStatusCode, resp.StatusCode) + require.NoError(t, resp.Body.Close()) + }) + } + }) +} + +func newUpstreamServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + + upstream := httptest.NewServer(handler) + return upstream +} + +type cancelledRoundTripper struct{} + +func (cancelledRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, context.Canceled +} + +type failingRoundTripper struct{} + +func (failingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("some error") +} diff --git a/pkg/web/webtest/webtest_test.go b/pkg/web/webtest/webtest_test.go index 0432b19e2d0..3bb55911ded 100644 --- a/pkg/web/webtest/webtest_test.go +++ b/pkg/web/webtest/webtest_test.go @@ -99,7 +99,7 @@ func TestServerClient(t *testing.T) { require.NoError(t, err) require.NoError(t, resp.Body.Close()) - var user *models.SignedInUser + var user models.SignedInUser err = json.Unmarshal(bytes, &user) require.NoError(t, err) require.NotNil(t, user) @@ -121,7 +121,7 @@ func TestServerClient(t *testing.T) { require.NoError(t, err) require.NoError(t, resp.Body.Close()) - var user *models.SignedInUser + var user models.SignedInUser err = json.Unmarshal(bytes, &user) require.NoError(t, err) require.NotNil(t, user) diff --git a/public/api-merged.json b/public/api-merged.json index ff5e20db5e9..2a329b78226 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -506,6 +506,14 @@ "summary": "Add a user role assignment.", "operationId": "addUserRole", "parameters": [ + { + "type": "integer", + "format": "int64", + "x-go-name": "UserID", + "name": "user_id", + "in": "path", + "required": true + }, { "x-go-name": "Body", "name": "body", @@ -514,14 +522,6 @@ "schema": { "$ref": "#/definitions/AddUserRoleCommand" } - }, - { - "type": "integer", - "format": "int64", - "x-go-name": "UserID", - "name": "user_id", - "in": "path", - "required": true } ], "responses": { @@ -547,6 +547,14 @@ "summary": "Remove a user role assignment.", "operationId": "removeUserRole", "parameters": [ + { + "type": "integer", + "format": "int64", + "x-go-name": "UserID", + "name": "user_id", + "in": "path", + "required": true + }, { "type": "string", "x-go-name": "RoleUID", @@ -560,14 +568,6 @@ "description": "A flag indicating if the assignment is global or not. If set to false, the default org ID of the authenticated user will be used from the request to remove assignment.", "name": "global", "in": "query" - }, - { - "type": "integer", - "format": "int64", - "x-go-name": "UserID", - "name": "user_id", - "in": "path", - "required": true } ], "responses": { @@ -1025,7 +1025,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:delete` and scope `global:users:*`.", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:delete` and scope `global.users:*`.", "tags": ["admin_users"], "summary": "Delete global User.", "operationId": "deleteUser", @@ -1065,7 +1065,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:list` and scope `global:users:*`.", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:list` and scope `global.users:*`.", "tags": ["admin_users"], "summary": "Return a list of all auth tokens (devices) that the user currently have logged in from.", "operationId": "getAuthTokens", @@ -1102,7 +1102,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:disable` and scope `global:users:1` (userIDScope).", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:disable` and scope `global.users:1` (userIDScope).", "tags": ["admin_users"], "summary": "Disable user.", "operationId": "disableUser", @@ -1142,7 +1142,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:enable` and scope `global:users:1` (userIDScope).", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:enable` and scope `global.users:1` (userIDScope).", "tags": ["admin_users"], "summary": "Enable user.", "operationId": "enableUser", @@ -1182,7 +1182,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.logout` and scope `global:users:*`.", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.logout` and scope `global.users:*`.", "tags": ["admin_users"], "summary": "Logout user revokes all auth tokens (devices) for the user. User of issued auth tokens (devices) will no longer be logged in and will be required to authenticate again upon next activity.", "operationId": "logoutUser", @@ -1225,7 +1225,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.password:update` and scope `global:users:*`.", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.password:update` and scope `global.users:*`.", "tags": ["admin_users"], "summary": "Set password for user.", "operationId": "setPassword", @@ -1269,7 +1269,7 @@ }, "/admin/users/{user_id}/permissions": { "put": { - "description": "Only works with Basic Authentication (username and password). See introduction for an explanation.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.permissions:update` and scope `global:users:*`.", + "description": "Only works with Basic Authentication (username and password). See introduction for an explanation.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.permissions:update` and scope `global.users:*`.", "tags": ["admin_users"], "summary": "Set permissions for user.", "operationId": "setPermissions", @@ -1318,7 +1318,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:list` and scope `global:users:1` (userIDScope).", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:list` and scope `global.users:1` (userIDScope).", "tags": ["admin_users"], "summary": "Fetch user quota.", "operationId": "getUserQuota", @@ -1358,7 +1358,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:update` and scope `global:users:1` (userIDScope).", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:update` and scope `global.users:1` (userIDScope).", "tags": ["admin_users"], "summary": "Update user quota.", "operationId": "updateUserQuota", @@ -1414,7 +1414,7 @@ "basic": [] } ], - "description": "Revokes the given auth token (device) for the user. User of issued auth token (device) will no longer be logged in and will be required to authenticate again upon next activity.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:update` and scope `global:users:*`.", + "description": "Revokes the given auth token (device) for the user. User of issued auth token (device) will no longer be logged in and will be required to authenticate again upon next activity.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:update` and scope `global.users:*`.", "tags": ["admin_users"], "summary": "Revoke auth token for user.", "operationId": "revokeAuthToken", @@ -3150,7 +3150,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/DeleteAnnotationsCmd" + "$ref": "#/definitions/MassDeleteAnnotationsCmd" } } ], @@ -4063,6 +4063,137 @@ } } }, + "/datasources/proxy/uid/{datasource_uid}/{datasource_proxy_route}": { + "get": { + "description": "Proxies all calls to the actual data source.", + "tags": ["datasources"], + "summary": "Data source proxy GET calls.", + "operationId": "datasourceProxyGETByUIDcalls", + "parameters": [ + { + "type": "string", + "x-go-name": "DatasourceUID", + "name": "datasource_uid", + "in": "path", + "required": true + }, + { + "type": "string", + "x-go-name": "DatasourceProxyRoute", + "name": "datasource_proxy_route", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, + "post": { + "description": "Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined", + "tags": ["datasources"], + "summary": "Data source proxy POST calls.", + "operationId": "datasourceProxyPOSTByUIDcalls", + "parameters": [ + { + "type": "string", + "x-go-name": "DatasourceUID", + "name": "datasource_uid", + "in": "path", + "required": true + }, + { + "type": "string", + "x-go-name": "DatasourceProxyRoute", + "name": "datasource_proxy_route", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "" + }, + "202": { + "description": "" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, + "delete": { + "description": "Proxies all calls to the actual data source.", + "tags": ["datasources"], + "summary": "Data source proxy DELETE calls.", + "operationId": "datasourceProxyDELETEByUIDcalls", + "parameters": [ + { + "type": "string", + "x-go-name": "DatasourceUID", + "name": "datasource_uid", + "in": "path", + "required": true + }, + { + "type": "string", + "x-go-name": "DatasourceProxyRoute", + "name": "datasource_proxy_route", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/datasources/proxy/{datasource_id}/{datasource_proxy_route}": { "get": { "description": "Proxies all calls to the actual data source.", @@ -4273,7 +4404,7 @@ }, "/datasources/{datasource_id}": { "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).", "tags": ["datasources"], "summary": "Get a single data source by Id.", "operationId": "getDatasourceByID", @@ -4308,7 +4439,7 @@ } }, "put": { - "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).", + "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).", "tags": ["datasources"], "summary": "Update an existing data source.", "operationId": "updateDatasource", @@ -4345,7 +4476,7 @@ } }, "delete": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).", "tags": ["datasources"], "summary": "Delete an existing data source by id.", "operationId": "deleteDatasourceByID", @@ -4494,15 +4625,15 @@ "parameters": [ { "type": "string", - "x-go-name": "PermissionID", - "name": "permissionId", + "x-go-name": "DatasourceID", + "name": "datasource_id", "in": "path", "required": true }, { "type": "string", - "x-go-name": "DatasourceID", - "name": "datasource_id", + "x-go-name": "PermissionID", + "name": "permissionId", "in": "path", "required": true } @@ -6469,6 +6600,145 @@ } } }, + "/provisioning/contact-points": { + "get": { + "tags": ["provisioning"], + "summary": "Get all the contact points.", + "operationId": "RouteGetContactpoints", + "responses": { + "200": { + "description": "Route", + "schema": { + "$ref": "#/definitions/Route" + } + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + }, + "put": { + "consumes": ["application/json"], + "tags": ["provisioning"], + "summary": "Update an existing contact point.", + "operationId": "RoutePutContactpoints", + "parameters": [ + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/EmbeddedContactPoint" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + }, + "post": { + "consumes": ["application/json"], + "tags": ["provisioning"], + "summary": "Create a contact point.", + "operationId": "RoutePostContactpoints", + "parameters": [ + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/EmbeddedContactPoint" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + } + }, + "/provisioning/contact-points/{ID}": { + "delete": { + "consumes": ["application/json"], + "tags": ["provisioning"], + "summary": "Delete a contact point.", + "operationId": "RouteDeleteContactpoints", + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + } + }, + "/provisioning/policies": { + "get": { + "tags": ["provisioning"], + "summary": "Get the notification policy tree.", + "operationId": "RouteGetPolicyTree", + "responses": { + "200": { + "description": "Route", + "schema": { + "$ref": "#/definitions/Route" + } + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + }, + "post": { + "consumes": ["application/json"], + "tags": ["provisioning"], + "summary": "Sets the notification policy tree.", + "operationId": "RoutePostPolicyTree", + "parameters": [ + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/Route" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + } + }, "/recording-rules": { "get": { "tags": ["recording_rules", "enterprise"], @@ -10122,6 +10392,36 @@ }, "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" }, + "AnnotationActions": { + "type": "object", + "properties": { + "canAdd": { + "type": "boolean", + "x-go-name": "CanAdd" + }, + "canDelete": { + "type": "boolean", + "x-go-name": "CanDelete" + }, + "canEdit": { + "type": "boolean", + "x-go-name": "CanEdit" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/api/dtos" + }, + "AnnotationPermission": { + "type": "object", + "properties": { + "dashboard": { + "$ref": "#/definitions/AnnotationActions" + }, + "organization": { + "$ref": "#/definitions/AnnotationActions" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/api/dtos" + }, "ApiKeyDTO": { "type": "object", "properties": { @@ -10240,7 +10540,7 @@ "x-go-name": "ReportLogo" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "CalculateDiffTarget": { "type": "object", @@ -10390,7 +10690,7 @@ "x-go-name": "UserID" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "CreateAlertNotificationCommand": { "type": "object", @@ -10576,7 +10876,7 @@ "x-go-name": "TemplateVars" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "CreateOrgCommand": { "type": "object", @@ -10836,6 +11136,9 @@ "DashboardMeta": { "type": "object", "properties": { + "annotationsPermissions": { + "$ref": "#/definitions/AnnotationPermission" + }, "canAdmin": { "type": "boolean", "x-go-name": "CanAdmin" @@ -11499,32 +11802,6 @@ }, "x-go-package": "github.com/prometheus/alertmanager/timeinterval" }, - "DeleteAnnotationsCmd": { - "type": "object", - "properties": { - "alertId": { - "type": "integer", - "format": "int64", - "x-go-name": "AlertId" - }, - "annotationId": { - "type": "integer", - "format": "int64", - "x-go-name": "AnnotationId" - }, - "dashboardId": { - "type": "integer", - "format": "int64", - "x-go-name": "DashboardId" - }, - "panelId": { - "type": "integer", - "format": "int64", - "x-go-name": "PanelId" - } - }, - "x-go-package": "github.com/grafana/grafana/pkg/api/dtos" - }, "DeleteTokenCommand": { "type": "object", "properties": { @@ -11631,6 +11908,36 @@ }, "x-go-package": "github.com/prometheus/alertmanager/config" }, + "EmbeddedContactPoint": { + "description": "EmbeddedContactPoint is the contact point type that is used\nby grafanas embedded alertmanager implementation.", + "type": "object", + "properties": { + "disableResolveMessage": { + "type": "boolean", + "x-go-name": "DisableResolveMessage" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "provanance": { + "type": "string", + "x-go-name": "Provenance" + }, + "settings": { + "$ref": "#/definitions/Json" + }, + "type": { + "type": "string", + "x-go-name": "Type" + }, + "uid": { + "type": "string", + "x-go-name": "UID" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, "ErrorResponseBody": { "type": "object", "required": ["message"], @@ -12479,18 +12786,18 @@ "x-go-name": "URL" } }, - "x-go-package": "github.com/grafana/grafana/pkg/services/search" + "x-go-package": "github.com/grafana/grafana/pkg/models" }, "HitList": { "type": "array", "items": { "$ref": "#/definitions/Hit" }, - "x-go-package": "github.com/grafana/grafana/pkg/services/search" + "x-go-package": "github.com/grafana/grafana/pkg/models" }, "HitType": { "type": "string", - "x-go-package": "github.com/grafana/grafana/pkg/services/search" + "x-go-package": "github.com/grafana/grafana/pkg/models" }, "HostPort": { "type": "object", @@ -13033,6 +13340,27 @@ }, "x-go-package": "github.com/grafana/grafana/pkg/services/libraryelements" }, + "MassDeleteAnnotationsCmd": { + "type": "object", + "properties": { + "annotationId": { + "type": "integer", + "format": "int64", + "x-go-name": "AnnotationId" + }, + "dashboardId": { + "type": "integer", + "format": "int64", + "x-go-name": "DashboardId" + }, + "panelId": { + "type": "integer", + "format": "int64", + "x-go-name": "PanelId" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/api/dtos" + }, "MatchRegexps": { "type": "object", "title": "MatchRegexps represents a map of Regexp.", @@ -13691,6 +14019,9 @@ "navbar": { "$ref": "#/definitions/NavbarPreference" }, + "queryHistory": { + "$ref": "#/definitions/QueryHistoryPreference" + }, "theme": { "type": "string", "enum": ["light", "dark"], @@ -14153,6 +14484,9 @@ "navbar": { "$ref": "#/definitions/NavbarPreference" }, + "queryHistory": { + "$ref": "#/definitions/QueryHistoryPreference" + }, "theme": { "type": "string", "x-go-name": "Theme" @@ -14250,6 +14584,16 @@ }, "x-go-package": "github.com/grafana/grafana-plugin-sdk-go/backend" }, + "QueryHistoryPreference": { + "type": "object", + "properties": { + "homeTab": { + "type": "string", + "x-go-name": "HomeTab" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/models" + }, "Receiver": { "type": "object", "title": "Receiver configuration provides configuration on how to contact a receiver.", @@ -14427,7 +14771,7 @@ "x-go-name": "UseEmailsFromReport" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "ReportOptionsDTO": { "type": "object", @@ -14444,7 +14788,7 @@ "$ref": "#/definitions/TimeRangeDTO" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "ResponseDetails": { "type": "object", @@ -14900,7 +15244,7 @@ "x-go-name": "WorkdaysOnly" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "SearchTeamQueryResult": { "type": "object", @@ -15020,7 +15364,7 @@ "x-go-name": "UserID" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "SigV4Config": { "description": "SigV4Config is the configuration for signing remote write requests with\nAWS's SigV4 verification process. Empty values will be retrieved using the\nAWS default credentials chain.", @@ -15713,7 +16057,7 @@ "x-go-name": "To" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "Token": { "type": "object", @@ -15814,6 +16158,15 @@ "format": "int64", "x-go-name": "TokenExpiresWarnDays" }, + "trial": { + "type": "boolean", + "x-go-name": "Trial" + }, + "trial_exp": { + "type": "integer", + "format": "int64", + "x-go-name": "TrialExpires" + }, "update_days": { "type": "integer", "format": "int64", @@ -15856,9 +16209,8 @@ "x-go-package": "github.com/grafana/grafana/pkg/api/dtos" }, "URL": { - "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", "type": "object", - "title": "A URL represents a parsed URL (technically, a URI reference).", + "title": "URL is a custom URL type that allows validation at configuration load time.", "properties": { "ForceQuery": { "type": "boolean" @@ -15891,7 +16243,7 @@ "$ref": "#/definitions/Userinfo" } }, - "x-go-package": "net/url" + "x-go-package": "github.com/prometheus/common/config" }, "UpdateAlertNotificationCommand": { "type": "object", @@ -16204,6 +16556,9 @@ "navbar": { "$ref": "#/definitions/NavbarPreference" }, + "queryHistory": { + "$ref": "#/definitions/QueryHistoryPreference" + }, "theme": { "type": "string", "enum": ["light", "dark"], @@ -16792,12 +17147,11 @@ } }, "alertGroups": { + "description": "AlertGroups alert groups", "type": "array", "items": { "$ref": "#/definitions/alertGroup" - }, - "x-go-name": "AlertGroups", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" + } }, "alertStatus": { "description": "AlertStatus alert status", @@ -16900,7 +17254,6 @@ "$ref": "#/definitions/Duration" }, "gettableAlert": { - "description": "GettableAlert gettable alert", "type": "object", "required": ["labels", "annotations", "endsAt", "fingerprint", "receivers", "startsAt", "status", "updatedAt"], "properties": { @@ -16950,7 +17303,9 @@ "format": "date-time", "x-go-name": "UpdatedAt" } - } + }, + "x-go-name": "GettableAlert", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "gettableAlerts": { "description": "GettableAlerts gettable alerts", @@ -17006,12 +17361,11 @@ } }, "gettableSilences": { + "description": "GettableSilences gettable silences", "type": "array", "items": { "$ref": "#/definitions/gettableSilence" - }, - "x-go-name": "GettableSilences", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" + } }, "labelSet": { "description": "LabelSet label set", diff --git a/public/api-spec.json b/public/api-spec.json index 2d5f6cbbf2b..117b03382c3 100644 --- a/public/api-spec.json +++ b/public/api-spec.json @@ -506,6 +506,14 @@ "summary": "Add a user role assignment.", "operationId": "addUserRole", "parameters": [ + { + "type": "integer", + "format": "int64", + "x-go-name": "UserID", + "name": "user_id", + "in": "path", + "required": true + }, { "x-go-name": "Body", "name": "body", @@ -514,14 +522,6 @@ "schema": { "$ref": "#/definitions/AddUserRoleCommand" } - }, - { - "type": "integer", - "format": "int64", - "x-go-name": "UserID", - "name": "user_id", - "in": "path", - "required": true } ], "responses": { @@ -547,6 +547,14 @@ "summary": "Remove a user role assignment.", "operationId": "removeUserRole", "parameters": [ + { + "type": "integer", + "format": "int64", + "x-go-name": "UserID", + "name": "user_id", + "in": "path", + "required": true + }, { "type": "string", "x-go-name": "RoleUID", @@ -560,14 +568,6 @@ "description": "A flag indicating if the assignment is global or not. If set to false, the default org ID of the authenticated user will be used from the request to remove assignment.", "name": "global", "in": "query" - }, - { - "type": "integer", - "format": "int64", - "x-go-name": "UserID", - "name": "user_id", - "in": "path", - "required": true } ], "responses": { @@ -1025,7 +1025,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:delete` and scope `global:users:*`.", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:delete` and scope `global.users:*`.", "tags": ["admin_users"], "summary": "Delete global User.", "operationId": "deleteUser", @@ -1065,7 +1065,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:list` and scope `global:users:*`.", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:list` and scope `global.users:*`.", "tags": ["admin_users"], "summary": "Return a list of all auth tokens (devices) that the user currently have logged in from.", "operationId": "getAuthTokens", @@ -1102,7 +1102,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:disable` and scope `global:users:1` (userIDScope).", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:disable` and scope `global.users:1` (userIDScope).", "tags": ["admin_users"], "summary": "Disable user.", "operationId": "disableUser", @@ -1142,7 +1142,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:enable` and scope `global:users:1` (userIDScope).", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:enable` and scope `global.users:1` (userIDScope).", "tags": ["admin_users"], "summary": "Enable user.", "operationId": "enableUser", @@ -1182,7 +1182,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.logout` and scope `global:users:*`.", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.logout` and scope `global.users:*`.", "tags": ["admin_users"], "summary": "Logout user revokes all auth tokens (devices) for the user. User of issued auth tokens (devices) will no longer be logged in and will be required to authenticate again upon next activity.", "operationId": "logoutUser", @@ -1225,7 +1225,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.password:update` and scope `global:users:*`.", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.password:update` and scope `global.users:*`.", "tags": ["admin_users"], "summary": "Set password for user.", "operationId": "setPassword", @@ -1269,7 +1269,7 @@ }, "/admin/users/{user_id}/permissions": { "put": { - "description": "Only works with Basic Authentication (username and password). See introduction for an explanation.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.permissions:update` and scope `global:users:*`.", + "description": "Only works with Basic Authentication (username and password). See introduction for an explanation.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.permissions:update` and scope `global.users:*`.", "tags": ["admin_users"], "summary": "Set permissions for user.", "operationId": "setPermissions", @@ -1318,7 +1318,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:list` and scope `global:users:1` (userIDScope).", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:list` and scope `global.users:1` (userIDScope).", "tags": ["admin_users"], "summary": "Fetch user quota.", "operationId": "getUserQuota", @@ -1358,7 +1358,7 @@ "basic": [] } ], - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:update` and scope `global:users:1` (userIDScope).", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:update` and scope `global.users:1` (userIDScope).", "tags": ["admin_users"], "summary": "Update user quota.", "operationId": "updateUserQuota", @@ -1414,7 +1414,7 @@ "basic": [] } ], - "description": "Revokes the given auth token (device) for the user. User of issued auth token (device) will no longer be logged in and will be required to authenticate again upon next activity.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:update` and scope `global:users:*`.", + "description": "Revokes the given auth token (device) for the user. User of issued auth token (device) will no longer be logged in and will be required to authenticate again upon next activity.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:update` and scope `global.users:*`.", "tags": ["admin_users"], "summary": "Revoke auth token for user.", "operationId": "revokeAuthToken", @@ -2200,7 +2200,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/DeleteAnnotationsCmd" + "$ref": "#/definitions/MassDeleteAnnotationsCmd" } } ], @@ -3113,6 +3113,137 @@ } } }, + "/datasources/proxy/uid/{datasource_uid}/{datasource_proxy_route}": { + "get": { + "description": "Proxies all calls to the actual data source.", + "tags": ["datasources"], + "summary": "Data source proxy GET calls.", + "operationId": "datasourceProxyGETByUIDcalls", + "parameters": [ + { + "type": "string", + "x-go-name": "DatasourceUID", + "name": "datasource_uid", + "in": "path", + "required": true + }, + { + "type": "string", + "x-go-name": "DatasourceProxyRoute", + "name": "datasource_proxy_route", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, + "post": { + "description": "Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined", + "tags": ["datasources"], + "summary": "Data source proxy POST calls.", + "operationId": "datasourceProxyPOSTByUIDcalls", + "parameters": [ + { + "type": "string", + "x-go-name": "DatasourceUID", + "name": "datasource_uid", + "in": "path", + "required": true + }, + { + "type": "string", + "x-go-name": "DatasourceProxyRoute", + "name": "datasource_proxy_route", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "" + }, + "202": { + "description": "" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, + "delete": { + "description": "Proxies all calls to the actual data source.", + "tags": ["datasources"], + "summary": "Data source proxy DELETE calls.", + "operationId": "datasourceProxyDELETEByUIDcalls", + "parameters": [ + { + "type": "string", + "x-go-name": "DatasourceUID", + "name": "datasource_uid", + "in": "path", + "required": true + }, + { + "type": "string", + "x-go-name": "DatasourceProxyRoute", + "name": "datasource_proxy_route", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/datasources/proxy/{datasource_id}/{datasource_proxy_route}": { "get": { "description": "Proxies all calls to the actual data source.", @@ -3323,7 +3454,7 @@ }, "/datasources/{datasource_id}": { "get": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).", "tags": ["datasources"], "summary": "Get a single data source by Id.", "operationId": "getDatasourceByID", @@ -3358,7 +3489,7 @@ } }, "put": { - "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).", + "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).", "tags": ["datasources"], "summary": "Update an existing data source.", "operationId": "updateDatasource", @@ -3395,7 +3526,7 @@ } }, "delete": { - "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).", + "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).", "tags": ["datasources"], "summary": "Delete an existing data source by id.", "operationId": "deleteDatasourceByID", @@ -3544,15 +3675,15 @@ "parameters": [ { "type": "string", - "x-go-name": "PermissionID", - "name": "permissionId", + "x-go-name": "DatasourceID", + "name": "datasource_id", "in": "path", "required": true }, { "type": "string", - "x-go-name": "DatasourceID", - "name": "datasource_id", + "x-go-name": "PermissionID", + "name": "permissionId", "in": "path", "required": true } @@ -8374,6 +8505,36 @@ }, "x-go-package": "github.com/grafana/grafana/pkg/api/dtos" }, + "AnnotationActions": { + "type": "object", + "properties": { + "canAdd": { + "type": "boolean", + "x-go-name": "CanAdd" + }, + "canDelete": { + "type": "boolean", + "x-go-name": "CanDelete" + }, + "canEdit": { + "type": "boolean", + "x-go-name": "CanEdit" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/api/dtos" + }, + "AnnotationPermission": { + "type": "object", + "properties": { + "dashboard": { + "$ref": "#/definitions/AnnotationActions" + }, + "organization": { + "$ref": "#/definitions/AnnotationActions" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/api/dtos" + }, "ApiKeyDTO": { "type": "object", "properties": { @@ -8421,7 +8582,7 @@ "x-go-name": "ReportLogo" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "CalculateDiffTarget": { "type": "object", @@ -8537,7 +8698,7 @@ "x-go-name": "UserID" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "CreateAlertNotificationCommand": { "type": "object", @@ -8723,7 +8884,7 @@ "x-go-name": "TemplateVars" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "CreateOrgCommand": { "type": "object", @@ -8983,6 +9144,9 @@ "DashboardMeta": { "type": "object", "properties": { + "annotationsPermissions": { + "$ref": "#/definitions/AnnotationPermission" + }, "canAdmin": { "type": "boolean", "x-go-name": "CanAdmin" @@ -9625,32 +9789,6 @@ }, "x-go-package": "github.com/grafana/grafana/pkg/tsdb/legacydata" }, - "DeleteAnnotationsCmd": { - "type": "object", - "properties": { - "alertId": { - "type": "integer", - "format": "int64", - "x-go-name": "AlertId" - }, - "annotationId": { - "type": "integer", - "format": "int64", - "x-go-name": "AnnotationId" - }, - "dashboardId": { - "type": "integer", - "format": "int64", - "x-go-name": "DashboardId" - }, - "panelId": { - "type": "integer", - "format": "int64", - "x-go-name": "PanelId" - } - }, - "x-go-package": "github.com/grafana/grafana/pkg/api/dtos" - }, "DeleteTokenCommand": { "type": "object", "properties": { @@ -9952,18 +10090,18 @@ "x-go-name": "URL" } }, - "x-go-package": "github.com/grafana/grafana/pkg/services/search" + "x-go-package": "github.com/grafana/grafana/pkg/models" }, "HitList": { "type": "array", "items": { "$ref": "#/definitions/Hit" }, - "x-go-package": "github.com/grafana/grafana/pkg/services/search" + "x-go-package": "github.com/grafana/grafana/pkg/models" }, "HitType": { "type": "string", - "x-go-package": "github.com/grafana/grafana/pkg/services/search" + "x-go-package": "github.com/grafana/grafana/pkg/models" }, "ImportDashboardInput": { "type": "object", @@ -10395,6 +10533,27 @@ }, "x-go-package": "github.com/grafana/grafana/pkg/services/libraryelements" }, + "MassDeleteAnnotationsCmd": { + "type": "object", + "properties": { + "annotationId": { + "type": "integer", + "format": "int64", + "x-go-name": "AnnotationId" + }, + "dashboardId": { + "type": "integer", + "format": "int64", + "x-go-name": "DashboardId" + }, + "panelId": { + "type": "integer", + "format": "int64", + "x-go-name": "PanelId" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/api/dtos" + }, "Metadata": { "description": "Metadata contains user accesses for a given resource\nEx: map[string]bool{\"create\":true, \"delete\": true}", "type": "object", @@ -10711,6 +10870,9 @@ "navbar": { "$ref": "#/definitions/NavbarPreference" }, + "queryHistory": { + "$ref": "#/definitions/QueryHistoryPreference" + }, "theme": { "type": "string", "enum": ["light", "dark"], @@ -10857,6 +11019,9 @@ "navbar": { "$ref": "#/definitions/NavbarPreference" }, + "queryHistory": { + "$ref": "#/definitions/QueryHistoryPreference" + }, "theme": { "type": "string", "x-go-name": "Theme" @@ -10901,6 +11066,16 @@ }, "x-go-package": "github.com/grafana/grafana-plugin-sdk-go/backend" }, + "QueryHistoryPreference": { + "type": "object", + "properties": { + "homeTab": { + "type": "string", + "x-go-name": "HomeTab" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/models" + }, "RecordingRuleJSON": { "description": "RecordingRuleJSON is the external representation of a recording rule", "type": "object", @@ -10984,7 +11159,7 @@ "x-go-name": "UseEmailsFromReport" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "ReportOptionsDTO": { "type": "object", @@ -11001,7 +11176,7 @@ "$ref": "#/definitions/TimeRangeDTO" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "Responses": { "description": "The QueryData method the QueryDataHandler method will set the RefId\nproperty on the DataRespones' frames based on these RefIDs.", @@ -11175,7 +11350,7 @@ "x-go-name": "WorkdaysOnly" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "SearchTeamQueryResult": { "type": "object", @@ -11286,7 +11461,7 @@ "x-go-name": "UserID" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "Status": { "type": "object", @@ -11562,7 +11737,7 @@ "x-go-name": "To" } }, - "x-go-package": "github.com/grafana/grafana/pkg/extensions/report" + "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api" }, "Token": { "type": "object", @@ -11663,6 +11838,15 @@ "format": "int64", "x-go-name": "TokenExpiresWarnDays" }, + "trial": { + "type": "boolean", + "x-go-name": "Trial" + }, + "trial_exp": { + "type": "integer", + "format": "int64", + "x-go-name": "TrialExpires" + }, "update_days": { "type": "integer", "format": "int64", @@ -12015,6 +12199,9 @@ "navbar": { "$ref": "#/definitions/NavbarPreference" }, + "queryHistory": { + "$ref": "#/definitions/QueryHistoryPreference" + }, "theme": { "type": "string", "enum": ["light", "dark"], diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index 47c00a004b7..95c0477196a 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -3,7 +3,7 @@ import { Router, Route, Redirect, Switch } from 'react-router-dom'; import { config, locationService, navigationLogger } from '@grafana/runtime'; import { Provider } from 'react-redux'; import { store } from 'app/store/store'; -import { ErrorBoundaryAlert, GlobalStyles, ModalRoot, ModalsProvider } from '@grafana/ui'; +import { ErrorBoundaryAlert, GlobalStyles, ModalRoot, ModalsProvider, PortalContainer } from '@grafana/ui'; import { GrafanaApp } from './app'; import { getAppRoutes } from 'app/routes/routes'; import { ConfigContext, ThemeProvider } from './core/utils/ConfigProvider'; @@ -113,6 +113,7 @@ export class AppWrapper extends React.Component + diff --git a/public/app/plugins/panel/heatmap-new/ColorScale.tsx b/public/app/core/components/ColorScale/ColorScale.tsx similarity index 89% rename from public/app/plugins/panel/heatmap-new/ColorScale.tsx rename to public/app/core/components/ColorScale/ColorScale.tsx index e21f231e94f..31080de26bd 100644 --- a/public/app/plugins/panel/heatmap-new/ColorScale.tsx +++ b/public/app/core/components/ColorScale/ColorScale.tsx @@ -11,6 +11,7 @@ type Props = { // Show a value as string -- when not defined, the raw values will not be shown display?: (v: number) => string; hoverValue?: number; + useStopsPercentage?: boolean; }; type HoverState = { @@ -19,8 +20,9 @@ type HoverState = { }; const LEFT_OFFSET = 2; +const GRADIENT_STOPS = 10; -export const ColorScale = ({ colorPalette, min, max, display, hoverValue }: Props) => { +export const ColorScale = ({ colorPalette, min, max, display, hoverValue, useStopsPercentage }: Props) => { const [colors, setColors] = useState([]); const [scaleHover, setScaleHover] = useState({ isShown: false, value: 0 }); const [percent, setPercent] = useState(null); @@ -29,8 +31,8 @@ export const ColorScale = ({ colorPalette, min, max, display, hoverValue }: Prop const styles = getStyles(theme, colors); useEffect(() => { - setColors(getGradientStops({ colorArray: colorPalette })); - }, [colorPalette]); + setColors(getGradientStops({ colorArray: colorPalette, stops: GRADIENT_STOPS, useStopsPercentage })); + }, [colorPalette, useStopsPercentage]); const onScaleMouseMove = (event: React.MouseEvent) => { const divOffset = event.nativeEvent.offsetX; @@ -79,9 +81,17 @@ export const ColorScale = ({ colorPalette, min, max, display, hoverValue }: Prop ); }; -const getGradientStops = ({ colorArray, stops = 10 }: { colorArray: string[]; stops?: number }): string[] => { +const getGradientStops = ({ + colorArray, + stops, + useStopsPercentage = true, +}: { + colorArray: string[]; + stops: number; + useStopsPercentage?: boolean; +}): string[] => { const colorCount = colorArray.length; - if (colorCount <= 20) { + if (useStopsPercentage && colorCount <= 20) { const incr = (1 / colorCount) * 100; let per = 0; const stops: string[] = []; @@ -114,8 +124,6 @@ const getStyles = (theme: GrafanaTheme2, colors: string[]) => ({ scaleWrapper: css` width: 100%; max-width: 300px; - margin-left: 25px; - padding: 10px 0; font-size: 11px; opacity: 1; `, diff --git a/public/app/core/components/NavBar/Next/NavBarItem.tsx b/public/app/core/components/NavBar/Next/NavBarItem.tsx index fb4731ce773..844686b6e00 100644 --- a/public/app/core/components/NavBar/Next/NavBarItem.tsx +++ b/public/app/core/components/NavBar/Next/NavBarItem.tsx @@ -102,12 +102,13 @@ const NavBarItem = ({ const translationKey = item.id && menuItemTranslations[item.id]; const itemText = translationKey ? i18n._(translationKey) : item.text; const isSection = item.menuItemType === NavMenuItemType.Section; + const icon = item.showIconInNavbar && !isSection ? (item.icon as IconName) : undefined; return ( {element} {state.isOpen && ( - + ({ bottom: 0, flexDirection: 'column', left: 0, - whiteSpace: 'nowrap', paddingTop: theme.spacing(1), marginRight: theme.spacing(1.5), right: 0, @@ -126,6 +125,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ itemList: css({ display: 'grid', gridAutoRows: `minmax(${theme.spacing(6)}, auto)`, + minWidth: '300px', }), menuCollapseIcon: css({ position: 'absolute', @@ -283,7 +283,6 @@ const getNavItemStyles = (theme: GrafanaTheme2) => ({ }), item: css({ padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`, - whiteSpace: 'normal', '&::before': { display: 'none', }, @@ -312,6 +311,7 @@ const getNavItemStyles = (theme: GrafanaTheme2) => ({ width: '100%', }), iconContainer: css({ + display: 'flex', placeContent: 'center', }), savedItemsMenuItemWrapper: css({ @@ -324,7 +324,7 @@ const getNavItemStyles = (theme: GrafanaTheme2) => ({ linkText: css({ fontSize: theme.typography.pxToRem(14), justifySelf: 'start', - paddingLeft: theme.spacing(0.5), + padding: theme.spacing(0.5, 4.25, 0.5, 0.5), }), }); @@ -356,6 +356,7 @@ function CollapsibleNavItem({ onClose(); }} className={styles.collapsibleMenuItem} + elClassName={styles.collapsibleIcon} > {link.img && ( {`${link.text} @@ -392,6 +393,9 @@ const getCollapsibleStyles = (theme: GrafanaTheme2) => ({ height: theme.spacing(6), width: theme.spacing(7), display: 'grid', + }), + collapsibleIcon: css({ + display: 'grid', placeContent: 'center', }), collapsibleSectionWrapper: css({ @@ -403,7 +407,8 @@ const getCollapsibleStyles = (theme: GrafanaTheme2) => ({ collapseWrapper: css({ paddingLeft: theme.spacing(0.5), paddingRight: theme.spacing(4.25), - height: theme.spacing(6), + minHeight: theme.spacing(6), + overflowWrap: 'anywhere', alignItems: 'center', color: theme.colors.text.secondary, '&:hover, &:focus-within': { diff --git a/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx b/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx index eddb0de25b5..60caa5c074f 100644 --- a/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx +++ b/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx @@ -28,11 +28,11 @@ export function NavBarMenuItem({ isMobile = false, }: Props) { const theme = useTheme2(); - const styles = getStyles(theme, isActive); + const styles = getStyles(theme, isActive, Boolean(icon)); const elStyle = cx(styles.element, styleOverrides); - const linkContent = (
    + {icon && } {text} {target === '_blank' && ( @@ -76,7 +76,7 @@ export function NavBarMenuItem({ NavBarMenuItem.displayName = 'NavBarMenuItem'; -const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ +const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], hasIcon: boolean) => ({ linkContent: css({ display: 'grid', placeItems: 'center', @@ -96,9 +96,9 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ flex: 1, fontSize: 'inherit', height: '100%', - padding: '5px 12px 5px 10px', + overflowWrap: 'anywhere', + padding: !hasIcon ? `${theme.spacing(0.5, 2)}` : '5px 12px 5px 10px', textAlign: 'left', - whiteSpace: 'nowrap', '&:hover, &:focus-visible': { backgroundColor: theme.colors.action.hover, color: theme.colors.text.primary, diff --git a/public/app/core/components/Select/DashboardPicker.tsx b/public/app/core/components/Select/DashboardPicker.tsx new file mode 100644 index 00000000000..cc74304e69f --- /dev/null +++ b/public/app/core/components/Select/DashboardPicker.tsx @@ -0,0 +1,89 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import debounce from 'debounce-promise'; +import { SelectableValue } from '@grafana/data'; +import { DashboardSearchHit } from 'app/features/search/types'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { AsyncSelectProps, AsyncSelect } from '@grafana/ui'; +import { DashboardDTO } from 'app/types'; + +interface Props + extends Omit, 'value' | 'onChange' | 'loadOptions' | 'menuShouldPortal'> { + value?: DashboardPickerDTO['uid']; + onChange?: (value?: DashboardPickerDTO) => void; +} + +export type DashboardPickerDTO = Pick & + Pick; + +const formatLabel = (folderTitle = 'General', dashboardTitle: string) => `${folderTitle}/${dashboardTitle}`; + +const getDashboards = debounce((query = ''): Promise>> => { + return backendSrv.search({ type: 'dash-db', query, limit: 100 }).then((result: DashboardSearchHit[]) => { + return result.map((item: DashboardSearchHit) => ({ + value: { + // dashboards uid here is always defined as this endpoint does not return the default home dashboard + uid: item.uid!, + title: item.title, + folderTitle: item.folderTitle, + folderUid: item.folderUid, + }, + label: formatLabel(item?.folderTitle, item.title), + })); + }); +}, 300); + +// TODO: this component should provide a way to apply different filters to the search APIs +export const DashboardPicker = ({ + value, + onChange, + placeholder = 'Select dashboard', + noOptionsMessage = 'No dashboards found', + ...props +}: Props) => { + const [current, setCurrent] = useState>(); + + // This is required because the async select does not match the raw uid value + // We can not use a simple Select because the dashboard search should not return *everything* + useEffect(() => { + if (!value || value === current?.value?.uid) { + return; + } + + (async () => { + // value was manually changed from outside or we are rendering for the first time. + // We need to fetch dashboard information. + const res = await backendSrv.getDashboardByUid(value); + setCurrent({ + value: { + uid: res.dashboard.uid, + title: res.dashboard.title, + folderTitle: res.meta.folderTitle, + folderUid: res.meta.folderUid, + }, + label: formatLabel(res.meta?.folderTitle, res.dashboard.title), + }); + })(); + // we don't need to rerun this effect every time `current` changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + const onPicked = useCallback( + (sel: SelectableValue) => { + setCurrent(sel); + onChange?.(sel?.value); + }, + [onChange, setCurrent] + ); + + return ( + + ); +}; diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx index c69fd5ac889..c419f6a8db7 100644 --- a/public/app/core/components/TagFilter/TagFilter.tsx +++ b/public/app/core/components/TagFilter/TagFilter.tsx @@ -1,11 +1,9 @@ -// Libraries -import React, { FC } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { css } from '@emotion/css'; import { components } from 'react-select'; -import debounce from 'debounce-promise'; -import { stylesFactory, useTheme, Icon, AsyncMultiSelect } from '@grafana/ui'; -import { escapeStringForRegex, GrafanaTheme } from '@grafana/data'; -// Components +import { Icon, MultiSelect, useStyles2 } from '@grafana/ui'; +import { escapeStringForRegex, GrafanaTheme2 } from '@grafana/data'; + import { TagOption } from './TagOption'; import { TagBadge } from './TagBadge'; @@ -14,6 +12,12 @@ export interface TermCount { count: number; } +interface TagSelectOption { + value: string; + label: string; + count: number; +} + export interface Props { allowCustomValue?: boolean; formatCreateLabel?: (input: string) => string; @@ -45,29 +49,70 @@ export const TagFilter: FC = ({ tags, width, }) => { - const theme = useTheme(); - const styles = getStyles(theme); + const styles = useStyles2(getStyles); - const onLoadOptions = async (query: string) => { + const currentlySelectedTags = tags.map((tag) => ({ value: tag, label: tag, count: 0 })); + const [options, setOptions] = useState(currentlySelectedTags); + const [isLoading, setIsLoading] = useState(false); + const [previousTags, setPreviousTags] = useState(tags); + + // Necessary to force re-render to keep tag options up to date / relevant + const selectKey = useMemo(() => tags.join(), [tags]); + + const onLoadOptions = useCallback(async () => { const options = await tagOptions(); - return options.map((option) => ({ - value: option.term, - label: option.term, - count: option.count, - })); - }; + return options.map((option) => { + if (tags.includes(option.term)) { + return { + value: option.term, + label: option.term, + count: 0, + }; + } else { + return { + value: option.term, + label: option.term, + count: option.count, + }; + } + }); + }, [tagOptions, tags]); - const debouncedLoadOptions = debounce(onLoadOptions, 300); + const onFocus = useCallback(async () => { + setIsLoading(true); + const results = await onLoadOptions(); + setOptions(results); + setIsLoading(false); + }, [onLoadOptions]); + + useEffect(() => { + // Load options when tag is selected externally + if (tags.length > 0 && options.length === 0) { + onFocus(); + } + }, [onFocus, options.length, tags.length]); + + useEffect(() => { + // Update selected tags to not include (counts) when selected externally + if (tags !== previousTags) { + setPreviousTags(tags); + onFocus(); + } + }, [onFocus, previousTags, tags]); const onTagChange = (newTags: any[]) => { // On remove with 1 item returns null, so we need to make sure it's an empty array in that case // https://github.com/JedWatson/react-select/issues/3632 + newTags.forEach((tag) => (tag.count = 0)); + onChange((newTags || []).map((tag) => tag.value)); }; - const value = tags.map((tag) => ({ value: tag, label: tag, count: 0 })); - const selectOptions = { + key: selectKey, + onFocus, + isLoading, + options, allowCreateWhileLoading: true, allowCustomValue, formatCreateLabel, @@ -77,12 +122,11 @@ export const TagFilter: FC = ({ getOptionValue: (i: any) => i.value, inputId, isMulti: true, - loadOptions: debouncedLoadOptions, loadingMessage: 'Loading...', noOptionsMessage: 'No tags found', onChange: onTagChange, placeholder, - value, + value: currentlySelectedTags, width, components: { Option: TagOption, @@ -109,37 +153,35 @@ export const TagFilter: FC = ({ Clear tags )} - } aria-label="Tag filter" /> + } aria-label="Tag filter" />
    ); }; TagFilter.displayName = 'TagFilter'; -const getStyles = stylesFactory((theme: GrafanaTheme) => { - return { - tagFilter: css` - position: relative; - min-width: 180px; - flex-grow: 1; +const getStyles = (theme: GrafanaTheme2) => ({ + tagFilter: css` + position: relative; + min-width: 180px; + flex-grow: 1; - .label-tag { - margin-left: 6px; - cursor: pointer; - } - `, - clear: css` - text-decoration: underline; - font-size: 12px; - position: absolute; - top: -22px; - right: 0; + .label-tag { + margin-left: 6px; cursor: pointer; - color: ${theme.colors.textWeak}; + } + `, + clear: css` + text-decoration: underline; + font-size: 12px; + position: absolute; + top: -22px; + right: 0; + cursor: pointer; + color: ${theme.colors.text.secondary}; - &:hover { - color: ${theme.colors.textStrong}; - } - `, - }; + &:hover { + color: ${theme.colors.text.primary}; + } + `, }); diff --git a/public/app/core/components/editors/DashboardPicker.tsx b/public/app/core/components/editors/DashboardPicker.tsx index f806d44b300..51192d9c0f8 100644 --- a/public/app/core/components/editors/DashboardPicker.tsx +++ b/public/app/core/components/editors/DashboardPicker.tsx @@ -1,63 +1,28 @@ -import React, { FC, useCallback, useState } from 'react'; -import debounce from 'debounce-promise'; +import React, { FC, useCallback } from 'react'; import { SelectableValue, StandardEditorProps } from '@grafana/data'; -import { DashboardSearchHit } from 'app/features/search/types'; -import { backendSrv } from 'app/core/services/backend_srv'; -import { AsyncSelect } from '@grafana/ui'; -import { useAsync } from 'react-use'; +import { DashboardPicker as BasePicker, DashboardPickerDTO } from 'app/core/components/Select/DashboardPicker'; export interface DashboardPickerOptions { placeholder?: string; isClearable?: boolean; } -const getDashboards = (query = '') => { - return backendSrv.search({ type: 'dash-db', query, limit: 100 }).then((result: DashboardSearchHit[]) => { - return result.map((item: DashboardSearchHit) => ({ - value: item.uid, - label: `${item?.folderTitle ?? 'General'}/${item.title}`, - })); - }); -}; - /** This will return the item UID */ -export const DashboardPicker: FC> = ({ value, onChange, item }) => { - const [current, setCurrent] = useState>(); - - // This is required because the async select does not match the raw uid value - // We can not use a simple Select because the dashboard search should not return *everything* - useAsync(async () => { - if (!value) { - setCurrent(undefined); - return; - } - const res = await backendSrv.getDashboardByUid(value); - setCurrent({ - value: res.dashboard.uid, - label: `${res.meta?.folderTitle ?? 'General'}/${res.dashboard.title}`, - }); - return undefined; - }, [value]); +export const DashboardPicker: FC> = ({ + value, + onChange, + item, +}) => { + const { placeholder, isClearable } = item?.settings ?? {}; const onPicked = useCallback( - (sel: SelectableValue) => { - onChange(sel?.value); + (sel?: SelectableValue) => { + onChange(sel?.value?.uid); }, [onChange] ); - const debouncedSearch = debounce(getDashboards, 300); - const { placeholder, isClearable } = item?.settings ?? {}; return ( - + ); }; diff --git a/public/app/core/config.ts b/public/app/core/config.ts index 3ff14355734..fcd399c9d30 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -18,5 +18,5 @@ export const updateConfig = (update: Partial) => { }; }; -// The `enable_alpha` flag is no exposed directly, this is equivolant +// The `enable_alpha` flag is not exposed directly, this is equivalent export const hasAlphaPanels = Boolean(config.panels?.debug?.state === PluginState.alpha); diff --git a/public/app/core/history/RichHistoryLocalStorage.test.ts b/public/app/core/history/RichHistoryLocalStorage.test.ts index 1bbc7f7b30f..747b6720935 100644 --- a/public/app/core/history/RichHistoryLocalStorage.test.ts +++ b/public/app/core/history/RichHistoryLocalStorage.test.ts @@ -5,6 +5,7 @@ import { DataQuery } from '@grafana/data'; import { afterEach, beforeEach } from '../../../test/lib/common'; import { RichHistoryStorageWarning } from './RichHistoryStorage'; import { backendSrv } from '../services/backend_srv'; +import { RichHistorySettings } from '../utils/richHistoryTypes'; const key = 'grafana.explore.richHistory'; @@ -99,6 +100,19 @@ describe('RichHistoryLocalStorage', () => { expect(await storage.getRichHistory()).toEqual([]); expect(store.getObject(key)).toEqual([]); }); + + it('should save and read settings', async () => { + const settings: RichHistorySettings = { + retentionPeriod: 2, + starredTabAsFirstTab: true, + activeDatasourceOnly: true, + lastUsedDatasourceFilters: [{ value: 'foobar' }], + }; + await storage.updateSettings(settings); + const storageSettings = storage.getSettings(); + + expect(settings).toMatchObject(storageSettings); + }); }); describe('retention policy and max limits', () => { diff --git a/public/app/core/history/RichHistoryLocalStorage.ts b/public/app/core/history/RichHistoryLocalStorage.ts index b4d05c0c931..ca98b0c6a45 100644 --- a/public/app/core/history/RichHistoryLocalStorage.ts +++ b/public/app/core/history/RichHistoryLocalStorage.ts @@ -5,6 +5,7 @@ import { DataQuery } from '@grafana/data'; import { find, isEqual, omit } from 'lodash'; import { createRetentionPeriodBoundary, RICH_HISTORY_SETTING_KEYS } from './richHistoryLocalStorageUtils'; import { fromDTO, toDTO } from './localStorageConverter'; +import { RichHistorySettings } from '../utils/richHistoryTypes'; export const RICH_HISTORY_KEY = 'grafana.explore.richHistory'; export const MAX_HISTORY_ITEMS = 10000; @@ -101,6 +102,22 @@ export default class RichHistoryLocalStorage implements RichHistoryStorage { async updateComment(id: string, comment: string) { return updateRichHistory(id, (richHistoryDTO) => (richHistoryDTO.comment = comment)); } + + async getSettings() { + return { + activeDatasourceOnly: store.getObject(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, false), + retentionPeriod: store.getObject(RICH_HISTORY_SETTING_KEYS.retentionPeriod, 7), + starredTabAsFirstTab: store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false), + lastUsedDatasourceFilters: store.getObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, []), + }; + } + + async updateSettings(settings: RichHistorySettings) { + store.set(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, settings.activeDatasourceOnly); + store.set(RICH_HISTORY_SETTING_KEYS.retentionPeriod, settings.retentionPeriod); + store.set(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, settings.starredTabAsFirstTab); + store.setObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, settings.lastUsedDatasourceFilters); + } } function updateRichHistory( diff --git a/public/app/core/history/RichHistoryStorage.ts b/public/app/core/history/RichHistoryStorage.ts index 4242e13658f..5e833c73f44 100644 --- a/public/app/core/history/RichHistoryStorage.ts +++ b/public/app/core/history/RichHistoryStorage.ts @@ -1,4 +1,5 @@ import { RichHistoryQuery } from '../../types'; +import { RichHistorySettings } from '../utils/richHistoryTypes'; /** * Errors are used when the operation on Rich History was not successful. @@ -44,4 +45,7 @@ export default interface RichHistoryStorage { deleteRichHistory(id: string): Promise; updateStarred(id: string, starred: boolean): Promise; updateComment(id: string, comment: string | undefined): Promise; + + getSettings(): Promise; + updateSettings(settings: RichHistorySettings): Promise; } diff --git a/public/app/core/logs_model.test.ts b/public/app/core/logs_model.test.ts index 06d81158942..e09b6205849 100644 --- a/public/app/core/logs_model.test.ts +++ b/public/app/core/logs_model.test.ts @@ -332,7 +332,10 @@ describe('dataFrameToLogsModel', () => { expect(logsModel.meta).toHaveLength(2); expect(logsModel.meta![0]).toMatchObject({ label: COMMON_LABELS, - value: series[0].fields[1].labels, + value: { + filename: '/var/log/grafana/grafana.log', + job: 'grafana', + }, kind: LogsMetaKind.LabelsMap, }); expect(logsModel.meta![1]).toMatchObject({ @@ -342,6 +345,147 @@ describe('dataFrameToLogsModel', () => { }); }); + it('given one series with labels-field should return expected logs model', () => { + const series: DataFrame[] = [ + new MutableDataFrame({ + fields: [ + { + name: 'labels', + type: FieldType.other, + values: [ + { + filename: '/var/log/grafana/grafana.log', + job: 'grafana', + }, + { + filename: '/var/log/grafana/grafana.log', + job: 'grafana', + }, + ], + }, + { + name: 'time', + type: FieldType.time, + values: ['2019-04-26T09:28:11.352440161Z', '2019-04-26T14:42:50.991981292Z'], + }, + { + name: 'message', + type: FieldType.string, + values: [ + 't=2019-04-26T11:05:28+0200 lvl=info msg="Initializing DatasourceCacheService" logger=server', + 't=2019-04-26T16:42:50+0200 lvl=eror msg="new token…t unhashed token=56d9fdc5c8b7400bd51b060eea8ca9d7', + ], + }, + { + name: 'id', + type: FieldType.string, + values: ['foo', 'bar'], + }, + ], + meta: { + limit: 1000, + }, + }), + ]; + const logsModel = dataFrameToLogsModel(series, 1); + expect(logsModel.hasUniqueLabels).toBeFalsy(); + expect(logsModel.rows).toHaveLength(2); + expect(logsModel.rows).toMatchObject([ + { + entry: 't=2019-04-26T11:05:28+0200 lvl=info msg="Initializing DatasourceCacheService" logger=server', + labels: { filename: '/var/log/grafana/grafana.log', job: 'grafana' }, + logLevel: 'info', + uniqueLabels: {}, + uid: 'foo', + }, + { + entry: 't=2019-04-26T16:42:50+0200 lvl=eror msg="new token…t unhashed token=56d9fdc5c8b7400bd51b060eea8ca9d7', + labels: { filename: '/var/log/grafana/grafana.log', job: 'grafana' }, + logLevel: 'error', + uniqueLabels: {}, + uid: 'bar', + }, + ]); + + expect(logsModel.series).toHaveLength(2); + expect(logsModel.series).toMatchObject([ + { + name: 'info', + fields: [ + { type: 'time', values: new ArrayVector([1556270891000, 1556289770000]) }, + { type: 'number', values: new ArrayVector([1, 0]) }, + ], + }, + { + name: 'error', + fields: [ + { type: 'time', values: new ArrayVector([1556289770000]) }, + { type: 'number', values: new ArrayVector([1]) }, + ], + }, + ]); + expect(logsModel.meta).toHaveLength(2); + expect(logsModel.meta![0]).toMatchObject({ + label: COMMON_LABELS, + value: { filename: '/var/log/grafana/grafana.log', job: 'grafana' }, + kind: LogsMetaKind.LabelsMap, + }); + expect(logsModel.meta![1]).toMatchObject({ + label: LIMIT_LABEL, + value: `1000 (2 returned)`, + kind: LogsMetaKind.String, + }); + }); + + it('given one series with labels-field it should work regardless the label-fields position', () => { + const labels = { + name: 'labels', + type: FieldType.other, + values: [ + { + node: 'first', + mode: 'slow', + }, + ], + }; + + const time = { + name: 'time', + type: FieldType.time, + values: ['2019-04-26T09:28:11.352440161Z'], + }; + + const line = { + name: 'line', + type: FieldType.string, + values: ['line1'], + }; + + const frame1 = new MutableDataFrame({ + fields: [labels, time, line], + }); + + const frame2 = new MutableDataFrame({ + fields: [time, labels, line], + }); + + const frame3 = new MutableDataFrame({ + fields: [time, line, labels], + }); + + const logsModel1 = dataFrameToLogsModel([frame1], 1); + expect(logsModel1.rows).toHaveLength(1); + expect(logsModel1.rows[0].labels).toStrictEqual({ mode: 'slow', node: 'first' }); + + const logsModel2 = dataFrameToLogsModel([frame2], 1); + expect(logsModel2.rows).toHaveLength(1); + expect(logsModel2.rows[0].labels).toStrictEqual({ mode: 'slow', node: 'first' }); + + const logsModel3 = dataFrameToLogsModel([frame3], 1); + expect(logsModel3.rows).toHaveLength(1); + expect(logsModel3.rows[0].labels).toStrictEqual({ mode: 'slow', node: 'first' }); + }); + it('given one series with error should return expected logs model', () => { const series: DataFrame[] = [ new MutableDataFrame({ diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index 3bee4f2b5d3..36316d6a85e 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -303,11 +303,38 @@ interface LogFields { timeField: FieldWithIndex; stringField: FieldWithIndex; + labelsField?: FieldWithIndex; timeNanosecondField?: FieldWithIndex; logLevelField?: FieldWithIndex; idField?: FieldWithIndex; } +function getAllLabels(fields: LogFields): Labels[] { + // there are two types of dataframes we handle: + // 1. labels are in a separate field (more efficient when labels change by every log-row) + // 2. labels are in in the string-field's `.labels` attribute + + const { stringField, labelsField } = fields; + + const fieldLabels = stringField.labels !== undefined ? [stringField.labels] : []; + + const labelsFieldLabels: Labels[] = labelsField !== undefined ? labelsField.values.toArray() : []; + + return [...fieldLabels, ...labelsFieldLabels]; +} + +function getLabelsForFrameRow(fields: LogFields, index: number): Labels { + // there are two types of dataframes we handle. + // either labels-on-the-string-field, or labels-in-the-labels-field + + const { stringField, labelsField } = fields; + + return { + ...stringField.labels, + ...labelsField?.values.get(index), + }; +} + /** * Converts dataFrames into LogsModel. This involves merging them into one list, sorting them and computing metadata * like common labels. @@ -316,7 +343,7 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi if (logSeries.length === 0) { return undefined; } - const allLabels: Labels[] = []; + const allLabels: Labels[][] = []; // Find the fields we care about and collect all labels let allSeries: LogFields[] = []; @@ -330,43 +357,39 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi const fieldCache = new FieldCache(series); const stringField = fieldCache.getFirstFieldOfType(FieldType.string); const timeField = fieldCache.getFirstFieldOfType(FieldType.time); + const labelsField = fieldCache.getFieldByName('labels'); if (stringField !== undefined && timeField !== undefined) { - if (stringField?.labels) { - allLabels.push(stringField.labels); - } - - allSeries.push({ + const info = { series, timeField, + labelsField, timeNanosecondField: fieldCache.hasFieldWithNameAndType('tsNs', FieldType.time) ? fieldCache.getFieldByName('tsNs') : undefined, stringField, logLevelField: fieldCache.getFieldByName('level'), idField: getIdField(fieldCache), - }); + }; + + allSeries.push(info); + + const labels = getAllLabels(info); + if (labels.length > 0) { + allLabels.push(labels); + } } }); } - const commonLabels = allLabels.length > 0 ? findCommonLabels(allLabels) : {}; + const flatAllLabels = allLabels.flat(); + const commonLabels = flatAllLabels.length > 0 ? findCommonLabels(flatAllLabels) : {}; const rows: LogRowModel[] = []; let hasUniqueLabels = false; for (const info of allSeries) { const { timeField, timeNanosecondField, stringField, logLevelField, idField, series } = info; - const labels = stringField.labels; - const uniqueLabels = findUniqueLabels(labels, commonLabels); - if (Object.keys(uniqueLabels).length > 0) { - hasUniqueLabels = true; - } - - let seriesLogLevel: LogLevel | undefined = undefined; - if (labels && Object.keys(labels).indexOf('level') !== -1) { - seriesLogLevel = getLogLevelFromKey(labels['level']); - } for (let j = 0; j < series.length; j++) { const ts = timeField.values.get(j); @@ -386,14 +409,20 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi const searchWords = series.meta && series.meta.searchWords ? series.meta.searchWords : []; const entry = hasAnsi ? ansicolor.strip(message) : message; + const labels = getLabelsForFrameRow(info, j); + const uniqueLabels = findUniqueLabels(labels, commonLabels); + if (Object.keys(uniqueLabels).length > 0) { + hasUniqueLabels = true; + } + let logLevel = LogLevel.unknown; - if (logLevelField && logLevelField.values.get(j)) { - logLevel = getLogLevelFromKey(logLevelField.values.get(j)); - } else if (seriesLogLevel) { - logLevel = seriesLogLevel; + const logLevelKey = (logLevelField && logLevelField.values.get(j)) || (labels && labels['level']); + if (logLevelKey) { + logLevel = getLogLevelFromKey(logLevelKey); } else { logLevel = getLogLevel(entry); } + rows.push({ entryFieldIndex: stringField.index, rowIndex: j, @@ -410,7 +439,7 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi searchWords, entry, raw: message, - labels: stringField.labels || {}, + labels: labels || {}, uid: idField ? idField.values.get(j) : j.toString(), }); } diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index d26fcbd4905..72e7c92b993 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -18,7 +18,7 @@ import { AppEvents, DataQueryErrorType } from '@grafana/data'; import appEvents from 'app/core/app_events'; import { getConfig } from 'app/core/config'; import { DashboardSearchHit } from 'app/features/search/types'; -import { FolderDTO } from 'app/types'; +import { DashboardDTO, FolderDTO } from 'app/types'; import { ContextSrv, contextSrv } from './context_srv'; import { isContentTypeApplicationJson, @@ -423,7 +423,7 @@ export class BackendSrv implements BackendService { } getDashboardByUid(uid: string) { - return this.get(`/api/dashboards/uid/${uid}`); + return this.get(`/api/dashboards/uid/${uid}`); } getFolderByUid(uid: string) { diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts index feebda044e3..5e08a4fa046 100644 --- a/public/app/core/utils/richHistory.ts +++ b/public/app/core/utils/richHistory.ts @@ -23,7 +23,7 @@ import { filterQueriesByTime, sortQueries, } from 'app/core/history/richHistoryLocalStorageUtils'; -import { SortOrder } from './richHistoryTypes'; +import { RichHistorySettings, SortOrder } from './richHistoryTypes'; export { SortOrder }; @@ -86,6 +86,14 @@ export async function getRichHistory(): Promise { return await getRichHistoryStorage().getRichHistory(); } +export async function updateRichHistorySettings(settings: RichHistorySettings): Promise { + await getRichHistoryStorage().updateSettings(settings); +} + +export async function getRichHistorySettings(): Promise { + return await getRichHistoryStorage().getSettings(); +} + export async function deleteAllFromRichHistory(): Promise { return getRichHistoryStorage().deleteAll(); } diff --git a/public/app/core/utils/richHistoryTypes.ts b/public/app/core/utils/richHistoryTypes.ts index 2322258de13..0f2030069ac 100644 --- a/public/app/core/utils/richHistoryTypes.ts +++ b/public/app/core/utils/richHistoryTypes.ts @@ -1,6 +1,23 @@ +import { SelectableValue } from '@grafana/data'; + export enum SortOrder { Descending = 'Descending', Ascending = 'Ascending', DatasourceAZ = 'Datasource A-Z', DatasourceZA = 'Datasource Z-A', } + +export interface RichHistorySettings { + retentionPeriod: number; + starredTabAsFirstTab: boolean; + activeDatasourceOnly: boolean; + lastUsedDatasourceFilters: SelectableValue[]; +} + +export type RichHistorySearchFilters = { + search: string; + sortOrder: SortOrder; + datasourceFilters: SelectableValue[]; + from: number; + to: number; +}; diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 1e64912cc57..d8eaa9cd338 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -1,11 +1,11 @@ -import React from 'react'; -import { Redirect } from 'react-router-dom'; +import { OrgRole } from '@grafana/data'; import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; import { config } from 'app/core/config'; import { RouteDescriptor } from 'app/core/navigation/types'; -import { uniq } from 'lodash'; -import { contextSrv } from 'app/core/core'; import { AccessControlAction } from 'app/types'; +import { uniq } from 'lodash'; +import React from 'react'; +import { Redirect } from 'react-router-dom'; import { evaluateAccess } from './unified/utils/access-control'; const commonRoutes: RouteDescriptor[] = [ @@ -90,17 +90,20 @@ const unifiedRoutes: RouteDescriptor[] = [ ...commonRoutes, { path: '/alerting/list', + roles: evaluateAccess( + [AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead], + [OrgRole.Viewer, OrgRole.Editor, OrgRole.Admin] + ), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertRuleListIndex" */ 'app/features/alerting/unified/RuleList') ), }, { path: '/alerting/routes', - roles: () => - contextSrv.evaluatePermission(config.unifiedAlertingEnabled ? () => ['Editor', 'Admin'] : () => [], [ - AccessControlAction.AlertingNotificationsRead, - AccessControlAction.AlertingNotificationsExternalRead, - ]), + roles: evaluateAccess( + [AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsExternalRead], + [OrgRole.Editor, OrgRole.Admin] + ), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertAmRoutes" */ 'app/features/alerting/unified/AmRoutes') ), @@ -214,6 +217,10 @@ const unifiedRoutes: RouteDescriptor[] = [ }, { path: '/alerting/groups/', + roles: evaluateAccess( + [AccessControlAction.AlertingInstanceRead, AccessControlAction.AlertingInstancesExternalRead], + [OrgRole.Viewer, OrgRole.Editor, OrgRole.Admin] + ), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertGroups" */ 'app/features/alerting/unified/AlertGroups') ), @@ -221,6 +228,10 @@ const unifiedRoutes: RouteDescriptor[] = [ { path: '/alerting/new', pageClass: 'page-alerting', + roles: evaluateAccess( + [AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite], + [OrgRole.Editor, OrgRole.Admin] + ), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor') ), @@ -228,6 +239,10 @@ const unifiedRoutes: RouteDescriptor[] = [ { path: '/alerting/:id/edit', pageClass: 'page-alerting', + roles: evaluateAccess( + [AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleExternalWrite], + [OrgRole.Editor, OrgRole.Admin] + ), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor') ), @@ -235,6 +250,10 @@ const unifiedRoutes: RouteDescriptor[] = [ { path: '/alerting/:sourceName/:id/view', pageClass: 'page-alerting', + roles: evaluateAccess( + [AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead], + [OrgRole.Viewer, OrgRole.Editor, OrgRole.Admin] + ), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertingRule"*/ 'app/features/alerting/unified/RuleViewer') ), @@ -242,6 +261,10 @@ const unifiedRoutes: RouteDescriptor[] = [ { path: '/alerting/:sourceName/:name/find', pageClass: 'page-alerting', + roles: evaluateAccess( + [AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead], + [OrgRole.Viewer, OrgRole.Editor, OrgRole.Admin] + ), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertingRedirectToRule"*/ 'app/features/alerting/unified/RedirectToRuleViewer') ), diff --git a/public/app/features/alerting/unified/AlertGroups.test.tsx b/public/app/features/alerting/unified/AlertGroups.test.tsx index bbd5450e400..40fecd2d7bf 100644 --- a/public/app/features/alerting/unified/AlertGroups.test.tsx +++ b/public/app/features/alerting/unified/AlertGroups.test.tsx @@ -16,6 +16,7 @@ jest.mock('app/core/services/context_srv', () => ({ contextSrv: { isEditor: true, hasAccess: () => true, + hasPermission: () => true, }, })); const mocks = { diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.tsx index 7460af80219..6832a62b200 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.tsx @@ -7,6 +7,8 @@ import { NewRuleFromPanelButton } from './components/panel-alerts-tab/NewRuleFro import { RulesTable } from './components/rules/RulesTable'; import { usePanelCombinedRules } from './hooks/usePanelCombinedRules'; import { selectors } from '@grafana/e2e-selectors'; +import { getRulesPermissions } from './utils/access-control'; +import { contextSrv } from 'app/core/services/context_srv'; interface Props { dashboard: DashboardModel; @@ -20,6 +22,8 @@ export const PanelAlertTabContent: FC = ({ dashboard, panel }) => { panel, poll: true, }); + const permissions = getRulesPermissions('grafana'); + const canCreateRules = contextSrv.hasPermission(permissions.create); const alert = errors.length ? ( @@ -44,7 +48,7 @@ export const PanelAlertTabContent: FC = ({ dashboard, panel }) => {
    {alert} - {!!dashboard.meta.canSave && ( + {!!dashboard.meta.canSave && canCreateRules && ( )}
    @@ -58,7 +62,7 @@ export const PanelAlertTabContent: FC = ({ dashboard, panel }) => { {!!dashboard.uid && ( <>

    There are no alert rules linked to this panel.

    - {!!dashboard.meta.canSave && } + {!!dashboard.meta.canSave && canCreateRules && } )} {!dashboard.uid && !!dashboard.meta.canSave && ( diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx index 1b904541fdc..105e078bc4a 100644 --- a/public/app/features/alerting/unified/RuleViewer.tsx +++ b/public/app/features/alerting/unified/RuleViewer.tsx @@ -1,36 +1,37 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useObservable } from 'react-use'; import { css } from '@emotion/css'; import { GrafanaTheme2, LoadingState, PanelData } from '@grafana/data'; import { - withErrorBoundary, - useStyles2, Alert, + Button, + Icon, LoadingPlaceholder, PanelChromeLoadingIndicator, - Icon, - Button, + useStyles2, VerticalGroup, + withErrorBoundary, } from '@grafana/ui'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { AlertingQueryRunner } from './state/AlertingQueryRunner'; -import { useCombinedRule } from './hooks/useCombinedRule'; -import { alertRuleToQueries } from './utils/query'; -import { RuleState } from './components/rules/RuleState'; -import { getRulesSourceByName } from './utils/datasource'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useObservable } from 'react-use'; +import { AlertQuery } from '../../../types/unified-alerting-dto'; +import { AlertLabels } from './components/AlertLabels'; import { DetailsField } from './components/DetailsField'; -import { RuleHealth } from './components/rules/RuleHealth'; +import { RuleViewerLayout, RuleViewerLayoutContent } from './components/rule-viewer/RuleViewerLayout'; import { RuleViewerVisualization } from './components/rule-viewer/RuleViewerVisualization'; import { RuleDetailsActionButtons } from './components/rules/RuleDetailsActionButtons'; -import { RuleDetailsMatchingInstances } from './components/rules/RuleDetailsMatchingInstances'; -import { RuleDetailsDataSources } from './components/rules/RuleDetailsDataSources'; -import { RuleViewerLayout, RuleViewerLayoutContent } from './components/rule-viewer/RuleViewerLayout'; -import { AlertLabels } from './components/AlertLabels'; -import { RuleDetailsExpression } from './components/rules/RuleDetailsExpression'; import { RuleDetailsAnnotations } from './components/rules/RuleDetailsAnnotations'; -import * as ruleId from './utils/rule-id'; -import { AlertQuery } from '../../../types/unified-alerting-dto'; +import { RuleDetailsDataSources } from './components/rules/RuleDetailsDataSources'; +import { RuleDetailsExpression } from './components/rules/RuleDetailsExpression'; import { RuleDetailsFederatedSources } from './components/rules/RuleDetailsFederatedSources'; +import { RuleDetailsMatchingInstances } from './components/rules/RuleDetailsMatchingInstances'; +import { RuleHealth } from './components/rules/RuleHealth'; +import { RuleState } from './components/rules/RuleState'; +import { useAlertQueriesStatus } from './hooks/useAlertQueriesStatus'; +import { useCombinedRule } from './hooks/useCombinedRule'; +import { AlertingQueryRunner } from './state/AlertingQueryRunner'; +import { getRulesSourceByName } from './utils/datasource'; +import { alertRuleToQueries } from './utils/query'; +import * as ruleId from './utils/rule-id'; import { isFederatedRuleGroup } from './utils/rules'; type RuleViewerProps = GrafanaRouteComponentProps<{ id?: string; sourceName?: string }>; @@ -49,19 +50,23 @@ export function RuleViewer({ match }: RuleViewerProps) { const queries2 = useMemo(() => alertRuleToQueries(rule), [rule]); const [queries, setQueries] = useState([]); + const { allDataSourcesAvailable } = useAlertQueriesStatus(queries2); + const onRunQueries = useCallback(() => { - if (queries.length > 0) { + if (queries.length > 0 && allDataSourcesAvailable) { runner.run(queries); } - }, [queries, runner]); + }, [queries, runner, allDataSourcesAvailable]); useEffect(() => { setQueries(queries2); }, [queries2]); useEffect(() => { - onRunQueries(); - }, [onRunQueries]); + if (allDataSourcesAvailable) { + onRunQueries(); + } + }, [onRunQueries, allDataSourcesAvailable]); useEffect(() => { return () => runner.destroy(); @@ -192,6 +197,11 @@ export function RuleViewer({ match }: RuleViewerProps) { )} + {!isFederatedRule && !allDataSourcesAvailable && ( + + Cannot display the query preview. Some of the data sources used in the queries are not available. + + )} ); } @@ -219,6 +229,9 @@ const getStyles = (theme: GrafanaTheme2) => { border-bottom: 1px solid ${theme.colors.border.medium}; padding: ${theme.spacing(2)}; `, + queryWarning: css` + margin: ${theme.spacing(4, 0)}; + `, details: css` display: flex; flex-direction: row; diff --git a/public/app/features/alerting/unified/api/annotations.test.ts b/public/app/features/alerting/unified/api/annotations.test.ts index ca52b08ca65..183c103f58c 100644 --- a/public/app/features/alerting/unified/api/annotations.test.ts +++ b/public/app/features/alerting/unified/api/annotations.test.ts @@ -1,10 +1,17 @@ import '@grafana/runtime'; -import { fetchAnnotations } from './annotations'; +import { fetchAnnotations, sortStateHistory } from './annotations'; +import { StateHistoryItem } from 'app/types/unified-alerting'; -const get = jest.fn(); +const get = jest.fn(() => { + return new Promise((resolve) => { + resolve(undefined); + }); +}); jest.mock('@grafana/runtime', () => ({ - getBackendSrv: () => ({ get }), + getBackendSrv: () => ({ + get, + }), })); describe('annotations', () => { @@ -16,3 +23,41 @@ describe('annotations', () => { expect(get).toBeCalledWith('/api/annotations', { alertId: ALERT_ID }); }); }); + +describe(sortStateHistory, () => { + describe('should stably sort', () => { + describe('when timeEnd is different', () => { + it('should not sort by rule id', () => { + let data: StateHistoryItem[] = [ + { timeEnd: 23, time: 22, id: 1 } as StateHistoryItem, + { timeEnd: 22, time: 21, id: 3 } as StateHistoryItem, + { timeEnd: 22, time: 22, id: 2 } as StateHistoryItem, + { timeEnd: 24, id: 3 } as StateHistoryItem, + ]; + + data.sort(sortStateHistory); + expect(data[0].timeEnd).toBe(24); + expect(data[1].timeEnd).toBe(23); + expect(data[2].time).toBe(22); + expect(data[3].id).toBe(3); + }); + }); + + describe('when only the rule id is different', () => { + it('should sort by rule id', () => { + let data: StateHistoryItem[] = [ + { timeEnd: 23, time: 22, id: 1 } as StateHistoryItem, + { timeEnd: 23, time: 22, id: 3 } as StateHistoryItem, + { timeEnd: 23, time: 22, id: 2 } as StateHistoryItem, + { timeEnd: 23, time: 22, id: 6 } as StateHistoryItem, + ]; + + data.sort(sortStateHistory); + expect(data[0].id).toBe(6); + expect(data[1].id).toBe(3); + expect(data[2].id).toBe(2); + expect(data[3].id).toBe(1); + }); + }); + }); +}); diff --git a/public/app/features/alerting/unified/api/annotations.ts b/public/app/features/alerting/unified/api/annotations.ts index 2df5ee2cacd..39195dc1f48 100644 --- a/public/app/features/alerting/unified/api/annotations.ts +++ b/public/app/features/alerting/unified/api/annotations.ts @@ -2,7 +2,37 @@ import { getBackendSrv } from '@grafana/runtime'; import { StateHistoryItem } from 'app/types/unified-alerting'; export function fetchAnnotations(alertId: string): Promise { - return getBackendSrv().get('/api/annotations', { - alertId, - }); + return getBackendSrv() + .get('/api/annotations', { + alertId, + }) + .then((result) => { + return result?.sort(sortStateHistory); + }); +} + +export function sortStateHistory(a: StateHistoryItem, b: StateHistoryItem): number { + const compareDesc = (a: number, b: number): number => { + // Larger numbers first. + if (a > b) { + return -1; + } + + if (b > a) { + return 1; + } + return 0; + }; + + const endNeq = compareDesc(a.timeEnd, b.timeEnd); + if (endNeq) { + return endNeq; + } + + const timeNeq = compareDesc(a.time, b.time); + if (timeNeq) { + return timeNeq; + } + + return compareDesc(a.id, b.id); } diff --git a/public/app/features/alerting/unified/components/CollapseToggle.test.tsx b/public/app/features/alerting/unified/components/CollapseToggle.test.tsx new file mode 100644 index 00000000000..384da5879fd --- /dev/null +++ b/public/app/features/alerting/unified/components/CollapseToggle.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; +import { noop } from 'lodash'; +import React from 'react'; +import { CollapseToggle } from './CollapseToggle'; + +describe('TestToggle', () => { + it('should render text', () => { + render(); + expect(screen.getByRole('button')).toHaveTextContent('Hello, world'); + }); + + it('should respect isCollapsed', () => { + const { rerender } = render(); + expect(screen.getByRole('button', { expanded: true })).toBeInTheDocument(); + + rerender(); + expect(screen.getByRole('button', { expanded: false })).toBeInTheDocument(); + }); + + it('should call onToggle', () => { + const onToggle = jest.fn(); + render(); + screen.getByRole('button').click(); + + expect(onToggle).toHaveBeenCalledWith(false); + // it should also not have any impact on the actual expanded state since the component does not track its own state + expect(screen.getByRole('button', { expanded: false })).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/alerting/unified/components/CollapseToggle.tsx b/public/app/features/alerting/unified/components/CollapseToggle.tsx index c63256e077f..3e670a396be 100644 --- a/public/app/features/alerting/unified/components/CollapseToggle.tsx +++ b/public/app/features/alerting/unified/components/CollapseToggle.tsx @@ -34,7 +34,9 @@ export const CollapseToggle: FC = ({ icon={isCollapsed ? 'angle-right' : 'angle-down'} onClick={() => onToggle(!isCollapsed)} {...restOfProps} - /> + > + {text} + ); }; diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx index 53818045141..878620f5fd5 100644 --- a/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx @@ -3,11 +3,13 @@ import { GrafanaTheme2 } from '@grafana/data'; import { LinkButton, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; import { AlertmanagerAlert, AlertState } from 'app/plugins/datasource/alertmanager/types'; +import { AccessControlAction } from 'app/types'; import React, { FC } from 'react'; +import { getInstancesPermissions } from '../../utils/access-control'; +import { isGrafanaRulesSource } from '../../utils/datasource'; import { makeAMLink, makeLabelBasedSilenceLink } from '../../utils/misc'; import { AnnotationDetailsField } from '../AnnotationDetailsField'; import { Authorize } from '../Authorize'; -import { getInstancesPermissions } from '../../utils/access-control'; interface AmNotificationsAlertDetailsProps { alertManagerSourceName: string; @@ -16,11 +18,19 @@ interface AmNotificationsAlertDetailsProps { export const AlertDetails: FC = ({ alert, alertManagerSourceName }) => { const styles = useStyles2(getStyles); - const permissions = getInstancesPermissions(alertManagerSourceName); + const instancePermissions = getInstancesPermissions(alertManagerSourceName); + + // For Grafana Managed alerts the Generator URL redirects to the alert rule edit page, so update permission is required + // For external alert manager the Generator URL redirects to an external service which we don't control + const isGrafanaSource = isGrafanaRulesSource(alertManagerSourceName); + const isSeeSourceButtonEnabled = isGrafanaSource + ? contextSrv.hasPermission(AccessControlAction.AlertingRuleUpdate) + : true; + return ( <>
    - + {alert.status.state === AlertState.Suppressed && ( = ({ alert, aler )} - - {alert.generatorURL && ( - - See source - - )} - + {isSeeSourceButtonEnabled && alert.generatorURL && ( + + See source + + )}
    {Object.entries(alert.annotations).map(([annotationKey, annotationValue]) => ( diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx index 76ec7d4a250..ae7f9a1a142 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx @@ -14,6 +14,8 @@ import { Select, Switch, useStyles2, + Badge, + VerticalGroup, } from '@grafana/ui'; import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes'; import { @@ -56,73 +58,85 @@ export const AmRoutesExpandedForm: FC = ({ onCancel, {({ fields, append, remove }) => ( <> -
    Matching labels
    -
    - {fields.map((field, index) => { - const localPath = `object_matchers[${index}]`; - return ( - - - - - - ( - - )} - defaultValue={field.operator} - control={control} - name={`${localPath}.operator` as const} - rules={{ required: { value: true, message: 'Required.' } }} - /> - - - - - remove(index)} - > - Remove - - - ); - })} -
    - + + + ( + + + remove(index)} + > + Remove + + + ); + })} + + )} + + )}
    @@ -376,5 +390,8 @@ const getStyles = (theme: GrafanaTheme2) => { margin-left: ${theme.spacing(1.5)}; } `, + noMatchersWarning: css` + padding: ${theme.spacing(1)} ${theme.spacing(2)}; + `, }; }; diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx index c25f812d57a..8719728235e 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx @@ -90,7 +90,13 @@ export const AmRoutesTable: FC = ({ id: 'matchingCriteria', label: 'Matching labels', // eslint-disable-next-line react/display-name - renderCell: (item) => , + renderCell: (item) => { + return item.data.object_matchers.length ? ( + + ) : ( + Matches all alert instances + ); + }, size: 10, }, { diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.test.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.test.tsx new file mode 100644 index 00000000000..7eacd6a1fcb --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.test.tsx @@ -0,0 +1,75 @@ +import { render } from '@testing-library/react'; +import { contextSrv } from 'app/core/services/context_srv'; +import { configureStore } from 'app/store/configureStore'; +import { AccessControlAction } from 'app/types'; +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { Provider } from 'react-redux'; +import { byText } from 'testing-library-selector'; +import { AlertTypeStep } from './AlertTypeStep'; + +const ui = { + ruleTypePicker: { + grafanaManagedButton: byText('Grafana managed alert'), + mimirOrLokiButton: byText('Mimir or Loki alert'), + mimirOrLokiRecordingButton: byText('Mimir or Loki recording rule'), + }, +}; + +const FormProviderWrapper: React.FC = ({ children }) => { + const methods = useForm({}); + return {children}; +}; + +function renderAlertTypeStep() { + const store = configureStore(); + + render( + + + , + { wrapper: FormProviderWrapper } + ); +} + +describe('RuleTypePicker', () => { + describe('FGAC', () => { + it('Should display grafana, mimir alert and mimir recording buttons when user has rule create and write permissions', async () => { + jest.spyOn(contextSrv, 'hasPermission').mockImplementation((action) => { + return [AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite].includes( + action as AccessControlAction + ); + }); + + renderAlertTypeStep(); + + expect(ui.ruleTypePicker.grafanaManagedButton.get()).toBeInTheDocument(); + expect(ui.ruleTypePicker.mimirOrLokiButton.get()).toBeInTheDocument(); + expect(ui.ruleTypePicker.mimirOrLokiRecordingButton.get()).toBeInTheDocument(); + }); + + it('Should hide grafana button when user does not have rule create permission', () => { + jest.spyOn(contextSrv, 'hasPermission').mockImplementation((action) => { + return [AccessControlAction.AlertingRuleExternalWrite].includes(action as AccessControlAction); + }); + + renderAlertTypeStep(); + + expect(ui.ruleTypePicker.grafanaManagedButton.query()).not.toBeInTheDocument(); + expect(ui.ruleTypePicker.mimirOrLokiButton.get()).toBeInTheDocument(); + expect(ui.ruleTypePicker.mimirOrLokiRecordingButton.get()).toBeInTheDocument(); + }); + + it('Should hide mimir alert and mimir recording when user does not have rule external write permission', () => { + jest.spyOn(contextSrv, 'hasPermission').mockImplementation((action) => { + return [AccessControlAction.AlertingRuleCreate].includes(action as AccessControlAction); + }); + + renderAlertTypeStep(); + + expect(ui.ruleTypePicker.grafanaManagedButton.get()).toBeInTheDocument(); + expect(ui.ruleTypePicker.mimirOrLokiButton.query()).not.toBeInTheDocument(); + expect(ui.ruleTypePicker.mimirOrLokiRecordingButton.query()).not.toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx index aa2179f2265..c0ebdaa73bf 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx @@ -10,6 +10,8 @@ import { GroupAndNamespaceFields } from './GroupAndNamespaceFields'; import { CloudRulesSourcePicker } from './CloudRulesSourcePicker'; import { checkForPathSeparator } from './util'; import { RuleTypePicker } from './rule-types/RuleTypePicker'; +import { contextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction } from 'app/types'; interface Props { editingExistingRule: boolean; @@ -24,6 +26,8 @@ const recordingRuleNameValidationPattern = { export const AlertTypeStep: FC = ({ editingExistingRule }) => { const styles = useStyles2(getStyles); + const { enabledRuleTypes, defaultRuleType } = getAvailableRuleTypes(); + const { register, control, @@ -38,27 +42,25 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => { return ( - - ( - - )} - name="type" - control={control} - rules={{ - required: { value: true, message: 'Please select alert type' }, - }} - /> - + {!editingExistingRule && ( + + ( + + )} + name="type" + control={control} + rules={{ + required: { value: true, message: 'Please select alert type' }, + }} + /> + + )} = ({ editingExistingRule }) => { ); }; +function getAvailableRuleTypes() { + const canCreateGrafanaRules = contextSrv.hasPermission(AccessControlAction.AlertingRuleCreate); + const canCreateCloudRules = contextSrv.hasPermission(AccessControlAction.AlertingRuleExternalWrite); + const defaultRuleType = canCreateGrafanaRules ? RuleFormType.grafana : RuleFormType.cloudAlerting; + + const enabledRuleTypes: RuleFormType[] = []; + if (canCreateGrafanaRules) { + enabledRuleTypes.push(RuleFormType.grafana); + } + if (canCreateCloudRules) { + enabledRuleTypes.push(RuleFormType.cloudAlerting, RuleFormType.cloudRecording); + } + + return { enabledRuleTypes, defaultRuleType }; +} + const getStyles = (theme: GrafanaTheme2) => ({ formInput: css` width: 330px; diff --git a/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx b/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx index 9cac7d83d27..4b2bf6a9510 100644 --- a/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx @@ -1,33 +1,44 @@ -import React, { useCallback, useState } from 'react'; import { css } from '@emotion/css'; -import { useFormContext } from 'react-hook-form'; -import { takeWhile } from 'rxjs/operators'; -import { useMountedState } from 'react-use'; -import { Button, HorizontalGroup, useStyles2 } from '@grafana/ui'; import { dateTimeFormatISO, GrafanaTheme2, LoadingState } from '@grafana/data'; -import { RuleFormType } from '../../types/rule-form'; -import { PreviewRuleRequest, PreviewRuleResponse } from '../../types/preview'; +import { Alert, Button, HorizontalGroup, useStyles2 } from '@grafana/ui'; +import React, { useCallback, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { useMountedState } from 'react-use'; +import { takeWhile } from 'rxjs/operators'; import { previewAlertRule } from '../../api/preview'; +import { useAlertQueriesStatus } from '../../hooks/useAlertQueriesStatus'; +import { PreviewRuleRequest, PreviewRuleResponse } from '../../types/preview'; +import { RuleFormType, RuleFormValues } from '../../types/rule-form'; import { PreviewRuleResult } from './PreviewRuleResult'; -const fields: string[] = ['type', 'dataSourceName', 'condition', 'queries', 'expression']; +const fields: Array = ['type', 'dataSourceName', 'condition', 'queries', 'expression']; export function PreviewRule(): React.ReactElement | null { const styles = useStyles2(getStyles); const [preview, onPreview] = usePreview(); - const { watch } = useFormContext(); - const [type, condition] = watch(['type', 'condition']); + const { watch } = useFormContext(); + const [type, condition, queries] = watch(['type', 'condition', 'queries']); + const { allDataSourcesAvailable } = useAlertQueriesStatus(queries); if (type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) { return null; } + const isPreviewAvailable = Boolean(condition) && allDataSourcesAvailable; + return (
    - + {allDataSourcesAvailable && ( + + )} + {!allDataSourcesAvailable && ( + + Cannot display the query preview. Some of the data sources used in the queries are not available. + + )}
    @@ -36,7 +47,7 @@ export function PreviewRule(): React.ReactElement | null { function usePreview(): [PreviewRuleResponse | undefined, () => void] { const [preview, setPreview] = useState(); - const { getValues } = useFormContext(); + const { getValues } = useFormContext(); const isMounted = useMountedState(); const onPreview = useCallback(() => { diff --git a/public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx b/public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx index 85e3365cd7f..e9dcc0c3450 100644 --- a/public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx @@ -1,3 +1,6 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data/src'; +import { Stack } from '@grafana/experimental'; import { useStyles2 } from '@grafana/ui'; import { isEmpty } from 'lodash'; import React, { FC } from 'react'; @@ -6,16 +9,14 @@ import { RuleFormType } from '../../../types/rule-form'; import { GrafanaManagedRuleType } from './GrafanaManagedAlert'; import { MimirFlavoredType } from './MimirOrLokiAlert'; import { RecordingRuleType } from './MimirOrLokiRecordingRule'; -import { css } from '@emotion/css'; -import { GrafanaTheme2 } from '@grafana/data/src'; -import { Stack } from '@grafana/experimental'; interface RuleTypePickerProps { onChange: (value: RuleFormType) => void; selected: RuleFormType; + enabledTypes: RuleFormType[]; } -const RuleTypePicker: FC = ({ selected, onChange }) => { +const RuleTypePicker: FC = ({ selected, onChange, enabledTypes }) => { const rulesSourcesWithRuler = useRulesSourcesWithRuler(); const hasLotexDatasources = !isEmpty(rulesSourcesWithRuler); @@ -24,22 +25,30 @@ const RuleTypePicker: FC = ({ selected, onChange }) => { return ( <> - - - + {enabledTypes.includes(RuleFormType.grafana) && ( + + )} + {enabledTypes.includes(RuleFormType.cloudAlerting) && ( + + )} + {enabledTypes.includes(RuleFormType.cloudRecording) && ( + + )} - - Select “Grafana managed” unless you have a Mimir, Loki or Cortex data source with the Ruler API - enabled. - + {enabledTypes.includes(RuleFormType.grafana) && ( + + Select “Grafana managed” unless you have a Mimir, Loki or Cortex data source with the Ruler API + enabled. + + )} ); }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx index 26eb3ed2fd0..5d419399fcc 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx @@ -9,6 +9,8 @@ import { AlertQuery } from 'app/types/unified-alerting-dto'; import AutoSizer from 'react-virtualized-auto-sizer'; import { PanelPluginsButtonGroup, SupportedPanelPlugins } from '../PanelPluginsButtonGroup'; import { TABLE, TIMESERIES } from '../../utils/constants'; +import { Authorize } from '../Authorize'; +import { AccessControlAction } from 'app/types'; type RuleViewerVisualizationProps = { data?: PanelData; @@ -91,20 +93,22 @@ export function RuleViewerVisualization(props: RuleViewerVisualizationProps): JS /> ) : null} - {!isExpressionQuery(query.model) && ( - <> -
    - - View in Explore - - - )} + + {!isExpressionQuery(query.model) && ( + <> +
    + + View in Explore + + + )} +
    { + describe('Grafana rules action buttons', () => { + const grafanaRule = getGrafanaRule({ name: 'Grafana' }); + it('Should not render Edit button for users without the update permission', () => { + // Arrange + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false }); + + // Act + renderRuleDetails(grafanaRule); + + // Assert + expect(ui.actionButtons.edit.query()).not.toBeInTheDocument(); + }); + + it('Should not render Delete button for users without the delete permission', () => { + // Arrange + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false }); + + // Act + renderRuleDetails(grafanaRule); + + // Assert + expect(ui.actionButtons.delete.query()).not.toBeInTheDocument(); + }); + + it('Should render Edit button for users with the update permission', () => { + // Arrange + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); + + // Act + renderRuleDetails(grafanaRule); + + // Assert + expect(ui.actionButtons.edit.query()).toBeInTheDocument(); + }); + + it('Should render Delete button for users with the delete permission', () => { + // Arrange + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true }); + + // Act + renderRuleDetails(grafanaRule); + + // Assert + expect(ui.actionButtons.delete.query()).toBeInTheDocument(); + }); + + it('Should not render Silence button for users wihout the instance create permission', () => { + // Arrange + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); + + // Act + renderRuleDetails(grafanaRule); + + // Assert + expect(ui.actionButtons.silence.query()).not.toBeInTheDocument(); + }); + + it('Should render Silence button for users with the instance create permissions', () => { + // Arrange + jest + .spyOn(contextSrv, 'hasPermission') + .mockImplementation((action) => action === AccessControlAction.AlertingInstanceCreate); + + // Act + renderRuleDetails(grafanaRule); + + // Assert + expect(ui.actionButtons.silence.query()).toBeInTheDocument(); + }); + }); + + describe('Cloud rules action buttons', () => { + const cloudRule = getCloudRule({ name: 'Cloud' }); + it('Should not render Edit button for users without the update permission', () => { + // Arrange + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false }); + + // Act + renderRuleDetails(cloudRule); + + // Assert + expect(ui.actionButtons.edit.query()).not.toBeInTheDocument(); + }); + + it('Should not render Delete button for users without the delete permission', () => { + // Arrange + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false }); + + // Act + renderRuleDetails(cloudRule); + + // Assert + expect(ui.actionButtons.delete.query()).not.toBeInTheDocument(); + }); + + it('Should render Edit button for users with the update permission', () => { + // Arrange + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); + + // Act + renderRuleDetails(cloudRule); + + // Assert + expect(ui.actionButtons.edit.query()).toBeInTheDocument(); + }); + + it('Should render Delete button for users with the delete permission', () => { + // Arrange + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true }); + + // Act + renderRuleDetails(cloudRule); + + // Assert + expect(ui.actionButtons.delete.query()).toBeInTheDocument(); + }); + }); +}); + +function renderRuleDetails(rule: CombinedRule) { + const store = configureStore(); + + render( + + + + + + ); +} + +function getGrafanaRule(override?: Partial) { + return mockCombinedRule({ + namespace: { + groups: [], + name: 'Grafana', + rulesSource: 'grafana', + }, + ...override, + }); +} + +function getCloudRule(override?: Partial) { + return mockCombinedRule({ + namespace: { + groups: [], + name: 'Cortex', + rulesSource: mockDataSource(), + }, + promRule: mockPromAlertingRule(), + rulerRule: mockRulerAlertingRule(), + ...override, + }); +} diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx index af21b4d4ab2..db8a5fc8e8c 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx @@ -1,15 +1,15 @@ -import { CombinedRule } from 'app/types/unified-alerting'; -import React, { FC } from 'react'; -import { useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { CombinedRule } from 'app/types/unified-alerting'; +import React, { FC } from 'react'; import { AlertLabels } from '../AlertLabels'; import { DetailsField } from '../DetailsField'; import { RuleDetailsActionButtons } from './RuleDetailsActionButtons'; -import { RuleDetailsDataSources } from './RuleDetailsDataSources'; -import { RuleDetailsMatchingInstances } from './RuleDetailsMatchingInstances'; -import { RuleDetailsExpression } from './RuleDetailsExpression'; import { RuleDetailsAnnotations } from './RuleDetailsAnnotations'; +import { RuleDetailsDataSources } from './RuleDetailsDataSources'; +import { RuleDetailsExpression } from './RuleDetailsExpression'; +import { RuleDetailsMatchingInstances } from './RuleDetailsMatchingInstances'; interface Props { rule: CombinedRule; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index 3e6640a24ef..43604a68733 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -1,24 +1,24 @@ -import React, { FC, Fragment, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { useLocation } from 'react-router-dom'; import { css } from '@emotion/css'; import { GrafanaTheme2, urlUtil } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { Button, ConfirmModal, ClipboardButton, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui'; -import { contextSrv } from 'app/core/services/context_srv'; +import { Button, ClipboardButton, ConfirmModal, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; +import { contextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction } from 'app/types'; +import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; +import { RulerGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto'; +import React, { FC, Fragment, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useLocation } from 'react-router-dom'; import { useIsRuleEditable } from '../../hooks/useIsRuleEditable'; +import { useStateHistoryModal } from '../../hooks/useStateHistoryModal'; +import { deleteRuleAction } from '../../state/actions'; +import { getAlertmanagerByUid } from '../../utils/alertmanager'; import { Annotation } from '../../utils/constants'; import { getRulesSourceName, isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource'; import { createExploreLink, createViewLink, makeRuleBasedSilenceLink } from '../../utils/misc'; import * as ruleId from '../../utils/rule-id'; -import { deleteRuleAction } from '../../state/actions'; -import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; -import { getAlertmanagerByUid } from '../../utils/alertmanager'; -import { useStateHistoryModal } from '../../hooks/useStateHistoryModal'; -import { RulerGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { isFederatedRuleGroup } from '../../utils/rules'; -import { AccessControlAction } from 'app/types'; interface Props { rule: CombinedRule; @@ -38,12 +38,15 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { const alertmanagerSourceName = isGrafanaRulesSource(rulesSource) ? rulesSource : getAlertmanagerByUid(rulesSource.jsonData.alertmanagerUid)?.name; + const rulesSourceName = getRulesSourceName(rulesSource); + + const hasExplorePermission = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore); const leftButtons: JSX.Element[] = []; const rightButtons: JSX.Element[] = []; const isFederated = isFederatedRuleGroup(group); - const { isEditable } = useIsRuleEditable(getRulesSourceName(rulesSource), rulerRule); + const { isEditable, isRemovable } = useIsRuleEditable(rulesSourceName, rulerRule); const returnTo = location.pathname + location.search; const isViewMode = inViewMode(location.pathname); @@ -74,7 +77,7 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { // explore does not support grafana rule queries atm // neither do "federated rules" - if (isCloudRulesSource(rulesSource) && contextSrv.isEditor && !isFederated) { + if (isCloudRulesSource(rulesSource) && hasExplorePermission && !isFederated) { leftButtons.push( = ({ rule, rulesSource }) => { rightButtons.push( Edit - , + + ); + } + + if (isRemovable && rulerRule && !isFederated) { + rightButtons.push( ); } + if (leftButtons.length || rightButtons.length) { return ( <> diff --git a/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx new file mode 100644 index 00000000000..e7d2192e5d8 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx @@ -0,0 +1,108 @@ +import { render } from '@testing-library/react'; +import { contextSrv } from 'app/core/services/context_srv'; +import { configureStore } from 'app/store/configureStore'; +import { AccessControlAction } from 'app/types'; +import { CombinedRuleNamespace } from 'app/types/unified-alerting'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { byRole } from 'testing-library-selector'; +import { mockCombinedRule, mockDataSource } from '../../mocks'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { RuleListGroupView } from './RuleListGroupView'; + +const ui = { + grafanaRulesHeading: byRole('heading', { name: 'Grafana' }), + cloudRulesHeading: byRole('heading', { name: 'Mimir / Cortex / Loki' }), +}; + +describe('RuleListGroupView', () => { + describe('FGAC', () => { + jest.spyOn(contextSrv, 'accessControlEnabled').mockReturnValue(true); + + it('Should display Grafana rules when the user has the alert rule read permission', () => { + const grafanaNamespace = getGrafanaNamespace(); + const namespaces: CombinedRuleNamespace[] = [grafanaNamespace]; + + jest + .spyOn(contextSrv, 'hasPermission') + .mockImplementation((action) => action === AccessControlAction.AlertingRuleRead); + + renderRuleList(namespaces); + + expect(ui.grafanaRulesHeading.get()).toBeInTheDocument(); + }); + + it('Should display Cloud rules when the user has the external rules read permission', () => { + const cloudNamespace = getCloudNamespace(); + const namespaces: CombinedRuleNamespace[] = [cloudNamespace]; + + jest + .spyOn(contextSrv, 'hasPermission') + .mockImplementation((action) => action === AccessControlAction.AlertingRuleExternalRead); + + renderRuleList(namespaces); + + expect(ui.cloudRulesHeading.get()).toBeInTheDocument(); + }); + + it('Should not display Grafana rules when the user does not have alert rule read permission', () => { + const grafanaNamespace = getGrafanaNamespace(); + const namespaces: CombinedRuleNamespace[] = [grafanaNamespace]; + + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); + + renderRuleList(namespaces); + + expect(ui.grafanaRulesHeading.query()).not.toBeInTheDocument(); + }); + + it('Should not display Cloud rules when the user does not have the external rules read permission', () => { + const cloudNamespace = getCloudNamespace(); + + const namespaces: CombinedRuleNamespace[] = [cloudNamespace]; + renderRuleList(namespaces); + + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); + + renderRuleList(namespaces); + + expect(ui.cloudRulesHeading.query()).not.toBeInTheDocument(); + }); + }); +}); + +function renderRuleList(namespaces: CombinedRuleNamespace[]) { + const store = configureStore(); + + render( + + + + ); +} + +function getGrafanaNamespace(): CombinedRuleNamespace { + return { + name: 'Grafana Test Namespace', + rulesSource: GRAFANA_RULES_SOURCE_NAME, + groups: [ + { + name: 'default', + rules: [mockCombinedRule()], + }, + ], + }; +} + +function getCloudNamespace(): CombinedRuleNamespace { + return { + name: 'Cloud Test Namespace', + rulesSource: mockDataSource(), + groups: [ + { + name: 'Prom group', + rules: [mockCombinedRule()], + }, + ], + }; +} diff --git a/public/app/features/alerting/unified/components/rules/RuleListGroupView.tsx b/public/app/features/alerting/unified/components/rules/RuleListGroupView.tsx index e2a51b2b390..9bb64492da7 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListGroupView.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListGroupView.tsx @@ -1,6 +1,8 @@ +import { AccessControlAction } from 'app/types'; import { CombinedRuleNamespace } from 'app/types/unified-alerting'; import React, { FC, useMemo } from 'react'; import { isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource'; +import { Authorize } from '../Authorize'; import { CloudRules } from './CloudRules'; import { GrafanaRules } from './GrafanaRules'; @@ -25,8 +27,12 @@ export const RuleListGroupView: FC = ({ namespaces, expandAll }) => { return ( <> - - + + + + + + ); }; diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index b534a3e8592..ef668398c0c 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -1,7 +1,9 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { Badge, ConfirmModal, HorizontalGroup, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; import kbn from 'app/core/utils/kbn'; +import { AccessControlAction } from 'app/types'; import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; import pluralize from 'pluralize'; import React, { FC, useEffect, useState } from 'react'; @@ -32,6 +34,8 @@ export const RulesGroup: FC = React.memo(({ group, namespace, expandAll } const [isDeletingGroup, setIsDeletingGroup] = useState(false); const [isCollapsed, setIsCollapsed] = useState(!expandAll); + const canEditCloudRules = contextSrv.hasPermission(AccessControlAction.AlertingRuleExternalWrite); + useEffect(() => { setIsCollapsed(!expandAll); }, [expandAll]); @@ -88,7 +92,7 @@ export const RulesGroup: FC = React.memo(({ group, namespace, expandAll } ); } } - } else if (hasRuler(rulesSource)) { + } else if (canEditCloudRules && hasRuler(rulesSource)) { if (!isFederated) { actionIcons.push( queries.every((query) => Boolean(getDataSourceSrv().getInstanceSettings(query.datasourceUid))), + [queries] + ); + + return { allDataSourcesAvailable }; +} diff --git a/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx b/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx new file mode 100644 index 00000000000..7925df6f9d6 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { contextSrv } from 'app/core/services/context_srv'; +import { configureStore } from 'app/store/configureStore'; +import { AccessControlAction, FolderDTO, StoreState } from 'app/types'; +import { Provider } from 'react-redux'; +import { mockFolder, mockRulerAlertingRule, mockRulerGrafanaRule } from '../mocks'; +import { useFolder } from './useFolder'; +import { useIsRuleEditable } from './useIsRuleEditable'; +import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; + +jest.mock('./useFolder'); + +const mocks = { + useFolder: jest.mocked(useFolder), + useUnifiedAlertingSelector: jest.mocked(useUnifiedAlertingSelector), +}; + +describe('useIsRuleEditable', () => { + describe('FGAC enabled', () => { + jest.spyOn(contextSrv, 'accessControlEnabled').mockReturnValue(true); + describe('Grafana rules', () => { + it('Should allow editing when the user has the alert rule update permission and folder permissions', () => { + mockPermissions([AccessControlAction.AlertingRuleUpdate]); + mockUseFolder({ canSave: true }); + const wrapper = getProviderWrapper(); + + const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper }); + + expect(result.current.loading).toBe(false); + expect(result.current.isEditable).toBe(true); + }); + + it('Should allow deleting when the user has the alert rule delete permission and folder permissions', () => { + mockPermissions([AccessControlAction.AlertingRuleDelete]); + mockUseFolder({ canSave: true }); + const wrapper = getProviderWrapper(); + + const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper }); + + expect(result.current.loading).toBe(false); + expect(result.current.isRemovable).toBe(true); + }); + + it('Should forbid editing when the user has no alert rule update permission and has folder permissions', () => { + mockPermissions([]); + mockUseFolder({ canSave: true }); + const wrapper = getProviderWrapper(); + + const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper }); + + expect(result.current.loading).toBe(false); + expect(result.current.isEditable).toBe(false); + }); + + it('Should forbid deleting when the user has no alert rule delete permission and has folder permissions', () => { + mockPermissions([]); + mockUseFolder({ canSave: true }); + const wrapper = getProviderWrapper(); + + const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper }); + + expect(result.current.loading).toBe(false); + expect(result.current.isRemovable).toBe(false); + }); + + it('Should forbid editing and deleting when the user has aler rule permissions but does not have folder permissions', () => { + mockPermissions([AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleDelete]); + mockUseFolder({ canSave: false }); + const wrapper = getProviderWrapper(); + + const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper }); + + expect(result.current.loading).toBe(false); + expect(result.current.isEditable).toBe(false); + expect(result.current.isRemovable).toBe(false); + }); + }); + + describe('Cloud rules', () => { + it('Should allow editing and deleting when the user has alert rule external write permission', () => { + mockPermissions([AccessControlAction.AlertingRuleExternalWrite]); + const wrapper = getProviderWrapper(); + + const { result } = renderHook(() => useIsRuleEditable('cortex', mockRulerAlertingRule()), { wrapper }); + + expect(result.current.loading).toBe(false); + expect(result.current.isEditable).toBe(true); + expect(result.current.isRemovable).toBe(true); + }); + + it('Should forbid editing and deleting when the user has no alert rule external write permission', () => { + mockPermissions([]); + const wrapper = getProviderWrapper(); + + const { result } = renderHook(() => useIsRuleEditable('cortex', mockRulerAlertingRule()), { wrapper }); + + expect(result.current.loading).toBe(false); + expect(result.current.isEditable).toBe(false); + expect(result.current.isRemovable).toBe(false); + }); + }); + }); +}); + +function mockUseFolder(partial?: Partial) { + mocks.useFolder.mockReturnValue({ loading: false, folder: mockFolder(partial) }); +} + +function mockPermissions(grantedPermissions: AccessControlAction[]) { + jest + .spyOn(contextSrv, 'hasPermission') + .mockImplementation((action) => grantedPermissions.includes(action as AccessControlAction)); +} + +function getProviderWrapper() { + const dataSources = getMockedDataSources(); + const store = mockStore({ dataSources }); + const wrapper: React.FC = ({ children }) => {children}; + return wrapper; +} + +function getMockedDataSources(): StoreState['unifiedAlerting']['dataSources'] { + return { + grafana: { + loading: false, + dispatched: false, + result: { + id: 'grafana', + name: 'grafana', + rulerConfig: { dataSourceName: 'grafana', apiVersion: 'legacy' }, + }, + }, + cortex: { + loading: false, + dispatched: false, + result: { + id: 'cortex', + name: 'Cortex', + rulerConfig: { dataSourceName: 'cortex', apiVersion: 'legacy' }, + }, + }, + }; +} + +function mockStore(unifiedAlerting?: Partial) { + const defaultState = configureStore().getState(); + + return configureStore({ + ...defaultState, + unifiedAlerting: { + ...defaultState.unifiedAlerting, + ...unifiedAlerting, + }, + }); +} diff --git a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts index 97695319adf..5d42819b433 100644 --- a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts +++ b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts @@ -1,11 +1,13 @@ import { contextSrv } from 'app/core/services/context_srv'; import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; +import { getRulesPermissions } from '../utils/access-control'; import { isGrafanaRulerRule } from '../utils/rules'; import { useFolder } from './useFolder'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; interface ResultBag { isEditable?: boolean; + isRemovable?: boolean; loading: boolean; } @@ -13,10 +15,14 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): const dataSources = useUnifiedAlertingSelector((state) => state.dataSources); const folderUID = rule && isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined; + const rulePermission = getRulesPermissions(rulesSourceName); + const hasEditPermission = contextSrv.hasAccess(rulePermission.update, contextSrv.isEditor); + const hasRemovePermission = contextSrv.hasAccess(rulePermission.delete, contextSrv.isEditor); + const { folder, loading } = useFolder(folderUID); if (!rule) { - return { isEditable: false, loading: false }; + return { isEditable: false, isRemovable: false, loading: false }; } // grafana rules can be edited if user can edit the folder they're in @@ -27,14 +33,17 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): ); } return { - isEditable: folder?.canSave, + isEditable: hasEditPermission && folder?.canSave, + isRemovable: hasRemovePermission && folder?.canSave, loading, }; } // prom rules are only editable by users with Editor role and only if rules source supports editing + const isRulerAvailable = Boolean(dataSources[rulesSourceName]?.result?.rulerConfig); return { - isEditable: contextSrv.isEditor && Boolean(dataSources[rulesSourceName]?.result?.rulerConfig), + isEditable: hasEditPermission && isRulerAvailable, + isRemovable: hasRemovePermission && isRulerAvailable, loading: dataSources[rulesSourceName]?.loading, }; } diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index eca52ac0931..952037e6f44 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -29,6 +29,7 @@ import { Silence, SilenceState, } from 'app/plugins/datasource/alertmanager/types'; +import { FolderDTO } from 'app/types'; let nextDataSourceId = 1; @@ -449,3 +450,18 @@ export const mockCombinedRule = (partial?: Partial): CombinedRule rulerRule: mockRulerAlertingRule(), ...partial, }); + +export const mockFolder = (partial?: Partial): FolderDTO => { + return { + id: 1, + uid: 'gdev-1', + title: 'Gdev', + version: 1, + url: '', + canAdmin: true, + canDelete: true, + canEdit: true, + canSave: true, + ...partial, + }; +}; diff --git a/public/app/features/alerting/unified/utils/access-control.ts b/public/app/features/alerting/unified/utils/access-control.ts index 7d18ec3c7fb..fa884513679 100644 --- a/public/app/features/alerting/unified/utils/access-control.ts +++ b/public/app/features/alerting/unified/utils/access-control.ts @@ -2,12 +2,14 @@ import { AccessControlAction } from 'app/types'; import { isGrafanaRulesSource } from './datasource'; import { contextSrv } from 'app/core/services/context_srv'; -function getAMversion(alertManagerSourceName: string) { +type RulesSourceType = 'grafana' | 'external'; + +function getRulesSourceType(alertManagerSourceName: string): RulesSourceType { return isGrafanaRulesSource(alertManagerSourceName) ? 'grafana' : 'external'; } -export function getInstancesPermissions(alertManagerSourceName: string) { - const amVersion = getAMversion(alertManagerSourceName); +export function getInstancesPermissions(rulesSourceName: string) { + const sourceType = getRulesSourceType(rulesSourceName); const permissions = { read: { @@ -26,23 +28,18 @@ export function getInstancesPermissions(alertManagerSourceName: string) { grafana: AccessControlAction.AlertingInstanceUpdate, external: AccessControlAction.AlertingInstancesExternalWrite, }, - viewSource: { - grafana: AccessControlAction.AlertingInstanceRead, - external: AccessControlAction.DataSourcesExplore, - }, }; return { - read: permissions.read[amVersion], - create: permissions.create[amVersion], - update: permissions.update[amVersion], - delete: permissions.delete[amVersion], - viewSource: permissions.viewSource[amVersion], + read: permissions.read[sourceType], + create: permissions.create[sourceType], + update: permissions.update[sourceType], + delete: permissions.delete[sourceType], }; } -export function getNotificationsPermissions(alertManagerSourceName: string) { - const amVersion = getAMversion(alertManagerSourceName); +export function getNotificationsPermissions(rulesSourceName: string) { + const sourceType = getRulesSourceType(rulesSourceName); const permissions = { read: { @@ -64,10 +61,40 @@ export function getNotificationsPermissions(alertManagerSourceName: string) { }; return { - read: permissions.read[amVersion], - create: permissions.create[amVersion], - update: permissions.update[amVersion], - delete: permissions.delete[amVersion], + read: permissions.read[sourceType], + create: permissions.create[sourceType], + update: permissions.update[sourceType], + delete: permissions.delete[sourceType], + }; +} + +export function getRulesPermissions(rulesSourceName: string) { + const sourceType = getRulesSourceType(rulesSourceName); + + const permissions = { + read: { + grafana: AccessControlAction.AlertingRuleRead, + external: AccessControlAction.AlertingRuleExternalRead, + }, + create: { + grafana: AccessControlAction.AlertingRuleCreate, + external: AccessControlAction.AlertingRuleExternalWrite, + }, + update: { + grafana: AccessControlAction.AlertingRuleUpdate, + external: AccessControlAction.AlertingRuleExternalWrite, + }, + delete: { + grafana: AccessControlAction.AlertingRuleDelete, + external: AccessControlAction.AlertingRuleExternalWrite, + }, + }; + + return { + read: permissions.read[sourceType], + create: permissions.create[sourceType], + update: permissions.update[sourceType], + delete: permissions.delete[sourceType], }; } diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index 9f61c323bf6..51d91021993 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -256,7 +256,6 @@ export class PanelEditorUnconnected extends PureComponent { lazy={false} width={panelSize.width} height={panelSize.height} - skipStateCleanUp={true} /> diff --git a/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts b/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts index a41ece9b4de..1e864f160ac 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts @@ -1,7 +1,7 @@ import { thunkTester } from '../../../../../../test/core/thunk/thunkTester'; import { closeEditor, initialState, PanelEditorState } from './reducers'; import { exitPanelEditor, initPanelEditor, skipPanelUpdate } from './actions'; -import { cleanUpPanelState, panelModelAndPluginReady } from 'app/features/panel/state/reducers'; +import { panelModelAndPluginReady, removePanel } from 'app/features/panel/state/reducers'; import { DashboardModel, PanelModel } from '../../../state'; import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks'; @@ -48,6 +48,7 @@ describe('panelEditor actions', () => { }; const dispatchedActions = await thunkTester({ + panels: {}, panelEditor: state, dashboard: { getModel: () => dashboard, @@ -57,7 +58,7 @@ describe('panelEditor actions', () => { .whenThunkIsDispatched(); expect(dispatchedActions.length).toBe(2); - expect(dispatchedActions[0].type).toBe(cleanUpPanelState.type); + expect(dispatchedActions[0].type).toBe(removePanel.type); expect(dispatchedActions[1].type).toBe(closeEditor.type); expect(sourcePanel.getOptions()).toEqual({ prop: true }); expect(sourcePanel.id).toEqual(12); @@ -84,6 +85,7 @@ describe('panelEditor actions', () => { const dispatchedActions = await thunkTester({ panelEditor: state, + panels: {}, dashboard: { getModel: () => dashboard, }, @@ -119,6 +121,7 @@ describe('panelEditor actions', () => { const dispatchedActions = await thunkTester({ panelEditor: state, + panels: {}, dashboard: { getModel: () => dashboard, }, diff --git a/public/app/features/dashboard/components/PanelEditor/state/actions.ts b/public/app/features/dashboard/components/PanelEditor/state/actions.ts index f604c29128f..7093dd47cdc 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/actions.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/actions.ts @@ -1,5 +1,8 @@ -import { DashboardModel, PanelModel } from '../../../state'; +import { pick } from 'lodash'; import { ThunkResult } from 'app/types'; +import store from 'app/core/store'; +import { panelModelAndPluginReady } from 'app/features/panel/state/reducers'; +import { cleanUpPanelState, initPanelState } from 'app/features/panel/state/actions'; import { closeEditor, PANEL_EDITOR_UI_STATE_STORAGE_KEY, @@ -8,10 +11,7 @@ import { setPanelEditorUIState, updateEditorInitState, } from './reducers'; -import { cleanUpPanelState, panelModelAndPluginReady } from 'app/features/panel/state/reducers'; -import store from 'app/core/store'; -import { pick } from 'lodash'; -import { initPanelState } from 'app/features/panel/state/actions'; +import { DashboardModel, PanelModel } from '../../../state'; export function initPanelEditor(sourcePanel: PanelModel, dashboard: DashboardModel): ThunkResult { return async (dispatch) => { @@ -63,9 +63,10 @@ export function updateDuplicateLibraryPanels( panel.configRev++; if (pluginChanged) { + const cleanUpKey = panel.key; panel.generateNewKey(); - dispatch(panelModelAndPluginReady({ key: panel.key, plugin: panel.plugin! })); + dispatch(panelModelAndPluginReady({ key: panel.key, plugin: panel.plugin!, cleanUpKey })); } // Resend last query result on source panel query runner @@ -125,9 +126,10 @@ export function exitPanelEditor(): ThunkResult { if (panelTypeChanged) { // Loaded plugin is not included in the persisted properties so is not handled by restoreModel sourcePanel.plugin = panel.plugin; + const cleanUpKey = sourcePanel.key; sourcePanel.generateNewKey(); - await dispatch(panelModelAndPluginReady({ key: sourcePanel.key, plugin: panel.plugin! })); + await dispatch(panelModelAndPluginReady({ key: sourcePanel.key, plugin: panel.plugin!, cleanUpKey })); } // Resend last query result on source panel query runner @@ -138,7 +140,7 @@ export function exitPanelEditor(): ThunkResult { }, 20); } - dispatch(cleanUpPanelState({ key: panel.key })); + dispatch(cleanUpPanelState(panel.key)); dispatch(closeEditor()); }; } diff --git a/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts b/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts index 5768728b784..0bab17d32f9 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts @@ -2,6 +2,13 @@ import { getPanelEditorTabs } from './selectors'; import { PanelPlugin } from '@grafana/data'; import { PanelEditorTabId } from '../types'; import { updateConfig } from '../../../../../core/config'; +import { contextSrv } from 'app/core/services/context_srv'; + +jest.mock('app/core/services/context_srv'); + +const mocks = { + contextSrv: jest.mocked(contextSrv), +}; describe('getPanelEditorTabs selector', () => { it('return no tabs when no plugin provided', () => { @@ -79,5 +86,37 @@ describe('getPanelEditorTabs selector', () => { expect(tabs[1].id).toEqual(PanelEditorTabId.Transform); }); }); + + describe('with unified alerting enabled', () => { + beforeAll(() => { + updateConfig({ unifiedAlertingEnabled: true }); + }); + + it('shows the alert tab for users with read permission', () => { + mocks.contextSrv.hasPermission.mockReturnValue(true); + + const tabs = getPanelEditorTabs(undefined, { + meta: { + id: 'graph', + }, + } as PanelPlugin); + + expect(tabs.length).toEqual(3); + expect(tabs[2].id).toEqual(PanelEditorTabId.Alert); + }); + + it('hides the alert tab for users with read permission', () => { + mocks.contextSrv.hasPermission.mockReturnValue(false); + + const tabs = getPanelEditorTabs(undefined, { + meta: { + id: 'graph', + }, + } as PanelPlugin); + + expect(tabs.length).toEqual(2); + expect(tabs[1].id).toEqual(PanelEditorTabId.Transform); + }); + }); }); }); diff --git a/public/app/features/dashboard/components/PanelEditor/state/selectors.ts b/public/app/features/dashboard/components/PanelEditor/state/selectors.ts index 64ce72c531f..007945700da 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/selectors.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/selectors.ts @@ -2,6 +2,9 @@ import memoizeOne from 'memoize-one'; import { PanelPlugin } from '@grafana/data'; import { PanelEditorTab, PanelEditorTabId } from '../types'; import { getConfig } from 'app/core/config'; +import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control'; +import { contextSrv } from 'app/core/services/context_srv'; +import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; export const getPanelEditorTabs = memoizeOne((tab?: string, plugin?: PanelPlugin) => { const tabs: PanelEditorTab[] = []; @@ -34,10 +37,14 @@ export const getPanelEditorTabs = memoizeOne((tab?: string, plugin?: PanelPlugin }); } - if ( - ((getConfig().alertingEnabled || getConfig().unifiedAlertingEnabled) && plugin.meta.id === 'graph') || - plugin.meta.id === 'timeseries' - ) { + const { alertingEnabled, unifiedAlertingEnabled } = getConfig(); + const hasRuleReadPermissions = contextSrv.hasPermission(getRulesPermissions(GRAFANA_RULES_SOURCE_NAME).read); + const isAlertingAvailable = alertingEnabled || (unifiedAlertingEnabled && hasRuleReadPermissions); + + const isGraph = plugin.meta.id === 'graph'; + const isTimeseries = plugin.meta.id === 'timeseries'; + + if ((isAlertingAvailable && isGraph) || isTimeseries) { tabs.push({ id: PanelEditorTabId.Alert, text: 'Alert', diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 860e645f6bb..c73c0c6fb16 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -4,7 +4,6 @@ import { connect, ConnectedProps } from 'react-redux'; import { locationService } from '@grafana/runtime'; import { selectors } from '@grafana/e2e-selectors'; import { CustomScrollbar, stylesFactory, Themeable2, withTheme2 } from '@grafana/ui'; - import { createErrorNotification } from 'app/core/copy/appNotification'; import { Branding } from 'app/core/components/Branding/Branding'; import { DashboardGrid } from '../dashgrid/DashboardGrid'; @@ -78,6 +77,7 @@ export interface State { showLoadingState: boolean; panelNotFound: boolean; editPanelAccessDenied: boolean; + scrollElement?: HTMLDivElement; } export class UnthemedDashboardPage extends PureComponent { @@ -224,14 +224,14 @@ export class UnthemedDashboardPage extends PureComponent { } if (dashboard.canEditPanel(panel)) { - return { ...state, editPanel: panel }; + return { ...state, editPanel: panel, rememberScrollTop: state.scrollElement?.scrollTop }; } else { return { ...state, editPanelAccessDenied: true }; } } // Leaving edit mode else if (state.editPanel && !urlEditPanelId) { - return { ...state, editPanel: null }; + return { ...state, editPanel: null, updateScrollTop: state.rememberScrollTop }; } // Entering view mode @@ -245,11 +245,7 @@ export class UnthemedDashboardPage extends PureComponent { // Should move this state out of dashboard in the future dashboard.initViewPanel(panel); - return { - ...state, - viewPanel: panel, - updateScrollTop: 0, - }; + return { ...state, viewPanel: panel, rememberScrollTop: state.scrollElement?.scrollTop, updateScrollTop: 0 }; } // Leaving view mode else if (state.viewPanel && !urlViewPanelId) { @@ -290,6 +286,10 @@ export class UnthemedDashboardPage extends PureComponent { this.setState({ updateScrollTop: 0 }); }; + setScrollRef = (scrollElement: HTMLDivElement): void => { + this.setState({ scrollElement }); + }; + getInspectPanel() { const { dashboard, queryParams } = this.props; @@ -346,6 +346,7 @@ export class UnthemedDashboardPage extends PureComponent {
    { @@ -67,6 +67,7 @@ function dashboardGridScenario(description: string, scenarioFn: (ctx: ScenarioCo editPanel: null, viewPanel: null, dashboard: getTestDashboard(), + cleanAndRemoveMany: jest.fn, }, setProps: (props: Partial) => { Object.assign(ctx.props, props); diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 34a5eeb6926..29d6578dc9a 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -13,11 +13,13 @@ import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core import { DashboardPanel } from './DashboardPanel'; import { DashboardModel, PanelModel } from '../state'; import { Subscription } from 'rxjs'; +import { connect, ConnectedProps } from 'react-redux'; import { DashboardPanelsChangedEvent } from 'app/types/events'; +import { cleanAndRemoveMany } from 'app/features/panel/state/actions'; import { GridPos } from '../state/PanelModel'; import { config } from '@grafana/runtime'; -export interface Props { +export interface OwnProps { dashboard: DashboardModel; editPanel: PanelModel | null; viewPanel: PanelModel | null; @@ -27,7 +29,15 @@ export interface State { isLayoutInitialized: boolean; } -export class DashboardGrid extends PureComponent { +const mapDispatchToProps = { + cleanAndRemoveMany, +}; + +const connector = connect(null, mapDispatchToProps); + +export type Props = OwnProps & ConnectedProps; + +export class DashboardGridUnconnected extends PureComponent { private panelMap: { [key: string]: PanelModel } = {}; private eventSubs = new Subscription(); private windowHeight = 1200; @@ -51,6 +61,7 @@ export class DashboardGrid extends PureComponent { componentWillUnmount() { this.eventSubs.unsubscribe(); + this.props.cleanAndRemoveMany(Object.keys(this.panelMap)); } buildLayout() { @@ -315,3 +326,5 @@ function translateGridHeightToScreenHeight(gridHeight: number): number { } GrafanaGridItem.displayName = 'GridItemWithDimensions'; + +export const DashboardGrid = connector(DashboardGridUnconnected); diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx index 668ed6e8d42..0401ecf9397 100644 --- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -5,7 +5,7 @@ import { PanelChromeAngular } from './PanelChromeAngular'; import { DashboardModel, PanelModel } from '../state'; import { StoreState } from 'app/types'; import { PanelPlugin } from '@grafana/data'; -import { cleanUpPanelState, setPanelInstanceState } from '../../panel/state/reducers'; +import { setPanelInstanceState } from '../../panel/state/reducers'; import { initPanelState } from '../../panel/state/actions'; import { LazyLoader } from './LazyLoader'; @@ -17,7 +17,6 @@ export interface OwnProps { isViewing: boolean; width: number; height: number; - skipStateCleanUp?: boolean; lazy?: boolean; } @@ -35,7 +34,6 @@ const mapStateToProps = (state: StoreState, props: OwnProps) => { const mapDispatchToProps = { initPanelState, - cleanUpPanelState, setPanelInstanceState, }; @@ -48,8 +46,6 @@ export class DashboardPanelUnconnected extends PureComponent { lazy: true, }; - specialPanels: { [key: string]: Function } = {}; - componentDidMount() { this.props.panel.isInView = !this.props.lazy; if (!this.props.plugin) { @@ -57,13 +53,6 @@ export class DashboardPanelUnconnected extends PureComponent { } } - componentWillUnmount() { - // Most of the time an unmount should result in cleanup but in PanelEdit it should not - if (!this.props.skipStateCleanUp) { - this.props.cleanUpPanelState({ key: this.props.stateKey }); - } - } - onInstanceStateChange = (value: any) => { this.props.setPanelInstanceState({ key: this.props.stateKey, value }); }; diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 04776c6cbdc..874127c04d0 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -171,6 +171,7 @@ export class DashboardModel implements TimeModel { this.links = data.links ?? []; this.gnetId = data.gnetId || null; this.panels = map(data.panels ?? [], (panelData: any) => new PanelModel(panelData)); + this.ensurePanelsHaveIds(); this.formatDate = this.formatDate.bind(this); this.resetOriginalVariables(true); @@ -452,6 +453,22 @@ export class DashboardModel implements TimeModel { this.panelsAffectedByVariableChange = null; } + private ensurePanelsHaveIds() { + for (const panel of this.panels) { + if (!panel.id) { + panel.id = this.getNextPanelId(); + } + + if (panel.panels) { + for (const rowPanel of panel.panels) { + if (!rowPanel.id) { + rowPanel.id = this.getNextPanelId(); + } + } + } + } + } + private ensureListExist(data: any) { if (!data) { data = {}; diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index eef4165468d..654bf8cdab1 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -19,6 +19,7 @@ import { config, locationService } from '@grafana/runtime'; import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; import { getIfExistsLastKey } from '../../variables/state/selectors'; import { toStateKey } from 'app/features/variables/utils'; +import store from 'app/core/store'; export interface InitDashboardArgs { urlUid?: string; @@ -34,6 +35,13 @@ async function fetchDashboard( dispatch: ThunkDispatch, getState: () => StoreState ): Promise { + // When creating new or adding panels to a dashboard from explore we load it from local storage + const model = store.getObject(DASHBOARD_FROM_LS_KEY); + if (model) { + removeDashboardToFetchFromLocalStorage(); + return model; + } + try { switch (args.routeName) { case DashboardRoutes.Home: { @@ -200,7 +208,7 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { }; } -function getNewDashboardModelData(urlFolderId?: string | null): any { +export function getNewDashboardModelData(urlFolderId?: string | null): any { const data = { meta: { canStar: false, @@ -226,3 +234,13 @@ function getNewDashboardModelData(urlFolderId?: string | null): any { return data; } + +const DASHBOARD_FROM_LS_KEY = 'DASHBOARD_FROM_LS_KEY'; + +export function setDashboardToFetchFromLocalStorage(model: DashboardDTO) { + store.setObject(DASHBOARD_FROM_LS_KEY, model); +} + +export function removeDashboardToFetchFromLocalStorage() { + store.delete(DASHBOARD_FROM_LS_KEY); +} diff --git a/public/app/features/dashboard/utils/panel.ts b/public/app/features/dashboard/utils/panel.ts index 2c68b00cd5b..d92497a43f0 100644 --- a/public/app/features/dashboard/utils/panel.ts +++ b/public/app/features/dashboard/utils/panel.ts @@ -1,5 +1,6 @@ // Store import store from 'app/core/store'; +import { dispatch } from 'app/store/store'; // Models import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; @@ -21,6 +22,7 @@ import { ShareModal } from 'app/features/dashboard/components/ShareModal'; import { ShowConfirmModalEvent, ShowModalReactEvent } from '../../../types/events'; import { AddLibraryPanelModal } from 'app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal'; import { UnlinkModal } from 'app/features/library-panels/components/UnlinkModal/UnlinkModal'; +import { cleanUpPanelState } from 'app/features/panel/state/actions'; export const removePanel = (dashboard: DashboardModel, panel: PanelModel, ask: boolean) => { // confirm deletion @@ -46,6 +48,7 @@ export const removePanel = (dashboard: DashboardModel, panel: PanelModel, ask: b } dashboard.removePanel(panel); + dispatch(cleanUpPanelState(panel.key)); }; export const duplicatePanel = (dashboard: DashboardModel, panel: PanelModel) => { diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index b2dcdadee76..54fa40cc03d 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -24,6 +24,7 @@ import { } from './reducers'; import { getDataSource, getDataSourceMeta } from './selectors'; import { accessControlQueryParam } from 'app/core/utils/accessControl'; +import { contextSrv } from '../../../core/services/context_srv'; export interface DataSourceTypesLoadedPayload { plugins: DataSourcePluginMeta[]; @@ -217,6 +218,9 @@ export function addDataSource(plugin: DataSourcePluginMeta): ThunkResult { const result = await getBackendSrv().post('/api/datasources', newInstance); await getDatasourceSrv().reload(); + + await contextSrv.fetchUserPermissions(); + locationService.push(`/datasources/edit/${result.datasource.uid}`); }; } diff --git a/public/app/features/explore/AddToDashboard/AddToDashboardModal.test.tsx b/public/app/features/explore/AddToDashboard/AddToDashboardModal.test.tsx deleted file mode 100644 index ffd6b0f9fa4..00000000000 --- a/public/app/features/explore/AddToDashboard/AddToDashboardModal.test.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React from 'react'; -import { act, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { AddToDashboardModal } from './AddToDashboardModal'; -import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; -import * as dashboardApi from 'app/features/manage-dashboards/state/actions'; - -const createFolder = (title: string, id: number): DashboardSearchHit => ({ - title, - id, - isStarred: false, - type: DashboardSearchItemType.DashFolder, - items: [], - url: '', - uri: '', - tags: [], -}); - -describe('Add to Dashboard Modal', () => { - const searchFoldersResponse = Promise.resolve([createFolder('Folder 1', 1), createFolder('Folder 2', 2)]); - - const waitForSearchFolderResponse = async () => { - return act(async () => { - // FolderPicker asynchronously sets its internal state based on search results, causing warnings when testing. - // Given we are not aware of the component implementation to wait on certain element to appear or disappear (for example a loading indicator), - // we wait for the mocked promise we know it internally uses. - // This is less than ideal as we are relying on implementation details, but is a reasonable solution for this test's scope - await searchFoldersResponse; - }); - }; - - beforeEach(() => { - jest.spyOn(dashboardApi, 'searchFolders').mockReturnValue(searchFoldersResponse); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe('Save to new dashboard', () => { - it('Does not submit if the form is invalid', async () => { - const saveMock = jest.fn(); - - render( {}} />); - - // there shouldn't be any alert in the modal - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - - const dashboardNameInput = screen.getByRole('textbox', { name: /dashboard name/i }); - - // dashboard name is required - await userEvent.clear(dashboardNameInput); - - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); - - // The error message should appear - await screen.findByRole('alert'); - - // Create dashboard API is not invoked - expect(saveMock).not.toHaveBeenCalled(); - }); - - it('Correctly submits if the form is valid', async () => { - const saveMock = jest.fn(); - - render( {}} />); - await waitForSearchFolderResponse(); - - const dashboardNameInput = screen.getByRole('textbox', { name: /dashboard name/i }); - - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); - - await waitFor(() => { - expect(screen.getByRole('button', { name: /save and keep exploring/i })).toBeEnabled(); - }); - - expect(saveMock).toHaveBeenCalledWith( - { - dashboardName: dashboardNameInput.value, - folderId: 1, - }, - expect.anything() - ); - }); - }); - - describe('Handling API errors', () => { - it('Correctly handles name-exist API Error', async () => { - // name-exists is triggered when trying to create a dashboard in a folder that already has a dashboard with the same name - const saveMock = jest.fn().mockResolvedValue({ status: 'name-exists', message: 'name exists' }); - - render( {}} />); - - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); - - expect(await screen.findByRole('alert')).toHaveTextContent( - 'A dashboard with the same name already exists in this folder.' - ); - }); - - it('Correctly handles empty name API Error', async () => { - // empty-name is triggered when trying to create a dashboard having an empty name. - // FE validation usually avoids this use case, but can be triggered by using only whitespaces in - // dashboard name field - const saveMock = jest.fn().mockResolvedValue({ status: 'empty-name', message: 'empty name' }); - - render( {}} />); - - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); - - expect(await screen.findByRole('alert')).toHaveTextContent('Dashboard name is required.'); - }); - - it('Correctly handles name match API Error', async () => { - // name-match, triggered when trying to create a dashboard in a folder that has the same name. - // it doesn't seem to ever be triggered, but matches the error in - // https://github.com/grafana/grafana/blob/44f1e381cbc7a5e236b543bc6bd06b00e3152d7f/pkg/models/dashboards.go#L71 - const saveMock = jest.fn().mockResolvedValue({ status: 'name-match', message: 'name match' }); - - render( {}} />); - - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); - - expect(await screen.findByRole('alert')).toHaveTextContent('name match'); - }); - - it('Correctly handles unknown API Errors', async () => { - const saveMock = jest.fn().mockResolvedValue({ status: 'unknown-error', message: 'unknown error' }); - - render( {}} />); - - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); - - expect(await screen.findByRole('alert')).toHaveTextContent('unknown error'); - }); - }); -}); diff --git a/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx b/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx index 4ef879a9829..161f6b57443 100644 --- a/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx +++ b/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx @@ -1,136 +1,201 @@ -import React, { useState } from 'react'; -import { Alert, Button, Field, Input, InputControl, Modal } from '@grafana/ui'; -import { FolderPicker } from 'app/core/components/Select/FolderPicker'; -import { useForm } from 'react-hook-form'; -import { SaveToNewDashboardDTO } from './addToDashboard'; +import React, { useEffect, useState } from 'react'; +import { Alert, Button, Field, InputControl, Modal, RadioButtonGroup } from '@grafana/ui'; +import { locationUtil, SelectableValue } from '@grafana/data'; +import { setDashboardInLocalStorage, AddToDashboardError } from './addToDashboard'; +import { useSelector } from 'react-redux'; +import { ExploreId } from 'app/types'; +import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; +import { DeepMap, FieldError, useForm } from 'react-hook-form'; +import { config, locationService, reportInteraction } from '@grafana/runtime'; +import { getExploreItemSelector } from '../state/selectors'; +import { partial } from 'lodash'; +import { removeDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard'; -export interface ErrorResponse { - status: string; - message?: string; +enum SaveTarget { + NewDashboard = 'new-dashboard', + ExistingDashboard = 'existing-dashboard', } -const ERRORS = { - NAME_REQUIRED: 'Dashboard name is required.', - NAME_EXISTS: 'A dashboard with the same name already exists in this folder.', - INVALID_FIELD: 'This field is invalid.', - UNKNOWN_ERROR: 'An unknown error occurred while saving the dashboard. Please try again.', - INVALID_FOLDER: 'Select a valid folder to save your dashboard in.', -}; +const SAVE_TARGETS: Array> = [ + { + label: 'New Dashboard', + value: SaveTarget.NewDashboard, + }, + { + label: 'Existing Dashboard', + value: SaveTarget.ExistingDashboard, + }, +]; -type FormDTO = SaveToNewDashboardDTO; +interface SaveTargetDTO { + saveTarget: SaveTarget; +} +interface SaveToNewDashboardDTO extends SaveTargetDTO { + saveTarget: SaveTarget.NewDashboard; +} + +interface SaveToExistingDashboard extends SaveTargetDTO { + saveTarget: SaveTarget.ExistingDashboard; + dashboardUid: string; +} + +type FormDTO = SaveToNewDashboardDTO | SaveToExistingDashboard; + +function assertIsSaveToExistingDashboardError( + errors: DeepMap +): asserts errors is DeepMap { + // the shape of the errors object is always compatible with the type above, but we need to + // explicitly assert its type so that TS can narrow down FormDTO to SaveToExistingDashboard + // when we use it in the form. +} + +function getDashboardURL(dashboardUid?: string) { + return dashboardUid ? `d/${dashboardUid}` : 'dashboard/new'; +} + +enum GenericError { + UNKNOWN = 'unknown-error', + NAVIGATION = 'navigation-error', +} + +interface SubmissionError { + error: AddToDashboardError | GenericError; + message: string; +} interface Props { onClose: () => void; - onSave: (data: FormDTO, redirect: boolean) => Promise; + exploreId: ExploreId; } -function withRedirect(fn: (redirect: boolean, ...args: T) => {}, redirect: boolean) { - return async (...args: T) => fn(redirect, ...args); -} - -export const AddToDashboardModal = ({ onClose, onSave }: Props) => { - const [submissionError, setSubmissionError] = useState(); +export const AddToDashboardModal = ({ onClose, exploreId }: Props) => { + const exploreItem = useSelector(getExploreItemSelector(exploreId))!; + const [submissionError, setSubmissionError] = useState(); const { - register, handleSubmit, control, - formState: { errors, isSubmitting }, - setError, - } = useForm(); + formState: { errors }, + watch, + } = useForm({ + defaultValues: { saveTarget: SaveTarget.NewDashboard }, + }); + const saveTarget = watch('saveTarget'); - const onSubmit = async (withRedirect: boolean, data: FormDTO) => { + const onSubmit = async (openInNewTab: boolean, data: FormDTO) => { setSubmissionError(undefined); - const error = await onSave(data, withRedirect); + const dashboardUid = data.saveTarget === SaveTarget.ExistingDashboard ? data.dashboardUid : undefined; - if (error) { - switch (error.status) { - case 'name-match': - // error.message should always be defined here - setError('dashboardName', { message: error.message ?? ERRORS.INVALID_FIELD }); + reportInteraction('e2d_submit', { + newTab: openInNewTab, + saveTarget: data.saveTarget, + queries: exploreItem.queries.length, + }); + + try { + await setDashboardInLocalStorage({ + dashboardUid, + datasource: exploreItem.datasourceInstance?.getRef(), + queries: exploreItem.queries, + queryResponse: exploreItem.queryResponse, + }); + } catch (error) { + switch (error) { + case AddToDashboardError.FETCH_DASHBOARD: + setSubmissionError({ error, message: 'Could not fetch dashboard information. Please try again.' }); break; - case 'empty-name': - setError('dashboardName', { message: ERRORS.NAME_REQUIRED }); - break; - case 'name-exists': - setError('dashboardName', { message: ERRORS.NAME_EXISTS }); + case AddToDashboardError.SET_DASHBOARD_LS: + setSubmissionError({ error, message: 'Could not add panel to dashboard. Please try again.' }); break; default: - setSubmissionError(error.message ?? ERRORS.UNKNOWN_ERROR); + setSubmissionError({ error: GenericError.UNKNOWN, message: 'Something went wrong. Please try again.' }); } + return; } + + const dashboardURL = getDashboardURL(dashboardUid); + if (!openInNewTab) { + onClose(); + locationService.push(locationUtil.stripBaseFromUrl(dashboardURL)); + return; + } + + const didTabOpen = !!global.open(config.appUrl + dashboardURL, '_blank'); + if (!didTabOpen) { + setSubmissionError({ + error: GenericError.NAVIGATION, + message: 'Could not navigate to the selected dashboard. Please try again.', + }); + removeDashboardToFetchFromLocalStorage(); + return; + } + onClose(); }; + useEffect(() => { + reportInteraction('e2d_open'); + }, []); + return (
    -

    Create a new dashboard and add a panel with the explored queries.

    + ( + + + + )} + name="saveTarget" + /> - - - - - - ( - onChange(e.id)} {...field} enableCreateNew inputId="folder" /> - )} - control={control} - name="folderId" - shouldUnregister - rules={{ required: { value: true, message: ERRORS.INVALID_FOLDER } }} - /> - + {saveTarget === SaveTarget.ExistingDashboard && + (() => { + assertIsSaveToExistingDashboardError(errors); + return ( + ( + + onChange(d?.uid)} + /> + + )} + control={control} + name="dashboardUid" + shouldUnregister + rules={{ required: { value: true, message: 'This field is required.' } }} + /> + ); + })()} {submissionError && ( - - {submissionError} + + {submissionError.message} )} - - diff --git a/public/app/features/explore/AddToDashboard/addToDashboard.test.ts b/public/app/features/explore/AddToDashboard/addToDashboard.test.ts new file mode 100644 index 00000000000..b81c7c9ca48 --- /dev/null +++ b/public/app/features/explore/AddToDashboard/addToDashboard.test.ts @@ -0,0 +1,146 @@ +import { DataQuery, MutableDataFrame } from '@grafana/data'; +import { ExplorePanelData } from 'app/types'; +import { createEmptyQueryResponse } from '../state/utils'; +import { setDashboardInLocalStorage } from './addToDashboard'; +import * as api from 'app/features/dashboard/state/initDashboard'; +import { backendSrv } from 'app/core/services/backend_srv'; + +describe('addPanelToDashboard', () => { + let spy: jest.SpyInstance; + beforeAll(() => { + spy = jest.spyOn(api, 'setDashboardToFetchFromLocalStorage'); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('Correct datasource ref is used', async () => { + await setDashboardInLocalStorage({ + queries: [], + queryResponse: createEmptyQueryResponse(), + datasource: { type: 'loki', uid: 'someUid' }, + }); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + dashboard: expect.objectContaining({ + panels: expect.arrayContaining([expect.objectContaining({ datasource: { type: 'loki', uid: 'someUid' } })]), + }), + }) + ); + }); + + it('All queries are correctly passed through', async () => { + const queries: DataQuery[] = [{ refId: 'A' }, { refId: 'B', hide: true }]; + + await setDashboardInLocalStorage({ + queries, + queryResponse: createEmptyQueryResponse(), + }); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + dashboard: expect.objectContaining({ + panels: expect.arrayContaining([expect.objectContaining({ targets: expect.arrayContaining(queries) })]), + }), + }) + ); + }); + + it('Previous panels should not be removed', async () => { + const queries: DataQuery[] = [{ refId: 'A' }]; + const existingPanel = { prop: 'this should be kept' }; + jest.spyOn(backendSrv, 'getDashboardByUid').mockResolvedValue({ + dashboard: { + templating: { list: [] }, + title: 'Previous panels should not be removed', + uid: 'someUid', + panels: [existingPanel], + }, + meta: {}, + }); + + await setDashboardInLocalStorage({ + queries, + queryResponse: createEmptyQueryResponse(), + dashboardUid: 'someUid', + datasource: { type: '' }, + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + dashboard: expect.objectContaining({ + panels: expect.arrayContaining([ + expect.objectContaining({ targets: expect.arrayContaining(queries) }), + existingPanel, + ]), + }), + }) + ); + }); + + describe('Setting visualization type', () => { + describe('Defaults to table', () => { + const cases: Array<[string, DataQuery[], ExplorePanelData]> = [ + ['If response is empty', [{ refId: 'A' }], createEmptyQueryResponse()], + ['If no query is active', [{ refId: 'A', hide: true }], createEmptyQueryResponse()], + [ + 'If no query is active, even when there is a response from a previous execution', + [{ refId: 'A', hide: true }], + { ...createEmptyQueryResponse(), logsFrames: [new MutableDataFrame({ refId: 'A', fields: [] })] }, + ], + [ + // trace view is not supported in dashboards, we expect to fallback to table panel + 'If there are trace frames', + [{ refId: 'A' }], + { ...createEmptyQueryResponse(), traceFrames: [new MutableDataFrame({ refId: 'A', fields: [] })] }, + ], + ]; + + it.each(cases)('%s', async (_, queries, queryResponse) => { + await setDashboardInLocalStorage({ queries, queryResponse }); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + dashboard: expect.objectContaining({ + panels: expect.arrayContaining([expect.objectContaining({ type: 'table' })]), + }), + }) + ); + }); + }); + + describe('Correctly set visualization based on response', () => { + type TestArgs = { + framesType: string; + expectedPanel: string; + }; + // Note: traceFrames test is "duplicated" in "Defaults to table" tests. + // This is intentional as a way to enforce explicit tests for that case whenever in the future we'll + // add support for creating traceview panels + it.each` + framesType | expectedPanel + ${'logsFrames'} | ${'logs'} + ${'graphFrames'} | ${'timeseries'} + ${'nodeGraphFrames'} | ${'nodeGraph'} + ${'traceFrames'} | ${'table'} + `( + 'Sets visualization to $expectedPanel if there are $frameType frames', + async ({ framesType, expectedPanel }: TestArgs) => { + const queries = [{ refId: 'A' }]; + const queryResponse: ExplorePanelData = { + ...createEmptyQueryResponse(), + [framesType]: [new MutableDataFrame({ refId: 'A', fields: [] })], + }; + + await setDashboardInLocalStorage({ queries, queryResponse }); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + dashboard: expect.objectContaining({ + panels: expect.arrayContaining([expect.objectContaining({ type: expectedPanel })]), + }), + }) + ); + } + ); + }); + }); +}); diff --git a/public/app/features/explore/AddToDashboard/addToDashboard.ts b/public/app/features/explore/AddToDashboard/addToDashboard.ts index e27e041cf7d..6128f221de8 100644 --- a/public/app/features/explore/AddToDashboard/addToDashboard.ts +++ b/public/app/features/explore/AddToDashboard/addToDashboard.ts @@ -1,37 +1,81 @@ -import { DataQuery, DataSourceRef } from '@grafana/data'; -import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { DataFrame, DataQuery, DataSourceRef } from '@grafana/data'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { + getNewDashboardModelData, + setDashboardToFetchFromLocalStorage, +} from 'app/features/dashboard/state/initDashboard'; +import { DashboardDTO, ExplorePanelData } from 'app/types'; -export interface SaveToNewDashboardDTO { - dashboardName: string; - folderId: number; +export enum AddToDashboardError { + FETCH_DASHBOARD = 'fetch-dashboard', + SET_DASHBOARD_LS = 'set-dashboard-ls-error', } -interface SaveOptions { + +interface AddPanelToDashboardOptions { queries: DataQuery[]; - panel: string; - datasource: DataSourceRef; + queryResponse: ExplorePanelData; + datasource?: DataSourceRef; + dashboardUid?: string; } -const createDashboard = ( - dashboardName: string, - folderId: number, - queries: DataQuery[], - datasource: DataSourceRef, - panel: string -) => { - const dashboard = getDashboardSrv().create({ title: dashboardName }, { folderId }); +function createDashboard(): DashboardDTO { + const dto = getNewDashboardModelData(); - dashboard.addPanel({ targets: queries, type: panel, title: 'New Panel', datasource }); + // getNewDashboardModelData adds by default the "add-panel" panel. We don't want that. + dto.dashboard.panels = []; - return getDashboardSrv().saveDashboard({ dashboard, folderId }, { showErrorAlert: false, showSuccessAlert: false }); -}; + return dto; +} -export const addToDashboard = async (data: SaveToNewDashboardDTO, options: SaveOptions): Promise => { - const res = await createDashboard( - data.dashboardName, - data.folderId, - options.queries, - options.datasource, - options.panel - ); - return res.data.url; -}; +export async function setDashboardInLocalStorage(options: AddPanelToDashboardOptions) { + const panelType = getPanelType(options.queries, options.queryResponse); + const panel = { + targets: options.queries, + type: panelType, + title: 'New Panel', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + datasource: options.datasource, + }; + + let dto: DashboardDTO; + + if (options.dashboardUid) { + try { + dto = await backendSrv.getDashboardByUid(options.dashboardUid); + } catch (e) { + throw AddToDashboardError.FETCH_DASHBOARD; + } + } else { + dto = createDashboard(); + } + + dto.dashboard.panels = [panel, ...(dto.dashboard.panels ?? [])]; + + try { + setDashboardToFetchFromLocalStorage(dto); + } catch { + throw AddToDashboardError.SET_DASHBOARD_LS; + } +} + +const isVisible = (query: DataQuery) => !query.hide; +const hasRefId = (refId: DataFrame['refId']) => (frame: DataFrame) => frame.refId === refId; + +function getPanelType(queries: DataQuery[], queryResponse: ExplorePanelData) { + for (const { refId } of queries.filter(isVisible)) { + // traceview is not supported in dashboards, skipping it for now. + const hasQueryRefId = hasRefId(refId); + if (queryResponse.graphFrames.some(hasQueryRefId)) { + return 'timeseries'; + } + if (queryResponse.logsFrames.some(hasQueryRefId)) { + return 'logs'; + } + if (queryResponse.nodeGraphFrames.some(hasQueryRefId)) { + return 'nodeGraph'; + } + } + + // falling back to table + return 'table'; +} diff --git a/public/app/features/explore/AddToDashboard/index.test.tsx b/public/app/features/explore/AddToDashboard/index.test.tsx index 93757bda547..8522c4bb00f 100644 --- a/public/app/features/explore/AddToDashboard/index.test.tsx +++ b/public/app/features/explore/AddToDashboard/index.test.tsx @@ -1,31 +1,25 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { act, render, screen, waitFor } from '@testing-library/react'; -import { ExploreId, ExplorePanelData, ExploreState } from 'app/types'; -import { Provider } from 'react-redux'; -import { configureStore } from 'app/store/configureStore'; import userEvent from '@testing-library/user-event'; -import { DataQuery, MutableDataFrame } from '@grafana/data'; -import { createEmptyQueryResponse } from '../state/utils'; -import { locationService } from '@grafana/runtime'; -import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; -import * as api from './addToDashboard'; -import * as dashboardApi from 'app/features/manage-dashboards/state/actions'; +import { configureStore } from 'app/store/configureStore'; +import { ExploreId, ExploreState } from 'app/types'; +import { Provider } from 'react-redux'; import { AddToDashboard } from '.'; +import * as api from './addToDashboard'; +import { locationService, setEchoSrv } from '@grafana/runtime'; +import * as initDashboard from 'app/features/dashboard/state/initDashboard'; +import { DataQuery } from '@grafana/data'; +import { createEmptyQueryResponse } from '../state/utils'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { DashboardSearchItemType } from 'app/features/search/types'; +import { Echo } from 'app/core/services/echo/Echo'; -const setup = ( - children: JSX.Element, - queries: DataQuery[] = [], - queryResponse: ExplorePanelData = createEmptyQueryResponse() -) => { +const setup = (children: ReactNode, queries: DataQuery[] = [{ refId: 'A' }]) => { const store = configureStore({ explore: { left: { queries, - queryResponse, - datasourceInstance: { - type: 'loki', - uid: 'someuid', - }, + queryResponse: createEmptyQueryResponse(), }, } as ExploreState, }); @@ -33,287 +27,336 @@ const setup = ( return render({children}); }; -const createFolder = (title: string, id: number): DashboardSearchHit => ({ - title, - id, - isStarred: false, - type: DashboardSearchItemType.DashFolder, - items: [], - url: '', - uri: '', - tags: [], -}); - const openModal = async () => { await userEvent.click(screen.getByRole('button', { name: /add to dashboard/i })); expect(await screen.findByRole('dialog', { name: 'Add panel to dashboard' })).toBeInTheDocument(); }; -describe('Add to Dashboard Button', () => { - const searchFoldersResponse = Promise.resolve([createFolder('Folder 1', 1), createFolder('Folder 2', 2)]); - const redirectURL = '/some/redirect/url'; - let addToDashboardMock: jest.SpyInstance< - ReturnType, - Parameters - >; - - const waitForSearchFolderResponse = async () => { - return act(async () => { - await searchFoldersResponse; - }); - }; - - beforeEach(() => { - jest.spyOn(dashboardApi, 'searchFolders').mockReturnValue(searchFoldersResponse); - addToDashboardMock = jest.spyOn(api, 'addToDashboard').mockResolvedValue('/some/redirect/url'); +describe('AddToDashboardButton', () => { + beforeAll(() => { + setEchoSrv(new Echo()); }); - afterEach(() => { - jest.restoreAllMocks(); - }); + it('Is disabled if explore pane has no queries', async () => { + setup(, []); - it('Opens and closes the modal correctly', async () => { - setup(, [{ refId: 'A' }]); + const button = await screen.findByRole('button', { name: /add to dashboard/i }); + expect(button).toBeDisabled(); - await openModal(); - - await userEvent.click(screen.getByRole('button', { name: /cancel/i })); + await userEvent.click(button); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); - describe('navigation', () => { - it('Navigates to dashboard when clicking on "Save and go to dashboard"', async () => { - locationService.push = jest.fn(); + describe('Success path', () => { + const addToDashboardResponse = Promise.resolve(); - setup(, [{ refId: 'A' }]); + const waitForAddToDashboardResponse = async () => { + return act(async () => { + await addToDashboardResponse; + }); + }; - await openModal(); - - await userEvent.click(screen.getByRole('button', { name: /save and go to dashboard/i })); - - await waitForSearchFolderResponse(); - - expect(locationService.push).toHaveBeenCalledWith(redirectURL); + beforeEach(() => { + jest.spyOn(api, 'setDashboardInLocalStorage').mockReturnValue(addToDashboardResponse); }); - it('Does NOT navigate to dashboard when clicking on "Save and keep exploring"', async () => { - locationService.push = jest.fn(); + afterEach(() => { + jest.restoreAllMocks(); + }); - setup(, [{ refId: 'A' }]); + it('Opens and closes the modal correctly', async () => { + setup(); await openModal(); - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); - - await waitForSearchFolderResponse(); + await userEvent.click(screen.getByRole('button', { name: /cancel/i })); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); - expect(locationService.push).not.toHaveBeenCalled(); + describe('navigation', () => { + it('Navigates to dashboard when clicking on "Open"', async () => { + // @ts-expect-error global.open should return a Window, but is not implemented in js-dom. + const openSpy = jest.spyOn(global, 'open').mockReturnValue(true); + const pushSpy = jest.spyOn(locationService, 'push'); + + setup(); + + await openModal(); + + await userEvent.click(screen.getByRole('button', { name: /open$/i })); + + await waitForAddToDashboardResponse(); + + expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument(); + + expect(pushSpy).toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('Navigates to dashboard in a new tab when clicking on "Open in a new tab"', async () => { + // @ts-expect-error global.open should return a Window, but is not implemented in js-dom. + const openSpy = jest.spyOn(global, 'open').mockReturnValue(true); + const pushSpy = jest.spyOn(locationService, 'push'); + + setup(); + + await openModal(); + + await userEvent.click(screen.getByRole('button', { name: /open in new tab/i })); + + await waitForAddToDashboardResponse(); + + expect(openSpy).toHaveBeenCalledWith(expect.anything(), '_blank'); + expect(pushSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Save to new dashboard', () => { + describe('Navigate to correct dashboard when saving', () => { + it('Opens the new dashboard in a new tab', async () => { + // @ts-expect-error global.open should return a Window, but is not implemented in js-dom. + const openSpy = jest.spyOn(global, 'open').mockReturnValue(true); + + setup(); + + await openModal(); + + await userEvent.click(screen.getByRole('button', { name: /open in new tab/i })); + + await waitForAddToDashboardResponse(); + + expect(openSpy).toHaveBeenCalledWith('dashboard/new', '_blank'); + }); + + it('Navigates to the new dashboard', async () => { + const pushSpy = jest.spyOn(locationService, 'push'); + + setup(); + + await openModal(); + + await userEvent.click(screen.getByRole('button', { name: /open$/i })); + + await waitForAddToDashboardResponse(); + + expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument(); + + expect(pushSpy).toHaveBeenCalledWith('dashboard/new'); + }); + }); + }); + + describe('Save to existing dashboard', () => { + it('Renders the dashboard picker when switching to "Existing Dashboard"', async () => { + setup(); + + await openModal(); + + expect(screen.queryByRole('combobox', { name: /dashboard/ })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i })); + expect(screen.getByRole('combobox', { name: /dashboard/ })).toBeInTheDocument(); + }); + + it('Does not submit if no dashboard is selected', async () => { + locationService.push = jest.fn(); + + setup(); + + await openModal(); + + await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i })); + + await userEvent.click(screen.getByRole('button', { name: /open$/i })); + await waitForAddToDashboardResponse(); + + expect(locationService.push).not.toHaveBeenCalled(); + }); + + describe('Navigate to correct dashboard when saving', () => { + it('Opens the selected dashboard in a new tab', async () => { + // @ts-expect-error global.open should return a Window, but is not implemented in js-dom. + const openSpy = jest.spyOn(global, 'open').mockReturnValue(true); + + jest.spyOn(backendSrv, 'getDashboardByUid').mockResolvedValue({ + dashboard: { templating: { list: [] }, title: 'Dashboard Title', uid: 'someUid' }, + meta: {}, + }); + jest.spyOn(backendSrv, 'search').mockResolvedValue([ + { + id: 1, + uid: 'someUid', + isStarred: false, + items: [], + title: 'Dashboard Title', + tags: [], + type: DashboardSearchItemType.DashDB, + uri: 'someUri', + url: 'someUrl', + }, + ]); + + setup(); + + await openModal(); + + await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i })); + + await userEvent.click(screen.getByRole('combobox', { name: /dashboard/i })); + + await waitFor(async () => { + await screen.findByLabelText('Select option'); + }); + await userEvent.click(screen.getByLabelText('Select option')); + + await userEvent.click(screen.getByRole('button', { name: /open in new tab/i })); + + await waitFor(async () => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + expect(openSpy).toBeCalledWith('d/someUid', '_blank'); + }); + + it('Navigates to the selected dashboard', async () => { + const pushSpy = jest.spyOn(locationService, 'push'); + + jest.spyOn(backendSrv, 'getDashboardByUid').mockResolvedValue({ + dashboard: { templating: { list: [] }, title: 'Dashboard Title', uid: 'someUid' }, + meta: {}, + }); + jest.spyOn(backendSrv, 'search').mockResolvedValue([ + { + id: 1, + uid: 'someUid', + isStarred: false, + items: [], + title: 'Dashboard Title', + tags: [], + type: DashboardSearchItemType.DashDB, + uri: 'someUri', + url: 'someUrl', + }, + ]); + + setup(); + + await openModal(); + + await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i })); + + await userEvent.click(screen.getByRole('combobox', { name: /dashboard/i })); + + await waitFor(async () => { + await screen.findByLabelText('Select option'); + }); + await userEvent.click(screen.getByLabelText('Select option')); + + await userEvent.click(screen.getByRole('button', { name: /open$/i })); + + await waitFor(async () => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + expect(pushSpy).toBeCalledWith('d/someUid'); + }); + }); }); }); - it('Correct datasource ref is useed', async () => { - setup(, [{ refId: 'A' }]); + describe('Error handling', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); - await openModal(); + it('Shows an error if opening a new tab fails', async () => { + jest.spyOn(global, 'open').mockReturnValue(null); + const removeDashboardSpy = jest.spyOn(initDashboard, 'removeDashboardToFetchFromLocalStorage'); - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + setup(); - await waitFor(() => - expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument() - ); + await openModal(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(addToDashboardMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - datasource: { - type: 'loki', - uid: 'someuid', + await userEvent.click(screen.getByRole('button', { name: /open in new tab/i })); + + await waitFor(async () => { + expect(await screen.findByRole('alert')).toBeInTheDocument(); + }); + + expect(removeDashboardSpy).toHaveBeenCalled(); + }); + + it('Shows an error if saving to localStorage fails', async () => { + jest.spyOn(initDashboard, 'setDashboardToFetchFromLocalStorage').mockImplementation(() => { + throw 'SOME ERROR'; + }); + + setup(); + + await openModal(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /open in new tab/i })); + + await waitFor(async () => { + expect(await screen.findByRole('alert')).toBeInTheDocument(); + }); + }); + + it('Shows an error if fetching dashboard fails', async () => { + jest.spyOn(backendSrv, 'getDashboardByUid').mockRejectedValue('SOME ERROR'); + jest.spyOn(backendSrv, 'search').mockResolvedValue([ + { + id: 1, + uid: 'someUid', + isStarred: false, + items: [], + title: 'Dashboard Title', + tags: [], + type: DashboardSearchItemType.DashDB, + uri: 'someUri', + url: 'someUrl', }, - }) - ); - }); + ]); - it('All queries are correctly passed through', async () => { - const queries: DataQuery[] = [{ refId: 'A' }, { refId: 'B', hide: true }]; - setup(, queries); + setup(); - await openModal(); + await openModal(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i })); - await waitFor(() => - expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument() - ); + await userEvent.click(screen.getByRole('combobox', { name: /dashboard/i })); - expect(addToDashboardMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - queries: queries, - }) - ); - }); + await waitFor(async () => { + await screen.findByLabelText('Select option'); + }); + await userEvent.click(screen.getByLabelText('Select option')); - it('Defaults to table if no response is available', async () => { - const queries: DataQuery[] = [{ refId: 'A' }]; - setup(, queries, createEmptyQueryResponse()); + await userEvent.click(screen.getByRole('button', { name: /open in new tab/i })); - await openModal(); - - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); - - await waitFor(() => - expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument() - ); - - expect(addToDashboardMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - panel: 'table', - }) - ); - }); - - it('Defaults to table if no query is active', async () => { - const queries: DataQuery[] = [{ refId: 'A', hide: true }]; - setup(, queries); - - await openModal(); - - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); - - await waitFor(() => - expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument() - ); - - expect(addToDashboardMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - panel: 'table', - }) - ); - }); - - it('Filters out hidden queries when selecting visualization', async () => { - const queries: DataQuery[] = [{ refId: 'A', hide: true }, { refId: 'B' }]; - setup(, queries, { - ...createEmptyQueryResponse(), - graphFrames: [new MutableDataFrame({ refId: 'B', fields: [] })], - logsFrames: [new MutableDataFrame({ refId: 'A', fields: [] })], + await waitFor(async () => { + expect(await screen.findByRole('alert')).toBeInTheDocument(); + }); }); - await openModal(); + it('Shows an error if an unknown error happens', async () => { + jest.spyOn(api, 'setDashboardInLocalStorage').mockRejectedValue('SOME ERROR'); - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); + setup(); - await waitFor(() => - expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument() - ); + await openModal(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - // Query A comes before B, but it's hidden. visualization will be picked according to frames generated by B - expect(addToDashboardMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - queries: queries, - panel: 'timeseries', - }) - ); - }); + await userEvent.click(screen.getByRole('button', { name: /open in new tab/i })); - it('Sets visualization to logs if there are log frames', async () => { - const queries: DataQuery[] = [{ refId: 'A' }]; - setup(, queries, { - ...createEmptyQueryResponse(), - logsFrames: [new MutableDataFrame({ refId: 'A', fields: [] })], + await waitFor(async () => { + expect(await screen.findByRole('alert')).toBeInTheDocument(); + }); }); - - await openModal(); - - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); - - await waitFor(() => - expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument() - ); - - // Query A comes before B, but it's hidden. visualization will be picked according to frames generated by B - expect(addToDashboardMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - panel: 'logs', - }) - ); - }); - - it('Sets visualization to timeseries if there are graph frames', async () => { - const queries: DataQuery[] = [{ refId: 'A' }]; - setup(, queries, { - ...createEmptyQueryResponse(), - graphFrames: [new MutableDataFrame({ refId: 'A', fields: [] })], - }); - - await openModal(); - - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); - - await waitFor(() => - expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument() - ); - - expect(addToDashboardMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - panel: 'timeseries', - }) - ); - }); - - it('Sets visualization to nodeGraph if there are node graph frames', async () => { - const queries: DataQuery[] = [{ refId: 'A' }]; - setup(, queries, { - ...createEmptyQueryResponse(), - nodeGraphFrames: [new MutableDataFrame({ refId: 'A', fields: [] })], - }); - - await openModal(); - - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); - - await waitFor(() => - expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument() - ); - - expect(addToDashboardMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - panel: 'nodeGraph', - }) - ); - }); - - // trace view is not supported in dashboards, defaulting to table - it('Sets visualization to table if there are trace frames', async () => { - const queries: DataQuery[] = [{ refId: 'A' }]; - setup(, queries, { - ...createEmptyQueryResponse(), - traceFrames: [new MutableDataFrame({ refId: 'A', fields: [] })], - }); - - await openModal(); - - await userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i })); - - await waitFor(() => - expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument() - ); - - expect(addToDashboardMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - panel: 'table', - }) - ); }); }); diff --git a/public/app/features/explore/AddToDashboard/index.tsx b/public/app/features/explore/AddToDashboard/index.tsx index 6cc0f31303f..50d19c3e643 100644 --- a/public/app/features/explore/AddToDashboard/index.tsx +++ b/public/app/features/explore/AddToDashboard/index.tsx @@ -1,41 +1,9 @@ import React, { useState } from 'react'; -import { DataFrame, DataQuery } from '@grafana/data'; -import { ExploreId, StoreState } from 'app/types'; -import { useSelector, useDispatch } from 'react-redux'; -import { getExploreItemSelector } from '../state/selectors'; -import { addToDashboard, SaveToNewDashboardDTO } from './addToDashboard'; -import { locationService } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; -import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { ExploreId } from 'app/types'; +import { AddToDashboardModal } from './AddToDashboardModal'; import { ToolbarButton } from '@grafana/ui'; -import { AddToDashboardModal, ErrorResponse } from './AddToDashboardModal'; - -const isVisible = (query: DataQuery) => !query.hide; -const hasRefId = (refId: DataFrame['refId']) => (frame: DataFrame) => frame.refId === refId; - -const getMainPanel = ( - queries: DataQuery[], - graphFrames?: DataFrame[], - logsFrames?: DataFrame[], - nodeGraphFrames?: DataFrame[] -) => { - for (const { refId } of queries.filter(isVisible)) { - // traceview is not supported in dashboards, skipping it for now. - const hasQueryRefId = hasRefId(refId); - if (graphFrames?.some(hasQueryRefId)) { - return 'timeseries'; - } - if (logsFrames?.some(hasQueryRefId)) { - return 'logs'; - } - if (nodeGraphFrames?.some(hasQueryRefId)) { - return 'nodeGraph'; - } - } - - // falling back to table - return 'table'; -}; +import { useSelector } from 'react-redux'; +import { getExploreItemSelector } from '../state/selectors'; interface Props { exploreId: ExploreId; @@ -43,41 +11,8 @@ interface Props { export const AddToDashboard = ({ exploreId }: Props) => { const [isOpen, setIsOpen] = useState(false); - const dispatch = useDispatch(); const selectExploreItem = getExploreItemSelector(exploreId); - - const { queries, panel, datasource } = useSelector((state: StoreState) => { - const exploreItem = selectExploreItem(state); - const queries = exploreItem?.queries || []; - const datasource = exploreItem?.datasourceInstance; - const { graphFrames, logsFrames, nodeGraphFrames } = exploreItem?.queryResponse || {}; - - return { - queries, - datasource: { type: datasource?.type, uid: datasource?.uid }, - panel: getMainPanel(queries, graphFrames, logsFrames, nodeGraphFrames), - }; - }); - - const handleSave = async (data: SaveToNewDashboardDTO, redirect: boolean): Promise => { - try { - const redirectURL = await addToDashboard(data, { - queries, - datasource, - panel, - }); - - if (redirect) { - locationService.push(redirectURL); - } else { - dispatch(notifyApp(createSuccessNotification(`Panel saved to ${data.dashboardName}`))); - setIsOpen(false); - } - return; - } catch (e) { - return { message: e.data?.message, status: e.data?.status ?? 'unknown-error' }; - } - }; + const explorePaneHasQueries = !!useSelector(selectExploreItem)?.queries?.length; return ( <> @@ -85,12 +20,12 @@ export const AddToDashboard = ({ exploreId }: Props) => { icon="apps" onClick={() => setIsOpen(true)} aria-label="Add to dashboard" - disabled={queries.length === 0} + disabled={!explorePaneHasQueries} > Add to dashboard - {isOpen && setIsOpen(false)} onSave={handleSave} />} + {isOpen && setIsOpen(false)} exploreId={exploreId} />} ); }; diff --git a/public/app/features/explore/ErrorContainer.test.tsx b/public/app/features/explore/ErrorContainer.test.tsx index 589cdba349c..accc19c1877 100644 --- a/public/app/features/explore/ErrorContainer.test.tsx +++ b/public/app/features/explore/ErrorContainer.test.tsx @@ -11,7 +11,7 @@ describe('ErrorContainer', () => { error: 'Error data content', }, message: 'Error message', - status: 'Error status', + status: 500, statusText: 'Error status text', refId: 'A', }, @@ -30,7 +30,7 @@ describe('ErrorContainer', () => { message: 'Error data message', error: 'Error data content', }, - status: 'Error status', + status: 500, statusText: 'Error status text', refId: 'A', }, diff --git a/public/app/features/explore/ErrorContainer.tsx b/public/app/features/explore/ErrorContainer.tsx index 675b2dcf6f7..fffa9c10313 100644 --- a/public/app/features/explore/ErrorContainer.tsx +++ b/public/app/features/explore/ErrorContainer.tsx @@ -1,8 +1,7 @@ import React, { FunctionComponent } from 'react'; import { DataQueryError } from '@grafana/data'; -import { Alert, useTheme2 } from '@grafana/ui'; +import { Alert } from '@grafana/ui'; import { FadeIn } from 'app/core/components/Animations/FadeIn'; -import { css } from '@emotion/css'; export interface ErrorContainerProps { queryError?: DataQueryError; @@ -10,18 +9,14 @@ export interface ErrorContainerProps { export const ErrorContainer: FunctionComponent = (props) => { const { queryError } = props; - const theme = useTheme2(); const showError = queryError ? true : false; const duration = showError ? 100 : 10; const title = queryError ? 'Query error' : 'Unknown error'; const message = queryError?.message || queryError?.data?.message || null; - const alertWithTopMargin = css` - margin-top: ${theme.spacing(2)}; - `; return ( - + {message} diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx new file mode 100644 index 00000000000..b9ce6c9f5f7 --- /dev/null +++ b/public/app/features/explore/Explore.test.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { DataSourceApi, LoadingState, CoreApp, createTheme } from '@grafana/data'; +import { render, screen } from '@testing-library/react'; +import { ExploreId } from 'app/types/explore'; +import { Explore, Props } from './Explore'; +import { scanStopAction } from './state/query'; +import { Provider } from 'react-redux'; +import { configureStore } from 'app/store/configureStore'; +import { AutoSizerProps } from 'react-virtualized-auto-sizer'; +import { createEmptyQueryResponse } from './state/utils'; + +const makeEmptyQueryResponse = (loadingState: LoadingState) => { + const baseEmptyResponse = createEmptyQueryResponse(); + + baseEmptyResponse.request = { + requestId: '1', + intervalMs: 0, + interval: '1s', + dashboardId: 0, + panelId: 1, + range: baseEmptyResponse.timeRange, + scopedVars: { + apps: { + value: 'value', + text: 'text', + }, + }, + targets: [ + { + refId: 'A', + }, + ], + timezone: 'UTC', + app: CoreApp.Explore, + startTime: 0, + }; + + baseEmptyResponse.state = loadingState; + + return baseEmptyResponse; +}; + +const dummyProps: Props = { + logsResult: undefined, + changeSize: jest.fn(), + datasourceInstance: { + meta: { + metrics: true, + logs: true, + }, + components: { + QueryEditorHelp: {}, + }, + } as DataSourceApi, + datasourceMissing: false, + exploreId: ExploreId.left, + loading: false, + modifyQueries: jest.fn(), + scanStart: jest.fn(), + scanStopAction: scanStopAction, + setQueries: jest.fn(), + queryKeys: [], + isLive: false, + syncedTimes: false, + updateTimeRange: jest.fn(), + makeAbsoluteTime: jest.fn(), + graphResult: [], + absoluteRange: { + from: 0, + to: 0, + }, + timeZone: 'UTC', + queryResponse: makeEmptyQueryResponse(LoadingState.NotStarted), + addQueryRow: jest.fn(), + theme: createTheme(), + showMetrics: true, + showLogs: true, + showTable: true, + showTrace: true, + showNodeGraph: true, + splitOpen: (() => {}) as any, + logsVolumeData: undefined, + loadLogsVolumeData: () => {}, + changeGraphStyle: () => {}, + graphStyle: 'lines', +}; + +jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => { + return { + getDataSourceSrv: () => ({ + get: () => Promise.resolve({}), + getList: () => [], + getInstanceSettings: () => {}, + }), + }; +}); + +// for the AutoSizer component to have a width +jest.mock('react-virtualized-auto-sizer', () => { + return ({ children }: AutoSizerProps) => children({ height: 1, width: 1 }); +}); + +const setup = (overrideProps?: Partial) => { + const store = configureStore(); + const exploreProps = { ...dummyProps, ...overrideProps }; + + return render( + + + + ); +}; + +describe('Explore', () => { + it('should not render no data with not started loading state', () => { + setup(); + expect(screen.queryByTestId('explore-no-data')).not.toBeInTheDocument(); + }); + + it('should render no data with done loading state', async () => { + const queryResp = makeEmptyQueryResponse(LoadingState.Done); + setup({ queryResponse: queryResp }); + expect(screen.getByTestId('explore-no-data')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index a793f1f537f..38ec9457441 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -34,6 +34,7 @@ import appEvents from 'app/core/app_events'; import { AbsoluteTimeEvent } from 'app/types/events'; import { Unsubscribable } from 'rxjs'; import { getNodeGraphDataFrames } from 'app/plugins/panel/nodeGraph/utils'; +import { NoData } from './NoData'; const getStyles = (theme: GrafanaTheme2) => { return { @@ -213,6 +214,10 @@ export class Explore extends React.PureComponent { ); } + renderNoData() { + return ; + } + renderGraphPanel(width: number) { const { graphResult, absoluteRange, timeZone, splitOpen, queryResponse, loading, theme, graphStyle } = this.props; const spacing = parseInt(theme.spacing(2).slice(0, -2), 10); @@ -334,6 +339,15 @@ export class Explore extends React.PureComponent { const showPanels = queryResponse && queryResponse.state !== LoadingState.NotStarted; const showRichHistory = openDrawer === ExploreDrawer.RichHistory; const showQueryInspector = openDrawer === ExploreDrawer.QueryInspector; + const showNoData = + queryResponse.state === LoadingState.Done && + [ + queryResponse.logsFrames, + queryResponse.graphFrames, + queryResponse.nodeGraphFrames, + queryResponse.tableFrames, + queryResponse.traceFrames, + ].every((e) => e.length === 0); return ( { {showLogs && {this.renderLogsPanel(width)}} {showNodeGraph && {this.renderNodeGraphPanel()}} {showTrace && {this.renderTraceViewPanel()}} + {showNoData && {this.renderNoData()}} )} {showRichHistory && ( diff --git a/public/app/features/explore/LogsMetaRow.tsx b/public/app/features/explore/LogsMetaRow.tsx index 7a62da7a8ad..c7841420117 100644 --- a/public/app/features/explore/LogsMetaRow.tsx +++ b/public/app/features/explore/LogsMetaRow.tsx @@ -71,7 +71,7 @@ export const LogsMetaRow: React.FC = React.memo( label: 'Your logs might have incorrectly escaped content', value: ( +
    {tokens && ( )} - + {contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) && ( + + )} ); diff --git a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx index 2c94e38b171..368134f9077 100644 --- a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx @@ -1,9 +1,10 @@ import React, { PureComponent, useRef, useState } from 'react'; -import { Role, ServiceAccountDTO } from 'app/types'; +import { Role, ServiceAccountDTO, AccessControlAction } from 'app/types'; import { css, cx } from '@emotion/css'; import { dateTimeFormat, GrafanaTheme2, OrgRole, TimeZone } from '@grafana/data'; import { Button, ConfirmButton, ConfirmModal, Input, LegacyInputStatus, useStyles2 } from '@grafana/ui'; import { ServiceAccountRoleRow } from './ServiceAccountRoleRow'; +import { contextSrv } from 'app/core/core'; interface Props { serviceAccount: ServiceAccountDTO; @@ -26,6 +27,8 @@ export function ServiceAccountProfile({ const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDisableModal, setShowDisableModal] = useState(false); + const ableToWrite = contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite); + const deleteServiceAccountRef = useRef(null); const showDeleteServiceAccountModal = (show: boolean) => () => { setShowDeleteModal(show); @@ -66,18 +69,27 @@ export function ServiceAccountProfile({ return ( <> -

    Information

    - -