diff --git a/.betterer.results b/.betterer.results index a354be90586..57cc3ad8dce 100644 --- a/.betterer.results +++ b/.betterer.results @@ -230,21 +230,6 @@ exports[`no enzyme tests`] = { "public/app/features/explore/LiveLogs.test.tsx:1667605379": [ [2, 17, 13, "RegExp match", "2409514259"] ], - "public/app/features/explore/RichHistory/RichHistory.test.tsx:409631018": [ - [1, 17, 13, "RegExp match", "2409514259"] - ], - "public/app/features/explore/RichHistory/RichHistoryCard.test.tsx:689438177": [ - [1, 19, 13, "RegExp match", "2409514259"] - ], - "public/app/features/explore/RichHistory/RichHistoryContainer.test.tsx:396471778": [ - [1, 17, 13, "RegExp match", "2409514259"] - ], - "public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx:3436519226": [ - [1, 17, 13, "RegExp match", "2409514259"] - ], - "public/app/features/explore/RichHistory/RichHistorySettings.test.tsx:538589654": [ - [1, 17, 13, "RegExp match", "2409514259"] - ], "public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx:3948011811": [ [1, 17, 13, "RegExp match", "2409514259"] ], diff --git a/.drone.yml b/.drone.yml index 9d4c9f1550a..a64dddc89aa 100644 --- a/.drone.yml +++ b/.drone.yml @@ -11,7 +11,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -114,7 +114,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -356,7 +356,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -420,7 +420,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -503,7 +503,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -565,8 +565,6 @@ trigger: branch: main event: - push - repo: - - grafana/grafana type: docker volumes: - host: @@ -585,7 +583,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -668,8 +666,6 @@ trigger: branch: main event: - push - repo: - - grafana/grafana type: docker volumes: - host: @@ -688,7 +684,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -727,6 +723,8 @@ steps: paths: include: - .drone.yml + repo: + - grafana/grafana - image: grafana/drone-downstream name: trigger-enterprise-downstream settings: @@ -903,7 +901,8 @@ steps: - commands: - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json - gcloud auth activate-service-account --key-file=/tmp/gcpkey.json - - gsutil -m rsync -d -r ./packages/grafana-ui/dist/storybook gs://$${PRERELEASE_BUCKET}/artifacts/storybook/canary + - gsutil -m rm -r gs://$${PRERELEASE_BUCKET}/artifacts/storybook/canary && gsutil + -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${PRERELEASE_BUCKET}/artifacts/storybook/canary depends_on: - build-storybook - end-to-end-tests-dashboards-suite @@ -917,6 +916,9 @@ steps: from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: store-storybook + when: + repo: + - grafana/grafana - commands: - yarn wait-on http://$HOST:$PORT - pa11y-ci --config .pa11yci.conf.js --json > pa11y-ci-results.json @@ -940,6 +942,9 @@ steps: failure: ignore image: grafana/build-container:1.5.3 name: publish-frontend-metrics + when: + repo: + - grafana/grafana - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ @@ -989,6 +994,9 @@ steps: volumes: - name: docker path: /var/run/docker.sock + when: + repo: + - grafana/grafana - commands: - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana-oss --base alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 @@ -1007,6 +1015,9 @@ steps: volumes: - name: docker path: /var/run/docker.sock + when: + repo: + - grafana/grafana - commands: - ./scripts/circle-release-canary-packages.sh depends_on: @@ -1019,6 +1030,9 @@ steps: from_secret: npm_token image: grafana/build-container:1.5.3 name: release-canary-npm-packages + when: + repo: + - grafana/grafana - commands: - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads depends_on: @@ -1033,6 +1047,9 @@ steps: from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-packages + when: + repo: + - grafana/grafana - commands: - ./bin/grabpl upload-cdn --edition oss --src-bucket "grafana-static-assets" depends_on: @@ -1044,12 +1061,13 @@ steps: from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-cdn-assets + when: + repo: + - grafana/grafana trigger: branch: main event: - push - repo: - - grafana/grafana type: docker volumes: - host: @@ -1094,7 +1112,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -1132,8 +1150,6 @@ trigger: branch: main event: - push - repo: - - grafana/grafana type: docker volumes: - host: @@ -1164,7 +1180,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1251,7 +1267,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -1319,8 +1335,6 @@ trigger: branch: main event: - push - repo: - - grafana/grafana status: - failure type: docker @@ -1337,7 +1351,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -1580,8 +1594,10 @@ steps: - commands: - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json - gcloud auth activate-service-account --key-file=/tmp/gcpkey.json - - gsutil -m rsync -d -r ./packages/grafana-ui/dist/storybook gs://$${PRERELEASE_BUCKET}/artifacts/storybook/latest - - gsutil -m rsync -d -r ./packages/grafana-ui/dist/storybook gs://$${PRERELEASE_BUCKET}/artifacts/storybook/${DRONE_TAG} + - gsutil -m rm -r gs://$${PRERELEASE_BUCKET}/artifacts/storybook/latest && 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} && + gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${PRERELEASE_BUCKET}/artifacts/storybook/${DRONE_TAG} depends_on: - build-storybook - end-to-end-tests-dashboards-suite @@ -1595,16 +1611,10 @@ steps: from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: store-storybook -- commands: - - ./scripts/build/build-npm-packages.sh ${DRONE_TAG} - depends_on: - - store-storybook - image: grafana/build-container:1.5.3 - name: build-npm-packages - commands: - ./bin/grabpl artifacts npm store --tag ${DRONE_TAG} depends_on: - - build-npm-packages + - build-frontend-packages environment: GCP_KEY: from_secret: gcp_key @@ -1645,7 +1655,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -1766,7 +1776,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -1850,7 +1860,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1909,7 +1919,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -2170,6 +2180,17 @@ steps: from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-packages +- commands: + - ./bin/grabpl artifacts npm store --tag ${DRONE_TAG} + depends_on: + - build-frontend-packages + environment: + GCP_KEY: + from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + 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} @@ -2252,7 +2273,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -2425,7 +2446,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -2553,7 +2574,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -2628,7 +2649,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -2706,7 +2727,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -2767,7 +2788,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -2846,7 +2867,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -2908,7 +2929,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -2944,7 +2965,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -2991,7 +3012,7 @@ steps: name: initialize - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -3041,7 +3062,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -3104,7 +3125,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -3322,6 +3343,9 @@ steps: from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-cdn-assets + when: + repo: + - grafana/grafana - commands: - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads depends_on: @@ -3336,11 +3360,12 @@ steps: from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-packages + when: + repo: + - grafana/grafana trigger: ref: - refs/heads/v[0-9]* - repo: - - grafana/grafana type: docker volumes: - host: @@ -3365,7 +3390,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -3441,8 +3466,6 @@ steps: trigger: ref: - refs/heads/v[0-9]* - repo: - - grafana/grafana type: docker volumes: - host: @@ -3481,7 +3504,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -3528,8 +3551,6 @@ steps: trigger: ref: - refs/heads/v[0-9]* - repo: - - grafana/grafana type: docker volumes: - host: @@ -3560,7 +3581,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -3586,8 +3607,6 @@ steps: trigger: ref: - refs/heads/v[0-9]* - repo: - - grafana/grafana type: docker volumes: - host: @@ -3610,7 +3629,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -3859,6 +3878,9 @@ steps: from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-cdn-assets + when: + repo: + - grafana/grafana - commands: - ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads depends_on: @@ -3870,6 +3892,9 @@ steps: from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-packages + when: + repo: + - grafana/grafana - commands: - ./bin/grabpl package --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} --variants linux-amd64 --sign @@ -3917,8 +3942,6 @@ steps: trigger: ref: - refs/heads/v[0-9]* - repo: - - grafana/grafana type: docker volumes: - host: @@ -3947,7 +3970,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -4062,8 +4085,6 @@ steps: trigger: ref: - refs/heads/v[0-9]* - repo: - - grafana/grafana type: docker volumes: - host: @@ -4112,7 +4133,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl + - 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 @@ -4196,8 +4217,6 @@ steps: trigger: ref: - refs/heads/v[0-9]* - repo: - - grafana/grafana type: docker volumes: - host: @@ -4232,7 +4251,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -4278,8 +4297,6 @@ steps: trigger: ref: - refs/heads/v[0-9]* - repo: - - grafana/grafana type: docker volumes: - host: @@ -4431,6 +4448,6 @@ kind: secret name: gcp_upload_artifacts_key --- kind: signature -hmac: 430843c058dc4f40a3f881e13fc61ab12f5b6dc823f99067e56ab49fc1bdd459 +hmac: ec763e70ae08f79845dd4a59c56e6d08ef8e6b39a1f59663aabcc6d9fe758287 ... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cde59147f7e..cc67825fdb8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -164,3 +164,7 @@ lerna.json @grafana/frontend-ops # Cloud middleware /grafana-mixin/ @grafana/hosted-grafana-team + +# Grafana authentication and authorization +/pkg/services/accesscontrol @grafana/grafana-enterprise-operations-team +/pkg/services/serviceaccounts @grafana/grafana-enterprise-operations-team diff --git a/.github/workflows/cloud-data-sources-code-coverage.yml b/.github/workflows/cloud-data-sources-code-coverage.yml new file mode 100644 index 00000000000..a1af9904e89 --- /dev/null +++ b/.github/workflows/cloud-data-sources-code-coverage.yml @@ -0,0 +1,17 @@ +name: Cloud data sources test code coverage +on: + pull_request: + paths: + - 'pkg/tsdb/azuremonitor/**' + - 'pkg/tsdb/cloudwatch/**' + - 'pkg/tsdb/cloudmonitoring/**' + - 'public/app/plugins/datasource/grafana-azure-monitor-datasource/**' + - 'public/app/plugins/datasource/cloudwatch/**' + - 'public/app/plugins/datasource/cloud-monitoring/**' + branches-ignore: + - dependabot/** + - backport-** + +jobs: + workflow-call: + uses: grafana/code-coverage/.github/workflows/code-coverage.yml@v0.1.2 diff --git a/.yarnrc.yml b/.yarnrc.yml index 8740e79a6d0..98678568a76 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -34,7 +34,7 @@ packageExtensions: doctrine@3.0.0: dependencies: assert: 2.0.0 - moveable@0.27.3: + moveable@0.28.0: dependencies: "@daybrush/utils": 1.6.0 framework-utils: ^1.1.0 @@ -49,13 +49,13 @@ packageExtensions: react-compat-css-styled@1.0.8: dependencies: react-simple-compat: 1.2.1 - react-compat-moveable@0.15.2: + react-compat-moveable@0.16.0: dependencies: "@egjs/agent": ^2.2.1 "@egjs/children-differ": ^1.0.1 "@scena/matrix": 1.1.1 css-to-mat: ^1.0.3 - gesto: ^1.4.0 + gesto: ^1.7.0 overlap-area: ^1.0.0 react-simple-compat: 1.2.1 peerDependencies: diff --git a/conf/defaults.ini b/conf/defaults.ini index 3f0a03de907..d0ab0c1a16c 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -193,12 +193,19 @@ reporting_enabled = true reporting_distributor = grafana-labs # Set to false to disable all checks to https://grafana.com -# for new versions (grafana itself and plugins), check is used -# in some UI views to notify that grafana or plugin update exists +# for new versions of grafana. The check is used +# in some UI views to notify that a grafana update exists. # This option does not cause any auto updates, nor send any information -# only a GET request to https://grafana.com to get latest versions +# only a GET request to https://raw.githubusercontent.com/grafana/grafana/main/latest.json to get the latest version. check_for_updates = true +# Set to false to disable all checks to https://grafana.com +# for new versions of plugins. The check is used +# in some UI views to notify that a plugin update exists. +# This option does not cause any auto updates, nor send any information +# only a GET request to https://grafana.com to get the latest versions. +check_for_plugin_updates = true + # Google Analytics universal tracking code, only enabled if you specify an id here google_analytics_ua_id = diff --git a/conf/sample.ini b/conf/sample.ini index d56075fd037..f20d03f7461 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -198,13 +198,20 @@ # The name of the distributor of the Grafana instance. Ex hosted-grafana, grafana-labs ;reporting_distributor = grafana-labs -# Set to false to disable all checks to https://grafana.net -# for new versions (grafana itself and plugins), check is used -# in some UI views to notify that grafana or plugin update exists +# Set to false to disable all checks to https://grafana.com +# for new versions of grafana. The check is used +# in some UI views to notify that a grafana update exists. # This option does not cause any auto updates, nor send any information -# only a GET request to http://grafana.com to get latest versions +# only a GET request to https://raw.githubusercontent.com/grafana/grafana/main/latest.json to get the latest version. ;check_for_updates = true +# Set to false to disable all checks to https://grafana.com +# for new versions of plugins. The check is used +# in some UI views to notify that a plugin update exists. +# This option does not cause any auto updates, nor send any information +# only a GET request to https://grafana.com to get the latest versions. +;check_for_plugin_updates = true + # Google Analytics universal tracking code, only enabled if you specify an id here ;google_analytics_ua_id = diff --git a/contribute/documentation/README.md b/contribute/documentation/README.md index 9fdd2492c34..dc9820bbabf 100644 --- a/contribute/documentation/README.md +++ b/contribute/documentation/README.md @@ -212,10 +212,10 @@ of the sentence. When you write in passive voice, the recipient of the action (a Active-voice sentences are more direct and clearly identify _who_ is doing _what_. Not all tasks are completed by a user; sometimes the system can also be a performer (and by extension, the subject of the sentence). When you write in active voice you clearly make that distinction, which results in more engaging and less wordy content. | Use (active) | Avoid (passive) | -| ----------------------------------------------------- | -------------------------------------------------------------------- | --- | +| ----------------------------------------------------- | -------------------------------------------------------------------- | | After you upgrade the software, restart the computer. | After the software has been upgraded, the computer can be restarted. | | Click **OK** to save the dashboard. | The dashboard is saved when the **OK** button is clicked. | -| Create a dashboard. | A dashboard is created by you. | . | +| Create a dashboard. | A dashboard is created by you. | ### Avoid obscure non-English words and abbreviations diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md index c44ce31b24a..58cdcc98e3b 100644 --- a/docs/sources/administration/configuration.md +++ b/docs/sources/administration/configuration.md @@ -461,7 +461,13 @@ value is `true`. ### check_for_updates -Set to false to disable all checks to https://grafana.com for new versions of installed plugins and to the Grafana GitHub repository to check for a newer version of Grafana. The version information is used in some UI views to notify that a new Grafana update or a plugin update exists. This option does not cause any auto updates, nor send any sensitive information. The check is run every 10 minutes. +Set to false, disables checking for new versions of Grafana from Grafana's GitHub repository. When enabled, the check for a new version runs every 10 minutes. It will notify, via the UI, when a new version is available. The check itself will not prompt any auto-updates of the Grafana software, nor will it send any sensitive information. + +### check_for_plugin_updates + +> **Note**: Available in Grafana v8.5.0 and later versions. + +Set to false disables checking for new versions of installed plugins from https://grafana.com. When enabled, the check for a new plugin runs every 10 minutes. It will notify, via the UI, when a new plugin update exists. The check itself will not prompt any auto-updates of the plugin, nor will it send any sensitive information. ### google_analytics_ua_id diff --git a/docs/sources/alerting/_index.md b/docs/sources/alerting/_index.md index d94d09cda63..54c7bfb19c2 100644 --- a/docs/sources/alerting/_index.md +++ b/docs/sources/alerting/_index.md @@ -10,7 +10,7 @@ Alerts allow you to learn about problems in your systems moments after they occu Grafana 8.0 introduced new and improved alerting that centralizes alerting information in a single, searchable view. It allows you to: - Create and manage Grafana alerts -- Create and manage Cortex and Loki managed alerts +- Create and manage Grafana Mimir and Loki managed alerts - View alerting information from Prometheus and Alertmanager compatible data sources Grafana alerting is enabled by default for new OSS installations. For older installations, it is still an [opt-in]({{< relref "./unified-alerting/opt-in.md" >}}) feature. diff --git a/docs/sources/alerting/unified-alerting/_index.md b/docs/sources/alerting/unified-alerting/_index.md index c5664fd3bbe..2836b133608 100644 --- a/docs/sources/alerting/unified-alerting/_index.md +++ b/docs/sources/alerting/unified-alerting/_index.md @@ -11,7 +11,7 @@ Grafana 8.0 has new and improved alerting that centralizes alerting information When Grafana alerting is enabled, you can: - [Create Grafana managed alerting rules]({{< relref "alerting-rules/create-grafana-managed-rule.md" >}}) -- [Create Cortex or Loki managed alerting rules]({{< relref "alerting-rules/create-cortex-loki-managed-rule.md" >}}) +- [Create Grafana Mimir or Loki managed alerting rules]({{< relref "alerting-rules/create-mimir-loki-managed-rule.md" >}}) - [View existing alerting rules and manage their current state]({{< relref "alerting-rules/rule-list.md" >}}) - [View the state and health of alerting rules]({{< relref "./fundamentals/state-and-health.md" >}}) - [Add or edit an alert contact point]({{< relref "./contact-points.md" >}}) diff --git a/docs/sources/alerting/unified-alerting/alerting-rules/_index.md b/docs/sources/alerting/unified-alerting/alerting-rules/_index.md index 5b45b1700f3..00dcf726d7b 100644 --- a/docs/sources/alerting/unified-alerting/alerting-rules/_index.md +++ b/docs/sources/alerting/unified-alerting/alerting-rules/_index.md @@ -12,9 +12,9 @@ While queries and expressions select the data set to evaluate, a condition sets You can: -- [Create Cortex or Loki managed alert rule]({{< relref "./create-cortex-loki-managed-rule.md" >}}) -- [Create Cortex or Loki managed recording rule]({{< relref "./create-cortex-loki-managed-recording-rule.md" >}}) -- [Edit Cortex or Loki rule groups and namespaces]({{< relref "./edit-cortex-loki-namespace-group.md" >}}) +- [Create Grafana Mimir or Loki managed alert rule]({{< relref "./create-mimir-loki-managed-rule.md" >}}) +- [Create Grafana Mimir or Loki managed recording rule]({{< relref "./create-mimir-loki-managed-recording-rule.md" >}}) +- [Edit Grafana Mimir or Loki rule groups and namespaces]({{< relref "./edit-mimir-loki-namespace-group.md" >}}) - [Create Grafana managed alert rule]({{< relref "./create-grafana-managed-rule.md" >}}) - [State and health of alerting rules]({{< relref "../fundamentals/state-and-health.md" >}}) - [Manage alerting rules]({{< relref "./rule-list.md" >}}) diff --git a/docs/sources/alerting/unified-alerting/alerting-rules/create-cortex-loki-managed-recording-rule.md b/docs/sources/alerting/unified-alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md similarity index 65% rename from docs/sources/alerting/unified-alerting/alerting-rules/create-cortex-loki-managed-recording-rule.md rename to docs/sources/alerting/unified-alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md index 0b2dc3a6305..d56fb1a41cb 100644 --- a/docs/sources/alerting/unified-alerting/alerting-rules/create-cortex-loki-managed-recording-rule.md +++ b/docs/sources/alerting/unified-alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md @@ -1,38 +1,38 @@ +++ -title = "Create Cortex or Loki managed recording rule" -description = "Create Cortex or Loki managed recording rule" +title = "Create Grafana Mimir or Loki managed recording rule" +description = "Create Grafana Mimir or Loki managed recording rule" keywords = ["grafana", "alerting", "guide", "rules", "recording rules", "create"] weight = 400 +++ -# Create a Cortex or Loki managed recording rule +# Create a Grafana Mimir or Loki managed recording rule -You can create and manage recording rules for an external Cortex or Loki instance. Recording rules calculate frequently needed expressions or computationally expensive expressions in advance and save the result as a new set of time series. Querying this new time series is faster, especially for dashboards since they query the same expression every time the dashboards refresh. +You can create and manage recording rules for an external Grafana Mimir or Loki instance. Recording rules calculate frequently needed expressions or computationally expensive expressions in advance and save the result as a new set of time series. Querying this new time series is faster, especially for dashboards since they query the same expression every time the dashboards refresh. ## Before you begin -For Cortex and Loki data sources to work with Grafana 8.0 alerting, enable the ruler API by configuring their respective services. +For Grafana Mimir and Loki data sources to work with Grafana 8.0 alerting, enable the ruler API by configuring their respective services. **Loki** - The `local` rule storage type, default for the Loki data source, supports only viewing of rules. To edit rules, configure one of the other rule storage types. -**Cortex** - When configuring a Grafana Prometheus data source to point to Cortex, use the legacy `/api/prom` prefix, not `/prometheus`. Currently, we support only single-binary mode and you cannot provide a separate URL for the ruler API. +**Grafana Mimir** - When configuring a Grafana Prometheus data source to point to Grafana Mimir, use the legacy `/api/prom` prefix, not `/prometheus`. Currently, we support only single-binary mode and you cannot provide a separate URL for the ruler API. > **Note:** If you do not want to manage alerting rules for a particular Loki or Prometheus data source, go to its settings page and clear the **Manage alerts via Alerting UI** checkbox. -## Add a Cortex or Loki managed recording rule +## Add a Grafana Mimir or Loki managed recording rule 1. In the Grafana menu, click the **Alerting** (bell) icon to open the Alerting page listing existing alerts. 1. Click **New alert rule**. 1. In Step 1, add the rule name, type, and storage location. - In **Rule name**, add a descriptive name. This name is displayed in the alert rule list. It is also the `alertname` label for every alert instance that is created from this rule. - - From the **Rule type** drop-down, select **Cortex / Loki managed alert**. + - From the **Rule type** drop-down, select **Mimir / Loki managed alert**. - From the **Select data source** drop-down, select an external Prometheus, an external Loki, or a Grafana Cloud data source. - From the **Namespace** drop-down, select an existing rule namespace. Otherwise, click **Add new** and enter a name to create a new one. Namespaces can contain one or more rule groups and only have an organizational purpose. - From the **Group** drop-down, select an existing group within the selected namespace. Otherwise, click **Add new** and enter a name to create a new one. Newly created rules are appended to the end of the group. Rules within a group are run sequentially at a regular interval, with the same evaluation time. - {{< figure src="/static/img/docs/alerting/unified/rule-edit-cortex-alert-type-8-0.png" max-width="550px" caption="Alert details" >}} + {{< figure src="/static/img/docs/alerting/unified/rule-edit-mimir-alert-type-8-0.png" max-width="550px" caption="Alert details" >}} 1. In Step 2, add the query to evaluate. - Enter a PromQL or LogQL expression. The rule fires if the evaluation result has at least one series with a value that is greater than 0. An alert is created for each series. - {{< figure src="/static/img/docs/alerting/unified/rule-edit-cortex-query-8-0.png" max-width="550px" caption="Alert details" >}} + {{< figure src="/static/img/docs/alerting/unified/rule-edit-mimir-query-8-0.png" max-width="550px" caption="Alert details" >}} 1. In Step 3, add additional metadata associated with the rule. - Add a description and summary to customize alert messages. Use the guidelines in [Annotations and labels for alerting]({{< relref "./alert-annotation-label.md" >}}). - Add Runbook URL, panel, dashboard, and alert IDs. diff --git a/docs/sources/alerting/unified-alerting/alerting-rules/create-cortex-loki-managed-rule.md b/docs/sources/alerting/unified-alerting/alerting-rules/create-mimir-loki-managed-rule.md similarity index 67% rename from docs/sources/alerting/unified-alerting/alerting-rules/create-cortex-loki-managed-rule.md rename to docs/sources/alerting/unified-alerting/alerting-rules/create-mimir-loki-managed-rule.md index 49197362786..b91642f5496 100644 --- a/docs/sources/alerting/unified-alerting/alerting-rules/create-cortex-loki-managed-rule.md +++ b/docs/sources/alerting/unified-alerting/alerting-rules/create-mimir-loki-managed-rule.md @@ -1,40 +1,40 @@ +++ -title = "Create Cortex or Loki managed alert rule" -description = "Create Cortex or Loki managed alerting rule" +title = "Create Grafana Mimir or Loki managed alert rule" +description = "Create Grafana Mimir or Loki managed alerting rule" keywords = ["grafana", "alerting", "guide", "rules", "create"] weight = 400 +++ -# Create a Cortex or Loki managed alerting rule +# Create a Grafana Mimir or Loki managed alerting rule -Grafana allows you to create alerting rules for an external Cortex or Loki instance. +Grafana allows you to create alerting rules for an external Grafana Mimir or Loki instance. ## Before you begin -- Verify that you have write permission to the Prometheus data source. Otherwise, you will not be able to create or update Cortex managed alerting rules. +- Verify that you have write permission to the Prometheus data source. Otherwise, you will not be able to create or update Grafana Mimir managed alerting rules. -- For Cortex and Loki data sources, enable the ruler API by configuring their respective services. +- For Grafana Mimir and Loki data sources, enable the ruler API by configuring their respective services. - **Loki** - The `local` rule storage type, default for the Loki data source, supports only viewing of rules. To edit rules, configure one of the other rule storage types. - - **Cortex** - use the [legacy `/api/prom` prefix](https://cortexmetrics.io/docs/api/#path-prefixes), not `/prometheus`. The Prometheus data source supports both Cortex and Prometheus, and Grafana expects that both the [Query API](https://cortexmetrics.io/docs/api/#querier--query-frontend) and [Ruler API](https://cortexmetrics.io/docs/api/#ruler) are under the same URL. You cannot provide a separate URL for the Ruler API. + - **Grafana Mimir** - use the [legacy `/api/prom` prefix](https://grafana.com/docs/mimir/latest/operators-guide/reference-http-api/#path-prefixes), not `/prometheus`. The Prometheus data source supports both Grafana Mimir and Prometheus, and Grafana expects that both the [Query API](https://grafana.com/docs/mimir/latest/operators-guide/reference-http-api/#querier--query-frontend) and [Ruler API](https://grafana.com/docs/mimir/latest/operators-guide/reference-http-api/#ruler) are under the same URL. You cannot provide a separate URL for the Ruler API. > **Note:** If you do not want to manage alerting rules for a particular Loki or Prometheus data source, go to its settings and clear the **Manage alerts via Alerting UI** checkbox. -## Add a Cortex or Loki managed alerting rule +## Add a Grafana Mimir or Loki managed alerting rule 1. In the Grafana menu, click the **Alerting** (bell) icon to open the Alerting page listing existing alerts. 1. Click **New alert rule**. 1. In Step 1, add the rule name, type, and storage location. - In **Rule name**, add a descriptive name. This name is displayed in the alert rule list. It is also the `alertname` label for every alert instance that is created from this rule. - - From the **Rule type** drop-down, select **Cortex / Loki managed alert**. + - From the **Rule type** drop-down, select **Mimir / Loki managed alert**. - From the **Select data source** drop-down, select an external Prometheus, an external Loki, or a Grafana Cloud data source. - - From the **Namespace** drop-down, select an existing rule namespace. Otherwise, click **Add new** and enter a name to create a new one. Namespaces can contain one or more rule groups and only have an organizational purpose. For more information, see [Cortex or Loki rule groups and namespaces]({{< relref "./edit-cortex-loki-namespace-group.md" >}}). + - From the **Namespace** drop-down, select an existing rule namespace. Otherwise, click **Add new** and enter a name to create a new one. Namespaces can contain one or more rule groups and only have an organizational purpose. For more information, see [Grafana Mimir or Loki rule groups and namespaces]({{< relref "./edit-mimir-loki-namespace-group.md" >}}). - From the **Group** drop-down, select an existing group within the selected namespace. Otherwise, click **Add new** and enter a name to create a new one. Newly created rules are appended to the end of the group. Rules within a group are run sequentially at a regular interval, with the same evaluation time. - {{< figure src="/static/img/docs/alerting/unified/rule-edit-cortex-alert-type-8-0.png" max-width="550px" caption="Alert details" >}} + {{< figure src="/static/img/docs/alerting/unified/rule-edit-mimir-alert-type-8-0.png" max-width="550px" caption="Alert details" >}} 1. In Step 2, add the query to evaluate. - Enter a PromQL or LogQL expression. The rule fires if the evaluation result has at least one series with a value that is greater than 0. An alert is created for each series. - {{< figure src="/static/img/docs/alerting/unified/rule-edit-cortex-query-8-0.png" max-width="550px" caption="Alert details" >}} + {{< figure src="/static/img/docs/alerting/unified/rule-edit-mimir-query-8-0.png" max-width="550px" caption="Alert details" >}} 1. In Step 3, add conditions. - In the **For** text box, specify the duration for which the condition must be true before an alert fires. If you specify `5m`, the condition must be true for 5 minutes before the alert fires. > **Note:** Once a condition is met, the alert goes into the `Pending` state. If the condition remains active for the duration specified, the alert transitions to the `Firing` state, else it reverts to the `Normal` state. diff --git a/docs/sources/alerting/unified-alerting/alerting-rules/edit-cortex-loki-namespace-group.md b/docs/sources/alerting/unified-alerting/alerting-rules/edit-mimir-loki-namespace-group.md similarity index 63% rename from docs/sources/alerting/unified-alerting/alerting-rules/edit-cortex-loki-namespace-group.md rename to docs/sources/alerting/unified-alerting/alerting-rules/edit-mimir-loki-namespace-group.md index 5c49fd4dbf3..992d58f2d73 100644 --- a/docs/sources/alerting/unified-alerting/alerting-rules/edit-cortex-loki-namespace-group.md +++ b/docs/sources/alerting/unified-alerting/alerting-rules/edit-mimir-loki-namespace-group.md @@ -1,24 +1,24 @@ +++ -title = "Cortex or Loki rule groups and namespaces" -description = "Edit Cortex or Loki rule groups and namespaces" -keywords = ["grafana", "alerting", "guide", "group", "namespace", "cortex", "loki"] +title = "Grafana Mimir or Loki rule groups and namespaces" +description = "Edit Grafana Mimir or Loki rule groups and namespaces" +keywords = ["grafana", "alerting", "guide", "group", "namespace", "grafana mimir", "loki"] weight = 405 +++ -# Cortex or Loki rule groups and namespaces +# Grafana Mimir or Loki rule groups and namespaces -A namespace contains one or more groups. The rules within a group are run sequentially at a regular interval. The default interval is one (1) minute. You can rename Cortex or Loki rule namespaces and groups, and edit group evaluation intervals. +A namespace contains one or more groups. The rules within a group are run sequentially at a regular interval. The default interval is one (1) minute. You can rename Grafana Mimir or Loki rule namespaces and groups, and edit group evaluation intervals. -![Group list](/static/img/docs/alerting/unified/rule-list-edit-cortex-loki-icon-8-2.png 'Rule group list screenshot') +![Group list](/static/img/docs/alerting/unified/rule-list-edit-mimir-loki-icon-8-2.png 'Rule group list screenshot') -{{< figure src="/static/img/docs/alerting/unified/rule-list-edit-cortex-loki-icon-8-2.png" max-width="550px" caption="Alert details" >}} +{{< figure src="/static/img/docs/alerting/unified/rule-list-edit-mimir-loki-icon-8-2.png" max-width="550px" caption="Alert details" >}} ## Rename a namespace To rename a namespace: 1. In the Grafana menu, click the **Alerting** (bell) icon to open the Alerting page listing existing alerts. -1. Find a Cortex or Loki managed rule with the group that belongs to the namespace you want to edit. +1. Find a Grafana Mimir or Loki managed rule with the group that belongs to the namespace you want to edit. 1. Click the **Edit** (pen) icon. 1. Enter a new name in the **Namespace** field, then click **Save changes**. @@ -29,11 +29,11 @@ A new namespace is created and all groups are copied into this namespace from th The rules within a group are run sequentially at a regular interval, the default interval is one (1) minute. You can modify this interval using the following instructions. 1. n the Grafana menu, click the **Alerting** (bell) icon to open the Alerting page listing existing alerts. -1. Find a Cortex or Loki managed rule with the group you want to edit. +1. Find a Grafana Mimir or Loki managed rule with the group you want to edit. 1. Click **Edit** (pen) icon. 1. Modify the **Rule group** and **Rule group evaluation interval** information as necessary. 1. Click **Save changes**. When you rename the group, a new group with all the rules from the old group is created. The old group is deleted. -![Group edit modal](/static/img/docs/alerting/unified/rule-list-cortex-loki-edit-ns-group-8-2.png 'Rule group edit modal screenshot') +![Group edit modal](/static/img/docs/alerting/unified/rule-list-mimir-loki-edit-ns-group-8-2.png 'Rule group edit modal screenshot') diff --git a/docs/sources/alerting/unified-alerting/alerting-rules/rule-list.md b/docs/sources/alerting/unified-alerting/alerting-rules/rule-list.md index b88d31d6252..3a4213c8298 100644 --- a/docs/sources/alerting/unified-alerting/alerting-rules/rule-list.md +++ b/docs/sources/alerting/unified-alerting/alerting-rules/rule-list.md @@ -9,11 +9,14 @@ weight = 402 The Alerting page lists existing Grafana 8 alerting rules. By default, rules are grouped by types of data sources. The Grafana section lists all Grafana managed rules. Alerting rules for Prometheus compatible data sources are also listed here. You can view alerting rules for Prometheus compatible data sources but you cannot edit them. -The Cortex/Loki rules section lists all rules for external Prometheus or Loki data sources. Cloud alerting rules are also listed in this section. +The Mimir/Loki rules section lists all rules for external Prometheus or Loki data sources. Cloud alerting rules are also listed in this section. -- [View alerting rules](#view-alerting-rule) -- [Filter alerting rules](#filter-alerting-rules) -- [Edit or delete an alerting rule](#edit-or-delete-an-alerting-rule) +- [Manage alerting rules](#manage-alerting-rules) + - [View alerting rules](#view-alerting-rules) + - [Group view](#group-view) + - [State view](#state-view) + - [Filter alerting rules](#filter-alerting-rules) + - [Edit or delete an alerting rule](#edit-or-delete-an-alerting-rule) ## View alerting rules @@ -47,9 +50,9 @@ To filter alerting rules: ## Edit or delete an alerting rule -Grafana managed alerting rules can only be edited or deleted by users with Edit permissions for the folder storing the rules. Alerting rules for an external Cortex or Loki instance can be edited or deleted by users with Editor or Admin roles. +Grafana managed alerting rules can only be edited or deleted by users with Edit permissions for the folder storing the rules. Alerting rules for an external Grafana Mimir or Loki instance can be edited or deleted by users with Editor or Admin roles. To edit or delete a rule: 1. Expand a rule row until you can see the rule controls of **View**, **Edit**, and **Delete**. -1. Click **Edit** to open the create rule page. Make updates following instructions in [Create a Grafana managed alerting rule]({{< relref "./create-grafana-managed-rule.md" >}}) or [Create a Cortex or Loki managed alerting rule]({{< relref "./create-cortex-loki-managed-rule.md" >}}). +1. Click **Edit** to open the create rule page. Make updates following instructions in [Create a Grafana managed alerting rule]({{< relref "./create-grafana-managed-rule.md" >}}) or [Create a Grafana Mimir or Loki managed alerting rule]({{< relref "./create-mimir-loki-managed-rule.md" >}}). 1. Click **Delete** to delete a rule. diff --git a/docs/sources/alerting/unified-alerting/difference-old-new.md b/docs/sources/alerting/unified-alerting/difference-old-new.md index a5875ab4a1c..05b00763ff0 100644 --- a/docs/sources/alerting/unified-alerting/difference-old-new.md +++ b/docs/sources/alerting/unified-alerting/difference-old-new.md @@ -19,9 +19,9 @@ Unlike legacy dashboard alerts, Grafana alerts allow you to create queries and e Since unified alerts are no longer directly tied to panel queries, they do not include images or query values in the notification email. You can use customized notification templates to view query values. -## Create Loki and Cortex alerting rules +## Create Loki and Grafana Mimir alerting rules -In Grafana alerting, you can manage Loki and Cortex alerting rules using the same UI and API as your Grafana managed alerts. +In Grafana alerting, you can manage Loki and Grafana Mimir alerting rules using the same UI and API as your Grafana managed alerts. ## View and search for alerts from Prometheus compatible data sources diff --git a/docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md b/docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md index 9b6fd56dc7f..1b8169c086c 100644 --- a/docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md +++ b/docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md @@ -12,7 +12,7 @@ Grafana includes built-in support for Prometheus Alertmanager. By default, notif > **Note:** Before v8.2, the configuration of the embedded Alertmanager was shared across organizations. If you are on an older Grafana version, we recommend that you use Grafana alerts only if you have one organization. Otherwise, your contact points are visible to all organizations. -Grafana alerting added support for external Alertmanager configuration. When you add an [Alertmanager data source]({{< relref "../../../datasources/alertmanager.md" >}}), the Alertmanager drop-down shows a list of available external Alertmanager data sources. Select a data source to create and manage alerting for standalone Cortex or Loki data sources. +Grafana alerting added support for external Alertmanager configuration. When you add an [Alertmanager data source]({{< relref "../../../datasources/alertmanager.md" >}}), the Alertmanager drop-down shows a list of available external Alertmanager data sources. Select a data source to create and manage alerting for standalone Grafana Mimir or Loki data sources. {{< figure max-width="40%" src="/static/img/docs/alerting/unified/contact-points-select-am-8-0.gif" max-width="250px" caption="Select Alertmanager" >}} diff --git a/docs/sources/datasources/alertmanager.md b/docs/sources/datasources/alertmanager.md index 5822e2fc522..7522fdc7daf 100644 --- a/docs/sources/datasources/alertmanager.md +++ b/docs/sources/datasources/alertmanager.md @@ -12,7 +12,7 @@ Grafana includes built-in support for Prometheus Alertmanager. It is presently i ## Alertmanager implementations -[Prometheus](https://prometheus.io/) and [Cortex](https://cortexmetrics.io/) (default) implementations of Alertmanager are supported. You can specify implementation in the data source settings page. In case of Prometheus contact points and notification policies are read-only in the Grafana alerting UI, as it does not support updating configuration via HTTP API. +[Prometheus](https://prometheus.io/) and [Grafana Mimir](https://grafana.com/docs/mimir/latest/) (default) implementations of Alertmanager are supported. You can specify implementation in the data source settings page. In case of Prometheus contact points and notification policies are read-only in the Grafana alerting UI, as it does not support updating configuration via HTTP API. ## Provision the Alertmanager data source @@ -29,7 +29,6 @@ datasources: url: http://localhost:9093 access: proxy jsonData: - implementation: 'prometheus' # alternatively 'cortex' # optionally basicAuth: true basicAuthUser: my_user diff --git a/docs/sources/datasources/jaeger.md b/docs/sources/datasources/jaeger.md index 3f4b690ec7a..a740f617744 100644 --- a/docs/sources/datasources/jaeger.md +++ b/docs/sources/datasources/jaeger.md @@ -28,15 +28,15 @@ To access Jaeger settings, click the **Configuration** (gear) icon, then click * > **Note:** This feature is available in Grafana 7.4+. -This is a configuration for the [trace to logs feature]({{< relref "../explore/trace-integration" >}}). Select target data source (at this moment limited to Loki data sources) and select which tags will be used in the logs query. +This is a configuration for the [trace to logs feature]({{< relref "../explore/trace-integration" >}}). Select target data source (at this moment limited to Loki and Splunk \[logs\] data sources) and select which tags will be used in the logs query. - **Data source -** Target data source. -- **Tags -** The tags that will be used in the Loki query. Default is `'cluster', 'hostname', 'namespace', 'pod'`. -- **Map tag names -** When enabled, allows configuring how Jaeger tag names map to Loki label names. For example, map `service.name` to `service`. -- **Span start time shift -** Shift in the start time for the Loki query based on the span start time. In order to extend to the past, you need to use a negative value. Use time interval units like 5s, 1m, 3h. The default is 0. -- **Span end time shift -** Shift in the end time for the Loki query based on the span end time. Time units can be used here, for example, 5s, 1m, 3h. The default is 0. -- **Filter by Trace ID -** Toggle to append the trace ID to the Loki query. -- **Filter by Span ID -** Toggle to append the span ID to the Loki query. +- **Tags -** The tags that will be used in the logs query. Default is `'cluster', 'hostname', 'namespace', 'pod'`. +- **Map tag names -** When enabled, allows configuring how Jaeger tag names map to logs label names. For example, map `service.name` to `service`. +- **Span start time shift -** Shift in the start time for the logs query based on the span start time. In order to extend to the past, you need to use a negative value. Use time interval units like 5s, 1m, 3h. The default is 0. +- **Span end time shift -** Shift in the end time for the logs query based on the span end time. Time units can be used here, for example, 5s, 1m, 3h. The default is 0. +- **Filter by Trace ID -** Toggle to append the trace ID to the logs query. +- **Filter by Span ID -** Toggle to append the span ID to the logs query. ![Trace to logs settings](/static/img/docs/explore/trace-to-logs-settings-8-2.png 'Screenshot of the trace to logs settings') @@ -146,8 +146,8 @@ datasources: isDefault: false jsonData: tracesToLogs: - # Field with internal link pointing to a Loki data source in Grafana. - # datasourceUid value must match the `datasourceUid` value of the Loki data source. + # Field with internal link pointing to a logs data source in Grafana. + # datasourceUid value must match the `datasourceUid` value of the logs data source. datasourceUid: 'loki' tags: ['job', 'instance', 'pod', 'namespace'] mappedTags: [{ key: 'service.name', value: 'service' }] diff --git a/docs/sources/datasources/mssql.md b/docs/sources/datasources/mssql.md index 6827b580cef..f56a01070f1 100644 --- a/docs/sources/datasources/mssql.md +++ b/docs/sources/datasources/mssql.md @@ -30,7 +30,7 @@ To access data source settings, hover your mouse over the **Configuration** (gea ### Min time interval -A lower limit for the [$__interval]({{< relref "../variables/variable-types/_index.md#the-interval-variable" >}}) and [$__interval_ms]({{< relref "../variables/variable-types/_index.md#the-interval-ms-variable" >}}) variables. +A lower limit for the [$__interval]({{< relref "../variables/variable-types/global-variables/#__interval" >}}) and [$__interval_ms]({{< relref "../variables/variable-types/global-variables/#__interval_ms" >}}) variables. Recommended to be set to write frequency, for example `1m` if your data is written every minute. This option can also be overridden/configured in a dashboard panel under data source options. It's important to note that this value **needs** to be formatted as a number followed by a valid time identifier, e.g. `1m` (1 minute) or `30s` (30 seconds). The following time identifiers are supported: diff --git a/docs/sources/datasources/mysql.md b/docs/sources/datasources/mysql.md index ab0b7982c96..957cf7e3770 100644 --- a/docs/sources/datasources/mysql.md +++ b/docs/sources/datasources/mysql.md @@ -36,7 +36,7 @@ Grafana ships with a built-in MySQL data source plugin that allows you to query ### Min time interval -A lower limit for the [$__interval]({{< relref "../variables/variable-types/_index.md#the-interval-variable" >}}) and [$__interval_ms]({{< relref "../variables/variable-types/_index.md#the-interval-ms-variable" >}}) variables. +A lower limit for the [$__interval]({{< relref "../variables/variable-types/global-variables/#__interval" >}}) and [$__interval_ms]({{< relref "../variables/variable-types/global-variables/#__interval_ms" >}}) variables. Recommended to be set to write frequency, for example `1m` if your data is written every minute. This option can also be overridden/configured in a dashboard panel under data source options. It's important to note that this value **needs** to be formatted as a number followed by a valid time identifier, e.g. `1m` (1 minute) or `30s` (30 seconds). The following time identifiers are supported: diff --git a/docs/sources/datasources/postgres.md b/docs/sources/datasources/postgres.md index 3e18046d530..24f9fd3456a 100644 --- a/docs/sources/datasources/postgres.md +++ b/docs/sources/datasources/postgres.md @@ -33,7 +33,7 @@ To access PostgreSQL settings, hover your mouse over the **Configuration** (gear ### Min time interval -A lower limit for the [$__interval]({{< relref "../variables/variable-types/_index.md#the-interval-variable" >}}) and [$__interval_ms]({{< relref "../variables/variable-types/_index.md#the-interval-ms-variable" >}}) variables. +A lower limit for the [$__interval]({{< relref "../variables/variable-types/global-variables/#__interval" >}}) and [$__interval_ms]({{< relref "../variables/variable-types/global-variables/#__interval_ms" >}}) variables. Recommended to be set to write frequency, for example `1m` if your data is written every minute. This option can also be overridden/configured in a dashboard panel under data source options. It's important to note that this value **needs** to be formatted as a number followed by a valid time identifier, e.g. `1m` (1 minute) or `30s` (30 seconds). The following time identifiers are supported: diff --git a/docs/sources/datasources/prometheus.md b/docs/sources/datasources/prometheus.md index 82f0dfd83c5..8768966321e 100644 --- a/docs/sources/datasources/prometheus.md +++ b/docs/sources/datasources/prometheus.md @@ -200,7 +200,7 @@ For detailed instructions, refer to [Internal Grafana metrics]({{< relref "../ad The Prometheus data source works with other projects that implement the [Prometheus query API](https://prometheus.io/docs/prometheus/latest/querying/api/) including: -- [Cortex](https://cortexmetrics.io/docs/) +- [Grafana Mimir](https://grafana.com/docs/mimir/latest/) - [Thanos](https://thanos.io/v0.17/components/query.md/) For more information on how to query other Prometheus-compatible projects from Grafana, refer to the specific project documentation. diff --git a/docs/sources/datasources/tempo.md b/docs/sources/datasources/tempo.md index 01b64bce0cc..be78cf56908 100644 --- a/docs/sources/datasources/tempo.md +++ b/docs/sources/datasources/tempo.md @@ -27,15 +27,15 @@ To access Tempo settings, click the **Configuration** (gear) icon, then click ** > **Note:** This feature is available in Grafana 7.4+. -This is a configuration for the [trace to logs feature]({{< relref "../explore/trace-integration" >}}). Select target data source (at this moment limited to Loki data sources) and select which tags will be used in the logs query. +This is a configuration for the [trace to logs feature]({{< relref "../explore/trace-integration" >}}). Select target data source (at this moment limited to Loki or Splunk \[logs\] data sources) and select which tags will be used in the logs query. - **Data source -** Target data source. -- **Tags -** The tags that will be used in the Loki query. Default is `'cluster', 'hostname', 'namespace', 'pod'`. -- **Map tag names -** When enabled, allows configuring how Tempo tag names map to Loki label names. For example, map `service.name` to `service`. -- **Span start time shift -** A shift in the start time for the Loki query based on the start time for the span. To extend the time to the past, use a negative value. You can use time units, for example, 5s, 1m, 3h. The default is 0. -- **Span end time shift -** Shift in the end time for the Loki query based on the span end time. Time units can be used here, for example, 5s, 1m, 3h. The default is 0. -- **Filter by Trace ID -** Toggle to append the trace ID to the Loki query. -- **Filter by Span ID -** Toggle to append the span ID to the Loki query. +- **Tags -** The tags that will be used in the logs query. Default is `'cluster', 'hostname', 'namespace', 'pod'`. +- **Map tag names -** When enabled, allows configuring how Tempo tag names map to logs label names. For example, map `service.name` to `service`. +- **Span start time shift -** A shift in the start time for the logs query based on the start time for the span. To extend the time to the past, use a negative value. You can use time units, for example, 5s, 1m, 3h. The default is 0. +- **Span end time shift -** Shift in the end time for the logs query based on the span end time. Time units can be used here, for example, 5s, 1m, 3h. The default is 0. +- **Filter by Trace ID -** Toggle to append the trace ID to the logs query. +- **Filter by Span ID -** Toggle to append the span ID to the logs query. {{< figure src="/static/img/docs/explore/traces-to-logs-settings-8-2.png" class="docs-image--no-shadow" caption="Screenshot of the trace to logs settings" >}} diff --git a/docs/sources/datasources/zipkin.md b/docs/sources/datasources/zipkin.md index ed530afa4ec..867952bfb91 100644 --- a/docs/sources/datasources/zipkin.md +++ b/docs/sources/datasources/zipkin.md @@ -28,15 +28,15 @@ To access Zipkin settings, click the **Configuration** (gear) icon, then click * > **Note:** This feature is available in Grafana 7.4+. -This is a configuration for the [trace to logs feature]({{< relref "../explore/trace-integration" >}}). Select target data source (at this moment limited to Loki data sources) and select which tags will be used in the logs query. +This is a configuration for the [trace to logs feature]({{< relref "../explore/trace-integration" >}}). Select target data source (at this moment limited to Loki or Splunk \[logs\] data sources) and select which tags will be used in the logs query. - **Data source -** Target data source. -- **Tags -** The tags that will be used in the Loki query. Default is `'cluster', 'hostname', 'namespace', 'pod'`. -- **Map tag names -** When enabled, allows configuring how Zipkin tag names map to Loki label names. For example, map `service.name` to `service`. -- **Span start time shift -** Shift in the start time for the Loki query based on the span start time. In order to extend to the past, you need to use a negative value. Use time interval units like 5s, 1m, 3h. The default is 0. -- **Span end time shift -** Shift in the end time for the Loki query based on the span end time. Time units can be used here, for example, 5s, 1m, 3h. The default is 0. -- **Filter by Trace ID -** Toggle to append the trace ID to the Loki query. -- **Filter by Span ID -** Toggle to append the span ID to the Loki query. +- **Tags -** The tags that will be used in the logs query. Default is `'cluster', 'hostname', 'namespace', 'pod'`. +- **Map tag names -** When enabled, allows configuring how Zipkin tag names map to logs label names. For example, map `service.name` to `service`. +- **Span start time shift -** Shift in the start time for the logs query based on the span start time. In order to extend to the past, you need to use a negative value. Use time interval units like 5s, 1m, 3h. The default is 0. +- **Span end time shift -** Shift in the end time for the logs query based on the span end time. Time units can be used here, for example, 5s, 1m, 3h. The default is 0. +- **Filter by Trace ID -** Toggle to append the trace ID to the logs query. +- **Filter by Span ID -** Toggle to append the span ID to the logs query. ![Trace to logs settings](/static/img/docs/explore/trace-to-logs-settings-8-2.png 'Screenshot of the trace to logs settings') @@ -98,4 +98,4 @@ Here is an example JSON: ## Linking Trace ID from logs -You can link to Zipkin trace from logs in Loki by configuring a derived field with internal link. See [Loki documentation]({{< relref "loki#derived-fields" >}}) for details. +You can link to Zipkin trace from logs in Loki or Splunk by configuring a derived field with internal link. See [Loki documentation]({{< relref "loki#derived-fields" >}}) for details. diff --git a/docs/sources/developers/plugins/add-authentication-for-data-source-plugins.md b/docs/sources/developers/plugins/add-authentication-for-data-source-plugins.md index 4cb9c2bbc0b..563735e79ad 100644 --- a/docs/sources/developers/plugins/add-authentication-for-data-source-plugins.md +++ b/docs/sources/developers/plugins/add-authentication-for-data-source-plugins.md @@ -315,4 +315,15 @@ func (ds *dataSource) QueryData(ctx context.Context, req *backend.QueryDataReque } ``` +The `Authorization` and `X-ID-Token` headers will also be available on the `CallResourceRequest` object on the `CallResource` request in your backend data source when `jsonData.oauthPassThru` is `true`. + +```go +func (ds *dataSource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + token := req.Headers["Authorization"] + idToken := req.Headers["X-ID-Token"] // present if user's token includes an ID token + + // ... +} +``` + > **Note:** Due to a bug in Grafana, using this feature with PostgreSQL can cause a deadlock. For more information, refer to [Grafana causes deadlocks in PostgreSQL, while trying to refresh users token](https://github.com/grafana/grafana/issues/20515). diff --git a/docs/sources/visualizations/news-panel.md b/docs/sources/visualizations/news-panel.md index 020a1ea983b..11c75358198 100644 --- a/docs/sources/visualizations/news-panel.md +++ b/docs/sources/visualizations/news-panel.md @@ -10,3 +10,7 @@ weight = 800 This panel visualization displays an RSS feed. By default, it displays articles from the Grafana Labs blog. Enter the URL of an RSS in the URL field in the Display section. This panel type does not accept any other queries. + +In version 8.5, we discontinued the "Use Proxy" option for Grafana news panels. As a result, RSS feeds that are not configured for request by Grafana's frontend (with the appropriate [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)) may not load. + +If the RSS feed you're trying to display fails to load, consider rehosting the RSS feed or prefixing the RSS URL with your own "CORS proxy". Alternatively, you can use the community [RSS/Atom data source](https://grafana.com/grafana/plugins/volkovlabs-rss-datasource/) in combination with the [Dynamic text](https://grafana.com/grafana/plugins/marcusolsson-dynamictext-panel/) community panel to display the RSS feed. diff --git a/go.mod b/go.mod index 8a948df1eeb..0c8971576ff 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,7 @@ require ( github.com/gosimple/slug v1.9.0 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.0.0 + github.com/grafana/grafana-azure-sdk-go v1.1.0 github.com/grafana/grafana-plugin-sdk-go v0.129.0 github.com/grafana/loki v1.6.2-0.20211015002020-7832783b1caa github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 @@ -127,7 +127,7 @@ require ( require ( cloud.google.com/go v0.97.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.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 github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect @@ -174,7 +174,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.1.0 // indirect + github.com/golang-jwt/jwt/v4 v4.2.0 // 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,7 +182,7 @@ 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 // indirect + github.com/googleapis/gax-go/v2 v2.1.1 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 @@ -246,20 +246,24 @@ require ( 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 // indirect + google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect ) require ( cloud.google.com/go/kms v1.1.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 ) require ( - github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.10.0 // indirect - github.com/Azure/go-autorest/autorest/adal v0.9.17 // 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 @@ -270,9 +274,10 @@ require ( github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect github.com/getkin/kin-openapi v0.91.0 // indirect github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 // indirect - github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f // 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/kylelemons/godebug v1.1.0 // indirect github.com/opencontainers/image-spec v1.0.2 // indirect github.com/pierrec/lz4/v4 v4.1.8 // indirect github.com/segmentio/asm v1.1.1 // indirect diff --git a/go.sum b/go.sum index 26accc85fd1..9d96f8e6df1 100644 --- a/go.sum +++ b/go.sum @@ -106,12 +106,21 @@ github.com/Azure/azure-sdk-for-go v55.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo github.com/Azure/azure-sdk-for-go v57.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v59.3.0+incompatible h1:dPIm0BO4jsMXFcCI/sLTPkBtE7mk8WMuRHA0JeWhlcQ= github.com/Azure/azure-sdk-for-go v59.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0 h1:lhSJz9RMbJcTgxifR1hUNJnn6CNYtbgEDtQV22/9RBA= -github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.10.0 h1:jq5Urf8QJK6h0wr8CMiwggo4OSMkXwpArQlkSjSpaBk= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.10.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= -github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0 h1:v9p9TfTbf7AwNb5NYQt7hI41IfPoLFiFkLtb+bmGjT0= -github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= +github.com/Azure/azure-sdk-for-go/sdk/azcore v0.20.0/go.mod h1:ZPW/Z0kLCTdDZaDbYTetxc9Cxl/2lNqxYHYNOF2bti0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v0.21.0/go.mod h1:fBF9PQNqB8scdgpZ3ufzaLntG0AG7C1WjPMsiFOmfHM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v0.22.0 h1:zBJcBJwte0x6PcPK7XaWDMvK2o2ZM2f1sMaqNNavQ5g= +github.com/Azure/azure-sdk-for-go/sdk/azcore v0.22.0/go.mod h1:fBF9PQNqB8scdgpZ3ufzaLntG0AG7C1WjPMsiFOmfHM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.13.0/go.mod h1:TmXReXZ9yPp5D5TBRMTAtyz+UyOl15Py4hL5E5p6igQ= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.13.2 h1:mM/yraAumqMMIYev6zX0oxHqX6hreUs5wXf76W47r38= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.13.2/go.mod h1:+nVKciyKD2J9TyVcEQ82Bo9b+3F92PiQfHrIE/zqLqM= +github.com/Azure/azure-sdk-for-go/sdk/internal v0.8.1/go.mod h1:KLF4gFr6DcKFZwSuH8w8yEK6DpFl3LP5rhdvAb7Yz5I= +github.com/Azure/azure-sdk-for-go/sdk/internal v0.8.3/go.mod h1:KLF4gFr6DcKFZwSuH8w8yEK6DpFl3LP5rhdvAb7Yz5I= +github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1 h1:sLZ/Y+P/5RRtsXWylBjB5lkgixYfm0MQPiwrSX//JSo= +github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1/go.mod h1:KLF4gFr6DcKFZwSuH8w8yEK6DpFl3LP5rhdvAb7Yz5I= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.4.0 h1:t10+CFWGK92HGTQaYZyXchiVetuEWfND3abV8inz6n8= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.4.0/go.mod h1:LxYa4KH5ni+OMT8DJBAP6FVYg3YFW3ACTJluqpnTDBg= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.2.1 h1:lirjIOHv5RrmDbZXw9lUz/fY68uU05qR4uIef58WMvQ= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.2.1/go.mod h1:j1J9XXIo/eXD7YSrr73sYZTEY/AQ0+/Q6Aa96z1e2j8= github.com/Azure/azure-service-bus-go v0.11.5/go.mod h1:MI6ge2CuQWBVq+ly456MY7XqNLJip5LO1iSFodbNLbU= github.com/Azure/azure-storage-blob-go v0.6.0/go.mod h1:oGfmITT1V6x//CswqY2gtAHND+xIP64/qL7a5QJix0Y= github.com/Azure/azure-storage-blob-go v0.8.0/go.mod h1:lPI3aLPpuLTeUwh1sViKXFxwl2B6teiRqI0deQUvsw0= @@ -188,6 +197,8 @@ github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZ github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 h1:WVsrXCnHlDDX8ls+tootqRE87/hL9S/g4ewig9RsD/c= +github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= @@ -720,7 +731,8 @@ github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/ github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91 h1:Izz0+t1Z5nI16/II7vuEo/nHjodOg0p7+OiDpjX5t1E= github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= -github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dnaeon/go-vcr v1.1.0 h1:ReYa/UBrRyQdant9B4fNHGoCNKw6qh6P0fsdGmZpR7c= +github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY= github.com/docker/distribution v2.6.0-rc.1.0.20170726174610-edc3ab29cdff+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/distribution v2.7.0+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= @@ -1128,9 +1140,12 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/gogo/status v1.0.3/go.mod h1:SavQ51ycCLnc7dGyJxp8YAmudx8xqiVrRf+6IXRsugc= github.com/gogo/status v1.1.0 h1:+eIkrewn5q6b30y+g/BJINVVdi2xH7je5MPJ3ZPK3JA= github.com/gogo/status v1.1.0/go.mod h1:BFv9nrluPLmrS0EmGVvLaPNmRosr9KapBYd5/hpY1WM= +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 h1:XUgk2Ex5veyVFVeLm0xhusUTQybEbexJXrvPNOKkSY0= 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-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= @@ -1335,8 +1350,8 @@ github.com/grafana/go-mssqldb v0.0.0-20210326084033-d0ce3c521036 h1:GplhUk6Xes5J github.com/grafana/go-mssqldb v0.0.0-20210326084033-d0ce3c521036/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/grafana/grafana-aws-sdk v0.10.1 h1:Ksguhjx6EuGLN/5Oc7oZoxuDReJ5RxIH99yqSMpLGUs= github.com/grafana/grafana-aws-sdk v0.10.1/go.mod h1:vFIOHEnY1u5nY0/tge1IHQjPuG6DRKr2ISf/HikUdjE= -github.com/grafana/grafana-azure-sdk-go v1.0.0 h1:RIVQyVb89/y/BnOVsVDcxiMtmWF8NmAX8ql0OJvzwNc= -github.com/grafana/grafana-azure-sdk-go v1.0.0/go.mod h1:xbzMaG74BN4rOP1NYEsCMNWkPbK7GfSU09PGYfQYm+g= +github.com/grafana/grafana-azure-sdk-go v1.1.0 h1:Gh0fjs7jr4Lp5y+4cjn48U+MQaJeV8i9m1ds/1xszto= +github.com/grafana/grafana-azure-sdk-go v1.1.0/go.mod h1:rgrnK9m6CgKlgx4rH3FFP/6dTdyRO6LYC2mVZov35yo= github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58 h1:2ud7NNM7LrGPO4x0NFR8qLq68CqI4SmB7I2yRN2w9oE= github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58/go.mod h1:Vo2TKWfDVmNTELBUM+3lkrZvFtBws0qSZdXhQxRdJrE= github.com/grafana/grafana-plugin-sdk-go v0.94.0/go.mod h1:3VXz4nCv6wH5SfgB3mlW39s+c+LetqSCjFj7xxPC5+M= @@ -1894,6 +1909,7 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= @@ -2063,6 +2079,7 @@ github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuR github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= +github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -2823,6 +2840,7 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= @@ -2847,6 +2865,7 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +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= @@ -3044,6 +3063,7 @@ golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBc 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-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= diff --git a/package.json b/package.json index 84abf2cf7b2..74ddc4c7a4d 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "packages:publishLatest": "lerna publish from-package --contents dist --yes --no-verify-access", "packages:publishNext": "lerna publish from-package --contents dist --dist-tag next --yes --no-verify-access", "packages:publishTest": "lerna publish from-package --contents dist --dist-tag test --yes --no-verify-access", - "packages:publishPrevious": "lerna publish from-package --contents dist --dist-tag previous --yes --no-verify-access", "packages:publishDev": "lerna publish from-package --contents dist --dist-tag dev --yes --registry http://grafana-npm.local:4873 --force-publish=*", "packages:typecheck": "lerna run typecheck", "packages:clean": "lerna run clean", @@ -58,7 +57,7 @@ "betterer:stats": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/reportBettererStats.ts" }, "grafana": { - "whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v8-4/", + "whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v8-5/", "releaseNotesUrl": "https://grafana.com/docs/grafana/next/release-notes/" }, "lint-staged": { @@ -72,6 +71,9 @@ ], "*pkg/**/*.go": [ "gofmt -w -s" + ], + "*.{star}": [ + "make drone" ] }, "devDependencies": { @@ -329,7 +331,7 @@ "monaco-promql": "^1.7.2", "mousetrap": "1.6.5", "mousetrap-global-bind": "1.1.0", - "moveable": "0.27.3", + "moveable": "0.28.0", "ol": "6.14.1", "papaparse": "5.3.2", "pluralize": "^8.0.0", @@ -390,11 +392,12 @@ "resolutions": { "underscore": "1.13.2", "@types/slate": "0.47.2", - "@microsoft/api-extractor-model": "7.15.4", + "@microsoft/api-extractor-model": "7.16.0", "@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.70.0", + "node-fetch": "2.6.7" }, "workspaces": { "packages": [ diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index b1a02c0ce95..bb003374d92 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -93,6 +93,7 @@ export type OAuthSettings = Partial): Props { @@ -20,6 +20,7 @@ function getProps(propOverrides?: Partial): Props { config: {}, }, }, + count: 1, theme: createTheme(), }; @@ -39,6 +40,43 @@ describe('BigValueLayout', () => { expect(layout).toBeInstanceOf(StackedWithChartLayout); }); + it('should not include title height when count is 1 and title is auto hidden', () => { + const layout = buildLayout( + getProps({ + value: { + text: '25', + title: '10', + numeric: 25, + }, + sparkline: undefined, + textMode: BigValueTextMode.Auto, + count: 1, + }) + ); + expect(layout.titleFontSize).toBe(0); + }); + + it('should not use chart layout if only one sparkline point', () => { + const layout = buildLayout( + getProps({ + value: { + text: '25', + title: '10', + numeric: 25, + }, + sparkline: { + y: { + name: '', + values: new ArrayVector([1]), + type: FieldType.number, + config: {}, + }, + }, + }) + ); + expect(layout).toBeInstanceOf(StackedWithNoChartLayout); + }); + it('should auto select to wide layout', () => { const layout = buildLayout( getProps({ diff --git a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx index a42db2a3f28..7f3bde9344f 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx @@ -38,8 +38,8 @@ export abstract class BigValueLayout { this.justifyCenter = shouldJustifyCenter(props.justifyMode, this.textValues.title); this.valueToAlignTo = this.textValues.valueToAlignTo; this.titleToAlignTo = this.textValues.titleToAlignTo; - this.titleFontSize = 14; - this.valueFontSize = 14; + this.titleFontSize = 0; + this.valueFontSize = 0; this.chartHeight = 0; this.chartWidth = 0; this.maxTextWidth = width - this.panelPadding * 2; @@ -335,8 +335,9 @@ export class StackedWithChartLayout extends BigValueLayout { LINE_HEIGHT, MAX_TITLE_SIZE ); + + titleHeight = this.titleFontSize * LINE_HEIGHT; } - titleHeight = this.titleFontSize * LINE_HEIGHT; if (this.valueToAlignTo.length) { this.valueFontSize = calculateFontSize( @@ -399,8 +400,10 @@ export class StackedWithNoChartLayout extends BigValueLayout { ); } - // make title fontsize it's a bit smaller than valueFontSize - this.titleFontSize = Math.min(this.valueFontSize * 0.7, this.titleFontSize); + if (this.titleToAlignTo?.length) { + // make title fontsize it's a bit smaller than valueFontSize + this.titleFontSize = Math.min(this.valueFontSize * 0.7, this.titleFontSize); + } } getValueAndTitleContainerStyles() { @@ -422,7 +425,7 @@ export function buildLayout(props: Props): BigValueLayout { const useWideLayout = width / height > 2.5; if (useWideLayout) { - if (height > 50 && !!sparkline) { + if (height > 50 && !!sparkline && sparkline.y.values.length > 1) { return new WideWithChartLayout(props); } else { return new WideNoChartLayout(props); @@ -430,7 +433,7 @@ export function buildLayout(props: Props): BigValueLayout { } // stacked layouts - if (height > 100 && !!sparkline) { + if (height > 100 && sparkline && sparkline.y.values.length > 1) { return new StackedWithChartLayout(props); } else { return new StackedWithNoChartLayout(props); diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx index 17134a109e7..c817ef4d391 100644 --- a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx +++ b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx @@ -87,7 +87,7 @@ export function RadioButtonGroup({ > {o.icon && } {o.imgUrl && {o.label}} - {o.label} + {o.label} {o.component ? : null} ); })} diff --git a/packages/grafana-ui/src/components/GraphNG/utils.ts b/packages/grafana-ui/src/components/GraphNG/utils.ts index b67d694d793..30450b0dd01 100644 --- a/packages/grafana-ui/src/components/GraphNG/utils.ts +++ b/packages/grafana-ui/src/components/GraphNG/utils.ts @@ -2,7 +2,13 @@ import { XYFieldMatchers } from './types'; import { ArrayVector, DataFrame, FieldConfig, FieldType, outerJoinDataFrames, TimeRange } from '@grafana/data'; import { nullToUndefThreshold } from './nullToUndefThreshold'; import { applyNullInsertThreshold } from './nullInsertThreshold'; -import { AxisPlacement, GraphFieldConfig, ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema'; +import { + AxisPlacement, + GraphDrawStyle, + GraphFieldConfig, + ScaleDistribution, + ScaleDistributionConfig, +} from '@grafana/schema'; import { FIXED_UNIT } from './GraphNG'; // will mutate the DataFrame's fields' values @@ -31,7 +37,23 @@ function applySpanNullsThresholds(frame: DataFrame) { export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers, timeRange?: TimeRange | null) { let alignedFrame = outerJoinDataFrames({ - frames: frames.map((frame) => applyNullInsertThreshold(frame, null, timeRange?.to.valueOf())), + frames: frames.map((frame) => { + let fr = applyNullInsertThreshold(frame, null, timeRange?.to.valueOf()); + + // prevent minesweeper-expansion of nulls (gaps) when joining bars + // since bar width is determined from the minimum distance between non-undefined values + // (this strategy will still retain any original pre-join nulls, though) + fr.fields.forEach((f) => { + if (f.type === FieldType.number && f.config.custom?.drawStyle === GraphDrawStyle.Bars) { + f.config.custom = { + ...f.config.custom, + spanNulls: -1, + }; + } + }); + + return fr; + }), joinBy: dimFields.x, keep: dimFields.y, keepOriginIndices: true, diff --git a/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx b/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx index b13d341c15a..07c02c3bc65 100644 --- a/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx +++ b/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx @@ -21,11 +21,25 @@ export interface Props { children?: ReactNode; className?: string; isFullscreen?: boolean; + 'aria-label'?: string; } /** @alpha */ export const PageToolbar: FC = React.memo( - ({ title, parent, pageIcon, onGoBack, children, titleHref, parentHref, leftItems, isFullscreen, className }) => { + ({ + title, + parent, + pageIcon, + onGoBack, + children, + titleHref, + parentHref, + leftItems, + isFullscreen, + className, + /** main nav-container aria-label **/ + 'aria-label': ariaLabel, + }) => { const styles = useStyles2(getStyles); /** @@ -44,7 +58,7 @@ export const PageToolbar: FC = React.memo( ); return ( -
+
+ ); } ); diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx index 96e6a506413..50f7f976166 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx @@ -45,13 +45,13 @@ export const VizTooltipContainer: React.FC = ({ const tH = Math.floor(entry.contentRect.height + 2 * 8); if (tooltipMeasurement.width !== tW || tooltipMeasurement.height !== tH) { setTooltipMeasurement({ - width: tW, - height: tH, + width: Math.min(tW, width), + height: Math.min(tH, height), }); } } }), - [tooltipMeasurement] + [tooltipMeasurement, width, height] ); useLayoutEffect(() => { diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index 3778c8db92c..b2d331eb213 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/login/loginservice" + "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" @@ -27,28 +28,6 @@ const ( existingTestLogin = "existing@example.com" ) -type mockAuthInfoService struct { - LatestUserID int64 - ExpectedError error -} - -func (m *mockAuthInfoService) LookupAndUpdate(ctx context.Context, query *models.GetUserByAuthInfoQuery) (*models.User, error) { - m.LatestUserID = query.UserId - return nil, m.ExpectedError -} -func (m *mockAuthInfoService) GetAuthInfo(ctx context.Context, query *models.GetAuthInfoQuery) error { - m.LatestUserID = query.UserId - return m.ExpectedError -} - -func (m *mockAuthInfoService) SetAuthInfo(ctx context.Context, query *models.SetAuthInfoCommand) error { - return m.ExpectedError -} - -func (m *mockAuthInfoService) UpdateAuthInfo(ctx context.Context, query *models.UpdateAuthInfoCommand) error { - return m.ExpectedError -} - func TestAdminAPIEndpoint(t *testing.T) { const role = models.ROLE_ADMIN @@ -282,7 +261,7 @@ func putAdminScenario(t *testing.T, desc string, url string, routePattern string hs := &HTTPServer{ Cfg: setting.NewCfg(), SQLStore: sqlStore, - authInfoService: &mockAuthInfoService{}, + authInfoService: &logintest.AuthInfoServiceFake{}, } sc := setupScenarioContext(t, url) @@ -397,7 +376,7 @@ func adminDisableUserScenario(t *testing.T, desc string, action string, url stri fakeAuthTokenService := auth.NewFakeUserAuthTokenService() - authInfoService := &mockAuthInfoService{} + authInfoService := &logintest.AuthInfoServiceFake{} hs := HTTPServer{ Bus: bus.GetBus(), @@ -435,7 +414,7 @@ func adminDeleteUserScenario(t *testing.T, desc string, url string, routePattern sc := setupScenarioContext(t, url) sc.sqlStore = hs.SQLStore - sc.authInfoService = &mockAuthInfoService{} + sc.authInfoService = &logintest.AuthInfoServiceFake{} sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { sc.context = c sc.context.UserId = testUserID diff --git a/pkg/api/api.go b/pkg/api/api.go index b75ab24a953..16af1f809c6 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -4,7 +4,6 @@ package api import ( "time" - "github.com/grafana/grafana/pkg/api/avatar" "github.com/grafana/grafana/pkg/api/frontendlogging" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/log" @@ -542,8 +541,7 @@ func (hs *HTTPServer) registerRoutes() { r.Any("/api/gnet/*", reqSignedIn, hs.ProxyGnetRequest) // Gravatar service. - avatarCacheServer := avatar.NewCacheServer(hs.Cfg) - r.Get("/avatar/:hash", avatarCacheServer.Handler) + r.Get("/avatar/:hash", hs.AvatarCacheServer.Handler) // Snapshots r.Post("/api/snapshots/", reqSnapshotPublicModeOrSignedIn, hs.CreateDashboardSnapshot) diff --git a/pkg/api/avatar/avatar.go b/pkg/api/avatar/avatar.go index 6a712fe6071..4ff143a67c7 100644 --- a/pkg/api/avatar/avatar.go +++ b/pkg/api/avatar/avatar.go @@ -34,22 +34,31 @@ const ( // Avatar represents the avatar object. type Avatar struct { hash string - reqParams string data *bytes.Buffer notFound bool + isCustom bool timestamp time.Time } -var alog = log.New("avatar") +var ( + alog = log.New("avatar") + // Represents a singleton AvatarCacheServer instance + csi *AvatarCacheServer + // Paremeters needed to fetch Gravatar with a retro fallback + gravatarReqParams = url.Values{ + "d": {"retro"}, + "size": {"200"}, + "r": {"pg"}, + }.Encode() + // Parameters needed to see if a Gravatar is custom + hasCustomReqParams = url.Values{ + "d": {"404"}, + }.Encode() + cacheInitOnce sync.Once +) func New(hash string) *Avatar { - return &Avatar{ - hash: hash, - reqParams: url.Values{ - "d": {"retro"}, - "size": {"200"}, - "r": {"pg"}}.Encode(), - } + return &Avatar{hash: hash} } func (a *Avatar) Expired() bool { @@ -61,16 +70,27 @@ func (a *Avatar) Encode(wr io.Writer) error { return err } -func (a *Avatar) Update() (err error) { +func (a *Avatar) update(baseUrl string) (err error) { + customUrl := baseUrl + a.hash + "?" select { case <-time.After(time.Second * 3): err = fmt.Errorf("get gravatar image %s timeout", a.hash) - case err = <-thunder.GoFetch(gravatarSource+a.hash+"?"+a.reqParams, a): + case err = <-thunder.GoFetch(customUrl, a): } return err } -type CacheServer struct { +func (a *Avatar) GetIsCustom() bool { + return a.isCustom +} + +// Quick error handler to avoid multiple copy pastes +func (a *Avatar) setAvatarNotFound() { + a.notFound = true + a.isCustom = false +} + +type AvatarCacheServer struct { cfg *setting.Cfg notFound *Avatar cache *gocache.Cache @@ -78,7 +98,7 @@ type CacheServer struct { var validMD5 = regexp.MustCompile("^[a-fA-F0-9]{32}$") -func (a *CacheServer) Handler(ctx *models.ReqContext) { +func (a *AvatarCacheServer) Handler(ctx *models.ReqContext) { hash := web.Params(ctx.Req)[":hash"] if len(hash) != 32 || !validMD5.MatchString(hash) { @@ -86,29 +106,7 @@ func (a *CacheServer) Handler(ctx *models.ReqContext) { return } - var avatar *Avatar - obj, exists := a.cache.Get(hash) - if exists { - avatar = obj.(*Avatar) - } else { - avatar = New(hash) - } - - if avatar.Expired() { - // The cache item is either expired or newly created, update it from the server - if err := avatar.Update(); err != nil { - ctx.Logger.Debug("avatar update", "err", err) - avatar = a.notFound - } - } - - if avatar.notFound { - avatar = a.notFound - } else if !exists { - if err := a.cache.Add(hash, avatar, gocache.DefaultExpiration); err != nil { - ctx.Logger.Debug("add avatar to cache", "err", err) - } - } + avatar := a.GetAvatarForHash(hash) ctx.Resp.Header().Set("Content-Type", "image/jpeg") @@ -120,12 +118,56 @@ func (a *CacheServer) Handler(ctx *models.ReqContext) { if err := avatar.Encode(ctx.Resp); err != nil { ctx.Logger.Warn("avatar encode error:", "err", err) - ctx.Resp.WriteHeader(500) + ctx.Resp.WriteHeader(http.StatusInternalServerError) } } -func NewCacheServer(cfg *setting.Cfg) *CacheServer { - return &CacheServer{ +func (a *AvatarCacheServer) GetAvatarForHash(hash string) *Avatar { + if setting.DisableGravatar { + alog.Warn("'GetGravatarForHash' called despite gravatars being disabled; returning default profile image") + return a.notFound + } + return a.getAvatarForHash(hash, gravatarSource) +} + +func (a *AvatarCacheServer) getAvatarForHash(hash string, baseUrl string) *Avatar { + var avatar *Avatar + obj, exists := a.cache.Get(hash) + if exists { + avatar = obj.(*Avatar) + } else { + avatar = New(hash) + } + + if avatar.Expired() { + // The cache item is either expired or newly created, update it from the server + if err := avatar.update(baseUrl); err != nil { + alog.Debug("avatar update", "err", err) + avatar = a.notFound + } + } + + if avatar.notFound { + avatar = a.notFound + } else if !exists { + if err := a.cache.Add(hash, avatar, gocache.DefaultExpiration); err != nil { + alog.Debug("add avatar to cache", "err", err) + } + } + return avatar +} + +// Access cache server singleton instance +func ProvideAvatarCacheServer(cfg *setting.Cfg) *AvatarCacheServer { + cacheInitOnce.Do(func() { + csi = newCacheServer(cfg) + }) + + return csi +} + +func newCacheServer(cfg *setting.Cfg) *AvatarCacheServer { + return &AvatarCacheServer{ cfg: cfg, notFound: newNotFound(cfg), cache: gocache.New(time.Hour, time.Hour*2), @@ -133,7 +175,10 @@ func NewCacheServer(cfg *setting.Cfg) *CacheServer { } func newNotFound(cfg *setting.Cfg) *Avatar { - avatar := &Avatar{notFound: true} + avatar := &Avatar{ + notFound: true, + isCustom: false, + } // load user_profile png into buffer // It's safe to ignore gosec warning G304 since the variable part of the file path comes from a configuration @@ -176,11 +221,11 @@ func (t *Thunder) init() { } } -func (t *Thunder) Fetch(url string, avatar *Avatar) error { +func (t *Thunder) Fetch(baseUrl string, avatar *Avatar) error { t.once.Do(t.init) task := &thunderTask{ - Url: url, - Avatar: avatar, + BaseUrl: baseUrl, + Avatar: avatar, } task.Add(1) t.q <- task @@ -188,18 +233,18 @@ func (t *Thunder) Fetch(url string, avatar *Avatar) error { return task.err } -func (t *Thunder) GoFetch(url string, avatar *Avatar) chan error { +func (t *Thunder) GoFetch(baseUrl string, avatar *Avatar) chan error { c := make(chan error) go func() { - c <- t.Fetch(url, avatar) + c <- t.Fetch(baseUrl, avatar) }() return c } // thunder download type thunderTask struct { - Url string - Avatar *Avatar + BaseUrl string + Avatar *Avatar sync.WaitGroup err error } @@ -214,11 +259,48 @@ var client = &http.Client{ Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, } +// We fetch the same url with param tweaks twice in a row +// Break out the fetch function in a way that makes each +// Portion highly reusable func (a *thunderTask) fetch() error { a.Avatar.timestamp = time.Now() - alog.Debug("avatar.fetch(fetch new avatar)", "url", a.Url) - req, err := http.NewRequest("GET", a.Url, nil) + alog.Debug("avatar.fetch(fetch new avatar)", "url", a.BaseUrl) + // First do the fetch to get the Gravatar with a retro icon fallback + err := performGet(a.BaseUrl+gravatarReqParams, a.Avatar, getGravatarHandler) + + if err == nil { + // Next do a fetch with a 404 fallback to see if it's a custom gravatar + return performGet(a.BaseUrl+hasCustomReqParams, a.Avatar, checkIsCustomHandler) + } + return err +} + +type ResponseHandler func(av *Avatar, resp *http.Response) error + +// Verifies the Gravatar response code was 200, then stores the image byte slice +func getGravatarHandler(av *Avatar, resp *http.Response) error { + if resp.StatusCode != http.StatusOK { + av.setAvatarNotFound() + return fmt.Errorf("status code: %d", resp.StatusCode) + } + + av.data = &bytes.Buffer{} + writer := bufio.NewWriter(av.data) + + _, err := io.Copy(writer, resp.Body) + return err +} + +// Uses the d=404 fallback to see if the gravatar we got back is custom +func checkIsCustomHandler(av *Avatar, resp *http.Response) error { + av.isCustom = resp.StatusCode != http.StatusNotFound + return nil +} + +// Reusable Get helper that allows us to pass in custom handling depending on the endpoint +func performGet(url string, av *Avatar, handler ResponseHandler) error { + req, err := http.NewRequest("GET", url, nil) if err != nil { return err } @@ -227,9 +309,10 @@ func (a *thunderTask) fetch() error { req.Header.Set("Accept-Language", "zh-CN,zh;q=0.8") req.Header.Set("Cache-Control", "no-cache") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.154 Safari/537.36") + alog.Debug("Fetching avatar url with parameters", "url", url) resp, err := client.Do(req) if err != nil { - a.Avatar.notFound = true + av.setAvatarNotFound() return fmt.Errorf("gravatar unreachable: %w", err) } defer func() { @@ -238,14 +321,6 @@ func (a *thunderTask) fetch() error { } }() - if resp.StatusCode != 200 { - a.Avatar.notFound = true - return fmt.Errorf("status code: %d", resp.StatusCode) - } - - a.Avatar.data = &bytes.Buffer{} - writer := bufio.NewWriter(a.Avatar.data) - - _, err = io.Copy(writer, resp.Body) + err = handler(av, resp) return err } diff --git a/pkg/api/avatar/avatar_test.go b/pkg/api/avatar/avatar_test.go new file mode 100644 index 00000000000..abc98a59b77 --- /dev/null +++ b/pkg/api/avatar/avatar_test.go @@ -0,0 +1,120 @@ +package avatar + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" +) + +const DEFAULT_NONSENSE_HASH string = "9e107d9d372bb6826bd81d3542a419d6" +const CUSTOM_NONSENSE_HASH string = "d2a9116d4a63304733ca0f3471e57d16" + +var NONSENSE_BODY []byte = []byte("Bogus API response") + +func TestAvatar_AvatarRetrieval(t *testing.T) { + avc := ProvideAvatarCacheServer(setting.NewCfg()) + callCounter := 0 + mockServer := setupMockGravatarServer(&callCounter, false) + + t.Cleanup(func() { + avc.cache.Flush() + mockServer.Close() + }) + + av := avc.getAvatarForHash(DEFAULT_NONSENSE_HASH, mockServer.URL+"/avatar/") + // verify there was a call to get the image and a call to the 404 fallback + require.Equal(t, callCounter, 2) + require.Equal(t, av.data.Bytes(), NONSENSE_BODY) + + avc.getAvatarForHash(DEFAULT_NONSENSE_HASH, mockServer.URL+"/avatar/") + //since the avatar is cached, there should not have been anymore REST calls + require.Equal(t, callCounter, 2) +} + +func TestAvatar_CheckCustom(t *testing.T) { + avc := ProvideAvatarCacheServer(setting.NewCfg()) + callCounter := 0 + mockServer := setupMockGravatarServer(&callCounter, false) + + t.Cleanup(func() { + avc.cache.Flush() + mockServer.Close() + }) + + av := avc.getAvatarForHash(DEFAULT_NONSENSE_HASH, mockServer.URL+"/avatar/") + // verify this avatar is not marked custom + require.False(t, av.isCustom) + + av2 := avc.getAvatarForHash(CUSTOM_NONSENSE_HASH, mockServer.URL+"/avatar/") + // verify this avatar is marked custom + require.True(t, av2.isCustom) +} + +func TestAvatar_FallbackCase(t *testing.T) { + avc := ProvideAvatarCacheServer(setting.NewCfg()) + callCounter := 0 + mockServer := setupMockGravatarServer(&callCounter, true) + + t.Cleanup(func() { + avc.cache.Flush() + mockServer.Close() + }) + + av := avc.getAvatarForHash(DEFAULT_NONSENSE_HASH, mockServer.URL+"/avatar/") + // the client should not have gotten a valid response back from the first call + // there should only be one REST call, and the avatar url should be the default + require.Equal(t, callCounter, 1) + require.False(t, av.isCustom) + require.True(t, av.notFound) + require.Equal(t, av, avc.notFound) +} + +func TestAvatar_ExpirationHandler(t *testing.T) { + avc := ProvideAvatarCacheServer(setting.NewCfg()) + callCounter := 0 + mockServer := setupMockGravatarServer(&callCounter, false) + + t.Cleanup(func() { + avc.cache.Flush() + mockServer.Close() + }) + + av := avc.getAvatarForHash(DEFAULT_NONSENSE_HASH, mockServer.URL+"/avatar/") + // verify there was a call to get the image and a call to the 404 fallback + require.Equal(t, callCounter, 2) + require.Equal(t, av.data.Bytes(), NONSENSE_BODY) + + // manually expire the avatar in the cache + av.timestamp = av.timestamp.Add(-time.Minute * 15) + avc.getAvatarForHash(DEFAULT_NONSENSE_HASH, mockServer.URL+"/avatar/") + //since the avatar is expired, there should be two more REST calls + require.Equal(t, callCounter, 4) +} + +func setupMockGravatarServer(counter *int, simulateError bool) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + (*counter)++ + splitUri := strings.Split(r.RequestURI, "?") + urlHash := splitUri[0][len("/avatar/"):] + params := splitUri[1] + if params == "d=404" { + if urlHash == DEFAULT_NONSENSE_HASH { + w.WriteHeader(404) + } else { + _, _ = w.Write(NONSENSE_BODY) + } + } else { + if simulateError { + w.WriteHeader(500) + } else { + _, _ = w.Write(NONSENSE_BODY) + } + } + })) + return server +} diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 915fe604200..b256c1454d7 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -34,6 +34,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/login/loginservice" + "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/searchusers" @@ -168,7 +169,7 @@ type scenarioContext struct { url string userAuthTokenService *auth.FakeUserAuthTokenService sqlStore sqlstore.Store - authInfoService *mockAuthInfoService + authInfoService *logintest.AuthInfoServiceFake } func (sc *scenarioContext) exec() { @@ -399,8 +400,9 @@ func setupHTTPServerWithCfgDb(t *testing.T, useFakeAccessControl, enableAccessCo require.NoError(t, err) hs.teamPermissionsService = teamPermissionService } else { - ac := ossaccesscontrol.ProvideService(hs.Features, &usagestats.UsageStatsMock{T: t}, + ac, errInitAc := ossaccesscontrol.ProvideService(hs.Features, &usagestats.UsageStatsMock{T: t}, database.ProvideService(db), routing.NewRouteRegister()) + require.NoError(t, errInitAc) hs.AccessControl = ac // Perform role registration err := hs.declareFixedRoles() diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 293d40ddf82..cb71e7eec9a 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -202,7 +202,13 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { func (hs *HTTPServer) getAnnotationPermissionsByScope(c *models.ReqContext, actions *dtos.AnnotationActions, scope string) { var err error - evaluate := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsDelete, scope) + evaluate := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, scope) + actions.CanAdd, err = hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluate) + if err != nil { + hs.log.Warn("Failed to evaluate permission", "err", err, "action", accesscontrol.ActionAnnotationsCreate, "scope", scope) + } + + evaluate = accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsDelete, scope) actions.CanDelete, err = hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluate) if err != nil { hs.log.Warn("Failed to evaluate permission", "err", err, "action", accesscontrol.ActionAnnotationsDelete, "scope", scope) diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index 707bd549804..2e25ca4e132 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -40,6 +40,7 @@ type AnnotationPermission struct { } type AnnotationActions struct { + CanAdd bool `json:"canAdd"` CanEdit bool `json:"canEdit"` CanDelete bool `json:"canDelete"` } diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index 1d9ccaeee1b..f0ceb354802 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -28,6 +28,7 @@ type LoginCommand struct { type CurrentUser struct { IsSignedIn bool `json:"isSignedIn"` Id int64 `json:"id"` + ExternalUserId string `json:"externalUserId"` Login string `json:"login"` Email string `json:"email"` Name string `json:"name"` @@ -78,11 +79,20 @@ func GetGravatarUrl(text string) string { return "" } + hash, _ := GetGravatarHash(text) + return fmt.Sprintf(setting.AppSubUrl+"/avatar/%x", hash) +} + +func GetGravatarHash(text string) ([]byte, bool) { + if text == "" { + return make([]byte, 0), false + } + hasher := md5.New() if _, err := hasher.Write([]byte(strings.ToLower(text))); err != nil { mlog.Warn("Failed to hash text", "err", err) } - return fmt.Sprintf(setting.AppSubUrl+"/avatar/%x", hasher.Sum(nil)) + return hasher.Sum(nil), true } func GetGravatarUrlWithDefault(text string, defaultText string) string { diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index e508bcd15b9..6d026fc5f26 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -13,6 +13,7 @@ import ( "strings" "sync" + "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" @@ -21,6 +22,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "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/login/social" "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/models" @@ -134,6 +136,7 @@ type HTTPServer struct { queryDataService *query.Service serviceAccountsService serviceaccounts.Service authInfoService login.AuthInfoService + authenticator loginpkg.Authenticator teamPermissionsService accesscontrol.PermissionsService permissionServices accesscontrol.PermissionsServices NotificationService *notifications.NotificationService @@ -145,6 +148,7 @@ type HTTPServer struct { AlertNotificationService *alerting.AlertNotificationService DashboardsnapshotsService *dashboardsnapshots.Service PluginSettings *pluginSettings.Service + AvatarCacheServer *avatar.AvatarCacheServer } type ServerOptions struct { @@ -160,7 +164,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi dataSourceCache datasources.CacheService, userTokenService models.UserTokenService, cleanUpService *cleanup.CleanUpService, shortURLService shorturls.Service, queryHistoryService queryhistory.Service, thumbService thumbs.Service, remoteCache *remotecache.RemoteCache, provisioningService provisioning.ProvisioningService, - loginService login.Service, accessControl accesscontrol.AccessControl, + loginService login.Service, authenticator loginpkg.Authenticator, accessControl accesscontrol.AccessControl, dataSourceProxy *datasourceproxy.DataSourceProxyService, searchService *search.SearchService, live *live.GrafanaLive, livePushGateway *pushhttp.Gateway, plugCtxProvider *plugincontext.Provider, contextHandler *contexthandler.ContextHandler, features *featuremgmt.FeatureManager, @@ -176,6 +180,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService, datasourcePermissionsService permissions.DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService, dashboardsnapshotsService *dashboardsnapshots.Service, commentsService *comments.Service, pluginSettings *pluginSettings.Service, + avatarCacheServer *avatar.AvatarCacheServer, ) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -236,6 +241,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi queryDataService: queryDataService, serviceAccountsService: serviceaccountsService, authInfoService: authInfoService, + authenticator: authenticator, NotificationService: notificationService, dashboardService: dashboardService, dashboardProvisioningService: dashboardProvisioningService, @@ -247,6 +253,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi DashboardsnapshotsService: dashboardsnapshotsService, PluginSettings: pluginSettings, permissionServices: permissionsServices, + AvatarCacheServer: avatarCacheServer, } if hs.Listener != nil { hs.log.Debug("Using provided listener") diff --git a/pkg/api/index.go b/pkg/api/index.go index 8d959182067..20c21a3c7c6 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -120,11 +120,14 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) } if include.Type == "dashboard" && include.AddToNav { - link := &dtos.NavLink{ - Url: hs.Cfg.AppSubURL + include.GetSlugOrUIDLink(), - Text: include.Name, + dboardURL := include.DashboardURLPath() + if dboardURL != "" { + link := &dtos.NavLink{ + Url: path.Join(hs.Cfg.AppSubURL, dboardURL), + Text: include.Name, + } + appLink.Children = append(appLink.Children, link) } - appLink.Children = append(appLink.Children, link) } } @@ -653,6 +656,7 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat IsSignedIn: c.IsSignedIn, Login: c.Login, Email: c.Email, + ExternalUserId: c.SignedInUser.ExternalAuthId, Name: c.Name, OrgCount: c.OrgCount, OrgId: c.OrgId, diff --git a/pkg/api/ldap_debug.go b/pkg/api/ldap_debug.go index f59277c0d7a..a477bceedd6 100644 --- a/pkg/api/ldap_debug.go +++ b/pkg/api/ldap_debug.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/multildap" @@ -199,7 +198,7 @@ func (hs *HTTPServer) PostSyncUserWithLDAP(c *models.ReqContext) response.Respon } // Since the user was not in the LDAP server. Let's disable it. - err := login.DisableExternalUser(c.Req.Context(), query.Result.Login) + err := hs.Login.DisableExternalUser(c.Req.Context(), query.Result.Login) if err != nil { return response.Error(http.StatusInternalServerError, "Failed to disable the user", err) } diff --git a/pkg/api/ldap_debug_test.go b/pkg/api/ldap_debug_test.go index 15c66cc13a4..f9d796018bc 100644 --- a/pkg/api/ldap_debug_test.go +++ b/pkg/api/ldap_debug_test.go @@ -1,7 +1,6 @@ package api import ( - "context" "errors" "net/http" "net/http/httptest" @@ -10,12 +9,12 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/login/loginservice" + "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "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/ldap" @@ -368,6 +367,7 @@ func postSyncUserWithLDAPContext(t *testing.T, requestURL string, preHook func(* t.Helper() sc := setupScenarioContext(t, requestURL) + sc.authInfoService = &logintest.AuthInfoServiceFake{} ldap := setting.LDAPEnabled t.Cleanup(func() { @@ -380,7 +380,7 @@ func postSyncUserWithLDAPContext(t *testing.T, requestURL string, preHook func(* AuthTokenService: auth.NewFakeUserAuthTokenService(), SQLStore: sqlstoremock, Login: loginservice.LoginServiceMock{}, - authInfoService: &mockAuthInfoService{}, + authInfoService: sc.authInfoService, } sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { @@ -483,6 +483,7 @@ func TestPostSyncUserWithLDAPAPIEndpoint_WhenGrafanaAdmin(t *testing.T) { func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotInLDAP(t *testing.T) { sqlstoremock := mockstore.SQLStoreMock{ExpectedUser: &models.User{Login: "ldap-daniel", Id: 34}} sc := postSyncUserWithLDAPContext(t, "/api/admin/ldap/sync/34", func(t *testing.T, sc *scenarioContext) { + sc.authInfoService.ExpectedExternalUser = &models.ExternalUserInfo{IsDisabled: true, UserId: 34} getLDAPConfig = func(*setting.Cfg) (*ldap.Config, error) { return &ldap.Config{}, nil } @@ -492,18 +493,7 @@ func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotInLDAP(t *testing.T) { } userSearchResult = nil - - bus.AddHandler("test", func(ctx context.Context, q *models.GetExternalUserInfoByLoginQuery) error { - assert.Equal(t, "ldap-daniel", q.LoginOrEmail) - q.Result = &models.ExternalUserInfo{IsDisabled: true, UserId: 34} - - return nil - }) - - bus.AddHandler("test", func(ctx context.Context, cmd *models.DisableUserCommand) error { - assert.Equal(t, 34, cmd.UserId) - return nil - }) + userSearchError = multildap.ErrDidNotFindUser }, &sqlstoremock) assert.Equal(t, http.StatusBadRequest, sc.resp.Code) @@ -616,7 +606,7 @@ func TestLDAP_AccessControl(t *testing.T) { cfg.LDAPEnabled = true sc, hs := setupAccessControlScenarioContext(t, cfg, test.url, test.permissions) hs.SQLStore = &mockstore.SQLStoreMock{ExpectedUser: &models.User{}} - hs.authInfoService = &mockAuthInfoService{} + hs.authInfoService = &logintest.AuthInfoServiceFake{} hs.Login = &loginservice.LoginServiceMock{} sc.resp = httptest.NewRecorder() sc.req, err = http.NewRequest(test.method, test.url, nil) diff --git a/pkg/api/login.go b/pkg/api/login.go index bad639e6d1d..49a33fa5744 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -209,7 +209,7 @@ func (hs *HTTPServer) LoginPost(c *models.ReqContext) response.Response { Cfg: hs.Cfg, } - err := login.AuthenticateUserFunc(c.Req.Context(), authQuery) + err := hs.authenticator.AuthenticateUser(c.Req.Context(), authQuery) authModule = authQuery.AuthModule if err != nil { resp = response.Error(401, "Invalid username or password", err) diff --git a/pkg/api/login_test.go b/pkg/api/login_test.go index 1c126f8ad79..4a1807caa2a 100644 --- a/pkg/api/login_test.go +++ b/pkg/api/login_test.go @@ -351,8 +351,7 @@ func TestLoginPostRedirect(t *testing.T) { Email: "", } - mockAuthenticateUserFunc(user, "", nil) - t.Cleanup(resetAuthenticateUserFunc) + hs.authenticator = &fakeAuthenticator{user, "", nil} redirectCases := []redirectCase{ { @@ -684,8 +683,7 @@ func TestLoginPostRunLokingHook(t *testing.T) { for _, c := range testCases { t.Run(c.desc, func(t *testing.T) { - mockAuthenticateUserFunc(c.authUser, c.authModule, c.authErr) - t.Cleanup(resetAuthenticateUserFunc) + hs.authenticator = &fakeAuthenticator{c.authUser, c.authModule, c.authErr} sc.m.Post(sc.url, sc.defaultHandler) sc.fakeReqNoAssertions("POST", sc.url).exec() @@ -732,13 +730,14 @@ func (m *mockSocialService) GetConnector(string) (social.SocialConnector, error) return m.socialConnector, m.err } -func mockAuthenticateUserFunc(user *models.User, authmodule string, err error) { - login.AuthenticateUserFunc = func(ctx context.Context, query *models.LoginUserQuery) error { - query.User = user - query.AuthModule = authmodule - return err - } +type fakeAuthenticator struct { + ExpectedUser *models.User + ExpectedAuthModule string + ExpectedError error } -func resetAuthenticateUserFunc() { - login.AuthenticateUserFunc = login.AuthenticateUser + +func (fa *fakeAuthenticator) AuthenticateUser(c context.Context, query *models.LoginUserQuery) error { + query.User = fa.ExpectedUser + query.AuthModule = fa.ExpectedAuthModule + return fa.ExpectedError } diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 01b073cdf0e..25a1dcbfe60 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -506,6 +506,31 @@ func (hs *HTTPServer) callPluginResource(c *models.ReqContext, pluginID, dsUID s } clonedReq.URL = urlPath + if dsUID != "" { + ds, err := hs.DataSourceCache.GetDatasourceByUID(c.Req.Context(), dsUID, c.SignedInUser, c.SkipCache) + + if err != nil { + if errors.Is(err, models.ErrDataSourceNotFound) { + c.JsonApiErr(404, "Datasource not found", err) + return + } + + c.JsonApiErr(500, "Failed to get datasource", err) + return + } + + if hs.DataProxy.OAuthTokenService.IsOAuthPassThruEnabled(ds) { + if token := hs.DataProxy.OAuthTokenService.GetCurrentOAuthToken(c.Req.Context(), c.SignedInUser); token != nil { + clonedReq.Header.Add("Authorization", fmt.Sprintf("%s %s", token.Type(), token.AccessToken)) + + idToken, ok := token.Extra("id_token").(string) + if ok && idToken != "" { + clonedReq.Header.Add("X-ID-Token", idToken) + } + } + } + } + if err = hs.makePluginResourceRequest(c.Resp, clonedReq, pCtx); err != nil { handleCallResourceError(err, c) } diff --git a/pkg/extensions/main.go b/pkg/extensions/main.go index f3a27dabd60..65fde070218 100644 --- a/pkg/extensions/main.go +++ b/pkg/extensions/main.go @@ -5,12 +5,18 @@ import ( // remove the cron (v1) dependency _ "cloud.google.com/go/kms/apiv1" + _ "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + _ "github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys" _ "github.com/Azure/azure-sdk-for-go/services/keyvault/v7.1/keyvault" _ "github.com/Azure/go-autorest/autorest" + _ "github.com/Azure/go-autorest/autorest/adal" _ "github.com/beevik/etree" _ "github.com/cortexproject/cortex/pkg/util" _ "github.com/crewjam/saml" _ "github.com/gobwas/glob" + _ "github.com/googleapis/gax-go/v2" + _ "github.com/grafana/dskit/backoff" + _ "github.com/grafana/dskit/flagext" _ "github.com/grafana/loki/clients/pkg/promtail/client" _ "github.com/grafana/loki/pkg/logproto" _ "github.com/grpc-ecosystem/go-grpc-middleware" @@ -24,6 +30,7 @@ import ( _ "github.com/stretchr/testify/require" _ "github.com/vectordotdev/go-datemath" _ "golang.org/x/time/rate" + _ "google.golang.org/genproto/googleapis/cloud/kms/v1" _ "gopkg.in/square/go-jose.v2" ) diff --git a/pkg/infra/log/log.go b/pkg/infra/log/log.go index c2157887d8e..21c11705e6a 100644 --- a/pkg/infra/log/log.go +++ b/pkg/infra/log/log.go @@ -7,6 +7,7 @@ package log import ( "fmt" "io" + "io/ioutil" "os" "path/filepath" "sort" @@ -41,8 +42,10 @@ func init() { loggersToClose = make([]DisposableHandler, 0) loggersToReload = make([]ReloadableHandler, 0) - // Use console by default - format := getLogFormat("console") + // Use discard by default + format := func(w io.Writer) gokitlog.Logger { + return gokitlog.NewLogfmtLogger(gokitlog.NewSyncWriter(ioutil.Discard)) + } logger := level.NewFilter(format(os.Stderr), level.AllowInfo()) root = newManager(logger) } diff --git a/pkg/login/auth.go b/pkg/login/auth.go index 97eb4acd62d..ae76ee3bec5 100644 --- a/pkg/login/auth.go +++ b/pkg/login/auth.go @@ -8,6 +8,8 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ldap" + "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/sqlstore" ) var ( @@ -25,15 +27,27 @@ var ( var loginLogger = log.New("login") -var AuthenticateUserFunc = AuthenticateUser +type Authenticator interface { + AuthenticateUser(context.Context, *models.LoginUserQuery) error +} -func Init() { - bus.AddHandler("auth", AuthenticateUser) +type AuthenticatorService struct { + store sqlstore.Store + loginService login.Service +} + +func ProvideService(store sqlstore.Store, loginService login.Service) *AuthenticatorService { + a := &AuthenticatorService{ + store: store, + loginService: loginService, + } + bus.AddHandler("auth", a.AuthenticateUser) + return a } // AuthenticateUser authenticates the user via username & password -func AuthenticateUser(ctx context.Context, query *models.LoginUserQuery) error { - if err := validateLoginAttempts(ctx, query); err != nil { +func (a *AuthenticatorService) AuthenticateUser(ctx context.Context, query *models.LoginUserQuery) error { + if err := validateLoginAttempts(ctx, query, a.store); err != nil { return err } @@ -41,14 +55,14 @@ func AuthenticateUser(ctx context.Context, query *models.LoginUserQuery) error { return err } - err := loginUsingGrafanaDB(ctx, query) + err := loginUsingGrafanaDB(ctx, query, a.store) if err == nil || (!errors.Is(err, models.ErrUserNotFound) && !errors.Is(err, ErrInvalidCredentials) && !errors.Is(err, ErrUserDisabled)) { query.AuthModule = "grafana" return err } - ldapEnabled, ldapErr := loginUsingLDAP(ctx, query) + ldapEnabled, ldapErr := loginUsingLDAP(ctx, query, a.loginService) if ldapEnabled { query.AuthModule = models.AuthModuleLDAP if ldapErr == nil || !errors.Is(ldapErr, ldap.ErrInvalidCredentials) { @@ -61,7 +75,7 @@ func AuthenticateUser(ctx context.Context, query *models.LoginUserQuery) error { } if errors.Is(err, ErrInvalidCredentials) || errors.Is(err, ldap.ErrInvalidCredentials) { - if err := saveInvalidLoginAttempt(ctx, query); err != nil { + if err := saveInvalidLoginAttempt(ctx, query, a.store); err != nil { loginLogger.Error("Failed to save invalid login attempt", "err", err) } diff --git a/pkg/login/auth_test.go b/pkg/login/auth_test.go index c8f1d7ca488..2dad43286c4 100644 --- a/pkg/login/auth_test.go +++ b/pkg/login/auth_test.go @@ -7,6 +7,10 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ldap" + "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/login/logintest" + "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" ) @@ -21,7 +25,8 @@ func TestAuthenticateUser(t *testing.T) { Username: "user", Password: "", } - err := AuthenticateUserFunc(context.Background(), &loginQuery) + a := AuthenticatorService{store: mockstore.NewSQLStoreMock(), loginService: &logintest.LoginServiceFake{}} + err := a.AuthenticateUser(context.Background(), &loginQuery) require.EqualError(t, err, ErrPasswordEmpty.Error()) assert.False(t, sc.grafanaLoginWasCalled) @@ -35,7 +40,8 @@ func TestAuthenticateUser(t *testing.T) { mockLoginUsingLDAP(true, nil, sc) mockSaveInvalidLoginAttempt(sc) - err := AuthenticateUserFunc(context.Background(), sc.loginUserQuery) + a := AuthenticatorService{store: mockstore.NewSQLStoreMock(), loginService: &logintest.LoginServiceFake{}} + err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.EqualError(t, err, ErrTooManyLoginAttempts.Error()) assert.True(t, sc.loginAttemptValidationWasCalled) @@ -51,7 +57,8 @@ func TestAuthenticateUser(t *testing.T) { mockLoginUsingLDAP(true, ErrInvalidCredentials, sc) mockSaveInvalidLoginAttempt(sc) - err := AuthenticateUserFunc(context.Background(), sc.loginUserQuery) + a := AuthenticatorService{store: mockstore.NewSQLStoreMock(), loginService: &logintest.LoginServiceFake{}} + err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.NoError(t, err) assert.True(t, sc.loginAttemptValidationWasCalled) @@ -68,7 +75,8 @@ func TestAuthenticateUser(t *testing.T) { mockLoginUsingLDAP(true, ErrInvalidCredentials, sc) mockSaveInvalidLoginAttempt(sc) - err := AuthenticateUserFunc(context.Background(), sc.loginUserQuery) + a := AuthenticatorService{store: mockstore.NewSQLStoreMock(), loginService: &logintest.LoginServiceFake{}} + err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.EqualError(t, err, customErr.Error()) assert.True(t, sc.loginAttemptValidationWasCalled) @@ -84,7 +92,8 @@ func TestAuthenticateUser(t *testing.T) { mockLoginUsingLDAP(false, nil, sc) mockSaveInvalidLoginAttempt(sc) - err := AuthenticateUserFunc(context.Background(), sc.loginUserQuery) + a := AuthenticatorService{store: mockstore.NewSQLStoreMock(), loginService: &logintest.LoginServiceFake{}} + err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.EqualError(t, err, models.ErrUserNotFound.Error()) assert.True(t, sc.loginAttemptValidationWasCalled) @@ -100,7 +109,8 @@ func TestAuthenticateUser(t *testing.T) { mockLoginUsingLDAP(true, ldap.ErrInvalidCredentials, sc) mockSaveInvalidLoginAttempt(sc) - err := AuthenticateUserFunc(context.Background(), sc.loginUserQuery) + a := AuthenticatorService{store: mockstore.NewSQLStoreMock(), loginService: &logintest.LoginServiceFake{}} + err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.EqualError(t, err, ErrInvalidCredentials.Error()) assert.True(t, sc.loginAttemptValidationWasCalled) @@ -116,7 +126,8 @@ func TestAuthenticateUser(t *testing.T) { mockLoginUsingLDAP(true, nil, sc) mockSaveInvalidLoginAttempt(sc) - err := AuthenticateUserFunc(context.Background(), sc.loginUserQuery) + a := AuthenticatorService{store: mockstore.NewSQLStoreMock(), loginService: &logintest.LoginServiceFake{}} + err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.NoError(t, err) assert.True(t, sc.loginAttemptValidationWasCalled) @@ -133,7 +144,8 @@ func TestAuthenticateUser(t *testing.T) { mockLoginUsingLDAP(true, customErr, sc) mockSaveInvalidLoginAttempt(sc) - err := AuthenticateUserFunc(context.Background(), sc.loginUserQuery) + a := AuthenticatorService{store: mockstore.NewSQLStoreMock(), loginService: &logintest.LoginServiceFake{}} + err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.EqualError(t, err, customErr.Error()) assert.True(t, sc.loginAttemptValidationWasCalled) @@ -149,7 +161,8 @@ func TestAuthenticateUser(t *testing.T) { mockLoginUsingLDAP(true, ldap.ErrInvalidCredentials, sc) mockSaveInvalidLoginAttempt(sc) - err := AuthenticateUserFunc(context.Background(), sc.loginUserQuery) + a := AuthenticatorService{store: mockstore.NewSQLStoreMock(), loginService: &logintest.LoginServiceFake{}} + err := a.AuthenticateUser(context.Background(), sc.loginUserQuery) require.EqualError(t, err, ErrInvalidCredentials.Error()) assert.True(t, sc.loginAttemptValidationWasCalled) @@ -170,28 +183,28 @@ type authScenarioContext struct { type authScenarioFunc func(sc *authScenarioContext) func mockLoginUsingGrafanaDB(err error, sc *authScenarioContext) { - loginUsingGrafanaDB = func(ctx context.Context, query *models.LoginUserQuery) error { + loginUsingGrafanaDB = func(ctx context.Context, query *models.LoginUserQuery, _ sqlstore.Store) error { sc.grafanaLoginWasCalled = true return err } } func mockLoginUsingLDAP(enabled bool, err error, sc *authScenarioContext) { - loginUsingLDAP = func(ctx context.Context, query *models.LoginUserQuery) (bool, error) { + loginUsingLDAP = func(ctx context.Context, query *models.LoginUserQuery, _ login.Service) (bool, error) { sc.ldapLoginWasCalled = true return enabled, err } } func mockLoginAttemptValidation(err error, sc *authScenarioContext) { - validateLoginAttempts = func(context.Context, *models.LoginUserQuery) error { + validateLoginAttempts = func(context.Context, *models.LoginUserQuery, sqlstore.Store) error { sc.loginAttemptValidationWasCalled = true return err } } func mockSaveInvalidLoginAttempt(sc *authScenarioContext) { - saveInvalidLoginAttempt = func(ctx context.Context, query *models.LoginUserQuery) error { + saveInvalidLoginAttempt = func(ctx context.Context, query *models.LoginUserQuery, _ sqlstore.Store) error { sc.saveInvalidLoginAttemptWasCalled = true return nil } diff --git a/pkg/login/brute_force_login_protection.go b/pkg/login/brute_force_login_protection.go index 6e4084152d0..2fb30d97da7 100644 --- a/pkg/login/brute_force_login_protection.go +++ b/pkg/login/brute_force_login_protection.go @@ -4,8 +4,8 @@ import ( "context" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" ) var ( @@ -13,7 +13,7 @@ var ( loginAttemptsWindow = time.Minute * 5 ) -var validateLoginAttempts = func(ctx context.Context, query *models.LoginUserQuery) error { +var validateLoginAttempts = func(ctx context.Context, query *models.LoginUserQuery, store sqlstore.Store) error { if query.Cfg.DisableBruteForceLoginProtection { return nil } @@ -23,7 +23,7 @@ var validateLoginAttempts = func(ctx context.Context, query *models.LoginUserQue Since: time.Now().Add(-loginAttemptsWindow), } - if err := bus.Dispatch(ctx, &loginAttemptCountQuery); err != nil { + if err := store.GetUserLoginAttemptCount(ctx, &loginAttemptCountQuery); err != nil { return err } @@ -34,7 +34,7 @@ var validateLoginAttempts = func(ctx context.Context, query *models.LoginUserQue return nil } -var saveInvalidLoginAttempt = func(ctx context.Context, query *models.LoginUserQuery) error { +var saveInvalidLoginAttempt = func(ctx context.Context, query *models.LoginUserQuery, store sqlstore.Store) error { if query.Cfg.DisableBruteForceLoginProtection { return nil } @@ -44,5 +44,5 @@ var saveInvalidLoginAttempt = func(ctx context.Context, query *models.LoginUserQ IpAddress: query.IpAddress, } - return bus.Dispatch(ctx, &loginAttemptCommand) + return store.CreateLoginAttempt(ctx, &loginAttemptCommand) } diff --git a/pkg/login/brute_force_login_protection_test.go b/pkg/login/brute_force_login_protection_test.go index a0fd5be034e..adda89990b1 100644 --- a/pkg/login/brute_force_login_protection_test.go +++ b/pkg/login/brute_force_login_protection_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -59,11 +59,12 @@ func TestValidateLoginAttempts(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - withLoginAttempts(t, tc.loginAttempts) + store := mockstore.NewSQLStoreMock() + store.ExpectedLoginAttempts = tc.loginAttempts query := &models.LoginUserQuery{Username: "user", Cfg: tc.cfg} - err := validateLoginAttempts(context.Background(), query) + err := validateLoginAttempts(context.Background(), query, store) require.Equal(t, tc.expected, err) }) } @@ -71,45 +72,31 @@ func TestValidateLoginAttempts(t *testing.T) { func TestSaveInvalidLoginAttempt(t *testing.T) { t.Run("When brute force protection enabled", func(t *testing.T) { - t.Cleanup(func() { bus.ClearBusHandlers() }) - - createLoginAttemptCmd := &models.CreateLoginAttemptCommand{} - bus.AddHandler("test", func(ctx context.Context, cmd *models.CreateLoginAttemptCommand) error { - createLoginAttemptCmd = cmd - return nil - }) - + store := mockstore.NewSQLStoreMock() err := saveInvalidLoginAttempt(context.Background(), &models.LoginUserQuery{ Username: "user", Password: "pwd", IpAddress: "192.168.1.1:56433", Cfg: cfgWithBruteForceLoginProtectionEnabled(t), - }) + }, store) require.NoError(t, err) - require.NotNil(t, createLoginAttemptCmd) - assert.Equal(t, "user", createLoginAttemptCmd.Username) - assert.Equal(t, "192.168.1.1:56433", createLoginAttemptCmd.IpAddress) + require.NotNil(t, store.LastLoginAttemptCommand) + assert.Equal(t, "user", store.LastLoginAttemptCommand.Username) + assert.Equal(t, "192.168.1.1:56433", store.LastLoginAttemptCommand.IpAddress) }) t.Run("When brute force protection disabled", func(t *testing.T) { - t.Cleanup(func() { bus.ClearBusHandlers() }) - - var createLoginAttemptCmd *models.CreateLoginAttemptCommand - bus.AddHandler("test", func(ctx context.Context, cmd *models.CreateLoginAttemptCommand) error { - createLoginAttemptCmd = cmd - return nil - }) - + store := mockstore.NewSQLStoreMock() err := saveInvalidLoginAttempt(context.Background(), &models.LoginUserQuery{ Username: "user", Password: "pwd", IpAddress: "192.168.1.1:56433", Cfg: cfgWithBruteForceLoginProtectionDisabled(t), - }) + }, store) require.NoError(t, err) - require.Nil(t, createLoginAttemptCmd) + require.Nil(t, store.LastLoginAttemptCommand) }) } @@ -126,11 +113,3 @@ func cfgWithBruteForceLoginProtectionEnabled(t *testing.T) *setting.Cfg { require.False(t, cfg.DisableBruteForceLoginProtection) return cfg } - -func withLoginAttempts(t *testing.T, loginAttempts int64) { - t.Helper() - bus.AddHandler("test", func(ctx context.Context, query *models.GetUserLoginAttemptCountQuery) error { - query.Result = loginAttempts - return nil - }) -} diff --git a/pkg/login/grafana_login.go b/pkg/login/grafana_login.go index 593cfc1c85a..cae3c97be2c 100644 --- a/pkg/login/grafana_login.go +++ b/pkg/login/grafana_login.go @@ -4,8 +4,8 @@ import ( "context" "crypto/subtle" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/util" ) @@ -21,10 +21,10 @@ var validatePassword = func(providedPassword string, userPassword string, userSa return nil } -var loginUsingGrafanaDB = func(ctx context.Context, query *models.LoginUserQuery) error { +var loginUsingGrafanaDB = func(ctx context.Context, query *models.LoginUserQuery, store sqlstore.Store) error { userQuery := models.GetUserByLoginQuery{LoginOrEmail: query.Username} - if err := bus.Dispatch(ctx, &userQuery); err != nil { + if err := store.GetUserByLogin(ctx, &userQuery); err != nil { return err } diff --git a/pkg/login/grafana_login_test.go b/pkg/login/grafana_login_test.go index 28fea924c9b..fb9b1b1cd16 100644 --- a/pkg/login/grafana_login_test.go +++ b/pkg/login/grafana_login_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -13,7 +13,7 @@ import ( func TestLoginUsingGrafanaDB(t *testing.T) { grafanaLoginScenario(t, "When login with non-existing user", func(sc *grafanaLoginScenarioContext) { sc.withNonExistingUser() - err := loginUsingGrafanaDB(context.Background(), sc.loginUserQuery) + err := loginUsingGrafanaDB(context.Background(), sc.loginUserQuery, sc.store) require.EqualError(t, err, models.ErrUserNotFound.Error()) assert.False(t, sc.validatePasswordCalled) @@ -22,7 +22,7 @@ func TestLoginUsingGrafanaDB(t *testing.T) { grafanaLoginScenario(t, "When login with invalid credentials", func(sc *grafanaLoginScenarioContext) { sc.withInvalidPassword() - err := loginUsingGrafanaDB(context.Background(), sc.loginUserQuery) + err := loginUsingGrafanaDB(context.Background(), sc.loginUserQuery, sc.store) require.EqualError(t, err, ErrInvalidCredentials.Error()) @@ -32,7 +32,7 @@ func TestLoginUsingGrafanaDB(t *testing.T) { grafanaLoginScenario(t, "When login with valid credentials", func(sc *grafanaLoginScenarioContext) { sc.withValidCredentials() - err := loginUsingGrafanaDB(context.Background(), sc.loginUserQuery) + err := loginUsingGrafanaDB(context.Background(), sc.loginUserQuery, sc.store) require.NoError(t, err) assert.True(t, sc.validatePasswordCalled) @@ -44,7 +44,7 @@ func TestLoginUsingGrafanaDB(t *testing.T) { grafanaLoginScenario(t, "When login with disabled user", func(sc *grafanaLoginScenarioContext) { sc.withDisabledUser() - err := loginUsingGrafanaDB(context.Background(), sc.loginUserQuery) + err := loginUsingGrafanaDB(context.Background(), sc.loginUserQuery, sc.store) require.EqualError(t, err, ErrUserDisabled.Error()) assert.False(t, sc.validatePasswordCalled) @@ -53,6 +53,7 @@ func TestLoginUsingGrafanaDB(t *testing.T) { } type grafanaLoginScenarioContext struct { + store *mockstore.SQLStoreMock loginUserQuery *models.LoginUserQuery validatePasswordCalled bool } @@ -66,6 +67,7 @@ func grafanaLoginScenario(t *testing.T, desc string, fn grafanaLoginScenarioFunc origValidatePassword := validatePassword sc := &grafanaLoginScenarioContext{ + store: mockstore.NewSQLStoreMock(), loginUserQuery: &models.LoginUserQuery{ Username: "user", Password: "pwd", @@ -95,14 +97,10 @@ func mockPasswordValidation(valid bool, sc *grafanaLoginScenarioContext) { } func (sc *grafanaLoginScenarioContext) getUserByLoginQueryReturns(user *models.User) { - bus.AddHandler("test", func(ctx context.Context, query *models.GetUserByLoginQuery) error { - if user == nil { - return models.ErrUserNotFound - } - - query.Result = user - return nil - }) + sc.store.ExpectedUser = user + if user == nil { + sc.store.ExpectedError = models.ErrUserNotFound + } } func (sc *grafanaLoginScenarioContext) withValidCredentials() { diff --git a/pkg/login/ldap_login.go b/pkg/login/ldap_login.go index 2658de7fd9d..b52f431cd06 100644 --- a/pkg/login/ldap_login.go +++ b/pkg/login/ldap_login.go @@ -4,10 +4,10 @@ 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" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/multildap" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util/errutil" @@ -27,7 +27,7 @@ var ldapLogger = log.New("login.ldap") // loginUsingLDAP logs in user using LDAP. It returns whether LDAP is enabled and optional error and query arg will be // populated with the logged in user if successful. -var loginUsingLDAP = func(ctx context.Context, query *models.LoginUserQuery) (bool, error) { +var loginUsingLDAP = func(ctx context.Context, query *models.LoginUserQuery, loginService login.Service) (bool, error) { enabled := isLDAPEnabled() if !enabled { @@ -43,7 +43,7 @@ var loginUsingLDAP = func(ctx context.Context, query *models.LoginUserQuery) (bo if err != nil { if errors.Is(err, ldap.ErrCouldNotFindUser) { // Ignore the error since user might not be present anyway - if err := DisableExternalUser(ctx, query.Username); err != nil { + if err := loginService.DisableExternalUser(ctx, query.Username); err != nil { ldapLogger.Debug("Failed to disable external user", "err", err) } @@ -58,7 +58,7 @@ var loginUsingLDAP = func(ctx context.Context, query *models.LoginUserQuery) (bo ExternalUser: externalUser, SignupAllowed: setting.LDAPAllowSignup, } - err = bus.Dispatch(ctx, upsert) + err = loginService.UpsertUser(ctx, upsert) if err != nil { return true, err } @@ -66,42 +66,3 @@ var loginUsingLDAP = func(ctx context.Context, query *models.LoginUserQuery) (bo return true, nil } - -// DisableExternalUser marks external user as disabled in Grafana db -func DisableExternalUser(ctx context.Context, username string) error { - // Check if external user exist in Grafana - userQuery := &models.GetExternalUserInfoByLoginQuery{ - LoginOrEmail: username, - } - - if err := bus.Dispatch(ctx, userQuery); err != nil { - return err - } - - userInfo := userQuery.Result - if !userInfo.IsDisabled { - ldapLogger.Debug( - "Disabling external user", - "user", - userQuery.Result.Login, - ) - - // Mark user as disabled in grafana db - disableUserCmd := &models.DisableUserCommand{ - UserId: userQuery.Result.UserId, - IsDisabled: true, - } - - if err := bus.Dispatch(ctx, disableUserCmd); err != nil { - ldapLogger.Debug( - "Error disabling external user", - "user", - userQuery.Result.Login, - "message", - err.Error(), - ) - return err - } - } - return nil -} diff --git a/pkg/login/ldap_login_test.go b/pkg/login/ldap_login_test.go index 25c53bada07..4503b272e92 100644 --- a/pkg/login/ldap_login_test.go +++ b/pkg/login/ldap_login_test.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ldap" + "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/multildap" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" @@ -28,7 +29,8 @@ func TestLoginUsingLDAP(t *testing.T) { return config, nil } - enabled, err := loginUsingLDAP(context.Background(), sc.loginUserQuery) + loginService := &logintest.LoginServiceFake{} + enabled, err := loginUsingLDAP(context.Background(), sc.loginUserQuery, loginService) require.EqualError(t, err, errTest.Error()) assert.True(t, enabled) @@ -39,7 +41,8 @@ func TestLoginUsingLDAP(t *testing.T) { setting.LDAPEnabled = false sc.withLoginResult(false) - enabled, err := loginUsingLDAP(context.Background(), sc.loginUserQuery) + loginService := &logintest.LoginServiceFake{} + enabled, err := loginUsingLDAP(context.Background(), sc.loginUserQuery, loginService) require.NoError(t, err) assert.False(t, enabled) diff --git a/pkg/middleware/auth_test.go b/pkg/middleware/auth_test.go index db6bdc96d5d..282c4a6eca8 100644 --- a/pkg/middleware/auth_test.go +++ b/pkg/middleware/auth_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -39,9 +38,7 @@ func TestMiddlewareAuth(t *testing.T) { middlewareScenario(t, "ReqSignIn true and NoAnonynmous true", func( t *testing.T, sc *scenarioContext) { - sqlStore := mockstore.NewSQLStoreMock() - sqlStore.ExpectedOrg = &models.Org{Id: orgID, Name: "test"} - sc.sqlStore = sqlStore + sc.mockSQLStore.ExpectedOrg = &models.Org{Id: orgID, Name: "test"} sc.m.Get("/api/secure", ReqSignedInNoAnonymous, sc.defaultHandler) sc.fakeReq("GET", "/api/secure").exec() @@ -50,9 +47,7 @@ func TestMiddlewareAuth(t *testing.T) { middlewareScenario(t, "ReqSignIn true and request with forceLogin in query string", func( t *testing.T, sc *scenarioContext) { - sqlStore := mockstore.NewSQLStoreMock() - sqlStore.ExpectedOrg = &models.Org{Id: orgID, Name: "test"} - sc.sqlStore = sqlStore + sc.mockSQLStore.ExpectedOrg = &models.Org{Id: orgID, Name: "test"} sc.m.Get("/secure", reqSignIn, sc.defaultHandler) sc.fakeReq("GET", "/secure?forceLogin=true").exec() @@ -65,7 +60,8 @@ func TestMiddlewareAuth(t *testing.T) { middlewareScenario(t, "ReqSignIn true and request with same org provided in query string", func( t *testing.T, sc *scenarioContext) { - org, err := sc.sqlStore.CreateOrgWithMember(sc.cfg.AnonymousOrgName, 1) + sc.mockSQLStore.ExpectedOrg = &models.Org{Id: 1, Name: sc.cfg.AnonymousOrgName} + org, err := sc.mockSQLStore.CreateOrgWithMember(sc.cfg.AnonymousOrgName, 1) require.NoError(t, err) sc.m.Get("/secure", reqSignIn, sc.defaultHandler) @@ -77,6 +73,7 @@ func TestMiddlewareAuth(t *testing.T) { middlewareScenario(t, "ReqSignIn true and request with different org provided in query string", func( t *testing.T, sc *scenarioContext) { + sc.mockSQLStore.ExpectedOrg = &models.Org{Id: 1, Name: sc.cfg.AnonymousOrgName} sc.m.Get("/secure", reqSignIn, sc.defaultHandler) sc.fakeReq("GET", "/secure?orgId=2").exec() diff --git a/pkg/middleware/logger.go b/pkg/middleware/logger.go index 1409d6249f5..15174880807 100644 --- a/pkg/middleware/logger.go +++ b/pkg/middleware/logger.go @@ -33,7 +33,7 @@ func Logger(cfg *setting.Cfg) web.Handler { c.Next() timeTaken := time.Since(start) / time.Millisecond - + duration := time.Since(start).String() ctx := contexthandler.FromContext(c.Req.Context()) if ctx != nil && ctx.PerfmonTimer != nil { ctx.PerfmonTimer.Observe(float64(timeTaken)) @@ -53,6 +53,7 @@ func Logger(cfg *setting.Cfg) web.Handler { "status", status, "remote_addr", c.RemoteAddr(), "time_ms", int64(timeTaken), + "duration", duration, "size", rw.Size(), "referer", req.Referer(), } diff --git a/pkg/middleware/middleware_basic_auth_test.go b/pkg/middleware/middleware_basic_auth_test.go index 38847fd4891..7588d2172e1 100644 --- a/pkg/middleware/middleware_basic_auth_test.go +++ b/pkg/middleware/middleware_basic_auth_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/stretchr/testify/assert" @@ -28,10 +29,7 @@ func TestMiddlewareBasicAuth(t *testing.T) { keyhash, err := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") require.NoError(t, err) - bus.AddHandler("test", func(ctx context.Context, query *models.GetApiKeyByNameQuery) error { - query.Result = &models.ApiKey{OrgId: orgID, Role: models.ROLE_EDITOR, Key: keyhash} - return nil - }) + sc.mockSQLStore.ExpectedAPIKey = &models.ApiKey{OrgId: orgID, Role: models.ROLE_EDITOR, Key: keyhash} authHeader := util.GetBasicAuthHeader("api_key", "eyJrIjoidjVuQXdwTWFmRlA2em5hUzR1cmhkV0RMUzU1MTFNNDIiLCJuIjoiYXNkIiwiaWQiOjF9") sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec() @@ -60,11 +58,7 @@ func TestMiddlewareBasicAuth(t *testing.T) { return nil }) - bus.AddHandler("get-sign-user", func(ctx context.Context, query *models.GetSignedInUserQuery) error { - t.Log("Handling GetSignedInUserQuery") - query.Result = &models.SignedInUser{OrgId: orgID, UserId: id} - return nil - }) + sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{OrgId: orgID, UserId: id} authHeader := util.GetBasicAuthHeader("myUser", password) sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec() @@ -78,25 +72,12 @@ func TestMiddlewareBasicAuth(t *testing.T) { const password = "MyPass" const salt = "Salt" - login.Init() + encoded, err := util.EncodePassword(password, salt) + require.NoError(t, err) - bus.AddHandler("user-query", func(ctx context.Context, query *models.GetUserByLoginQuery) error { - encoded, err := util.EncodePassword(password, salt) - if err != nil { - return err - } - query.Result = &models.User{ - Password: encoded, - Id: id, - Salt: salt, - } - return nil - }) - - bus.AddHandler("get-sign-user", func(ctx context.Context, query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{UserId: query.UserId} - return nil - }) + sc.mockSQLStore.ExpectedUser = &models.User{Password: encoded, Id: id, Salt: salt} + sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{UserId: id} + login.ProvideService(sc.mockSQLStore, &logintest.LoginServiceFake{}) authHeader := util.GetBasicAuthHeader("myUser", password) sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec() diff --git a/pkg/middleware/middleware_jwt_auth_test.go b/pkg/middleware/middleware_jwt_auth_test.go index 770d7b1c802..02d4075201d 100644 --- a/pkg/middleware/middleware_jwt_auth_test.go +++ b/pkg/middleware/middleware_jwt_auth_test.go @@ -46,14 +46,7 @@ func TestMiddlewareJWTAuth(t *testing.T) { "foo-username": myUsername, }, nil } - bus.AddHandler("get-sign-user", func(ctx context.Context, query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{ - UserId: id, - OrgId: orgID, - Login: query.Login, - } - return nil - }) + sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{UserId: id, OrgId: orgID, Login: myUsername} sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec() assert.Equal(t, verifiedToken, token) @@ -74,14 +67,7 @@ func TestMiddlewareJWTAuth(t *testing.T) { "foo-email": myEmail, }, nil } - bus.AddHandler("get-sign-user", func(ctx context.Context, query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{ - UserId: id, - OrgId: orgID, - Email: query.Email, - } - return nil - }) + sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{UserId: id, OrgId: orgID, Email: myEmail} sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec() assert.Equal(t, verifiedToken, token) @@ -103,9 +89,7 @@ func TestMiddlewareJWTAuth(t *testing.T) { "foo-email": myEmail, }, nil } - bus.AddHandler("get-sign-user", func(ctx context.Context, query *models.GetSignedInUserQuery) error { - return models.ErrUserNotFound - }) + sc.mockSQLStore.ExpectedError = models.ErrUserNotFound sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec() assert.Equal(t, verifiedToken, token) @@ -124,14 +108,7 @@ func TestMiddlewareJWTAuth(t *testing.T) { "foo-email": myEmail, }, nil } - bus.AddHandler("get-sign-user", func(ctx context.Context, query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{ - UserId: id, - OrgId: orgID, - Email: query.Email, - } - return 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, diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 637e02c0a10..fe645b87cd2 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -26,7 +26,6 @@ import ( "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/rendering" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -150,10 +149,7 @@ func TestMiddlewareContext(t *testing.T) { keyhash, err := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") require.NoError(t, err) - bus.AddHandler("test", func(ctx context.Context, query *models.GetApiKeyByNameQuery) error { - query.Result = &models.ApiKey{OrgId: orgID, Role: models.ROLE_EDITOR, Key: keyhash} - return nil - }) + sc.mockSQLStore.ExpectedAPIKey = &models.ApiKey{OrgId: orgID, Role: models.ROLE_EDITOR, Key: keyhash} sc.fakeReq("GET", "/").withValidApiKey().exec() @@ -166,11 +162,7 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "Valid API key, but does not match DB hash", func(t *testing.T, sc *scenarioContext) { const keyhash = "Something_not_matching" - - bus.AddHandler("test", func(ctx context.Context, query *models.GetApiKeyByNameQuery) error { - query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash} - return nil - }) + sc.mockSQLStore.ExpectedAPIKey = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash} sc.fakeReq("GET", "/").withValidApiKey().exec() @@ -184,13 +176,8 @@ func TestMiddlewareContext(t *testing.T) { keyhash, err := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") require.NoError(t, err) - bus.AddHandler("test", func(ctx context.Context, query *models.GetApiKeyByNameQuery) error { - // api key expired one second before - expires := sc.contextHandler.GetTime().Add(-1 * time.Second).Unix() - query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash, - Expires: &expires} - return nil - }) + expires := sc.contextHandler.GetTime().Add(-1 * time.Second).Unix() + sc.mockSQLStore.ExpectedAPIKey = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash, Expires: &expires} sc.fakeReq("GET", "/").withValidApiKey().exec() @@ -203,11 +190,7 @@ func TestMiddlewareContext(t *testing.T) { const userID int64 = 12 sc.withTokenSessionCookie("token") - - bus.AddHandler("test", func(ctx context.Context, query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 2, UserId: userID} - return nil - }) + sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{OrgId: 2, UserId: userID} sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { return &models.UserToken{ @@ -231,11 +214,7 @@ func TestMiddlewareContext(t *testing.T) { const userID int64 = 12 sc.withTokenSessionCookie("token") - - bus.AddHandler("test", func(ctx context.Context, query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 2, UserId: userID} - return nil - }) + sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{OrgId: 2, UserId: userID} sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { return &models.UserToken{ @@ -332,7 +311,8 @@ func TestMiddlewareContext(t *testing.T) { }) middlewareScenario(t, "When anonymous access is enabled", func(t *testing.T, sc *scenarioContext) { - org, err := sc.sqlStore.CreateOrgWithMember(sc.cfg.AnonymousOrgName, 1) + sc.mockSQLStore.ExpectedOrg = &models.Org{Id: 1, Name: sc.cfg.AnonymousOrgName} + org, err := sc.mockSQLStore.CreateOrgWithMember(sc.cfg.AnonymousOrgName, 1) require.NoError(t, err) sc.fakeReq("GET", "/").exec() @@ -651,7 +631,6 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func( func getContextHandler(t *testing.T, cfg *setting.Cfg, mockSQLStore *mockstore.SQLStoreMock, loginService *loginservice.LoginServiceMock) *contexthandler.ContextHandler { t.Helper() - sqlStore := sqlstore.InitTestDB(t) if cfg == nil { cfg = setting.NewCfg() } @@ -666,7 +645,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg, mockSQLStore *mockstore.S tracer, err := tracing.InitializeTracerForTest() authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginService, mockSQLStore) require.NoError(t, err) - return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy) + return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy) } type fakeRenderService struct { diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index 4c4620843f6..5917e9a5377 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -46,15 +46,11 @@ func TestOrgRedirectMiddleware(t *testing.T) { for _, tc := range testCases { 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 }) - bus.AddHandler("test", func(ctx context.Context, query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 1, UserId: 12} - return nil - }) - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { return &models.UserToken{ UserId: 0, @@ -75,11 +71,7 @@ func TestOrgRedirectMiddleware(t *testing.T) { bus.AddHandler("test", func(ctx context.Context, query *models.SetUsingOrgCommand) error { return fmt.Errorf("") }) - - bus.AddHandler("test", func(ctx context.Context, query *models.GetSignedInUserQuery) error { - query.Result = &models.SignedInUser{OrgId: 1, UserId: 12} - return nil - }) + sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{OrgId: 1, UserId: 12} sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { return &models.UserToken{ diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index 3112798a4a4..0d3ffec35ea 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -60,6 +60,7 @@ func TestMiddlewareQuota(t *testing.T) { const quotaUsed = 4 setUp := func(sc *scenarioContext) { sc.withTokenSessionCookie("token") + sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{UserId: 12} sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { return &models.UserToken{ UserId: 12, diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index 41bce5c2231..d2f0f732dd4 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -49,6 +49,17 @@ func (r RoleType) Children() []RoleType { } } +func (r RoleType) Parents() []RoleType { + switch r { + case ROLE_EDITOR: + return []RoleType{ROLE_ADMIN} + case ROLE_VIEWER: + return []RoleType{ROLE_EDITOR, ROLE_ADMIN} + default: + return nil + } +} + func (r *RoleType) UnmarshalJSON(data []byte) error { var str string err := json.Unmarshal(data, &str) diff --git a/pkg/models/user.go b/pkg/models/user.go index 0e528818f02..4d4b2bace22 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -169,20 +169,22 @@ type GetUserOrgListQuery struct { // DTO & Projections type SignedInUser struct { - UserId int64 - OrgId int64 - OrgName string - OrgRole RoleType - Login string - Name string - Email string - ApiKeyId int64 - OrgCount int - IsGrafanaAdmin bool - IsAnonymous bool - HelpFlags1 HelpFlags1 - LastSeenAt time.Time - Teams []int64 + UserId int64 + OrgId int64 + OrgName string + OrgRole RoleType + ExternalAuthModule string + ExternalAuthId string + Login string + Name string + Email string + ApiKeyId int64 + OrgCount int + IsGrafanaAdmin bool + IsAnonymous bool + HelpFlags1 HelpFlags1 + LastSeenAt time.Time + Teams []int64 // Permissions grouped by orgID and actions Permissions map[int64]map[string][]string `json:"-"` } @@ -231,7 +233,7 @@ type UserProfileDTO struct { Name string `json:"name"` Login string `json:"login"` Theme string `json:"theme"` - OrgId int64 `json:"orgId"` + OrgId int64 `json:"orgId,omitempty"` IsGrafanaAdmin bool `json:"isGrafanaAdmin"` IsDisabled bool `json:"isDisabled"` IsExternal bool `json:"isExternal"` diff --git a/pkg/plugins/manager/loader/loader.go b/pkg/plugins/manager/loader/loader.go index 03c0819d90b..dd940bf4e02 100644 --- a/pkg/plugins/manager/loader/loader.go +++ b/pkg/plugins/manager/loader/loader.go @@ -271,7 +271,13 @@ func setDefaultNavURL(p *plugins.Plugin) { p.DefaultNavURL = path.Join("/plugins/", p.ID, "/page/", include.Slug) } if include.Type == "dashboard" { - p.DefaultNavURL = path.Join("/dashboard/db/", include.Slug) + dboardURL := include.DashboardURLPath() + if dboardURL == "" { + p.Logger().Warn("Included dashboard is missing a UID field") + continue + } + + p.DefaultNavURL = dboardURL } } } diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index b21a9b6f02f..ec523fe3646 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -378,7 +378,7 @@ func TestLoader_Load(t *testing.T) { Plugins: []plugins.Dependency{}, }, Includes: []*plugins.Includes{ - {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: "Viewer", Slug: "nginx-memory", DefaultNav: true}, + {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: "Viewer", Slug: "nginx-memory"}, {Name: "Root Page (react)", Type: "page", Role: "Viewer", Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"}, }, Backend: false, @@ -411,6 +411,60 @@ func TestLoader_Load(t *testing.T) { } } +func TestLoader_setDefaultNavURL(t *testing.T) { + t.Run("When including a dashboard with DefaultNav: true", func(t *testing.T) { + pluginWithDashboard := &plugins.Plugin{ + JSONData: plugins.JSONData{Includes: []*plugins.Includes{ + { + Type: "dashboard", + DefaultNav: true, + UID: "", + }, + }}, + } + logger := &fakeLogger{loggedLines: []string{}} + pluginWithDashboard.SetLogger(logger) + + t.Run("Default nav URL is not set if dashboard UID field not is set", func(t *testing.T) { + setDefaultNavURL(pluginWithDashboard) + require.Equal(t, "", pluginWithDashboard.DefaultNavURL) + require.Equal(t, []string{"Included dashboard is missing a UID field"}, logger.loggedLines) + }) + + t.Run("Default nav URL is set if dashboard UID field is set", func(t *testing.T) { + pluginWithDashboard.Includes[0].UID = "a1b2c3" + + setDefaultNavURL(pluginWithDashboard) + require.Equal(t, "/d/a1b2c3", pluginWithDashboard.DefaultNavURL) + }) + }) + + t.Run("When including a page with DefaultNav: true", func(t *testing.T) { + pluginWithPage := &plugins.Plugin{ + JSONData: plugins.JSONData{Includes: []*plugins.Includes{ + { + Type: "page", + DefaultNav: true, + Slug: "testPage", + }, + }}, + } + + t.Run("Default nav URL is set using slug", func(t *testing.T) { + setDefaultNavURL(pluginWithPage) + require.Equal(t, "/plugins/page/testPage", pluginWithPage.DefaultNavURL) + }) + + t.Run("Default nav URL is set using slugified Name field if Slug field is empty", func(t *testing.T) { + pluginWithPage.Includes[0].Slug = "" + pluginWithPage.Includes[0].Name = "My Test Page" + + setDefaultNavURL(pluginWithPage) + require.Equal(t, "/plugins/page/my-test-page", pluginWithPage.DefaultNavURL) + }) + }) +} + func TestLoader_Load_MultiplePlugins(t *testing.T) { parentDir, err := filepath.Abs("../") if err != nil { @@ -1126,20 +1180,22 @@ func (*fakeLicensingService) FeatureEnabled(feature string) bool { type fakeLogger struct { log.Logger + + loggedLines []string } -func (fl fakeLogger) New(_ ...interface{}) *log.ConcreteLogger { +func (fl *fakeLogger) New(_ ...interface{}) *log.ConcreteLogger { return &log.ConcreteLogger{} } -func (fl fakeLogger) Info(_ string, _ ...interface{}) { - +func (fl *fakeLogger) Info(l string, _ ...interface{}) { + fl.loggedLines = append(fl.loggedLines, l) } -func (fl fakeLogger) Debug(_ string, _ ...interface{}) { - +func (fl *fakeLogger) Debug(l string, _ ...interface{}) { + fl.loggedLines = append(fl.loggedLines, l) } -func (fl fakeLogger) Warn(_ string, _ ...interface{}) { - +func (fl *fakeLogger) Warn(l string, _ ...interface{}) { + fl.loggedLines = append(fl.loggedLines, l) } diff --git a/pkg/plugins/manager/testdata/test-app-with-includes/plugin.json b/pkg/plugins/manager/testdata/test-app-with-includes/plugin.json index 043e7544bf1..65aa7bab5c9 100644 --- a/pkg/plugins/manager/testdata/test-app-with-includes/plugin.json +++ b/pkg/plugins/manager/testdata/test-app-with-includes/plugin.json @@ -20,8 +20,7 @@ { "type": "dashboard", "name": "Nginx Memory", - "path": "dashboards/memory.json", - "defaultNav": true + "path": "dashboards/memory.json" }, { "type": "page", diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index d2d5bd9ae48..3da073d8e21 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -97,12 +97,11 @@ type Includes struct { ID string `json:"-"` } -func (e Includes) GetSlugOrUIDLink() string { - if len(e.UID) > 0 { - return "/d/" + e.UID - } else { - return "/dashboard/db/" + e.Slug +func (e Includes) DashboardURLPath() string { + if e.Type != "dashboard" || len(e.UID) == 0 { + return "" } + return "/d/" + e.UID } type Dependency struct { diff --git a/pkg/server/server.go b/pkg/server/server.go index 92a2839db8b..b1e8df32f94 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -118,7 +118,7 @@ func (s *Server) init() error { return err } - login.Init() + login.ProvideService(s.HTTPServer.SQLStore, s.HTTPServer.Login) social.ProvideService(s.cfg) if err := s.roleRegistry.RegisterFixedRoles(); err != nil { diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 243ba26576b..ab9210af8ac 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -7,6 +7,7 @@ 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/expr" @@ -20,6 +21,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" + loginpkg "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" @@ -160,6 +162,8 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(login.AuthInfoService), new(*authinfoservice.Implementation)), authinfodatabase.ProvideAuthInfoStore, wire.Bind(new(login.Store), new(*authinfodatabase.AuthInfoStore)), + loginpkg.ProvideService, + wire.Bind(new(loginpkg.Authenticator), new(*loginpkg.AuthenticatorService)), datasourceproxy.ProvideService, search.ProvideService, searchV2.ProvideService, @@ -231,6 +235,7 @@ var wireBasicSet = wire.NewSet( comments.ProvideService, guardian.ProvideService, secretsStore.ProvideService, + avatar.ProvideAvatarCacheServer, authproxy.ProvideAuthProxy, ) @@ -260,7 +265,7 @@ var wireTestSet = wire.NewSet( wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationServiceMock)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationServiceMock)), mockstore.NewSQLStoreMock, - wire.Bind(new(sqlstore.Store), new(*mockstore.SQLStoreMock)), + wire.Bind(new(sqlstore.Store), new(*sqlstore.SQLStore)), ) func Initialize(cla setting.CommandLineArgs, opts Options, apiOpts api.ServerOptions) (*Server, error) { diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go index 4ac29c682e6..36ed825e626 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go @@ -16,38 +16,81 @@ import ( ) func ProvideService(features featuremgmt.FeatureToggles, usageStats usagestats.Service, - provider accesscontrol.PermissionsProvider, routeRegister routing.RouteRegister) *OSSAccessControlService { - s := ProvideOSSAccessControl(features, usageStats, provider) - s.registerUsageMetrics() + provider accesscontrol.PermissionsProvider, routeRegister routing.RouteRegister) (*OSSAccessControlService, error) { + var errDeclareRoles error + s := ProvideOSSAccessControl(features, provider) + s.registerUsageMetrics(usageStats) if !s.IsDisabled() { api := api.AccessControlAPI{ RouteRegister: routeRegister, AccessControl: s, } api.RegisterAPIEndpoints() + + errDeclareRoles = accesscontrol.DeclareFixedRoles(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{}, + }, } - return s } // ProvideOSSAccessControl creates an oss implementation of access control without usage stats registration -func ProvideOSSAccessControl(features featuremgmt.FeatureToggles, usageStats usagestats.Service, provider accesscontrol.PermissionsProvider) *OSSAccessControlService { - return &OSSAccessControlService{ +func ProvideOSSAccessControl(features featuremgmt.FeatureToggles, provider accesscontrol.PermissionsProvider) *OSSAccessControlService { + s := &OSSAccessControlService{ features: features, provider: provider, - usageStats: usageStats, log: log.New("accesscontrol"), scopeResolver: accesscontrol.NewScopeResolver(), + roles: macroRoles(), } + + return s } // OSSAccessControlService is the service implementing role based access control. type OSSAccessControlService struct { log log.Logger - usageStats usagestats.Service features featuremgmt.FeatureToggles scopeResolver accesscontrol.ScopeResolver provider accesscontrol.PermissionsProvider registrations accesscontrol.RegistrationList + roles map[string]*accesscontrol.RoleDTO } func (ac *OSSAccessControlService) IsDisabled() bool { @@ -57,8 +100,8 @@ func (ac *OSSAccessControlService) IsDisabled() bool { return !ac.features.IsEnabled(featuremgmt.FlagAccesscontrol) } -func (ac *OSSAccessControlService) registerUsageMetrics() { - ac.usageStats.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) { +func (ac *OSSAccessControlService) registerUsageMetrics(usageStats usagestats.Service) { + usageStats.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) { return map[string]interface{}{ "stats.oss.accesscontrol.enabled.count": ac.getUsageMetrics(), }, nil @@ -140,15 +183,9 @@ func (ac *OSSAccessControlService) getFixedPermissions(ctx context.Context, user permissions := make([]*accesscontrol.Permission, 0) for _, builtin := range ac.GetUserBuiltInRoles(user) { - if roleNames, ok := accesscontrol.FixedRoleGrants[builtin]; ok { - for _, name := range roleNames { - role, exists := accesscontrol.FixedRoles[name] - if !exists { - continue - } - for i := range role.Permissions { - permissions = append(permissions, &role.Permissions[i]) - } + if macroRole, ok := ac.roles[builtin]; ok { + for i := range macroRole.Permissions { + permissions = append(permissions, ¯oRole.Permissions[i]) } } } @@ -157,49 +194,20 @@ func (ac *OSSAccessControlService) getFixedPermissions(ctx context.Context, user } func (ac *OSSAccessControlService) GetUserBuiltInRoles(user *models.SignedInUser) []string { - roles := []string{string(user.OrgRole)} - for _, role := range user.OrgRole.Children() { - roles = append(roles, string(role)) + builtInRoles := []string{string(user.OrgRole)} + + // With built-in role simplifying, inheritance is performed upon role registration. + if !ac.features.IsEnabled(featuremgmt.FlagAccesscontrolBuiltins) { + for _, br := range user.OrgRole.Children() { + builtInRoles = append(builtInRoles, string(br)) + } } + if user.IsGrafanaAdmin { - roles = append(roles, accesscontrol.RoleGrafanaAdmin) + builtInRoles = append(builtInRoles, accesscontrol.RoleGrafanaAdmin) } - return roles -} - -func (ac *OSSAccessControlService) saveFixedRole(role accesscontrol.RoleDTO) { - if storedRole, ok := accesscontrol.FixedRoles[role.Name]; ok { - // If a package wants to override another package's role, the version - // needs to be increased. Hence, we don't overwrite a role with a - // greater version. - if storedRole.Version >= role.Version { - ac.log.Debug("the role has already been stored in a greater version, skipping registration", "role", role.Name) - return - } - } - // Save role - accesscontrol.FixedRoles[role.Name] = role -} - -func (ac *OSSAccessControlService) assignFixedRole(role accesscontrol.RoleDTO, builtInRoles []string) { - for _, builtInRole := range builtInRoles { - // Only record new assignments - alreadyAssigned := false - assignments, ok := accesscontrol.FixedRoleGrants[builtInRole] - if ok { - for _, assignedRole := range assignments { - if assignedRole == role.Name { - ac.log.Debug("the role has already been assigned", "rolename", role.Name, "build_in_role", builtInRole) - alreadyAssigned = true - } - } - } - if !alreadyAssigned { - assignments = append(assignments, role.Name) - accesscontrol.FixedRoleGrants[builtInRole] = assignments - } - } + return builtInRoles } // RegisterFixedRoles registers all declared roles in RAM @@ -208,18 +216,33 @@ func (ac *OSSAccessControlService) RegisterFixedRoles() error { if ac.IsDisabled() { return nil } - var err error ac.registrations.Range(func(registration accesscontrol.RoleRegistration) bool { ac.registerFixedRole(registration.Role, registration.Grants) return true }) - return err + return nil } // RegisterFixedRole saves a fixed role and assigns it to built-in roles func (ac *OSSAccessControlService) registerFixedRole(role accesscontrol.RoleDTO, builtInRoles []string) { - ac.saveFixedRole(role) - ac.assignFixedRole(role, builtInRoles) + // 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 { + if macroRole, ok := ac.roles[br]; ok { + macroRole.Permissions = append(macroRole.Permissions, role.Permissions...) + } else { + ac.log.Error("Unknown builtin role", "builtInRole", br) + } + } } // DeclareFixedRoles allow the caller to declare, to the service, fixed roles and their assignments diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go index 7ff45b1e87c..f26fb33788c 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go @@ -2,7 +2,6 @@ package ossaccesscontrol import ( "context" - "fmt" "testing" "github.com/stretchr/testify/assert" @@ -23,37 +22,16 @@ func setupTestEnv(t testing.TB) *OSSAccessControlService { ac := &OSSAccessControlService{ features: featuremgmt.WithFeatures(featuremgmt.FlagAccesscontrol), - usageStats: &usagestats.UsageStatsMock{T: t}, log: log.New("accesscontrol"), registrations: accesscontrol.RegistrationList{}, scopeResolver: accesscontrol.NewScopeResolver(), provider: database.ProvideService(sqlstore.InitTestDB(t)), + roles: macroRoles(), } + require.NoError(t, ac.RegisterFixedRoles()) return ac } -func removeRoleHelper(role string) { - delete(accesscontrol.FixedRoles, role) - - // Compute new grants removing any appearance of the role in the list - replaceGrants := map[string][]string{} - - for builtInRole, grants := range accesscontrol.FixedRoleGrants { - newGrants := make([]string, len(grants)) - for _, r := range grants { - if r != role { - newGrants = append(newGrants, r) - } - } - replaceGrants[builtInRole] = newGrants - } - - // Replace grants - for br, grants := range replaceGrants { - accesscontrol.FixedRoleGrants[br] = grants - } -} - // extractRawPermissionsHelper extracts action and scope fields only from a permission slice func extractRawPermissionsHelper(perms []*accesscontrol.Permission) []*accesscontrol.Permission { res := make([]*accesscontrol.Permission, len(perms)) @@ -112,6 +90,13 @@ func TestEvaluatingPermissions(t *testing.T) { t.Run(tc.desc, func(t *testing.T) { ac := setupTestEnv(t) + // Use OSS roles for this test to pass + err := accesscontrol.DeclareFixedRoles(ac) + require.NoError(t, err) + + errRegisterRoles := ac.RegisterFixedRoles() + require.NoError(t, errRegisterRoles) + user := &models.SignedInUser{ UserId: 1, OrgId: 1, @@ -149,13 +134,16 @@ func TestUsageMetrics(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := ProvideService( + usagestatsmock := &usagestats.UsageStatsMock{T: t} + + _, errInitAc := ProvideService( featuremgmt.WithFeatures("accesscontrol", tt.enabled), - &usagestats.UsageStatsMock{T: t}, + usagestatsmock, database.ProvideService(sqlstore.InitTestDB(t)), routing.NewRouteRegister(), ) - report, err := s.usageStats.GetUsageReport(context.Background()) + require.NoError(t, errInitAc) + report, err := usagestatsmock.GetUsageReport(context.Background()) assert.Nil(t, err) assert.Equal(t, tt.expectedValue, report.Metrics["stats.oss.accesscontrol.enabled.count"]) @@ -163,151 +151,35 @@ func TestUsageMetrics(t *testing.T) { } } -type assignmentTestCase struct { - role accesscontrol.RoleDTO - builtInRoles []string -} - func TestOSSAccessControlService_RegisterFixedRole(t *testing.T) { - tests := []struct { - name string - runs []assignmentTestCase - }{ - { - name: "Successfully register role no assignments", - runs: []assignmentTestCase{ - { - role: accesscontrol.RoleDTO{ - Version: 1, - Name: "fixed:test:test", - }, - }, - }, - }, - { - name: "Successfully ignore overwriting existing role", - runs: []assignmentTestCase{ - { - role: accesscontrol.RoleDTO{ - Version: 1, - Name: "fixed:test:test", - }, - }, - { - role: accesscontrol.RoleDTO{ - Version: 1, - Name: "fixed:test:test", - }, - }, - }, - }, - { - name: "Successfully register and assign role", - runs: []assignmentTestCase{ - { - role: accesscontrol.RoleDTO{ - Version: 1, - Name: "fixed:test:test", - }, - builtInRoles: []string{"Viewer", "Editor", "Admin"}, - }, - }, - }, - { - name: "Successfully ignore unchanged assignment", - runs: []assignmentTestCase{ - { - role: accesscontrol.RoleDTO{ - Version: 1, - Name: "fixed:test:test", - }, - builtInRoles: []string{"Viewer"}, - }, - { - role: accesscontrol.RoleDTO{ - Version: 2, - Name: "fixed:test:test", - }, - builtInRoles: []string{"Viewer"}, - }, - }, - }, - { - name: "Successfully add a new assignment", - runs: []assignmentTestCase{ - { - role: accesscontrol.RoleDTO{ - Version: 1, - Name: "fixed:test:test", - }, - builtInRoles: []string{"Viewer"}, - }, - { - role: accesscontrol.RoleDTO{ - Version: 1, - Name: "fixed:test:test", - }, - builtInRoles: []string{"Editor"}, - }, - }, - }, + perm := accesscontrol.Permission{Action: "test:test", Scope: "test:*"} + + role := accesscontrol.RoleDTO{ + Version: 1, + Name: "fixed:test:test", + Permissions: []accesscontrol.Permission{perm}, + } + builtInRoles := []string{"Editor"} + + // Admin is going to get the role as well + includedBuiltInRoles := []string{"Editor", "Admin"} + + // Grafana Admin and Viewer won't get the role + excludedbuiltInRoles := []string{"Viewer", "Grafana Admin"} + + ac := setupTestEnv(t) + ac.registerFixedRole(role, builtInRoles) + + for _, br := range includedBuiltInRoles { + builtinRole, ok := ac.roles[br] + assert.True(t, ok) + assert.Contains(t, builtinRole.Permissions, perm) } - // Check all runs performed so far to get the number of assignments seeder - // should have recorded - getTotalAssignCount := func(curRunIdx int, runs []assignmentTestCase) int { - builtIns := map[string]struct{}{} - for i := 0; i < curRunIdx+1; i++ { - for _, br := range runs[i].builtInRoles { - builtIns[br] = struct{}{} - } - } - return len(builtIns) - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - ac := &OSSAccessControlService{ - features: featuremgmt.WithFeatures(), - usageStats: &usagestats.UsageStatsMock{T: t}, - log: log.New("accesscontrol-test"), - } - - for i, run := range tc.runs { - // Remove any inserted role after the test case has been run - t.Cleanup(func() { removeRoleHelper(run.role.Name) }) - - ac.registerFixedRole(run.role, run.builtInRoles) - - // Check role has been registered - storedRole, ok := accesscontrol.FixedRoles[run.role.Name] - assert.True(t, ok, "role should have been registered") - - // Check registered role has not been altered - assert.Equal(t, run.role, storedRole, "role should not have been altered") - - // Check assignments - // Count number of times the role has been assigned - assignCnt := 0 - for _, grants := range accesscontrol.FixedRoleGrants { - for _, r := range grants { - if r == run.role.Name { - assignCnt++ - } - } - } - assert.Equal(t, getTotalAssignCount(i, tc.runs), assignCnt, - "assignments should only be added, never removed") - - for _, br := range run.builtInRoles { - assigns, ok := accesscontrol.FixedRoleGrants[br] - assert.True(t, ok, - fmt.Sprintf("role %s should have been assigned to %s", run.role.Name, br)) - assert.Contains(t, assigns, run.role.Name, - fmt.Sprintf("role %s should have been assigned to %s", run.role.Name, br)) - } - } - }) + for _, br := range excludedbuiltInRoles { + builtinRole, ok := ac.roles[br] + assert.True(t, ok) + assert.NotContains(t, builtinRole.Permissions, perm) } } @@ -388,6 +260,9 @@ func TestOSSAccessControlService_DeclareFixedRoles(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ac := setupTestEnv(t) + // Reset the registations + ac.registrations = accesscontrol.RegistrationList{} + // Test err := ac.DeclareFixedRoles(tt.registrations...) if tt.wantErr { @@ -423,10 +298,11 @@ func TestOSSAccessControlService_RegisterFixedRoles(t *testing.T) { registrations: []accesscontrol.RoleRegistration{ { Role: accesscontrol.RoleDTO{ - Version: 1, - Name: "fixed:test:test", + Version: 1, + Name: "fixed:test:test", + Permissions: []accesscontrol.Permission{{Action: "test:test"}}, }, - Grants: []string{"Admin"}, + Grants: []string{"Editor"}, }, }, wantErr: false, @@ -436,17 +312,22 @@ func TestOSSAccessControlService_RegisterFixedRoles(t *testing.T) { registrations: []accesscontrol.RoleRegistration{ { Role: accesscontrol.RoleDTO{ - Version: 1, - Name: "fixed:test:test", + Version: 1, + Name: "fixed:test:test", + Permissions: []accesscontrol.Permission{{Action: "test:test"}}, }, - Grants: []string{"Admin"}, + Grants: []string{"Editor"}, }, { Role: accesscontrol.RoleDTO{ Version: 1, Name: "fixed:test2:test2", + Permissions: []accesscontrol.Permission{ + {Action: "test:test2"}, + {Action: "test:test3", Scope: "test:*"}, + }, }, - Grants: []string{"Admin"}, + Grants: []string{"Viewer"}, }, }, wantErr: false, @@ -455,13 +336,8 @@ func TestOSSAccessControlService_RegisterFixedRoles(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Remove any inserted role after the test case has been run - t.Cleanup(func() { - for _, registration := range tt.registrations { - removeRoleHelper(registration.Role.Name) - } - }) ac := setupTestEnv(t) + ac.registrations.Append(tt.registrations...) // Test @@ -474,18 +350,24 @@ func TestOSSAccessControlService_RegisterFixedRoles(t *testing.T) { // Check for _, registration := range tt.registrations { - role, ok := accesscontrol.FixedRoles[registration.Role.Name] - assert.True(t, ok, - fmt.Sprintf("role %s should have been registered", registration.Role.Name)) - assert.NotNil(t, role, - fmt.Sprintf("role %s should have been registered", registration.Role.Name)) - + // Prepare list of builtin roles to check + brAndParents := map[string]struct{}{} for _, br := range registration.Grants { - rolesWithGrant, ok := accesscontrol.FixedRoleGrants[br] - assert.True(t, ok, - fmt.Sprintf("role %s should have been assigned to %s", registration.Role.Name, br)) - assert.Contains(t, rolesWithGrant, registration.Role.Name, - fmt.Sprintf("role %s should have been assigned to %s", registration.Role.Name, br)) + 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 { + builtinRole, ok := ac.roles[br] + assert.True(t, ok) + for _, expectedPermission := range registration.Role.Permissions { + assert.Contains(t, builtinRole.Permissions, expectedPermission) + } } } }) @@ -529,11 +411,6 @@ func TestOSSAccessControlService_GetUserPermissions(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Remove any inserted role after the test case has been run - t.Cleanup(func() { - removeRoleHelper(registration.Role.Name) - }) - // Setup ac := setupTestEnv(t) @@ -614,11 +491,6 @@ func TestOSSAccessControlService_Evaluate(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Remove any inserted role after the test case has been run - t.Cleanup(func() { - removeRoleHelper(registration.Role.Name) - }) - // Setup ac := setupTestEnv(t) ac.RegisterAttributeScopeResolver("users:login:", userLoginScopeSolver) diff --git a/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go index 03a3dee7243..217812cea9f 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go @@ -169,13 +169,18 @@ func ProvideDashboardPermissions( return nil }, + InheritedScopePrefixes: []string{"folders:uid:"}, InheritedScopesSolver: func(ctx context.Context, orgID int64, resourceID string) ([]string, error) { dashboard, err := getDashboard(ctx, orgID, resourceID) if err != nil { return nil, err } if dashboard.FolderId > 0 { - return []string{dashboards.ScopeFoldersProvider.GetResourceScopeUID(dashboard.Uid)}, nil + query := &models.GetDashboardQuery{Id: dashboard.FolderId, OrgId: orgID} + if err := sql.GetDashboard(ctx, query); err != nil { + return nil, err + } + return []string{dashboards.ScopeFoldersProvider.GetResourceScopeUID(query.Result.Uid)}, nil } return []string{}, nil }, diff --git a/pkg/services/accesscontrol/resourcepermissions/api.go b/pkg/services/accesscontrol/resourcepermissions/api.go index 582e410b6b1..2894c74a77b 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api.go +++ b/pkg/services/accesscontrol/resourcepermissions/api.go @@ -30,18 +30,45 @@ func newApi(ac accesscontrol.AccessControl, router routing.RouteRegister, manage return &api{ac, router, manager, permissions} } +func (a *api) getEvaluators(actionRead, actionWrite, scope string) (read, write accesscontrol.Evaluator) { + if a.service.options.InheritedScopesSolver == nil || a.service.options.InheritedScopePrefixes == nil { + read = accesscontrol.EvalPermission(actionRead, scope) + write = accesscontrol.EvalPermission(actionWrite, scope) + } else { + // Add inherited scopes to the evaluators protecting the endpoint. + // Scopes in the request context parameters are to be added by solveInheritedScopes. + // If a user got actionRead on any of the inherited scopes, they will be granted access to the endpoint. + // Ex: a user inherits dashboards:read from the containing folder (folders:uid:BCeknZL7k) + inheritedRead := []accesscontrol.Evaluator{accesscontrol.EvalPermission(actionRead, scope)} + inheritedWrite := []accesscontrol.Evaluator{accesscontrol.EvalPermission(actionWrite, scope)} + for _, scopePrefix := range a.service.options.InheritedScopePrefixes { + inheritedRead = append(inheritedRead, + accesscontrol.EvalPermission(actionRead, accesscontrol.Parameter(scopePrefix))) + inheritedWrite = append(inheritedWrite, + accesscontrol.EvalPermission(actionWrite, accesscontrol.Parameter(scopePrefix))) + } + + read = accesscontrol.EvalAny(inheritedRead...) + write = accesscontrol.EvalAny(inheritedWrite...) + } + return +} + func (a *api) registerEndpoints() { auth := middleware.Middleware(a.ac) uidSolver := solveUID(a.service.options.UidSolver) + inheritanceSolver := solveInheritedScopes(a.service.options.InheritedScopesSolver) disable := middleware.Disable(a.ac.IsDisabled()) a.router.Group(fmt.Sprintf("/api/access-control/%s", a.service.options.Resource), func(r routing.RouteRegister) { + actionRead := fmt.Sprintf("%s.permissions:read", a.service.options.Resource) + actionWrite := fmt.Sprintf("%s.permissions:write", a.service.options.Resource) scope := accesscontrol.Scope(a.service.options.Resource, a.service.options.ResourceAttribute, accesscontrol.Parameter(":resourceID")) - actionWrite, actionRead := fmt.Sprintf("%s.permissions:write", a.service.options.Resource), fmt.Sprintf("%s.permissions:read", a.service.options.Resource) + readEvaluator, writeEvaluator := a.getEvaluators(actionRead, actionWrite, scope) r.Get("/description", auth(disable, accesscontrol.EvalPermission(actionRead)), routing.Wrap(a.getDescription)) - r.Get("/:resourceID", uidSolver, auth(disable, accesscontrol.EvalPermission(actionRead, scope)), routing.Wrap(a.getPermissions)) - r.Post("/:resourceID/users/:userID", uidSolver, auth(disable, accesscontrol.EvalPermission(actionWrite, scope)), routing.Wrap(a.setUserPermission)) - r.Post("/:resourceID/teams/:teamID", uidSolver, auth(disable, accesscontrol.EvalPermission(actionWrite, scope)), routing.Wrap(a.setTeamPermission)) - r.Post("/:resourceID/builtInRoles/:builtInRole", uidSolver, auth(disable, accesscontrol.EvalPermission(actionWrite, scope)), routing.Wrap(a.setBuiltinRolePermission)) + r.Get("/:resourceID", inheritanceSolver, uidSolver, auth(disable, readEvaluator), routing.Wrap(a.getPermissions)) + r.Post("/:resourceID/users/:userID", inheritanceSolver, uidSolver, auth(disable, writeEvaluator), routing.Wrap(a.setUserPermission)) + r.Post("/:resourceID/teams/:teamID", inheritanceSolver, uidSolver, auth(disable, writeEvaluator), routing.Wrap(a.setTeamPermission)) + r.Post("/:resourceID/builtInRoles/:builtInRole", inheritanceSolver, uidSolver, auth(disable, writeEvaluator), routing.Wrap(a.setBuiltinRolePermission)) }) } diff --git a/pkg/services/accesscontrol/resourcepermissions/api_test.go b/pkg/services/accesscontrol/resourcepermissions/api_test.go index db357923d85..efda961430b 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/api_test.go @@ -167,16 +167,7 @@ func TestApi_getPermissions(t *testing.T) { assert.Equal(t, tt.expectedStatus, recorder.Code) if tt.expectedStatus == http.StatusOK { - assert.Len(t, permissions, 3, "expected three assignments: user, team, builtin") - for _, p := range permissions { - if p.UserID != 0 { - assert.Equal(t, "View", p.Permission) - } else if p.TeamID != 0 { - assert.Equal(t, "Edit", p.Permission) - } else { - assert.Equal(t, "Edit", p.Permission) - } - } + checkSeededPermissions(t, permissions) } }) } @@ -468,18 +459,7 @@ func TestApi_UidSolver(t *testing.T) { assert.Equal(t, tt.expectedStatus, recorder.Code) if tt.expectedStatus == http.StatusOK { - assert.Len(t, permissions, 3, "expected three assignments: user, team, builtin") - for _, p := range permissions { - if p.UserID != 0 { - assert.Equal(t, "View", p.Permission) - } else if p.TeamID != 0 { - assert.Equal(t, "Edit", p.Permission) - } else { - assert.Equal(t, "Edit", p.Permission) - } - } - } else { - assert.Equal(t, tt.expectedStatus, recorder.Code) + checkSeededPermissions(t, permissions) } }) } @@ -490,6 +470,66 @@ func withSolver(options Options, solver UidSolver) Options { return options } +type inheritSolverTestCase struct { + desc string + resourceID string + expectedStatus int +} + +func TestApi_InheritSolver(t *testing.T) { + tests := []inheritSolverTestCase{ + { + desc: "expect parents permission to apply", + resourceID: "resourceID", + expectedStatus: http.StatusOK, + }, + { + desc: "expect direct permissions to apply (no inheritance)", + resourceID: "orphanedID", + expectedStatus: http.StatusOK, + }, + { + desc: "expect 404 when resource is not found", + resourceID: "notfound", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + userPermissions := []*accesscontrol.Permission{ + {Action: "dashboards.permissions:read", Scope: "parents:id:parentID"}, // Inherited permission + {Action: "dashboards.permissions:read", Scope: "dashboards:id:orphanedID"}, // Direct permission + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, + {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll}, + } + // Add the inheritance solver "resourceID -> [parentID]" "orphanedID -> []" + service, sql := setupTestEnvironment(t, userPermissions, + withInheritance(testOptions, testInheritedScopeSolver, testInheritedScopePrefixes), + ) + server := setupTestServer(t, &models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{ + 1: accesscontrol.GroupScopesByAction(userPermissions), + }}, service) + + // Seed permissions for users/teams/built-in roles specific to the test case resourceID + seedPermissions(t, tt.resourceID, sql, service) + + permissions, recorder := getPermission(t, server, testOptions.Resource, tt.resourceID) + require.Equal(t, tt.expectedStatus, recorder.Code) + + if tt.expectedStatus == http.StatusOK { + checkSeededPermissions(t, permissions) + } + }) + } +} + +func withInheritance(options Options, solver InheritedScopesSolver, inheritedPrefixes []string) Options { + options.InheritedScopesSolver = solver + options.InheritedScopePrefixes = inheritedPrefixes + return options +} + func setupTestServer(t *testing.T, user *models.SignedInUser, service *Service) *web.Mux { server := web.New() server.UseMiddleware(web.Renderer(path.Join(setting.StaticRootPath, "views"), "[[", "]]")) @@ -530,6 +570,17 @@ var testOptions = Options{ }, } +var testInheritedScopePrefixes = []string{"parents:id:"} +var testInheritedScopeSolver = func(ctx context.Context, orgID int64, id string) ([]string, error) { + if id == "resourceID" { // Has parent + return []string{"parents:id:parentID"}, nil + } + if id == "orphanedID" { // Exists but with no parent + return nil, nil + } + return nil, errors.New("not found") +} + var testSolver = func(ctx context.Context, orgID int64, uid string) (int64, error) { if uid == "resourceUID" { return 1, nil @@ -561,6 +612,19 @@ func setPermission(t *testing.T, server *web.Mux, resource, resourceID, permissi return recorder } +func checkSeededPermissions(t *testing.T, permissions []resourcePermissionDTO) { + assert.Len(t, permissions, 3, "expected three assignments: user, team, builtin") + for _, p := range permissions { + if p.UserID != 0 { + assert.Equal(t, "View", p.Permission) + } else if p.TeamID != 0 { + assert.Equal(t, "Edit", p.Permission) + } else { + assert.Equal(t, "Edit", p.Permission) + } + } +} + func seedPermissions(t *testing.T, resourceID string, sql *sqlstore.SQLStore, service *Service) { t.Helper() // seed team 1 with "Edit" permission on dashboard 1 diff --git a/pkg/services/accesscontrol/resourcepermissions/middleware.go b/pkg/services/accesscontrol/resourcepermissions/middleware.go index d1802336013..b7ff11ff345 100644 --- a/pkg/services/accesscontrol/resourcepermissions/middleware.go +++ b/pkg/services/accesscontrol/resourcepermissions/middleware.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/grafana/grafana/pkg/models" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -23,3 +24,22 @@ func solveUID(solve UidSolver) web.Handler { } } } + +// solveInheritedScopes will add the inherited scopes to the context param by prefix +// Ex: params["folders:uid:"] = "folders:uid:BCeknZL7k" +func solveInheritedScopes(solve InheritedScopesSolver) web.Handler { + return func(c *models.ReqContext) { + if solve != nil && util.IsValidShortUID(web.Params(c.Req)[":resourceID"]) { + params := web.Params(c.Req) + scopes, err := solve(c.Req.Context(), c.OrgId, params[":resourceID"]) + if err != nil { + c.JsonApiErr(http.StatusNotFound, "Resource not found", err) + return + } + for _, scope := range scopes { + params[ac.ScopePrefix(scope)] = scope + } + web.SetURLParams(c.Req, params) + } + } +} diff --git a/pkg/services/accesscontrol/resourcepermissions/options.go b/pkg/services/accesscontrol/resourcepermissions/options.go index 16939e8a3e7..819622b4df0 100644 --- a/pkg/services/accesscontrol/resourcepermissions/options.go +++ b/pkg/services/accesscontrol/resourcepermissions/options.go @@ -42,4 +42,6 @@ type Options struct { UidSolver UidSolver // InheritedScopesSolver if configured can generate additional scopes that will be used when fetching permissions for a resource InheritedScopesSolver InheritedScopesSolver + // InheritedScopePrefixes if configured are used to create evaluators with the scopes returned by InheritedScopesSolver + InheritedScopePrefixes []string } diff --git a/pkg/services/accesscontrol/roles.go b/pkg/services/accesscontrol/roles.go index b8d20490a13..7561c836151 100644 --- a/pkg/services/accesscontrol/roles.go +++ b/pkg/services/accesscontrol/roles.go @@ -16,7 +16,7 @@ type RoleRegistry interface { // Roles definition var ( ldapReaderRole = RoleDTO{ - Name: ldapReader, + Name: "fixed:ldap:reader", DisplayName: "LDAP reader", Description: "Read LDAP configuration and status.", Group: "LDAP", @@ -32,7 +32,7 @@ var ( } ldapWriterRole = RoleDTO{ - Name: ldapWriter, + Name: "fixed:ldap:writer", DisplayName: "LDAP writer", Description: "Read and update LDAP configuration and read LDAP status.", Group: "LDAP", @@ -48,7 +48,7 @@ var ( } orgUsersWriterRole = RoleDTO{ - Name: orgUsersWriter, + Name: "fixed:org.users:writer", DisplayName: "Organization user writer", Description: "Within a single organization, add a user, invite a user, read information about a user and their role, remove a user from that organization, or change the role of a user.", Group: "User administration (organizational)", @@ -70,7 +70,7 @@ var ( } orgUsersReaderRole = RoleDTO{ - Name: orgUsersReader, + Name: "fixed:org.users:reader", DisplayName: "Organization user reader", Description: "Read users within a single organization.", Group: "User administration (organizational)", @@ -83,12 +83,12 @@ var ( }, } - settingsReaderRole = RoleDTO{ - Version: 4, + SettingsReaderRole = RoleDTO{ + Name: "fixed:settings:reader", DisplayName: "Setting reader", Description: "Read Grafana instance settings.", Group: "Settings", - Name: settingsReader, + Version: 4, Permissions: []Permission{ { Action: ActionSettingsRead, @@ -98,11 +98,11 @@ var ( } statsReaderRole = RoleDTO{ - Version: 3, - Name: statsReader, + Name: "fixed:stats:reader", DisplayName: "Statistics reader", Description: "Read Grafana instance statistics.", Group: "Statistics", + Version: 3, Permissions: []Permission{ { Action: ActionServerStatsRead, @@ -111,7 +111,7 @@ var ( } usersReaderRole = RoleDTO{ - Name: usersReader, + Name: "fixed:users:reader", DisplayName: "User reader", Description: "Read all users and their information, such as team memberships, authentication tokens, and quotas.", Group: "User administration (global)", @@ -137,7 +137,7 @@ var ( } usersWriterRole = RoleDTO{ - Name: usersWriter, + Name: "fixed:users:writer", DisplayName: "User writer", Description: "Read and update all attributes and settings for all users in Grafana: update user information, read user information, create or enable or disable a user, make a user a Grafana administrator, sign out a user, update a user’s authentication token, or update quotas for all users.", Group: "User administration (global)", @@ -186,55 +186,44 @@ var ( } ) -// Role names definitions -const ( - ldapReader = "fixed:ldap:reader" - ldapWriter = "fixed:ldap:writer" - orgUsersReader = "fixed:org.users:reader" - orgUsersWriter = "fixed:org.users:writer" - settingsReader = "fixed:settings:reader" - statsReader = "fixed:stats:reader" - usersReader = "fixed:users:reader" - usersWriter = "fixed:users:writer" -) - -var ( - // FixedRoles provides a map of permission sets/roles which can be - // assigned to a set of users. When adding a new resource protected by - // Grafana access control the default permissions should be added to a - // new fixed role in this set so that users can access the new - // resource. FixedRoleGrants lists which built-in roles are - // assigned which fixed roles in this list. - FixedRoles = map[string]RoleDTO{ - ldapReader: ldapReaderRole, - ldapWriter: ldapWriterRole, - orgUsersReader: orgUsersReaderRole, - orgUsersWriter: orgUsersWriterRole, - settingsReader: settingsReaderRole, - statsReader: statsReaderRole, - usersReader: usersReaderRole, - usersWriter: usersWriterRole, +// Declare OSS roles to the accesscontrol service +func DeclareFixedRoles(ac AccessControl) error { + ldapReader := RoleRegistration{ + Role: ldapReaderRole, + Grants: []string{RoleGrafanaAdmin}, + } + ldapWriter := RoleRegistration{ + Role: ldapWriterRole, + Grants: []string{RoleGrafanaAdmin}, + } + orgUsersReader := RoleRegistration{ + Role: orgUsersReaderRole, + Grants: []string{RoleGrafanaAdmin, string(models.ROLE_ADMIN)}, + } + orgUsersWriter := RoleRegistration{ + Role: orgUsersWriterRole, + Grants: []string{RoleGrafanaAdmin, string(models.ROLE_ADMIN)}, + } + settingsReader := RoleRegistration{ + Role: SettingsReaderRole, + Grants: []string{RoleGrafanaAdmin}, + } + statsReader := RoleRegistration{ + Role: statsReaderRole, + Grants: []string{RoleGrafanaAdmin}, + } + usersReader := RoleRegistration{ + Role: usersReaderRole, + Grants: []string{RoleGrafanaAdmin}, + } + usersWriter := RoleRegistration{ + Role: usersWriterRole, + Grants: []string{RoleGrafanaAdmin}, } - // FixedRoleGrants specifies which built-in roles are assigned - // to which set of FixedRoles by default. Alphabetically sorted. - FixedRoleGrants = map[string][]string{ - RoleGrafanaAdmin: { - ldapReader, - ldapWriter, - orgUsersReader, - orgUsersWriter, - settingsReader, - statsReader, - usersReader, - usersWriter, - }, - string(models.ROLE_ADMIN): { - orgUsersReader, - orgUsersWriter, - }, - } -) + return ac.DeclareFixedRoles(ldapReader, ldapWriter, orgUsersReader, orgUsersWriter, + settingsReader, statsReader, usersReader, usersWriter) +} func ConcatPermissions(permissions ...[]Permission) []Permission { if permissions == nil { diff --git a/pkg/services/accesscontrol/roles_test.go b/pkg/services/accesscontrol/roles_test.go index 9c0eec44e6b..6199d613e0d 100644 --- a/pkg/services/accesscontrol/roles_test.go +++ b/pkg/services/accesscontrol/roles_test.go @@ -1,41 +1,11 @@ package accesscontrol import ( - "sort" - "strings" "testing" "github.com/stretchr/testify/assert" ) -func TestFixedRoles(t *testing.T) { - for name, role := range FixedRoles { - assert.Truef(t, - strings.HasPrefix(name, "fixed:"), - "expected all fixed roles to be prefixed by 'fixed:', found role '%s'", name, - ) - assert.Equal(t, name, role.Name) - assert.NotZero(t, role.Version) - } -} - -func TestFixedRoleGrants(t *testing.T) { - for _, grants := range FixedRoleGrants { - // Check grants list is sorted - assert.True(t, - sort.SliceIsSorted(grants, func(i, j int) bool { - return grants[i] < grants[j] - }), - "require role grant lists to be sorted", - ) - - // Check all granted roles have been registered - for _, r := range grants { - assert.Contains(t, FixedRoles, r) - } - } -} - func TestConcatPermissions(t *testing.T) { perms1 := []Permission{ { diff --git a/pkg/services/accesscontrol/scope.go b/pkg/services/accesscontrol/scope.go index 8d87b0eaf5a..3d58e60e0f7 100644 --- a/pkg/services/accesscontrol/scope.go +++ b/pkg/services/accesscontrol/scope.go @@ -143,7 +143,7 @@ func (s *ScopeResolver) GetResolveAttributeScopeMutator(orgID int64) ScopeMutato var err error // By default the scope remains unchanged resolvedScope := scope - prefix := scopePrefix(scope) + prefix := ScopePrefix(scope) if fn, ok := s.attributeResolvers[prefix]; ok { resolvedScope, err = fn(ctx, orgID, scope) if err != nil { @@ -157,10 +157,10 @@ func (s *ScopeResolver) GetResolveAttributeScopeMutator(orgID int64) ScopeMutato } } -// scopePrefix returns the prefix associated to a given scope +// ScopePrefix returns the prefix associated to a given scope // we assume prefixes are all in the form :: // ex: "datasources:name:test" returns "datasources:name:" -func scopePrefix(scope string) string { +func ScopePrefix(scope string) string { parts := strings.Split(scope, ":") // We assume prefixes don't have more than maxPrefixParts parts if len(parts) > maxPrefixParts { diff --git a/pkg/services/accesscontrol/scope_test.go b/pkg/services/accesscontrol/scope_test.go index 74c58cbbcfd..666d3c73862 100644 --- a/pkg/services/accesscontrol/scope_test.go +++ b/pkg/services/accesscontrol/scope_test.go @@ -198,7 +198,7 @@ func Test_scopePrefix(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - prefix := scopePrefix(tt.scope) + prefix := ScopePrefix(tt.scope) assert.Equal(t, tt.want, prefix) }) diff --git a/pkg/services/contexthandler/auth_jwt.go b/pkg/services/contexthandler/auth_jwt.go index 748bd13c120..d29b4b07d65 100644 --- a/pkg/services/contexthandler/auth_jwt.go +++ b/pkg/services/contexthandler/auth_jwt.go @@ -73,7 +73,7 @@ func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64) } } - if err := bus.Dispatch(ctx.Req.Context(), &query); err != nil { + if err := h.SQLStore.GetSignedInUserWithCacheCtx(ctx.Req.Context(), &query); err != nil { if errors.Is(err, models.ErrUserNotFound) { ctx.Logger.Debug( "Failed to find user using JWT claims", diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index 0a62173b64a..99111ac9194 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -35,7 +35,7 @@ const ( const ServiceName = "ContextHandler" func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtService models.JWTService, - remoteCache *remotecache.RemoteCache, renderService rendering.Service, sqlStore *sqlstore.SQLStore, + remoteCache *remotecache.RemoteCache, renderService rendering.Service, sqlStore sqlstore.Store, tracer tracing.Tracer, authProxy *authproxy.AuthProxy) *ContextHandler { return &ContextHandler{ Cfg: cfg, @@ -151,7 +151,7 @@ func (h *ContextHandler) Middleware(mContext *web.Context) { // update last seen every 5min if reqContext.ShouldUpdateLastSeenAt() { reqContext.Logger.Debug("Updating last user_seen_at", "user_id", reqContext.UserId) - if err := bus.Dispatch(mContext.Req.Context(), &models.UpdateUserLastSeenAtCommand{UserId: reqContext.UserId}); err != nil { + if err := h.SQLStore.UpdateUserLastSeenAt(mContext.Req.Context(), &models.UpdateUserLastSeenAtCommand{UserId: reqContext.UserId}); err != nil { reqContext.Logger.Error("Failed to update last_seen_at", "error", err) } } @@ -209,7 +209,7 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo // fetch key keyQuery := models.GetApiKeyByNameQuery{KeyName: decoded.Name, OrgId: decoded.OrgId} - if err := bus.Dispatch(reqContext.Req.Context(), &keyQuery); err != nil { + if err := h.SQLStore.GetApiKeyByName(reqContext.Req.Context(), &keyQuery); err != nil { reqContext.JsonApiErr(401, InvalidAPIKey, err) return true } @@ -251,7 +251,7 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo //Use service account linked to API key as the signed in user query := models.GetSignedInUserQuery{UserId: *apikey.ServiceAccountId, OrgId: apikey.OrgId} - if err := bus.Dispatch(reqContext.Req.Context(), &query); err != nil { + if err := h.SQLStore.GetSignedInUserWithCacheCtx(reqContext.Req.Context(), &query); err != nil { reqContext.Logger.Error( "Failed to link API key to service account in", "id", query.UserId, @@ -308,7 +308,7 @@ func (h *ContextHandler) initContextWithBasicAuth(reqContext *models.ReqContext, user := authQuery.User query := models.GetSignedInUserQuery{UserId: user.Id, OrgId: orgID} - if err := bus.Dispatch(ctx, &query); err != nil { + if err := h.SQLStore.GetSignedInUserWithCacheCtx(ctx, &query); err != nil { reqContext.Logger.Error( "Failed at user signed in", "id", user.Id, @@ -344,7 +344,7 @@ func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, org } query := models.GetSignedInUserQuery{UserId: token.UserId, OrgId: orgID} - if err := bus.Dispatch(ctx, &query); err != nil { + if err := h.SQLStore.GetSignedInUserWithCacheCtx(ctx, &query); err != nil { reqContext.Logger.Error("Failed to get user with id", "userId", token.UserId, "error", err) return false } diff --git a/pkg/services/login/authinfo.go b/pkg/services/login/authinfo.go index 7ada13996e7..4db77aa6046 100644 --- a/pkg/services/login/authinfo.go +++ b/pkg/services/login/authinfo.go @@ -9,6 +9,7 @@ import ( type AuthInfoService interface { LookupAndUpdate(ctx context.Context, query *models.GetUserByAuthInfoQuery) (*models.User, error) GetAuthInfo(ctx context.Context, query *models.GetAuthInfoQuery) error + GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error } diff --git a/pkg/services/login/authinfoservice/service.go b/pkg/services/login/authinfoservice/service.go index 5dbd218c7e8..bf61cbcf510 100644 --- a/pkg/services/login/authinfoservice/service.go +++ b/pkg/services/login/authinfoservice/service.go @@ -183,3 +183,7 @@ func (s *Implementation) UpdateAuthInfo(ctx context.Context, cmd *models.UpdateA func (s *Implementation) SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error { return s.authInfoStore.SetAuthInfo(ctx, cmd) } + +func (s *Implementation) GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error { + return s.authInfoStore.GetExternalUserInfoByLogin(ctx, query) +} diff --git a/pkg/services/login/login.go b/pkg/services/login/login.go index e8a995197c0..c698ee55dbe 100644 --- a/pkg/services/login/login.go +++ b/pkg/services/login/login.go @@ -18,5 +18,6 @@ type TeamSyncFunc func(user *models.User, externalUser *models.ExternalUserInfo) type Service interface { CreateUser(cmd models.CreateUserCommand) (*models.User, error) UpsertUser(ctx context.Context, cmd *models.UpsertUserCommand) error + DisableExternalUser(ctx context.Context, username string) error SetTeamSyncFunc(TeamSyncFunc) } diff --git a/pkg/services/login/loginservice/loginservice.go b/pkg/services/login/loginservice/loginservice.go index d3f9ffe01b1..db5ccf002cb 100644 --- a/pkg/services/login/loginservice/loginservice.go +++ b/pkg/services/login/loginservice/loginservice.go @@ -130,6 +130,46 @@ func (ls *Implementation) UpsertUser(ctx context.Context, cmd *models.UpsertUser return nil } +func (ls *Implementation) DisableExternalUser(ctx context.Context, username string) error { + // Check if external user exist in Grafana + userQuery := &models.GetExternalUserInfoByLoginQuery{ + LoginOrEmail: username, + } + + if err := ls.AuthInfoService.GetExternalUserInfoByLogin(ctx, userQuery); err != nil { + return err + } + + userInfo := userQuery.Result + if userInfo.IsDisabled { + return nil + } + + logger.Debug( + "Disabling external user", + "user", + userQuery.Result.Login, + ) + + // Mark user as disabled in grafana db + disableUserCmd := &models.DisableUserCommand{ + UserId: userQuery.Result.UserId, + IsDisabled: true, + } + + if err := ls.SQLStore.DisableUser(ctx, disableUserCmd); err != nil { + logger.Debug( + "Error disabling external user", + "user", + userQuery.Result.Login, + "message", + err.Error(), + ) + return err + } + return nil +} + // SetTeamSyncFunc sets the function received through args as the team sync function. func (ls *Implementation) SetTeamSyncFunc(teamSyncFunc login.TeamSyncFunc) { ls.TeamSync = teamSyncFunc diff --git a/pkg/services/login/loginservice/loginservice_mock.go b/pkg/services/login/loginservice/loginservice_mock.go index abd77f9f2bd..0090653bdc2 100644 --- a/pkg/services/login/loginservice/loginservice_mock.go +++ b/pkg/services/login/loginservice/loginservice_mock.go @@ -45,3 +45,7 @@ func (s LoginServiceMock) UpsertUser(ctx context.Context, cmd *models.UpsertUser cmd.Result = s.ExpectedUser return s.ExpectedError } + +func (s LoginServiceMock) DisableExternalUser(ctx context.Context, username string) error { + return nil +} diff --git a/pkg/services/login/loginservice/loginservice_test.go b/pkg/services/login/loginservice/loginservice_test.go index faceae8d15f..4bace3556f9 100644 --- a/pkg/services/login/loginservice/loginservice_test.go +++ b/pkg/services/login/loginservice/loginservice_test.go @@ -10,6 +10,7 @@ import ( "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" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/stretchr/testify/assert" @@ -19,7 +20,7 @@ import ( func Test_syncOrgRoles_doesNotBreakWhenTryingToRemoveLastOrgAdmin(t *testing.T) { user := createSimpleUser() externalUser := createSimpleExternalUser() - authInfoMock := &authInfoServiceMock{} + authInfoMock := &logintest.AuthInfoServiceFake{} store := &mockstore.SQLStoreMock{ ExpectedUserOrgList: createUserOrgDTO(), @@ -44,7 +45,7 @@ func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) { user := createSimpleUser() externalUser := createSimpleExternalUser() - authInfoMock := &authInfoServiceMock{} + authInfoMock := &logintest.AuthInfoServiceFake{} store := &mockstore.SQLStoreMock{ ExpectedUserOrgList: createUserOrgDTO(), @@ -63,29 +64,8 @@ func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) { assert.Contains(t, buf.String(), models.ErrLastOrgAdmin.Error()) } -type authInfoServiceMock struct { - user *models.User - err error -} - -func (a *authInfoServiceMock) LookupAndUpdate(ctx context.Context, query *models.GetUserByAuthInfoQuery) (*models.User, error) { - return a.user, a.err -} - -func (a *authInfoServiceMock) GetAuthInfo(ctx context.Context, query *models.GetAuthInfoQuery) error { - return nil -} - -func (a *authInfoServiceMock) SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error { - return nil -} - -func (a *authInfoServiceMock) UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error { - return nil -} - func Test_teamSync(t *testing.T) { - authInfoMock := &authInfoServiceMock{} + authInfoMock := &logintest.AuthInfoServiceFake{} login := Implementation{ Bus: bus.New(), QuotaService: "a.QuotaService{}, @@ -99,7 +79,7 @@ func Test_teamSync(t *testing.T) { Name: "test_user", Login: "test_user", } - authInfoMock.user = expectedUser + authInfoMock.ExpectedUser = expectedUser bus.ClearBusHandlers() t.Cleanup(func() { bus.ClearBusHandlers() }) diff --git a/pkg/services/login/logintest/logintest.go b/pkg/services/login/logintest/logintest.go new file mode 100644 index 00000000000..6197e8d5ce9 --- /dev/null +++ b/pkg/services/login/logintest/logintest.go @@ -0,0 +1,51 @@ +package logintest + +import ( + "context" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/login" +) + +type LoginServiceFake struct{} + +func (l *LoginServiceFake) CreateUser(cmd models.CreateUserCommand) (*models.User, error) { + return nil, nil +} +func (l *LoginServiceFake) UpsertUser(ctx context.Context, cmd *models.UpsertUserCommand) error { + return nil +} +func (l *LoginServiceFake) DisableExternalUser(ctx context.Context, username string) error { + return nil +} +func (l *LoginServiceFake) SetTeamSyncFunc(login.TeamSyncFunc) {} + +type AuthInfoServiceFake struct { + LatestUserID int64 + ExpectedUser *models.User + ExpectedExternalUser *models.ExternalUserInfo + ExpectedError error +} + +func (a *AuthInfoServiceFake) LookupAndUpdate(ctx context.Context, query *models.GetUserByAuthInfoQuery) (*models.User, error) { + a.LatestUserID = query.UserId + return a.ExpectedUser, a.ExpectedError +} + +func (a *AuthInfoServiceFake) GetAuthInfo(ctx context.Context, query *models.GetAuthInfoQuery) error { + a.LatestUserID = query.UserId + return a.ExpectedError +} + +func (a *AuthInfoServiceFake) SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error { + return a.ExpectedError +} + +func (a *AuthInfoServiceFake) UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error { + return a.ExpectedError +} + +func (a *AuthInfoServiceFake) GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error { + query.Result = a.ExpectedExternalUser + return a.ExpectedError +} diff --git a/pkg/services/ngalert/accesscontrol.go b/pkg/services/ngalert/accesscontrol.go index b44b4b9b95b..2689ef03dc1 100644 --- a/pkg/services/ngalert/accesscontrol.go +++ b/pkg/services/ngalert/accesscontrol.go @@ -16,16 +16,12 @@ var ( DisplayName: "Rules Reader", Description: "Can read alert rules in all Grafana folders and external providers", Group: AlertRolesGroup, - Version: 1, + Version: 2, Permissions: []accesscontrol.Permission{ { Action: accesscontrol.ActionAlertingRuleRead, Scope: dashboards.ScopeFoldersAll, }, - { - Action: dashboards.ActionFoldersRead, - Scope: dashboards.ScopeFoldersAll, - }, { Action: accesscontrol.ActionAlertingRuleExternalRead, Scope: datasources.ScopeAll, @@ -40,7 +36,7 @@ var ( DisplayName: "Rules Editor", Description: "Can add, update, and delete rules in any Grafana folder and external providers", Group: AlertRolesGroup, - Version: 1, + Version: 2, Permissions: accesscontrol.ConcatPermissions(rulesReaderRole.Role.Permissions, []accesscontrol.Permission{ { Action: accesscontrol.ActionAlertingRuleCreate, @@ -154,7 +150,7 @@ var ( DisplayName: "Full read-only access", Description: "Can read alert rules, instances, silences, contact points, and notification policies in Grafana and all external providers", Group: AlertRolesGroup, - Version: 1, + Version: 2, Permissions: accesscontrol.ConcatPermissions(rulesReaderRole.Role.Permissions, instancesReaderRole.Role.Permissions, notificationsReaderRole.Role.Permissions), }, Grants: []string{string(models.ROLE_VIEWER)}, @@ -166,7 +162,7 @@ var ( DisplayName: "Full access", Description: "Can add,update and delete alert rules, instances, silences, contact points, and notification policies in Grafana and all external providers", Group: AlertRolesGroup, - Version: 1, + Version: 2, Permissions: accesscontrol.ConcatPermissions(rulesEditorRole.Role.Permissions, instancesEditorRole.Role.Permissions, notificationsEditorRole.Role.Permissions), }, Grants: []string{string(models.ROLE_EDITOR)}, diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index 234b45f75d8..46f62d054db 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -11,11 +11,13 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -64,7 +66,7 @@ type API struct { ExpressionService *expr.Service QuotaService *quota.QuotaService Schedule schedule.ScheduleService - TransactionManager store.TransactionManager + TransactionManager provisioning.TransactionManager RuleStore store.RuleStore InstanceStore store.InstanceStore AlertingStore AlertingStore @@ -74,6 +76,7 @@ type API struct { StateManager *state.Manager SecretsService secrets.Service AccessControl accesscontrol.AccessControl + Policies *provisioning.NotificationPolicyService } // RegisterAPIEndpoints registers API handlers @@ -126,4 +129,11 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { scheduler: api.Schedule, }, ), m) + + if api.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagAlertProvisioning) { + api.RegisterProvisioningApiEndpoints(NewForkedProvisioningApi(&ProvisioningSrv{ + log: logger, + policies: api.Policies, + }), m) + } } diff --git a/pkg/services/ngalert/api/api_prometheus.go b/pkg/services/ngalert/api/api_prometheus.go index 3dfbc483316..37b84ac21bb 100644 --- a/pkg/services/ngalert/api/api_prometheus.go +++ b/pkg/services/ngalert/api/api_prometheus.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "sort" "strconv" "strings" "time" @@ -47,8 +48,9 @@ func (srv PrometheusSrv) RouteGetAlertStatuses(c *models.ReqContext) response.Re for _, alertState := range srv.manager.GetAll(c.OrgId) { startsAt := alertState.StartsAt valString := "" - if alertState.State == eval.Alerting { - valString = alertState.LastEvaluationString + + if alertState.State == eval.Alerting || alertState.State == eval.Pending { + valString = formatValues(alertState) } alertResponse.Data.Alerts = append(alertResponse.Data.Alerts, &apimodels.Alert{ @@ -63,6 +65,34 @@ func (srv PrometheusSrv) RouteGetAlertStatuses(c *models.ReqContext) response.Re return response.JSON(http.StatusOK, alertResponse) } +func formatValues(alertState *state.State) string { + var fv string + values := alertState.GetLastEvaluationValuesForCondition() + + switch len(values) { + case 0: + fv = alertState.LastEvaluationString + case 1: + for _, v := range values { + fv = strconv.FormatFloat(v, 'e', -1, 64) + break + } + + default: + vs := make([]string, 0, len(values)) + + for k, v := range values { + vs = append(vs, fmt.Sprintf("%s: %s", k, strconv.FormatFloat(v, 'e', -1, 64))) + } + + // Ensure we have a consistent natural ordering after formatting e.g. A0, A1, A10, A11, A3, etc. + sort.Strings(vs) + fv = strings.Join(vs, ", ") + } + + return fv +} + func getPanelIDFromRequest(r *http.Request) (int64, error) { if s := strings.TrimSpace(r.URL.Query().Get("panel_id")); s != "" { return strconv.ParseInt(s, 10, 64) @@ -178,8 +208,8 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res for _, alertState := range srv.manager.GetStatesForRuleUID(c.OrgId, rule.UID) { activeAt := alertState.StartsAt valString := "" - if alertState.State == eval.Alerting { - valString = alertState.LastEvaluationString + if alertState.State == eval.Alerting || alertState.State == eval.Pending { + valString = formatValues(alertState) } alert := &apimodels.Alert{ @@ -187,7 +217,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res Annotations: alertState.Annotations, State: alertState.State.String(), ActiveAt: &activeAt, - Value: valString, // TODO: set this once it is added to the evaluation results + Value: valString, } if alertState.LastEvaluationTime.After(newRule.LastEvaluation) { diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index f9e7a548c87..74ec3dc9809 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "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" @@ -20,6 +21,64 @@ import ( "github.com/stretchr/testify/require" ) +func Test_FormatValues(t *testing.T) { + val1 := 1.1 + val2 := 1.4 + + tc := []struct { + name string + alertState *state.State + expected string + }{ + { + name: "with no value, it renders the evaluation string", + alertState: &state.State{ + LastEvaluationString: "[ var='A' metric='vector(10) + time() % 50' labels={} value=1.1 ]", + Results: []state.Evaluation{ + {Condition: "A", Values: map[string]*float64{}}, + }, + }, + expected: "[ var='A' metric='vector(10) + time() % 50' labels={} value=1.1 ]", + }, + { + name: "with one value, it renders the single value", + alertState: &state.State{ + LastEvaluationString: "[ var='A' metric='vector(10) + time() % 50' labels={} value=1.1 ]", + Results: []state.Evaluation{ + {Condition: "A", Values: map[string]*float64{"A": &val1}}, + }, + }, + expected: "1.1e+00", + }, + { + name: "with two values, it renders the value based on their refID and position", + alertState: &state.State{ + LastEvaluationString: "[ var='B0' metric='vector(10) + time() % 50' labels={} value=1.1 ], [ var='B1' metric='vector(10) + time() % 50' labels={} value=1.4 ]", + Results: []state.Evaluation{ + {Condition: "B", Values: map[string]*float64{"B0": &val1, "B1": &val2}}, + }, + }, + expected: "B0: 1.1e+00, B1: 1.4e+00", + }, + { + name: "with a high number of values, it renders the value based on their refID and position using a natural order", + alertState: &state.State{ + LastEvaluationString: "[ var='B0' metric='vector(10) + time() % 50' labels={} value=1.1 ], [ var='B1' metric='vector(10) + time() % 50' labels={} value=1.4 ]", + Results: []state.Evaluation{ + {Condition: "B", Values: map[string]*float64{"B0": &val1, "B1": &val2, "B2": &val1, "B10": &val2, "B11": &val1}}, + }, + }, + expected: "B0: 1.1e+00, B10: 1.4e+00, B11: 1.1e+00, B1: 1.4e+00, B2: 1.1e+00", + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, formatValues(tt.alertState)) + }) + } +} + func TestRouteGetAlertStatuses(t *testing.T) { orgID := int64(1) @@ -83,6 +142,48 @@ func TestRouteGetAlertStatuses(t *testing.T) { }`, string(r.Body())) }) + t.Run("with two firing alerts", func(t *testing.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) + c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &models.SignedInUser{OrgId: orgID}} + + r := api.RouteGetAlertStatuses(c) + require.Equal(t, http.StatusOK, r.Status()) + require.JSONEq(t, ` +{ + "status": "success", + "data": { + "alerts": [{ + "labels": { + "alertname": "test_title_0", + "instance_label": "test", + "label": "test" + }, + "annotations": { + "annotation": "test" + }, + "state": "Alerting", + "activeAt": "0001-01-01T00:00:00Z", + "value": "1.1e+00" + }, { + "labels": { + "alertname": "test_title_1", + "instance_label": "test", + "label": "test" + }, + "annotations": { + "annotation": "test" + }, + "state": "Alerting", + "activeAt": "0001-01-01T00:00:00Z", + "value": "1.1e+00" + }] + } +}`, string(r.Body())) + }) + t.Run("with the inclusion of internal labels", func(t *testing.T) { _, fakeAIM, api := setupAPI(t) fakeAIM.GenerateAlertInstances(orgID, util.GenerateShortUID(), 2) @@ -130,6 +231,20 @@ func TestRouteGetAlertStatuses(t *testing.T) { }) } +func withAlertingState() forEachState { + return func(s *state.State) *state.State { + s.State = eval.Alerting + value := float64(1.1) + s.Results = append(s.Results, state.Evaluation{ + EvaluationState: eval.Alerting, + EvaluationTime: timeNow(), + Values: map[string]*float64{"B": &value}, + Condition: "B", + }) + return s + } +} + func TestRouteGetRuleStatuses(t *testing.T) { timeNow = func() time.Time { return time.Date(2022, 3, 10, 14, 0, 0, 0, time.UTC) } orgID := int64(1) diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go new file mode 100644 index 00000000000..679e432107c --- /dev/null +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -0,0 +1,51 @@ +package api + +import ( + "context" + "errors" + "net/http" + + "github.com/grafana/grafana/pkg/api/response" + "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" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/util" +) + +type ProvisioningSrv struct { + log log.Logger + policies NotificationPolicyService +} + +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 +} + +func (srv *ProvisioningSrv) RouteGetPolicyTree(c *models.ReqContext) response.Response { + policies, err := srv.policies.GetPolicyTree(c.Req.Context(), c.OrgId) + if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { + return ErrResp(http.StatusNotFound, err, "") + } + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "") + } + + return response.JSON(http.StatusOK, policies) +} + +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) + if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { + return ErrResp(http.StatusNotFound, err, "") + } + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "") + } + + return response.JSON(http.StatusAccepted, util.DynMap{"message": "policies updated"}) +} diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go new file mode 100644 index 00000000000..5cac3a26a1b --- /dev/null +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -0,0 +1,150 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "testing" + + "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" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/require" +) + +func TestProvisioningApi(t *testing.T) { + t.Run("successful GET policies returns 200", func(t *testing.T) { + sut := createProvisioningSrvSut() + rc := createTestRequestCtx() + + response := sut.RouteGetPolicyTree(&rc) + + require.Equal(t, 200, response.Status()) + }) + + t.Run("successful POST policies returns 202", func(t *testing.T) { + sut := createProvisioningSrvSut() + rc := createTestRequestCtx() + tree := apimodels.Route{} + + response := sut.RoutePostPolicyTree(&rc, tree) + + require.Equal(t, 202, response.Status()) + }) + + // TODO: we have not lifted out validation yet. Test that we are returning errors properly once validation has been lifted. + + t.Run("when org has no AM config", func(t *testing.T) { + t.Run("GET policies returns 404", func(t *testing.T) { + sut := createProvisioningSrvSut() + rc := createTestRequestCtx() + rc.SignedInUser.OrgId = 2 + + response := sut.RouteGetPolicyTree(&rc) + + require.Equal(t, 404, response.Status()) + }) + + t.Run("POST policies returns 404", func(t *testing.T) { + sut := createProvisioningSrvSut() + rc := createTestRequestCtx() + rc.SignedInUser.OrgId = 2 + + response := sut.RouteGetPolicyTree(&rc) + + require.Equal(t, 404, response.Status()) + }) + }) + + t.Run("when an unspecified error occurrs", func(t *testing.T) { + t.Run("GET policies returns 500", func(t *testing.T) { + sut := createProvisioningSrvSut() + sut.policies = &fakeFailingNotificationPolicyService{} + rc := createTestRequestCtx() + + response := sut.RouteGetPolicyTree(&rc) + + require.Equal(t, 500, response.Status()) + require.NotEmpty(t, response.Body()) + require.Contains(t, string(response.Body()), "something went wrong") + }) + + t.Run("POST policies returns 500", func(t *testing.T) { + sut := createProvisioningSrvSut() + sut.policies = &fakeFailingNotificationPolicyService{} + rc := createTestRequestCtx() + tree := apimodels.Route{} + + response := sut.RoutePostPolicyTree(&rc, tree) + + require.Equal(t, 500, response.Status()) + require.NotEmpty(t, response.Body()) + require.Contains(t, string(response.Body()), "something went wrong") + }) + }) +} + +func createProvisioningSrvSut() ProvisioningSrv { + return ProvisioningSrv{ + log: log.NewNopLogger(), + policies: newFakeNotificationPolicyService(), + } +} + +func createTestRequestCtx() models.ReqContext { + return models.ReqContext{ + Context: &web.Context{ + Req: &http.Request{}, + }, + SignedInUser: &models.SignedInUser{ + OrgId: 1, + }, + } +} + +type fakeNotificationPolicyService struct { + tree apimodels.Route + prov domain.Provenance +} + +func newFakeNotificationPolicyService() *fakeNotificationPolicyService { + return &fakeNotificationPolicyService{ + tree: apimodels.Route{ + Receiver: "some-receiver", + }, + prov: domain.ProvenanceNone, + } +} + +func (f *fakeNotificationPolicyService) GetPolicyTree(ctx context.Context, orgID int64) (provisioning.EmbeddedRoutingTree, error) { + if orgID != 1 { + return provisioning.EmbeddedRoutingTree{}, store.ErrNoAlertmanagerConfiguration + } + return provisioning.EmbeddedRoutingTree{ + Route: f.tree, + Provenance: f.prov, + }, nil +} + +func (f *fakeNotificationPolicyService) UpdatePolicyTree(ctx context.Context, orgID int64, tree apimodels.Route, p domain.Provenance) error { + if orgID != 1 { + return store.ErrNoAlertmanagerConfiguration + } + f.tree = tree + f.prov = p + return nil +} + +type fakeFailingNotificationPolicyService struct{} + +func (f *fakeFailingNotificationPolicyService) GetPolicyTree(ctx context.Context, orgID int64) (provisioning.EmbeddedRoutingTree, error) { + return provisioning.EmbeddedRoutingTree{}, fmt.Errorf("something went wrong") +} + +func (f *fakeFailingNotificationPolicyService) UpdatePolicyTree(ctx context.Context, orgID int64, tree apimodels.Route, p domain.Provenance) error { + return fmt.Errorf("something went wrong") +} diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 7094c6aa956..14852ac0f34 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" @@ -28,7 +29,7 @@ import ( ) type RulerSrv struct { - xactManager store.TransactionManager + xactManager provisioning.TransactionManager store store.RuleStore DatasourceCache datasources.CacheService QuotaService *quota.QuotaService diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 9173ad0229c..317c7b21037 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -179,6 +179,13 @@ func (api *API) authorize(method, path string) web.Handler { http.MethodPost + "/api/v1/ngalert/admin_config", http.MethodGet + "/api/v1/ngalert/alertmanagers": return middleware.ReqOrgAdmin + + // Grafana-only Provisioning Read Paths + case http.MethodGet + "/api/provisioning/policies": + return middleware.ReqSignedIn + + case http.MethodPost + "/api/provisioning/policies": + return middleware.ReqEditorRole } if eval != nil { diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go index 9a1302e7531..b39597a9fb8 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, 29) + require.Len(t, paths, 30) 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 new file mode 100644 index 00000000000..74213e3c783 --- /dev/null +++ b/pkg/services/ngalert/api/forked_provisioning.go @@ -0,0 +1,28 @@ +package api + +import ( + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/models" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" +) + +// ForkedProvisioningApi always forwards requests to a Grafana backend. +// We do not currently support provisioning of external systems through Grafana's API. +type ForkedProvisioningApi struct { + svc *ProvisioningSrv +} + +// NewForkedProvisioningApi creates a new ForkedProvisioningApi instance. +func NewForkedProvisioningApi(svc *ProvisioningSrv) *ForkedProvisioningApi { + return &ForkedProvisioningApi{ + svc: svc, + } +} + +func (f *ForkedProvisioningApi) forkRouteGetPolicyTree(ctx *models.ReqContext) response.Response { + return f.svc.RouteGetPolicyTree(ctx) +} + +func (f *ForkedProvisioningApi) forkRoutePostPolicyTree(ctx *models.ReqContext, route apimodels.Route) response.Response { + return f.svc.RoutePostPolicyTree(ctx, route) +} diff --git a/pkg/services/ngalert/api/generated_base_api_provisioning.go b/pkg/services/ngalert/api/generated_base_api_provisioning.go new file mode 100644 index 00000000000..2d5e72ae571 --- /dev/null +++ b/pkg/services/ngalert/api/generated_base_api_provisioning.go @@ -0,0 +1,62 @@ +/*Package api contains base API implementation of unified alerting + * + *Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + * + *Do not manually edit these files, please find ngalert/api/swagger-codegen/ for commands on how to generate them. + */ + +package api + +import ( + "net/http" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/models" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" + "github.com/grafana/grafana/pkg/web" +) + +type ProvisioningApiForkingService interface { + RouteGetPolicyTree(*models.ReqContext) response.Response + RoutePostPolicyTree(*models.ReqContext) response.Response +} + +func (f *ForkedProvisioningApi) RouteGetPolicyTree(ctx *models.ReqContext) response.Response { + return f.forkRouteGetPolicyTree(ctx) +} + +func (f *ForkedProvisioningApi) RoutePostPolicyTree(ctx *models.ReqContext) response.Response { + conf := apimodels.Route{} + if err := web.Bind(ctx.Req, &conf); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + return f.forkRoutePostPolicyTree(ctx, conf) +} + +func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingService, m *metrics.API) { + api.RouteRegister.Group("", func(group routing.RouteRegister) { + group.Get( + toMacaronPath("/api/provisioning/policies"), + api.authorize(http.MethodGet, "/api/provisioning/policies"), + metrics.Instrument( + http.MethodGet, + "/api/provisioning/policies", + srv.RouteGetPolicyTree, + m, + ), + ) + group.Post( + toMacaronPath("/api/provisioning/policies"), + api.authorize(http.MethodPost, "/api/provisioning/policies"), + metrics.Instrument( + http.MethodPost, + "/api/provisioning/policies", + srv.RoutePostPolicyTree, + m, + ), + ) + }, middleware.ReqSignedIn) +} diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index df301280ec2..94eb137d7b7 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -804,6 +804,14 @@ func AsGrafanaRoute(r *config.Route) *Route { return gRoute } +func (r *Route) ResourceType() string { + return "route" +} + +func (r *Route) ResourceID() string { + return "" +} + // Config is the entrypoint for the embedded Alertmanager config with the exception of receivers. // Prometheus historically uses yaml files as the method of configuration and thus some // post-validation is included in the UnmarshalYAML method. Here we simply run this with diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning.go b/pkg/services/ngalert/api/tooling/definitions/provisioning.go new file mode 100644 index 00000000000..b2019239b36 --- /dev/null +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning.go @@ -0,0 +1,26 @@ +package definitions + +// swagger:route GET /api/provisioning/policies provisioning RouteGetPolicyTree +// +// Get the notification policy tree. +// +// Responses: +// 200: Route +// 400: ValidationError + +// swagger:route POST /api/provisioning/policies provisioning RoutePostPolicyTree +// +// Sets the notification policy tree. +// +// Consumes: +// - application/json +// +// Responses: +// 202: Accepted +// 400: ValidationError + +// swagger:parameters RoutePostPolicyTree +type Policytree struct { + // in:body + Body Route +} diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 2c852fd2faa..dd87745df0d 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -2803,7 +2803,6 @@ "x-go-package": "github.com/prometheus/alertmanager/timeinterval" }, "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.", "properties": { "ForceQuery": { "type": "boolean" @@ -2836,9 +2835,9 @@ "$ref": "#/definitions/Userinfo" } }, - "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.", "type": "object", - "x-go-package": "net/url" + "x-go-package": "github.com/prometheus/common/config" }, "Userinfo": { "description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a URL. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.", @@ -3035,7 +3034,6 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "alertGroup": { - "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -3057,7 +3055,9 @@ "labels", "receiver" ], - "type": "object" + "type": "object", + "x-go-name": "AlertGroup", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "alertGroups": { "items": { @@ -3185,7 +3185,6 @@ "$ref": "#/definitions/Duration" }, "gettableAlert": { - "description": "GettableAlert gettable alert", "properties": { "annotations": { "$ref": "#/definitions/labelSet" @@ -3244,7 +3243,9 @@ "status", "updatedAt" ], - "type": "object" + "type": "object", + "x-go-name": "GettableAlert", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "gettableAlerts": { "description": "GettableAlerts gettable alerts", @@ -3254,7 +3255,6 @@ "type": "array" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -3306,7 +3306,9 @@ "status", "updatedAt" ], - "type": "object" + "type": "object", + "x-go-name": "GettableSilence", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "gettableSilences": { "items": { @@ -3443,6 +3445,7 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "postableSilence": { + "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -3482,9 +3485,7 @@ "matchers", "startsAt" ], - "type": "object", - "x-go-name": "PostableSilence", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" + "type": "object" }, "receiver": { "properties": { @@ -4732,6 +4733,59 @@ ] } }, + "/api/provisioning/policies": { + "get": { + "operationId": "RouteGetPolicyTree", + "responses": { + "200": { + "description": "Route", + "schema": { + "$ref": "#/definitions/Route" + } + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + }, + "summary": "Get the notification policy tree.", + "tags": [ + "provisioning" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "operationId": "RoutePostPolicyTree", + "parameters": [ + { + "in": "body", + "name": "Body", + "schema": { + "$ref": "#/definitions/Route" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + }, + "summary": "Sets the notification policy tree.", + "tags": [ + "provisioning" + ] + } + }, "/api/ruler/grafana/api/v1/rules": { "get": { "description": "List rule groups", diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 481564747b6..fff24ed4f08 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1136,6 +1136,59 @@ } } }, + "/api/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" + } + } + } + } + }, "/api/ruler/grafana/api/v1/rules": { "get": { "description": "List rule groups", @@ -4564,9 +4617,8 @@ "x-go-package": "github.com/prometheus/alertmanager/timeinterval" }, "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" @@ -4599,7 +4651,7 @@ "$ref": "#/definitions/Userinfo" } }, - "x-go-package": "net/url" + "x-go-package": "github.com/prometheus/common/config" }, "Userinfo": { "description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a URL. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.", @@ -4796,7 +4848,6 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "alertGroup": { - "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -4819,6 +4870,8 @@ "$ref": "#/definitions/receiver" } }, + "x-go-name": "AlertGroup", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/alertGroup" }, "alertGroups": { @@ -4948,7 +5001,6 @@ "$ref": "#/definitions/Duration" }, "gettableAlert": { - "description": "GettableAlert gettable alert", "type": "object", "required": [ "labels", @@ -5008,6 +5060,8 @@ "x-go-name": "UpdatedAt" } }, + "x-go-name": "GettableAlert", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/gettableAlert" }, "gettableAlerts": { @@ -5019,7 +5073,6 @@ "$ref": "#/definitions/gettableAlerts" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -5072,6 +5125,8 @@ "x-go-name": "UpdatedAt" } }, + "x-go-name": "GettableSilence", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/gettableSilence" }, "gettableSilences": { @@ -5210,6 +5265,7 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "postableSilence": { + "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", @@ -5250,8 +5306,6 @@ "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/api/tooling/swagger-codegen/templates/controller-api.mustache b/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache index c8686a22d00..fd4885996b8 100644 --- a/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache +++ b/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache @@ -24,10 +24,10 @@ func (f *Forked{{classname}}) {{nickname}}(ctx *models.ReqContext) response.Resp if err := web.Bind(ctx.Req, &conf); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } - return f.fork{{nickname}}(ctx, conf) + return f.fork{{nickname}}(ctx, conf) {{/bodyParams}} {{^bodyParams}} - return f.fork{{nickname}}(ctx) + return f.fork{{nickname}}(ctx) {{/bodyParams}} } {{/operation}}{{/operations}} diff --git a/pkg/services/ngalert/eval/eval.go b/pkg/services/ngalert/eval/eval.go index 7ad329a0095..2667c661b0d 100644 --- a/pkg/services/ngalert/eval/eval.go +++ b/pkg/services/ngalert/eval/eval.go @@ -12,6 +12,7 @@ import ( "time" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/expr/classic" "github.com/grafana/grafana/pkg/infra/log" m "github.com/grafana/grafana/pkg/models" @@ -22,8 +23,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" - - "github.com/grafana/grafana/pkg/expr" ) //go:generate mockery --name Evaluator --structname FakeEvaluator --inpackage --filename evaluator_mock.go --with-expecter diff --git a/pkg/services/ngalert/eval/extract_md.go b/pkg/services/ngalert/eval/extract_md.go index 8ba7bfb490e..e08a7b22bbc 100644 --- a/pkg/services/ngalert/eval/extract_md.go +++ b/pkg/services/ngalert/eval/extract_md.go @@ -20,7 +20,6 @@ func extractEvalString(frame *data.Frame) (s string) { if evalMatches, ok := frame.Meta.Custom.([]classic.EvalMatch); ok { sb := strings.Builder{} - // TODO: Should we simplify when we only have one match and use the name notation of $labels.A? for i, m := range evalMatches { sb.WriteString("[ ") sb.WriteString(fmt.Sprintf("var='%s%v' ", frame.RefID, i)) diff --git a/pkg/services/ngalert/models/provisioning.go b/pkg/services/ngalert/models/provisioning.go index c17e3de09a1..b3ad9612ef5 100644 --- a/pkg/services/ngalert/models/provisioning.go +++ b/pkg/services/ngalert/models/provisioning.go @@ -14,3 +14,9 @@ type Provisionable interface { ResourceID() string ResourceOrgID() int64 } + +// ProvisionableInOrg represents a resource that can be provisioned, given external org-related information. +type ProvisionableInOrg interface { + ResourceType() string + ResourceID() string +} diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index df2588e1fbb..fe070adf47a 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/notifier" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -136,6 +137,9 @@ func (ng *AlertNG) init() error { ng.stateManager = stateManager ng.schedule = scheduler + // Provisioning + policyService := provisioning.NewNotificationPolicyService(store, store, store, ng.Log) + api := api.API{ Cfg: ng.Cfg, DatasourceCache: ng.DataSourceCache, @@ -153,6 +157,7 @@ func (ng *AlertNG) init() error { MultiOrgAlertmanager: ng.MultiOrgAlertmanager, StateManager: ng.stateManager, AccessControl: ng.accesscontrol, + Policies: policyService, } api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics()) diff --git a/pkg/services/ngalert/notifier/testing.go b/pkg/services/ngalert/notifier/testing.go index 2c11dc4a9c4..79f35e464bc 100644 --- a/pkg/services/ngalert/notifier/testing.go +++ b/pkg/services/ngalert/notifier/testing.go @@ -70,7 +70,7 @@ func (f *FakeConfigStore) SaveAlertmanagerConfigurationWithCallback(_ context.Co return nil } -func (f *FakeConfigStore) UpdateAlertManagerConfiguration(cmd *models.SaveAlertmanagerConfigurationCmd) error { +func (f *FakeConfigStore) UpdateAlertmanagerConfiguration(_ context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error { if config, exists := f.configs[cmd.OrgID]; exists && config.ConfigurationHash == cmd.FetchedConfigurationHash { f.configs[cmd.OrgID] = &models.AlertConfiguration{ AlertmanagerConfiguration: cmd.AlertmanagerConfiguration, diff --git a/pkg/services/ngalert/provisioning/notification_policies.go b/pkg/services/ngalert/provisioning/notification_policies.go new file mode 100644 index 00000000000..e77b5f11aea --- /dev/null +++ b/pkg/services/ngalert/provisioning/notification_policies.go @@ -0,0 +1,138 @@ +package provisioning + +import ( + "context" + "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" +) + +type NotificationPolicyService struct { + amStore AMConfigStore + provenanceStore ProvisioningStore + xact TransactionManager + log log.Logger +} + +func NewNotificationPolicyService(am AMConfigStore, prov ProvisioningStore, xact TransactionManager, log log.Logger) *NotificationPolicyService { + return &NotificationPolicyService{ + amStore: am, + provenanceStore: prov, + xact: xact, + log: log, + } +} + +// TODO: move to Swagger codegen +type EmbeddedRoutingTree struct { + definitions.Route + Provenance models.Provenance +} + +func (nps *NotificationPolicyService) GetAMConfigStore() AMConfigStore { + return nps.amStore +} + +func (nps *NotificationPolicyService) GetPolicyTree(ctx context.Context, orgID int64) (EmbeddedRoutingTree, error) { + q := models.GetLatestAlertmanagerConfigurationQuery{ + OrgID: orgID, + } + err := nps.amStore.GetLatestAlertmanagerConfiguration(ctx, &q) + if err != nil { + return EmbeddedRoutingTree{}, err + } + + cfg, err := DeserializeAlertmanagerConfig([]byte(q.Result.AlertmanagerConfiguration)) + if err != nil { + return EmbeddedRoutingTree{}, err + } + + if cfg.AlertmanagerConfig.Config.Route == nil { + return EmbeddedRoutingTree{}, fmt.Errorf("no route present in current alertmanager config") + } + + adapter := provenanceOrgAdapter{ + inner: cfg.AlertmanagerConfig.Route, + orgID: orgID, + } + provenance, err := nps.provenanceStore.GetProvenance(ctx, adapter) + if err != nil { + return EmbeddedRoutingTree{}, err + } + + result := EmbeddedRoutingTree{ + Route: *cfg.AlertmanagerConfig.Route, + Provenance: provenance, + } + + return result, nil +} + +func (nps *NotificationPolicyService) UpdatePolicyTree(ctx context.Context, orgID int64, tree definitions.Route, p models.Provenance) error { + q := models.GetLatestAlertmanagerConfigurationQuery{ + OrgID: orgID, + } + err := nps.amStore.GetLatestAlertmanagerConfiguration(ctx, &q) + if err != nil { + return err + } + + concurrencyToken := q.Result.ConfigurationHash + cfg, err := DeserializeAlertmanagerConfig([]byte(q.Result.AlertmanagerConfiguration)) + if err != nil { + return err + } + + cfg.AlertmanagerConfig.Config.Route = &tree + + serialized, err := SerializeAlertmanagerConfig(*cfg) + if err != nil { + return err + } + cmd := models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: string(serialized), + ConfigurationVersion: q.Result.ConfigurationVersion, + FetchedConfigurationHash: concurrencyToken, + Default: false, + OrgID: orgID, + } + err = nps.xact.InTransaction(ctx, func(ctx context.Context) error { + err = nps.amStore.UpdateAlertmanagerConfiguration(ctx, &cmd) + if err != nil { + return err + } + adapter := provenanceOrgAdapter{ + inner: &tree, + orgID: orgID, + } + err = nps.provenanceStore.SetProvenance(ctx, adapter, p) + if err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + + return nil +} + +type provenanceOrgAdapter struct { + inner models.ProvisionableInOrg + orgID int64 +} + +func (a provenanceOrgAdapter) ResourceType() string { + return a.inner.ResourceType() +} + +func (a provenanceOrgAdapter) ResourceID() string { + return a.inner.ResourceID() +} + +func (a provenanceOrgAdapter) ResourceOrgID() int64 { + return a.orgID +} diff --git a/pkg/services/ngalert/provisioning/notification_policies_test.go b/pkg/services/ngalert/provisioning/notification_policies_test.go new file mode 100644 index 00000000000..3df52d4bc81 --- /dev/null +++ b/pkg/services/ngalert/provisioning/notification_policies_test.go @@ -0,0 +1,88 @@ +package provisioning + +import ( + "context" + "testing" + + "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/stretchr/testify/require" +) + +func TestNotificationPolicyService(t *testing.T) { + t.Run("service gets policy tree from org's AM config", func(t *testing.T) { + sut := createNotificationPolicyServiceSut() + + tree, err := sut.GetPolicyTree(context.Background(), 1) + require.NoError(t, err) + + require.Equal(t, "grafana-default-email", tree.Receiver) + }) + + t.Run("service stitches policy tree into org's AM config", func(t *testing.T) { + sut := createNotificationPolicyServiceSut() + newRoute := createTestRoutingTree() + + err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceNone) + require.NoError(t, err) + + updated, err := sut.GetPolicyTree(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, "a new receiver", updated.Receiver) + }) + + t.Run("default provenance of records is none", func(t *testing.T) { + sut := createNotificationPolicyServiceSut() + + tree, err := sut.GetPolicyTree(context.Background(), 1) + require.NoError(t, err) + + require.Equal(t, models.ProvenanceNone, tree.Provenance) + }) + + t.Run("service returns upgraded provenance value", func(t *testing.T) { + sut := createNotificationPolicyServiceSut() + newRoute := createTestRoutingTree() + + 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) + }) + + t.Run("service respects concurrency token when updating", func(t *testing.T) { + sut := createNotificationPolicyServiceSut() + newRoute := createTestRoutingTree() + q := models.GetLatestAlertmanagerConfigurationQuery{ + OrgID: 1, + } + err := sut.GetAMConfigStore().GetLatestAlertmanagerConfiguration(context.Background(), &q) + require.NoError(t, err) + expectedConcurrencyToken := q.Result.ConfigurationHash + + err = sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceApi) + require.NoError(t, err) + + fake := sut.GetAMConfigStore().(*fakeAMConfigStore) + intercepted := fake.lastSaveCommand + require.Equal(t, expectedConcurrencyToken, intercepted.FetchedConfigurationHash) + }) +} + +func createNotificationPolicyServiceSut() *NotificationPolicyService { + return &NotificationPolicyService{ + amStore: newFakeAMConfigStore(), + provenanceStore: newFakeProvisioningStore(), + xact: newNopTransactionManager(), + log: log.NewNopLogger(), + } +} + +func createTestRoutingTree() definitions.Route { + return definitions.Route{ + Receiver: "a new receiver", + } +} diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go new file mode 100644 index 00000000000..2a550db327f --- /dev/null +++ b/pkg/services/ngalert/provisioning/persist.go @@ -0,0 +1,24 @@ +package provisioning + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +// AMStore is a store of Alertmanager configurations. +type AMConfigStore interface { + GetLatestAlertmanagerConfiguration(ctx context.Context, query *models.GetLatestAlertmanagerConfigurationQuery) error + UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error +} + +// ProvisioningStore is a store of provisioning data for arbitrary objects. +type ProvisioningStore interface { + GetProvenance(ctx context.Context, o models.Provisionable) (models.Provenance, error) + SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error +} + +// TransactionManager represents the ability to issue and close transactions through contexts. +type TransactionManager interface { + InTransaction(ctx context.Context, work func(ctx context.Context) error) error +} diff --git a/pkg/services/ngalert/provisioning/serialize.go b/pkg/services/ngalert/provisioning/serialize.go new file mode 100644 index 00000000000..db73440bdb8 --- /dev/null +++ b/pkg/services/ngalert/provisioning/serialize.go @@ -0,0 +1,20 @@ +package provisioning + +import ( + "encoding/json" + "fmt" + + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" +) + +func DeserializeAlertmanagerConfig(config []byte) (*definitions.PostableUserConfig, error) { + result := definitions.PostableUserConfig{} + if err := json.Unmarshal(config, &result); err != nil { + return nil, fmt.Errorf("failed to deserialize alertmanager configuration: %w", err) + } + return &result, nil +} + +func SerializeAlertmanagerConfig(config definitions.PostableUserConfig) ([]byte, error) { + return json.Marshal(config) +} diff --git a/pkg/services/ngalert/provisioning/testing.go b/pkg/services/ngalert/provisioning/testing.go new file mode 100644 index 00000000000..12c54cc02c7 --- /dev/null +++ b/pkg/services/ngalert/provisioning/testing.go @@ -0,0 +1,126 @@ +package provisioning + +import ( + "context" + "crypto/md5" + "fmt" + + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +const defaultAlertmanagerConfigJSON = ` +{ + "template_files": null, + "alertmanager_config": { + "route": { + "receiver": "grafana-default-email", + "group_by": [ + "..." + ], + "routes": [{ + "receiver": "grafana-default-email", + "object_matchers": [["a", "=", "b"]] + }] + }, + "templates": null, + "receivers": [{ + "name": "grafana-default-email", + "grafana_managed_receiver_configs": [{ + "uid": "", + "name": "email receiver", + "type": "email", + "disableResolveMessage": false, + "settings": { + "addresses": "\u003cexample@email.com\u003e" + }, + "secureFields": {} + }] + }, { + "name": "a new receiver", + "grafana_managed_receiver_configs": [{ + "uid": "", + "name": "email receiver", + "type": "email", + "disableResolveMessage": false, + "settings": { + "addresses": "\u003canother@email.com\u003e" + }, + "secureFields": {} + }] + }] + } +} +` + +type fakeAMConfigStore struct { + config models.AlertConfiguration + lastSaveCommand *models.SaveAlertmanagerConfigurationCmd +} + +func newFakeAMConfigStore() *fakeAMConfigStore { + return &fakeAMConfigStore{ + config: models.AlertConfiguration{ + AlertmanagerConfiguration: defaultAlertmanagerConfigJSON, + ConfigurationVersion: "v1", + Default: true, + OrgID: 1, + }, + lastSaveCommand: nil, + } +} + +func (f *fakeAMConfigStore) GetLatestAlertmanagerConfiguration(ctx context.Context, query *models.GetLatestAlertmanagerConfigurationQuery) error { + query.Result = &f.config + query.Result.OrgID = query.OrgID + query.Result.ConfigurationHash = fmt.Sprintf("%x", md5.Sum([]byte(f.config.AlertmanagerConfiguration))) + return nil +} + +func (f *fakeAMConfigStore) UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error { + f.config = models.AlertConfiguration{ + AlertmanagerConfiguration: cmd.AlertmanagerConfiguration, + ConfigurationVersion: cmd.ConfigurationVersion, + Default: cmd.Default, + OrgID: cmd.OrgID, + } + f.lastSaveCommand = cmd + return nil +} + +type fakeProvisioningStore struct { + records map[int64]map[string]models.Provenance +} + +func newFakeProvisioningStore() *fakeProvisioningStore { + return &fakeProvisioningStore{ + records: map[int64]map[string]models.Provenance{}, + } +} + +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 { + return prov, nil + } + } + return models.ProvenanceNone, 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 + return nil +} + +type nopTransactionManager struct{} + +func newNopTransactionManager() *nopTransactionManager { + return &nopTransactionManager{} +} + +func (n *nopTransactionManager) InTransaction(ctx context.Context, work func(ctx context.Context) error) error { + return work(ctx) +} diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index c4cbc3b4ba0..a40f8fc0c82 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -171,6 +171,7 @@ func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRu EvaluationTime: result.EvaluatedAt, EvaluationState: result.State, Values: NewEvaluationValues(result.Values), + Condition: alertRule.Condition, }) currentState.LastEvaluationString = result.EvaluationString currentState.TrimResults(alertRule) diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index b6e1b1d78c4..df34f4b4f3d 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -1719,6 +1719,7 @@ func TestStaleResultsHandler(t *testing.T) { EvaluationTime: evaluationTime.Add(3 * time.Minute), EvaluationState: eval.Normal, Values: make(map[string]*float64), + Condition: "A", }, }, LastEvaluationTime: evaluationTime.Add(3 * time.Minute), diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index 0ed75b50de4..63f3b9d5930 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -2,6 +2,7 @@ package state import ( "errors" + "strings" "time" "github.com/grafana/grafana-plugin-sdk-go/data" @@ -36,6 +37,8 @@ type Evaluation struct { // Classic conditions can have different values for the same RefID as they can include multiple conditions. // For these, we use the index of the condition in addition RefID as the key e.g. "A0, A1, A2, etc.". Values map[string]*float64 + // Condition is the refID specified as the condition in the alerting rule at the time of the evaluation. + Condition string } // NewEvaluationValues returns the labels and values for each RefID in the capture. @@ -203,3 +206,20 @@ func (a *State) GetLabels(opts ...ngModels.LabelOption) map[string]string { return labels } + +func (a *State) GetLastEvaluationValuesForCondition() map[string]float64 { + if len(a.Results) <= 0 { + return nil + } + + lastResult := a.Results[len(a.Results)-1] + r := make(map[string]float64, len(lastResult.Values)) + + for refID, value := range lastResult.Values { + if strings.Contains(refID, lastResult.Condition) { + r[refID] = *value + } + } + + return r +} diff --git a/pkg/services/ngalert/store/alertmanager.go b/pkg/services/ngalert/store/alertmanager.go index f475d3e1877..0138cc901d5 100644 --- a/pkg/services/ngalert/store/alertmanager.go +++ b/pkg/services/ngalert/store/alertmanager.go @@ -85,8 +85,8 @@ func (st DBstore) SaveAlertmanagerConfigurationWithCallback(ctx context.Context, }) } -func (st *DBstore) UpdateAlertManagerConfiguration(cmd *models.SaveAlertmanagerConfigurationCmd) error { - return st.SQLStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { +func (st *DBstore) UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error { + return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { config := models.AlertConfiguration{ AlertmanagerConfiguration: cmd.AlertmanagerConfiguration, ConfigurationHash: fmt.Sprintf("%x", md5.Sum([]byte(cmd.AlertmanagerConfiguration))), diff --git a/pkg/services/ngalert/store/alertmanager_test.go b/pkg/services/ngalert/store/alertmanager_test.go index 89d02ab04e6..ea86b0ff558 100644 --- a/pkg/services/ngalert/store/alertmanager_test.go +++ b/pkg/services/ngalert/store/alertmanager_test.go @@ -49,7 +49,7 @@ func TestAlertManagerHash(t *testing.T) { require.NoError(t, err) require.Equal(t, configMD5, req.Result.ConfigurationHash) newConfig, newConfigMD5 := "my-config-new", fmt.Sprintf("%x", md5.Sum([]byte("my-config-new"))) - err = store.UpdateAlertManagerConfiguration(&models.SaveAlertmanagerConfigurationCmd{ + err = store.UpdateAlertmanagerConfiguration(context.Background(), &models.SaveAlertmanagerConfigurationCmd{ AlertmanagerConfiguration: newConfig, FetchedConfigurationHash: configMD5, ConfigurationVersion: "v1", @@ -71,7 +71,7 @@ func TestAlertManagerHash(t *testing.T) { err := store.GetLatestAlertmanagerConfiguration(context.Background(), req) require.NoError(t, err) require.Equal(t, configMD5, req.Result.ConfigurationHash) - err = store.UpdateAlertManagerConfiguration(&models.SaveAlertmanagerConfigurationCmd{ + err = store.UpdateAlertmanagerConfiguration(context.Background(), &models.SaveAlertmanagerConfigurationCmd{ AlertmanagerConfiguration: config, FetchedConfigurationHash: "the-wrong-hash", ConfigurationVersion: "v1", diff --git a/pkg/services/ngalert/store/database.go b/pkg/services/ngalert/store/database.go index 90b61a5e44c..52ea19e7029 100644 --- a/pkg/services/ngalert/store/database.go +++ b/pkg/services/ngalert/store/database.go @@ -23,7 +23,7 @@ type AlertingStore interface { GetAllLatestAlertmanagerConfiguration(ctx context.Context) ([]*models.AlertConfiguration, error) SaveAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error SaveAlertmanagerConfigurationWithCallback(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd, callback SaveCallback) error - UpdateAlertManagerConfiguration(cmd *models.SaveAlertmanagerConfigurationCmd) error + UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error } // DBstore stores the alert definitions and instances in the database. diff --git a/pkg/services/ngalert/store/provisioning_store.go b/pkg/services/ngalert/store/provisioning_store.go index 6681a290f58..56c5d157606 100644 --- a/pkg/services/ngalert/store/provisioning_store.go +++ b/pkg/services/ngalert/store/provisioning_store.go @@ -20,13 +20,6 @@ func (pr provenanceRecord) TableName() string { return "provenance_type" } -// ProvisioningStore is a store of provisioning data for arbitrary objects. -type ProvisioningStore interface { - GetProvenance(ctx context.Context, o models.Provisionable) (models.Provenance, error) - // TODO: API to query all provenances for a specific type? - SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error -} - // GetProvenance gets the provenance status for a provisionable object. func (st DBstore) GetProvenance(ctx context.Context, o models.Provisionable) (models.Provenance, error) { recordType := o.ResourceType() diff --git a/pkg/services/ngalert/store/provisioning_store_test.go b/pkg/services/ngalert/store/provisioning_store_test.go index 4e9770c2d0b..815b799f080 100644 --- a/pkg/services/ngalert/store/provisioning_store_test.go +++ b/pkg/services/ngalert/store/provisioning_store_test.go @@ -2,11 +2,11 @@ package store_test import ( "context" - "fmt" "testing" "github.com/grafana/grafana/pkg/services/ngalert" "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/services/ngalert/tests" "github.com/stretchr/testify/require" @@ -15,7 +15,7 @@ import ( const testAlertingIntervalSeconds = 10 func TestProvisioningStore(t *testing.T) { - store, xact := createSut(tests.SetupTestEnv(t, testAlertingIntervalSeconds)) + store := createProvisioningStoreSut(tests.SetupTestEnv(t, testAlertingIntervalSeconds)) t.Run("Default provenance of a known type is None", func(t *testing.T) { rule := models.AlertRule{ @@ -83,39 +83,8 @@ func TestProvisioningStore(t *testing.T) { require.NoError(t, err) require.Equal(t, models.ProvenanceFile, p) }) - - t.Run("Store saves provenance type when contextual transaction is applied", func(t *testing.T) { - rule := models.AlertRule{ - UID: "456", - } - - err := xact.InTransaction(context.Background(), func(ctx context.Context) error { - return store.SetProvenance(ctx, &rule, models.ProvenanceFile) - }) - require.NoError(t, err) - - provenance, err := store.GetProvenance(context.Background(), &rule) - require.NoError(t, err) - require.Equal(t, models.ProvenanceFile, provenance) - }) - - t.Run("Contextual transaction which errors before saving rolls back type update", func(t *testing.T) { - rule := models.AlertRule{ - UID: "789", - } - - _ = xact.InTransaction(context.Background(), func(ctx context.Context) error { - err := store.SetProvenance(ctx, &rule, models.ProvenanceFile) - require.NoError(t, err) - return fmt.Errorf("something happened!") - }) - - provenance, err := store.GetProvenance(context.Background(), &rule) - require.NoError(t, err) - require.Equal(t, models.ProvenanceNone, provenance) - }) } -func createSut(_ *ngalert.AlertNG, db *store.DBstore) (store.ProvisioningStore, store.TransactionManager) { - return db, db +func createProvisioningStoreSut(_ *ngalert.AlertNG, db *store.DBstore) provisioning.ProvisioningStore { + return db } diff --git a/pkg/services/ngalert/store/transactions.go b/pkg/services/ngalert/store/transactions.go index a29223727c1..a9673109082 100644 --- a/pkg/services/ngalert/store/transactions.go +++ b/pkg/services/ngalert/store/transactions.go @@ -2,11 +2,6 @@ package store import "context" -// TransactionManager represents the ability to issue and close transactions through contexts. -type TransactionManager interface { - InTransaction(ctx context.Context, work func(ctx context.Context) error) error -} - func (st *DBstore) InTransaction(ctx context.Context, f func(ctx context.Context) error) error { return st.SQLStore.InTransaction(ctx, f) } diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index db669d4121a..35ef0930872 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -39,6 +39,11 @@ func SetupTestEnv(t *testing.T, baseInterval time.Duration) (*ngalert.AlertNG, * cfg.UnifiedAlerting.Enabled = new(bool) *cfg.UnifiedAlerting.Enabled = true + cfg.IsFeatureToggleEnabled = func(key string) bool { + // Enable alert provisioning FF when running tests. + return key == featuremgmt.FlagAlertProvisioning + } + m := metrics.NewNGAlert(prometheus.NewRegistry()) sqlStore := sqlstore.InitTestDB(t) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index a95e5435172..2094f0100da 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -12,8 +12,9 @@ type OrgListResponse []struct { Response error } type SQLStoreMock struct { - LastGetAlertsQuery *models.GetAlertsQuery - LatestUserId int64 + LastGetAlertsQuery *models.GetAlertsQuery + LastLoginAttemptCommand *models.CreateLoginAttemptCommand + LatestUserId int64 ExpectedUser *models.User ExpectedDatasource *models.DataSource @@ -39,8 +40,11 @@ type SQLStoreMock struct { ExpectedNotifierUsageStats []*models.NotifierUsageStats ExpectedPersistedDashboards models.HitList ExpectedSignedInUser *models.SignedInUser + ExpectedAPIKey *models.ApiKey ExpectedUserStars map[int64]bool - ExpectedError error + ExpectedLoginAttempts int64 + + ExpectedError error } func NewSQLStoreMock() *SQLStoreMock { @@ -130,6 +134,12 @@ func (m SQLStoreMock) DeleteOrphanedProvisionedDashboards(ctx context.Context, c } func (m *SQLStoreMock) CreateLoginAttempt(ctx context.Context, cmd *models.CreateLoginAttemptCommand) error { + m.LastLoginAttemptCommand = cmd + return m.ExpectedError +} + +func (m *SQLStoreMock) GetUserLoginAttemptCount(ctx context.Context, query *models.GetUserLoginAttemptCountQuery) error { + query.Result = m.ExpectedLoginAttempts return m.ExpectedError } @@ -147,6 +157,7 @@ func (m *SQLStoreMock) GetUserById(ctx context.Context, query *models.GetUserByI } func (m *SQLStoreMock) GetUserByLogin(ctx context.Context, query *models.GetUserByLoginQuery) error { + query.Result = m.ExpectedUser return m.ExpectedError } @@ -612,6 +623,7 @@ func (m *SQLStoreMock) GetApiKeyById(ctx context.Context, query *models.GetApiKe } func (m *SQLStoreMock) GetApiKeyByName(ctx context.Context, query *models.GetApiKeyByNameQuery) error { + query.Result = m.ExpectedAPIKey return m.ExpectedError } diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index 5d695bc4e1d..24f1a9e8119 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -307,6 +307,12 @@ func (ss *SQLStore) RemoveOrgUser(ctx context.Context, cmd *models.RemoveOrgUser } cmd.UserWasDeleted = true + } else { + // no orgs, but keep the user -> clean up orgId + err = removeUserOrg(sess, user.Id) + if err != nil { + return err + } } return nil diff --git a/pkg/services/sqlstore/org_users_test.go b/pkg/services/sqlstore/org_users_test.go index a018393fd47..b68eb0d17c3 100644 --- a/pkg/services/sqlstore/org_users_test.go +++ b/pkg/services/sqlstore/org_users_test.go @@ -144,6 +144,53 @@ func TestSQLStore_SearchOrgUsers(t *testing.T) { } } +func TestSQLStore_RemoveOrgUser(t *testing.T) { + store := InitTestDB(t) + + // create org and admin + _, err := store.CreateUser(context.Background(), models.CreateUserCommand{ + Login: "admin", + OrgId: 1, + }) + require.NoError(t, err) + + // create a user with no org + _, err = store.CreateUser(context.Background(), models.CreateUserCommand{ + Login: "user", + OrgId: 1, + SkipOrgSetup: true, + }) + require.NoError(t, err) + + // assign the user to the org + err = store.AddOrgUser(context.Background(), &models.AddOrgUserCommand{ + Role: "Viewer", + OrgId: 1, + UserId: 2, + }) + require.NoError(t, err) + + // assert the org has been assigned + user := &models.GetUserByIdQuery{Id: 2} + err = store.GetUserById(context.Background(), user) + require.NoError(t, err) + require.Equal(t, user.Result.OrgId, int64(1)) + + // remove the user org + err = store.RemoveOrgUser(context.Background(), &models.RemoveOrgUserCommand{ + UserId: 2, + OrgId: 1, + ShouldDeleteOrphanedUser: false, + }) + require.NoError(t, err) + + // assert the org has been removed + user = &models.GetUserByIdQuery{Id: 2} + err = store.GetUserById(context.Background(), user) + require.NoError(t, err) + require.Equal(t, user.Result.OrgId, int64(0)) +} + func seedOrgUsers(t *testing.T, store *SQLStore, numUsers int) { t.Helper() // Seed users diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go index 05e4a8aafd2..35e66d58350 100644 --- a/pkg/services/sqlstore/store.go +++ b/pkg/services/sqlstore/store.go @@ -26,6 +26,7 @@ type Store interface { GetOrgById(context.Context, *models.GetOrgByIdQuery) error GetOrgByNameHandler(ctx context.Context, query *models.GetOrgByNameQuery) error CreateLoginAttempt(ctx context.Context, cmd *models.CreateLoginAttemptCommand) error + GetUserLoginAttemptCount(ctx context.Context, query *models.GetUserLoginAttemptCountQuery) error DeleteOldLoginAttempts(ctx context.Context, cmd *models.DeleteOldLoginAttemptsCommand) error CreateUser(ctx context.Context, cmd models.CreateUserCommand) (*models.User, error) GetUserById(ctx context.Context, query *models.GetUserByIdQuery) error diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 238f4e987b6..c9ce4d9b098 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -445,6 +445,16 @@ func setUsingOrgInTransaction(sess *DBSession, userID int64, orgID int64) error return err } +func removeUserOrg(sess *DBSession, userID int64) error { + user := models.User{ + Id: userID, + OrgId: 0, + } + + _, err := sess.ID(userID).MustCols("org_id").Update(&user) + return err +} + func (ss *SQLStore) GetUserProfile(ctx context.Context, query *models.GetUserProfileQuery) error { return ss.WithDbSession(ctx, func(sess *DBSession) error { var user models.User @@ -540,18 +550,21 @@ func (ss *SQLStore) GetSignedInUser(ctx context.Context, query *models.GetSigned } var rawSQL = `SELECT - u.id as user_id, - u.is_admin as is_grafana_admin, - u.email as email, - u.login as login, - u.name as name, - u.help_flags1 as help_flags1, - u.last_seen_at as last_seen_at, + u.id as user_id, + u.is_admin as is_grafana_admin, + u.email as email, + u.login as login, + u.name as name, + 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, - org.name as org_name, - org_user.role as org_role, - org.id as org_id + user_auth.auth_module as external_auth_module, + user_auth.auth_id as external_auth_id, + org.name as org_name, + org_user.role as org_role, + org.id as org_id FROM ` + dialect.Quote("user") + ` as u + LEFT OUTER JOIN user_auth on user_auth.user_id = u.id LEFT OUTER JOIN org_user on org_user.org_id = ` + orgId + ` and org_user.user_id = u.id LEFT OUTER JOIN org on org.id = org_user.org_id ` @@ -579,6 +592,10 @@ func (ss *SQLStore) GetSignedInUser(ctx context.Context, query *models.GetSigned user.OrgName = "Org missing" } + if user.ExternalAuthModule != "oauth_grafana_com" { + user.ExternalAuthId = "" + } + getTeamsByUserQuery := &models.GetTeamsByUserQuery{OrgId: user.OrgId, UserId: user.UserId} err = ss.GetTeamsByUser(ctx, getTeamsByUserQuery) if err != nil { diff --git a/pkg/services/updatechecker/grafana.go b/pkg/services/updatechecker/grafana.go index 777fcd07a19..f66fb76e9d6 100644 --- a/pkg/services/updatechecker/grafana.go +++ b/pkg/services/updatechecker/grafana.go @@ -28,7 +28,7 @@ type GrafanaService struct { func ProvideGrafanaService(cfg *setting.Cfg) *GrafanaService { return &GrafanaService{ - enabled: cfg.CheckForUpdates, + enabled: cfg.CheckForGrafanaUpdates, grafanaVersion: cfg.BuildVersion, httpClient: http.Client{Timeout: 10 * time.Second}, log: log.New("grafana.update.checker"), diff --git a/pkg/services/updatechecker/plugins.go b/pkg/services/updatechecker/plugins.go index 618597acda6..967fdb9fdcb 100644 --- a/pkg/services/updatechecker/plugins.go +++ b/pkg/services/updatechecker/plugins.go @@ -29,7 +29,7 @@ type PluginsService struct { func ProvidePluginsService(cfg *setting.Cfg, pluginStore plugins.Store) *PluginsService { return &PluginsService{ - enabled: cfg.CheckForUpdates, + enabled: cfg.CheckForPluginUpdates, grafanaVersion: cfg.BuildVersion, httpClient: &http.Client{Timeout: 10 * time.Second}, log: log.New("plugins.update.checker"), diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 6d5729b731c..10834bf4556 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -387,7 +387,8 @@ type Cfg struct { Env string // Analytics - CheckForUpdates bool + CheckForGrafanaUpdates bool + CheckForPluginUpdates bool ReportingDistributor string ReportingEnabled bool ApplicationInsightsConnectionString string @@ -929,7 +930,8 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.MetricsEndpointDisableTotalStats = iniFile.Section("metrics").Key("disable_total_stats").MustBool(false) analytics := iniFile.Section("analytics") - cfg.CheckForUpdates = analytics.Key("check_for_updates").MustBool(true) + cfg.CheckForGrafanaUpdates = analytics.Key("check_for_updates").MustBool(true) + cfg.CheckForPluginUpdates = analytics.Key("check_for_plugin_updates").MustBool(true) GoogleAnalyticsId = analytics.Key("google_analytics_ua_id").String() GoogleTagManagerId = analytics.Key("google_tag_manager_id").String() RudderstackWriteKey = analytics.Key("rudderstack_write_key").String() diff --git a/pkg/tests/api/alerting/api_provisioning_test.go b/pkg/tests/api/alerting/api_provisioning_test.go new file mode 100644 index 00000000000..1cdcef6055b --- /dev/null +++ b/pkg/tests/api/alerting/api_provisioning_test.go @@ -0,0 +1,157 @@ +package alerting + +import ( + "bytes" + "fmt" + "io" + "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" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/stretchr/testify/require" +) + +func TestProvisioning(t *testing.T) { + _, err := tracing.InitializeTracerForTest() + require.NoError(t, err) + + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + EnableFeatureToggles: []string{featuremgmt.FlagAlertProvisioning}, + }) + + 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{ + DefaultOrgRole: string(models.ROLE_VIEWER), + Password: "viewer", + Login: "viewer", + }) + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_EDITOR), + Password: "editor", + Login: "editor", + }) + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_ADMIN), + Password: "admin", + Login: "admin", + }) + + t.Run("when provisioning notification policies", func(t *testing.T) { + url := fmt.Sprintf("http://%s/api/provisioning/policies", grafanaListedAddr) + body := ` + { + "receiver": "grafana-default-email", + "group_by": [ + "..." + ], + "routes": [] + }` + + 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) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 202, resp.StatusCode) + }) + }) +} + +func createTestRequest(method string, url string, user string, body string) *http.Request { + var bodyBuf io.Reader + if body != "" { + bodyBuf = bytes.NewReader([]byte(body)) + } + req, _ := http.NewRequest(method, url, bodyBuf) + if bodyBuf != nil { + req.Header.Set("Content-Type", "application/json") + } + if user != "" { + req.SetBasicAuth(user, user) + } + return req +} diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index b9732df06dd..ad7d5f3029a 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -177,8 +177,6 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { logSect, err := cfg.NewSection("log") require.NoError(t, err) - _, err = logSect.NewKey("mode", "console") - require.NoError(t, err) _, err = logSect.NewKey("level", "debug") require.NoError(t, err) diff --git a/pkg/tsdb/loki/framing_test.go b/pkg/tsdb/loki/framing_test.go index 8cb118dd809..78d8df2cfec 100644 --- a/pkg/tsdb/loki/framing_test.go +++ b/pkg/tsdb/loki/framing_test.go @@ -39,6 +39,9 @@ func TestSuccessResponse(t *testing.T) { {name: "parse a matrix response with Infinity", filepath: "matrix_inf", query: matrixQuery}, {name: "parse a matrix response with very small step value", filepath: "matrix_small_step", query: matrixQuery}, + // Prometheus handles the `__name__` label in a special way, but Loki should not. + {name: "parse a matrix response with __name__ label normally", filepath: "matrix_name", query: matrixQuery}, + // loki adds stats to matrix-responses too {name: "parse a matrix response with stats", filepath: "matrix_with_stats", query: matrixQuery}, diff --git a/pkg/tsdb/loki/testdata/matrix_name.golden.txt b/pkg/tsdb/loki/testdata/matrix_name.golden.txt new file mode 100644 index 00000000000..496d6550319 --- /dev/null +++ b/pkg/tsdb/loki/testdata/matrix_name.golden.txt @@ -0,0 +1,18 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] { + "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" +} +Name: {__name__="moon", level="error"} +Dimensions: 2 Fields by 1 Rows ++-----------------------------------+------------------------------------+ +| Name: time | Name: value | +| Labels: | Labels: __name__=moon, level=error | +| Type: []time.Time | Type: []float64 | ++-----------------------------------+------------------------------------+ +| 2021-12-10 08:36:06.989 +0000 UTC | 0.4 | ++-----------------------------------+------------------------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////uAIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAMgAAAADAAAAbAAAACgAAAAEAAAA6P3//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAAI/v//CAAAACwAAAAgAAAAe19fbmFtZV9fPSJtb29uIiwgbGV2ZWw9ImVycm9yIn0AAAAABAAAAG5hbWUAAAAASP7//wgAAABAAAAANQAAAHsiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHVwKEFMRVJUUylcblN0ZXA6IDQycyJ9AAAABAAAAG1ldGEAAAAAAgAAABgBAAAEAAAAAv///xQAAADgAAAA4AAAAAAAAAPgAAAAAwAAAHAAAAAsAAAABAAAAND+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAAPT+//8IAAAALAAAACMAAAB7Il9fbmFtZV9fIjoibW9vbiIsImxldmVsIjoiZXJyb3IifQAGAAAAbGFiZWxzAAA0////CAAAAEgAAAA8AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntfX25hbWVfXz1cIm1vb25cIiwgbGV2ZWw9XCJlcnJvclwifSJ9AAAAAAYAAABjb25maWcAAAAAAABW////AAACAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAHgAAACAAAAAAAAACoAAAAACAAAANAAAAAQAAADc////CAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAIAAwACAAEAAgAAAAIAAAAHAAAABIAAAB7ImludGVydmFsIjo0MjAwMH0AAAYAAABjb25maWcAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAHRpbWUAAAAAAAAAAP////+4AAAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAEAAAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAWAAAAAEAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAIAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAEANtjnWV78WmpmZmZmZ2T8QAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAOAAAAAAABAABAAAAyAIAAAAAAADAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAoADAAAAAgABAAKAAAACAAAAMgAAAADAAAAbAAAACgAAAAEAAAA6P3//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAAI/v//CAAAACwAAAAgAAAAe19fbmFtZV9fPSJtb29uIiwgbGV2ZWw9ImVycm9yIn0AAAAABAAAAG5hbWUAAAAASP7//wgAAABAAAAANQAAAHsiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHVwKEFMRVJUUylcblN0ZXA6IDQycyJ9AAAABAAAAG1ldGEAAAAAAgAAABgBAAAEAAAAAv///xQAAADgAAAA4AAAAAAAAAPgAAAAAwAAAHAAAAAsAAAABAAAAND+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAAPT+//8IAAAALAAAACMAAAB7Il9fbmFtZV9fIjoibW9vbiIsImxldmVsIjoiZXJyb3IifQAGAAAAbGFiZWxzAAA0////CAAAAEgAAAA8AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntfX25hbWVfXz1cIm1vb25cIiwgbGV2ZWw9XCJlcnJvclwifSJ9AAAAAAYAAABjb25maWcAAAAAAABW////AAACAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAHgAAACAAAAAAAAACoAAAAACAAAANAAAAAQAAADc////CAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAIAAwACAAEAAgAAAAIAAAAHAAAABIAAAB7ImludGVydmFsIjo0MjAwMH0AAAYAAABjb25maWcAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAHRpbWUAAAAA4AIAAEFSUk9XMQ== diff --git a/pkg/tsdb/loki/testdata/matrix_name.json b/pkg/tsdb/loki/testdata/matrix_name.json new file mode 100644 index 00000000000..7870730fd9e --- /dev/null +++ b/pkg/tsdb/loki/testdata/matrix_name.json @@ -0,0 +1,17 @@ +{ + "status": "success", + "data": { + "resultType": "matrix", + "result": [ + { + "metric": { + "level": "error", + "__name__": "moon" + }, + "values": [ + [1639125366.989, "0.4"] + ] + } + ] + } +} diff --git a/pkg/util/converter/prom.go b/pkg/util/converter/prom.go new file mode 100644 index 00000000000..640dd148eac --- /dev/null +++ b/pkg/util/converter/prom.go @@ -0,0 +1,386 @@ +package converter + +import ( + "fmt" + "strconv" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + jsoniter "github.com/json-iterator/go" +) + +// helpful while debugging all the options that may appear +func logf(format string, a ...interface{}) { + //fmt.Printf(format, a...) +} + +// ReadPrometheusStyleResult will read results from a prometheus or loki server and return data frames +func ReadPrometheusStyleResult(iter *jsoniter.Iterator) *backend.DataResponse { + var rsp *backend.DataResponse + status := "unknown" + + for l1Field := iter.ReadObject(); l1Field != ""; l1Field = iter.ReadObject() { + switch l1Field { + case "status": + status = iter.ReadString() + + case "data": + rsp = readPrometheusData(iter) + + // case "error": + // case "errorType": + // case "warnings": + default: + v := iter.Read() + logf("[ROOT] TODO, support key: %s / %v\n", l1Field, v) + } + } + + if status != "success" { + logf("ERROR: %s\n", status) + } + + return rsp +} + +func readPrometheusData(iter *jsoniter.Iterator) *backend.DataResponse { + t := iter.WhatIsNext() + if t == jsoniter.ArrayValue { + return readArrayData(iter) + } + + if t != jsoniter.ObjectValue { + return &backend.DataResponse{ + Error: fmt.Errorf("expected object type"), + } + } + + resultType := "" + var rsp *backend.DataResponse + + for l1Field := iter.ReadObject(); l1Field != ""; l1Field = iter.ReadObject() { + switch l1Field { + case "resultType": + resultType = iter.ReadString() + + case "result": + switch resultType { + case "matrix": + rsp = readMatrixOrVector(iter) + case "vector": + rsp = readMatrixOrVector(iter) + case "streams": + rsp = readStream(iter) + default: + iter.Skip() + rsp = &backend.DataResponse{ + Error: fmt.Errorf("unknown result type: %s", resultType), + } + } + + case "stats": + v := iter.Read() + if len(rsp.Frames) > 0 { + meta := rsp.Frames[0].Meta + if meta == nil { + meta = &data.FrameMeta{} + rsp.Frames[0].Meta = meta + } + meta.Custom = map[string]interface{}{ + "stats": v, + } + } + + default: + v := iter.Read() + logf("[data] TODO, support key: %s / %v\n", l1Field, v) + } + } + + return rsp +} + +// will return strings or exemplars +func readArrayData(iter *jsoniter.Iterator) *backend.DataResponse { + lookup := make(map[string]*data.Field) + + var labelFrame *data.Frame + rsp := &backend.DataResponse{} + stringField := data.NewFieldFromFieldType(data.FieldTypeString, 0) + stringField.Name = "Value" + for iter.ReadArray() { + switch iter.WhatIsNext() { + case jsoniter.StringValue: + stringField.Append(iter.ReadString()) + + // Either label or exemplars + case jsoniter.ObjectValue: + exemplar, labelPairs := readLabelsOrExemplars(iter) + if exemplar != nil { + rsp.Frames = append(rsp.Frames, exemplar) + } else if labelPairs != nil { + max := 0 + for _, pair := range labelPairs { + k := pair[0] + v := pair[1] + f, ok := lookup[k] + if !ok { + f = data.NewFieldFromFieldType(data.FieldTypeString, 0) + f.Name = k + lookup[k] = f + + if labelFrame == nil { + labelFrame = data.NewFrame("") + rsp.Frames = append(rsp.Frames, labelFrame) + } + labelFrame.Fields = append(labelFrame.Fields, f) + } + f.Append(fmt.Sprintf("%v", v)) + if f.Len() > max { + max = f.Len() + } + } + + // Make sure all fields have equal length + for _, f := range lookup { + diff := max - f.Len() + if diff > 0 { + f.Extend(diff) + } + } + } + + default: + { + ext := iter.ReadAny() + v := fmt.Sprintf("%v", ext) + stringField.Append(v) + } + } + } + + if rsp.Frames == nil || stringField.Len() > 0 { + rsp.Frames = append(rsp.Frames, data.NewFrame("", stringField)) + } + + return rsp +} + +// For consistent ordering read values to an array not a map +func readLabelsAsPairs(iter *jsoniter.Iterator) [][2]string { + pairs := make([][2]string, 0, 10) + for k := iter.ReadObject(); k != ""; k = iter.ReadObject() { + pairs = append(pairs, [2]string{k, iter.ReadString()}) + } + return pairs +} + +func readLabelsOrExemplars(iter *jsoniter.Iterator) (*data.Frame, [][2]string) { + pairs := make([][2]string, 0, 10) + labels := data.Labels{} + var frame *data.Frame + + for l1Field := iter.ReadObject(); l1Field != ""; l1Field = iter.ReadObject() { + switch l1Field { + case "seriesLabels": + iter.ReadVal(&labels) + case "exemplars": + lookup := make(map[string]*data.Field) + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0) + valueField := data.NewFieldFromFieldType(data.FieldTypeFloat64, 0) + valueField.Name = labels["__name__"] + delete(labels, "__name__") + valueField.Labels = labels + frame = data.NewFrame("", timeField, valueField) + for iter.ReadArray() { + for l2Field := iter.ReadObject(); l2Field != ""; l2Field = iter.ReadObject() { + switch l2Field { + // nolint:goconst + case "value": + v, _ := strconv.ParseFloat(iter.ReadString(), 64) + valueField.Append(v) + + case "timestamp": + ts := timeFromFloat(iter.ReadFloat64()) + timeField.Append(ts) + + case "labels": + max := 0 + for _, pair := range readLabelsAsPairs(iter) { + k := pair[0] + v := pair[1] + f, ok := lookup[k] + if !ok { + f = data.NewFieldFromFieldType(data.FieldTypeString, 0) + f.Name = k + lookup[k] = f + frame.Fields = append(frame.Fields, f) + } + f.Append(v) + if f.Len() > max { + max = f.Len() + } + } + + // Make sure all fields have equal length + for _, f := range lookup { + diff := max - f.Len() + if diff > 0 { + f.Extend(diff) + } + } + + default: + iter.Skip() + frame.AppendNotices(data.Notice{ + Severity: data.NoticeSeverityError, + Text: fmt.Sprintf("unable to parse key: %s in response body", l2Field), + }) + } + } + } + default: + v := fmt.Sprintf("%v", iter.Read()) + pairs = append(pairs, [2]string{l1Field, v}) + } + } + + return frame, pairs +} + +func readMatrixOrVector(iter *jsoniter.Iterator) *backend.DataResponse { + rsp := &backend.DataResponse{} + + for iter.ReadArray() { + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0) + timeField.Name = data.TimeSeriesTimeFieldName + valueField := data.NewFieldFromFieldType(data.FieldTypeFloat64, 0) + valueField.Labels = data.Labels{} + + for l1Field := iter.ReadObject(); l1Field != ""; l1Field = iter.ReadObject() { + switch l1Field { + case "metric": + iter.ReadVal(&valueField.Labels) + + case "value": + t, v, err := readTimeValuePair(iter) + if err == nil { + timeField.Append(t) + valueField.Append(v) + } + + // nolint:goconst + case "values": + for iter.ReadArray() { + t, v, err := readTimeValuePair(iter) + if err == nil { + timeField.Append(t) + valueField.Append(v) + } + } + } + } + + name, ok := valueField.Labels["__name__"] + if ok { + valueField.Name = name + delete(valueField.Labels, "__name__") + } else { + valueField.Name = data.TimeSeriesValueFieldName + } + + frame := data.NewFrame("", timeField, valueField) + frame.Meta = &data.FrameMeta{ + Type: data.FrameTypeTimeSeriesMany, + } + rsp.Frames = append(rsp.Frames, frame) + } + + return rsp +} + +func readTimeValuePair(iter *jsoniter.Iterator) (time.Time, float64, error) { + iter.ReadArray() + t := iter.ReadFloat64() + iter.ReadArray() + v := iter.ReadString() + iter.ReadArray() + + tt := timeFromFloat(t) + fv, err := strconv.ParseFloat(v, 64) + return tt, fv, err +} + +func readStream(iter *jsoniter.Iterator) *backend.DataResponse { + rsp := &backend.DataResponse{} + + labelsField := data.NewFieldFromFieldType(data.FieldTypeString, 0) + labelsField.Name = "__labels" // avoid automatically spreading this by labels + + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0) + timeField.Name = "Time" + + lineField := data.NewFieldFromFieldType(data.FieldTypeString, 0) + lineField.Name = "Line" + + // Nanoseconds time field + tsField := data.NewFieldFromFieldType(data.FieldTypeString, 0) + tsField.Name = "TS" + + labels := data.Labels{} + labelString := labels.String() + + for iter.ReadArray() { + for l1Field := iter.ReadObject(); l1Field != ""; l1Field = iter.ReadObject() { + switch l1Field { + case "stream": + iter.ReadVal(&labels) + labelString = labels.String() + + case "values": + for iter.ReadArray() { + iter.ReadArray() + ts := iter.ReadString() + iter.ReadArray() + line := iter.ReadString() + iter.ReadArray() + + t := timeFromLokiString(ts) + + labelsField.Append(labelString) + timeField.Append(t) + lineField.Append(line) + tsField.Append(ts) + } + } + } + } + + frame := data.NewFrame("", labelsField, timeField, lineField, tsField) + frame.Meta = &data.FrameMeta{} + rsp.Frames = append(rsp.Frames, frame) + + return rsp +} + +func timeFromFloat(fv float64) time.Time { + return time.UnixMilli(int64(fv * 1000.0)).UTC() +} + +func timeFromLokiString(str string) time.Time { + // normal time values look like: 1645030246277587968 + // and are less than: math.MaxInt65=9223372036854775807 + // This will do a fast path for any date before 2033 + s := len(str) + if s < 19 || (s == 19 && str[0] == '1') { + ns, err := strconv.ParseInt(str, 10, 64) + if err == nil { + return time.Unix(0, ns).UTC() + } + } + + ss, _ := strconv.ParseInt(str[0:10], 10, 64) + ns, _ := strconv.ParseInt(str[10:], 10, 64) + return time.Unix(ss, ns).UTC() +} diff --git a/pkg/util/converter/prom_test.go b/pkg/util/converter/prom_test.go new file mode 100644 index 00000000000..04b71545364 --- /dev/null +++ b/pkg/util/converter/prom_test.go @@ -0,0 +1,83 @@ +package converter + +import ( + "io/ioutil" + "os" + "path" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/experimental" + jsoniter "github.com/json-iterator/go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadPromFrames(t *testing.T) { + files := []string{ + "prom-labels", + "prom-matrix", + "prom-matrix-with-nans", + "prom-vector", + "prom-series", + "prom-exemplars", + "loki-streams-a", + "loki-streams-b", + } + + for _, name := range files { + t.Run(name, func(t *testing.T) { + // nolint:gosec + // We can ignore the gosec G304 because this is a test with static defined paths + f, err := os.Open(path.Join("testdata", name+".json")) + require.NoError(t, err) + + iter := jsoniter.Parse(jsoniter.ConfigDefault, f, 1024) + rsp := ReadPrometheusStyleResult(iter) + + out, err := jsoniter.MarshalIndent(rsp, "", " ") + require.NoError(t, err) + + save := false + fpath := path.Join("testdata", name+"-frame.json") + + // nolint:gosec + // We can ignore the gosec G304 because this is a test with static defined paths + current, err := ioutil.ReadFile(fpath) + if err == nil { + same := assert.JSONEq(t, string(out), string(current)) + if !same { + save = true + } + } else { + assert.Fail(t, "missing file: "+fpath) + save = true + } + + if save { + err = os.WriteFile(fpath, out, 0600) + require.NoError(t, err) + } + + fpath = path.Join("testdata", name+"-golden.txt") + err = experimental.CheckGoldenDataResponse(fpath, rsp, true) + assert.NoError(t, err) + }) + } +} + +func TestTimeConversions(t *testing.T) { + // include millisecond precision + assert.Equal(t, + time.Date(2020, time.September, 14, 15, 22, 25, 479000000, time.UTC), + timeFromFloat(1600096945.479)) + + // Loki date parsing + assert.Equal(t, + time.Date(2022, time.February, 16, 16, 50, 46, 277587968, time.UTC), + timeFromLokiString("1645030246277587968")) + + assert.Equal(t, + time.Date(2033, time.May, 18, 3, 33, 20, 0, time.UTC), + timeFromLokiString("2000000000000000000")) +} diff --git a/pkg/util/converter/testdata/loki-streams-a-frame.json b/pkg/util/converter/testdata/loki-streams-a-frame.json new file mode 100644 index 00000000000..bd5df94989e --- /dev/null +++ b/pkg/util/converter/testdata/loki-streams-a-frame.json @@ -0,0 +1,90 @@ +{ + "frames": [ + { + "schema": { + "meta": { + "custom": { + "stats": { + "ingester": { + "totalChunksMatched": 0, + "totalBatches": 0, + "totalLinesSent": 0, + "headChunkBytes": 0, + "headChunkLines": 0, + "decompressedLines": 0, + "compressedBytes": 0, + "totalReached": 0, + "totalDuplicates": 0, + "decompressedBytes": 0 + }, + "summary": { + "bytesProcessedPerSecond": 3507022, + "linesProcessedPerSecond": 24818, + "totalBytesProcessed": 7772, + "totalLinesProcessed": 55, + "execTime": 0.002216125 + }, + "store": { + "totalChunksDownloaded": 2, + "headChunkBytes": 0, + "decompressedLines": 55, + "totalDuplicates": 0, + "totalChunksRef": 2, + "headChunkLines": 0, + "decompressedBytes": 7772, + "compressedBytes": 31432, + "chunksDownloadTime": 0.000390958 + } + } + } + }, + "fields": [ + { + "name": "__labels", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "Line", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "TS", + "type": "string", + "typeInfo": { + "frame": "string" + } + } + ] + }, + "data": { + "values": [ + [ + "level=error, location=moon🌙","level=info, location=moon🌙","level=info, location=moon🌙","level=info, location=moon🌙","level=info, location=moon🌙","level=info, location=moon🌙" + ], + [ + 1645030244810,1645030247027,1645030246277,1645030246277,1645030245539,1645030244091 + ], + [ + "log line error 1","log line info 1","log line info 2","log line info 2","log line info 3","log line info 4" + ], + [ + "1645030244810757120","1645030247027735040","1645030246277587968","1645030246277587968","1645030245539423744","1645030244091700992" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/util/converter/testdata/loki-streams-a-golden.txt b/pkg/util/converter/testdata/loki-streams-a-golden.txt new file mode 100644 index 00000000000..727f13947ae --- /dev/null +++ b/pkg/util/converter/testdata/loki-streams-a-golden.txt @@ -0,0 +1,56 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] { + "custom": { + "stats": { + "ingester": { + "compressedBytes": 0, + "decompressedBytes": 0, + "decompressedLines": 0, + "headChunkBytes": 0, + "headChunkLines": 0, + "totalBatches": 0, + "totalChunksMatched": 0, + "totalDuplicates": 0, + "totalLinesSent": 0, + "totalReached": 0 + }, + "store": { + "chunksDownloadTime": 0.000390958, + "compressedBytes": 31432, + "decompressedBytes": 7772, + "decompressedLines": 55, + "headChunkBytes": 0, + "headChunkLines": 0, + "totalChunksDownloaded": 2, + "totalChunksRef": 2, + "totalDuplicates": 0 + }, + "summary": { + "bytesProcessedPerSecond": 3507022, + "execTime": 0.002216125, + "linesProcessedPerSecond": 24818, + "totalBytesProcessed": 7772, + "totalLinesProcessed": 55 + } + } + } +} +Name: +Dimensions: 4 Fields by 6 Rows ++------------------------------+-----------------------------------------+------------------+---------------------+ +| Name: __labels | Name: Time | Name: Line | Name: TS | +| Labels: | Labels: | Labels: | Labels: | +| Type: []string | Type: []time.Time | Type: []string | Type: []string | ++------------------------------+-----------------------------------------+------------------+---------------------+ +| level=error, location=moon🌙 | 2022-02-16 16:50:44.81075712 +0000 UTC | log line error 1 | 1645030244810757120 | +| level=info, location=moon🌙 | 2022-02-16 16:50:47.02773504 +0000 UTC | log line info 1 | 1645030247027735040 | +| level=info, location=moon🌙 | 2022-02-16 16:50:46.277587968 +0000 UTC | log line info 2 | 1645030246277587968 | +| level=info, location=moon🌙 | 2022-02-16 16:50:46.277587968 +0000 UTC | log line info 2 | 1645030246277587968 | +| level=info, location=moon🌙 | 2022-02-16 16:50:45.539423744 +0000 UTC | log line info 3 | 1645030245539423744 | +| level=info, location=moon🌙 | 2022-02-16 16:50:44.091700992 +0000 UTC | log line info 4 | 1645030244091700992 | ++------------------------------+-----------------------------------------+------------------+---------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////oAQAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAANACAAADAAAATAAAACgAAAAEAAAA9Pv//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAAU/P//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAADT8//8IAAAAaAIAAFwCAAB7ImN1c3RvbSI6eyJzdGF0cyI6eyJpbmdlc3RlciI6eyJjb21wcmVzc2VkQnl0ZXMiOjAsImRlY29tcHJlc3NlZEJ5dGVzIjowLCJkZWNvbXByZXNzZWRMaW5lcyI6MCwiaGVhZENodW5rQnl0ZXMiOjAsImhlYWRDaHVua0xpbmVzIjowLCJ0b3RhbEJhdGNoZXMiOjAsInRvdGFsQ2h1bmtzTWF0Y2hlZCI6MCwidG90YWxEdXBsaWNhdGVzIjowLCJ0b3RhbExpbmVzU2VudCI6MCwidG90YWxSZWFjaGVkIjowfSwic3RvcmUiOnsiY2h1bmtzRG93bmxvYWRUaW1lIjowLjAwMDM5MDk1OCwiY29tcHJlc3NlZEJ5dGVzIjozMTQzMiwiZGVjb21wcmVzc2VkQnl0ZXMiOjc3NzIsImRlY29tcHJlc3NlZExpbmVzIjo1NSwiaGVhZENodW5rQnl0ZXMiOjAsImhlYWRDaHVua0xpbmVzIjowLCJ0b3RhbENodW5rc0Rvd25sb2FkZWQiOjIsInRvdGFsQ2h1bmtzUmVmIjoyLCJ0b3RhbER1cGxpY2F0ZXMiOjB9LCJzdW1tYXJ5Ijp7ImJ5dGVzUHJvY2Vzc2VkUGVyU2Vjb25kIjozNTA3MDIyLCJleGVjVGltZSI6MC4wMDIyMTYxMjUsImxpbmVzUHJvY2Vzc2VkUGVyU2Vjb25kIjoyNDgxOCwidG90YWxCeXRlc1Byb2Nlc3NlZCI6Nzc3MiwidG90YWxMaW5lc1Byb2Nlc3NlZCI6NTV9fX19AAAAAAQAAABtZXRhAAAAAAQAAAAsAQAAtAAAAFgAAAAEAAAA9v7//xQAAAA4AAAAOAAAAAAAAAU0AAAAAQAAAAQAAADk/v//CAAAAAwAAAACAAAAVFMAAAQAAABuYW1lAAAAAAAAAADU/v//AgAAAFRTAABG////FAAAADwAAAA8AAAAAAAABTgAAAABAAAABAAAADT///8IAAAAEAAAAAQAAABMaW5lAAAAAAQAAABuYW1lAAAAAAAAAAAo////BAAAAExpbmUAAAAAnv///xQAAAA8AAAARAAAAAAAAApEAAAAAQAAAAQAAACM////CAAAABAAAAAEAAAAVGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAABUaW1lAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEgAAABMAAAAAAAABUgAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAUAAAACAAAAF9fbGFiZWxzAAAAAAQAAABuYW1lAAAAAAAAAAAEAAQABAAAAAgAAABfX2xhYmVscwAAAAAAAAAA/////0gBAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAAAYAgAAAAAAABQAAAAAAAADBAAKABgADAAIAAQACgAAABQAAADIAAAABgAAAAAAAAAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAAAAAAAAAIAAAAAAAAACvAAAAAAAAANAAAAAAAAAAAAAAAAAAAADQAAAAAAAAADAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAHAAAAAAAAAAgAQAAAAAAAFsAAAAAAAAAgAEAAAAAAAAAAAAAAAAAAIABAAAAAAAAHAAAAAAAAACgAQAAAAAAAHIAAAAAAAAAAAAAAAQAAAAGAAAAAAAAAAAAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAAAAAAAAAAAAAAAAB4AAAA7AAAAWAAAAHUAAACSAAAArwAAAAAAAABsZXZlbD1lcnJvciwgbG9jYXRpb249bW9vbvCfjJlsZXZlbD1pbmZvLCBsb2NhdGlvbj1tb29u8J+MmWxldmVsPWluZm8sIGxvY2F0aW9uPW1vb27wn4yZbGV2ZWw9aW5mbywgbG9jYXRpb249bW9vbvCfjJlsZXZlbD1pbmZvLCBsb2NhdGlvbj1tb29u8J+MmWxldmVsPWluZm8sIGxvY2F0aW9uPW1vb27wn4yZAAAUuLpKUtQWAHrcPktS1BYAJCYSS1LUFgAkJhJLUtQWAKYm5kpS1BYAJ9yPSlLUFgAAAAAQAAAAHwAAAC4AAAA9AAAATAAAAFsAAAAAAAAAbG9nIGxpbmUgZXJyb3IgMWxvZyBsaW5lIGluZm8gMWxvZyBsaW5lIGluZm8gMmxvZyBsaW5lIGluZm8gMmxvZyBsaW5lIGluZm8gM2xvZyBsaW5lIGluZm8gNAAAAAAAAAAAABMAAAAmAAAAOQAAAEwAAABfAAAAcgAAAAAAAAAxNjQ1MDMwMjQ0ODEwNzU3MTIwMTY0NTAzMDI0NzAyNzczNTA0MDE2NDUwMzAyNDYyNzc1ODc5NjgxNjQ1MDMwMjQ2Mjc3NTg3OTY4MTY0NTAzMDI0NTUzOTQyMzc0NDE2NDUwMzAyNDQwOTE3MDA5OTIAAAAAAAAQAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAOAAAAAAABAABAAAAsAQAAAAAAABQAQAAAAAAABgCAAAAAAAAAAAAAAAAAAAAAAoADAAAAAgABAAKAAAACAAAANACAAADAAAATAAAACgAAAAEAAAA9Pv//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAAU/P//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAADT8//8IAAAAaAIAAFwCAAB7ImN1c3RvbSI6eyJzdGF0cyI6eyJpbmdlc3RlciI6eyJjb21wcmVzc2VkQnl0ZXMiOjAsImRlY29tcHJlc3NlZEJ5dGVzIjowLCJkZWNvbXByZXNzZWRMaW5lcyI6MCwiaGVhZENodW5rQnl0ZXMiOjAsImhlYWRDaHVua0xpbmVzIjowLCJ0b3RhbEJhdGNoZXMiOjAsInRvdGFsQ2h1bmtzTWF0Y2hlZCI6MCwidG90YWxEdXBsaWNhdGVzIjowLCJ0b3RhbExpbmVzU2VudCI6MCwidG90YWxSZWFjaGVkIjowfSwic3RvcmUiOnsiY2h1bmtzRG93bmxvYWRUaW1lIjowLjAwMDM5MDk1OCwiY29tcHJlc3NlZEJ5dGVzIjozMTQzMiwiZGVjb21wcmVzc2VkQnl0ZXMiOjc3NzIsImRlY29tcHJlc3NlZExpbmVzIjo1NSwiaGVhZENodW5rQnl0ZXMiOjAsImhlYWRDaHVua0xpbmVzIjowLCJ0b3RhbENodW5rc0Rvd25sb2FkZWQiOjIsInRvdGFsQ2h1bmtzUmVmIjoyLCJ0b3RhbER1cGxpY2F0ZXMiOjB9LCJzdW1tYXJ5Ijp7ImJ5dGVzUHJvY2Vzc2VkUGVyU2Vjb25kIjozNTA3MDIyLCJleGVjVGltZSI6MC4wMDIyMTYxMjUsImxpbmVzUHJvY2Vzc2VkUGVyU2Vjb25kIjoyNDgxOCwidG90YWxCeXRlc1Byb2Nlc3NlZCI6Nzc3MiwidG90YWxMaW5lc1Byb2Nlc3NlZCI6NTV9fX19AAAAAAQAAABtZXRhAAAAAAQAAAAsAQAAtAAAAFgAAAAEAAAA9v7//xQAAAA4AAAAOAAAAAAAAAU0AAAAAQAAAAQAAADk/v//CAAAAAwAAAACAAAAVFMAAAQAAABuYW1lAAAAAAAAAADU/v//AgAAAFRTAABG////FAAAADwAAAA8AAAAAAAABTgAAAABAAAABAAAADT///8IAAAAEAAAAAQAAABMaW5lAAAAAAQAAABuYW1lAAAAAAAAAAAo////BAAAAExpbmUAAAAAnv///xQAAAA8AAAARAAAAAAAAApEAAAAAQAAAAQAAACM////CAAAABAAAAAEAAAAVGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAABUaW1lAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEgAAABMAAAAAAAABUgAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAUAAAACAAAAF9fbGFiZWxzAAAAAAQAAABuYW1lAAAAAAAAAAAEAAQABAAAAAgAAABfX2xhYmVscwAAAADIBAAAQVJST1cx diff --git a/pkg/util/converter/testdata/loki-streams-a.json b/pkg/util/converter/testdata/loki-streams-a.json new file mode 100644 index 00000000000..7531b4dd656 --- /dev/null +++ b/pkg/util/converter/testdata/loki-streams-a.json @@ -0,0 +1,81 @@ +{ + "status": "success", + "data": { + "resultType": "streams", + "result": [ + { + "stream": { + "level": "error", + "location": "moon🌙" + }, + "values": [ + [ + "1645030244810757120", + "log line error 1" + ] + ] + }, + { + "stream": { + "level": "info", + "location": "moon🌙" + }, + "values": [ + [ + "1645030247027735040", + "log line info 1" + ], + [ + "1645030246277587968", + "log line info 2" + ], + [ + "1645030246277587968", + "log line info 2" + ], + [ + "1645030245539423744", + "log line info 3" + ], + [ + "1645030244091700992", + "log line info 4" + ] + ] + } + ], + "stats": { + "summary": { + "bytesProcessedPerSecond": 3507022, + "linesProcessedPerSecond": 24818, + "totalBytesProcessed": 7772, + "totalLinesProcessed": 55, + "execTime": 0.002216125 + }, + "store": { + "totalChunksRef": 2, + "totalChunksDownloaded": 2, + "chunksDownloadTime": 0.000390958, + "headChunkBytes": 0, + "headChunkLines": 0, + "decompressedBytes": 7772, + "decompressedLines": 55, + "compressedBytes": 31432, + "totalDuplicates": 0 + }, + "ingester": { + "totalReached": 0, + "totalChunksMatched": 0, + "totalBatches": 0, + "totalLinesSent": 0, + "headChunkBytes": 0, + "headChunkLines": 0, + "decompressedBytes": 0, + "decompressedLines": 0, + "compressedBytes": 0, + "totalDuplicates": 0 + } + } + } + } + \ No newline at end of file diff --git a/pkg/util/converter/testdata/loki-streams-b-frame.json b/pkg/util/converter/testdata/loki-streams-b-frame.json new file mode 100644 index 00000000000..28ef33d2768 --- /dev/null +++ b/pkg/util/converter/testdata/loki-streams-b-frame.json @@ -0,0 +1,90 @@ +{ + "frames": [ + { + "schema": { + "meta": { + "custom": { + "stats": { + "summary": { + "execTime": 0.002216125, + "bytesProcessedPerSecond": 3507022, + "linesProcessedPerSecond": 24818, + "totalBytesProcessed": 7772, + "totalLinesProcessed": 55 + }, + "store": { + "headChunkBytes": 0, + "decompressedLines": 55, + "compressedBytes": 31432, + "totalDuplicates": 0, + "totalChunksDownloaded": 2, + "chunksDownloadTime": 0.000390958, + "headChunkLines": 0, + "decompressedBytes": 7772, + "totalChunksRef": 2 + }, + "ingester": { + "headChunkBytes": 0, + "decompressedBytes": 0, + "totalBatches": 0, + "totalLinesSent": 0, + "headChunkLines": 0, + "decompressedLines": 0, + "compressedBytes": 0, + "totalDuplicates": 0, + "totalReached": 0, + "totalChunksMatched": 0 + } + } + } + }, + "fields": [ + { + "name": "__labels", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "Line", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "TS", + "type": "string", + "typeInfo": { + "frame": "string" + } + } + ] + }, + "data": { + "values": [ + [ + "level=error, location=moon","level=info, location=moon","level=info, location=moon","level=info, location=moon","level=info, location=moon" + ], + [ + 1645030244810,1645030247027,1645030246277,1645030245539,1645030244091 + ], + [ + "log line error 1","log line info 1","log line info 2","log line info 3","log line info 4" + ], + [ + "1645030244810757120","1645030247027735040","1645030246277587968","1645030245539423744","1645030244091700992" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/util/converter/testdata/loki-streams-b-golden.txt b/pkg/util/converter/testdata/loki-streams-b-golden.txt new file mode 100644 index 00000000000..c5291a2e1a5 --- /dev/null +++ b/pkg/util/converter/testdata/loki-streams-b-golden.txt @@ -0,0 +1,55 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] { + "custom": { + "stats": { + "ingester": { + "compressedBytes": 0, + "decompressedBytes": 0, + "decompressedLines": 0, + "headChunkBytes": 0, + "headChunkLines": 0, + "totalBatches": 0, + "totalChunksMatched": 0, + "totalDuplicates": 0, + "totalLinesSent": 0, + "totalReached": 0 + }, + "store": { + "chunksDownloadTime": 0.000390958, + "compressedBytes": 31432, + "decompressedBytes": 7772, + "decompressedLines": 55, + "headChunkBytes": 0, + "headChunkLines": 0, + "totalChunksDownloaded": 2, + "totalChunksRef": 2, + "totalDuplicates": 0 + }, + "summary": { + "bytesProcessedPerSecond": 3507022, + "execTime": 0.002216125, + "linesProcessedPerSecond": 24818, + "totalBytesProcessed": 7772, + "totalLinesProcessed": 55 + } + } + } +} +Name: +Dimensions: 4 Fields by 5 Rows ++----------------------------+-----------------------------------------+------------------+---------------------+ +| Name: __labels | Name: Time | Name: Line | Name: TS | +| Labels: | Labels: | Labels: | Labels: | +| Type: []string | Type: []time.Time | Type: []string | Type: []string | ++----------------------------+-----------------------------------------+------------------+---------------------+ +| level=error, location=moon | 2022-02-16 16:50:44.81075712 +0000 UTC | log line error 1 | 1645030244810757120 | +| level=info, location=moon | 2022-02-16 16:50:47.02773504 +0000 UTC | log line info 1 | 1645030247027735040 | +| level=info, location=moon | 2022-02-16 16:50:46.277587968 +0000 UTC | log line info 2 | 1645030246277587968 | +| level=info, location=moon | 2022-02-16 16:50:45.539423744 +0000 UTC | log line info 3 | 1645030245539423744 | +| level=info, location=moon | 2022-02-16 16:50:44.091700992 +0000 UTC | log line info 4 | 1645030244091700992 | ++----------------------------+-----------------------------------------+------------------+---------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////oAQAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAANACAAADAAAATAAAACgAAAAEAAAA9Pv//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAAU/P//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAADT8//8IAAAAaAIAAFwCAAB7ImN1c3RvbSI6eyJzdGF0cyI6eyJpbmdlc3RlciI6eyJjb21wcmVzc2VkQnl0ZXMiOjAsImRlY29tcHJlc3NlZEJ5dGVzIjowLCJkZWNvbXByZXNzZWRMaW5lcyI6MCwiaGVhZENodW5rQnl0ZXMiOjAsImhlYWRDaHVua0xpbmVzIjowLCJ0b3RhbEJhdGNoZXMiOjAsInRvdGFsQ2h1bmtzTWF0Y2hlZCI6MCwidG90YWxEdXBsaWNhdGVzIjowLCJ0b3RhbExpbmVzU2VudCI6MCwidG90YWxSZWFjaGVkIjowfSwic3RvcmUiOnsiY2h1bmtzRG93bmxvYWRUaW1lIjowLjAwMDM5MDk1OCwiY29tcHJlc3NlZEJ5dGVzIjozMTQzMiwiZGVjb21wcmVzc2VkQnl0ZXMiOjc3NzIsImRlY29tcHJlc3NlZExpbmVzIjo1NSwiaGVhZENodW5rQnl0ZXMiOjAsImhlYWRDaHVua0xpbmVzIjowLCJ0b3RhbENodW5rc0Rvd25sb2FkZWQiOjIsInRvdGFsQ2h1bmtzUmVmIjoyLCJ0b3RhbER1cGxpY2F0ZXMiOjB9LCJzdW1tYXJ5Ijp7ImJ5dGVzUHJvY2Vzc2VkUGVyU2Vjb25kIjozNTA3MDIyLCJleGVjVGltZSI6MC4wMDIyMTYxMjUsImxpbmVzUHJvY2Vzc2VkUGVyU2Vjb25kIjoyNDgxOCwidG90YWxCeXRlc1Byb2Nlc3NlZCI6Nzc3MiwidG90YWxMaW5lc1Byb2Nlc3NlZCI6NTV9fX19AAAAAAQAAABtZXRhAAAAAAQAAAAsAQAAtAAAAFgAAAAEAAAA9v7//xQAAAA4AAAAOAAAAAAAAAU0AAAAAQAAAAQAAADk/v//CAAAAAwAAAACAAAAVFMAAAQAAABuYW1lAAAAAAAAAADU/v//AgAAAFRTAABG////FAAAADwAAAA8AAAAAAAABTgAAAABAAAABAAAADT///8IAAAAEAAAAAQAAABMaW5lAAAAAAQAAABuYW1lAAAAAAAAAAAo////BAAAAExpbmUAAAAAnv///xQAAAA8AAAARAAAAAAAAApEAAAAAQAAAAQAAACM////CAAAABAAAAAEAAAAVGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAABUaW1lAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEgAAABMAAAAAAAABUgAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAUAAAACAAAAF9fbGFiZWxzAAAAAAQAAABuYW1lAAAAAAAAAAAEAAQABAAAAAgAAABfX2xhYmVscwAAAAAAAAAA/////0gBAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAACgAQAAAAAAABQAAAAAAAADBAAKABgADAAIAAQACgAAABQAAADIAAAABQAAAAAAAAAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgAAAAAAAAAGAAAAAAAAAB+AAAAAAAAAJgAAAAAAAAAAAAAAAAAAACYAAAAAAAAACgAAAAAAAAAwAAAAAAAAAAAAAAAAAAAAMAAAAAAAAAAGAAAAAAAAADYAAAAAAAAAEwAAAAAAAAAKAEAAAAAAAAAAAAAAAAAACgBAAAAAAAAGAAAAAAAAABAAQAAAAAAAF8AAAAAAAAAAAAAAAQAAAAFAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAABoAAAAzAAAATAAAAGUAAAB+AAAAbGV2ZWw9ZXJyb3IsIGxvY2F0aW9uPW1vb25sZXZlbD1pbmZvLCBsb2NhdGlvbj1tb29ubGV2ZWw9aW5mbywgbG9jYXRpb249bW9vbmxldmVsPWluZm8sIGxvY2F0aW9uPW1vb25sZXZlbD1pbmZvLCBsb2NhdGlvbj1tb29uAAAAFLi6SlLUFgB63D5LUtQWACQmEktS1BYApibmSlLUFgAn3I9KUtQWAAAAABAAAAAfAAAALgAAAD0AAABMAAAAbG9nIGxpbmUgZXJyb3IgMWxvZyBsaW5lIGluZm8gMWxvZyBsaW5lIGluZm8gMmxvZyBsaW5lIGluZm8gM2xvZyBsaW5lIGluZm8gNAAAAAAAAAAAEwAAACYAAAA5AAAATAAAAF8AAAAxNjQ1MDMwMjQ0ODEwNzU3MTIwMTY0NTAzMDI0NzAyNzczNTA0MDE2NDUwMzAyNDYyNzc1ODc5NjgxNjQ1MDMwMjQ1NTM5NDIzNzQ0MTY0NTAzMDI0NDA5MTcwMDk5MgAQAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAOAAAAAAABAABAAAAsAQAAAAAAABQAQAAAAAAAKABAAAAAAAAAAAAAAAAAAAAAAoADAAAAAgABAAKAAAACAAAANACAAADAAAATAAAACgAAAAEAAAA9Pv//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAAU/P//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAADT8//8IAAAAaAIAAFwCAAB7ImN1c3RvbSI6eyJzdGF0cyI6eyJpbmdlc3RlciI6eyJjb21wcmVzc2VkQnl0ZXMiOjAsImRlY29tcHJlc3NlZEJ5dGVzIjowLCJkZWNvbXByZXNzZWRMaW5lcyI6MCwiaGVhZENodW5rQnl0ZXMiOjAsImhlYWRDaHVua0xpbmVzIjowLCJ0b3RhbEJhdGNoZXMiOjAsInRvdGFsQ2h1bmtzTWF0Y2hlZCI6MCwidG90YWxEdXBsaWNhdGVzIjowLCJ0b3RhbExpbmVzU2VudCI6MCwidG90YWxSZWFjaGVkIjowfSwic3RvcmUiOnsiY2h1bmtzRG93bmxvYWRUaW1lIjowLjAwMDM5MDk1OCwiY29tcHJlc3NlZEJ5dGVzIjozMTQzMiwiZGVjb21wcmVzc2VkQnl0ZXMiOjc3NzIsImRlY29tcHJlc3NlZExpbmVzIjo1NSwiaGVhZENodW5rQnl0ZXMiOjAsImhlYWRDaHVua0xpbmVzIjowLCJ0b3RhbENodW5rc0Rvd25sb2FkZWQiOjIsInRvdGFsQ2h1bmtzUmVmIjoyLCJ0b3RhbER1cGxpY2F0ZXMiOjB9LCJzdW1tYXJ5Ijp7ImJ5dGVzUHJvY2Vzc2VkUGVyU2Vjb25kIjozNTA3MDIyLCJleGVjVGltZSI6MC4wMDIyMTYxMjUsImxpbmVzUHJvY2Vzc2VkUGVyU2Vjb25kIjoyNDgxOCwidG90YWxCeXRlc1Byb2Nlc3NlZCI6Nzc3MiwidG90YWxMaW5lc1Byb2Nlc3NlZCI6NTV9fX19AAAAAAQAAABtZXRhAAAAAAQAAAAsAQAAtAAAAFgAAAAEAAAA9v7//xQAAAA4AAAAOAAAAAAAAAU0AAAAAQAAAAQAAADk/v//CAAAAAwAAAACAAAAVFMAAAQAAABuYW1lAAAAAAAAAADU/v//AgAAAFRTAABG////FAAAADwAAAA8AAAAAAAABTgAAAABAAAABAAAADT///8IAAAAEAAAAAQAAABMaW5lAAAAAAQAAABuYW1lAAAAAAAAAAAo////BAAAAExpbmUAAAAAnv///xQAAAA8AAAARAAAAAAAAApEAAAAAQAAAAQAAACM////CAAAABAAAAAEAAAAVGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAABUaW1lAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEgAAABMAAAAAAAABUgAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAUAAAACAAAAF9fbGFiZWxzAAAAAAQAAABuYW1lAAAAAAAAAAAEAAQABAAAAAgAAABfX2xhYmVscwAAAADIBAAAQVJST1cx diff --git a/pkg/util/converter/testdata/loki-streams-b.json b/pkg/util/converter/testdata/loki-streams-b.json new file mode 100644 index 00000000000..87fb82a3952 --- /dev/null +++ b/pkg/util/converter/testdata/loki-streams-b.json @@ -0,0 +1,76 @@ +{ + "status": "success", + "data": { + "resultType": "streams", + "result": [ + { + "stream": { + "level": "error", + "location": "moon" + }, + "values": [ + [ + "1645030244810757120", + "log line error 1" + ] + ] + }, + { + "stream": { + "level": "info", + "location": "moon" + }, + "values": [ + [ + "1645030247027735040", + "log line info 1" + ], + [ + "1645030246277587968", + "log line info 2" + ], + [ + "1645030245539423744", + "log line info 3" + ], + [ + "1645030244091700992", + "log line info 4" + ] + ] + } + ], + "stats": { + "summary": { + "bytesProcessedPerSecond": 3507022, + "linesProcessedPerSecond": 24818, + "totalBytesProcessed": 7772, + "totalLinesProcessed": 55, + "execTime": 0.002216125 + }, + "store": { + "totalChunksRef": 2, + "totalChunksDownloaded": 2, + "chunksDownloadTime": 0.000390958, + "headChunkBytes": 0, + "headChunkLines": 0, + "decompressedBytes": 7772, + "decompressedLines": 55, + "compressedBytes": 31432, + "totalDuplicates": 0 + }, + "ingester": { + "totalReached": 0, + "totalChunksMatched": 0, + "totalBatches": 0, + "totalLinesSent": 0, + "headChunkBytes": 0, + "headChunkLines": 0, + "decompressedBytes": 0, + "decompressedLines": 0, + "compressedBytes": 0, + "totalDuplicates": 0 + } + } + } +} diff --git a/pkg/util/converter/testdata/prom-exemplars-frame.json b/pkg/util/converter/testdata/prom-exemplars-frame.json new file mode 100644 index 00000000000..9f1a87297e8 --- /dev/null +++ b/pkg/util/converter/testdata/prom-exemplars-frame.json @@ -0,0 +1,102 @@ +{ + "frames": [ + { + "schema": { + "fields": [ + { + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "test_exemplar_metric_total", + "type": "number", + "typeInfo": { + "frame": "float64" + }, + "labels": { + "instance": "localhost:8090", + "job": "prometheus", + "service": "bar" + } + }, + { + "name": "traceID", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "a", + "type": "string", + "typeInfo": { + "frame": "string" + } + } + ] + }, + "data": { + "values": [ + [ + 1600096945479 + ], + [ + 6 + ], + [ + "EpTxMJ40fUus7aGY" + ], + [ + "not in next" + ] + ] + } + }, + { + "schema": { + "fields": [ + { + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "test_exemplar_metric_total", + "type": "number", + "typeInfo": { + "frame": "float64" + }, + "labels": { + "instance": "localhost:8090", + "job": "prometheus", + "service": "foo" + } + }, + { + "name": "traceID", + "type": "string", + "typeInfo": { + "frame": "string" + } + } + ] + }, + "data": { + "values": [ + [ + 1600096955479,1600096965489 + ], + [ + 19,20 + ], + [ + "Olp9XHlq763ccsfa","hCtjygkIHwAN9vs4" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/util/converter/testdata/prom-exemplars-golden.txt b/pkg/util/converter/testdata/prom-exemplars-golden.txt new file mode 100644 index 00000000000..2ed17bd815e --- /dev/null +++ b/pkg/util/converter/testdata/prom-exemplars-golden.txt @@ -0,0 +1,31 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] +Name: +Dimensions: 4 Fields by 1 Rows ++-----------------------------------+--------------------------------------------------------------+------------------+----------------+ +| Name: | Name: test_exemplar_metric_total | Name: traceID | Name: a | +| Labels: | Labels: instance=localhost:8090, job=prometheus, service=bar | Labels: | Labels: | +| Type: []time.Time | Type: []float64 | Type: []string | Type: []string | ++-----------------------------------+--------------------------------------------------------------+------------------+----------------+ +| 2020-09-14 15:22:25.479 +0000 UTC | 6 | EpTxMJ40fUus7aGY | not in next | ++-----------------------------------+--------------------------------------------------------------+------------------+----------------+ + + + +Frame[1] +Name: +Dimensions: 3 Fields by 2 Rows ++-----------------------------------+--------------------------------------------------------------+------------------+ +| Name: | Name: test_exemplar_metric_total | Name: traceID | +| Labels: | Labels: instance=localhost:8090, job=prometheus, service=foo | Labels: | +| Type: []time.Time | Type: []float64 | Type: []string | ++-----------------------------------+--------------------------------------------------------------+------------------+ +| 2020-09-14 15:22:35.479 +0000 UTC | 19 | Olp9XHlq763ccsfa | +| 2020-09-14 15:22:45.489 +0000 UTC | 20 | hCtjygkIHwAN9vs4 | ++-----------------------------------+--------------------------------------------------------------+------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////oAIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAFAAAAACAAAAKAAAAAQAAADk/f//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAAT+//8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAABAAAALgBAAC4AAAAWAAAAAQAAABq/v//FAAAADgAAAA4AAAAAAAABTQAAAABAAAABAAAAFj+//8IAAAADAAAAAEAAABhAAAABAAAAG5hbWUAAAAAAAAAAKz///8BAAAAYQAAALr+//8UAAAAPAAAAEAAAAAAAAAFPAAAAAEAAAAEAAAAqP7//wgAAAAQAAAABwAAAHRyYWNlSUQABAAAAG5hbWUAAAAAAAAAAAQABAAEAAAABwAAAHRyYWNlSUQAFv///xQAAAC0AAAAtAAAAAAAAAO0AAAAAgAAAEAAAAAEAAAACP///wgAAAAkAAAAGgAAAHRlc3RfZXhlbXBsYXJfbWV0cmljX3RvdGFsAAAEAAAAbmFtZQAAAABA////CAAAAEwAAABAAAAAeyJpbnN0YW5jZSI6ImxvY2FsaG9zdDo4MDkwIiwiam9iIjoicHJvbWV0aGV1cyIsInNlcnZpY2UiOiJiYXIifQAAAAAGAAAAbGFiZWxzAAAAAAAAdv///wAAAgAaAAAAdGVzdF9leGVtcGxhcl9tZXRyaWNfdG90YWwAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABAAAAASAAAAAAAAApIAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAAAAAAAAAAAP////84AQAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAQAAAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAuAAAAAEAAAAAAAAAAAAAAAoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAACAAAAAAAAAAYAAAAAAAAABAAAAAAAAAAKAAAAAAAAAAAAAAAAAAAACgAAAAAAAAACAAAAAAAAAAwAAAAAAAAAAsAAAAAAAAAAAAAAAQAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAwJ92ubGvNBYAAAAAAAAYQAAAAAAQAAAARXBUeE1KNDBmVXVzN2FHWQAAAAALAAAAbm90IGluIG5leHQAAAAAABAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA8AAAAAAAEAAEAAACwAgAAAAAAAEABAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoADAAAAAgABAAKAAAACAAAAFAAAAACAAAAKAAAAAQAAADk/f//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAAT+//8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAABAAAALgBAAC4AAAAWAAAAAQAAABq/v//FAAAADgAAAA4AAAAAAAABTQAAAABAAAABAAAAFj+//8IAAAADAAAAAEAAABhAAAABAAAAG5hbWUAAAAAAAAAAKz///8BAAAAYQAAALr+//8UAAAAPAAAAEAAAAAAAAAFPAAAAAEAAAAEAAAAqP7//wgAAAAQAAAABwAAAHRyYWNlSUQABAAAAG5hbWUAAAAAAAAAAAQABAAEAAAABwAAAHRyYWNlSUQAFv///xQAAAC0AAAAtAAAAAAAAAO0AAAAAgAAAEAAAAAEAAAACP///wgAAAAkAAAAGgAAAHRlc3RfZXhlbXBsYXJfbWV0cmljX3RvdGFsAAAEAAAAbmFtZQAAAABA////CAAAAEwAAABAAAAAeyJpbnN0YW5jZSI6ImxvY2FsaG9zdDo4MDkwIiwiam9iIjoicHJvbWV0aGV1cyIsInNlcnZpY2UiOiJiYXIifQAAAAAGAAAAbGFiZWxzAAAAAAAAdv///wAAAgAaAAAAdGVzdF9leGVtcGxhcl9tZXRyaWNfdG90YWwAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABAAAAASAAAAAAAAApIAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAAAAAAAAAAANACAABBUlJPVzE= +FRAME=QVJST1cxAAD/////UAIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAFAAAAACAAAAKAAAAAQAAAA4/v//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAFj+//8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAAwAAAGQBAABkAAAABAAAALr+//8UAAAAPAAAAEAAAAAAAAAFPAAAAAEAAAAEAAAAqP7//wgAAAAQAAAABwAAAHRyYWNlSUQABAAAAG5hbWUAAAAAAAAAAAQABAAEAAAABwAAAHRyYWNlSUQAFv///xQAAAC0AAAAtAAAAAAAAAO0AAAAAgAAAEAAAAAEAAAACP///wgAAAAkAAAAGgAAAHRlc3RfZXhlbXBsYXJfbWV0cmljX3RvdGFsAAAEAAAAbmFtZQAAAABA////CAAAAEwAAABAAAAAeyJpbnN0YW5jZSI6ImxvY2FsaG9zdDo4MDkwIiwiam9iIjoicHJvbWV0aGV1cyIsInNlcnZpY2UiOiJmb28ifQAAAAAGAAAAbGFiZWxzAAAAAAAAdv///wAAAgAaAAAAdGVzdF9leGVtcGxhcl9tZXRyaWNfdG90YWwAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABAAAAASAAAAAAAAApIAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAAAAAAAAAAAAAAAAD/////+AAAABQAAAAAAAAADAAWABQAEwAMAAQADAAAAFAAAAAAAAAAFAAAAAAAAAMEAAoAGAAMAAgABAAKAAAAFAAAAIgAAAACAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAwAAAAAAAAAMAAAAAAAAAAgAAAAAAAAAAAAAAADAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAwIOCDbSvNBZA/iZitq80FgAAAAAAADNAAAAAAAAANEAAAAAAEAAAACAAAAAAAAAAT2xwOVhIbHE3NjNjY3NmYWhDdGp5Z2tJSHdBTjl2czQQAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAOAAAAAAABAABAAAAYAIAAAAAAAAAAQAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAoADAAAAAgABAAKAAAACAAAAFAAAAACAAAAKAAAAAQAAAA4/v//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAFj+//8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAAwAAAGQBAABkAAAABAAAALr+//8UAAAAPAAAAEAAAAAAAAAFPAAAAAEAAAAEAAAAqP7//wgAAAAQAAAABwAAAHRyYWNlSUQABAAAAG5hbWUAAAAAAAAAAAQABAAEAAAABwAAAHRyYWNlSUQAFv///xQAAAC0AAAAtAAAAAAAAAO0AAAAAgAAAEAAAAAEAAAACP///wgAAAAkAAAAGgAAAHRlc3RfZXhlbXBsYXJfbWV0cmljX3RvdGFsAAAEAAAAbmFtZQAAAABA////CAAAAEwAAABAAAAAeyJpbnN0YW5jZSI6ImxvY2FsaG9zdDo4MDkwIiwiam9iIjoicHJvbWV0aGV1cyIsInNlcnZpY2UiOiJmb28ifQAAAAAGAAAAbGFiZWxzAAAAAAAAdv///wAAAgAaAAAAdGVzdF9leGVtcGxhcl9tZXRyaWNfdG90YWwAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABAAAAASAAAAAAAAApIAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAAAAAAAAAAAHgCAABBUlJPVzE= diff --git a/pkg/util/converter/testdata/prom-exemplars.json b/pkg/util/converter/testdata/prom-exemplars.json new file mode 100644 index 00000000000..e194e82b8a8 --- /dev/null +++ b/pkg/util/converter/testdata/prom-exemplars.json @@ -0,0 +1,47 @@ +{ + "status": "success", + "data": [ + { + "seriesLabels": { + "__name__": "test_exemplar_metric_total", + "instance": "localhost:8090", + "job": "prometheus", + "service": "bar" + }, + "exemplars": [ + { + "labels": { + "traceID": "EpTxMJ40fUus7aGY", + "a": "not in next" + }, + "value": "6", + "timestamp": 1600096945.479 + } + ] + }, + { + "seriesLabels": { + "__name__": "test_exemplar_metric_total", + "instance": "localhost:8090", + "job": "prometheus", + "service": "foo" + }, + "exemplars": [ + { + "labels": { + "traceID": "Olp9XHlq763ccsfa" + }, + "value": "19", + "timestamp": 1600096955.479 + }, + { + "labels": { + "traceID": "hCtjygkIHwAN9vs4" + }, + "value": "20", + "timestamp": 1600096965.489 + } + ] + } + ] +} diff --git a/pkg/util/converter/testdata/prom-labels-frame.json b/pkg/util/converter/testdata/prom-labels-frame.json new file mode 100644 index 00000000000..ce2ea92da99 --- /dev/null +++ b/pkg/util/converter/testdata/prom-labels-frame.json @@ -0,0 +1,24 @@ +{ + "frames": [ + { + "schema": { + "fields": [ + { + "name": "Value", + "type": "string", + "typeInfo": { + "frame": "string" + } + } + ] + }, + "data": { + "values": [ + [ + "__name__","call","code","config","dialer_name","endpoint","event","goversion","handler","instance","interval","job","le","listener_name","name","quantile","reason","role","scrape_job","slice","version" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/util/converter/testdata/prom-labels-golden.txt b/pkg/util/converter/testdata/prom-labels-golden.txt new file mode 100644 index 00000000000..bcff6d3e963 --- /dev/null +++ b/pkg/util/converter/testdata/prom-labels-golden.txt @@ -0,0 +1,25 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] +Name: +Dimensions: 1 Fields by 21 Rows ++----------------+ +| Name: Value | +| Labels: | +| Type: []string | ++----------------+ +| __name__ | +| call | +| code | +| config | +| dialer_name | +| endpoint | +| event | +| goversion | +| handler | +| ... | ++----------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////AAEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAFAAAAACAAAAKAAAAAQAAACE////CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAKT///8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAAQAAABgAAAAAABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEgAAAAAAAAFRAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAFAAAAVmFsdWUAAAAEAAAAbmFtZQAAAAAAAAAABAAEAAQAAAAFAAAAVmFsdWUAAAD/////mAAAABQAAAAAAAAADAAWABQAEwAMAAQADAAAAOgAAAAAAAAAFAAAAAAAAAMEAAoAGAAMAAgABAAKAAAAFAAAAEgAAAAVAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWAAAAAAAAABYAAAAAAAAAIwAAAAAAAAAAAAAAAEAAAAVAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAMAAAAEAAAABYAAAAhAAAAKQAAAC4AAAA3AAAAPgAAAEYAAABOAAAAUQAAAFMAAABgAAAAZAAAAGwAAAByAAAAdgAAAIAAAACFAAAAjAAAAF9fbmFtZV9fY2FsbGNvZGVjb25maWdkaWFsZXJfbmFtZWVuZHBvaW50ZXZlbnRnb3ZlcnNpb25oYW5kbGVyaW5zdGFuY2VpbnRlcnZhbGpvYmxlbGlzdGVuZXJfbmFtZW5hbWVxdWFudGlsZXJlYXNvbnJvbGVzY3JhcGVfam9ic2xpY2V2ZXJzaW9uAAAAABAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA8AAAAAAAEAAEAAAAQAQAAAAAAAKAAAAAAAAAA6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAoADAAAAAgABAAKAAAACAAAAFAAAAACAAAAKAAAAAQAAACE////CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAKT///8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAAQAAABgAAAAAABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEgAAAAAAAAFRAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAFAAAAVmFsdWUAAAAEAAAAbmFtZQAAAAAAAAAABAAEAAQAAAAFAAAAVmFsdWUAAAAwAQAAQVJST1cx diff --git a/pkg/util/converter/testdata/prom-labels.json b/pkg/util/converter/testdata/prom-labels.json new file mode 100644 index 00000000000..fd505b23d98 --- /dev/null +++ b/pkg/util/converter/testdata/prom-labels.json @@ -0,0 +1,26 @@ +{ + "status": "success", + "data": [ + "__name__", + "call", + "code", + "config", + "dialer_name", + "endpoint", + "event", + "goversion", + "handler", + "instance", + "interval", + "job", + "le", + "listener_name", + "name", + "quantile", + "reason", + "role", + "scrape_job", + "slice", + "version" + ] +} diff --git a/pkg/util/converter/testdata/prom-matrix-frame.json b/pkg/util/converter/testdata/prom-matrix-frame.json new file mode 100644 index 00000000000..af91613cb20 --- /dev/null +++ b/pkg/util/converter/testdata/prom-matrix-frame.json @@ -0,0 +1,78 @@ +{ + "frames": [ + { + "schema": { + "meta": { + "type": "timeseries-many" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "up", + "type": "number", + "typeInfo": { + "frame": "float64" + }, + "labels": { + "job": "prometheus", + "instance": "localhost:9090" + } + } + ] + }, + "data": { + "values": [ + [ + 1435781430781,1435781445781,1435781460781 + ], + [ + 1,1,1 + ] + ] + } + }, + { + "schema": { + "meta": { + "type": "timeseries-many" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "up", + "type": "number", + "typeInfo": { + "frame": "float64" + }, + "labels": { + "job": "node", + "instance": "localhost:9091" + } + } + ] + }, + "data": { + "values": [ + [ + 1435781430781,1435781445781,1435781460781 + ], + [ + 0,0,1 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/util/converter/testdata/prom-matrix-golden.txt b/pkg/util/converter/testdata/prom-matrix-golden.txt new file mode 100644 index 00000000000..83088450f9d --- /dev/null +++ b/pkg/util/converter/testdata/prom-matrix-golden.txt @@ -0,0 +1,38 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] { + "type": "timeseries-many" +} +Name: +Dimensions: 2 Fields by 3 Rows ++-----------------------------------+-------------------------------------------------+ +| Name: Time | Name: up | +| Labels: | Labels: instance=localhost:9090, job=prometheus | +| Type: []time.Time | Type: []float64 | ++-----------------------------------+-------------------------------------------------+ +| 2015-07-01 20:10:30.781 +0000 UTC | 1 | +| 2015-07-01 20:10:45.781 +0000 UTC | 1 | +| 2015-07-01 20:11:00.781 +0000 UTC | 1 | ++-----------------------------------+-------------------------------------------------+ + + + +Frame[1] { + "type": "timeseries-many" +} +Name: +Dimensions: 2 Fields by 3 Rows ++-----------------------------------+-------------------------------------------+ +| Name: Time | Name: up | +| Labels: | Labels: instance=localhost:9091, job=node | +| Type: []time.Time | Type: []float64 | ++-----------------------------------+-------------------------------------------+ +| 2015-07-01 20:10:30.781 +0000 UTC | 0 | +| 2015-07-01 20:10:45.781 +0000 UTC | 0 | +| 2015-07-01 20:11:00.781 +0000 UTC | 1 | ++-----------------------------------+-------------------------------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////8AEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAIwAAAADAAAATAAAACgAAAAEAAAAoP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADA/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAOD+//8IAAAAJAAAABoAAAB7InR5cGUiOiJ0aW1lc2VyaWVzLW1hbnkifQAABAAAAG1ldGEAAAAAAgAAAMQAAAAEAAAAVv///xQAAACMAAAAjAAAAAAAAAOMAAAAAgAAACgAAAAEAAAASP///wgAAAAMAAAAAgAAAHVwAAAEAAAAbmFtZQAAAABo////CAAAADwAAAAwAAAAeyJpbnN0YW5jZSI6ImxvY2FsaG9zdDo5MDkwIiwiam9iIjoicHJvbWV0aGV1cyJ9AAAAAAYAAABsYWJlbHMAAAAAAACK////AAACAAIAAAB1cAAAAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAFRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAVGltZQAAAAD/////uAAAABQAAAAAAAAADAAWABQAEwAMAAQADAAAADAAAAAAAAAAFAAAAAAAAAMEAAoAGAAMAAgABAAKAAAAFAAAAFgAAAADAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAYAAAAAAAAAAAAAAACAAAAAwAAAAAAAAAAAAAAAAAAAAMAAAAAAAAAAAAAAAAAAABAuc2smuvsE0CP3yqe6+wTQGXxqKHr7BMAAAAAAADwPwAAAAAAAPA/AAAAAAAA8D8QAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAPAAAAAAABAABAAAAAAIAAAAAAADAAAAAAAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAACMAAAAAwAAAEwAAAAoAAAABAAAAKD+//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAAwP7//wgAAAAMAAAAAAAAAAAAAAAEAAAAbmFtZQAAAADg/v//CAAAACQAAAAaAAAAeyJ0eXBlIjoidGltZXNlcmllcy1tYW55In0AAAQAAABtZXRhAAAAAAIAAADEAAAABAAAAFb///8UAAAAjAAAAIwAAAAAAAADjAAAAAIAAAAoAAAABAAAAEj///8IAAAADAAAAAIAAAB1cAAABAAAAG5hbWUAAAAAaP///wgAAAA8AAAAMAAAAHsiaW5zdGFuY2UiOiJsb2NhbGhvc3Q6OTA5MCIsImpvYiI6InByb21ldGhldXMifQAAAAAGAAAAbGFiZWxzAAAAAAAAiv///wAAAgACAAAAdXAAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAAAApMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAABUaW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAFRpbWUAAAAAIAIAAEFSUk9XMQ== +FRAME=QVJST1cxAAD/////6AEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAIwAAAADAAAATAAAACgAAAAEAAAAqP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADI/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAOj+//8IAAAAJAAAABoAAAB7InR5cGUiOiJ0aW1lc2VyaWVzLW1hbnkifQAABAAAAG1ldGEAAAAAAgAAALwAAAAEAAAAXv///xQAAACEAAAAhAAAAAAAAAOEAAAAAgAAACgAAAAEAAAAUP///wgAAAAMAAAAAgAAAHVwAAAEAAAAbmFtZQAAAABw////CAAAADQAAAAqAAAAeyJpbnN0YW5jZSI6ImxvY2FsaG9zdDo5MDkxIiwiam9iIjoibm9kZSJ9AAAGAAAAbGFiZWxzAAAAAAAAiv///wAAAgACAAAAdXAAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAAAApMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAABUaW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAFRpbWUAAAAA/////7gAAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAAAwAAAAAAAAABQAAAAAAAADBAAKABgADAAIAAQACgAAABQAAABYAAAAAwAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgAAAAAAAAAGAAAAAAAAAAAAAAAAAAAABgAAAAAAAAAGAAAAAAAAAAAAAAAAgAAAAMAAAAAAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAAAAAAQLnNrJrr7BNAj98qnuvsE0Bl8aih6+wTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPA/EAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADwAAAAAAAQAAQAAAPgBAAAAAAAAwAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAjAAAAAMAAABMAAAAKAAAAAQAAACo/v//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAMj+//8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAA6P7//wgAAAAkAAAAGgAAAHsidHlwZSI6InRpbWVzZXJpZXMtbWFueSJ9AAAEAAAAbWV0YQAAAAACAAAAvAAAAAQAAABe////FAAAAIQAAACEAAAAAAAAA4QAAAACAAAAKAAAAAQAAABQ////CAAAAAwAAAACAAAAdXAAAAQAAABuYW1lAAAAAHD///8IAAAANAAAACoAAAB7Imluc3RhbmNlIjoibG9jYWxob3N0OjkwOTEiLCJqb2IiOiJub2RlIn0AAAYAAABsYWJlbHMAAAAAAACK////AAACAAIAAAB1cAAAAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAFRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAVGltZQAAAAAYAgAAQVJST1cx diff --git a/pkg/util/converter/testdata/prom-matrix-with-nans-frame.json b/pkg/util/converter/testdata/prom-matrix-with-nans-frame.json new file mode 100644 index 00000000000..93ba618a21c --- /dev/null +++ b/pkg/util/converter/testdata/prom-matrix-with-nans-frame.json @@ -0,0 +1,55 @@ +{ + "frames": [ + { + "schema": { + "meta": { + "type": "timeseries-many" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "Value", + "type": "number", + "typeInfo": { + "frame": "float64" + }, + "labels": { + "job": "prometheus", + "handler": "/api/v1/query_range" + } + } + ] + }, + "data": { + "values": [ + [ + 1641889530000,1641889531000,1641889532000 + ], + [ + null,null,null + ] + ], + "entities": [ + null, + { + "NaN": [ + 1 + ], + "Inf": [ + 0 + ], + "NegInf": [ + 2 + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/util/converter/testdata/prom-matrix-with-nans-golden.txt b/pkg/util/converter/testdata/prom-matrix-with-nans-golden.txt new file mode 100644 index 00000000000..7aa69989cf2 --- /dev/null +++ b/pkg/util/converter/testdata/prom-matrix-with-nans-golden.txt @@ -0,0 +1,20 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] { + "type": "timeseries-many" +} +Name: +Dimensions: 2 Fields by 3 Rows ++-------------------------------+-----------------------------------------------------+ +| Name: Time | Name: Value | +| Labels: | Labels: handler=/api/v1/query_range, job=prometheus | +| Type: []time.Time | Type: []float64 | ++-------------------------------+-----------------------------------------------------+ +| 2022-01-11 08:25:30 +0000 UTC | +Inf | +| 2022-01-11 08:25:31 +0000 UTC | NaN | +| 2022-01-11 08:25:32 +0000 UTC | -Inf | ++-------------------------------+-----------------------------------------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////+AEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAIwAAAADAAAATAAAACgAAAAEAAAAmP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAC4/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAANj+//8IAAAAJAAAABoAAAB7InR5cGUiOiJ0aW1lc2VyaWVzLW1hbnkifQAABAAAAG1ldGEAAAAAAgAAAMwAAAAEAAAATv///xQAAACUAAAAlAAAAAAAAAOUAAAAAgAAACwAAAAEAAAAQP///wgAAAAQAAAABQAAAFZhbHVlAAAABAAAAG5hbWUAAAAAZP///wgAAABAAAAANAAAAHsiaGFuZGxlciI6Ii9hcGkvdjEvcXVlcnlfcmFuZ2UiLCJqb2IiOiJwcm9tZXRoZXVzIn0AAAAABgAAAGxhYmVscwAAAAAAAIr///8AAAIABQAAAFZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAVGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAABUaW1lAAAAAP////+4AAAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAMAAAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAWAAAAAMAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAYAAAAAAAAABgAAAAAAAAAAAAAAAIAAAADAAAAAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAABEFRTUKckWAA6wT9QpyRYA2EqL1CnJFgAAAAAAAPB/AQAAAAAA+H8AAAAAAADw/xAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA8AAAAAAAEAAEAAAAIAgAAAAAAAMAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoADAAAAAgABAAKAAAACAAAAIwAAAADAAAATAAAACgAAAAEAAAAmP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAC4/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAANj+//8IAAAAJAAAABoAAAB7InR5cGUiOiJ0aW1lc2VyaWVzLW1hbnkifQAABAAAAG1ldGEAAAAAAgAAAMwAAAAEAAAATv///xQAAACUAAAAlAAAAAAAAAOUAAAAAgAAACwAAAAEAAAAQP///wgAAAAQAAAABQAAAFZhbHVlAAAABAAAAG5hbWUAAAAAZP///wgAAABAAAAANAAAAHsiaGFuZGxlciI6Ii9hcGkvdjEvcXVlcnlfcmFuZ2UiLCJqb2IiOiJwcm9tZXRoZXVzIn0AAAAABgAAAGxhYmVscwAAAAAAAIr///8AAAIABQAAAFZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAVGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAABUaW1lAAAAACgCAABBUlJPVzE= diff --git a/pkg/util/converter/testdata/prom-matrix-with-nans.json b/pkg/util/converter/testdata/prom-matrix-with-nans.json new file mode 100644 index 00000000000..c2c9a609a37 --- /dev/null +++ b/pkg/util/converter/testdata/prom-matrix-with-nans.json @@ -0,0 +1,16 @@ +{ + "status": "success", + "data": { + "resultType": "matrix", + "result": [ + { + "metric": { "handler": "/api/v1/query_range", "job": "prometheus" }, + "values": [ + [1641889530, "+Inf"], + [1641889531, "NaN"], + [1641889532, "-Inf"] + ] + } + ] + } + } \ No newline at end of file diff --git a/pkg/util/converter/testdata/prom-matrix.json b/pkg/util/converter/testdata/prom-matrix.json new file mode 100644 index 00000000000..368eaa1f19f --- /dev/null +++ b/pkg/util/converter/testdata/prom-matrix.json @@ -0,0 +1,33 @@ +{ + "status" : "success", + "data" : { + "resultType" : "matrix", + "result" : [ + { + "metric" : { + "__name__" : "up", + "job" : "prometheus", + "instance" : "localhost:9090" + }, + "values" : [ + [ 1435781430.781, "1" ], + [ 1435781445.781, "1" ], + [ 1435781460.781, "1" ] + ] + }, + { + "metric" : { + "__name__" : "up", + "job" : "node", + "instance" : "localhost:9091" + }, + "values" : [ + [ 1435781430.781, "0" ], + [ 1435781445.781, "0" ], + [ 1435781460.781, "1" ] + ] + } + ] + } + } + \ No newline at end of file diff --git a/pkg/util/converter/testdata/prom-series-frame.json b/pkg/util/converter/testdata/prom-series-frame.json new file mode 100644 index 00000000000..397dcef8981 --- /dev/null +++ b/pkg/util/converter/testdata/prom-series-frame.json @@ -0,0 +1,44 @@ +{ + "frames": [ + { + "schema": { + "fields": [ + { + "name": "__name__", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "job", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "instance", + "type": "string", + "typeInfo": { + "frame": "string" + } + } + ] + }, + "data": { + "values": [ + [ + "up","up","process_start_time_seconds" + ], + [ + "prometheus","node","prometheus" + ], + [ + "localhost:9090","localhost:9091","localhost:9090" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/util/converter/testdata/prom-series-golden.txt b/pkg/util/converter/testdata/prom-series-golden.txt new file mode 100644 index 00000000000..da12d51ed1a --- /dev/null +++ b/pkg/util/converter/testdata/prom-series-golden.txt @@ -0,0 +1,18 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] +Name: +Dimensions: 3 Fields by 3 Rows ++----------------------------+----------------+----------------+ +| Name: __name__ | Name: job | Name: instance | +| Labels: | Labels: | Labels: | +| Type: []string | Type: []string | Type: []string | ++----------------------------+----------------+----------------+ +| up | prometheus | localhost:9090 | +| up | node | localhost:9091 | +| process_start_time_seconds | prometheus | localhost:9090 | ++----------------------------+----------------+----------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////wAEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAFAAAAACAAAAKAAAAAQAAADM/v//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAOz+//8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAAwAAANAAAABoAAAABAAAAE7///8UAAAAQAAAAEAAAAAAAAAFPAAAAAEAAAAEAAAAPP///wgAAAAUAAAACAAAAGluc3RhbmNlAAAAAAQAAABuYW1lAAAAAAAAAAA0////CAAAAGluc3RhbmNlAAAAAK7///8UAAAAOAAAADgAAAAAAAAFNAAAAAEAAAAEAAAAnP///wgAAAAMAAAAAwAAAGpvYgAEAAAAbmFtZQAAAAAAAAAAjP///wMAAABqb2IAAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEgAAABMAAAAAAAABUgAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAUAAAACAAAAF9fbmFtZV9fAAAAAAQAAABuYW1lAAAAAAAAAAAEAAQABAAAAAgAAABfX25hbWVfXwAAAAD/////GAEAABQAAAAAAAAADAAWABQAEwAMAAQADAAAAJgAAAAAAAAAFAAAAAAAAAMEAAoAGAAMAAgABAAKAAAAFAAAAKgAAAADAAAAAAAAAAAAAAAJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAAB4AAAAAAAAAMAAAAAAAAAAAAAAAAAAAADAAAAAAAAAAEAAAAAAAAABAAAAAAAAAABgAAAAAAAAAWAAAAAAAAAAAAAAAAAAAAFgAAAAAAAAAEAAAAAAAAABoAAAAAAAAACoAAAAAAAAAAAAAAAMAAAADAAAAAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAQAAAAeAAAAdXB1cHByb2Nlc3Nfc3RhcnRfdGltZV9zZWNvbmRzAAAAAAAACgAAAA4AAAAYAAAAcHJvbWV0aGV1c25vZGVwcm9tZXRoZXVzAAAAAA4AAAAcAAAAKgAAAGxvY2FsaG9zdDo5MDkwbG9jYWxob3N0OjkwOTFsb2NhbGhvc3Q6OTA5MAAAAAAAABAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA8AAAAAAAEAAEAAADQAQAAAAAAACABAAAAAAAAmAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoADAAAAAgABAAKAAAACAAAAFAAAAACAAAAKAAAAAQAAADM/v//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAOz+//8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAAwAAANAAAABoAAAABAAAAE7///8UAAAAQAAAAEAAAAAAAAAFPAAAAAEAAAAEAAAAPP///wgAAAAUAAAACAAAAGluc3RhbmNlAAAAAAQAAABuYW1lAAAAAAAAAAA0////CAAAAGluc3RhbmNlAAAAAK7///8UAAAAOAAAADgAAAAAAAAFNAAAAAEAAAAEAAAAnP///wgAAAAMAAAAAwAAAGpvYgAEAAAAbmFtZQAAAAAAAAAAjP///wMAAABqb2IAAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEgAAABMAAAAAAAABUgAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAUAAAACAAAAF9fbmFtZV9fAAAAAAQAAABuYW1lAAAAAAAAAAAEAAQABAAAAAgAAABfX25hbWVfXwAAAADwAQAAQVJST1cx diff --git a/pkg/util/converter/testdata/prom-series.json b/pkg/util/converter/testdata/prom-series.json new file mode 100644 index 00000000000..a5e0efd1c54 --- /dev/null +++ b/pkg/util/converter/testdata/prom-series.json @@ -0,0 +1,21 @@ +{ + "status" : "success", + "data" : [ + { + "__name__" : "up", + "job" : "prometheus", + "instance" : "localhost:9090" + }, + { + "__name__" : "up", + "job" : "node", + "instance" : "localhost:9091" + }, + { + "__name__" : "process_start_time_seconds", + "job" : "prometheus", + "instance" : "localhost:9090" + } + ] + } + \ No newline at end of file diff --git a/pkg/util/converter/testdata/prom-vector-frame.json b/pkg/util/converter/testdata/prom-vector-frame.json new file mode 100644 index 00000000000..8d81e0ba3ca --- /dev/null +++ b/pkg/util/converter/testdata/prom-vector-frame.json @@ -0,0 +1,213 @@ +{ + "frames": [ + { + "schema": { + "meta": { + "type": "timeseries-many" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "up", + "type": "number", + "typeInfo": { + "frame": "float64" + }, + "labels": { + "job": "prometheus", + "instance": "localhost:9090" + } + } + ] + }, + "data": { + "values": [ + [ + 1435781451781 + ], + [ + 1 + ] + ] + } + }, + { + "schema": { + "meta": { + "type": "timeseries-many" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "up", + "type": "number", + "typeInfo": { + "frame": "float64" + }, + "labels": { + "job": "node", + "instance": "localhost:9100" + } + } + ] + }, + "data": { + "values": [ + [ + 1435781451781 + ], + [ + 0 + ] + ] + } + }, + { + "schema": { + "meta": { + "type": "timeseries-many" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "Value", + "type": "number", + "typeInfo": { + "frame": "float64" + }, + "labels": { + "level": "error", + "location": "moon" + } + } + ] + }, + "data": { + "values": [ + [ + 1645029699000 + ], + [ + null + ] + ], + "entities": [ + null, + { + "Inf": [ + 0 + ] + } + ] + } + }, + { + "schema": { + "meta": { + "type": "timeseries-many" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "Value", + "type": "number", + "typeInfo": { + "frame": "float64" + }, + "labels": { + "level": "info", + "location": "moon" + } + } + ] + }, + "data": { + "values": [ + [ + 1645029699000 + ], + [ + null + ] + ], + "entities": [ + null, + { + "NegInf": [ + 0 + ] + } + ] + } + }, + { + "schema": { + "meta": { + "type": "timeseries-many" + }, + "fields": [ + { + "name": "Time", + "type": "time", + "typeInfo": { + "frame": "time.Time" + } + }, + { + "name": "Value", + "type": "number", + "typeInfo": { + "frame": "float64" + }, + "labels": { + "level": "debug", + "location": "moon" + } + } + ] + }, + "data": { + "values": [ + [ + 1645029699000 + ], + [ + null + ] + ], + "entities": [ + null, + { + "NaN": [ + 0 + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/util/converter/testdata/prom-vector-golden.txt b/pkg/util/converter/testdata/prom-vector-golden.txt new file mode 100644 index 00000000000..a1dfe5665b5 --- /dev/null +++ b/pkg/util/converter/testdata/prom-vector-golden.txt @@ -0,0 +1,82 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] { + "type": "timeseries-many" +} +Name: +Dimensions: 2 Fields by 1 Rows ++-----------------------------------+-------------------------------------------------+ +| Name: Time | Name: up | +| Labels: | Labels: instance=localhost:9090, job=prometheus | +| Type: []time.Time | Type: []float64 | ++-----------------------------------+-------------------------------------------------+ +| 2015-07-01 20:10:51.781 +0000 UTC | 1 | ++-----------------------------------+-------------------------------------------------+ + + + +Frame[1] { + "type": "timeseries-many" +} +Name: +Dimensions: 2 Fields by 1 Rows ++-----------------------------------+-------------------------------------------+ +| Name: Time | Name: up | +| Labels: | Labels: instance=localhost:9100, job=node | +| Type: []time.Time | Type: []float64 | ++-----------------------------------+-------------------------------------------+ +| 2015-07-01 20:10:51.781 +0000 UTC | 0 | ++-----------------------------------+-------------------------------------------+ + + + +Frame[2] { + "type": "timeseries-many" +} +Name: +Dimensions: 2 Fields by 1 Rows ++-------------------------------+------------------------------------+ +| Name: Time | Name: Value | +| Labels: | Labels: level=error, location=moon | +| Type: []time.Time | Type: []float64 | ++-------------------------------+------------------------------------+ +| 2022-02-16 16:41:39 +0000 UTC | +Inf | ++-------------------------------+------------------------------------+ + + + +Frame[3] { + "type": "timeseries-many" +} +Name: +Dimensions: 2 Fields by 1 Rows ++-------------------------------+-----------------------------------+ +| Name: Time | Name: Value | +| Labels: | Labels: level=info, location=moon | +| Type: []time.Time | Type: []float64 | ++-------------------------------+-----------------------------------+ +| 2022-02-16 16:41:39 +0000 UTC | -Inf | ++-------------------------------+-----------------------------------+ + + + +Frame[4] { + "type": "timeseries-many" +} +Name: +Dimensions: 2 Fields by 1 Rows ++-------------------------------+------------------------------------+ +| Name: Time | Name: Value | +| Labels: | Labels: level=debug, location=moon | +| Type: []time.Time | Type: []float64 | ++-------------------------------+------------------------------------+ +| 2022-02-16 16:41:39 +0000 UTC | NaN | ++-------------------------------+------------------------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////8AEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAIwAAAADAAAATAAAACgAAAAEAAAAoP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADA/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAOD+//8IAAAAJAAAABoAAAB7InR5cGUiOiJ0aW1lc2VyaWVzLW1hbnkifQAABAAAAG1ldGEAAAAAAgAAAMQAAAAEAAAAVv///xQAAACMAAAAjAAAAAAAAAOMAAAAAgAAACgAAAAEAAAASP///wgAAAAMAAAAAgAAAHVwAAAEAAAAbmFtZQAAAABo////CAAAADwAAAAwAAAAeyJpbnN0YW5jZSI6ImxvY2FsaG9zdDo5MDkwIiwiam9iIjoicHJvbWV0aGV1cyJ9AAAAAAYAAABsYWJlbHMAAAAAAACK////AAACAAIAAAB1cAAAAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAFRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAVGltZQAAAAD/////uAAAABQAAAAAAAAADAAWABQAEwAMAAQADAAAABAAAAAAAAAAFAAAAAAAAAMEAAoAGAAMAAgABAAKAAAAFAAAAFgAAAABAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAIAAAAAAAAAAAAAAACAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAABAS4CQn+vsEwAAAAAAAPA/EAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADwAAAAAAAQAAQAAAAACAAAAAAAAwAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAjAAAAAMAAABMAAAAKAAAAAQAAACg/v//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAMD+//8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAA4P7//wgAAAAkAAAAGgAAAHsidHlwZSI6InRpbWVzZXJpZXMtbWFueSJ9AAAEAAAAbWV0YQAAAAACAAAAxAAAAAQAAABW////FAAAAIwAAACMAAAAAAAAA4wAAAACAAAAKAAAAAQAAABI////CAAAAAwAAAACAAAAdXAAAAQAAABuYW1lAAAAAGj///8IAAAAPAAAADAAAAB7Imluc3RhbmNlIjoibG9jYWxob3N0OjkwOTAiLCJqb2IiOiJwcm9tZXRoZXVzIn0AAAAABgAAAGxhYmVscwAAAAAAAIr///8AAAIAAgAAAHVwAAAAABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAVGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAABUaW1lAAAAACACAABBUlJPVzE= +FRAME=QVJST1cxAAD/////6AEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAIwAAAADAAAATAAAACgAAAAEAAAAqP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADI/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAOj+//8IAAAAJAAAABoAAAB7InR5cGUiOiJ0aW1lc2VyaWVzLW1hbnkifQAABAAAAG1ldGEAAAAAAgAAALwAAAAEAAAAXv///xQAAACEAAAAhAAAAAAAAAOEAAAAAgAAACgAAAAEAAAAUP///wgAAAAMAAAAAgAAAHVwAAAEAAAAbmFtZQAAAABw////CAAAADQAAAAqAAAAeyJpbnN0YW5jZSI6ImxvY2FsaG9zdDo5MTAwIiwiam9iIjoibm9kZSJ9AAAGAAAAbGFiZWxzAAAAAAAAiv///wAAAgACAAAAdXAAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAAAApMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAABUaW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAFRpbWUAAAAA/////7gAAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAAAQAAAAAAAAABQAAAAAAAADBAAKABgADAAIAAQACgAAABQAAABYAAAAAQAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAACAAAAAAAAAAAAAAAAgAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAQEuAkJ/r7BMAAAAAAAAAABAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA8AAAAAAAEAAEAAAD4AQAAAAAAAMAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoADAAAAAgABAAKAAAACAAAAIwAAAADAAAATAAAACgAAAAEAAAAqP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADI/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAOj+//8IAAAAJAAAABoAAAB7InR5cGUiOiJ0aW1lc2VyaWVzLW1hbnkifQAABAAAAG1ldGEAAAAAAgAAALwAAAAEAAAAXv///xQAAACEAAAAhAAAAAAAAAOEAAAAAgAAACgAAAAEAAAAUP///wgAAAAMAAAAAgAAAHVwAAAEAAAAbmFtZQAAAABw////CAAAADQAAAAqAAAAeyJpbnN0YW5jZSI6ImxvY2FsaG9zdDo5MTAwIiwiam9iIjoibm9kZSJ9AAAGAAAAbGFiZWxzAAAAAAAAiv///wAAAgACAAAAdXAAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAAAApMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAABUaW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAFRpbWUAAAAAGAIAAEFSUk9XMQ== +FRAME=QVJST1cxAAD/////6AEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAIwAAAADAAAATAAAACgAAAAEAAAArP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADM/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAOz+//8IAAAAJAAAABoAAAB7InR5cGUiOiJ0aW1lc2VyaWVzLW1hbnkifQAABAAAAG1ldGEAAAAAAgAAALgAAAAEAAAAYv///xQAAACAAAAAgAAAAAAAAAOAAAAAAgAAACwAAAAEAAAAVP///wgAAAAQAAAABQAAAFZhbHVlAAAABAAAAG5hbWUAAAAAeP///wgAAAAsAAAAIwAAAHsibGV2ZWwiOiJlcnJvciIsImxvY2F0aW9uIjoibW9vbiJ9AAYAAABsYWJlbHMAAAAAAACK////AAACAAUAAABWYWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAFRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAVGltZQAAAAAAAAAA/////7gAAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAAAQAAAAAAAAABQAAAAAAAADBAAKABgADAAIAAQACgAAABQAAABYAAAAAQAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAACAAAAAAAAAAAAAAAAgAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAN7cpctR1BYAAAAAAADwfxAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA4AAAAAAAEAAEAAAD4AQAAAAAAAMAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAjAAAAAMAAABMAAAAKAAAAAQAAACs/v//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAMz+//8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAA7P7//wgAAAAkAAAAGgAAAHsidHlwZSI6InRpbWVzZXJpZXMtbWFueSJ9AAAEAAAAbWV0YQAAAAACAAAAuAAAAAQAAABi////FAAAAIAAAACAAAAAAAAAA4AAAAACAAAALAAAAAQAAABU////CAAAABAAAAAFAAAAVmFsdWUAAAAEAAAAbmFtZQAAAAB4////CAAAACwAAAAjAAAAeyJsZXZlbCI6ImVycm9yIiwibG9jYXRpb24iOiJtb29uIn0ABgAAAGxhYmVscwAAAAAAAIr///8AAAIABQAAAFZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAVGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAABUaW1lAAAAABACAABBUlJPVzE= +FRAME=QVJST1cxAAD/////6AEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAIwAAAADAAAATAAAACgAAAAEAAAArP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADM/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAOz+//8IAAAAJAAAABoAAAB7InR5cGUiOiJ0aW1lc2VyaWVzLW1hbnkifQAABAAAAG1ldGEAAAAAAgAAALgAAAAEAAAAYv///xQAAACAAAAAgAAAAAAAAAOAAAAAAgAAACwAAAAEAAAAVP///wgAAAAQAAAABQAAAFZhbHVlAAAABAAAAG5hbWUAAAAAeP///wgAAAAsAAAAIgAAAHsibGV2ZWwiOiJpbmZvIiwibG9jYXRpb24iOiJtb29uIn0AAAYAAABsYWJlbHMAAAAAAACK////AAACAAUAAABWYWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAFRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAVGltZQAAAAAAAAAA/////7gAAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAAAQAAAAAAAAABQAAAAAAAADBAAKABgADAAIAAQACgAAABQAAABYAAAAAQAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAACAAAAAAAAAAAAAAAAgAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAN7cpctR1BYAAAAAAADw/xAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA4AAAAAAAEAAEAAAD4AQAAAAAAAMAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAjAAAAAMAAABMAAAAKAAAAAQAAACs/v//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAMz+//8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAA7P7//wgAAAAkAAAAGgAAAHsidHlwZSI6InRpbWVzZXJpZXMtbWFueSJ9AAAEAAAAbWV0YQAAAAACAAAAuAAAAAQAAABi////FAAAAIAAAACAAAAAAAAAA4AAAAACAAAALAAAAAQAAABU////CAAAABAAAAAFAAAAVmFsdWUAAAAEAAAAbmFtZQAAAAB4////CAAAACwAAAAiAAAAeyJsZXZlbCI6ImluZm8iLCJsb2NhdGlvbiI6Im1vb24ifQAABgAAAGxhYmVscwAAAAAAAIr///8AAAIABQAAAFZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAVGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAABUaW1lAAAAABACAABBUlJPVzE= +FRAME=QVJST1cxAAD/////6AEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAIwAAAADAAAATAAAACgAAAAEAAAArP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADM/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAOz+//8IAAAAJAAAABoAAAB7InR5cGUiOiJ0aW1lc2VyaWVzLW1hbnkifQAABAAAAG1ldGEAAAAAAgAAALgAAAAEAAAAYv///xQAAACAAAAAgAAAAAAAAAOAAAAAAgAAACwAAAAEAAAAVP///wgAAAAQAAAABQAAAFZhbHVlAAAABAAAAG5hbWUAAAAAeP///wgAAAAsAAAAIwAAAHsibGV2ZWwiOiJkZWJ1ZyIsImxvY2F0aW9uIjoibW9vbiJ9AAYAAABsYWJlbHMAAAAAAACK////AAACAAUAAABWYWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAFRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAVGltZQAAAAAAAAAA/////7gAAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAAAQAAAAAAAAABQAAAAAAAADBAAKABgADAAIAAQACgAAABQAAABYAAAAAQAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAACAAAAAAAAAAAAAAAAgAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAN7cpctR1BYBAAAAAAD4fxAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA4AAAAAAAEAAEAAAD4AQAAAAAAAMAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAjAAAAAMAAABMAAAAKAAAAAQAAACs/v//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAMz+//8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAA7P7//wgAAAAkAAAAGgAAAHsidHlwZSI6InRpbWVzZXJpZXMtbWFueSJ9AAAEAAAAbWV0YQAAAAACAAAAuAAAAAQAAABi////FAAAAIAAAACAAAAAAAAAA4AAAAACAAAALAAAAAQAAABU////CAAAABAAAAAFAAAAVmFsdWUAAAAEAAAAbmFtZQAAAAB4////CAAAACwAAAAjAAAAeyJsZXZlbCI6ImRlYnVnIiwibG9jYXRpb24iOiJtb29uIn0ABgAAAGxhYmVscwAAAAAAAIr///8AAAIABQAAAFZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAVGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAABUaW1lAAAAABACAABBUlJPVzE= diff --git a/pkg/util/converter/testdata/prom-vector.json b/pkg/util/converter/testdata/prom-vector.json new file mode 100644 index 00000000000..641301f4521 --- /dev/null +++ b/pkg/util/converter/testdata/prom-vector.json @@ -0,0 +1,36 @@ +{ + "status" : "success", + "data" : { + "resultType" : "vector", + "result" : [ + { + "metric" : { + "__name__" : "up", + "job" : "prometheus", + "instance" : "localhost:9090" + }, + "value": [ 1435781451.781, "1" ] + }, + { + "metric" : { + "__name__" : "up", + "job" : "node", + "instance" : "localhost:9100" + }, + "value" : [ 1435781451.781, "0" ] + }, + { + "metric": { "level": "error", "location": "moon"}, + "value": [1645029699, "+Inf"] + }, + { + "metric": { "level": "info", "location": "moon" }, + "value": [1645029699, "-Inf"] + }, + { + "metric": { "level": "debug", "location": "moon" }, + "value": [1645029699, "NaN"] + } + ] + } + } diff --git a/public/app/core/components/NavBar/NavBarMenuItem.tsx b/public/app/core/components/NavBar/NavBarMenuItem.tsx index 02e45bb5f83..b978cffc30e 100644 --- a/public/app/core/components/NavBar/NavBarMenuItem.tsx +++ b/public/app/core/components/NavBar/NavBarMenuItem.tsx @@ -102,24 +102,6 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], styleOverr background-color: ${theme.colors.action.hover}; } } - - > .pin-button { - opacity: 0; - } - - &:hover > .pin-button, - &:focus-visible > .pin-button { - opacity: 100%; - } - `, - pinButton: css` - position: relative; - flex-shrink: 2; - color: ${theme.colors.text.secondary}; - - &:focus-visible { - opacity: 100%; - } `, element: css` align-items: center; @@ -133,10 +115,6 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], styleOverr text-align: left; white-space: nowrap; - &:focus-visible + .pin-button { - opacity: 100%; - } - &:focus-visible { outline: none; box-shadow: none; diff --git a/public/app/core/components/NavBar/Next/NavBarItem.tsx b/public/app/core/components/NavBar/Next/NavBarItem.tsx index 752e8807431..fb4731ce773 100644 --- a/public/app/core/components/NavBar/Next/NavBarItem.tsx +++ b/public/app/core/components/NavBar/Next/NavBarItem.tsx @@ -12,6 +12,7 @@ import { NavBarItemMenu } from './NavBarItemMenu'; import { getNavModelItemKey } from '../utils'; import { useLingui } from '@lingui/react'; import menuItemTranslations from '../navBarItem-translations'; +import { useNavBarContext } from '../context'; export interface Props { isActive?: boolean; @@ -33,6 +34,7 @@ const NavBarItem = ({ const { i18n } = useLingui(); const theme = useTheme2(); const menuItems = link.children ?? []; + const { menuIdOpen } = useNavBarContext(); // Spreading `menuItems` here as otherwise we'd be mutating props const menuItemsSorted = reverseMenuDirection ? [...menuItems].reverse() : menuItems; @@ -81,7 +83,7 @@ const NavBarItem = ({ ); } else { return ( -
  • +
  • ({ ...getNavBarItemWithoutMenuStyles(theme, isActive), + containerHover: css({ + backgroundColor: theme.colors.action.hover, + color: theme.colors.text.primary, + }), primaryText: css({ color: theme.colors.text.primary, }), diff --git a/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx b/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx index 9d81447e735..0d266ac2165 100644 --- a/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx +++ b/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx @@ -47,9 +47,10 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE onHoverChange: (isHovering) => { if (isHovering) { state.open(); - setMenuIdOpen(ref.current?.id || null); + setMenuIdOpen(item.id); } else { state.close(); + setMenuIdOpen(undefined); } }, }); @@ -57,13 +58,13 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE useEffect(() => { // close the menu when changing submenus // or when the state of the overlay changes (i.e hovering outside) - if (menuIdOpen !== ref.current?.id || !state.isOpen) { + if (menuIdOpen !== item.id || !state.isOpen) { state.close(); setMenuHasFocus(false); } else { state.open(); } - }, [menuIdOpen, state]); + }, [menuIdOpen, state, item.id]); const { keyboardProps } = useKeyboard({ onKeyDown: (e) => { @@ -71,11 +72,12 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE case 'ArrowRight': if (!state.isOpen) { state.open(); + setMenuIdOpen(item.id); } setMenuHasFocus(true); break; case 'Tab': - setMenuIdOpen(null); + setMenuIdOpen(undefined); break; default: break; @@ -142,7 +144,10 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE const { dialogProps } = useDialog({}, overlayRef); const { overlayProps } = useOverlay( { - onClose: () => state.close(), + onClose: () => { + state.close(); + setMenuIdOpen(undefined); + }, isOpen: state.isOpen, isDismissable: true, }, @@ -160,7 +165,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE onFocusWithin: (e) => { if (e.target.id === ref.current?.id) { // If focussing on the trigger itself, set the menu id that is open - setMenuIdOpen(ref.current?.id); + setMenuIdOpen(item.id); state.open(); } }, @@ -168,7 +173,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE if (e.target?.getAttribute('role') === 'menuitem' && !overlayRef.current?.contains(e.relatedTarget)) { // If it is blurring from a menuitem to an element outside the current overlay // close the menu that is open - setMenuIdOpen(null); + setMenuIdOpen(undefined); } }, }); diff --git a/public/app/core/components/NavBar/Next/NavBarMenu.tsx b/public/app/core/components/NavBar/Next/NavBarMenu.tsx index e5c17517721..751de398665 100644 --- a/public/app/core/components/NavBar/Next/NavBarMenu.tsx +++ b/public/app/core/components/NavBar/Next/NavBarMenu.tsx @@ -1,27 +1,33 @@ import React, { useRef } from 'react'; +import CSSTransition from 'react-transition-group/CSSTransition'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { CollapsableSection, CustomScrollbar, Icon, IconName, useStyles2 } from '@grafana/ui'; +import { CollapsableSection, CustomScrollbar, Icon, IconName, useStyles2, useTheme2 } from '@grafana/ui'; import { FocusScope } from '@react-aria/focus'; import { useDialog } from '@react-aria/dialog'; -import { useOverlay } from '@react-aria/overlays'; +import { OverlayContainer, useOverlay } from '@react-aria/overlays'; import { css, cx } from '@emotion/css'; import { NavBarMenuItem } from './NavBarMenuItem'; import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; import { isMatchOrChildMatch } from '../utils'; import { NavBarToggle } from './NavBarToggle'; +import { useLocalStorage } from 'react-use'; export interface Props { activeItem?: NavModelItem; isOpen: boolean; navItems: NavModelItem[]; + setMenuAnimationInProgress: (isInProgress: boolean) => void; onClose: () => void; } -export function NavBarMenu({ activeItem, isOpen, navItems, onClose }: Props) { - const styles = useStyles2(getStyles); +export function NavBarMenu({ activeItem, isOpen, navItems, onClose, setMenuAnimationInProgress }: Props) { + const theme = useTheme2(); + const styles = getStyles(theme); + const ANIMATION_DURATION = theme.transitions.duration.standard; + const animStyles = getAnimStyles(theme, ANIMATION_DURATION); const ref = useRef(null); const { dialogProps } = useDialog({}, ref); - const { overlayProps } = useOverlay( + const { overlayProps, underlayProps } = useOverlay( { isDismissable: true, isOpen, @@ -31,26 +37,50 @@ export function NavBarMenu({ activeItem, isOpen, navItems, onClose }: Props) { ); return ( -
    + - + setMenuAnimationInProgress(true)} + onExited={() => setMenuAnimationInProgress(false)} + appear={isOpen} + in={isOpen} + classNames={animStyles.overlay} + timeout={ANIMATION_DURATION} + > +
    + + +
    +
    -
    + +
    + + ); } NavBarMenu.displayName = 'NavBarMenu'; const getStyles = (theme: GrafanaTheme2) => ({ + backdrop: css({ + backdropFilter: 'blur(1px)', + backgroundColor: theme.components.overlay.background, + bottom: 0, + left: 0, + position: 'fixed', + right: 0, + top: 0, + zIndex: theme.zIndex.modalBackdrop, + }), container: css({ bottom: 0, display: 'flex', @@ -59,9 +89,9 @@ const getStyles = (theme: GrafanaTheme2) => ({ whiteSpace: 'nowrap', paddingTop: theme.spacing(1), marginRight: theme.spacing(1.5), - overflow: 'hidden', right: 0, - zIndex: theme.zIndex.sidemenu, + zIndex: theme.zIndex.modal, + position: 'fixed', top: 0, boxSizing: 'content-box', [theme.breakpoints.up('md')]: { @@ -82,9 +112,65 @@ const getStyles = (theme: GrafanaTheme2) => ({ position: 'absolute', top: '43px', right: '0px', + transform: `translateX(50%)`, }), }); +const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => { + const commonTransition = { + transitionProperty: 'width, background-color, opacity', + transitionDuration: `${animationDuration}ms`, + transitionTimingFunction: theme.transitions.easing.easeInOut, + }; + + const overlayTransition = { + ...commonTransition, + transitionProperty: 'width, background-color, box-shadow', + }; + + const backdropTransition = { + ...commonTransition, + transitionProperty: 'opacity', + }; + + const overlayOpen = { + backgroundColor: theme.colors.background.canvas, + boxShadow: theme.shadows.z3, + width: '300px', + }; + + const overlayClosed = { + backgroundColor: theme.colors.background.primary, + boxShadow: 'none', + width: theme.spacing(7), + }; + + const backdropOpen = { + opacity: 1, + }; + + const backdropClosed = { + opacity: 0, + }; + + return { + backdrop: { + appear: css(backdropClosed), + appearActive: css(backdropTransition, backdropOpen), + appearDone: css(backdropOpen), + exit: css(backdropOpen), + exitActive: css(backdropTransition, backdropClosed), + }, + overlay: { + appear: css(overlayClosed), + appearActive: css(overlayTransition, overlayOpen), + appearDone: css(overlayOpen), + exit: css(overlayOpen), + exitActive: css(overlayTransition, overlayClosed), + }, + }; +}; + function NavItem({ link, activeItem, @@ -98,8 +184,8 @@ function NavItem({ if (linkHasChildren(link)) { return ( - -
      + +
        {link.children.map( (childLink) => !childLink.divider && ( @@ -124,7 +210,12 @@ function NavItem({ ); } else if (link.id === 'saved-items') { return ( - + No saved items ); @@ -159,8 +250,12 @@ function NavItem({ } const getNavItemStyles = (theme: GrafanaTheme2) => ({ + children: css({ + display: 'flex', + flexDirection: 'column', + }), item: css({ - padding: `${theme.spacing(1)} 0`, + padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`, whiteSpace: 'normal', '&::before': { display: 'none', @@ -202,6 +297,7 @@ const getNavItemStyles = (theme: GrafanaTheme2) => ({ linkText: css({ fontSize: theme.typography.pxToRem(14), justifySelf: 'start', + paddingLeft: theme.spacing(0.5), }), }); @@ -210,13 +306,16 @@ function CollapsibleNavItem({ isActive, children, className, + onClose, }: { link: NavModelItem; isActive?: boolean; children: React.ReactNode; className?: string; + onClose: () => void; }) { const styles = useStyles2(getCollapsibleStyles); + const [sectionExpanded, setSectionExpanded] = useLocalStorage(`grafana.navigation.expanded[${link.text}]`, false); return (
      • @@ -225,7 +324,10 @@ function CollapsibleNavItem({ label={link.text} url={link.url} target={link.target} - onClick={link.onClick} + onClick={() => { + link.onClick?.(); + onClose(); + }} className={styles.collapsibleMenuItem} > {link.img && ( @@ -235,7 +337,8 @@ function CollapsibleNavItem({
        setSectionExpanded(isOpen)} className={styles.collapseWrapper} contentClassName={styles.collapseContent} label={ @@ -271,18 +374,27 @@ const getCollapsibleStyles = (theme: GrafanaTheme2) => ({ flexDirection: 'column', }), collapseWrapper: css({ - borderRadius: theme.shape.borderRadius(2), + paddingLeft: theme.spacing(0.5), paddingRight: theme.spacing(4.25), height: theme.spacing(6), alignItems: 'center', + color: theme.colors.text.secondary, + '&:hover, &:focus-within': { + backgroundColor: theme.colors.action.hover, + color: theme.colors.text.primary, + }, + '&:focus-within': { + boxShadow: 'none', + outline: `2px solid ${theme.colors.primary.main}`, + outlineOffset: '-2px', + transition: 'none', + }, }), collapseContent: css({ padding: 0, - paddingLeft: theme.spacing(1.25), }), labelWrapper: css({ fontSize: '15px', - color: theme.colors.text.secondary, }), primary: css({ color: theme.colors.text.primary, diff --git a/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx b/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx index 29928a993a0..eddb0de25b5 100644 --- a/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx +++ b/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx @@ -93,28 +93,22 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ border: 'none', color: isActive ? theme.colors.text.primary : theme.colors.text.secondary, display: 'flex', + flex: 1, fontSize: 'inherit', height: '100%', padding: '5px 12px 5px 10px', textAlign: 'left', whiteSpace: 'nowrap', - - '&:focus-visible + .pin-button': { - opacity: '100%', + '&:hover, &:focus-visible': { + backgroundColor: theme.colors.action.hover, + color: theme.colors.text.primary, }, - '&:focus-visible': { - outline: 'none', boxShadow: 'none', - - '&::after': { - boxShadow: 'none', - outline: `${theme.shape.borderRadius} solid ${theme.colors.primary.main}`, - outlineOffset: `-${theme.shape.borderRadius(1)}`, - transition: 'none', - }, + outline: `2px solid ${theme.colors.primary.main}`, + outlineOffset: '-2px', + transition: 'none', }, - '&::before': { display: isActive ? 'block' : 'none', content: '" "', @@ -126,15 +120,6 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ borderRadius: theme.shape.borderRadius(1), backgroundImage: theme.colors.gradients.brandVertical, }, - - '&::after': { - position: 'absolute', - content: '" "', - left: 0, - top: 0, - bottom: 0, - right: 0, - }, }), listItem: css({ position: 'relative', @@ -148,14 +133,6 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ backgroundColor: theme.colors.action.hover, }, }, - - '> .pin-button': { - opacity: 0, - }, - - '&:hover > .pin-button, &:focusVisible > .pin-button': { - opacity: '100%', - }, }), divider: css({ borderBottom: `1px solid ${theme.colors.border.weak}`, diff --git a/public/app/core/components/NavBar/Next/NavBarNext.tsx b/public/app/core/components/NavBar/Next/NavBarNext.tsx index 73ba3d64c47..a2a07fbac5e 100644 --- a/public/app/core/components/NavBar/Next/NavBarNext.tsx +++ b/public/app/core/components/NavBar/Next/NavBarNext.tsx @@ -1,10 +1,9 @@ import React, { useState } from 'react'; import { useLocation } from 'react-router-dom'; -import CSSTransition from 'react-transition-group/CSSTransition'; import { css, cx } from '@emotion/css'; import { cloneDeep } from 'lodash'; import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data'; -import { Icon, IconName, useStyles2, useTheme2 } from '@grafana/ui'; +import { Icon, IconName, useTheme2 } from '@grafana/ui'; import { config, locationService } from '@grafana/runtime'; import { getKioskMode } from 'app/core/navigation/kiosk'; import { KioskMode, StoreState } from 'app/types'; @@ -25,7 +24,7 @@ const onOpenSearch = () => { const searchItem: NavModelItem = { id: SEARCH_ITEM_ID, onClick: onOpenSearch, - text: 'Search dashboards', + text: 'Search Dashboards', icon: 'search', }; @@ -41,7 +40,6 @@ export const NavBarNext = React.memo(() => { const navBarTree = useSelector((state: StoreState) => state.navBarTree); const theme = useTheme2(); const styles = getStyles(theme); - const animStyles = useStyles2(getAnimStyles); const location = useLocation(); const kiosk = getKioskMode(); const [showSwitcherModal, setShowSwitcherModal] = useState(false); @@ -60,7 +58,8 @@ export const NavBarNext = React.memo(() => { ); const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname); const [menuOpen, setMenuOpen] = useState(false); - const [menuIdOpen, setMenuIdOpen] = useState(null); + const [menuAnimationInProgress, setMenuAnimationInProgress] = useState(false); + const [menuIdOpen, setMenuIdOpen] = useState(undefined); if (kiosk !== KioskMode.Off) { return null; @@ -135,16 +134,17 @@ export const NavBarNext = React.memo(() => { {showSwitcherModal && } -
        - + {(menuOpen || menuAnimationInProgress) && ( +
        setMenuOpen(false)} /> - -
        +
        + )}
        ); }); @@ -242,41 +242,3 @@ const getStyles = (theme: GrafanaTheme2) => ({ transform: `translateX(50%)`, }), }); - -const getAnimStyles = (theme: GrafanaTheme2) => { - const transitionProps = { - transitionProperty: 'width, background-color', - transitionDuration: '150ms', - transitionTimingFunction: 'ease-in-out', - }; - - const openStyles = { - backgroundColor: theme.colors.background.canvas, - width: '300px', - }; - - const closedStyles = { - backgroundColor: theme.colors.background.primary, - width: theme.spacing(7), - }; - - return { - enter: css({ - ...closedStyles, - }), - enterActive: css({ - ...transitionProps, - ...openStyles, - }), - enterDone: css({ - ...openStyles, - }), - exit: css({ - ...openStyles, - }), - exitActive: css({ - ...transitionProps, - ...closedStyles, - }), - }; -}; diff --git a/public/app/core/components/NavBar/context.tsx b/public/app/core/components/NavBar/context.tsx index 2beb99d8ab6..e7baf632b4c 100644 --- a/public/app/core/components/NavBar/context.tsx +++ b/public/app/core/components/NavBar/context.tsx @@ -18,12 +18,12 @@ export function useNavBarItemMenuContext(): NavBarItemMenuContextProps { } export interface NavBarContextProps { - menuIdOpen: string | null; - setMenuIdOpen: (id: string | null) => void; + menuIdOpen: string | undefined; + setMenuIdOpen: (id: string | undefined) => void; } export const NavBarContext = createContext({ - menuIdOpen: null, + menuIdOpen: undefined, setMenuIdOpen: () => undefined, }); diff --git a/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx b/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx index f101ffa0cee..871a967f2bf 100644 --- a/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx +++ b/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx @@ -1,6 +1,7 @@ import { css } from '@emotion/css'; import { DataSourceJsonData, + DataSourceInstanceSettings, DataSourcePluginOptionsEditorProps, GrafanaTheme, KeyValue, @@ -44,12 +45,13 @@ export function TraceToLogsSettings({ options, onOptionsChange }: Props) { + onChange={(ds: DataSourceInstanceSettings) => updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { + ...options.jsonData.tracesToLogs, datasourceUid: ds.uid, tags: options.jsonData.tracesToLogs?.tags, }) diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index a4d838035d7..ecee561f4a0 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -11,6 +11,7 @@ export class User implements CurrentUserInternal { login: string; email: string; name: string; + externalUserId: string; lightTheme: boolean; orgCount: number; orgId: number; @@ -34,6 +35,7 @@ export class User implements CurrentUserInternal { this.orgId = 0; this.orgName = ''; this.login = ''; + this.externalUserId = ''; this.orgCount = 0; this.timezone = ''; this.fiscalYearStartMonth = 0; diff --git a/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts b/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts index e9f1ffc8e15..0f3b16c4923 100644 --- a/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts +++ b/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts @@ -7,12 +7,13 @@ import { isPageviewEvent, PageviewEchoEvent, } from '@grafana/runtime'; -import { User } from '../sentry/types'; +import { CurrentUserDTO } from '@grafana/data'; +import { getUserIdentifier } from '../../utils'; export interface RudderstackBackendOptions { writeKey: string; dataPlaneUrl: string; - user?: User; + user?: CurrentUserDTO; sdkUrl?: string; configUrl?: string; } @@ -57,7 +58,9 @@ export class RudderstackBackend implements EchoBackend { + it('should return the external user ID (gcom ID) if available', () => { + const id = getUserIdentifier(gcomUser); + expect(id).toBe('abc-123'); + }); + + it('should fall back to the email address', () => { + const id = getUserIdentifier(baseUser); + expect(id).toBe('email@example.com'); + }); +}); diff --git a/public/app/core/services/echo/utils.ts b/public/app/core/services/echo/utils.ts index 1765b0017fe..edcfe096d77 100644 --- a/public/app/core/services/echo/utils.ts +++ b/public/app/core/services/echo/utils.ts @@ -1,5 +1,20 @@ import { attachDebugger, createLogger } from '@grafana/ui'; +import { CurrentUserDTO } from '@grafana/data'; + +/** + * Returns an opaque identifier for a user, for reporting purposes. + * Because this is for use when reporting across multiple Grafana installations + * It cannot simply be user.id because that's not unique across two installations. + */ +export function getUserIdentifier(user: CurrentUserDTO) { + if (user.externalUserId.length) { + return user.externalUserId; + } + + return user.email; +} + /** @internal */ export const echoLogger = createLogger('EchoSrv'); export const echoLog = echoLogger.logger; diff --git a/public/app/core/utils/richHistory.test.ts b/public/app/core/utils/richHistory.test.ts index e89d3860e15..cd6b12a58ac 100644 --- a/public/app/core/utils/richHistory.test.ts +++ b/public/app/core/utils/richHistory.test.ts @@ -12,7 +12,8 @@ import { } from './richHistory'; import store from 'app/core/store'; import { dateTime, DataQuery } from '@grafana/data'; -import RichHistoryStorage, { RichHistoryServiceError, RichHistoryStorageWarning } from '../history/RichHistoryStorage'; +import RichHistoryStorage, { RichHistoryStorageWarning } from '../history/RichHistoryStorage'; +import { RichHistoryQuery } from '../../types'; const richHistoryStorageMock: RichHistoryStorage = {} as RichHistoryStorage; @@ -22,20 +23,27 @@ jest.mock('../history/richHistoryStorageProvider', () => { }; }); +interface MockQuery extends DataQuery { + expr: string; + maxLines?: number | null; +} + +const storedHistory: Array> = [ + { + id: '1', + createdAt: 1, + comment: '', + datasourceUid: 'datasource uid', + datasourceName: 'datasource history name', + queries: [ + { expr: 'query1', maxLines: null, refId: '1' }, + { expr: 'query2', refId: '2' }, + ], + starred: true, + }, +]; + const mock: any = { - storedHistory: [ - { - id: '1', - createdAt: 1, - comment: '', - datasourceName: 'datasource history name', - queries: [ - { expr: 'query1', maxLines: null, refId: '1' }, - { expr: 'query2', refId: '2' }, - ], - starred: true, - }, - ], testComment: '', testDatasourceUid: 'datasourceUid', testDatasourceName: 'datasourceName', @@ -83,23 +91,10 @@ describe('richHistory', () => { deleteAllFromRichHistory(); expect(store.exists(key)).toBeFalsy(); }); - const expectedResult = [ - { - comment: mock.testComment, - datasourceUid: mock.testDatasourceUid, - datasourceName: mock.testDatasourceName, - queries: mock.testQueries, - starred: mock.testStarred, - createdAt: 2, - id: 'GENERATED ID', - }, - mock.storedHistory[0], - ]; it('should append query to query history', async () => { Date.now = jest.fn(() => 2); - const { richHistory: newHistory } = await addToRichHistory( - mock.storedHistory, + const { limitExceeded, richHistoryStorageFull } = await addToRichHistory( mock.testDatasourceUid, mock.testDatasourceName, mock.testQueries, @@ -108,23 +103,8 @@ describe('richHistory', () => { true, true ); - expect(newHistory).toEqual(expectedResult); - }); - - it('should add query history to storage', async () => { - Date.now = jest.fn(() => 2); - - const { richHistory } = await addToRichHistory( - mock.storedHistory, - mock.testDatasourceUid, - mock.testDatasourceName, - mock.testQueries, - mock.testStarred, - mock.testComment, - true, - true - ); - expect(richHistory).toMatchObject(expectedResult); + expect(limitExceeded).toBeFalsy(); + expect(richHistoryStorageFull).toBeFalsy(); expect(richHistoryStorageMock.addToRichHistory).toBeCalledWith({ datasourceUid: mock.testDatasourceUid, datasourceName: mock.testDatasourceName, @@ -134,27 +114,7 @@ describe('richHistory', () => { }); }); - it('should not append duplicated query to query history', async () => { - Date.now = jest.fn(() => 2); - - const duplicatedEntryError = new Error(); - duplicatedEntryError.name = RichHistoryServiceError.DuplicatedEntry; - richHistoryStorageMock.addToRichHistory = jest.fn().mockRejectedValue(duplicatedEntryError); - - const { richHistory: newHistory } = await addToRichHistory( - mock.storedHistory, - mock.storedHistory[0].datasourceUid, - mock.storedHistory[0].datasourceName, - [{ expr: 'query1', maxLines: null, refId: 'A' } as DataQuery, { expr: 'query2', refId: 'B' } as DataQuery], - mock.testStarred, - mock.testComment, - true, - true - ); - expect(newHistory).toEqual([mock.storedHistory[0]]); - }); - - it('it should append new items even when the limit is exceeded', async () => { + it('it should return a flag indicating that the limit has been exceed', async () => { Date.now = jest.fn(() => 2); richHistoryStorageMock.addToRichHistory = jest.fn((query) => { @@ -167,8 +127,7 @@ describe('richHistory', () => { }); }); - const { richHistory, limitExceeded } = await addToRichHistory( - mock.storedHistory, + const { richHistoryStorageFull, limitExceeded } = await addToRichHistory( mock.testDatasourceUid, mock.testDatasourceName, mock.testQueries, @@ -177,29 +136,29 @@ describe('richHistory', () => { true, true ); - expect(richHistory).toEqual(expectedResult); + expect(richHistoryStorageFull).toBeFalsy(); expect(limitExceeded).toBeTruthy(); }); }); describe('updateStarredInRichHistory', () => { it('should update starred in query in history', async () => { - const updatedStarred = await updateStarredInRichHistory(mock.storedHistory, '1', !mock.starred); - expect(updatedStarred[0].starred).toEqual(!mock.starred); + const updatedStarred = await updateStarredInRichHistory('1', !mock.starred); + expect(updatedStarred!.starred).toEqual(!mock.starred); }); }); describe('updateCommentInRichHistory', () => { it('should update comment in query in history', async () => { - const updatedComment = await updateCommentInRichHistory(mock.storedHistory, '1', 'new comment'); - expect(updatedComment[0].comment).toEqual('new comment'); + const updatedComment = await updateCommentInRichHistory('1', 'new comment'); + expect(updatedComment!.comment).toEqual('new comment'); }); }); describe('deleteQueryInRichHistory', () => { it('should delete query in query in history', async () => { - const deletedHistory = await deleteQueryInRichHistory(mock.storedHistory, '1'); - expect(deletedHistory).toEqual([]); + const deletedHistoryId = await deleteQueryInRichHistory('1'); + expect(deletedHistoryId).toEqual('1'); }); }); @@ -220,7 +179,7 @@ describe('richHistory', () => { describe('filterQueries', () => { it('should filter out queries based on data source filter', () => { const filteredQueries = filterAndSortQueries( - mock.storedHistory, + storedHistory, SortOrder.Ascending, ['not provided data source'], '' @@ -228,25 +187,15 @@ describe('richHistory', () => { expect(filteredQueries).toHaveLength(0); }); it('should keep queries based on data source filter', () => { - const filteredQueries = filterAndSortQueries( - mock.storedHistory, - SortOrder.Ascending, - ['datasource history name'], - '' - ); + const filteredQueries = filterAndSortQueries(storedHistory, SortOrder.Ascending, ['datasource history name'], ''); expect(filteredQueries).toHaveLength(1); }); it('should filter out all queries based on search filter', () => { - const filteredQueries = filterAndSortQueries( - mock.storedHistory, - SortOrder.Ascending, - [], - 'i do not exist in query' - ); + const filteredQueries = filterAndSortQueries(storedHistory, SortOrder.Ascending, [], 'i do not exist in query'); expect(filteredQueries).toHaveLength(0); }); it('should include queries based on search filter', () => { - const filteredQueries = filterAndSortQueries(mock.storedHistory, SortOrder.Ascending, [], 'query1'); + const filteredQueries = filterAndSortQueries(storedHistory, SortOrder.Ascending, [], 'query1'); expect(filteredQueries).toHaveLength(1); }); }); @@ -254,13 +203,13 @@ describe('richHistory', () => { describe('createQueryHeading', () => { it('should correctly create heading for queries when sort order is ascending ', () => { // Have to offset the timezone of a 1 microsecond epoch, and then reverse the changes - mock.storedHistory[0].createdAt = 1 + -1 * dateTime().utcOffset() * 60 * 1000; - const heading = createQueryHeading(mock.storedHistory[0], SortOrder.Ascending); + storedHistory[0].createdAt = 1 + -1 * dateTime().utcOffset() * 60 * 1000; + const heading = createQueryHeading(storedHistory[0], SortOrder.Ascending); expect(heading).toEqual('January 1'); }); it('should correctly create heading for queries when sort order is datasourceAZ ', () => { - const heading = createQueryHeading(mock.storedHistory[0], SortOrder.DatasourceAZ); - expect(heading).toEqual(mock.storedHistory[0].datasourceName); + const heading = createQueryHeading(storedHistory[0], SortOrder.DatasourceAZ); + expect(heading).toEqual(storedHistory[0].datasourceName); }); }); }); diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts index 567fbd97005..feebda044e3 100644 --- a/public/app/core/utils/richHistory.ts +++ b/public/app/core/utils/richHistory.ts @@ -33,7 +33,6 @@ export { SortOrder }; */ export async function addToRichHistory( - richHistory: RichHistoryQuery[], datasourceUid: string, datasourceName: string | null, queries: DataQuery[], @@ -41,7 +40,7 @@ export async function addToRichHistory( comment: string | null, showQuotaExceededError: boolean, showLimitExceededWarning: boolean -): Promise<{ richHistory: RichHistoryQuery[]; richHistoryStorageFull?: boolean; limitExceeded?: boolean }> { +): Promise<{ richHistoryStorageFull?: boolean; limitExceeded?: boolean }> { /* Save only queries, that are not falsy (e.g. empty object, null, ...) */ const newQueriesToSave: DataQuery[] = queries && queries.filter((query) => notEmptyQuery(query)); @@ -49,7 +48,6 @@ export async function addToRichHistory( let richHistoryStorageFull = false; let limitExceeded = false; let warning: RichHistoryStorageWarningDetails | undefined; - let newRichHistory: RichHistoryQuery; try { const result = await getRichHistoryStorage().addToRichHistory({ @@ -60,7 +58,6 @@ export async function addToRichHistory( comment: comment ?? '', }); warning = result.warning; - newRichHistory = result.richHistoryQuery; } catch (error) { if (error.name === RichHistoryServiceError.StorageFull) { richHistoryStorageFull = true; @@ -69,7 +66,7 @@ export async function addToRichHistory( dispatch(notifyApp(createErrorNotification('Rich History update failed', error.message))); } // Saving failed. Do not add new entry. - return { richHistory, richHistoryStorageFull, limitExceeded }; + return { richHistoryStorageFull, limitExceeded }; } // Limit exceeded but new entry was added. Notify that old entries have been removed. @@ -78,12 +75,11 @@ export async function addToRichHistory( showLimitExceededWarning && dispatch(notifyApp(createWarningNotification(warning.message))); } - // Saving successful - add new entry. - return { richHistory: [newRichHistory, ...richHistory], richHistoryStorageFull, limitExceeded }; + return { richHistoryStorageFull, limitExceeded }; } - // Nothing to save - return { richHistory }; + // Nothing to change + return {}; } export async function getRichHistory(): Promise { @@ -94,41 +90,31 @@ export async function deleteAllFromRichHistory(): Promise { return getRichHistoryStorage().deleteAll(); } -export async function updateStarredInRichHistory(richHistory: RichHistoryQuery[], id: string, starred: boolean) { +export async function updateStarredInRichHistory(id: string, starred: boolean) { try { - const updatedQuery = await getRichHistoryStorage().updateStarred(id, starred); - return richHistory.map((query) => (query.id === id ? updatedQuery : query)); + return await getRichHistoryStorage().updateStarred(id, starred); } catch (error) { dispatch(notifyApp(createErrorNotification('Saving rich history failed', error.message))); - return richHistory; + return undefined; } } -export async function updateCommentInRichHistory( - richHistory: RichHistoryQuery[], - id: string, - newComment: string | undefined -) { +export async function updateCommentInRichHistory(id: string, newComment: string | undefined) { try { - const updatedQuery = await getRichHistoryStorage().updateComment(id, newComment); - return richHistory.map((query) => (query.id === id ? updatedQuery : query)); + return await getRichHistoryStorage().updateComment(id, newComment); } catch (error) { dispatch(notifyApp(createErrorNotification('Saving rich history failed', error.message))); - return richHistory; + return undefined; } } -export async function deleteQueryInRichHistory( - richHistory: RichHistoryQuery[], - id: string -): Promise { - const updatedHistory = richHistory.filter((query) => query.id !== id); +export async function deleteQueryInRichHistory(id: string) { try { await getRichHistoryStorage().deleteRichHistory(id); - return updatedHistory; + return id; } catch (error) { dispatch(notifyApp(createErrorNotification('Saving rich history failed', error.message))); - return richHistory; + return undefined; } } diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 3a082c19a07..890c6c86075 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -88,7 +88,7 @@ export class PanelChrome extends PureComponent { onAnnotationCreate: this.onAnnotationCreate, onAnnotationUpdate: this.onAnnotationUpdate, onAnnotationDelete: this.onAnnotationDelete, - canAddAnnotations: () => Boolean(props.dashboard.meta.canEdit || props.dashboard.meta.canMakeEditable), + canAddAnnotations: this.canAddAnnotation, onInstanceStateChange: this.onInstanceStateChange, onToggleLegendSort: this.onToggleLegendSort, canEditAnnotations: this.canEditAnnotation, @@ -98,6 +98,17 @@ export class PanelChrome extends PureComponent { }; } + canEditDashboard = () => Boolean(this.props.dashboard.meta.canEdit || this.props.dashboard.meta.canMakeEditable); + + canAddAnnotation = () => { + let canAdd = true; + + if (contextSrv.accessControlEnabled()) { + canAdd = !!this.props.dashboard.meta.annotationsPermissions?.dashboard.canAdd; + } + return canAdd && this.canEditDashboard(); + }; + canEditAnnotation = (dashboardId: number) => { let canEdit = true; @@ -108,7 +119,7 @@ export class PanelChrome extends PureComponent { canEdit = !!this.props.dashboard.meta.annotationsPermissions?.organization.canEdit; } } - return canEdit && Boolean(this.props.dashboard.meta.canEdit || this.props.dashboard.meta.canMakeEditable); + return canEdit && this.canEditDashboard(); }; canDeleteAnnotation = (dashboardId: number) => { @@ -121,7 +132,7 @@ export class PanelChrome extends PureComponent { canDelete = !!this.props.dashboard.meta.annotationsPermissions?.organization.canDelete; } } - return canDelete && Boolean(this.props.dashboard.meta.canEdit || this.props.dashboard.meta.canMakeEditable); + return canDelete && this.canEditDashboard(); }; // Due to a mutable panel model we get the sync settings via function that proactively reads from the model diff --git a/public/app/features/dashboard/state/DashboardModel.test.ts b/public/app/features/dashboard/state/DashboardModel.test.ts index 1a79a7f8aa1..764cccd8fcd 100644 --- a/public/app/features/dashboard/state/DashboardModel.test.ts +++ b/public/app/features/dashboard/state/DashboardModel.test.ts @@ -863,7 +863,7 @@ describe('DashboardModel', () => { dashboard.meta.canEdit = canEdit; dashboard.meta.canMakeEditable = canMakeEditable; - const result = dashboard.canAddAnnotations(); + const result = dashboard.canEditDashboard(); expect(result).toBe(expected); } diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index c307f004196..04776c6cbdc 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -1189,10 +1189,21 @@ export class DashboardModel implements TimeModel { canEdit = !!this.meta.annotationsPermissions?.dashboard.canEdit; } } - return this.canAddAnnotations() && canEdit; + return this.canEditDashboard() && canEdit; } canAddAnnotations() { + let canAdd = true; + + // if FGAC is enabled there are additional conditions to check + if (contextSrv.accessControlEnabled()) { + canAdd = !!this.meta.annotationsPermissions?.dashboard.canAdd; + } + + return this.canEditDashboard() && canAdd; + } + + canEditDashboard() { return this.meta.canEdit || this.meta.canMakeEditable; } diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 29ca0cf5df7..f38824de47f 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -81,6 +81,7 @@ class UnConnectedExploreToolbar extends PureComponent { return (
        ({ getExploreDatasources: jest.fn() })); @@ -21,30 +20,23 @@ const setup = (propOverrides?: Partial) => { Object.assign(props, propOverrides); - const wrapper = mount(); - return wrapper; + render(); }; describe('RichHistory', () => { - it('should render all tabs in tab bar', () => { - const wrapper = setup(); - expect(wrapper.find(Tab)).toHaveLength(3); + it('should render tabs as defined', () => { + setup(); + const tabs = screen.getAllByLabelText(/Tab*/); + expect(tabs).toHaveLength(3); + expect(tabs[0]).toHaveTextContent('Query history'); + expect(tabs[1]).toHaveTextContent('Starred'); + expect(tabs[2]).toHaveTextContent('Settings'); }); - it('should render correct lebels of tabs in tab bar', () => { - const wrapper = setup(); - expect(wrapper.find(Tab).at(0).text()).toEqual('Query history'); - expect(wrapper.find(Tab).at(1).text()).toEqual('Starred'); - expect(wrapper.find(Tab).at(2).text()).toEqual('Settings'); - }); - - it('should correctly render query history tab as active tab', () => { - const wrapper = setup(); - expect(wrapper.find('RichHistoryQueriesTab')).toHaveLength(1); - }); - - it('should correctly render starred tab as active tab', () => { - const wrapper = setup({ firstTab: Tabs.Starred }); - expect(wrapper.find('RichHistoryStarredTab')).toHaveLength(1); + it('should render defined default', () => { + setup(); + const tabs = screen.getAllByLabelText(/Tab*/); + expect(tabs[0].className).toMatch(/-*activeTabStyle/); + expect(tabs[1].className).not.toMatch(/-*activeTabStyle/); }); }); diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx index 004d5983c62..6b2a36efb64 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx @@ -1,10 +1,34 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { render, screen, fireEvent, getByText } from '@testing-library/react'; import { RichHistoryCard, Props } from './RichHistoryCard'; -import { ExploreId, RichHistoryQuery } from '../../../types/explore'; +import { ExploreId, RichHistoryQuery } from 'app/types/explore'; import { DataSourceApi, DataQuery } from '@grafana/data'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; +import { ShowConfirmModalEvent } from 'app/types/events'; +import appEvents from 'app/core/app_events'; const starRichHistoryMock = jest.fn(); +const deleteRichHistoryMock = jest.fn(); + +const mockDS = mockDataSource({ + name: 'CloudManager', + type: DataSourceType.Alertmanager, +}); + +jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => { + return { + getDataSourceSrv: () => ({ + get: () => Promise.resolve(mockDS), + getList: () => [mockDS], + getInstanceSettings: () => mockDS, + }), + }; +}); + +jest.mock('app/core/app_events', () => ({ + publish: jest.fn(), +})); interface MockQuery extends DataQuery { query: string; @@ -29,8 +53,8 @@ const setup = (propOverrides?: Partial>) => { isRemoved: false, changeDatasource: jest.fn(), starHistoryItem: starRichHistoryMock, + deleteHistoryItem: deleteRichHistoryMock, commentHistoryItem: jest.fn(), - deleteHistoryItem: jest.fn(), setQueries: jest.fn(), exploreId: ExploreId.left, datasourceInstance: { name: 'Datasource' } as DataSourceApi, @@ -38,8 +62,7 @@ const setup = (propOverrides?: Partial>) => { Object.assign(props, propOverrides); - const wrapper = shallow(); - return wrapper; + render(); }; const starredQueryWithComment: RichHistoryQuery = { @@ -56,93 +79,140 @@ const starredQueryWithComment: RichHistoryQuery = { ], }; +afterEach(() => { + jest.clearAllMocks(); +}); + describe('RichHistoryCard', () => { - it('should render all queries', () => { - const wrapper = setup(); - expect(wrapper.find({ 'aria-label': 'Query text' })).toHaveLength(3); - expect(wrapper.find({ 'aria-label': 'Query text' }).at(0).text()).toEqual('{"query":"query1"}'); - expect(wrapper.find({ 'aria-label': 'Query text' }).at(1).text()).toEqual('{"query":"query2"}'); - expect(wrapper.find({ 'aria-label': 'Query text' }).at(2).text()).toEqual('{"query":"query3"}'); + it('should render all queries', async () => { + setup(); + const queries = await screen.findAllByLabelText('Query text'); + expect(queries).toHaveLength(3); + expect(queries[0]).toHaveTextContent('query1'); + expect(queries[1]).toHaveTextContent('query2'); + expect(queries[2]).toHaveTextContent('query3'); }); - it('should render data source icon', () => { - const wrapper = setup(); - expect(wrapper.find({ 'aria-label': 'Data source icon' })).toHaveLength(1); + it('should render data source icon and name', async () => { + setup(); + const datasourceIcon = await screen.findByLabelText('Data source icon'); + const datasourceName = screen.getByLabelText('Data source name'); + expect(datasourceIcon).toBeInTheDocument(); + expect(datasourceName).toBeInTheDocument(); }); - it('should render data source name', () => { - const wrapper = setup(); - expect(wrapper.find({ 'aria-label': 'Data source name' }).text()).toEqual('Test datasource'); - }); - it('should render "Data source does not exist anymore" if removed data source', () => { - const wrapper = setup({ isRemoved: true }); - expect(wrapper.find({ 'aria-label': 'Data source name' }).text()).toEqual('Data source does not exist anymore'); + it('should render "Data source does not exist anymore" if removed data source', async () => { + setup({ isRemoved: true }); + const datasourceName = await screen.findByLabelText('Data source name'); + expect(datasourceName).toHaveTextContent('Data source does not exist anymore'); }); describe('commenting', () => { - it('should render comment, if comment present', () => { - const wrapper = setup({ query: starredQueryWithComment }); - expect(wrapper.find({ 'aria-label': 'Query comment' })).toHaveLength(1); - expect(wrapper.find({ 'aria-label': 'Query comment' }).text()).toEqual('test comment'); + it('should render comment, if comment present', async () => { + setup({ query: starredQueryWithComment }); + const queryComment = await screen.findByLabelText('Query comment'); + expect(queryComment).toBeInTheDocument(); + expect(queryComment).toHaveTextContent('test comment'); }); - it('should have title "Edit comment" at comment icon, if comment present', () => { - const wrapper = setup({ query: starredQueryWithComment }); - expect(wrapper.find({ title: 'Edit comment' })).toHaveLength(1); - expect(wrapper.find({ title: 'Add comment' })).toHaveLength(0); + it('should have title "Edit comment" at comment icon, if comment present', async () => { + setup({ query: starredQueryWithComment }); + const editComment = await screen.findByTitle('Edit comment'); + const addComment = screen.queryByTitle('Add comment'); + expect(editComment).toBeInTheDocument(); + expect(addComment).not.toBeInTheDocument(); }); - it('should have title "Add comment" at comment icon, if no comment present', () => { - const wrapper = setup(); - expect(wrapper.find({ title: 'Add comment' })).toHaveLength(1); - expect(wrapper.find({ title: 'Edit comment' })).toHaveLength(0); + it('should have title "Add comment" at comment icon, if no comment present', async () => { + setup(); + const addComment = await screen.findByTitle('Add comment'); + const editComment = await screen.queryByTitle('Edit comment'); + expect(addComment).toBeInTheDocument(); + expect(editComment).not.toBeInTheDocument(); }); - it('should open update comment form when edit comment button clicked', () => { - const wrapper = setup({ query: starredQueryWithComment }); - const editCommentButton = wrapper.find({ title: 'Edit comment' }); - editCommentButton.simulate('click'); - expect(wrapper.find({ 'aria-label': 'Update comment form' })).toHaveLength(1); + it('should open update comment form when edit comment button clicked', async () => { + setup({ query: starredQueryWithComment }); + const editComment = await screen.findByTitle('Edit comment'); + fireEvent.click(editComment); + const updateCommentForm = await screen.findByLabelText('Update comment form'); + expect(updateCommentForm).toBeInTheDocument(); }); - it('should close update comment form when escape key pressed', () => { - const wrapper = setup({ query: starredQueryWithComment }); - const editCommentButton = wrapper.find({ title: 'Edit comment' }); - editCommentButton.simulate('click'); - wrapper.simulate('keydown', { key: 'Escape' }); - expect(wrapper.find({ 'aria-label': 'Update comment form' })).toHaveLength(0); + it('should close update comment form when escape key pressed', async () => { + setup({ query: starredQueryWithComment }); + const editComment = await screen.findByTitle('Edit comment'); + fireEvent.click(editComment); + const updateCommentForm = await screen.findByLabelText('Update comment form'); + fireEvent.keyDown(getByText(updateCommentForm, starredQueryWithComment.comment), { + key: 'Escape', + }); + const findCommentForm = screen.queryByLabelText('Update comment form'); + expect(findCommentForm).not.toBeInTheDocument(); }); - it('should close update comment form when enter and shift keys pressed', () => { - const wrapper = setup({ query: starredQueryWithComment }); - const editCommentButton = wrapper.find({ title: 'Edit comment' }); - editCommentButton.simulate('click'); - wrapper.simulate('keydown', { key: 'Enter', shiftKey: true }); - expect(wrapper.find({ 'aria-label': 'Update comment form' })).toHaveLength(0); + it('should close update comment form when enter and shift keys pressed', async () => { + setup({ query: starredQueryWithComment }); + const editComment = await screen.findByTitle('Edit comment'); + fireEvent.click(editComment); + const updateCommentForm = await screen.findByLabelText('Update comment form'); + fireEvent.keyDown(getByText(updateCommentForm, starredQueryWithComment.comment), { + key: 'Enter', + shiftKey: true, + }); + const findCommentForm = screen.queryByLabelText('Update comment form'); + expect(findCommentForm).not.toBeInTheDocument(); }); - it('should close update comment form when enter and ctrl keys pressed', () => { - const wrapper = setup({ query: starredQueryWithComment }); - const editCommentButton = wrapper.find({ title: 'Edit comment' }); - editCommentButton.simulate('click'); - wrapper.simulate('keydown', { key: 'Enter', ctrlKey: true }); - expect(wrapper.find({ 'aria-label': 'Update comment form' })).toHaveLength(0); + it('should close update comment form when enter and ctrl keys pressed', async () => { + setup({ query: starredQueryWithComment }); + const editComment = await screen.findByTitle('Edit comment'); + fireEvent.click(editComment); + const updateCommentForm = await screen.findByLabelText('Update comment form'); + fireEvent.keyDown(getByText(updateCommentForm, starredQueryWithComment.comment), { + key: 'Enter', + ctrlKey: true, + }); + const findCommentForm = screen.queryByLabelText('Update comment form'); + expect(findCommentForm).not.toBeInTheDocument(); }); - it('should not close update comment form when enter key pressed', () => { - const wrapper = setup({ query: starredQueryWithComment }); - const editCommentButton = wrapper.find({ title: 'Edit comment' }); - editCommentButton.simulate('click'); - wrapper.simulate('keydown', { key: 'Enter', shiftKey: false }); - expect(wrapper.find({ 'aria-label': 'Update comment form' })).toHaveLength(1); + it('should not close update comment form when enter key pressed', async () => { + setup({ query: starredQueryWithComment }); + const editComment = await screen.findByTitle('Edit comment'); + fireEvent.click(editComment); + const updateCommentForm = await screen.findByLabelText('Update comment form'); + fireEvent.keyDown(getByText(updateCommentForm, starredQueryWithComment.comment), { + key: 'Enter', + shiftKey: false, + }); + const findCommentForm = screen.queryByLabelText('Update comment form'); + expect(findCommentForm).toBeInTheDocument(); }); }); describe('starring', () => { - it('should have title "Star query", if not starred', () => { - const wrapper = setup(); - const starButton = wrapper.find({ title: 'Star query' }); - expect(starButton).toHaveLength(1); - starButton.simulate('click'); + it('should have title "Star query", if not starred', async () => { + setup(); + const starButton = await screen.findByTitle('Star query'); + expect(starButton).toBeInTheDocument(); + fireEvent.click(starButton); expect(starRichHistoryMock).toBeCalledWith(starredQueryWithComment.id, true); }); - it('should have title "Unstar query", if not starred', () => { - const wrapper = setup({ query: starredQueryWithComment }); - const starButton = wrapper.find({ title: 'Unstar query' }); - expect(starButton).toHaveLength(1); - starButton.simulate('click'); + it('should have title "Unstar query", if not starred', async () => { + setup({ query: starredQueryWithComment }); + const unstarButton = await screen.findByTitle('Unstar query'); + expect(unstarButton).toBeInTheDocument(); + fireEvent.click(unstarButton); expect(starRichHistoryMock).toBeCalledWith(starredQueryWithComment.id, false); }); }); + + describe('deleting', () => { + it('should delete if not starred', async () => { + setup(); + const deleteButton = await screen.findByTitle('Delete query'); + expect(deleteButton).toBeInTheDocument(); + fireEvent.click(deleteButton); + expect(deleteRichHistoryMock).toBeCalledWith(starredQueryWithComment.id); + }); + it('should display modal before deleting if starred', async () => { + setup({ query: starredQueryWithComment }); + const deleteButton = await screen.findByTitle('Delete query'); + fireEvent.click(deleteButton); + expect(deleteRichHistoryMock).not.toBeCalled(); + expect(appEvents.publish).toHaveBeenCalledWith(new ShowConfirmModalEvent(expect.anything())); + }); + }); }); diff --git a/public/app/features/explore/RichHistory/RichHistoryContainer.test.tsx b/public/app/features/explore/RichHistory/RichHistoryContainer.test.tsx index e92049c22dc..03625e75ff5 100644 --- a/public/app/features/explore/RichHistory/RichHistoryContainer.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryContainer.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { mount } from 'enzyme'; -import { Resizable } from 're-resizable'; +import { render } from '@testing-library/react'; import { ExploreId } from '../../../types/explore'; import { RichHistoryContainer, Props } from './RichHistoryContainer'; @@ -16,26 +15,33 @@ const setup = (propOverrides?: Partial) => { richHistory: [], firstTab: Tabs.RichHistory, deleteRichHistory: jest.fn(), + loadRichHistory: jest.fn(), onClose: jest.fn(), }; Object.assign(props, propOverrides); - const wrapper = mount(); - return wrapper; + return render(); }; describe('RichHistoryContainer', () => { - it('should render reseizable component', () => { - const wrapper = setup(); - expect(wrapper.find(Resizable)).toHaveLength(1); - }); it('should render component with correct width', () => { - const wrapper = setup(); - expect(wrapper.getDOMNode().getAttribute('style')).toContain('width: 531.5px'); + const { container } = setup(); + expect(container.firstElementChild!.getAttribute('style')).toContain('width: 531.5px'); }); it('should render component with correct height', () => { - const wrapper = setup(); - expect(wrapper.getDOMNode().getAttribute('style')).toContain('height: 400px'); + const { container } = setup(); + expect(container.firstElementChild!.getAttribute('style')).toContain('height: 400px'); + }); + it('should re-request rich history every time the component is mounted', () => { + const loadRichHistory = jest.fn(); + const { unmount } = setup({ loadRichHistory }); + expect(loadRichHistory).toBeCalledTimes(1); + + unmount(); + expect(loadRichHistory).toBeCalledTimes(1); + + setup({ loadRichHistory }); + expect(loadRichHistory).toBeCalledTimes(2); }); }); diff --git a/public/app/features/explore/RichHistory/RichHistoryContainer.tsx b/public/app/features/explore/RichHistory/RichHistoryContainer.tsx index c68162f9834..aa13771e438 100644 --- a/public/app/features/explore/RichHistory/RichHistoryContainer.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryContainer.tsx @@ -1,5 +1,5 @@ // Libraries -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; // Services & Utils @@ -14,7 +14,7 @@ import { ExploreId } from 'app/types/explore'; import { RichHistory, Tabs } from './RichHistory'; //Actions -import { deleteRichHistory } from '../state/history'; +import { deleteRichHistory, loadRichHistory } from '../state/history'; import { ExploreDrawer } from '../ExploreDrawer'; function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { @@ -25,7 +25,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreI const firstTab = store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false) ? Tabs.Starred : Tabs.RichHistory; - const { richHistory } = explore; + const { richHistory } = item; return { richHistory, firstTab, @@ -34,6 +34,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreI } const mapDispatchToProps = { + loadRichHistory, deleteRichHistory, }; @@ -49,7 +50,20 @@ export type Props = ConnectedProps & OwnProps; export function RichHistoryContainer(props: Props) { const [height, setHeight] = useState(400); - const { richHistory, width, firstTab, activeDatasourceInstance, exploreId, deleteRichHistory, onClose } = props; + const { + richHistory, + width, + firstTab, + activeDatasourceInstance, + exploreId, + deleteRichHistory, + loadRichHistory, + onClose, + } = props; + + useEffect(() => { + loadRichHistory(exploreId); + }, [loadRichHistory, exploreId]); return ( ({ getExploreDatasources: jest.fn() })); - -const setup = (propOverrides?: Partial) => { - const props: Props = { - queries: [], - sortOrder: SortOrder.Ascending, - activeDatasourceOnly: false, - datasourceFilters: [], - retentionPeriod: 14, - height: 100, - exploreId: ExploreId.left, - onChangeSortOrder: jest.fn(), - onSelectDatasourceFilters: jest.fn(), - }; - - Object.assign(props, propOverrides); - - const wrapper = mount(); - return wrapper; -}; - -describe('RichHistoryQueriesTab', () => { - describe('slider', () => { - it('should render slider', () => { - const wrapper = setup(); - expect(wrapper.find(RangeSlider)).toHaveLength(1); - }); - it('should render slider with correct timerange', () => { - const wrapper = setup(); - expect(wrapper.find('.label-slider').at(1).text()).toEqual('today'); - expect(wrapper.find('.label-slider').at(2).text()).toEqual('two weeks ago'); - }); - }); - - describe('sort options', () => { - it('should render sorter', () => { - const wrapper = setup(); - expect(wrapper.find({ 'aria-label': 'Sort queries' })).toHaveLength(1); - }); - }); - - describe('select datasource', () => { - it('should render select datasource if activeDatasourceOnly is false', () => { - const wrapper = setup(); - expect(wrapper.find({ 'aria-label': 'Filter datasources' })).toHaveLength(1); - }); - it('should not render select datasource if activeDatasourceOnly is true', () => { - const wrapper = setup({ activeDatasourceOnly: true }); - expect(wrapper.find({ 'aria-label': 'Filter datasources' })).toHaveLength(0); - }); - }); -}); diff --git a/public/app/features/explore/RichHistory/RichHistorySettings.test.tsx b/public/app/features/explore/RichHistory/RichHistorySettings.test.tsx index 9b205049c5e..16f80c5a81b 100644 --- a/public/app/features/explore/RichHistory/RichHistorySettings.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistorySettings.test.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import { mount } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import { RichHistorySettings, RichHistorySettingsProps } from './RichHistorySettings'; -import { Select, Switch } from '@grafana/ui'; const setup = (propOverrides?: Partial) => { const props: RichHistorySettingsProps = { @@ -16,21 +15,19 @@ const setup = (propOverrides?: Partial) => { Object.assign(props, propOverrides); - const wrapper = mount(); - return wrapper; + return render(); }; describe('RichHistorySettings', () => { it('should render component with correct retention period', () => { - const wrapper = setup(); - expect(wrapper.find(Select).text()).toEqual('2 weeks'); + setup(); + expect(screen.queryByText('2 weeks')).toBeInTheDocument(); }); - it('should render component with correctly checked starredTabAsFirstTab settings', () => { - const wrapper = setup(); - expect(wrapper.find(Switch).at(0).prop('value')).toBe(true); - }); - it('should render component with correctly not checked toggleactiveDatasourceOnly settings', () => { - const wrapper = setup(); - expect(wrapper.find(Switch).at(1).prop('value')).toBe(false); + it('should render component with correctly checked starredTabAsFirstTab and uncheched toggleActiveDatasourceOnly settings', () => { + setup(); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBe(2); + expect(checkboxes[0]).toHaveAttribute('checked'); + expect(checkboxes[1]).not.toHaveAttribute('checked'); }); }); diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts index 9ace5203e94..58b4a7d2145 100644 --- a/public/app/features/explore/TraceView/createSpanLink.test.ts +++ b/public/app/features/explore/TraceView/createSpanLink.test.ts @@ -13,11 +13,11 @@ describe('createSpanLinkFactory', () => { expect(createLink).not.toBeDefined(); }); - describe('should return link', () => { + describe('should return loki link', () => { beforeAll(() => { setDataSourceSrv({ getInstanceSettings(uid: string): DataSourceInstanceSettings | undefined { - return { uid: 'loki1', name: 'loki1' } as any; + return { uid: 'loki1', name: 'loki1', type: 'loki' } as any; }, } as any); @@ -215,14 +215,140 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeUndefined(); }); }); + + describe('should return splunk link', () => { + const splunkUID = 'splunkUID'; + + beforeAll(() => { + setDataSourceSrv({ + getInstanceSettings(uid: string): DataSourceInstanceSettings | undefined { + return { uid: splunkUID, name: 'Splunk 8', type: 'grafana-splunk-datasource' } as any; + }, + } as any); + + setLinkSrv(new LinkSrv()); + setTemplateSrv(new TemplateSrv()); + }); + + it('the `query` keyword is used in the link rather than `expr` that loki uses', () => { + const createLink = setupSpanLinkFactory({ + datasourceUid: splunkUID, + }); + const linkDef = createLink!(createTraceSpan()); + expect(linkDef!.href).toContain(`${encodeURIComponent('datasource":"Splunk 8","queries":[{"query"')}`); + expect(linkDef!.href).not.toContain(`${encodeURIComponent('datasource":"Splunk 8","queries":[{"expr"')}`); + }); + + it('automatically timeshifts the timerange by one second in a splunk query', () => { + const createLink = setupSpanLinkFactory({ + datasourceUid: splunkUID, + }); + const linkDef = createLink!(createTraceSpan()); + expect(linkDef!.href).toContain( + `${encodeURIComponent('{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"}')}` + ); + expect(linkDef!.href).not.toContain( + `${encodeURIComponent('{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:00.000Z"}')}` + ); + }); + + it('formats query correctly if filterByTraceID and or filterBySpanID is true', () => { + const createLink = setupSpanLinkFactory({ + datasourceUid: splunkUID, + filterByTraceID: true, + filterBySpanID: true, + }); + + expect(createLink).toBeDefined(); + const linkDef = createLink!(createTraceSpan()); + + expect(linkDef!.href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"Splunk 8","queries":[{"query":"cluster=\\"cluster1\\" hostname=\\"hostname1\\" \\"7946b05c2e2e4e5a\\" \\"6605c7b08e715d6c\\"","refId":""}],"panelsState":{}}' + )}` + ); + }); + + it('should format one tag correctly', () => { + const createLink = setupSpanLinkFactory({ + tags: ['ip'], + }); + expect(createLink).toBeDefined(); + const linkDef = createLink!( + createTraceSpan({ + process: { + serviceName: 'service', + tags: [{ key: 'ip', value: '192.168.0.1' }], + }, + }) + ); + + expect(linkDef!.href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"Splunk 8","queries":[{"query":"ip=\\"192.168.0.1\\"","refId":""}],"panelsState":{}}' + )}` + ); + }); + + it('should format multiple tags correctly', () => { + const createLink = setupSpanLinkFactory({ + tags: ['ip', 'hostname'], + }); + expect(createLink).toBeDefined(); + const linkDef = createLink!( + createTraceSpan({ + process: { + serviceName: 'service', + tags: [ + { key: 'hostname', value: 'hostname1' }, + { key: 'ip', value: '192.168.0.1' }, + ], + }, + }) + ); + + expect(linkDef!.href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"Splunk 8","queries":[{"query":"hostname=\\"hostname1\\" ip=\\"192.168.0.1\\"","refId":""}],"panelsState":{}}' + )}` + ); + }); + + it('handles renamed tags', () => { + const createLink = setupSpanLinkFactory({ + mapTagNamesEnabled: true, + mappedTags: [ + { key: 'service.name', value: 'service' }, + { key: 'k8s.pod.name', value: 'pod' }, + ], + }); + expect(createLink).toBeDefined(); + const linkDef = createLink!( + createTraceSpan({ + process: { + serviceName: 'service', + tags: [ + { key: 'service.name', value: 'serviceName' }, + { key: 'k8s.pod.name', value: 'podName' }, + ], + }, + }) + ); + expect(linkDef!.href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"Splunk 8","queries":[{"query":"service=\\"serviceName\\" pod=\\"podName\\"","refId":""}],"panelsState":{}}' + )}` + ); + }); + }); }); -function setupSpanLinkFactory(options: Partial = {}) { +function setupSpanLinkFactory(options: Partial = {}, datasourceUid = 'lokiUid') { const splitOpenFn = jest.fn(); return createSpanLinkFactory({ splitOpenFn, traceToLogsOptions: { - datasourceUid: 'lokiUid', + datasourceUid, ...options, }, }); diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx index 5c74fecb728..5bfcd690816 100644 --- a/public/app/features/explore/TraceView/createSpanLink.tsx +++ b/public/app/features/explore/TraceView/createSpanLink.tsx @@ -1,9 +1,12 @@ import { DataFrame, DataLink, + DataQuery, + DataSourceInstanceSettings, dateTime, Field, KeyValue, + LinkModel, mapInternalLinkToExplore, rangeUtil, SplitOpen, @@ -70,6 +73,7 @@ function legacyCreateSpanLinkFactory(splitOpenFn: SplitOpen, traceToLogsOptions? } const dataSourceSettings = getDatasourceSrv().getInstanceSettings(traceToLogsOptions.datasourceUid); + const isSplunkDS = dataSourceSettings?.type === 'grafana-splunk-datasource'; if (!dataSourceSettings) { return undefined; @@ -80,35 +84,37 @@ function legacyCreateSpanLinkFactory(splitOpenFn: SplitOpen, traceToLogsOptions? // the moment. Issue is that the trace itself isn't clearly mapped to dataFrame (right now it's just a json blob // inside a single field) so the dataLinks as config of that dataFrame abstraction breaks down a bit and we do // it manually here instead of leaving it for the data source to supply the config. + let dataLink: DataLink | undefined = {} as DataLink | undefined; + let link: LinkModel; - const expr = getLokiQueryFromSpan(span, traceToLogsOptions); - if (!expr) { - return undefined; + switch (dataSourceSettings?.type) { + case 'loki': + dataLink = getLinkForLoki(span, traceToLogsOptions, dataSourceSettings); + if (!dataLink) { + return undefined; + } + break; + case 'grafana-splunk-datasource': + dataLink = getLinkForSplunk(span, traceToLogsOptions, dataSourceSettings); + break; + default: + return undefined; } - const dataLink: DataLink = { - title: dataSourceSettings.name, - url: '', - internal: { - datasourceUid: dataSourceSettings.uid, - datasourceName: dataSourceSettings.name, - query: { - expr, - refId: '', - }, - }, - }; - - const link = mapInternalLinkToExplore({ + link = mapInternalLinkToExplore({ link: dataLink, - internalLink: dataLink.internal!, + internalLink: dataLink?.internal!, scopedVars: {}, - range: getTimeRangeFromSpan(span, { - startMs: traceToLogsOptions.spanStartTimeShift - ? rangeUtil.intervalToMs(traceToLogsOptions.spanStartTimeShift) - : 0, - endMs: traceToLogsOptions.spanEndTimeShift ? rangeUtil.intervalToMs(traceToLogsOptions.spanEndTimeShift) : 0, - }), + range: getTimeRangeFromSpan( + span, + { + startMs: traceToLogsOptions.spanStartTimeShift + ? rangeUtil.intervalToMs(traceToLogsOptions.spanStartTimeShift) + : 0, + endMs: traceToLogsOptions.spanEndTimeShift ? rangeUtil.intervalToMs(traceToLogsOptions.spanEndTimeShift) : 0, + }, + isSplunkDS + ), field: {} as Field, onClickFn: splitOpenFn, replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), @@ -126,13 +132,11 @@ function legacyCreateSpanLinkFactory(splitOpenFn: SplitOpen, traceToLogsOptions? * Default keys to use when there are no configured tags. */ const defaultKeys = ['cluster', 'hostname', 'namespace', 'pod']; - -function getLokiQueryFromSpan(span: TraceSpan, options: TraceToLogsOptions): string | undefined { +function getLinkForLoki(span: TraceSpan, options: TraceToLogsOptions, dataSourceSettings: DataSourceInstanceSettings) { const { tags: keys, filterByTraceID, filterBySpanID, mapTagNamesEnabled, mappedTags } = options; // In order, try to use mapped tags -> tags -> default tags const keysToCheck = mapTagNamesEnabled && mappedTags?.length ? mappedTags : keys?.length ? keys : defaultKeys; - // Build tag portion of query const tags = [...span.process.tags, ...span.tags].reduce((acc, tag) => { if (mapTagNamesEnabled) { @@ -152,17 +156,79 @@ function getLokiQueryFromSpan(span: TraceSpan, options: TraceToLogsOptions): str if (!tags.length) { return undefined; } - - let query = `{${tags.join(', ')}}`; - + let expr = `{${tags.join(', ')}}`; if (filterByTraceID && span.traceID) { - query += ` |="${span.traceID}"`; + expr += ` |="${span.traceID}"`; } if (filterBySpanID && span.spanID) { - query += ` |="${span.spanID}"`; + expr += ` |="${span.spanID}"`; } - return query; + const dataLink: DataLink = { + title: dataSourceSettings.name, + url: '', + internal: { + datasourceUid: dataSourceSettings.uid, + datasourceName: dataSourceSettings.name, + query: { + expr: expr, + refId: '', + }, + }, + }; + + return dataLink; +} + +function getLinkForSplunk( + span: TraceSpan, + options: TraceToLogsOptions, + dataSourceSettings: DataSourceInstanceSettings +) { + const { tags: keys, filterByTraceID, filterBySpanID, mapTagNamesEnabled, mappedTags } = options; + + // In order, try to use mapped tags -> tags -> default tags + const keysToCheck = mapTagNamesEnabled && mappedTags?.length ? mappedTags : keys?.length ? keys : defaultKeys; + // Build tag portion of query + const tags = [...span.process.tags, ...span.tags].reduce((acc, tag) => { + if (mapTagNamesEnabled) { + const keyValue = (keysToCheck as KeyValue[]).find((keyValue: KeyValue) => keyValue.key === tag.key); + if (keyValue) { + acc.push(`${keyValue.value ? keyValue.value : keyValue.key}="${tag.value}"`); + } + } else { + if ((keysToCheck as string[]).includes(tag.key)) { + acc.push(`${tag.key}="${tag.value}"`); + } + } + return acc; + }, [] as string[]); + + let query = ''; + if (tags.length > 0) { + query += `${tags.join(' ')}`; + } + if (filterByTraceID && span.traceID) { + query += ` "${span.traceID}"`; + } + if (filterBySpanID && span.spanID) { + query += ` "${span.spanID}"`; + } + + const dataLink: DataLink = { + title: dataSourceSettings.name, + url: '', + internal: { + datasourceUid: dataSourceSettings.uid, + datasourceName: dataSourceSettings.name, + query: { + query: query, + refId: '', + }, + }, + } as DataLink; + + return dataLink; } /** @@ -170,18 +236,23 @@ function getLokiQueryFromSpan(span: TraceSpan, options: TraceToLogsOptions): str */ function getTimeRangeFromSpan( span: TraceSpan, - timeShift: { startMs: number; endMs: number } = { startMs: 0, endMs: 0 } + timeShift: { startMs: number; endMs: number } = { startMs: 0, endMs: 0 }, + isSplunkDS = false ): TimeRange { const adjustedStartTime = Math.floor(span.startTime / 1000 + timeShift.startMs); const from = dateTime(adjustedStartTime); const spanEndMs = (span.startTime + span.duration) / 1000; let adjustedEndTime = Math.floor(spanEndMs + timeShift.endMs); - // Because we can only pass milliseconds in the url we need to check if they equal. - // We need end time to be later than start time - if (adjustedStartTime === adjustedEndTime) { + // Splunk requires a time interval of >= 1s, rather than >=1ms like Loki timerange in below elseif block + if (isSplunkDS && adjustedEndTime - adjustedStartTime < 1000) { + adjustedEndTime = adjustedStartTime + 1000; + } else if (adjustedStartTime === adjustedEndTime) { + // Because we can only pass milliseconds in the url we need to check if they equal. + // We need end time to be later than start time adjustedEndTime++; } + const to = dateTime(adjustedEndTime); // Beware that public/app/features/explore/state/main.ts SplitOpen fn uses the range from here. No matter what is in the url. diff --git a/public/app/features/explore/Wrapper.test.tsx b/public/app/features/explore/Wrapper.test.tsx index 09ff2fef6ed..d26b026620b 100644 --- a/public/app/features/explore/Wrapper.test.tsx +++ b/public/app/features/explore/Wrapper.test.tsx @@ -66,7 +66,7 @@ describe('Wrapper', () => { range: { from: 'now-1h', to: 'now' }, }), }; - const { datasources, store } = setupExplore({ urlParams }); + const { datasources } = setupExplore({ urlParams }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); // Make sure we render the logs panel @@ -84,11 +84,6 @@ describe('Wrapper', () => { ...urlParams, }); - expect(store.getState().explore.richHistory[0]).toMatchObject({ - datasourceName: 'loki', - queries: [{ expr: '{ label="value"}', refId: 'A' }], - }); - // We called the data source query method once expect(datasources.loki.query).toBeCalledTimes(1); expect((datasources.loki.query as Mock).mock.calls[0][0]).toMatchObject({ diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx index 4a6d46a0458..4f2d5439ea4 100644 --- a/public/app/features/explore/Wrapper.tsx +++ b/public/app/features/explore/Wrapper.tsx @@ -3,7 +3,6 @@ import { connect, ConnectedProps } from 'react-redux'; import { ExploreId, ExploreQueryParams } from 'app/types/explore'; import { ErrorBoundaryAlert } from '@grafana/ui'; import { lastSavedUrl, resetExploreAction, richHistoryUpdatedAction } from './state/main'; -import { getRichHistory } from '../../core/utils/richHistory'; import { ExplorePaneContainer } from './ExplorePaneContainer'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { Branding } from '../../core/components/Branding/Branding'; @@ -52,10 +51,6 @@ class WrapperUnconnected extends PureComponent { if (searchParams.from || searchParams.to) { locationService.partial({ from: undefined, to: undefined }, true); } - - getRichHistory().then((richHistory) => { - this.props.richHistoryUpdatedAction({ richHistory }); - }); } componentDidUpdate(prevProps: Props) { diff --git a/public/app/features/explore/spec/helper/assert.ts b/public/app/features/explore/spec/helper/assert.ts index e0ec61f94f1..353278aa21a 100644 --- a/public/app/features/explore/spec/helper/assert.ts +++ b/public/app/features/explore/spec/helper/assert.ts @@ -1,7 +1,32 @@ -import { screen } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; +import { ExploreId } from '../../../../types'; +import { withinExplore } from './setup'; -export const assertQueryHistoryExists = (query: string) => { - expect(screen.getByText('1 queries')).toBeInTheDocument(); - const queryItem = screen.getByLabelText('Query text'); +export const assertQueryHistoryExists = (query: string, exploreId: ExploreId = ExploreId.left) => { + const selector = withinExplore(exploreId); + + expect(selector.getByText('1 queries')).toBeInTheDocument(); + const queryItem = selector.getByLabelText('Query text'); expect(queryItem).toHaveTextContent(query); }; + +export const assertQueryHistory = async (expectedQueryTexts: string[], exploreId: ExploreId = ExploreId.left) => { + const selector = withinExplore(exploreId); + await waitFor(() => { + expect(selector.getByText(`${expectedQueryTexts.length} queries`)).toBeInTheDocument(); + const queryTexts = selector.getAllByLabelText('Query text'); + expectedQueryTexts.forEach((expectedQueryText, queryIndex) => { + expect(queryTexts[queryIndex]).toHaveTextContent(expectedQueryText); + }); + }); +}; + +export const assertQueryHistoryIsStarred = async (expectedStars: boolean[], exploreId: ExploreId = ExploreId.left) => { + const selector = withinExplore(exploreId); + const starButtons = selector.getAllByRole('button', { name: /Star query|Unstar query/ }); + await waitFor(() => + expectedStars.forEach((starred, queryIndex) => { + expect(starButtons[queryIndex]).toHaveAccessibleName(starred ? 'Unstar query' : 'Star query'); + }) + ); +}; diff --git a/public/app/features/explore/spec/helper/interactions.ts b/public/app/features/explore/spec/helper/interactions.ts index 50675cac69c..df8f51fb0bc 100644 --- a/public/app/features/explore/spec/helper/interactions.ts +++ b/public/app/features/explore/spec/helper/interactions.ts @@ -1,6 +1,8 @@ import { selectors } from '@grafana/e2e-selectors'; import userEvent from '@testing-library/user-event'; -import { fireEvent, screen } from '@testing-library/react'; +import { fireEvent, screen, within } from '@testing-library/react'; +import { ExploreId } from '../../../../types'; +import { withinExplore } from './setup'; export const changeDatasource = async (name: string) => { const datasourcePicker = (await screen.findByLabelText(selectors.components.DataSourcePicker.container)).children[0]; @@ -9,20 +11,38 @@ export const changeDatasource = async (name: string) => { fireEvent.click(option); }; -export const inputQuery = (query: string) => { - const input = screen.getByRole('textbox', { name: 'query' }); +export const inputQuery = (query: string, exploreId: ExploreId = ExploreId.left) => { + const input = withinExplore(exploreId).getByRole('textbox', { name: 'query' }); + userEvent.clear(input); userEvent.type(input, query); }; -export const runQuery = () => { - const button = screen.getByRole('button', { name: /run query/i }); +export const runQuery = (exploreId: ExploreId = ExploreId.left) => { + const explore = withinExplore(exploreId); + const toolbar = within(explore.getByLabelText('Explore toolbar')); + const button = toolbar.getByRole('button', { name: /run query/i }); userEvent.click(button); }; -export const openQueryHistory = async () => { - const button = screen.getByRole('button', { name: 'Rich history button' }); +export const openQueryHistory = async (exploreId: ExploreId = ExploreId.left) => { + const selector = withinExplore(exploreId); + const button = selector.getByRole('button', { name: 'Rich history button' }); userEvent.click(button); expect( - await screen.findByText('The history is local to your browser and is not shared with others.') + await selector.findByText('The history is local to your browser and is not shared with others.') ).toBeInTheDocument(); }; + +export const starQueryHistory = (queryIndex: number, exploreId: ExploreId = ExploreId.left) => { + invokeAction(queryIndex, 'Star query', exploreId); +}; + +export const deleteQueryHistory = (queryIndex: number, exploreId: ExploreId = ExploreId.left) => { + invokeAction(queryIndex, 'Delete query', exploreId); +}; + +const invokeAction = (queryIndex: number, actionAccessibleName: string, exploreId: ExploreId) => { + const selector = withinExplore(exploreId); + const buttons = selector.getAllByRole('button', { name: actionAccessibleName }); + userEvent.click(buttons[queryIndex]); +}; diff --git a/public/app/features/explore/spec/helper/setup.tsx b/public/app/features/explore/spec/helper/setup.tsx index 81e292cceac..2d3b243c8a2 100644 --- a/public/app/features/explore/spec/helper/setup.tsx +++ b/public/app/features/explore/spec/helper/setup.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import { within } from '@testing-library/dom'; import { EnhancedStore } from '@reduxjs/toolkit'; import { Provider } from 'react-redux'; import { Route, Router } from 'react-router-dom'; @@ -16,6 +17,7 @@ import { initialUserState } from '../../../profile/state/reducers'; import { LokiDatasource } from '../../../../plugins/datasource/loki/datasource'; import { LokiQuery } from '../../../../plugins/datasource/loki/types'; +import { ExploreId } from '../../../../types'; type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceApi }; @@ -31,6 +33,7 @@ export function setupExplore(options?: SetupOptions): { datasources: { [name: string]: DataSourceApi }; store: EnhancedStore; unmount: () => void; + container: HTMLElement; } { // Clear this up otherwise it persists data source selection // TODO: probably add test for that too @@ -87,7 +90,7 @@ export function setupExplore(options?: SetupOptions): { const route = { component: Wrapper }; - const { unmount } = render( + const { unmount, container } = render( } /> @@ -95,7 +98,7 @@ export function setupExplore(options?: SetupOptions): { ); - return { datasources: fromPairs(dsSettings.map((d) => [d.api.name, d.api])), store, unmount }; + return { datasources: fromPairs(dsSettings.map((d) => [d.api.name, d.api])), store, unmount, container }; } function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: number } = {}): DatasourceSetup { @@ -137,16 +140,21 @@ function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: nu name: name, uid: name, query: jest.fn(), - getRef: jest.fn(), + getRef: jest.fn().mockReturnValue(name), meta, } as any, }; } -export const waitForExplore = async () => { - await screen.findAllByText(/Editor/i); +export const waitForExplore = async (exploreId: ExploreId = ExploreId.left) => { + return await withinExplore(exploreId).findByText(/Editor/i); }; export const tearDown = () => { window.localStorage.clear(); }; + +export const withinExplore = (exploreId: ExploreId) => { + const container = screen.getAllByTestId('data-testid Explore'); + return within(container[exploreId === ExploreId.left ? 0 : 1]); +}; diff --git a/public/app/features/explore/spec/queryHistory.test.tsx b/public/app/features/explore/spec/queryHistory.test.tsx index 2451de00a3a..87e9f8a2bd4 100644 --- a/public/app/features/explore/spec/queryHistory.test.tsx +++ b/public/app/features/explore/spec/queryHistory.test.tsx @@ -1,8 +1,11 @@ import React from 'react'; +import { serializeStateToUrlParam } from '@grafana/data'; import { setupExplore, tearDown, waitForExplore } from './helper/setup'; -import { inputQuery, openQueryHistory, runQuery } from './helper/interactions'; -import { assertQueryHistoryExists } from './helper/assert'; +import { deleteQueryHistory, inputQuery, openQueryHistory, runQuery, starQueryHistory } from './helper/interactions'; +import { assertQueryHistory, assertQueryHistoryExists, assertQueryHistoryIsStarred } from './helper/assert'; import { makeLogsQueryResponse } from './helper/query'; +import { ExploreId } from '../../../types'; +import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; jest.mock('react-virtualized-auto-sizer', () => { return { @@ -17,6 +20,8 @@ describe('Explore: Query History', () => { const USER_INPUT = 'my query'; const RAW_QUERY = `{"expr":"${USER_INPUT}"}`; + silenceConsoleOutput(); + afterEach(() => { tearDown(); }); @@ -33,7 +38,7 @@ describe('Explore: Query History', () => { await openQueryHistory(); // the query that was run is in query history - assertQueryHistoryExists(RAW_QUERY); + await assertQueryHistoryExists(RAW_QUERY); // when Explore is opened again unmount(); @@ -42,6 +47,60 @@ describe('Explore: Query History', () => { // previously added query is in query history await openQueryHistory(); - assertQueryHistoryExists(RAW_QUERY); + await assertQueryHistoryExists(RAW_QUERY); + }); + + it('adds recently added query if the query history panel is already open', async () => { + const urlParams = { + left: serializeStateToUrlParam({ + datasource: 'loki', + queries: [{ refId: 'A', expr: 'query #1' }], + range: { from: 'now-1h', to: 'now' }, + }), + }; + + const { datasources } = setupExplore({ urlParams }); + (datasources.loki.query as jest.Mock).mockReturnValueOnce(makeLogsQueryResponse()); + await waitForExplore(); + await openQueryHistory(); + + inputQuery('query #2'); + runQuery(); + await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}']); + }); + + it('updates the state in both Explore panes', async () => { + const urlParams = { + left: serializeStateToUrlParam({ + datasource: 'loki', + queries: [{ refId: 'A', expr: 'query #1' }], + range: { from: 'now-1h', to: 'now' }, + }), + right: serializeStateToUrlParam({ + datasource: 'loki', + queries: [{ refId: 'A', expr: 'query #2' }], + range: { from: 'now-1h', to: 'now' }, + }), + }; + + const { datasources } = setupExplore({ urlParams }); + (datasources.loki.query as jest.Mock).mockReturnValue(makeLogsQueryResponse()); + await waitForExplore(); + await waitForExplore(ExploreId.right); + + // queries in history + await openQueryHistory(ExploreId.left); + await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}'], ExploreId.left); + await openQueryHistory(ExploreId.right); + await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}'], ExploreId.right); + + // star one one query + starQueryHistory(1, ExploreId.left); + await assertQueryHistoryIsStarred([false, true], ExploreId.left); + await assertQueryHistoryIsStarred([false, true], ExploreId.right); + + deleteQueryHistory(0, ExploreId.left); + await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.left); + await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.right); }); }); diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index a40de3799cb..38b07eb3f5b 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -36,7 +36,6 @@ import { import { ThunkResult } from 'app/types'; import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors'; import { getDataSourceSrv } from '@grafana/runtime'; -import { getRichHistory } from '../../../core/utils/richHistory'; import { richHistoryUpdatedAction, stateSave } from './main'; import { keybindingSrv } from 'app/core/services/keybindingSrv'; @@ -181,9 +180,6 @@ export function initializeExplore( // user to go back to previous url. dispatch(runQueries(exploreId, { replaceUrl: true })); } - - const richHistory = await getRichHistory(); - dispatch(richHistoryUpdatedAction({ richHistory })); }; } @@ -259,6 +255,13 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac state = timeReducer(state, action); state = historyReducer(state, action); + if (richHistoryUpdatedAction.match(action)) { + return { + ...state, + richHistory: action.payload.richHistory, + }; + } + if (changeSizeAction.match(action)) { const containerWidth = action.payload.width; return { ...state, containerWidth }; diff --git a/public/app/features/explore/state/helpers.ts b/public/app/features/explore/state/helpers.ts index c4c0fc84224..5e744449262 100644 --- a/public/app/features/explore/state/helpers.ts +++ b/public/app/features/explore/state/helpers.ts @@ -38,6 +38,7 @@ export const createDefaultInitialState = () => { value: 0, }, cache: [], + richHistory: [], }, }, }; diff --git a/public/app/features/explore/state/history.ts b/public/app/features/explore/state/history.ts index 1f680ef367e..a44329ba995 100644 --- a/public/app/features/explore/state/history.ts +++ b/public/app/features/explore/state/history.ts @@ -1,12 +1,14 @@ import { + addToRichHistory, deleteAllFromRichHistory, deleteQueryInRichHistory, + getRichHistory, updateCommentInRichHistory, updateStarredInRichHistory, } from 'app/core/utils/richHistory'; -import { ExploreId, ExploreItemState, ThunkResult } from 'app/types'; -import { richHistoryUpdatedAction } from './main'; -import { HistoryItem } from '@grafana/data'; +import { ExploreId, ExploreItemState, ExploreState, RichHistoryQuery, ThunkResult } from 'app/types'; +import { richHistoryLimitExceededAction, richHistoryStorageFullAction, richHistoryUpdatedAction } from './main'; +import { DataQuery, HistoryItem } from '@grafana/data'; import { AnyAction, createAction } from '@reduxjs/toolkit'; // @@ -23,31 +25,89 @@ export const historyUpdatedAction = createAction('explore // Action creators // +type SyncHistoryUpdatesOptions = { + updatedQuery?: RichHistoryQuery; + deletedId?: string; +}; + +/** + * Updates current state in both Explore panes after changing or deleting a query history item + */ +const updateRichHistoryState = ({ updatedQuery, deletedId }: SyncHistoryUpdatesOptions): ThunkResult => { + return async (dispatch, getState) => { + forEachExplorePane(getState().explore, (item, exploreId) => { + const newRichHistory = item.richHistory + // update + .map((query) => (query.id === updatedQuery?.id ? updatedQuery : query)) + // or remove + .filter((query) => query.id !== deletedId); + dispatch(richHistoryUpdatedAction({ richHistory: newRichHistory, exploreId })); + }); + }; +}; + +const forEachExplorePane = (state: ExploreState, callback: (item: ExploreItemState, exploreId: ExploreId) => void) => { + callback(state.left, ExploreId.left); + state.right && callback(state.right, ExploreId.right); +}; + +export const addHistoryItem = ( + datasourceUid: string, + datasourceName: string, + queries: DataQuery[] +): ThunkResult => { + return async (dispatch, getState) => { + const { richHistoryStorageFull, limitExceeded } = await addToRichHistory( + datasourceUid, + datasourceName, + queries, + false, + '', + !getState().explore.richHistoryStorageFull, + !getState().explore.richHistoryLimitExceededWarningShown + ); + if (richHistoryStorageFull) { + dispatch(richHistoryStorageFullAction()); + } + if (limitExceeded) { + dispatch(richHistoryLimitExceededAction()); + } + }; +}; + export const starHistoryItem = (id: string, starred: boolean): ThunkResult => { return async (dispatch, getState) => { - const nextRichHistory = await updateStarredInRichHistory(getState().explore.richHistory, id, starred); - dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); + const updatedQuery = await updateStarredInRichHistory(id, starred); + dispatch(updateRichHistoryState({ updatedQuery })); }; }; export const commentHistoryItem = (id: string, comment?: string): ThunkResult => { - return async (dispatch, getState) => { - const nextRichHistory = await updateCommentInRichHistory(getState().explore.richHistory, id, comment); - dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); + return async (dispatch) => { + const updatedQuery = await updateCommentInRichHistory(id, comment); + dispatch(updateRichHistoryState({ updatedQuery })); }; }; export const deleteHistoryItem = (id: string): ThunkResult => { - return async (dispatch, getState) => { - const nextRichHistory = await deleteQueryInRichHistory(getState().explore.richHistory, id); - dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); + return async (dispatch) => { + const deletedId = await deleteQueryInRichHistory(id); + dispatch(updateRichHistoryState({ deletedId })); }; }; export const deleteRichHistory = (): ThunkResult => { return async (dispatch) => { await deleteAllFromRichHistory(); - dispatch(richHistoryUpdatedAction({ richHistory: [] })); + dispatch(richHistoryUpdatedAction({ richHistory: [], exploreId: ExploreId.left })); + dispatch(richHistoryUpdatedAction({ richHistory: [], exploreId: ExploreId.right })); + }; +}; + +export const loadRichHistory = (exploreId: ExploreId): ThunkResult => { + return async (dispatch) => { + const richHistory = await getRichHistory(); + dispatch(richHistoryUpdatedAction({ richHistory, exploreId })); }; }; diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index 5d2bb1f10c9..249d7cda74c 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -2,7 +2,7 @@ import { AnyAction } from 'redux'; import { DataSourceSrv, getDataSourceSrv, locationService } from '@grafana/runtime'; import { ExploreUrlState, serializeStateToUrlParam, SplitOpen, UrlQueryMap } from '@grafana/data'; import { GetExploreUrlArguments, stopQueryState } from 'app/core/utils/explore'; -import { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore'; +import { ExploreId, ExploreItemState, ExploreState, RichHistoryQuery } from 'app/types/explore'; import { paneReducer } from './explorePane'; import { createAction } from '@reduxjs/toolkit'; import { getUrlStateFromPaneState, makeExplorePaneState } from './utils'; @@ -19,7 +19,8 @@ export interface SyncTimesPayload { } export const syncTimesAction = createAction('explore/syncTimes'); -export const richHistoryUpdatedAction = createAction('explore/richHistoryUpdated'); +export const richHistoryUpdatedAction = + createAction<{ richHistory: RichHistoryQuery[]; exploreId: ExploreId }>('explore/richHistoryUpdated'); export const richHistoryStorageFullAction = createAction('explore/richHistoryStorageFullAction'); export const richHistoryLimitExceededAction = createAction('explore/richHistoryLimitExceededAction'); @@ -157,7 +158,6 @@ export const initialExploreState: ExploreState = { syncedTimes: false, left: initialExploreItemState, right: undefined, - richHistory: [], richHistoryStorageFull: false, richHistoryLimitExceededWarningShown: false, }; @@ -207,13 +207,6 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): return { ...state, syncedTimes: action.payload.syncedTimes }; } - if (richHistoryUpdatedAction.match(action)) { - return { - ...state, - richHistory: action.payload.richHistory, - }; - } - if (richHistoryStorageFullAction.match(action)) { return { ...state, diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 1dc93d3f76f..10216ecdb3c 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -27,7 +27,6 @@ import { stopQueryState, updateHistory, } from 'app/core/utils/explore'; -import { addToRichHistory } from 'app/core/utils/richHistory'; import { ExploreItemState, ExplorePanelData, ThunkDispatch, ThunkResult } from 'app/types'; import { ExploreId, ExploreState, QueryOptions } from 'app/types/explore'; import { getTimeZone } from 'app/features/profile/state/selectors'; @@ -36,15 +35,10 @@ import { notifyApp } from '../../../core/actions'; import { runRequest } from '../../query/state/runRequest'; import { decorateData } from '../utils/decorators'; import { createErrorNotification } from '../../../core/copy/appNotification'; -import { - richHistoryStorageFullAction, - richHistoryLimitExceededAction, - richHistoryUpdatedAction, - stateSave, -} from './main'; +import { stateSave } from './main'; import { AnyAction, createAction, PayloadAction } from '@reduxjs/toolkit'; import { updateTime } from './time'; -import { historyUpdatedAction } from './history'; +import { addHistoryItem, historyUpdatedAction, loadRichHistory } from './history'; import { createCacheKey, getResultsFromCache } from './utils'; import deepEqual from 'fast-deep-equal'; @@ -320,29 +314,14 @@ async function handleHistory( ) { const datasourceId = datasource.meta.id; const nextHistory = updateHistory(history, datasourceId, queries); - const { - richHistory: nextRichHistory, - richHistoryStorageFull, - limitExceeded, - } = await addToRichHistory( - state.richHistory || [], - datasource.uid, - datasource.name, - queries, - false, - '', - !state.richHistoryStorageFull, - !state.richHistoryLimitExceededWarningShown - ); dispatch(historyUpdatedAction({ exploreId, history: nextHistory })); - dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); - if (richHistoryStorageFull) { - dispatch(richHistoryStorageFullAction()); - } - if (limitExceeded) { - dispatch(richHistoryLimitExceededAction()); - } + dispatch(addHistoryItem(datasource.uid, datasource.name, queries)); + + // Because filtering happens in the backend we cannot add a new entry without checking if it matches currently + // used filters. Instead, we refresh the query history list. + // TODO: run only if Query History list is opened (#47252) + dispatch(loadRichHistory(exploreId)); } /** diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index a75c193d2c7..563c86326eb 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -61,6 +61,7 @@ export const makeExplorePaneState = (): ExploreItemState => ({ logsResult: null, eventBridge: null as unknown as EventBusExtended, cache: [], + richHistory: [], logsVolumeDataProvider: undefined, logsVolumeData: undefined, graphStyle: loadGraphStyle(), diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index 1e83411147e..a78301cc87d 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -212,6 +212,9 @@ export class DatasourceSrv implements DataSourceService { if (filters.tracing && !x.meta.tracing) { return false; } + if (filters.logs && x.meta.category !== 'logging' && !x.meta.logs) { + return false; + } if (filters.annotations && !x.meta.annotations) { return false; } diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 97f59886a0f..f1ae76f8aef 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -259,25 +259,27 @@ export class CloudWatchDatasource metricQueries: CloudWatchMetricsQuery[], options: DataQueryRequest ): Observable => { - const validMetricsQueries = metricQueries.map((item: CloudWatchMetricsQuery): MetricQuery => { - item.region = this.templateSrv.replace(this.getActualRegion(item.region), options.scopedVars); - item.namespace = this.replace(item.namespace, options.scopedVars, true, 'namespace'); - item.metricName = this.replace(item.metricName, options.scopedVars, true, 'metric name'); - item.dimensions = this.convertDimensionFormat(item.dimensions ?? {}, options.scopedVars); - item.statistic = this.templateSrv.replace(item.statistic, options.scopedVars); - item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting - item.id = this.templateSrv.replace(item.id, options.scopedVars); - item.expression = this.templateSrv.replace(item.expression, options.scopedVars); - item.sqlExpression = this.templateSrv.replace(item.sqlExpression, options.scopedVars, 'raw'); + const validMetricsQueries = metricQueries + .filter(this.filterQuery) + .map((item: CloudWatchMetricsQuery): MetricQuery => { + item.region = this.templateSrv.replace(this.getActualRegion(item.region), options.scopedVars); + item.namespace = this.replace(item.namespace, options.scopedVars, true, 'namespace'); + item.metricName = this.replace(item.metricName, options.scopedVars, true, 'metric name'); + item.dimensions = this.convertDimensionFormat(item.dimensions ?? {}, options.scopedVars); + item.statistic = this.templateSrv.replace(item.statistic, options.scopedVars); + item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting + item.id = this.templateSrv.replace(item.id, options.scopedVars); + item.expression = this.templateSrv.replace(item.expression, options.scopedVars); + item.sqlExpression = this.templateSrv.replace(item.sqlExpression, options.scopedVars, 'raw'); - return { - intervalMs: options.intervalMs, - maxDataPoints: options.maxDataPoints, - ...item, - type: 'timeSeriesQuery', - datasource: this.getRef(), - }; - }); + return { + intervalMs: options.intervalMs, + maxDataPoints: options.maxDataPoints, + ...item, + type: 'timeSeriesQuery', + datasource: this.getRef(), + }; + }); // No valid targets, return the empty result to save a round trip. if (isEmpty(validMetricsQueries)) { diff --git a/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.test.ts b/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.test.ts index 8690854ad57..da4c68b1134 100644 --- a/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.test.ts +++ b/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.test.ts @@ -31,6 +31,33 @@ describe('LokiQueryModeller', () => { ).toBe('{app="grafana"} | logfmt'); }); + it('Can query with pipeline operation regexp', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [{ id: LokiOperationId.Regexp, params: ['re'] }], + }) + ).toBe('{app="grafana"} | regexp `re`'); + }); + + it('Can query with pipeline operation pattern', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [{ id: LokiOperationId.Pattern, params: [''] }], + }) + ).toBe('{app="grafana"} | pattern ``'); + }); + + it('Can query with pipeline operation unpack', () => { + expect( + modeller.renderQuery({ + labels: [{ label: 'app', op: '=', value: 'grafana' }], + operations: [{ id: LokiOperationId.Unpack, params: [] }], + }) + ).toBe('{app="grafana"} | unpack'); + }); + it('Can query with line filter contains operation', () => { expect( modeller.renderQuery({ @@ -82,7 +109,7 @@ describe('LokiQueryModeller', () => { labels: [{ label: 'app', op: '=', value: 'grafana' }], operations: [{ id: LokiOperationId.LabelFilter, params: ['__error__', '=', 'value'] }], }) - ).toBe('{app="grafana"} | __error__="value"'); + ).toBe('{app="grafana"} | __error__=`value`'); }); it('Can query with label filter expression using greater than operator', () => { @@ -100,7 +127,7 @@ describe('LokiQueryModeller', () => { labels: [{ label: 'app', op: '=', value: 'grafana' }], operations: [{ id: LokiOperationId.LabelFilterNoErrors, params: [] }], }) - ).toBe('{app="grafana"} | __error__=""'); + ).toBe('{app="grafana"} | __error__=``'); }); it('Can query with unwrap operation', () => { @@ -118,7 +145,7 @@ describe('LokiQueryModeller', () => { labels: [{ label: 'app', op: '=', value: 'grafana' }], operations: [{ id: LokiOperationId.LineFormat, params: ['{{.status_code}}'] }], }) - ).toBe('{app="grafana"} | line_format "{{.status_code}}"'); + ).toBe('{app="grafana"} | line_format `{{.status_code}}`'); }); it('Can render with label_format operation', () => { diff --git a/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts b/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts index 9cdee3e9e11..1139e312a9a 100644 --- a/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts +++ b/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts @@ -1,11 +1,11 @@ import { LokiAndPromQueryModellerBase } from '../../prometheus/querybuilder/shared/LokiAndPromQueryModellerBase'; import { QueryBuilderLabelFilter } from '../../prometheus/querybuilder/shared/types'; -import { getOperationDefintions } from './operations'; +import { getOperationDefinitions } from './operations'; import { LokiOperationId, LokiQueryPattern, LokiVisualQueryOperationCategory } from './types'; export class LokiQueryModeller extends LokiAndPromQueryModellerBase { constructor() { - super(getOperationDefintions); + super(getOperationDefinitions); this.setOperationCategories([ LokiVisualQueryOperationCategory.Aggregations, diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.test.tsx index 61d05aabcb7..46e6fa8a22e 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.test.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.test.tsx @@ -187,9 +187,9 @@ function expectExplain() { function switchToMode(mode: QueryEditorMode) { const label = { - [QueryEditorMode.Code]: 'Code', - [QueryEditorMode.Explain]: 'Explain', - [QueryEditorMode.Builder]: 'Builder', + [QueryEditorMode.Code]: /Code/, + [QueryEditorMode.Explain]: /Explain/, + [QueryEditorMode.Builder]: /Builder/, }[mode]; const switchEl = screen.getByLabelText(label); diff --git a/public/app/plugins/datasource/loki/querybuilder/operations.ts b/public/app/plugins/datasource/loki/querybuilder/operations.ts index 81ab45fd079..d44f3e9ba7a 100644 --- a/public/app/plugins/datasource/loki/querybuilder/operations.ts +++ b/public/app/plugins/datasource/loki/querybuilder/operations.ts @@ -10,7 +10,7 @@ import { FUNCTIONS } from '../syntax'; import { binaryScalarOperations } from './binaryScalarOperations'; import { LokiOperationId, LokiOperationOrder, LokiVisualQuery, LokiVisualQueryOperationCategory } from './types'; -export function getOperationDefintions(): QueryBuilderOperationDef[] { +export function getOperationDefinitions(): QueryBuilderOperationDef[] { const aggregations = [ LokiOperationId.Sum, LokiOperationId.Min, @@ -18,6 +18,9 @@ export function getOperationDefintions(): QueryBuilderOperationDef[] { LokiOperationId.Avg, LokiOperationId.TopK, LokiOperationId.BottomK, + LokiOperationId.Stddev, + LokiOperationId.Stdvar, + LokiOperationId.Count, ].flatMap((opId) => createAggregationOperation(opId, { addOperationHandler: addLokiOperation, @@ -32,6 +35,14 @@ export function getOperationDefintions(): QueryBuilderOperationDef[] { createRangeOperation(LokiOperationId.BytesRate), createRangeOperation(LokiOperationId.BytesOverTime), createRangeOperation(LokiOperationId.AbsentOverTime), + createRangeOperation(LokiOperationId.AvgOverTime), + createRangeOperation(LokiOperationId.MaxOverTime), + createRangeOperation(LokiOperationId.MinOverTime), + createRangeOperation(LokiOperationId.FirstOverTime), + createRangeOperation(LokiOperationId.LastOverTime), + createRangeOperation(LokiOperationId.StdvarOverTime), + createRangeOperation(LokiOperationId.StddevOverTime), + createRangeOperation(LokiOperationId.QuantileOverTime), ...aggregations, { id: LokiOperationId.Json, @@ -55,7 +66,64 @@ export function getOperationDefintions(): QueryBuilderOperationDef[] { renderer: pipelineRenderer, addOperationHandler: addLokiOperation, explainHandler: () => - `This will extract all keys and values from a [logfmt](https://grafana.com/docs/loki/latest/logql/log_queries/#logfmt) formatted log line as labels. The extracted lables can be used in label filter expressions and used as values for a range aggregation via the unwrap operation. `, + `This will extract all keys and values from a [logfmt](https://grafana.com/docs/loki/latest/logql/log_queries/#logfmt) formatted log line as labels. The extracted labels can be used in label filter expressions and used as values for a range aggregation via the unwrap operation.`, + }, + { + id: LokiOperationId.Regexp, + name: 'Regexp', + params: [ + { + name: 'String', + type: 'string', + hideName: true, + placeholder: '', + description: 'The regexp expression that matches the structure of a log line.', + minWidth: 20, + }, + ], + defaultParams: [''], + alternativesKey: 'format', + category: LokiVisualQueryOperationCategory.Formats, + orderRank: LokiOperationOrder.LineFormats, + renderer: (model, def, innerExpr) => `${innerExpr} | regexp \`${model.params[0]}\``, + addOperationHandler: addLokiOperation, + explainHandler: () => + `The [regexp parser](https://grafana.com/docs/loki/latest/logql/log_queries/#regular-expression) takes a single parameter | regexp "" which is the regular expression using the Golang RE2 syntax. The regular expression must contain a least one named sub-match (e.g (?Pre)), each sub-match will extract a different label. The expression matches the structure of a log line. The extracted labels can be used in label filter expressions and used as values for a range aggregation via the unwrap operation.`, + }, + { + id: LokiOperationId.Pattern, + name: 'Pattern', + params: [ + { + name: 'String', + type: 'string', + hideName: true, + placeholder: '', + description: 'The expression that matches the structure of a log line.', + minWidth: 20, + }, + ], + defaultParams: [''], + alternativesKey: 'format', + category: LokiVisualQueryOperationCategory.Formats, + orderRank: LokiOperationOrder.LineFormats, + renderer: (model, def, innerExpr) => `${innerExpr} | pattern \`${model.params[0]}\``, + addOperationHandler: addLokiOperation, + explainHandler: () => + `The [pattern parser](https://grafana.com/docs/loki/latest/logql/log_queries/#pattern) allows the explicit extraction of fields from log lines by defining a pattern expression (| pattern \`\`). The expression matches the structure of a log line. The extracted labels can be used in label filter expressions and used as values for a range aggregation via the unwrap operation.`, + }, + { + id: LokiOperationId.Unpack, + name: 'Unpack', + params: [], + defaultParams: [], + alternativesKey: 'format', + category: LokiVisualQueryOperationCategory.Formats, + orderRank: LokiOperationOrder.LineFormats, + renderer: pipelineRenderer, + addOperationHandler: addLokiOperation, + explainHandler: () => + `This will extract all keys and values from a JSON log line, [unpacking](https://grafana.com/docs/loki/latest/logql/log_queries/#unpack) all embedded labels in the pack stage. The extracted labels can be used in label filter expressions and used as values for a range aggregation via the unwrap operation.`, }, { id: LokiOperationId.LineFormat, @@ -74,7 +142,7 @@ export function getOperationDefintions(): QueryBuilderOperationDef[] { alternativesKey: 'format', category: LokiVisualQueryOperationCategory.Formats, orderRank: LokiOperationOrder.LineFormats, - renderer: (model, def, innerExpr) => `${innerExpr} | line_format "${model.params[0]}"`, + renderer: (model, def, innerExpr) => `${innerExpr} | line_format \`${model.params[0]}\``, addOperationHandler: addLokiOperation, explainHandler: () => `This will replace log line using a specified template. The template can refer to stream labels and extracted labels. @@ -212,7 +280,7 @@ export function getOperationDefintions(): QueryBuilderOperationDef[] { defaultParams: [], category: LokiVisualQueryOperationCategory.LabelFilters, orderRank: LokiOperationOrder.NoErrors, - renderer: (model, def, innerExpr) => `${innerExpr} | __error__=""`, + renderer: (model, def, innerExpr) => `${innerExpr} | __error__=\`\``, addOperationHandler: addLokiOperation, explainHandler: () => `Filter out all formatting and parsing errors.`, }, @@ -246,15 +314,28 @@ export function getOperationDefintions(): QueryBuilderOperationDef[] { } function createRangeOperation(name: string): QueryBuilderOperationDef { + const params = [getRangeVectorParamDef()]; + const defaultParams = ['$__interval']; + let renderer = operationWithRangeVectorRenderer; + + if (name === LokiOperationId.QuantileOverTime) { + defaultParams.push('0.95'); + params.push({ + name: 'Quantile', + type: 'number', + }); + renderer = operationWithRangeVectorRendererAndParam; + } + return { id: name, name: getPromAndLokiOperationDisplayName(name), - params: [getRangeVectorParamDef()], - defaultParams: ['$__interval'], + params, + defaultParams, alternativesKey: 'range function', category: LokiVisualQueryOperationCategory.RangeFunctions, orderRank: LokiOperationOrder.RangeVectorFunction, - renderer: operationWithRangeVectorRenderer, + renderer, addOperationHandler: addLokiOperation, explainHandler: (op, def) => { let opDocs = FUNCTIONS.find((x) => x.insertText === op.id)?.documentation ?? ''; @@ -285,6 +366,17 @@ function operationWithRangeVectorRenderer( return `${def.id}(${innerExpr} [${rangeVector}])`; } +function operationWithRangeVectorRendererAndParam( + model: QueryBuilderOperation, + def: QueryBuilderOperationDef, + innerExpr: string +) { + const params = model.params ?? []; + const rangeVector = params[0] ?? '$__interval'; + const param = params[1]; + return `${def.id}(${param}, ${innerExpr} [${rangeVector}])`; +} + function getLineFilterRenderer(operation: string) { return function lineFilterRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { if (model.params[0] === '') { @@ -303,7 +395,7 @@ function labelFilterRenderer(model: QueryBuilderOperation, def: QueryBuilderOper return `${innerExpr} | ${model.params[0]} ${model.params[1]} ${model.params[2]}`; } - return `${innerExpr} | ${model.params[0]}${model.params[1]}"${model.params[2]}"`; + return `${innerExpr} | ${model.params[0]}${model.params[1]}\`${model.params[2]}\``; } function pipelineRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { diff --git a/public/app/plugins/datasource/loki/querybuilder/parsing.test.ts b/public/app/plugins/datasource/loki/querybuilder/parsing.test.ts index 64ee292ad82..51201da9d36 100644 --- a/public/app/plugins/datasource/loki/querybuilder/parsing.test.ts +++ b/public/app/plugins/datasource/loki/querybuilder/parsing.test.ts @@ -2,6 +2,15 @@ import { buildVisualQueryFromString } from './parsing'; import { LokiVisualQuery } from './types'; describe('buildVisualQueryFromString', () => { + it('creates no errors for empty query', () => { + expect(buildVisualQueryFromString('')).toEqual( + noErrors({ + labels: [], + operations: [], + }) + ); + }); + it('parses simple query with label-values', () => { expect(buildVisualQueryFromString('{app="frontend"}')).toEqual( noErrors({ @@ -67,6 +76,18 @@ describe('buildVisualQueryFromString', () => { ); }); + it('returns error for query with ip matching line filter', () => { + const context = buildVisualQueryFromString('{app="frontend"} |= ip("192.168.4.5/16")'); + expect(context.errors).toEqual([ + { + text: 'Matching ip addresses not supported in query builder: |= ip("192.168.4.5/16")', + from: 17, + to: 40, + parentType: 'LineFilters', + }, + ]); + }); + it('parses query with matcher label filter', () => { expect(buildVisualQueryFromString('{app="frontend"} | bar="baz"')).toEqual( noErrors({ @@ -127,6 +148,30 @@ describe('buildVisualQueryFromString', () => { ); }); + it('returns error for query with "and", "or", "comma" in label filter', () => { + const context = buildVisualQueryFromString('{app="frontend"} | logfmt | level="error" and job="grafana"'); + expect(context.errors).toEqual([ + { + text: 'Label filter with comma, "and", "or" not supported in query builder: level="error" and job="grafana"', + from: 28, + to: 59, + parentType: 'PipelineStage', + }, + ]); + }); + + it('returns error for query with ip label filter', () => { + const context = buildVisualQueryFromString('{app="frontend"} | logfmt | address=ip("192.168.4.5/16")'); + expect(context.errors).toEqual([ + { + text: 'IpLabelFilter not supported in query builder: address=ip("192.168.4.5/16")', + from: 28, + to: 56, + parentType: 'PipelineStage', + }, + ]); + }); + it('parses query with with parser', () => { expect(buildVisualQueryFromString('{app="frontend"} | json')).toEqual( noErrors({ @@ -142,6 +187,18 @@ describe('buildVisualQueryFromString', () => { ); }); + it('returns error for query with JSON expression parser', () => { + const context = buildVisualQueryFromString('{app="frontend"} | json label="value" '); + expect(context.errors).toEqual([ + { + text: 'JsonExpressionParser not supported in visual query builder: json label="value"', + from: 19, + to: 37, + parentType: 'PipelineStage', + }, + ]); + }); + it('parses query with with simple unwrap', () => { expect( buildVisualQueryFromString('sum_over_time({app="frontend"} | logfmt | unwrap bytes_processed [1m])') @@ -163,6 +220,20 @@ describe('buildVisualQueryFromString', () => { ); }); + it('returns error for query with unwrap and conversion operation', () => { + const context = buildVisualQueryFromString( + 'sum_over_time({app="frontend"} | logfmt | unwrap duration(label) [5m])' + ); + expect(context.errors).toEqual([ + { + text: 'Unwrap with conversion operator not supported in query builder: | unwrap duration(label)', + from: 40, + to: 64, + parentType: 'LogRangeExpr', + }, + ]); + }); + it('parses metrics query with function', () => { expect(buildVisualQueryFromString('rate({app="frontend"} | json [5m])')).toEqual( noErrors({ @@ -323,7 +394,7 @@ describe('buildVisualQueryFromString', () => { label: 'app', }, ], - operations: [{ id: 'label_format', params: ['newLabel', '=', 'oldLabel'] }], + operations: [{ id: 'label_format', params: ['newLabel', 'oldLabel'] }], }) ); }); @@ -339,8 +410,8 @@ describe('buildVisualQueryFromString', () => { }, ], operations: [ - { id: 'label_format', params: ['newLabel', '=', 'oldLabel'] }, - { id: 'label_format', params: ['bar', '=', 'baz'] }, + { id: 'label_format', params: ['newLabel', 'oldLabel'] }, + { id: 'label_format', params: ['bar', 'baz'] }, ], }) ); diff --git a/public/app/plugins/datasource/loki/querybuilder/parsing.ts b/public/app/plugins/datasource/loki/querybuilder/parsing.ts index 83d06b09f62..43168019c2e 100644 --- a/public/app/plugins/datasource/loki/querybuilder/parsing.ts +++ b/public/app/plugins/datasource/loki/querybuilder/parsing.ts @@ -20,8 +20,8 @@ interface Context { interface ParsingError { text: string; - from: number; - to: number; + from?: number; + to?: number; parentType?: string; } @@ -36,12 +36,25 @@ export function buildVisualQueryFromString(expr: string): Context { operations: [], }; - const context = { + const context: Context = { query: visQuery, errors: [], }; - handleExpression(replacedExpr, node, context); + try { + handleExpression(replacedExpr, node, context); + } catch (err) { + // Not ideal to log it here, but otherwise we would lose the stack trace. + console.error(err); + context.errors.push({ + text: err.message, + }); + } + + // If we have empty query, we want to reset errors + if (isEmptyQuery(context.query)) { + context.errors = []; + } return context; } @@ -58,7 +71,14 @@ export function handleExpression(expr: string, node: SyntaxNode, context: Contex } case 'LineFilter': { - visQuery.operations.push(getLineFilter(expr, node)); + const { operation, error } = getLineFilter(expr, node); + if (operation) { + visQuery.operations.push(operation); + } + // Show error for query patterns not supported in visual query builder + if (error) { + context.errors.push(createNotSupportedError(expr, node, error)); + } break; } @@ -68,11 +88,23 @@ export function handleExpression(expr: string, node: SyntaxNode, context: Contex } case 'LabelFilter': { - visQuery.operations.push(getLabelFilter(expr, node)); + const { operation, error } = getLabelFilter(expr, node); + if (operation) { + visQuery.operations.push(operation); + } + // Show error for query patterns not supported in visual query builder + if (error) { + context.errors.push(createNotSupportedError(expr, node, error)); + } break; } - // Need to figure out JsonExpressionParser + case 'JsonExpressionParser': { + // JsonExpressionParser is not supported in query builder + const error = 'JsonExpressionParser not supported in visual query builder'; + + context.errors.push(createNotSupportedError(expr, node, error)); + } case 'LineFormatExpr': { visQuery.operations.push(getLineFormat(expr, node)); @@ -85,7 +117,15 @@ export function handleExpression(expr: string, node: SyntaxNode, context: Contex } case 'UnwrapExpr': { - visQuery.operations.push(getUnwrap(expr, node)); + const { operation, error } = getUnwrap(expr, node); + if (operation) { + visQuery.operations.push(operation); + } + // Show error for query patterns not supported in visual query builder + if (error) { + context.errors.push(createNotSupportedError(expr, node, error)); + } + break; } @@ -139,7 +179,15 @@ function getLabel(expr: string, node: SyntaxNode): QueryBuilderLabelFilter { }; } -function getLineFilter(expr: string, node: SyntaxNode): QueryBuilderOperation { +function getLineFilter(expr: string, node: SyntaxNode): { operation?: QueryBuilderOperation; error?: string } { + // Check for nodes not supported in visual builder and return error + const ipLineFilter = getAllByType(expr, node, 'Ip'); + if (ipLineFilter.length > 0) { + return { + error: 'Matching ip addresses not supported in query builder', + }; + } + const mapFilter: any = { '|=': '__line_contains', '!=': '__line_contains_not', @@ -150,8 +198,10 @@ function getLineFilter(expr: string, node: SyntaxNode): QueryBuilderOperation { const filterExpr = handleQuotes(getString(expr, node.getChild('String'))); return { - id: mapFilter[filter], - params: [filterExpr], + operation: { + id: mapFilter[filter], + params: [filterExpr], + }, }; } @@ -167,9 +217,20 @@ function getLabelParser(expr: string, node: SyntaxNode): QueryBuilderOperation { }; } -function getLabelFilter(expr: string, node: SyntaxNode): QueryBuilderOperation { - const id = '__label_filter'; +function getLabelFilter(expr: string, node: SyntaxNode): { operation?: QueryBuilderOperation; error?: string } { + // Check for nodes not supported in visual builder and return error + if (node.getChild('Or') || node.getChild('And') || node.getChild('Comma')) { + return { + error: 'Label filter with comma, "and", "or" not supported in query builder', + }; + } + if (node.firstChild!.name === 'IpLabelFilter') { + return { + error: 'IpLabelFilter not supported in query builder', + }; + } + const id = '__label_filter'; if (node.firstChild!.name === 'UnitFilter') { const filter = node.firstChild!.firstChild; const label = filter!.firstChild; @@ -178,48 +239,35 @@ function getLabelFilter(expr: string, node: SyntaxNode): QueryBuilderOperation { const valueString = handleQuotes(getString(expr, value)); return { - id, - params: [getString(expr, label), getString(expr, op), valueString], + operation: { + id, + params: [getString(expr, label), getString(expr, op), valueString], + }, }; } + // In this case it is Matcher or NumberFilter + const filter = node.firstChild; + const label = filter!.firstChild; + const op = label!.nextSibling; + const value = op!.nextSibling; + const params = [getString(expr, label), getString(expr, op), handleQuotes(getString(expr, value))]; - if (node.firstChild!.name === 'IpLabelFilter') { - // Not implemented in visual query builder yet - const filter = node.firstChild!; - const label = filter.firstChild!; - const op = label.nextSibling!; - const ip = label.nextSibling; - const value = op.nextSibling!; + // Special case of pipe filtering - no errors + if (params.join('') === `__error__=`) { return { - id, - params: [ - getString(expr, label), - getString(expr, op), - handleQuotes(getString(expr, ip)), - handleQuotes(getString(expr, value)), - ], - }; - } else { - // In this case it is Matcher or NumberFilter - const filter = node.firstChild; - const label = filter!.firstChild; - const op = label!.nextSibling; - const value = op!.nextSibling; - const params = [getString(expr, label), getString(expr, op), getString(expr, value).replace(/"/g, '')]; - - //Special case of pipe filtering - no errors - if (params.join('') === `__error__=`) { - return { + operation: { id: '__label_filter_no_errors', params: [], - }; - } - - return { - id, - params, + }, }; } + + return { + operation: { + id, + params, + }, + }; } function getLineFormat(expr: string, node: SyntaxNode): QueryBuilderOperation { @@ -242,17 +290,26 @@ function getLabelFormat(expr: string, node: SyntaxNode): QueryBuilderOperation { return { id, - params: [getString(expr, identifier), getString(expr, op), valueString], + params: [getString(expr, identifier), valueString], }; } -function getUnwrap(expr: string, node: SyntaxNode): QueryBuilderOperation { +function getUnwrap(expr: string, node: SyntaxNode): { operation?: QueryBuilderOperation; error?: string } { + // Check for nodes not supported in visual builder and return error + if (node.getChild('ConvOp')) { + return { + error: 'Unwrap with conversion operator not supported in query builder', + }; + } + const id = 'unwrap'; const string = getString(expr, node.getChild('Identifier')); return { - id, - params: [string], + operation: { + id, + params: [string], + }, }; } @@ -440,3 +497,22 @@ function getLastChildWithSelector(node: SyntaxNode, selector: string) { } return child; } + +/** + * Helper function to enrich error text with information that visual query builder doesn't support that logQL + * @param expr + * @param node + * @param error + */ +function createNotSupportedError(expr: string, node: SyntaxNode, error: string) { + const err = makeError(expr, node); + err.text = `${error}: ${err.text}`; + return err; +} + +function isEmptyQuery(query: LokiVisualQuery) { + if (query.labels.length === 0 && query.operations.length === 0) { + return true; + } + return false; +} diff --git a/public/app/plugins/datasource/loki/querybuilder/types.ts b/public/app/plugins/datasource/loki/querybuilder/types.ts index ef506eb4ae1..36939dc783d 100644 --- a/public/app/plugins/datasource/loki/querybuilder/types.ts +++ b/public/app/plugins/datasource/loki/querybuilder/types.ts @@ -30,11 +30,22 @@ export enum LokiVisualQueryOperationCategory { export enum LokiOperationId { Json = 'json', Logfmt = 'logfmt', + Regexp = 'regexp', + Pattern = 'pattern', + Unpack = 'unpack', LineFormat = 'line_format', LabelFormat = 'label_format', Rate = 'rate', CountOverTime = 'count_over_time', SumOverTime = 'sum_over_time', + AvgOverTime = 'avg_over_time', + MaxOverTime = 'max_over_time', + MinOverTime = 'min_over_time', + FirstOverTime = 'first_over_time', + LastOverTime = 'last_over_time', + StdvarOverTime = 'stdvar_over_time', + StddevOverTime = 'stddev_over_time', + QuantileOverTime = 'quantile_over_time', BytesRate = 'bytes_rate', BytesOverTime = 'bytes_over_time', AbsentOverTime = 'absent_over_time', @@ -42,6 +53,9 @@ export enum LokiOperationId { Avg = 'avg', Min = 'min', Max = 'max', + Stddev = 'stddev', + Stdvar = 'stdvar', + Count = 'count', TopK = 'topk', BottomK = 'bottomk', LineContains = '__line_contains', diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx index 1b7acc5667a..bb92ec241d0 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx @@ -51,7 +51,7 @@ describe('PromQueryEditorSelector', () => { expectCodeEditor(); }); - it('shows builder if new query', async () => { + it('shows code if new query', async () => { render( { }} /> ); - expectBuilder(); + expectCodeEditor(); }); it('shows code editor when code mode is set', async () => { @@ -189,9 +189,9 @@ function expectExplain() { function switchToMode(mode: QueryEditorMode) { const label = { - [QueryEditorMode.Code]: 'Code', - [QueryEditorMode.Explain]: 'Explain', - [QueryEditorMode.Builder]: 'Builder', + [QueryEditorMode.Code]: /Code/, + [QueryEditorMode.Explain]: /Explain/, + [QueryEditorMode.Builder]: /Builder/, }[mode]; const switchEl = screen.getByLabelText(label); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/parsing.test.ts b/public/app/plugins/datasource/prometheus/querybuilder/parsing.test.ts index fc7ffb8142c..a340f44fb63 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/parsing.test.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/parsing.test.ts @@ -2,6 +2,15 @@ import { buildVisualQueryFromString } from './parsing'; import { PromVisualQuery } from './types'; describe('buildVisualQueryFromString', () => { + it('creates no errors for empty query', () => { + expect(buildVisualQueryFromString('')).toEqual( + noErrors({ + labels: [], + operations: [], + metric: '', + }) + ); + }); it('parses simple query', () => { expect(buildVisualQueryFromString('counters_logins{app="frontend"}')).toEqual( noErrors({ diff --git a/public/app/plugins/datasource/prometheus/querybuilder/parsing.ts b/public/app/plugins/datasource/prometheus/querybuilder/parsing.ts index 0db720b4b0f..0db7f39daed 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/parsing.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/parsing.ts @@ -46,6 +46,11 @@ export function buildVisualQueryFromString(expr: string): Context { text: err.message, }); } + + // If we have empty query, we want to reset errors + if (isEmptyQuery(context.query)) { + context.errors = []; + } return context; } @@ -373,3 +378,10 @@ function getBinaryModifier( }; } } + +function isEmptyQuery(query: PromVisualQuery) { + if (query.labels.length === 0 && query.operations.length === 0 && !query.metric) { + return true; + } + return false; +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle.tsx index 725e608130a..ae44a01e4da 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle.tsx @@ -1,5 +1,6 @@ -import { RadioButtonGroup } from '@grafana/ui'; +import { RadioButtonGroup, Tag } from '@grafana/ui'; import React from 'react'; +import { css } from '@emotion/css'; import { QueryEditorMode } from './types'; export interface Props { @@ -9,7 +10,21 @@ export interface Props { const editorModes = [ { label: 'Explain', value: QueryEditorMode.Explain }, - { label: 'Builder', value: QueryEditorMode.Builder }, + { + label: 'Builder', + value: QueryEditorMode.Builder, + component: () => ( + + ), + }, { label: 'Code', value: QueryEditorMode.Code }, ]; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/parsingUtils.ts b/public/app/plugins/datasource/prometheus/querybuilder/shared/parsingUtils.ts index 9895860e27e..e2d89d4db6d 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/parsingUtils.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/parsingUtils.ts @@ -30,7 +30,7 @@ export function makeError(expr: string, node: SyntaxNode) { const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g; /** - * As variables with $ are creating parsing errors, we first replace them with magic string that is parseable and at + * As variables with $ are creating parsing errors, we first replace them with magic string that is parsable and at * the same time we can get the variable and it's format back from it. * @param expr */ diff --git a/public/app/plugins/datasource/prometheus/querybuilder/state.test.ts b/public/app/plugins/datasource/prometheus/querybuilder/state.test.ts index 46d056845bd..f3d3acd77a4 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/state.test.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/state.test.ts @@ -5,7 +5,7 @@ import { changeEditorMode, getQueryWithDefaults } from './state'; describe('getQueryWithDefaults(', () => { it('should set defaults', () => { expect(getQueryWithDefaults({ refId: 'A' } as any, CoreApp.Dashboard)).toEqual({ - editorMode: 'builder', + editorMode: 'code', expr: '', legendFormat: '__auto', range: true, @@ -15,7 +15,7 @@ describe('getQueryWithDefaults(', () => { it('should set both range and instant to true when in Explore', () => { expect(getQueryWithDefaults({ refId: 'A' } as any, CoreApp.Explore)).toEqual({ - editorMode: 'builder', + editorMode: 'code', expr: '', legendFormat: '__auto', range: true, diff --git a/public/app/plugins/datasource/prometheus/querybuilder/state.ts b/public/app/plugins/datasource/prometheus/querybuilder/state.ts index 9a69b6ab11d..5c59d4bece4 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/state.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/state.ts @@ -14,7 +14,8 @@ export function changeEditorMode(query: PromQuery, editorMode: QueryEditorMode, onChange({ ...query, editorMode }); } -export function getDefaultEditorMode(expr: string) { +// @ts-ignore Will be used after builder is out of beta +function getDefaultEditorMode(expr: string) { // If we already have an expression default to code view if (expr != null && expr !== '') { return QueryEditorMode.Code; @@ -35,11 +36,11 @@ export function getDefaultEditorMode(expr: string) { * Returns query with defaults, and boolean true/false depending on change was required */ export function getQueryWithDefaults(query: PromQuery, app: CoreApp | undefined): PromQuery { - // If no expr (ie new query) then default to builder let result = query; if (!query.editorMode) { - result = { ...query, editorMode: getDefaultEditorMode(query.expr) }; + // Default to Code mode until we are out of beta with the builder, then use getDefaultEditorMode. + result = { ...query, editorMode: QueryEditorMode.Code }; } if (query.expr == null) { diff --git a/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx b/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx index 9e993249c07..26f1a9f5d27 100644 --- a/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx +++ b/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx @@ -113,7 +113,7 @@ class TempoQueryFieldComponent extends React.PureComponent { {query.queryType === 'nativeSearch' && ( -

        +

        {config.featureToggles.tempoBackendSearch ? ( <> Tempo search is currently in beta. @@ -124,7 +124,7 @@ class TempoQueryFieldComponent extends React.PureComponent { future! )} -

        +
        )} {query.queryType === 'search' && ( Tempo uses {ds.name} to find traces. - field.datasourceUid === this.uid && field.matcherRegex) .map((field) => field.matcherRegex) || []; + if (!traceLinkMatcher || traceLinkMatcher.length === 0) { return throwError( () => diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index 28fd8f6cfa3..ddd24370c6b 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -244,39 +244,49 @@ export const CandlestickPanel: React.FC = ({ )} {/* Enables annotations creation*/} - - {({ startAnnotating }) => { - return ( - { - if (!p) { - return; - } - startAnnotating({ coords: p.coords }); + {enableAnnotationCreation ? ( + + {({ startAnnotating }) => { + return ( + { + if (!p) { + return; + } + startAnnotating({ coords: p.coords }); + }, }, - }, - ], - }, - ] - : [] - } - /> - ); - }} - + ], + }, + ] + : [] + } + /> + ); + }} + + ) : ( + + )} {data.annotations && ( { async loadChannel() { const { options } = this.props; try { - const url = options.feedUrl - ? options.useProxy - ? `${PROXY_PREFIX}${options.feedUrl}` - : options.feedUrl - : DEFAULT_FEED_URL; + const url = options.feedUrl || DEFAULT_FEED_URL; const feed = await loadFeed(url); const frame = feedToDataFrame(feed); + this.setState({ news: new DataFrameView(frame), isError: false, }); } catch (err) { console.error('Error Loading News', err); + this.setState({ news: undefined, isError: true, @@ -79,10 +77,10 @@ export class NewsPanel extends PureComponent { const useWideLayout = width > 600; if (isError) { - return
        Error Loading News
        ; + return
        Error loading RSS feed.
        ; } if (!news) { - return
        loading...
        ; + return
        Loading...
        ; } return ( diff --git a/public/app/plugins/panel/news/constants.ts b/public/app/plugins/panel/news/constants.ts index 3cdab1a3ab8..8af16365442 100644 --- a/public/app/plugins/panel/news/constants.ts +++ b/public/app/plugins/panel/news/constants.ts @@ -1,2 +1 @@ export const DEFAULT_FEED_URL = 'https://grafana.com/blog/news.xml'; -export const PROXY_PREFIX = 'https://cors-anywhere.herokuapp.com/'; diff --git a/public/app/plugins/panel/news/models.cue b/public/app/plugins/panel/news/models.cue index 49dba195210..b4e632e9c66 100644 --- a/public/app/plugins/panel/news/models.cue +++ b/public/app/plugins/panel/news/models.cue @@ -21,7 +21,6 @@ Panel: { PanelOptions: { // empty/missing will default to grafana blog feedUrl?: string - useProxy?: bool showImage?: bool | *true } } diff --git a/public/app/plugins/panel/news/models.gen.ts b/public/app/plugins/panel/news/models.gen.ts index 61a31557145..fa953457c4a 100644 --- a/public/app/plugins/panel/news/models.gen.ts +++ b/public/app/plugins/panel/news/models.gen.ts @@ -9,7 +9,6 @@ export const modelVersion = Object.freeze([0, 0]); export interface PanelOptions { feedUrl?: string; showImage?: boolean; - useProxy?: boolean; } export const defaultPanelOptions: PanelOptions = { diff --git a/public/app/plugins/panel/news/module.tsx b/public/app/plugins/panel/news/module.tsx index 34f0447b44e..382cddb2c7b 100755 --- a/public/app/plugins/panel/news/module.tsx +++ b/public/app/plugins/panel/news/module.tsx @@ -1,8 +1,7 @@ -import { isString } from 'lodash'; import { PanelPlugin } from '@grafana/data'; import { NewsPanel } from './NewsPanel'; import { PanelOptions, defaultPanelOptions } from './models.gen'; -import { DEFAULT_FEED_URL, PROXY_PREFIX } from './constants'; +import { DEFAULT_FEED_URL } from './constants'; export const plugin = new PanelPlugin(NewsPanel).setPanelOptions((builder) => { builder @@ -19,18 +18,6 @@ export const plugin = new PanelPlugin(NewsPanel).setPanelOptions(( path: 'showImage', name: 'Show image', description: 'Controls if the news item social (og:image) image is shown above text content', - showIf: (currentConfig: PanelOptions) => { - return isString(currentConfig.feedUrl) && !currentConfig.feedUrl.startsWith(PROXY_PREFIX); - }, defaultValue: defaultPanelOptions.showImage, - }) - .addBooleanSwitch({ - path: 'useProxy', - name: 'Use Proxy', - description: 'If the feed is unable to connect, consider a CORS proxy', - showIf: (currentConfig: PanelOptions) => { - return isString(currentConfig.feedUrl) && !currentConfig.feedUrl.startsWith(PROXY_PREFIX); - }, - defaultValue: defaultPanelOptions.useProxy, }); }); diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index 2fd12e51062..aec415207be 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -39,6 +39,7 @@ export interface DashboardMeta { } export interface AnnotationActions { + canAdd: boolean; canEdit: boolean; canDelete: boolean; } diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 52bbef90223..626b894070b 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -42,10 +42,6 @@ export interface ExploreState { * Explore state of the right area in split view. */ right?: ExploreItemState; - /** - * History of all queries - */ - richHistory: RichHistoryQuery[]; /** * True if local storage quota was exceeded when a rich history item was added. This is to prevent showing @@ -153,6 +149,11 @@ export interface ExploreItemState { showTrace?: boolean; showNodeGraph?: boolean; + /** + * History of all queries + */ + richHistory: RichHistoryQuery[]; + /** * We are using caching to store query responses of queries run from logs navigation. * In logs navigation, we do pagination and we don't want our users to unnecessarily run the same queries that they've run just moments before. diff --git a/scripts/build/build-npm-packages.sh b/scripts/build/build-npm-packages.sh deleted file mode 100755 index 8321916021c..00000000000 --- a/scripts/build/build-npm-packages.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -# lerna bootstrap might have created yarn.lock -git checkout . - -echo 'Building packages' -yarn packages:build \ No newline at end of file diff --git a/scripts/build/release-npm-packages.sh b/scripts/build/release-npm-packages.sh deleted file mode 100755 index 7d03d2f1cd1..00000000000 --- a/scripts/build/release-npm-packages.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash - -PACKAGES=("@grafana/ui" "@grafana/data" "@grafana/toolkit" "@grafana/runtime" "@grafana/e2e" "@grafana/e2e-selectors" "@grafana/schema") - -GRAFANA_TAG=${1:-} -RELEASE_CHANNEL="latest" - -if echo "$GRAFANA_TAG" | grep -q "^v"; then - _grafana_version=$(echo "${GRAFANA_TAG}" | cut -d "v" -f 2) -else - echo "Provided tag is not a version tag, skipping packages release..." - exit -fi - -if grep -q "beta" <<< "$GRAFANA_TAG"; then - RELEASE_CHANNEL="next" -fi - -echo "$_grafana_version" - -# lerna bootstrap might have created yarn.lock -git checkout . - -# Get current version from lerna.json -# Since this happens on tagged branch, the lerna.json version and package.json file SHOULD be updated already -# as specified in release guideline -PACKAGE_VERSION=$(grep '"version"' lerna.json | cut -d '"' -f 4) - -echo "Releasing grafana packages @ ${PACKAGE_VERSION} under ${RELEASE_CHANNEL} channel" - -if [ $RELEASE_CHANNEL == "latest" ]; then - SCRIPT="publishLatest" -elif [ $RELEASE_CHANNEL == "next" ]; then - SCRIPT="publishNext" -else - echo "Unknown channel, skipping packages release" - exit -fi - -# Publish to NPM registry -echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> ~/.npmrc - -echo $'\nPublishing packages to NPM registry' -yarn packages:${SCRIPT} - -# When releasing stable(latest) version of packages we are updating previously published next tag(beta) to be the same version as latest -if [ $RELEASE_CHANNEL == "latest" ]; then - for i in "${PACKAGES[@]}" - do - : - npm dist-tag add "$i"@"$PACKAGE_VERSION" next - done -fi diff --git a/scripts/drone/pipelines/main.star b/scripts/drone/pipelines/main.star index f937358fe1b..91562878bab 100644 --- a/scripts/drone/pipelines/main.star +++ b/scripts/drone/pipelines/main.star @@ -38,7 +38,8 @@ load( 'upload_cdn_step', 'validate_scuemata_step', 'ensure_cuetsified_step', - 'test_a11y_frontend_step' + 'test_a11y_frontend_step', + 'trigger_oss' ) load( @@ -118,23 +119,23 @@ def get_steps(edition, is_downstream=False): e2e_tests_step('various-suite', edition=edition), e2e_tests_artifacts(edition=edition), build_storybook_step(edition=edition, ver_mode=ver_mode), - store_storybook_step(edition=edition, ver_mode=ver_mode), + store_storybook_step(edition=edition, ver_mode=ver_mode, trigger=trigger_oss), test_a11y_frontend_step(ver_mode=ver_mode, edition=edition), - frontend_metrics_step(edition=edition), + frontend_metrics_step(edition=edition, trigger=trigger_oss), copy_packages_for_docker_step(), build_docker_images_step(edition=edition, ver_mode=ver_mode, publish=False), build_docker_images_step(edition=edition, ver_mode=ver_mode, ubuntu=True, publish=False), - publish_images_step(edition=edition, ver_mode=ver_mode, mode='', docker_repo='grafana', ubuntu=False), - publish_images_step(edition=edition, ver_mode=ver_mode, mode='', docker_repo='grafana-oss', ubuntu=True) + publish_images_step(edition=edition, ver_mode=ver_mode, mode='', docker_repo='grafana', trigger=trigger_oss), + publish_images_step(edition=edition, ver_mode=ver_mode, mode='', docker_repo='grafana-oss', trigger=trigger_oss) ]) if include_enterprise2: integration_test_steps.extend([redis_integration_tests_step(edition=edition2, ver_mode=ver_mode), memcached_integration_tests_step(edition=edition2, ver_mode=ver_mode)]) build_steps.extend([ - release_canary_npm_packages_step(edition), - upload_packages_step(edition=edition, ver_mode=ver_mode, is_downstream=is_downstream), - upload_cdn_step(edition=edition, ver_mode=ver_mode) + release_canary_npm_packages_step(edition, trigger=trigger_oss), + upload_packages_step(edition=edition, ver_mode=ver_mode, is_downstream=is_downstream, trigger=trigger_oss), + upload_cdn_step(edition=edition, ver_mode=ver_mode, trigger=trigger_oss) ]) if include_enterprise2: @@ -180,7 +181,10 @@ def trigger_test_release(): 'include': [ '.drone.yml', ] - } + }, + 'repo': [ + 'grafana/grafana', + ] } } @@ -190,9 +194,6 @@ def main_pipelines(edition): trigger = { 'event': ['push',], 'branch': 'main', - 'repo': [ - 'grafana/grafana', - ], } drone_change_trigger = { 'event': ['push',], @@ -233,7 +234,7 @@ def main_pipelines(edition): volumes=volumes, ), pipeline( - name='windows-main', edition=edition, trigger=trigger, + name='windows-main', edition=edition, trigger=dict(trigger, repo = ['grafana/grafana']), steps=initialize_step(edition, platform='windows', ver_mode=ver_mode) + windows_steps, depends_on=['main-test', 'main-build-e2e-publish', 'main-integration-tests'], platform='windows', ), notify_pipeline( @@ -242,7 +243,7 @@ def main_pipelines(edition): ] if edition != 'enterprise': pipelines.append(pipeline( - name='publish-main', edition=edition, trigger=trigger, + name='publish-main', edition=edition, trigger=dict(trigger, repo = ['grafana/grafana']), steps=[download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode, install_deps=False) + store_steps, depends_on=['main-test', 'main-build-e2e-publish', 'main-integration-tests', 'windows-main',], )) diff --git a/scripts/drone/pipelines/release.star b/scripts/drone/pipelines/release.star index 963b6236c50..de794ca93c1 100644 --- a/scripts/drone/pipelines/release.star +++ b/scripts/drone/pipelines/release.star @@ -4,7 +4,6 @@ load( 'download_grabpl_step', 'initialize_step', 'lint_drone_step', - 'test_release_ver', 'build_image', 'publish_image', 'lint_backend_step', @@ -31,14 +30,14 @@ load( 'memcached_integration_tests_step', 'get_windows_steps', 'benchmark_ldap_step', - 'frontend_metrics_step', 'store_storybook_step', 'upload_packages_step', 'store_packages_step', 'upload_cdn_step', 'validate_scuemata_step', 'ensure_cuetsified_step', - 'publish_images_step' + 'publish_images_step', + 'trigger_oss' ) load( @@ -57,27 +56,12 @@ load( ) load('scripts/drone/vault.star', 'from_secret', 'github_token', 'pull_secret', 'drone_token', 'prerelease_bucket') - -def build_npm_packages_step(edition, ver_mode): - if edition == 'enterprise' or ver_mode != 'release': - return None - - return { - 'name': 'build-npm-packages', - 'image': build_image, - 'depends_on': [ - # Has to run after store-storybook since this step cleans the files publish-storybook depends on - 'store-storybook', - ], - 'commands': ['./scripts/build/build-npm-packages.sh ${DRONE_TAG}'], - } - def store_npm_packages_step(): return { 'name': 'store-npm-packages', 'image': publish_image, 'depends_on': [ - 'build-npm-packages', + 'build-frontend-packages', ], 'environment': { 'GCP_KEY': from_secret('gcp_key'), @@ -228,16 +212,14 @@ def get_steps(edition, ver_mode): integration_test_steps.extend([redis_integration_tests_step(edition=edition2, ver_mode=ver_mode), memcached_integration_tests_step(edition=edition2, ver_mode=ver_mode)]) if should_upload: - publish_steps.append(upload_cdn_step(edition=edition, ver_mode=ver_mode)) - publish_steps.append(upload_packages_step(edition=edition, ver_mode=ver_mode)) + publish_steps.append(upload_cdn_step(edition=edition, ver_mode=ver_mode, trigger=trigger_oss)) + publish_steps.append(upload_packages_step(edition=edition, ver_mode=ver_mode, trigger=trigger_oss)) if should_publish: publish_step = store_storybook_step(edition=edition, ver_mode=ver_mode) - build_npm_step = build_npm_packages_step(edition=edition, ver_mode=ver_mode) store_npm_step = store_npm_packages_step() if publish_step: publish_steps.append(publish_step) - if build_npm_step and store_npm_step: - publish_steps.append(build_npm_step) + if store_npm_step: publish_steps.append(store_npm_step) windows_package_steps = get_windows_steps(edition=edition, ver_mode=ver_mode) @@ -433,7 +415,7 @@ def release_pipelines(ver_mode='release', trigger=None, environment=None): }, 'ref': ['refs/tags/v*',], 'repo': { - 'exclude': ['grafana/grafana'], + 'exclude': ['grafana/grafana'], }, } diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star index 4592cca28f7..f373ccf00ca 100644 --- a/scripts/drone/steps/lib.star +++ b/scripts/drone/steps/lib.star @@ -1,6 +1,6 @@ load('scripts/drone/vault.star', 'from_secret', 'github_token', 'pull_secret', 'drone_token', 'prerelease_bucket') -grabpl_version = 'v2.9.30' +grabpl_version = 'v2.9.32' build_image = 'grafana/build-container:1.5.3' publish_image = 'grafana/grafana-ci-deploy:1.3.1' deploy_docker_image = 'us.gcr.io/kubernetes-dev/drone/plugins/deploy-image' @@ -8,9 +8,15 @@ alpine_image = 'alpine:3.15' curl_image = 'byrnedo/alpine-curl:0.1.8' windows_image = 'mcr.microsoft.com/windows:1809' wix_image = 'grafana/ci-wix:0.1.1' -test_release_ver = 'v7.3.0-test' disable_tests = False +trigger_oss = { + 'when': { + 'repo': [ + 'grafana/grafana', + ] + } +} def slack_step(channel, template, secret): return { @@ -253,7 +259,7 @@ def build_storybook_step(edition, ver_mode): } -def store_storybook_step(edition, ver_mode): +def store_storybook_step(edition, ver_mode, trigger=None): if edition in ('enterprise', 'enterprise2'): return None @@ -267,12 +273,12 @@ def store_storybook_step(edition, ver_mode): 'printenv GCP_KEY | base64 -d > /tmp/gcpkey.json', 'gcloud auth activate-service-account --key-file=/tmp/gcpkey.json', ] + [ - 'gsutil -m rsync -d -r ./packages/grafana-ui/dist/storybook gs://$${{PRERELEASE_BUCKET}}/artifacts/storybook/{}'.format( - c) + 'gsutil -m rm -r gs://$${{PRERELEASE_BUCKET}}/artifacts/storybook/{} && gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${{PRERELEASE_BUCKET}}/artifacts/storybook/{}'.format( + c, c) for c in channels ]) - return { + step = { 'name': 'store-storybook', 'image': publish_image, 'depends_on': ['build-storybook',] + end_to_end_tests_deps(edition), @@ -282,6 +288,9 @@ def store_storybook_step(edition, ver_mode): }, 'commands': commands, } + if trigger and ver_mode in ("release-branch", "main"): + step.update(trigger) + return step def e2e_tests_artifacts(edition): return { @@ -322,7 +331,7 @@ def e2e_tests_artifacts(edition): } -def upload_cdn_step(edition, ver_mode): +def upload_cdn_step(edition, ver_mode, trigger=None): src_dir = '' if ver_mode == "release": bucket = "$${PRERELEASE_BUCKET}" @@ -340,7 +349,7 @@ def upload_cdn_step(edition, ver_mode): 'grafana-server', ]) - return { + step = { 'name': 'upload-cdn-assets' + enterprise2_suffix(edition), 'image': publish_image, 'depends_on': deps, @@ -352,6 +361,9 @@ def upload_cdn_step(edition, ver_mode): './bin/grabpl upload-cdn --edition {} --src-bucket "{}"{}'.format(edition, bucket, src_dir), ], } + if trigger and ver_mode in ("release-branch", "main"): + step.update(trigger) + return step def build_backend_step(edition, ver_mode, variants=None, is_downstream=False): @@ -581,11 +593,11 @@ def test_a11y_frontend_step(ver_mode, edition, port=3001): } -def frontend_metrics_step(edition): +def frontend_metrics_step(edition, trigger=None): if edition in ('enterprise', 'enterprise2'): return None - return { + step = { 'name': 'publish-frontend-metrics', 'image': build_image, 'depends_on': [ @@ -599,6 +611,9 @@ def frontend_metrics_step(edition): './scripts/ci-frontend-metrics.sh | ./bin/grabpl publish-metrics $${GRAFANA_MISC_STATS_API_KEY}', ], } + if trigger: + step.update(trigger) + return step def codespell_step(): @@ -798,7 +813,7 @@ def build_docker_images_step(edition, ver_mode, archs=None, ubuntu=False, publis }, } -def publish_images_step(edition, ver_mode, mode, docker_repo, ubuntu=False): +def publish_images_step(edition, ver_mode, mode, docker_repo, trigger=None): if mode == 'security': mode = '--{} '.format(mode) else: @@ -812,7 +827,7 @@ def publish_images_step(edition, ver_mode, mode, docker_repo, ubuntu=False): else: deps = ['build-docker-images', 'build-docker-images-ubuntu'] - return { + step = { 'name': 'publish-images-{}'.format(docker_repo), 'image': 'google/cloud-sdk', 'environment': { @@ -827,6 +842,10 @@ def publish_images_step(edition, ver_mode, mode, docker_repo, ubuntu=False): 'path': '/var/run/docker.sock' }], } + if trigger and ver_mode in ("release-branch", "main"): + step.update(trigger) + + return step def postgres_integration_tests_step(edition, ver_mode): @@ -923,11 +942,11 @@ def memcached_integration_tests_step(edition, ver_mode): } -def release_canary_npm_packages_step(edition): +def release_canary_npm_packages_step(edition, trigger=None): if edition in ('enterprise', 'enterprise2'): return None - return { + step = { 'name': 'release-canary-npm-packages', 'image': build_image, 'depends_on': end_to_end_tests_deps(edition), @@ -938,6 +957,9 @@ def release_canary_npm_packages_step(edition): './scripts/circle-release-canary-packages.sh', ], } + if trigger: + step.update(trigger) + return step def enterprise2_suffix(edition): @@ -946,7 +968,7 @@ def enterprise2_suffix(edition): return '' -def upload_packages_step(edition, ver_mode, is_downstream=False): +def upload_packages_step(edition, ver_mode, is_downstream=False, trigger=None): if ver_mode == 'main' and edition in ('enterprise', 'enterprise2') and not is_downstream: return None @@ -966,7 +988,7 @@ def upload_packages_step(edition, ver_mode, is_downstream=False): else: deps.extend(end_to_end_tests_deps(edition)) - return { + step = { 'name': 'upload-packages' + enterprise2_suffix(edition), 'image': publish_image, 'depends_on': deps, @@ -976,6 +998,9 @@ def upload_packages_step(edition, ver_mode, is_downstream=False): }, 'commands': [cmd, ], } + if trigger and ver_mode in ("release-branch", "main"): + step.update(trigger) + return step def store_packages_step(edition, ver_mode, is_downstream=False): diff --git a/scripts/drone/version.star b/scripts/drone/version.star index 983620aada8..9a5b39d41de 100644 --- a/scripts/drone/version.star +++ b/scripts/drone/version.star @@ -8,7 +8,4 @@ ver_mode = 'release-branch' def version_branch_pipelines(): return release_pipelines(ver_mode=ver_mode, trigger={ 'ref': ['refs/heads/v[0-9]*'], - 'repo': [ - 'grafana/grafana', - ], }) diff --git a/yarn.lock b/yarn.lock index 8ad75352d82..ace1dddad49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4321,7 +4321,7 @@ __metadata: sass: 1.27.0 sass-loader: 8.0.2 semver: ^7.1.3 - simple-git: ^2.46.0 + simple-git: ^3.5.0 style-loader: 1.1.3 terser-webpack-plugin: 2.3.7 ts-jest: 26.4.4 @@ -6245,14 +6245,14 @@ __metadata: languageName: node linkType: hard -"@microsoft/api-extractor-model@npm:7.15.4": - version: 7.15.4 - resolution: "@microsoft/api-extractor-model@npm:7.15.4" +"@microsoft/api-extractor-model@npm:7.16.0": + version: 7.16.0 + resolution: "@microsoft/api-extractor-model@npm:7.16.0" dependencies: "@microsoft/tsdoc": 0.13.2 "@microsoft/tsdoc-config": ~0.15.2 "@rushstack/node-core-library": 3.45.1 - checksum: 9e4cce0d2575da97413c7df8a26da39ed1d73e654035f811cbc001d231650647dd0692881f46cdfd3a08b53c89589960464347050953a8dfe8f4172367104273 + checksum: ba1bf057f5eed213aea45887a93c0cdabd0d7c9da2644aeb7460748fa33a01f04b91507923e7ea511452a7dfb849498089a2c3a844986ffc4c2abf8ab6d831a6 languageName: node linkType: hard @@ -7370,16 +7370,6 @@ __metadata: languageName: node linkType: hard -"@scena/dragscroll@npm:^1.0.1, @scena/dragscroll@npm:^1.0.3": - version: 1.0.3 - resolution: "@scena/dragscroll@npm:1.0.3" - dependencies: - "@daybrush/utils": 1.6.0 - "@scena/event-emitter": ^1.0.2 - checksum: d7f3695cba4503d39292e899bed5d61aa24b98cb0443450b434fad29fa05d671837022eae4d324eb6e23cd5c1a179d2b4a1236245e6025b932a722eca26bcb83 - languageName: node - linkType: hard - "@scena/dragscroll@npm:^1.1.1": version: 1.1.1 resolution: "@scena/dragscroll@npm:1.1.1" @@ -7399,7 +7389,7 @@ __metadata: languageName: node linkType: hard -"@scena/matrix@npm:1.1.1, @scena/matrix@npm:^1.0.0, @scena/matrix@npm:^1.1.1": +"@scena/matrix@npm:^1.0.0, @scena/matrix@npm:^1.1.1": version: 1.1.1 resolution: "@scena/matrix@npm:1.1.1" dependencies: @@ -19909,16 +19899,6 @@ __metadata: languageName: node linkType: hard -"gesto@npm:^1.2.1, gesto@npm:^1.4.0": - version: 1.4.0 - resolution: "gesto@npm:1.4.0" - dependencies: - "@daybrush/utils": ^1.0.0 - "@scena/event-emitter": ^1.0.2 - checksum: 2514adc783f42e3012564618d67eae65fc6557703b559bbf6c223975137fd7956d4aea8bbc4e7d988cc1940f78f9ed1a554403c63c4053f3d113506ab84de2be - languageName: node - linkType: hard - "gesto@npm:^1.7.0": version: 1.7.0 resolution: "gesto@npm:1.7.0" @@ -20631,7 +20611,7 @@ __metadata: monaco-promql: ^1.7.2 mousetrap: 1.6.5 mousetrap-global-bind: 1.1.0 - moveable: 0.27.3 + moveable: 0.28.0 mutationobserver-shim: 0.3.7 ngtemplate-loader: 2.1.0 node-notifier: 10.0.1 @@ -26330,14 +26310,14 @@ __metadata: languageName: node linkType: hard -"moveable@npm:0.27.3": - version: 0.27.3 - resolution: "moveable@npm:0.27.3" +"moveable@npm:0.28.0": + version: 0.28.0 + resolution: "moveable@npm:0.28.0" dependencies: "@scena/event-emitter": ^1.0.3 - react-compat-moveable: ~0.15.2 + react-compat-moveable: ~0.16.0 react-simple-compat: ^1.2.1 - checksum: 2c99a866da521ebdfb6111fd7e732f94ebfa19a7caa20125f2f0fcc4e4d378a5f8039e58bdb28111a6c1eb18ec8ecd935386acbc572bf5b2d8a0748594a5be19 + checksum: 1fee85a8c72ad80e55f7c009986fe579ef409afd909a2774f32470a2784dc83e97bd6b8694c5e3d2f73129a84099b009f08ff2565530dbf93009ec9d858ec093 languageName: node linkType: hard @@ -26611,12 +26591,17 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^2.6.1": - version: 2.6.5 - resolution: "node-fetch@npm:2.6.5" +"node-fetch@npm:2.6.7": + version: 2.6.7 + resolution: "node-fetch@npm:2.6.7" dependencies: whatwg-url: ^5.0.0 - checksum: 4e83db450718e70762882f00d96f647a7f2f3170035225934ddd5450cb1d91ef339ceb180d3687bcb0a6ed78c3fa5636ce8d3e44ec81ab59e0224ebf8965f65f + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 8d816ffd1ee22cab8301c7756ef04f3437f18dace86a1dae22cf81db8ef29c0bf6655f3215cb0cdb22b420b6fe141e64b26905e7f33f9377a7fa59135ea3e10b languageName: node linkType: hard @@ -30506,15 +30491,23 @@ __metadata: languageName: node linkType: hard -"react-compat-moveable@npm:~0.15.2": - version: 0.15.2 - resolution: "react-compat-moveable@npm:0.15.2" +"react-compat-moveable@npm:~0.16.0": + version: 0.16.0 + resolution: "react-compat-moveable@npm:0.16.0" dependencies: "@daybrush/utils": ^1.6.0 - "@scena/dragscroll": ^1.0.1 + "@egjs/agent": ^2.2.1 + "@egjs/children-differ": ^1.0.1 + "@scena/dragscroll": ^1.1.1 + "@scena/matrix": ^1.1.1 + css-to-mat: ^1.0.3 + framework-utils: ^1.1.0 + gesto: ^1.7.0 + overlap-area: ^1.0.0 react-compat-css-styled: ^1.0.8 - react-moveable: ~0.30.3 - checksum: 65c8e8b54af6df4b4fddeab957ed97f2fe6d73cdc903903e70f2391d3f179b8b75772fae07754a1e26dfd1c60320be7f720b5b511124e5d7e74e9cef786a2c03 + react-css-styled: ^1.0.3 + react-moveable: ~0.31.0 + checksum: b20bd91698d51a0f7e05534d09c246c4995e26c8e598c169ecc2f81602e3df5124d0327e5eeceef6cb1dc5df97efd81f8e6ee467a022ff4b83c2ae8704295a4a languageName: node linkType: hard @@ -30908,7 +30901,7 @@ __metadata: languageName: node linkType: hard -"react-moveable@npm:0.31.1": +"react-moveable@npm:0.31.1, react-moveable@npm:~0.31.0": version: 0.31.1 resolution: "react-moveable@npm:0.31.1" dependencies: @@ -30926,24 +30919,6 @@ __metadata: languageName: node linkType: hard -"react-moveable@npm:~0.30.3": - version: 0.30.3 - resolution: "react-moveable@npm:0.30.3" - dependencies: - "@daybrush/utils": ^1.6.0 - "@egjs/agent": ^2.2.1 - "@egjs/children-differ": ^1.0.1 - "@scena/dragscroll": ^1.0.3 - "@scena/matrix": ^1.1.1 - css-to-mat: ^1.0.3 - framework-utils: ^1.1.0 - gesto: ^1.2.1 - overlap-area: ^1.0.0 - react-css-styled: ^1.0.3 - checksum: 552528854d01ce4abb7a6346c489f1fcf920f288e7a06d03ab893fef2ad1586900dc7c1241dd332d41bf8256ac378fd265f8c33c2bbfcc9e522b5572564c1cb7 - languageName: node - linkType: hard - "react-popper-tooltip@npm:^3.1.1": version: 3.1.1 resolution: "react-popper-tooltip@npm:3.1.1" @@ -33066,17 +33041,6 @@ __metadata: languageName: node linkType: hard -"simple-git@npm:^2.46.0": - version: 2.47.0 - resolution: "simple-git@npm:2.47.0" - dependencies: - "@kwsites/file-exists": ^1.1.1 - "@kwsites/promise-deferred": ^1.1.1 - debug: ^4.3.2 - checksum: 0fefd12a2aaec9b27bcd13e713991769cab3eb185fcf0ad3e1afff17d34c63a7dbb80c4acd3220b2d9522dd9d171f84b237e6dedb4e17545c8bb7aadf746efb9 - languageName: node - linkType: hard - "simple-git@npm:^3.1.1": version: 3.4.0 resolution: "simple-git@npm:3.4.0" @@ -33088,6 +33052,17 @@ __metadata: languageName: node linkType: hard +"simple-git@npm:^3.5.0": + version: 3.5.0 + resolution: "simple-git@npm:3.5.0" + dependencies: + "@kwsites/file-exists": ^1.1.1 + "@kwsites/promise-deferred": ^1.1.1 + debug: ^4.3.3 + checksum: be078d9e898d1dea99bb15d4f6927c0f61aafde73114c7423fb0efca6d6f99012da2a2f43b5e2151a1707994cc9c75139bc12e3d307dd25da48ed1dce1167748 + languageName: node + linkType: hard + "simple-is@npm:~0.2.0": version: 0.2.0 resolution: "simple-is@npm:0.2.0"