diff --git a/.betterer.results b/.betterer.results index ee83f06d95e..a354be90586 100644 --- a/.betterer.results +++ b/.betterer.results @@ -92,7 +92,7 @@ exports[`no enzyme tests`] = { "packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.js:3669774385": [ [15, 26, 13, "RegExp match", "2409514259"] ], - "packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.js:1298620967": [ + "packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.js:2095199604": [ [15, 19, 13, "RegExp match", "2409514259"] ], "packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.js:793239444": [ @@ -305,7 +305,7 @@ exports[`no enzyme tests`] = { "public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx:2974837543": [ [1, 19, 13, "RegExp match", "2409514259"] ], - "public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx:4272466995": [ + "public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx:132770839": [ [1, 19, 13, "RegExp match", "2409514259"] ], "public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx:1089831034": [ diff --git a/.bra.toml b/.bra.toml index e9be6c13c66..0d6e19cfd01 100644 --- a/.bra.toml +++ b/.bra.toml @@ -1,8 +1,8 @@ [run] init_cmds = [ ["make", "gen-go"], - ["go", "run", "build.go", "-dev", "build-cli"], - ["go", "run", "build.go", "-dev", "build-server"], + ["GO_BUILD_DEV=1", "make", "build-cli"], + ["GO_BUILD_DEV=1", "make", "build-server"], ["./bin/grafana-server", "-packaging=dev", "cfg:app_mode=development"] ] watch_all = true @@ -13,10 +13,10 @@ watch_dirs = [ "$WORKDIR/conf", ] watch_exts = [".go", ".ini", ".toml", ".template.html"] -ignore_files = ["wire_gen.go"] +ignore_files = ["wire_gen.go"] build_delay = 1500 cmds = [ ["make", "gen-go"], - ["go", "run", "build.go", "-dev", "build-server"], + ["GO_BUILD_DEV=1", "make", "build-server"], ["./bin/grafana-server", "-packaging=dev", "cfg:app_mode=development"] ] diff --git a/.drone.yml b/.drone.yml index 3da774d37a0..9d4c9f1550a 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.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/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.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -130,7 +130,7 @@ steps: name: initialize - commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} - --variants linux-amd64,linux-amd64-musl,darwin-amd64,windows-amd64,armv6 --no-pull-enterprise + --variants linux-amd64,linux-amd64-musl,darwin-amd64,windows-amd64,armv6 depends_on: - initialize environment: {} @@ -138,7 +138,6 @@ steps: name: build-backend - commands: - ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise depends_on: - initialize environment: @@ -147,7 +146,6 @@ steps: name: build-frontend - commands: - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise depends_on: - initialize environment: @@ -182,7 +180,7 @@ steps: name: ensure-cuetsified - commands: - . scripts/build/gpg-test-vars.sh && ./bin/grabpl package --jobs 8 --edition oss - --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --variants linux-amd64,linux-amd64-musl,darwin-amd64,windows-amd64,armv6 + --build-id ${DRONE_BUILD_NUMBER} --variants linux-amd64,linux-amd64-musl,darwin-amd64,windows-amd64,armv6 depends_on: - build-plugins - build-backend @@ -358,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.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -422,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.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -460,7 +458,6 @@ steps: name: lint-docs - commands: - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise depends_on: - initialize environment: @@ -506,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.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -544,7 +541,6 @@ steps: name: lint-docs - commands: - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise depends_on: - initialize environment: @@ -589,7 +585,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -692,7 +688,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -744,7 +740,6 @@ steps: from_secret: drone_token - commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise depends_on: - initialize environment: {} @@ -752,7 +747,6 @@ steps: name: build-backend - commands: - ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise depends_on: - initialize environment: @@ -761,7 +755,6 @@ steps: name: build-frontend - commands: - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise depends_on: - initialize environment: @@ -797,8 +790,7 @@ steps: image: grafana/build-container:1.5.3 name: ensure-cuetsified - commands: - - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise - --sign + - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --sign depends_on: - build-plugins - build-backend @@ -1102,7 +1094,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1172,7 +1164,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1239,6 +1231,8 @@ trigger: - exclude include: - .drone.yml + repo: + - grafana/grafana type: docker --- depends_on: @@ -1257,7 +1251,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1343,7 +1337,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1360,7 +1354,7 @@ steps: name: initialize - commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} - --no-pull-enterprise ${DRONE_TAG} + ${DRONE_TAG} depends_on: - initialize environment: @@ -1370,7 +1364,7 @@ steps: name: build-backend - commands: - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --edition - oss --no-pull-enterprise ${DRONE_TAG} + oss ${DRONE_TAG} depends_on: - initialize environment: @@ -1379,7 +1373,7 @@ steps: name: build-frontend - commands: - ./bin/grabpl build-frontend-packages --jobs 8 --github-token $${GITHUB_TOKEN} - --edition oss --no-pull-enterprise ${DRONE_TAG} + --edition oss ${DRONE_TAG} depends_on: - initialize environment: @@ -1415,8 +1409,8 @@ steps: image: grafana/build-container:1.5.3 name: ensure-cuetsified - commands: - - ./bin/grabpl package --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise - --sign ${DRONE_TAG} + - ./bin/grabpl package --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --sign + ${DRONE_TAG} depends_on: - build-plugins - build-backend @@ -1651,7 +1645,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1772,7 +1766,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1856,7 +1850,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1915,7 +1909,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1954,7 +1948,7 @@ steps: name: initialize - commands: - ./bin/grabpl build-backend --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} - --no-pull-enterprise ${DRONE_TAG} + ${DRONE_TAG} depends_on: - initialize environment: @@ -1964,7 +1958,7 @@ steps: name: build-backend - commands: - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --edition - enterprise --no-pull-enterprise ${DRONE_TAG} + enterprise ${DRONE_TAG} depends_on: - initialize environment: @@ -1973,7 +1967,7 @@ steps: name: build-frontend - commands: - ./bin/grabpl build-frontend-packages --jobs 8 --github-token $${GITHUB_TOKEN} - --edition enterprise --no-pull-enterprise ${DRONE_TAG} + --edition enterprise ${DRONE_TAG} depends_on: - initialize environment: @@ -2010,7 +2004,7 @@ steps: name: ensure-cuetsified - commands: - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} - --no-pull-enterprise ${DRONE_TAG} + ${DRONE_TAG} depends_on: - initialize environment: @@ -2020,7 +2014,7 @@ steps: name: build-backend-enterprise2 - commands: - ./bin/grabpl package --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} - --no-pull-enterprise --sign ${DRONE_TAG} + --sign ${DRONE_TAG} depends_on: - build-plugins - build-backend @@ -2178,7 +2172,7 @@ steps: name: upload-packages - commands: - ./bin/grabpl package --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} - --no-pull-enterprise --sign ${DRONE_TAG} + --sign ${DRONE_TAG} depends_on: - build-plugins - build-backend @@ -2258,7 +2252,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2431,7 +2425,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2559,7 +2553,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -2634,7 +2628,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2712,7 +2706,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2773,7 +2767,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2852,7 +2846,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2914,7 +2908,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2950,7 +2944,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2997,7 +2991,7 @@ steps: name: initialize - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3034,6 +3028,8 @@ volumes: --- depends_on: - publish-artifacts-public +- publish-docker-oss-public +- publish-docker-enterprise-public kind: pipeline name: publish-packages node: @@ -3045,7 +3041,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3108,7 +3104,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3124,7 +3120,6 @@ steps: name: initialize - commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise depends_on: - initialize environment: {} @@ -3132,7 +3127,6 @@ steps: name: build-backend - commands: - ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise depends_on: - initialize environment: @@ -3141,7 +3135,6 @@ steps: name: build-frontend - commands: - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise depends_on: - initialize environment: @@ -3177,8 +3170,7 @@ steps: image: grafana/build-container:1.5.3 name: ensure-cuetsified - commands: - - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise - --sign + - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --sign depends_on: - build-plugins - build-backend @@ -3373,7 +3365,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3489,7 +3481,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3568,7 +3560,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -3618,7 +3610,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3653,7 +3645,6 @@ steps: name: initialize - commands: - ./bin/grabpl build-backend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise depends_on: - initialize environment: {} @@ -3661,7 +3652,6 @@ steps: name: build-backend - commands: - ./bin/grabpl build-frontend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise depends_on: - initialize environment: @@ -3670,7 +3660,7 @@ steps: name: build-frontend - commands: - ./bin/grabpl build-frontend-packages --jobs 8 --edition enterprise --build-id - ${DRONE_BUILD_NUMBER} --no-pull-enterprise + ${DRONE_BUILD_NUMBER} depends_on: - initialize environment: @@ -3707,7 +3697,7 @@ steps: name: ensure-cuetsified - commands: - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} - --variants linux-amd64 --no-pull-enterprise + --variants linux-amd64 depends_on: - initialize environment: {} @@ -3715,7 +3705,7 @@ steps: name: build-backend-enterprise2 - commands: - ./bin/grabpl package --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise --sign + --sign depends_on: - build-plugins - build-backend @@ -3882,7 +3872,7 @@ steps: name: upload-packages - commands: - ./bin/grabpl package --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise --variants linux-amd64 --sign + --variants linux-amd64 --sign depends_on: - build-plugins - build-backend @@ -3957,7 +3947,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4122,7 +4112,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4242,7 +4232,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.30/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -4441,6 +4431,6 @@ kind: secret name: gcp_upload_artifacts_key --- kind: signature -hmac: b5191e597c2ea7a1152ab18ca2c4aece939024ceb8fd5c76581c087a64ae6ad3 +hmac: 430843c058dc4f40a3f881e13fc61ab12f5b6dc823f99067e56ab49fc1bdd459 ... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6e2be288b92..cde59147f7e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -63,6 +63,7 @@ go.sum @grafana/backend-platform /pkg/services/ngalert @grafana/alerting-squad-backend /pkg/services/sqlstore/migrations/ualert @grafana/alerting-squad-backend /pkg/services/alerting @grafana/alerting-squad-backend +/pkg/tests/api/alerting @grafana/alerting-squad-backend /public/app/features/alerting @grafana/alerting-squad-frontend # Library Services diff --git a/.github/pr-commands.json b/.github/pr-commands.json index 5f87bad992f..59edcbb9397 100644 --- a/.github/pr-commands.json +++ b/.github/pr-commands.json @@ -181,7 +181,7 @@ }, { "type": "changedfiles", - "matches": ["/pkg/services/ngalert/**/*", "/pkg/services/sqlstore/migrations/ualert/**/*", "/pkg/services/alerting/**/*", "/public/app/features/alerting/**/*"], + "matches": ["/pkg/services/ngalert/**/*", "/pkg/services/sqlstore/migrations/ualert/**/*", "/pkg/services/alerting/**/*", "/public/app/features/alerting/**/*", "/pkg/tests/api/alerting/**/*"], "action": "updateLabel", "addLabel": "area/alerting" }, diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index debee02c499..f0aface7844 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -60,12 +60,6 @@ jobs: echo "::set-output name=branch_name::v${{steps.regex-match.outputs.group1}}" echo "::set-output name=branch_exist::$(git ls-remote --heads https://github.com/grafana/grafana.git v${{ steps.regex-match.outputs.group1 }}.x | wc -l)" - - name: Check input version is aligned with branch(not main) - if: ${{ github.event.inputs.version != '' && steps.intermedia.outputs.branch_exist != '0' && !contains(steps.intermedia.outputs.short_ref, steps.intermedia.outputs.branch_name) }} - run: | - echo " You need to run the workflow on branch v${{steps.regex-match.outputs.group1}}.x - exit 1 - - name: Check input version is aligned with branch(main) if: ${{ github.event.inputs.version != '' && steps.intermedia.outputs.branch_exist == '0' && !contains(steps.intermedia.outputs.short_ref, 'main') }} run: | diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 61f9dc4c477..c1578b4840f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -8,10 +8,8 @@ name: "CodeQL" on: push: branches: [main, v1.8.x, v2.0.x, v2.1.x, v2.6.x, v3.0.x, v3.1.x, v4.0.x, v4.1.x, v4.2.x, v4.3.x, v4.4.x, v4.5.x, v4.6.x, v4.7.x, v5.0.x, v5.1.x, v5.2.x, v5.3.x, v5.4.x, v6.0.x, v6.1.x, v6.2.x, v6.3.x, v6.4.x, v6.5.x, v6.6.x, v6.7.x, v7.0.x, v7.1.x, v7.2.x] - pull_request: - # The branches below must be a subset of the branches above - branches: [main] paths-ignore: + - '**/*.cue' - '**/*.json' - '**/*.md' - '**/*.txt' @@ -51,21 +49,5 @@ jobs: # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/pr-codeql-analysis-go.yml b/.github/workflows/pr-codeql-analysis-go.yml new file mode 100644 index 00000000000..6e6bd524cc7 --- /dev/null +++ b/.github/workflows/pr-codeql-analysis-go.yml @@ -0,0 +1,29 @@ +name: "CodeQL for PR / go" + +on: + pull_request: + branches: [main] + paths: + - '**/*.go' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: "go" + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/pr-codeql-analysis-javascript.yml b/.github/workflows/pr-codeql-analysis-javascript.yml new file mode 100644 index 00000000000..943bbfc1543 --- /dev/null +++ b/.github/workflows/pr-codeql-analysis-javascript.yml @@ -0,0 +1,31 @@ +name: "CodeQL for PR / javascript" + +on: + pull_request: + branches: [main] + paths: + - '**/*.js' + - '**/*.ts' + - '**/*.tsx' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: "javascript" + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/pr-codeql-analysis-python.yml b/.github/workflows/pr-codeql-analysis-python.yml new file mode 100644 index 00000000000..4f887526812 --- /dev/null +++ b/.github/workflows/pr-codeql-analysis-python.yml @@ -0,0 +1,29 @@ +name: "CodeQL for PR / python" + +on: + pull_request: + branches: [main] + paths: + - '**/*.py' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: "python" + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.yarn/releases/yarn-3.2.0.cjs b/.yarn/releases/yarn-3.2.0.cjs old mode 100755 new mode 100644 diff --git a/.yarn/sdks/eslint/package.json b/.yarn/sdks/eslint/package.json index a098a21bceb..9a22a3b2b31 100644 --- a/.yarn/sdks/eslint/package.json +++ b/.yarn/sdks/eslint/package.json @@ -1,6 +1,6 @@ { "name": "eslint", - "version": "8.10.0-sdk", + "version": "8.11.0-sdk", "main": "./lib/api.js", "type": "commonjs" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f81a4f95ff1..b7f97bec4e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ + + +# 8.4.5 (2022-03-31) + +### Features and enhancements + +- **Instrumentation:** Make backend plugin metrics endpoints available with optional authentication. [#46467](https://github.com/grafana/grafana/pull/46467), [@marefr](https://github.com/marefr) +- **Table panel:** Show datalinks for cell display modes JSON View and Gauge derivates. [#46020](https://github.com/grafana/grafana/pull/46020), [@mdvictor](https://github.com/mdvictor) + +### Bug fixes + +- **Azure Monitor:** Small bug fixes for Resource Picker. [#46665](https://github.com/grafana/grafana/pull/46665), [@sarahzinger](https://github.com/sarahzinger) +- **Logger:** Use specified format for file logger. [#46970](https://github.com/grafana/grafana/pull/46970), [@sakjur](https://github.com/sakjur) +- **Logs:** Handle missing fields in dataframes better. [#46963](https://github.com/grafana/grafana/pull/46963), [@gabor](https://github.com/gabor) +- **ManageDashboards:** Fix error when deleting all dashboards from folder view. [#46877](https://github.com/grafana/grafana/pull/46877), [@joshhunt](https://github.com/joshhunt) + + # 8.4.4 (2022-03-16) diff --git a/Makefile b/Makefile index 1c74cc3569a..f83188b9eea 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,8 @@ GO_FILES ?= ./pkg/... SH_FILES ?= $(shell find ./scripts -name *.sh) API_DEFINITION_FILES = $(shell find ./pkg/api/docs/definitions -name '*.go' -print) SWAGGER_TAG ?= latest +GO_BUILD_FLAGS += $(if $(GO_BUILD_DEV),-dev) +GO_BUILD_FLAGS += $(if $(GO_BUILD_TAGS),-build-tags=$(GO_BUILD_TAGS)) all: deps build @@ -87,15 +89,15 @@ gen-go: $(WIRE) build-go: $(MERGED_SPEC_TARGET) gen-go ## Build all Go binaries. @echo "build go files" - $(GO) run build.go build + $(GO) run build.go $(GO_BUILD_FLAGS) build build-server: ## Build Grafana server. @echo "build server" - $(GO) run build.go build-server + $(GO) run build.go $(GO_BUILD_FLAGS) build-server build-cli: ## Build Grafana CLI application. @echo "build grafana-cli" - $(GO) run build.go build-cli + $(GO) run build.go $(GO_BUILD_FLAGS) build-cli build-js: ## Build frontend assets. @echo "build frontend" diff --git a/docs/sources/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule.md b/docs/sources/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule.md index 2acc19046d1..56fb61fc26e 100644 --- a/docs/sources/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule.md +++ b/docs/sources/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule.md @@ -69,8 +69,8 @@ Configure alerting behavior in the absence of data using information in the foll | Alerting | Set alert rule state to `Alerting`. | | Ok | Set alert rule state to `Normal`. | -| Error or timeout option | Description | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| Alerting | Set alert rule state to `Alerting` | -| OK | Set alert rule state to `Normal` | -| Error | Create a new alert `DatasourceError` with the name and UID of the alert rule, and UID of the datasource that returned no data as labels. | +| Error or timeout option | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Alerting | Set alert rule state to `Alerting`. From Grafana 8.5, the alert rule waits for the entire duration for which the condition is true before firing. | +| OK | Set alert rule state to `Normal` | +| Error | Create a new alert `DatasourceError` with the name and UID of the alert rule, and UID of the datasource that returned no data as labels. | diff --git a/docs/sources/datasources/aws-cloudwatch/_index.md b/docs/sources/datasources/aws-cloudwatch/_index.md index 22833348ea2..f86572dc142 100644 --- a/docs/sources/datasources/aws-cloudwatch/_index.md +++ b/docs/sources/datasources/aws-cloudwatch/_index.md @@ -323,7 +323,16 @@ If you're not currently logged in to the CloudWatch console, the link will forwa ## Alerting -Since CloudWatch Logs queries can return numeric data, for example through the use of the `stats` command, alerts are supported. +Alerting require queries that return numeric data, which CloudWatch Logs support. For example through the use of the `stats` command, alerts are supported. For example, this is a valid query for alerting on messages that include the text "Exception": + +``` +filter @message like /Exception/ + | stats count(*) as exceptionCount by bin(1h) + | sort exceptionCount desc +``` + +**NOTE**: When trying to alert on a query, if an error like `input data must be a wide series but got ...` is received, make sure that your query returns valid numeric data that can be printed in a Time series panel. + For more information on Grafana alerts, refer to [Alerting]({{< relref "../../alerting/_index.md" >}}) documentation. ## Configure CloudWatch with grafana.ini diff --git a/docs/sources/developers/plugins/_index.md b/docs/sources/developers/plugins/_index.md index 64a4a13f582..f7a0cbbb769 100644 --- a/docs/sources/developers/plugins/_index.md +++ b/docs/sources/developers/plugins/_index.md @@ -53,6 +53,7 @@ Improve an existing plugin with one of our guides: - [Build a streaming data source plugin]({{< relref "build-a-streaming-data-source-plugin.md" >}}) - [Error handling]({{< relref "error-handling.md" >}}) - [Working with data frames]({{< relref "working-with-data-frames.md" >}}) +- [Development with local Grafana]({{< relref "development-with-local-grafana.md" >}}) ### Concepts diff --git a/docs/sources/developers/plugins/development-with-local-grafana.md b/docs/sources/developers/plugins/development-with-local-grafana.md new file mode 100644 index 00000000000..61589775596 --- /dev/null +++ b/docs/sources/developers/plugins/development-with-local-grafana.md @@ -0,0 +1,83 @@ ++++ +title = "Development with local Grafana" ++++ + +# Development with local Grafana + +This guide allows you to setup a development environment where you run Grafana and your plugin locally. With this, you will be able to see your changes as you add them. + +## Run Grafana in your host + +If you have git, Go and the required version of NodeJS in your system, you can clone and run Grafana locally: + +1. Download and set up Grafana. You can find instructions on how to do it in the [developer-guide](https://github.com/grafana/grafana/blob/HEAD/contribute/developer-guide.md). + +2. Grafana will look for plugins, by default, on its `data/plugins` directory. You can create a symbolic link to your plugin repository to detect new changes: + + ```bash + ln -s /dist data/plugins/ + ``` + +3. (Optional) If the step above doesn't work for you (e.g. you are running on Windows), you can also modify the default path in the Grafana configuration (that can be found at `conf/custom.ini`) and point to the directory with your plugin: + + ```ini + [paths] + plugins = + ``` + +## Run Grafana with docker-compose + +Another possibility is to run Grafana with docker-compose so it runs in a container. For doing so, create the docker-compose file in your plugin directory: + +**NOTE**: Some plugins already include a docker-compose file so you can skip this step. + +```yaml +version: '3.7' + +services: + grafana: + # Change latest with your target version, if needed + image: grafana/grafana:latest + ports: + - 3000:3000/tcp + volumes: + # Use your plugin folder (e.g. redshift-datasource) + - ./dist:/var/lib/grafana/plugins/ + - ./provisioning:/etc/grafana/provisioning + environment: + - TERM=linux + - GF_LOG_LEVEL=debug + - GF_DATAPROXY_LOGGING=true + - GF_DEFAULT_APP_MODE=development +``` + +## Run your plugin + +Finally start your plugin in development mode. Go to your plugin root directory and follow these steps: + +1. Build your plugin backend and start the frontend in watch mode: + + ```bash + mage -v + yarn watch + ``` + +2. Start Grafana backend and frontend: + + 2.1 For a local copy of Grafana, go to the directory with Grafana source code and run: + + ```bash + make run + ``` + + ```bash + yarn start + ``` + + 2.2 Or with docker-compose, in your plugin directory, run: + + ```bash + docker-compose up + ``` + +After this, you should be able to see your plugin listed in Grafana and test your changes. Note that any change in the fronted will require you to refresh your browser while changes in the backend may require to rebuild your plugin binaries and reload the plugin (`mage && mage reloadPlugin` for local development or `docker-compose up` again if you are using docker-compose). diff --git a/docs/sources/enterprise/access-control/fine-grained-access-control-references.md b/docs/sources/enterprise/access-control/fine-grained-access-control-references.md index 5a15cbf5223..fd77a19dde6 100644 --- a/docs/sources/enterprise/access-control/fine-grained-access-control-references.md +++ b/docs/sources/enterprise/access-control/fine-grained-access-control-references.md @@ -50,6 +50,9 @@ The reference information that follows complements conceptual information about | `fixed:folders:writer` | All permissions from `fixed:dashboards:writer` and
`folders:read`
`folders:write`
`folders:create`
`folders:delete`
`folders.permissions:read`
`folders.permissions:write` | Read, create, update, and delete all folders and dashboards. | | `fixed:folders.permissions:reader` | `folders.permissions:read` | Read all folder permissions. | | `fixed:folders.permissions:writer` | All permissions from `fixed:folders.permissions:reader` and
`folders.permissions:write` | Read and update all folder permissions. | +| `fixed:annotations:reader` | `annotations:read` | Read all annotations and annotation tags. | +| `fixed:annotations.dashboard:writer` | `annotations:write`
`annotations.create`
`annotations:delete` for scope `annotations:type:dashboard` | Create, update and delete dashboard annotations and annotation tags. | +| `fixed:annotations:writer` | `annotations:write`
`annotations.create`
`annotations:delete` for scope `annotations:type:*` | Create, update and delete all annotations and annotation tags. | ## Default built-in role assignments @@ -57,5 +60,5 @@ The reference information that follows complements conceptual information about | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Grafana Admin | `fixed:roles:reader`
`fixed:roles:writer`
`fixed:users:reader`
`fixed:users:writer`
`fixed:org.users:reader`
`fixed:org.users:writer`
`fixed:ldap:reader`
`fixed:ldap:writer`
`fixed:stats:reader`
`fixed:settings:reader`
`fixed:settings:writer`
`fixed:provisioning:writer`
`fixed:organization:reader`
`fixed:organization:maintainer`
`fixed:licensing:reader`
`fixed:licensing:writer` | Default [Grafana server administrator]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions.md#grafana-server-administrators" >}}) assignments. | | Admin | `fixed:reports:reader`
`fixed:reports:writer`
`fixed:datasources:reader`
`fixed:datasources:writer`
`fixed:organization:writer`
`fixed:datasources.permissions:reader`
`fixed:datasources.permissions:writer`
`fixed:teams:writer`
`fixed:dashboards:reader`
`fixed:dashboards:writer`
`fixed:dashboards.permissions:reader`
`fixed:dashboards.permissions:writer`
`fixed:folders:reader`
`fixes:folders:writer`
`fixed:folders.permissions:reader`
`fixed:folders.permissions:writer` | Default [Grafana organization administrator]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions.md#organization-users-and-permissions" >}}) assignments. | -| Editor | `fixed:datasources:explorer`
`fixed:dashboards:creator`
`fixed:folders:creator`
`fixed:teams:creator` if the `editors_can_admin` configuration flag is enabled | Default [Editor]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions.md#organization-users-and-permissions" >}}) assignments. | -| Viewer | `fixed:datasources:id:reader`
`fixed:organization:reader` | Default [Viewer]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions.md#organization-users-and-permissions" >}}) assignments. | +| Editor | `fixed:datasources:explorer`
`fixed:dashboards:creator`
`fixed:folders:creator`
`fixed:annotations:writer`
`fixed:teams:creator` if the `editors_can_admin` configuration flag is enabled | Default [Editor]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions.md#organization-users-and-permissions" >}}) assignments. | +| Viewer | `fixed:datasources:id:reader`
`fixed:organization:reader`
`fixed:annotations:reader`
`fixed:annotations.dashboard:writer` | Default [Viewer]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions.md#organization-users-and-permissions" >}}) assignments. | diff --git a/docs/sources/enterprise/access-control/permissions.md b/docs/sources/enterprise/access-control/permissions.md index e37a1604f4c..8cd6ae0c2a4 100644 --- a/docs/sources/enterprise/access-control/permissions.md +++ b/docs/sources/enterprise/access-control/permissions.md @@ -113,23 +113,28 @@ The following list contains fine-grained access control actions. | `folders:delete` | `folders:*`
`folders:id:*` | Delete one or more folders. | | `folers.permissions:read` | `folders:*`
`folders:id:*` | Read permissions for one or more folders. | | `folders.permissions:write` | `folders:*`
`folders:id:*` | Update permissions for one or more folders. | +| `annotations.read` | `annotations:*`
`annotations:type:*` | Read annotations and annotation tags. | +| `annotations.create` | `annotations:*`
`annotations:type:*` | Create annotations. | +| `annotations.write` | `annotations:*`
`annotations:type:*` | Update annotations. | +| `annotations.delete` | `annotations:*`
`annotations:type:*` | Delete annotations. | ## Scope definitions The following list contains fine-grained access control scopes. -| Scopes | Descriptions | -| ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `permissions:delegate` | The scope is only applicable for roles associated with the Access Control itself and indicates that you can delegate your permissions only, or a subset of it, by creating a new role or making an assignment. | -| `roles:*`
`roles:uid:*` | Restrict an action to a set of roles. For example, `roles:*` matches any role and `roles:uid:randomuid` matches only the role whose UID is `randomuid`. | -| `reports:*`
`reports:id:*` | Restrict an action to a set of reports. For example, `reports:*` matches any report and `reports:id:1` matches the report whose ID is `1`. | -| `services:accesscontrol` | Restrict an action to target only the fine-grained access control service. You can use this in conjunction with the `status:accesscontrol` actions. | -| `global.users:*`
`global.users:id:*` | Restrict an action to a set of global users. For example, `global.users:*` matches any user and `global.users:id:1` matches the user whose ID is `1`. | -| `teams:*`
`teams:id:*` | Restrict an action to a set of teams from an organization. For example, `teams:*` matches any team and `teams:id:1` matches the team whose ID is `1`. | -| `users:*`
`users:id:*` | Restrict an action to a set of users from an organization. For example, `users:*` matches any user and `users:id:1` matches the user whose ID is `1`. | -| `orgs:*`
`orgs:id:*` | Restrict an action to a set of organizations. For example, `orgs:*` matches any organization and `orgs:id:1` matches the organization whose ID is `1`. | -| `settings:*` | Restrict an action to a subset of settings. For example, `settings:*` matches all settings, `settings:auth.saml:*` matches all SAML settings, and `settings:auth.saml:enabled` matches the enable property on the SAML settings. | -| `provisioners:*` | Restrict an action to a set of provisioners. For example, `provisioners:*` matches any provisioner, and `provisioners:accesscontrol` matches the fine-grained access control [provisioner]({{< relref "./provisioning.md" >}}). | -| `datasources:*`
`datasources:id:*`
`datasources:uid:*`
`datasources:name:*` | Restrict an action to a set of data sources. For example, `datasources:*` matches any data source, and `datasources:name:postgres` matches the data source named `postgres`. | -| `folders:*`
`folders:id:*` | Restrict an action to a set of folders. For example, `folders:*` matches any folder, and `folders:id:1` matches the folder whose ID is `1`. | -| `dashboards:*`
`dashboards:id:*` | Restrict an action to a set of dashboards. For example, `dashboards:*` matches any dashboard, and `dashboards:id:1` matches the dashboard whose ID is `1`. | +| Scopes | Descriptions | +| ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `permissions:delegate` | The scope is only applicable for roles associated with the Access Control itself and indicates that you can delegate your permissions only, or a subset of it, by creating a new role or making an assignment. | +| `roles:*`
`roles:uid:*` | Restrict an action to a set of roles. For example, `roles:*` matches any role and `roles:uid:randomuid` matches only the role whose UID is `randomuid`. | +| `reports:*`
`reports:id:*` | Restrict an action to a set of reports. For example, `reports:*` matches any report and `reports:id:1` matches the report whose ID is `1`. | +| `services:accesscontrol` | Restrict an action to target only the fine-grained access control service. You can use this in conjunction with the `status:accesscontrol` actions. | +| `global.users:*`
`global.users:id:*` | Restrict an action to a set of global users. For example, `global.users:*` matches any user and `global.users:id:1` matches the user whose ID is `1`. | +| `teams:*`
`teams:id:*` | Restrict an action to a set of teams from an organization. For example, `teams:*` matches any team and `teams:id:1` matches the team whose ID is `1`. | +| `users:*`
`users:id:*` | Restrict an action to a set of users from an organization. For example, `users:*` matches any user and `users:id:1` matches the user whose ID is `1`. | +| `orgs:*`
`orgs:id:*` | Restrict an action to a set of organizations. For example, `orgs:*` matches any organization and `orgs:id:1` matches the organization whose ID is `1`. | +| `settings:*` | Restrict an action to a subset of settings. For example, `settings:*` matches all settings, `settings:auth.saml:*` matches all SAML settings, and `settings:auth.saml:enabled` matches the enable property on the SAML settings. | +| `provisioners:*` | Restrict an action to a set of provisioners. For example, `provisioners:*` matches any provisioner, and `provisioners:accesscontrol` matches the fine-grained access control [provisioner]({{< relref "./provisioning.md" >}}). | +| `datasources:*`
`datasources:id:*`
`datasources:uid:*`
`datasources:name:*` | Restrict an action to a set of data sources. For example, `datasources:*` matches any data source, and `datasources:name:postgres` matches the data source named `postgres`. | +| `folders:*`
`folders:id:*` | Restrict an action to a set of folders. For example, `folders:*` matches any folder, and `folders:id:1` matches the folder whose ID is `1`. | +| `dashboards:*`
`dashboards:id:*` | Restrict an action to a set of dashboards. For example, `dashboards:*` matches any dashboard, and `dashboards:id:1` matches the dashboard whose ID is `1`. | +| `annotations:*`
`annotations:type:*` | Restrict an action to a set of annotations. For example, `annotations:*` matches any annotation, `annotations:type:dashboard` matches annotations associated with dashboards and `annotations:type:organization` matches organization annotations. | diff --git a/docs/sources/http_api/annotations.md b/docs/sources/http_api/annotations.md index ee281b0dc69..e784f6b1841 100644 --- a/docs/sources/http_api/annotations.md +++ b/docs/sources/http_api/annotations.md @@ -5,14 +5,25 @@ keywords = ["grafana", "http", "documentation", "api", "annotation", "annotation aliases = ["/docs/grafana/latest/http_api/annotations/"] +++ -# Annotations resources / actions +# Annotations API -This is the API documentation for the new Grafana Annotations feature released in Grafana 4.6. Annotations are saved in the Grafana database (sqlite, mysql or postgres). Annotations can be global annotations that can be shown on any dashboard by configuring an annotation data source - they are filtered by tags. Or they can be tied to a panel on a dashboard and are then only shown on that panel. +This is the API documentation for the new Grafana Annotations feature released in Grafana 4.6. Annotations are saved in the Grafana database (sqlite, mysql or postgres). Annotations can be organization annotations that can be shown on any dashboard by configuring an annotation data source - they are filtered by tags. Or they can be tied to a panel on a dashboard and are then only shown on that panel. + +> If you are running Grafana Enterprise and have [Fine-grained access control]({{< relref "../enterprise/access-control/_index.md" >}}) enabled, access to endpoints will be controlled by Fine-grained access control permissions. +> Refer to specific endpoints to understand what permissions are required. ## Find Annotations `GET /api/annotations?from=1506676478816&to=1507281278816&tags=tag1&tags=tag2&limit=100` +#### Required permissions + +See note in the [introduction]({{< ref "#annotations-api" >}}) for an explanation. + +| Action | Scope | +| ---------------- | -------------- | +| annotations:read | annotations:\* | + **Example Request**: ```http @@ -32,7 +43,7 @@ Query Parameters: - `panelId`: number. Optional. Find annotations that are scoped to a specific panel - `userId`: number. Optional. Find annotations created by a specific user - `type`: string. Optional. `alert`|`annotation` Return alerts or user created annotations -- `tags`: string. Optional. Use this to filter global annotations. Global annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. To do an "AND" filtering with multiple tags, specify the tags parameter multiple times e.g. `tags=tag1&tags=tag2`. +- `tags`: string. Optional. Use this to filter organization annotations. Organization annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. To do an "AND" filtering with multiple tags, specify the tags parameter multiple times e.g. `tags=tag1&tags=tag2`. **Example Response**: @@ -85,13 +96,21 @@ Content-Type: application/json ## Create Annotation Creates an annotation in the Grafana database. The `dashboardId` and `panelId` fields are optional. -If they are not specified then a global annotation is created and can be queried in any dashboard that adds +If they are not specified then an organization annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. When creating a region annotation include the timeEnd property. The format for `time` and `timeEnd` should be epoch numbers in millisecond resolution. `POST /api/annotations` +#### Required permissions + +See note in the [introduction]({{< ref "#annotations-api" >}}) for an explanation. + +| Action | Scope | +| ------------------ | ----------------------- | +| annotations:create | annotations:type: | + **Example Request**: ```http @@ -132,6 +151,14 @@ format (string with multiple tags being separated by a space). `POST /api/annotations/graphite` +#### Required permissions + +See note in the [introduction]({{< ref "#annotations-api" >}}) for an explanation. + +| Action | Scope | +| ------------------ | ----------------------------- | +| annotations:create | annotations:type:organization | + **Example Request**: ```http @@ -165,6 +192,14 @@ Content-Type: application/json Updates all properties of an annotation that matches the specified id. To only update certain property, consider using the [Patch Annotation](#patch-annotation) operation. +#### Required permissions + +See note in the [introduction]({{< ref "#annotations-api" >}}) for an explanation. + +| Action | Scope | +| ----------------- | ----------------------- | +| annotations:write | annotations:type: | + **Example Request**: ```http @@ -202,6 +237,14 @@ Updates one or more properties of an annotation that matches the specified id. This operation currently supports updating of the `text`, `tags`, `time` and `timeEnd` properties. +#### Required permissions + +See note in the [introduction]({{< ref "#annotations-api" >}}) for an explanation. + +| Action | Scope | +| ----------------- | ----------------------- | +| annotations:write | annotations:type: | + **Example Request**: ```http @@ -233,6 +276,14 @@ Content-Type: application/json Deletes the annotation that matches the specified id. +#### Required permissions + +See note in the [introduction]({{< ref "#annotations-api" >}}) for an explanation. + +| Action | Scope | +| ------------------ | ----------------------- | +| annotations:delete | annotations:type: | + **Example Request**: ```http @@ -259,6 +310,14 @@ Content-Type: application/json Find all the event tags created in the annotations. +#### Required permissions + +See note in the [introduction]({{< ref "#annotations-api" >}}) for an explanation. + +| Action | Scope | +| ---------------- | ----- | +| annotations:read | N/A | + **Example Request**: ```http diff --git a/docs/sources/panels/library-panels/about-library-panels.md b/docs/sources/panels/library-panels/about-library-panels.md index f9d4c693164..39cc1cc7bf0 100644 --- a/docs/sources/panels/library-panels/about-library-panels.md +++ b/docs/sources/panels/library-panels/about-library-panels.md @@ -6,6 +6,6 @@ aliases = ["/docs/sources/panels/library-panels/about-library-panels/"] # About Grafana panel libraries -A library panel is a reusable panel that you can use in any dashboard. When you make a change to a library panel, that change propegates to all instances of where the panel is used. Library panels streamline reuse of panels across multiple dashboards. +A library panel is a reusable panel that you can use in any dashboard. When you make a change to a library panel, that change propagates to all instances of where the panel is used. Library panels streamline reuse of panels across multiple dashboards. You can save a library panel in a folder alongside saved dashboards. diff --git a/docs/sources/release-notes/_index.md b/docs/sources/release-notes/_index.md index 582d87f6088..7eb143ae827 100644 --- a/docs/sources/release-notes/_index.md +++ b/docs/sources/release-notes/_index.md @@ -8,6 +8,7 @@ weight = 10000 Here you can find detailed release notes that list everything that is included in every release as well as notices about deprecations, breaking changes as well as changes that relate to plugin development. +- [Release notes for 8.4.5]({{< relref "release-notes-8-4-5" >}}) - [Release notes for 8.4.4]({{< relref "release-notes-8-4-4" >}}) - [Release notes for 8.4.3]({{< relref "release-notes-8-4-3" >}}) - [Release notes for 8.4.2]({{< relref "release-notes-8-4-2" >}}) diff --git a/docs/sources/release-notes/release-notes-8-4-5.md b/docs/sources/release-notes/release-notes-8-4-5.md new file mode 100644 index 00000000000..8b74042a833 --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-4-5.md @@ -0,0 +1,20 @@ ++++ +title = "Release notes for Grafana 8.4.5" +hide_menu = true ++++ + + + +# Release notes for Grafana 8.4.5 + +### Features and enhancements + +- **Instrumentation:** Make backend plugin metrics endpoints available with optional authentication. [#46467](https://github.com/grafana/grafana/pull/46467), [@marefr](https://github.com/marefr) +- **Table panel:** Show datalinks for cell display modes JSON View and Gauge derivates. [#46020](https://github.com/grafana/grafana/pull/46020), [@mdvictor](https://github.com/mdvictor) + +### Bug fixes + +- **Azure Monitor:** Small bug fixes for Resource Picker. [#46665](https://github.com/grafana/grafana/pull/46665), [@sarahzinger](https://github.com/sarahzinger) +- **Logger:** Use specified format for file logger. [#46970](https://github.com/grafana/grafana/pull/46970), [@sakjur](https://github.com/sakjur) +- **Logs:** Handle missing fields in dataframes better. [#46963](https://github.com/grafana/grafana/pull/46963), [@gabor](https://github.com/gabor) +- **ManageDashboards:** Fix error when deleting all dashboards from folder view. [#46877](https://github.com/grafana/grafana/pull/46877), [@joshhunt](https://github.com/joshhunt) diff --git a/docs/sources/visualizations/table/_index.md b/docs/sources/visualizations/table/_index.md index 57d8589d59a..6d74d495128 100644 --- a/docs/sources/visualizations/table/_index.md +++ b/docs/sources/visualizations/table/_index.md @@ -105,3 +105,7 @@ Enables value inspection from table cell. The raw value is presented in a modal ## Column filter You can temporarily change how column data is displayed. For example, you can order values from highest to lowest or hide specific values. For more information, refer to [Filter table columns]({{< relref "./filter-table-columns.md" >}}). + +## Pagination + +Use this option to enable or disable pagination. It is a front-end option that does not affect queries. When enabled, the page size automatically adjusts to the height of the table. diff --git a/e2e/panels-suite/panelEdit_base.spec.ts b/e2e/panels-suite/panelEdit_base.spec.ts index 121111ec9d8..1436fc457ac 100644 --- a/e2e/panels-suite/panelEdit_base.spec.ts +++ b/e2e/panels-suite/panelEdit_base.spec.ts @@ -102,7 +102,7 @@ e2e.scenario({ e2e.components.PanelEditor.DataPane.content().should('be.visible'); // Field & Overrides tabs (need to switch to React based vis, i.e. Table) - e2e.components.PanelEditor.OptionsPane.fieldLabel('Table Show header').should('be.visible'); + e2e.components.PanelEditor.OptionsPane.fieldLabel('Header and footer Show header').should('be.visible'); e2e.components.PanelEditor.OptionsPane.fieldLabel('Table Column width').should('be.visible'); }, }); diff --git a/go.mod b/go.mod index 8795ba6f163..8a948df1eeb 100644 --- a/go.mod +++ b/go.mod @@ -17,8 +17,6 @@ require ( cloud.google.com/go/storage v1.18.2 cuelang.org/go v0.4.0 github.com/Azure/azure-sdk-for-go v59.3.0+incompatible - github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0 - github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.10.0 github.com/Azure/go-autorest/autorest v0.11.22 github.com/BurntSushi/toml v0.3.1 github.com/Masterminds/semver v1.5.0 @@ -54,6 +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-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 @@ -183,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 + github.com/googleapis/gax-go/v2 v2.1.1 // indirect 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 @@ -247,19 +246,20 @@ 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 + google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 // indirect gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect ) require ( cloud.google.com/go/kms v1.1.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/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,6 +270,7 @@ 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/imdario/mergo v0.3.12 // indirect github.com/klauspost/compress v1.13.6 // indirect github.com/opencontainers/image-spec v1.0.2 // indirect diff --git a/go.sum b/go.sum index 792b3aa4a50..26accc85fd1 100644 --- a/go.sum +++ b/go.sum @@ -1335,6 +1335,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-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= @@ -2462,8 +2464,6 @@ github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBn github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= -github.com/vectordotdev/go-datemath v0.1.1-0.20220110192739-f9ce83ec349f h1:2upw/ZfjkCKpc4k6DXg7lMfCSLkfw/8epV5/y2ZUQ8U= -github.com/vectordotdev/go-datemath v0.1.1-0.20220110192739-f9ce83ec349f/go.mod h1:PnwzbSst7KD3vpBzzlntZU5gjVa455Uqa5QPiKSYJzQ= github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae h1:oyiy3uBj1F4O3AaFh7hUGBrJjAssJhKyAbwxtkslxqo= github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae/go.mod h1:PnwzbSst7KD3vpBzzlntZU5gjVa455Uqa5QPiKSYJzQ= github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw= diff --git a/latest.json b/latest.json index 0215f236bb2..68649300d2e 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "8.4.4", - "testing": "8.4.4" + "stable": "8.4.5", + "testing": "8.4.5" } diff --git a/package.json b/package.json index 88a1c885987..84abf2cf7b2 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "i18n:extract": "lingui extract", "i18n:compile": "lingui compile", "postinstall": "husky install", - "betterer": "betterer" + "betterer": "betterer", + "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/", @@ -86,6 +87,7 @@ "@babel/preset-env": "7.16.11", "@babel/preset-react": "7.16.7", "@babel/preset-typescript": "7.16.7", + "@betterer/betterer": "5.1.7", "@betterer/cli": "5.1.7", "@betterer/regexp": "5.1.7", "@emotion/eslint-plugin": "11.7.0", @@ -128,9 +130,10 @@ "@types/hoist-non-react-statics": "3.3.1", "@types/jest": "27.4.1", "@types/jquery": "3.5.14", + "@types/js-yaml": "^4.0.5", "@types/jsurl": "^1.2.28", "@types/lingui__macro": "^3", - "@types/lodash": "4.14.149", + "@types/lodash": "4.14.181", "@types/logfmt": "^1.2.1", "@types/mousetrap": "1.6.9", "@types/node": "16.11.26", @@ -246,6 +249,7 @@ "@grafana/e2e-selectors": "workspace:*", "@grafana/experimental": "0.0.2-canary.22", "@grafana/google-sdk": "0.0.3", + "@grafana/lezer-logql": "^0.0.11", "@grafana/runtime": "workspace:*", "@grafana/schema": "workspace:*", "@grafana/slate-react": "0.22.10-grafana", @@ -310,6 +314,7 @@ "immer": "9.0.12", "immutable": "4.0.0", "jquery": "3.6.0", + "js-yaml": "^4.1.0", "json-source-map": "0.6.1", "jsurl": "^0.1.5", "lezer-promql": "0.22.0", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 21e06c70a8f..bda9b762e7e 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -55,7 +55,7 @@ "@types/history": "4.7.11", "@types/jest": "27.4.1", "@types/jquery": "3.5.14", - "@types/lodash": "4.14.149", + "@types/lodash": "4.14.181", "@types/marked": "4.0.3", "@types/node": "16.11.26", "@types/papaparse": "5.3.2", diff --git a/packages/grafana-data/src/dataframe/DataFrameJSON.ts b/packages/grafana-data/src/dataframe/DataFrameJSON.ts index 98fbd050903..d99e87ad22d 100644 --- a/packages/grafana-data/src/dataframe/DataFrameJSON.ts +++ b/packages/grafana-data/src/dataframe/DataFrameJSON.ts @@ -202,7 +202,8 @@ export function dataFrameToJSON(frame: DataFrame): DataFrameJSON { meta: frame.meta, name: frame.name, fields: frame.fields.map((f) => { - const { values, ...sfield } = f; + const { values, state, display, ...sfield } = f; + delete (sfield as any).entities; data.values.push(values.toArray()); return sfield; }), diff --git a/packages/grafana-data/src/datetime/timezones.ts b/packages/grafana-data/src/datetime/timezones.ts index b1528a66aaa..efd198474da 100644 --- a/packages/grafana-data/src/datetime/timezones.ts +++ b/packages/grafana-data/src/datetime/timezones.ts @@ -84,7 +84,7 @@ export const getTimeZoneGroups = memoize( return groups; } - const group = zone.substr(0, delimiter); + const group = zone.slice(0, delimiter); groups[group] = groups[group] ?? []; groups[group].push(zone); diff --git a/packages/grafana-data/src/field/displayProcessor.test.ts b/packages/grafana-data/src/field/displayProcessor.test.ts index 7cb49137e7a..f6e45e844d7 100644 --- a/packages/grafana-data/src/field/displayProcessor.test.ts +++ b/packages/grafana-data/src/field/displayProcessor.test.ts @@ -414,6 +414,46 @@ describe('Date display options', () => { expect(processor('2020-08-01T08:48:43.783337Z').text).toEqual('2020-08-01 08:48:43'); }); + it('should handle ISO string dates when in other timezones than UTC', () => { + const processor = getDisplayProcessor({ + timeZone: 'CET', + field: { + type: FieldType.time, + config: {}, + }, + theme: createTheme(), + }); + + expect(processor('2020-08-01T08:48:43.783337Z').text).toEqual('2020-08-01 10:48:43'); //DST + expect(processor('2020-12-01T08:48:43.783337Z').text).toEqual('2020-12-01 09:48:43'); //STD + }); + + it('should handle ISO string dates with timezone offset', () => { + const processor = getDisplayProcessor({ + timeZone: 'utc', + field: { + type: FieldType.time, + config: {}, + }, + theme: createTheme(), + }); + + expect(processor('2020-12-01T08:48:43.783337+02:00').text).toEqual('2020-12-01 06:48:43'); + }); + + it('should handle ISO string dates without timezone qualifier by assuming UTC', () => { + const processor = getDisplayProcessor({ + timeZone: 'CET', + field: { + type: FieldType.time, + config: {}, + }, + theme: createTheme(), + }); + + expect(processor('2020-12-01T08:48:43.783337').text).toEqual('2020-12-01 09:48:43'); + }); + describe('number formatting for string values', () => { it('should preserve string unchanged if unit is strings', () => { const processor = getDisplayProcessor({ diff --git a/packages/grafana-data/src/field/displayProcessor.ts b/packages/grafana-data/src/field/displayProcessor.ts index a8bab9f7ac6..c3cc793df99 100644 --- a/packages/grafana-data/src/field/displayProcessor.ts +++ b/packages/grafana-data/src/field/displayProcessor.ts @@ -6,7 +6,7 @@ import { Field, FieldType } from '../types/dataFrame'; import { DisplayProcessor, DisplayValue } from '../types/displayValue'; import { getValueFormat, isBooleanUnit } from '../valueFormats/valueFormats'; import { getValueMappingResult } from '../utils/valueMappings'; -import { dateTime, dateTimeParse } from '../datetime'; +import { toUtc, dateTimeParse } from '../datetime'; import { KeyValue, TimeZone } from '../types'; import { getScaleCalculator } from './scale'; import { GrafanaTheme2 } from '../themes/types'; @@ -77,7 +77,7 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP const isStringUnit = unit === 'string'; if (hasDateUnit && typeof value === 'string') { - value = dateTime(value).valueOf(); + value = toUtc(value).valueOf(); } let numeric = isStringUnit ? NaN : anyToNumber(value); diff --git a/packages/grafana-data/src/text/string.test.ts b/packages/grafana-data/src/text/string.test.ts index b9cd3eb0866..c0ab2b3a854 100644 --- a/packages/grafana-data/src/text/string.test.ts +++ b/packages/grafana-data/src/text/string.test.ts @@ -57,12 +57,32 @@ describe('stringToMs', () => { }); }); +describe('[un]escapeStringForRegex', () => { + it.each([ + '[]', + '\\', + '[(abc])', + 'onetwothree', + '', + 'string\\with\\backslash', + 'everyspecialchar([{])}.,/?&*-^&<>#', + ])('should be symmetric', (input) => { + const output = unEscapeStringFromRegex(escapeStringForRegex(input)); + expect(output).toEqual(input); + }); +}); + describe('escapeStringForRegex', () => { - describe('when using a string with special chars', () => { - it('then all special chars should be escaped', () => { - const result = escapeStringForRegex('([{}])|*+-.?<>#&^$'); - expect(result).toBe('\\(\\[\\{\\}\\]\\)\\|\\*\\+\\-\\.\\?\\<\\>\\#\\&\\^\\$'); - }); + it.each([ + '[[[', + '[]\\', + '[(abc])', + 'onetwothree', + '', + 'string\\with\\backslash', + 'everyspecialchar([{])}.,/?&*-^&<>#', + ])('should always produce output that compiles', (value) => { + expect(() => new RegExp(escapeStringForRegex(value))).not.toThrowError(); }); describe('when using a string without special chars', () => { diff --git a/packages/grafana-data/src/text/string.ts b/packages/grafana-data/src/text/string.ts index 79cf3ae05cd..4031ea6fdb1 100644 --- a/packages/grafana-data/src/text/string.ts +++ b/packages/grafana-data/src/text/string.ts @@ -1,12 +1,16 @@ import { camelCase } from 'lodash'; -const specialChars = ['(', '[', '{', '}', ']', ')', '|', '*', '+', '-', '.', '?', '<', '>', '#', '&', '^', '$']; + +const specialChars = ['(', '[', '{', '}', ']', ')', '\\', '|', '*', '+', '-', '.', '?', '<', '>', '#', '&', '^', '$']; +const specialMatcher = '([\\' + specialChars.join('\\') + '])'; +const specialCharEscape = new RegExp(specialMatcher, 'g'); +const specialCharUnescape = new RegExp('(\\\\)' + specialMatcher, 'g'); export const escapeStringForRegex = (value: string) => { if (!value) { return value; } - return specialChars.reduce((escaped, currentChar) => escaped.replace(currentChar, '\\' + currentChar), value); + return value.replace(specialCharEscape, '\\$1'); }; export const unEscapeStringFromRegex = (value: string) => { @@ -14,7 +18,7 @@ export const unEscapeStringFromRegex = (value: string) => { return value; } - return specialChars.reduce((escaped, currentChar) => escaped.replace('\\' + currentChar, currentChar), value); + return value.replace(specialCharUnescape, '$2'); }; export function stringStartsAsRegEx(str: string): boolean { @@ -45,7 +49,7 @@ export function stringToMs(str: string): number { } const nr = parseInt(str, 10); - const unit = str.substr(String(nr).length); + const unit = str.slice(String(nr).length); const s = 1000; const m = s * 60; const h = m * 60; diff --git a/packages/grafana-data/src/themes/colorManipulator.ts b/packages/grafana-data/src/themes/colorManipulator.ts index 1626d573e17..b74c745e075 100644 --- a/packages/grafana-data/src/themes/colorManipulator.ts +++ b/packages/grafana-data/src/themes/colorManipulator.ts @@ -29,7 +29,7 @@ function clamp(value: number, min = 0, max = 1) { * @beta */ export function hexToRgb(color: string) { - color = color.substr(1); + color = color.slice(1); const re = new RegExp(`.{1,${color.length >= 6 ? 2 : 1}}`, 'g'); let colors = color.match(re); @@ -141,7 +141,7 @@ export function decomposeColor(color: string | DecomposeColor): DecomposeColor { values = values.split(' '); colorSpace = values.shift(); if (values.length === 4 && values[3].charAt(0) === '/') { - values[3] = values[3].substr(1); + values[3] = values[3].slice(1); } if (['srgb', 'display-p3', 'a98-rgb', 'prophoto-rgb', 'rec-2020'].indexOf(colorSpace) === -1) { throw new Error( diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 04137b73699..dcd04ac309e 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -34,6 +34,7 @@ export interface FeatureToggles { tempoServiceGraph?: boolean; lokiBackendMode?: boolean; accesscontrol?: boolean; + ['accesscontrol-builtins']?: boolean; prometheus_azure_auth?: boolean; influxdbBackendMigration?: boolean; newNavigation?: boolean; @@ -46,7 +47,9 @@ export interface FeatureToggles { dashboardComments?: boolean; annotationComments?: boolean; migrationLocking?: boolean; + saveDashboardDrawer?: boolean; storage?: boolean; + alertProvisioning?: boolean; storageLocalUpload?: boolean; azureMonitorResourcePickerForMetrics?: boolean; } diff --git a/packages/grafana-data/src/utils/csv.ts b/packages/grafana-data/src/utils/csv.ts index a9a7e7dfd45..11dc0be344a 100644 --- a/packages/grafana-data/src/utils/csv.ts +++ b/packages/grafana-data/src/utils/csv.ts @@ -87,7 +87,7 @@ export class CSVReader { // #{columkey}#a,b,c const idx = first.indexOf('#', 2); if (idx > 0) { - const k = first.substr(1, idx - 1); + const k = first.slice(1, idx); const isName = 'name' === k; // Simple object used to check if headers match @@ -103,7 +103,7 @@ export class CSVReader { this.data.push(this.current); } - const v = first.substr(idx + 1); + const v = first.slice(idx + 1); if (isName) { this.current.addFieldFor(undefined, v); for (let j = 1; j < line.length; j++) { diff --git a/packages/grafana-data/src/valueFormats/valueFormats.ts b/packages/grafana-data/src/valueFormats/valueFormats.ts index a23253b0c2c..76a38232cdf 100644 --- a/packages/grafana-data/src/valueFormats/valueFormats.ts +++ b/packages/grafana-data/src/valueFormats/valueFormats.ts @@ -66,7 +66,7 @@ export function toFixed(value: number, decimals?: DecimalCount): string { const decimalPos = formatted.indexOf('.'); const precision = decimalPos === -1 ? 0 : formatted.length - decimalPos - 1; if (precision < decimals) { - return (precision ? formatted : formatted + '.') + String(factor).substr(1, decimals - precision); + return (precision ? formatted : formatted + '.') + String(factor).slice(1, decimals - precision + 1); } return formatted; diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index f40b3d16f02..10aeec505b3 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -34,7 +34,7 @@ "@rollup/plugin-commonjs": "21.0.2", "@rollup/plugin-node-resolve": "13.1.3", "@types/chrome-remote-interface": "0.31.4", - "@types/lodash": "4.14.180", + "@types/lodash": "4.14.181", "@types/node": "16.11.26", "@types/uuid": "8.3.4", "rollup": "2.70.1", diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 903624e581f..26039a58f38 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -44,7 +44,7 @@ "@types/angular": "1.8.4", "@types/history": "4.7.11", "@types/jest": "27.4.1", - "@types/lodash": "4.14.149", + "@types/lodash": "4.14.181", "@types/react": "17.0.42", "@types/react-dom": "17.0.14", "@types/systemjs": "^0.20.6", diff --git a/packages/grafana-runtime/src/components/DataSourcePicker.tsx b/packages/grafana-runtime/src/components/DataSourcePicker.tsx index 3e1bff0ad29..adf02a92f9c 100644 --- a/packages/grafana-runtime/src/components/DataSourcePicker.tsx +++ b/packages/grafana-runtime/src/components/DataSourcePicker.tsx @@ -105,7 +105,7 @@ export class DataSourcePicker extends PureComponent { if (extension.startsWith('.')) { - extension = extension.substr(1); + extension = extension.slice(1); } switch (extension) { case 'zip': diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts index 9764abc0070..60ccf8f3c81 100644 --- a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts @@ -197,7 +197,7 @@ const getBaseWebpackConfig: WebpackConfigurationGetter = async (options) => { (context, request, callback) => { const prefix = 'grafana/'; if (request.indexOf(prefix) === 0) { - return callback(null, request.substr(prefix.length)); + return callback(null, request.slice(prefix.length)); } // @ts-ignore diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 2d166e82fb4..b10a5ba1d76 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -130,7 +130,7 @@ "@types/is-hotkey": "0.1.7", "@types/jest": "27.4.1", "@types/jquery": "3.5.14", - "@types/lodash": "4.14.149", + "@types/lodash": "4.14.181", "@types/mock-raf": "1.0.3", "@types/node": "16.11.26", "@types/prismjs": "1.26.0", diff --git a/packages/grafana-ui/src/components/Forms/InlineLabel.tsx b/packages/grafana-ui/src/components/Forms/InlineLabel.tsx index c9c6cc8a98c..7f98ba25684 100644 --- a/packages/grafana-ui/src/components/Forms/InlineLabel.tsx +++ b/packages/grafana-ui/src/components/Forms/InlineLabel.tsx @@ -1,9 +1,10 @@ -import React, { FunctionComponent } from 'react'; -import { GrafanaTheme } from '@grafana/data'; import { css, cx } from '@emotion/css'; -import { Tooltip, PopoverContent } from '../Tooltip'; -import { Icon } from '../Icon/Icon'; +import { GrafanaTheme } from '@grafana/data'; +import React, { FunctionComponent } from 'react'; + import { useTheme } from '../../themes'; +import { Icon } from '../Icon/Icon'; +import { PopoverContent, Tooltip } from '../Tooltip'; import { LabelProps } from './Label'; export interface Props extends Omit { @@ -41,7 +42,7 @@ export const InlineLabel: FunctionComponent = ({ {children} {tooltip && ( - + )} diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index 1581c4abd34..9758bfcb61a 100755 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -216,7 +216,7 @@ export class GraphNG extends React.Component { const propsChanged = !sameProps(prevProps, this.props, propsToDiff); - if (frames !== prevProps.frames || propsChanged) { + if (frames !== prevProps.frames || propsChanged || timeZone !== prevProps.timeZone) { let newState = this.prepState(this.props, false); if (newState) { diff --git a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts b/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts index 144f9ddb7d8..f56b3f4d777 100644 --- a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts +++ b/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts @@ -112,6 +112,22 @@ describe('nullInsertThreshold Transformer', () => { expect(result.fields[0].values.toArray()).toStrictEqual([1, 2, 3, 4, 10, 11]); expect(result.fields[1].values.toArray()).toStrictEqual([4, null, 6, null, 8, null]); expect(result.fields[2].values.toArray()).toStrictEqual(['a', null, 'b', null, 'c', null]); + + // should work for frames with 1 datapoint + const df2 = new MutableDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1] }, + { name: 'One', type: FieldType.number, values: [1] }, + { name: 'Two', type: FieldType.string, values: ['a'] }, + ], + }); + + const result2 = applyNullInsertThreshold(df2, null, 13); + + expect(result2.fields[0].values.toArray()).toStrictEqual([1, 2]); + expect(result2.fields[1].values.toArray()).toStrictEqual([1, null]); + expect(result2.fields[2].values.toArray()).toStrictEqual(['a', null]); }); // TODO: make this work @@ -132,12 +148,12 @@ describe('nullInsertThreshold Transformer', () => { expect(result.fields[2].values.toArray()).toStrictEqual(['a', null, 'b', null, 'c']); }); - test('should noop on fewer than two values', () => { + test('should noop on 0 datapoints', () => { const df = new MutableDataFrame({ refId: 'A', fields: [ - { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1] }, - { name: 'Value', type: FieldType.number, values: [1] }, + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [] }, + { name: 'Value', type: FieldType.number, values: [] }, ], }); diff --git a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts b/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts index 35b0fcd325f..c46b6cf6af0 100644 --- a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts +++ b/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts @@ -15,7 +15,7 @@ export function applyNullInsertThreshold( refFieldPseudoMax: number | null = null, insertMode: InsertMode = INSERT_MODES.threshold ): DataFrame { - if (frame.length < 2) { + if (frame.length === 0) { return frame; } diff --git a/packages/grafana-ui/src/components/Pagination/Pagination.test.tsx b/packages/grafana-ui/src/components/Pagination/Pagination.test.tsx new file mode 100644 index 00000000000..305e25050a3 --- /dev/null +++ b/packages/grafana-ui/src/components/Pagination/Pagination.test.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Pagination } from './Pagination'; + +describe('Pagination component', () => { + it('should render only 10 buttons when number of pages is higher than 8', () => { + render( {}} />); + expect(screen.getAllByRole('button')).toHaveLength(10); + }); + it('should only show 3 buttons when showSmallVersion is true', () => { + render( {}} showSmallVersion />); + expect(screen.getAllByRole('button')).toHaveLength(4); + }); +}); diff --git a/packages/grafana-ui/src/components/Pagination/Pagination.tsx b/packages/grafana-ui/src/components/Pagination/Pagination.tsx index 34b30e07f08..a15b7a1fe24 100644 --- a/packages/grafana-ui/src/components/Pagination/Pagination.tsx +++ b/packages/grafana-ui/src/components/Pagination/Pagination.tsx @@ -1,77 +1,94 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { css } from '@emotion/css'; -import { stylesFactory } from '../../themes'; +import { useStyles2 } from '../../themes'; import { Button, ButtonVariant } from '../Button'; import { Icon } from '../Icon/Icon'; -const PAGE_LENGTH_TO_CONDENSE = 8; - export interface Props { - /** The current page index being shown. */ + /** The current page index being shown. */ currentPage: number; - /** Number of total pages. */ + /** Number of total pages. */ numberOfPages: number; - /** Callback function for fetching the selected page */ + /** Callback function for fetching the selected page. */ onNavigate: (toPage: number) => void; - /** When set to true and the pagination result is only one page it will not render the pagination at all */ + /** When set to true and the pagination result is only one page it will not render the pagination at all. */ hideWhenSinglePage?: boolean; + /** Small version only shows the current page and the navigation buttons. */ + showSmallVersion?: boolean; } -export const Pagination: React.FC = ({ currentPage, numberOfPages, onNavigate, hideWhenSinglePage }) => { - const styles = getStyles(); - const pages = [...new Array(numberOfPages).keys()]; +export const Pagination: React.FC = ({ + currentPage, + numberOfPages, + onNavigate, + hideWhenSinglePage, + showSmallVersion, +}) => { + const styles = useStyles2(getStyles); + const pageLengthToCondense = showSmallVersion ? 1 : 8; - const condensePages = numberOfPages > PAGE_LENGTH_TO_CONDENSE; - const getListItem = (page: number, variant: 'primary' | 'secondary') => ( -
  • - -
  • - ); + const pageButtons = useMemo(() => { + const pages = [...new Array(numberOfPages).keys()]; - const pageButtons = pages.reduce((pagesToRender, pageIndex) => { - const page = pageIndex + 1; - const variant: ButtonVariant = page === currentPage ? 'primary' : 'secondary'; + const condensePages = numberOfPages > pageLengthToCondense; + const getListItem = (page: number, variant: 'primary' | 'secondary') => ( +
  • + +
  • + ); - // The indexes at which to start and stop condensing pages - const lowerBoundIndex = PAGE_LENGTH_TO_CONDENSE; - const upperBoundIndex = numberOfPages - PAGE_LENGTH_TO_CONDENSE + 1; - // When the indexes overlap one another this number is negative - const differenceOfBounds = upperBoundIndex - lowerBoundIndex; + return pages.reduce((pagesToRender, pageIndex) => { + const page = pageIndex + 1; + const variant: ButtonVariant = page === currentPage ? 'primary' : 'secondary'; - const isFirstOrLastPage = page === 1 || page === numberOfPages; - // This handles when the lowerBoundIndex < currentPage < upperBoundIndex - const currentPageIsBetweenBounds = - differenceOfBounds > -1 && currentPage >= lowerBoundIndex && currentPage <= upperBoundIndex; + // The indexes at which to start and stop condensing pages + const lowerBoundIndex = pageLengthToCondense; + const upperBoundIndex = numberOfPages - pageLengthToCondense + 1; + // When the indexes overlap one another this number is negative + const differenceOfBounds = upperBoundIndex - lowerBoundIndex; - if (condensePages) { - if ( - isFirstOrLastPage || - (currentPage < lowerBoundIndex && page < lowerBoundIndex) || - (differenceOfBounds >= 0 && currentPage > upperBoundIndex && page > upperBoundIndex) || - (differenceOfBounds < 0 && currentPage >= lowerBoundIndex && page > upperBoundIndex) || - (currentPageIsBetweenBounds && page >= currentPage - 2 && page <= currentPage + 2) - ) { - // Renders a button for the page + const isFirstOrLastPage = page === 1 || page === numberOfPages; + // This handles when the lowerBoundIndex < currentPage < upperBoundIndex + const currentPageIsBetweenBounds = + differenceOfBounds > -1 && currentPage >= lowerBoundIndex && currentPage <= upperBoundIndex; + + // Show ellipsis after that many pages + const ellipsisOffset = showSmallVersion ? 1 : 3; + + // The offset to show more pages when currentPageIsBetweenBounds + const pageOffset = showSmallVersion ? 0 : 2; + + if (condensePages) { + if ( + isFirstOrLastPage || + (currentPage < lowerBoundIndex && page < lowerBoundIndex) || + (differenceOfBounds >= 0 && currentPage > upperBoundIndex && page > upperBoundIndex) || + (differenceOfBounds < 0 && currentPage >= lowerBoundIndex && page > upperBoundIndex) || + (currentPageIsBetweenBounds && page >= currentPage - pageOffset && page <= currentPage + pageOffset) + ) { + // Renders a button for the page + pagesToRender.push(getListItem(page, variant)); + } else if ( + (page === lowerBoundIndex && currentPage < lowerBoundIndex) || + (page === upperBoundIndex && currentPage > upperBoundIndex) || + (currentPageIsBetweenBounds && + (page === currentPage - ellipsisOffset || page === currentPage + ellipsisOffset)) + ) { + // Renders and ellipsis to represent condensed pages + pagesToRender.push( +
  • + +
  • + ); + } + } else { pagesToRender.push(getListItem(page, variant)); - } else if ( - (page === lowerBoundIndex && currentPage < lowerBoundIndex) || - (page === upperBoundIndex && currentPage > upperBoundIndex) || - (currentPageIsBetweenBounds && (page === currentPage - 3 || page === currentPage + 3)) - ) { - // Renders and ellipsis to represent condensed pages - pagesToRender.push( -
  • - -
  • - ); } - } else { - pagesToRender.push(getListItem(page, variant)); - } - return pagesToRender; - }, []); + return pagesToRender; + }, []); + }, [currentPage, numberOfPages, onNavigate, pageLengthToCondense, showSmallVersion, styles.ellipsis, styles.item]); if (hideWhenSinglePage && numberOfPages <= 1) { return null; @@ -108,7 +125,7 @@ export const Pagination: React.FC = ({ currentPage, numberOfPages, onNavi ); }; -const getStyles = stylesFactory(() => { +const getStyles = () => { return { container: css` float: right; @@ -122,4 +139,4 @@ const getStyles = stylesFactory(() => { transform: rotate(90deg); `, }; -}); +}; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts index a83f5fb0cdc..fbbf77f9dba 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts +++ b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts @@ -30,6 +30,8 @@ export interface PanelContext { onToggleSeriesVisibility?: (label: string, mode: SeriesVisibilityChangeMode) => void; canAddAnnotations?: () => boolean; + canEditAnnotations?: (dashboardId: number) => boolean; + canDeleteAnnotations?: (dashboardId: number) => boolean; onAnnotationCreate?: (annotation: AnnotationEventUIModel) => void; onAnnotationUpdate?: (annotation: AnnotationEventUIModel) => void; onAnnotationDelete?: (id: string) => void; diff --git a/packages/grafana-ui/src/components/Table/FooterRow.tsx b/packages/grafana-ui/src/components/Table/FooterRow.tsx index 5bdc13b8b61..c032c3d3597 100644 --- a/packages/grafana-ui/src/components/Table/FooterRow.tsx +++ b/packages/grafana-ui/src/components/Table/FooterRow.tsx @@ -10,18 +10,19 @@ export interface FooterRowProps { totalColumnsWidth: number; footerGroups: HeaderGroup[]; footerValues: FooterItem[]; + isPaginationVisible: boolean; height: number; } export const FooterRow = (props: FooterRowProps) => { - const { totalColumnsWidth, footerGroups, height } = props; + const { totalColumnsWidth, footerGroups, height, isPaginationVisible } = props; const e2eSelectorsTable = selectors.components.Panels.Visualization.Table; const tableStyles = useStyles2(getTableStyles); return (
    - {!link &&
    {value}
    } + {!link &&
    {displayValue}
    } {link && ( {
    ); }; + +export const Pagination: Story = (args) => ; +Pagination.args = { + pageSize: 10, +}; diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index a558a80514b..1366744fd91 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -1,15 +1,14 @@ -import React, { FC, memo, useCallback, useMemo } from 'react'; +import React, { FC, memo, useCallback, useEffect, useMemo } from 'react'; import { DataFrame, getFieldDisplayName } from '@grafana/data'; import { Cell, Column, + TableState, useAbsoluteLayout, useFilters, - UseFiltersState, + usePagination, useResizeColumns, - UseResizeColumnsState, useSortBy, - UseSortByState, useTable, } from 'react-table'; import { FixedSizeList } from 'react-window'; @@ -27,6 +26,7 @@ import { TableCell } from './TableCell'; import { useStyles2 } from '../../themes'; import { FooterRow } from './FooterRow'; import { HeaderRow } from './HeaderRow'; +import { Pagination } from '../Pagination/Pagination'; const COLUMN_MIN_WIDTH = 150; @@ -45,13 +45,12 @@ export interface Props { onSortByChange?: TableSortByActionCallback; onCellFilterAdded?: TableFilterActionCallback; footerValues?: FooterItem[]; + enablePagination?: boolean; } -interface ReactTableInternalState extends UseResizeColumnsState<{}>, UseSortByState<{}>, UseFiltersState<{}> {} - function useTableStateReducer({ onColumnResize, onSortByChange, data }: Props) { return useCallback( - (newState: ReactTableInternalState, action: any) => { + (newState: TableState, action: any) => { switch (action.type) { case 'columnDoneResizing': if (onColumnResize) { @@ -95,8 +94,8 @@ function useTableStateReducer({ onColumnResize, onSortByChange, data }: Props) { ); } -function getInitialState(initialSortBy: Props['initialSortBy'], columns: Column[]): Partial { - const state: Partial = {}; +function getInitialState(initialSortBy: Props['initialSortBy'], columns: Column[]): Partial { + const state: Partial = {}; if (initialSortBy) { state.sortBy = []; @@ -126,6 +125,7 @@ export const Table: FC = memo((props: Props) => { initialSortBy, footerValues, showTypeIcons, + enablePagination, } = props; const tableStyles = useStyles2(getTableStyles); @@ -188,17 +188,40 @@ export const Table: FC = memo((props: Props) => { [initialSortBy, memoizedColumns, memoizedData, resizable, stateReducer] ); - const { getTableProps, headerGroups, rows, prepareRow, totalColumnsWidth, footerGroups } = useTable( - options, - useFilters, - useSortBy, - useAbsoluteLayout, - useResizeColumns - ); + const { + getTableProps, + headerGroups, + rows, + prepareRow, + totalColumnsWidth, + footerGroups, + page, + state, + gotoPage, + setPageSize, + pageOptions, + } = useTable(options, useFilters, useSortBy, usePagination, useAbsoluteLayout, useResizeColumns); + + let listHeight = height - (headerHeight + footerHeight); + if (enablePagination) { + listHeight -= tableStyles.cellHeight; + } + const pageSize = Math.round(listHeight / tableStyles.cellHeight) - 1; + + useEffect(() => { + // Don't update the page size if it is less than 1 + if (pageSize <= 0) { + return; + } + setPageSize(pageSize); + }, [pageSize, setPageSize]); const RenderRow = React.useCallback( ({ index: rowIndex, style }) => { - const row = rows[rowIndex]; + let row = rows[rowIndex]; + if (enablePagination) { + row = page[rowIndex]; + } prepareRow(row); return (
    @@ -215,20 +238,52 @@ export const Table: FC = memo((props: Props) => {
    ); }, - [onCellFilterAdded, prepareRow, rows, tableStyles] + [onCellFilterAdded, page, enablePagination, prepareRow, rows, tableStyles] ); - const listHeight = height - (headerHeight + footerHeight); + const onNavigate = useCallback( + (toPage: number) => { + gotoPage(toPage - 1); + }, + [gotoPage] + ); + const itemCount = enablePagination ? page.length : data.length; + let paginationEl = null; + if (enablePagination) { + const itemsRangeStart = state.pageIndex * state.pageSize + 1; + let itemsRangeEnd = itemsRangeStart + state.pageSize - 1; + const isSmall = width < 500; + if (itemsRangeEnd > data.length) { + itemsRangeEnd = data.length; + } + paginationEl = ( +
    +
    + +
    + {isSmall ? null : ( +
    + {itemsRangeStart} - {itemsRangeEnd} of {data.length} rows +
    + )} +
    + ); + } return (
    -
    +
    {!noHeader && } - {rows.length > 0 ? ( + {itemCount > 0 ? ( = memo((props: Props) => { {footerValues && ( )} + {paginationEl}
    diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index d11a186dd58..c70f0c9e199 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -170,6 +170,32 @@ export const getTableStyles = (theme: GrafanaTheme2) => { label: headerFilter; cursor: pointer; `, + paginationWrapper: css` + display: flex; + background: ${headerBg}; + height: ${cellHeight}px; + justify-content: center; + align-items: center; + width: 100%; + border-top: 1px solid ${theme.colors.border.weak}; + li { + margin-bottom: 0; + } + div:not(:only-child):first-child { + flex-grow: 0.6; + } + `, + paginationSummary: css` + color: ${theme.colors.text.secondary}; + font-size: ${theme.typography.bodySmall.fontSize}; + margin-left: auto; + `, + + tableContentWrapper: (totalColumnsWidth: number) => css` + width: ${totalColumnsWidth ?? '100%'}; + display: flex; + flex-direction: column; + `, row: css` label: row; border-bottom: 1px solid ${borderColor}; diff --git a/packages/grafana-ui/src/components/Typeahead/TypeaheadInfo.tsx b/packages/grafana-ui/src/components/Typeahead/TypeaheadInfo.tsx index 30febf4cdd1..df7b0ba3d55 100644 --- a/packages/grafana-ui/src/components/Typeahead/TypeaheadInfo.tsx +++ b/packages/grafana-ui/src/components/Typeahead/TypeaheadInfo.tsx @@ -21,7 +21,7 @@ const getStyles = (theme: GrafanaTheme, height: number, visible: boolean) => { box-shadow: 0 0 20px ${theme.colors.dropdownShadow}; visibility: ${visible === true ? 'visible' : 'hidden'}; width: 250px; - height: ${height + parseInt(theme.spacing.xxs, 10)}px; + min-height: ${height + parseInt(theme.spacing.xxs, 10)}px; position: relative; word-break: break-word; `, diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx index 45557b917d4..15032a07a90 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx @@ -214,7 +214,7 @@ export const TooltipPlugin: React.FC = ({ let series: SeriesTableRowProps[] = []; const frame = otherProps.data; const fields = frame.fields; - const sortIdx: Array<[number, number]> = []; + const sortIdx: any[] = []; for (let i = 0; i < fields.length; i++) { const field = frame.fields[i]; @@ -232,7 +232,7 @@ export const TooltipPlugin: React.FC = ({ const v = otherProps.data.fields[i].values.get(focusedPointIdxs[i]!); const display = field.display!(v); - sortIdx.push([series.length, v]); + sortIdx.push(v); series.push({ color: display.color || FALLBACK_COLOR, label: getFieldDisplayName(field, frame), @@ -242,7 +242,16 @@ export const TooltipPlugin: React.FC = ({ } if (sortOrder !== SortOrder.None) { - series.sort((a, b) => arrayUtils.sortValues(sortOrder)(a.value, b.value)); + // create sort reference series array, as Array.sort() mutates the original array + const sortRef = [...series]; + const sortFn = arrayUtils.sortValues(sortOrder); + + series.sort((a, b) => { + // get compared values indices to retrieve raw values from sortIdx + const aIdx = sortRef.indexOf(a); + const bIdx = sortRef.indexOf(b); + return sortFn(sortIdx[aIdx], sortIdx[bIdx]); + }); } tooltip = ; diff --git a/packages/grafana-ui/src/types/react-table-config.d.ts b/packages/grafana-ui/src/types/react-table-config.d.ts new file mode 100644 index 00000000000..b0e872a7c31 --- /dev/null +++ b/packages/grafana-ui/src/types/react-table-config.d.ts @@ -0,0 +1,111 @@ +import type { + UseColumnOrderInstanceProps, + UseColumnOrderState, + UseExpandedHooks, + UseExpandedInstanceProps, + UseExpandedOptions, + UseExpandedRowProps, + UseExpandedState, + UseFiltersColumnOptions, + UseFiltersColumnProps, + UseFiltersInstanceProps, + UseFiltersOptions, + UseFiltersState, + UseGlobalFiltersColumnOptions, + UseGlobalFiltersInstanceProps, + UseGlobalFiltersOptions, + UseGlobalFiltersState, + UseGroupByCellProps, + UseGroupByColumnOptions, + UseGroupByColumnProps, + UseGroupByHooks, + UseGroupByInstanceProps, + UseGroupByOptions, + UseGroupByRowProps, + UseGroupByState, + UsePaginationInstanceProps, + UsePaginationOptions, + UsePaginationState, + UseResizeColumnsColumnOptions, + UseResizeColumnsColumnProps, + UseResizeColumnsOptions, + UseResizeColumnsState, + UseRowSelectHooks, + UseRowSelectInstanceProps, + UseRowSelectOptions, + UseRowSelectRowProps, + UseRowSelectState, + UseRowStateCellProps, + UseRowStateInstanceProps, + UseRowStateOptions, + UseRowStateRowProps, + UseRowStateState, + UseSortByColumnOptions, + UseSortByColumnProps, + UseSortByHooks, + UseSortByInstanceProps, + UseSortByOptions, + UseSortByState, +} from 'react-table'; + +declare module 'react-table' { + export interface TableOptions> + extends UseExpandedOptions, + UseFiltersOptions, + UseGlobalFiltersOptions, + UseGroupByOptions, + UsePaginationOptions, + UseResizeColumnsOptions, + UseRowSelectOptions, + UseRowStateOptions, + UseSortByOptions, + // note that having Record here allows you to add anything to the options, this matches the spirit of the + // underlying js library, but might be cleaner if it's replaced by a more specific type that matches your + // feature set, this is a safe default. + Record {} + + export interface Hooks = Record> + extends UseExpandedHooks, + UseGroupByHooks, + UseRowSelectHooks, + UseSortByHooks {} + + export interface TableInstance = Record> + extends UseColumnOrderInstanceProps, + UseExpandedInstanceProps, + UseFiltersInstanceProps, + UseGlobalFiltersInstanceProps, + UseGroupByInstanceProps, + UsePaginationInstanceProps, + UseRowSelectInstanceProps, + UseRowStateInstanceProps, + UseSortByInstanceProps {} + + export interface TableState = Record> + extends UseColumnOrderState, + UseExpandedState, + UseFiltersState, + UseGlobalFiltersState, + UseGroupByState, + UsePaginationState, + UseResizeColumnsState, + UseRowSelectState, + UseRowStateState, + UseSortByState {} + + export interface ColumnInterface = Record> + extends UseGlobalFiltersColumnOptions, + UseGroupByColumnOptions, + UseResizeColumnsColumnOptions, + UseSortByColumnOptions {} + + export interface ColumnInstance = Record> + extends UseFiltersColumnProps, + UseGroupByColumnProps, + UseResizeColumnsColumnProps, + UseSortByColumnProps {} + + export interface Cell = Record, V = any> + extends UseGroupByCellProps, + UseRowStateCellProps {} +} diff --git a/packages/grafana-ui/src/utils/dom.ts b/packages/grafana-ui/src/utils/dom.ts index 72730da7d2c..c81d80af9d8 100644 --- a/packages/grafana-ui/src/utils/dom.ts +++ b/packages/grafana-ui/src/utils/dom.ts @@ -37,5 +37,5 @@ export function getNextCharacter(global?: any) { const range = selection.getRangeAt(0); const text = selection.anchorNode.textContent; const offset = range.startOffset; - return text!.substr(offset, 1); + return text!.slice(offset, offset + 1); } diff --git a/packages/jaeger-ui-components/package.json b/packages/jaeger-ui-components/package.json index 009b40ba01f..992888ce8ad 100644 --- a/packages/jaeger-ui-components/package.json +++ b/packages/jaeger-ui-components/package.json @@ -14,7 +14,7 @@ "@types/deep-freeze": "^0.1.1", "@types/grafana__slate-react": "npm:@types/slate-react@0.22.5", "@types/hoist-non-react-statics": "^3.3.1", - "@types/lodash": "4.14.149", + "@types/lodash": "4.14.181", "@types/prop-types": "^15", "@types/react": "17.0.42", "@types/react-icons": "2.2.7", diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx index 938b6c5f5b6..2deb6921592 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx @@ -21,7 +21,6 @@ import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import SpanGraph from './SpanGraph'; -import TracePageSearchBar from './TracePageSearchBar'; import { autoColor, TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from '..'; import LabeledList from '../common/LabeledList'; import TraceName from '../common/TraceName'; @@ -138,22 +137,15 @@ const getStyles = (theme: GrafanaTheme2) => { type TracePageHeaderEmbedProps = { canCollapse: boolean; - clearSearch: () => void; - focusUiFindMatches: () => void; hideMap: boolean; hideSummary: boolean; - nextResult: () => void; onSlimViewClicked: () => void; onTraceGraphViewClicked: () => void; - prevResult: () => void; - resultCount: number; slimView: boolean; trace: Trace; updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void; updateViewRangeTime: TUpdateViewRangeTimeFunction; viewRange: ViewRange; - searchValue: string; - onSearchValueChange: (value: string) => void; timeZone: TimeZone; }; @@ -200,21 +192,14 @@ export const HEADER_ITEMS = [ export default function TracePageHeader(props: TracePageHeaderEmbedProps) { const { canCollapse, - clearSearch, - focusUiFindMatches, hideMap, hideSummary, - nextResult, onSlimViewClicked, - prevResult, - resultCount, slimView, trace, updateNextViewRangeTime, updateViewRangeTime, viewRange, - searchValue, - onSearchValueChange, timeZone, } = props; @@ -267,17 +252,6 @@ export default function TracePageHeader(props: TracePageHeaderEmbedProps) { ) : ( title )} -
    {summaryItems && } {!hideMap && !slimView && ( diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.js b/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.js index 51b5eab3b57..47b4e916cde 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.js +++ b/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.js @@ -14,6 +14,7 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { createTheme } from '@grafana/data'; import * as markers from './TracePageSearchBar.markers'; import TracePageSearchBar, { getStyles } from './TracePageSearchBar'; @@ -24,7 +25,7 @@ const defaultProps = { navigable: true, nextResult: () => {}, prevResult: () => {}, - resultCount: 0, + suffix: '', searchValue: 'something', }; @@ -45,26 +46,28 @@ describe('', () => { name: 'search', }) ); - expect(suffix.hasClass(getStyles().TracePageSearchBarCount)).toBe(true); - expect(suffix.text()).toBe(String(defaultProps.resultCount)); + const theme = createTheme(); + expect(suffix.hasClass(getStyles(theme).TracePageSearchBarSuffix)).toBe(true); + expect(suffix.text()).toBe(String(defaultProps.suffix)); }); it('renders buttons', () => { const buttons = wrapper.find('Button'); - expect(buttons.length).toBe(4); + expect(buttons.length).toBe(2); buttons.forEach((button) => { expect(button.prop('disabled')).toBe(false); }); expect(wrapper.find('Button[icon="arrow-up"]').prop('onClick')).toBe(defaultProps.prevResult); expect(wrapper.find('Button[icon="arrow-down"]').prop('onClick')).toBe(defaultProps.nextResult); - expect(wrapper.find('Button[icon="times"]').prop('onClick')).toBe(defaultProps.clearSearch); }); - it('hides navigation buttons when not navigable', () => { + it('only shows navigable buttons when navigable is true', () => { wrapper.setProps({ navigable: false }); - const button = wrapper.find('Button'); - expect(button.length).toBe(1); - expect(button.prop('icon')).toBe('times'); + var buttons = wrapper.find('Button'); + expect(buttons.length).toBe(0); + wrapper.setProps({ navigable: true }); + buttons = wrapper.find('Button'); + expect(buttons.length).toBe(2); }); }); @@ -79,7 +82,7 @@ describe('', () => { it('renders buttons', () => { const buttons = wrapper.find('Button'); - expect(buttons.length).toBe(4); + expect(buttons.length).toBe(2); buttons.forEach((button) => { expect(button.prop('disabled')).toBe(true); }); diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.tsx b/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.tsx index 9e2df0446fe..4eef52b5ef0 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.tsx @@ -14,7 +14,6 @@ import * as React from 'react'; import cx from 'classnames'; -import IoAndroidLocate from 'react-icons/lib/io/android-locate'; import { css } from '@emotion/css'; import { Button, useStyles2 } from '@grafana/ui'; @@ -24,11 +23,24 @@ import UiFindInput from '../common/UiFindInput'; import { ubFlexAuto, ubJustifyEnd } from '../uberUtilityStyles'; // eslint-disable-next-line no-duplicate-imports import { memo } from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; -export const getStyles = () => { +export const getStyles = (theme: GrafanaTheme2) => { return { TracePageSearchBar: css` label: TracePageSearchBar; + float: right; + position: sticky; + top: 8px; + right: 0; + z-index: ${theme.zIndex.navbarFixed}; + background: ${theme.colors.background.primary}; + margin-top: 8px; + margin-bottom: -48px; + padding: 8px; + margin-right: 2px; + border-radius: 4px; + box-shadow: ${theme.shadows.z2}; `, TracePageSearchBarBar: css` label: TracePageSearchBarBar; @@ -38,14 +50,14 @@ export const getStyles = () => { max-width: 100%; } `, - TracePageSearchBarCount: css` - label: TracePageSearchBarCount; + TracePageSearchBarSuffix: css` + label: TracePageSearchBarSuffix; opacity: 0.6; `, TracePageSearchBarBtn: css` label: TracePageSearchBarBtn; - border-left: none; transition: 0.2s; + margin-left: 8px; `, TracePageSearchBarBtnDisabled: css` label: TracePageSearchBarBtnDisabled; @@ -61,72 +73,62 @@ export const getStyles = () => { type TracePageSearchBarProps = { prevResult: () => void; nextResult: () => void; - clearSearch: () => void; - focusUiFindMatches: () => void; - resultCount: number; navigable: boolean; searchValue: string; onSearchValueChange: (value: string) => void; + searchBarSuffix: string; }; export default memo(function TracePageSearchBar(props: TracePageSearchBarProps) { - const { - clearSearch, - focusUiFindMatches, - navigable, - nextResult, - prevResult, - resultCount, - onSearchValueChange, - searchValue, - } = props; + const { navigable, nextResult, prevResult, onSearchValueChange, searchValue, searchBarSuffix } = props; const styles = useStyles2(getStyles); - const count = searchValue ? {resultCount} : null; + const suffix = searchValue ? ( + + {searchBarSuffix} + + ) : null; const btnClass = cx(styles.TracePageSearchBarBtn, { [styles.TracePageSearchBarBtnDisabled]: !searchValue }); const uiFindInputInputProps = { 'data-test': markers.IN_TRACE_SEARCH, className: cx(styles.TracePageSearchBarBar, ubFlexAuto), name: 'search', - suffix: count, + suffix, }; return (
    - + <> {navigable && ( <> - -
    diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx index 0244e410252..10321578de1 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx @@ -166,17 +166,17 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => { `, rowMatchingFilter: css` label: rowMatchingFilter; - background-color: ${autoColor(theme, '#fffce4')}; + background-color: ${autoColor(theme, '#fffbde')}; &:hover .${nameWrapperClassName} { background: linear-gradient( 90deg, - ${autoColor(theme, '#fff5e1')}, - ${autoColor(theme, '#fff5e1')} 75%, - ${autoColor(theme, '#ffe6c9')} + ${autoColor(theme, '#fffbde')}, + ${autoColor(theme, '#fffbde')} 75%, + ${autoColor(theme, '#f7f1c6')} ); } &:hover .${viewClassName} { - background-color: ${autoColor(theme, '#fff3d7')}; + background-color: ${autoColor(theme, '#f7f1c6')}; outline: 1px solid ${autoColor(theme, '#ddd')}; } `, diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx index 8796d0d60b5..cf5d11a36e5 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx @@ -13,7 +13,7 @@ // limitations under the License. import * as React from 'react'; -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { Icon, useStyles2 } from '@grafana/ui'; import AccordianKeyValues from './AccordianKeyValues'; @@ -98,6 +98,9 @@ const getStyles = (theme: GrafanaTheme2) => { content: attr(data-label); } `, + serviceName: css` + margin-right: 8px; + `, }; }; @@ -134,7 +137,9 @@ export function References(props: ReferenceItemProps) { {reference.span ? ( - {reference.span.process.serviceName} + + {reference.span.process.serviceName} + {reference.span.operationName} ) : ( diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx index d808eb07f5f..b365fcc9919 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx @@ -101,6 +101,7 @@ type TVirtualizedTraceViewOwnProps = { createSpanLink?: SpanLinkFunc; scrollElement?: Element; focusedSpanId?: string; + focusedSpanIdForSearch: string; createFocusSpanLink: (traceId: string, spanId: string) => LinkModel; topOfExploreViewRef?: RefObject; }; @@ -223,6 +224,7 @@ export class UnthemedVirtualizedTraceView extends React.Component LinkModel; topOfExploreViewRef?: RefObject; }; @@ -163,6 +164,7 @@ export class UnthemedTraceTimelineViewer extends React.PureComponent diff --git a/packages/jaeger-ui-components/src/utils/filter-spans.tsx b/packages/jaeger-ui-components/src/utils/filter-spans.tsx index de9e52634e2..6647201bf37 100644 --- a/packages/jaeger-ui-components/src/utils/filter-spans.tsx +++ b/packages/jaeger-ui-components/src/utils/filter-spans.tsx @@ -32,7 +32,7 @@ export default function filterSpans(textFilter: string, spans: TraceSpan[] | TNi .filter(Boolean) .forEach((w) => { if (w[0] === '-') { - excludeKeys.push(w.substr(1).toLowerCase()); + excludeKeys.push(w.slice(1).toLowerCase()); } else { includeFilters.push(w.toLowerCase()); } diff --git a/packages/jaeger-ui-components/tsconfig.json b/packages/jaeger-ui-components/tsconfig.json index 0f75321d0df..83f4d88d032 100644 --- a/packages/jaeger-ui-components/tsconfig.json +++ b/packages/jaeger-ui-components/tsconfig.json @@ -6,5 +6,11 @@ }, "exclude": ["dist", "node_modules"], "extends": "@grafana/tsconfig", - "include": ["src/**/*.ts*", "typings", "../../public/app/types/jquery/*.ts", "../../public/app/types/*.d.ts"] + "include": [ + "src/**/*.ts*", + "typings", + "../../public/app/types/jquery/*.ts", + "../../public/app/types/*.d.ts", + "../grafana-ui/src/types/*.d.ts" + ] } diff --git a/pkg/api/accesscontrol.go b/pkg/api/accesscontrol.go index 9ef25c8f161..6f08cfd6382 100644 --- a/pkg/api/accesscontrol.go +++ b/pkg/api/accesscontrol.go @@ -265,10 +265,9 @@ func (hs *HTTPServer) declareFixedRoles() error { DisplayName: "Annotation reader", Description: "Read annotations and tags", Group: "Annotations", - Version: 1, + Version: 2, Permissions: []ac.Permission{ {Action: ac.ActionAnnotationsRead, Scope: ac.ScopeAnnotationsAll}, - {Action: ac.ActionAnnotationsTagsRead, Scope: ac.ScopeAnnotationsTagsAll}, }, }, Grants: []string{string(models.ROLE_VIEWER)}, diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index a8a5835a4b7..30b0af8b746 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -495,7 +495,7 @@ func TestAPI_Annotations_AccessControl(t *testing.T) { { name: "AccessControl getting tags for annotations with correct permissions is allowed", args: args{ - permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionAnnotationsTagsRead, Scope: accesscontrol.ScopeAnnotationsTagsAll}}, + permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionAnnotationsRead}}, url: "/api/annotations/tags", method: http.MethodGet, }, @@ -504,7 +504,7 @@ func TestAPI_Annotations_AccessControl(t *testing.T) { { name: "AccessControl getting tags for annotations without correct permissions is forbidden", args: args{ - permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionAnnotationsTagsRead}}, + permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionAnnotationsWrite}}, url: "/api/annotations/tags", method: http.MethodGet, }, diff --git a/pkg/api/api.go b/pkg/api/api.go index 9d5c33cf134..b75ab24a953 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -448,7 +448,7 @@ func (hs *HTTPServer) registerRoutes() { annotationsRoute.Put("/:annotationId", authorize(reqSignedIn, ac.EvalPermission(ac.ActionAnnotationsWrite, ac.ScopeAnnotationsID)), routing.Wrap(hs.UpdateAnnotation)) annotationsRoute.Patch("/:annotationId", authorize(reqSignedIn, ac.EvalPermission(ac.ActionAnnotationsWrite, ac.ScopeAnnotationsID)), routing.Wrap(hs.PatchAnnotation)) annotationsRoute.Post("/graphite", authorize(reqEditorRole, ac.EvalPermission(ac.ActionAnnotationsCreate, ac.ScopeAnnotationsTypeOrganization)), routing.Wrap(hs.PostGraphiteAnnotation)) - annotationsRoute.Get("/tags", authorize(reqSignedIn, ac.EvalPermission(ac.ActionAnnotationsTagsRead, ac.ScopeAnnotationsTagsAll)), routing.Wrap(hs.GetAnnotationTags)) + annotationsRoute.Get("/tags", authorize(reqSignedIn, ac.EvalPermission(ac.ActionAnnotationsRead)), routing.Wrap(hs.GetAnnotationTags)) }) apiRoute.Post("/frontend-metrics", routing.Wrap(hs.PostFrontendMetrics)) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 17e1e4b49c9..293d40ddf82 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/guardian" @@ -115,25 +116,33 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { creator = hs.getUserLogin(c.Req.Context(), dash.CreatedBy) } + annotationPermissions := &dtos.AnnotationPermission{} + + if !hs.AccessControl.IsDisabled() { + hs.getAnnotationPermissionsByScope(c, &annotationPermissions.Dashboard, accesscontrol.ScopeAnnotationsTypeDashboard) + hs.getAnnotationPermissionsByScope(c, &annotationPermissions.Organization, accesscontrol.ScopeAnnotationsTypeOrganization) + } + meta := dtos.DashboardMeta{ - IsStarred: isStarred, - Slug: dash.Slug, - Type: models.DashTypeDB, - CanStar: c.IsSignedIn, - CanSave: canSave, - CanEdit: canEdit, - CanAdmin: canAdmin, - CanDelete: canDelete, - Created: dash.Created, - Updated: dash.Updated, - UpdatedBy: updater, - CreatedBy: creator, - Version: dash.Version, - HasAcl: dash.HasAcl, - IsFolder: dash.IsFolder, - FolderId: dash.FolderId, - Url: dash.GetUrl(), - FolderTitle: "General", + IsStarred: isStarred, + Slug: dash.Slug, + Type: models.DashTypeDB, + CanStar: c.IsSignedIn, + CanSave: canSave, + CanEdit: canEdit, + CanAdmin: canAdmin, + CanDelete: canDelete, + Created: dash.Created, + Updated: dash.Updated, + UpdatedBy: updater, + CreatedBy: creator, + Version: dash.Version, + HasAcl: dash.HasAcl, + IsFolder: dash.IsFolder, + FolderId: dash.FolderId, + Url: dash.GetUrl(), + FolderTitle: "General", + AnnotationsPermissions: annotationPermissions, } // lookup folder title @@ -190,6 +199,22 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { return response.JSON(200, dto) } +func (hs *HTTPServer) getAnnotationPermissionsByScope(c *models.ReqContext, actions *dtos.AnnotationActions, scope string) { + var err error + + 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) + } + + evaluate = accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsWrite, scope) + actions.CanEdit, err = hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluate) + if err != nil { + hs.log.Warn("Failed to evaluate permission", "err", err, "action", accesscontrol.ActionAnnotationsWrite, "scope", scope) + } +} + func (hs *HTTPServer) getUserLogin(ctx context.Context, userID int64) string { query := models.GetUserByIdQuery{Id: userID} err := hs.SQLStore.GetUserById(ctx, &query) diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index d33ddc3349b..54b77fbb520 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -118,10 +118,11 @@ func TestDashboardAPIEndpoint(t *testing.T) { mockSQLStore.ExpectedDashboard = fakeDash hs := &HTTPServer{ - Cfg: setting.NewCfg(), - pluginStore: &fakePluginStore{}, - SQLStore: mockSQLStore, - Features: featuremgmt.WithFeatures(), + Cfg: setting.NewCfg(), + pluginStore: &fakePluginStore{}, + SQLStore: mockSQLStore, + AccessControl: accesscontrolmock.New(), + Features: featuremgmt.WithFeatures(), } hs.SQLStore = mockSQLStore @@ -224,6 +225,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, SQLStore: mockSQLStore, + AccessControl: accesscontrolmock.New(), dashboardService: service.ProvideDashboardService( cfg, dashboardStore, nil, features, accesscontrolmock.NewPermissionsServicesMock(), ), @@ -890,6 +892,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { LibraryElementService: &mockLibraryElementService{}, dashboardProvisioningService: mockDashboardProvisioningService{}, SQLStore: mockSQLStore, + AccessControl: accesscontrolmock.New(), } hs.callGetDashboard(sc) @@ -927,6 +930,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr LibraryElementService: &libraryElementsService, SQLStore: sc.sqlStore, ProvisioningService: provisioningService, + AccessControl: accesscontrolmock.New(), dashboardProvisioningService: service.ProvideDashboardService( cfg, dashboardStore, nil, features, accesscontrolmock.NewPermissionsServicesMock(), ), diff --git a/pkg/api/docs/definitions/annotations.go b/pkg/api/docs/definitions/annotations.go index 280842fedc3..6493a77e8ee 100644 --- a/pkg/api/docs/definitions/annotations.go +++ b/pkg/api/docs/definitions/annotations.go @@ -29,7 +29,7 @@ import ( // // Create Annotation. // -// Creates an annotation in the Grafana database. The dashboardId and panelId fields are optional. If they are not specified then a global annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. When creating a region annotation include the timeEnd property. +// Creates an annotation in the Grafana database. The dashboardId and panelId fields are optional. If they are not specified then an organization annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. When creating a region annotation include the timeEnd property. // The format for `time` and `timeEnd` should be epoch numbers in millisecond resolution. // The response for this HTTP request is slightly different in versions prior to v6.4. In prior versions you would also get an endId if you where creating a region. But in 6.4 regions are represented using a single event with time and timeEnd properties. // @@ -141,7 +141,7 @@ type GetAnnotationsParams struct { // in:query // required:false Limit int64 `json:"limit"` - // Use this to filter global annotations. Organization annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. You can filter by multiple tags. + // Use this to filter organization annotations. Organization annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. You can filter by multiple tags. // in:query // required:false // type: array diff --git a/pkg/api/docs/tags.json b/pkg/api/docs/tags.json index 30c103748f6..17b6bf9aa82 100644 --- a/pkg/api/docs/tags.json +++ b/pkg/api/docs/tags.json @@ -26,7 +26,7 @@ }, { "name": "annotations", - "description": "Grafana Annotations feature released in Grafana 4.6. Annotations are saved in the Grafana database (sqlite, mysql or postgres). Annotations can be global annotations that can be shown on any dashboard by configuring an annotation data source - they are filtered by tags. Or they can be tied to a panel on a dashboard and are then only shown on that panel." + "description": "Grafana Annotations feature released in Grafana 4.6. Annotations are saved in the Grafana database (sqlite, mysql or postgres). Annotations can be organization annotations that can be shown on any dashboard by configuring an annotation data source - they are filtered by tags. Or they can be tied to a panel on a dashboard and are then only shown on that panel." }, { "name": "library_elements", @@ -81,4 +81,4 @@ "description": "Grafana Alerting Prometheus-compatible endpoints" } ] -} \ No newline at end of file +} diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index c7f14f12ab7..707bd549804 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -7,31 +7,41 @@ import ( ) type DashboardMeta struct { - IsStarred bool `json:"isStarred,omitempty"` - IsHome bool `json:"isHome,omitempty"` - IsSnapshot bool `json:"isSnapshot,omitempty"` - Type string `json:"type,omitempty"` - CanSave bool `json:"canSave"` - CanEdit bool `json:"canEdit"` - CanAdmin bool `json:"canAdmin"` - CanStar bool `json:"canStar"` - CanDelete bool `json:"canDelete"` - Slug string `json:"slug"` - Url string `json:"url"` - Expires time.Time `json:"expires"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - UpdatedBy string `json:"updatedBy"` - CreatedBy string `json:"createdBy"` - Version int `json:"version"` - HasAcl bool `json:"hasAcl"` - IsFolder bool `json:"isFolder"` - FolderId int64 `json:"folderId"` - FolderUid string `json:"folderUid"` - FolderTitle string `json:"folderTitle"` - FolderUrl string `json:"folderUrl"` - Provisioned bool `json:"provisioned"` - ProvisionedExternalId string `json:"provisionedExternalId"` + IsStarred bool `json:"isStarred,omitempty"` + IsHome bool `json:"isHome,omitempty"` + IsSnapshot bool `json:"isSnapshot,omitempty"` + Type string `json:"type,omitempty"` + CanSave bool `json:"canSave"` + CanEdit bool `json:"canEdit"` + CanAdmin bool `json:"canAdmin"` + CanStar bool `json:"canStar"` + CanDelete bool `json:"canDelete"` + Slug string `json:"slug"` + Url string `json:"url"` + Expires time.Time `json:"expires"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + UpdatedBy string `json:"updatedBy"` + CreatedBy string `json:"createdBy"` + Version int `json:"version"` + HasAcl bool `json:"hasAcl"` + IsFolder bool `json:"isFolder"` + FolderId int64 `json:"folderId"` + FolderUid string `json:"folderUid"` + FolderTitle string `json:"folderTitle"` + FolderUrl string `json:"folderUrl"` + Provisioned bool `json:"provisioned"` + ProvisionedExternalId string `json:"provisionedExternalId"` + AnnotationsPermissions *AnnotationPermission `json:"annotationsPermissions"` +} +type AnnotationPermission struct { + Dashboard AnnotationActions `json:"dashboard"` + Organization AnnotationActions `json:"organization"` +} + +type AnnotationActions struct { + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` } type DashboardFullWithMeta struct { diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 5723015970b..ab937c60f3c 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -35,6 +35,7 @@ import ( ) func TestDataSourceProxy_routeRule(t *testing.T) { + cfg := &setting.Cfg{} httpClientProvider := httpclient.NewProvider() tracer, err := tracing.InitializeTracerForTest() require.NoError(t, err) @@ -128,11 +129,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { return ctx, req } - cfg := &setting.Cfg{} - t.Run("When matching route path", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -145,7 +144,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic url", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/common/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) proxy.matchedRoute = routes[3] @@ -157,7 +156,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path with no url", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) proxy.matchedRoute = routes[4] @@ -168,7 +167,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic body", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/body", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) proxy.matchedRoute = routes[5] @@ -182,7 +181,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("Validating request", func(t *testing.T) { t.Run("plugin route with valid role", func(t *testing.T) { ctx, _ := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) err = proxy.validateRequest() @@ -191,7 +190,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is editor", func(t *testing.T) { ctx, _ := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) err = proxy.validateRequest() @@ -201,7 +200,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is admin", func(t *testing.T) { ctx, _ := setUp() ctx.SignedInUser.OrgRole = models.ROLE_ADMIN - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) err = proxy.validateRequest() @@ -280,8 +279,6 @@ func TestDataSourceProxy_routeRule(t *testing.T) { client = newFakeHTTPClient(t, json) defer func() { client = originalClient }() - cfg := &setting.Cfg{} - jd, err := ds.JsonData.Map() require.NoError(t, err) dsInfo := DSInfo{ @@ -293,7 +290,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }, } - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) @@ -309,7 +306,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { req, err := http.NewRequest("GET", "http://localhost/asd", nil) require.NoError(t, err) client = newFakeHTTPClient(t, json2) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken2", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[1], dsInfo, cfg) @@ -326,7 +323,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { require.NoError(t, err) client = newFakeHTTPClient(t, []byte{}) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) @@ -349,7 +346,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{BuildVersion: "5.3.0"}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -376,7 +373,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -401,7 +398,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -430,7 +427,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { var pluginRoutes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, pluginRoutes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -454,7 +451,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -519,7 +516,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &mockAuthToken, dsService, tracer, secretsService) require.NoError(t, err) req, err = http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -588,13 +585,14 @@ func TestDataSourceProxy_routeRule(t *testing.T) { createAuthTest(t, secretsService, models.DS_ES, "http://localhost:9200", authTypeBasic, authCheckHeader, true), } for _, test := range tests { - runDatasourceAuthTest(t, secretsService, test) + runDatasourceAuthTest(t, secretsService, cfg, test) } }) } // test DataSourceProxy request handling. func TestDataSourceProxy_requestHandling(t *testing.T) { + cfg := &setting.Cfg{} httpClientProvider := httpclient.NewProvider() var writeErr error @@ -653,7 +651,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -672,7 +670,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -687,7 +685,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -710,7 +708,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -736,7 +734,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -761,7 +759,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -781,14 +779,14 @@ func TestNewDataSourceProxy_InvalidURL(t *testing.T) { Type: "test", Url: "://host/root", } - cfg := setting.Cfg{} + cfg := &setting.Cfg{} tracer, err := tracing.InitializeTracerForTest() require.NoError(t, err) var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) - _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.Error(t, err) assert.True(t, strings.HasPrefix(err.Error(), `validation of data source URL "://host/root" failed`)) } @@ -802,15 +800,15 @@ func TestNewDataSourceProxy_ProtocolLessURL(t *testing.T) { Type: "test", Url: "127.0.01:5432", } - cfg := setting.Cfg{} + cfg := &setting.Cfg{} tracer, err := tracing.InitializeTracerForTest() require.NoError(t, err) var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) - _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) } @@ -844,7 +842,7 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) { } for _, tc := range tcs { t.Run(tc.description, func(t *testing.T) { - cfg := setting.Cfg{} + cfg := &setting.Cfg{} ds := models.DataSource{ Type: "mssql", Url: tc.url, @@ -853,8 +851,8 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) { var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) - p, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + p, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) if tc.err == nil { require.NoError(t, err) assert.Equal(t, &url.URL{ @@ -881,7 +879,7 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *models.ReqContext, cfg *sett var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -993,14 +991,14 @@ func createAuthTest(t *testing.T, secretsService secrets.Service, dsType string, return test } -func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, test *testCase) { +func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, cfg *setting.Cfg, test *testCase) { ctx := &models.ReqContext{} tracer, err := tracing.InitializeTracerForTest() require.NoError(t, err) var routes []*plugins.Route secretsStore := kvstore.SetupTestService(t) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -1013,6 +1011,7 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, test *t } func Test_PathCheck(t *testing.T) { + cfg := &setting.Cfg{} // Ensure that we test routes appropriately. This test reproduces a historical bug where two routes were defined with different role requirements but the same method and the more privileged route was tested first. Here we ensure auth checks are applied based on the correct route, not just the method. routes := []*plugins.Route{ { @@ -1043,7 +1042,7 @@ func Test_PathCheck(t *testing.T) { ctx, _ := setUp() secretsStore := kvstore.SetupTestService(t) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(&models.DataSource{}, routes, ctx, "b", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) diff --git a/pkg/api/pluginproxy/token_provider_azure.go b/pkg/api/pluginproxy/token_provider_azure.go index b0e3150ac87..a0db3524c35 100644 --- a/pkg/api/pluginproxy/token_provider_azure.go +++ b/pkg/api/pluginproxy/token_provider_azure.go @@ -4,10 +4,12 @@ import ( "context" "strings" + "github.com/grafana/grafana-azure-sdk-go/azcredentials" + "github.com/grafana/grafana-azure-sdk-go/azsettings" + "github.com/grafana/grafana-azure-sdk-go/aztokenprovider" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/aztokenprovider" ) type azureAccessTokenProvider struct { @@ -17,8 +19,8 @@ type azureAccessTokenProvider struct { } func newAzureAccessTokenProvider(ctx context.Context, cfg *setting.Cfg, authParams *plugins.JWTTokenAuth) (*azureAccessTokenProvider, error) { - credentials := getAzureCredentials(cfg, authParams) - tokenProvider, err := aztokenprovider.NewAzureAccessTokenProvider(cfg, credentials) + credentials := getAzureCredentials(cfg.Azure, authParams) + tokenProvider, err := aztokenprovider.NewAzureAccessTokenProvider(cfg.Azure, credentials) if err != nil { return nil, err } @@ -33,7 +35,7 @@ func (provider *azureAccessTokenProvider) GetAccessToken() (string, error) { return provider.tokenProvider.GetAccessToken(provider.ctx, provider.scopes) } -func getAzureCredentials(cfg *setting.Cfg, authParams *plugins.JWTTokenAuth) azcredentials.AzureCredentials { +func getAzureCredentials(settings *azsettings.AzureSettings, authParams *plugins.JWTTokenAuth) azcredentials.AzureCredentials { authType := strings.ToLower(authParams.Params["azure_auth_type"]) clientId := authParams.Params["client_id"] @@ -43,7 +45,7 @@ func getAzureCredentials(cfg *setting.Cfg, authParams *plugins.JWTTokenAuth) azc // before managed identities where introduced, therefore use client secret authentication // * If authType and other fields aren't set then it means the datasource never been configured // and managed identity is the default authentication choice as long as managed identities are enabled - isManagedIdentity := authType == "msi" || (authType == "" && clientId == "" && cfg.Azure.ManagedIdentityEnabled) + isManagedIdentity := authType == "msi" || (authType == "" && clientId == "" && settings.ManagedIdentityEnabled) if isManagedIdentity { return &azcredentials.AzureManagedIdentityCredentials{} diff --git a/pkg/infra/httpclient/httpclientprovider/azure_middleware.go b/pkg/infra/httpclient/httpclientprovider/azure_middleware.go deleted file mode 100644 index 93beebc45b1..00000000000 --- a/pkg/infra/httpclient/httpclientprovider/azure_middleware.go +++ /dev/null @@ -1,86 +0,0 @@ -package httpclientprovider - -import ( - "fmt" - "net/http" - "net/url" - "path" - - "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/aztokenprovider" -) - -const azureMiddlewareName = "AzureAuthentication.Provider" - -func AzureMiddleware(cfg *setting.Cfg) httpclient.Middleware { - return httpclient.NamedMiddlewareFunc(azureMiddlewareName, func(opts httpclient.Options, next http.RoundTripper) http.RoundTripper { - credentials, err := getAzureCredentials(opts.CustomOptions) - if err != nil { - return errorResponse(err) - } else if credentials == nil { - return next - } - - tokenProvider, err := aztokenprovider.NewAzureAccessTokenProvider(cfg, credentials) - if err != nil { - return errorResponse(err) - } - - scopes, err := getAzureEndpointScopes(opts.CustomOptions) - if err != nil { - return errorResponse(err) - } - - return aztokenprovider.ApplyAuth(tokenProvider, scopes, next) - }) -} - -func errorResponse(err error) http.RoundTripper { - return httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { - return nil, fmt.Errorf("invalid Azure configuration: %s", err) - }) -} - -func getAzureCredentials(customOptions map[string]interface{}) (azcredentials.AzureCredentials, error) { - if untypedValue, ok := customOptions["_azureCredentials"]; !ok { - return nil, nil - } else if value, ok := untypedValue.(azcredentials.AzureCredentials); !ok { - err := fmt.Errorf("the field 'azureCredentials' should be a valid credentials object") - return nil, err - } else { - return value, nil - } -} - -func getAzureEndpointResourceId(customOptions map[string]interface{}) (*url.URL, error) { - var value string - if untypedValue, ok := customOptions["azureEndpointResourceId"]; !ok { - err := fmt.Errorf("the field 'azureEndpointResourceId' should be set") - return nil, err - } else if value, ok = untypedValue.(string); !ok { - err := fmt.Errorf("the field 'azureEndpointResourceId' should be a string") - return nil, err - } - - resourceId, err := url.Parse(value) - if err != nil || resourceId.Scheme == "" || resourceId.Host == "" { - err := fmt.Errorf("invalid endpoint Resource ID URL '%s'", value) - return nil, err - } - - return resourceId, nil -} - -func getAzureEndpointScopes(customOptions map[string]interface{}) ([]string, error) { - resourceId, err := getAzureEndpointResourceId(customOptions) - if err != nil { - return nil, err - } - - resourceId.Path = path.Join(resourceId.Path, ".default") - scopes := []string{resourceId.String()} - - return scopes, nil -} diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go index 8c6d6028a9c..26b9b79c4b4 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go +++ b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/mwitkow/go-conntrack" ) @@ -17,7 +16,7 @@ import ( var newProviderFunc = sdkhttpclient.NewProvider // New creates a new HTTP client provider with pre-configured middlewares. -func New(cfg *setting.Cfg, tracer tracing.Tracer, features featuremgmt.FeatureToggles) *sdkhttpclient.Provider { +func New(cfg *setting.Cfg, tracer tracing.Tracer) *sdkhttpclient.Provider { logger := log.New("httpclient") userAgent := fmt.Sprintf("Grafana/%s", cfg.BuildVersion) @@ -36,10 +35,6 @@ func New(cfg *setting.Cfg, tracer tracing.Tracer, features featuremgmt.FeatureTo setDefaultTimeoutOptions(cfg) - if features.IsEnabled(featuremgmt.FlagHttpclientproviderAzureAuth) { - middlewares = append(middlewares, AzureMiddleware(cfg)) - } - return newProviderFunc(sdkhttpclient.ProviderOptions{ Middlewares: middlewares, ConfigureTransport: func(opts sdkhttpclient.Options, transport *http.Transport) { diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go index 9fbafaa5342..d00deedf3bf 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go +++ b/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go @@ -5,7 +5,6 @@ import ( sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" ) @@ -23,7 +22,7 @@ func TestHTTPClientProvider(t *testing.T) { }) tracer, err := tracing.InitializeTracerForTest() require.NoError(t, err) - _ = New(&setting.Cfg{SigV4AuthEnabled: false}, tracer, featuremgmt.WithFeatures()) + _ = New(&setting.Cfg{SigV4AuthEnabled: false}, tracer) require.Len(t, providerOpts, 1) o := providerOpts[0] require.Len(t, o.Middlewares, 6) @@ -47,7 +46,7 @@ func TestHTTPClientProvider(t *testing.T) { }) tracer, err := tracing.InitializeTracerForTest() require.NoError(t, err) - _ = New(&setting.Cfg{SigV4AuthEnabled: true}, tracer, featuremgmt.WithFeatures()) + _ = New(&setting.Cfg{SigV4AuthEnabled: true}, tracer) require.Len(t, providerOpts, 1) o := providerOpts[0] require.Len(t, o.Middlewares, 7) diff --git a/pkg/infra/log/interface.go b/pkg/infra/log/interface.go index c0909d682cb..009d4e5a39c 100644 --- a/pkg/infra/log/interface.go +++ b/pkg/infra/log/interface.go @@ -11,7 +11,7 @@ const ( ) type Logger interface { - // New returns a new Logger that has this logger's context plus the given context + // New returns a new contextual Logger that has this logger's context plus the given context. New(ctx ...interface{}) *ConcreteLogger Log(keyvals ...interface{}) error diff --git a/pkg/infra/log/level/level.go b/pkg/infra/log/level/level.go index d6fdccfdaa1..cc3ef214491 100644 --- a/pkg/infra/log/level/level.go +++ b/pkg/infra/log/level/level.go @@ -1,6 +1,9 @@ package level -import "github.com/go-kit/log" +import ( + "github.com/go-kit/log" + gokitlevel "github.com/go-kit/log/level" +) // Error returns a logger that includes a Key/ErrorValue pair. func Error(logger log.Logger) log.Logger { @@ -53,6 +56,12 @@ func (l *logger) Log(keyvals ...interface{}) error { levelAllowed = l.allowed&v.level != 0 break } + + if v, ok := keyvals[i].(gokitlevel.Value); ok { + hasLevel = true + levelAllowed = l.allowed&levelFromGokitLevel(v) != 0 + break + } } if !hasLevel && l.squelchNoLevel { return l.errNoLevel @@ -196,6 +205,35 @@ const ( levelError ) +func IsKey(v interface{}) bool { + return v != nil && (v == Key() || v == gokitlevel.Key()) +} + +func GetValue(v interface{}) Value { + if v == nil { + return nil + } + + if val, ok := v.(Value); ok { + return val + } + + if val, ok := v.(gokitlevel.Value); ok { + switch val { + case gokitlevel.InfoValue(): + return InfoValue() + case gokitlevel.WarnValue(): + return WarnValue() + case gokitlevel.ErrorValue(): + return ErrorValue() + case gokitlevel.DebugValue(): + return DebugValue() + } + } + + return nil +} + type levelValue struct { name string level @@ -203,3 +241,16 @@ type levelValue struct { func (v *levelValue) String() string { return v.name } func (v *levelValue) levelVal() {} + +func levelFromGokitLevel(l gokitlevel.Value) level { + switch l.String() { + case gokitlevel.ErrorValue().String(): + return levelError + case gokitlevel.WarnValue().String(): + return levelWarn + case gokitlevel.DebugValue().String(): + return levelDebug + } + + return levelInfo +} diff --git a/pkg/infra/log/level/level_test.go b/pkg/infra/log/level/level_test.go new file mode 100644 index 00000000000..2e655aa2c9a --- /dev/null +++ b/pkg/infra/log/level/level_test.go @@ -0,0 +1,154 @@ +package level_test + +import ( + "testing" + + gokitlog "github.com/go-kit/log" + gokitlevel "github.com/go-kit/log/level" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/log/level" + "github.com/stretchr/testify/require" +) + +func TestNewFilter(t *testing.T) { + newFilteredLoggerScenario(t, "Given all levels is allowed should log all messages", level.AllowAll(), func(t *testing.T, ctx *scenarioContext) { + logTestMessages(t, ctx) + + require.Len(t, ctx.loggedArgs, 8) + require.Equal(t, "lvl", ctx.loggedArgs[0][2].(string)) + require.Equal(t, "info", ctx.loggedArgs[0][3].(level.Value).String()) + require.Equal(t, "lvl", ctx.loggedArgs[1][2].(string)) + require.Equal(t, "warn", ctx.loggedArgs[1][3].(level.Value).String()) + require.Equal(t, "lvl", ctx.loggedArgs[2][2].(string)) + require.Equal(t, "eror", ctx.loggedArgs[2][3].(level.Value).String()) + require.Equal(t, "lvl", ctx.loggedArgs[3][2].(string)) + require.Equal(t, "dbug", ctx.loggedArgs[3][3].(level.Value).String()) + + require.Equal(t, "level", ctx.loggedArgs[4][0].(string)) + require.Equal(t, "info", ctx.loggedArgs[4][1].(gokitlevel.Value).String()) + require.Equal(t, "level", ctx.loggedArgs[5][0].(string)) + require.Equal(t, "warn", ctx.loggedArgs[5][1].(gokitlevel.Value).String()) + require.Equal(t, "level", ctx.loggedArgs[6][0].(string)) + require.Equal(t, "error", ctx.loggedArgs[6][1].(gokitlevel.Value).String()) + require.Equal(t, "level", ctx.loggedArgs[7][0].(string)) + require.Equal(t, "debug", ctx.loggedArgs[7][1].(gokitlevel.Value).String()) + }) + + newFilteredLoggerScenario(t, "Given error, warnings, info, debug is allowed should log all messages", level.AllowDebug(), func(t *testing.T, ctx *scenarioContext) { + logTestMessages(t, ctx) + + require.Len(t, ctx.loggedArgs, 8) + require.Equal(t, "lvl", ctx.loggedArgs[0][2].(string)) + require.Equal(t, "info", ctx.loggedArgs[0][3].(level.Value).String()) + require.Equal(t, "lvl", ctx.loggedArgs[1][2].(string)) + require.Equal(t, "warn", ctx.loggedArgs[1][3].(level.Value).String()) + require.Equal(t, "lvl", ctx.loggedArgs[2][2].(string)) + require.Equal(t, "eror", ctx.loggedArgs[2][3].(level.Value).String()) + require.Equal(t, "lvl", ctx.loggedArgs[3][2].(string)) + require.Equal(t, "dbug", ctx.loggedArgs[3][3].(level.Value).String()) + + require.Equal(t, "level", ctx.loggedArgs[4][0].(string)) + require.Equal(t, "info", ctx.loggedArgs[4][1].(gokitlevel.Value).String()) + require.Equal(t, "level", ctx.loggedArgs[5][0].(string)) + require.Equal(t, "warn", ctx.loggedArgs[5][1].(gokitlevel.Value).String()) + require.Equal(t, "level", ctx.loggedArgs[6][0].(string)) + require.Equal(t, "error", ctx.loggedArgs[6][1].(gokitlevel.Value).String()) + require.Equal(t, "level", ctx.loggedArgs[7][0].(string)) + require.Equal(t, "debug", ctx.loggedArgs[7][1].(gokitlevel.Value).String()) + }) + + newFilteredLoggerScenario(t, "Given error, warnings is allowed should log error and warning messages", level.AllowWarn(), func(t *testing.T, ctx *scenarioContext) { + logTestMessages(t, ctx) + + require.Len(t, ctx.loggedArgs, 4) + require.Equal(t, "lvl", ctx.loggedArgs[0][2].(string)) + require.Equal(t, "warn", ctx.loggedArgs[0][3].(level.Value).String()) + require.Equal(t, "lvl", ctx.loggedArgs[1][2].(string)) + require.Equal(t, "eror", ctx.loggedArgs[1][3].(level.Value).String()) + + require.Equal(t, "level", ctx.loggedArgs[2][0].(string)) + require.Equal(t, "warn", ctx.loggedArgs[2][1].(gokitlevel.Value).String()) + require.Equal(t, "level", ctx.loggedArgs[3][0].(string)) + require.Equal(t, "error", ctx.loggedArgs[3][1].(gokitlevel.Value).String()) + }) + + newFilteredLoggerScenario(t, "Given error allowed should log error messages", level.AllowError(), func(t *testing.T, ctx *scenarioContext) { + logTestMessages(t, ctx) + + require.Len(t, ctx.loggedArgs, 2) + require.Equal(t, "lvl", ctx.loggedArgs[0][2].(string)) + require.Equal(t, "eror", ctx.loggedArgs[0][3].(level.Value).String()) + + require.Equal(t, "level", ctx.loggedArgs[1][0].(string)) + require.Equal(t, "error", ctx.loggedArgs[1][1].(gokitlevel.Value).String()) + }) + + newFilteredLoggerScenario(t, "Given error, warnings, info is allowed should log error, warning and info messages", level.AllowInfo(), func(t *testing.T, ctx *scenarioContext) { + logTestMessages(t, ctx) + + require.Len(t, ctx.loggedArgs, 6) + require.Equal(t, "lvl", ctx.loggedArgs[0][2].(string)) + require.Equal(t, "info", ctx.loggedArgs[0][3].(level.Value).String()) + require.Equal(t, "lvl", ctx.loggedArgs[1][2].(string)) + require.Equal(t, "warn", ctx.loggedArgs[1][3].(level.Value).String()) + require.Equal(t, "lvl", ctx.loggedArgs[2][2].(string)) + require.Equal(t, "eror", ctx.loggedArgs[2][3].(level.Value).String()) + + require.Equal(t, "level", ctx.loggedArgs[3][0].(string)) + require.Equal(t, "info", ctx.loggedArgs[3][1].(gokitlevel.Value).String()) + require.Equal(t, "level", ctx.loggedArgs[4][0].(string)) + require.Equal(t, "warn", ctx.loggedArgs[4][1].(gokitlevel.Value).String()) + require.Equal(t, "level", ctx.loggedArgs[5][0].(string)) + require.Equal(t, "error", ctx.loggedArgs[5][1].(gokitlevel.Value).String()) + }) + + newFilteredLoggerScenario(t, "Given no levels is allowed should not log any messages", level.AllowNone(), func(t *testing.T, ctx *scenarioContext) { + logTestMessages(t, ctx) + + require.Len(t, ctx.loggedArgs, 0) + }) +} + +func logTestMessages(t *testing.T, ctx *scenarioContext) { + t.Helper() + + ctx.logger.Info("info msg") + ctx.logger.Warn("warn msg") + ctx.logger.Error("error msg") + ctx.logger.Debug("debug msg") + err := gokitlevel.Info(ctx.logger).Log("msg", "gokit info msg") + require.NoError(t, err) + err = gokitlevel.Warn(ctx.logger).Log("msg", "gokit warn msg") + require.NoError(t, err) + err = gokitlevel.Error(ctx.logger).Log("msg", "gokit error msg") + require.NoError(t, err) + err = gokitlevel.Debug(ctx.logger).Log("msg", "gokit debug msg") + require.NoError(t, err) +} + +type scenarioContext struct { + loggedArgs [][]interface{} + logger log.Logger +} + +func newFilteredLoggerScenario(t *testing.T, desc string, option level.Option, fn func(t *testing.T, ctx *scenarioContext)) { + t.Helper() + + ctx := &scenarioContext{ + loggedArgs: [][]interface{}{}, + } + + l := gokitlog.LoggerFunc(func(i ...interface{}) error { + ctx.loggedArgs = append(ctx.loggedArgs, i) + return nil + }) + filteredLogger := level.NewFilter(l, option) + testLogger := log.New("test") + testLogger.Swap(filteredLogger) + + ctx.logger = testLogger + + t.Run(desc, func(t *testing.T) { + fn(t, ctx) + }) +} diff --git a/pkg/infra/log/log.go b/pkg/infra/log/log.go index ae0b99cfd1f..c2157887d8e 100644 --- a/pkg/infra/log/log.go +++ b/pkg/infra/log/log.go @@ -192,6 +192,15 @@ func (cl *ConcreteLogger) New(ctx ...interface{}) *ConcreteLogger { return newConcreteLogger(gokitlog.With(&cl.SwapLogger), ctx...) } +// New creates a new logger. +// First ctx argument is expected to be the name of the logger. +// Note: For a contextual logger, i.e. a logger with a shared +// name plus additional contextual information, you must use the +// Logger interface New method for it to work as expected. +// Example creating a shared logger: +// requestLogger := log.New("request-logger") +// Example creating a contextual logger: +// contextualLogger := requestLogger.New("username", "user123") func New(ctx ...interface{}) *ConcreteLogger { if len(ctx) == 0 { return root.New() diff --git a/pkg/infra/log/log_test.go b/pkg/infra/log/log_test.go index a3c7fef24fd..8bcdabae49a 100644 --- a/pkg/infra/log/log_test.go +++ b/pkg/infra/log/log_test.go @@ -30,8 +30,9 @@ func TestLogger(t *testing.T) { log4 := log3.New("key", "value") err = log4.Log("msg", "hello 4") require.NoError(t, err) + log3.Error("hello 3 again") - require.Len(t, ctx.loggedArgs, 4) + require.Len(t, ctx.loggedArgs, 5) require.Len(t, ctx.loggedArgs[0], 4) require.Equal(t, "logger", ctx.loggedArgs[0][0].(string)) require.Equal(t, "one", ctx.loggedArgs[0][1].(string)) @@ -62,6 +63,16 @@ func TestLogger(t *testing.T) { require.Equal(t, "msg", ctx.loggedArgs[3][4].(string)) require.Equal(t, "hello 4", ctx.loggedArgs[3][5].(string)) + require.Len(t, ctx.loggedArgs[4], 8) + require.Equal(t, "logger", ctx.loggedArgs[4][0].(string)) + require.Equal(t, "three", ctx.loggedArgs[4][1].(string)) + require.Equal(t, "t", ctx.loggedArgs[4][2].(string)) + require.Equal(t, ctx.mockedTime.Format("2006-01-02T15:04:05.99-0700"), ctx.loggedArgs[4][3].(fmt.Stringer).String()) + require.Equal(t, "lvl", ctx.loggedArgs[4][4].(string)) + require.Equal(t, level.ErrorValue(), ctx.loggedArgs[4][5].(level.Value)) + require.Equal(t, "msg", ctx.loggedArgs[4][6].(string)) + require.Equal(t, "hello 3 again", ctx.loggedArgs[4][7].(string)) + t.Run("When initializing root logger should swap loggers as expected", func(t *testing.T) { swappedLoggedArgs := [][]interface{}{} swapLogger := gokitlog.LoggerFunc(func(i ...interface{}) error { @@ -87,7 +98,7 @@ func TestLogger(t *testing.T) { log3.Error("hello 3") log3.Debug("debug") - require.Len(t, ctx.loggedArgs, 4) + require.Len(t, ctx.loggedArgs, 5) require.Len(t, swappedLoggedArgs, 7, "expected 4 messages for AllowAll logger and 3 messages for AllowInfo logger") }) }) diff --git a/pkg/infra/log/syslog.go b/pkg/infra/log/syslog.go index 150a8c6fae3..62564bcf3cf 100644 --- a/pkg/infra/log/syslog.go +++ b/pkg/infra/log/syslog.go @@ -8,8 +8,8 @@ import ( "os" "github.com/go-kit/log" - "github.com/go-kit/log/level" gokitsyslog "github.com/go-kit/log/syslog" + "github.com/grafana/grafana/pkg/infra/log/level" "gopkg.in/ini.v1" ) @@ -25,31 +25,26 @@ type SysLogHandler struct { var selector = func(keyvals ...interface{}) syslog.Priority { for i := 0; i < len(keyvals); i += 2 { - if keyvals[i] == level.Key() { - if v, ok := keyvals[i+1].(string); ok { - switch v { - case "emergency": - return syslog.LOG_EMERG - case "alert": - return syslog.LOG_ALERT - case "critical": - return syslog.LOG_CRIT - case "error": + if level.IsKey(keyvals[i]) { + val := level.GetValue(keyvals[i+1]) + if val != nil { + switch val { + case level.ErrorValue(): return syslog.LOG_ERR - case "warning": + case level.WarnValue(): return syslog.LOG_WARNING - case "notice": - return syslog.LOG_NOTICE - case "info": + case level.InfoValue(): return syslog.LOG_INFO - case "debug": + case level.DebugValue(): return syslog.LOG_DEBUG } - return syslog.LOG_LOCAL0 } + + break } } - return syslog.LOG_LOCAL0 + + return syslog.LOG_INFO } func NewSyslog(sec *ini.Section, format Formatedlogger) *SysLogHandler { @@ -62,7 +57,7 @@ func NewSyslog(sec *ini.Section, format Formatedlogger) *SysLogHandler { handler.Tag = sec.Key("tag").MustString("") if err := handler.Init(); err != nil { - _ = level.Error(root).Log("Failed to init syslog log handler", "error", err) + root.Error("Failed to init syslog log handler", "error", err) os.Exit(1) } handler.logger = gokitsyslog.NewSyslogLogger(handler.syslog, format, gokitsyslog.PrioritySelectorOption(selector)) diff --git a/pkg/plugins/config.go b/pkg/plugins/config.go index 3ad06bb54b8..66d74ac564d 100644 --- a/pkg/plugins/config.go +++ b/pkg/plugins/config.go @@ -1,6 +1,8 @@ package plugins import ( + "github.com/grafana/grafana-azure-sdk-go/azsettings" + "github.com/grafana/grafana/pkg/setting" ) @@ -19,7 +21,7 @@ type Cfg struct { AWSAssumeRoleEnabled bool // Azure Cloud settings - Azure setting.AzureSettings + Azure *azsettings.AzureSettings BuildVersion string // TODO Remove } diff --git a/pkg/plugins/manager/loader/initializer/initializer.go b/pkg/plugins/manager/loader/initializer/initializer.go index 608e4cc66fe..b4bc12ccee4 100644 --- a/pkg/plugins/manager/loader/initializer/initializer.go +++ b/pkg/plugins/manager/loader/initializer/initializer.go @@ -84,14 +84,17 @@ func (i *Initializer) awsEnvVars() []string { func (i *Initializer) azureEnvVars() []string { var variables []string - if i.cfg.Azure.Cloud != "" { - variables = append(variables, "AZURE_CLOUD="+i.cfg.Azure.Cloud) - } - if i.cfg.Azure.ManagedIdentityClientId != "" { - variables = append(variables, "AZURE_MANAGED_IDENTITY_CLIENT_ID="+i.cfg.Azure.ManagedIdentityClientId) - } - if i.cfg.Azure.ManagedIdentityEnabled { - variables = append(variables, "AZURE_MANAGED_IDENTITY_ENABLED=true") + + if i.cfg.Azure != nil { + if i.cfg.Azure.Cloud != "" { + variables = append(variables, "AZURE_CLOUD="+i.cfg.Azure.Cloud) + } + if i.cfg.Azure.ManagedIdentityClientId != "" { + variables = append(variables, "AZURE_MANAGED_IDENTITY_CLIENT_ID="+i.cfg.Azure.ManagedIdentityClientId) + } + if i.cfg.Azure.ManagedIdentityEnabled { + variables = append(variables, "AZURE_MANAGED_IDENTITY_ENABLED=true") + } } return variables diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go index fe61b232729..3a74f718648 100644 --- a/pkg/plugins/manager/manager_integration_test.go +++ b/pkg/plugins/manager/manager_integration_test.go @@ -80,7 +80,7 @@ func TestPluginManager_int_init(t *testing.T) { idb := influxdb.ProvideService(hcp) lk := loki.ProvideService(hcp, tracer) otsdb := opentsdb.ProvideService(hcp) - pr := prometheus.ProvideService(hcp, tracer) + pr := prometheus.ProvideService(hcp, cfg, features, tracer) tmpo := tempo.ProvideService(hcp) td := testdatasource.ProvideService(cfg, features) pg := postgres.ProvideService(cfg) diff --git a/pkg/plugins/manager/manager_test.go b/pkg/plugins/manager/manager_test.go index 5f47109b2c0..b18609f6da9 100644 --- a/pkg/plugins/manager/manager_test.go +++ b/pkg/plugins/manager/manager_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/infra/log" @@ -574,9 +575,11 @@ func newScenario(t *testing.T, managed bool, fn func(t *testing.T, ctx *managerS cfg.AWSAllowedAuthProviders = []string{"keys", "credentials"} cfg.AWSAssumeRoleEnabled = true - cfg.Azure.ManagedIdentityEnabled = true - cfg.Azure.Cloud = "AzureCloud" - cfg.Azure.ManagedIdentityClientId = "client-id" + cfg.Azure = &azsettings.AzureSettings{ + ManagedIdentityEnabled: true, + Cloud: "AzureCloud", + ManagedIdentityClientId: "client-id", + } loader := &fakeLoader{} manager := New(cfg, nil, loader) diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 676cdcb4c75..d3df60664a5 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -320,11 +320,10 @@ const ( ScopeTeamsAll = "teams:*" // Annotations related actions - ActionAnnotationsCreate = "annotations:create" - ActionAnnotationsDelete = "annotations:delete" - ActionAnnotationsRead = "annotations:read" - ActionAnnotationsWrite = "annotations:write" - ActionAnnotationsTagsRead = "annotations.tags:read" + ActionAnnotationsCreate = "annotations:create" + ActionAnnotationsDelete = "annotations:delete" + ActionAnnotationsRead = "annotations:read" + ActionAnnotationsWrite = "annotations:write" // Dashboard actions ActionDashboardsCreate = "dashboards:create" @@ -382,9 +381,6 @@ var ( ScopeAnnotationsID = Scope(ScopeAnnotationsRoot, "id", Parameter(":annotationId")) ScopeAnnotationsTypeDashboard = ScopeAnnotationsProvider.GetResourceScopeType("dashboard") ScopeAnnotationsTypeOrganization = ScopeAnnotationsProvider.GetResourceScopeType("organization") - - // Annotation tag scopes - ScopeAnnotationsTagsAll = "annotations:tags:*" ) const RoleGrafanaAdmin = "Grafana Admin" diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index 3cf9c391810..0a62173b64a 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -137,7 +137,7 @@ func (h *ContextHandler) Middleware(mContext *web.Context) { case h.initContextWithAnonymousUser(reqContext): } - reqContext.Logger = log.New("context", "userId", reqContext.UserId, "orgId", reqContext.OrgId, "uname", reqContext.Login) + reqContext.Logger = reqContext.Logger.New("userId", reqContext.UserId, "orgId", reqContext.OrgId, "uname", reqContext.Login) span.AddEvents( []string{"uname", "orgId", "userId"}, []tracing.EventValue{ diff --git a/pkg/services/datasources/fake_cache_service.go b/pkg/services/datasources/fake_cache_service.go new file mode 100644 index 00000000000..e4c2ed9193b --- /dev/null +++ b/pkg/services/datasources/fake_cache_service.go @@ -0,0 +1,31 @@ +package datasources + +import ( + "context" + + "github.com/grafana/grafana/pkg/models" +) + +type FakeCacheService struct { + DataSources []*models.DataSource +} + +var _ CacheService = &FakeCacheService{} + +func (c *FakeCacheService) GetDatasource(ctx context.Context, datasourceID int64, user *models.SignedInUser, skipCache bool) (*models.DataSource, error) { + for _, datasource := range c.DataSources { + if datasource.Id == datasourceID { + return datasource, nil + } + } + return nil, models.ErrDataSourceNotFound +} + +func (c *FakeCacheService) GetDatasourceByUID(ctx context.Context, datasourceUID string, user *models.SignedInUser, skipCache bool) (*models.DataSource, error) { + for _, datasource := range c.DataSources { + if datasource.Uid == datasourceUID { + return datasource, nil + } + } + return nil, models.ErrDataSourceNotFound +} diff --git a/pkg/services/datasources/service/datasource_service.go b/pkg/services/datasources/service/datasource_service.go index 683847f59ee..2a4566d38f5 100644 --- a/pkg/services/datasources/service/datasource_service.go +++ b/pkg/services/datasources/service/datasource_service.go @@ -6,11 +6,15 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" + "path" "strconv" "strings" "sync" "time" + "github.com/grafana/grafana-azure-sdk-go/azcredentials" + "github.com/grafana/grafana-azure-sdk-go/azhttpclient" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana/pkg/bus" @@ -23,13 +27,13 @@ import ( "github.com/grafana/grafana/pkg/services/secrets/kvstore" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" ) type Service struct { Bus bus.Bus SQLStore *sqlstore.SQLStore SecretsStore kvstore.SecretsKVStore + cfg *setting.Cfg features featuremgmt.FeatureToggles permissionsService accesscontrol.PermissionsService @@ -47,7 +51,7 @@ type cachedRoundTripper struct { } func ProvideService( - bus bus.Bus, store *sqlstore.SQLStore, secretsStore kvstore.SecretsKVStore, features featuremgmt.FeatureToggles, + bus bus.Bus, store *sqlstore.SQLStore, secretsStore kvstore.SecretsKVStore, cfg *setting.Cfg, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, permissionsServices accesscontrol.PermissionsServices, ) *Service { s := &Service{ @@ -57,6 +61,7 @@ func ProvideService( ptc: proxyTransportCache{ cache: make(map[int64]cachedRoundTripper), }, + cfg: cfg, features: features, permissionsService: permissionsServices.GetDataSourceService(), } @@ -236,7 +241,7 @@ func (s *Service) GetHTTPTransport(ctx context.Context, ds *models.DataSource, p return nil, err } - opts.Middlewares = customMiddlewares + opts.Middlewares = append(opts.Middlewares, customMiddlewares...) rt, err := provider.GetTransport(*opts) if err != nil { @@ -359,7 +364,7 @@ func (s *Service) httpClientOptions(ctx context.Context, ds *models.DataSource) } } - if ds.JsonData != nil { + if ds.JsonData != nil && s.features.IsEnabled(featuremgmt.FlagHttpclientproviderAzureAuth) { credentials, err := azcredentials.FromDatasourceData(ds.JsonData.MustMap(), decryptedValues) if err != nil { err = fmt.Errorf("invalid Azure credentials: %s", err) @@ -367,7 +372,22 @@ func (s *Service) httpClientOptions(ctx context.Context, ds *models.DataSource) } if credentials != nil { - opts.CustomOptions["_azureCredentials"] = credentials + resourceIdStr := ds.JsonData.Get("azureEndpointResourceId").MustString() + if resourceIdStr == "" { + err := fmt.Errorf("endpoint resource ID (audience) not provided") + return nil, err + } + + resourceId, err := url.Parse(resourceIdStr) + if err != nil || resourceId.Scheme == "" || resourceId.Host == "" { + err := fmt.Errorf("endpoint resource ID (audience) '%s' invalid", resourceIdStr) + return nil, err + } + + resourceId.Path = path.Join(resourceId.Path, ".default") + scopes := []string{resourceId.String()} + + azhttpclient.AddAzureAuthentication(opts, s.cfg.Azure, credentials, scopes) } } diff --git a/pkg/services/datasources/service/datasource_service_test.go b/pkg/services/datasources/service/datasource_service_test.go index 6713a26c09d..34f9a72d0b7 100644 --- a/pkg/services/datasources/service/datasource_service_test.go +++ b/pkg/services/datasources/service/datasource_service_test.go @@ -8,7 +8,9 @@ import ( "testing" "time" + "github.com/grafana/grafana-azure-sdk-go/azsettings" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/httpclient" @@ -16,9 +18,9 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/secrets/kvstore" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -174,6 +176,8 @@ func TestService_IDScopeResolver(t *testing.T) { //nolint:goconst func TestService_GetHttpTransport(t *testing.T) { + cfg := &setting.Cfg{} + t.Run("Should use cached proxy", func(t *testing.T) { var configuredTransport *http.Transport provider := httpclient.NewProvider(sdkhttpclient.ProviderOptions{ @@ -189,7 +193,7 @@ func TestService_GetHttpTransport(t *testing.T) { } secretsStore := kvstore.SetupTestService(t) - dsService := ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) rt1, err := dsService.GetHTTPTransport(&ds, provider) require.NoError(t, err) @@ -222,7 +226,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("tlsAuthWithCACert", true) secretsStore := kvstore.SetupTestService(t) - dsService := ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Id: 1, @@ -269,7 +273,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("tlsAuth", true) secretsStore := kvstore.SetupTestService(t) - dsService := ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Id: 1, @@ -306,7 +310,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("serverName", "server-name") secretsStore := kvstore.SetupTestService(t) - dsService := ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Id: 1, @@ -340,7 +344,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("tlsSkipVerify", true) secretsStore := kvstore.SetupTestService(t) - dsService := ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Id: 1, @@ -371,7 +375,7 @@ func TestService_GetHttpTransport(t *testing.T) { }) secretsStore := kvstore.SetupTestService(t) - dsService := ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Id: 1, @@ -427,7 +431,7 @@ func TestService_GetHttpTransport(t *testing.T) { }) secretsStore := kvstore.SetupTestService(t) - dsService := ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Id: 1, @@ -460,7 +464,7 @@ func TestService_GetHttpTransport(t *testing.T) { require.NoError(t, err) secretsStore := kvstore.SetupTestService(t) - dsService := ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Type: models.DS_ES, @@ -476,6 +480,7 @@ func TestService_GetHttpTransport(t *testing.T) { } func TestService_getTimeout(t *testing.T) { + cfg := &setting.Cfg{} originalTimeout := sdkhttpclient.DefaultTimeoutOptions.Timeout sdkhttpclient.DefaultTimeoutOptions.Timeout = 60 * time.Second t.Cleanup(func() { @@ -494,7 +499,7 @@ func TestService_getTimeout(t *testing.T) { } secretsStore := kvstore.SetupTestService(t) - dsService := ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) for _, tc := range testCases { ds := &models.DataSource{ @@ -505,6 +510,10 @@ func TestService_getTimeout(t *testing.T) { } func TestService_HTTPClientOptions(t *testing.T) { + cfg := &setting.Cfg{ + Azure: &azsettings.AzureSettings{}, + } + emptyJsonData := simplejson.New() emptySecureJsonData := map[string][]byte{} @@ -515,70 +524,144 @@ func TestService_HTTPClientOptions(t *testing.T) { } t.Run("Azure authentication", func(t *testing.T) { - t.Run("should be disabled if no Azure credentials configured", func(t *testing.T) { - t.Cleanup(func() { ds.JsonData = emptyJsonData; ds.SecureJsonData = emptySecureJsonData }) + t.Run("given feature flag enabled", func(t *testing.T) { + features := featuremgmt.WithFeatures(featuremgmt.FlagHttpclientproviderAzureAuth) - secretsStore := kvstore.SetupTestService(t) - dsService := ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + t.Run("should set Azure middleware when JsonData contains valid credentials", func(t *testing.T) { + t.Cleanup(func() { ds.JsonData = emptyJsonData; ds.SecureJsonData = emptySecureJsonData }) - opts, err := dsService.httpClientOptions(&ds) - require.NoError(t, err) + ds.JsonData = simplejson.NewFromAny(map[string]interface{}{ + "httpMethod": "POST", + "azureCredentials": map[string]interface{}{ + "authType": "msi", + }, + "azureEndpointResourceId": "https://api.example.com/abd5c4ce-ca73-41e9-9cb2-bed39aa2adb5", + }) - assert.NotContains(t, opts.CustomOptions, "_azureCredentials") - }) + secretsStore := kvstore.SetupTestService(t) + dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) - t.Run("should be enabled if Azure credentials configured", func(t *testing.T) { - t.Cleanup(func() { ds.JsonData = emptyJsonData; ds.SecureJsonData = emptySecureJsonData }) + opts, err := dsService.httpClientOptions(&ds) + require.NoError(t, err) - ds.JsonData = simplejson.NewFromAny(map[string]interface{}{ - "azureCredentials": map[string]interface{}{ - "authType": "msi", - }, + require.NotNil(t, opts.Middlewares) + assert.Len(t, opts.Middlewares, 1) }) - secretsStore := kvstore.SetupTestService(t) - dsService := ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + t.Run("should not set Azure middleware when JsonData doesn't contain valid credentials", func(t *testing.T) { + t.Cleanup(func() { ds.JsonData = emptyJsonData; ds.SecureJsonData = emptySecureJsonData }) - opts, err := dsService.httpClientOptions(&ds) - require.NoError(t, err) + ds.JsonData = simplejson.NewFromAny(map[string]interface{}{ + "httpMethod": "POST", + }) - require.Contains(t, opts.CustomOptions, "_azureCredentials") - credentials := opts.CustomOptions["_azureCredentials"] + secretsStore := kvstore.SetupTestService(t) + dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) - assert.IsType(t, &azcredentials.AzureManagedIdentityCredentials{}, credentials) - }) + opts, err := dsService.httpClientOptions(&ds) + require.NoError(t, err) - t.Run("should fail if credentials are invalid", func(t *testing.T) { - t.Cleanup(func() { ds.JsonData = emptyJsonData; ds.SecureJsonData = emptySecureJsonData }) - - ds.JsonData = simplejson.NewFromAny(map[string]interface{}{ - "azureCredentials": "invalid", + if opts.Middlewares != nil { + assert.Len(t, opts.Middlewares, 0) + } }) - secretsStore := kvstore.SetupTestService(t) - dsService := ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + t.Run("should return error when JsonData contains invalid credentials", func(t *testing.T) { + t.Cleanup(func() { ds.JsonData = emptyJsonData; ds.SecureJsonData = emptySecureJsonData }) - _, err := dsService.httpClientOptions(&ds) - assert.Error(t, err) - }) + ds.JsonData = simplejson.NewFromAny(map[string]interface{}{ + "httpMethod": "POST", + "azureCredentials": "invalid", + }) - t.Run("should pass resourceId from JsonData", func(t *testing.T) { - t.Cleanup(func() { ds.JsonData = emptyJsonData; ds.SecureJsonData = emptySecureJsonData }) + secretsStore := kvstore.SetupTestService(t) + dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) - ds.JsonData = simplejson.NewFromAny(map[string]interface{}{ - "azureEndpointResourceId": "https://api.example.com/abd5c4ce-ca73-41e9-9cb2-bed39aa2adb5", + _, err := dsService.httpClientOptions(&ds) + assert.Error(t, err) }) - secretsStore := kvstore.SetupTestService(t) - dsService := ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + t.Run("should set Azure middleware when JsonData contains credentials and valid audience", func(t *testing.T) { + t.Cleanup(func() { ds.JsonData = emptyJsonData; ds.SecureJsonData = emptySecureJsonData }) - opts, err := dsService.httpClientOptions(&ds) - require.NoError(t, err) + ds.JsonData = simplejson.NewFromAny(map[string]interface{}{ + "httpMethod": "POST", + "azureCredentials": map[string]interface{}{ + "authType": "msi", + }, + "azureEndpointResourceId": "https://api.example.com/abd5c4ce-ca73-41e9-9cb2-bed39aa2adb5", + }) - require.Contains(t, opts.CustomOptions, "azureEndpointResourceId") - azureEndpointResourceId := opts.CustomOptions["azureEndpointResourceId"] + secretsStore := kvstore.SetupTestService(t) + dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) - assert.Equal(t, "https://api.example.com/abd5c4ce-ca73-41e9-9cb2-bed39aa2adb5", azureEndpointResourceId) + opts, err := dsService.httpClientOptions(ctx, &ds) + require.NoError(t, err) + + require.NotNil(t, opts.Middlewares) + assert.Len(t, opts.Middlewares, 1) + }) + + t.Run("should not set Azure middleware when JsonData doesn't contain credentials", func(t *testing.T) { + t.Cleanup(func() { ds.JsonData = emptyJsonData; ds.SecureJsonData = emptySecureJsonData }) + + ds.JsonData = simplejson.NewFromAny(map[string]interface{}{ + "httpMethod": "POST", + "azureEndpointResourceId": "https://api.example.com/abd5c4ce-ca73-41e9-9cb2-bed39aa2adb5", + }) + + secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) + dsService := ProvideService(bus.New(), nil, secretsService, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock()) + + opts, err := dsService.httpClientOptions(&ds) + require.NoError(t, err) + + if opts.Middlewares != nil { + assert.Len(t, opts.Middlewares, 0) + } + }) + + t.Run("should return error when JsonData contains invalid audience", func(t *testing.T) { + t.Cleanup(func() { ds.JsonData = emptyJsonData; ds.SecureJsonData = emptySecureJsonData }) + + ds.JsonData = simplejson.NewFromAny(map[string]interface{}{ + "httpMethod": "POST", + "azureCredentials": map[string]interface{}{ + "authType": "msi", + }, + "azureEndpointResourceId": "invalid", + }) + + secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) + dsService := ProvideService(bus.New(), nil, secretsService, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock()) + + _, err := dsService.httpClientOptions(&ds) + assert.Error(t, err) + }) + }) + + t.Run("given feature flag not enabled", func(t *testing.T) { + t.Run("should not set Azure middleware even when JsonData contains credentials", func(t *testing.T) { + t.Cleanup(func() { ds.JsonData = emptyJsonData; ds.SecureJsonData = emptySecureJsonData }) + + ds.JsonData = simplejson.NewFromAny(map[string]interface{}{ + "httpMethod": "POST", + "azureCredentials": map[string]interface{}{ + "authType": "msi", + }, + "azureEndpointResourceId": "https://api.example.com/abd5c4ce-ca73-41e9-9cb2-bed39aa2adb5", + }) + + secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) + dsService := ProvideService(bus.New(), nil, secretsService, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + + opts, err := dsService.httpClientOptions(&ds) + require.NoError(t, err) + + if opts.Middlewares != nil { + assert.Len(t, opts.Middlewares, 0) + } + }) }) }) } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d9bebce3bfd..a55232ecbf3 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1,3 +1,8 @@ +// To change feature flags, edit: +// pkg/services/featuremgmt/registry.go +// Then run tests in: +// pkg/services/featuremgmt/toggles_gen_test.go + package featuremgmt var ( @@ -15,7 +20,7 @@ var ( }, { Name: "httpclientprovider_azure_auth", - Description: "use http client for azure auth", + Description: "Experimental. Allow datasources to configure Azure authentication directly via JsonData", State: FeatureStateBeta, }, { @@ -99,9 +104,15 @@ var ( Description: "Support robust access control", State: FeatureStateBeta, }, + { + Name: "accesscontrol-builtins", + Description: "Simplify access control builtin roles", + State: FeatureStateAlpha, + RequiresDevMode: true, + }, { Name: "prometheus_azure_auth", - Description: "Use azure authentication for prometheus datasource", + Description: "Experimental. Azure authentication for Prometheus datasource", State: FeatureStateBeta, }, { @@ -162,11 +173,21 @@ var ( Description: "Lock database during migrations", State: FeatureStateBeta, }, + { + Name: "saveDashboardDrawer", + Description: "Use a drawer to show save dashboard dialog", + State: FeatureStateBeta, + }, { Name: "storage", Description: "Configurable storage for dashboards, datasources, and resources", State: FeatureStateAlpha, }, + { + Name: "alertProvisioning", + Description: "Provisioning-friendly routes for alerting", + State: FeatureStateAlpha, + }, { Name: "storageLocalUpload", Description: "allow uploads to local storage", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 4cbd538088c..06c470a7a72 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -1,4 +1,8 @@ -// NOTE: This file is autogenerated +// NOTE: This file was auto generated. DO NOT EDIT DIRECTLY! +// To change feature flags, edit: +// pkg/services/featuremgmt/registry.go +// Then run tests in: +// pkg/services/featuremgmt/toggles_gen_test.go package featuremgmt @@ -12,7 +16,7 @@ const ( FlagEnvelopeEncryption = "envelopeEncryption" // FlagHttpclientproviderAzureAuth - // use http client for azure auth + // Experimental. Allow datasources to configure Azure authentication directly via JsonData FlagHttpclientproviderAzureAuth = "httpclientprovider_azure_auth" // FlagServiceAccounts @@ -75,8 +79,12 @@ const ( // Support robust access control FlagAccesscontrol = "accesscontrol" + // FlagAccesscontrolBuiltins + // Simplify access control builtin roles + FlagAccesscontrolBuiltins = "accesscontrol-builtins" + // FlagPrometheusAzureAuth - // Use azure authentication for prometheus datasource + // Experimental. Azure authentication for Prometheus datasource FlagPrometheusAzureAuth = "prometheus_azure_auth" // FlagInfluxdbBackendMigration @@ -123,10 +131,18 @@ const ( // Lock database during migrations FlagMigrationLocking = "migrationLocking" + // FlagSaveDashboardDrawer + // Use a drawer to show save dashboard dialog + FlagSaveDashboardDrawer = "saveDashboardDrawer" + // FlagStorage // Configurable storage for dashboards, datasources, and resources FlagStorage = "storage" + // FlagAlertProvisioning + // Provisioning-friendly routes for alerting + FlagAlertProvisioning = "alertProvisioning" + // FlagStorageLocalUpload // allow uploads to local storage FlagStorageLocalUpload = "storageLocalUpload" diff --git a/pkg/services/featuremgmt/toggles_gen_test.go b/pkg/services/featuremgmt/toggles_gen_test.go index 28e371617d6..27c3f05a753 100644 --- a/pkg/services/featuremgmt/toggles_gen_test.go +++ b/pkg/services/featuremgmt/toggles_gen_test.go @@ -117,7 +117,11 @@ func generateRegistry(t *testing.T) string { var buff bytes.Buffer - buff.WriteString(`// NOTE: This file is autogenerated + buff.WriteString(`// NOTE: This file was auto generated. DO NOT EDIT DIRECTLY! +// To change feature flags, edit: +// pkg/services/featuremgmt/registry.go +// Then run tests in: +// pkg/services/featuremgmt/toggles_gen_test.go package featuremgmt diff --git a/pkg/services/ngalert/api/Makefile b/pkg/services/ngalert/api/Makefile deleted file mode 100644 index 68828e51b6c..00000000000 --- a/pkg/services/ngalert/api/Makefile +++ /dev/null @@ -1,28 +0,0 @@ -.DEFAULT_GOAL := all - -GENERATED_GO_MATCHERS = ./go/*.go - -swagger-codegen-api: - docker run --rm -v $$(pwd):/local --user $$(id -u):$$(id -g) swaggerapi/swagger-codegen-cli generate \ - -i /local/tooling/post.json \ - -l go-server \ - -Dapis \ - -o /local \ - --additional-properties packageName=api \ - -t /local/tooling/swagger-codegen/templates \ - # --import-mappings eval.RelativeTimeRange="github.com/grafana/grafana/pkg/services/ngalert/eval" \ - # --type-mappings RelativeTimeRange=eval.RelativeTimeRange - -copy-files: - ls -1 go | xargs -n 1 -I {} mv go/{} generated_base_{} - -fix: - sed -i -e 's/apimodels\.\[\]PostableAlert/apimodels.PostableAlerts/' $(GENERATED_GO_MATCHERS) - sed -i -e 's/apimodels\.\[\]UpdateDashboardAclCommand/apimodels.Permissions/' $(GENERATED_GO_MATCHERS) - sed -i -e 's/apimodels\.\[\]PostableApiReceiver/apimodels.TestReceiversConfigParams/' $(GENERATED_GO_MATCHERS) - goimports -w -v $(GENERATED_GO_MATCHERS) - -clean: - rm -rf ./go - -all: swagger-codegen-api fix copy-files clean diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index 464cab974d8..234b45f75d8 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/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" @@ -86,7 +87,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { api.RegisterAlertmanagerApiEndpoints(NewForkedAM( api.DatasourceCache, NewLotexAM(proxy, logger), - &AlertmanagerSrv{store: api.AlertingStore, mam: api.MultiOrgAlertmanager, secrets: api.SecretsService, log: logger}, + &AlertmanagerSrv{store: api.AlertingStore, mam: api.MultiOrgAlertmanager, secrets: api.SecretsService, log: logger, ac: api.AccessControl}, ), m) // Register endpoints for proxying to Prometheus-compatible backends. api.RegisterPrometheusApiEndpoints(NewForkedProm( @@ -112,11 +113,11 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { api.RegisterTestingApiEndpoints(NewForkedTestingApi( &TestingApiSrv{ AlertingProxy: proxy, - Cfg: api.Cfg, ExpressionService: api.ExpressionService, DatasourceCache: api.DatasourceCache, - secretsService: api.SecretsService, log: logger, + accessControl: api.AccessControl, + evaluator: eval.NewEvaluator(api.Cfg, log.New("ngalert.eval"), api.DatasourceCache, api.SecretsService), }), m) api.RegisterConfigurationApiEndpoints(NewForkedConfiguration( &AdminSrv{ diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index 7089671d96d..047a637c32c 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" @@ -32,6 +33,7 @@ type AlertmanagerSrv struct { secrets secrets.Service store AlertingStore log log.Logger + ac accesscontrol.AccessControl } type UnknownReceiverError struct { @@ -130,6 +132,18 @@ func (srv AlertmanagerSrv) RouteCreateSilence(c *models.ReqContext, postableSile return errResp } + action := accesscontrol.ActionAlertingInstanceUpdate + if postableSilence.ID == "" { + action = accesscontrol.ActionAlertingInstanceCreate + } + if !accesscontrol.HasAccess(srv.ac, c)(accesscontrol.ReqOrgAdminOrEditor, accesscontrol.EvalPermission(action)) { + errAction := "update" + if postableSilence.ID == "" { + errAction = "create" + } + return ErrResp(http.StatusUnauthorized, fmt.Errorf("user is not authorized to %s silences", errAction), "") + } + silenceID, err := am.CreateSilence(&postableSilence) if err != nil { if errors.Is(err, notifier.ErrSilenceNotFound) { diff --git a/pkg/services/ngalert/api/api_alertmanager_test.go b/pkg/services/ngalert/api/api_alertmanager_test.go index b9f1470906c..13943adc9ed 100644 --- a/pkg/services/ngalert/api/api_alertmanager_test.go +++ b/pkg/services/ngalert/api/api_alertmanager_test.go @@ -2,15 +2,20 @@ package api import ( "context" + "math/rand" "net/http" "testing" "time" + "github.com/go-openapi/strfmt" + amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" + acMock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -18,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -151,7 +157,7 @@ func TestStatusForTestReceivers(t *testing.T) { } func TestAlertmanagerConfig(t *testing.T) { - sut := createSut(t) + sut := createSut(t, nil) t.Run("assert 404 Not Found when applying config to nonexistent org", func(t *testing.T) { rc := models.ReqContext{ @@ -187,7 +193,7 @@ func TestAlertmanagerConfig(t *testing.T) { }) t.Run("assert 202 when alertmanager to configure is not ready", func(t *testing.T) { - sut := createSut(t) + sut := createSut(t, nil) rc := models.ReqContext{ Context: &web.Context{ Req: &http.Request{}, @@ -204,7 +210,139 @@ func TestAlertmanagerConfig(t *testing.T) { }) } -func createSut(t *testing.T) AlertmanagerSrv { +func TestRouteCreateSilence(t *testing.T) { + tesCases := []struct { + name string + silence func() apimodels.PostableSilence + accessControl func() accesscontrol.AccessControl + role models.RoleType + expectedStatus int + }{ + { + name: "new silence, fine-grained access control is enabled, not authorized", + silence: silenceGen(withEmptyID), + accessControl: func() accesscontrol.AccessControl { + return acMock.New() + }, + expectedStatus: http.StatusUnauthorized, + }, + { + name: "new silence, fine-grained access control is enabled, authorized", + silence: silenceGen(withEmptyID), + accessControl: func() accesscontrol.AccessControl { + return acMock.New().WithPermissions([]*accesscontrol.Permission{ + {Action: accesscontrol.ActionAlertingInstanceCreate}, + }) + }, + expectedStatus: http.StatusAccepted, + }, + { + name: "new silence, fine-grained access control is disabled, Viewer", + silence: silenceGen(withEmptyID), + accessControl: func() accesscontrol.AccessControl { + return acMock.New().WithDisabled() + }, + role: models.ROLE_VIEWER, + expectedStatus: http.StatusUnauthorized, + }, + { + name: "new silence, fine-grained access control is disabled, Editor", + silence: silenceGen(withEmptyID), + accessControl: func() accesscontrol.AccessControl { + return acMock.New().WithDisabled() + }, + role: models.ROLE_EDITOR, + expectedStatus: http.StatusAccepted, + }, + { + name: "new silence, fine-grained access control is disabled, Admin", + silence: silenceGen(withEmptyID), + accessControl: func() accesscontrol.AccessControl { + return acMock.New().WithDisabled() + }, + role: models.ROLE_ADMIN, + expectedStatus: http.StatusAccepted, + }, + { + name: "update silence, fine-grained access control is enabled, not authorized", + silence: silenceGen(), + accessControl: func() accesscontrol.AccessControl { + return acMock.New() + }, + expectedStatus: http.StatusUnauthorized, + }, + { + name: "update silence, fine-grained access control is enabled, authorized", + silence: silenceGen(), + accessControl: func() accesscontrol.AccessControl { + return acMock.New().WithPermissions([]*accesscontrol.Permission{ + {Action: accesscontrol.ActionAlertingInstanceUpdate}, + }) + }, + expectedStatus: http.StatusAccepted, + }, + { + name: "update silence, fine-grained access control is disabled, Viewer", + silence: silenceGen(), + accessControl: func() accesscontrol.AccessControl { + return acMock.New().WithDisabled() + }, + role: models.ROLE_VIEWER, + expectedStatus: http.StatusUnauthorized, + }, + { + name: "update silence, fine-grained access control is disabled, Editor", + silence: silenceGen(), + accessControl: func() accesscontrol.AccessControl { + return acMock.New().WithDisabled() + }, + role: models.ROLE_EDITOR, + expectedStatus: http.StatusAccepted, + }, + { + name: "update silence, fine-grained access control is disabled, Admin", + silence: silenceGen(), + accessControl: func() accesscontrol.AccessControl { + return acMock.New().WithDisabled() + }, + role: models.ROLE_ADMIN, + expectedStatus: http.StatusAccepted, + }, + } + + for _, tesCase := range tesCases { + t.Run(tesCase.name, func(t *testing.T) { + ac := tesCase.accessControl() + sut := createSut(t, ac) + + rc := models.ReqContext{ + Context: &web.Context{ + Req: &http.Request{}, + }, + SignedInUser: &models.SignedInUser{ + OrgRole: tesCase.role, + OrgId: 1, + }, + } + + silence := tesCase.silence() + + if silence.ID != "" { + alertmanagerFor, err := sut.mam.AlertmanagerFor(1) + require.NoError(t, err) + silence.ID = "" + newID, err := alertmanagerFor.CreateSilence(&silence) + require.NoError(t, err) + silence.ID = newID + } + + response := sut.RouteCreateSilence(&rc, silence) + require.Equal(t, tesCase.expectedStatus, response.Status()) + }) + } +} + +func createSut(t *testing.T, accessControl accesscontrol.AccessControl) AlertmanagerSrv { t.Helper() mam := createMultiOrgAlertmanager(t) @@ -213,7 +351,10 @@ func createSut(t *testing.T) AlertmanagerSrv { store.Setup(2) store.Setup(3) secrets := fakes.NewFakeSecretsService() - return AlertmanagerSrv{mam: mam, store: store, secrets: secrets} + if accessControl == nil { + accessControl = acMock.New().WithDisabled() + } + return AlertmanagerSrv{mam: mam, store: store, secrets: secrets, ac: accessControl} } func createAmConfigRequest(t *testing.T) apimodels.PostableUserConfig { @@ -277,3 +418,41 @@ var brokenConfig = ` }] } }` + +func silenceGen(mutatorFuncs ...func(*apimodels.PostableSilence)) func() apimodels.PostableSilence { + return func() apimodels.PostableSilence { + testString := util.GenerateShortUID() + isEqual := rand.Int()%2 == 0 + isRegex := rand.Int()%2 == 0 + value := util.GenerateShortUID() + if isRegex { + value = ".*" + util.GenerateShortUID() + } + + matchers := amv2.Matchers{&amv2.Matcher{Name: &testString, IsEqual: &isEqual, IsRegex: &isRegex, Value: &value}} + comment := util.GenerateShortUID() + starts := strfmt.DateTime(timeNow().Add(-time.Duration(rand.Int63n(9)+1) * time.Second)) + ends := strfmt.DateTime(timeNow().Add(time.Duration(rand.Int63n(9)+1) * time.Second)) + createdBy := "User-" + util.GenerateShortUID() + s := apimodels.PostableSilence{ + ID: util.GenerateShortUID(), + Silence: amv2.Silence{ + Comment: &comment, + CreatedBy: &createdBy, + EndsAt: &ends, + Matchers: matchers, + StartsAt: &starts, + }, + } + + for _, mutator := range mutatorFuncs { + mutator(&s) + } + + return s + } +} + +func withEmptyID(silence *apimodels.PostableSilence) { + silence.ID = "" +} diff --git a/pkg/services/ngalert/api/api_prometheus.go b/pkg/services/ngalert/api/api_prometheus.go index f00b1ec0124..3dfbc483316 100644 --- a/pkg/services/ngalert/api/api_prometheus.go +++ b/pkg/services/ngalert/api/api_prometheus.go @@ -85,7 +85,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res labelOptions = append(labelOptions, ngmodels.WithoutInternalLabels()) } - namespaceMap, err := srv.store.GetNamespaces(c.Req.Context(), c.OrgId, c.SignedInUser) + namespaceMap, err := srv.store.GetUserVisibleNamespaces(c.Req.Context(), c.OrgId, c.SignedInUser) if err != nil { return ErrResp(http.StatusInternalServerError, err, "failed to get namespaces visible to the user") } diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 74b9d2ade0a..7094c6aa956 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -196,7 +196,7 @@ func (srv RulerSrv) RouteGetRulegGroupConfig(c *models.ReqContext) response.Resp } func (srv RulerSrv) RouteGetRulesConfig(c *models.ReqContext) response.Response { - namespaceMap, err := srv.store.GetNamespaces(c.Req.Context(), c.OrgId, c.SignedInUser) + namespaceMap, err := srv.store.GetUserVisibleNamespaces(c.Req.Context(), c.OrgId, c.SignedInUser) if err != nil { return ErrResp(http.StatusInternalServerError, err, "failed to get namespaces visible to the user") } diff --git a/pkg/services/ngalert/api/api_testing.go b/pkg/services/ngalert/api/api_testing.go index 22a01417304..3df26ad261c 100644 --- a/pkg/services/ngalert/api/api_testing.go +++ b/pkg/services/ngalert/api/api_testing.go @@ -8,32 +8,65 @@ import ( "net/url" "strconv" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/datasources" 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/secrets" - "github.com/grafana/grafana/pkg/setting" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) type TestingApiSrv struct { *AlertingProxy - Cfg *setting.Cfg ExpressionService *expr.Service DatasourceCache datasources.CacheService log log.Logger - secretsService secrets.Service + accessControl accesscontrol.AccessControl + evaluator eval.Evaluator } func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *models.ReqContext, body apimodels.TestRulePayload) response.Response { if body.Type() != apimodels.GrafanaBackend || body.GrafanaManagedCondition == nil { return ErrResp(http.StatusBadRequest, errors.New("unexpected payload"), "") } - return conditionEval(c, *body.GrafanaManagedCondition, srv.DatasourceCache, srv.ExpressionService, srv.secretsService, srv.Cfg, srv.log) + + if !authorizeDatasourceAccessForRule(&ngmodels.AlertRule{Data: body.GrafanaManagedCondition.Data}, func(evaluator accesscontrol.Evaluator) bool { + return accesscontrol.HasAccess(srv.accessControl, c)(accesscontrol.ReqSignedIn, evaluator) + }) { + return ErrResp(http.StatusUnauthorized, fmt.Errorf("%w to query one or many data sources used by the rule", ErrAuthorization), "") + } + + evalCond := ngmodels.Condition{ + Condition: body.GrafanaManagedCondition.Condition, + OrgID: c.SignedInUser.OrgId, + Data: body.GrafanaManagedCondition.Data, + } + + if err := validateCondition(c.Req.Context(), evalCond, c.SignedInUser, c.SkipCache, srv.DatasourceCache); err != nil { + return ErrResp(http.StatusBadRequest, err, "invalid condition") + } + + now := body.GrafanaManagedCondition.Now + if now.IsZero() { + now = timeNow() + } + + evalResults, err := srv.evaluator.ConditionEval(&evalCond, now, srv.ExpressionService) + if err != nil { + return ErrResp(http.StatusBadRequest, err, "Failed to evaluate conditions") + } + + frame := evalResults.AsDataFrame() + return response.JSONStreaming(http.StatusOK, util.DynMap{ + "instances": []*data.Frame{&frame}, + }) } func (srv TestingApiSrv) RouteTestRuleConfig(c *models.ReqContext, body apimodels.TestRulePayload) response.Response { @@ -84,12 +117,17 @@ func (srv TestingApiSrv) RouteEvalQueries(c *models.ReqContext, cmd apimodels.Ev now = timeNow() } + if !authorizeDatasourceAccessForRule(&ngmodels.AlertRule{Data: cmd.Data}, func(evaluator accesscontrol.Evaluator) bool { + return accesscontrol.HasAccess(srv.accessControl, c)(accesscontrol.ReqSignedIn, evaluator) + }) { + return ErrResp(http.StatusUnauthorized, fmt.Errorf("%w to query one or many data sources used by the rule", ErrAuthorization), "") + } + if _, err := validateQueriesAndExpressions(c.Req.Context(), cmd.Data, c.SignedInUser, c.SkipCache, srv.DatasourceCache); err != nil { return ErrResp(http.StatusBadRequest, err, "invalid queries or expressions") } - evaluator := eval.NewEvaluator(srv.Cfg, srv.log, srv.DatasourceCache, srv.secretsService) - evalResults, err := evaluator.QueriesAndExpressionsEval(c.SignedInUser.OrgId, cmd.Data, now, srv.ExpressionService) + evalResults, err := srv.evaluator.QueriesAndExpressionsEval(c.SignedInUser.OrgId, cmd.Data, now, srv.ExpressionService) if err != nil { return ErrResp(http.StatusBadRequest, err, "Failed to evaluate queries and expressions") } diff --git a/pkg/services/ngalert/api/api_testing_test.go b/pkg/services/ngalert/api/api_testing_test.go new file mode 100644 index 00000000000..91b976b7364 --- /dev/null +++ b/pkg/services/ngalert/api/api_testing_test.go @@ -0,0 +1,278 @@ +package api + +import ( + "net/http" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + models2 "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" + acMock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/datasources" + "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/models" + "github.com/grafana/grafana/pkg/web" +) + +func TestRouteTestGrafanaRuleConfig(t *testing.T) { + t.Run("when fine-grained access is enabled", func(t *testing.T) { + rc := &models2.ReqContext{ + Context: &web.Context{ + Req: &http.Request{}, + }, + SignedInUser: &models2.SignedInUser{ + OrgId: 1, + }, + } + + t.Run("should return 401 if user cannot query a data source", func(t *testing.T) { + data1 := models.GenerateAlertQuery() + data2 := models.GenerateAlertQuery() + + ac := acMock.New().WithPermissions([]*accesscontrol.Permission{ + {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)}, + }) + + srv := createTestingApiSrv(nil, ac, nil) + + response := srv.RouteTestGrafanaRuleConfig(rc, definitions.TestRulePayload{ + Expr: "", + GrafanaManagedCondition: &models.EvalAlertConditionCommand{ + Condition: data1.RefID, + Data: []models.AlertQuery{data1, data2}, + Now: time.Time{}, + }, + }) + + require.Equal(t, http.StatusUnauthorized, response.Status()) + }) + + t.Run("should return 200 if user can query all data sources", func(t *testing.T) { + data1 := models.GenerateAlertQuery() + data2 := models.GenerateAlertQuery() + + ac := acMock.New().WithPermissions([]*accesscontrol.Permission{ + {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)}, + {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data2.DatasourceUID)}, + }) + + ds := &datasources.FakeCacheService{DataSources: []*models2.DataSource{ + {Uid: data1.DatasourceUID}, + {Uid: data2.DatasourceUID}, + }} + + evaluator := &eval.FakeEvaluator{} + var result []eval.Result + evaluator.EXPECT().ConditionEval(mock.Anything, mock.Anything, mock.Anything).Return(result, nil) + + srv := createTestingApiSrv(ds, ac, evaluator) + + response := srv.RouteTestGrafanaRuleConfig(rc, definitions.TestRulePayload{ + Expr: "", + GrafanaManagedCondition: &models.EvalAlertConditionCommand{ + Condition: data1.RefID, + Data: []models.AlertQuery{data1, data2}, + Now: time.Time{}, + }, + }) + + require.Equal(t, http.StatusOK, response.Status()) + + evaluator.AssertCalled(t, "ConditionEval", mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("when fine-grained access is disabled", func(t *testing.T) { + rc := &models2.ReqContext{ + Context: &web.Context{ + Req: &http.Request{}, + }, + IsSignedIn: false, + SignedInUser: &models2.SignedInUser{ + OrgId: 1, + }, + } + ac := acMock.New().WithDisabled() + + t.Run("should require user to be signed in", func(t *testing.T) { + data1 := models.GenerateAlertQuery() + + ds := &datasources.FakeCacheService{DataSources: []*models2.DataSource{ + {Uid: data1.DatasourceUID}, + }} + + evaluator := &eval.FakeEvaluator{} + var result []eval.Result + evaluator.EXPECT().ConditionEval(mock.Anything, mock.Anything, mock.Anything).Return(result, nil) + + srv := createTestingApiSrv(ds, ac, evaluator) + + response := srv.RouteTestGrafanaRuleConfig(rc, definitions.TestRulePayload{ + Expr: "", + GrafanaManagedCondition: &models.EvalAlertConditionCommand{ + Condition: data1.RefID, + Data: []models.AlertQuery{data1}, + Now: time.Time{}, + }, + }) + + require.Equal(t, http.StatusUnauthorized, response.Status()) + evaluator.AssertNotCalled(t, "ConditionEval", mock.Anything, mock.Anything, mock.Anything) + + rc.IsSignedIn = true + + response = srv.RouteTestGrafanaRuleConfig(rc, definitions.TestRulePayload{ + Expr: "", + GrafanaManagedCondition: &models.EvalAlertConditionCommand{ + Condition: data1.RefID, + Data: []models.AlertQuery{data1}, + Now: time.Time{}, + }, + }) + + require.Equal(t, http.StatusOK, response.Status()) + + evaluator.AssertCalled(t, "ConditionEval", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func TestRouteEvalQueries(t *testing.T) { + t.Run("when fine-grained access is enabled", func(t *testing.T) { + rc := &models2.ReqContext{ + Context: &web.Context{ + Req: &http.Request{}, + }, + SignedInUser: &models2.SignedInUser{ + OrgId: 1, + }, + } + + t.Run("should return 401 if user cannot query a data source", func(t *testing.T) { + data1 := models.GenerateAlertQuery() + data2 := models.GenerateAlertQuery() + + ac := acMock.New().WithPermissions([]*accesscontrol.Permission{ + {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)}, + }) + + srv := &TestingApiSrv{ + accessControl: ac, + } + + response := srv.RouteEvalQueries(rc, definitions.EvalQueriesPayload{ + Data: []models.AlertQuery{data1, data2}, + Now: time.Time{}, + }) + + require.Equal(t, http.StatusUnauthorized, response.Status()) + }) + + t.Run("should return 200 if user can query all data sources", func(t *testing.T) { + data1 := models.GenerateAlertQuery() + data2 := models.GenerateAlertQuery() + + ac := acMock.New().WithPermissions([]*accesscontrol.Permission{ + {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)}, + {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data2.DatasourceUID)}, + }) + + ds := &datasources.FakeCacheService{DataSources: []*models2.DataSource{ + {Uid: data1.DatasourceUID}, + {Uid: data2.DatasourceUID}, + }} + + evaluator := &eval.FakeEvaluator{} + result := &backend.QueryDataResponse{ + Responses: map[string]backend.DataResponse{ + "test": { + Frames: nil, + Error: nil, + }, + }, + } + evaluator.EXPECT().QueriesAndExpressionsEval(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(result, nil) + + srv := createTestingApiSrv(ds, ac, evaluator) + + response := srv.RouteEvalQueries(rc, definitions.EvalQueriesPayload{ + Data: []models.AlertQuery{data1, data2}, + Now: time.Time{}, + }) + + require.Equal(t, http.StatusOK, response.Status()) + + evaluator.AssertCalled(t, "QueriesAndExpressionsEval", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("when fine-grained access is disabled", func(t *testing.T) { + rc := &models2.ReqContext{ + Context: &web.Context{ + Req: &http.Request{}, + }, + IsSignedIn: false, + SignedInUser: &models2.SignedInUser{ + OrgId: 1, + }, + } + ac := acMock.New().WithDisabled() + + t.Run("should require user to be signed in", func(t *testing.T) { + data1 := models.GenerateAlertQuery() + + ds := &datasources.FakeCacheService{DataSources: []*models2.DataSource{ + {Uid: data1.DatasourceUID}, + }} + + evaluator := &eval.FakeEvaluator{} + result := &backend.QueryDataResponse{ + Responses: map[string]backend.DataResponse{ + "test": { + Frames: nil, + Error: nil, + }, + }, + } + evaluator.EXPECT().QueriesAndExpressionsEval(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(result, nil) + + srv := createTestingApiSrv(ds, ac, evaluator) + + response := srv.RouteEvalQueries(rc, definitions.EvalQueriesPayload{ + Data: []models.AlertQuery{data1}, + Now: time.Time{}, + }) + + require.Equal(t, http.StatusUnauthorized, response.Status()) + evaluator.AssertNotCalled(t, "QueriesAndExpressionsEval", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + + rc.IsSignedIn = true + + response = srv.RouteEvalQueries(rc, definitions.EvalQueriesPayload{ + Data: []models.AlertQuery{data1}, + Now: time.Time{}, + }) + + require.Equal(t, http.StatusOK, response.Status()) + + evaluator.AssertCalled(t, "QueriesAndExpressionsEval", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func createTestingApiSrv(ds *datasources.FakeCacheService, ac *acMock.Mock, evaluator *eval.FakeEvaluator) *TestingApiSrv { + if ac == nil { + ac = acMock.New().WithDisabled() + } + + return &TestingApiSrv{ + DatasourceCache: ds, + accessControl: ac, + evaluator: evaluator, + } +} diff --git a/pkg/services/ngalert/api/lotex_ruler.go b/pkg/services/ngalert/api/lotex_ruler.go index 9cee9799cbb..8c71f8dd962 100644 --- a/pkg/services/ngalert/api/lotex_ruler.go +++ b/pkg/services/ngalert/api/lotex_ruler.go @@ -16,9 +16,32 @@ import ( "github.com/grafana/grafana/pkg/models" ) +const ( + Prometheus = "prometheus" + Cortex = "cortex" + Mimir = "mimir" +) + +const ( + PrometheusDatasourceType = "prometheus" + LokiDatasourceType = "loki" + + mimirPrefix = "/config/v1/rules" + prometheusPrefix = "/rules" + lokiPrefix = "/api/prom/rules" + + subtypeQuery = "subtype" +) + var dsTypeToRulerPrefix = map[string]string{ - "prometheus": "/rules", - "loki": "/api/prom/rules", + PrometheusDatasourceType: prometheusPrefix, + LokiDatasourceType: lokiPrefix, +} + +var subtypeToPrefix = map[string]string{ + Prometheus: prometheusPrefix, + Cortex: prometheusPrefix, + Mimir: mimirPrefix, } type LotexRuler struct { @@ -171,7 +194,22 @@ func (r *LotexRuler) validateAndGetPrefix(ctx *models.ReqContext) (string, error if !ok { return "", fmt.Errorf("unexpected datasource type. expecting loki or prometheus") } - return prefix, nil + + // If the datasource is Loki, there's nothing else for us to do - it doesn't have subtypes. + if ds.Type == LokiDatasourceType { + return prefix, nil + } + + // A Prometheus datasource, can have many subtypes: Cortex, Mimir and vanilla Prometheus. + // Based on these subtypes, we want to use a different proxying path. + subtype := ctx.Query(subtypeQuery) + subTypePrefix, ok := subtypeToPrefix[subtype] + if !ok { + r.log.Debug("unable to determine prometheus datasource subtype, using default prefix", "subtype", subtype) + return prefix, nil + } + + return subTypePrefix, nil } func withPath(u url.URL, newPath string) *url.URL { diff --git a/pkg/services/ngalert/api/lotex_ruler_test.go b/pkg/services/ngalert/api/lotex_ruler_test.go new file mode 100644 index 00000000000..cc7d1d27c70 --- /dev/null +++ b/pkg/services/ngalert/api/lotex_ruler_test.go @@ -0,0 +1,130 @@ +package api + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/datasourceproxy" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/web" +) + +func TestLotexRuler_ValidateAndGetPrefix(t *testing.T) { + tc := []struct { + name string + namedParams map[string]string + urlParams string + datasourceCache datasources.CacheService + expected string + err error + }{ + { + name: "with an invalid recipient", + namedParams: map[string]string{":Recipient": "AAABBB"}, + err: errors.New("recipient is invalid"), + }, + { + name: "with an error while trying to fetch the datasource", + namedParams: map[string]string{":Recipient": "164"}, + datasourceCache: fakeCacheService{err: models.ErrDataSourceNotFound}, + err: errors.New("data source not found"), + }, + { + name: "with an empty datasource URL", + namedParams: map[string]string{":Recipient": "164"}, + datasourceCache: fakeCacheService{datasource: &models.DataSource{}}, + err: errors.New("URL for this data source is empty"), + }, + { + name: "with an unsupported datasource type", + namedParams: map[string]string{":Recipient": "164"}, + datasourceCache: fakeCacheService{datasource: &models.DataSource{Url: "http://loki.com"}}, + err: errors.New("unexpected datasource type. expecting loki or prometheus"), + }, + { + name: "with a Loki datasource", + namedParams: map[string]string{":Recipient": "164"}, + datasourceCache: fakeCacheService{datasource: &models.DataSource{Url: "http://loki.com", Type: LokiDatasourceType}}, + expected: "/api/prom/rules", + }, + { + name: "with a Prometheus datasource", + namedParams: map[string]string{":Recipient": "164"}, + datasourceCache: fakeCacheService{datasource: &models.DataSource{Url: "http://loki.com", Type: PrometheusDatasourceType}}, + expected: "/rules", + }, + { + name: "with a Prometheus datasource and subtype of Cortex", + namedParams: map[string]string{":Recipient": "164"}, + urlParams: "?subtype=cortex", + datasourceCache: fakeCacheService{datasource: &models.DataSource{Url: "http://loki.com", Type: PrometheusDatasourceType}}, + expected: "/rules", + }, + { + name: "with a Prometheus datasource and subtype of Mimir", + namedParams: map[string]string{":Recipient": "164"}, + urlParams: "?subtype=mimir", + datasourceCache: fakeCacheService{datasource: &models.DataSource{Url: "http://loki.com", Type: PrometheusDatasourceType}}, + expected: "/config/v1/rules", + }, + { + name: "with a Prometheus datasource and subtype of Prometheus", + namedParams: map[string]string{":Recipient": "164"}, + urlParams: "?subtype=prometheus", + datasourceCache: fakeCacheService{datasource: &models.DataSource{Url: "http://loki.com", Type: PrometheusDatasourceType}}, + expected: "/rules", + }, + { + name: "with a Prometheus datasource and no subtype", + namedParams: map[string]string{":Recipient": "164"}, + datasourceCache: fakeCacheService{datasource: &models.DataSource{Url: "http://loki.com", Type: PrometheusDatasourceType}}, + expected: "/rules", + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + // Setup Proxy. + proxy := &AlertingProxy{DataProxy: &datasourceproxy.DataSourceProxyService{DataSourceCache: tt.datasourceCache}} + ruler := &LotexRuler{AlertingProxy: proxy, log: log.NewNopLogger()} + + // Setup request context. + httpReq, err := http.NewRequest(http.MethodGet, "http://grafanacloud.com"+tt.urlParams, nil) + require.NoError(t, err) + ctx := &models.ReqContext{Context: &web.Context{Req: web.SetURLParams(httpReq, tt.namedParams)}} + + prefix, err := ruler.validateAndGetPrefix(ctx) + require.Equal(t, tt.expected, prefix) + if tt.err != nil { + require.EqualError(t, err, tt.err.Error()) + } + }) + } +} + +type fakeCacheService struct { + datasource *models.DataSource + err error +} + +func (f fakeCacheService) GetDatasource(_ context.Context, datasourceID int64, _ *models.SignedInUser, _ bool) (*models.DataSource, error) { + if f.err != nil { + return nil, f.err + } + + return f.datasource, nil +} + +func (f fakeCacheService) GetDatasourceByUID(ctx context.Context, datasourceUID string, user *models.SignedInUser, skipCache bool) (*models.DataSource, error) { + if f.err != nil { + return nil, f.err + } + + return f.datasource, nil +} diff --git a/pkg/services/ngalert/api/tooling/.gitignore b/pkg/services/ngalert/api/tooling/.gitignore new file mode 100644 index 00000000000..67739ba134b --- /dev/null +++ b/pkg/services/ngalert/api/tooling/.gitignore @@ -0,0 +1 @@ +go/ \ No newline at end of file diff --git a/pkg/services/ngalert/api/tooling/Makefile b/pkg/services/ngalert/api/tooling/Makefile index 8e1536612d5..2364a797a90 100644 --- a/pkg/services/ngalert/api/tooling/Makefile +++ b/pkg/services/ngalert/api/tooling/Makefile @@ -7,6 +7,10 @@ SWAGGER_TAG ?= latest PATH_DOWN = pkg/services/ngalert/api/tooling PATH_UP = ../../../../.. +.DEFAULT_GOAL := all + +GENERATED_GO_MATCHERS = ./go/*.go + spec.json: $(GO_PKG_FILES) # this is slow because this image does not use the cache # https://github.com/go-swagger/go-swagger/blob/v0.27.0/Dockerfile#L5 @@ -25,6 +29,30 @@ spec.json-mac: ensure_go-swagger_mac $(GO_PKG_FILES) post.json: spec.json go run cmd/clean-swagger/main.go -if $(<) -of $@ -.PHONY: openapi -openapi: post.json +swagger-codegen-api: + docker run --rm -v $$(pwd):/local --user $$(id -u):$$(id -g) swaggerapi/swagger-codegen-cli generate \ + -i /local/post.json \ + -l go-server \ + -Dapis \ + -o /local \ + --additional-properties packageName=api \ + -t /local/swagger-codegen/templates \ + # --import-mappings eval.RelativeTimeRange="github.com/grafana/grafana/pkg/services/ngalert/eval" \ + # --type-mappings RelativeTimeRange=eval.RelativeTimeRange + +copy-files: + ls -1 go | xargs -n 1 -I {} mv go/{} ../generated_base_{} + +fix: + sed -i -e 's/apimodels\.\[\]PostableAlert/apimodels.PostableAlerts/' $(GENERATED_GO_MATCHERS) + sed -i -e 's/apimodels\.\[\]UpdateDashboardAclCommand/apimodels.Permissions/' $(GENERATED_GO_MATCHERS) + sed -i -e 's/apimodels\.\[\]PostableApiReceiver/apimodels.TestReceiversConfigParams/' $(GENERATED_GO_MATCHERS) + goimports -w -v $(GENERATED_GO_MATCHERS) + +clean: + rm -rf ./go + +serve: post.json docker run --rm -p 80:8080 -v $$(pwd):/tmp -e SWAGGER_FILE=/tmp/$(<) swaggerapi/swagger-editor + +all: post.json swagger-codegen-api fix copy-files clean diff --git a/pkg/services/ngalert/api/tooling/README.md b/pkg/services/ngalert/api/tooling/README.md index bef256184e1..a07b4af4c80 100644 --- a/pkg/services/ngalert/api/tooling/README.md +++ b/pkg/services/ngalert/api/tooling/README.md @@ -1,13 +1,18 @@ ## What -[view api](http://localhost) - -This aims to define the unified alerting API as code. It generates OpenAPI definitions from go structs - +This aims to define the unified alerting API as code. It generates OpenAPI definitions from go structs. It also generates server/route stubs based on our documentation. ## Running -`make openapi` +`make` - regenerate everything - documentation and server stubs. +`make serve` - regenerate the Swagger document, and host rendered docs on port 80. [view api](http://localhost) ## Requires - [go-swagger](https://github.com/go-swagger/go-swagger) + - [goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports) + +## Why + +The current state of Swagger extraction from golang is relatively limited. It's easier to generate server stubs from an existing Swagger doc, as there are limitations with producing a Swagger doc from a hand-written API stub. The current extractor instead relies on comments describing the routes, but the comments and actual implementation may drift, which we don't want to allow. + +Instead, we use a hybrid approach - we define the types in Golang, with comments describing the routes, in a standalone package with minimal dependencies. From this, we produce a Swagger doc, and then turn the Swagger doc back into a full-blown server stub. diff --git a/pkg/services/ngalert/api/util.go b/pkg/services/ngalert/api/util.go index 186af0d99e1..76808313bae 100644 --- a/pkg/services/ngalert/api/util.go +++ b/pkg/services/ngalert/api/util.go @@ -12,22 +12,15 @@ import ( "strconv" "strings" - "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/pkg/errors" "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/expr" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" - "github.com/grafana/grafana/pkg/services/ngalert/eval" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/secrets" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -239,33 +232,6 @@ func validateQueriesAndExpressions(ctx context.Context, data []ngmodels.AlertQue return refIDs, nil } -func conditionEval(c *models.ReqContext, cmd ngmodels.EvalAlertConditionCommand, datasourceCache datasources.CacheService, expressionService *expr.Service, secretsService secrets.Service, cfg *setting.Cfg, log log.Logger) response.Response { - evalCond := ngmodels.Condition{ - Condition: cmd.Condition, - OrgID: c.SignedInUser.OrgId, - Data: cmd.Data, - } - if err := validateCondition(c.Req.Context(), evalCond, c.SignedInUser, c.SkipCache, datasourceCache); err != nil { - return ErrResp(http.StatusBadRequest, err, "invalid condition") - } - - now := cmd.Now - if now.IsZero() { - now = timeNow() - } - - evaluator := eval.NewEvaluator(cfg, log, datasourceCache, secretsService) - evalResults, err := evaluator.ConditionEval(&evalCond, now, expressionService) - if err != nil { - return ErrResp(http.StatusBadRequest, err, "Failed to evaluate conditions") - } - - frame := evalResults.AsDataFrame() - return response.JSONStreaming(http.StatusOK, util.DynMap{ - "instances": []*data.Frame{&frame}, - }) -} - // ErrorResp creates a response with a visible error func ErrResp(status int, err error, msg string, args ...interface{}) *response.NormalResponse { if msg != "" { diff --git a/pkg/services/ngalert/eval/eval.go b/pkg/services/ngalert/eval/eval.go index 7d93132ef5a..7ad329a0095 100644 --- a/pkg/services/ngalert/eval/eval.go +++ b/pkg/services/ngalert/eval/eval.go @@ -22,10 +22,19 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/expr" ) -type Evaluator struct { +//go:generate mockery --name Evaluator --structname FakeEvaluator --inpackage --filename evaluator_mock.go --with-expecter +type Evaluator interface { + // ConditionEval executes conditions and evaluates the result. + ConditionEval(condition *models.Condition, now time.Time, expressionService *expr.Service) (Results, error) + // QueriesAndExpressionsEval executes queries and expressions and returns the result. + QueriesAndExpressionsEval(orgID int64, data []models.AlertQuery, now time.Time, expressionService *expr.Service) (*backend.QueryDataResponse, error) +} + +type evaluatorImpl struct { cfg *setting.Cfg log log.Logger dataSourceCache datasources.CacheService @@ -36,8 +45,8 @@ func NewEvaluator( cfg *setting.Cfg, log log.Logger, datasourceCache datasources.CacheService, - secretsService secrets.Service) *Evaluator { - return &Evaluator{ + secretsService secrets.Service) Evaluator { + return &evaluatorImpl{ cfg: cfg, log: log, dataSourceCache: datasourceCache, @@ -576,7 +585,7 @@ func (evalResults Results) AsDataFrame() data.Frame { } // ConditionEval executes conditions and evaluates the result. -func (e *Evaluator) ConditionEval(condition *models.Condition, now time.Time, expressionService *expr.Service) (Results, error) { +func (e *evaluatorImpl) ConditionEval(condition *models.Condition, now time.Time, expressionService *expr.Service) (Results, error) { alertCtx, cancelFn := context.WithTimeout(context.Background(), e.cfg.UnifiedAlerting.EvaluationTimeout) defer cancelFn() @@ -589,7 +598,7 @@ func (e *Evaluator) ConditionEval(condition *models.Condition, now time.Time, ex } // QueriesAndExpressionsEval executes queries and expressions and returns the result. -func (e *Evaluator) QueriesAndExpressionsEval(orgID int64, data []models.AlertQuery, now time.Time, expressionService *expr.Service) (*backend.QueryDataResponse, error) { +func (e *evaluatorImpl) QueriesAndExpressionsEval(orgID int64, data []models.AlertQuery, now time.Time, expressionService *expr.Service) (*backend.QueryDataResponse, error) { alertCtx, cancelFn := context.WithTimeout(context.Background(), e.cfg.UnifiedAlerting.EvaluationTimeout) defer cancelFn() diff --git a/pkg/services/ngalert/eval/evaluator_mock.go b/pkg/services/ngalert/eval/evaluator_mock.go new file mode 100644 index 00000000000..70ff9b14871 --- /dev/null +++ b/pkg/services/ngalert/eval/evaluator_mock.go @@ -0,0 +1,124 @@ +// Code generated by mockery v2.10.0. DO NOT EDIT. + +package eval + +import ( + backend "github.com/grafana/grafana-plugin-sdk-go/backend" + expr "github.com/grafana/grafana/pkg/expr" + + mock "github.com/stretchr/testify/mock" + + models "github.com/grafana/grafana/pkg/services/ngalert/models" + + time "time" +) + +// FakeEvaluator is an autogenerated mock type for the Evaluator type +type FakeEvaluator struct { + mock.Mock +} + +type FakeEvaluator_Expecter struct { + mock *mock.Mock +} + +func (_m *FakeEvaluator) EXPECT() *FakeEvaluator_Expecter { + return &FakeEvaluator_Expecter{mock: &_m.Mock} +} + +// ConditionEval provides a mock function with given fields: condition, now, expressionService +func (_m *FakeEvaluator) ConditionEval(condition *models.Condition, now time.Time, expressionService *expr.Service) (Results, error) { + ret := _m.Called(condition, now, expressionService) + + var r0 Results + if rf, ok := ret.Get(0).(func(*models.Condition, time.Time, *expr.Service) Results); ok { + r0 = rf(condition, now, expressionService) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(Results) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*models.Condition, time.Time, *expr.Service) error); ok { + r1 = rf(condition, now, expressionService) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FakeEvaluator_ConditionEval_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConditionEval' +type FakeEvaluator_ConditionEval_Call struct { + *mock.Call +} + +// ConditionEval is a helper method to define mock.On call +// - condition *models.Condition +// - now time.Time +// - expressionService *expr.Service +func (_e *FakeEvaluator_Expecter) ConditionEval(condition interface{}, now interface{}, expressionService interface{}) *FakeEvaluator_ConditionEval_Call { + return &FakeEvaluator_ConditionEval_Call{Call: _e.mock.On("ConditionEval", condition, now, expressionService)} +} + +func (_c *FakeEvaluator_ConditionEval_Call) Run(run func(condition *models.Condition, now time.Time, expressionService *expr.Service)) *FakeEvaluator_ConditionEval_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*models.Condition), args[1].(time.Time), args[2].(*expr.Service)) + }) + return _c +} + +func (_c *FakeEvaluator_ConditionEval_Call) Return(_a0 Results, _a1 error) *FakeEvaluator_ConditionEval_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +// QueriesAndExpressionsEval provides a mock function with given fields: orgID, data, now, expressionService +func (_m *FakeEvaluator) QueriesAndExpressionsEval(orgID int64, data []models.AlertQuery, now time.Time, expressionService *expr.Service) (*backend.QueryDataResponse, error) { + ret := _m.Called(orgID, data, now, expressionService) + + var r0 *backend.QueryDataResponse + if rf, ok := ret.Get(0).(func(int64, []models.AlertQuery, time.Time, *expr.Service) *backend.QueryDataResponse); ok { + r0 = rf(orgID, data, now, expressionService) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*backend.QueryDataResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int64, []models.AlertQuery, time.Time, *expr.Service) error); ok { + r1 = rf(orgID, data, now, expressionService) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FakeEvaluator_QueriesAndExpressionsEval_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueriesAndExpressionsEval' +type FakeEvaluator_QueriesAndExpressionsEval_Call struct { + *mock.Call +} + +// QueriesAndExpressionsEval is a helper method to define mock.On call +// - orgID int64 +// - data []models.AlertQuery +// - now time.Time +// - expressionService *expr.Service +func (_e *FakeEvaluator_Expecter) QueriesAndExpressionsEval(orgID interface{}, data interface{}, now interface{}, expressionService interface{}) *FakeEvaluator_QueriesAndExpressionsEval_Call { + return &FakeEvaluator_QueriesAndExpressionsEval_Call{Call: _e.mock.On("QueriesAndExpressionsEval", orgID, data, now, expressionService)} +} + +func (_c *FakeEvaluator_QueriesAndExpressionsEval_Call) Run(run func(orgID int64, data []models.AlertQuery, now time.Time, expressionService *expr.Service)) *FakeEvaluator_QueriesAndExpressionsEval_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(int64), args[1].([]models.AlertQuery), args[2].(time.Time), args[3].(*expr.Service)) + }) + return _c +} + +func (_c *FakeEvaluator_QueriesAndExpressionsEval_Call) Return(_a0 *backend.QueryDataResponse, _a1 error) *FakeEvaluator_QueriesAndExpressionsEval_Call { + _c.Call.Return(_a0, _a1) + return _c +} diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 3e0eec85e0c..df2588e1fbb 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -93,6 +93,7 @@ func (ng *AlertNG) init() error { SQLStore: ng.SQLStore, Logger: ng.Log, FolderService: ng.folderService, + AccessControl: ng.accesscontrol, } decryptFn := ng.SecretsService.GetDecryptedValue diff --git a/pkg/services/ngalert/notifier/channels/dingding.go b/pkg/services/ngalert/notifier/channels/dingding.go index 9e92a4c6d83..17b06f8c648 100644 --- a/pkg/services/ngalert/notifier/channels/dingding.go +++ b/pkg/services/ngalert/notifier/channels/dingding.go @@ -122,9 +122,15 @@ func (dd *DingDingNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo } } - u := tmpl(dd.URL) if tmplErr != nil { dd.log.Warn("failed to template DingDing message", "err", tmplErr.Error()) + tmplErr = nil + } + + u := tmpl(dd.URL) + if tmplErr != nil { + dd.log.Warn("failed to template DingDing URL", "err", tmplErr.Error(), "fallback", dd.URL) + u = dd.URL } body, err := json.Marshal(bodyMsg) diff --git a/pkg/services/ngalert/notifier/channels/dingding_test.go b/pkg/services/ngalert/notifier/channels/dingding_test.go index a4b9fd8a286..db1a927aea4 100644 --- a/pkg/services/ngalert/notifier/channels/dingding_test.go +++ b/pkg/services/ngalert/notifier/channels/dingding_test.go @@ -79,6 +79,64 @@ func TestDingdingNotifier(t *testing.T) { "msgtype": "actionCard", }, expMsgError: nil, + }, { + name: "Missing field in template", + settings: `{ + "url": "http://localhost", + "message": "I'm a custom template {{ .NotAField }} bad template", + "msgType": "actionCard" + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2"}, + }, + }, + }, + expMsg: map[string]interface{}{ + "link": map[string]interface{}{ + "messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist", + "text": "I'm a custom template ", + "title": "", + }, + "msgtype": "link", + }, + expMsgError: nil, + }, { + name: "Invalid template", + settings: `{ + "url": "http://localhost", + "message": "I'm a custom template {{ {.NotAField }} bad template", + "msgType": "actionCard" + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2"}, + }, + }, + }, + expMsg: map[string]interface{}{ + "link": map[string]interface{}{ + "messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist", + "text": "", + "title": "", + }, + "msgtype": "link", + }, + expMsgError: nil, }, { name: "Error in initing", settings: `{}`, @@ -118,6 +176,8 @@ func TestDingdingNotifier(t *testing.T) { require.NoError(t, err) require.True(t, ok) + require.NotEmpty(t, webhookSender.Webhook.Url) + expBody, err := json.Marshal(c.expMsg) require.NoError(t, err) diff --git a/pkg/services/ngalert/notifier/channels/googlechat.go b/pkg/services/ngalert/notifier/channels/googlechat.go index 20003464722..be7f7537ec9 100644 --- a/pkg/services/ngalert/notifier/channels/googlechat.go +++ b/pkg/services/ngalert/notifier/channels/googlechat.go @@ -139,9 +139,15 @@ func (gcn *GoogleChatNotifier) Notify(ctx context.Context, as ...*types.Alert) ( }, } - u := tmpl(gcn.URL) if tmplErr != nil { gcn.log.Warn("failed to template GoogleChat message", "err", tmplErr.Error()) + tmplErr = nil + } + + u := tmpl(gcn.URL) + if tmplErr != nil { + gcn.log.Warn("failed to template GoogleChat URL", "err", tmplErr.Error(), "fallback", gcn.URL) + u = gcn.URL } body, err := json.Marshal(res) diff --git a/pkg/services/ngalert/notifier/channels/googlechat_test.go b/pkg/services/ngalert/notifier/channels/googlechat_test.go index 93455ae8a61..7b4629cec57 100644 --- a/pkg/services/ngalert/notifier/channels/googlechat_test.go +++ b/pkg/services/ngalert/notifier/channels/googlechat_test.go @@ -205,7 +205,7 @@ func TestGoogleChatNotifier(t *testing.T) { }, expMsgError: nil, }, { - name: "Invalid template", + name: "Missing field in template", settings: `{"url": "http://localhost", "message": "I'm a custom template {{ .NotAField }} bad template"}`, alerts: []*types.Alert{ { @@ -258,6 +258,55 @@ func TestGoogleChatNotifier(t *testing.T) { }, }, expMsgError: nil, + }, { + name: "Invalid template", + settings: `{"url": "http://localhost", "message": "I'm a custom template {{ {.NotAField }} bad template"}`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + }, + }, + }, + expMsg: &outerStruct{ + PreviewText: "[FIRING:1] (val1)", + FallbackText: "[FIRING:1] (val1)", + Cards: []card{ + { + Header: header{ + Title: "[FIRING:1] (val1)", + }, + Sections: []section{ + { + Widgets: []widget{ + buttonWidget{ + Buttons: []button{ + { + TextButton: textButton{ + Text: "OPEN IN GRAFANA", + OnClick: onClick{ + OpenLink: openLink{ + URL: "http://localhost/alerting/list", + }, + }, + }, + }, + }, + }, + textParagraphWidget{ + Text: text{ + // RFC822 only has the minute, hence it works in most cases. + Text: "Grafana v" + setting.BuildVersion + " | " + constNow.Format(time.RFC822), + }, + }, + }, + }, + }, + }, + }, + }, + expMsgError: nil, }, } @@ -294,6 +343,8 @@ func TestGoogleChatNotifier(t *testing.T) { require.NoError(t, err) require.True(t, ok) + require.NotEmpty(t, webhookSender.Webhook.Url) + expBody, err := json.Marshal(c.expMsg) require.NoError(t, err) diff --git a/pkg/services/ngalert/notifier/channels/teams.go b/pkg/services/ngalert/notifier/channels/teams.go index e03a48bf8d7..f358084fd1e 100644 --- a/pkg/services/ngalert/notifier/channels/teams.go +++ b/pkg/services/ngalert/notifier/channels/teams.go @@ -108,9 +108,15 @@ func (tn *TeamsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, }, } - u := tmpl(tn.URL) if tmplErr != nil { tn.log.Warn("failed to template Teams message", "err", tmplErr.Error()) + tmplErr = nil + } + + u := tmpl(tn.URL) + if tmplErr != nil { + tn.log.Warn("failed to template Teams URL", "err", tmplErr.Error(), "fallback", tn.URL) + u = tn.URL } b, err := json.Marshal(&body) diff --git a/pkg/services/ngalert/notifier/channels/teams_test.go b/pkg/services/ngalert/notifier/channels/teams_test.go index 1d09c78661c..f91322c3905 100644 --- a/pkg/services/ngalert/notifier/channels/teams_test.go +++ b/pkg/services/ngalert/notifier/channels/teams_test.go @@ -103,6 +103,88 @@ func TestTeamsNotifier(t *testing.T) { }, }, expMsgError: nil, + }, { + name: "Missing field in template", + settings: `{ + "url": "http://localhost", + "message": "I'm a custom template {{ .NotAField }} bad template" + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2"}, + }, + }, + }, + expMsg: map[string]interface{}{ + "@type": "MessageCard", + "@context": "http://schema.org/extensions", + "summary": "[FIRING:2] ", + "title": "[FIRING:2] ", + "themeColor": "#D63232", + "sections": []map[string]interface{}{ + { + "title": "Details", + "text": "I'm a custom template ", + }, + }, + "potentialAction": []map[string]interface{}{ + { + "@context": "http://schema.org", + "@type": "OpenUri", + "name": "View Rule", + "targets": []map[string]interface{}{{"os": "default", "uri": "http://localhost/alerting/list"}}, + }, + }, + }, + expMsgError: nil, + }, { + name: "Invalid template", + settings: `{ + "url": "http://localhost", + "message": "I'm a custom template {{ {.NotAField }} bad template" + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2"}, + }, + }, + }, + expMsg: map[string]interface{}{ + "@type": "MessageCard", + "@context": "http://schema.org/extensions", + "summary": "[FIRING:2] ", + "title": "[FIRING:2] ", + "themeColor": "#D63232", + "sections": []map[string]interface{}{ + { + "title": "Details", + "text": "", + }, + }, + "potentialAction": []map[string]interface{}{ + { + "@context": "http://schema.org", + "@type": "OpenUri", + "name": "View Rule", + "targets": []map[string]interface{}{{"os": "default", "uri": "http://localhost/alerting/list"}}, + }, + }, + }, + expMsgError: nil, }, { name: "Error in initing", settings: `{}`, @@ -143,6 +225,8 @@ func TestTeamsNotifier(t *testing.T) { require.True(t, ok) require.NoError(t, err) + require.NotEmpty(t, webhookSender.Webhook.Url) + expBody, err := json.Marshal(c.expMsg) require.NoError(t, err) diff --git a/pkg/services/ngalert/notifier/channels/victorops.go b/pkg/services/ngalert/notifier/channels/victorops.go index 12f9fb0c7fc..1035a149f16 100644 --- a/pkg/services/ngalert/notifier/channels/victorops.go +++ b/pkg/services/ngalert/notifier/channels/victorops.go @@ -118,9 +118,15 @@ func (vn *VictoropsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bo ruleURL := joinUrlPath(vn.tmpl.ExternalURL.String(), "/alerting/list", vn.log) bodyJSON.Set("alert_url", ruleURL) - u := tmpl(vn.URL) if tmplErr != nil { vn.log.Warn("failed to template VictorOps message", "err", tmplErr.Error()) + tmplErr = nil + } + + u := tmpl(vn.URL) + if tmplErr != nil { + vn.log.Info("failed to template VictorOps URL", "err", tmplErr.Error(), "fallback", vn.URL) + u = vn.URL } b, err := bodyJSON.MarshalJSON() diff --git a/pkg/services/ngalert/notifier/channels/victorops_test.go b/pkg/services/ngalert/notifier/channels/victorops_test.go index f0c8740f9c1..04f1f6e167e 100644 --- a/pkg/services/ngalert/notifier/channels/victorops_test.go +++ b/pkg/services/ngalert/notifier/channels/victorops_test.go @@ -75,6 +75,81 @@ func TestVictoropsNotifier(t *testing.T) { "state_message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", }, expMsgError: nil, + }, { + name: "Custom message", + settings: `{"url": "http://localhost", "messageType": "Alerts firing: {{ len .Alerts.Firing }}"}`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2"}, + }, + }, + }, + expMsg: map[string]interface{}{ + "alert_url": "http://localhost/alerting/list", + "entity_display_name": "[FIRING:2] ", + "entity_id": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", + "message_type": "ALERTS FIRING: 2", + "monitoring_tool": "Grafana v" + setting.BuildVersion, + "state_message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", + }, + expMsgError: nil, + }, { + name: "Missing field in template", + settings: `{"url": "http://localhost", "messageType": "custom template {{ .NotAField }} bad template"}`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2"}, + }, + }, + }, + expMsg: map[string]interface{}{ + "alert_url": "http://localhost/alerting/list", + "entity_display_name": "", + "entity_id": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", + "message_type": "CUSTOM TEMPLATE ", + "monitoring_tool": "Grafana v" + setting.BuildVersion, + "state_message": "", + }, + expMsgError: nil, + }, { + name: "Invalid template", + settings: `{"url": "http://localhost", "messageType": "custom template {{ {.NotAField }} bad template"}`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2"}, + }, + }, + }, + expMsg: map[string]interface{}{ + "alert_url": "http://localhost/alerting/list", + "entity_display_name": "", + "entity_id": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", + "message_type": "CRITICAL", + "monitoring_tool": "Grafana v" + setting.BuildVersion, + "state_message": "", + }, + expMsgError: nil, }, { name: "Error in initing, no URL", settings: `{}`, @@ -115,6 +190,8 @@ func TestVictoropsNotifier(t *testing.T) { require.NoError(t, err) require.True(t, ok) + require.NotEmpty(t, webhookSender.Webhook.Url) + // Remove the non-constant timestamp j, err := simplejson.NewJson([]byte(webhookSender.Webhook.Body)) require.NoError(t, err) diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go index 962daff8f90..a15438d5f8d 100644 --- a/pkg/services/ngalert/schedule/schedule.go +++ b/pkg/services/ngalert/schedule/schedule.go @@ -76,7 +76,7 @@ type schedule struct { log log.Logger - evaluator *eval.Evaluator + evaluator eval.Evaluator ruleStore store.RuleStore instanceStore store.InstanceStore @@ -109,7 +109,7 @@ type SchedulerCfg struct { EvalAppliedFunc func(models.AlertRuleKey, time.Time) MaxAttempts int64 StopAppliedFunc func(models.AlertRuleKey) - Evaluator *eval.Evaluator + Evaluator eval.Evaluator RuleStore store.RuleStore OrgStore store.OrgStore InstanceStore store.InstanceStore diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index 8fcc8cf943b..b6e1b1d78c4 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -1160,7 +1160,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, { - desc: "normal -> alerting when result is Error and ExecErrState is Alerting", + desc: "normal -> pending when For is set but not exceeded, result is Error and ExecErrState is Alerting", alertRule: &models.AlertRule{ OrgID: 1, Title: "test_title", @@ -1203,7 +1203,7 @@ func TestProcessEvalResults(t *testing.T) { "label": "test", "instance_label": "test", }, - State: eval.Alerting, + State: eval.Pending, Results: []state.Evaluation{ { EvaluationTime: evaluationTime, @@ -1224,6 +1224,100 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + { + desc: "normal -> alerting when For is exceeded, result is Error and ExecErrState is Alerting", + alertRule: &models.AlertRule{ + OrgID: 1, + Title: "test_title", + UID: "test_alert_rule_uid_2", + NamespaceUID: "test_namespace_uid", + Annotations: map[string]string{"annotation": "test"}, + Labels: map[string]string{"label": "test"}, + IntervalSeconds: 10, + For: 30 * time.Second, + ExecErrState: models.AlertingErrState, + }, + evalResults: []eval.Results{ + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Normal, + EvaluatedAt: evaluationTime, + EvaluationDuration: evaluationDuration, + }, + }, + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Error, + EvaluatedAt: evaluationTime.Add(10 * time.Second), + EvaluationDuration: evaluationDuration, + }, + }, + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Error, + EvaluatedAt: evaluationTime.Add(20 * time.Second), + EvaluationDuration: evaluationDuration, + }, + }, + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Error, + EvaluatedAt: evaluationTime.Add(30 * time.Second), + EvaluationDuration: evaluationDuration, + }, + }, + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Error, + EvaluatedAt: evaluationTime.Add(40 * time.Second), + EvaluationDuration: evaluationDuration, + }, + }, + }, + expectedAnnotations: 2, + expectedStates: map[string]*state.State{ + `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { + AlertRuleUID: "test_alert_rule_uid_2", + OrgID: 1, + CacheId: `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`, + Labels: data.Labels{ + "__alert_rule_namespace_uid__": "test_namespace_uid", + "__alert_rule_uid__": "test_alert_rule_uid_2", + "alertname": "test_title", + "label": "test", + "instance_label": "test", + }, + State: eval.Alerting, + Results: []state.Evaluation{ + { + EvaluationTime: evaluationTime.Add(20 * time.Second), + EvaluationState: eval.Error, + Values: make(map[string]*float64), + }, + { + EvaluationTime: evaluationTime.Add(30 * time.Second), + EvaluationState: eval.Error, + Values: make(map[string]*float64), + }, + { + EvaluationTime: evaluationTime.Add(40 * time.Second), + EvaluationState: eval.Error, + Values: make(map[string]*float64), + }, + }, + StartsAt: evaluationTime.Add(40 * time.Second), + EndsAt: evaluationTime.Add(40 * time.Second).Add(state.ResendDelay * 3), + LastEvaluationTime: evaluationTime.Add(40 * time.Second), + EvaluationDuration: evaluationDuration, + Annotations: map[string]string{"annotation": "test"}, + }, + }, + }, { desc: "normal -> error when result is Error and ExecErrState is Error", alertRule: &models.AlertRule{ diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index 925e0397556..0ed75b50de4 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -84,16 +84,10 @@ func (a *State) resultAlerting(alertRule *ngModels.AlertRule, result eval.Result func (a *State) resultError(alertRule *ngModels.AlertRule, result eval.Result) { a.Error = result.Error - if a.StartsAt.IsZero() { - a.StartsAt = result.EvaluatedAt - } - a.setEndsAt(alertRule, result) - + execErrState := eval.Error if alertRule.ExecErrState == ngModels.AlertingErrState { - a.State = eval.Alerting + execErrState = eval.Alerting } else if alertRule.ExecErrState == ngModels.ErrorErrState { - a.State = eval.Error - // If the evaluation failed because a query returned an error then // update the state with the Datasource UID as a label and the error // message as an annotation so other code can use this metadata to @@ -109,6 +103,28 @@ func (a *State) resultError(alertRule *ngModels.AlertRule, result eval.Result) { } a.Annotations["Error"] = queryError.Error() } + execErrState = eval.Error + } + + switch a.State { + case eval.Alerting, eval.Error: + a.setEndsAt(alertRule, result) + case eval.Pending: + if result.EvaluatedAt.Sub(a.StartsAt) >= alertRule.For { + a.State = execErrState + a.StartsAt = result.EvaluatedAt + a.setEndsAt(alertRule, result) + } + default: + // For is observed when Alerting is chosen for the alert state + // if execution error or timeout. + if execErrState == eval.Alerting && alertRule.For > 0 { + a.State = eval.Pending + } else { + a.State = execErrState + } + a.StartsAt = result.EvaluatedAt + a.setEndsAt(alertRule, result) } } diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index e76d33274b8..06beea3a1d2 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -6,13 +6,12 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/services/guardian" - "github.com/grafana/grafana/pkg/models" - + "github.com/grafana/grafana/pkg/services/guardian" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/util" ) @@ -42,7 +41,7 @@ type RuleStore interface { GetOrgAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) error GetNamespaceAlertRules(ctx context.Context, query *ngmodels.ListNamespaceAlertRulesQuery) error GetAlertRules(ctx context.Context, query *ngmodels.GetAlertRulesQuery) error - GetNamespaces(context.Context, int64, *models.SignedInUser) (map[string]*models.Folder, error) + GetUserVisibleNamespaces(context.Context, int64, *models.SignedInUser) (map[string]*models.Folder, error) GetNamespaceByTitle(context.Context, string, int64, *models.SignedInUser, bool) (*models.Folder, error) GetOrgRuleGroups(ctx context.Context, query *ngmodels.ListOrgRuleGroupsQuery) error UpsertAlertRules(ctx context.Context, rule []UpsertRule) error @@ -270,23 +269,44 @@ func (st DBstore) GetAlertRules(ctx context.Context, query *ngmodels.GetAlertRul }) } -// GetNamespaces returns the folders that are visible to the user -func (st DBstore) GetNamespaces(ctx context.Context, orgID int64, user *models.SignedInUser) (map[string]*models.Folder, error) { +// GetNamespaces returns the folders that are visible to the user and have at least one alert in it +func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, user *models.SignedInUser) (map[string]*models.Folder, error) { namespaceMap := make(map[string]*models.Folder) + + searchQuery := models.FindPersistedDashboardsQuery{ + OrgId: orgID, + SignedInUser: user, + Type: searchstore.TypeAlertFolder, + Limit: -1, + Permission: models.PERMISSION_VIEW, + Sort: models.SortOption{}, + Filters: []interface{}{ + searchstore.FolderWithAlertsFilter{}, + }, + } + var page int64 = 1 for { - // if limit is negative; it fetches at most 1000 - folders, err := st.FolderService.GetFolders(ctx, user, orgID, -1, page) + query := searchQuery + query.Page = page + proj, err := st.SQLStore.FindDashboards(ctx, &query) if err != nil { return nil, err } - if len(folders) == 0 { + if len(proj) == 0 { break } - for _, f := range folders { - namespaceMap[f.Uid] = f + for _, hit := range proj { + if !hit.IsFolder { + continue + } + namespaceMap[hit.UID] = &models.Folder{ + Id: hit.ID, + Uid: hit.UID, + Title: hit.Title, + } } page += 1 } @@ -300,7 +320,8 @@ func (st DBstore) GetNamespaceByTitle(ctx context.Context, namespace string, org return nil, err } - if withCanSave { + // if access control is disabled, check that the user is allowed to save in the folder. + if withCanSave && st.AccessControl.IsDisabled() { g := guardian.New(ctx, folder.Id, orgID, user) if canSave, err := g.CanSave(); err != nil || !canSave { if err != nil { diff --git a/pkg/services/ngalert/store/database.go b/pkg/services/ngalert/store/database.go index 0ff8ea92eba..90b61a5e44c 100644 --- a/pkg/services/ngalert/store/database.go +++ b/pkg/services/ngalert/store/database.go @@ -5,6 +5,7 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -34,4 +35,5 @@ type DBstore struct { SQLStore *sqlstore.SQLStore Logger log.Logger FolderService dashboards.FolderService + AccessControl accesscontrol.AccessControl } diff --git a/pkg/services/ngalert/store/testing.go b/pkg/services/ngalert/store/testing.go index 6b6d89b6ba6..527626c3e98 100644 --- a/pkg/services/ngalert/store/testing.go +++ b/pkg/services/ngalert/store/testing.go @@ -208,7 +208,7 @@ func (f *FakeRuleStore) GetAlertRules(_ context.Context, q *models.GetAlertRules q.Result = result return nil } -func (f *FakeRuleStore) GetNamespaces(_ context.Context, orgID int64, _ *models2.SignedInUser) (map[string]*models2.Folder, error) { +func (f *FakeRuleStore) GetUserVisibleNamespaces(_ context.Context, orgID int64, _ *models2.SignedInUser) (map[string]*models2.Folder, error) { f.mtx.Lock() defer f.mtx.Unlock() diff --git a/pkg/services/notifications/webhook.go b/pkg/services/notifications/webhook.go index 2ae7f5f4fcc..2ea0ea19048 100644 --- a/pkg/services/notifications/webhook.go +++ b/pkg/services/notifications/webhook.go @@ -42,12 +42,12 @@ var netClient = &http.Client{ } func (ns *NotificationService) sendWebRequestSync(ctx context.Context, webhook *Webhook) error { - ns.log.Debug("Sending webhook", "url", webhook.Url, "http method", webhook.HttpMethod) - if webhook.HttpMethod == "" { webhook.HttpMethod = http.MethodPost } + ns.log.Debug("Sending webhook", "url", webhook.Url, "http method", webhook.HttpMethod) + if webhook.HttpMethod != http.MethodPost && webhook.HttpMethod != http.MethodPut { return fmt.Errorf("webhook only supports HTTP methods PUT or POST") } diff --git a/pkg/services/sqlstore/permissions/dashboard.go b/pkg/services/sqlstore/permissions/dashboard.go index c4ff0978446..21751538381 100644 --- a/pkg/services/sqlstore/permissions/dashboard.go +++ b/pkg/services/sqlstore/permissions/dashboard.go @@ -91,7 +91,7 @@ func NewAccessControlDashboardPermissionFilter(user *models.SignedInUser, permis if queryType == searchstore.TypeAlertFolder { folderActions = append(folderActions, accesscontrol.ActionAlertingRuleRead) if needEdit { - folderActions = append(folderActions, accesscontrol.ActionAlertingRuleUpdate) + folderActions = append(folderActions, accesscontrol.ActionAlertingRuleCreate) } } else { dashboardActions = append(dashboardActions, accesscontrol.ActionDashboardsRead) diff --git a/pkg/services/sqlstore/permissions/dashboard_test.go b/pkg/services/sqlstore/permissions/dashboard_test.go index e514a783ce1..264954d2739 100644 --- a/pkg/services/sqlstore/permissions/dashboard_test.go +++ b/pkg/services/sqlstore/permissions/dashboard_test.go @@ -29,7 +29,7 @@ func TestNewAccessControlDashboardPermissionFilter(t *testing.T) { expectedFolderActions: []string{ dashboards.ActionFoldersRead, accesscontrol.ActionAlertingRuleRead, - accesscontrol.ActionAlertingRuleUpdate, + accesscontrol.ActionAlertingRuleCreate, }, }, { @@ -39,7 +39,7 @@ func TestNewAccessControlDashboardPermissionFilter(t *testing.T) { expectedFolderActions: []string{ dashboards.ActionFoldersRead, accesscontrol.ActionAlertingRuleRead, - accesscontrol.ActionAlertingRuleUpdate, + accesscontrol.ActionAlertingRuleCreate, }, }, { diff --git a/pkg/services/sqlstore/searchstore/filters.go b/pkg/services/sqlstore/searchstore/filters.go index c1c43933105..99810190c38 100644 --- a/pkg/services/sqlstore/searchstore/filters.go +++ b/pkg/services/sqlstore/searchstore/filters.go @@ -149,3 +149,13 @@ func sqlIDin(column string, ids []int64) (string, []interface{}) { } return fmt.Sprintf("%s IN %s", column, sqlArray), params } + +// FolderWithAlertsFilter applies a filter that makes the result contain only folders that contain alert rules +type FolderWithAlertsFilter struct { +} + +var _ FilterWhere = &FolderWithAlertsFilter{} + +func (f FolderWithAlertsFilter) Where() (string, []interface{}) { + return "EXISTS (SELECT 1 FROM alert_rule WHERE alert_rule.namespace_uid = dashboard.uid)", nil +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index c7786fe3014..6d5729b731c 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -20,6 +20,7 @@ import ( "time" "github.com/grafana/grafana-aws-sdk/pkg/awsds" + "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana/pkg/infra/log" @@ -302,7 +303,7 @@ type Cfg struct { AWSListMetricsPageLimit int // Azure Cloud settings - Azure AzureSettings + Azure *azsettings.AzureSettings // Auth proxy settings AuthProxyEnabled bool @@ -824,6 +825,7 @@ func NewCfg() *Cfg { return &Cfg{ Logger: log.New("settings"), Raw: ini.Empty(), + Azure: &azsettings.AzureSettings{}, } } diff --git a/pkg/setting/setting_azure.go b/pkg/setting/setting_azure.go index f42c2365b19..0a6d5cc4022 100644 --- a/pkg/setting/setting_azure.go +++ b/pkg/setting/setting_azure.go @@ -1,73 +1,19 @@ package setting -import "strings" - -const ( - AzurePublic = "AzureCloud" - AzureChina = "AzureChinaCloud" - AzureUSGovernment = "AzureUSGovernment" - AzureGermany = "AzureGermanCloud" -) - -type AzureSettings struct { - Cloud string - ManagedIdentityEnabled bool - ManagedIdentityClientId string -} +import "github.com/grafana/grafana-azure-sdk-go/azsettings" func (cfg *Cfg) readAzureSettings() { + azureSettings := &azsettings.AzureSettings{} + azureSection := cfg.Raw.Section("azure") // Cloud - cloudName := azureSection.Key("cloud").MustString(AzurePublic) - cfg.Azure.Cloud = normalizeAzureCloud(cloudName) + cloudName := azureSection.Key("cloud").MustString(azsettings.AzurePublic) + azureSettings.Cloud = azsettings.NormalizeAzureCloud(cloudName) // Managed Identity - cfg.Azure.ManagedIdentityEnabled = azureSection.Key("managed_identity_enabled").MustBool(false) - cfg.Azure.ManagedIdentityClientId = azureSection.Key("managed_identity_client_id").String() -} + azureSettings.ManagedIdentityEnabled = azureSection.Key("managed_identity_enabled").MustBool(false) + azureSettings.ManagedIdentityClientId = azureSection.Key("managed_identity_client_id").String() -func normalizeAzureCloud(cloudName string) string { - switch strings.ToLower(cloudName) { - // Public - case "azurecloud": - fallthrough - case "azurepublic": - fallthrough - case "azurepubliccloud": - fallthrough - case "public": - return AzurePublic - - // China - case "azurechina": - fallthrough - case "azurechinacloud": - fallthrough - case "china": - return AzureChina - - // US Government - case "azureusgovernment": - fallthrough - case "azureusgovernmentcloud": - fallthrough - case "usgov": - fallthrough - case "usgovernment": - return AzureUSGovernment - - // Germany - case "azuregermancloud": - fallthrough - case "azuregermany": - fallthrough - case "german": - fallthrough - case "germany": - return AzureGermany - } - - // Pass the name unchanged if it's not known - return cloudName + cfg.Azure = azureSettings } diff --git a/pkg/setting/setting_azure_test.go b/pkg/setting/setting_azure_test.go index e00fb07677f..b5fe304df49 100644 --- a/pkg/setting/setting_azure_test.go +++ b/pkg/setting/setting_azure_test.go @@ -3,6 +3,8 @@ package setting import ( "testing" + "github.com/grafana/grafana-azure-sdk-go/azsettings" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -17,27 +19,27 @@ func TestAzureSettings(t *testing.T) { { name: "should be Public if not set", configuredValue: "", - resolvedValue: AzurePublic, + resolvedValue: azsettings.AzurePublic, }, { name: "should be Public if set to Public", - configuredValue: AzurePublic, - resolvedValue: AzurePublic, + configuredValue: azsettings.AzurePublic, + resolvedValue: azsettings.AzurePublic, }, { name: "should be Public if set to Public using alternative name", configuredValue: "AzurePublicCloud", - resolvedValue: AzurePublic, + resolvedValue: azsettings.AzurePublic, }, { name: "should be China if set to China", - configuredValue: AzureChina, - resolvedValue: AzureChina, + configuredValue: azsettings.AzureChina, + resolvedValue: azsettings.AzureChina, }, { name: "should be US Government if set to US Government using alternative name", configuredValue: "usgov", - resolvedValue: AzureUSGovernment, + resolvedValue: azsettings.AzureUSGovernment, }, { name: "should be same as set if not known", diff --git a/pkg/tsdb/azuremonitor/azcredentials/builder.go b/pkg/tsdb/azuremonitor/azcredentials/builder.go deleted file mode 100644 index ee66d03fcea..00000000000 --- a/pkg/tsdb/azuremonitor/azcredentials/builder.go +++ /dev/null @@ -1,83 +0,0 @@ -package azcredentials - -import ( - "fmt" -) - -func FromDatasourceData(data map[string]interface{}, secureData map[string]string) (AzureCredentials, error) { - if credentialsObj, err := getMapOptional(data, "azureCredentials"); err != nil { - return nil, err - } else if credentialsObj == nil { - return nil, nil - } else { - return getFromCredentialsObject(credentialsObj, secureData) - } -} - -func getFromCredentialsObject(credentialsObj map[string]interface{}, secureData map[string]string) (AzureCredentials, error) { - authType, err := getStringValue(credentialsObj, "authType") - if err != nil { - return nil, err - } - - switch authType { - case AzureAuthManagedIdentity: - credentials := &AzureManagedIdentityCredentials{} - return credentials, nil - - case AzureAuthClientSecret: - cloud, err := getStringValue(credentialsObj, "azureCloud") - if err != nil { - return nil, err - } - tenantId, err := getStringValue(credentialsObj, "tenantId") - if err != nil { - return nil, err - } - clientId, err := getStringValue(credentialsObj, "clientId") - if err != nil { - return nil, err - } - clientSecret := secureData["azureClientSecret"] - - credentials := &AzureClientSecretCredentials{ - AzureCloud: cloud, - TenantId: tenantId, - ClientId: clientId, - ClientSecret: clientSecret, - } - return credentials, nil - - default: - err := fmt.Errorf("the authentication type '%s' not supported", authType) - return nil, err - } -} - -func getMapOptional(obj map[string]interface{}, key string) (map[string]interface{}, error) { - if untypedValue, ok := obj[key]; ok { - if value, ok := untypedValue.(map[string]interface{}); ok { - return value, nil - } else { - err := fmt.Errorf("the field '%s' should be an object", key) - return nil, err - } - } else { - // Value optional, not error - return nil, nil - } -} - -func getStringValue(obj map[string]interface{}, key string) (string, error) { - if untypedValue, ok := obj[key]; ok { - if value, ok := untypedValue.(string); ok { - return value, nil - } else { - err := fmt.Errorf("the field '%s' should be a string", key) - return "", err - } - } else { - err := fmt.Errorf("the field '%s' should be set", key) - return "", err - } -} diff --git a/pkg/tsdb/azuremonitor/azcredentials/credentials.go b/pkg/tsdb/azuremonitor/azcredentials/credentials.go deleted file mode 100644 index 49311e5c11a..00000000000 --- a/pkg/tsdb/azuremonitor/azcredentials/credentials.go +++ /dev/null @@ -1,30 +0,0 @@ -package azcredentials - -const ( - AzureAuthManagedIdentity = "msi" - AzureAuthClientSecret = "clientsecret" -) - -type AzureCredentials interface { - AzureAuthType() string -} - -type AzureManagedIdentityCredentials struct { - ClientId string -} - -type AzureClientSecretCredentials struct { - AzureCloud string - Authority string - TenantId string - ClientId string - ClientSecret string -} - -func (credentials *AzureManagedIdentityCredentials) AzureAuthType() string { - return AzureAuthManagedIdentity -} - -func (credentials *AzureClientSecretCredentials) AzureAuthType() string { - return AzureAuthClientSecret -} diff --git a/pkg/tsdb/azuremonitor/aztokenprovider/middleware.go b/pkg/tsdb/azuremonitor/aztokenprovider/middleware.go deleted file mode 100644 index f9b6e013927..00000000000 --- a/pkg/tsdb/azuremonitor/aztokenprovider/middleware.go +++ /dev/null @@ -1,27 +0,0 @@ -package aztokenprovider - -import ( - "fmt" - "net/http" - - "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" -) - -const authenticationMiddlewareName = "AzureAuthentication" - -func AuthMiddleware(tokenProvider AzureTokenProvider, scopes []string) httpclient.Middleware { - return httpclient.NamedMiddlewareFunc(authenticationMiddlewareName, func(opts httpclient.Options, next http.RoundTripper) http.RoundTripper { - return ApplyAuth(tokenProvider, scopes, next) - }) -} - -func ApplyAuth(tokenProvider AzureTokenProvider, scopes []string, next http.RoundTripper) http.RoundTripper { - return httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { - token, err := tokenProvider.GetAccessToken(req.Context(), scopes) - if err != nil { - return nil, fmt.Errorf("failed to retrieve Azure access token: %w", err) - } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - return next.RoundTrip(req) - }) -} diff --git a/pkg/tsdb/azuremonitor/aztokenprovider/token_cache.go b/pkg/tsdb/azuremonitor/aztokenprovider/token_cache.go deleted file mode 100644 index 3c375979e4d..00000000000 --- a/pkg/tsdb/azuremonitor/aztokenprovider/token_cache.go +++ /dev/null @@ -1,189 +0,0 @@ -package aztokenprovider - -import ( - "context" - "sort" - "strings" - "sync" - "sync/atomic" - "time" -) - -var ( - // timeNow makes it possible to test usage of time - timeNow = time.Now -) - -type AccessToken struct { - Token string - ExpiresOn time.Time -} - -type TokenRetriever interface { - GetCacheKey() string - Init() error - GetAccessToken(ctx context.Context, scopes []string) (*AccessToken, error) -} - -type ConcurrentTokenCache interface { - GetAccessToken(ctx context.Context, tokenRetriever TokenRetriever, scopes []string) (string, error) -} - -func NewConcurrentTokenCache() ConcurrentTokenCache { - return &tokenCacheImpl{} -} - -type tokenCacheImpl struct { - cache sync.Map // of *credentialCacheEntry -} -type credentialCacheEntry struct { - retriever TokenRetriever - - credInit uint32 - credMutex sync.Mutex - cache sync.Map // of *scopesCacheEntry -} - -type scopesCacheEntry struct { - retriever TokenRetriever - scopes []string - - cond *sync.Cond - refreshing bool - accessToken *AccessToken -} - -func (c *tokenCacheImpl) GetAccessToken(ctx context.Context, tokenRetriever TokenRetriever, scopes []string) (string, error) { - return c.getEntryFor(tokenRetriever).getAccessToken(ctx, scopes) -} - -func (c *tokenCacheImpl) getEntryFor(credential TokenRetriever) *credentialCacheEntry { - var entry interface{} - var ok bool - - key := credential.GetCacheKey() - - if entry, ok = c.cache.Load(key); !ok { - entry, _ = c.cache.LoadOrStore(key, &credentialCacheEntry{ - retriever: credential, - }) - } - - return entry.(*credentialCacheEntry) -} - -func (c *credentialCacheEntry) getAccessToken(ctx context.Context, scopes []string) (string, error) { - err := c.ensureInitialized() - if err != nil { - return "", err - } - - return c.getEntryFor(scopes).getAccessToken(ctx) -} - -func (c *credentialCacheEntry) ensureInitialized() error { - if atomic.LoadUint32(&c.credInit) == 0 { - c.credMutex.Lock() - defer c.credMutex.Unlock() - - if c.credInit == 0 { - // Initialize retriever - err := c.retriever.Init() - if err != nil { - return err - } - - atomic.StoreUint32(&c.credInit, 1) - } - } - - return nil -} - -func (c *credentialCacheEntry) getEntryFor(scopes []string) *scopesCacheEntry { - var entry interface{} - var ok bool - - key := getKeyForScopes(scopes) - - if entry, ok = c.cache.Load(key); !ok { - entry, _ = c.cache.LoadOrStore(key, &scopesCacheEntry{ - retriever: c.retriever, - scopes: scopes, - cond: sync.NewCond(&sync.Mutex{}), - }) - } - - return entry.(*scopesCacheEntry) -} - -func (c *scopesCacheEntry) getAccessToken(ctx context.Context) (string, error) { - var accessToken *AccessToken - var err error - shouldRefresh := false - - c.cond.L.Lock() - for { - if c.accessToken != nil && c.accessToken.ExpiresOn.After(timeNow().Add(2*time.Minute)) { - // Use the cached token since it's available and not expired yet - accessToken = c.accessToken - break - } - - if !c.refreshing { - // Start refreshing the token - c.refreshing = true - shouldRefresh = true - break - } - - // Wait for the token to be refreshed - c.cond.Wait() - } - c.cond.L.Unlock() - - if shouldRefresh { - accessToken, err = c.refreshAccessToken(ctx) - if err != nil { - return "", err - } - } - - return accessToken.Token, nil -} - -func (c *scopesCacheEntry) refreshAccessToken(ctx context.Context) (*AccessToken, error) { - var accessToken *AccessToken - - // Safeguarding from panic caused by retriever implementation - defer func() { - c.cond.L.Lock() - - c.refreshing = false - - if accessToken != nil { - c.accessToken = accessToken - } - - c.cond.Broadcast() - c.cond.L.Unlock() - }() - - token, err := c.retriever.GetAccessToken(ctx, c.scopes) - if err != nil { - return nil, err - } - accessToken = token - return accessToken, nil -} - -func getKeyForScopes(scopes []string) string { - if len(scopes) > 1 { - arr := make([]string, len(scopes)) - copy(arr, scopes) - sort.Strings(arr) - scopes = arr - } - - return strings.Join(scopes, " ") -} diff --git a/pkg/tsdb/azuremonitor/aztokenprovider/token_cache_test.go b/pkg/tsdb/azuremonitor/aztokenprovider/token_cache_test.go deleted file mode 100644 index 1d26d004c3f..00000000000 --- a/pkg/tsdb/azuremonitor/aztokenprovider/token_cache_test.go +++ /dev/null @@ -1,457 +0,0 @@ -package aztokenprovider - -import ( - "context" - "errors" - "fmt" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type fakeRetriever struct { - key string - initCalledTimes int - calledTimes int - initFunc func() error - getAccessTokenFunc func(ctx context.Context, scopes []string) (*AccessToken, error) -} - -func (c *fakeRetriever) GetCacheKey() string { - return c.key -} - -func (c *fakeRetriever) Reset() { - c.initCalledTimes = 0 - c.calledTimes = 0 -} - -func (c *fakeRetriever) Init() error { - c.initCalledTimes = c.initCalledTimes + 1 - if c.initFunc != nil { - return c.initFunc() - } - return nil -} - -func (c *fakeRetriever) GetAccessToken(ctx context.Context, scopes []string) (*AccessToken, error) { - c.calledTimes = c.calledTimes + 1 - if c.getAccessTokenFunc != nil { - return c.getAccessTokenFunc(ctx, scopes) - } - fakeAccessToken := &AccessToken{Token: fmt.Sprintf("%v-token-%v", c.key, c.calledTimes), ExpiresOn: timeNow().Add(time.Hour)} - return fakeAccessToken, nil -} - -func TestConcurrentTokenCache_GetAccessToken(t *testing.T) { - ctx := context.Background() - - scopes1 := []string{"Scope1"} - scopes2 := []string{"Scope2"} - - t.Run("should request access token from retriever", func(t *testing.T) { - cache := NewConcurrentTokenCache() - tokenRetriever := &fakeRetriever{key: "retriever"} - - token, err := cache.GetAccessToken(ctx, tokenRetriever, scopes1) - require.NoError(t, err) - assert.Equal(t, "retriever-token-1", token) - - assert.Equal(t, 1, tokenRetriever.calledTimes) - }) - - t.Run("should return cached token for same scopes", func(t *testing.T) { - var token1, token2 string - var err error - - cache := NewConcurrentTokenCache() - credential := &fakeRetriever{key: "credential-1"} - - token1, err = cache.GetAccessToken(ctx, credential, scopes1) - require.NoError(t, err) - assert.Equal(t, "credential-1-token-1", token1) - - token2, err = cache.GetAccessToken(ctx, credential, scopes2) - require.NoError(t, err) - assert.Equal(t, "credential-1-token-2", token2) - - token1, err = cache.GetAccessToken(ctx, credential, scopes1) - require.NoError(t, err) - assert.Equal(t, "credential-1-token-1", token1) - - token2, err = cache.GetAccessToken(ctx, credential, scopes2) - require.NoError(t, err) - assert.Equal(t, "credential-1-token-2", token2) - - assert.Equal(t, 2, credential.calledTimes) - }) - - t.Run("should return cached token for same credentials", func(t *testing.T) { - var token1, token2 string - var err error - - cache := NewConcurrentTokenCache() - credential1 := &fakeRetriever{key: "credential-1"} - credential2 := &fakeRetriever{key: "credential-2"} - - token1, err = cache.GetAccessToken(ctx, credential1, scopes1) - require.NoError(t, err) - assert.Equal(t, "credential-1-token-1", token1) - - token2, err = cache.GetAccessToken(ctx, credential2, scopes1) - require.NoError(t, err) - assert.Equal(t, "credential-2-token-1", token2) - - token1, err = cache.GetAccessToken(ctx, credential1, scopes1) - require.NoError(t, err) - assert.Equal(t, "credential-1-token-1", token1) - - token2, err = cache.GetAccessToken(ctx, credential2, scopes1) - require.NoError(t, err) - assert.Equal(t, "credential-2-token-1", token2) - - assert.Equal(t, 1, credential1.calledTimes) - assert.Equal(t, 1, credential2.calledTimes) - }) -} - -func TestCredentialCacheEntry_EnsureInitialized(t *testing.T) { - t.Run("when retriever init returns error", func(t *testing.T) { - tokenRetriever := &fakeRetriever{ - initFunc: func() error { - return errors.New("unable to initialize") - }, - } - - t.Run("should return error", func(t *testing.T) { - cacheEntry := &credentialCacheEntry{ - retriever: tokenRetriever, - } - - err := cacheEntry.ensureInitialized() - - assert.Error(t, err) - }) - - t.Run("should call init again each time and return error", func(t *testing.T) { - tokenRetriever.Reset() - - cacheEntry := &credentialCacheEntry{ - retriever: tokenRetriever, - } - - var err error - err = cacheEntry.ensureInitialized() - assert.Error(t, err) - - err = cacheEntry.ensureInitialized() - assert.Error(t, err) - - err = cacheEntry.ensureInitialized() - assert.Error(t, err) - - assert.Equal(t, 3, tokenRetriever.initCalledTimes) - }) - }) - - t.Run("when retriever init returns error only once", func(t *testing.T) { - var times = 0 - tokenRetriever := &fakeRetriever{ - initFunc: func() error { - times = times + 1 - if times == 1 { - return errors.New("unable to initialize") - } - return nil - }, - } - - t.Run("should call retriever init again only while it returns error", func(t *testing.T) { - cacheEntry := &credentialCacheEntry{ - retriever: tokenRetriever, - } - - var err error - err = cacheEntry.ensureInitialized() - assert.Error(t, err) - - err = cacheEntry.ensureInitialized() - assert.NoError(t, err) - - err = cacheEntry.ensureInitialized() - assert.NoError(t, err) - - assert.Equal(t, 2, tokenRetriever.initCalledTimes) - }) - }) - - t.Run("when retriever init panics", func(t *testing.T) { - tokenRetriever := &fakeRetriever{ - initFunc: func() error { - panic(errors.New("unable to initialize")) - }, - } - - t.Run("should call retriever init again each time", func(t *testing.T) { - tokenRetriever.Reset() - - cacheEntry := &credentialCacheEntry{ - retriever: tokenRetriever, - } - - func() { - defer func() { - assert.NotNil(t, recover(), "retriever expected to panic") - }() - _ = cacheEntry.ensureInitialized() - }() - - func() { - defer func() { - assert.NotNil(t, recover(), "retriever expected to panic") - }() - _ = cacheEntry.ensureInitialized() - }() - - func() { - defer func() { - assert.NotNil(t, recover(), "retriever expected to panic") - }() - _ = cacheEntry.ensureInitialized() - }() - - assert.Equal(t, 3, tokenRetriever.initCalledTimes) - }) - }) - - t.Run("when retriever init panics only once", func(t *testing.T) { - var times = 0 - tokenRetriever := &fakeRetriever{ - initFunc: func() error { - times = times + 1 - if times == 1 { - panic(errors.New("unable to initialize")) - } - return nil - }, - } - - t.Run("should call retriever init again only while it panics", func(t *testing.T) { - cacheEntry := &credentialCacheEntry{ - retriever: tokenRetriever, - } - - var err error - - func() { - defer func() { - assert.NotNil(t, recover(), "retriever expected to panic") - }() - _ = cacheEntry.ensureInitialized() - }() - - func() { - defer func() { - assert.Nil(t, recover(), "retriever not expected to panic") - }() - err = cacheEntry.ensureInitialized() - assert.NoError(t, err) - }() - - func() { - defer func() { - assert.Nil(t, recover(), "retriever not expected to panic") - }() - err = cacheEntry.ensureInitialized() - assert.NoError(t, err) - }() - - assert.Equal(t, 2, tokenRetriever.initCalledTimes) - }) - }) -} - -func TestScopesCacheEntry_GetAccessToken(t *testing.T) { - ctx := context.Background() - - scopes := []string{"Scope1"} - - t.Run("when retriever getAccessToken returns error", func(t *testing.T) { - tokenRetriever := &fakeRetriever{ - getAccessTokenFunc: func(ctx context.Context, scopes []string) (*AccessToken, error) { - invalidToken := &AccessToken{Token: "invalid_token", ExpiresOn: timeNow().Add(time.Hour)} - return invalidToken, errors.New("unable to get access token") - }, - } - - t.Run("should return error", func(t *testing.T) { - cacheEntry := &scopesCacheEntry{ - retriever: tokenRetriever, - scopes: scopes, - cond: sync.NewCond(&sync.Mutex{}), - } - - accessToken, err := cacheEntry.getAccessToken(ctx) - - assert.Error(t, err) - assert.Equal(t, "", accessToken) - }) - - t.Run("should call retriever again each time and return error", func(t *testing.T) { - tokenRetriever.Reset() - - cacheEntry := &scopesCacheEntry{ - retriever: tokenRetriever, - scopes: scopes, - cond: sync.NewCond(&sync.Mutex{}), - } - - var err error - _, err = cacheEntry.getAccessToken(ctx) - assert.Error(t, err) - - _, err = cacheEntry.getAccessToken(ctx) - assert.Error(t, err) - - _, err = cacheEntry.getAccessToken(ctx) - assert.Error(t, err) - - assert.Equal(t, 3, tokenRetriever.calledTimes) - }) - }) - - t.Run("when retriever getAccessToken returns error only once", func(t *testing.T) { - var times = 0 - retriever := &fakeRetriever{ - getAccessTokenFunc: func(ctx context.Context, scopes []string) (*AccessToken, error) { - times = times + 1 - if times == 1 { - invalidToken := &AccessToken{Token: "invalid_token", ExpiresOn: timeNow().Add(time.Hour)} - return invalidToken, errors.New("unable to get access token") - } - fakeAccessToken := &AccessToken{Token: fmt.Sprintf("token-%v", times), ExpiresOn: timeNow().Add(time.Hour)} - return fakeAccessToken, nil - }, - } - - t.Run("should call retriever again only while it returns error", func(t *testing.T) { - cacheEntry := &scopesCacheEntry{ - retriever: retriever, - scopes: scopes, - cond: sync.NewCond(&sync.Mutex{}), - } - - var accessToken string - var err error - - _, err = cacheEntry.getAccessToken(ctx) - assert.Error(t, err) - - accessToken, err = cacheEntry.getAccessToken(ctx) - assert.NoError(t, err) - assert.Equal(t, "token-2", accessToken) - - accessToken, err = cacheEntry.getAccessToken(ctx) - assert.NoError(t, err) - assert.Equal(t, "token-2", accessToken) - - assert.Equal(t, 2, retriever.calledTimes) - }) - }) - - t.Run("when retriever getAccessToken panics", func(t *testing.T) { - tokenRetriever := &fakeRetriever{ - getAccessTokenFunc: func(ctx context.Context, scopes []string) (*AccessToken, error) { - panic(errors.New("unable to get access token")) - }, - } - - t.Run("should call retriever again each time", func(t *testing.T) { - tokenRetriever.Reset() - - cacheEntry := &scopesCacheEntry{ - retriever: tokenRetriever, - scopes: scopes, - cond: sync.NewCond(&sync.Mutex{}), - } - - func() { - defer func() { - assert.NotNil(t, recover(), "retriever expected to panic") - }() - _, _ = cacheEntry.getAccessToken(ctx) - }() - - func() { - defer func() { - assert.NotNil(t, recover(), "retriever expected to panic") - }() - _, _ = cacheEntry.getAccessToken(ctx) - }() - - func() { - defer func() { - assert.NotNil(t, recover(), "retriever expected to panic") - }() - _, _ = cacheEntry.getAccessToken(ctx) - }() - - assert.Equal(t, 3, tokenRetriever.calledTimes) - }) - }) - - t.Run("when retriever getAccessToken panics only once", func(t *testing.T) { - var times = 0 - tokenRetriever := &fakeRetriever{ - getAccessTokenFunc: func(ctx context.Context, scopes []string) (*AccessToken, error) { - times = times + 1 - if times == 1 { - panic(errors.New("unable to get access token")) - } - fakeAccessToken := &AccessToken{Token: fmt.Sprintf("token-%v", times), ExpiresOn: timeNow().Add(time.Hour)} - return fakeAccessToken, nil - }, - } - - t.Run("should call retriever again only while it panics", func(t *testing.T) { - cacheEntry := &scopesCacheEntry{ - retriever: tokenRetriever, - scopes: scopes, - cond: sync.NewCond(&sync.Mutex{}), - } - - var accessToken string - var err error - - func() { - defer func() { - assert.NotNil(t, recover(), "retriever expected to panic") - }() - _, _ = cacheEntry.getAccessToken(ctx) - }() - - func() { - defer func() { - assert.Nil(t, recover(), "retriever not expected to panic") - }() - accessToken, err = cacheEntry.getAccessToken(ctx) - assert.NoError(t, err) - assert.Equal(t, "token-2", accessToken) - }() - - func() { - defer func() { - assert.Nil(t, recover(), "retriever not expected to panic") - }() - accessToken, err = cacheEntry.getAccessToken(ctx) - assert.NoError(t, err) - assert.Equal(t, "token-2", accessToken) - }() - - assert.Equal(t, 2, tokenRetriever.calledTimes) - }) - }) -} diff --git a/pkg/tsdb/azuremonitor/aztokenprovider/token_provider.go b/pkg/tsdb/azuremonitor/aztokenprovider/token_provider.go deleted file mode 100644 index 0bbc1b02931..00000000000 --- a/pkg/tsdb/azuremonitor/aztokenprovider/token_provider.go +++ /dev/null @@ -1,187 +0,0 @@ -package aztokenprovider - -import ( - "context" - "crypto/sha256" - "fmt" - - "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" - "github.com/Azure/azure-sdk-for-go/sdk/azidentity" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" -) - -var ( - azureTokenCache = NewConcurrentTokenCache() -) - -type AzureTokenProvider interface { - GetAccessToken(ctx context.Context, scopes []string) (string, error) -} - -type tokenProviderImpl struct { - tokenRetriever TokenRetriever -} - -func NewAzureAccessTokenProvider(cfg *setting.Cfg, credentials azcredentials.AzureCredentials) (AzureTokenProvider, error) { - if cfg == nil { - err := fmt.Errorf("parameter 'cfg' cannot be nil") - return nil, err - } - if credentials == nil { - err := fmt.Errorf("parameter 'credentials' cannot be nil") - return nil, err - } - - var tokenRetriever TokenRetriever - - switch c := credentials.(type) { - case *azcredentials.AzureManagedIdentityCredentials: - if !cfg.Azure.ManagedIdentityEnabled { - err := fmt.Errorf("managed identity authentication is not enabled in Grafana config") - return nil, err - } else { - tokenRetriever = getManagedIdentityTokenRetriever(cfg, c) - } - case *azcredentials.AzureClientSecretCredentials: - tokenRetriever = getClientSecretTokenRetriever(c) - default: - err := fmt.Errorf("credentials of type '%s' not supported by authentication provider", c.AzureAuthType()) - return nil, err - } - - tokenProvider := &tokenProviderImpl{ - tokenRetriever: tokenRetriever, - } - - return tokenProvider, nil -} - -func (provider *tokenProviderImpl) GetAccessToken(ctx context.Context, scopes []string) (string, error) { - if ctx == nil { - err := fmt.Errorf("parameter 'ctx' cannot be nil") - return "", err - } - if scopes == nil { - err := fmt.Errorf("parameter 'scopes' cannot be nil") - return "", err - } - - accessToken, err := azureTokenCache.GetAccessToken(ctx, provider.tokenRetriever, scopes) - if err != nil { - return "", err - } - return accessToken, nil -} - -func getManagedIdentityTokenRetriever(cfg *setting.Cfg, credentials *azcredentials.AzureManagedIdentityCredentials) TokenRetriever { - var clientId string - if credentials.ClientId != "" { - clientId = credentials.ClientId - } else { - clientId = cfg.Azure.ManagedIdentityClientId - } - return &managedIdentityTokenRetriever{ - clientId: clientId, - } -} - -func getClientSecretTokenRetriever(credentials *azcredentials.AzureClientSecretCredentials) TokenRetriever { - var authority string - if credentials.Authority != "" { - authority = credentials.Authority - } else { - authority = resolveAuthorityForCloud(credentials.AzureCloud) - } - return &clientSecretTokenRetriever{ - authority: authority, - tenantId: credentials.TenantId, - clientId: credentials.ClientId, - clientSecret: credentials.ClientSecret, - } -} - -func resolveAuthorityForCloud(cloudName string) string { - // Known Azure clouds - switch cloudName { - case setting.AzurePublic: - return azidentity.AzurePublicCloud - case setting.AzureChina: - return azidentity.AzureChina - case setting.AzureUSGovernment: - return azidentity.AzureGovernment - case setting.AzureGermany: - return azidentity.AzureGermany - default: - return "" - } -} - -type managedIdentityTokenRetriever struct { - clientId string - credential azcore.TokenCredential -} - -func (c *managedIdentityTokenRetriever) GetCacheKey() string { - clientId := c.clientId - if clientId == "" { - clientId = "system" - } - return fmt.Sprintf("azure|msi|%s", clientId) -} - -func (c *managedIdentityTokenRetriever) Init() error { - if credential, err := azidentity.NewManagedIdentityCredential(c.clientId, nil); err != nil { - return err - } else { - c.credential = credential - return nil - } -} - -func (c *managedIdentityTokenRetriever) GetAccessToken(ctx context.Context, scopes []string) (*AccessToken, error) { - accessToken, err := c.credential.GetToken(ctx, policy.TokenRequestOptions{Scopes: scopes}) - if err != nil { - return nil, err - } - - return &AccessToken{Token: accessToken.Token, ExpiresOn: accessToken.ExpiresOn}, nil -} - -type clientSecretTokenRetriever struct { - authority string - tenantId string - clientId string - clientSecret string - credential azcore.TokenCredential -} - -func (c *clientSecretTokenRetriever) GetCacheKey() string { - return fmt.Sprintf("azure|clientsecret|%s|%s|%s|%s", c.authority, c.tenantId, c.clientId, hashSecret(c.clientSecret)) -} - -func (c *clientSecretTokenRetriever) Init() error { - options := &azidentity.ClientSecretCredentialOptions{AuthorityHost: c.authority} - if credential, err := azidentity.NewClientSecretCredential(c.tenantId, c.clientId, c.clientSecret, options); err != nil { - return err - } else { - c.credential = credential - return nil - } -} - -func (c *clientSecretTokenRetriever) GetAccessToken(ctx context.Context, scopes []string) (*AccessToken, error) { - accessToken, err := c.credential.GetToken(ctx, policy.TokenRequestOptions{Scopes: scopes}) - if err != nil { - return nil, err - } - - return &AccessToken{Token: accessToken.Token, ExpiresOn: accessToken.ExpiresOn}, nil -} - -func hashSecret(secret string) string { - hash := sha256.New() - _, _ = hash.Write([]byte(secret)) - return fmt.Sprintf("%x", hash.Sum(nil)) -} diff --git a/pkg/tsdb/azuremonitor/aztokenprovider/token_provider_test.go b/pkg/tsdb/azuremonitor/aztokenprovider/token_provider_test.go deleted file mode 100644 index 5b7ec3bcb29..00000000000 --- a/pkg/tsdb/azuremonitor/aztokenprovider/token_provider_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package aztokenprovider - -import ( - "context" - "testing" - - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var getAccessTokenFunc func(credential TokenRetriever, scopes []string) - -type tokenCacheFake struct{} - -func (c *tokenCacheFake) GetAccessToken(_ context.Context, credential TokenRetriever, scopes []string) (string, error) { - getAccessTokenFunc(credential, scopes) - return "4cb83b87-0ffb-4abd-82f6-48a8c08afc53", nil -} - -func TestAzureTokenProvider_GetAccessToken(t *testing.T) { - ctx := context.Background() - - cfg := &setting.Cfg{} - - scopes := []string{ - "https://management.azure.com/.default", - } - - original := azureTokenCache - azureTokenCache = &tokenCacheFake{} - t.Cleanup(func() { azureTokenCache = original }) - - t.Run("when managed identities enabled", func(t *testing.T) { - cfg.Azure.ManagedIdentityEnabled = true - - t.Run("should resolve managed identity retriever if auth type is managed identity", func(t *testing.T) { - credentials := &azcredentials.AzureManagedIdentityCredentials{} - - provider, err := NewAzureAccessTokenProvider(cfg, credentials) - require.NoError(t, err) - - getAccessTokenFunc = func(credential TokenRetriever, scopes []string) { - assert.IsType(t, &managedIdentityTokenRetriever{}, credential) - } - - _, err = provider.GetAccessToken(ctx, scopes) - require.NoError(t, err) - }) - - t.Run("should resolve client secret retriever if auth type is client secret", func(t *testing.T) { - credentials := &azcredentials.AzureClientSecretCredentials{} - - provider, err := NewAzureAccessTokenProvider(cfg, credentials) - require.NoError(t, err) - - getAccessTokenFunc = func(credential TokenRetriever, scopes []string) { - assert.IsType(t, &clientSecretTokenRetriever{}, credential) - } - - _, err = provider.GetAccessToken(ctx, scopes) - require.NoError(t, err) - }) - }) - - t.Run("when managed identities disabled", func(t *testing.T) { - cfg.Azure.ManagedIdentityEnabled = false - - t.Run("should return error if auth type is managed identity", func(t *testing.T) { - credentials := &azcredentials.AzureManagedIdentityCredentials{} - - _, err := NewAzureAccessTokenProvider(cfg, credentials) - assert.Error(t, err, "managed identity authentication is not enabled in Grafana config") - }) - }) -} - -func TestAzureTokenProvider_getClientSecretCredential(t *testing.T) { - credentials := &azcredentials.AzureClientSecretCredentials{ - AzureCloud: setting.AzurePublic, - Authority: "", - TenantId: "7dcf1d1a-4ec0-41f2-ac29-c1538a698bc4", - ClientId: "1af7c188-e5b6-4f96-81b8-911761bdd459", - ClientSecret: "0416d95e-8af8-472c-aaa3-15c93c46080a", - } - - t.Run("should return clientSecretTokenRetriever with values", func(t *testing.T) { - result := getClientSecretTokenRetriever(credentials) - assert.IsType(t, &clientSecretTokenRetriever{}, result) - - credential := (result).(*clientSecretTokenRetriever) - - assert.Equal(t, "https://login.microsoftonline.com/", credential.authority) - assert.Equal(t, "7dcf1d1a-4ec0-41f2-ac29-c1538a698bc4", credential.tenantId) - assert.Equal(t, "1af7c188-e5b6-4f96-81b8-911761bdd459", credential.clientId) - assert.Equal(t, "0416d95e-8af8-472c-aaa3-15c93c46080a", credential.clientSecret) - }) - - t.Run("authority should selected based on cloud", func(t *testing.T) { - originalCloud := credentials.AzureCloud - defer func() { credentials.AzureCloud = originalCloud }() - - credentials.AzureCloud = setting.AzureChina - - result := getClientSecretTokenRetriever(credentials) - assert.IsType(t, &clientSecretTokenRetriever{}, result) - - credential := (result).(*clientSecretTokenRetriever) - - assert.Equal(t, "https://login.chinacloudapi.cn/", credential.authority) - }) - - t.Run("explicitly set authority should have priority over cloud", func(t *testing.T) { - originalCloud := credentials.AzureCloud - defer func() { credentials.AzureCloud = originalCloud }() - - credentials.AzureCloud = setting.AzureChina - credentials.Authority = "https://another.com/" - - result := getClientSecretTokenRetriever(credentials) - assert.IsType(t, &clientSecretTokenRetriever{}, result) - - credential := (result).(*clientSecretTokenRetriever) - - assert.Equal(t, "https://another.com/", credential.authority) - }) -} diff --git a/pkg/tsdb/azuremonitor/azuremonitor-resource-handler_test.go b/pkg/tsdb/azuremonitor/azuremonitor-resource-handler_test.go index efe20cc8f30..61b71e480de 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-resource-handler_test.go +++ b/pkg/tsdb/azuremonitor/azuremonitor-resource-handler_test.go @@ -6,9 +6,11 @@ import ( "net/http/httptest" "testing" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana-azure-sdk-go/azsettings" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/metrics" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" + "github.com/stretchr/testify/require" ) @@ -99,7 +101,7 @@ func Test_handleResourceReq(t *testing.T) { im: &fakeInstance{ services: map[string]types.DatasourceService{ azureMonitor: { - URL: routes[setting.AzurePublic][azureMonitor].URL, + URL: routes[azsettings.AzurePublic][azureMonitor].URL, HTTPClient: &http.Client{}, }, }, diff --git a/pkg/tsdb/azuremonitor/azuremonitor.go b/pkg/tsdb/azuremonitor/azuremonitor.go index 10d54716027..2ef013fab7d 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor.go +++ b/pkg/tsdb/azuremonitor/azuremonitor.go @@ -37,7 +37,7 @@ func ProvideService(cfg *setting.Cfg, httpClientProvider *httpclient.Provider, t executors[deprecated.AppInsights] = &deprecated.ApplicationInsightsDatasource{Proxy: proxy} } - im := datasource.NewInstanceManager(NewInstanceSettings(cfg, *httpClientProvider, executors)) + im := datasource.NewInstanceManager(NewInstanceSettings(cfg, httpClientProvider, executors)) s := &Service{ im: im, @@ -68,7 +68,7 @@ type Service struct { tracer tracing.Tracer } -func getDatasourceService(cfg *setting.Cfg, clientProvider httpclient.Provider, dsInfo types.DatasourceInfo, routeName string) (types.DatasourceService, error) { +func getDatasourceService(cfg *setting.Cfg, clientProvider *httpclient.Provider, dsInfo types.DatasourceInfo, routeName string) (types.DatasourceService, error) { route := dsInfo.Routes[routeName] client, err := newHTTPClient(route, dsInfo, cfg, clientProvider) if err != nil { @@ -80,7 +80,7 @@ func getDatasourceService(cfg *setting.Cfg, clientProvider httpclient.Provider, }, nil } -func NewInstanceSettings(cfg *setting.Cfg, clientProvider httpclient.Provider, executors map[string]azDatasourceExecutor) datasource.InstanceFactoryFunc { +func NewInstanceSettings(cfg *setting.Cfg, clientProvider *httpclient.Provider, executors map[string]azDatasourceExecutor) datasource.InstanceFactoryFunc { return func(settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { jsonData, err := simplejson.NewJson(settings.JSONData) if err != nil { diff --git a/pkg/tsdb/azuremonitor/azuremonitor_test.go b/pkg/tsdb/azuremonitor/azuremonitor_test.go index 656e221d3d0..0e5d1f99017 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor_test.go +++ b/pkg/tsdb/azuremonitor/azuremonitor_test.go @@ -6,14 +6,17 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/grafana/grafana-azure-sdk-go/azcredentials" + "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/deprecated" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -61,10 +64,10 @@ func TestNewInstanceSettings(t *testing.T) { ID: 40, }, expectedModel: types.DatasourceInfo{ - Cloud: setting.AzurePublic, + Cloud: azsettings.AzurePublic, Credentials: &azcredentials.AzureManagedIdentityCredentials{}, Settings: types.AzureMonitorSettings{}, - Routes: routes[setting.AzurePublic], + Routes: routes[azsettings.AzurePublic], JSONData: map[string]interface{}{"azureAuthType": "msi"}, DatasourceID: 40, DecryptedSecureJSONData: map[string]string{"key": "value"}, @@ -75,14 +78,14 @@ func TestNewInstanceSettings(t *testing.T) { } cfg := &setting.Cfg{ - Azure: setting.AzureSettings{ - Cloud: setting.AzurePublic, + Azure: &azsettings.AzureSettings{ + Cloud: azsettings.AzurePublic, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - factory := NewInstanceSettings(cfg, httpclient.Provider{}, map[string]azDatasourceExecutor{}) + factory := NewInstanceSettings(cfg, &httpclient.Provider{}, map[string]azDatasourceExecutor{}) instance, err := factory(tt.settings) tt.Err(t, err) if !cmp.Equal(instance, tt.expectedModel) { diff --git a/pkg/tsdb/azuremonitor/credentials.go b/pkg/tsdb/azuremonitor/credentials.go index 1e8b6e08abf..be155b764ee 100644 --- a/pkg/tsdb/azuremonitor/credentials.go +++ b/pkg/tsdb/azuremonitor/credentials.go @@ -3,9 +3,11 @@ package azuremonitor import ( "fmt" + "github.com/grafana/grafana-azure-sdk-go/azcredentials" + "github.com/grafana/grafana-azure-sdk-go/azsettings" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" ) // Azure cloud names specific to Azure Monitor @@ -43,17 +45,17 @@ func getDefaultAzureCloud(cfg *setting.Cfg) (string, error) { // Allow only known cloud names cloudName := cfg.Azure.Cloud switch cloudName { - case setting.AzurePublic: - return setting.AzurePublic, nil - case setting.AzureChina: - return setting.AzureChina, nil - case setting.AzureUSGovernment: - return setting.AzureUSGovernment, nil - case setting.AzureGermany: - return setting.AzureGermany, nil + case azsettings.AzurePublic: + return azsettings.AzurePublic, nil + case azsettings.AzureChina: + return azsettings.AzureChina, nil + case azsettings.AzureUSGovernment: + return azsettings.AzureUSGovernment, nil + case azsettings.AzureGermany: + return azsettings.AzureGermany, nil case "": // Not set cloud defaults to public - return setting.AzurePublic, nil + return azsettings.AzurePublic, nil default: err := fmt.Errorf("the cloud '%s' not supported", cloudName) return "", err @@ -63,13 +65,13 @@ func getDefaultAzureCloud(cfg *setting.Cfg) (string, error) { func normalizeAzureCloud(cloudName string) (string, error) { switch cloudName { case azureMonitorPublic: - return setting.AzurePublic, nil + return azsettings.AzurePublic, nil case azureMonitorChina: - return setting.AzureChina, nil + return azsettings.AzureChina, nil case azureMonitorUSGovernment: - return setting.AzureUSGovernment, nil + return azsettings.AzureUSGovernment, nil case azureMonitorGermany: - return setting.AzureGermany, nil + return azsettings.AzureGermany, nil default: err := fmt.Errorf("the cloud '%s' not supported", cloudName) return "", err diff --git a/pkg/tsdb/azuremonitor/credentials_test.go b/pkg/tsdb/azuremonitor/credentials_test.go index 4cefa5697c3..35fb2ebfb9f 100644 --- a/pkg/tsdb/azuremonitor/credentials_test.go +++ b/pkg/tsdb/azuremonitor/credentials_test.go @@ -3,15 +3,19 @@ package azuremonitor import ( "testing" + "github.com/grafana/grafana-azure-sdk-go/azcredentials" + "github.com/grafana/grafana-azure-sdk-go/azsettings" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCredentials_getAuthType(t *testing.T) { - cfg := &setting.Cfg{} + cfg := &setting.Cfg{ + Azure: &azsettings.AzureSettings{}, + } t.Run("when managed identities enabled", func(t *testing.T) { cfg.Azure.ManagedIdentityEnabled = true @@ -76,8 +80,8 @@ func TestCredentials_getAuthType(t *testing.T) { func TestCredentials_getAzureCloud(t *testing.T) { cfg := &setting.Cfg{ - Azure: setting.AzureSettings{ - Cloud: setting.AzureChina, + Azure: &azsettings.AzureSettings{ + Cloud: azsettings.AzureChina, }, } @@ -91,12 +95,12 @@ func TestCredentials_getAzureCloud(t *testing.T) { cloud, err := getAzureCloud(cfg, jsonData) require.NoError(t, err) - assert.Equal(t, setting.AzureChina, cloud) + assert.Equal(t, azsettings.AzureChina, cloud) }) t.Run("should be public if not set in server configuration", func(t *testing.T) { cfg := &setting.Cfg{ - Azure: setting.AzureSettings{ + Azure: &azsettings.AzureSettings{ Cloud: "", }, } @@ -104,7 +108,7 @@ func TestCredentials_getAzureCloud(t *testing.T) { cloud, err := getAzureCloud(cfg, jsonData) require.NoError(t, err) - assert.Equal(t, setting.AzurePublic, cloud) + assert.Equal(t, azsettings.AzurePublic, cloud) }) }) @@ -118,7 +122,7 @@ func TestCredentials_getAzureCloud(t *testing.T) { cloud, err := getAzureCloud(cfg, jsonData) require.NoError(t, err) - assert.Equal(t, setting.AzureGermany, cloud) + assert.Equal(t, azsettings.AzureGermany, cloud) }) t.Run("should be from server configuration if not set in datasource", func(t *testing.T) { @@ -130,15 +134,15 @@ func TestCredentials_getAzureCloud(t *testing.T) { cloud, err := getAzureCloud(cfg, jsonData) require.NoError(t, err) - assert.Equal(t, setting.AzureChina, cloud) + assert.Equal(t, azsettings.AzureChina, cloud) }) }) } func TestCredentials_getAzureCredentials(t *testing.T) { cfg := &setting.Cfg{ - Azure: setting.AzureSettings{ - Cloud: setting.AzureChina, + Azure: &azsettings.AzureSettings{ + Cloud: azsettings.AzureChina, }, } @@ -175,8 +179,8 @@ func TestCredentials_getAzureCredentials(t *testing.T) { t.Run("should return client secret credentials", func(t *testing.T) { cfg := &setting.Cfg{ - Azure: setting.AzureSettings{ - Cloud: setting.AzureChina, + Azure: &azsettings.AzureSettings{ + Cloud: azsettings.AzureChina, }, } @@ -185,7 +189,7 @@ func TestCredentials_getAzureCredentials(t *testing.T) { require.IsType(t, &azcredentials.AzureClientSecretCredentials{}, credentials) clientSecretCredentials := credentials.(*azcredentials.AzureClientSecretCredentials) - assert.Equal(t, setting.AzureGermany, clientSecretCredentials.AzureCloud) + assert.Equal(t, azsettings.AzureGermany, clientSecretCredentials.AzureCloud) assert.Equal(t, "9b9d90ee-a5cc-49c2-b97e-0d1b0f086b5c", clientSecretCredentials.TenantId) assert.Equal(t, "849ccbb0-92eb-4226-b228-ef391abd8fe6", clientSecretCredentials.ClientId) assert.Equal(t, "59e3498f-eb12-4943-b8f0-a5aa42640058", clientSecretCredentials.ClientSecret) diff --git a/pkg/tsdb/azuremonitor/httpclient.go b/pkg/tsdb/azuremonitor/httpclient.go index ad031264f89..a8dc2ad377d 100644 --- a/pkg/tsdb/azuremonitor/httpclient.go +++ b/pkg/tsdb/azuremonitor/httpclient.go @@ -3,23 +3,17 @@ package azuremonitor import ( "net/http" - "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-azure-sdk-go/azhttpclient" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + + "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/aztokenprovider" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/deprecated" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) -func getMiddlewares(route types.AzRoute, model types.DatasourceInfo, cfg *setting.Cfg) ([]httpclient.Middleware, error) { - middlewares := []httpclient.Middleware{} - - if len(route.Scopes) > 0 { - tokenProvider, err := aztokenprovider.NewAzureAccessTokenProvider(cfg, model.Credentials) - if err != nil { - return nil, err - } - middlewares = append(middlewares, aztokenprovider.AuthMiddleware(tokenProvider, route.Scopes)) - } +func getMiddlewares(route types.AzRoute, model types.DatasourceInfo) ([]sdkhttpclient.Middleware, error) { + var middlewares []sdkhttpclient.Middleware // Remove with Grafana 9 if apiKeyMiddleware := deprecated.GetAppInsightsMiddleware(route.URL, model.DecryptedSecureJSONData["appInsightsApiKey"]); apiKeyMiddleware != nil { @@ -30,13 +24,20 @@ func getMiddlewares(route types.AzRoute, model types.DatasourceInfo, cfg *settin } func newHTTPClient(route types.AzRoute, model types.DatasourceInfo, cfg *setting.Cfg, clientProvider httpclient.Provider) (*http.Client, error) { - m, err := getMiddlewares(route, model, cfg) + m, err := getMiddlewares(route, model) if err != nil { return nil, err } - return clientProvider.New(httpclient.Options{ + opts := sdkhttpclient.Options{ Headers: route.Headers, Middlewares: m, - }) + } + + // Use Azure credentials if the route has OAuth scopes configured + if len(route.Scopes) > 0 { + azhttpclient.AddAzureAuthentication(&opts, cfg.Azure, model.Credentials, route.Scopes) + } + + return clientProvider.New(opts) } diff --git a/pkg/tsdb/azuremonitor/httpclient_test.go b/pkg/tsdb/azuremonitor/httpclient_test.go index 5679529459c..0867acd0144 100644 --- a/pkg/tsdb/azuremonitor/httpclient_test.go +++ b/pkg/tsdb/azuremonitor/httpclient_test.go @@ -1,17 +1,22 @@ package azuremonitor import ( + "crypto/tls" + "net/http" "testing" + "github.com/grafana/grafana-azure-sdk-go/azcredentials" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + + "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/deprecated" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func Test_httpCliProvider(t *testing.T) { - cfg := &setting.Cfg{} +func TestHttpClient_Middlewares(t *testing.T) { tests := []struct { name string route types.AzRoute @@ -19,18 +24,6 @@ func Test_httpCliProvider(t *testing.T) { expectedMiddlewares int Err require.ErrorAssertionFunc }{ - { - name: "creates an HTTP client with a middleware due to the scope", - route: types.AzRoute{ - URL: "http://route", - Scopes: []string{"http://route/.default"}, - }, - model: types.DatasourceInfo{ - Credentials: &azcredentials.AzureClientSecretCredentials{}, - }, - expectedMiddlewares: 1, - Err: require.NoError, - }, { name: "creates an HTTP client with a middleware due to an app key", route: types.AzRoute{ @@ -61,7 +54,7 @@ func Test_httpCliProvider(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - m, err := getMiddlewares(tt.route, tt.model, cfg) + m, err := getMiddlewares(tt.route, tt.model) require.NoError(t, err) // Cannot test that the cli middleware works properly since the azcore sdk @@ -72,3 +65,63 @@ func Test_httpCliProvider(t *testing.T) { }) } } + +func TestHttpClient_AzureCredentials(t *testing.T) { + model := types.DatasourceInfo{ + Credentials: &azcredentials.AzureManagedIdentityCredentials{}, + } + + cfg := &setting.Cfg{} + provider := &fakeHttpClientProvider{} + + t.Run("should have Azure middleware when scopes provided", func(t *testing.T) { + route := types.AzRoute{ + URL: deprecated.AzAppInsights.URL, + Scopes: []string{"https://management.azure.com/.default"}, + } + + _, err := newHTTPClient(route, model, cfg, provider) + require.NoError(t, err) + + require.NotNil(t, provider.opts) + require.NotNil(t, provider.opts.Middlewares) + assert.Len(t, provider.opts.Middlewares, 1) + }) + + t.Run("should not have Azure middleware when scopes are not provided", func(t *testing.T) { + route := types.AzRoute{ + URL: deprecated.AzAppInsights.URL, + Scopes: []string{}, + } + + _, err := newHTTPClient(route, model, cfg, provider) + require.NoError(t, err) + + assert.NotNil(t, provider.opts) + + if provider.opts.Middlewares != nil { + assert.Len(t, provider.opts.Middlewares, 0) + } + }) +} + +type fakeHttpClientProvider struct { + httpclient.Provider + + opts sdkhttpclient.Options +} + +func (p *fakeHttpClientProvider) New(opts ...sdkhttpclient.Options) (*http.Client, error) { + p.opts = opts[0] + return nil, nil +} + +func (p *fakeHttpClientProvider) GetTransport(opts ...sdkhttpclient.Options) (http.RoundTripper, error) { + p.opts = opts[0] + return nil, nil +} + +func (p *fakeHttpClientProvider) GetTLSConfig(opts ...sdkhttpclient.Options) (*tls.Config, error) { + p.opts = opts[0] + return nil, nil +} diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go index c639ac3a3e9..3def772d76b 100644 --- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go +++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go @@ -81,13 +81,15 @@ func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInf urlComponents["resourceName"] = azJSONModel.ResourceName ub := urlBuilder{ + ResourceURI: azJSONModel.ResourceURI, + // Legacy, used to reconstruct resource URI if it's not present DefaultSubscription: dsInfo.Settings.SubscriptionId, Subscription: queryJSONModel.Subscription, - ResourceGroup: queryJSONModel.AzureMonitor.ResourceGroup, + ResourceGroup: azJSONModel.ResourceGroup, MetricDefinition: azJSONModel.MetricDefinition, ResourceName: azJSONModel.ResourceName, } - azureURL := ub.Build() + azureURL := ub.BuildMetricsURL() alias := azJSONModel.Alias diff --git a/pkg/tsdb/azuremonitor/metrics/url-builder.go b/pkg/tsdb/azuremonitor/metrics/url-builder.go index 53bee4f2269..4745f3bcdf0 100644 --- a/pkg/tsdb/azuremonitor/metrics/url-builder.go +++ b/pkg/tsdb/azuremonitor/metrics/url-builder.go @@ -7,6 +7,10 @@ import ( // urlBuilder builds the URL for calling the Azure Monitor API type urlBuilder struct { + ResourceURI string + + // Following fields will be deprecated in grafana 9 and will not included in new queries. + // For backwards compat, we recreate the ResourceURI using these fields DefaultSubscription string Subscription string ResourceGroup string @@ -14,26 +18,39 @@ type urlBuilder struct { ResourceName string } -// Build checks the metric definition property to see which form of the url +// BuildMetricsURL checks the metric definition property to see which form of the url // should be returned -func (ub *urlBuilder) Build() string { - subscription := ub.Subscription +func (params *urlBuilder) BuildMetricsURL() string { + resourceURI := params.ResourceURI - if ub.Subscription == "" { - subscription = ub.DefaultSubscription + // Prior to Grafana 9, we had a legacy query object rather than a resourceURI, so we manually create the resource URI + if resourceURI == "" { + subscription := params.Subscription + + if params.Subscription == "" { + subscription = params.DefaultSubscription + } + + metricDefinitionArray := strings.Split(params.MetricDefinition, "/") + resourceNameArray := strings.Split(params.ResourceName, "/") + provider := metricDefinitionArray[0] + metricDefinitionArray = metricDefinitionArray[1:] + + urlArray := []string{ + subscription, + "resourceGroups", + params.ResourceGroup, + "providers", + provider, + } + + for i := range metricDefinitionArray { + urlArray = append(urlArray, metricDefinitionArray[i]) + urlArray = append(urlArray, resourceNameArray[i]) + } + + resourceURI = strings.Join(urlArray[:], "/") } - metricDefinitionArray := strings.Split(ub.MetricDefinition, "/") - resourceNameArray := strings.Split(ub.ResourceName, "/") - provider := metricDefinitionArray[0] - metricDefinitionArray = metricDefinitionArray[1:] - - urlArray := []string{subscription, "resourceGroups", ub.ResourceGroup, "providers", provider} - - for i := range metricDefinitionArray { - urlArray = append(urlArray, metricDefinitionArray[i]) - urlArray = append(urlArray, resourceNameArray[i]) - } - - return fmt.Sprintf("%s/providers/microsoft.insights/metrics", strings.Join(urlArray[:], "/")) + return fmt.Sprintf("%s/providers/microsoft.insights/metrics", resourceURI) } diff --git a/pkg/tsdb/azuremonitor/metrics/url-builder_test.go b/pkg/tsdb/azuremonitor/metrics/url-builder_test.go index f6bb94a4664..458cf7b491c 100644 --- a/pkg/tsdb/azuremonitor/metrics/url-builder_test.go +++ b/pkg/tsdb/azuremonitor/metrics/url-builder_test.go @@ -8,65 +8,89 @@ import ( func TestURLBuilder(t *testing.T) { t.Run("AzureMonitor URL Builder", func(t *testing.T) { - t.Run("when metric definition is in the short form", func(t *testing.T) { + t.Run("when only resource uri is provided it returns resource/uri/providers/microsoft.insights/metrics", func(t *testing.T) { ub := &urlBuilder{ - DefaultSubscription: "default-sub", - ResourceGroup: "rg", - MetricDefinition: "Microsoft.Compute/virtualMachines", - ResourceName: "rn", + ResourceURI: "resource/uri", } - url := ub.Build() - require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics") + url := ub.BuildMetricsURL() + require.Equal(t, url, "resource/uri/providers/microsoft.insights/metrics") }) - t.Run("when metric definition is in the short form and a subscription is defined", func(t *testing.T) { - ub := &urlBuilder{ - DefaultSubscription: "default-sub", - Subscription: "specified-sub", - ResourceGroup: "rg", - MetricDefinition: "Microsoft.Compute/virtualMachines", - ResourceName: "rn", - } - - url := ub.Build() - require.Equal(t, url, "specified-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics") - }) - - t.Run("when metric definition is Microsoft.Storage/storageAccounts/blobServices", func(t *testing.T) { - ub := &urlBuilder{ - DefaultSubscription: "default-sub", - ResourceGroup: "rg", - MetricDefinition: "Microsoft.Storage/storageAccounts/blobServices", - ResourceName: "rn1/default", - } - - url := ub.Build() - require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/providers/microsoft.insights/metrics") - }) - - t.Run("when metric definition is Microsoft.Storage/storageAccounts/fileServices", func(t *testing.T) { - ub := &urlBuilder{ - DefaultSubscription: "default-sub", - ResourceGroup: "rg", - MetricDefinition: "Microsoft.Storage/storageAccounts/fileServices", - ResourceName: "rn1/default", - } - - url := ub.Build() - require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/providers/microsoft.insights/metrics") - }) - - t.Run("when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes", func(t *testing.T) { + t.Run("when resource uri and legacy fields are provided the legacy fields are ignored", func(t *testing.T) { ub := &urlBuilder{ + ResourceURI: "resource/uri", DefaultSubscription: "default-sub", ResourceGroup: "rg", MetricDefinition: "Microsoft.NetApp/netAppAccounts/capacityPools/volumes", ResourceName: "rn1/rn2/rn3", } - url := ub.Build() - require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/providers/microsoft.insights/metrics") + url := ub.BuildMetricsURL() + require.Equal(t, url, "resource/uri/providers/microsoft.insights/metrics") + }) + + t.Run("Legacy URL Builder params", func(t *testing.T) { + t.Run("when metric definition is in the short form", func(t *testing.T) { + ub := &urlBuilder{ + DefaultSubscription: "default-sub", + ResourceGroup: "rg", + MetricDefinition: "Microsoft.Compute/virtualMachines", + ResourceName: "rn", + } + + url := ub.BuildMetricsURL() + require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics") + }) + + t.Run("when metric definition is in the short form and a subscription is defined", func(t *testing.T) { + ub := &urlBuilder{ + DefaultSubscription: "default-sub", + Subscription: "specified-sub", + ResourceGroup: "rg", + MetricDefinition: "Microsoft.Compute/virtualMachines", + ResourceName: "rn", + } + + url := ub.BuildMetricsURL() + require.Equal(t, url, "specified-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics") + }) + + t.Run("when metric definition is Microsoft.Storage/storageAccounts/blobServices", func(t *testing.T) { + ub := &urlBuilder{ + DefaultSubscription: "default-sub", + ResourceGroup: "rg", + MetricDefinition: "Microsoft.Storage/storageAccounts/blobServices", + ResourceName: "rn1/default", + } + + url := ub.BuildMetricsURL() + require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/providers/microsoft.insights/metrics") + }) + + t.Run("when metric definition is Microsoft.Storage/storageAccounts/fileServices", func(t *testing.T) { + ub := &urlBuilder{ + DefaultSubscription: "default-sub", + ResourceGroup: "rg", + MetricDefinition: "Microsoft.Storage/storageAccounts/fileServices", + ResourceName: "rn1/default", + } + + url := ub.BuildMetricsURL() + require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/providers/microsoft.insights/metrics") + }) + + t.Run("when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes", func(t *testing.T) { + ub := &urlBuilder{ + DefaultSubscription: "default-sub", + ResourceGroup: "rg", + MetricDefinition: "Microsoft.NetApp/netAppAccounts/capacityPools/volumes", + ResourceName: "rn1/rn2/rn3", + } + + url := ub.BuildMetricsURL() + require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/providers/microsoft.insights/metrics") + }) }) }) } diff --git a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go index 39ce41b8b22..f99a837a6bc 100644 --- a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go +++ b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go @@ -2,8 +2,6 @@ package resourcegraph import ( "bytes" - "time" - "context" "encoding/json" "fmt" @@ -11,9 +9,12 @@ import ( "net/http" "net/url" "path" + "time" + "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/setting" @@ -268,13 +269,13 @@ func (e *AzureResourceGraphDatasource) unmarshalResponse(res *http.Response) (Az func GetAzurePortalUrl(azureCloud string) (string, error) { switch azureCloud { - case setting.AzurePublic: + case azsettings.AzurePublic: return "https://portal.azure.com", nil - case setting.AzureChina: + case azsettings.AzureChina: return "https://portal.azure.cn", nil - case setting.AzureUSGovernment: + case azsettings.AzureUSGovernment: return "https://portal.azure.us", nil - case setting.AzureGermany: + case azsettings.AzureGermany: return "https://portal.microsoftazure.de", nil default: return "", fmt.Errorf("the cloud is not supported") diff --git a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go index a742a066a8f..ca397a7d80d 100644 --- a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go +++ b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go @@ -10,10 +10,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -137,12 +138,12 @@ func TestAddConfigData(t *testing.T) { } func TestGetAzurePortalUrl(t *testing.T) { - clouds := []string{setting.AzurePublic, setting.AzureChina, setting.AzureUSGovernment, setting.AzureGermany} + clouds := []string{azsettings.AzurePublic, azsettings.AzureChina, azsettings.AzureUSGovernment, azsettings.AzureGermany} expectedAzurePortalUrl := map[string]interface{}{ - setting.AzurePublic: "https://portal.azure.com", - setting.AzureChina: "https://portal.azure.cn", - setting.AzureUSGovernment: "https://portal.azure.us", - setting.AzureGermany: "https://portal.microsoftazure.de", + azsettings.AzurePublic: "https://portal.azure.com", + azsettings.AzureChina: "https://portal.azure.cn", + azsettings.AzureUSGovernment: "https://portal.azure.us", + azsettings.AzureGermany: "https://portal.microsoftazure.de", } for _, cloud := range clouds { diff --git a/pkg/tsdb/azuremonitor/routes.go b/pkg/tsdb/azuremonitor/routes.go index e2a370a36dc..1a883826f5f 100644 --- a/pkg/tsdb/azuremonitor/routes.go +++ b/pkg/tsdb/azuremonitor/routes.go @@ -1,7 +1,8 @@ package azuremonitor import ( - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana-azure-sdk-go/azsettings" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/deprecated" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) @@ -59,22 +60,22 @@ var ( // The different Azure routes are identified by its cloud (e.g. public or gov) // and the service to query (e.g. Azure Monitor or Azure Log Analytics) routes = map[string]map[string]types.AzRoute{ - setting.AzurePublic: { + azsettings.AzurePublic: { azureMonitor: azManagement, azureLogAnalytics: azLogAnalytics, azureResourceGraph: azManagement, deprecated.AppInsights: deprecated.AzAppInsights, deprecated.InsightsAnalytics: deprecated.AzAppInsights, }, - setting.AzureUSGovernment: { + azsettings.AzureUSGovernment: { azureMonitor: azUSGovManagement, azureLogAnalytics: azUSGovLogAnalytics, azureResourceGraph: azUSGovManagement, }, - setting.AzureGermany: { + azsettings.AzureGermany: { azureMonitor: azGermanyManagement, }, - setting.AzureChina: { + azsettings.AzureChina: { azureMonitor: azChinaManagement, azureLogAnalytics: azChinaLogAnalytics, azureResourceGraph: azChinaManagement, diff --git a/pkg/tsdb/azuremonitor/types/types.go b/pkg/tsdb/azuremonitor/types/types.go index 6d310f850fd..f0d40823c7b 100644 --- a/pkg/tsdb/azuremonitor/types/types.go +++ b/pkg/tsdb/azuremonitor/types/types.go @@ -7,8 +7,8 @@ import ( "regexp" "time" + "github.com/grafana/grafana-azure-sdk-go/azcredentials" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" ) const ( @@ -109,21 +109,26 @@ type AzureResponseTable struct { // AzureMonitorJSONQuery is the frontend JSON query model for an Azure Monitor query. type AzureMonitorJSONQuery struct { AzureMonitor struct { - Aggregation string `json:"aggregation"` - Alias string `json:"alias"` + ResourceURI string `json:"resourceUri"` + MetricNamespace string `json:"metricNamespace"` + MetricName string `json:"metricName"` + + Aggregation string `json:"aggregation"` + Alias string `json:"alias"` + DimensionFilters []AzureMonitorDimensionFilter `json:"dimensionFilters"` // new model + TimeGrain string `json:"timeGrain"` + Top string `json:"top"` + + // Legecy "resource" fields from before the resource picker provided just a single ResourceURI + // These are used for pre-resource picker queries to reconstruct a resource URI + MetricDefinition string `json:"metricDefinition"` + ResourceGroup string `json:"resourceGroup"` + ResourceName string `json:"resourceName"` + AllowedTimeGrainsMs []int64 `json:"allowedTimeGrainsMs"` Dimension string `json:"dimension"` // old model DimensionFilter string `json:"dimensionFilter"` // old model Format string `json:"format"` - MetricDefinition string `json:"metricDefinition"` - MetricName string `json:"metricName"` - MetricNamespace string `json:"metricNamespace"` - ResourceGroup string `json:"resourceGroup"` - ResourceName string `json:"resourceName"` - TimeGrain string `json:"timeGrain"` - Top string `json:"top"` - - DimensionFilters []AzureMonitorDimensionFilter `json:"dimensionFilters"` // new model } `json:"azureMonitor"` Subscription string `json:"subscription"` } diff --git a/pkg/tsdb/cloudwatch/get_metric_data_error_codes.go b/pkg/tsdb/cloudwatch/get_metric_data_error_codes.go new file mode 100644 index 00000000000..2c688ad7bbf --- /dev/null +++ b/pkg/tsdb/cloudwatch/get_metric_data_error_codes.go @@ -0,0 +1,8 @@ +package cloudwatch + +const ( + maxMetricsExceeded = "MaxMetricsExceeded" + maxQueryTimeRangeExceeded = "MaxQueryTimeRangeExceeded" + maxQueryResultsExceeded = "MaxQueryResultsExceeded" + maxMatchingResultsExceeded = "MaxMatchingResultsExceeded" +) diff --git a/pkg/tsdb/cloudwatch/query_row_response.go b/pkg/tsdb/cloudwatch/query_row_response.go index 0978bf6deba..3a471273fa7 100644 --- a/pkg/tsdb/cloudwatch/query_row_response.go +++ b/pkg/tsdb/cloudwatch/query_row_response.go @@ -4,25 +4,29 @@ import "github.com/aws/aws-sdk-go/service/cloudwatch" // queryRowResponse represents the GetMetricData response for a query row in the query editor. type queryRowResponse struct { - ID string - RequestExceededMaxLimit bool - PartialData bool - Labels []string - HasArithmeticError bool - ArithmeticErrorMessage string - Metrics map[string]*cloudwatch.MetricDataResult - StatusCode string + ID string + ErrorCodes map[string]bool + PartialData bool + Labels []string + HasArithmeticError bool + ArithmeticErrorMessage string + Metrics map[string]*cloudwatch.MetricDataResult + StatusCode string } func newQueryRowResponse(id string) queryRowResponse { return queryRowResponse{ - ID: id, - RequestExceededMaxLimit: false, - PartialData: false, - HasArithmeticError: false, - ArithmeticErrorMessage: "", - Labels: []string{}, - Metrics: map[string]*cloudwatch.MetricDataResult{}, + ID: id, + ErrorCodes: map[string]bool{ + maxMetricsExceeded: false, + maxQueryTimeRangeExceeded: false, + maxQueryResultsExceeded: false, + maxMatchingResultsExceeded: false}, + PartialData: false, + HasArithmeticError: false, + ArithmeticErrorMessage: "", + Labels: []string{}, + Metrics: map[string]*cloudwatch.MetricDataResult{}, } } diff --git a/pkg/tsdb/cloudwatch/response_parser.go b/pkg/tsdb/cloudwatch/response_parser.go index be530a7fb2e..9b0defde3dc 100644 --- a/pkg/tsdb/cloudwatch/response_parser.go +++ b/pkg/tsdb/cloudwatch/response_parser.go @@ -47,11 +47,16 @@ func (e *cloudWatchExecutor) parseResponse(startTime time.Time, endTime time.Tim func aggregateResponse(getMetricDataOutputs []*cloudwatch.GetMetricDataOutput) map[string]queryRowResponse { responseByID := make(map[string]queryRowResponse) + errorCodes := map[string]bool{ + maxMetricsExceeded: false, + maxQueryTimeRangeExceeded: false, + maxQueryResultsExceeded: false, + maxMatchingResultsExceeded: false, + } for _, gmdo := range getMetricDataOutputs { - requestExceededMaxLimit := false for _, message := range gmdo.Messages { - if *message.Code == "MaxMetricsExceeded" { - requestExceededMaxLimit = true + if _, exists := errorCodes[*message.Code]; exists { + errorCodes[*message.Code] = true } } for _, r := range gmdo.MetricDataResults { @@ -75,7 +80,11 @@ func aggregateResponse(getMetricDataOutputs []*cloudwatch.GetMetricDataOutput) m response.appendTimeSeries(r) } - response.RequestExceededMaxLimit = response.RequestExceededMaxLimit || requestExceededMaxLimit + for code := range errorCodes { + if _, exists := response.ErrorCodes[code]; exists { + response.ErrorCodes[code] = errorCodes[code] + } + } responseByID[id] = response } } @@ -183,11 +192,19 @@ func buildDataFrames(startTime time.Time, endTime time.Time, aggregatedResponse Meta: createMeta(query), } - if aggregatedResponse.RequestExceededMaxLimit { - frame.AppendNotices(data.Notice{ - Severity: data.NoticeSeverityWarning, - Text: "cloudwatch GetMetricData error: Maximum number of allowed metrics exceeded. Your search may have been limited", - }) + warningTextMap := map[string]string{ + "MaxMetricsExceeded": "Maximum number of allowed metrics exceeded. Your search may have been limited", + "MaxQueryTimeRangeExceeded": "Max time window exceeded for query", + "MaxQueryResultsExceeded": "Only the first 500 time series can be returned by a query.", + "MaxMatchingResultsExceeded": "The query matched more than 10.000 metrics, results might not be accurate.", + } + for code := range aggregatedResponse.ErrorCodes { + if aggregatedResponse.ErrorCodes[code] { + frame.AppendNotices(data.Notice{ + Severity: data.NoticeSeverityWarning, + Text: "cloudwatch GetMetricData error: " + warningTextMap[code], + }) + } } if aggregatedResponse.StatusCode != "Complete" { diff --git a/pkg/tsdb/cloudwatch/response_parser_test.go b/pkg/tsdb/cloudwatch/response_parser_test.go index 16195076e6f..b1c3de6db8c 100644 --- a/pkg/tsdb/cloudwatch/response_parser_test.go +++ b/pkg/tsdb/cloudwatch/response_parser_test.go @@ -3,6 +3,7 @@ package cloudwatch import ( "encoding/json" "io/ioutil" + "path/filepath" "strings" "testing" "time" @@ -13,9 +14,10 @@ import ( "github.com/stretchr/testify/require" ) -func loadGetMetricDataOutputsFromFile() ([]*cloudwatch.GetMetricDataOutput, error) { +func loadGetMetricDataOutputsFromFile(filePath string) ([]*cloudwatch.GetMetricDataOutput, error) { var getMetricDataOutputs []*cloudwatch.GetMetricDataOutput - jsonBody, err := ioutil.ReadFile("./test-data/multiple-outputs.json") + cleanFilePath := filepath.Clean(filePath) + jsonBody, err := ioutil.ReadFile(cleanFilePath) if err != nil { return getMetricDataOutputs, err } @@ -27,7 +29,7 @@ func TestCloudWatchResponseParser(t *testing.T) { startTime := time.Now() endTime := startTime.Add(2 * time.Hour) t.Run("when aggregating response", func(t *testing.T) { - getMetricDataOutputs, err := loadGetMetricDataOutputsFromFile() + getMetricDataOutputs, err := loadGetMetricDataOutputsFromFile("./test-data/multiple-outputs.json") require.NoError(t, err) aggregatedResponse := aggregateResponse(getMetricDataOutputs) t.Run("response for id a", func(t *testing.T) { @@ -43,7 +45,16 @@ func TestCloudWatchResponseParser(t *testing.T) { assert.Equal(t, "Complete", aggregatedResponse[idA].StatusCode) }) t.Run("should have exceeded request limit", func(t *testing.T) { - assert.True(t, aggregatedResponse[idA].RequestExceededMaxLimit) + assert.True(t, aggregatedResponse[idA].ErrorCodes["MaxMetricsExceeded"]) + }) + t.Run("should have exceeded query time range", func(t *testing.T) { + assert.True(t, aggregatedResponse[idA].ErrorCodes["MaxQueryTimeRangeExceeded"]) + }) + t.Run("should have exceeded max query results", func(t *testing.T) { + assert.True(t, aggregatedResponse[idA].ErrorCodes["MaxQueryResultsExceeded"]) + }) + t.Run("should have exceeded max matching results", func(t *testing.T) { + assert.True(t, aggregatedResponse[idA].ErrorCodes["MaxMatchingResultsExceeded"]) }) }) t.Run("response for id b", func(t *testing.T) { @@ -58,6 +69,27 @@ func TestCloudWatchResponseParser(t *testing.T) { }) }) + t.Run("when aggregating response and error codes are in first GetMetricDataOutput", func(t *testing.T) { + getMetricDataOutputs, err := loadGetMetricDataOutputsFromFile("./test-data/multiple-outputs2.json") + require.NoError(t, err) + aggregatedResponse := aggregateResponse(getMetricDataOutputs) + t.Run("response for id a", func(t *testing.T) { + idA := "a" + t.Run("should have exceeded request limit", func(t *testing.T) { + assert.True(t, aggregatedResponse[idA].ErrorCodes["MaxMetricsExceeded"]) + }) + t.Run("should have exceeded query time range", func(t *testing.T) { + assert.True(t, aggregatedResponse[idA].ErrorCodes["MaxQueryTimeRangeExceeded"]) + }) + t.Run("should have exceeded max query results", func(t *testing.T) { + assert.True(t, aggregatedResponse[idA].ErrorCodes["MaxQueryResultsExceeded"]) + }) + t.Run("should have exceeded max matching results", func(t *testing.T) { + assert.True(t, aggregatedResponse[idA].ErrorCodes["MaxMatchingResultsExceeded"]) + }) + }) + }) + t.Run("Expand dimension value using exact match", func(t *testing.T) { timestamp := time.Unix(0, 0) response := &queryRowResponse{ diff --git a/pkg/tsdb/cloudwatch/test-data/multiple-outputs.json b/pkg/tsdb/cloudwatch/test-data/multiple-outputs.json index 947b20006a3..d0e9edcfe30 100644 --- a/pkg/tsdb/cloudwatch/test-data/multiple-outputs.json +++ b/pkg/tsdb/cloudwatch/test-data/multiple-outputs.json @@ -52,7 +52,10 @@ { "Messages": [ { "Code": "", "Value": null }, - { "Code": "MaxMetricsExceeded", "Value": null } + { "Code": "MaxMetricsExceeded", "Value": null }, + { "Code": "MaxQueryTimeRangeExceeded", "Value": null }, + { "Code": "MaxQueryResultsExceeded", "Value": null }, + { "Code": "MaxMatchingResultsExceeded", "Value": null } ], "MetricDataResults": [ { diff --git a/pkg/tsdb/cloudwatch/test-data/multiple-outputs2.json b/pkg/tsdb/cloudwatch/test-data/multiple-outputs2.json new file mode 100644 index 00000000000..ed1aec8a35b --- /dev/null +++ b/pkg/tsdb/cloudwatch/test-data/multiple-outputs2.json @@ -0,0 +1,99 @@ +[ + { + "Messages": [ + { "Code": "", "Value": null }, + { "Code": "MaxMetricsExceeded", "Value": null }, + { "Code": "MaxQueryTimeRangeExceeded", "Value": null }, + { "Code": "MaxQueryResultsExceeded", "Value": null }, + { "Code": "MaxMatchingResultsExceeded", "Value": null } + ], + "MetricDataResults": [ + { + "Id": "a", + "Label": "label1", + "Messages": null, + "StatusCode": "Complete", + "Timestamps": [ + "2021-01-15T19:44:00Z", + "2021-01-15T19:59:00Z", + "2021-01-15T20:14:00Z", + "2021-01-15T20:29:00Z", + "2021-01-15T20:44:00Z" + ], + "Values": [ + 0.1333395078879982, + 0.244268469636633, + 0.15574387947267768, + 0.14447563659125626, + 0.15519743138527173 + ] + }, + { + "Id": "a", + "Label": "label2", + "Messages": null, + "StatusCode": "Complete", + "Timestamps": [ + "2021-01-15T19:44:00Z" + ], + "Values": [ + 0.1333395078879982 + ] + }, + { + "Id": "b", + "Label": "label2", + "Messages": null, + "StatusCode": "Complete", + "Timestamps": [ + "2021-01-15T19:44:00Z" + ], + "Values": [ + 0.1333395078879982 + ] + } + ], + "NextToken": null + }, + { + "Messages": null, + "MetricDataResults": [ + { + "Id": "a", + "Label": "label1", + "Messages": null, + "StatusCode": "Complete", + "Timestamps": [ + "2021-01-15T19:44:00Z", + "2021-01-15T19:59:00Z", + "2021-01-15T20:14:00Z", + "2021-01-15T20:29:00Z", + "2021-01-15T20:44:00Z" + ], + "Values": [ + 0.1333395078879982, + 0.244268469636633, + 0.15574387947267768, + 0.14447563659125626, + 0.15519743138527173 + ] + }, + { + "Id": "b", + "Label": "label2", + "Messages": [{ + "Code": "ArithmeticError", + "Value": "One or more data-points have been dropped due to non-numeric values (NaN, -Infinite, +Infinite)" + }], + "StatusCode": "Partial", + "Timestamps": [ + "2021-01-15T19:44:00Z" + ], + "Values": [ + 0.1333395078879982 + ] + } + ], + "NextToken": null + } +] diff --git a/pkg/tsdb/legacydata/service/service_test.go b/pkg/tsdb/legacydata/service/service_test.go index cb16213b60c..23af5c2a495 100644 --- a/pkg/tsdb/legacydata/service/service_test.go +++ b/pkg/tsdb/legacydata/service/service_test.go @@ -14,11 +14,14 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/secrets/kvstore" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/legacydata" "github.com/stretchr/testify/require" ) func TestHandleRequest(t *testing.T) { + cfg := &setting.Cfg{} + t.Run("Should invoke plugin manager QueryData when handling request for query", func(t *testing.T) { origOAuthIsOAuthPassThruEnabledFunc := oAuthIsOAuthPassThruEnabledFunc oAuthIsOAuthPassThruEnabledFunc = func(oAuthTokenService oauthtoken.OAuthTokenService, ds *models.DataSource) bool { @@ -36,7 +39,7 @@ func TestHandleRequest(t *testing.T) { return backend.NewQueryDataResponse(), nil } secretsStore := kvstore.SetupTestService(t) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock()) s := ProvideService(client, nil, dsService) ds := &models.DataSource{Id: 12, Type: "unregisteredType", JsonData: simplejson.New()} diff --git a/pkg/tsdb/loki/framing_test.go b/pkg/tsdb/loki/framing_test.go index e6c862a91e7..8cb118dd809 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}, + // loki adds stats to matrix-responses too + {name: "parse a matrix response with stats", filepath: "matrix_with_stats", query: matrixQuery}, + {name: "parse a simple vector response", filepath: "vector_simple", query: vectorQuery}, {name: "parse a vector response with special values", filepath: "vector_special_values", query: vectorQuery}, diff --git a/pkg/tsdb/loki/parse_response.go b/pkg/tsdb/loki/parse_response.go index d56627b6bec..48eae74861e 100644 --- a/pkg/tsdb/loki/parse_response.go +++ b/pkg/tsdb/loki/parse_response.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/loki/pkg/loghttp" + "github.com/grafana/loki/pkg/logqlmodel/stats" ) func parseResponse(value *loghttp.QueryResponse, query *lokiQuery) (data.Frames, error) { @@ -23,19 +24,20 @@ func parseResponse(value *loghttp.QueryResponse, query *lokiQuery) (data.Frames, } func lokiResponseToDataFrames(value *loghttp.QueryResponse, query *lokiQuery) (data.Frames, error) { + stats := parseStats(value.Data.Statistics) switch res := value.Data.Result.(type) { case loghttp.Matrix: - return lokiMatrixToDataFrames(res, query), nil + return lokiMatrixToDataFrames(res, query, stats), nil case loghttp.Vector: - return lokiVectorToDataFrames(res, query), nil + return lokiVectorToDataFrames(res, query, stats), nil case loghttp.Streams: - return lokiStreamsToDataFrames(res, query), nil + return lokiStreamsToDataFrames(res, query, stats), nil default: return nil, fmt.Errorf("resultType %T not supported{", res) } } -func lokiMatrixToDataFrames(matrix loghttp.Matrix, query *lokiQuery) data.Frames { +func lokiMatrixToDataFrames(matrix loghttp.Matrix, query *lokiQuery, stats []data.QueryStat) data.Frames { frames := data.Frames{} for _, v := range matrix { @@ -56,6 +58,9 @@ func lokiMatrixToDataFrames(matrix loghttp.Matrix, query *lokiQuery) data.Frames valueField := data.NewField("", tags, values) frame := data.NewFrame("", timeField, valueField) + frame.SetMeta(&data.FrameMeta{ + Stats: stats, + }) frames = append(frames, frame) } @@ -63,7 +68,7 @@ func lokiMatrixToDataFrames(matrix loghttp.Matrix, query *lokiQuery) data.Frames return frames } -func lokiVectorToDataFrames(vector loghttp.Vector, query *lokiQuery) data.Frames { +func lokiVectorToDataFrames(vector loghttp.Vector, query *lokiQuery, stats []data.QueryStat) data.Frames { frames := data.Frames{} for _, v := range vector { @@ -78,6 +83,9 @@ func lokiVectorToDataFrames(vector loghttp.Vector, query *lokiQuery) data.Frames valueField := data.NewField("", tags, values) frame := data.NewFrame("", timeField, valueField) + frame.SetMeta(&data.FrameMeta{ + Stats: stats, + }) frames = append(frames, frame) } @@ -85,7 +93,7 @@ func lokiVectorToDataFrames(vector loghttp.Vector, query *lokiQuery) data.Frames return frames } -func lokiStreamsToDataFrames(streams loghttp.Streams, query *lokiQuery) data.Frames { +func lokiStreamsToDataFrames(streams loghttp.Streams, query *lokiQuery, stats []data.QueryStat) data.Frames { frames := data.Frames{} for _, v := range streams { @@ -106,9 +114,68 @@ func lokiStreamsToDataFrames(streams loghttp.Streams, query *lokiQuery) data.Fra valueField := data.NewField("", tags, values) frame := data.NewFrame("", timeField, valueField) + frame.SetMeta(&data.FrameMeta{ + Stats: stats, + }) frames = append(frames, frame) } return frames } + +func parseStats(result stats.Result) []data.QueryStat { + data := []data.QueryStat{ + makeStat("Summary: bytes processed per second", float64(result.Summary.BytesProcessedPerSecond), "Bps"), + makeStat("Summary: lines processed per second", float64(result.Summary.LinesProcessedPerSecond), ""), + makeStat("Summary: total bytes processed", float64(result.Summary.TotalBytesProcessed), "decbytes"), + makeStat("Summary: total lines processed", float64(result.Summary.TotalLinesProcessed), ""), + makeStat("Summary: exec time", result.Summary.ExecTime, "s"), + makeStat("Store: total chunks ref", float64(result.Store.TotalChunksRef), ""), + makeStat("Store: total chunks downloaded", float64(result.Store.TotalChunksDownloaded), ""), + makeStat("Store: chunks download time", result.Store.ChunksDownloadTime, "s"), + makeStat("Store: head chunk bytes", float64(result.Store.HeadChunkBytes), "decbytes"), + makeStat("Store: head chunk lines", float64(result.Store.HeadChunkLines), ""), + makeStat("Store: decompressed bytes", float64(result.Store.DecompressedBytes), "decbytes"), + makeStat("Store: decompressed lines", float64(result.Store.DecompressedLines), ""), + makeStat("Store: compressed bytes", float64(result.Store.CompressedBytes), "decbytes"), + makeStat("Store: total duplicates", float64(result.Store.TotalDuplicates), ""), + makeStat("Ingester: total reached", float64(result.Ingester.TotalReached), ""), + makeStat("Ingester: total chunks matched", float64(result.Ingester.TotalChunksMatched), ""), + makeStat("Ingester: total batches", float64(result.Ingester.TotalBatches), ""), + makeStat("Ingester: total lines sent", float64(result.Ingester.TotalLinesSent), ""), + makeStat("Ingester: head chunk bytes", float64(result.Ingester.HeadChunkBytes), "decbytes"), + makeStat("Ingester: head chunk lines", float64(result.Ingester.HeadChunkLines), ""), + makeStat("Ingester: decompressed bytes", float64(result.Ingester.DecompressedBytes), "decbytes"), + makeStat("Ingester: decompressed lines", float64(result.Ingester.DecompressedLines), ""), + makeStat("Ingester: compressed bytes", float64(result.Ingester.CompressedBytes), "decbytes"), + makeStat("Ingester: total duplicates", float64(result.Ingester.TotalDuplicates), ""), + } + + // it is not possible to know whether the given statistics was missing, or + // it's value was zero. + // we do a heuristic here, if every stat-value is zero, we assume we got no stats-data + allStatsZero := true + for _, stat := range data { + if stat.Value > 0 { + allStatsZero = false + break + } + } + + if allStatsZero { + return nil + } + + return data +} + +func makeStat(name string, value float64, unit string) data.QueryStat { + return data.QueryStat{ + FieldConfig: data.FieldConfig{ + DisplayName: name, + Unit: unit, + }, + Value: value, + } +} diff --git a/pkg/tsdb/loki/parse_response_test.go b/pkg/tsdb/loki/parse_response_test.go index 7e7309e179a..9639a0e17ee 100644 --- a/pkg/tsdb/loki/parse_response_test.go +++ b/pkg/tsdb/loki/parse_response_test.go @@ -7,6 +7,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/loki/pkg/loghttp" + "github.com/grafana/loki/pkg/logqlmodel/stats" p "github.com/prometheus/common/model" "github.com/stretchr/testify/require" ) @@ -105,4 +106,77 @@ func TestParseResponse(t *testing.T) { require.NotNil(t, timeFieldConfig) require.Equal(t, float64(42000), timeFieldConfig.Interval) }) + + t.Run("should parse response stats", func(t *testing.T) { + stats := stats.Result{ + Summary: stats.Summary{ + BytesProcessedPerSecond: 1, + LinesProcessedPerSecond: 2, + TotalBytesProcessed: 3, + TotalLinesProcessed: 4, + ExecTime: 5.5, + }, + Store: stats.Store{ + TotalChunksRef: 6, + TotalChunksDownloaded: 7, + ChunksDownloadTime: 8.8, + HeadChunkBytes: 9, + HeadChunkLines: 10, + DecompressedBytes: 11, + DecompressedLines: 12, + CompressedBytes: 13, + TotalDuplicates: 14, + }, + Ingester: stats.Ingester{ + TotalReached: 15, + TotalChunksMatched: 16, + TotalBatches: 17, + TotalLinesSent: 18, + HeadChunkBytes: 19, + HeadChunkLines: 20, + DecompressedBytes: 21, + DecompressedLines: 22, + CompressedBytes: 23, + TotalDuplicates: 24, + }, + } + + expected := []data.QueryStat{ + {FieldConfig: data.FieldConfig{DisplayName: "Summary: bytes processed per second", Unit: "Bps"}, Value: 1}, + {FieldConfig: data.FieldConfig{DisplayName: "Summary: lines processed per second", Unit: ""}, Value: 2}, + {FieldConfig: data.FieldConfig{DisplayName: "Summary: total bytes processed", Unit: "decbytes"}, Value: 3}, + {FieldConfig: data.FieldConfig{DisplayName: "Summary: total lines processed", Unit: ""}, Value: 4}, + {FieldConfig: data.FieldConfig{DisplayName: "Summary: exec time", Unit: "s"}, Value: 5.5}, + + {FieldConfig: data.FieldConfig{DisplayName: "Store: total chunks ref", Unit: ""}, Value: 6}, + {FieldConfig: data.FieldConfig{DisplayName: "Store: total chunks downloaded", Unit: ""}, Value: 7}, + {FieldConfig: data.FieldConfig{DisplayName: "Store: chunks download time", Unit: "s"}, Value: 8.8}, + {FieldConfig: data.FieldConfig{DisplayName: "Store: head chunk bytes", Unit: "decbytes"}, Value: 9}, + {FieldConfig: data.FieldConfig{DisplayName: "Store: head chunk lines", Unit: ""}, Value: 10}, + {FieldConfig: data.FieldConfig{DisplayName: "Store: decompressed bytes", Unit: "decbytes"}, Value: 11}, + {FieldConfig: data.FieldConfig{DisplayName: "Store: decompressed lines", Unit: ""}, Value: 12}, + {FieldConfig: data.FieldConfig{DisplayName: "Store: compressed bytes", Unit: "decbytes"}, Value: 13}, + {FieldConfig: data.FieldConfig{DisplayName: "Store: total duplicates", Unit: ""}, Value: 14}, + + {FieldConfig: data.FieldConfig{DisplayName: "Ingester: total reached", Unit: ""}, Value: 15}, + {FieldConfig: data.FieldConfig{DisplayName: "Ingester: total chunks matched", Unit: ""}, Value: 16}, + {FieldConfig: data.FieldConfig{DisplayName: "Ingester: total batches", Unit: ""}, Value: 17}, + {FieldConfig: data.FieldConfig{DisplayName: "Ingester: total lines sent", Unit: ""}, Value: 18}, + {FieldConfig: data.FieldConfig{DisplayName: "Ingester: head chunk bytes", Unit: "decbytes"}, Value: 19}, + {FieldConfig: data.FieldConfig{DisplayName: "Ingester: head chunk lines", Unit: ""}, Value: 20}, + {FieldConfig: data.FieldConfig{DisplayName: "Ingester: decompressed bytes", Unit: "decbytes"}, Value: 21}, + {FieldConfig: data.FieldConfig{DisplayName: "Ingester: decompressed lines", Unit: ""}, Value: 22}, + {FieldConfig: data.FieldConfig{DisplayName: "Ingester: compressed bytes", Unit: "decbytes"}, Value: 23}, + {FieldConfig: data.FieldConfig{DisplayName: "Ingester: total duplicates", Unit: ""}, Value: 24}, + } + + result := parseStats((stats)) + + // NOTE: i compare it item-by-item otherwise the test-fail-error-message is very hard to read + require.Len(t, result, len(expected)) + + for i := 0; i < len(result); i++ { + require.Equal(t, expected[i], result[i]) + } + }) } diff --git a/pkg/tsdb/loki/testdata/matrix_with_stats.golden.txt b/pkg/tsdb/loki/testdata/matrix_with_stats.golden.txt new file mode 100644 index 00000000000..001125984d6 --- /dev/null +++ b/pkg/tsdb/loki/testdata/matrix_with_stats.golden.txt @@ -0,0 +1,252 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] { + "stats": [ + { + "displayName": "Summary: bytes processed per second", + "unit": "Bps", + "value": 3507022 + }, + { + "displayName": "Summary: lines processed per second", + "value": 24818 + }, + { + "displayName": "Summary: total bytes processed", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Summary: total lines processed", + "value": 55 + }, + { + "displayName": "Summary: exec time", + "unit": "s", + "value": 0.002216125 + }, + { + "displayName": "Store: total chunks ref", + "value": 2 + }, + { + "displayName": "Store: total chunks downloaded", + "value": 3 + }, + { + "displayName": "Store: chunks download time", + "unit": "s", + "value": 0.000390958 + }, + { + "displayName": "Store: head chunk bytes", + "unit": "decbytes", + "value": 4 + }, + { + "displayName": "Store: head chunk lines", + "value": 5 + }, + { + "displayName": "Store: decompressed bytes", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Store: decompressed lines", + "value": 55 + }, + { + "displayName": "Store: compressed bytes", + "unit": "decbytes", + "value": 31432 + }, + { + "displayName": "Store: total duplicates", + "value": 6 + }, + { + "displayName": "Ingester: total reached", + "value": 7 + }, + { + "displayName": "Ingester: total chunks matched", + "value": 8 + }, + { + "displayName": "Ingester: total batches", + "value": 9 + }, + { + "displayName": "Ingester: total lines sent", + "value": 10 + }, + { + "displayName": "Ingester: head chunk bytes", + "unit": "decbytes", + "value": 11 + }, + { + "displayName": "Ingester: head chunk lines", + "value": 12 + }, + { + "displayName": "Ingester: decompressed bytes", + "unit": "decbytes", + "value": 13 + }, + { + "displayName": "Ingester: decompressed lines", + "value": 14 + }, + { + "displayName": "Ingester: compressed bytes", + "unit": "decbytes", + "value": 15 + }, + { + "displayName": "Ingester: total duplicates", + "value": 16 + } + ], + "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" +} +Name: {level="error", location="moon"} +Dimensions: 2 Fields by 2 Rows ++-----------------------------------+------------------------------------+ +| Name: time | Name: value | +| Labels: | Labels: level=error, location=moon | +| Type: []time.Time | Type: []float64 | ++-----------------------------------+------------------------------------+ +| 2021-12-10 08:36:06.989 +0000 UTC | 0.4 | +| 2021-12-10 08:36:46.989 +0000 UTC | 0.2 | ++-----------------------------------+------------------------------------+ + + + +Frame[1] { + "stats": [ + { + "displayName": "Summary: bytes processed per second", + "unit": "Bps", + "value": 3507022 + }, + { + "displayName": "Summary: lines processed per second", + "value": 24818 + }, + { + "displayName": "Summary: total bytes processed", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Summary: total lines processed", + "value": 55 + }, + { + "displayName": "Summary: exec time", + "unit": "s", + "value": 0.002216125 + }, + { + "displayName": "Store: total chunks ref", + "value": 2 + }, + { + "displayName": "Store: total chunks downloaded", + "value": 3 + }, + { + "displayName": "Store: chunks download time", + "unit": "s", + "value": 0.000390958 + }, + { + "displayName": "Store: head chunk bytes", + "unit": "decbytes", + "value": 4 + }, + { + "displayName": "Store: head chunk lines", + "value": 5 + }, + { + "displayName": "Store: decompressed bytes", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Store: decompressed lines", + "value": 55 + }, + { + "displayName": "Store: compressed bytes", + "unit": "decbytes", + "value": 31432 + }, + { + "displayName": "Store: total duplicates", + "value": 6 + }, + { + "displayName": "Ingester: total reached", + "value": 7 + }, + { + "displayName": "Ingester: total chunks matched", + "value": 8 + }, + { + "displayName": "Ingester: total batches", + "value": 9 + }, + { + "displayName": "Ingester: total lines sent", + "value": 10 + }, + { + "displayName": "Ingester: head chunk bytes", + "unit": "decbytes", + "value": 11 + }, + { + "displayName": "Ingester: head chunk lines", + "value": 12 + }, + { + "displayName": "Ingester: decompressed bytes", + "unit": "decbytes", + "value": 13 + }, + { + "displayName": "Ingester: decompressed lines", + "value": 14 + }, + { + "displayName": "Ingester: compressed bytes", + "unit": "decbytes", + "value": 15 + }, + { + "displayName": "Ingester: total duplicates", + "value": 16 + } + ], + "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" +} +Name: {level="info", location="mars"} +Dimensions: 2 Fields by 2 Rows ++-----------------------------------+-----------------------------------+ +| Name: time | Name: value | +| Labels: | Labels: level=info, location=mars | +| Type: []time.Time | Type: []float64 | ++-----------------------------------+-----------------------------------+ +| 2021-12-10 08:36:26.989 +0000 UTC | 0.6 | +| 2021-12-10 08:36:36.989 +0000 UTC | 0.8 | ++-----------------------------------+-----------------------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////wAgAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAANQGAAADAAAAbAAAACgAAAAEAAAA3Pf//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAD89///CAAAACwAAAAgAAAAe2xldmVsPSJlcnJvciIsIGxvY2F0aW9uPSJtb29uIn0AAAAABAAAAG5hbWUAAAAAPPj//wgAAABMBgAAQQYAAHsic3RhdHMiOlt7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogYnl0ZXMgcHJvY2Vzc2VkIHBlciBzZWNvbmQiLCJ1bml0IjoiQnBzIiwidmFsdWUiOjM1MDcwMjJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBsaW5lcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInZhbHVlIjoyNDgxOH0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IHRvdGFsIGJ5dGVzIHByb2Nlc3NlZCIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo3NzcyfSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgbGluZXMgcHJvY2Vzc2VkIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogZXhlYyB0aW1lIiwidW5pdCI6InMiLCJ2YWx1ZSI6MC4wMDIyMTYxMjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIHJlZiIsInZhbHVlIjoyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGNodW5rcyBkb3dubG9hZGVkIiwidmFsdWUiOjN9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogY2h1bmtzIGRvd25sb2FkIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMDM5MDk1OH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjR9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjo1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGRlY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo3NzcyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGRlY29tcHJlc3NlZCBsaW5lcyIsInZhbHVlIjo1NX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjMxNDMyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGR1cGxpY2F0ZXMiLCJ2YWx1ZSI6Nn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCByZWFjaGVkIiwidmFsdWUiOjd9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgY2h1bmtzIG1hdGNoZWQiLCJ2YWx1ZSI6OH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBiYXRjaGVzIiwidmFsdWUiOjl9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgbGluZXMgc2VudCIsInZhbHVlIjoxMH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBoZWFkIGNodW5rIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjExfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgbGluZXMiLCJ2YWx1ZSI6MTJ9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjEzfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGRlY29tcHJlc3NlZCBsaW5lcyIsInZhbHVlIjoxNH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjE1fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGR1cGxpY2F0ZXMiLCJ2YWx1ZSI6MTZ9XSwiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHVwKEFMRVJUUylcblN0ZXA6IDQycyJ9AAAABAAAAG1ldGEAAAAAAgAAABgBAAAEAAAAAv///xQAAADgAAAA4AAAAAAAAAPgAAAAAwAAAHAAAAAsAAAABAAAAND+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAAPT+//8IAAAALAAAACMAAAB7ImxldmVsIjoiZXJyb3IiLCJsb2NhdGlvbiI6Im1vb24ifQAGAAAAbGFiZWxzAAA0////CAAAAEgAAAA8AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImVycm9yXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAAAAAYAAABjb25maWcAAAAAAABW////AAACAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAHgAAACAAAAAAAAACoAAAAACAAAANAAAAAQAAADc////CAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAIAAwACAAEAAgAAAAIAAAAHAAAABIAAAB7ImludGVydmFsIjo0MjAwMH0AAAYAAABjb25maWcAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAHRpbWUAAAAA/////7gAAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAAAgAAAAAAAAABQAAAAAAAADBAAKABgADAAIAAQACgAAABQAAABYAAAAAgAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAAgAAAAIAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAQA22OdZXvxZAneWJ31e/FpqZmZmZmdk/mpmZmZmZyT8QAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAPAAAAAAABAABAAAA0AgAAAAAAADAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAADUBgAAAwAAAGwAAAAoAAAABAAAANz3//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAA/Pf//wgAAAAsAAAAIAAAAHtsZXZlbD0iZXJyb3IiLCBsb2NhdGlvbj0ibW9vbiJ9AAAAAAQAAABuYW1lAAAAADz4//8IAAAATAYAAEEGAAB7InN0YXRzIjpbeyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGJ5dGVzIHByb2Nlc3NlZCBwZXIgc2Vjb25kIiwidW5pdCI6IkJwcyIsInZhbHVlIjozNTA3MDIyfSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogbGluZXMgcHJvY2Vzc2VkIHBlciBzZWNvbmQiLCJ2YWx1ZSI6MjQ4MTh9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiB0b3RhbCBieXRlcyBwcm9jZXNzZWQiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6Nzc3Mn0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IHRvdGFsIGxpbmVzIHByb2Nlc3NlZCIsInZhbHVlIjo1NX0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGV4ZWMgdGltZSIsInVuaXQiOiJzIiwidmFsdWUiOjAuMDAyMjE2MTI1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGNodW5rcyByZWYiLCJ2YWx1ZSI6Mn0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBjaHVua3MgZG93bmxvYWRlZCIsInZhbHVlIjozfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGNodW5rcyBkb3dubG9hZCB0aW1lIiwidW5pdCI6InMiLCJ2YWx1ZSI6MC4wMDAzOTA5NTh9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogaGVhZCBjaHVuayBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo0fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGhlYWQgY2h1bmsgbGluZXMiLCJ2YWx1ZSI6NX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBkZWNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6Nzc3Mn0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBkZWNvbXByZXNzZWQgbGluZXMiLCJ2YWx1ZSI6NTV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjozMTQzMn0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBkdXBsaWNhdGVzIiwidmFsdWUiOjZ9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgcmVhY2hlZCIsInZhbHVlIjo3fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGNodW5rcyBtYXRjaGVkIiwidmFsdWUiOjh9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgYmF0Y2hlcyIsInZhbHVlIjo5fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGxpbmVzIHNlbnQiLCJ2YWx1ZSI6MTB9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogaGVhZCBjaHVuayBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjoxMX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBoZWFkIGNodW5rIGxpbmVzIiwidmFsdWUiOjEyfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGRlY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjoxM30seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBkZWNvbXByZXNzZWQgbGluZXMiLCJ2YWx1ZSI6MTR9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjoxNX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBkdXBsaWNhdGVzIiwidmFsdWUiOjE2fV0sImV4ZWN1dGVkUXVlcnlTdHJpbmciOiJFeHByOiB1cChBTEVSVFMpXG5TdGVwOiA0MnMifQAAAAQAAABtZXRhAAAAAAIAAAAYAQAABAAAAAL///8UAAAA4AAAAOAAAAAAAAAD4AAAAAMAAABwAAAALAAAAAQAAADQ/v//CAAAABAAAAAFAAAAdmFsdWUAAAAEAAAAbmFtZQAAAAD0/v//CAAAACwAAAAjAAAAeyJsZXZlbCI6ImVycm9yIiwibG9jYXRpb24iOiJtb29uIn0ABgAAAGxhYmVscwAANP///wgAAABIAAAAPAAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJ7bGV2ZWw9XCJlcnJvclwiLCBsb2NhdGlvbj1cIm1vb25cIn0ifQAAAAAGAAAAY29uZmlnAAAAAAAAVv///wAAAgAFAAAAdmFsdWUAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAAB4AAAAgAAAAAAAAAqAAAAAAgAAADQAAAAEAAAA3P///wgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAACAAMAAgABAAIAAAACAAAABwAAAASAAAAeyJpbnRlcnZhbCI6NDIwMDB9AAAGAAAAY29uZmlnAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAAB0aW1lAAAAAPAIAABBUlJPVzE= +FRAME=QVJST1cxAAD/////uAgAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAANAGAAADAAAAaAAAACgAAAAEAAAA5Pf//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAAE+P//CAAAACgAAAAfAAAAe2xldmVsPSJpbmZvIiwgbG9jYXRpb249Im1hcnMifQAEAAAAbmFtZQAAAABA+P//CAAAAEwGAABBBgAAeyJzdGF0cyI6W3siZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBieXRlcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInVuaXQiOiJCcHMiLCJ2YWx1ZSI6MzUwNzAyMn0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGxpbmVzIHByb2Nlc3NlZCBwZXIgc2Vjb25kIiwidmFsdWUiOjI0ODE4fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgYnl0ZXMgcHJvY2Vzc2VkIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiB0b3RhbCBsaW5lcyBwcm9jZXNzZWQiLCJ2YWx1ZSI6NTV9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBleGVjIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMjIxNjEyNX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBjaHVua3MgcmVmIiwidmFsdWUiOjJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIGRvd25sb2FkZWQiLCJ2YWx1ZSI6M30seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjaHVua3MgZG93bmxvYWQgdGltZSIsInVuaXQiOiJzIiwidmFsdWUiOjAuMDAwMzkwOTU4fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6NH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGxpbmVzIiwidmFsdWUiOjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MzE0MzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjo2fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIHJlYWNoZWQiLCJ2YWx1ZSI6N30seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBjaHVua3MgbWF0Y2hlZCIsInZhbHVlIjo4fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGJhdGNoZXMiLCJ2YWx1ZSI6OX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBsaW5lcyBzZW50IiwidmFsdWUiOjEwfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTF9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjoxMn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBkZWNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTN9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjE0fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTV9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjoxNn1dLCJleGVjdXRlZFF1ZXJ5U3RyaW5nIjoiRXhwcjogdXAoQUxFUlRTKVxuU3RlcDogNDJzIn0AAAAEAAAAbWV0YQAAAAACAAAAFAEAAAQAAAAG////FAAAANwAAADcAAAAAAAAA9wAAAADAAAAcAAAACwAAAAEAAAA1P7//wgAAAAQAAAABQAAAHZhbHVlAAAABAAAAG5hbWUAAAAA+P7//wgAAAAsAAAAIgAAAHsibGV2ZWwiOiJpbmZvIiwibG9jYXRpb24iOiJtYXJzIn0AAAYAAABsYWJlbHMAADj///8IAAAARAAAADsAAAB7ImRpc3BsYXlOYW1lRnJvbURTIjoie2xldmVsPVwiaW5mb1wiLCBsb2NhdGlvbj1cIm1hcnNcIn0ifQAGAAAAY29uZmlnAAAAAAAAVv///wAAAgAFAAAAdmFsdWUAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAAB4AAAAgAAAAAAAAAqAAAAAAgAAADQAAAAEAAAA3P///wgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAACAAMAAgABAAIAAAACAAAABwAAAASAAAAeyJpbnRlcnZhbCI6NDIwMDB9AAAGAAAAY29uZmlnAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAAB0aW1lAAAAAP////+4AAAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAIAAAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAWAAAAAIAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAAAAAAAIAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAEDVzeHaV78WQLnZNd1XvxYzMzMzMzPjP5qZmZmZmek/EAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADwAAAAAAAQAAQAAAMgIAAAAAAAAwAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAA0AYAAAMAAABoAAAAKAAAAAQAAADk9///CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAAT4//8IAAAAKAAAAB8AAAB7bGV2ZWw9ImluZm8iLCBsb2NhdGlvbj0ibWFycyJ9AAQAAABuYW1lAAAAAED4//8IAAAATAYAAEEGAAB7InN0YXRzIjpbeyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGJ5dGVzIHByb2Nlc3NlZCBwZXIgc2Vjb25kIiwidW5pdCI6IkJwcyIsInZhbHVlIjozNTA3MDIyfSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogbGluZXMgcHJvY2Vzc2VkIHBlciBzZWNvbmQiLCJ2YWx1ZSI6MjQ4MTh9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiB0b3RhbCBieXRlcyBwcm9jZXNzZWQiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6Nzc3Mn0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IHRvdGFsIGxpbmVzIHByb2Nlc3NlZCIsInZhbHVlIjo1NX0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGV4ZWMgdGltZSIsInVuaXQiOiJzIiwidmFsdWUiOjAuMDAyMjE2MTI1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGNodW5rcyByZWYiLCJ2YWx1ZSI6Mn0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBjaHVua3MgZG93bmxvYWRlZCIsInZhbHVlIjozfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGNodW5rcyBkb3dubG9hZCB0aW1lIiwidW5pdCI6InMiLCJ2YWx1ZSI6MC4wMDAzOTA5NTh9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogaGVhZCBjaHVuayBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo0fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGhlYWQgY2h1bmsgbGluZXMiLCJ2YWx1ZSI6NX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBkZWNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6Nzc3Mn0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBkZWNvbXByZXNzZWQgbGluZXMiLCJ2YWx1ZSI6NTV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjozMTQzMn0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBkdXBsaWNhdGVzIiwidmFsdWUiOjZ9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgcmVhY2hlZCIsInZhbHVlIjo3fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGNodW5rcyBtYXRjaGVkIiwidmFsdWUiOjh9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgYmF0Y2hlcyIsInZhbHVlIjo5fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGxpbmVzIHNlbnQiLCJ2YWx1ZSI6MTB9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogaGVhZCBjaHVuayBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjoxMX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBoZWFkIGNodW5rIGxpbmVzIiwidmFsdWUiOjEyfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGRlY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjoxM30seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBkZWNvbXByZXNzZWQgbGluZXMiLCJ2YWx1ZSI6MTR9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjoxNX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBkdXBsaWNhdGVzIiwidmFsdWUiOjE2fV0sImV4ZWN1dGVkUXVlcnlTdHJpbmciOiJFeHByOiB1cChBTEVSVFMpXG5TdGVwOiA0MnMifQAAAAQAAABtZXRhAAAAAAIAAAAUAQAABAAAAAb///8UAAAA3AAAANwAAAAAAAAD3AAAAAMAAABwAAAALAAAAAQAAADU/v//CAAAABAAAAAFAAAAdmFsdWUAAAAEAAAAbmFtZQAAAAD4/v//CAAAACwAAAAiAAAAeyJsZXZlbCI6ImluZm8iLCJsb2NhdGlvbiI6Im1hcnMifQAABgAAAGxhYmVscwAAOP///wgAAABEAAAAOwAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJ7bGV2ZWw9XCJpbmZvXCIsIGxvY2F0aW9uPVwibWFyc1wifSJ9AAYAAABjb25maWcAAAAAAABW////AAACAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAHgAAACAAAAAAAAACoAAAAACAAAANAAAAAQAAADc////CAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAIAAwACAAEAAgAAAAIAAAAHAAAABIAAAB7ImludGVydmFsIjo0MjAwMH0AAAYAAABjb25maWcAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAHRpbWUAAAAA6AgAAEFSUk9XMQ== diff --git a/pkg/tsdb/loki/testdata/matrix_with_stats.json b/pkg/tsdb/loki/testdata/matrix_with_stats.json new file mode 100644 index 00000000000..609ce7ff170 --- /dev/null +++ b/pkg/tsdb/loki/testdata/matrix_with_stats.json @@ -0,0 +1,60 @@ +{ + "status": "success", + "data": { + "resultType": "matrix", + "result": [ + { + "metric": { + "level": "error", + "location": "moon" + }, + "values": [ + [1639125366.989, "0.4"], + [1639125406.989, "0.2"] + ] + }, + { + "metric": { + "level": "info", + "location": "mars" + }, + "values": [ + [1639125386.989, "0.6"], + [1639125396.989, "0.8"] + ] + } + ], + "stats": { + "summary": { + "bytesProcessedPerSecond": 3507022, + "linesProcessedPerSecond": 24818, + "totalBytesProcessed": 7772, + "totalLinesProcessed": 55, + "execTime": 0.002216125 + }, + "store": { + "totalChunksRef": 2, + "totalChunksDownloaded": 3, + "chunksDownloadTime": 0.000390958, + "headChunkBytes": 4, + "headChunkLines": 5, + "decompressedBytes": 7772, + "decompressedLines": 55, + "compressedBytes": 31432, + "totalDuplicates": 6 + }, + "ingester": { + "totalReached": 7, + "totalChunksMatched": 8, + "totalBatches": 9, + "totalLinesSent": 10, + "headChunkBytes": 11, + "headChunkLines": 12, + "decompressedBytes": 13, + "decompressedLines": 14, + "compressedBytes": 15, + "totalDuplicates": 16 + } + } + } +} diff --git a/pkg/tsdb/loki/testdata/streams_simple.golden.txt b/pkg/tsdb/loki/testdata/streams_simple.golden.txt index 10312ee6f42..76204b3a22f 100644 --- a/pkg/tsdb/loki/testdata/streams_simple.golden.txt +++ b/pkg/tsdb/loki/testdata/streams_simple.golden.txt @@ -1,6 +1,114 @@ 🌟 This was machine generated. Do not edit. 🌟 Frame[0] { + "stats": [ + { + "displayName": "Summary: bytes processed per second", + "unit": "Bps", + "value": 3507022 + }, + { + "displayName": "Summary: lines processed per second", + "value": 24818 + }, + { + "displayName": "Summary: total bytes processed", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Summary: total lines processed", + "value": 55 + }, + { + "displayName": "Summary: exec time", + "unit": "s", + "value": 0.002216125 + }, + { + "displayName": "Store: total chunks ref", + "value": 2 + }, + { + "displayName": "Store: total chunks downloaded", + "value": 3 + }, + { + "displayName": "Store: chunks download time", + "unit": "s", + "value": 0.000390958 + }, + { + "displayName": "Store: head chunk bytes", + "unit": "decbytes", + "value": 4 + }, + { + "displayName": "Store: head chunk lines", + "value": 5 + }, + { + "displayName": "Store: decompressed bytes", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Store: decompressed lines", + "value": 55 + }, + { + "displayName": "Store: compressed bytes", + "unit": "decbytes", + "value": 31432 + }, + { + "displayName": "Store: total duplicates", + "value": 6 + }, + { + "displayName": "Ingester: total reached", + "value": 7 + }, + { + "displayName": "Ingester: total chunks matched", + "value": 8 + }, + { + "displayName": "Ingester: total batches", + "value": 9 + }, + { + "displayName": "Ingester: total lines sent", + "value": 10 + }, + { + "displayName": "Ingester: head chunk bytes", + "unit": "decbytes", + "value": 11 + }, + { + "displayName": "Ingester: head chunk lines", + "value": 12 + }, + { + "displayName": "Ingester: decompressed bytes", + "unit": "decbytes", + "value": 13 + }, + { + "displayName": "Ingester: decompressed lines", + "value": 14 + }, + { + "displayName": "Ingester: compressed bytes", + "unit": "decbytes", + "value": 15 + }, + { + "displayName": "Ingester: total duplicates", + "value": 16 + } + ], "executedQueryString": "Expr: query1" } Name: {level="error", location="moon"} @@ -16,6 +124,114 @@ Dimensions: 3 Fields by 1 Rows Frame[1] { + "stats": [ + { + "displayName": "Summary: bytes processed per second", + "unit": "Bps", + "value": 3507022 + }, + { + "displayName": "Summary: lines processed per second", + "value": 24818 + }, + { + "displayName": "Summary: total bytes processed", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Summary: total lines processed", + "value": 55 + }, + { + "displayName": "Summary: exec time", + "unit": "s", + "value": 0.002216125 + }, + { + "displayName": "Store: total chunks ref", + "value": 2 + }, + { + "displayName": "Store: total chunks downloaded", + "value": 3 + }, + { + "displayName": "Store: chunks download time", + "unit": "s", + "value": 0.000390958 + }, + { + "displayName": "Store: head chunk bytes", + "unit": "decbytes", + "value": 4 + }, + { + "displayName": "Store: head chunk lines", + "value": 5 + }, + { + "displayName": "Store: decompressed bytes", + "unit": "decbytes", + "value": 7772 + }, + { + "displayName": "Store: decompressed lines", + "value": 55 + }, + { + "displayName": "Store: compressed bytes", + "unit": "decbytes", + "value": 31432 + }, + { + "displayName": "Store: total duplicates", + "value": 6 + }, + { + "displayName": "Ingester: total reached", + "value": 7 + }, + { + "displayName": "Ingester: total chunks matched", + "value": 8 + }, + { + "displayName": "Ingester: total batches", + "value": 9 + }, + { + "displayName": "Ingester: total lines sent", + "value": 10 + }, + { + "displayName": "Ingester: head chunk bytes", + "unit": "decbytes", + "value": 11 + }, + { + "displayName": "Ingester: head chunk lines", + "value": 12 + }, + { + "displayName": "Ingester: decompressed bytes", + "unit": "decbytes", + "value": 13 + }, + { + "displayName": "Ingester: decompressed lines", + "value": 14 + }, + { + "displayName": "Ingester: compressed bytes", + "unit": "decbytes", + "value": 15 + }, + { + "displayName": "Ingester: total duplicates", + "value": 16 + } + ], "executedQueryString": "Expr: query1" } Name: {level="info", location="moon"} @@ -33,5 +249,5 @@ Dimensions: 3 Fields by 4 Rows ====== TEST DATA RESPONSE (arrow base64) ====== -FRAME=QVJST1cxAAD/////8AIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAALgAAAADAAAAbAAAACgAAAAEAAAAoP3//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADA/f//CAAAACwAAAAgAAAAe2xldmVsPSJlcnJvciIsIGxvY2F0aW9uPSJtb29uIn0AAAAABAAAAG5hbWUAAAAAAP7//wgAAAAwAAAAJgAAAHsiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHF1ZXJ5MSJ9AAAEAAAAbWV0YQAAAAADAAAAmAEAAIQAAAAEAAAAhv7//xQAAABgAAAAYAAAAAAAAAVcAAAAAgAAACwAAAAEAAAAeP7//wgAAAAQAAAABAAAAHRzTnMAAAAABAAAAG5hbWUAAAAAnP7//wgAAAAMAAAAAgAAAHt9AAAGAAAAbGFiZWxzAAAAAAAABP///wQAAAB0c05zAAAAAAL///8UAAAA4AAAAOQAAAAAAAAF4AAAAAMAAABwAAAALAAAAAQAAAD4/v//CAAAABAAAAAFAAAAdmFsdWUAAAAEAAAAbmFtZQAAAAAc////CAAAACwAAAAjAAAAeyJsZXZlbCI6ImVycm9yIiwibG9jYXRpb24iOiJtb29uIn0ABgAAAGxhYmVscwAAXP///wgAAABIAAAAPAAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJ7bGV2ZWw9XCJlcnJvclwiLCBsb2NhdGlvbj1cIm1vb25cIn0ifQAAAAAGAAAAY29uZmlnAAAAAAAABAAEAAQAAAAFAAAAdmFsdWUAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAAAApMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAAB0aW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAHRpbWUAAAAA/////wgBAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAABAAAAAAAAAABQAAAAAAAADBAAKABgADAAIAAQACgAAABQAAACYAAAAAQAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAACAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAACAAAAAAAAAACAAAAAAAAAAoAAAAAAAAABMAAAAAAAAAAAAAAAMAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAFLi6SlLUFgAAAAAQAAAAbG9nIGxpbmUgZXJyb3IgMQAAAAATAAAAMTY0NTAzMDI0NDgxMDc1NzEyMAAAAAAAEAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADwAAAAAAAQAAQAAAAADAAAAAAAAEAEAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAuAAAAAMAAABsAAAAKAAAAAQAAACg/f//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAMD9//8IAAAALAAAACAAAAB7bGV2ZWw9ImVycm9yIiwgbG9jYXRpb249Im1vb24ifQAAAAAEAAAAbmFtZQAAAAAA/v//CAAAADAAAAAmAAAAeyJleGVjdXRlZFF1ZXJ5U3RyaW5nIjoiRXhwcjogcXVlcnkxIn0AAAQAAABtZXRhAAAAAAMAAACYAQAAhAAAAAQAAACG/v//FAAAAGAAAABgAAAAAAAABVwAAAACAAAALAAAAAQAAAB4/v//CAAAABAAAAAEAAAAdHNOcwAAAAAEAAAAbmFtZQAAAACc/v//CAAAAAwAAAACAAAAe30AAAYAAABsYWJlbHMAAAAAAAAE////BAAAAHRzTnMAAAAAAv///xQAAADgAAAA5AAAAAAAAAXgAAAAAwAAAHAAAAAsAAAABAAAAPj+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAABz///8IAAAALAAAACMAAAB7ImxldmVsIjoiZXJyb3IiLCJsb2NhdGlvbiI6Im1vb24ifQAGAAAAbGFiZWxzAABc////CAAAAEgAAAA8AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImVycm9yXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAAAAAYAAABjb25maWcAAAAAAAAEAAQABAAAAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAAAgAwAAQVJST1cx -FRAME=QVJST1cxAAD/////6AIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAALQAAAADAAAAaAAAACgAAAAEAAAAqP3//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADI/f//CAAAACgAAAAfAAAAe2xldmVsPSJpbmZvIiwgbG9jYXRpb249Im1vb24ifQAEAAAAbmFtZQAAAAAE/v//CAAAADAAAAAmAAAAeyJleGVjdXRlZFF1ZXJ5U3RyaW5nIjoiRXhwcjogcXVlcnkxIn0AAAQAAABtZXRhAAAAAAMAAACUAQAAhAAAAAQAAACK/v//FAAAAGAAAABgAAAAAAAABVwAAAACAAAALAAAAAQAAAB8/v//CAAAABAAAAAEAAAAdHNOcwAAAAAEAAAAbmFtZQAAAACg/v//CAAAAAwAAAACAAAAe30AAAYAAABsYWJlbHMAAAAAAAAI////BAAAAHRzTnMAAAAABv///xQAAADcAAAA4AAAAAAAAAXcAAAAAwAAAHAAAAAsAAAABAAAAPz+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAACD///8IAAAALAAAACIAAAB7ImxldmVsIjoiaW5mbyIsImxvY2F0aW9uIjoibW9vbiJ9AAAGAAAAbGFiZWxzAABg////CAAAAEQAAAA7AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImluZm9cIiwgbG9jYXRpb249XCJtb29uXCJ9In0ABgAAAGNvbmZpZwAAAAAAAAQABAAEAAAABQAAAHZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAAB0aW1lAAAAAP////8IAQAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAA4AAAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAmAAAAAQAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAABQAAAAAAAAAOAAAAAAAAAA8AAAAAAAAAHgAAAAAAAAAAAAAAAAAAAB4AAAAAAAAABQAAAAAAAAAkAAAAAAAAABMAAAAAAAAAAAAAAADAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAHrcPktS1BYAJCYSS1LUFgCmJuZKUtQWACfcj0pS1BYAAAAADwAAAB4AAAAtAAAAPAAAAAAAAABsb2cgbGluZSBpbmZvIDFsb2cgbGluZSBpbmZvIDJsb2cgbGluZSBpbmZvIDNsb2cgbGluZSBpbmZvIDQAAAAAAAAAABMAAAAmAAAAOQAAAEwAAAAAAAAAMTY0NTAzMDI0NzAyNzczNTA0MDE2NDUwMzAyNDYyNzc1ODc5NjgxNjQ1MDMwMjQ1NTM5NDIzNzQ0MTY0NTAzMDI0NDA5MTcwMDk5MgAAAAAQAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAPAAAAAAABAABAAAA+AIAAAAAAAAQAQAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAAC0AAAAAwAAAGgAAAAoAAAABAAAAKj9//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAAyP3//wgAAAAoAAAAHwAAAHtsZXZlbD0iaW5mbyIsIGxvY2F0aW9uPSJtb29uIn0ABAAAAG5hbWUAAAAABP7//wgAAAAwAAAAJgAAAHsiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHF1ZXJ5MSJ9AAAEAAAAbWV0YQAAAAADAAAAlAEAAIQAAAAEAAAAiv7//xQAAABgAAAAYAAAAAAAAAVcAAAAAgAAACwAAAAEAAAAfP7//wgAAAAQAAAABAAAAHRzTnMAAAAABAAAAG5hbWUAAAAAoP7//wgAAAAMAAAAAgAAAHt9AAAGAAAAbGFiZWxzAAAAAAAACP///wQAAAB0c05zAAAAAAb///8UAAAA3AAAAOAAAAAAAAAF3AAAAAMAAABwAAAALAAAAAQAAAD8/v//CAAAABAAAAAFAAAAdmFsdWUAAAAEAAAAbmFtZQAAAAAg////CAAAACwAAAAiAAAAeyJsZXZlbCI6ImluZm8iLCJsb2NhdGlvbiI6Im1vb24ifQAABgAAAGxhYmVscwAAYP///wgAAABEAAAAOwAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJ7bGV2ZWw9XCJpbmZvXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAYAAABjb25maWcAAAAAAAAEAAQABAAAAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAAAYAwAAQVJST1cx +FRAME=QVJST1cxAAD/////AAkAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAMQGAAADAAAAbAAAACgAAAAEAAAAlPf//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAC09///CAAAACwAAAAgAAAAe2xldmVsPSJlcnJvciIsIGxvY2F0aW9uPSJtb29uIn0AAAAABAAAAG5hbWUAAAAA9Pf//wgAAAA8BgAAMgYAAHsic3RhdHMiOlt7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogYnl0ZXMgcHJvY2Vzc2VkIHBlciBzZWNvbmQiLCJ1bml0IjoiQnBzIiwidmFsdWUiOjM1MDcwMjJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBsaW5lcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInZhbHVlIjoyNDgxOH0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IHRvdGFsIGJ5dGVzIHByb2Nlc3NlZCIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo3NzcyfSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgbGluZXMgcHJvY2Vzc2VkIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogZXhlYyB0aW1lIiwidW5pdCI6InMiLCJ2YWx1ZSI6MC4wMDIyMTYxMjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIHJlZiIsInZhbHVlIjoyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGNodW5rcyBkb3dubG9hZGVkIiwidmFsdWUiOjN9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogY2h1bmtzIGRvd25sb2FkIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMDM5MDk1OH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjR9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjo1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGRlY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo3NzcyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGRlY29tcHJlc3NlZCBsaW5lcyIsInZhbHVlIjo1NX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjMxNDMyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGR1cGxpY2F0ZXMiLCJ2YWx1ZSI6Nn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCByZWFjaGVkIiwidmFsdWUiOjd9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgY2h1bmtzIG1hdGNoZWQiLCJ2YWx1ZSI6OH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBiYXRjaGVzIiwidmFsdWUiOjl9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgbGluZXMgc2VudCIsInZhbHVlIjoxMH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBoZWFkIGNodW5rIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjExfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgbGluZXMiLCJ2YWx1ZSI6MTJ9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjEzfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGRlY29tcHJlc3NlZCBsaW5lcyIsInZhbHVlIjoxNH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjE1fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGR1cGxpY2F0ZXMiLCJ2YWx1ZSI6MTZ9XSwiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHF1ZXJ5MSJ9AAAEAAAAbWV0YQAAAAADAAAAmAEAAIQAAAAEAAAAhv7//xQAAABgAAAAYAAAAAAAAAVcAAAAAgAAACwAAAAEAAAAeP7//wgAAAAQAAAABAAAAHRzTnMAAAAABAAAAG5hbWUAAAAAnP7//wgAAAAMAAAAAgAAAHt9AAAGAAAAbGFiZWxzAAAAAAAABP///wQAAAB0c05zAAAAAAL///8UAAAA4AAAAOQAAAAAAAAF4AAAAAMAAABwAAAALAAAAAQAAAD4/v//CAAAABAAAAAFAAAAdmFsdWUAAAAEAAAAbmFtZQAAAAAc////CAAAACwAAAAjAAAAeyJsZXZlbCI6ImVycm9yIiwibG9jYXRpb24iOiJtb29uIn0ABgAAAGxhYmVscwAAXP///wgAAABIAAAAPAAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJ7bGV2ZWw9XCJlcnJvclwiLCBsb2NhdGlvbj1cIm1vb25cIn0ifQAAAAAGAAAAY29uZmlnAAAAAAAABAAEAAQAAAAFAAAAdmFsdWUAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAAAApMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAAB0aW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAHRpbWUAAAAAAAAAAP////8IAQAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAQAAAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAmAAAAAEAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAEAAAAAAAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAgAAAAAAAAAKAAAAAAAAAATAAAAAAAAAAAAAAADAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAABS4ukpS1BYAAAAAEAAAAGxvZyBsaW5lIGVycm9yIDEAAAAAEwAAADE2NDUwMzAyNDQ4MTA3NTcxMjAAAAAAABAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA4AAAAAAAEAAEAAAAQCQAAAAAAABABAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAxAYAAAMAAABsAAAAKAAAAAQAAACU9///CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAALT3//8IAAAALAAAACAAAAB7bGV2ZWw9ImVycm9yIiwgbG9jYXRpb249Im1vb24ifQAAAAAEAAAAbmFtZQAAAAD09///CAAAADwGAAAyBgAAeyJzdGF0cyI6W3siZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBieXRlcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInVuaXQiOiJCcHMiLCJ2YWx1ZSI6MzUwNzAyMn0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGxpbmVzIHByb2Nlc3NlZCBwZXIgc2Vjb25kIiwidmFsdWUiOjI0ODE4fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgYnl0ZXMgcHJvY2Vzc2VkIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiB0b3RhbCBsaW5lcyBwcm9jZXNzZWQiLCJ2YWx1ZSI6NTV9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBleGVjIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMjIxNjEyNX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBjaHVua3MgcmVmIiwidmFsdWUiOjJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIGRvd25sb2FkZWQiLCJ2YWx1ZSI6M30seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjaHVua3MgZG93bmxvYWQgdGltZSIsInVuaXQiOiJzIiwidmFsdWUiOjAuMDAwMzkwOTU4fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6NH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGxpbmVzIiwidmFsdWUiOjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MzE0MzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjo2fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIHJlYWNoZWQiLCJ2YWx1ZSI6N30seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBjaHVua3MgbWF0Y2hlZCIsInZhbHVlIjo4fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGJhdGNoZXMiLCJ2YWx1ZSI6OX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBsaW5lcyBzZW50IiwidmFsdWUiOjEwfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTF9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjoxMn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBkZWNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTN9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjE0fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTV9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjoxNn1dLCJleGVjdXRlZFF1ZXJ5U3RyaW5nIjoiRXhwcjogcXVlcnkxIn0AAAQAAABtZXRhAAAAAAMAAACYAQAAhAAAAAQAAACG/v//FAAAAGAAAABgAAAAAAAABVwAAAACAAAALAAAAAQAAAB4/v//CAAAABAAAAAEAAAAdHNOcwAAAAAEAAAAbmFtZQAAAACc/v//CAAAAAwAAAACAAAAe30AAAYAAABsYWJlbHMAAAAAAAAE////BAAAAHRzTnMAAAAAAv///xQAAADgAAAA5AAAAAAAAAXgAAAAAwAAAHAAAAAsAAAABAAAAPj+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAABz///8IAAAALAAAACMAAAB7ImxldmVsIjoiZXJyb3IiLCJsb2NhdGlvbiI6Im1vb24ifQAGAAAAbGFiZWxzAABc////CAAAAEgAAAA8AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImVycm9yXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAAAAAYAAABjb25maWcAAAAAAAAEAAQABAAAAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAAAoCQAAQVJST1cx +FRAME=QVJST1cxAAD/////+AgAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAMAGAAADAAAAaAAAACgAAAAEAAAAnPf//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAC89///CAAAACgAAAAfAAAAe2xldmVsPSJpbmZvIiwgbG9jYXRpb249Im1vb24ifQAEAAAAbmFtZQAAAAD49///CAAAADwGAAAyBgAAeyJzdGF0cyI6W3siZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBieXRlcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInVuaXQiOiJCcHMiLCJ2YWx1ZSI6MzUwNzAyMn0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGxpbmVzIHByb2Nlc3NlZCBwZXIgc2Vjb25kIiwidmFsdWUiOjI0ODE4fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgYnl0ZXMgcHJvY2Vzc2VkIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiB0b3RhbCBsaW5lcyBwcm9jZXNzZWQiLCJ2YWx1ZSI6NTV9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBleGVjIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMjIxNjEyNX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBjaHVua3MgcmVmIiwidmFsdWUiOjJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIGRvd25sb2FkZWQiLCJ2YWx1ZSI6M30seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjaHVua3MgZG93bmxvYWQgdGltZSIsInVuaXQiOiJzIiwidmFsdWUiOjAuMDAwMzkwOTU4fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6NH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGxpbmVzIiwidmFsdWUiOjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MzE0MzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjo2fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIHJlYWNoZWQiLCJ2YWx1ZSI6N30seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBjaHVua3MgbWF0Y2hlZCIsInZhbHVlIjo4fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGJhdGNoZXMiLCJ2YWx1ZSI6OX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBsaW5lcyBzZW50IiwidmFsdWUiOjEwfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTF9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjoxMn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBkZWNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTN9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjE0fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTV9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjoxNn1dLCJleGVjdXRlZFF1ZXJ5U3RyaW5nIjoiRXhwcjogcXVlcnkxIn0AAAQAAABtZXRhAAAAAAMAAACUAQAAhAAAAAQAAACK/v//FAAAAGAAAABgAAAAAAAABVwAAAACAAAALAAAAAQAAAB8/v//CAAAABAAAAAEAAAAdHNOcwAAAAAEAAAAbmFtZQAAAACg/v//CAAAAAwAAAACAAAAe30AAAYAAABsYWJlbHMAAAAAAAAI////BAAAAHRzTnMAAAAABv///xQAAADcAAAA4AAAAAAAAAXcAAAAAwAAAHAAAAAsAAAABAAAAPz+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAACD///8IAAAALAAAACIAAAB7ImxldmVsIjoiaW5mbyIsImxvY2F0aW9uIjoibW9vbiJ9AAAGAAAAbGFiZWxzAABg////CAAAAEQAAAA7AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImluZm9cIiwgbG9jYXRpb249XCJtb29uXCJ9In0ABgAAAGNvbmZpZwAAAAAAAAQABAAEAAAABQAAAHZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAAB0aW1lAAAAAAAAAAD/////CAEAABQAAAAAAAAADAAWABQAEwAMAAQADAAAAOAAAAAAAAAAFAAAAAAAAAMEAAoAGAAMAAgABAAKAAAAFAAAAJgAAAAEAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAUAAAAAAAAADgAAAAAAAAAPAAAAAAAAAB4AAAAAAAAAAAAAAAAAAAAeAAAAAAAAAAUAAAAAAAAAJAAAAAAAAAATAAAAAAAAAAAAAAAAwAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAB63D5LUtQWACQmEktS1BYApibmSlLUFgAn3I9KUtQWAAAAAA8AAAAeAAAALQAAADwAAAAAAAAAbG9nIGxpbmUgaW5mbyAxbG9nIGxpbmUgaW5mbyAybG9nIGxpbmUgaW5mbyAzbG9nIGxpbmUgaW5mbyA0AAAAAAAAAAATAAAAJgAAADkAAABMAAAAAAAAADE2NDUwMzAyNDcwMjc3MzUwNDAxNjQ1MDMwMjQ2Mjc3NTg3OTY4MTY0NTAzMDI0NTUzOTQyMzc0NDE2NDUwMzAyNDQwOTE3MDA5OTIAAAAAEAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADgAAAAAAAQAAQAAAAgJAAAAAAAAEAEAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAADABgAAAwAAAGgAAAAoAAAABAAAAJz3//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAAvPf//wgAAAAoAAAAHwAAAHtsZXZlbD0iaW5mbyIsIGxvY2F0aW9uPSJtb29uIn0ABAAAAG5hbWUAAAAA+Pf//wgAAAA8BgAAMgYAAHsic3RhdHMiOlt7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogYnl0ZXMgcHJvY2Vzc2VkIHBlciBzZWNvbmQiLCJ1bml0IjoiQnBzIiwidmFsdWUiOjM1MDcwMjJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBsaW5lcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInZhbHVlIjoyNDgxOH0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IHRvdGFsIGJ5dGVzIHByb2Nlc3NlZCIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo3NzcyfSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgbGluZXMgcHJvY2Vzc2VkIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogZXhlYyB0aW1lIiwidW5pdCI6InMiLCJ2YWx1ZSI6MC4wMDIyMTYxMjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIHJlZiIsInZhbHVlIjoyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGNodW5rcyBkb3dubG9hZGVkIiwidmFsdWUiOjN9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogY2h1bmtzIGRvd25sb2FkIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMDM5MDk1OH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjR9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjo1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGRlY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo3NzcyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGRlY29tcHJlc3NlZCBsaW5lcyIsInZhbHVlIjo1NX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjMxNDMyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGR1cGxpY2F0ZXMiLCJ2YWx1ZSI6Nn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCByZWFjaGVkIiwidmFsdWUiOjd9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgY2h1bmtzIG1hdGNoZWQiLCJ2YWx1ZSI6OH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBiYXRjaGVzIiwidmFsdWUiOjl9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgbGluZXMgc2VudCIsInZhbHVlIjoxMH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBoZWFkIGNodW5rIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjExfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgbGluZXMiLCJ2YWx1ZSI6MTJ9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjEzfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGRlY29tcHJlc3NlZCBsaW5lcyIsInZhbHVlIjoxNH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjE1fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGR1cGxpY2F0ZXMiLCJ2YWx1ZSI6MTZ9XSwiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHF1ZXJ5MSJ9AAAEAAAAbWV0YQAAAAADAAAAlAEAAIQAAAAEAAAAiv7//xQAAABgAAAAYAAAAAAAAAVcAAAAAgAAACwAAAAEAAAAfP7//wgAAAAQAAAABAAAAHRzTnMAAAAABAAAAG5hbWUAAAAAoP7//wgAAAAMAAAAAgAAAHt9AAAGAAAAbGFiZWxzAAAAAAAACP///wQAAAB0c05zAAAAAAb///8UAAAA3AAAAOAAAAAAAAAF3AAAAAMAAABwAAAALAAAAAQAAAD8/v//CAAAABAAAAAFAAAAdmFsdWUAAAAEAAAAbmFtZQAAAAAg////CAAAACwAAAAiAAAAeyJsZXZlbCI6ImluZm8iLCJsb2NhdGlvbiI6Im1vb24ifQAABgAAAGxhYmVscwAAYP///wgAAABEAAAAOwAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJ7bGV2ZWw9XCJpbmZvXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAYAAABjb25maWcAAAAAAAAEAAQABAAAAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAAAgCQAAQVJST1cx diff --git a/pkg/tsdb/loki/testdata/streams_simple.json b/pkg/tsdb/loki/testdata/streams_simple.json index 87fb82a3952..8754d444ad0 100644 --- a/pkg/tsdb/loki/testdata/streams_simple.json +++ b/pkg/tsdb/loki/testdata/streams_simple.json @@ -50,26 +50,26 @@ }, "store": { "totalChunksRef": 2, - "totalChunksDownloaded": 2, + "totalChunksDownloaded": 3, "chunksDownloadTime": 0.000390958, - "headChunkBytes": 0, - "headChunkLines": 0, + "headChunkBytes": 4, + "headChunkLines": 5, "decompressedBytes": 7772, "decompressedLines": 55, "compressedBytes": 31432, - "totalDuplicates": 0 + "totalDuplicates": 6 }, "ingester": { - "totalReached": 0, - "totalChunksMatched": 0, - "totalBatches": 0, - "totalLinesSent": 0, - "headChunkBytes": 0, - "headChunkLines": 0, - "decompressedBytes": 0, - "decompressedLines": 0, - "compressedBytes": 0, - "totalDuplicates": 0 + "totalReached": 7, + "totalChunksMatched": 8, + "totalBatches": 9, + "totalLinesSent": 10, + "headChunkBytes": 11, + "headChunkLines": 12, + "decompressedBytes": 13, + "decompressedLines": 14, + "compressedBytes": 15, + "totalDuplicates": 16 } } } diff --git a/pkg/tsdb/postgres/postgres.go b/pkg/tsdb/postgres/postgres.go index e7e73f0c3ea..527102831b6 100644 --- a/pkg/tsdb/postgres/postgres.go +++ b/pkg/tsdb/postgres/postgres.go @@ -122,18 +122,36 @@ func (s *Service) generateConnectionString(dsInfo sqleng.DataSourceInfo) (string host = dsInfo.URL logger.Debug("Generating connection string with Unix socket specifier", "socket", host) } else { + index := strings.LastIndex(dsInfo.URL, ":") + v6Index := strings.Index(dsInfo.URL, "]") sp := strings.SplitN(dsInfo.URL, ":", 2) host = sp[0] - if len(sp) > 1 { - var err error - port, err = strconv.Atoi(sp[1]) - if err != nil { - return "", errutil.Wrapf(err, "invalid port in host specifier %q", sp[1]) - } + if v6Index == -1 { + if len(sp) > 1 { + var err error + port, err = strconv.Atoi(sp[1]) + if err != nil { + return "", errutil.Wrapf(err, "invalid port in host specifier %q", sp[1]) + } - logger.Debug("Generating connection string with network host/port pair", "host", host, "port", port) + logger.Debug("Generating connection string with network host/port pair", "host", host, "port", port) + } else { + logger.Debug("Generating connection string with network host", "host", host) + } } else { - logger.Debug("Generating connection string with network host", "host", host) + if index == v6Index+1 { + host = dsInfo.URL[0:index] + var err error + port, err = strconv.Atoi(dsInfo.URL[index+1:]) + if err != nil { + return "", errutil.Wrapf(err, "invalid port in host specifier %q", dsInfo.URL[index+1:]) + } + + logger.Debug("Generating ipv6 connection string with network host/port pair", "host", host, "port", port) + } else { + host = dsInfo.URL + logger.Debug("Generating ipv6 connection string with network host", "host", host) + } } } diff --git a/pkg/tsdb/postgres/postgres_test.go b/pkg/tsdb/postgres/postgres_test.go index ae9e4baa2a9..eb7278d47ff 100644 --- a/pkg/tsdb/postgres/postgres_test.go +++ b/pkg/tsdb/postgres/postgres_test.go @@ -67,6 +67,24 @@ func TestGenerateConnectionString(t *testing.T) { tlsSettings: tlsSettings{Mode: "verify-full"}, expConnStr: "user='user' password='password' host='host' dbname='database' port=1234 sslmode='verify-full'", }, + { + desc: "Ipv6 host", + host: "[::1]", + user: "user", + password: "password", + database: "database", + tlsSettings: tlsSettings{Mode: "verify-full"}, + expConnStr: "user='user' password='password' host='[::1]' dbname='database' sslmode='verify-full'", + }, + { + desc: "Ipv6/port host", + host: "[::1]:1234", + user: "user", + password: "password", + database: "database", + tlsSettings: tlsSettings{Mode: "verify-full"}, + expConnStr: "user='user' password='password' host='[::1]' dbname='database' port=1234 sslmode='verify-full'", + }, { desc: "Invalid port", host: "host:invalid", @@ -84,6 +102,15 @@ func TestGenerateConnectionString(t *testing.T) { tlsSettings: tlsSettings{Mode: "verify-full"}, expConnStr: `user='user' password='p\'\\assword' host='host' dbname='database' sslmode='verify-full'`, }, + { + desc: "User/DB with single quote and backslash", + host: "host", + user: `u'\ser`, + password: `password`, + database: `d'\atabase`, + tlsSettings: tlsSettings{Mode: "verify-full"}, + expConnStr: `user='u\'\\ser' password='password' host='host' dbname='d\'\\atabase' sslmode='verify-full'`, + }, { desc: "Custom TLS mode disabled", host: "host", diff --git a/pkg/tsdb/prometheus/promclient/provider.go b/pkg/tsdb/prometheus/promclient/provider.go index c33d6a14c11..3ccff28004c 100644 --- a/pkg/tsdb/prometheus/promclient/provider.go +++ b/pkg/tsdb/prometheus/promclient/provider.go @@ -4,6 +4,8 @@ import ( "strings" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/prometheus/middleware" "github.com/grafana/grafana/pkg/util/maputil" @@ -19,6 +21,8 @@ type Provider struct { jsonData map[string]interface{} httpMethod string clientProvider httpclient.Provider + cfg *setting.Cfg + features featuremgmt.FeatureToggles log log.Logger } @@ -26,6 +30,8 @@ func NewProvider( settings backend.DataSourceInstanceSettings, jsonData map[string]interface{}, clientProvider httpclient.Provider, + cfg *setting.Cfg, + features featuremgmt.FeatureToggles, log log.Logger, ) *Provider { httpMethod, _ := maputil.GetStringOptional(jsonData, "httpMethod") @@ -34,6 +40,8 @@ func NewProvider( jsonData: jsonData, httpMethod: httpMethod, clientProvider: clientProvider, + cfg: cfg, + features: features, log: log, } } @@ -53,7 +61,7 @@ func (p *Provider) GetClient(headers map[string]string) (apiv1.API, error) { } // Azure authentication - err = p.configureAzureAuthentication(opts) + err = p.configureAzureAuthentication(&opts) if err != nil { return nil, err } diff --git a/pkg/tsdb/prometheus/promclient/provider_azure.go b/pkg/tsdb/prometheus/promclient/provider_azure.go index 920f253a75d..318867ef681 100644 --- a/pkg/tsdb/prometheus/promclient/provider_azure.go +++ b/pkg/tsdb/prometheus/promclient/provider_azure.go @@ -2,13 +2,23 @@ package promclient import ( "fmt" + "net/url" + "path" + "github.com/grafana/grafana-azure-sdk-go/azcredentials" + "github.com/grafana/grafana-azure-sdk-go/azhttpclient" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" + + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/util/maputil" ) -func (p *Provider) configureAzureAuthentication(opts sdkhttpclient.Options) error { +func (p *Provider) configureAzureAuthentication(opts *sdkhttpclient.Options) error { + // Azure authentication is experimental (#35857) + if !p.features.IsEnabled(featuremgmt.FlagPrometheusAzureAuth) { + return nil + } + credentials, err := azcredentials.FromDatasourceData(p.jsonData, p.settings.DecryptedSecureJSONData) if err != nil { err = fmt.Errorf("invalid Azure credentials: %s", err) @@ -16,16 +26,24 @@ func (p *Provider) configureAzureAuthentication(opts sdkhttpclient.Options) erro } if credentials != nil { - opts.CustomOptions["_azureCredentials"] = credentials - - resourceId, err := maputil.GetStringOptional(p.jsonData, "azureEndpointResourceId") + resourceIdStr, err := maputil.GetStringOptional(p.jsonData, "azureEndpointResourceId") if err != nil { return err + } else if resourceIdStr == "" { + err := fmt.Errorf("endpoint resource ID (audience) not provided") + return err } - if resourceId != "" { - opts.CustomOptions["azureEndpointResourceId"] = resourceId + resourceId, err := url.Parse(resourceIdStr) + if err != nil || resourceId.Scheme == "" || resourceId.Host == "" { + err := fmt.Errorf("endpoint resource ID (audience) '%s' invalid", resourceIdStr) + return err } + + resourceId.Path = path.Join(resourceId.Path, ".default") + scopes := []string{resourceId.String()} + + azhttpclient.AddAzureAuthentication(opts, p.cfg.Azure, credentials, scopes) } return nil diff --git a/pkg/tsdb/prometheus/promclient/provider_azure_test.go b/pkg/tsdb/prometheus/promclient/provider_azure_test.go new file mode 100644 index 00000000000..ee4c4536664 --- /dev/null +++ b/pkg/tsdb/prometheus/promclient/provider_azure_test.go @@ -0,0 +1,150 @@ +package promclient + +import ( + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigureAzureAuthentication(t *testing.T) { + cfg := &setting.Cfg{} + settings := backend.DataSourceInstanceSettings{} + + t.Run("given feature flag enabled", func(t *testing.T) { + features := featuremgmt.WithFeatures(featuremgmt.FlagPrometheusAzureAuth) + + t.Run("should set Azure middleware when JsonData contains valid credentials", func(t *testing.T) { + jsonData := map[string]interface{}{ + "httpMethod": "POST", + "azureCredentials": map[string]interface{}{ + "authType": "msi", + }, + "azureEndpointResourceId": "https://api.example.com/abd5c4ce-ca73-41e9-9cb2-bed39aa2adb5", + } + + var p = NewProvider(settings, jsonData, nil, cfg, features, nil) + + var opts = &sdkhttpclient.Options{CustomOptions: map[string]interface{}{}} + + err := p.configureAzureAuthentication(opts) + require.NoError(t, err) + + require.NotNil(t, opts.Middlewares) + assert.Len(t, opts.Middlewares, 1) + }) + + t.Run("should not set Azure middleware when JsonData doesn't contain valid credentials", func(t *testing.T) { + jsonData := map[string]interface{}{ + "httpMethod": "POST", + } + + var p = NewProvider(settings, jsonData, nil, cfg, features, nil) + + var opts = &sdkhttpclient.Options{CustomOptions: map[string]interface{}{}} + + err := p.configureAzureAuthentication(opts) + require.NoError(t, err) + + assert.NotContains(t, opts.CustomOptions, "_azureCredentials") + }) + + t.Run("should return error when JsonData contains invalid credentials", func(t *testing.T) { + jsonData := map[string]interface{}{ + "httpMethod": "POST", + "azureCredentials": "invalid", + } + + var p = NewProvider(settings, jsonData, nil, cfg, features, nil) + + var opts = &sdkhttpclient.Options{CustomOptions: map[string]interface{}{}} + + err := p.configureAzureAuthentication(opts) + assert.Error(t, err) + }) + + t.Run("should set Azure middleware when JsonData contains credentials and valid audience", func(t *testing.T) { + jsonData := map[string]interface{}{ + "httpMethod": "POST", + "azureCredentials": map[string]interface{}{ + "authType": "msi", + }, + "azureEndpointResourceId": "https://api.example.com/abd5c4ce-ca73-41e9-9cb2-bed39aa2adb5", + } + + var p = NewProvider(settings, jsonData, nil, cfg, features, nil) + + var opts = &sdkhttpclient.Options{CustomOptions: map[string]interface{}{}} + + err := p.configureAzureAuthentication(opts) + require.NoError(t, err) + + require.NotNil(t, opts.Middlewares) + assert.Len(t, opts.Middlewares, 1) + }) + + t.Run("should not set Azure middleware when JsonData doesn't contain credentials", func(t *testing.T) { + jsonData := map[string]interface{}{ + "httpMethod": "POST", + "azureEndpointResourceId": "https://api.example.com/abd5c4ce-ca73-41e9-9cb2-bed39aa2adb5", + } + + var p = NewProvider(settings, jsonData, nil, cfg, features, nil) + + var opts = &sdkhttpclient.Options{CustomOptions: map[string]interface{}{}} + + err := p.configureAzureAuthentication(opts) + require.NoError(t, err) + + if opts.Middlewares != nil { + assert.Len(t, opts.Middlewares, 0) + } + }) + + t.Run("should return error when JsonData contains invalid audience", func(t *testing.T) { + jsonData := map[string]interface{}{ + "httpMethod": "POST", + "azureCredentials": map[string]interface{}{ + "authType": "msi", + }, + "azureEndpointResourceId": "invalid", + } + + var p = NewProvider(settings, jsonData, nil, cfg, features, nil) + + var opts = &sdkhttpclient.Options{CustomOptions: map[string]interface{}{}} + + err := p.configureAzureAuthentication(opts) + assert.Error(t, err) + }) + }) + + t.Run("given feature flag not enabled", func(t *testing.T) { + features := featuremgmt.WithFeatures() + + t.Run("should not set Azure Credentials even when JsonData contains credentials", func(t *testing.T) { + jsonData := map[string]interface{}{ + "httpMethod": "POST", + "azureCredentials": map[string]interface{}{ + "authType": "msi", + }, + "azureEndpointResourceId": "https://api.example.com/abd5c4ce-ca73-41e9-9cb2-bed39aa2adb5", + } + + var p = NewProvider(settings, jsonData, nil, cfg, features, nil) + + var opts = &sdkhttpclient.Options{CustomOptions: map[string]interface{}{}} + + err := p.configureAzureAuthentication(opts) + require.NoError(t, err) + + if opts.Middlewares != nil { + assert.Len(t, opts.Middlewares, 0) + } + }) + }) +} diff --git a/pkg/tsdb/prometheus/promclient/provider_test.go b/pkg/tsdb/prometheus/promclient/provider_test.go index 43acc14b362..b62af351c39 100644 --- a/pkg/tsdb/prometheus/promclient/provider_test.go +++ b/pkg/tsdb/prometheus/promclient/provider_test.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/tsdb/prometheus/promclient" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -138,9 +139,11 @@ func setup(jsonData ...string) *testContext { var jd map[string]interface{} _ = json.Unmarshal(rawData, &jd) + cfg := &setting.Cfg{} settings := backend.DataSourceInstanceSettings{URL: "test-url", JSONData: rawData} + features := featuremgmt.WithFeatures() hp := &fakeHttpClientProvider{} - p := promclient.NewProvider(settings, jd, hp, nil) + p := promclient.NewProvider(settings, jd, hp, cfg, features, nil) return &testContext{ httpProvider: hp, diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index 9ba855411ad..71c1726e996 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -7,15 +7,16 @@ import ( "fmt" "regexp" - "github.com/grafana/grafana/pkg/tsdb/prometheus/promclient" - "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/intervalv2" + "github.com/grafana/grafana/pkg/tsdb/prometheus/promclient" "github.com/grafana/grafana/pkg/util/maputil" apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" ) @@ -32,16 +33,16 @@ type Service struct { tracer tracing.Tracer } -func ProvideService(httpClientProvider httpclient.Provider, tracer tracing.Tracer) *Service { +func ProvideService(httpClientProvider httpclient.Provider, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer) *Service { plog.Debug("initializing") return &Service{ intervalCalculator: intervalv2.NewCalculator(), - im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)), + im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider, cfg, features)), tracer: tracer, } } -func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.InstanceFactoryFunc { +func newInstanceSettings(httpClientProvider httpclient.Provider, cfg *setting.Cfg, features featuremgmt.FeatureToggles) datasource.InstanceFactoryFunc { return func(settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { var jsonData map[string]interface{} err := json.Unmarshal(settings.JSONData, &jsonData) @@ -49,7 +50,7 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst return nil, fmt.Errorf("error reading settings: %w", err) } - p := promclient.NewProvider(settings, jsonData, httpClientProvider, plog) + p := promclient.NewProvider(settings, jsonData, httpClientProvider, cfg, features, plog) pc, err := promclient.NewProviderCache(p) if err != nil { return nil, err diff --git a/public/api-merged.json b/public/api-merged.json index a7b2fd4826e..ff5e20db5e9 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -3035,7 +3035,7 @@ }, "collectionFormat": "multi", "x-go-name": "Tags", - "description": "Use this to filter global annotations. Global annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. You can filter by multiple tags.", + "description": "Use this to filter organization annotations. Organization annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. You can filter by multiple tags.", "name": "tags", "in": "query" }, @@ -3068,7 +3068,7 @@ } }, "post": { - "description": "Creates an annotation in the Grafana database. The dashboardId and panelId fields are optional. If they are not specified then a global annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. When creating a region annotation include the timeEnd property.\nThe format for `time` and `timeEnd` should be epoch numbers in millisecond resolution.\nThe response for this HTTP request is slightly different in versions prior to v6.4. In prior versions you would also get an endId if you where creating a region. But in 6.4 regions are represented using a single event with time and timeEnd properties.", + "description": "Creates an annotation in the Grafana database. The dashboardId and panelId fields are optional. If they are not specified then an organization annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. When creating a region annotation include the timeEnd property.\nThe format for `time` and `timeEnd` should be epoch numbers in millisecond resolution.\nThe response for this HTTP request is slightly different in versions prior to v6.4. In prior versions you would also get an endId if you where creating a region. But in 6.4 regions are represented using a single event with time and timeEnd properties.", "tags": ["annotations"], "summary": "Create Annotation.", "operationId": "createAnnotation", @@ -18302,7 +18302,7 @@ "name": "legacy_alerts_notification_channels" }, { - "description": "Grafana Annotations feature released in Grafana 4.6. Annotations are saved in the Grafana database (sqlite, mysql or postgres). Annotations can be global annotations that can be shown on any dashboard by configuring an annotation data source - they are filtered by tags. Or they can be tied to a panel on a dashboard and are then only shown on that panel.", + "description": "Grafana Annotations feature released in Grafana 4.6. Annotations are saved in the Grafana database (sqlite, mysql or postgres). Annotations can be organization annotations that can be shown on any dashboard by configuring an annotation data source - they are filtered by tags. Or they can be tied to a panel on a dashboard and are then only shown on that panel.", "name": "annotations" }, { diff --git a/public/api-spec.json b/public/api-spec.json index ca2d7f1d234..2d5f6cbbf2b 100644 --- a/public/api-spec.json +++ b/public/api-spec.json @@ -2085,7 +2085,7 @@ }, "collectionFormat": "multi", "x-go-name": "Tags", - "description": "Use this to filter global annotations. Global annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. You can filter by multiple tags.", + "description": "Use this to filter organization annotations. Organization annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. You can filter by multiple tags.", "name": "tags", "in": "query" }, @@ -2118,7 +2118,7 @@ } }, "post": { - "description": "Creates an annotation in the Grafana database. The dashboardId and panelId fields are optional. If they are not specified then a global annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. When creating a region annotation include the timeEnd property.\nThe format for `time` and `timeEnd` should be epoch numbers in millisecond resolution.\nThe response for this HTTP request is slightly different in versions prior to v6.4. In prior versions you would also get an endId if you where creating a region. But in 6.4 regions are represented using a single event with time and timeEnd properties.", + "description": "Creates an annotation in the Grafana database. The dashboardId and panelId fields are optional. If they are not specified then an organization annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. When creating a region annotation include the timeEnd property.\nThe format for `time` and `timeEnd` should be epoch numbers in millisecond resolution.\nThe response for this HTTP request is slightly different in versions prior to v6.4. In prior versions you would also get an endId if you where creating a region. But in 6.4 regions are represented using a single event with time and timeEnd properties.", "tags": ["annotations"], "summary": "Create Annotation.", "operationId": "createAnnotation", @@ -13422,7 +13422,7 @@ "name": "legacy_alerts_notification_channels" }, { - "description": "Grafana Annotations feature released in Grafana 4.6. Annotations are saved in the Grafana database (sqlite, mysql or postgres). Annotations can be global annotations that can be shown on any dashboard by configuring an annotation data source - they are filtered by tags. Or they can be tied to a panel on a dashboard and are then only shown on that panel.", + "description": "Grafana Annotations feature released in Grafana 4.6. Annotations are saved in the Grafana database (sqlite, mysql or postgres). Annotations can be organization annotations that can be shown on any dashboard by configuring an annotation data source - they are filtered by tags. Or they can be tied to a panel on a dashboard and are then only shown on that panel.", "name": "annotations" }, { diff --git a/public/app/angular/AngularLocationWrapper.ts b/public/app/angular/AngularLocationWrapper.ts index b7c8a1318ea..4c6f5c133c2 100644 --- a/public/app/angular/AngularLocationWrapper.ts +++ b/public/app/angular/AngularLocationWrapper.ts @@ -35,7 +35,7 @@ export class AngularLocationWrapper { navigationLogger('AngularLocationWrapper', false, 'Angular compat layer: hash'); if (!newHash) { - return locationService.getLocation().hash.substr(1); + return locationService.getLocation().hash.slice(1); } else { throw new Error('AngularLocationWrapper method not implemented.'); } diff --git a/public/app/core/components/NavBar/Next/NavBarItem.tsx b/public/app/core/components/NavBar/Next/NavBarItem.tsx index 61c3b9400a2..752e8807431 100644 --- a/public/app/core/components/NavBar/Next/NavBarItem.tsx +++ b/public/app/core/components/NavBar/Next/NavBarItem.tsx @@ -8,7 +8,7 @@ import { locationService } from '@grafana/runtime'; import { NavBarMenuItem } from './NavBarMenuItem'; import { getNavBarItemWithoutMenuStyles, NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger'; -import { NavBarItemMenu } from '../NavBarItemMenu'; +import { NavBarItemMenu } from './NavBarItemMenu'; import { getNavModelItemKey } from '../utils'; import { useLingui } from '@lingui/react'; import menuItemTranslations from '../navBarItem-translations'; @@ -82,7 +82,12 @@ const NavBarItem = ({ } else { return (
  • - + { + onNavigate: (item: NavModelItem) => void; + adjustHeightForBorder: boolean; + reverseMenuDirection?: boolean; +} + +export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null { + const { reverseMenuDirection, adjustHeightForBorder, disabledKeys, onNavigate, ...rest } = props; + const contextProps = useNavBarItemMenuContext(); + const completeProps = { + ...mergeProps(contextProps, rest), + }; + const { menuHasFocus, menuProps: contextMenuProps = {} } = contextProps; + const theme = useTheme2(); + const styles = getStyles(theme, reverseMenuDirection); + const state = useTreeState({ ...rest, disabledKeys }); + const ref = useRef(null); + const { menuProps } = useMenu(completeProps, { ...state }, ref); + const allItems = [...state.collection]; + const items = allItems.filter((item) => item.value.menuItemType === NavMenuItemType.Item); + const section = allItems.find((item) => item.value.menuItemType === NavMenuItemType.Section); + + useEffect(() => { + if (menuHasFocus && !state.selectionManager.isFocused) { + state.selectionManager.setFocusedKey(section?.key ?? ''); + state.selectionManager.setFocused(true); + } else if (!menuHasFocus) { + state.selectionManager.setFocused(false); + state.selectionManager.setFocusedKey(''); + state.selectionManager.clearSelection(); + } + }, [menuHasFocus, state.selectionManager, reverseMenuDirection, section?.key]); + + if (!section) { + return null; + } + + const menuSubTitle = section.value.subTitle; + + const sectionComponent = ( + + ); + + const itemComponents = items.map((item) => ( + + )); + + const subTitleComponent = menuSubTitle && ( +
  • + {menuSubTitle} +
  • + ); + + const menu = [sectionComponent, itemComponents, subTitleComponent]; + + return ( +
      + {reverseMenuDirection ? menu.reverse() : menu} +
    + ); +} + +function getStyles(theme: GrafanaTheme2, reverseDirection?: boolean) { + return { + menu: css` + background-color: ${theme.colors.background.primary}; + border: 1px solid ${theme.components.panel.borderColor}; + bottom: ${reverseDirection ? 0 : 'auto'}; + box-shadow: ${theme.shadows.z3}; + display: flex; + flex-direction: column; + left: 100%; + list-style: none; + min-width: 140px; + top: ${reverseDirection ? 'auto' : 0}; + transition: ${theme.transitions.create('opacity')}; + z-index: ${theme.zIndex.sidemenu}; + `, + subtitle: css` + background-color: transparent; + border-${reverseDirection ? 'bottom' : 'top'}: 1px solid ${theme.colors.border.weak}; + color: ${theme.colors.text.secondary}; + font-size: ${theme.typography.bodySmall.fontSize}; + font-weight: ${theme.typography.bodySmall.fontWeight}; + padding: ${theme.spacing(1)} ${theme.spacing(2)} ${theme.spacing(1)}; + text-align: left; + white-space: nowrap; + `, + }; +} diff --git a/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx b/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx index edd29e0cd48..9d81447e735 100644 --- a/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx +++ b/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx @@ -8,10 +8,10 @@ import { useMenuTrigger } from '@react-aria/menu'; import { useFocusWithin, useHover, useKeyboard } from '@react-aria/interactions'; import { useButton } from '@react-aria/button'; import { useDialog } from '@react-aria/dialog'; -import { DismissButton, useOverlay } from '@react-aria/overlays'; +import { DismissButton, OverlayContainer, useOverlay, useOverlayPosition } from '@react-aria/overlays'; import { FocusScope } from '@react-aria/focus'; -import { NavBarItemMenuContext } from '../context'; +import { NavBarItemMenuContext, useNavBarContext } from '../context'; import { NavFeatureHighlight } from '../NavFeatureHighlight'; import { reportExperimentView } from '@grafana/runtime'; @@ -20,11 +20,13 @@ export interface NavBarItemMenuTriggerProps extends MenuTriggerProps { item: NavModelItem; isActive?: boolean; label: string; + reverseMenuDirection: boolean; } export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactElement { - const { item, isActive, label, children: menu, ...rest } = props; + const { item, isActive, label, children: menu, reverseMenuDirection, ...rest } = props; const [menuHasFocus, setMenuHasFocus] = useState(false); + const { menuIdOpen, setMenuIdOpen } = useNavBarContext(); const theme = useTheme2(); const styles = getStyles(theme, isActive); @@ -45,23 +47,23 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE onHoverChange: (isHovering) => { if (isHovering) { state.open(); + setMenuIdOpen(ref.current?.id || null); } else { state.close(); } }, }); - const { focusWithinProps } = useFocusWithin({ - onFocusWithinChange: (isFocused) => { - if (isFocused) { - state.open(); - } - if (!isFocused) { - state.close(); - setMenuHasFocus(false); - } - }, - }); + 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) { + state.close(); + setMenuHasFocus(false); + } else { + state.open(); + } + }, [menuIdOpen, state]); const { keyboardProps } = useKeyboard({ onKeyDown: (e) => { @@ -72,6 +74,9 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE } setMenuHasFocus(true); break; + case 'Tab': + setMenuIdOpen(null); + break; default: break; } @@ -133,7 +138,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE ); } - const overlayRef = React.useRef(null); + const overlayRef = React.useRef(null); const { dialogProps } = useDialog({}, overlayRef); const { overlayProps } = useOverlay( { @@ -144,29 +149,55 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE overlayRef ); + let { overlayProps: overlayPositionProps } = useOverlayPosition({ + targetRef: ref, + overlayRef, + placement: reverseMenuDirection ? 'right bottom' : 'right top', + isOpen: state.isOpen, + }); + + const { focusWithinProps } = useFocusWithin({ + 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); + state.open(); + } + }, + onBlurWithin: (e) => { + 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); + } + }, + }); + return (
    {element} {state.isOpen && ( - state.close(), - onLeft: () => { - setMenuHasFocus(false); - ref.current?.focus(); - }, - }} - > - -
    - state.close()} /> - {menu} - state.close()} /> -
    -
    -
    + + state.close(), + onLeft: () => { + setMenuHasFocus(false); + ref.current?.focus(); + }, + }} + > + +
    + state.close()} /> + {menu} + state.close()} /> +
    +
    +
    +
    )}
    ); diff --git a/public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx b/public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx index f4de8e34142..aaa0af55995 100644 --- a/public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx +++ b/public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx @@ -32,10 +32,10 @@ export function NavBarItemWithoutMenu({ const content = highlightText ? ( - {children} +
    {children}
    ) : ( - {children} +
    {children}
    ); const elStyle = cx(styles.element, elClassName); @@ -76,12 +76,6 @@ export function getNavBarItemWithoutMenuStyles(theme: GrafanaTheme2, isActive?: '&:hover': { backgroundColor: theme.colors.action.hover, color: theme.colors.text.primary, - - // TODO don't use a hardcoded class here, use isVisible in NavBarDropdown - '.navbar-dropdown': { - opacity: 1, - visibility: 'visible', - }, }, }), element: css({ diff --git a/public/app/core/components/NavBar/Next/NavBarMenu.tsx b/public/app/core/components/NavBar/Next/NavBarMenu.tsx index 6117402d64c..e5c17517721 100644 --- a/public/app/core/components/NavBar/Next/NavBarMenu.tsx +++ b/public/app/core/components/NavBar/Next/NavBarMenu.tsx @@ -4,34 +4,37 @@ import { CollapsableSection, CustomScrollbar, Icon, IconName, useStyles2 } from import { FocusScope } from '@react-aria/focus'; import { useDialog } from '@react-aria/dialog'; import { useOverlay } from '@react-aria/overlays'; -import { css, cx, keyframes } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { NavBarMenuItem } from './NavBarMenuItem'; import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; import { isMatchOrChildMatch } from '../utils'; +import { NavBarToggle } from './NavBarToggle'; export interface Props { activeItem?: NavModelItem; + isOpen: boolean; navItems: NavModelItem[]; onClose: () => void; } -export function NavBarMenu({ activeItem, navItems, onClose }: Props) { +export function NavBarMenu({ activeItem, isOpen, navItems, onClose }: Props) { const styles = useStyles2(getStyles); const ref = useRef(null); const { dialogProps } = useDialog({}, ref); const { overlayProps } = useOverlay( { isDismissable: true, - isOpen: true, + isOpen, onClose, }, ref ); return ( - -
    -
    ); } NavBarMenu.displayName = 'NavBarMenu'; -const getStyles = (theme: GrafanaTheme2) => { - const fadeIn = keyframes` - from { - background-color: ${theme.colors.background.primary}; - width: ${theme.spacing(7)}; - } - to { - background-color: ${theme.colors.background.canvas}; - width: 300px; - }`; - - return { - container: css({ - animation: `150ms ease-in 0s 1 normal forwards ${fadeIn}`, - bottom: 0, - display: 'flex', - flexDirection: 'column', - left: 0, - whiteSpace: 'nowrap', - marginTop: theme.spacing(1), - marginRight: theme.spacing(1.5), - right: 0, - zIndex: 9999, - top: 0, - [theme.breakpoints.up('md')]: { - borderRight: `1px solid ${theme.colors.border.weak}`, - right: 'unset', - }, - }), - content: css({ - display: 'flex', - flexDirection: 'column', - overflow: 'auto', - }), - itemList: css({ - display: 'grid', - gridAutoRows: `minmax(${theme.spacing(6)}, auto)`, - }), - }; -}; +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + bottom: 0, + display: 'flex', + flexDirection: 'column', + left: 0, + whiteSpace: 'nowrap', + paddingTop: theme.spacing(1), + marginRight: theme.spacing(1.5), + overflow: 'hidden', + right: 0, + zIndex: theme.zIndex.sidemenu, + top: 0, + boxSizing: 'content-box', + [theme.breakpoints.up('md')]: { + borderRight: `1px solid ${theme.colors.border.weak}`, + right: 'unset', + }, + }), + content: css({ + display: 'flex', + flexDirection: 'column', + overflow: 'auto', + }), + itemList: css({ + display: 'grid', + gridAutoRows: `minmax(${theme.spacing(6)}, auto)`, + }), + menuCollapseIcon: css({ + position: 'absolute', + top: '43px', + right: '0px', + }), +}); function NavItem({ link, @@ -147,10 +144,12 @@ function NavItem({ isActive={link === activeItem} >
    - {link.img && ( - {`${link.text} - )} - {link.icon && } +
    + {link.icon && } + {link.img && ( + {`${link.text} + )} +
    {link.text}
    @@ -162,6 +161,7 @@ function NavItem({ const getNavItemStyles = (theme: GrafanaTheme2) => ({ item: css({ padding: `${theme.spacing(1)} 0`, + whiteSpace: 'normal', '&::before': { display: 'none', }, @@ -186,13 +186,18 @@ const getNavItemStyles = (theme: GrafanaTheme2) => ({ alignItems: 'center', }), fullWidth: css({ + height: '100%', width: '100%', }), + iconContainer: css({ + placeContent: 'center', + }), savedItemsMenuItemWrapper: css({ display: 'grid', gridAutoFlow: 'column', gridTemplateColumns: `${theme.spacing(7)} auto`, alignItems: 'center', + height: '100%', }), linkText: css({ fontSize: theme.typography.pxToRem(14), @@ -249,7 +254,9 @@ function CollapsibleNavItem({ const getCollapsibleStyles = (theme: GrafanaTheme2) => ({ menuItem: css({ position: 'relative', - display: 'flex', + display: 'grid', + gridAutoFlow: 'column', + gridTemplateColumns: '56px auto', }), collapsibleMenuItem: css({ height: theme.spacing(6), diff --git a/public/app/core/components/NavBar/Next/NavBarNext.tsx b/public/app/core/components/NavBar/Next/NavBarNext.tsx index c1526a91a7d..73ba3d64c47 100644 --- a/public/app/core/components/NavBar/Next/NavBarNext.tsx +++ b/public/app/core/components/NavBar/Next/NavBarNext.tsx @@ -1,9 +1,10 @@ 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, IconButton, IconName, useTheme2 } from '@grafana/ui'; +import { Icon, IconName, useStyles2, useTheme2 } from '@grafana/ui'; import { config, locationService } from '@grafana/runtime'; import { getKioskMode } from 'app/core/navigation/kiosk'; import { KioskMode, StoreState } from 'app/types'; @@ -13,6 +14,9 @@ import { NavBarMenu } from './NavBarMenu'; import NavBarItem from './NavBarItem'; import { useSelector } from 'react-redux'; import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; +import { FocusScope } from '@react-aria/focus'; +import { NavBarContext } from '../context'; +import { NavBarToggle } from './NavBarToggle'; const onOpenSearch = () => { locationService.partial({ search: 'open' }); @@ -37,6 +41,7 @@ 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); @@ -55,6 +60,7 @@ export const NavBarNext = React.memo(() => { ); const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname); const [menuOpen, setMenuOpen] = useState(false); + const [menuIdOpen, setMenuIdOpen] = useState(null); if (kiosk !== KioskMode.Off) { return null; @@ -63,71 +69,81 @@ export const NavBarNext = React.memo(() => { return (
    {showSwitcherModal && }
    - {menuOpen && ( + setMenuOpen(false)} /> - )} - setMenuOpen(!menuOpen)} - /> +
    ); @@ -139,6 +155,10 @@ const getStyles = (theme: GrafanaTheme2) => ({ navWrapper: css({ position: 'relative', display: 'flex', + + '.sidemenu-hidden &': { + display: 'none', + }, }), sidemenu: css({ label: 'sidemenu', @@ -149,15 +169,13 @@ const getStyles = (theme: GrafanaTheme2) => ({ padding: `${theme.spacing(1)} 0`, position: 'relative', width: theme.spacing(7), + borderRight: `1px solid ${theme.colors.border.weak}`, [theme.breakpoints.down('md')]: { position: 'fixed', paddingTop: '0px', backgroundColor: 'inherit', - }, - - '.sidemenu-hidden &': { - visibility: 'hidden', + borderRight: 0, }, }), mobileSidemenuLogo: css({ @@ -215,23 +233,50 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: 'grid', gridAutoFlow: 'column', height: '100%', - zIndex: 9999, + zIndex: theme.zIndex.sidemenu, }), - menuToggle: css({ + menuExpandIcon: css({ position: 'absolute', - marginRight: 0, top: '43px', right: '0px', - zIndex: 9999, - transform: `translateX(calc(${theme.spacing(7)} + 50%))`, - background: 'gray', - borderRadius: '50%', - - [theme.breakpoints.down('md')]: { - display: 'none', - }, - }), - menuOpen: css({ - transform: 'translateX(0%)', + 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/Next/NavBarToggle.tsx b/public/app/core/components/NavBar/Next/NavBarToggle.tsx new file mode 100644 index 00000000000..fc830d0fe0f --- /dev/null +++ b/public/app/core/components/NavBar/Next/NavBarToggle.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { IconButton, useTheme2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { css } from '@emotion/css'; +import classnames from 'classnames'; + +export interface Props { + className?: string; + isExpanded: boolean; + onClick: () => void; +} + +export const NavBarToggle = ({ className, isExpanded, onClick }: Props) => { + const theme = useTheme2(); + const styles = getStyles(theme); + + return ( + + ); +}; + +NavBarToggle.displayName = 'NavBarToggle'; + +const getStyles = (theme: GrafanaTheme2) => ({ + icon: css({ + backgroundColor: theme.colors.background.secondary, + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: '50%', + marginRight: 0, + zIndex: theme.zIndex.sidemenu, + + [theme.breakpoints.down('md')]: { + display: 'none', + }, + }), +}); diff --git a/public/app/core/components/NavBar/context.tsx b/public/app/core/components/NavBar/context.tsx index e2c5b0c0759..2beb99d8ab6 100644 --- a/public/app/core/components/NavBar/context.tsx +++ b/public/app/core/components/NavBar/context.tsx @@ -16,3 +16,17 @@ export const NavBarItemMenuContext = createContext({ export function useNavBarItemMenuContext(): NavBarItemMenuContextProps { return useContext(NavBarItemMenuContext); } + +export interface NavBarContextProps { + menuIdOpen: string | null; + setMenuIdOpen: (id: string | null) => void; +} + +export const NavBarContext = createContext({ + menuIdOpen: null, + setMenuIdOpen: () => undefined, +}); + +export function useNavBarContext(): NavBarContextProps { + return useContext(NavBarContext); +} diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index d2f66114231..3bee4f2b5d3 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -326,24 +326,27 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi const seriesWithFields = logSeries.filter((series) => series.fields.length); if (seriesWithFields.length) { - allSeries = seriesWithFields.map((series) => { + seriesWithFields.forEach((series) => { const fieldCache = new FieldCache(series); const stringField = fieldCache.getFirstFieldOfType(FieldType.string); + const timeField = fieldCache.getFirstFieldOfType(FieldType.time); - if (stringField?.labels) { - allLabels.push(stringField.labels); + if (stringField !== undefined && timeField !== undefined) { + if (stringField?.labels) { + allLabels.push(stringField.labels); + } + + allSeries.push({ + series, + timeField, + timeNanosecondField: fieldCache.hasFieldWithNameAndType('tsNs', FieldType.time) + ? fieldCache.getFieldByName('tsNs') + : undefined, + stringField, + logLevelField: fieldCache.getFieldByName('level'), + idField: getIdField(fieldCache), + }); } - - return { - series, - timeField: fieldCache.getFirstFieldOfType(FieldType.time), - timeNanosecondField: fieldCache.hasFieldWithNameAndType('tsNs', FieldType.time) - ? fieldCache.getFieldByName('tsNs') - : undefined, - stringField, - logLevelField: fieldCache.getFieldByName('level'), - idField: getIdField(fieldCache), - } as LogFields; }); } diff --git a/public/app/core/navigation/patch/interceptLinkClicks.ts b/public/app/core/navigation/patch/interceptLinkClicks.ts index fb789bb44bf..f7381447e12 100644 --- a/public/app/core/navigation/patch/interceptLinkClicks.ts +++ b/public/app/core/navigation/patch/interceptLinkClicks.ts @@ -23,8 +23,8 @@ export function interceptLinkClicks(e: MouseEvent) { // Make sure external links are handled correctly // That is they where seen as being absolute from app root if (href[0] !== '/') { - // if still contains protocol it's an absolute link to another domain or web application - if (href.indexOf('://') > 0) { + // if still contains protocol or is a mailto link, it's an absolute link to another domain or web application + if (href.indexOf('://') > 0 || href.indexOf('mailto:') === 0) { window.location.href = href; return; } else { diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index b96ef66c835..598f8448fc6 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -6,7 +6,7 @@ import appEvents from 'app/core/app_events'; import { getExploreUrl } from 'app/core/utils/explore'; import { DashboardModel } from 'app/features/dashboard/state'; import { ShareModal } from 'app/features/dashboard/components/ShareModal'; -import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer'; +import { SaveDashboardProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardProxy'; import { locationService } from '@grafana/runtime'; import { exitKioskMode, toggleKioskMode } from '../navigation/kiosk'; import { @@ -201,7 +201,7 @@ export class KeybindingSrv { if (dashboard.meta.canSave) { appEvents.publish( new ShowModalReactEvent({ - component: SaveDashboardDrawer, + component: SaveDashboardProxy, props: { dashboard, }, diff --git a/public/app/core/utils/kbn.ts b/public/app/core/utils/kbn.ts index d4ff3e73d72..1921244088a 100644 --- a/public/app/core/utils/kbn.ts +++ b/public/app/core/utils/kbn.ts @@ -105,7 +105,7 @@ const kbn = { const decimalPos = formatted.indexOf('.'); const precision = decimalPos === -1 ? 0 : formatted.length - decimalPos - 1; if (precision < decimals) { - return (precision ? formatted : formatted + '.') + String(factor).substr(1, decimals - precision); + return (precision ? formatted : formatted + '.') + String(factor).slice(1, decimals - precision + 1); } } diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx index 1e595e199f4..659d9076f79 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx @@ -333,10 +333,13 @@ describe('PanelAlertTabContent', () => { ], }); - expect(mocks.api.fetchRulerRules).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, { - dashboardUID: dashboard.uid, - panelId: panel.id, - }); + expect(mocks.api.fetchRulerRules).toHaveBeenCalledWith( + { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, + { + dashboardUID: dashboard.uid, + panelId: panel.id, + } + ); expect(mocks.api.fetchRules).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, { dashboardUID: dashboard.uid, panelId: panel.id, diff --git a/public/app/features/alerting/unified/RuleEditor.test.tsx b/public/app/features/alerting/unified/RuleEditor.test.tsx index c3efcb61d44..d257846c641 100644 --- a/public/app/features/alerting/unified/RuleEditor.test.tsx +++ b/public/app/features/alerting/unified/RuleEditor.test.tsx @@ -17,8 +17,9 @@ import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { DashboardSearchHit } from 'app/features/search/types'; import { getDefaultQueries } from './utils/rule-form'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; -import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; +import { GrafanaAlertStateDecision, PromApplication } from 'app/types/unified-alerting-dto'; +import { fetchBuildInfo } from './api/buildInfo'; jest.mock('./components/rule-editor/ExpressionEditor', () => ({ // eslint-disable-next-line react/display-name @@ -27,6 +28,7 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({ ), })); +jest.mock('./api/buildInfo'); jest.mock('./api/ruler'); jest.mock('./utils/config'); jest.mock('../../../../app/features/manage-dashboards/state/actions'); @@ -42,6 +44,7 @@ const mocks = { getAllDataSources: jest.mocked(getAllDataSources), searchFolders: jest.mocked(searchFolders), api: { + fetchBuildInfo: jest.mocked(fetchBuildInfo), fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), setRulerRuleGroup: jest.mocked(setRulerRuleGroup), fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), @@ -130,10 +133,17 @@ describe('RuleEditor', () => { }); mocks.searchFolders.mockResolvedValue([]); + mocks.api.fetchBuildInfo.mockResolvedValue({ + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + }, + }); + await renderRuleEditor(); await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.fetchRulerRulesGroup).toHaveBeenCalled()); + await waitFor(() => expect(mocks.api.fetchBuildInfo).toHaveBeenCalled()); userEvent.type(await ui.inputs.name.find(), 'my great new rule'); userEvent.click(await ui.buttons.lotexAlert.get()); const dataSourceSelect = ui.inputs.dataSource.get(); @@ -159,18 +169,22 @@ describe('RuleEditor', () => { // save and check what was sent to backend userEvent.click(ui.buttons.save.get()); await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith('Prom', 'namespace2', { - name: 'group2', - rules: [ - { - alert: 'my great new rule', - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - expr: 'up == 1', - for: '1m', - }, - ], - }); + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: 'Prom', apiVersion: 'legacy' }, + 'namespace2', + { + name: 'group2', + rules: [ + { + alert: 'my great new rule', + annotations: { description: 'some description', summary: 'some summary' }, + labels: { severity: 'warn', team: 'the a-team' }, + expr: 'up == 1', + for: '1m', + }, + ], + } + ); }); it('can create new grafana managed alert', async () => { @@ -218,9 +232,17 @@ describe('RuleEditor', () => { }, ] as DashboardSearchHit[]); + mocks.api.fetchBuildInfo.mockResolvedValue({ + application: PromApplication.Prometheus, + features: { + rulerApiEnabled: false, + }, + }); + // fill out the form await renderRuleEditor(); await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); + await waitFor(() => expect(mocks.api.fetchBuildInfo).toHaveBeenCalled()); userEvent.type(await ui.inputs.name.find(), 'my great new rule'); @@ -241,24 +263,28 @@ describe('RuleEditor', () => { // save and check what was sent to backend userEvent.click(ui.buttons.save.get()); await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, 'Folder A', { - interval: '1m', - name: 'my great new rule', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - for: '5m', - grafana_alert: { - condition: 'B', - data: getDefaultQueries(), - exec_err_state: 'Alerting', - no_data_state: 'NoData', - title: 'my great new rule', + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, + 'Folder A', + { + interval: '1m', + name: 'my great new rule', + rules: [ + { + annotations: { description: 'some description', summary: 'some summary' }, + labels: { severity: 'warn', team: 'the a-team' }, + for: '5m', + grafana_alert: { + condition: 'B', + data: getDefaultQueries(), + exec_err_state: 'Alerting', + no_data_state: 'NoData', + title: 'my great new rule', + }, }, - }, - ], - }); + ], + } + ); }); it('can create a new cloud recording rule', async () => { @@ -297,9 +323,16 @@ describe('RuleEditor', () => { }); mocks.searchFolders.mockResolvedValue([]); + mocks.api.fetchBuildInfo.mockResolvedValue({ + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + }, + }); + await renderRuleEditor(); await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - + await waitFor(() => expect(mocks.api.fetchBuildInfo).toHaveBeenCalled()); userEvent.type(await ui.inputs.name.find(), 'my great new recording rule'); userEvent.click(await ui.buttons.lotexRecordingRule.get()); @@ -337,16 +370,20 @@ describe('RuleEditor', () => { // save and check what was sent to backend userEvent.click(ui.buttons.save.get()); await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith('Prom', 'namespace2', { - name: 'group2', - rules: [ - { - record: 'my:great:new:recording:rule', - labels: { team: 'the a-team' }, - expr: 'up == 1', - }, - ], - }); + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: 'Prom', apiVersion: 'legacy' }, + 'namespace2', + { + name: 'group2', + rules: [ + { + record: 'my:great:new:recording:rule', + labels: { team: 'the a-team' }, + expr: 'up == 1', + }, + ], + } + ); }); it('can edit grafana managed rule', async () => { @@ -373,6 +410,7 @@ describe('RuleEditor', () => { } as any as BackendSrv; setBackendSrv(backendSrv); setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); mocks.api.setRulerRuleGroup.mockResolvedValue(); mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); @@ -405,6 +443,8 @@ describe('RuleEditor', () => { await renderRuleEditor(uid); await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); + await waitFor(() => expect(mocks.api.fetchBuildInfo).toHaveBeenCalled()); + await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); // check that it's filled in const nameInput = await ui.inputs.name.find(); @@ -426,25 +466,29 @@ describe('RuleEditor', () => { userEvent.click(ui.buttons.save.get()); await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, 'Folder A', { - interval: '1m', - name: 'my great new rule', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary', custom: 'value' }, - labels: { severity: 'warn', team: 'the a-team', custom: 'value' }, - for: '5m', - grafana_alert: { - uid, - condition: 'B', - data: getDefaultQueries(), - exec_err_state: 'Alerting', - no_data_state: 'NoData', - title: 'my great new rule', + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, + 'Folder A', + { + interval: '1m', + name: 'my great new rule', + rules: [ + { + annotations: { description: 'some description', summary: 'some summary', custom: 'value' }, + labels: { severity: 'warn', team: 'the a-team', custom: 'value' }, + for: '5m', + grafana_alert: { + uid, + condition: 'B', + data: getDefaultQueries(), + exec_err_state: 'Alerting', + no_data_state: 'NoData', + title: 'my great new rule', + }, }, - }, - ], - }); + ], + } + ); }); it('for cloud alerts, should only allow to select editable rules sources', async () => { @@ -501,7 +545,45 @@ describe('RuleEditor', () => { ), }; - mocks.api.fetchRulerRulesGroup.mockImplementation(async (dataSourceName: string) => { + mocks.api.fetchBuildInfo.mockImplementation(async (dataSourceName) => { + if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { + return { + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + alertManagerConfigApi: false, + federatedRules: false, + querySharding: false, + }, + }; + } + if (dataSourceName === 'loki with local rule store') { + return { + application: PromApplication.Cortex, + features: { + rulerApiEnabled: false, + alertManagerConfigApi: false, + federatedRules: false, + querySharding: false, + }, + }; + } + if (dataSourceName === 'cortex without ruler api') { + return { + application: PromApplication.Cortex, + features: { + rulerApiEnabled: false, + alertManagerConfigApi: false, + federatedRules: false, + querySharding: false, + }, + }; + } + + throw new Error(`${dataSourceName} not handled`); + }); + + mocks.api.fetchRulerRulesGroup.mockImplementation(async ({ dataSourceName }) => { if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { return null; } @@ -525,9 +607,8 @@ describe('RuleEditor', () => { // render rule editor, select mimir/loki managed alerts await renderRuleEditor(); + await waitFor(() => expect(mocks.api.fetchBuildInfo).toHaveBeenCalled()); await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - // wait for ui theck each datasource if it supports rule editing - await waitFor(() => expect(mocks.api.fetchRulerRulesGroup).toHaveBeenCalledTimes(4)); await ui.inputs.name.find(); userEvent.click(await ui.buttons.lotexAlert.get()); diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx index d22917be733..143c1f597ab 100644 --- a/public/app/features/alerting/unified/RuleEditor.tsx +++ b/public/app/features/alerting/unified/RuleEditor.tsx @@ -2,16 +2,17 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { Alert, LinkButton, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui'; import Page from 'app/core/components/Page/Page'; -import { contextSrv } from 'app/core/services/context_srv'; import { useCleanup } from 'app/core/hooks/useCleanup'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { contextSrv } from 'app/core/services/context_srv'; import { RuleIdentifier } from 'app/types/unified-alerting'; import React, { FC, useEffect } from 'react'; import { useDispatch } from 'react-redux'; +import { useAsync } from 'react-use'; import { AlertRuleForm } from './components/rule-editor/AlertRuleForm'; import { useIsRuleEditable } from './hooks/useIsRuleEditable'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; -import { fetchEditableRuleAction } from './state/actions'; +import { fetchAllPromBuildInfoAction, fetchEditableRuleAction } from './state/actions'; import * as ruleId from './utils/rule-id'; interface ExistingRuleEditorProps { @@ -58,15 +59,30 @@ const ExistingRuleEditor: FC = ({ identifier }) => { type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string }>; const RuleEditor: FC = ({ match }) => { + const dispatch = useDispatch(); const { id } = match.params; const identifier = ruleId.tryParse(id, true); + const { loading } = useAsync(async () => { + await dispatch(fetchAllPromBuildInfoAction()); + }, [dispatch]); + + if (!(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor)) { + return Sorry! You are not allowed to create rules.; + } + + if (loading) { + return ( + + + + ); + } + if (identifier) { return ; } - if (!(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor)) { - return Sorry! You are not allowed to create rules.; - } + return ; }; diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index d7f24e8712f..1e55be07468 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -6,6 +6,7 @@ import RuleList from './RuleList'; import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector'; import { getAllDataSources } from './utils/config'; import { fetchRules } from './api/prometheus'; +import { fetchBuildInfo } from './api/buildInfo'; import { fetchRulerRules, deleteRulerRulesGroup, deleteNamespace, setRulerRuleGroup } from './api/ruler'; import { mockDataSource, @@ -20,11 +21,12 @@ import { } from './mocks'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { SerializedError } from '@reduxjs/toolkit'; -import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; +import { PromAlertingRuleState, PromApplication } from 'app/types/unified-alerting-dto'; import userEvent from '@testing-library/user-event'; import { locationService, setDataSourceSrv } from '@grafana/runtime'; import { Router } from 'react-router-dom'; +jest.mock('./api/buildInfo'); jest.mock('./api/prometheus'); jest.mock('./api/ruler'); jest.mock('./utils/config'); @@ -41,6 +43,7 @@ const mocks = { getAllDataSourcesMock: jest.mocked(getAllDataSources), api: { + fetchBuildInfo: jest.mocked(fetchBuildInfo), fetchRules: jest.mocked(fetchRules), fetchRulerRules: jest.mocked(fetchRulerRules), deleteGroup: jest.mocked(deleteRulerRulesGroup), @@ -115,6 +118,13 @@ describe('RuleList', () => { setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.api.fetchBuildInfo.mockResolvedValue({ + application: PromApplication.Prometheus, + features: { + rulerApiEnabled: true, + }, + }); + mocks.api.fetchRules.mockImplementation((dataSourceName: string) => { if (dataSourceName === dataSources.prom.name) { return Promise.resolve([ @@ -198,7 +208,15 @@ describe('RuleList', () => { it('expand rule group, rule and alert details', async () => { mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); + setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); + mocks.api.fetchBuildInfo.mockResolvedValue({ + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + }, + }); + mocks.api.fetchRules.mockImplementation((dataSourceName: string) => { if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { return Promise.resolve([]); @@ -333,6 +351,14 @@ describe('RuleList', () => { it('filters rules and alerts by labels', async () => { mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); + + mocks.api.fetchBuildInfo.mockResolvedValue({ + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + }, + }); + mocks.api.fetchRulerRules.mockResolvedValue({}); mocks.api.fetchRules.mockImplementation((dataSourceName: string) => { if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { @@ -470,11 +496,19 @@ describe('RuleList', () => { it(name, async () => { mocks.getAllDataSourcesMock.mockReturnValue(Object.values(testDatasources)); setDataSourceSrv(new MockDataSourceSrv(testDatasources)); + + mocks.api.fetchBuildInfo.mockResolvedValue({ + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + }, + }); + mocks.api.fetchRules.mockImplementation((sourceName) => Promise.resolve(sourceName === testDatasources.prom.name ? somePromRules() : []) ); - mocks.api.fetchRulerRules.mockImplementation((sourceName) => - Promise.resolve(sourceName === testDatasources.prom.name ? someRulerRules : {}) + mocks.api.fetchRulerRules.mockImplementation(({ dataSourceName }) => + Promise.resolve(dataSourceName === testDatasources.prom.name ? someRulerRules : {}) ); mocks.api.setRulerRuleGroup.mockResolvedValue(); mocks.api.deleteNamespace.mockResolvedValue(); @@ -514,18 +548,26 @@ describe('RuleList', () => { expect(mocks.api.deleteNamespace).toHaveBeenCalledTimes(1); expect(mocks.api.deleteGroup).not.toHaveBeenCalled(); expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4); - expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(1, testDatasources.prom.name, 'super namespace', { - ...someRulerRules['namespace1'][0], - name: 'super group', - interval: '5m', - }); + expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith( + 1, + { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, + 'super namespace', + { + ...someRulerRules['namespace1'][0], + name: 'super group', + interval: '5m', + } + ); expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith( 2, - testDatasources.prom.name, + { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, 'super namespace', someRulerRules['namespace1'][1] ); - expect(mocks.api.deleteNamespace).toHaveBeenLastCalledWith('Prometheus', 'namespace1'); + expect(mocks.api.deleteNamespace).toHaveBeenLastCalledWith( + { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, + 'namespace1' + ); }); testCase('rename just the lotex group', async () => { @@ -543,12 +585,21 @@ describe('RuleList', () => { expect(mocks.api.deleteGroup).toHaveBeenCalledTimes(1); expect(mocks.api.deleteNamespace).not.toHaveBeenCalled(); expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4); - expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(1, testDatasources.prom.name, 'namespace1', { - ...someRulerRules['namespace1'][0], - name: 'super group', - interval: '5m', - }); - expect(mocks.api.deleteGroup).toHaveBeenLastCalledWith('Prometheus', 'namespace1', 'group1'); + expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith( + 1, + { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, + 'namespace1', + { + ...someRulerRules['namespace1'][0], + name: 'super group', + interval: '5m', + } + ); + expect(mocks.api.deleteGroup).toHaveBeenLastCalledWith( + { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, + 'namespace1', + 'group1' + ); }); testCase('edit lotex group eval interval, no renaming', async () => { @@ -564,10 +615,15 @@ describe('RuleList', () => { expect(mocks.api.deleteGroup).not.toHaveBeenCalled(); expect(mocks.api.deleteNamespace).not.toHaveBeenCalled(); expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4); - expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(1, testDatasources.prom.name, 'namespace1', { - ...someRulerRules['namespace1'][0], - interval: '5m', - }); + expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith( + 1, + { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, + 'namespace1', + { + ...someRulerRules['namespace1'][0], + interval: '5m', + } + ); }); }); }); diff --git a/public/app/features/alerting/unified/api/buildInfo.test.ts b/public/app/features/alerting/unified/api/buildInfo.test.ts new file mode 100644 index 00000000000..1564d7a19f6 --- /dev/null +++ b/public/app/features/alerting/unified/api/buildInfo.test.ts @@ -0,0 +1,123 @@ +import { PromApplication } from 'app/types/unified-alerting-dto'; +import { of, throwError } from 'rxjs'; +import { fetchDataSourceBuildInfo } from './buildInfo'; +import { fetchRules } from './prometheus'; +import { fetchTestRulerRulesGroup } from './ruler'; + +const fetch = jest.fn(); + +jest.mock('./prometheus'); +jest.mock('./ruler'); +jest.mock('@grafana/runtime', () => ({ + getBackendSrv: () => ({ fetch }), +})); + +const mocks = { + fetchRules: jest.mocked(fetchRules), + fetchTestRulerRulesGroup: jest.mocked(fetchTestRulerRulesGroup), +}; + +beforeEach(() => jest.clearAllMocks()); + +describe('buildInfo', () => { + describe('When buildinfo returns 200 response', () => { + it('Should return Prometheus with disabled ruler API when application and features fields are missing', async () => { + fetch.mockReturnValue( + of({ + data: { + status: 'success', + data: { + version: '2.32.1', + }, + }, + }) + ); + + const response = await fetchDataSourceBuildInfo({ url: '/datasource/proxy', name: 'Prometheus' }); + + expect(response.application).toBe(PromApplication.Prometheus); + expect(response.features.rulerApiEnabled).toBe(false); + expect(mocks.fetchRules).not.toHaveBeenCalled(); + expect(mocks.fetchTestRulerRulesGroup).not.toHaveBeenCalled(); + }); + + it.each([true, false])( + `Should return Mimir with rulerApiEnabled set to %p according to the ruler_config_api value`, + async (rulerApiEnabled) => { + fetch.mockReturnValue( + of({ + data: { + status: 'success', + data: { + version: '2.32.1', + features: { + // 'true' and 'false' as strings is intentional + // This is the format returned from the buildinfo endpoint + ruler_config_api: rulerApiEnabled ? 'true' : 'false', + }, + }, + }, + }) + ); + + const response = await fetchDataSourceBuildInfo({ url: '/datasource/proxy', name: 'Prometheus' }); + + expect(response.application).toBe(PromApplication.Mimir); + expect(response.features.rulerApiEnabled).toBe(rulerApiEnabled); + expect(mocks.fetchRules).not.toHaveBeenCalled(); + expect(mocks.fetchTestRulerRulesGroup).not.toHaveBeenCalled(); + } + ); + }); + + describe('When buildinfo returns 404 error', () => { + it('Should return cortex with ruler API disabled when prom rules works and ruler api returns not avaiable errors', async () => { + fetch.mockReturnValue( + throwError(() => ({ + status: 404, + })) + ); + + mocks.fetchTestRulerRulesGroup.mockRejectedValue({ + status: 404, + data: { + message: 'page not found', + }, + }); + mocks.fetchRules.mockResolvedValue([]); + + const response = await fetchDataSourceBuildInfo({ url: '/datasource/proxy', name: 'Cortex' }); + + expect(response.application).toBe(PromApplication.Cortex); + expect(response.features.rulerApiEnabled).toBe(false); + + expect(mocks.fetchTestRulerRulesGroup).toHaveBeenCalledTimes(1); + expect(mocks.fetchTestRulerRulesGroup).toHaveBeenCalledWith('Cortex'); + + expect(mocks.fetchRules).toHaveBeenCalledTimes(1); + expect(mocks.fetchRules).toHaveBeenCalledWith('Cortex'); + }); + + it('Should return cortex with ruler API enabled when prom rules works and ruler api returns cortex error', async () => { + fetch.mockReturnValue( + throwError(() => ({ + status: 404, + })) + ); + + mocks.fetchTestRulerRulesGroup.mockResolvedValue(null); + mocks.fetchRules.mockResolvedValue([]); + + const response = await fetchDataSourceBuildInfo({ url: '/datasource/proxy', name: 'Cortex' }); + + expect(response.application).toBe(PromApplication.Cortex); + expect(response.features.rulerApiEnabled).toBe(true); + + expect(mocks.fetchTestRulerRulesGroup).toHaveBeenCalledTimes(1); + expect(mocks.fetchTestRulerRulesGroup).toHaveBeenCalledWith('Cortex'); + + expect(mocks.fetchRules).toHaveBeenCalledTimes(1); + expect(mocks.fetchRules).toHaveBeenCalledWith('Cortex'); + }); + }); +}); diff --git a/public/app/features/alerting/unified/api/buildInfo.ts b/public/app/features/alerting/unified/api/buildInfo.ts new file mode 100644 index 00000000000..465908f6a95 --- /dev/null +++ b/public/app/features/alerting/unified/api/buildInfo.ts @@ -0,0 +1,130 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { PromApplication, PromBuildInfo, PromBuildInfoResponse } from 'app/types/unified-alerting-dto'; +import { lastValueFrom } from 'rxjs'; +import { isFetchError } from '../utils/alertmanager'; +import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; +import { getDataSourceByName } from '../utils/datasource'; +import { fetchRules } from './prometheus'; +import { fetchTestRulerRulesGroup } from './ruler'; + +/** + * This function will attempt to detect what type of system we are talking to; this could be + * Prometheus (vanilla) | Cortex | Mimir + * + * Cortex and Mimir allow editing rules via their API, Prometheus does not. + * Prometheus and Mimir expose a `buildinfo` endpoint, Cortex does not. + * Mimir reports which "features" are enabled or available via the buildinfo endpoint, Prometheus does not. + */ +export async function fetchDataSourceBuildInfo(dsSettings: { url: string; name: string }): Promise { + const { url, name } = dsSettings; + + const response = await lastValueFrom( + getBackendSrv().fetch({ + url: `${url}/api/v1/status/buildinfo`, + showErrorAlert: false, + showSuccessAlert: false, + }) + ).catch((e) => { + if ('status' in e && e.status === 404) { + return null; // Cortex does not support buildinfo endpoint, we return an empty response + } + + throw e; + }); + + // check if the component returns buildinfo + const hasBuildInfo = response !== null; + + // we are dealing with a Cortex datasource since the response for buildinfo came up empty + if (!hasBuildInfo) { + // check if we can fetch rules via the prometheus compatible api + const promRulesSupported = await hasPromRulesSupport(name); + if (!promRulesSupported) { + throw new Error(`Unable to fetch alert rules. Is the ${name} data source properly configured?`); + } + + // check if the ruler is enabled + const rulerSupported = await hasRulerSupport(name); + + return { + application: PromApplication.Cortex, + features: { + rulerApiEnabled: rulerSupported, + }, + }; + } + + // if no features are reported but buildinfo was return we're talking to Prometheus + const { features } = response.data.data; + if (!features) { + return { + application: PromApplication.Prometheus, + features: { + rulerApiEnabled: false, + }, + }; + } + + // if we have both features and buildinfo reported we're talking to Mimir + return { + application: PromApplication.Mimir, + features: { + rulerApiEnabled: features?.ruler_config_api === 'true', + }, + }; +} + +/** + * Attempt to fetch buildinfo from our component + */ +export async function fetchBuildInfo(dataSourceName: string): Promise { + const dsConfig = getDataSourceByName(dataSourceName); + if (!dsConfig) { + throw new Error(`Cannot find data source configuration for ${dataSourceName}`); + } + const { url, name } = dsConfig; + if (!url) { + throw new Error(`The data souce url cannot be empty.`); + } + + return fetchDataSourceBuildInfo({ name, url }); +} + +/** + * Check if the component allows us to fetch rules + */ +async function hasPromRulesSupport(dataSourceName: string) { + try { + await fetchRules(dataSourceName); + return true; + } catch (e) { + return false; + } +} + +/** + * Attempt to check if the ruler API is enabled for Cortex, Prometheus does not support it and Mimir + * reports this via the buildInfo "features" + */ +async function hasRulerSupport(dataSourceName: string) { + try { + await fetchTestRulerRulesGroup(dataSourceName); + return true; + } catch (e) { + if (errorIndicatesMissingRulerSupport(e)) { + return false; + } + throw e; + } +} + +// there errors indicate that the ruler API might be disabled or not supported for Cortex +function errorIndicatesMissingRulerSupport(error: any) { + return ( + (isFetchError(error) && + (error.data.message?.includes('GetRuleGroup unsupported in rule local store') || // "local" rule storage + error.data.message?.includes('page not found'))) || // ruler api disabled + error.message?.includes('404 from rules config endpoint') || // ruler api disabled + error.data.message?.includes(RULER_NOT_SUPPORTED_MSG) // ruler api not supported + ); +} diff --git a/public/app/features/alerting/unified/api/prometheus.ts b/public/app/features/alerting/unified/api/prometheus.ts index 5d830406bd6..db3598f04bc 100644 --- a/public/app/features/alerting/unified/api/prometheus.ts +++ b/public/app/features/alerting/unified/api/prometheus.ts @@ -10,25 +10,53 @@ export interface FetchPromRulesFilter { panelId?: number; } -export async function fetchRules(dataSourceName: string, filter?: FetchPromRulesFilter): Promise { - if (filter?.dashboardUID && dataSourceName !== GRAFANA_RULES_SOURCE_NAME) { - throw new Error('Filtering by dashboard UID is not supported for cloud rules sources.'); - } +export interface PrometheusDataSourceConfig { + dataSourceName: string; +} - const params: Record = {}; +export function prometheusUrlBuilder(dataSourceConfig: PrometheusDataSourceConfig) { + const { dataSourceName } = dataSourceConfig; + + return { + rules: (filter?: FetchPromRulesFilter) => { + const searchParams = new URLSearchParams(); + const params = prepareRulesFilterQueryParams(searchParams, filter); + + return { + url: `/api/prometheus/${getDatasourceAPIId(dataSourceName)}/api/v1/rules`, + params: params, + }; + }, + }; +} + +export function prepareRulesFilterQueryParams( + params: URLSearchParams, + filter?: FetchPromRulesFilter +): Record { if (filter?.dashboardUID) { - params['dashboard_uid'] = filter.dashboardUID; - if (filter.panelId) { - params['panel_id'] = String(filter.panelId); + params.set('dashboard_uid', filter.dashboardUID); + if (filter?.panelId) { + params.set('panel_id', String(filter.panelId)); } } + return Object.fromEntries(params); +} + +export async function fetchRules(dataSourceName: string, filter?: FetchPromRulesFilter): Promise { + if (filter?.dashboardUID && dataSourceName !== GRAFANA_RULES_SOURCE_NAME) { + throw new Error('Filtering by dashboard UID is only supported for Grafana Managed rules.'); + } + + const { url, params } = prometheusUrlBuilder({ dataSourceName }).rules(filter); + const response = await lastValueFrom( getBackendSrv().fetch({ - url: `/api/prometheus/${getDatasourceAPIId(dataSourceName)}/api/v1/rules`, + url, + params, showErrorAlert: false, showSuccessAlert: false, - params, }) ).catch((e) => { if ('status' in e && e.status === 404) { diff --git a/public/app/features/alerting/unified/api/ruler.test.ts b/public/app/features/alerting/unified/api/ruler.test.ts new file mode 100644 index 00000000000..1e1ec9ed62c --- /dev/null +++ b/public/app/features/alerting/unified/api/ruler.test.ts @@ -0,0 +1,104 @@ +import { RulerDataSourceConfig } from 'app/types/unified-alerting'; +import { getDatasourceAPIId } from '../utils/datasource'; +import { rulerUrlBuilder } from './ruler'; + +jest.mock('../utils/datasource'); + +const mocks = { + getDatasourceAPIId: jest.mocked(getDatasourceAPIId), +}; + +describe('rulerUrlBuilder', () => { + it('Should use /api/v1/rules endpoint with subtype = cortex param for legacy api version', () => { + // Arrange + const config: RulerDataSourceConfig = { + dataSourceName: 'Cortex', + apiVersion: 'legacy', + }; + + mocks.getDatasourceAPIId.mockReturnValue('ds-uid'); + + // Act + const builder = rulerUrlBuilder(config); + + const rules = builder.rules(); + const namespace = builder.namespace('test-ns'); + const group = builder.namespaceGroup('test-ns', 'test-gr'); + + // Assert + expect(rules.path).toBe('/api/ruler/ds-uid/api/v1/rules'); + expect(rules.params).toMatchObject({ subtype: 'cortex' }); + + expect(namespace.path).toBe('/api/ruler/ds-uid/api/v1/rules/test-ns'); + expect(namespace.params).toMatchObject({ subtype: 'cortex' }); + + expect(group.path).toBe('/api/ruler/ds-uid/api/v1/rules/test-ns/test-gr'); + expect(group.params).toMatchObject({ subtype: 'cortex' }); + }); + + it('Should use /api/v1/rules endpoint with subtype = mimir parameter for config api version', () => { + // Arrange + const config: RulerDataSourceConfig = { + dataSourceName: 'Cortex v2', + apiVersion: 'config', + }; + + mocks.getDatasourceAPIId.mockReturnValue('ds-uid'); + + // Act + const builder = rulerUrlBuilder(config); + + const rules = builder.rules(); + const namespace = builder.namespace('test-ns'); + const group = builder.namespaceGroup('test-ns', 'test-gr'); + + // Assert + expect(rules.path).toBe('/api/ruler/ds-uid/api/v1/rules'); + expect(rules.params).toMatchObject({ subtype: 'mimir' }); + + expect(namespace.path).toBe('/api/ruler/ds-uid/api/v1/rules/test-ns'); + expect(namespace.params).toMatchObject({ subtype: 'mimir' }); + + expect(group.path).toBe('/api/ruler/ds-uid/api/v1/rules/test-ns/test-gr'); + expect(group.params).toMatchObject({ subtype: 'mimir' }); + }); + + it('Should append source=rules parameter when custom ruler enabled', () => { + // Arrange + const config: RulerDataSourceConfig = { + dataSourceName: 'Cortex v2', + apiVersion: 'config', + }; + + mocks.getDatasourceAPIId.mockReturnValue('ds-uid'); + + // Act + const builder = rulerUrlBuilder(config); + + const rules = builder.rules(); + const namespace = builder.namespace('test-ns'); + const group = builder.namespaceGroup('test-ns', 'test-gr'); + + // Assert + expect(rules.params).toMatchObject({ subtype: 'mimir' }); + expect(namespace.params).toMatchObject({ subtype: 'mimir' }); + expect(group.params).toMatchObject({ subtype: 'mimir' }); + }); + + it('Should append dashboard_uid and panel_id for rules endpoint when specified', () => { + // Arrange + const config: RulerDataSourceConfig = { + dataSourceName: 'Cortex v2', + apiVersion: 'config', + }; + + mocks.getDatasourceAPIId.mockReturnValue('ds-uid'); + + // Act + const builder = rulerUrlBuilder(config); + const rules = builder.rules({ dashboardUID: 'dashboard-uid', panelId: 1234 }); + + // Assert + expect(rules.params).toMatchObject({ dashboard_uid: 'dashboard-uid', panel_id: '1234', subtype: 'mimir' }); + }); +}); diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts index ac3edeacf1c..c5c82f6317e 100644 --- a/public/app/features/alerting/unified/api/ruler.ts +++ b/public/app/features/alerting/unified/api/ruler.ts @@ -4,25 +4,62 @@ import { FetchResponse, getBackendSrv } from '@grafana/runtime'; import { PostableRulerRuleGroupDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; +import { RulerDataSourceConfig } from 'app/types/unified-alerting'; +import { prepareRulesFilterQueryParams } from './prometheus'; interface ErrorResponseMessage { message?: string; error?: string; } -// upsert a rule group. use this to update rules +export interface RulerRequestUrl { + path: string; + params?: Record; +} + +export function rulerUrlBuilder(rulerConfig: RulerDataSourceConfig) { + const grafanaServerPath = `/api/ruler/${getDatasourceAPIId(rulerConfig.dataSourceName)}`; + + const rulerPath = `${grafanaServerPath}/api/v1/rules`; + const rulerSearchParams = new URLSearchParams(); + + rulerSearchParams.set('subtype', rulerConfig.apiVersion === 'legacy' ? 'cortex' : 'mimir'); + + return { + rules: (filter?: FetchRulerRulesFilter): RulerRequestUrl => { + const params = prepareRulesFilterQueryParams(rulerSearchParams, filter); + + return { + path: `${rulerPath}`, + params: params, + }; + }, + namespace: (namespace: string): RulerRequestUrl => ({ + path: `${rulerPath}/${encodeURIComponent(namespace)}`, + params: Object.fromEntries(rulerSearchParams), + }), + namespaceGroup: (namespace: string, group: string): RulerRequestUrl => ({ + path: `${rulerPath}/${encodeURIComponent(namespace)}/${encodeURIComponent(group)}`, + params: Object.fromEntries(rulerSearchParams), + }), + }; +} + +// upsert a rule group. use this to update rule export async function setRulerRuleGroup( - dataSourceName: string, + rulerConfig: RulerDataSourceConfig, namespace: string, group: PostableRulerRuleGroupDTO ): Promise { + const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace); await lastValueFrom( getBackendSrv().fetch({ method: 'POST', - url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`, + url: path, data: group, showErrorAlert: false, showSuccessAlert: false, + params, }) ); } @@ -33,59 +70,51 @@ export interface FetchRulerRulesFilter { } // fetch all ruler rule namespaces and included groups -export async function fetchRulerRules(dataSourceName: string, filter?: FetchRulerRulesFilter) { - if (filter?.dashboardUID && dataSourceName !== GRAFANA_RULES_SOURCE_NAME) { - throw new Error('Filtering by dashboard UID is not supported for cloud rules sources.'); +export async function fetchRulerRules(rulerConfig: RulerDataSourceConfig, filter?: FetchRulerRulesFilter) { + if (filter?.dashboardUID && rulerConfig.dataSourceName !== GRAFANA_RULES_SOURCE_NAME) { + throw new Error('Filtering by dashboard UID is only supported by Grafana.'); } - const params: Record = {}; - if (filter?.dashboardUID) { - params['dashboard_uid'] = filter.dashboardUID; - if (filter.panelId) { - params['panel_id'] = String(filter.panelId); - } - } - return rulerGetRequest( - `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules`, - {}, - params - ); + // TODO Move params creation to the rules function + const { path: url, params } = rulerUrlBuilder(rulerConfig).rules(filter); + return rulerGetRequest(url, {}, params); } // fetch rule groups for a particular namespace // will throw with { status: 404 } if namespace does not exist -export async function fetchRulerRulesNamespace(dataSourceName: string, namespace: string) { - const result = await rulerGetRequest>( - `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`, - {} - ); +export async function fetchRulerRulesNamespace(rulerConfig: RulerDataSourceConfig, namespace: string) { + const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace); + const result = await rulerGetRequest>(path, {}, params); return result[namespace] || []; } // fetch a particular rule group // will throw with { status: 404 } if rule group does not exist -export async function fetchRulerRulesGroup( - dataSourceName: string, - namespace: string, - group: string -): Promise { +export async function fetchTestRulerRulesGroup(dataSourceName: string): Promise { return rulerGetRequest( - `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent( - namespace - )}/${encodeURIComponent(group)}`, + `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/test/test`, null ); } -export async function deleteRulerRulesGroup(dataSourceName: string, namespace: string, groupName: string) { +export async function fetchRulerRulesGroup( + rulerConfig: RulerDataSourceConfig, + namespace: string, + group: string +): Promise { + const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group); + return rulerGetRequest(path, null, params); +} + +export async function deleteRulerRulesGroup(rulerConfig: RulerDataSourceConfig, namespace: string, groupName: string) { + const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, groupName); await lastValueFrom( getBackendSrv().fetch({ - url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent( - namespace - )}/${encodeURIComponent(groupName)}`, + url: path, method: 'DELETE', showSuccessAlert: false, showErrorAlert: false, + params, }) ); } @@ -144,13 +173,15 @@ function isCortexErrorResponse(error: FetchResponse) { ); } -export async function deleteNamespace(dataSourceName: string, namespace: string): Promise { +export async function deleteNamespace(rulerConfig: RulerDataSourceConfig, namespace: string): Promise { + const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace); await lastValueFrom( getBackendSrv().fetch({ method: 'DELETE', - url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`, + url: path, showErrorAlert: false, showSuccessAlert: false, + params, }) ); } diff --git a/public/app/features/alerting/unified/components/AlertLabels.tsx b/public/app/features/alerting/unified/components/AlertLabels.tsx index d4bbf0f5bbb..4397e56fd9e 100644 --- a/public/app/features/alerting/unified/components/AlertLabels.tsx +++ b/public/app/features/alerting/unified/components/AlertLabels.tsx @@ -1,30 +1,13 @@ -import { GrafanaTheme } from '@grafana/data'; -import { useStyles } from '@grafana/ui'; -import { css, cx } from '@emotion/css'; import React from 'react'; -import { AlertLabel } from './AlertLabel'; +import { TagList } from '@grafana/ui'; type Props = { labels: Record; className?: string }; export const AlertLabels = ({ labels, className }: Props) => { - const styles = useStyles(getStyles); const pairs = Object.entries(labels).filter(([key]) => !(key.startsWith('__') && key.endsWith('__'))); - return ( -
    - {pairs.map(([key, value], index) => ( - - ))} +
    + `${label}=${value}`)} />
    ); }; - -const getStyles = (theme: GrafanaTheme) => ({ - wrapper: css` - & > * { - margin-bottom: ${theme.spacing.xs}; - margin-right: ${theme.spacing.xs}; - } - padding-bottom: ${theme.spacing.xs}; - `, -}); diff --git a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx index 782a7b8346b..e0107dbc575 100644 --- a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx +++ b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx @@ -25,7 +25,7 @@ export const AlertManagerPicker: FC = ({ onChange, current, disabled = fa ...getAllDataSources() .filter((ds) => ds.type === DataSourceType.Alertmanager) .map((ds) => ({ - label: ds.name.substr(0, 37), + label: ds.name.slice(0, 37), value: ds.name, imgUrl: ds.meta.info.logos.small, meta: ds.meta, diff --git a/public/app/features/alerting/unified/components/CollapseToggle.tsx b/public/app/features/alerting/unified/components/CollapseToggle.tsx index 3c73a4779cd..c63256e077f 100644 --- a/public/app/features/alerting/unified/components/CollapseToggle.tsx +++ b/public/app/features/alerting/unified/components/CollapseToggle.tsx @@ -26,6 +26,7 @@ export const CollapseToggle: FC = ({ return ( ) : null} + {isCortexLokiOrRecordingRule(watch) && ( + + )}
    + } + onClose={onClose} + > + {activeTab === 'yaml' && } + + ); +}; + +interface SubtitleProps { + activeTab: string; + setActiveTab: (tab: string) => void; +} + +const RuleInspectorSubtitle: FC = ({ activeTab, setActiveTab }) => { + return ( + + {tabs.map((tab, index) => { + return ( + setActiveTab(tab.value)} + active={activeTab === tab.value} + /> + ); + })} + + ); +}; + +interface YamlTabProps { + onSubmit: (newModel: RuleFormValues) => void; +} + +const InspectorYamlTab: FC = ({ onSubmit }) => { + const styles = useStyles2(yamlTabStyle); + const { getValues } = useFormContext(); + const [alertRuleAsYaml, setAlertRuleAsYaml] = useState(dump(getValues())); + + const onApply = () => { + onSubmit(load(alertRuleAsYaml) as RuleFormValues); + }; + + return ( + <> +
    + +
    + +
    + + {({ height }) => ( + + )} + +
    + + ); +}; + +const yamlTabStyle = (theme: GrafanaTheme2) => ({ + content: css` + flex-grow: 1; + height: 100%; + padding-bottom: 16px; + margin-bottom: ${theme.spacing(2)}; + `, + applyButton: css` + display: flex; + flex-grow: 0; + `, +}); + +const drawerStyles = () => ({ + subtitle: css` + display: flex; + align-items: center; + justify-content: space-between; + `, +}); diff --git a/public/app/features/alerting/unified/components/rules/AlertInstancesTable.tsx b/public/app/features/alerting/unified/components/rules/AlertInstancesTable.tsx index ab128527592..cccace4b931 100644 --- a/public/app/features/alerting/unified/components/rules/AlertInstancesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/AlertInstancesTable.tsx @@ -77,7 +77,7 @@ const columns: AlertTableColumnProps[] = [ label: 'Created', // eslint-disable-next-line react/display-name renderCell: ({ data: { activeAt } }) => ( - <>{activeAt.startsWith('0001') ? '-' : activeAt.substr(0, 19).replace('T', ' ')} + <>{activeAt.startsWith('0001') ? '-' : activeAt.slice(0, 19).replace('T', ' ')} ), size: '150px', }, diff --git a/public/app/features/alerting/unified/components/rules/CloudRules.tsx b/public/app/features/alerting/unified/components/rules/CloudRules.tsx index 68319be271c..b5015337798 100644 --- a/public/app/features/alerting/unified/components/rules/CloudRules.tsx +++ b/public/app/features/alerting/unified/components/rules/CloudRules.tsx @@ -26,7 +26,7 @@ export const CloudRules: FC = ({ namespaces, expandAll }) => { return (
    -
    Cortex / Loki
    +
    Mimir / Cortex / Loki
    {dataSourcesLoading.length ? ( state.dataSources); const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules); const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const styles = useStyles2(getStyles); const errors = useMemo((): JSX.Element[] => { - const [promRequestErrors, rulerRequestErrors] = [promRuleRequests, rulerRuleRequests].map((requests) => + const [dataSourceConfigErrors, promRequestErrors, rulerRequestErrors] = [ + dataSourceConfigRequests, + promRuleRequests, + rulerRuleRequests, + ].map((requests) => getRulesDataSources().reduce>( (result, dataSource) => { const error = requests[dataSource.name]?.error; @@ -41,6 +46,15 @@ export function RuleListErrors(): ReactElement { result.push(<>Failed to load Grafana rules config: {grafanaRulerError.message || 'Unknown error.'}); } + dataSourceConfigErrors.forEach(({ dataSource, error }) => { + result.push( + <> + Failed to load the data source configuration for{' '} +
    {dataSource.name}: {error.message || 'Unknown error.'} + + ); + }); + promRequestErrors.forEach(({ dataSource, error }) => result.push( <> @@ -60,7 +74,7 @@ export function RuleListErrors(): ReactElement { ); return result; - }, [promRuleRequests, rulerRuleRequests]); + }, [dataSourceConfigRequests, promRuleRequests, rulerRuleRequests]); return ( <> diff --git a/public/app/features/alerting/unified/components/silences/Matchers.tsx b/public/app/features/alerting/unified/components/silences/Matchers.tsx index 7b3520cec38..0d686d7027f 100644 --- a/public/app/features/alerting/unified/components/silences/Matchers.tsx +++ b/public/app/features/alerting/unified/components/silences/Matchers.tsx @@ -1,49 +1,25 @@ -import React, { useCallback } from 'react'; -import { GrafanaTheme } from '@grafana/data'; -import { useStyles } from '@grafana/ui'; +import React, { FC } from 'react'; import { css } from '@emotion/css'; +import { TagList, useStyles2 } from '@grafana/ui'; import { Matcher } from 'app/plugins/datasource/alertmanager/types'; -import { AlertLabel } from '../AlertLabel'; import { matcherToOperator } from '../../utils/alertmanager'; -type MatchersProps = { matchers: Matcher[]; onRemoveLabel?(index: number): void }; - -export const Matchers = ({ matchers, onRemoveLabel }: MatchersProps) => { - const styles = useStyles(getStyles); - - const removeLabel = useCallback( - (index: number) => { - if (!!onRemoveLabel) { - onRemoveLabel(index); - } - }, - [onRemoveLabel] - ); +type MatchersProps = { matchers: Matcher[] }; +export const Matchers: FC = ({ matchers }) => { + const styles = useStyles2(getStyles); return ( -
    - {matchers.map((matcher, index) => { - const { name, value } = matcher; - return ( - removeLabel(index) : undefined} - /> - ); - })} +
    + `${matcher.name}${matcherToOperator(matcher)}${matcher.value}`)} + />
    ); }; -const getStyles = (theme: GrafanaTheme) => ({ - wrapper: css` - & > * { - margin-top: ${theme.spacing.xs}; - margin-right: ${theme.spacing.xs}; - } - padding-bottom: ${theme.spacing.xs}; +const getStyles = () => ({ + tags: css` + justify-content: flex-start; `, }); diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts index 7809c75f1bf..79c517592dc 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRule.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts @@ -1,13 +1,14 @@ -import { useEffect, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; import { CombinedRule, RuleIdentifier, RuleNamespace } from 'app/types/unified-alerting'; -import { AsyncRequestMapSlice, AsyncRequestState, initialAsyncRequestState } from '../utils/redux'; -import { useCombinedRuleNamespaces } from './useCombinedRuleNamespaces'; -import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; -import { fetchPromRulesAction, fetchRulerRulesAction } from '../state/actions'; import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; +import { useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { useAsync } from 'react-use'; +import { fetchPromAndRulerRulesAction } from '../state/actions'; +import { AsyncRequestMapSlice, AsyncRequestState, initialAsyncRequestState } from '../utils/redux'; import * as ruleId from '../utils/rule-id'; import { isRulerNotSupportedResponse } from '../utils/rules'; +import { useCombinedRuleNamespaces } from './useCombinedRuleNamespaces'; +import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; export function useCombinedRule( identifier: RuleIdentifier | undefined, @@ -82,17 +83,16 @@ function useCombinedRulesLoader(rulesSourceName: string | undefined): AsyncReque const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const rulerRuleRequest = getRequestState(rulesSourceName, rulerRuleRequests); - useEffect(() => { + const { loading } = useAsync(async () => { if (!rulesSourceName) { return; } - dispatch(fetchPromRulesAction({ rulesSourceName })); - dispatch(fetchRulerRulesAction({ rulesSourceName })); + await dispatch(fetchPromAndRulerRulesAction({ rulesSourceName })); }, [dispatch, rulesSourceName]); return { - loading: promRuleRequest.loading || rulerRuleRequest.loading, + loading, error: promRuleRequest.error ?? isRulerNotSupportedResponse(rulerRuleRequest) ? undefined : rulerRuleRequest.error, dispatched: promRuleRequest.dispatched && rulerRuleRequest.dispatched, }; diff --git a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts index 657d33a9113..259ff2af8c3 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts @@ -228,7 +228,7 @@ function isCombinedRuleEqualToPromRule(combinedRule: CombinedRule, rule: Rule, c function hashQuery(query: string) { // one of them might be wrapped in parens if (query.length > 1 && query[0] === '(' && query[query.length - 1] === ')') { - query = query.substr(1, query.length - 2); + query = query.slice(1, -1); } // whitespace could be added or removed query = query.replace(/\s|\n/g, ''); diff --git a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts index 4a28c44004d..97695319adf 100644 --- a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts +++ b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts @@ -1,12 +1,8 @@ import { contextSrv } from 'app/core/services/context_srv'; -import { isGrafanaRulerRule } from '../utils/rules'; import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; +import { isGrafanaRulerRule } from '../utils/rules'; import { useFolder } from './useFolder'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; -import { useDispatch } from 'react-redux'; -import { useEffect } from 'react'; -import { checkIfLotexSupportsEditingRulesAction } from '../state/actions'; -import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; interface ResultBag { isEditable?: boolean; @@ -14,18 +10,11 @@ interface ResultBag { } export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): ResultBag { - const checkEditingRequests = useUnifiedAlertingSelector((state) => state.lotexSupportsRuleEditing); - const dispatch = useDispatch(); + const dataSources = useUnifiedAlertingSelector((state) => state.dataSources); const folderUID = rule && isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined; const { folder, loading } = useFolder(folderUID); - useEffect(() => { - if (checkEditingRequests[rulesSourceName] === undefined && rulesSourceName !== GRAFANA_RULES_SOURCE_NAME) { - dispatch(checkIfLotexSupportsEditingRulesAction(rulesSourceName)); - } - }, [rulesSourceName, checkEditingRequests, dispatch]); - if (!rule) { return { isEditable: false, loading: false }; } @@ -45,7 +34,7 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): // prom rules are only editable by users with Editor role and only if rules source supports editing return { - isEditable: contextSrv.isEditor && !!checkEditingRequests[rulesSourceName]?.result, - loading: !!checkEditingRequests[rulesSourceName]?.loading, + isEditable: contextSrv.isEditor && Boolean(dataSources[rulesSourceName]?.result?.rulerConfig), + loading: dataSources[rulesSourceName]?.loading, }; } diff --git a/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts b/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts index af22c1e5769..e5d49d680ac 100644 --- a/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts +++ b/public/app/features/alerting/unified/hooks/useRuleSourcesWithRuler.ts @@ -1,23 +1,17 @@ import { DataSourceInstanceSettings } from '@grafana/data'; -import { useEffect, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; -import { checkIfLotexSupportsEditingRulesAction } from '../state/actions'; -import { getRulesDataSources } from '../utils/datasource'; +import { PromBasedDataSource } from 'app/types/unified-alerting'; +import { getDataSourceByName } from '../utils/datasource'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; export function useRulesSourcesWithRuler(): DataSourceInstanceSettings[] { - const checkEditingRequests = useUnifiedAlertingSelector((state) => state.lotexSupportsRuleEditing); - const dispatch = useDispatch(); + const dataSources = useUnifiedAlertingSelector((state) => state.dataSources); + const dataSourcesWithRuler = Object.values(dataSources) + .map((ds) => ds.result) + .filter((ds): ds is PromBasedDataSource => Boolean(ds?.rulerConfig)); // try fetching rules for each prometheus to see if it has ruler - useEffect(() => { - getRulesDataSources() - .filter((ds) => checkEditingRequests[ds.name] === undefined) - .forEach((ds) => dispatch(checkIfLotexSupportsEditingRulesAction(ds.name))); - }, [dispatch, checkEditingRequests]); - return useMemo( - () => getRulesDataSources().filter((ds) => checkEditingRequests[ds.name]?.result), - [checkEditingRequests] - ); + return dataSourcesWithRuler + .map((ds) => getDataSourceByName(ds.name)) + .filter((dsConfig): dsConfig is DataSourceInstanceSettings => Boolean(dsConfig)); } diff --git a/public/app/features/alerting/unified/state/AlertingQueryRunner.test.ts b/public/app/features/alerting/unified/state/AlertingQueryRunner.test.ts index 147a506354e..ce5c6580251 100644 --- a/public/app/features/alerting/unified/state/AlertingQueryRunner.test.ts +++ b/public/app/features/alerting/unified/state/AlertingQueryRunner.test.ts @@ -93,12 +93,15 @@ describe('AlertingQueryRunner', () => { await expect(data.pipe(take(1))).toEmitValuesWith((values) => { const [data] = values; + + // these test are flakey since the absolute computed "timeRange" can differ from the relative "defaultRelativeTimeRange" + // so instead we will check if the size of the timeranges match const relativeA = rangeUtil.timeRangeToRelative(data.A.timeRange); const relativeB = rangeUtil.timeRangeToRelative(data.B.timeRange); - const expected = getDefaultRelativeTimeRange(); + const defaultRange = getDefaultRelativeTimeRange(); - expect(relativeA).toEqual(expected); - expect(relativeB).toEqual(expected); + expect(relativeA.from - defaultRange.from).toEqual(relativeA.to - defaultRange.to); + expect(relativeB.from - defaultRange.from).toEqual(relativeB.to - defaultRange.to); }); }); diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index c8c5113ef4a..92df3168f92 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -2,87 +2,100 @@ import { getBackendSrv, locationService } from '@grafana/runtime'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { AlertmanagerAlert, - ExternalAlertmanagerConfig, AlertManagerCortexConfig, AlertmanagerGroup, + ExternalAlertmanagerConfig, ExternalAlertmanagersResponse, Receiver, Silence, SilenceCreatePayload, TestReceiversAlert, } from 'app/plugins/datasource/alertmanager/types'; -import { FolderDTO, NotifierDTO, ThunkResult } from 'app/types'; +import messageFromError from 'app/plugins/datasource/grafana-azure-monitor-datasource/utils/messageFromError'; +import { FolderDTO, NotifierDTO, StoreState, ThunkResult } from 'app/types'; import { CombinedRuleGroup, CombinedRuleNamespace, + PromBasedDataSource, RuleIdentifier, RuleNamespace, + RulerDataSourceConfig, RuleWithLocation, StateHistoryItem, } from 'app/types/unified-alerting'; +import { PromApplication, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; +import { isEmpty } from 'lodash'; import { - PostableRulerRuleGroupDTO, - RulerGrafanaRuleDTO, - RulerRuleGroupDTO, - RulerRulesConfigDTO, -} from 'app/types/unified-alerting-dto'; -import { fetchNotifiers } from '../api/grafana'; -import { fetchAnnotations } from '../api/annotations'; -import { + addAlertManagers, + createOrUpdateSilence, + deleteAlertManagerConfig, expireSilence, + fetchAlertGroups, fetchAlertManagerConfig, fetchAlerts, - fetchAlertGroups, - fetchSilences, - createOrUpdateSilence, - updateAlertManagerConfig, - fetchStatus, - deleteAlertManagerConfig, - testReceivers, - addAlertManagers, - fetchExternalAlertmanagers, fetchExternalAlertmanagerConfig, + fetchExternalAlertmanagers, + fetchSilences, + fetchStatus, + testReceivers, + updateAlertManagerConfig, } from '../api/alertmanager'; +import { fetchAnnotations } from '../api/annotations'; +import { fetchBuildInfo } from '../api/buildInfo'; +import { fetchNotifiers } from '../api/grafana'; import { FetchPromRulesFilter, fetchRules } from '../api/prometheus'; import { deleteNamespace, deleteRulerRulesGroup, fetchRulerRules, - fetchRulerRulesGroup, - fetchRulerRulesNamespace, FetchRulerRulesFilter, setRulerRuleGroup, } from '../api/ruler'; import { RuleFormType, RuleFormValues } from '../types/rule-form'; +import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager'; import { getAllRulesSourceNames, + getRulesDataSource, getRulesSourceName, GRAFANA_RULES_SOURCE_NAME, - isGrafanaRulesSource, isVanillaPrometheusAlertManagerDataSource, } from '../utils/datasource'; import { makeAMLink, retryWhile } from '../utils/misc'; -import { withAppEvents, withSerializedError } from '../utils/redux'; -import { formValuesToRulerRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form'; -import { - isCloudRuleIdentifier, - isGrafanaRuleIdentifier, - isGrafanaRulerRule, - isPrometheusRuleIdentifier, - isRulerNotSupportedResponse, -} from '../utils/rules'; -import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute, isFetchError } from '../utils/alertmanager'; +import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux'; import * as ruleId from '../utils/rule-id'; -import { isEmpty } from 'lodash'; -import messageFromError from 'app/plugins/datasource/grafana-azure-monitor-datasource/utils/messageFromError'; -import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; +import { getRulerClient } from '../utils/rulerClient'; +import { isRulerNotSupportedResponse } from '../utils/rules'; const FETCH_CONFIG_RETRY_TIMEOUT = 30 * 1000; +function getDataSourceConfig(getState: () => unknown, rulesSourceName: string) { + const dataSources = (getState() as StoreState).unifiedAlerting.dataSources; + const dsConfig = dataSources[rulesSourceName]?.result; + if (!dsConfig) { + throw new Error(`Data source configuration is not available for "${rulesSourceName}" data source`); + } + + return dsConfig; +} + +function getDataSourceRulerConfig(getState: () => unknown, rulesSourceName: string) { + const dsConfig = getDataSourceConfig(getState, rulesSourceName); + if (!dsConfig.rulerConfig) { + throw new Error(`Ruler API is not available for ${rulesSourceName}`); + } + + return dsConfig.rulerConfig; +} + export const fetchPromRulesAction = createAsyncThunk( 'unifiedalerting/fetchPromRules', - ({ rulesSourceName, filter }: { rulesSourceName: string; filter?: FetchPromRulesFilter }): Promise => - withSerializedError(fetchRules(rulesSourceName, filter)) + async ( + { rulesSourceName, filter }: { rulesSourceName: string; filter?: FetchPromRulesFilter }, + thunkAPI + ): Promise => { + await thunkAPI.dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })); + return await withSerializedError(fetchRules(rulesSourceName, filter)); + } ); export const fetchAlertManagerConfigAction = createAsyncThunk( @@ -138,17 +151,34 @@ export const fetchExternalAlertmanagersConfigAction = createAsyncThunk( export const fetchRulerRulesAction = createAsyncThunk( 'unifiedalerting/fetchRulerRules', - ({ - rulesSourceName, - filter, - }: { - rulesSourceName: string; - filter?: FetchRulerRulesFilter; - }): Promise => { - return withSerializedError(fetchRulerRules(rulesSourceName, filter)); + async ( + { + rulesSourceName, + filter, + }: { + rulesSourceName: string; + filter?: FetchRulerRulesFilter; + }, + { dispatch, getState } + ): Promise => { + await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })); + const rulerConfig = getDataSourceRulerConfig(getState, rulesSourceName); + return await withSerializedError(fetchRulerRules(rulerConfig, filter)); } ); +export function fetchPromAndRulerRulesAction({ rulesSourceName }: { rulesSourceName: string }): ThunkResult { + return async (dispatch, getState) => { + await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })); + const dsConfig = getDataSourceConfig(getState, rulesSourceName); + + await dispatch(fetchPromRulesAction({ rulesSourceName })); + if (dsConfig.rulerConfig) { + await dispatch(fetchRulerRulesAction({ rulesSourceName })); + } + }; +} + export const fetchSilencesAction = createAsyncThunk( 'unifiedalerting/fetchSilences', (alertManagerSourceName: string): Promise => { @@ -167,14 +197,81 @@ export function fetchRulerRulesIfNotFetchedYet(rulesSourceName: string): ThunkRe }; } +export function fetchAllPromBuildInfoAction(): ThunkResult> { + return async (dispatch) => { + const allRequests = getAllRulesSourceNames().map((rulesSourceName) => + dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })) + ); + + await Promise.allSettled(allRequests); + }; +} + +export const fetchRulesSourceBuildInfoAction = createAsyncThunk( + 'unifiedalerting/fetchPromBuildinfo', + async ({ rulesSourceName }: { rulesSourceName: string }): Promise => { + return withSerializedError( + (async (): Promise => { + if (rulesSourceName === GRAFANA_RULES_SOURCE_NAME) { + return { + name: GRAFANA_RULES_SOURCE_NAME, + id: GRAFANA_RULES_SOURCE_NAME, + rulerConfig: { + dataSourceName: GRAFANA_RULES_SOURCE_NAME, + apiVersion: 'legacy', + }, + }; + } + + const ds = getRulesDataSource(rulesSourceName); + if (!ds) { + throw new Error(`Missing data source configuration for ${rulesSourceName}`); + } + + const { id, name } = ds; + const buildInfo = await fetchBuildInfo(name); + + const rulerConfig: RulerDataSourceConfig | undefined = buildInfo.features.rulerApiEnabled + ? { + dataSourceName: name, + apiVersion: buildInfo.application === PromApplication.Cortex ? 'legacy' : 'config', + } + : undefined; + + return { + name: name, + id: id, + rulerConfig, + }; + })() + ); + }, + { + condition: ({ rulesSourceName }, { getState }) => { + const dataSources: AsyncRequestMapSlice = (getState() as StoreState).unifiedAlerting + .dataSources; + const hasLoaded = Boolean(dataSources[rulesSourceName]?.result); + return !hasLoaded; + }, + } +); + export function fetchAllPromAndRulerRulesAction(force = false): ThunkResult { - return (dispatch, getStore) => { - const { promRules, rulerRules } = getStore().unifiedAlerting; + return async (dispatch, getStore) => { + await dispatch(fetchAllPromBuildInfoAction()); + + const { promRules, rulerRules, dataSources } = getStore().unifiedAlerting; + getAllRulesSourceNames().map((rulesSourceName) => { + const dataSourceConfig = dataSources[rulesSourceName].result; + if (!dataSourceConfig) { + return; + } + if (force || !promRules[rulesSourceName]?.loading) { dispatch(fetchPromRulesAction({ rulesSourceName })); } - if (force || !rulerRules[rulesSourceName]?.loading) { + if ((force || !rulerRules[rulesSourceName]?.loading) && dataSourceConfig.rulerConfig) { dispatch(fetchRulerRulesAction({ rulesSourceName })); } }); @@ -182,7 +279,7 @@ export function fetchAllPromAndRulerRulesAction(force = false): ThunkResult { - return (dispatch, getStore) => { + return async (dispatch, getStore) => { const { promRules } = getStore().unifiedAlerting; getAllRulesSourceNames().map((rulesSourceName) => { if (force || !promRules[rulesSourceName]?.loading) { @@ -192,97 +289,26 @@ export function fetchAllPromRulesAction(force = false): ThunkResult { }; } -async function findEditableRule(ruleIdentifier: RuleIdentifier): Promise { - if (isGrafanaRuleIdentifier(ruleIdentifier)) { - const namespaces = await fetchRulerRules(GRAFANA_RULES_SOURCE_NAME); - // find namespace and group that contains the uid for the rule - for (const [namespace, groups] of Object.entries(namespaces)) { - for (const group of groups) { - const rule = group.rules.find( - (rule) => isGrafanaRulerRule(rule) && rule.grafana_alert?.uid === ruleIdentifier.uid - ); - if (rule) { - return { - group, - ruleSourceName: GRAFANA_RULES_SOURCE_NAME, - namespace: namespace, - rule, - }; - } - } - } - } - - if (isCloudRuleIdentifier(ruleIdentifier)) { - const { ruleSourceName, namespace, groupName } = ruleIdentifier; - const group = await fetchRulerRulesGroup(ruleSourceName, namespace, groupName); - - if (!group) { - return null; - } - - const rule = group.rules.find((rule) => { - const identifier = ruleId.fromRulerRule(ruleSourceName, namespace, group.name, rule); - return ruleId.equal(identifier, ruleIdentifier); - }); - - if (!rule) { - return null; - } - - return { - group, - ruleSourceName, - namespace, - rule, - }; - } - - if (isPrometheusRuleIdentifier(ruleIdentifier)) { - throw new Error('Native prometheus rules can not be edited in grafana.'); - } - - return null; -} - export const fetchEditableRuleAction = createAsyncThunk( 'unifiedalerting/fetchEditableRule', - (ruleIdentifier: RuleIdentifier): Promise => - withSerializedError(findEditableRule(ruleIdentifier)) + (ruleIdentifier: RuleIdentifier, thunkAPI): Promise => { + const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, ruleIdentifier.ruleSourceName); + return withSerializedError(getRulerClient(rulerConfig).findEditableRule(ruleIdentifier)); + } ); -async function deleteRule(ruleWithLocation: RuleWithLocation): Promise { - const { ruleSourceName, namespace, group, rule } = ruleWithLocation; - // in case of GRAFANA, each group implicitly only has one rule. delete the group. - if (isGrafanaRulesSource(ruleSourceName)) { - await deleteRulerRulesGroup(GRAFANA_RULES_SOURCE_NAME, namespace, group.name); - return; - } - // in case of CLOUD - // it was the last rule, delete the entire group - if (group.rules.length === 1) { - await deleteRulerRulesGroup(ruleSourceName, namespace, group.name); - return; - } - // post the group with rule removed - await setRulerRuleGroup(ruleSourceName, namespace, { - ...group, - rules: group.rules.filter((r) => r !== rule), - }); -} - export function deleteRulesGroupAction( namespace: CombinedRuleNamespace, ruleGroup: CombinedRuleGroup ): ThunkResult { - return async (dispatch) => { + return async (dispatch, getState) => { withAppEvents( (async () => { const sourceName = getRulesSourceName(namespace.rulesSource); + const rulerConfig = getDataSourceRulerConfig(getState, sourceName); - await deleteRulerRulesGroup(sourceName, namespace.name, ruleGroup.name); - dispatch(fetchRulerRulesAction({ rulesSourceName: sourceName })); - dispatch(fetchPromRulesAction({ rulesSourceName: sourceName })); + await deleteRulerRulesGroup(rulerConfig, namespace.name, ruleGroup.name); + await dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: sourceName })); })(), { successMessage: 'Group deleted' } ); @@ -297,17 +323,19 @@ export function deleteRuleAction( * fetch the rules group from backend, delete group if it is found and+ * reload ruler rules */ - return async (dispatch) => { + return async (dispatch, getState) => { withAppEvents( (async () => { - const ruleWithLocation = await findEditableRule(ruleIdentifier); + const rulerConfig = getDataSourceRulerConfig(getState, ruleIdentifier.ruleSourceName); + const rulerClient = getRulerClient(rulerConfig); + const ruleWithLocation = await rulerClient.findEditableRule(ruleIdentifier); + if (!ruleWithLocation) { throw new Error('Rule not found.'); } - await deleteRule(ruleWithLocation); + await rulerClient.deleteRule(ruleWithLocation); // refetch rules for this rules source - dispatch(fetchRulerRulesAction({ rulesSourceName: ruleWithLocation.ruleSourceName })); - dispatch(fetchPromRulesAction({ rulesSourceName: ruleWithLocation.ruleSourceName })); + await dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: ruleWithLocation.ruleSourceName })); if (options.navigateTo) { locationService.replace(options.navigateTo); @@ -320,147 +348,42 @@ export function deleteRuleAction( }; } -async function saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise { - const { dataSourceName, group, namespace } = values; - const formRule = formValuesToRulerRuleDTO(values); - if (dataSourceName && group && namespace) { - // if we're updating a rule... - if (existing) { - // refetch it so we always have the latest greatest - const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing)); - if (!freshExisting) { - throw new Error('Rule not found.'); - } - // if namespace or group was changed, delete the old rule - if (freshExisting.namespace !== namespace || freshExisting.group.name !== group) { - await deleteRule(freshExisting); - } else { - // if same namespace or group, update the group replacing the old rule with new - const payload = { - ...freshExisting.group, - rules: freshExisting.group.rules.map((existingRule) => - existingRule === freshExisting.rule ? formRule : existingRule - ), - }; - await setRulerRuleGroup(dataSourceName, namespace, payload); - return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule); - } - } - - // if creating new rule or existing rule was in a different namespace/group, create new rule in target group - - const targetGroup = await fetchRulerRulesGroup(dataSourceName, namespace, group); - - const payload: RulerRuleGroupDTO = targetGroup - ? { - ...targetGroup, - rules: [...targetGroup.rules, formRule], - } - : { - name: group, - rules: [formRule], - }; - - await setRulerRuleGroup(dataSourceName, namespace, payload); - return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule); - } else { - throw new Error('Data source and location must be specified'); - } -} - -async function saveGrafanaRule(values: RuleFormValues, existing?: RuleWithLocation): Promise { - const { folder, evaluateEvery } = values; - const formRule = formValuesToRulerGrafanaRuleDTO(values); - - if (!folder) { - throw new Error('Folder must be specified'); - } - - // updating an existing rule... - if (existing) { - // refetch it to be sure we have the latest - const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing)); - if (!freshExisting) { - throw new Error('Rule not found.'); - } - - // if same folder, repost the group with updated rule - if (freshExisting.namespace === folder.title) { - const uid = (freshExisting.rule as RulerGrafanaRuleDTO).grafana_alert.uid!; - formRule.grafana_alert.uid = uid; - await setRulerRuleGroup(GRAFANA_RULES_SOURCE_NAME, freshExisting.namespace, { - name: freshExisting.group.name, - interval: evaluateEvery, - rules: [formRule], - }); - return { uid }; - } - } - - // if creating new rule or folder was changed, create rule in a new group - const targetFolderGroups = await fetchRulerRulesNamespace(GRAFANA_RULES_SOURCE_NAME, folder.title); - - // set group name to rule name, but be super paranoid and check that this group does not already exist - const groupName = getUniqueGroupName(values.name, targetFolderGroups); - formRule.grafana_alert.title = groupName; - - const payload: PostableRulerRuleGroupDTO = { - name: groupName, - interval: evaluateEvery, - rules: [formRule], - }; - await setRulerRuleGroup(GRAFANA_RULES_SOURCE_NAME, folder.title, payload); - - // now refetch this group to get the uid, hah - const result = await fetchRulerRulesGroup(GRAFANA_RULES_SOURCE_NAME, folder.title, groupName); - const newUid = (result?.rules[0] as RulerGrafanaRuleDTO)?.grafana_alert?.uid; - if (newUid) { - // if folder has changed, delete the old one - if (existing) { - const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing)); - if (freshExisting && freshExisting.namespace !== folder.title) { - await deleteRule(freshExisting); - } - } - - return { uid: newUid }; - } else { - throw new Error('Failed to fetch created rule.'); - } -} - -export function getUniqueGroupName(currentGroupName: string, existingGroups: RulerRuleGroupDTO[]) { - let newGroupName = currentGroupName; - let idx = 1; - while (!!existingGroups.find((g) => g.name === newGroupName)) { - newGroupName = `${currentGroupName}-${++idx}`; - } - - return newGroupName; -} - export const saveRuleFormAction = createAsyncThunk( 'unifiedalerting/saveRuleForm', - ({ - values, - existing, - redirectOnSave, - }: { - values: RuleFormValues; - existing?: RuleWithLocation; - redirectOnSave?: string; - }): Promise => + ( + { + values, + existing, + redirectOnSave, + }: { + values: RuleFormValues; + existing?: RuleWithLocation; + redirectOnSave?: string; + }, + thunkAPI + ): Promise => withAppEvents( withSerializedError( (async () => { const { type } = values; + + // TODO getRulerConfig should be smart enough to provide proper rulerClient implementation + // For the dataSourceName specified // in case of system (cortex/loki) let identifier: RuleIdentifier; if (type === RuleFormType.cloudAlerting || type === RuleFormType.cloudRecording) { - identifier = await saveLotexRule(values, existing); + if (!values.dataSourceName) { + throw new Error('The Data source has not been defined.'); + } + const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, values.dataSourceName); + const rulerClient = getRulerClient(rulerConfig); + identifier = await rulerClient.saveLotexRule(values, existing); + // in case of grafana managed } else if (type === RuleFormType.grafana) { - identifier = await saveGrafanaRule(values, existing); + const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, GRAFANA_RULES_SOURCE_NAME); + const rulerClient = getRulerClient(rulerConfig); + identifier = await rulerClient.saveGrafanaRule(values, existing); } else { throw new Error('Unexpected rule form type'); } @@ -651,33 +574,6 @@ export const fetchAlertGroupsAction = createAsyncThunk( } ); -export const checkIfLotexSupportsEditingRulesAction = createAsyncThunk( - 'unifiedalerting/checkIfLotexRuleEditingSupported', - async (rulesSourceName: string): Promise => - withAppEvents( - (async () => { - try { - await fetchRulerRulesGroup(rulesSourceName, 'test', 'test'); - return true; - } catch (e) { - if ( - (isFetchError(e) && - (e.data.message?.includes('GetRuleGroup unsupported in rule local store') || // "local" rule storage - e.data.message?.includes('page not found'))) || // ruler api disabled - e.message?.includes('404 from rules config endpoint') || // ruler api disabled - e.data.message?.includes(RULER_NOT_SUPPORTED_MSG) // ruler api not supported - ) { - return false; - } - throw e; - } - })(), - { - errorMessage: `Failed to determine if "${rulesSourceName}" allows editing rules`, - } - ) -); - export const deleteAlertManagerConfigAction = createAsyncThunk( 'unifiedalerting/deleteAlertManagerConfig', async (alertManagerSourceName: string, thunkAPI): Promise => { @@ -767,8 +663,10 @@ export const updateLotexNamespaceAndGroupAction = createAsyncThunk( if (options.rulesSourceName === GRAFANA_RULES_SOURCE_NAME) { throw new Error(`this action does not support Grafana rules`); } + + const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, rulesSourceName); // fetch rules and perform sanity checks - const rulesResult = await fetchRulerRules(rulesSourceName); + const rulesResult = await fetchRulerRules(rulerConfig); if (!rulesResult[namespaceName]) { throw new Error(`Namespace "${namespaceName}" not found.`); } @@ -794,7 +692,7 @@ export const updateLotexNamespaceAndGroupAction = createAsyncThunk( if (newNamespaceName !== namespaceName) { for (const group of rulesResult[namespaceName]) { await setRulerRuleGroup( - rulesSourceName, + rulerConfig, newNamespaceName, group.name === groupName ? { @@ -805,19 +703,19 @@ export const updateLotexNamespaceAndGroupAction = createAsyncThunk( : group ); } - await deleteNamespace(rulesSourceName, namespaceName); + await deleteNamespace(rulerConfig, namespaceName); // if only modifying group... } else { // save updated group - await setRulerRuleGroup(rulesSourceName, namespaceName, { + await setRulerRuleGroup(rulerConfig, namespaceName, { ...existingGroup, name: newGroupName, interval: groupInterval, }); // if group name was changed, delete old group if (newGroupName !== groupName) { - await deleteRulerRulesGroup(rulesSourceName, namespaceName, groupName); + await deleteRulerRulesGroup(rulerConfig, namespaceName, groupName); } } diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index 65f1aa8b157..30d2ff1d445 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -1,28 +1,33 @@ import { combineReducers } from 'redux'; import { createAsyncMapSlice, createAsyncSlice } from '../utils/redux'; import { + createOrUpdateSilenceAction, + deleteAlertManagerConfigAction, + fetchAlertGroupsAction, fetchAlertManagerConfigAction, fetchAmAlertsAction, fetchEditableRuleAction, + fetchExternalAlertmanagersAction, + fetchExternalAlertmanagersConfigAction, + fetchFolderAction, + fetchGrafanaAnnotationsAction, fetchGrafanaNotifiersAction, fetchPromRulesAction, fetchRulerRulesAction, + fetchRulesSourceBuildInfoAction, fetchSilencesAction, saveRuleFormAction, - updateAlertManagerConfigAction, - createOrUpdateSilenceAction, - fetchFolderAction, - fetchAlertGroupsAction, - checkIfLotexSupportsEditingRulesAction, - deleteAlertManagerConfigAction, testReceiversAction, + updateAlertManagerConfigAction, updateLotexNamespaceAndGroupAction, - fetchExternalAlertmanagersAction, - fetchExternalAlertmanagersConfigAction, - fetchGrafanaAnnotationsAction, } from './actions'; export const reducer = combineReducers({ + dataSources: createAsyncMapSlice( + 'dataSources', + fetchRulesSourceBuildInfoAction, + ({ rulesSourceName }) => rulesSourceName + ).reducer, promRules: createAsyncMapSlice('promRules', fetchPromRulesAction, ({ rulesSourceName }) => rulesSourceName).reducer, rulerRules: createAsyncMapSlice('rulerRules', fetchRulerRulesAction, ({ rulesSourceName }) => rulesSourceName) .reducer, @@ -49,11 +54,6 @@ export const reducer = combineReducers({ fetchAlertGroupsAction, (alertManagerSourceName) => alertManagerSourceName ).reducer, - lotexSupportsRuleEditing: createAsyncMapSlice( - 'lotexSupportsRuleEditing', - checkIfLotexSupportsEditingRulesAction, - (source) => source - ).reducer, testReceivers: createAsyncSlice('testReceivers', testReceiversAction).reducer, updateLotexNamespaceAndGroup: createAsyncSlice('updateLotexNamespaceAndGroup', updateLotexNamespaceAndGroupAction) .reducer, diff --git a/public/app/features/alerting/unified/utils/alertmanager.ts b/public/app/features/alerting/unified/utils/alertmanager.ts index 4e93b0824a4..cf904d2dc54 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.ts @@ -143,8 +143,8 @@ export function parseMatcher(matcher: string): Matcher { throw new Error(`Invalid matcher: ${trimmed}`); } const [operator, idx] = operatorsFound[0]; - const name = trimmed.substr(0, idx).trim(); - const value = trimmed.substr(idx + operator.length).trim(); + const name = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + operator.length).trim(); if (!name) { throw new Error(`Invalid matcher: ${trimmed}`); } @@ -235,12 +235,12 @@ export function getWeekdayString(weekdays?: string[]): string { .split(':') .map((d) => { const abbreviated = d.slice(0, 3); - return abbreviated[0].toLocaleUpperCase() + abbreviated.substr(1); + return abbreviated[0].toLocaleUpperCase() + abbreviated.slice(1); }) .join('-'); } else { const abbreviated = day.slice(0, 3); - return abbreviated[0].toLocaleUpperCase() + abbreviated.substr(1); + return abbreviated[0].toLocaleUpperCase() + abbreviated.slice(1); } }) .join(', ') ?? 'All') diff --git a/public/app/features/alerting/unified/utils/datasource.ts b/public/app/features/alerting/unified/utils/datasource.ts index 99bb03c9f2d..e494c62dda3 100644 --- a/public/app/features/alerting/unified/utils/datasource.ts +++ b/public/app/features/alerting/unified/utils/datasource.ts @@ -1,4 +1,4 @@ -import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; +import { DataSourceJsonData, DataSourceInstanceSettings } from '@grafana/data'; import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types'; import { RulesSource } from 'app/types/unified-alerting'; import { getAllDataSources } from './config'; @@ -20,6 +20,10 @@ export function getRulesDataSources() { .sort((a, b) => a.name.localeCompare(b.name)); } +export function getRulesDataSource(rulesSourceName: string) { + return getAllDataSources().find((x) => x.name === rulesSourceName); +} + export function getAlertManagerDataSources() { return getAllDataSources() .filter((ds) => ds.type === DataSourceType.Alertmanager) diff --git a/public/app/features/alerting/unified/utils/rule-id.ts b/public/app/features/alerting/unified/utils/rule-id.ts index 6b8d34f8c78..08454d58f12 100644 --- a/public/app/features/alerting/unified/utils/rule-id.ts +++ b/public/app/features/alerting/unified/utils/rule-id.ts @@ -19,7 +19,7 @@ export function fromRulerRule( rule: RulerRuleDTO ): RuleIdentifier { if (isGrafanaRulerRule(rule)) { - return { uid: rule.grafana_alert.uid! }; + return { uid: rule.grafana_alert.uid!, ruleSourceName: 'grafana' }; } return { ruleSourceName, @@ -99,7 +99,7 @@ export function parse(value: string, decodeFromUri = false): RuleIdentifier { const parts = source.split('$'); if (parts.length === 1) { - return { uid: value }; + return { uid: value, ruleSourceName: 'grafana' }; } if (parts.length === 5) { diff --git a/public/app/features/alerting/unified/state/actions.test.ts b/public/app/features/alerting/unified/utils/rulerClient.test.ts similarity index 94% rename from public/app/features/alerting/unified/state/actions.test.ts rename to public/app/features/alerting/unified/utils/rulerClient.test.ts index 99ac8dc0e06..ec5771833cf 100644 --- a/public/app/features/alerting/unified/state/actions.test.ts +++ b/public/app/features/alerting/unified/utils/rulerClient.test.ts @@ -1,5 +1,5 @@ import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; -import { getUniqueGroupName } from './actions'; +import { getUniqueGroupName } from './rulerClient'; describe('getUniqueGroupName', () => { it('Should return the original value when there are no duplicates', () => { diff --git a/public/app/features/alerting/unified/utils/rulerClient.ts b/public/app/features/alerting/unified/utils/rulerClient.ts new file mode 100644 index 00000000000..89eb8e6c286 --- /dev/null +++ b/public/app/features/alerting/unified/utils/rulerClient.ts @@ -0,0 +1,229 @@ +import { RuleIdentifier, RulerDataSourceConfig, RuleWithLocation } from 'app/types/unified-alerting'; +import { PostableRulerRuleGroupDTO, RulerGrafanaRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; +import { + deleteRulerRulesGroup, + fetchRulerRulesGroup, + fetchRulerRulesNamespace, + fetchRulerRules, + setRulerRuleGroup, +} from '../api/ruler'; +import { RuleFormValues } from '../types/rule-form'; +import * as ruleId from '../utils/rule-id'; +import { GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from './datasource'; +import { formValuesToRulerGrafanaRuleDTO, formValuesToRulerRuleDTO } from './rule-form'; +import { + isCloudRuleIdentifier, + isGrafanaRuleIdentifier, + isGrafanaRulerRule, + isPrometheusRuleIdentifier, +} from './rules'; + +export interface RulerClient { + findEditableRule(ruleIdentifier: RuleIdentifier): Promise; + deleteRule(ruleWithLocation: RuleWithLocation): Promise; + saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise; + saveGrafanaRule(values: RuleFormValues, existing?: RuleWithLocation): Promise; +} + +export function getUniqueGroupName(currentGroupName: string, existingGroups: RulerRuleGroupDTO[]) { + let newGroupName = currentGroupName; + let idx = 1; + while (!!existingGroups.find((g) => g.name === newGroupName)) { + newGroupName = `${currentGroupName}-${++idx}`; + } + + return newGroupName; +} + +export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient { + const findEditableRule = async (ruleIdentifier: RuleIdentifier): Promise => { + if (isGrafanaRuleIdentifier(ruleIdentifier)) { + const namespaces = await fetchRulerRules(rulerConfig); + // find namespace and group that contains the uid for the rule + for (const [namespace, groups] of Object.entries(namespaces)) { + for (const group of groups) { + const rule = group.rules.find( + (rule) => isGrafanaRulerRule(rule) && rule.grafana_alert?.uid === ruleIdentifier.uid + ); + if (rule) { + return { + group, + ruleSourceName: GRAFANA_RULES_SOURCE_NAME, + namespace: namespace, + rule, + }; + } + } + } + } + + if (isCloudRuleIdentifier(ruleIdentifier)) { + const { ruleSourceName, namespace, groupName } = ruleIdentifier; + const group = await fetchRulerRulesGroup(rulerConfig, namespace, groupName); + + if (!group) { + return null; + } + + const rule = group.rules.find((rule) => { + const identifier = ruleId.fromRulerRule(ruleSourceName, namespace, group.name, rule); + return ruleId.equal(identifier, ruleIdentifier); + }); + + if (!rule) { + return null; + } + + return { + group, + ruleSourceName, + namespace, + rule, + }; + } + + if (isPrometheusRuleIdentifier(ruleIdentifier)) { + throw new Error('Native prometheus rules can not be edited in grafana.'); + } + + return null; + }; + + const deleteRule = async (ruleWithLocation: RuleWithLocation): Promise => { + const { ruleSourceName, namespace, group, rule } = ruleWithLocation; + // in case of GRAFANA, each group implicitly only has one rule. delete the group. + if (isGrafanaRulesSource(ruleSourceName)) { + await deleteRulerRulesGroup(rulerConfig, namespace, group.name); + return; + } + // in case of CLOUD + // it was the last rule, delete the entire group + if (group.rules.length === 1) { + await deleteRulerRulesGroup(rulerConfig, namespace, group.name); + return; + } + // post the group with rule removed + await setRulerRuleGroup(rulerConfig, namespace, { + ...group, + rules: group.rules.filter((r) => r !== rule), + }); + }; + + const saveLotexRule = async (values: RuleFormValues, existing?: RuleWithLocation): Promise => { + const { dataSourceName, group, namespace } = values; + const formRule = formValuesToRulerRuleDTO(values); + if (dataSourceName && group && namespace) { + // if we're updating a rule... + if (existing) { + // refetch it so we always have the latest greatest + const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing)); + if (!freshExisting) { + throw new Error('Rule not found.'); + } + // if namespace or group was changed, delete the old rule + if (freshExisting.namespace !== namespace || freshExisting.group.name !== group) { + await deleteRule(freshExisting); + } else { + // if same namespace or group, update the group replacing the old rule with new + const payload = { + ...freshExisting.group, + rules: freshExisting.group.rules.map((existingRule) => + existingRule === freshExisting.rule ? formRule : existingRule + ), + }; + await setRulerRuleGroup(rulerConfig, namespace, payload); + return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule); + } + } + + // if creating new rule or existing rule was in a different namespace/group, create new rule in target group + + const targetGroup = await fetchRulerRulesGroup(rulerConfig, namespace, group); + + const payload: RulerRuleGroupDTO = targetGroup + ? { + ...targetGroup, + rules: [...targetGroup.rules, formRule], + } + : { + name: group, + rules: [formRule], + }; + + await setRulerRuleGroup(rulerConfig, namespace, payload); + return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule); + } else { + throw new Error('Data source and location must be specified'); + } + }; + + const saveGrafanaRule = async (values: RuleFormValues, existing?: RuleWithLocation): Promise => { + const { folder, evaluateEvery } = values; + const formRule = formValuesToRulerGrafanaRuleDTO(values); + + if (!folder) { + throw new Error('Folder must be specified'); + } + + // updating an existing rule... + if (existing) { + // refetch it to be sure we have the latest + const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing)); + if (!freshExisting) { + throw new Error('Rule not found.'); + } + + // if same folder, repost the group with updated rule + if (freshExisting.namespace === folder.title) { + const uid = (freshExisting.rule as RulerGrafanaRuleDTO).grafana_alert.uid!; + formRule.grafana_alert.uid = uid; + await setRulerRuleGroup(rulerConfig, freshExisting.namespace, { + name: freshExisting.group.name, + interval: evaluateEvery, + rules: [formRule], + }); + return { uid, ruleSourceName: 'grafana' }; + } + } + + // if creating new rule or folder was changed, create rule in a new group + const targetFolderGroups = await fetchRulerRulesNamespace(rulerConfig, folder.title); + + // set group name to rule name, but be super paranoid and check that this group does not already exist + const groupName = getUniqueGroupName(values.name, targetFolderGroups); + formRule.grafana_alert.title = groupName; + + const payload: PostableRulerRuleGroupDTO = { + name: groupName, + interval: evaluateEvery, + rules: [formRule], + }; + await setRulerRuleGroup(rulerConfig, folder.title, payload); + + // now refetch this group to get the uid, hah + const result = await fetchRulerRulesGroup(rulerConfig, folder.title, groupName); + const newUid = (result?.rules[0] as RulerGrafanaRuleDTO)?.grafana_alert?.uid; + if (newUid) { + // if folder has changed, delete the old one + if (existing) { + const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing)); + if (freshExisting && freshExisting.namespace !== folder.title) { + await deleteRule(freshExisting); + } + } + + return { uid: newUid, ruleSourceName: 'grafana' }; + } else { + throw new Error('Failed to fetch created rule.'); + } + }; + + // Would be nice to somehow align checking of ruler type between different methods + // Maybe each datasource should have its own ruler client implementation + return { + findEditableRule, + deleteRule, + saveLotexRule, + saveGrafanaRule, + }; +} diff --git a/public/app/features/annotations/partials/event_editor.html b/public/app/features/annotations/partials/event_editor.html index b11b24ca767..53add6789cc 100644 --- a/public/app/features/annotations/partials/event_editor.html +++ b/public/app/features/annotations/partials/event_editor.html @@ -27,7 +27,7 @@
    - + Cancel
    diff --git a/public/app/features/annotations/standardAnnotationSupport.ts b/public/app/features/annotations/standardAnnotationSupport.ts index 9348dd2535e..edc6ac668ca 100644 --- a/public/app/features/annotations/standardAnnotationSupport.ts +++ b/public/app/features/annotations/standardAnnotationSupport.ts @@ -124,6 +124,7 @@ const alertEventAndAnnotationFields: AnnotationFieldInfo[] = [ { key: 'data' as any }, { key: 'panelId' }, { key: 'alertId' }, + { key: 'dashboardId' }, ]; export function getAnnotationsFromData( diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index b4426369ecb..084e52ed1b3 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -15,7 +15,7 @@ import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; import { DashboardModel } from '../../state'; import { KioskMode } from 'app/types'; import { ShareModal } from 'app/features/dashboard/components/ShareModal'; -import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer'; +import { SaveDashboardProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardProxy'; import { DashboardCommentsModal } from 'app/features/dashboard/components/DashboardComments/DashboardCommentsModal'; import { locationService } from '@grafana/runtime'; import { toggleKioskMode } from 'app/core/navigation/kiosk'; @@ -227,7 +227,7 @@ export const DashNav = React.memo((props) => { tooltip="Save dashboard" icon="save" onClick={() => { - showModal(SaveDashboardDrawer, { + showModal(SaveDashboardProxy, { dashboard, onDismiss: hideModal, }); diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx index 2e31a65ed96..1a0acb63656 100644 --- a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx @@ -1,5 +1,8 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { Link } from 'react-router-dom'; +import { FocusScope } from '@react-aria/focus'; +import { useDialog } from '@react-aria/dialog'; +import { useOverlay } from '@react-aria/overlays'; import { css, cx } from '@emotion/css'; import { Button, CustomScrollbar, Icon, IconName, PageToolbar, stylesFactory, useForceUpdate } from '@grafana/ui'; import config from 'app/core/config'; @@ -40,6 +43,14 @@ const MakeEditable = (props: { onMakeEditable: () => any }) => ( ); export function DashboardSettings({ dashboard, editview }: Props) { + const ref = useRef(null); + const { overlayProps } = useOverlay({}, ref); + const { dialogProps } = useDialog( + { + 'aria-label': 'Dashboard settings', + }, + ref + ); const forceUpdate = useForceUpdate(); const onMakeEditable = useCallback(() => { dashboard.editable = true; @@ -139,35 +150,37 @@ export function DashboardSettings({ dashboard, editview }: Props) { const styles = getStyles(config.theme2); return ( -
    - - -
    -
    - -
    {currentPage.component}
    + +
    + + +
    +
    + +
    {currentPage.component}
    +
    -
    - -
    + +
    + ); } diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index bdef1ca6501..7939e89f415 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -25,7 +25,7 @@ import { DashNavTimeControls } from '../DashNav/DashNavTimeControls'; import { OptionsPane } from './OptionsPane'; import { SubMenuItems } from 'app/features/dashboard/components/SubMenu/SubMenuItems'; import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper'; -import { SaveDashboardDrawer } from '../SaveDashboard/SaveDashboardDrawer'; +import { SaveDashboardProxy } from '../SaveDashboard/SaveDashboardProxy'; import { DashboardPanel } from '../../dashgrid/DashboardPanel'; import { discardPanelChanges, initPanelEditor, updatePanelEditorUIState } from './state/actions'; @@ -145,7 +145,7 @@ export class PanelEditorUnconnected extends PureComponent { onSaveDashboard = () => { appEvents.publish( new ShowModalReactEvent({ - component: SaveDashboardDrawer, + component: SaveDashboardProxy, props: { dashboard: this.props.dashboard }, }) ); diff --git a/public/app/features/dashboard/components/PanelEditor/utils.ts b/public/app/features/dashboard/components/PanelEditor/utils.ts index 37c626d4c5b..868476b6241 100644 --- a/public/app/features/dashboard/components/PanelEditor/utils.ts +++ b/public/app/features/dashboard/components/PanelEditor/utils.ts @@ -71,7 +71,7 @@ export function setOptionImmutably(options: T, path: string | if (key.endsWith(']')) { const idx = key.lastIndexOf('['); const index = +key.substring(idx + 1, key.length - 1); - const propKey = key.substr(0, idx); + const propKey = key.substring(0, idx); let current = (options as Record)[propKey]; const arr = Array.isArray(current) ? [...current] : []; if (splat.length) { diff --git a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardAsModal.tsx b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardAsModal.tsx new file mode 100644 index 00000000000..ca9c0364b7c --- /dev/null +++ b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardAsModal.tsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import { css } from '@emotion/css'; +import { Modal } from '@grafana/ui'; +import { SaveDashboardAsForm } from './forms/SaveDashboardAsForm'; +import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy'; +import { useDashboardSave } from './useDashboardSave'; +import { SaveDashboardModalProps } from './types'; + +export const SaveDashboardAsModal: React.FC< + SaveDashboardModalProps & { + isNew?: boolean; + } +> = ({ dashboard, onDismiss, isNew }) => { + const { state, onDashboardSave } = useDashboardSave(dashboard); + const [dashboardSaveModelClone, setDashboardSaveModelClone] = useState(); + return ( + <> + {state.error && ( + + )} + {!state.error && ( + + { + setDashboardSaveModelClone(clone); + return onDashboardSave(clone, options, dashboard); + }} + isNew={isNew} + /> + + )} + + ); +}; diff --git a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx index 3e3b19780d5..d754fe352a4 100644 --- a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Button, ButtonVariant, ModalsController, FullWidthButtonContainer } from '@grafana/ui'; import { DashboardModel } from 'app/features/dashboard/state'; -import { SaveDashboardDrawer } from './SaveDashboardDrawer'; +import { SaveDashboardProxy } from './SaveDashboardProxy'; import { selectors } from '@grafana/e2e-selectors'; interface SaveDashboardButtonProps { @@ -16,7 +16,7 @@ export const SaveDashboardButton: React.FC = ({ dashbo return (
    )}
    diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 1194f412046..29ca0cf5df7 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -115,6 +115,8 @@ class UnConnectedExploreToolbar extends PureComponent { )} + + {!isLive && ( { /> )} - - - {}} queryResponse={mockPanelData} /> + {}} + traceProp={transformDataFrames(frames[0])!} + search="" + focusedSpanIdForSearch="" + expandOne={() => {}} + expandAll={() => {}} + collapseOne={() => {}} + collapseAll={() => {}} + childrenToggle={() => {}} + childrenHiddenIDs={new Set()} + queryResponse={mockPanelData} + /> ); + return traceView; +} + +function renderTraceView(frames = [frameOld]) { + const { container, baseElement } = render(getTraceView(frames)); + return { header: container.children[0], timeline: container.children[1], @@ -79,42 +100,6 @@ describe('TraceView', () => { expect(screen.queryByText(/Tags/)).toBeFalsy(); }); - it('toggles children visibility', () => { - renderTraceViewNew(); - expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3); - userEvent.click(screen.getAllByText('', { selector: 'span[data-test-id="SpanTreeOffset--indentGuide"]' })[0]); - expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(1); - - userEvent.click(screen.getAllByText('', { selector: 'span[data-test-id="SpanTreeOffset--indentGuide"]' })[0]); - expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3); - }); - - it('toggles collapses and expands one level of spans', () => { - renderTraceViewNew(); - expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3); - userEvent.click(screen.getByLabelText('Collapse +1')); - expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(2); - userEvent.click(screen.getByLabelText('Expand +1')); - expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3); - }); - - it('toggles collapses and expands all levels', () => { - renderTraceViewNew(); - expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3); - userEvent.click(screen.getByLabelText('Collapse All')); - expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(1); - userEvent.click(screen.getByLabelText('Expand All')); - expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3); - }); - - it('searches for spans', () => { - renderTraceViewNew(); - userEvent.type(screen.getByPlaceholderText('Find...'), '1ed38015486087ca'); - expect( - (screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' })[0].parentNode! as HTMLElement).className - ).toContain('rowMatchingFilter'); - }); - it('shows timeline ticks', () => { renderTraceViewNew(); function ticks() { @@ -151,38 +136,13 @@ describe('TraceView', () => { }); it('resets detail view for new trace with the identical spanID', () => { - const store = configureStore(); - const mockPanelData = { - state: LoadingState.Done, - series: [], - timeRange: getDefaultTimeRange(), - }; - - const { rerender } = render( - - {}} - queryResponse={mockPanelData} - /> - - ); + const { rerender } = render(getTraceView([frameOld])); const span = screen.getAllByText('', { selector: 'div[data-test-id="span-view"]' })[2]; userEvent.click(span); //Process is in detail view expect(screen.getByText(/Process/)).toBeInTheDocument(); - rerender( - - {}} - queryResponse={mockPanelData} - /> - - ); + rerender(getTraceView([frameNew])); expect(screen.queryByText(/Process/)).not.toBeInTheDocument(); }); }); @@ -298,7 +258,7 @@ const response: TraceData & { spans: TraceSpanData[] } = { warnings: null as any, }; -const frameOld = new MutableDataFrame({ +export const frameOld = new MutableDataFrame({ fields: [ { name: 'trace', diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index 697a36b2578..cfb3b61ccfa 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -1,6 +1,5 @@ import { DataFrame, - DataFrameView, DataLink, DataSourceApi, Field, @@ -9,16 +8,13 @@ import { mapInternalLinkToExplore, PanelData, SplitOpen, - TraceSpanRow, } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; import { Trace, TracePageHeader, - TraceProcess, - TraceResponse, + TraceSpan, TraceTimelineViewer, - transformTraceData, TTraceTimeline, } from '@jaegertracing/jaeger-ui-components'; import { TraceToLogsData } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; @@ -30,10 +26,8 @@ import React, { RefObject, useCallback, useEffect, useMemo, useState } from 'rea import { useDispatch, useSelector } from 'react-redux'; import { changePanelState } from '../state/explorePane'; import { createSpanLinkFactory } from './createSpanLink'; -import { useChildrenState } from './useChildrenState'; import { useDetailState } from './useDetailState'; import { useHoverIndentGuide } from './useHoverIndentGuide'; -import { useSearch } from './useSearch'; import { useViewRange } from './useViewRange'; function noop(): {} { @@ -46,14 +40,31 @@ type Props = { exploreId: ExploreId; scrollElement?: Element; topOfExploreViewRef?: RefObject; + traceProp: Trace; + spanFindMatches?: Set; + search: string; + focusedSpanIdForSearch: string; + expandOne: (spans: TraceSpan[]) => void; + expandAll: () => void; + collapseOne: (spans: TraceSpan[]) => void; + collapseAll: (spans: TraceSpan[]) => void; + childrenToggle: (spanId: string) => void; + childrenHiddenIDs: Set; queryResponse: PanelData; }; export function TraceView(props: Props) { - // At this point we only show single trace - const frame = props.dataFrames[0]; + const { + expandOne, + expandAll, + collapseOne, + collapseAll, + childrenToggle, + childrenHiddenIDs, + spanFindMatches, + traceProp, + } = props; - const { expandOne, collapseOne, childrenToggle, collapseAll, childrenHiddenIDs, expandAll } = useChildrenState(); const { detailStates, toggleDetail, @@ -65,7 +76,7 @@ export function TraceView(props: Props) { detailTagsToggle, detailWarningsToggle, detailStackTracesToggle, - } = useDetailState(frame); + } = useDetailState(props.dataFrames[0]); const { removeHoverIndentGuideId, addHoverIndentGuideId, hoverIndentGuideIds } = useHoverIndentGuide(); const { viewRange, updateViewRangeTime, updateNextViewRangeTime } = useViewRange(); @@ -79,15 +90,12 @@ export function TraceView(props: Props) { */ const [slim, setSlim] = useState(false); - const traceProp = useMemo(() => transformDataFrames(frame), [frame]); - const { search, setSearch, spanFindMatches, clearSearch } = useSearch(traceProp?.spans); - const datasource = useSelector( (state: StoreState) => state.explore[props.exploreId]?.datasourceInstance ?? undefined ); const [focusedSpanId, createFocusSpanLink] = useFocusSpanLink({ - refId: frame?.refId, + refId: props.dataFrames[0]?.refId, exploreId: props.exploreId, datasource, }); @@ -104,9 +112,9 @@ export function TraceView(props: Props) { hoverIndentGuideIds, shouldScrollToFirstUiFindMatch: false, spanNameColumnWidth, - traceID: traceProp?.traceID, + traceID: props.traceProp?.traceID, }), - [childrenHiddenIDs, detailStates, hoverIndentGuideIds, spanNameColumnWidth, traceProp?.traceID] + [childrenHiddenIDs, detailStates, hoverIndentGuideIds, spanNameColumnWidth, props.traceProp?.traceID] ); useEffect(() => { @@ -118,8 +126,8 @@ export function TraceView(props: Props) { const traceToLogsOptions = (getDatasourceSrv().getInstanceSettings(datasource?.name)?.jsonData as TraceToLogsData) ?.tracesToLogs; const createSpanLink = useMemo( - () => createSpanLinkFactory({ splitOpenFn: props.splitOpenFn, traceToLogsOptions, dataFrame: frame }), - [props.splitOpenFn, traceToLogsOptions, frame] + () => createSpanLinkFactory({ splitOpenFn: props.splitOpenFn, traceToLogsOptions, dataFrame: props.dataFrames[0] }), + [props.splitOpenFn, traceToLogsOptions, props.dataFrames] ); const onSlimViewClicked = useCallback(() => setSlim(!slim), [slim]); const timeZone = useSelector((state: StoreState) => getTimeZone(state.user)); @@ -132,22 +140,15 @@ export function TraceView(props: Props) { <> @@ -192,56 +194,6 @@ export function TraceView(props: Props) { ); } -function transformDataFrames(frame?: DataFrame): Trace | null { - if (!frame) { - return null; - } - let data: TraceResponse = - frame.fields.length === 1 - ? // For backward compatibility when we sent whole json response in a single field/value - frame.fields[0].values.get(0) - : transformTraceDataFrame(frame); - return transformTraceData(data); -} - -function transformTraceDataFrame(frame: DataFrame): TraceResponse { - const view = new DataFrameView(frame); - const processes: Record = {}; - for (let i = 0; i < view.length; i++) { - const span = view.get(i); - if (!processes[span.spanID]) { - processes[span.spanID] = { - serviceName: span.serviceName, - tags: span.serviceTags, - }; - } - } - - return { - traceID: view.get(0).traceID, - processes, - spans: view.toArray().map((s, index) => { - const references = []; - if (s.parentSpanID) { - references.push({ refType: 'CHILD_OF' as const, spanID: s.parentSpanID, traceID: s.traceID }); - } - if (s.references) { - references.push(...s.references.map((reference) => ({ refType: 'FOLLOWS_FROM' as const, ...reference }))); - } - return { - ...s, - duration: s.duration * 1000, - startTime: s.startTime * 1000, - processID: s.spanID, - flags: 0, - references, - logs: s.logs?.map((l) => ({ ...l, timestamp: l.timestamp * 1000 })) || [], - dataFrameRowIndex: index, - }; - }), - }; -} - /** * Handles focusing a span. Returns the span id to focus to based on what is in current explore state and also a * function to change the focused span id. diff --git a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx new file mode 100644 index 00000000000..68725bb429b --- /dev/null +++ b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { TraceViewContainer } from './TraceViewContainer'; +import { frameOld } from './TraceView.test'; +import { ExploreId } from 'app/types'; +import { configureStore } from '../../../store/configureStore'; +import { getDefaultTimeRange, LoadingState } from '@grafana/data'; +import { Provider } from 'react-redux'; +import userEvent from '@testing-library/user-event'; + +function renderTraceViewContainer(frames = [frameOld]) { + const store = configureStore(); + const mockPanelData = { + state: LoadingState.Done, + series: [], + timeRange: getDefaultTimeRange(), + }; + + const { container, baseElement } = render( + + {}} + queryResponse={mockPanelData} + /> + + ); + return { + header: container.children[0], + timeline: container.children[1], + container, + baseElement, + }; +} + +describe('TraceViewContainer', () => { + it('toggles children visibility', () => { + renderTraceViewContainer(); + expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3); + userEvent.click(screen.getAllByText('', { selector: 'span[data-test-id="SpanTreeOffset--indentGuide"]' })[0]); + expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(1); + + userEvent.click(screen.getAllByText('', { selector: 'span[data-test-id="SpanTreeOffset--indentGuide"]' })[0]); + expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3); + }); + + it('toggles collapses and expands one level of spans', () => { + renderTraceViewContainer(); + expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3); + userEvent.click(screen.getByLabelText('Collapse +1')); + expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(2); + userEvent.click(screen.getByLabelText('Expand +1')); + expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3); + }); + + it('toggles collapses and expands all levels', () => { + renderTraceViewContainer(); + expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3); + userEvent.click(screen.getByLabelText('Collapse All')); + expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(1); + userEvent.click(screen.getByLabelText('Expand All')); + expect(screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' }).length).toBe(3); + }); + + it('searches for spans', () => { + renderTraceViewContainer(); + userEvent.type(screen.getByPlaceholderText('Find...'), '1ed38015486087ca'); + expect( + (screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' })[0].parentNode! as HTMLElement).className + ).toContain('rowMatchingFilter'); + }); + + it('can select next/prev results', () => { + renderTraceViewContainer(); + userEvent.type(screen.getByPlaceholderText('Find...'), 'logproto'); + const nextResultButton = screen.getByTestId('trace-page-search-bar-next-result-button'); + const prevResultButton = screen.getByTestId('trace-page-search-bar-prev-result-button'); + const suffix = screen.getByTestId('trace-page-search-bar-suffix'); + + userEvent.click(nextResultButton); + expect(suffix.textContent).toBe('1 of 2'); + expect( + (screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' })[1].parentNode! as HTMLElement).className + ).toContain('rowFocused'); + userEvent.click(nextResultButton); + expect(suffix.textContent).toBe('2 of 2'); + expect( + (screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' })[2].parentNode! as HTMLElement).className + ).toContain('rowFocused'); + userEvent.click(nextResultButton); + expect(suffix.textContent).toBe('1 of 2'); + expect( + (screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' })[1].parentNode! as HTMLElement).className + ).toContain('rowFocused'); + userEvent.click(prevResultButton); + expect(suffix.textContent).toBe('2 of 2'); + expect( + (screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' })[2].parentNode! as HTMLElement).className + ).toContain('rowFocused'); + userEvent.click(prevResultButton); + expect(suffix.textContent).toBe('1 of 2'); + expect( + (screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' })[1].parentNode! as HTMLElement).className + ).toContain('rowFocused'); + userEvent.click(prevResultButton); + expect(suffix.textContent).toBe('2 of 2'); + expect( + (screen.queryAllByText('', { selector: 'div[data-test-id="span-view"]' })[2].parentNode! as HTMLElement).className + ).toContain('rowFocused'); + }); +}); diff --git a/public/app/features/explore/TraceView/TraceViewContainer.tsx b/public/app/features/explore/TraceView/TraceViewContainer.tsx index 787be09db35..80abebf309c 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.tsx @@ -1,9 +1,12 @@ -import React, { RefObject } from 'react'; +import React, { RefObject, useMemo, useState } from 'react'; import { Collapse } from '@grafana/ui'; -import { DataFrame, PanelData, SplitOpen } from '@grafana/data'; +import { DataFrame, SplitOpen, PanelData } from '@grafana/data'; import { TraceView } from './TraceView'; import { ExploreId } from 'app/types/explore'; - +import TracePageSearchBar from '@jaegertracing/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar'; +import { useSearch } from './useSearch'; +import { transformDataFrames } from './utils/transform'; +import { useChildrenState } from './useChildrenState'; interface Props { dataFrames: DataFrame[]; splitOpenFn: SplitOpen; @@ -13,18 +16,103 @@ interface Props { queryResponse: PanelData; } export function TraceViewContainer(props: Props) { + // At this point we only show single trace + const frame = props.dataFrames[0]; + const { dataFrames, splitOpenFn, exploreId, scrollElement, topOfExploreViewRef, queryResponse } = props; + const traceProp = useMemo(() => transformDataFrames(frame), [frame]); + const { search, setSearch, spanFindMatches } = useSearch(traceProp?.spans); + const { expandOne, collapseOne, childrenToggle, collapseAll, childrenHiddenIDs, expandAll } = useChildrenState(); + + const [focusedSpanIdForSearch, setFocusedSpanIdForSearch] = useState(''); + const [searchBarSuffix, setSearchBarSuffix] = useState(''); + + const setTraceSearch = (value: string) => { + setFocusedSpanIdForSearch(''); + setSearchBarSuffix(''); + setSearch(value); + }; + + const nextResult = () => { + expandAll(); + const spanMatches = Array.from(spanFindMatches!); + const prevMatchedIndex = spanMatches.indexOf(focusedSpanIdForSearch) + ? spanMatches.indexOf(focusedSpanIdForSearch) + : 0; + + // new query || at end, go to start + if (prevMatchedIndex === -1 || prevMatchedIndex === spanMatches.length - 1) { + setFocusedSpanIdForSearch(spanMatches[0]); + setSearchBarSuffix(getSearchBarSuffix(1)); + return; + } + + // get next + setFocusedSpanIdForSearch(spanMatches[prevMatchedIndex + 1]); + setSearchBarSuffix(getSearchBarSuffix(prevMatchedIndex + 2)); + }; + + const prevResult = () => { + expandAll(); + const spanMatches = Array.from(spanFindMatches!); + const prevMatchedIndex = spanMatches.indexOf(focusedSpanIdForSearch) + ? spanMatches.indexOf(focusedSpanIdForSearch) + : 0; + + // new query || at start, go to end + if (prevMatchedIndex === -1 || prevMatchedIndex === 0) { + setFocusedSpanIdForSearch(spanMatches[spanMatches.length - 1]); + setSearchBarSuffix(getSearchBarSuffix(spanMatches.length)); + return; + } + + // get prev + setFocusedSpanIdForSearch(spanMatches[prevMatchedIndex - 1]); + setSearchBarSuffix(getSearchBarSuffix(prevMatchedIndex)); + }; + + const getSearchBarSuffix = (index: number): string => { + if (spanFindMatches?.size && spanFindMatches?.size > 0) { + return index + ' of ' + spanFindMatches?.size; + } + return ''; + }; + + if (!traceProp) { + return null; + } return ( - - + - + + + + + ); } diff --git a/public/app/features/explore/TraceView/useSearch.ts b/public/app/features/explore/TraceView/useSearch.ts index 2d0084edaac..dba2602a7f3 100644 --- a/public/app/features/explore/TraceView/useSearch.ts +++ b/public/app/features/explore/TraceView/useSearch.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { filterSpans, TraceSpan } from '@jaegertracing/jaeger-ui-components'; /** @@ -11,9 +11,5 @@ export function useSearch(spans?: TraceSpan[]) { return search && spans ? filterSpans(search, spans) : undefined; }, [search, spans]); - const clearSearch = useCallback(() => { - setSearch(''); - }, [setSearch]); - - return { search, setSearch, spanFindMatches, clearSearch }; + return { search, setSearch, spanFindMatches }; } diff --git a/public/app/features/explore/TraceView/utils/transform.ts b/public/app/features/explore/TraceView/utils/transform.ts new file mode 100644 index 00000000000..7300566121f --- /dev/null +++ b/public/app/features/explore/TraceView/utils/transform.ts @@ -0,0 +1,52 @@ +import { DataFrame, DataFrameView, TraceSpanRow } from '@grafana/data'; +import { Trace, TraceProcess, TraceResponse, transformTraceData } from '@jaegertracing/jaeger-ui-components'; + +export function transformDataFrames(frame?: DataFrame): Trace | null { + if (!frame) { + return null; + } + let data: TraceResponse = + frame.fields.length === 1 + ? // For backward compatibility when we sent whole json response in a single field/value + frame.fields[0].values.get(0) + : transformTraceDataFrame(frame); + return transformTraceData(data); +} + +function transformTraceDataFrame(frame: DataFrame): TraceResponse { + const view = new DataFrameView(frame); + const processes: Record = {}; + for (let i = 0; i < view.length; i++) { + const span = view.get(i); + if (!processes[span.spanID]) { + processes[span.spanID] = { + serviceName: span.serviceName, + tags: span.serviceTags, + }; + } + } + + return { + traceID: view.get(0).traceID, + processes, + spans: view.toArray().map((s, index) => { + const references = []; + if (s.parentSpanID) { + references.push({ refType: 'CHILD_OF' as const, spanID: s.parentSpanID, traceID: s.traceID }); + } + if (s.references) { + references.push(...s.references.map((reference) => ({ refType: 'FOLLOWS_FROM' as const, ...reference }))); + } + return { + ...s, + duration: s.duration * 1000, + startTime: s.startTime * 1000, + processID: s.spanID, + flags: 0, + references, + logs: s.logs?.map((l) => ({ ...l, timestamp: l.timestamp * 1000 })) || [], + dataFrameRowIndex: index, + }; + }), + }; +} diff --git a/public/app/features/inspector/InspectJSONTab.tsx b/public/app/features/inspector/InspectJSONTab.tsx index dd3631d82ca..acc6d4b3c95 100644 --- a/public/app/features/inspector/InspectJSONTab.tsx +++ b/public/app/features/inspector/InspectJSONTab.tsx @@ -1,6 +1,5 @@ import React, { PureComponent } from 'react'; -import { chain } from 'lodash'; -import { AppEvents, PanelData, SelectableValue } from '@grafana/data'; +import { AppEvents, dataFrameToJSON, PanelData, SelectableValue } from '@grafana/data'; import { Button, CodeEditor, Field, Select } from '@grafana/ui'; import AutoSizer from 'react-virtualized-auto-sizer'; import { selectors } from '@grafana/e2e-selectors'; @@ -10,8 +9,8 @@ import { getPanelInspectorStyles } from '../inspector/styles'; enum ShowContent { PanelJSON = 'panel', - DataJSON = 'data', - DataStructure = 'structure', + PanelData = 'data', + DataFrames = 'frames', } const options: Array> = [ @@ -21,14 +20,14 @@ const options: Array> = [ value: ShowContent.PanelJSON, }, { - label: 'Data', + label: 'Panel data', description: 'The raw model passed to the panel visualization', - value: ShowContent.DataJSON, + value: ShowContent.PanelData, }, { - label: 'DataFrame structure', - description: 'Response info without any values', - value: ShowContent.DataStructure, + label: 'DataFrame JSON', + description: 'JSON formatted DataFrames', + value: ShowContent.DataFrames, }, ]; @@ -50,9 +49,9 @@ export class InspectJSONTab extends PureComponent { constructor(props: Props) { super(props); this.hasPanelJSON = !!(props.panel && props.dashboard); - // If we are in panel, we want to show PanelJSON, otherwise show DataJSON + // If we are in panel, we want to show PanelJSON, otherwise show DataFrames this.state = { - show: this.hasPanelJSON ? ShowContent.PanelJSON : ShowContent.DataJSON, + show: this.hasPanelJSON ? ShowContent.PanelJSON : ShowContent.DataFrames, text: this.hasPanelJSON ? getPrettyJSON(props.panel!.getSaveModel()) : getPrettyJSON(props.data), }; } @@ -70,24 +69,16 @@ export class InspectJSONTab extends PureComponent { getJSONObject(show: ShowContent) { const { data, panel } = this.props; - if (show === ShowContent.DataJSON) { + if (show === ShowContent.PanelData) { return data; } - if (show === ShowContent.DataStructure) { + if (show === ShowContent.DataFrames) { const series = data?.series; if (!series) { return { note: 'Missing Response Data' }; } - return data!.series.map((frame) => { - const { table, fields, ...rest } = frame as any; // remove 'table' from arrow response - return { - ...rest, - fields: frame.fields.map((field) => { - return chain(field).omit('values').omit('state').omit('display').value(); - }), - }; - }); + return data.series.map((frame) => dataFrameToJSON(frame)); } if (this.hasPanelJSON && show === ShowContent.PanelJSON) { diff --git a/public/app/features/manage-dashboards/components/SnapshotListTable.tsx b/public/app/features/manage-dashboards/components/SnapshotListTable.tsx index dd6c0eae769..29c7c38325e 100644 --- a/public/app/features/manage-dashboards/components/SnapshotListTable.tsx +++ b/public/app/features/manage-dashboards/components/SnapshotListTable.tsx @@ -19,7 +19,7 @@ export const SnapshotListTable: FC = () => { const [removeSnapshot, setRemoveSnapshot] = useState(); const currentPath = locationService.getLocation().pathname; const fullUrl = window.location.href; - const baseUrl = fullUrl.substr(0, fullUrl.indexOf(currentPath)); + const baseUrl = fullUrl.substring(0, fullUrl.indexOf(currentPath)); useAsync(async () => { const response = await getSnapshots(); diff --git a/public/app/features/search/hooks/useSearchQuery.ts b/public/app/features/search/hooks/useSearchQuery.ts index 91862b5093e..64ec9e8b075 100644 --- a/public/app/features/search/hooks/useSearchQuery.ts +++ b/public/app/features/search/hooks/useSearchQuery.ts @@ -11,6 +11,7 @@ import { SET_TAGS, TOGGLE_SORT, TOGGLE_STARRED, + DATASOURCE_CHANGE, } from '../reducers/actionTypes'; import { DashboardQuery, SearchLayout } from '../types'; import { hasFilters, parseRouteParams } from '../utils'; @@ -32,6 +33,11 @@ export const useSearchQuery = (defaults: Partial) => { updateLocation({ tag: tags }); }; + const onDatasourceChange = (datasource?: string) => { + dispatch({ type: DATASOURCE_CHANGE, payload: datasource }); + updateLocation({ datasource }); + }; + const onTagAdd = useCallback( (tag: string) => { dispatch({ type: ADD_TAG, payload: tag }); @@ -75,5 +81,6 @@ export const useSearchQuery = (defaults: Partial) => { onTagAdd, onSortChange, onLayoutChange, + onDatasourceChange, }; }; diff --git a/public/app/features/search/page/SearchPage.tsx b/public/app/features/search/page/SearchPage.tsx index c55edaab958..a9e8ced3802 100644 --- a/public/app/features/search/page/SearchPage.tsx +++ b/public/app/features/search/page/SearchPage.tsx @@ -1,6 +1,6 @@ -import React, { useState } from 'react'; +import React from 'react'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { Input, useStyles2, Spinner } from '@grafana/ui'; +import { Input, useStyles2, Spinner, Button } from '@grafana/ui'; import { config } from '@grafana/runtime'; import AutoSizer from 'react-virtualized-auto-sizer'; import { css } from '@emotion/css'; @@ -11,6 +11,7 @@ import { getGrafanaSearcher, QueryFilters } from '../service'; import { Table } from './table/Table'; import { TagFilter, TermCount } from 'app/core/components/TagFilter/TagFilter'; import { getTermCounts } from '../service/backend'; +import { useSearchQuery } from '../hooks/useSearchQuery'; const node: NavModelItem = { id: 'search', @@ -21,15 +22,17 @@ const node: NavModelItem = { export default function SearchPage() { const styles = useStyles2(getStyles); - const [query, setQuery] = useState(''); - const [tags, setTags] = useState([]); + const { query, onQueryChange, onTagFilterChange, onDatasourceChange } = useSearchQuery({}); const results = useAsync(() => { + const { query: searchQuery, tag: tags, datasource } = query; + const filters: QueryFilters = { tags, + datasource, }; - return getGrafanaSearcher().search(query, tags.length ? filters : undefined); - }, [query, tags]); + return getGrafanaSearcher().search(searchQuery, tags.length || datasource ? filters : undefined); + }, [query]); if (!config.featureToggles.panelTitleSearch) { return
    Unsupported
    ; @@ -44,20 +47,44 @@ export default function SearchPage() { return Promise.resolve([]); }; + const onSearchQueryChange = (event: React.ChangeEvent) => { + onQueryChange(event.currentTarget.value); + }; + + const onTagChange = (tags: string[]) => { + onTagFilterChange(tags); + }; + return ( - setQuery(e.currentTarget.value)} autoFocus spellCheck={false} /> +
    {results.loading && } {results.value?.body && (
    -
    +
    + {query.datasource && ( + + )} {({ width }) => { return ( <> -
    +
    ); }} @@ -78,4 +105,11 @@ const getStyles = (theme: GrafanaTheme2) => ({ height: 100%; font-size: 18px; `, + + clearClick: css` + &:hover { + text-decoration: line-through; + } + margin-bottom: 20px; + `, }); diff --git a/public/app/features/search/page/table/Table.tsx b/public/app/features/search/page/table/Table.tsx index e97e8669642..b91e96f67d2 100644 --- a/public/app/features/search/page/table/Table.tsx +++ b/public/app/features/search/page/table/Table.tsx @@ -13,6 +13,9 @@ import { generateColumns } from './columns'; type Props = { data: DataFrame; width: number; + tags: string[]; + onTagFilterChange: (tags: string[]) => void; + onDatasourceChange: (datasource?: string) => void; }; export type TableColumn = Column & { @@ -34,7 +37,7 @@ export interface FieldAccess { datasource: DataSourceRef[]; } -export const Table = ({ data, width }: Props) => { +export const Table = ({ data, width, tags, onTagFilterChange, onDatasourceChange }: Props) => { const styles = useStyles2(getStyles); const tableStyles = useStyles2(getTableStyles); @@ -52,8 +55,8 @@ export const Table = ({ data, width }: Props) => { const access = useMemo(() => new DataFrameView(data), [data]); const memoizedColumns = useMemo(() => { const isDashboardList = data.meta?.type === DataFrameType.DirectoryListing; - return generateColumns(access, isDashboardList, width, styles); - }, [data.meta?.type, access, width, styles]); + return generateColumns(access, isDashboardList, width, styles, tags, onTagFilterChange, onDatasourceChange); + }, [data.meta?.type, access, width, styles, tags, onTagFilterChange, onDatasourceChange]); const options: TableOptions<{}> = useMemo( () => ({ @@ -75,7 +78,11 @@ export const Table = ({ data, width }: Props) => { return (
    {row.cells.map((cell: Cell, index: number) => { - if (cell.column.id === 'column-checkbox') { + if ( + cell.column.id === 'column-checkbox' || + cell.column.id === 'column-tags' || + cell.column.id === 'column-datasource' + ) { return (
    { `, cellWrapper: css` display: flex; - pointer-events: none; `, headerCell: css` padding-top: 2px; @@ -218,6 +224,7 @@ const getStyles = (theme: GrafanaTheme2) => { `, tagList: css` justify-content: flex-start; + pointer-events: auto; `, }; }; diff --git a/public/app/features/search/page/table/columns.tsx b/public/app/features/search/page/table/columns.tsx index e42780d0b28..a88634fdf91 100644 --- a/public/app/features/search/page/table/columns.tsx +++ b/public/app/features/search/page/table/columns.tsx @@ -12,7 +12,10 @@ export const generateColumns = ( data: DataFrameView, isDashboardList: boolean, availableWidth: number, - styles: { [key: string]: string } + styles: { [key: string]: string }, + tags: string[], + onTagFilterChange: (tags: string[]) => void, + onDatasourceChange: (datasource?: string) => void ): TableColumn[] => { const columns: TableColumn[] = []; const urlField = data.fields.url!; @@ -89,7 +92,7 @@ export const generateColumns = ( // Show datasources if we have any if (access.datasource && hasFieldValue(access.datasource)) { width = DATASOURCE_COLUMN_WIDTH; - columns.push(makeDataSourceColumn(access.datasource, width, styles.typeIcon)); + columns.push(makeDataSourceColumn(access.datasource, width, styles.typeIcon, onDatasourceChange)); availableWidth -= width; } @@ -143,7 +146,7 @@ export const generateColumns = ( // Show tags if we have any if (access.tags && hasFieldValue(access.tags)) { width = Math.max(availableWidth, 250); - columns.push(makeTagsColumn(access.tags, width, styles.tagList)); + columns.push(makeTagsColumn(access.tags, width, styles.tagList, tags, onTagFilterChange)); } return columns; @@ -169,7 +172,12 @@ function getIconForKind(v: string): IconName { return 'question-circle'; } -function makeDataSourceColumn(field: Field, width: number, iconClass: string): TableColumn { +function makeDataSourceColumn( + field: Field, + width: number, + iconClass: string, + onDatasourceChange: (datasource?: string) => void +): TableColumn { return { Cell: DefaultCell, id: `column-datasource`, @@ -186,10 +194,18 @@ function makeDataSourceColumn(field: Field, width: number, icon const icon = settings?.meta?.info?.logos?.small; if (icon) { return ( - + { + e.stopPropagation(); + e.preventDefault(); + onDatasourceChange(settings.uid); + }} + > {settings.name} - + ); } return {v.type}; @@ -260,7 +276,19 @@ function makeTypeColumn( }; } -function makeTagsColumn(field: Field, width: number, tagListClass: string): TableColumn { +function makeTagsColumn( + field: Field, + width: number, + tagListClass: string, + currentTagFilter: string[], + onTagFilterChange: (tags: string[]) => void +): TableColumn { + const updateTagFilter = (tag: string) => { + if (!currentTagFilter.includes(tag)) { + onTagFilterChange([...currentTagFilter, tag]); + } + }; + return { Cell: DefaultCell, id: `column-tags`, @@ -269,7 +297,7 @@ function makeTagsColumn(field: Field, width: number, tagListClass: str accessor: (row: any, i: number) => { const tags = field.values.get(i); if (tags) { - return alert('CLICKED TAG: ' + v)} />; + return ; } return null; }, diff --git a/public/app/features/search/reducers/actionTypes.ts b/public/app/features/search/reducers/actionTypes.ts index 3e2d92fa35a..c843c23f73a 100644 --- a/public/app/features/search/reducers/actionTypes.ts +++ b/public/app/features/search/reducers/actionTypes.ts @@ -16,6 +16,7 @@ export const DELETE_ITEMS = 'DELETE_ITEMS'; export const TOGGLE_STARRED = 'TOGGLE_STARRED'; export const REMOVE_STARRED = 'REMOVE_STARRED'; export const QUERY_CHANGE = 'QUERY_CHANGE'; +export const DATASOURCE_CHANGE = 'DATASOURCE_CHANGE'; export const REMOVE_TAG = 'REMOVE_TAG'; export const CLEAR_FILTERS = 'CLEAR_FILTERS'; export const SET_TAGS = 'SET_TAGS'; diff --git a/public/app/features/search/reducers/searchQueryReducer.ts b/public/app/features/search/reducers/searchQueryReducer.ts index f2eeb2767d6..6ba096ae266 100644 --- a/public/app/features/search/reducers/searchQueryReducer.ts +++ b/public/app/features/search/reducers/searchQueryReducer.ts @@ -7,6 +7,7 @@ import { REMOVE_STARRED, REMOVE_TAG, SET_TAGS, + DATASOURCE_CHANGE, TOGGLE_SORT, TOGGLE_STARRED, } from './actionTypes'; @@ -43,6 +44,8 @@ export const queryReducer = (state: DashboardQuery, action: SearchAction) => { const tag = action.payload; return tag && !state.tag.includes(tag) ? { ...state, tag: [...state.tag, tag] } : state; } + case DATASOURCE_CHANGE: + return { ...state, datasource: action.payload }; case TOGGLE_STARRED: return { ...state, starred: action.payload }; case REMOVE_STARRED: diff --git a/public/app/features/search/service/backend.ts b/public/app/features/search/service/backend.ts index e6137f3de3f..34e4222f350 100644 --- a/public/app/features/search/service/backend.ts +++ b/public/app/features/search/service/backend.ts @@ -154,23 +154,40 @@ export function filterFrame(frame: DataFrame, filter?: QueryFilters): DataFrame const view = new DataFrameView(frame); const keep: number[] = []; + const ds = filter.datasource ? view.fields.datasource : undefined; + const tags = filter.tags?.length ? view.fields.tags : undefined; + let ok = true; for (let i = 0; i < view.length; i++) { ok = true; - const row = view.get(i); - if (filter.tags) { - const tags = row.tags; - if (!tags) { + + if (tags) { + const v = tags.values.get(i); + if (!v) { ok = false; - continue; - } - for (const t of filter.tags) { - if (!tags.includes(t)) { - ok = false; - break; + } else { + for (const t of filter.tags!) { + if (!v.includes(t)) { + ok = false; + break; + } } } } + + if (ok && ds && filter.datasource) { + ok = false; + const v = ds.values.get(i); + if (v) { + for (const d of v) { + if (d.uid === filter.datasource) { + ok = true; + break; + } + } + } + } + if (ok) { keep.push(i); } diff --git a/public/app/features/search/service/minisearcher.ts b/public/app/features/search/service/minisearcher.ts index 432bf702e58..68db6a10b72 100644 --- a/public/app/features/search/service/minisearcher.ts +++ b/public/app/features/search/service/minisearcher.ts @@ -243,7 +243,22 @@ function shouldKeep(filter: QueryFilters, doc: InputDoc, index: number): boolean } } } - return true; + + let keep = true; + // Any is OK + if (filter.datasource) { + keep = false; + const dss = doc.datasource?.get(index); + if (dss) { + for (const ds of dss) { + if (ds.uid === filter.datasource) { + keep = true; + break; + } + } + } + } + return keep; } function getInputDoc(kind: SearchResultKind, frame: DataFrame): InputDoc { diff --git a/public/app/features/search/types.ts b/public/app/features/search/types.ts index d553c88a098..d7bcebd32da 100644 --- a/public/app/features/search/types.ts +++ b/public/app/features/search/types.ts @@ -68,6 +68,7 @@ export interface DashboardQuery { skipRecent: boolean; skipStarred: boolean; folderIds: number[]; + datasource?: string; sort: SelectableValue | null; // Save sorting data between layouts prevSort: SelectableValue | null; diff --git a/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx b/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx index 56a4d35b331..2e58ee1faf4 100644 --- a/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx @@ -31,7 +31,7 @@ const ServiceAccountCreatePage: React.FC = ({ nav return ( -

    Add new service account

    +

    Create service account

    {({ register, errors }) => { return ( @@ -44,7 +44,7 @@ const ServiceAccountCreatePage: React.FC = ({ nav > - + ); }} diff --git a/public/app/features/serviceaccounts/ServiceAccountPage.tsx b/public/app/features/serviceaccounts/ServiceAccountPage.tsx index 918c54102e0..94ef57e3f5c 100644 --- a/public/app/features/serviceaccounts/ServiceAccountPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountPage.tsx @@ -16,7 +16,7 @@ import { } from './state/actions'; import { ServiceAccountTokensTable } from './ServiceAccountTokensTable'; import { getTimeZone, NavModel } from '@grafana/data'; -import { Button, VerticalGroup } from '@grafana/ui'; +import { Button } from '@grafana/ui'; import { CreateTokenModal } from './CreateTokenModal'; import { contextSrv } from 'app/core/core'; @@ -77,7 +77,7 @@ const ServiceAccountPageUnconnected = ({ const serviceAccountId = parseInt(match.params.id, 10); loadServiceAccount(serviceAccountId); loadServiceAccountTokens(serviceAccountId); - if (contextSrv.accessControlEnabled()) { + if (contextSrv.licensedAccessControlEnabled()) { fetchACOptions(); } }, [match, loadServiceAccount, loadServiceAccountTokens, fetchACOptions]); @@ -110,13 +110,15 @@ const ServiceAccountPageUnconnected = ({ /> )} - +
    +

    + Tokens +

    -

    Tokens

    - {tokens && ( - - )} - +
    + {tokens && ( + + )}
    diff --git a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx index 98df4a391a9..2c94e38b171 100644 --- a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx @@ -87,7 +87,7 @@ export function ServiceAccountProfile({ builtInRoles={builtInRoles} roleOptions={roleOptions} /> - + {/* */} void; + roleOptions: Role[]; + builtInRoles: Record; + onSetToRemove: (serviceAccount: ServiceAccountDTO) => void; +}; + +const getServiceAccountsAriaLabel = (name: string) => { + return `Edit service account's ${name} details`; +}; +const getServiceAccountsEnabledStatus = (disabled: boolean) => { + return disabled ? 'Disabled' : 'Enabled'; +}; + +const ServiceAccountListItem = memo( + ({ serviceAccount, onRoleChange, roleOptions, builtInRoles, onSetToRemove }: ServiceAccountListItemProps) => { + const editUrl = `org/serviceaccounts/${serviceAccount.id}`; + const styles = useStyles2(getStyles); + const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount); + const rolePickerDisabled = !canUpdateRole; + + return ( +
    + + + + + + + {contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsDelete, serviceAccount) && ( + + )} + + ); + } +); +ServiceAccountListItem.displayName = 'ServiceAccountListItem'; + +export default ServiceAccountListItem; diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx index 14dbcca88e9..3d597ed8ed2 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx @@ -1,10 +1,10 @@ -import React, { memo, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { Button, ConfirmModal, FilterInput, Icon, LinkButton, RadioButtonGroup, useStyles2 } from '@grafana/ui'; +import { ConfirmModal, FilterInput, LinkButton, RadioButtonGroup, useStyles2 } from '@grafana/ui'; import { css, cx } from '@emotion/css'; import Page from 'app/core/components/Page/Page'; -import { StoreState, ServiceAccountDTO, AccessControlAction, Role } from 'app/types'; +import { StoreState, ServiceAccountDTO, AccessControlAction } from 'app/types'; import { changeFilter, changeQuery, @@ -18,10 +18,9 @@ import { getNavModel } from 'app/core/selectors/navModel'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { GrafanaTheme2, OrgRole } from '@grafana/data'; import { contextSrv } from 'app/core/core'; -import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; -import { OrgRolePicker } from '../admin/OrgRolePicker'; import pluralize from 'pluralize'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; +import ServiceAccountListItem from './ServiceAccountsListItem'; interface OwnProps {} @@ -67,7 +66,7 @@ const ServiceAccountsListPage = ({ useEffect(() => { fetchServiceAccounts(); - if (contextSrv.accessControlEnabled()) { + if (contextSrv.licensedAccessControlEnabled()) { fetchACOptions(); } }, [fetchServiceAccounts, fetchACOptions]); @@ -79,7 +78,6 @@ const ServiceAccountsListPage = ({ return ( -

    Service accounts

    f.name === 'expiredTokens')?.value} className={styles.filter} /> + {serviceAccounts.length !== 0 && contextSrv.hasPermission(AccessControlAction.ServiceAccountsCreate) && ( + + Add service account + + )}
    {isLoading && } {!isLoading && serviceAccounts.length === 0 && ( @@ -104,7 +107,7 @@ const ServiceAccountsListPage = ({ title="You haven't created any service accounts yet." buttonIcon="key-skeleton-alt" buttonLink="org/serviceaccounts/create" - buttonTitle=" New service account" + buttonTitle="Add service account" buttonDisabled={!contextSrv.hasPermission(AccessControlAction.ServiceAccountsCreate)} proTip="Remember, you can provide specific permissions for API access to other applications." proTipLink="" @@ -115,11 +118,6 @@ const ServiceAccountsListPage = ({ )} {!isLoading && serviceAccounts.length !== 0 && ( <> - {contextSrv.hasPermission(AccessControlAction.ServiceAccountsCreate) && ( - - New service account - - )}
    + + {`Avatar + + + + {serviceAccount.name} + + + + {serviceAccount.login} + + + {contextSrv.licensedAccessControlEnabled() ? ( + onRoleChange(newRole, serviceAccount)} + roleOptions={roleOptions} + builtInRoles={builtInRoles} + disabled={rolePickerDisabled} + /> + ) : ( + onRoleChange(newRole, serviceAccount)} + /> + )} + + + {getServiceAccountsEnabledStatus(serviceAccount.isDisabled)} + + + + + + +   + {serviceAccount.tokens} + + +
    @@ -179,122 +177,7 @@ const ServiceAccountsListPage = ({ ); }; -type ServiceAccountListItemProps = { - serviceAccount: ServiceAccountDTO; - onRoleChange: (role: OrgRole, serviceAccount: ServiceAccountDTO) => void; - roleOptions: Role[]; - builtInRoles: Record; - onSetToRemove: (serviceAccount: ServiceAccountDTO) => void; -}; - -const getServiceAccountsAriaLabel = (name: string) => { - return `Edit service account's ${name} details`; -}; -const getServiceAccountsEnabledStatus = (disabled: boolean) => { - return disabled ? 'Disabled' : 'Enabled'; -}; - -const ServiceAccountListItem = memo( - ({ serviceAccount, onRoleChange, roleOptions, builtInRoles, onSetToRemove }: ServiceAccountListItemProps) => { - const editUrl = `org/serviceaccounts/${serviceAccount.id}`; - const styles = useStyles2(getStyles); - const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount); - const rolePickerDisabled = !canUpdateRole; - - return ( - - - - - - - - {contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsDelete, serviceAccount) && ( - - )} - - ); - } -); -ServiceAccountListItem.displayName = 'ServiceAccountListItem'; - -const getStyles = (theme: GrafanaTheme2) => { +export const getStyles = (theme: GrafanaTheme2) => { return { table: css` margin-top: ${theme.spacing(3)}; diff --git a/public/app/features/serviceaccounts/state/actions.ts b/public/app/features/serviceaccounts/state/actions.ts index c0a59cf2f54..0e1a6d750c7 100644 --- a/public/app/features/serviceaccounts/state/actions.ts +++ b/public/app/features/serviceaccounts/state/actions.ts @@ -155,7 +155,7 @@ export function changePage(page: number): ThunkResult { } export function deleteServiceAccount(serviceAccountId: number): ThunkResult { - return async (dispatch) => { + return async () => { await getBackendSrv().delete(`${BASE_URL}/${serviceAccountId}`); locationService.push('/org/serviceaccounts'); }; diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.ts b/public/app/features/transformers/calculateHeatmap/heatmap.ts index 3e01d84e70a..bac9bda5086 100644 --- a/public/app/features/transformers/calculateHeatmap/heatmap.ts +++ b/public/app/features/transformers/calculateHeatmap/heatmap.ts @@ -38,11 +38,12 @@ export const heatmapTransformer: SynchronousDataTransformerInfo { describe('containsVariable', () => { it.each` - value | expected - ${''} | ${false} - ${'$var'} | ${true} - ${{ thing1: '${var}' }} | ${true} - ${{ thing1: ['1', '${var}'] }} | ${true} - ${{ thing1: { thing2: '${var}' } }} | ${true} + value | expected + ${''} | ${false} + ${'$var'} | ${true} + ${{ thing1: '${var}' }} | ${true} + ${{ thing1: '${var:fmt}' }} | ${true} + ${{ thing1: ['1', '${var}'] }} | ${true} + ${{ thing1: ['1', '[[var]]'] }} | ${true} + ${{ thing1: ['1', '[[var:fmt]]'] }} | ${true} + ${{ thing1: { thing2: '${var}' } }} | ${true} + ${{ params: [['param', '$var']] }} | ${true} + ${{ params: [['param', '${var}']] }} | ${true} `('when called with value:$value then result should be:$expected', ({ value, expected }) => { expect(containsVariable(value, 'var')).toEqual(expected); }); diff --git a/public/app/features/variables/utils.ts b/public/app/features/variables/utils.ts index d6964a6aa67..dfe00845608 100644 --- a/public/app/features/variables/utils.ts +++ b/public/app/features/variables/utils.ts @@ -15,10 +15,10 @@ import { KeyedVariableIdentifier, VariableIdentifier, VariablePayload } from './ /* * This regex matches 3 types of variable reference with an optional format specifier * \$(\w+) $var1 - * \[\[([\s\S]+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] + * \[\[(\w+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] * \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3} */ -export const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g; +export const variableRegex = /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g; // Helper function since lastIndex is not reset export const variableRegexExec = (variableString: string) => { diff --git a/public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx b/public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx index 172c785c5d2..5572214d07d 100644 --- a/public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx @@ -5,11 +5,11 @@ import { ExploreId } from '../../../../types'; import { DescribeLogGroupsRequest } from '../types'; import { SelectableValue } from '@grafana/data'; // eslint-disable-next-line lodash/import-scope -import _, { Cancelable } from 'lodash'; +import _, { DebouncedFunc } from 'lodash'; jest .spyOn(_, 'debounce') - .mockImplementation((func: (...args: any) => any, wait?: number) => func as typeof func & Cancelable); + .mockImplementation((func: (...args: any) => any, wait?: number) => func as DebouncedFunc); describe('CloudWatchLogsQueryField', () => { it('updates upstream query log groups on region change', async () => { diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx new file mode 100644 index 00000000000..62d5c7cc65c --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx @@ -0,0 +1,141 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { select } from 'react-select-event'; +import { VariableQueryType } from '../../types'; +import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource'; +import { VariableQueryEditor, Props } from './VariableQueryEditor'; + +const defaultQuery = { + queryType: VariableQueryType.Regions, + namespace: '', + region: '', + metricName: '', + dimensionKey: '', + dimensionFilters: '', + ec2Filters: '', + instanceID: '', + attributeName: '', + resourceType: '', + tags: '', + refId: '', +}; + +const ds = setupMockedDataSource(); + +ds.datasource.getRegions = jest.fn().mockResolvedValue([ + { label: 'a1', value: 'a1' }, + { label: 'b1', value: 'b1' }, + { label: 'c1', value: 'c1' }, +]); +ds.datasource.getNamespaces = jest.fn().mockResolvedValue([ + { label: 'x2', value: 'x2' }, + { label: 'y2', value: 'y2' }, + { label: 'z2', value: 'z2' }, +]); +ds.datasource.getMetrics = jest.fn().mockResolvedValue([ + { label: 'h3', value: 'h3' }, + { label: 'i3', value: 'i3' }, + { label: 'j3', value: 'j3' }, +]); +ds.datasource.getDimensionKeys = jest.fn().mockImplementation((namespace: string, region: string) => { + if (region === 'a1') { + return Promise.resolve([ + { label: 'q4', value: 'q4' }, + { label: 'r4', value: 'r4' }, + { label: 's4', value: 's4' }, + ]); + } + return Promise.resolve([{ label: 't4', value: 't4' }]); +}); +ds.datasource.getVariables = jest.fn().mockReturnValue([]); + +const defaultProps: Props = { + onChange: jest.fn(), + query: defaultQuery, + datasource: ds.datasource, + onRunQuery: () => {}, +}; + +describe('VariableEditor', () => { + describe('and a new variable is created', () => { + it('should trigger a query using the first query type in the array', async () => { + const props = defaultProps; + props.query = defaultQuery; + render(); + + await waitFor(() => { + const querySelect = screen.queryByRole('combobox', { name: 'Query Type' }); + expect(querySelect).toBeInTheDocument(); + expect(screen.queryByText('Regions')).toBeInTheDocument(); + // Should not render any fields besides Query Type + const regionSelect = screen.queryByRole('combobox', { name: 'Region' }); + expect(regionSelect).not.toBeInTheDocument(); + }); + }); + }); + + describe('and an existing variable is edited', () => { + it('should trigger new query using the saved query type', async () => { + const props = defaultProps; + props.query = { + ...defaultQuery, + queryType: VariableQueryType.Metrics, + namespace: 'z2', + region: 'a1', + }; + render(); + + await waitFor(() => { + const querySelect = screen.queryByRole('combobox', { name: 'Query Type' }); + expect(querySelect).toBeInTheDocument(); + expect(screen.queryByText('Metrics')).toBeInTheDocument(); + const regionSelect = screen.queryByRole('combobox', { name: 'Region' }); + expect(regionSelect).toBeInTheDocument(); + expect(screen.queryByText('a1')).toBeInTheDocument(); + const namespaceSelect = screen.queryByRole('combobox', { name: 'Namespace' }); + expect(namespaceSelect).toBeInTheDocument(); + expect(screen.queryByText('z2')).toBeInTheDocument(); + // Should only render Query Type, Region, and Namespace selectors + const metricSelect = screen.queryByRole('combobox', { name: 'Metric' }); + expect(metricSelect).not.toBeInTheDocument(); + }); + }); + }); + describe('and a different region is selected', () => { + it('should clear invalid fields', async () => { + const props = defaultProps; + props.query = { + ...defaultQuery, + queryType: VariableQueryType.DimensionValues, + namespace: 'z2', + region: 'a1', + metricName: 'i3', + dimensionKey: 's4', + }; + render(); + + const querySelect = screen.queryByLabelText('Query Type'); + expect(querySelect).toBeInTheDocument(); + expect(screen.queryByText('Dimension Values')).toBeInTheDocument(); + const regionSelect = screen.getByRole('combobox', { name: 'Region' }); + regionSelect.click(); + await select(regionSelect, 'b1', { + container: document.body, + }); + + expect(ds.datasource.getMetrics).toHaveBeenCalledWith('z2', 'b1'); + expect(ds.datasource.getDimensionKeys).toHaveBeenCalledWith('z2', 'b1'); + expect(props.onChange).toHaveBeenCalledWith({ + ...defaultQuery, + refId: 'CloudWatchVariableQueryEditor-VariableQuery', + queryType: VariableQueryType.DimensionValues, + namespace: 'z2', + region: 'b1', + // metricName i3 exists in the new region and should not be removed + metricName: 'i3', + // dimensionKey s4 does not exist in the new region and should be removed + dimensionKey: '', + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx new file mode 100644 index 00000000000..6a02346cfb7 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx @@ -0,0 +1,181 @@ +import React from 'react'; + +import { QueryEditorProps, SelectableValue } from '@grafana/data'; +import { VariableTextField } from './VariableTextField'; +import { CloudWatchDatasource } from '../../datasource'; +import { useDimensionKeys, useMetrics, useNamespaces, useRegions } from '../../hooks'; +import { CloudWatchJsonData, CloudWatchQuery, VariableQuery, VariableQueryType } from '../../types'; +import { migrateVariableQuery } from '../../migrations'; +import { VariableQueryField } from './VariableQueryField'; + +export type Props = QueryEditorProps; + +const queryTypes: Array<{ value: string; label: string }> = [ + { value: VariableQueryType.Regions, label: 'Regions' }, + { value: VariableQueryType.Namespaces, label: 'Namespaces' }, + { value: VariableQueryType.Metrics, label: 'Metrics' }, + { value: VariableQueryType.DimensionKeys, label: 'Dimension Keys' }, + { value: VariableQueryType.DimensionValues, label: 'Dimension Values' }, + { value: VariableQueryType.EBSVolumeIDs, label: 'EBS Volume IDs' }, + { value: VariableQueryType.EC2InstanceAttributes, label: 'EC2 Instance Attributes' }, + { value: VariableQueryType.ResourceArns, label: 'Resource ARNs' }, + { value: VariableQueryType.Statistics, label: 'Statistics' }, +]; + +export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { + const parsedQuery = migrateVariableQuery(query); + + const { region, namespace, metricName, dimensionKey } = parsedQuery; + const [regions, regionIsLoading] = useRegions(datasource); + const namespaces = useNamespaces(datasource); + const metrics = useMetrics(datasource, region, namespace); + const dimensionKeys = useDimensionKeys(datasource, region, namespace, metricName); + + const onRegionChange = async (region: string) => { + const validatedQuery = await sanitizeQuery({ + ...parsedQuery, + region, + }); + onQueryChange(validatedQuery); + }; + + const onNamespaceChange = async (namespace: string) => { + const validatedQuery = await sanitizeQuery({ + ...parsedQuery, + namespace, + }); + onQueryChange(validatedQuery); + }; + + const onQueryChange = (newQuery: VariableQuery) => { + onChange({ ...newQuery, refId: 'CloudWatchVariableQueryEditor-VariableQuery' }); + }; + + // Reset dimensionValue parameters if namespace or region change + const sanitizeQuery = async (query: VariableQuery) => { + let { metricName, dimensionKey, dimensionFilters, namespace, region } = query; + if (metricName) { + await datasource.getMetrics(namespace, region).then((result: Array>) => { + if (!result.find((metric) => metric.value === metricName)) { + metricName = ''; + dimensionFilters = ''; + } + }); + } + if (dimensionKey) { + await datasource.getDimensionKeys(namespace, region).then((result: Array>) => { + if (!result.find((key) => key.value === dimensionKey)) { + dimensionKey = ''; + dimensionFilters = ''; + } + }); + } + return { ...query, metricName, dimensionKey, dimensionFilters }; + }; + + const hasRegionField = [ + VariableQueryType.Metrics, + VariableQueryType.DimensionKeys, + VariableQueryType.DimensionValues, + VariableQueryType.EBSVolumeIDs, + VariableQueryType.EC2InstanceAttributes, + VariableQueryType.ResourceArns, + ].includes(parsedQuery.queryType); + const hasNamespaceField = [ + VariableQueryType.Metrics, + VariableQueryType.DimensionKeys, + VariableQueryType.DimensionValues, + ].includes(parsedQuery.queryType); + + return ( + <> + onQueryChange({ ...parsedQuery, queryType: value })} + label="Query Type" + /> + {hasRegionField && ( + onRegionChange(value)} + label="Region" + isLoading={regionIsLoading} + /> + )} + {hasNamespaceField && ( + onNamespaceChange(value)} + label="Namespace" + /> + )} + {parsedQuery.queryType === VariableQueryType.DimensionValues && ( + <> + onQueryChange({ ...parsedQuery, metricName: value })} + label="Metric" + /> + onQueryChange({ ...parsedQuery, dimensionKey: value })} + label="Dimension Key" + /> + onQueryChange({ ...parsedQuery, dimensionFilters: value })} + label="Filters" + /> + + )} + {parsedQuery.queryType === VariableQueryType.EBSVolumeIDs && ( + onQueryChange({ ...parsedQuery, instanceID: value })} + label="Instance ID" + /> + )} + {parsedQuery.queryType === VariableQueryType.EC2InstanceAttributes && ( + <> + onQueryChange({ ...parsedQuery, attributeName: value })} + label="Attribute Name" + /> + onQueryChange({ ...parsedQuery, ec2Filters: value })} + label="Filters" + /> + + )} + {parsedQuery.queryType === VariableQueryType.ResourceArns && ( + <> + onQueryChange({ ...parsedQuery, resourceType: value })} + label="Resource Type" + /> + onQueryChange({ ...parsedQuery, tags: value })} + label="Tags" + /> + + )} + + ); +}; diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryField.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryField.tsx new file mode 100644 index 00000000000..cb1fa8e82dc --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryField.tsx @@ -0,0 +1,41 @@ +import { SelectableValue } from '@grafana/data'; +import { InlineField, Select } from '@grafana/ui'; +import React from 'react'; +import { VariableQueryType } from '../../types'; + +const LABEL_WIDTH = 20; + +interface VariableQueryFieldProps { + onChange: (value: T) => void; + options: SelectableValue[]; + value: T | null; + label: string; + allowCustomValue?: boolean; + isLoading?: boolean; + inputId?: string; +} + +export const VariableQueryField = ({ + label, + onChange, + value, + options, + allowCustomValue = false, + isLoading = false, +}: VariableQueryFieldProps) => { + return ( + + setLocalValue(e.currentTarget.value)} + onBlur={() => onBlur(localValue)} + width={TEXT_WIDTH} + /> + + ); +}; diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/index.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/index.tsx new file mode 100644 index 00000000000..423ec3dd3f6 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/index.tsx @@ -0,0 +1 @@ +export { VariableQueryEditor } from './VariableQueryEditor'; diff --git a/public/app/plugins/datasource/cloudwatch/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/datasource.test.ts index 00eb29f9bb7..5a06bce1476 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.test.ts @@ -10,8 +10,13 @@ import { namespaceVariable, setupMockedDataSource, } from './__mocks__/CloudWatchDataSource'; -import { CloudWatchDatasource } from './datasource'; -import { CloudWatchLogsQueryStatus, CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType } from './types'; +import { + CloudWatchLogsQuery, + CloudWatchLogsQueryStatus, + CloudWatchMetricsQuery, + MetricEditorMode, + MetricQueryType, +} from './types'; describe('datasource', () => { describe('query', () => { @@ -144,111 +149,124 @@ describe('datasource', () => { }); }); - describe('filterMetricQuery', () => { - let baseQuery: CloudWatchMetricsQuery; - let datasource: CloudWatchDatasource; - - beforeEach(() => { - datasource = setupMockedDataSource().datasource; - baseQuery = { + describe('filterQuery', () => { + const datasource = setupMockedDataSource().datasource; + describe('CloudWatchLogsQuery', () => { + const baseQuery: CloudWatchLogsQuery = { + queryMode: 'Logs', id: '', - region: 'us-east-2', - namespace: '', - period: '', - alias: '', - metricName: '', - dimensions: {}, - matchExact: true, - statistic: '', - expression: '', + region: '', refId: '', + logGroupNames: ['foo', 'bar'], }; + it('should return false if empty logGroupNames', () => { + expect(datasource.filterQuery({ ...baseQuery, logGroupNames: undefined })).toBeFalsy(); + }); + it('should return true if has logGroupNames', () => { + expect(datasource.filterQuery(baseQuery)).toBeTruthy(); + }); }); - - it('should error if invalid mode', async () => { - expect(() => datasource.filterMetricQuery(baseQuery)).toThrowError('invalid metric editor mode'); - }); - - describe('metric search queries', () => { + describe('CloudWatchMetricsQuery', () => { + let baseQuery: CloudWatchMetricsQuery; beforeEach(() => { - datasource = setupMockedDataSource().datasource; baseQuery = { - ...baseQuery, - namespace: 'AWS/EC2', - metricName: 'CPUUtilization', - statistic: 'Average', - metricQueryType: MetricQueryType.Search, - metricEditorMode: MetricEditorMode.Builder, + id: '', + region: 'us-east-2', + namespace: '', + period: '', + alias: '', + metricName: '', + dimensions: {}, + matchExact: true, + statistic: '', + expression: '', + refId: '', }; }); - it('should not allow builder queries that dont have namespace, metric or statistic', async () => { - expect(datasource.filterMetricQuery({ ...baseQuery, statistic: undefined })).toBeFalsy(); - expect(datasource.filterMetricQuery({ ...baseQuery, metricName: undefined })).toBeFalsy(); - expect(datasource.filterMetricQuery({ ...baseQuery, namespace: '' })).toBeFalsy(); + it('should error if invalid mode', async () => { + expect(() => datasource.filterQuery(baseQuery)).toThrowError('invalid metric editor mode'); }); - it('should allow builder queries that have namespace, metric or statistic', async () => { - expect(datasource.filterMetricQuery(baseQuery)).toBeTruthy(); + describe('metric search queries', () => { + beforeEach(() => { + baseQuery = { + ...baseQuery, + namespace: 'AWS/EC2', + metricName: 'CPUUtilization', + statistic: 'Average', + metricQueryType: MetricQueryType.Search, + metricEditorMode: MetricEditorMode.Builder, + }; + }); + + it('should not allow builder queries that dont have namespace, metric or statistic', async () => { + expect(datasource.filterQuery({ ...baseQuery, statistic: undefined })).toBeFalsy(); + expect(datasource.filterQuery({ ...baseQuery, metricName: undefined })).toBeFalsy(); + expect(datasource.filterQuery({ ...baseQuery, namespace: '' })).toBeFalsy(); + }); + + it('should allow builder queries that have namespace, metric or statistic', async () => { + expect(datasource.filterQuery(baseQuery)).toBeTruthy(); + }); + + it('should not allow code queries that dont have an expression', async () => { + expect( + datasource.filterQuery({ ...baseQuery, expression: undefined, metricEditorMode: MetricEditorMode.Code }) + ).toBeFalsy(); + }); + + it('should allow code queries that have an expression', async () => { + expect( + datasource.filterQuery({ ...baseQuery, expression: 'x * 2', metricEditorMode: MetricEditorMode.Code }) + ).toBeTruthy(); + }); }); - it('should not allow code queries that dont have an expression', async () => { - expect( - datasource.filterMetricQuery({ ...baseQuery, expression: undefined, metricEditorMode: MetricEditorMode.Code }) - ).toBeFalsy(); + describe('metric search expression queries', () => { + beforeEach(() => { + baseQuery = { + ...baseQuery, + metricQueryType: MetricQueryType.Search, + metricEditorMode: MetricEditorMode.Code, + }; + }); + + it('should not allow queries that dont have an expression', async () => { + const valid = datasource.filterQuery(baseQuery); + expect(valid).toBeFalsy(); + }); + + it('should allow queries that have an expression', async () => { + baseQuery.expression = 'SUM([a,x])'; + const valid = datasource.filterQuery(baseQuery); + expect(valid).toBeTruthy(); + }); }); - it('should allow code queries that have an expression', async () => { - expect( - datasource.filterMetricQuery({ ...baseQuery, expression: 'x * 2', metricEditorMode: MetricEditorMode.Code }) - ).toBeTruthy(); - }); - }); + describe('metric query queries', () => { + beforeEach(() => { + baseQuery = { + ...baseQuery, + metricQueryType: MetricQueryType.Query, + metricEditorMode: MetricEditorMode.Code, + }; + }); - describe('metric search expression queries', () => { - beforeEach(() => { - datasource = setupMockedDataSource().datasource; - baseQuery = { - ...baseQuery, - metricQueryType: MetricQueryType.Search, - metricEditorMode: MetricEditorMode.Code, - }; - }); + it('should not allow queries that dont have a sql expresssion', async () => { + const valid = datasource.filterQuery(baseQuery); + expect(valid).toBeFalsy(); + }); - it('should not allow queries that dont have an expresssion', async () => { - const valid = datasource.filterMetricQuery(baseQuery); - expect(valid).toBeFalsy(); - }); - - it('should allow queries that have an expresssion', async () => { - baseQuery.expression = 'SUM([a,x])'; - const valid = datasource.filterMetricQuery(baseQuery); - expect(valid).toBeTruthy(); - }); - }); - - describe('metric query queries', () => { - beforeEach(() => { - datasource = setupMockedDataSource().datasource; - baseQuery = { - ...baseQuery, - metricQueryType: MetricQueryType.Query, - metricEditorMode: MetricEditorMode.Code, - }; - }); - - it('should not allow queries that dont have a sql expresssion', async () => { - const valid = datasource.filterMetricQuery(baseQuery); - expect(valid).toBeFalsy(); - }); - - it('should allow queries that have a sql expresssion', async () => { - baseQuery.sqlExpression = 'select SUM(CPUUtilization) from "AWS/EC2"'; - const valid = datasource.filterMetricQuery(baseQuery); - expect(valid).toBeTruthy(); + it('should allow queries that have a sql expresssion', async () => { + baseQuery.sqlExpression = 'select SUM(CPUUtilization) from "AWS/EC2"'; + const valid = datasource.filterQuery(baseQuery); + expect(valid).toBeTruthy(); + }); }); }); }); + describe('resource requests', () => { it('should map resource response to metric response', async () => { const datasource = setupMockedDataSource().datasource; diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 734fa4c93fe..97f59886a0f 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -59,6 +59,7 @@ import { import { addDataLinksToLogsResponse } from './utils/datalinks'; import { runWithRetry } from './utils/logsRetry'; import { increasingInterval } from './utils/rxjs/increasingInterval'; +import { CloudWatchVariableSupport } from './variables'; const DS_QUERY_ENDPOINT = '/api/ds/query'; @@ -122,6 +123,7 @@ export class CloudWatchDatasource this.logsTimeout = instanceSettings.jsonData.logsTimeout || '15m'; this.sqlCompletionItemProvider = new SQLCompletionItemProvider(this, this.templateSrv); this.metricMathCompletionItemProvider = new MetricMathCompletionItemProvider(this, this.templateSrv); + this.variables = new CloudWatchVariableSupport(this); } query(options: DataQueryRequest): Observable { @@ -232,22 +234,15 @@ export class CloudWatchDatasource ); }; - filterMetricQuery({ - region, - metricQueryType, - metricEditorMode, - expression, - metricName, - namespace, - sqlExpression, - statistic, - dimensions, - ...rest - }: CloudWatchMetricsQuery): boolean { + filterQuery(query: CloudWatchQuery): boolean { + if (query.queryMode === 'Logs') { + return !!query.logGroupNames?.length; + } + const { region, metricQueryType, metricEditorMode, expression, metricName, namespace, sqlExpression, statistic } = + query; if (!region) { return false; } - if (metricQueryType === MetricQueryType.Search && metricEditorMode === MetricEditorMode.Builder) { return !!namespace && !!metricName && !!statistic; } else if (metricQueryType === MetricQueryType.Search && metricEditorMode === MetricEditorMode.Code) { @@ -264,27 +259,25 @@ export class CloudWatchDatasource metricQueries: CloudWatchMetricsQuery[], options: DataQueryRequest ): Observable => { - const validMetricsQueries = metricQueries - .filter(this.filterMetricQuery) - .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.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)) { @@ -735,83 +728,6 @@ export class CloudWatchDatasource }); } - async metricFindQuery(query: string) { - let region; - let namespace; - let metricName; - let filterJson; - - const regionQuery = query.match(/^regions\(\)/); - if (regionQuery) { - return this.getRegions(); - } - - const namespaceQuery = query.match(/^namespaces\(\)/); - if (namespaceQuery) { - return this.getNamespaces(); - } - - const metricNameQuery = query.match(/^metrics\(([^\)]+?)(,\s?([^,]+?))?\)/); - if (metricNameQuery) { - namespace = metricNameQuery[1]; - region = metricNameQuery[3]; - return this.getMetrics(namespace, region); - } - - const dimensionKeysQuery = query.match(/^dimension_keys\(([^\)]+?)(,\s?([^,]+?))?\)/); - if (dimensionKeysQuery) { - namespace = dimensionKeysQuery[1]; - region = dimensionKeysQuery[3]; - return this.getDimensionKeys(namespace, region); - } - - const dimensionValuesQuery = query.match( - /^dimension_values\(([^,]+?),\s?([^,]+?),\s?([^,]+?),\s?([^,]+?)(,\s?(.+))?\)/ - ); - if (dimensionValuesQuery) { - region = dimensionValuesQuery[1]; - namespace = dimensionValuesQuery[2]; - metricName = dimensionValuesQuery[3]; - const dimensionKey = dimensionValuesQuery[4]; - filterJson = {}; - if (dimensionValuesQuery[6]) { - filterJson = JSON.parse(this.templateSrv.replace(dimensionValuesQuery[6])); - } - - return this.getDimensionValues(region, namespace, metricName, dimensionKey, filterJson); - } - - const ebsVolumeIdsQuery = query.match(/^ebs_volume_ids\(([^,]+?),\s?([^,]+?)\)/); - if (ebsVolumeIdsQuery) { - region = ebsVolumeIdsQuery[1]; - const instanceId = ebsVolumeIdsQuery[2]; - return this.getEbsVolumeIds(region, instanceId); - } - - const ec2InstanceAttributeQuery = query.match(/^ec2_instance_attribute\(([^,]+?),\s?([^,]+?),\s?(.+?)\)/); - if (ec2InstanceAttributeQuery) { - region = ec2InstanceAttributeQuery[1]; - const targetAttributeName = ec2InstanceAttributeQuery[2]; - filterJson = JSON.parse(this.templateSrv.replace(ec2InstanceAttributeQuery[3])); - return this.getEc2InstanceAttribute(region, targetAttributeName, filterJson); - } - - const resourceARNsQuery = query.match(/^resource_arns\(([^,]+?),\s?([^,]+?),\s?(.+?)\)/); - if (resourceARNsQuery) { - region = resourceARNsQuery[1]; - const resourceType = resourceARNsQuery[2]; - const tagsJSON = JSON.parse(this.templateSrv.replace(resourceARNsQuery[3])); - return this.getResourceARNs(region, resourceType, tagsJSON); - } - - const statsQuery = query.match(/^statistics\(\)/); - if (statsQuery) { - return this.standardStatistics.map((s: string) => ({ value: s, label: s, text: s })); - } - - return Promise.resolve([]); - } - annotationQuery(options: any) { const annotation = options.annotation; const statistic = this.templateSrv.replace(annotation.statistic); @@ -1031,5 +947,5 @@ function withTeardown(observable: Observable, onUnsubscribe: () => v function parseLogGroupName(logIdentifier: string): string { const colonIndex = logIdentifier.lastIndexOf(':'); - return logIdentifier.substr(colonIndex + 1); + return logIdentifier.slice(colonIndex + 1); } diff --git a/public/app/plugins/datasource/cloudwatch/migration.test.ts b/public/app/plugins/datasource/cloudwatch/migration.test.ts index 9ea899a2157..4c56fd06946 100644 --- a/public/app/plugins/datasource/cloudwatch/migration.test.ts +++ b/public/app/plugins/datasource/cloudwatch/migration.test.ts @@ -3,8 +3,15 @@ import { migrateMultipleStatsAnnotationQuery, migrateMultipleStatsMetricsQuery, migrateCloudWatchQuery, + migrateVariableQuery, } from './migrations'; -import { CloudWatchAnnotationQuery, CloudWatchMetricsQuery, MetricQueryType, MetricEditorMode } from './types'; +import { + CloudWatchAnnotationQuery, + CloudWatchMetricsQuery, + MetricQueryType, + MetricEditorMode, + VariableQueryType, +} from './types'; describe('migration', () => { describe('migrateMultipleStatsMetricsQuery', () => { @@ -170,4 +177,51 @@ describe('migration', () => { }); }); }); + describe('migrateVariableQuery', () => { + describe('when metrics query is used', () => { + describe('and region param is left out', () => { + it('should leave an empty region', () => { + const query = migrateVariableQuery('metrics(testNamespace)'); + expect(query.queryType).toBe(VariableQueryType.Metrics); + expect(query.namespace).toBe('testNamespace'); + expect(query.region).toBe(''); + }); + }); + + describe('and region param is defined by user', () => { + it('should use the user defined region', () => { + const query = migrateVariableQuery('metrics(testNamespace2, custom-region)'); + expect(query.queryType).toBe(VariableQueryType.Metrics); + expect(query.namespace).toBe('testNamespace2'); + expect(query.region).toBe('custom-region'); + }); + }); + }); + describe('when dimension_values query is used', () => { + describe('and filter param is left out', () => { + it('should leave an empty filter', () => { + const query = migrateVariableQuery('dimension_values(us-east-1,AWS/RDS,CPUUtilization,DBInstanceIdentifier)'); + expect(query.queryType).toBe(VariableQueryType.DimensionValues); + expect(query.region).toBe('us-east-1'); + expect(query.namespace).toBe('AWS/RDS'); + expect(query.metricName).toBe('CPUUtilization'); + expect(query.dimensionKey).toBe('DBInstanceIdentifier'); + expect(query.dimensionFilters).toBe(''); + }); + }); + describe('and filter param is defined by user', () => { + it('should use the user defined filter', () => { + const query = migrateVariableQuery( + 'dimension_values(us-east-1,AWS/RDS,CPUUtilization,DBInstanceIdentifier,{"InstanceId":"$instance_id"})' + ); + expect(query.queryType).toBe(VariableQueryType.DimensionValues); + expect(query.region).toBe('us-east-1'); + expect(query.namespace).toBe('AWS/RDS'); + expect(query.metricName).toBe('CPUUtilization'); + expect(query.dimensionKey).toBe('DBInstanceIdentifier'); + expect(query.dimensionFilters).toBe('{"InstanceId":"$instance_id"}'); + }); + }); + }); + }); }); diff --git a/public/app/plugins/datasource/cloudwatch/migrations.ts b/public/app/plugins/datasource/cloudwatch/migrations.ts index fea24b5d1d9..8bc3387e28d 100644 --- a/public/app/plugins/datasource/cloudwatch/migrations.ts +++ b/public/app/plugins/datasource/cloudwatch/migrations.ts @@ -1,6 +1,13 @@ import { AnnotationQuery, DataQuery } from '@grafana/data'; import { getNextRefIdChar } from 'app/core/utils/query'; -import { MetricEditorMode, CloudWatchAnnotationQuery, CloudWatchMetricsQuery, MetricQueryType } from './types'; +import { + MetricEditorMode, + CloudWatchAnnotationQuery, + CloudWatchMetricsQuery, + MetricQueryType, + VariableQuery, + VariableQueryType, +} from './types'; // Migrates a metric query that use more than one statistic into multiple queries // E.g query.statistics = ['Max', 'Min'] will be migrated to two queries - query1.statistic = 'Max' and query2.statistic = 'Min' @@ -61,3 +68,94 @@ export function migrateCloudWatchQuery(query: CloudWatchMetricsQuery) { } } } + +export function migrateVariableQuery(rawQuery: string | VariableQuery): VariableQuery { + if (typeof rawQuery !== 'string') { + return rawQuery; + } + const newQuery: VariableQuery = { + refId: 'CloudWatchVariableQueryEditor-VariableQuery', + queryType: VariableQueryType.Regions, + namespace: '', + region: '', + metricName: '', + dimensionKey: '', + dimensionFilters: '', + ec2Filters: '', + instanceID: '', + attributeName: '', + resourceType: '', + tags: '', + }; + if (rawQuery === '') { + return newQuery; + } + + if (rawQuery.match(/^regions\(\)/)) { + return newQuery; + } + if (rawQuery.match(/^namespaces\(\)/)) { + newQuery.queryType = VariableQueryType.Namespaces; + return newQuery; + } + const metricNameQuery = rawQuery.match(/^metrics\(([^\)]+?)(,\s?([^,]+?))?\)/); + if (metricNameQuery) { + newQuery.queryType = VariableQueryType.Metrics; + newQuery.namespace = metricNameQuery[1]; + newQuery.region = metricNameQuery[3] || ''; + return newQuery; + } + const dimensionKeysQuery = rawQuery.match(/^dimension_keys\(([^\)]+?)(,\s?([^,]+?))?\)/); + if (dimensionKeysQuery) { + newQuery.queryType = VariableQueryType.DimensionKeys; + newQuery.namespace = dimensionKeysQuery[1]; + newQuery.region = dimensionKeysQuery[3] || ''; + return newQuery; + } + + const dimensionValuesQuery = rawQuery.match( + /^dimension_values\(([^,]+?),\s?([^,]+?),\s?([^,]+?),\s?([^,]+?)(,\s?(.+))?\)/ + ); + if (dimensionValuesQuery) { + newQuery.queryType = VariableQueryType.DimensionValues; + newQuery.region = dimensionValuesQuery[1]; + newQuery.namespace = dimensionValuesQuery[2]; + newQuery.metricName = dimensionValuesQuery[3]; + newQuery.dimensionKey = dimensionValuesQuery[4]; + newQuery.dimensionFilters = dimensionValuesQuery[6] || ''; + return newQuery; + } + + const ebsVolumeIdsQuery = rawQuery.match(/^ebs_volume_ids\(([^,]+?),\s?([^,]+?)\)/); + if (ebsVolumeIdsQuery) { + newQuery.queryType = VariableQueryType.EBSVolumeIDs; + newQuery.region = ebsVolumeIdsQuery[1]; + newQuery.instanceID = ebsVolumeIdsQuery[2]; + return newQuery; + } + + const ec2InstanceAttributeQuery = rawQuery.match(/^ec2_instance_attribute\(([^,]+?),\s?([^,]+?),\s?(.+?)\)/); + if (ec2InstanceAttributeQuery) { + newQuery.queryType = VariableQueryType.EC2InstanceAttributes; + newQuery.region = ec2InstanceAttributeQuery[1]; + newQuery.attributeName = ec2InstanceAttributeQuery[2]; + newQuery.ec2Filters = ec2InstanceAttributeQuery[3] || ''; + return newQuery; + } + + const resourceARNsQuery = rawQuery.match(/^resource_arns\(([^,]+?),\s?([^,]+?),\s?(.+?)\)/); + if (resourceARNsQuery) { + newQuery.queryType = VariableQueryType.ResourceArns; + newQuery.region = resourceARNsQuery[1]; + newQuery.resourceType = resourceARNsQuery[2]; + newQuery.tags = JSON.parse(resourceARNsQuery[3]) || ''; + return newQuery; + } + + const statsQuery = rawQuery.match(/^statistics\(\)/); + if (statsQuery) { + newQuery.queryType = VariableQueryType.Statistics; + return newQuery; + } + throw new Error('unable to parse old variable query'); +} diff --git a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts index 2db9cbf4f52..6e91e209b2a 100644 --- a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts @@ -506,35 +506,6 @@ describe('CloudWatchDatasource', () => { }); }); }); - describe('when regions query is used', () => { - describe('and region param is left out', () => { - it('should use the default region', async () => { - const { ds, instanceSettings } = getTestContext(); - ds.doMetricResourceRequest = jest.fn().mockResolvedValue([]); - - await ds.metricFindQuery('metrics(testNamespace)'); - - expect(ds.doMetricResourceRequest).toHaveBeenCalledWith('metrics', { - namespace: 'testNamespace', - region: instanceSettings.jsonData.defaultRegion, - }); - }); - }); - - describe('and region param is defined by user', () => { - it('should use the user defined region', async () => { - const { ds } = getTestContext(); - ds.doMetricResourceRequest = jest.fn().mockResolvedValue([]); - - await ds.metricFindQuery('metrics(testNamespace2, custom-region)'); - - expect(ds.doMetricResourceRequest).toHaveBeenCalledWith('metrics', { - namespace: 'testNamespace2', - region: 'custom-region', - }); - }); - }); - }); }); describe('When query region is "default"', () => { diff --git a/public/app/plugins/datasource/cloudwatch/types.ts b/public/app/plugins/datasource/cloudwatch/types.ts index 2401c0c3721..472d400b55b 100644 --- a/public/app/plugins/datasource/cloudwatch/types.ts +++ b/public/app/plugins/datasource/cloudwatch/types.ts @@ -367,3 +367,29 @@ export interface MetricQuery { maxDataPoints?: number; intervalMs?: number; } + +export enum VariableQueryType { + Regions = 'regions', + Namespaces = 'namespaces', + Metrics = 'metrics', + DimensionKeys = 'dimensionKeys', + DimensionValues = 'dimensionValues', + EBSVolumeIDs = 'ebsVolumeIDs', + EC2InstanceAttributes = 'ec2InstanceAttributes', + ResourceArns = 'resourceARNs', + Statistics = 'statistics', +} + +export interface VariableQuery extends DataQuery { + queryType: VariableQueryType; + namespace: string; + region: string; + metricName: string; + dimensionKey: string; + dimensionFilters: string; + ec2Filters: string; + instanceID: string; + attributeName: string; + resourceType: string; + tags: string; +} diff --git a/public/app/plugins/datasource/cloudwatch/variables.ts b/public/app/plugins/datasource/cloudwatch/variables.ts new file mode 100644 index 00000000000..1443df38ce3 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/variables.ts @@ -0,0 +1,157 @@ +import { from, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { CustomVariableSupport, DataQueryRequest, DataQueryResponse } from '@grafana/data'; + +import { CloudWatchDatasource } from './datasource'; +import { VariableQuery, VariableQueryType } from './types'; +import { migrateVariableQuery } from './migrations'; +import { VariableQueryEditor } from './components/VariableQueryEditor/VariableQueryEditor'; + +export class CloudWatchVariableSupport extends CustomVariableSupport { + private readonly datasource: CloudWatchDatasource; + + constructor(datasource: CloudWatchDatasource) { + super(); + this.datasource = datasource; + this.query = this.query.bind(this); + } + + editor = VariableQueryEditor; + + query(request: DataQueryRequest): Observable { + const queryObj = migrateVariableQuery(request.targets[0]); + return from(this.execute(queryObj)).pipe(map((data) => ({ data }))); + } + + async execute(query: VariableQuery) { + try { + switch (query.queryType) { + case VariableQueryType.Regions: + return this.handleRegionsQuery(); + case VariableQueryType.Namespaces: + return this.handleNamespacesQuery(); + case VariableQueryType.Metrics: + return this.handleMetricsQuery(query); + case VariableQueryType.DimensionKeys: + return this.handleDimensionKeysQuery(query); + case VariableQueryType.DimensionValues: + return this.handleDimensionValuesQuery(query); + case VariableQueryType.EBSVolumeIDs: + return this.handleEbsVolumeIdsQuery(query); + case VariableQueryType.EC2InstanceAttributes: + return this.handleEc2InstanceAttributeQuery(query); + case VariableQueryType.ResourceArns: + return this.handleResourceARNsQuery(query); + case VariableQueryType.Statistics: + return this.handleStatisticsQuery(); + } + } catch (error) { + console.error(`Could not run CloudWatchMetricFindQuery ${query}`, error); + return []; + } + } + + async handleRegionsQuery() { + const regions = await this.datasource.getRegions(); + return regions.map((s: { label: string; value: string }) => ({ + text: s.label, + value: s.value, + expandable: true, + })); + } + + async handleNamespacesQuery() { + const namespaces = await this.datasource.getNamespaces(); + return namespaces.map((s: { label: string; value: string }) => ({ + text: s.label, + value: s.value, + expandable: true, + })); + } + + async handleMetricsQuery({ namespace, region }: VariableQuery) { + const metrics = await this.datasource.getMetrics(namespace, region); + return metrics.map((s: { label: string; value: string }) => ({ + text: s.label, + value: s.value, + expandable: true, + })); + } + + async handleDimensionKeysQuery({ namespace, region }: VariableQuery) { + const keys = await this.datasource.getDimensionKeys(namespace, region); + return keys.map((s: { label: string; value: string }) => ({ + text: s.label, + value: s.value, + expandable: true, + })); + } + + async handleDimensionValuesQuery({ namespace, region, dimensionKey, metricName, dimensionFilters }: VariableQuery) { + if (!dimensionKey || !metricName) { + return []; + } + var filterJson = {}; + if (dimensionFilters) { + filterJson = JSON.parse(dimensionFilters); + } + const keys = await this.datasource.getDimensionValues(region, namespace, metricName, dimensionKey, filterJson); + return keys.map((s: { label: string; value: string }) => ({ + text: s.label, + value: s.value, + expandable: true, + })); + } + + async handleEbsVolumeIdsQuery({ region, instanceID }: VariableQuery) { + if (!instanceID) { + return []; + } + const ids = await this.datasource.getEbsVolumeIds(region, instanceID); + return ids.map((s: { label: string; value: string }) => ({ + text: s.label, + value: s.value, + expandable: true, + })); + } + + async handleEc2InstanceAttributeQuery({ region, attributeName, ec2Filters }: VariableQuery) { + if (!attributeName) { + return []; + } + var filterJson = {}; + if (ec2Filters) { + filterJson = JSON.parse(ec2Filters); + } + const values = await this.datasource.getEc2InstanceAttribute(region, attributeName, filterJson); + return values.map((s: { label: string; value: string }) => ({ + text: s.label, + value: s.value, + expandable: true, + })); + } + + async handleResourceARNsQuery({ region, resourceType, tags }: VariableQuery) { + if (!resourceType) { + return []; + } + var tagJson = {}; + if (tags) { + tagJson = JSON.parse(tags); + } + const keys = await this.datasource.getResourceARNs(region, resourceType, tagJson); + return keys.map((s: { label: string; value: string }) => ({ + text: s.label, + value: s.value, + expandable: true, + })); + } + + async handleStatisticsQuery() { + return this.datasource.standardStatistics.map((s: string) => ({ + text: s, + value: s, + expandable: true, + })); + } +} diff --git a/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx b/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx index cc8dd3ae1f4..cb92fcf5475 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx @@ -66,6 +66,7 @@ export const DataLinks = (props: Props) => { )}
    - - {`Avatar - - - - {serviceAccount.name} - - - - {serviceAccount.login} - - - {contextSrv.licensedAccessControlEnabled() ? ( - onRoleChange(newRole, serviceAccount)} - roleOptions={roleOptions} - builtInRoles={builtInRoles} - disabled={rolePickerDisabled} - /> - ) : ( - onRoleChange(newRole, serviceAccount)} - /> - )} - - - {getServiceAccountsEnabledStatus(serviceAccount.isDisabled)} - - - - - - - {serviceAccount.tokens} - - -