diff --git a/.betterer.results b/.betterer.results index 9bbf07ee673..a61180ce407 100644 --- a/.betterer.results +++ b/.betterer.results @@ -44,7 +44,7 @@ exports[`no enzyme tests`] = { "packages/grafana-ui/src/components/Logs/LogRows.test.tsx:2288254498": [ [3, 17, 13, "RegExp match", "2409514259"] ], - "packages/grafana-ui/src/components/QueryField/QueryField.test.tsx:1906163280": [ + "packages/grafana-ui/src/components/QueryField/QueryField.test.tsx:1297745712": [ [1, 19, 13, "RegExp match", "2409514259"] ], "packages/grafana-ui/src/components/Slider/Slider.test.tsx:2110443485": [ @@ -104,7 +104,7 @@ exports[`no enzyme tests`] = { "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js:2127169675": [ [15, 17, 13, "RegExp match", "2409514259"] ], - "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js:2454947085": [ + "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js:814916029": [ [15, 26, 13, "RegExp match", "2409514259"] ], "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.test.js:2200354834": [ @@ -113,7 +113,7 @@ exports[`no enzyme tests`] = { "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.test.js:3242453659": [ [15, 19, 13, "RegExp match", "2409514259"] ], - "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.test.js:3043344541": [ + "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.test.js:1301875390": [ [15, 19, 13, "RegExp match", "2409514259"] ], "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianText.test.js:2881451220": [ @@ -326,7 +326,7 @@ exports[`no enzyme tests`] = { "public/app/plugins/datasource/elasticsearch/configuration/DataLinks.test.tsx:2916632804": [ [1, 17, 13, "RegExp match", "2409514259"] ], - "public/app/plugins/datasource/grafana-azure-monitor-datasource/components/InsightsConfig.test.tsx:866257119": [ + "public/app/plugins/datasource/grafana-azure-monitor-datasource/components/deprecated/components/InsightsConfig.test.tsx:1635510338": [ [1, 19, 13, "RegExp match", "2409514259"] ], "public/app/plugins/datasource/influxdb/components/ConfigEditor.test.tsx:767000341": [ diff --git a/.drone.yml b/.drone.yml index a7ce2b6a034..3e3ca330122 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.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/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.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -145,6 +145,15 @@ steps: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.1 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: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.1 + name: build-frontend-packages - commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss depends_on: @@ -159,22 +168,14 @@ steps: image: grafana/build-container:1.5.1 name: validate-scuemata - commands: - - '# Make sure the git tree is clean.' - - '# Stashing changes, since packages that were produced in build-backend step are - needed.' - - git stash - - ./bin/linux-amd64/grafana-cli cue gen-ts --grafana-root . - - '# The above command generates Typescript files (*.gen.ts) from all appropriate - .cue files.' - '# It is required that the generated Typescript be in sync with the input CUE files.' - - '# ...Modulo eslint auto-fixes...:' - - yarn run eslint . --ext .gen.ts --fix - - '# If any filenames are emitted by the below script, run the generator command - `grafana-cli cue gen-ts` locally and commit the result.' - - ./scripts/clean-git-or-error.sh - - '# Un-stash changes.' - - git stash pop + - '# To enforce this, the following command will attempt to generate Typescript + from all' + - '# appropriate .cue files, then compare with the corresponding (*.gen.ts) file + the generated' + - '# code would have been written to. It exits 1 if any diffs are found.' + - ./bin/linux-amd64/grafana-cli cue gen-ts --grafana-root . --diff depends_on: - validate-scuemata image: grafana/build-container:1.5.1 @@ -186,6 +187,7 @@ steps: - build-plugins - build-backend - build-frontend + - build-frontend-packages environment: null image: grafana/build-container:1.5.1 name: package @@ -195,6 +197,7 @@ steps: - build-plugins - build-backend - build-frontend + - build-frontend-packages detach: true environment: ARCH: linux-amd64 @@ -208,7 +211,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-dashboards-suite - commands: - apt-get install -y netcat @@ -217,7 +220,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-smoke-tests-suite - commands: - apt-get install -y netcat @@ -226,7 +229,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-panels-suite - commands: - apt-get install -y netcat @@ -235,7 +238,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-various-suite - commands: - apt-get update @@ -274,6 +277,7 @@ steps: - ./bin/grabpl verify-storybook depends_on: - build-frontend + - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 image: grafana/build-container:1.5.1 @@ -354,7 +358,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -418,7 +422,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -474,7 +478,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -527,7 +531,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -628,7 +632,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -670,6 +674,15 @@ steps: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.1 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: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.1 + name: build-frontend-packages - commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss --sign --signing-admin depends_on: @@ -686,22 +699,14 @@ steps: image: grafana/build-container:1.5.1 name: validate-scuemata - commands: - - '# Make sure the git tree is clean.' - - '# Stashing changes, since packages that were produced in build-backend step are - needed.' - - git stash - - ./bin/linux-amd64/grafana-cli cue gen-ts --grafana-root . - - '# The above command generates Typescript files (*.gen.ts) from all appropriate - .cue files.' - '# It is required that the generated Typescript be in sync with the input CUE files.' - - '# ...Modulo eslint auto-fixes...:' - - yarn run eslint . --ext .gen.ts --fix - - '# If any filenames are emitted by the below script, run the generator command - `grafana-cli cue gen-ts` locally and commit the result.' - - ./scripts/clean-git-or-error.sh - - '# Un-stash changes.' - - git stash pop + - '# To enforce this, the following command will attempt to generate Typescript + from all' + - '# appropriate .cue files, then compare with the corresponding (*.gen.ts) file + the generated' + - '# code would have been written to. It exits 1 if any diffs are found.' + - ./bin/linux-amd64/grafana-cli cue gen-ts --grafana-root . --diff depends_on: - validate-scuemata image: grafana/build-container:1.5.1 @@ -713,6 +718,7 @@ steps: - build-plugins - build-backend - build-frontend + - build-frontend-packages environment: GITHUB_TOKEN: from_secret: github_token @@ -732,6 +738,7 @@ steps: - build-plugins - build-backend - build-frontend + - build-frontend-packages detach: true environment: ARCH: linux-amd64 @@ -745,7 +752,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-dashboards-suite - commands: - apt-get install -y netcat @@ -754,7 +761,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-smoke-tests-suite - commands: - apt-get install -y netcat @@ -763,7 +770,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-panels-suite - commands: - apt-get install -y netcat @@ -772,7 +779,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-various-suite - commands: - apt-get update @@ -811,6 +818,7 @@ steps: - ./bin/grabpl verify-storybook depends_on: - build-frontend + - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 image: grafana/build-container:1.5.1 @@ -1007,7 +1015,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1075,7 +1083,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1158,7 +1166,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1240,7 +1248,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1274,6 +1282,15 @@ steps: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.1 name: build-frontend +- commands: + - ./bin/grabpl build-frontend-packages --jobs 8 --github-token $${GITHUB_TOKEN} + --edition oss --no-pull-enterprise ${DRONE_TAG} + depends_on: + - initialize + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.1 + name: build-frontend-packages - commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss --sign --signing-admin depends_on: @@ -1290,22 +1307,14 @@ steps: image: grafana/build-container:1.5.1 name: validate-scuemata - commands: - - '# Make sure the git tree is clean.' - - '# Stashing changes, since packages that were produced in build-backend step are - needed.' - - git stash - - ./bin/linux-amd64/grafana-cli cue gen-ts --grafana-root . - - '# The above command generates Typescript files (*.gen.ts) from all appropriate - .cue files.' - '# It is required that the generated Typescript be in sync with the input CUE files.' - - '# ...Modulo eslint auto-fixes...:' - - yarn run eslint . --ext .gen.ts --fix - - '# If any filenames are emitted by the below script, run the generator command - `grafana-cli cue gen-ts` locally and commit the result.' - - ./scripts/clean-git-or-error.sh - - '# Un-stash changes.' - - git stash pop + - '# To enforce this, the following command will attempt to generate Typescript + from all' + - '# appropriate .cue files, then compare with the corresponding (*.gen.ts) file + the generated' + - '# code would have been written to. It exits 1 if any diffs are found.' + - ./bin/linux-amd64/grafana-cli cue gen-ts --grafana-root . --diff depends_on: - validate-scuemata image: grafana/build-container:1.5.1 @@ -1317,6 +1326,7 @@ steps: - build-plugins - build-backend - build-frontend + - build-frontend-packages environment: GITHUB_TOKEN: from_secret: github_token @@ -1367,6 +1377,7 @@ steps: - build-plugins - build-backend - build-frontend + - build-frontend-packages detach: true environment: ARCH: linux-amd64 @@ -1380,7 +1391,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-dashboards-suite - commands: - apt-get install -y netcat @@ -1389,7 +1400,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-smoke-tests-suite - commands: - apt-get install -y netcat @@ -1398,7 +1409,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-panels-suite - commands: - apt-get install -y netcat @@ -1407,7 +1418,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-various-suite - commands: - apt-get update @@ -1446,6 +1457,7 @@ steps: - ./bin/grabpl verify-storybook depends_on: - build-frontend + - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 image: grafana/build-container:1.5.1 @@ -1544,7 +1556,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1665,7 +1677,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1749,7 +1761,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1808,7 +1820,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1864,6 +1876,15 @@ steps: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.1 name: build-frontend +- commands: + - ./bin/grabpl build-frontend-packages --jobs 8 --github-token $${GITHUB_TOKEN} + --edition enterprise --no-pull-enterprise ${DRONE_TAG} + depends_on: + - initialize + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.1 + name: build-frontend-packages - commands: - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --sign --signing-admin depends_on: @@ -1880,22 +1901,14 @@ steps: image: grafana/build-container:1.5.1 name: validate-scuemata - commands: - - '# Make sure the git tree is clean.' - - '# Stashing changes, since packages that were produced in build-backend step are - needed.' - - git stash - - ./bin/linux-amd64/grafana-cli cue gen-ts --grafana-root . - - '# The above command generates Typescript files (*.gen.ts) from all appropriate - .cue files.' - '# It is required that the generated Typescript be in sync with the input CUE files.' - - '# ...Modulo eslint auto-fixes...:' - - yarn run eslint . --ext .gen.ts --fix - - '# If any filenames are emitted by the below script, run the generator command - `grafana-cli cue gen-ts` locally and commit the result.' - - ./scripts/clean-git-or-error.sh - - '# Un-stash changes.' - - git stash pop + - '# To enforce this, the following command will attempt to generate Typescript + from all' + - '# appropriate .cue files, then compare with the corresponding (*.gen.ts) file + the generated' + - '# code would have been written to. It exits 1 if any diffs are found.' + - ./bin/linux-amd64/grafana-cli cue gen-ts --grafana-root . --diff depends_on: - validate-scuemata image: grafana/build-container:1.5.1 @@ -1917,6 +1930,7 @@ steps: - build-plugins - build-backend - build-frontend + - build-frontend-packages - build-backend-enterprise2 environment: GITHUB_TOKEN: @@ -1968,6 +1982,7 @@ steps: - build-plugins - build-backend - build-frontend + - build-frontend-packages detach: true environment: ARCH: linux-amd64 @@ -1982,7 +1997,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-dashboards-suite - commands: - apt-get install -y netcat @@ -1991,7 +2006,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-smoke-tests-suite - commands: - apt-get install -y netcat @@ -2000,7 +2015,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-panels-suite - commands: - apt-get install -y netcat @@ -2009,7 +2024,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-various-suite - commands: - apt-get update @@ -2073,6 +2088,7 @@ steps: - build-plugins - build-backend - build-frontend + - build-frontend-packages - build-backend-enterprise2 environment: GITHUB_TOKEN: @@ -2147,7 +2163,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2320,7 +2336,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2448,7 +2464,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -2523,7 +2539,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2601,7 +2617,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2662,7 +2678,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2741,7 +2757,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2803,7 +2819,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2839,7 +2855,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2886,7 +2902,7 @@ steps: name: initialize - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2934,7 +2950,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2997,7 +3013,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3028,6 +3044,15 @@ steps: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.1 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: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.1 + name: build-frontend-packages - commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss --sign --signing-admin depends_on: @@ -3044,22 +3069,14 @@ steps: image: grafana/build-container:1.5.1 name: validate-scuemata - commands: - - '# Make sure the git tree is clean.' - - '# Stashing changes, since packages that were produced in build-backend step are - needed.' - - git stash - - ./bin/linux-amd64/grafana-cli cue gen-ts --grafana-root . - - '# The above command generates Typescript files (*.gen.ts) from all appropriate - .cue files.' - '# It is required that the generated Typescript be in sync with the input CUE files.' - - '# ...Modulo eslint auto-fixes...:' - - yarn run eslint . --ext .gen.ts --fix - - '# If any filenames are emitted by the below script, run the generator command - `grafana-cli cue gen-ts` locally and commit the result.' - - ./scripts/clean-git-or-error.sh - - '# Un-stash changes.' - - git stash pop + - '# To enforce this, the following command will attempt to generate Typescript + from all' + - '# appropriate .cue files, then compare with the corresponding (*.gen.ts) file + the generated' + - '# code would have been written to. It exits 1 if any diffs are found.' + - ./bin/linux-amd64/grafana-cli cue gen-ts --grafana-root . --diff depends_on: - validate-scuemata image: grafana/build-container:1.5.1 @@ -3071,6 +3088,7 @@ steps: - build-plugins - build-backend - build-frontend + - build-frontend-packages environment: GITHUB_TOKEN: from_secret: github_token @@ -3121,6 +3139,7 @@ steps: - build-plugins - build-backend - build-frontend + - build-frontend-packages detach: true environment: ARCH: linux-amd64 @@ -3134,7 +3153,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-dashboards-suite - commands: - apt-get install -y netcat @@ -3143,7 +3162,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-smoke-tests-suite - commands: - apt-get install -y netcat @@ -3152,7 +3171,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-panels-suite - commands: - apt-get install -y netcat @@ -3161,7 +3180,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-various-suite - commands: - apt-get update @@ -3200,6 +3219,7 @@ steps: - ./bin/grabpl verify-storybook depends_on: - build-frontend + - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 image: grafana/build-container:1.5.1 @@ -3256,7 +3276,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3370,7 +3390,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3447,7 +3467,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -3495,7 +3515,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3545,6 +3565,15 @@ steps: NODE_OPTIONS: --max_old_space_size=8192 image: grafana/build-container:1.5.1 name: build-frontend +- commands: + - ./bin/grabpl build-frontend-packages --jobs 8 --edition enterprise --build-id + ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.1 + name: build-frontend-packages - commands: - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --sign --signing-admin depends_on: @@ -3561,22 +3590,14 @@ steps: image: grafana/build-container:1.5.1 name: validate-scuemata - commands: - - '# Make sure the git tree is clean.' - - '# Stashing changes, since packages that were produced in build-backend step are - needed.' - - git stash - - ./bin/linux-amd64/grafana-cli cue gen-ts --grafana-root . - - '# The above command generates Typescript files (*.gen.ts) from all appropriate - .cue files.' - '# It is required that the generated Typescript be in sync with the input CUE files.' - - '# ...Modulo eslint auto-fixes...:' - - yarn run eslint . --ext .gen.ts --fix - - '# If any filenames are emitted by the below script, run the generator command - `grafana-cli cue gen-ts` locally and commit the result.' - - ./scripts/clean-git-or-error.sh - - '# Un-stash changes.' - - git stash pop + - '# To enforce this, the following command will attempt to generate Typescript + from all' + - '# appropriate .cue files, then compare with the corresponding (*.gen.ts) file + the generated' + - '# code would have been written to. It exits 1 if any diffs are found.' + - ./bin/linux-amd64/grafana-cli cue gen-ts --grafana-root . --diff depends_on: - validate-scuemata image: grafana/build-container:1.5.1 @@ -3596,6 +3617,7 @@ steps: - build-plugins - build-backend - build-frontend + - build-frontend-packages - build-backend-enterprise2 environment: GITHUB_TOKEN: @@ -3647,6 +3669,7 @@ steps: - build-plugins - build-backend - build-frontend + - build-frontend-packages detach: true environment: ARCH: linux-amd64 @@ -3661,7 +3684,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-dashboards-suite - commands: - apt-get install -y netcat @@ -3670,7 +3693,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-smoke-tests-suite - commands: - apt-get install -y netcat @@ -3679,7 +3702,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-panels-suite - commands: - apt-get install -y netcat @@ -3688,7 +3711,7 @@ steps: - grafana-server environment: HOST: grafana-server - image: cypress/included:9.3.1 + image: cypress/included:9.5.0 name: end-to-end-tests-various-suite - commands: - apt-get update @@ -3727,6 +3750,7 @@ steps: - ./bin/grabpl verify-storybook depends_on: - build-frontend + - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 image: grafana/build-container:1.5.1 @@ -3760,6 +3784,7 @@ steps: - build-plugins - build-backend - build-frontend + - build-frontend-packages - build-backend-enterprise2 environment: GITHUB_TOKEN: @@ -3827,7 +3852,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3990,7 +4015,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4108,7 +4133,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.5/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.7/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -4305,6 +4330,6 @@ kind: secret name: gcp_upload_artifacts_key --- kind: signature -hmac: d9de3f45c31338ed1936f253e8a1f3390822468daeb3395bc368dced68068519 +hmac: c2da781acf9dc4a0178c13a5d9029d9ef3af96a6171ceaafc3c35c59dcfd1ac3 ... diff --git a/.eslintignore b/.eslintignore index 1fc48c822a9..30d54f55eba 100644 --- a/.eslintignore +++ b/.eslintignore @@ -10,6 +10,9 @@ scripts/grafana-server/tmp public/lib/monaco deployment_tools_config.json +# TS generate from cue by cuetsy +**/*.gen.ts + # Auto-generated localisation files public/locales/_build/ public/locales/**/*.js diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fee3c1ec2b4..1a3db25c241 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -53,9 +53,10 @@ go.sum @grafana/backend-platform /pkg/services/sqlstore/migrations @grafana/backend-platform @grafana/hosted-grafana-team *_mig.go @grafana/backend-platform @grafana/hosted-grafana-team -# Grafana live +# Grafana edge /pkg/services/live/ @grafana/grafana-edge-squad /pkg/services/searchV2/ @grafana/grafana-edge-squad +/pkg/infra/filestore/ @grafana/grafana-edge-squad # Alerting /pkg/services/ngalert @grafana/alerting-squad-backend diff --git a/.github/commands.json b/.github/commands.json index 095d22e271d..1401dc09bbd 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -209,5 +209,13 @@ "removeFromProject":{ "url":"https://github.com/grafana/grafana/projects/33" } + }, + { + "type": "label", + "name": "team/grafana-partners", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/87" + } } ] diff --git a/.github/renovate.json5 b/.github/renovate.json5 index ef70d6fa713..047b72811cf 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -11,12 +11,15 @@ "@types/d3-scale-chromatic", // we should bump this once we move to esm modules "@types/grafana__slate-react", // should be updated when the `slate` package is updated "@types/react-icons", // jaeger-ui-components is being refactored to use @grafana/ui icons instead + "commander", // we are planning to remove this, so no need to update it "d3", "d3-force", // we should bump this once we move to esm modules "d3-interpolate", // we should bump this once we move to esm modules "d3-scale-chromatic", // we should bump this once we move to esm modules "execa", // we should bump this once we move to esm modules "history", // we should bump this together with react-router-dom + "@mdx-js/react", // storybook peer-depends on it's 1.x version, we should upgrade this when we upgrade storybook + "monaco-editor", // due to us exposing this via @grafana/ui/CodeEditor's props bumping can break plugins "react-hook-form", // due to us exposing these hooks via @grafana/ui form components bumping can break plugins "react-icons", // jaeger-ui-components is being refactored to use @grafana/ui icons instead "react-router-dom", // we should bump this together with history diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 701b0b260d0..00000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,47 +0,0 @@ -# Configuration for probot-stale - https://github.com/probot/stale - -# General configuration -# Label to use when marking as stale -staleLabel: stale - -# Pull request specific configuration -pulls: - # Number of days of inactivity before an Issue or Pull Request becomes stale - daysUntilStale: 14 - # Number of days of inactivity before a stale Issue or Pull Request is closed. - # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. - daysUntilClose: 30 - # Comment to post when marking as stale. Set to `false` to disable - markComment: > - This pull request has been automatically marked as stale because it has not had - activity in the last 2 weeks. It will be closed in 30 days if no further activity occurs. Please - feel free to give a status update now, ping for review, or re-open when it's ready. - Thank you for your contributions! - # Comment to post when closing a stale Issue or Pull Request. - closeComment: > - This pull request has been automatically closed because it has not had - activity in the last 30 days. Please feel free to give a status update now, ping for review, or re-open when it's ready. - Thank you for your contributions! - # Limit the number of actions per hour, from 1-30. Default is 30 - limitPerRun: 1 - -exemptLabels: - - help wanted - - type/bug - - type/feature-request - - Epic - - no stalebot - -# Issue specific configuration -issues: - limitPerRun: 1 - daysUntilStale: 100000 - daysUntilClose: 100000 - markComment: > - This issue has been automatically marked as stale because it has not had activity in the - last 100 days. It will be closed in the next 100 days if no activity occurs. - Thank you for your contributions. - closeComment: > - This issue has been automatically closed because it has not had activity in the - last month and a half. If this issue is still valid, please ping a maintainer and ask them to check this again. - Thank you for your contributions. diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index bb0f40ea205..debee02c499 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -16,6 +16,8 @@ on: required: true metricsWriteAPIKey: required: true +env: + YARN_ENABLE_IMMUTABLE_INSTALLS: false jobs: main: runs-on: ubuntu-latest @@ -80,7 +82,7 @@ jobs: ref: main - uses: actions/setup-node@v2.5.1 with: - node-version: '14' + node-version: '16' - name: Install Actions run: npm install --production --prefix ./actions - name: Run bump version (manually invoked) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index be455a168a2..16e00f6d094 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -13,7 +13,7 @@ on: - milestoned - demilestoned concurrency: - group: pr-checks-${{ github.event.issue.number }} + group: pr-checks-${{ github.event.number }} jobs: main: runs-on: ubuntu-latest diff --git a/.github/workflows/pr-commands-closed.yml b/.github/workflows/pr-commands-closed.yml index 0f0067450be..76719c6c009 100644 --- a/.github/workflows/pr-commands-closed.yml +++ b/.github/workflows/pr-commands-closed.yml @@ -4,7 +4,7 @@ on: types: - closed concurrency: - group: pr-commands-closed-${{ github.event.issue.number }} + group: pr-commands-closed-${{ github.event.number }} jobs: close_job: # this job will only run if the PR has been closed without being merged diff --git a/.github/workflows/pr-commands.yml b/.github/workflows/pr-commands.yml index 523628e545d..7f47cfd8e92 100644 --- a/.github/workflows/pr-commands.yml +++ b/.github/workflows/pr-commands.yml @@ -5,7 +5,7 @@ on: - opened - synchronize concurrency: - group: pr-commands-${{ github.event.issue.number }} + group: pr-commands-${{ github.event.number }} jobs: main: runs-on: ubuntu-latest diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000000..74c0bfaa1d7 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,33 @@ +name: 'Close stale issues and PRs' +on: + schedule: + - cron: '30 1 * * *' + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v4 + with: + repo-token: ${{ secrets.GH_BOT_ACCESS_TOKEN }} + # Number of days of inactivity before a stale Issue or Pull Request is closed. + # Set to -1 to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. + days-before-close: 14 + # Number of days of inactivity before an Issue or Pull Request becomes stale + days-before-stale: 30 + # We don't want any Issues to be marked as stale for now. + days-before-issue-stale: -1 + exempt-issue-labels: no stalebot + exempt-pr-labels: no stalebot + operations-per-run: 100 + stale-issue-label: stale + stale-pr-label: stale + stale-pr-message: > + This pull request has been automatically marked as stale because it has not had + activity in the last 30 days. It will be closed in 2 weeks if no further activity occurs. Please + feel free to give a status update now, ping for review, or re-open when it's ready. + Thank you for your contributions! + close-pr-message: > + This pull request has been automatically closed because it has not had + activity in the last 2 weeks. Please feel free to give a status update now, ping for review, or re-open when it's ready. + Thank you for your contributions! diff --git a/CHANGELOG.md b/CHANGELOG.md index 30bd5c155a3..9a1556f5db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ + + +# 8.4.3 (2022-03-02) + +### Features and enhancements + +- **Alerting:** Grafana uses > instead of >= when checking the For duration. [#46010](https://github.com/grafana/grafana/issues/46010) +- **Alerting:** Use expanded labels in dashboard annotations. [#45726](https://github.com/grafana/grafana/pull/45726), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Logs:** Escape windows newline into single newline. [#45771](https://github.com/grafana/grafana/pull/45771), [@perosb](https://github.com/perosb) + +### Bug fixes + +- **Alerting:** Fix use of > instead of >= when checking the For duration. [#46011](https://github.com/grafana/grafana/pull/46011), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Azure Monitor:** Fixes broken log queries that use workspace. [#45820](https://github.com/grafana/grafana/pull/45820), [@sunker](https://github.com/sunker) +- **CloudWatch:** Remove error message when using multi-valued template vars in region field. [#45886](https://github.com/grafana/grafana/pull/45886), [@sunker](https://github.com/sunker) +- **Middleware:** Fix IPv6 host parsing in CSRF check. [#45911](https://github.com/grafana/grafana/pull/45911), [@ying-jeanne](https://github.com/ying-jeanne) + +### Plugin development fixes & changes + +- **ClipboardButton:** Use a fallback when the Clipboard API is unavailable. [#45831](https://github.com/grafana/grafana/pull/45831), [@ashharrison90](https://github.com/ashharrison90) + + + + +# 8.4.2 (2022-02-23) + +### Features and enhancements + +- **OAuth:** Add setting to skip org assignment for external users. [#34834](https://github.com/grafana/grafana/pull/34834), [@baez90](https://github.com/baez90) +- **Tracing:** Add option to map tag names to log label names in trace to logs settings. [#45178](https://github.com/grafana/grafana/pull/45178), [@connorlindsey](https://github.com/connorlindsey) + +### Bug fixes + +- **Explore:** Fix closing split pane when logs panel is used. [#45602](https://github.com/grafana/grafana/pull/45602), [@ifrost](https://github.com/ifrost) + + # 8.4.1 (2022-02-18) @@ -87,6 +123,16 @@ AngularJS plugin support is now in a deprecated state, meaning it will be remove - **News:** Reload feed when changing the time range or refreshing. [#42217](https://github.com/grafana/grafana/pull/42217), [@ashharrison90](https://github.com/ashharrison90) - **UI/Plot:** Implement keyboard controls for plot cursor. [#42244](https://github.com/grafana/grafana/pull/42244), [@kaydelaney](https://github.com/kaydelaney) + + +# 8.3.7 (2022-03-01) + +### Bug fixes + +- **Provisioning:** Ensure that the default value for orgID is set when provisioning datasources to be deleted. [#44244](https://github.com/grafana/grafana/pull/44244), [@filewalkwithme](https://github.com/filewalkwithme) + + + # 8.3.6 (2022-02-09) diff --git a/README.md b/README.md index f4873c5b0e4..8e2299d522d 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ ![Grafana](docs/logo-horizontal.png) -The open-source platform for monitoring and observability. +The open-source platform for monitoring and observability [![License](https://img.shields.io/github/license/grafana/grafana)](LICENSE) [![Drone](https://drone.grafana.net/api/badges/grafana/grafana/status.svg)](https://drone.grafana.net/grafana/grafana) [![Go Report Card](https://goreportcard.com/badge/github.com/grafana/grafana)](https://goreportcard.com/report/github.com/grafana/grafana) -Grafana allows you to query, visualize, alert on and understand your metrics no matter where they are stored. Create, explore, and share dashboards with your team and foster a data driven culture: +Grafana allows you to query, visualize, alert on and understand your metrics no matter where they are stored. Create, explore, and share dashboards with your team and foster a data-driven culture: -- **Visualize:** Fast and flexible client side graphs with a multitude of options. Panel plugins offer many different ways to visualize metrics and logs. +- **Visualizations:** Fast and flexible client side graphs with a multitude of options. Panel plugins offer many different ways to visualize metrics and logs. - **Dynamic Dashboards:** Create dynamic & reusable dashboards with template variables that appear as dropdowns at the top of the dashboard. - **Explore Metrics:** Explore your data through ad-hoc queries and dynamic drilldown. Split view and compare different time ranges, queries and data sources side by side. - **Explore Logs:** Experience the magic of switching from metrics to logs with preserved label filters. Quickly search through all your logs or streaming them live. diff --git a/contribute/developer-guide.md b/contribute/developer-guide.md index ea2c2897d45..9eb31be1f00 100644 --- a/contribute/developer-guide.md +++ b/contribute/developer-guide.md @@ -136,6 +136,20 @@ Running the backend tests on Windows currently needs some tweaking, so use the b go run build.go test ``` +### Run PostgreSQL and MySQL integration tests + +To run PostgreSQL and MySQL integration tests locally, you need to start the docker blocks for MySQL and/or PostgreSQL test data sources by running `make devenv sources=mysql_tests,postgres_tests`. When your test data sources are running, you can execute integration tests by running: + +``` +GRAFANA_TEST_DB=mysql go test -covermode=atomic -tags=integration ./pkg/... +``` + +and/or + +``` +GRAFANA_TEST_DB=postgres go test -covermode=atomic -tags=integration ./pkg/... +``` + ### Run end-to-end tests The end to end tests in Grafana use [Cypress](https://www.cypress.io/) to run automated scripts in a headless Chromium browser. Read more about our [e2e framework](/contribute/style-guides/e2e.md). diff --git a/devenv/datasources.yaml b/devenv/datasources.yaml index 94b419e3cca..2bf076b4ec4 100644 --- a/devenv/datasources.yaml +++ b/devenv/datasources.yaml @@ -195,7 +195,7 @@ datasources: timeInterval: 10s interval: Daily timeField: "@timestamp" - esVersion: 70 + esVersion: 7.10.0 - name: gdev-elasticsearch-v7-logs type: elasticsearch @@ -205,7 +205,7 @@ datasources: jsonData: interval: Daily timeField: "@timestamp" - esVersion: 70 + esVersion: 7.10.0 - name: gdev-elasticsearch-v7-filebeat type: elasticsearch @@ -215,7 +215,7 @@ datasources: jsonData: interval: Daily timeField: "@timestamp" - esVersion: 70 + esVersion: 7.10.0 timeInterval: "10s" logMessageField: message logLevelField: fields.level @@ -228,7 +228,7 @@ datasources: jsonData: interval: Daily timeField: "@timestamp" - esVersion: 70 + esVersion: 7.10.0 timeInterval: "10s" - name: gdev-mysql diff --git a/devenv/docker/blocks/elastic7/docker-compose.yaml b/devenv/docker/blocks/elastic7/docker-compose.yaml index d4d981c6b55..b4e144c2b5b 100644 --- a/devenv/docker/blocks/elastic7/docker-compose.yaml +++ b/devenv/docker/blocks/elastic7/docker-compose.yaml @@ -1,7 +1,7 @@ # You need to run 'sysctl -w vm.max_map_count=262144' on the host machine elasticsearch7: - image: docker.elastic.co/elasticsearch/elasticsearch-oss:7.0.0 + image: docker.elastic.co/elasticsearch/elasticsearch-oss:7.10.2 command: elasticsearch -E "discovery.type=single-node" ports: - "12200:9200" @@ -17,7 +17,7 @@ FD_PORT: 9200 filebeat7: - image: docker.elastic.co/beats/filebeat-oss:7.0.0 + image: docker.elastic.co/beats/filebeat-oss:7.17.0 command: filebeat -e -strict.perms=false volumes: - ./docker/blocks/elastic7/filebeat.yml:/usr/share/filebeat/filebeat.yml:ro @@ -25,7 +25,7 @@ - ../data/log:/var/log/grafana:ro metricbeat7: - image: docker.elastic.co/beats/metricbeat-oss:7.0.0 + image: docker.elastic.co/beats/metricbeat-oss:7.17.0 command: metricbeat -e -strict.perms=false user: root volumes: diff --git a/devenv/docker/blocks/loki/docker-compose.yaml b/devenv/docker/blocks/loki/docker-compose.yaml index 8c5f33400f6..8b70449dcad 100644 --- a/devenv/docker/blocks/loki/docker-compose.yaml +++ b/devenv/docker/blocks/loki/docker-compose.yaml @@ -7,7 +7,7 @@ promtail: image: grafana/promtail:latest volumes: - - ./docker/blocks/loki/config.yaml:/etc/promtail/docker-config.yaml + - ./docker/blocks/loki/promtail-config.yaml:/etc/promtail/docker-config.yaml - /var/log:/var/log - ../data/log:/var/log/grafana command: diff --git a/devenv/docker/blocks/loki/config.yaml b/devenv/docker/blocks/loki/promtail-config.yaml similarity index 100% rename from devenv/docker/blocks/loki/config.yaml rename to devenv/docker/blocks/loki/promtail-config.yaml diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md index 9440a278a46..242fbc164a1 100644 --- a/docs/sources/administration/configuration.md +++ b/docs/sources/administration/configuration.md @@ -14,7 +14,7 @@ Grafana has default and custom configuration files. You can customize your Grafa ## Configuration file location -The default settings for a Grafana instance are stored in the `$WORKING_DIR/conf/defaults.ini` file. _Do not_ change the location in this file. +The default settings for a Grafana instance are stored in the `$WORKING_DIR/conf/defaults.ini` file. _Do not_ change this file. Depending on your OS, your custom configuration file is either the `$WORKING_DIR/conf/defaults.ini` file or the `/usr/local/etc/grafana/grafana.ini` file. The custom configuration file path can be overridden using the `--config` parameter. diff --git a/docs/sources/administration/manage-user-preferences/_index.md b/docs/sources/administration/manage-user-preferences/_index.md new file mode 100644 index 00000000000..6c4089851af --- /dev/null +++ b/docs/sources/administration/manage-user-preferences/_index.md @@ -0,0 +1,100 @@ ++++ +title = "Manage user preferences" +weight = 400 +description = "Learn how to update your user preferences and switch organizations" +keywords = ["password", "change", "organization", "change"] +aliases = ["/docs/grafana/latest/administration/change-your-password/", "docs/sources/administration/manage-user-preferences/_index.md"] ++++ + +# Manage user preferences + +Grafana allows you to manage certain aspects of your user account, including your user name, email, and password. + +You can also view important information about your account, such as the organizations and roles to which you are assigned and the Grafana sessions associated with your account. + +## Change your Grafana password + +You can change your Grafana password at any time. + +> **Note**: If your Grafana instance uses an external authentication provider, then you might not be able to change your password in Grafana. Contact your Grafana administrator for more information. + +**To change your password**: + +1. Sign in to Grafana. +1. Hover your mouse over the user icon in the lower-left corner of the page. +1. Click **Change Password**. + Grafana opens the **Change Password** tab. + +1. Enter your old password and a new password. +1. Confirm your new password. +1. Click **Change Password**. + +## Edit your profile + +Your profile includes your name, user name, and email address, which you can update. + +**To edit your profile**: + +1. Sign in to Grafana. +1. Hover your cursor over the user icon in the lower-left corner of the page and click **Preferences**. +1. In the **Edit Profile** section, update your profile and click **Save**. + +## Edit your preferences + +You can choose the way you would like data to appear in Grafana, including the UI theme, home dashboard, timezone, and first day of the week. You can set these preferences for your own account, for a team, for an organization, or Grafana-wide using configuration settings. Your user preferences take precedence over team, organization, and Grafana default preferences. For more information, see [Grafana preferences]({{< relref "../../administration/preferences/_index.md">}}). + +- **UI theme** determines whether Grafana appears in light mode or dark mode. By default, UI theme is set to dark mode. +- **Home dashboard** refers to the dashboard you see when you sign in to Grafana. By default, this is set to the Home dashboard. +- **Timezone** is used by dashboards when you set time ranges, so that you view data in your timezone instead of UTC. +- **Week start** is the first day of the week you want to use in dashboard time ranges, for example, `This week`. + +**To edit your preferences**: + +1. Sign in to Grafana. +1. Hover your cursor over the user icon in the lower-left corner of the page, and click **Preferences**. +1. Update any of the values in the **Preferences** section. +1. Click **Save** at the bottom of the Preferences section. + +## Switch organizations + +When you sign in to Grafana, the system signs you in to a default organization. If you are assigned to multiple organizations, then you might need to switch organizations. For example, if you need to view a dashboard not associated with your current organization, then you should switch organizations to view associated dashboards. + +**To switch organizations**: + +1. Sign in to Grafana. +1. Hover your cursor over the user icon in the lower-left corner of the page and click **Switch organization**. +1. Next to the organization that you want to sign in to, click **Switch to**. + +## View your assigned organizations + +Every user is a member of at least one organization. You can have different roles in each organization of which you are a member. + +**To view your assigned organizations**: + +1. Sign in to Grafana. +1. Hover your cursor over the user icon in the lower-left corner of the page and click **Preferences**. +1. Scroll down to the **Organizations** section and review the following information: + - **Name**: The name of the organizations of which you are a member. + - **Role**: The role to which you are assigned in the organization. For more information about roles and permissions, refer to [Organization users and permissions]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions.md#organization-users-and-permissions" >}}). + - **Current**: Grafana indicates the organization that you are currently signed into as _Current_. If you are a member of multiple organizations, you can click **Select** to switch to that organization. + +## View your Grafana sessions + +Grafana logs your sessions in each Grafana instance. You can review this section if you suspect someone has misused your Grafana credentials. + +**To view your Grafana sessions**: + +1. Sign in to Grafana. +1. Hover your cursor over the user icon in the lower-left corner of the page, and click **Preferences**. +1. Scroll down to the **Sessions** section. + +## Sign out a user session + +You can sign out other sessions using your account in order to prevent other people from accessing Grafana using your credentials. + +**To sign out one of your Grafana sessions**: + +1. Sign in to Grafana. +1. Hover your cursor over the user icon in the lower-left corner of the page, and click **Preferences**. +1. Scroll down to the **Sessions** section. +1. Click the red "sign out" icon next to the session you would like to sign out. diff --git a/docs/sources/administration/preferences/change-grafana-name.md b/docs/sources/administration/preferences/change-grafana-name.md index 69d4394a8e2..7924ffcc2b4 100644 --- a/docs/sources/administration/preferences/change-grafana-name.md +++ b/docs/sources/administration/preferences/change-grafana-name.md @@ -53,4 +53,4 @@ To change the team name or email, follow these steps: ## Change user name or email -To learn how to edit your user information, refer to [Grafana user account profile]({{< relref "../../manage-users/user-admin/user-profile.md" >}}). +To learn how to edit your user information, refer to [Edit your profile]({{< relref "../manage-user-preferences/_index.md#edit-your-profile" >}}). diff --git a/docs/sources/alerting/unified-alerting/alerting-rules/alert-annotation-label.md b/docs/sources/alerting/unified-alerting/alerting-rules/alert-annotation-label.md index c51c957447c..0b88bb1b866 100644 --- a/docs/sources/alerting/unified-alerting/alerting-rules/alert-annotation-label.md +++ b/docs/sources/alerting/unified-alerting/alerting-rules/alert-annotation-label.md @@ -11,7 +11,7 @@ Annotations and labels are key value pairs associated with alerts originating fr ## Annotations -Annotations are key-value pairs that provide additional meta-information about an alert. For example: a description, a summary, and runbook URL. These are displayed in rule and alert details in the UI and can be used in contact point message templates. +Annotations are key-value pairs that provide additional meta-information about an alert. You can use the following annotations: `description`, `summary`, `runbook_url`, `alertId`, `dashboardUid`, and `panelId`. For example, a description, a summary, and a runbook URL. These are displayed in rule and alert details in the UI and can be used in contact point message templates. ## Labels @@ -20,7 +20,7 @@ Labels are key-value pairs that contain information about, and are used to uniqu ### How are labels used? - The complete set of labels for an alert is what uniquely identifies an alert within Grafana Alerts. -- The Alertmanager uses labels to match alerts for [silences]({{< relref "../silences/" >}}) and [alert groups]({{< relref "../alert-groups/" >}}) in [notification policies]({{< relref "../notification-policies/" >}}). +- The Alertmanager uses labels to match alerts for [silences]({{< relref "../silences/" >}}) and [alert groups]({{< relref "../alert-groups/" >}}) in [notification policies]({{< relref "../notifications/_index.md" >}}). - The alerting UI displays labels for every alert instance generated by the evaluation of that rule. - Contact points can access labels to dynamically generate notifications that contain information specific to the alert that is resulting in a notification. - Labels can be added to an [alerting rule]({{< relref "../alerting-rules/" >}}). These manually configured labels are able to use template functions and reference other labels. Labels added to an alerting rule here take precedence in the event of a collision between labels. diff --git a/docs/sources/alerting/unified-alerting/notifications/_index.md b/docs/sources/alerting/unified-alerting/notifications/_index.md index ba1cbf0b638..3a263fb8143 100644 --- a/docs/sources/alerting/unified-alerting/notifications/_index.md +++ b/docs/sources/alerting/unified-alerting/notifications/_index.md @@ -9,7 +9,7 @@ weight = 450 Notification policies determine how alerts are routed to contact points. Policies have a tree structure, where each policy can have one or more child policies. Each policy, except for the root policy, can also match specific alert labels. Each alert is evaluated by the root policy and subsequently by each child policy. If you enable the `Continue matching subsequent sibling nodes` option is enabled for a specific policy, then evaluation continues even after one or more matches. A parent policy’s configuration settings and contact point information govern the behavior of an alert that does not match any of the child policies. A root policy governs any alert that does not match a specific policy. -You can configure Grafana managed notification policies as well as notification policies for an [external Alertmanager data source]({{< relref "../../datasources/alertmanager.md" >}}). For more information, see [Alertmanager]({{< relref "./fundamentals/alertmanager.md" >}}). +You can configure Grafana managed notification policies as well as notification policies for an [external Alertmanager data source]({{< relref "../../../datasources/alertmanager.md" >}}). For more information, see [Alertmanager]({{< relref "../fundamentals/alertmanager.md" >}}). ## Grouping @@ -33,7 +33,7 @@ You can configure grouping to be `group_by: [alertname]` (take note that the `en 1. Click **Notification policies**. 1. From the **Alertmanager** dropdown, select an external Alertmanager. By default, the Grafana Alertmanager is selected. 1. In the Root policy section, click **Edit** (pen icon). -1. In **Default contact point**, update the [contact point]({{< relref "./contact-points.md" >}}) to whom notifications should be sent for rules when alert rules do not match any specific policy. +1. In **Default contact point**, update the [contact point]({{< relref "../contact-points.md" >}}) to whom notifications should be sent for rules when alert rules do not match any specific policy. 1. In **Group by**, choose labels to group alerts by. If multiple alerts are matched for this policy, then they are grouped by these labels. A notification is sent per group. If the field is empty (default), then all notifications are sent in a single group. Use a special label `...` to group alerts by all labels (which effectively disables grouping). 1. In **Timing options**, select from the following options: - **Group wait** Time to wait to buffer alerts of the same group before sending an initial notification. Default is 30 seconds. @@ -48,7 +48,7 @@ You can configure grouping to be `group_by: [alertname]` (take note that the `en 1. From the **Alertmanager** dropdown, select an Alertmanager. By default, the Grafana Alertmanager is selected. 1. To add a top level specific policy, go to the **Specific routing** section and click **New specific policy**. 1. In **Matching labels** section, add one or more rules for matching alert labels. For more information, see ["How label matching works"](#how-label-matching-works). -1. In **Contact point**, add the [contact point]({{< relref "./contact-points.md" >}}) to send notification to if alert matches only this specific policy and not any of the nested policies. +1. In **Contact point**, add the [contact point]({{< relref "../contact-points.md" >}}) to send notification to if alert matches only this specific policy and not any of the nested policies. 1. Optionally, enable **Continue matching subsequent sibling nodes** to continue matching nested policies even after the alert matched the parent policy. When this option is enabled, you can get more than one notification. Use it to send notification to a catch-all contact point as well as to one of more specific contact points handled by nested policies. 1. Optionally, enable **Override grouping** to specify the same grouping as the root policy. If this option is not enabled, the root policy grouping is used. 1. Optionally, enable **Override general timings** to override the timing options configured in the group notification policy. diff --git a/docs/sources/alerting/unified-alerting/notifications/mute-timings.md b/docs/sources/alerting/unified-alerting/notifications/mute-timings.md index 254a9a0d26b..411f9fdb479 100644 --- a/docs/sources/alerting/unified-alerting/notifications/mute-timings.md +++ b/docs/sources/alerting/unified-alerting/notifications/mute-timings.md @@ -11,7 +11,7 @@ A mute timing is a recurring interval of time when no new notifications for a po Similar to silences, mute timings do not prevent alert rules from being evaluated, nor do they stop alert instances from being shown in the user interface. They only prevent notifications from being created. -You can configure Grafana managed mute timings as well as mute timings for an [external Alertmanager data source]({{< relref "../../datasources/alertmanager.md" >}}). For more information, see [Alertmanager]({{< relref "./fundamentals/alertmanager.md" >}}). +You can configure Grafana managed mute timings as well as mute timings for an [external Alertmanager data source]({{< relref "../../../datasources/alertmanager.md" >}}). For more information, see [Alertmanager]({{< relref "../fundamentals/alertmanager.md" >}}). ## Mute timings vs silences diff --git a/docs/sources/auth/ldap.md b/docs/sources/auth/ldap.md index 15b3f29cbd3..a289fc12038 100644 --- a/docs/sources/auth/ldap.md +++ b/docs/sources/auth/ldap.md @@ -68,7 +68,7 @@ ssl_skip_verify = false bind_dn = "cn=admin,dc=grafana,dc=org" # Search user bind password # If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;""" -bind_password = 'grafana' +bind_password = "grafana" # User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)" # Allow login from email or username, example "(|(sAMAccountName=%s)(userPrincipalName=%s))" @@ -236,7 +236,7 @@ use_ssl = false start_tls = false ssl_skip_verify = false bind_dn = "cn=admin,dc=grafana,dc=org" -bind_password = 'grafana' +bind_password = "grafana" search_filter = "(cn=%s)" search_base_dns = ["dc=grafana,dc=org"] @@ -263,7 +263,7 @@ use_ssl = false start_tls = false ssl_skip_verify = false bind_dn = "cn=admin,dc=grafana,dc=org" -bind_password = 'grafana' +bind_password = "grafana" search_filter = "(cn=%s)" search_base_dns = ["ou=users,dc=grafana,dc=org"] @@ -286,7 +286,7 @@ start_tls = false ssl_skip_verify = false bind_dn = "cn=admin,dc=grafana,dc=org" -bind_password = 'grafana' +bind_password = "grafana" search_filter = "(cn=%s)" search_base_dns = ["ou=users,dc=grafana,dc=org"] diff --git a/docs/sources/developers/plugins/add-authentication-for-data-source-plugins.md b/docs/sources/developers/plugins/add-authentication-for-data-source-plugins.md index bb0b333e998..4cb9c2bbc0b 100644 --- a/docs/sources/developers/plugins/add-authentication-for-data-source-plugins.md +++ b/docs/sources/developers/plugins/add-authentication-for-data-source-plugins.md @@ -291,16 +291,27 @@ When configured, Grafana will pass the user's token to the plugin in an Authoriz ```go func (ds *dataSource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - for _, q := range req.Queries { - token := strings.Fields(q.Headers.Get("Authorization")) + token := strings.Fields(req.Headers["Authorization"]) + var ( + tokenType = token[0] + accessToken = token[1] + ) - var ( - tokenType = token[0] - accessToken = token[1] - ) + for _, q := range req.Queries { + // ... + } +} +``` - // ... - } +In addition, if the user's token includes an ID token, Grafana will pass the user's ID token to the plugin in an `X-ID-Token` header, available on the `QueryDataRequest` object on the `QueryData` request in your backend data source. + +```go +func (ds *dataSource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + idToken := req.Headers["X-ID-Token"] + + for _, q := range req.Queries { + // ... + } } ``` diff --git a/docs/sources/enterprise/enterprise-encryption/using-aws-kms-to-encrypt-database-secrets.md b/docs/sources/enterprise/enterprise-encryption/using-aws-kms-to-encrypt-database-secrets.md index b91655c753c..9cf0cd04040 100644 --- a/docs/sources/enterprise/enterprise-encryption/using-aws-kms-to-encrypt-database-secrets.md +++ b/docs/sources/enterprise/enterprise-encryption/using-aws-kms-to-encrypt-database-secrets.md @@ -23,7 +23,7 @@ You can use an encryption key from AWS Key Management Service to encrypt secrets 3. Create a [programmatic credential](https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#access-keys-and-secret-access-keys) (access key ID and secret access key), which has permission to view the key that you created.

In AWS, you can control access to your KMS keys by using [key policies](https://docs.aws.amazon.com/kms/latest/developerguide/key-policies.html), [IAM policies](https://docs.aws.amazon.com/kms/latest/developerguide/iam-policies.html), and [grants](https://docs.aws.amazon.com/kms/latest/developerguide/grants.html). You can also create [temporary credentials](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_use-resources.html), which must provide a session token along with an access key ID and a secret access key. -4. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/envelope-encryption.md" >}}). +4. From within Grafana, turn on [envelope encryption]({{< relref "../../administration//database-encryption.md" >}}). 5. Add your AWS KMS details to the Grafana configuration file; depending on your operating system, it is usually named `grafana.ini`:

a. Add a new section to the configuration file, with a name in the format of `[security.encryption.awskms.]`, where `` is any name that uniquely identifies this key among other provider keys.

b. Fill in the section with the following values: diff --git a/docs/sources/enterprise/enterprise-encryption/using-azure-key-vault-to-encrypt-database-secrets.md b/docs/sources/enterprise/enterprise-encryption/using-azure-key-vault-to-encrypt-database-secrets.md index 8747fa81394..489649f24cd 100644 --- a/docs/sources/enterprise/enterprise-encryption/using-azure-key-vault-to-encrypt-database-secrets.md +++ b/docs/sources/enterprise/enterprise-encryption/using-azure-key-vault-to-encrypt-database-secrets.md @@ -24,7 +24,7 @@ You can use an encryption key from Azure Key Vault to encrypt secrets in the Gra 5. In the Key Permissions section, set encrypt and decrypt permissions, and click **Save**. -6. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/envelope-encryption.md" >}}). +6. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/database-encryption.md" >}}). 7. Add your Azure Key Vault details to the Grafana configuration file; depending on your operating system, is usually named `grafana.ini`:

a. Add a new section to the configuration file, with a name in the format of `[security.encryption.azurekv.]`, where `` is any name that uniquely identifies this key among other provider keys. diff --git a/docs/sources/enterprise/enterprise-encryption/using-google-cloud-kms-to-encrypt-database-secrets.md b/docs/sources/enterprise/enterprise-encryption/using-google-cloud-kms-to-encrypt-database-secrets.md index 0d66b4a89a4..0ef4491ce27 100644 --- a/docs/sources/enterprise/enterprise-encryption/using-google-cloud-kms-to-encrypt-database-secrets.md +++ b/docs/sources/enterprise/enterprise-encryption/using-google-cloud-kms-to-encrypt-database-secrets.md @@ -22,7 +22,7 @@ You can use an encryption key from Google Cloud Key Management Service to encryp 4. [Create a service account key and save its JSON file](https://cloud.google.com/iam/docs/creating-managing-service-account-keys#creating) to you computer, for example, as `~/.config/gcloud/sample-project-credentials.json`. -5. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/envelope-encryption.md" >}}). +5. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/database-encryption.md" >}}). 6. Add your Google Cloud KMS details to the Grafana configuration file; depending on your operating system, is usually named `grafana.ini`:

a. Add a new section to the configuration file, with a name in the format of `[security.encryption.azurekv.]`, where `` is any name that uniquely identifies this key among other provider keys. diff --git a/docs/sources/enterprise/enterprise-encryption/using-hashicorp-key-vault-to-encrypt-database-secrets.md b/docs/sources/enterprise/enterprise-encryption/using-hashicorp-key-vault-to-encrypt-database-secrets.md index b09bd2d582b..83d843bbab2 100644 --- a/docs/sources/enterprise/enterprise-encryption/using-hashicorp-key-vault-to-encrypt-database-secrets.md +++ b/docs/sources/enterprise/enterprise-encryption/using-hashicorp-key-vault-to-encrypt-database-secrets.md @@ -20,7 +20,7 @@ You can use an encryption key from Hashicorp Vault to encrypt secrets in the Gra 3. [Create a periodic service token](https://learn.hashicorp.com/tutorials/vault/tokens#periodic-service-tokens). -4. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/envelope-encryption.md" >}}). +4. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/database-encryption.md" >}}). 5. Add your Hashicorp Vault details to the Grafana configuration file; depending on your operating system, is usually named `grafana.ini`:

a. Add a new section to the configuration file, with a name in the format of `[security.encryption.hashicorpvault.]`, where `` is any name that uniquely identifies this key among other provider keys. diff --git a/docs/sources/explore/logs-integration.md b/docs/sources/explore/logs-integration.md index 51b33a7d632..3e19c75196f 100644 --- a/docs/sources/explore/logs-integration.md +++ b/docs/sources/explore/logs-integration.md @@ -31,7 +31,7 @@ If the data source does not support loading full range log volume histogram, the For logs where a level label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we try to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with an unknown log level. -> **Tip:** If you use Loki data source and the "level" is in you log content, try to use parsers (JSON, logfmt, regex,..) to extract level information into level label that is used to determine log level. +> **Tip:** If you use Loki data source and the "level" is in your log-line, use parsers (JSON, logfmt, regex,..) to extract the level information into a level label that is used to determine log level. This will allow the histogram to show the various log levels in separate bars. **Supported log levels and mapping of log level abbreviation and expressions:** diff --git a/docs/sources/http_api/dashboard_versions.md b/docs/sources/http_api/dashboard_versions.md index 7e301d390b3..23494e40e0e 100644 --- a/docs/sources/http_api/dashboard_versions.md +++ b/docs/sources/http_api/dashboard_versions.md @@ -67,7 +67,7 @@ Status Codes: ## Get dashboard version -`GET /api/dashboards/id/:dashboardId/versions/:id` +`GET /api/dashboards/id/:dashboardId/versions/:version` Get the dashboard version with the given version, for the dashboard with the given id. diff --git a/docs/sources/manage-users/user-admin/_index.md b/docs/sources/manage-users/user-admin/_index.md deleted file mode 100644 index 1734df64364..00000000000 --- a/docs/sources/manage-users/user-admin/_index.md +++ /dev/null @@ -1,10 +0,0 @@ -+++ -title = "User account tasks" -weight = 400 -+++ - -# User account tasks - -Grafana allows you to manage certain aspects of your user account, including the user name, email, and password. - -You can also view important aspects of your account, such as the organizations and roles assigned and the Grafana sessions associated with the account. diff --git a/docs/sources/manage-users/user-admin/change-your-password.md b/docs/sources/manage-users/user-admin/change-your-password.md deleted file mode 100644 index efcdcc267a0..00000000000 --- a/docs/sources/manage-users/user-admin/change-your-password.md +++ /dev/null @@ -1,21 +0,0 @@ -+++ -title = "Change your password" -description = "How to change your Grafana password" -keywords = ["grafana", "password", "change", "preferences"] -aliases = ["/docs/grafana/latest/administration/change-your-password/"] -weight = 200 -+++ - -# Change your Grafana password - -You can change your Grafana password in the Change Password tab. - -> **Note:** If your Grafana instance uses an external authentication provider, then you might not be able to change your password. Contact your Grafana administrator for more information. - -## Change your password - -1. Hover your mouse over your user icon in the lower left corner of the screen. -1. Click **Change Password**. Grafana opens the Change Password tab. -1. Enter your **Old password** to authorize the change. -1. Enter your **New password** and then **Confirm password**. -1. Click **Change Password**. diff --git a/docs/sources/manage-users/user-admin/switch-org.md b/docs/sources/manage-users/user-admin/switch-org.md deleted file mode 100644 index a9c7230442f..00000000000 --- a/docs/sources/manage-users/user-admin/switch-org.md +++ /dev/null @@ -1,12 +0,0 @@ -+++ -title = "Switch organization" -description = "Change which organization you are logged in with" -weight = 300 -+++ - -# Change the organization you are signed in to - -When you sign in to Grafana, you are always signed in with a particular organization. If you are assigned to multiple organizations, then you might need to switch which organization you are signed in to. For example, if you need to view a dashboard associated with a different org, then you might switch organizations. - -1. Hover your cursor over your user icon in the lower left corner of the screen, then click **Switch**. -1. Next to the organization that you want to sign in to, click **Switch to**. diff --git a/docs/sources/manage-users/user-admin/user-profile.md b/docs/sources/manage-users/user-admin/user-profile.md deleted file mode 100644 index 95973c1ac80..00000000000 --- a/docs/sources/manage-users/user-admin/user-profile.md +++ /dev/null @@ -1,42 +0,0 @@ -+++ -title = "User account profile" -description = "View and edit your Grafana user profile" -weight = 100 -+++ - -# Grafana user account profile - -You can edit and view important information about your Grafana user account, including your assigned organizations, your sessions, and the information associated with your account. - -## Edit your profile - -Your profile includes your name, user name, and email address. - -1. Navigate to the Preferences tab. Hover your cursor over your user icon in the lower left corner of the screen, and then click **Preferences.** -1. In the Edit Profile section, you can edit any of the following: - - **Name -** Edit this field to change the display name associated with your profile. - - **Email -** Edit this field to change the email address associated with your profile. - - **Username -** Edit this field to change your user name. -1. Click **Save**. - -## View your assigned organizations - -Every user is a member of at least one organization. You can have different roles in every organization that you are a member of. - -1. Navigate to the Preferences tab. Hover your cursor over your user icon in the lower left corner of the screen, and then click **Preferences.** -1. Scroll down to the Organizations section. - - **Name -** The name of the organizations you are a member of in that Grafana instance. - - **Role -** The role you are assigned in the organization. Refer to [Organization users and permissions]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions.md#organization-users-and-permissions" >}}) for more information about permissions assigned to each role. - - **Current -** Grafana tags the organization that you are currently signed in to as _Current_. If you are part of multiple organizations, then you can click **Select** to switch to that organization. - -## View your Grafana sessions - -Grafana logs your sessions in each Grafana instance. You can review this section if you suspect someone has misused your Grafana credentials. - -1. Navigate to the Preferences tab. Hover your cursor over your user icon in the lower left corner of the screen, and then click **Preferences.** -1. Scroll down to the Sessions section. Grafana displays the following: - - **Last seen -** How long ago you logged on. - - **Logged on -** The date you logged on to the current Grafana instance. - - **IP address -** The IP address that you logged on from. - - **Browser & OS -** The web browser and operating system used to log on to Grafana. - - If you are a Grafana Admin for the instance, then you can revoke a session by clicking the red signout icon in the session row. diff --git a/docs/sources/release-notes/_index.md b/docs/sources/release-notes/_index.md index cb08b39c58e..0c22f453f16 100644 --- a/docs/sources/release-notes/_index.md +++ b/docs/sources/release-notes/_index.md @@ -8,8 +8,11 @@ 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.3]({{< relref "release-notes-8-4-3" >}}) +- [Release notes for 8.4.2]({{< relref "release-notes-8-4-2" >}}) - [Release notes for 8.4.1]({{< relref "release-notes-8-4-1" >}}) - [Release notes for 8.4.0-beta1]({{< relref "release-notes-8-4-0-beta1" >}}) +- [Release notes for 8.3.7]({{< relref "release-notes-8-3-7" >}}) - [Release notes for 8.3.6]({{< relref "release-notes-8-3-6" >}}) - [Release notes for 8.3.5]({{< relref "release-notes-8-3-5" >}}) - [Release notes for 8.3.4]({{< relref "release-notes-8-3-4" >}}) diff --git a/docs/sources/release-notes/release-notes-8-3-7.md b/docs/sources/release-notes/release-notes-8-3-7.md new file mode 100644 index 00000000000..cf47672019a --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-3-7.md @@ -0,0 +1,12 @@ ++++ +title = "Release notes for Grafana 8.3.7" +hide_menu = true ++++ + + + +# Release notes for Grafana 8.3.7 + +### Bug fixes + +- **Provisioning:** Ensure that the default value for orgID is set when provisioning datasources to be deleted. [#44244](https://github.com/grafana/grafana/pull/44244), [@filewalkwithme](https://github.com/filewalkwithme) diff --git a/docs/sources/release-notes/release-notes-8-4-2.md b/docs/sources/release-notes/release-notes-8-4-2.md new file mode 100644 index 00000000000..760462b592f --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-4-2.md @@ -0,0 +1,17 @@ ++++ +title = "Release notes for Grafana 8.4.2" +hide_menu = true ++++ + + + +# Release notes for Grafana 8.4.2 + +### Features and enhancements + +- **OAuth:** Add setting to skip org assignment for external users. [#34834](https://github.com/grafana/grafana/pull/34834), [@baez90](https://github.com/baez90) +- **Tracing:** Add option to map tag names to log label names in trace to logs settings. [#45178](https://github.com/grafana/grafana/pull/45178), [@connorlindsey](https://github.com/connorlindsey) + +### Bug fixes + +- **Explore:** Fix closing split pane when logs panel is used. [#45602](https://github.com/grafana/grafana/pull/45602), [@ifrost](https://github.com/ifrost) diff --git a/docs/sources/release-notes/release-notes-8-4-3.md b/docs/sources/release-notes/release-notes-8-4-3.md new file mode 100644 index 00000000000..90f989988f3 --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-4-3.md @@ -0,0 +1,25 @@ ++++ +title = "Release notes for Grafana 8.4.3" +hide_menu = true ++++ + + + +# Release notes for Grafana 8.4.3 + +### Features and enhancements + +- **Alerting:** Grafana uses > instead of >= when checking the For duration. [#46010](https://github.com/grafana/grafana/issues/46010) +- **Alerting:** Use expanded labels in dashboard annotations. [#45726](https://github.com/grafana/grafana/pull/45726), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Logs:** Escape windows newline into single newline. [#45771](https://github.com/grafana/grafana/pull/45771), [@perosb](https://github.com/perosb) + +### Bug fixes + +- **Alerting:** Fix use of > instead of >= when checking the For duration. [#46011](https://github.com/grafana/grafana/pull/46011), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Azure Monitor:** Fixes broken log queries that use workspace. [#45820](https://github.com/grafana/grafana/pull/45820), [@sunker](https://github.com/sunker) +- **CloudWatch:** Remove error message when using multi-valued template vars in region field. [#45886](https://github.com/grafana/grafana/pull/45886), [@sunker](https://github.com/sunker) +- **Middleware:** Fix IPv6 host parsing in CSRF check. [#45911](https://github.com/grafana/grafana/pull/45911), [@ying-jeanne](https://github.com/ying-jeanne) + +### Plugin development fixes & changes + +- **ClipboardButton:** Use a fallback when the Clipboard API is unavailable. [#45831](https://github.com/grafana/grafana/pull/45831), [@ashharrison90](https://github.com/ashharrison90) diff --git a/docs/sources/visualizations/table/_index.md b/docs/sources/visualizations/table/_index.md index 8a43c64df62..57d8589d59a 100644 --- a/docs/sources/visualizations/table/_index.md +++ b/docs/sources/visualizations/table/_index.md @@ -96,6 +96,12 @@ If you have a field value that is an image URL or a base64 encoded image you can {{< figure src="/static/img/docs/v73/table_hover.gif" max-width="900px" caption="Table hover" >}} +## Cell value inspect + +Enables value inspection from table cell. The raw value is presented in a modal window. + +> **Note:** Cell value inspection is only available when cell display mode is set to Auto, Color text, Color background or JSON View. + ## 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" >}}). diff --git a/e2e/dashboards-suite/templating-dashboard-links-and-variables.spec.ts b/e2e/dashboards-suite/templating-dashboard-links-and-variables.spec.ts index 39c87b73448..c2eb79f780c 100644 --- a/e2e/dashboards-suite/templating-dashboard-links-and-variables.spec.ts +++ b/e2e/dashboards-suite/templating-dashboard-links-and-variables.spec.ts @@ -5,9 +5,8 @@ e2e.scenario({ itName: 'Tests dashboard links and variables in links', addScenarioDataSource: false, addScenarioDashBoard: false, - skipScenario: true, // Skipped because it was causing many failures in main. + skipScenario: false, scenario: () => { - e2e.flows.openDashboard({ uid: 'yBCC3aKGk' }); e2e() .intercept({ method: 'GET', @@ -21,7 +20,11 @@ e2e.scenario({ }) .as('tagsDemoSearch'); - // waiting for links to render, couldn't find a better way using routes for instance + e2e.flows.openDashboard({ uid: 'yBCC3aKGk' }); + + // waiting for network requests first + e2e().wait(['@tagsTemplatingSearch', '@tagsDemoSearch']); + // and then waiting for links to render e2e().wait(1000); const verifyLinks = (variableValue: string) => { @@ -36,11 +39,7 @@ e2e.scenario({ }); }; - e2e.components.DashboardLinks.dropDown() - .should('be.visible') - .click() - .wait('@tagsTemplatingSearch') - .wait('@tagsDemoSearch'); + e2e.components.DashboardLinks.dropDown().should('be.visible').click().wait('@tagsTemplatingSearch'); // verify all links, should have All value verifyLinks('All'); diff --git a/go.mod b/go.mod index 19c0d32aa94..04e2a981048 100644 --- a/go.mod +++ b/go.mod @@ -14,16 +14,16 @@ replace k8s.io/client-go => k8s.io/client-go v0.22.1 replace github.com/russellhaering/goxmldsig@v1.1.0 => github.com/russellhaering/goxmldsig v1.1.1 require ( - cloud.google.com/go/storage v1.14.0 + cloud.google.com/go/storage v1.18.2 cuelang.org/go v0.4.0 - github.com/Azure/azure-sdk-for-go v57.1.0+incompatible + 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.20 + github.com/Azure/go-autorest/autorest v0.11.22 github.com/BurntSushi/toml v0.3.1 github.com/Masterminds/semver v1.5.0 github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f - github.com/aws/aws-sdk-go v1.40.37 + github.com/aws/aws-sdk-go v1.42.8 github.com/beevik/etree v1.1.0 github.com/benbjohnson/clock v1.1.0 github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b @@ -31,7 +31,7 @@ require ( github.com/cortexproject/cortex v1.10.1-0.20211014125347-85c378182d0d github.com/crewjam/saml v0.4.6-0.20210521115923-29c6295245bd github.com/davecgh/go-spew v1.1.1 - github.com/denisenkom/go-mssqldb v0.10.0 + github.com/denisenkom/go-mssqldb v0.11.0 github.com/dop251/goja v0.0.0-20210804101310-32956a348b49 github.com/fatih/color v1.10.0 github.com/gchaincl/sqlhooks v1.3.0 @@ -66,7 +66,7 @@ require ( github.com/json-iterator/go v1.1.12 github.com/jung-kurt/gofpdf v1.16.2 github.com/laher/mergefs v0.1.1 - github.com/lib/pq v1.10.0 + github.com/lib/pq v1.10.4 github.com/linkedin/goavro/v2 v2.10.0 github.com/m3db/prometheus_remote_client_golang v0.4.4 github.com/magefile/mage v1.12.1 @@ -103,16 +103,16 @@ require ( go.opentelemetry.io/otel/exporters/jaeger v1.0.0 go.opentelemetry.io/otel/sdk v1.0.0 go.opentelemetry.io/otel/trace v1.2.0 - golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e - golang.org/x/exp v0.0.0-20210220032938-85be41e4509f // indirect - golang.org/x/net v0.0.0-20211013171255-e13a2654a71e - golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f + golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 + golang.org/x/exp v0.0.0-20210220032938-85be41e4509f + golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 + golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c - golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac + golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 golang.org/x/tools v0.1.5 gonum.org/v1/gonum v0.9.3 - google.golang.org/api v0.58.0 - google.golang.org/grpc v1.41.0 + google.golang.org/api v0.60.0 + google.golang.org/grpc v1.42.0 google.golang.org/protobuf v1.27.1 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/ini.v1 v1.62.0 @@ -168,14 +168,14 @@ require ( github.com/go-openapi/errors v0.20.0 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.19.6 // indirect - github.com/go-openapi/loads v0.20.2 // indirect + github.com/go-openapi/loads v0.20.2 github.com/go-openapi/runtime v0.19.29 // indirect - github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/spec v0.20.4 github.com/go-openapi/swag v0.19.15 // indirect github.com/go-openapi/validate v0.20.2 // indirect github.com/gogo/googleapis v1.4.1 // indirect github.com/gogo/status v1.1.0 // indirect - github.com/golang-jwt/jwt/v4 v4.0.0 // indirect + github.com/golang-jwt/jwt/v4 v4.1.0 // indirect github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect @@ -207,7 +207,7 @@ require ( github.com/mattn/go-runewidth v0.0.9 // indirect github.com/miekg/dns v1.1.43 // indirect github.com/mitchellh/go-testing-interface v1.14.0 // indirect - github.com/mitchellh/mapstructure v1.4.1 // indirect + github.com/mitchellh/mapstructure v1.4.2 // indirect github.com/mna/redisc v1.3.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -242,27 +242,32 @@ require ( go.mongodb.org/mongo-driver v1.7.0 // indirect go.opencensus.io v0.23.0 // indirect go.uber.org/atomic v1.9.0 - go.uber.org/goleak v1.1.10 // indirect - golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect + go.uber.org/goleak v1.1.11-0.20210813005559-691160354723 // indirect golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20211018162055-cf77aa76bad2 // indirect + 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/golang-migrate/migrate/v4 v4.7.0 + gocloud.dev v0.24.0 ) require ( - github.com/Azure/go-autorest/autorest/adal v0.9.15 // indirect + github.com/Azure/go-autorest/autorest/adal v0.9.17 // indirect + github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect + github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect + github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 // indirect github.com/containerd/containerd v1.5.9 // indirect + github.com/envoyproxy/go-control-plane v0.10.1 // indirect + github.com/envoyproxy/protoc-gen-validate v0.6.2 // 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.1 // indirect + github.com/klauspost/compress v1.13.6 // indirect github.com/opencontainers/image-spec v1.0.2 // indirect github.com/pierrec/lz4/v4 v4.1.8 // indirect github.com/segmentio/asm v1.1.1 // indirect @@ -274,3 +279,7 @@ replace github.com/crewjam/saml => github.com/grafana/saml v0.0.0-20211007135653 replace github.com/apache/thrift => github.com/apache/thrift v0.14.1 replace github.com/hashicorp/consul => github.com/hashicorp/consul v1.10.2 + +// TODO: remove once gocloud.dev releases 0.25.x +// `fileblob` implementation has buggy key ordering in 0.24.0 +replace gocloud.dev v0.24.0 => github.com/google/go-cloud v0.24.1-0.20220209172924-99801bbb523a diff --git a/go.sum b/go.sum index 67d93cc8c4c..b409fd39e11 100644 --- a/go.sum +++ b/go.sum @@ -20,10 +20,10 @@ cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOY cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= +cloud.google.com/go v0.82.0/go.mod h1:vlKccHJGuFBFufnAnuB08dfEH9Y3H7dzDzRECFdC2TA= cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= @@ -44,25 +44,33 @@ cloud.google.com/go/bigtable v1.3.0/go.mod h1:z5EyKrPE8OQmeg4h5MNdKvuSnI9CCT49Ki cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= cloud.google.com/go/kms v1.0.0/go.mod h1:nhUehi+w7zht2XrUfvTRNpxrfayBHqP4lu2NSywui/0= cloud.google.com/go/kms v1.1.0 h1:1yc4rLqCkVDS9Zvc7m+3mJ47kw0Uo5Q5+sMjcmUVUeM= cloud.google.com/go/kms v1.1.0/go.mod h1:WdbppnCDMDpOvoYBMn1+gNmOeEoZYqAv+HeuKARGCXI= +cloud.google.com/go/monitoring v1.1.0/go.mod h1:L81pzz7HKn14QCMaCs6NTQkdBnE87TElyanS95vIcl4= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/pubsub v1.17.1/go.mod h1:4qDxMr1WsM9+aQAz36ltDwCIM+R0QdlseyFjBuNvnss= +cloud.google.com/go/secretmanager v1.0.0/go.mod h1:+Qkm5qxIJ5mk74xxIXA+87fseaY1JLYBcFPQoc/GQxg= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.3.0/go.mod h1:9IAwXhoyBJ7z9LcAwkj0/7NnPzYaPeZxxVp3zm+5IqA= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -cloud.google.com/go/storage v1.14.0 h1:6RRlFMv1omScs6iq2hfE3IvgE+l6RfJPampq8UZc5TU= -cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +cloud.google.com/go/storage v1.18.2 h1:5NQw6tOn3eMm0oE8vTkfjau18kjL79FlMjy/CHTpmoY= +cloud.google.com/go/storage v1.18.2/go.mod h1:AiIj7BWXyhO5gGVmYJ+S8tbkCx3yb0IMjua8Aw4naVM= +cloud.google.com/go/trace v1.0.0/go.mod h1:4iErSByzxkyHWzzlAj63/Gmjz0NH1ASqhJguHpGcr6A= code.cloudfoundry.org/clock v1.0.0/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= collectd.org v0.3.0/go.mod h1:A/8DzQBkF6abtvrT2j/AU/4tiBgJWYyh0y/oB/4MlWE= +contrib.go.opencensus.io/exporter/aws v0.0.0-20200617204711-c478e41e60e9/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA= contrib.go.opencensus.io/exporter/ocagent v0.6.0/go.mod h1:zmKjrJcdo0aYcVS7bmEeSEBLPA9YJp5bjrofdU3pIXs= contrib.go.opencensus.io/exporter/prometheus v0.3.0/go.mod h1:rpCPVQKhiyH8oomWgm34ZmgIdZa8OVYO5WAIygPbBBE= +contrib.go.opencensus.io/exporter/stackdriver v0.13.10/go.mod h1:I5htMbyta491eUxufwwZPQdcKvvgzMB4O9ni41YnIM8= +contrib.go.opencensus.io/integrations/ocsql v0.1.7/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE= cuelang.org/go v0.4.0 h1:GLJblw6m2WGGCA3k1v6Wbk9gTOt2qto48ahO2MmSd6I= cuelang.org/go v0.4.0/go.mod h1:tz/edkPi+T37AZcb5GlPY+WJkL6KiDlDVupKwL3vvjs= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= @@ -70,6 +78,8 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7 gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/Azure/azure-amqp-common-go/v3 v3.0.0/go.mod h1:SY08giD/XbhTz07tJdpw1SoxQXHPN30+DI3Z04SYqyg= +github.com/Azure/azure-amqp-common-go/v3 v3.2.1/go.mod h1:O6X1iYHP7s2x7NjUKsXVhkwWrQhxrd+d8/3rRadj4CI= +github.com/Azure/azure-amqp-common-go/v3 v3.2.2/go.mod h1:O6X1iYHP7s2x7NjUKsXVhkwWrQhxrd+d8/3rRadj4CI= github.com/Azure/azure-event-hubs-go/v3 v3.2.0/go.mod h1:BPIIJNH/l/fVHYq3Rm6eg4clbrULrQ3q7+icmqHyyLc= github.com/Azure/azure-pipeline-go v0.1.8/go.mod h1:XA1kFWRVhSK+KNFiOhfv83Fv8L9achrP7OxIzeTn1Yg= github.com/Azure/azure-pipeline-go v0.1.9/go.mod h1:XA1kFWRVhSK+KNFiOhfv83Fv8L9achrP7OxIzeTn1Yg= @@ -88,23 +98,29 @@ github.com/Azure/azure-sdk-for-go v44.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo github.com/Azure/azure-sdk-for-go v45.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v46.4.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v48.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v51.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v51.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v52.5.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v54.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v55.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go v57.1.0+incompatible h1:TKQ3ieyB0vVKkF6t9dsWbMjq56O1xU3eh3Ec09v6ajM= github.com/Azure/azure-sdk-for-go v57.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v59.3.0+incompatible h1:dPIm0BO4jsMXFcCI/sLTPkBtE7mk8WMuRHA0JeWhlcQ= +github.com/Azure/azure-sdk-for-go v59.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0 h1:lhSJz9RMbJcTgxifR1hUNJnn6CNYtbgEDtQV22/9RBA= github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.10.0 h1:jq5Urf8QJK6h0wr8CMiwggo4OSMkXwpArQlkSjSpaBk= github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.10.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0 h1:v9p9TfTbf7AwNb5NYQt7hI41IfPoLFiFkLtb+bmGjT0= github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= +github.com/Azure/azure-service-bus-go v0.11.5/go.mod h1:MI6ge2CuQWBVq+ly456MY7XqNLJip5LO1iSFodbNLbU= github.com/Azure/azure-storage-blob-go v0.6.0/go.mod h1:oGfmITT1V6x//CswqY2gtAHND+xIP64/qL7a5QJix0Y= github.com/Azure/azure-storage-blob-go v0.8.0/go.mod h1:lPI3aLPpuLTeUwh1sViKXFxwl2B6teiRqI0deQUvsw0= github.com/Azure/azure-storage-blob-go v0.13.0/go.mod h1:pA9kNqtjUeQF2zOSu4s//nUdBD+e64lEuc4sVnuOfNs= +github.com/Azure/azure-storage-blob-go v0.14.0/go.mod h1:SMqIBi+SuiQH32bvyjngEewEeXoPfKMgWlBDaYf6fck= github.com/Azure/azure-storage-queue-go v0.0.0-20181215014128-6ed74e755687/go.mod h1:K6am8mT+5iFXgingS9LUc7TmbsW6XBw3nxaRyaMyWc8= github.com/Azure/go-amqp v0.12.6/go.mod h1:qApuH6OFTSKZFmCOxccvAv5rLizBQf4v8pRmG138DPo= +github.com/Azure/go-amqp v0.16.0/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fwmw9Zlg= +github.com/Azure/go-amqp v0.16.4/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fwmw9Zlg= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest v11.2.8+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= @@ -123,8 +139,9 @@ github.com/Azure/go-autorest/autorest v0.11.11/go.mod h1:eipySxLmqSyC5s5k1CLupqe github.com/Azure/go-autorest/autorest v0.11.17/go.mod h1:eipySxLmqSyC5s5k1CLupqet0PSENBEDP93LQ9a8QYw= github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= github.com/Azure/go-autorest/autorest v0.11.19/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= -github.com/Azure/go-autorest/autorest v0.11.20 h1:s8H1PbCZSqg/DH7JMlOz6YMig6htWLNPsjDdlLqCx3M= github.com/Azure/go-autorest/autorest v0.11.20/go.mod h1:o3tqFY+QR40VOlk+pV4d77mORO64jOXSgEnPQgLK6JY= +github.com/Azure/go-autorest/autorest v0.11.22 h1:bXiQwDjrRmBQOE67bwlvUKAC1EU1yZTPQ38c+bstZws= +github.com/Azure/go-autorest/autorest v0.11.22/go.mod h1:BAWYUWGPEtKPzjVkp0Q6an0MJcJDsoh5Z1BFAEFs4Xs= github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= github.com/Azure/go-autorest/autorest/adal v0.8.0/go.mod h1:Z6vX6WXXuyieHAXwMj0S6HY6e6wcHn37qQMBQlvY3lc= github.com/Azure/go-autorest/autorest/adal v0.8.1-0.20191028180845-3492b2aff503/go.mod h1:Z6vX6WXXuyieHAXwMj0S6HY6e6wcHn37qQMBQlvY3lc= @@ -137,12 +154,15 @@ github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyC github.com/Azure/go-autorest/autorest/adal v0.9.11/go.mod h1:nBKAnTomx8gDtl+3ZCJv2v0KACFHWTB2drffI1B68Pk= github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= github.com/Azure/go-autorest/autorest/adal v0.9.14/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= -github.com/Azure/go-autorest/autorest/adal v0.9.15 h1:X+p2GF0GWyOiSmqohIaEeuNFNDY4I4EOlVuUQvFdWMk= github.com/Azure/go-autorest/autorest/adal v0.9.15/go.mod h1:tGMin8I49Yij6AQ+rvV+Xa/zwxYQB5hmsd6DkfAx2+A= +github.com/Azure/go-autorest/autorest/adal v0.9.17 h1:esOPl2dhcz9P3jqBSJ8tPGEj2EqzPPT6zfyuloiogKY= +github.com/Azure/go-autorest/autorest/adal v0.9.17/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= github.com/Azure/go-autorest/autorest/azure/auth v0.4.2/go.mod h1:90gmfKdlmKgfjUpnCEpOJzsUEjrWDSLwHIG73tSXddM= github.com/Azure/go-autorest/autorest/azure/auth v0.5.8/go.mod h1:kxyKZTSfKh8OVFWPAgOgQ/frrJgeYQJPyR5fLFmXko4= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.9/go.mod h1:hg3/1yw0Bq87O3KvvnJoAh34/0zbP7SFizX/qN5JvjU= github.com/Azure/go-autorest/autorest/azure/cli v0.3.1/go.mod h1:ZG5p860J94/0kI9mNJVoIoLgXcirM2gF5i2kWloofxw= github.com/Azure/go-autorest/autorest/azure/cli v0.4.2/go.mod h1:7qkJkT+j6b+hIpzMOwPChJhTqS8VbsqqgULzMNRugoM= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.4/go.mod h1:yAQ2b6eP/CmLPnmLvxtT1ALIY3OR1oFcCqVBi8vHiTc= github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= github.com/Azure/go-autorest/autorest/date v0.2.0/go.mod h1:vcORJHLJEh643/Ioh9+vPmf1Ij9AEBM5FuBIXLmIy0g= github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw= @@ -183,6 +203,7 @@ github.com/FZambia/eagle v0.0.1 h1:FN1yTkPihMb5nE8SrlRjoCf7T9H9bTKJFQOm6ach2YU= github.com/FZambia/eagle v0.0.1/go.mod h1:xq6u/JeNZ5/8mrAQ76MMhzNTodASh9FavQlCgg4j48w= github.com/FZambia/sentinel v1.1.0 h1:qrCBfxc8SvJihYNjBWgwUI93ZCvFe/PJIPTHKmlp8a8= github.com/FZambia/sentinel v1.1.0/go.mod h1:ytL1Am/RLlAoAXG6Kj5LNuw/TRRQrv2rt2FT26vP5gI= +github.com/GoogleCloudPlatform/cloudsql-proxy v1.27.0/go.mod h1:bn9iHmAjogMoIPkqBGyJ9R1m9cXGCjBE/cuhBs3oEsQ= github.com/HdrHistogram/hdrhistogram-go v0.9.0/go.mod h1:nxrse8/Tzg2tg3DZcZjm6qEclQKK70g0KxO61gFFZD4= github.com/HdrHistogram/hdrhistogram-go v1.0.1/go.mod h1:BWJ+nMSHY3L41Zj7CA3uXnloDp7xxV0YvstAE7nKTaM= github.com/HdrHistogram/hdrhistogram-go v1.1.0 h1:6dpdDPTRoo78HxAJ6T1HfMiKSnqhgRRqzCuPshRkQ7I= @@ -306,6 +327,7 @@ github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= +github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= github.com/aws/aws-sdk-go v1.15.78/go.mod h1:E3/ieXAlvM0XWO57iftYVDLLvQ824smPP3ATZkfNZeM= github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.22.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= @@ -324,18 +346,57 @@ github.com/aws/aws-sdk-go v1.34.34/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/ github.com/aws/aws-sdk-go v1.35.5/go.mod h1:tlPOdRjfxPBpNIwqDj61rmsnA85v9jc0Ps9+muhnW+k= github.com/aws/aws-sdk-go v1.35.30/go.mod h1:tlPOdRjfxPBpNIwqDj61rmsnA85v9jc0Ps9+muhnW+k= github.com/aws/aws-sdk-go v1.35.31/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.37.8/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.38.3/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.38.60/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.40.11/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= -github.com/aws/aws-sdk-go v1.40.37 h1:I+Q6cLctkFyMMrKukcDnj+i2kjrQ37LGiOM6xmsxC48= github.com/aws/aws-sdk-go v1.40.37/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= +github.com/aws/aws-sdk-go v1.42.8 h1:Tj2RP4Fas1mYchwbmw0qWLJIEATAseyp5iTa1D+LWYQ= +github.com/aws/aws-sdk-go v1.42.8/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4= +github.com/aws/aws-sdk-go-v2 v1.11.0 h1:HxyD62DyNhCfiFGUHqJ/xITD6rAjJ7Dm/2nLxLmO4Ag= +github.com/aws/aws-sdk-go-v2 v1.11.0/go.mod h1:SQfA+m2ltnu1cA0soUkj4dRSsmITiVQUJvBIZjzfPyQ= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.0.0 h1:yVUAwvJC/0WNPbyl0nA3j1L6CW1CN8wBubCRqtG7JLI= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.0.0/go.mod h1:Xn6sxgRuIDflLRJFj5Ev7UxABIkNbccFPV/p8itDReM= +github.com/aws/aws-sdk-go-v2/config v1.10.1 h1:z/ViqIjW6ZeuLWgTWMTSyZzaVWo/1cWeVf1Uu+RF01E= +github.com/aws/aws-sdk-go-v2/config v1.10.1/go.mod h1:auIv5pIIn3jIBHNRcVQcsczn6Pfa6Dyv80Fai0ueoJU= +github.com/aws/aws-sdk-go-v2/credentials v1.6.1 h1:A39JYth2fFCx+omN/gib/jIppx3rRnt2r7UKPq7Mh5Y= +github.com/aws/aws-sdk-go-v2/credentials v1.6.1/go.mod h1:QyvQk1IYTqBWSi1T6UgT/W8DMxBVa5pVuLFSRLLhGf8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.8.0 h1:OpZjuUy8Jt3CA1WgJgBC5Bz+uOjE5Ppx4NFTRaooUuA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.8.0/go.mod h1:5E1J3/TTYy6z909QNR0QnXGBpfESYGDqd3O0zqONghU= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.7.1 h1:p9Dys1g2YdaqMalnp6AwCA+tpMMdJNGw5YYKP/u3sUk= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.7.1/go.mod h1:wN/mvkow08GauDwJ70jnzJ1e+hE+Q3Q7TwpYLXOe9oI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.0 h1:zY8cNmbBXt3pzjgWgdIbzpQ6qxoCwt+Nx9JbrAf2mbY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.0/go.mod h1:NO3Q5ZTTQtO2xIg2+xTXYDiT7knSejfeDm7WGDaOo0U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.0.0 h1:Z3aR/OXBnkYK9zXkNkfitHX6SmUBzSsx8VMHbH4Lvhw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.0.0/go.mod h1:anlUzBoEWglcUxUQwZA7HQOEVEnQALVZsizAapB2hq8= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.0 h1:c10Z7fWxtJCoyc8rv06jdh9xrKnu7bAJiRaKWvTb2mU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.0/go.mod h1:6oXGy4GLpypD3uCh8wcqztigGgmhLToMfjavgh+VySg= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.5.0/go.mod h1:acH3+MQoiMzozT/ivU+DbRg7Ooo2298RdRaWcOv+4vM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.5.0 h1:lPLbw4Gn59uoKqvOfSnkJr54XWk5Ak1NK20ZEiSWb3U= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.5.0/go.mod h1:80NaCIH9YU3rzTTs/J/ECATjXuRqzo/wB6ukO6MZ0XY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.5.0 h1:qGZWS/WgiFY+Zgad2u0gwBHpJxz6Ne401JE7iQI1nKs= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.5.0/go.mod h1:Mq6AEc+oEjCUlBuLiK5YwW4shSOAKCQ3tXN0sQeYoBA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.9.0 h1:0BOlTqnNnrEO04oYKzDxMMe68t107pmIotn18HtVonY= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.9.0/go.mod h1:xKCZ4YFSF2s4Hnb/J0TLeOsKuGzICzcElaOKNGrVnx4= +github.com/aws/aws-sdk-go-v2/service/kms v1.10.0/go.mod h1:ZkHWL8m5Nw1g9yMXqpCjnIJtSDToAmNbXXZ9gj0bO7s= +github.com/aws/aws-sdk-go-v2/service/s3 v1.19.0 h1:5mRAms4TjSTOGYsqKYte5kHr1PzpMJSyLThjF3J+hw0= +github.com/aws/aws-sdk-go-v2/service/s3 v1.19.0/go.mod h1:Gwz3aVctJe6mUY9T//bcALArPUaFmNAy2rTB9qN4No8= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.10.0/go.mod h1:qAgsrzF3Z2vvV01j79fs7D75ofCMQe81/OKBJx0rjFY= +github.com/aws/aws-sdk-go-v2/service/sns v1.11.0/go.mod h1:LIPf3BTbSY5UeVli+x/1y2Qw1w8T9DYyp7p18Qt8Zc8= +github.com/aws/aws-sdk-go-v2/service/sqs v1.12.0/go.mod h1:TDqDmQnsbgL2ZMIGUf3z9xTzCMqFX7FP1geAgIlYqvA= +github.com/aws/aws-sdk-go-v2/service/ssm v1.15.0/go.mod h1:kJa2uHklY03rKsNSbEsToeUgWJ1PambXBtRNacorRhg= +github.com/aws/aws-sdk-go-v2/service/sso v1.6.0 h1:JDgKIUZOmLFu/Rv6zXLrVTWCmzA0jcTdvsT8iFIKrAI= +github.com/aws/aws-sdk-go-v2/service/sso v1.6.0/go.mod h1:Q/l0ON1annSU+mc0JybDy1Gy6dnJxIcWjphO6qJPzvM= +github.com/aws/aws-sdk-go-v2/service/sts v1.10.0 h1:1jh8J+JjYRp+QWKOsaZt7rGUgoyrqiiVwIm+w0ymeUw= +github.com/aws/aws-sdk-go-v2/service/sts v1.10.0/go.mod h1:jLKCFqS+1T4i7HDqCP9GM4Uk75YW1cS0o82LdxpMyOE= github.com/aws/smithy-go v1.5.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= +github.com/aws/smithy-go v1.9.0 h1:c7FUdEqrQA1/UVKKCNDFQPNKGp4FQg3YW4Ck5SLTG58= +github.com/aws/smithy-go v1.9.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc= github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= @@ -395,6 +456,7 @@ github.com/cenkalti/backoff/v4 v4.1.0/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInq github.com/cenkalti/backoff/v4 v4.1.1 h1:G2HAfAmvm/GcKan2oOQpBXOd2tT2G57ZnZGWa1PxPBQ= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.3.0 h1:t/LhUZLVitR1Ow2YOnduCsavhwFUklBMoGVYUCqmCqk= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/centrifugal/centrifuge v0.19.0 h1:YHws0dRpgsBiI73tRl1wwaB13gzuaI1AM4IFcQQQqcw= github.com/centrifugal/centrifuge v0.19.0/go.mod h1:O2elf8q3Qkie3z97wkqVqxB52pnOpPsfFUa7L88Lpy0= @@ -432,9 +494,14 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/cncf/udpa/go v0.0.0-20200313221541-5f7e5dd04533/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 h1:hzAQntlaYRkVSFEfj9OTWlVV1H155FMD8BTKktLv0QI= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158 h1:CevA8fI91PAnP8vpnXuB8ZYAZ5wqY86nAbxfgK8tWO4= github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 h1:zH8ljVhhq7yC0MIeUL/IviMtY8hx2mK8cN9wEYb8ggw= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/apd/v2 v2.0.1/go.mod h1:DDxRlzC2lo3/vSlmSoS7JkqbbrARPuFOGr0B9pvN3Gw= @@ -722,11 +789,13 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.9/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021 h1:fP+fF0up6oPY49OrjPrhIJ8yQfdIM85NXMLkMg1EXVs= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/go-control-plane v0.10.1 h1:cgDRLG7bs59Zd+apAWuzLQL95obVYAymNJek76W3mgw= +github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v0.6.1 h1:4CF52PCseTFt4bE+Yk3dIpdVi7XWuPVMhPtm4FaIJPM= github.com/envoyproxy/protoc-gen-validate v0.6.1/go.mod h1:txg5va2Qkip90uYoSKH+nkAAmXrb2j3iq4FLwdrCbXQ= +github.com/envoyproxy/protoc-gen-validate v0.6.2 h1:JiO+kJTpmYGjEodY7O1Zk8oZcNz1+f30UtwtXoFUPzE= +github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= github.com/ericchiang/k8s v1.2.0/go.mod h1:/OmBgSq2cd9IANnsGHGlEz27nwMZV2YxlpXuQtU3Bz4= github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw= github.com/evanphx/json-patch v0.0.0-20200808040245-162e5629780b/go.mod h1:NAJj0yf/KaRKURN6nyi7A9IZydMivZEm9oQLWNjfKDc= @@ -761,8 +830,9 @@ github.com/frankban/quicktest v1.10.2/go.mod h1:K+q6oSqb0W0Ininfk863uOk1lMy69l/P github.com/frankban/quicktest v1.11.0/go.mod h1:K+q6oSqb0W0Ininfk863uOk1lMy69l/P6txr3mVT54s= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= +github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsouza/fake-gcs-server v1.7.0/go.mod h1:5XIRs4YvwNbNoz+1JF8j6KLAyDh7RHGAyAK3EP2EsNk= github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA= github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= @@ -783,6 +853,8 @@ github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NB github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/gin-gonic/gin v1.7.3/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= github.com/glinton/ping v0.1.4-0.20200311211934-5ac87da8cd96/go.mod h1:uY+1eqFUyotrQxF1wYFNtMeHp/swbYRsoGzfcPZ8x3o= github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= @@ -865,7 +937,6 @@ github.com/go-openapi/jsonreference v0.18.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3Hfo github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= github.com/go-openapi/jsonreference v0.19.4/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= -github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM= github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= @@ -907,7 +978,6 @@ github.com/go-openapi/spec v0.19.14/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeE github.com/go-openapi/spec v0.19.15/go.mod h1:+81FIL1JwC5P3/Iuuozq3pPE9dXdIEGxFutcFKaVbmU= github.com/go-openapi/spec v0.20.0/go.mod h1:+81FIL1JwC5P3/Iuuozq3pPE9dXdIEGxFutcFKaVbmU= github.com/go-openapi/spec v0.20.1/go.mod h1:93x7oh+d+FQsmsieroS4cmR3u0p/ywH649a3qwC9OsQ= -github.com/go-openapi/spec v0.20.3 h1:uH9RQ6vdyPSs2pSy9fL8QPspDF2AMIMPtmK5coSSjtQ= github.com/go-openapi/spec v0.20.3/go.mod h1:gG4F8wdEDN+YPBMVnzE85Rbhf+Th2DTvA9nFPQ5AYEg= github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= @@ -951,8 +1021,13 @@ github.com/go-openapi/validate v0.19.15/go.mod h1:tbn/fdOwYHgrhPBzidZfJC2MIVvs9G github.com/go-openapi/validate v0.20.1/go.mod h1:b60iJT+xNNLfaQJUqLI7946tYiFEOuE9E4k54HpKcJ0= github.com/go-openapi/validate v0.20.2 h1:AhqDegYV3J3iQkMPJSXkvzymHKMTw0BST3RK3hTT4ts= github.com/go-openapi/validate v0.20.2/go.mod h1:e7OJoKNgd0twXZwIn0A43tHbvIcr/rZIVCbJBpTUoY0= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= github.com/go-redis/redis/v8 v8.0.0-beta.10.0.20200905143926-df7fe4e2ce72/go.mod h1:CJP1ZIHwhosNYwIdaHPZK9vHsM3+roNBaZ7U9Of1DXc= @@ -1042,8 +1117,9 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/gogo/status v1.0.3/go.mod h1:SavQ51ycCLnc7dGyJxp8YAmudx8xqiVrRf+6IXRsugc= github.com/gogo/status v1.1.0 h1:+eIkrewn5q6b30y+g/BJINVVdi2xH7je5MPJ3ZPK3JA= github.com/gogo/status v1.1.0/go.mod h1:BFv9nrluPLmrS0EmGVvLaPNmRosr9KapBYd5/hpY1WM= -github.com/golang-jwt/jwt/v4 v4.0.0 h1:RAqyYixv1p7uEnocuy8P1nru5wprCh/MH2BIlW5z5/o= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.1.0 h1:XUgk2Ex5veyVFVeLm0xhusUTQybEbexJXrvPNOKkSY0= +github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-migrate/migrate/v4 v4.7.0 h1:gONcHxHApDTKXDyLH/H97gEHmpu1zcnnbAaq2zgrPrs= github.com/golang-migrate/migrate/v4 v4.7.0/go.mod h1:Qvut3N4xKWjoH3sokBccML6WyHSnggXm/DvMMnTsQIc= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= @@ -1113,6 +1189,8 @@ github.com/google/flatbuffers v1.11.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv github.com/google/flatbuffers v1.12.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v2.0.0+incompatible h1:dicJ2oXwypfwUGnB2/TYWYEKiuk9eYQlQO/AnOHl5mI= github.com/google/flatbuffers v2.0.0+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cloud v0.24.1-0.20220209172924-99801bbb523a h1:Kw18HR30Firrm4cB1BzcVVaFrrdDlAtnrCKgHxemgMI= +github.com/google/go-cloud v0.24.1-0.20220209172924-99801bbb523a/go.mod h1:TqAL9Q5Q8jFUfIDNbDiFbU3w0z3MQ7q4r1wm57tBhmI= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -1132,13 +1210,16 @@ github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3 github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6AwLM6miiXUQwP1S+I9icmhk= +github.com/google/go-replayers/httpreplay v1.0.0/go.mod h1:LJhKoTwS5Wy5Ld/peq8dFFG5OfJyHEz7ft+DsTUv25M= github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible h1:xmapqc1AyLoB+ddYT6r04bD9lIjlOqGaREovi0SzFaE= +github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ= @@ -1158,12 +1239,12 @@ github.com/google/pprof v0.0.0-20201007051231-1066cbb265c7/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201117184057-ae444373da19/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210208152844-1612e9be7af6/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210323184331-8eee2492667d/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210504235042-3a04a4d88a10/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210506205249-923b5ab0fc1a/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= @@ -1241,17 +1322,12 @@ github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f h1:FvvSVEbnGeM2bUivG github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f/go.mod h1:uPG2nyK4CtgNDmWv7qyzYcdI+S90kHHRWvHnBtEMBXM= github.com/grafana/go-mssqldb v0.0.0-20210326084033-d0ce3c521036 h1:GplhUk6Xes5JIhUUrggPcPBhOn+eT8+WsHiebvq7GgA= github.com/grafana/go-mssqldb v0.0.0-20210326084033-d0ce3c521036/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= -github.com/grafana/grafana-aws-sdk v0.9.1 h1:jMZlsLsWnqOwLt2UNcLUsJ2z6289hLYlscK35QgS158= -github.com/grafana/grafana-aws-sdk v0.9.1/go.mod h1:6KaQ8uUD4KpXr/b7bAC7zbfSXTVOiTk4XhIrwkGWn4w= -github.com/grafana/grafana-aws-sdk v0.10.0 h1:q7+mJtT/vsU5InDN57yM+BJ2z1kJDf1W4WwWPEZ0Cxw= -github.com/grafana/grafana-aws-sdk v0.10.0/go.mod h1:vFIOHEnY1u5nY0/tge1IHQjPuG6DRKr2ISf/HikUdjE= 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-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= github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk= -github.com/grafana/grafana-plugin-sdk-go v0.125.0 h1:wK2zopAaKhVIMkXzgbExKqZtt+x2ZTGfcY+3wvOuyYQ= github.com/grafana/grafana-plugin-sdk-go v0.125.0/go.mod h1:9YiJ5GUxIsIEUC0qR9+BJVP5M7mCSP6uc6Ne62YKkgc= github.com/grafana/grafana-plugin-sdk-go v0.126.0 h1:GFstod7B/r5Ls9QiYV18fnOVtpWAtfR8aYSXfBvbCjE= github.com/grafana/grafana-plugin-sdk-go v0.126.0/go.mod h1:9YiJ5GUxIsIEUC0qR9+BJVP5M7mCSP6uc6Ne62YKkgc= @@ -1284,6 +1360,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.15.0/go.mod h1:vO11I9oWA+KsxmfFQPhLnnI github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= +github.com/hanwen/go-fuse v1.0.0/go.mod h1:unqXarDXqzAk0rt98O2tVndEPIpUgLD9+rwFisZH3Ok= +github.com/hanwen/go-fuse/v2 v2.1.0/go.mod h1:oRyA5eK+pvJyv5otpO/DgccS8y/RvYMaO00GgRLGryc= github.com/harlow/kinesis-consumer v0.3.1-0.20181230152818-2f58b136fee0/go.mod h1:dk23l2BruuUzRP8wbybQbPn3J7sZga2QHICCeaEy5rQ= github.com/hashicorp/consul v1.10.2 h1:9YX5SX3hMifrXIt9wqN2jJsMnESSHfxEjW5N7qMAdjo= github.com/hashicorp/consul v1.10.2/go.mod h1:EJMYpT39ZL2BnxjGRNTjfTH3s9893yd/DCX60PUnGUY= @@ -1424,6 +1502,7 @@ github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbc github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= github.com/iancoleman/strcase v0.0.0-20180726023541-3605ed457bf7/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE= +github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/igm/sockjs-go/v3 v3.0.1 h1:rmgEkeKqBHCFf7uIAipYrYSX8x9LBB2nOxAac2sooak= @@ -1570,13 +1649,15 @@ github.com/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0 github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.0/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.12/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= -github.com/klauspost/compress v1.13.1 h1:wXr2uRxZTJXHLly6qhJabee5JqIhTRoLBhDOA74hDEQ= github.com/klauspost/compress v1.13.1/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/cpuid v0.0.0-20170728055534-ae7887de9fa5/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= @@ -1621,14 +1702,16 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6Fm github.com/leanovate/gopter v0.2.4/go.mod h1:gNcbPWNEWRe4lm+bycKqxUYoH5uoVje5SkOJ3uoLer8= github.com/leesper/go_rng v0.0.0-20190531154944-a612b043e353/go.mod h1:N0SVk0uhy+E1PZ3C9ctsPRlvOPAFPkCNlcPBDkt0N3U= github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/ragel-machinery v0.0.0-20181214104525-299bdde78165/go.mod h1:WZxr2/6a/Ar9bMDc2rN/LJrE/hF6bXE4LPyDSIxwAfg= github.com/leoluk/perflib_exporter v0.1.0/go.mod h1:rpV0lYj7lemdTm31t7zpCqYqPnw7xs86f+BaaNBVYFM= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.0 h1:Zx5DJFEYQXio93kgXnQ09fXNiUKsqv4OUEu2UtGcB1E= -github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= +github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.0/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= @@ -1643,10 +1726,10 @@ github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1 github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/iostat v1.1.0/go.mod h1:rEPNA0xXgjHQjuI5Cy05sLlS2oRcSlWHRLrvh/AQ+Pg= github.com/lyft/protoc-gen-star v0.5.1/go.mod h1:9toiA3cC7z5uVbODF7kEQ91Xn7XNFkVUl+SrEe+ZORU= +github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= github.com/m3db/prometheus_remote_client_golang v0.4.4 h1:DsAIjVKoCp7Ym35tAOFL1OuMLIdIikAEHeNPHY+yyM8= github.com/m3db/prometheus_remote_client_golang v0.4.4/go.mod h1:wHfVbA3eAK6dQvKjCkHhusWYegCk3bDGkA15zymSHdc= -github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls= github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magefile/mage v1.12.1 h1:oGdAbhIUd6iKamKlDGVtU6XGdy5SgNuCWn7gCTgHDtU= github.com/magefile/mage v1.12.1/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= @@ -1769,8 +1852,9 @@ github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.1-0.20210112042008-8ebf2d61a8b4/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.4.2 h1:6h7AQ0yhTcIsmFmnAwQls75jp2Gzs4iB8W7pjMO+rqo= +github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= github.com/mitchellh/pointerstructure v1.0.0/go.mod h1:k4XwG94++jLVsSiTxo7qdIfXA9pj9EAeo0QsNNJOLZ8= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= @@ -2485,6 +2569,7 @@ go.mongodb.org/mongo-driver v1.5.2/go.mod h1:gRXCHX4Jo7J0IJ1oDQyUxF7jfy19UfxniMS go.mongodb.org/mongo-driver v1.7.0 h1:hHrvOBWlWB2c7+8Gh/Xi5jj82AgidK/t7KVXBZ+IyUA= go.mongodb.org/mongo-driver v1.7.0/go.mod h1:Q4oFMbo1+MSNqICAdYMlC/zSTrwCogR4R8NzkI+yfU8= go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= +go.opencensus.io v0.15.0/go.mod h1:UffZAU+4sDEINUGP/B7UfBBkq4fqLu9zXAX7ke6CHW0= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= @@ -2534,13 +2619,15 @@ go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/automaxprocs v1.2.0/go.mod h1:YfO3fm683kQpzETxlTGZhGIVmXAhaw3gxeBADbpZtnU= go.uber.org/automaxprocs v1.4.0/go.mod h1:/mTEdr7LvHhs0v7mjdxDreTz1OG5zdZGqgOnhWiR/+Q= go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= -go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.1.11-0.20210813005559-691160354723 h1:sHOAIxRGBp443oHZIPB+HsUGaksVCXVQENPxwTfQdH4= +go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= @@ -2550,6 +2637,7 @@ go.uber.org/zap v1.14.1/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc= go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= go4.org/intern v0.0.0-20210108033219-3eb7198706b2/go.mod h1:vLqJ+12kCw61iCWsPto0EOHhBS+o4rO5VIucbc9g2Cc= go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222175341-b30ae309168e/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222180813-1025295fd063/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= @@ -2600,8 +2688,11 @@ golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e h1:gsTQYXdTw2Gq7RBsWvlQ91b+aEQ6bXFUngBGuR8sPpI= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI= +golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -2659,6 +2750,7 @@ golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hM golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -2739,9 +2831,12 @@ golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211013171255-e13a2654a71e h1:Xj+JO91noE97IN6F/7WZxzC5QE6yENAQPrwIYhW3bsA= -golang.org/x/net v0.0.0-20211013171255-e13a2654a71e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0= +golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -2762,8 +2857,10 @@ golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f h1:Qmd2pbz05z7z6lm0DrgQVVPuBm92jqujBKMHMOlOQEw= golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -2927,11 +3024,16 @@ golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211110154304-99a53858aa08/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8pY/4yfcXrddB8qAbU0= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -2961,8 +3063,9 @@ golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs= golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 h1:GZokNIeuVkl3aZHJchRrr13WCsols02MLUcz1U9is6M= +golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -3064,7 +3167,6 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= @@ -3129,8 +3231,10 @@ google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6 google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= -google.golang.org/api v0.58.0 h1:MDkAbYIB1JpSgCTOCYYoIec/coMlKK4oVbpnBLLcyT0= google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E= +google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= +google.golang.org/api v0.60.0 h1:eq/zs5WPH4J9undYM9IP1O7dSr7Yh8Y0GtSCpzGzIUk= +google.golang.org/api v0.60.0/go.mod h1:d7rl65NZAkEQ90JFzqBjcRq1TVeG5ZoGV3sSpEnnVb4= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -3199,9 +3303,7 @@ google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210312152112-fc591d9ea70f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= @@ -3209,6 +3311,7 @@ google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto v0.0.0-20210429181445-86c259c2b4ab/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= +google.golang.org/genproto v0.0.0-20210517163617-5e0236093d7a/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= @@ -3227,8 +3330,14 @@ google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEc google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210921142501-181ce0d877f6/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211018162055-cf77aa76bad2 h1:CUp93KYgL06Y/PdI8aRJaFiAHevPIGWQmijSqaUhue8= +google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211018162055-cf77aa76bad2/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211019152133-63b7e35f4404/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 h1:b9mVrqYfq3P4bCdaLg1qtBnPzUYgglsIdjZkL/fQVOE= +google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= @@ -3268,8 +3377,9 @@ google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.41.0 h1:f+PlOh7QV4iIJkPrx5NQ7qaNGFQ3OTse67yaDHfju4E= google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k= +google.golang.org/grpc v1.42.0 h1:XT2/MFpuPFsEX2fWh3YQtHkZ+WYZFQRfaUgLZYj/p6A= +google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= google.golang.org/grpc/cmd/protoc-gen-go-grpc v0.0.0-20200910201057-6591123024b3/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= @@ -3471,6 +3581,7 @@ modernc.org/mathutil v1.1.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6 modernc.org/memory v1.0.1/go.mod h1:NSjvC08+g3MLOpcAxQbdctcThAEX4YlJ20WWHYEhvRg= modernc.org/sqlite v1.7.4/go.mod h1:xse4RHCm8Fzw0COf5SJqAyiDrVeDwAQthAS1V/woNIA= modernc.org/tcl v1.4.1/go.mod h1:8YCvzidU9SIwkz7RZwlCWK61mhV8X9UwfkRDRp7y5e0= +nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= diff --git a/latest.json b/latest.json index 2351b122bce..360c9f7b7d9 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "8.4.1", - "testing": "8.4.1" + "stable": "8.4.3", + "testing": "8.4.3" } diff --git a/package.json b/package.json index ba20522e9b9..9061278b902 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@babel/plugin-proposal-object-rest-spread": "7.17.3", "@babel/plugin-proposal-optional-chaining": "7.16.7", "@babel/plugin-syntax-dynamic-import": "7.8.3", - "@babel/plugin-transform-react-constant-elements": "7.16.7", + "@babel/plugin-transform-react-constant-elements": "7.17.6", "@babel/plugin-transform-runtime": "7.17.0", "@babel/plugin-transform-typescript": "7.16.8", "@babel/preset-env": "7.16.11", @@ -155,8 +155,8 @@ "@types/testing-library__react-hooks": "^3.2.0", "@types/tinycolor2": "1.4.3", "@types/uuid": "8.3.4", - "@typescript-eslint/eslint-plugin": "5.12.1", - "@typescript-eslint/parser": "5.12.1", + "@typescript-eslint/eslint-plugin": "5.13.0", + "@typescript-eslint/parser": "5.13.0", "@wojtekmaj/enzyme-adapter-react-17": "0.6.6", "autoprefixer": "10.4.2", "axios": "0.26.0", @@ -167,16 +167,16 @@ "copy-webpack-plugin": "9.0.1", "css-loader": "6.6.0", "css-minimizer-webpack-plugin": "3.4.1", - "cypress": "9.3.1", + "cypress": "9.5.0", "enzyme": "3.11.0", "enzyme-to-json": "3.6.2", - "eslint": "8.9.0", + "eslint": "8.10.0", "eslint-config-prettier": "8.4.0", "eslint-plugin-jest": "^26.1.0", "eslint-plugin-jsdoc": "37.9.1", "eslint-plugin-lodash": "7.4.0", "eslint-plugin-prettier": "4.0.0", - "eslint-plugin-react": "7.28.0", + "eslint-plugin-react": "7.29.2", "eslint-plugin-react-hooks": "4.3.0", "eslint-webpack-plugin": "3.1.1", "expose-loader": "3.1.0", @@ -224,7 +224,7 @@ "testing-library-selector": "0.2.1", "ts-jest": "27.1.3", "ts-loader": "9.2.6", - "ts-node": "10.5.0", + "ts-node": "10.6.0", "typescript": "4.4.4", "wait-on": "6.0.0", "webpack": "5.69.1", @@ -247,6 +247,8 @@ "@grafana/ui": "workspace:*", "@jaegertracing/jaeger-ui-components": "workspace:*", "@kusto/monaco-kusto": "4.1.3", + "@lezer/common": "^0.15.11", + "@lezer/lr": "^0.15.8", "@lingui/core": "3.13.2", "@lingui/react": "3.13.2", "@opentelemetry/api": "1.1.0", @@ -264,9 +266,9 @@ "@react-stately/menu": "3.2.3", "@react-stately/tree": "3.2.0", "@reduxjs/toolkit": "1.7.2", - "@sentry/browser": "6.17.4", - "@sentry/types": "6.17.4", - "@sentry/utils": "6.17.4", + "@sentry/browser": "6.18.1", + "@sentry/types": "6.18.1", + "@sentry/utils": "6.18.1", "@visx/event": "2.6.0", "@visx/gradient": "2.1.0", "@visx/group": "2.1.0", @@ -305,9 +307,7 @@ "jquery": "3.6.0", "json-source-map": "0.6.1", "jsurl": "^0.1.5", - "lezer": "0.13.5", - "lezer-promql": "0.20.0", - "lezer-tree": "0.13.2", + "lezer-promql": "0.22.0", "lodash": "4.17.21", "logfmt": "^1.3.2", "lru-cache": "7.4.0", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index c211247abac..52c61f6d3f6 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -67,7 +67,7 @@ "@types/tinycolor2": "1.4.3", "react-test-renderer": "17.0.2", "rimraf": "3.0.2", - "rollup": "2.67.1", + "rollup": "2.69.0", "rollup-plugin-sourcemaps": "0.6.3", "rollup-plugin-terser": "7.0.2", "sinon": "13.0.1", diff --git a/packages/grafana-data/src/field/displayProcessor.test.ts b/packages/grafana-data/src/field/displayProcessor.test.ts index 3bae9138192..7cb49137e7a 100644 --- a/packages/grafana-data/src/field/displayProcessor.test.ts +++ b/packages/grafana-data/src/field/displayProcessor.test.ts @@ -460,7 +460,7 @@ describe('getRawDisplayProcessor', () => { ${'a string'} | ${'a string'} ${null} | ${'null'} ${undefined} | ${'undefined'} - ${{ value: 0, label: 'a label' }} | ${'[object Object]'} + ${{ value: 0, label: 'a label' }} | ${'{"value":0,"label":"a label"}'} `('when called with value:{$value}', ({ value, expected }) => { const result = processor(value); diff --git a/packages/grafana-data/src/field/displayProcessor.ts b/packages/grafana-data/src/field/displayProcessor.ts index 7e09960c4a7..a8bab9f7ac6 100644 --- a/packages/grafana-data/src/field/displayProcessor.ts +++ b/packages/grafana-data/src/field/displayProcessor.ts @@ -11,6 +11,7 @@ import { KeyValue, TimeZone } from '../types'; import { getScaleCalculator } from './scale'; import { GrafanaTheme2 } from '../themes/types'; import { anyToNumber } from '../utils/anyToNumber'; +import { getFieldTypeFromValue } from '../dataframe/processDataFrame'; interface DisplayProcessorOptions { field: Partial; @@ -168,7 +169,20 @@ function toStringProcessor(value: any): DisplayValue { export function getRawDisplayProcessor(): DisplayProcessor { return (value: any) => ({ - text: `${value}`, + text: getFieldTypeFromValue(value) === 'other' ? `${JSON.stringify(value, getCircularReplacer())}` : `${value}`, numeric: null as unknown as number, }); } + +const getCircularReplacer = () => { + const seen = new WeakSet(); + return (_key: any, value: object | null) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return; + } + seen.add(value); + } + return value; + }; +}; diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 46477608f9b..2cefae622b7 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -25,6 +25,7 @@ export { LayoutModes, LayoutMode } from './types/layout'; export { PanelPlugin, SetFieldConfigOptionsArgs, StandardOptionConfig } from './panel/PanelPlugin'; export { createFieldConfigRegistry } from './panel/registryFactories'; export { QueryRunner, QueryRunnerOptions } from './types/queryRunner'; +export { GroupingToMatrixTransformerOptions } from './transformations/transformers/groupingToMatrix'; // Moved to `@grafana/schema`, in Grafana 9, this will be removed export * from './schema'; diff --git a/packages/grafana-data/src/transformations/fieldReducer.ts b/packages/grafana-data/src/transformations/fieldReducer.ts index 8b2fea28f97..aaf400ba105 100644 --- a/packages/grafana-data/src/transformations/fieldReducer.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.ts @@ -140,14 +140,14 @@ export const fieldReducers = new Registry(() => [ standard: true, reduce: calculateLast, }, - { id: ReducerID.first, name: 'First', description: 'First Value', standard: true, reduce: calculateFirst }, { id: ReducerID.firstNotNull, - name: 'First', + name: 'First *', description: 'First non-null value', standard: true, reduce: calculateFirstNotNull, }, + { id: ReducerID.first, name: 'First', description: 'First Value', standard: true, reduce: calculateFirst }, { id: ReducerID.min, name: 'Min', description: 'Minimum Value', standard: true }, { id: ReducerID.max, name: 'Max', description: 'Maximum Value', standard: true }, { id: ReducerID.mean, name: 'Mean', description: 'Average Value', standard: true, aliasIds: ['avg'] }, diff --git a/packages/grafana-data/src/transformations/transformers.ts b/packages/grafana-data/src/transformations/transformers.ts index 835be0d0228..063e9f8ddf4 100644 --- a/packages/grafana-data/src/transformations/transformers.ts +++ b/packages/grafana-data/src/transformations/transformers.ts @@ -19,6 +19,7 @@ import { renameByRegexTransformer } from './transformers/renameByRegex'; import { filterByValueTransformer } from './transformers/filterByValue'; import { histogramTransformer } from './transformers/histogram'; import { convertFieldTypeTransformer } from './transformers/convertFieldType'; +import { groupingToMatrixTransformer } from './transformers/groupingToMatrix'; export const standardTransformers = { noopTransformer, @@ -43,4 +44,5 @@ export const standardTransformers = { renameByRegexTransformer, histogramTransformer, convertFieldTypeTransformer, + groupingToMatrixTransformer, }; diff --git a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts new file mode 100644 index 00000000000..103cf335a67 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts @@ -0,0 +1,170 @@ +import { + ArrayVector, + DataTransformerConfig, + DataTransformerID, + Field, + FieldType, + toDataFrame, + transformDataFrame, +} from '@grafana/data'; +import { GroupingToMatrixTransformerOptions, groupingToMatrixTransformer } from './groupingToMatrix'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; + +describe('Grouping to Matrix', () => { + beforeAll(() => { + mockTransformationsRegistry([groupingToMatrixTransformer]); + }); + + it('generates Matrix with default fields', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupingToMatrix, + options: {}, + }; + + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 1001, 1002] }, + { name: 'Value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA])).toEmitValuesWith((received) => { + const processed = received[0]; + const expected: Field[] = [ + { + name: 'Time\\Time', + type: FieldType.string, + values: new ArrayVector([1000, 1001, 1002]), + config: {}, + }, + { + name: '1000', + type: FieldType.number, + values: new ArrayVector([1, '', '']), + config: {}, + }, + { + name: '1001', + type: FieldType.number, + values: new ArrayVector(['', 2, '']), + config: {}, + }, + { + name: '1002', + type: FieldType.number, + values: new ArrayVector(['', '', 3]), + config: {}, + }, + ]; + + expect(processed[0].fields).toEqual(expected); + }); + }); + + it('generates Matrix with multiple fields', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupingToMatrix, + options: { + columnField: 'Column', + rowField: 'Row', + valueField: 'Temp', + }, + }; + + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Column', type: FieldType.string, values: ['C1', 'C1', 'C2'] }, + { name: 'Row', type: FieldType.string, values: ['R1', 'R2', 'R1'] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA])).toEmitValuesWith((received) => { + const processed = received[0]; + const expected: Field[] = [ + { + name: 'Row\\Column', + type: FieldType.string, + values: new ArrayVector(['R1', 'R2']), + config: {}, + }, + { + name: 'C1', + type: FieldType.number, + values: new ArrayVector([1, 4]), + config: {}, + }, + { + name: 'C2', + type: FieldType.number, + values: new ArrayVector([5, '']), + config: {}, + }, + ]; + + expect(processed[0].fields).toEqual(expected); + }); + }); + + it('generates Matrix with multiple fields and value type', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupingToMatrix, + options: { + columnField: 'Column', + rowField: 'Row', + valueField: 'Temp', + }, + }; + + const seriesA = toDataFrame({ + name: 'C', + fields: [ + { name: 'Column', type: FieldType.string, values: ['C1', 'C1', 'C2'] }, + { name: 'Row', type: FieldType.string, values: ['R1', 'R2', 'R1'] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5], config: { units: 'celsius' } }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA])).toEmitValuesWith((received) => { + const processed = received[0]; + + expect(processed[0].fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object {}, + "name": "Row\\\\Column", + "type": "string", + "values": Array [ + "R1", + "R2", + ], + }, + Object { + "config": Object { + "units": "celsius", + }, + "name": "C1", + "type": "number", + "values": Array [ + 1, + 4, + ], + }, + Object { + "config": Object { + "units": "celsius", + }, + "name": "C2", + "type": "number", + "values": Array [ + 5, + "", + ], + }, + ] + `); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts new file mode 100644 index 00000000000..4d8a8729ff7 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts @@ -0,0 +1,115 @@ +import { map } from 'rxjs/operators'; + +import { DataFrame, DataTransformerInfo, Field, FieldType, Vector } from '../../types'; +import { DataTransformerID } from './ids'; +import { MutableDataFrame } from '../../dataframe'; +import { getFieldDisplayName } from '../../field/fieldState'; + +export interface GroupingToMatrixTransformerOptions { + columnField?: string; + rowField?: string; + valueField?: string; +} + +const DEFAULT_COLUMN_FIELD = 'Time'; +const DEFAULT_ROW_FIELD = 'Time'; +const DEFAULT_VALUE_FIELD = 'Value'; + +export const groupingToMatrixTransformer: DataTransformerInfo = { + id: DataTransformerID.groupingToMatrix, + name: 'Grouping to Matrix', + description: 'Groups series by field and return a matrix visualisation', + defaultOptions: { + columnField: DEFAULT_COLUMN_FIELD, + rowField: DEFAULT_ROW_FIELD, + valueField: DEFAULT_VALUE_FIELD, + }, + + operator: (options) => (source) => + source.pipe( + map((data) => { + const columnFieldMatch = options.columnField || DEFAULT_COLUMN_FIELD; + const rowFieldMatch = options.rowField || DEFAULT_ROW_FIELD; + const valueFieldMatch = options.valueField || DEFAULT_VALUE_FIELD; + + // Accept only single queries + if (data.length !== 1) { + return data; + } + + const frame = data[0]; + const keyColumnField = findKeyField(frame, columnFieldMatch); + const keyRowField = findKeyField(frame, rowFieldMatch); + const valueField = findKeyField(frame, valueFieldMatch); + const rowColumnField = `${rowFieldMatch}\\${columnFieldMatch}`; + + if (!keyColumnField || !keyRowField || !valueField) { + return data; + } + + const columnValues = uniqueValues(keyColumnField.values); + const rowValues = uniqueValues(keyRowField.values); + + const matrixValues: { [key: string]: { [key: string]: any } } = {}; + + for (let index = 0; index < valueField.values.length; index++) { + const columnName = keyColumnField.values.get(index); + const rowName = keyRowField.values.get(index); + const value = valueField.values.get(index); + + if (!matrixValues[columnName]) { + matrixValues[columnName] = {}; + } + + matrixValues[columnName][rowName] = value; + } + + const resultFrame = new MutableDataFrame(); + + resultFrame.addField({ + name: rowColumnField, + values: rowValues, + type: FieldType.string, + }); + + for (const columnName of columnValues) { + let values = []; + for (const rowName of rowValues) { + const value = matrixValues[columnName][rowName] ?? ''; + values.push(value); + } + + resultFrame.addField({ + name: columnName.toString(), + values: values, + config: valueField.config, + type: valueField.type, + }); + } + + return [resultFrame]; + }) + ), +}; + +function uniqueValues(values: Vector): any[] { + const unique = new Set(); + + for (let index = 0; index < values.length; index++) { + unique.add(values.get(index)); + } + + return Array.from(unique); +} + +function findKeyField(frame: DataFrame, matchTitle: string): Field | null { + for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) { + const field = frame.fields[fieldIndex]; + + if (matchTitle === getFieldDisplayName(field)) { + return field; + } + } + + return null; +} diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts index e8254b1949a..1032e9c7d68 100644 --- a/packages/grafana-data/src/transformations/transformers/ids.ts +++ b/packages/grafana-data/src/transformations/transformers/ids.ts @@ -31,4 +31,5 @@ export enum DataTransformerID { heatmap = 'heatmap', spatial = 'spatial', extractFields = 'extractFields', + groupingToMatrix = 'groupingToMatrix', } diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts index f6941ede4f7..642c3cfee5e 100644 --- a/packages/grafana-data/src/types/data.ts +++ b/packages/grafana-data/src/types/data.ts @@ -20,7 +20,8 @@ export enum LoadingState { } // Should be kept in sync with grafana-plugin-sdk-go/data/frame_meta.go -export type PreferredVisualisationType = 'graph' | 'table' | 'logs' | 'trace' | 'nodeGraph'; +export const preferredVisualizationTypes = ['graph', 'table', 'logs', 'trace', 'nodeGraph'] as const; +export type PreferredVisualisationType = typeof preferredVisualizationTypes[number]; /** * @public diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index ef0242c0389..b838c5c379c 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -40,9 +40,11 @@ export interface FeatureToggles { showFeatureFlagsInUI?: boolean; disable_http_request_histogram?: boolean; validatedQueries?: boolean; + lokiLive?: boolean; swaggerUi?: boolean; featureHighlights?: boolean; dashboardComments?: boolean; annotationComments?: boolean; migrationLocking?: boolean; + fileStoreApi?: boolean; } diff --git a/packages/grafana-data/src/types/trace.ts b/packages/grafana-data/src/types/trace.ts index 363584adab7..db73ff9eebe 100644 --- a/packages/grafana-data/src/types/trace.ts +++ b/packages/grafana-data/src/types/trace.ts @@ -15,6 +15,12 @@ export type TraceLog = { fields: TraceKeyValuePair[]; }; +export type TraceSpanReference = { + traceID: string; + spanID: string; + tags?: TraceKeyValuePair[]; +}; + /** * This describes the structure of the dataframe that should be returned from a tracing data source to show trace * in a TraceView component. @@ -31,7 +37,7 @@ export interface TraceSpanRow { // Milliseconds duration: number; logs?: TraceLog[]; - + references?: TraceSpanReference[]; // Note: To mark spen as having error add tag error: true tags?: TraceKeyValuePair[]; warnings?: string[]; diff --git a/packages/grafana-data/src/utils/logs.ts b/packages/grafana-data/src/utils/logs.ts index 96af4c4a602..a95d14fc42e 100644 --- a/packages/grafana-data/src/utils/logs.ts +++ b/packages/grafana-data/src/utils/logs.ts @@ -229,4 +229,4 @@ export const checkLogsError = (logRow: LogRowModel): { hasError: boolean; errorM }; export const escapeUnescapedString = (string: string) => - string.replace(/\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n')); + string.replace(/\\r\\n|\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n')); diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index 6a6bd64642e..098d82aeb36 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -28,7 +28,7 @@ "@rollup/plugin-node-resolve": "13.1.3", "@types/node": "16.11.22", "rimraf": "3.0.2", - "rollup": "2.67.1", + "rollup": "2.69.0", "rollup-plugin-sourcemaps": "0.6.3", "rollup-plugin-terser": "7.0.2" }, diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index 18f138f6b27..59f06bc2958 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -37,7 +37,7 @@ "@types/lodash": "4.14.178", "@types/node": "16.11.22", "@types/uuid": "8.3.4", - "rollup": "2.67.1", + "rollup": "2.69.0", "rollup-plugin-copy": "3.4.0", "rollup-plugin-sourcemaps": "0.6.3", "rollup-plugin-terser": "7.0.2", @@ -55,7 +55,7 @@ "blink-diff": "1.0.13", "chrome-remote-interface": "0.31.2", "commander": "8.3.0", - "cypress": "9.3.1", + "cypress": "9.5.0", "cypress-file-upload": "5.0.8", "devtools-protocol": "0.0.967529", "execa": "5.1.1", diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 5d7b4315ec1..555355895f8 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -25,7 +25,7 @@ "@grafana/data": "8.5.0-pre", "@grafana/e2e-selectors": "8.5.0-pre", "@grafana/ui": "8.5.0-pre", - "@sentry/browser": "6.17.4", + "@sentry/browser": "6.18.1", "history": "4.10.1", "lodash": "4.17.21", "react": "17.0.2", @@ -50,7 +50,7 @@ "@types/systemjs": "^0.20.6", "lodash": "4.17.21", "rimraf": "3.0.2", - "rollup": "2.67.1", + "rollup": "2.69.0", "rollup-plugin-sourcemaps": "0.6.3", "rollup-plugin-terser": "7.0.2", "typescript": "4.4.4" diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index 178defa6c1f..0fb4f1b2c95 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -28,7 +28,7 @@ "@rollup/plugin-node-resolve": "13.1.3", "@swc/helpers": "0.3.2", "rimraf": "3.0.2", - "rollup": "2.67.1", + "rollup": "2.69.0", "rollup-plugin-sourcemaps": "0.6.3", "rollup-plugin-terser": "7.0.2", "typescript": "4.4.4" diff --git a/packages/grafana-schema/src/schema/graph.gen.ts b/packages/grafana-schema/src/schema/graph.gen.ts index 623d750a250..f055541e9ce 100644 --- a/packages/grafana-schema/src/schema/graph.gen.ts +++ b/packages/grafana-schema/src/schema/graph.gen.ts @@ -283,6 +283,7 @@ export enum BarGaugeDisplayMode { export interface TableFieldOptions { align: string; displayMode: TableCellDisplayMode; + inspect: boolean; hidden?: boolean; minWidth?: number; width?: number; @@ -292,6 +293,7 @@ export interface TableFieldOptions { export const defaultTableFieldOptions: TableFieldOptions = { align: 'auto', displayMode: TableCellDisplayMode.Auto, + inspect: false, }; export interface VizTooltipOptions { diff --git a/packages/grafana-toolkit/src/config/webpack/loaders.test.ts b/packages/grafana-toolkit/src/config/webpack/loaders.test.ts index 7f5b47e5755..0c5e3f02804 100644 --- a/packages/grafana-toolkit/src/config/webpack/loaders.test.ts +++ b/packages/grafana-toolkit/src/config/webpack/loaders.test.ts @@ -2,9 +2,11 @@ import { getStylesheetEntries, hasThemeStylesheets } from './loaders'; describe('Loaders', () => { describe('stylesheet helpers', () => { - jest.spyOn(console, 'log').mockImplementation(); + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + }); - afterAll(() => { + afterEach(() => { jest.restoreAllMocks(); }); @@ -23,12 +25,12 @@ describe('Loaders', () => { describe('hasThemeStylesheets', () => { it('throws when only one theme file is defined', () => { - jest.spyOn(console, 'error').mockImplementation(); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); const result = () => { hasThemeStylesheets(`${__dirname}/../mocks/stylesheetsSupport/missing-theme-file`); }; expect(result).toThrow(); - jest.restoreAllMocks(); + errorSpy.mockRestore(); }); it('returns false when no theme files present', () => { diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 321fadb1443..b4dee767060 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -45,7 +45,7 @@ "@react-aria/menu": "3.4.1", "@react-aria/overlays": "3.7.3", "@react-stately/menu": "3.2.3", - "@sentry/browser": "6.17.4", + "@sentry/browser": "6.18.1", "ansicolor": "1.1.100", "calculate-size": "1.1.1", "classnames": "2.3.1", @@ -72,7 +72,7 @@ "react-colorful": "5.5.1", "react-custom-scrollbars-2": "4.4.0", "react-dom": "17.0.2", - "react-dropzone": "11.5.1", + "react-dropzone": "12.0.4", "react-highlight-words": "0.17.0", "react-hook-form": "7.5.3", "react-inlinesvg": "2.3.0", @@ -136,7 +136,7 @@ "@types/prismjs": "1.26.0", "@types/react": "17.0.39", "@types/react-beautiful-dnd": "13.1.2", - "@types/react-calendar": "3.4.5", + "@types/react-calendar": "3.5.0", "@types/react-color": "3.0.6", "@types/react-dom": "17.0.11", "@types/react-router-dom": "5.3.3", @@ -166,7 +166,7 @@ "react-docgen-typescript-loader": "3.7.2", "react-test-renderer": "17.0.2", "rimraf": "3.0.2", - "rollup": "2.67.1", + "rollup": "2.69.0", "rollup-plugin-sourcemaps": "0.6.3", "rollup-plugin-terser": "7.0.2", "sass-loader": "12.6.0", diff --git a/packages/grafana-ui/src/components/Card/Card.mdx b/packages/grafana-ui/src/components/Card/Card.mdx index e3364418c5a..a5efbed8383 100644 --- a/packages/grafana-ui/src/components/Card/Card.mdx +++ b/packages/grafana-ui/src/components/Card/Card.mdx @@ -384,6 +384,28 @@ Card can have a disabled state, effectively making it and its actions non-clicka +### Selectable + +```jsx + + Option #1 + This is a really great option, you won't regret it. + + Grafana Logo + + +``` + + + + Option #1 + This is a really great option, you won't regret it. + + Grafana Logo + + + + ### Props diff --git a/packages/grafana-ui/src/components/Card/Card.story.tsx b/packages/grafana-ui/src/components/Card/Card.story.tsx index 6c88efd080c..08893cf8d50 100644 --- a/packages/grafana-ui/src/components/Card/Card.story.tsx +++ b/packages/grafana-ui/src/components/Card/Card.story.tsx @@ -154,3 +154,27 @@ export const Full: Story = ({ disabled }) => { ); }; + +export const Selected: Story = () => { + return ( + + Spaces + Spaces are the superior form of indenting code. + + Grafana Logo + + + ); +}; + +export const NotSelected: Story = () => { + return ( + + Tabs + Tabs are the preferred way of indentation. + + Grafana Logo + + + ); +}; diff --git a/packages/grafana-ui/src/components/Card/Card.test.tsx b/packages/grafana-ui/src/components/Card/Card.test.tsx index 6dd2b587348..9b147a42b01 100644 --- a/packages/grafana-ui/src/components/Card/Card.test.tsx +++ b/packages/grafana-ui/src/components/Card/Card.test.tsx @@ -99,5 +99,33 @@ describe('Card', () => { expect(screen.getByRole('button', { name: 'Click Me' })).not.toBeDisabled(); expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument(); }); + + it('Should allow selectable cards', () => { + const { rerender } = render( + + My Option + + ); + + expect(screen.getByRole('radio')).toBeInTheDocument(); + expect(screen.getByRole('radio')).toBeChecked(); + + rerender( + + My Option + + ); + + expect(screen.getByRole('radio')).toBeInTheDocument(); + expect(screen.getByRole('radio')).not.toBeChecked(); + + rerender( + + My Option + + ); + + expect(screen.queryByRole('radio')).not.toBeInTheDocument(); + }); }); }); diff --git a/packages/grafana-ui/src/components/Card/Card.tsx b/packages/grafana-ui/src/components/Card/Card.tsx index 92466b9315e..2ba11fc31f5 100644 --- a/packages/grafana-ui/src/components/Card/Card.tsx +++ b/packages/grafana-ui/src/components/Card/Card.tsx @@ -19,6 +19,7 @@ export interface Props extends Omit { @@ -35,6 +36,7 @@ const CardContext = React.createContext<{ href?: string; onClick?: () => void; disabled?: boolean; + isSelected?: boolean; } | null>(null); /** @@ -49,6 +51,7 @@ export const Card: CardInterface = ({ children, heading: deprecatedHeading, description: deprecatedDescription, + isSelected, className, ...htmlProps }) => { @@ -63,16 +66,17 @@ export const Card: CardInterface = ({ const disableHover = disabled || (!onClick && !href); const onCardClick = onClick && !disabled ? onClick : undefined; const theme = useTheme2(); - const styles = getCardContainerStyles(theme, disabled, disableHover); + const styles = getCardContainerStyles(theme, disabled, disableHover, isSelected); return ( - + {!hasHeadingComponent && } {deprecatedHeading && {deprecatedHeading}} {deprecatedDescription && {deprecatedDescription}} @@ -96,7 +100,7 @@ const Heading = ({ children, className, 'aria-label': ariaLabel }: ChildProps & const context = useContext(CardContext); const styles = useStyles2(getHeadingStyles); - const { href, onClick } = context ?? { href: undefined, onClick: undefined }; + const { href, onClick, isSelected } = context ?? { href: undefined, onClick: undefined, isSelected: undefined }; return (

@@ -111,6 +115,7 @@ const Heading = ({ children, className, 'aria-label': ariaLabel }: ChildProps & ) : ( <>{children} )} + {isSelected !== undefined && }

); }; diff --git a/packages/grafana-ui/src/components/Card/CardContainer.tsx b/packages/grafana-ui/src/components/Card/CardContainer.tsx index 8cfa2e2ae2b..64bfe047e9c 100644 --- a/packages/grafana-ui/src/components/Card/CardContainer.tsx +++ b/packages/grafana-ui/src/components/Card/CardContainer.tsx @@ -39,6 +39,8 @@ export interface CardContainerProps extends HTMLAttributes, Ca disableEvents?: boolean; /** No style change on hover */ disableHover?: boolean; + /** Makes the card selectable, set to "true" to apply selected styles */ + isSelected?: boolean; /** Custom container styles */ className?: string; } @@ -48,12 +50,13 @@ export const CardContainer = ({ children, disableEvents, disableHover, + isSelected, className, href, ...props }: CardContainerProps) => { const theme = useTheme2(); - const { oldContainer } = getCardContainerStyles(theme, disableEvents, disableHover); + const { oldContainer } = getCardContainerStyles(theme, disableEvents, disableHover, isSelected); return (
{children} @@ -61,59 +64,71 @@ export const CardContainer = ({ ); }; -export const getCardContainerStyles = stylesFactory((theme: GrafanaTheme2, disabled = false, disableHover = false) => { - return { - container: css({ - display: 'grid', - position: 'relative', - gridTemplateColumns: 'auto 1fr auto', - gridTemplateRows: '1fr auto auto auto', - gridAutoColumns: '1fr', - gridAutoFlow: 'row', - gridTemplateAreas: ` +export const getCardContainerStyles = stylesFactory( + (theme: GrafanaTheme2, disabled = false, disableHover = false, isSelected = false) => { + const isSelectable = isSelected !== undefined; + + return { + container: css({ + display: 'grid', + position: 'relative', + gridTemplateColumns: 'auto 1fr auto', + gridTemplateRows: '1fr auto auto auto', + gridAutoColumns: '1fr', + gridAutoFlow: 'row', + gridTemplateAreas: ` "Figure Heading Tags" "Figure Meta Tags" "Figure Description Tags" "Figure Actions Secondary"`, - width: '100%', - padding: theme.spacing(2), - background: theme.colors.background.secondary, - borderRadius: theme.shape.borderRadius(), - marginBottom: '8px', - pointerEvents: disabled ? 'none' : 'auto', - transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { - duration: theme.transitions.duration.short, - }), + width: '100%', + padding: theme.spacing(2), + background: theme.colors.background.secondary, + borderRadius: theme.shape.borderRadius(), + marginBottom: '8px', + pointerEvents: disabled ? 'none' : 'auto', + transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { + duration: theme.transitions.duration.short, + }), - ...(!disableHover && { - '&:hover': { - background: theme.colors.emphasize(theme.colors.background.secondary, 0.03), - cursor: 'pointer', - zIndex: 1, - }, - '&:focus': styleMixins.getFocusStyles(theme), - }), - }), - oldContainer: css({ - display: 'flex', - width: '100%', - background: theme.colors.background.secondary, - borderRadius: theme.shape.borderRadius(), - position: 'relative', - pointerEvents: disabled ? 'none' : 'auto', - marginBottom: theme.spacing(1), - transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { - duration: theme.transitions.duration.short, - }), + ...(!disableHover && { + '&:hover': { + background: theme.colors.emphasize(theme.colors.background.secondary, 0.03), + cursor: 'pointer', + zIndex: 1, + }, + '&:focus': styleMixins.getFocusStyles(theme), + }), - ...(!disableHover && { - '&:hover': { - background: theme.colors.emphasize(theme.colors.background.secondary, 0.03), + ...(isSelectable && { cursor: 'pointer', - zIndex: 1, - }, - '&:focus': styleMixins.getFocusStyles(theme), + }), + + ...(isSelected && { + outline: `solid 2px ${theme.colors.primary.border}`, + }), }), - }), - }; -}); + oldContainer: css({ + display: 'flex', + width: '100%', + background: theme.colors.background.secondary, + borderRadius: theme.shape.borderRadius(), + position: 'relative', + pointerEvents: disabled ? 'none' : 'auto', + marginBottom: theme.spacing(1), + transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { + duration: theme.transitions.duration.short, + }), + + ...(!disableHover && { + '&:hover': { + background: theme.colors.emphasize(theme.colors.background.secondary, 0.03), + cursor: 'pointer', + zIndex: 1, + }, + '&:focus': styleMixins.getFocusStyles(theme), + }), + }), + }; + } +); diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx index d979d8b6aa2..63c1aba06c9 100644 --- a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx @@ -21,24 +21,46 @@ export interface Props extends ButtonProps { const dummyClearFunc = () => {}; export function ClipboardButton({ onClipboardCopy, onClipboardError, children, getText, ...buttonProps }: Props) { - // Can be removed in 9.x const buttonRef = useRef(null); - const copyText = useCallback(() => { - const copiedText = getText(); + const copyTextCallback = useCallback(async () => { + const textToCopy = getText(); + // Can be removed in 9.x const dummyEvent: ClipboardEvent = { action: 'copy', clearSelection: dummyClearFunc, - text: copiedText, + text: textToCopy, trigger: buttonRef.current!, }; - navigator.clipboard - .writeText(copiedText) - .then(() => (onClipboardCopy?.(dummyEvent), () => onClipboardError?.(dummyEvent))); + try { + await copyText(textToCopy, buttonRef); + onClipboardCopy?.(dummyEvent); + } catch { + onClipboardError?.(dummyEvent); + } }, [getText, onClipboardCopy, onClipboardError]); return ( - ); } + +const copyText = async (text: string, buttonRef: React.MutableRefObject) => { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } else { + // Use a fallback method for browsers/contexts that don't support the Clipboard API. + // See https://web.dev/async-clipboard/#feature-detection. + const input = document.createElement('input'); + // Normally we'd append this to the body. However if we're inside a focus manager + // from react-aria, we can't focus anything outside of the managed area. + // Instead, let's append it to the button. Then we're guaranteed to be able to focus + copy. + buttonRef.current?.appendChild(input); + input.value = text; + input.focus(); + input.select(); + document.execCommand('copy'); + input.remove(); + } +}; diff --git a/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx b/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx index 3c49ffbc176..5f3f5bf120f 100644 --- a/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx +++ b/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx @@ -18,6 +18,7 @@ export interface FileDropzoneProps { * maxSize: Infinity, * minSize: 0, * multiple: true, + * useFsAccessApi: false, * maxFiles: 0, * } */ @@ -135,7 +136,7 @@ export function FileDropzone({ options, children, readAs, onLoad, fileListRender setFiles(newFiles); }; - const { getRootProps, getInputProps, isDragActive } = useDropzone({ ...options, onDrop }); + const { getRootProps, getInputProps, isDragActive } = useDropzone({ ...options, useFsAccessApi: false, onDrop }); const theme = useTheme2(); const styles = getStyles(theme, isDragActive); const fileList = files.map((file) => { diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index e33de639ef2..1581c4abd34 100755 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -116,7 +116,8 @@ export class GraphNG extends React.Component { fields || { x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), y: fieldMatchers.get(FieldMatcherID.numeric).get({}), - } + }, + props.timeRange ); pluginLog('GraphNG', false, 'data aligned', alignedFrame); diff --git a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts b/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts index c74fb32dbf5..144f9ddb7d8 100644 --- a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts +++ b/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts @@ -97,6 +97,23 @@ describe('nullInsertThreshold Transformer', () => { expect(result.fields[2].values.toArray()).toStrictEqual(['a', null, 'b', null, 'c']); }); + test('should insert trailing null at end +interval when timeRange.to.valueOf() exceeds threshold', () => { + const df = new MutableDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 3, 10] }, + { name: 'One', type: FieldType.number, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold(df, null, 13); + + 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]); + }); + // TODO: make this work test.skip('should insert nulls at +threshold (when defined) instead of +interval', () => { const df = new MutableDataFrame({ @@ -115,23 +132,6 @@ describe('nullInsertThreshold Transformer', () => { expect(result.fields[2].values.toArray()).toStrictEqual(['a', null, 'b', null, 'c']); }); - test('should insert nulls at midpoints between adjacent > interval: 2', () => { - const df = new MutableDataFrame({ - refId: 'A', - fields: [ - { name: 'Time', type: FieldType.time, config: { interval: 2 }, values: [5, 7, 11] }, - { name: 'One', type: FieldType.number, values: [4, 6, 8] }, - { name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] }, - ], - }); - - const result = applyNullInsertThreshold(df); - - expect(result.fields[0].values.toArray()).toStrictEqual([5, 7, 9, 11]); - expect(result.fields[1].values.toArray()).toStrictEqual([4, 6, null, 8]); - expect(result.fields[2].values.toArray()).toStrictEqual(['a', 'b', null, 'c']); - }); - test('should noop on fewer than two values', () => { const df = new MutableDataFrame({ refId: 'A', @@ -202,7 +202,8 @@ describe('nullInsertThreshold Transformer', () => { expect(result).toBe(df); }); - test('perf stress test should be <= 10ms', () => { + // Leave this test skipped - it should be run manually + test.skip('perf stress test should be <= 10ms', () => { // 10 fields x 3,000 values with 50% skip (output = 10 fields x 6,000 values) let bigFrameA = genFrame(); diff --git a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts b/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts index 4e22d0af2b1..35b0fcd325f 100644 --- a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts +++ b/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts @@ -12,6 +12,7 @@ const INSERT_MODES = { export function applyNullInsertThreshold( frame: DataFrame, refFieldName?: string | null, + refFieldPseudoMax: number | null = null, insertMode: InsertMode = INSERT_MODES.threshold ): DataFrame { if (frame.length < 2) { @@ -48,7 +49,7 @@ export function applyNullInsertThreshold( const frameValues = frame.fields.map((field) => field.values.toArray()); - const filledFieldValues = nullInsertThreshold(refValues, frameValues, threshold, insertMode); + const filledFieldValues = nullInsertThreshold(refValues, frameValues, threshold, refFieldPseudoMax, insertMode); if (filledFieldValues === frameValues) { return frame; @@ -70,7 +71,14 @@ export function applyNullInsertThreshold( return frame; } -function nullInsertThreshold(refValues: number[], frameValues: any[][], threshold: number, getInsertValue: InsertMode) { +function nullInsertThreshold( + refValues: number[], + frameValues: any[][], + threshold: number, + // will insert a trailing null when refFieldPseudoMax > last datapoint + threshold + refFieldPseudoMax: number | null = null, + getInsertValue: InsertMode +) { const len = refValues.length; let prevValue: number = refValues[0]; const refValuesNew: number[] = [prevValue]; @@ -87,6 +95,10 @@ function nullInsertThreshold(refValues: number[], frameValues: any[][], threshol prevValue = curValue; } + if (refFieldPseudoMax != null && prevValue + threshold <= refFieldPseudoMax) { + refValuesNew.push(getInsertValue(prevValue, refFieldPseudoMax, threshold)); + } + const filledLen = refValuesNew.length; if (filledLen === len) { diff --git a/packages/grafana-ui/src/components/GraphNG/utils.ts b/packages/grafana-ui/src/components/GraphNG/utils.ts index cc7feecd4a0..b67d694d793 100644 --- a/packages/grafana-ui/src/components/GraphNG/utils.ts +++ b/packages/grafana-ui/src/components/GraphNG/utils.ts @@ -1,5 +1,5 @@ import { XYFieldMatchers } from './types'; -import { ArrayVector, DataFrame, FieldConfig, FieldType, outerJoinDataFrames } from '@grafana/data'; +import { ArrayVector, DataFrame, FieldConfig, FieldType, outerJoinDataFrames, TimeRange } from '@grafana/data'; import { nullToUndefThreshold } from './nullToUndefThreshold'; import { applyNullInsertThreshold } from './nullInsertThreshold'; import { AxisPlacement, GraphFieldConfig, ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema'; @@ -29,9 +29,9 @@ function applySpanNullsThresholds(frame: DataFrame) { return frame; } -export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers) { +export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers, timeRange?: TimeRange | null) { let alignedFrame = outerJoinDataFrames({ - frames: frames.map((frame) => applyNullInsertThreshold(frame)), + frames: frames.map((frame) => applyNullInsertThreshold(frame, null, timeRange?.to.valueOf())), joinBy: dimFields.x, keep: dimFields.y, keepOriginIndices: true, diff --git a/packages/grafana-ui/src/components/Input/Input.tsx b/packages/grafana-ui/src/components/Input/Input.tsx index 4f2eb4664b2..e24d961514c 100644 --- a/packages/grafana-ui/src/components/Input/Input.tsx +++ b/packages/grafana-ui/src/components/Input/Input.tsx @@ -43,7 +43,7 @@ export const Input = React.forwardRef((props, ref) => { const styles = getInputStyles({ theme, invalid: !!invalid, width }); return ( -
+
{!!addonBefore &&
{addonBefore}
}
diff --git a/packages/grafana-ui/src/components/QueryField/QueryField.test.tsx b/packages/grafana-ui/src/components/QueryField/QueryField.test.tsx index 1e4deaf59e6..fdfee01c17f 100644 --- a/packages/grafana-ui/src/components/QueryField/QueryField.test.tsx +++ b/packages/grafana-ui/src/components/QueryField/QueryField.test.tsx @@ -1,30 +1,43 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { QueryField } from './QueryField'; +import { UnThemedQueryField } from './QueryField'; import { Editor } from 'slate'; +import { createTheme } from '@grafana/data'; describe('', () => { it('should render with null initial value', () => { - const wrapper = shallow(); + const wrapper = shallow( + + ); expect(wrapper.find('div').exists()).toBeTruthy(); }); it('should render with empty initial value', () => { - const wrapper = shallow(); + const wrapper = shallow( + + ); expect(wrapper.find('div').exists()).toBeTruthy(); }); it('should render with initial value', () => { - const wrapper = shallow(); + const wrapper = shallow( + + ); expect(wrapper.find('div').exists()).toBeTruthy(); }); it('should execute query on blur', () => { const onRun = jest.fn(); const wrapper = shallow( - + ); - const field = wrapper.instance() as QueryField; + const field = wrapper.instance() as UnThemedQueryField; expect(onRun.mock.calls.length).toBe(0); field.handleBlur(new Event('bogus'), new Editor({}), () => {}); expect(onRun.mock.calls.length).toBe(1); @@ -33,9 +46,15 @@ describe('', () => { it('should run onChange with clean text', () => { const onChange = jest.fn(); const wrapper = shallow( - + ); - const field = wrapper.instance() as QueryField; + const field = wrapper.instance() as UnThemedQueryField; field.runOnChange(); expect(onChange.mock.calls.length).toBe(1); expect(onChange.mock.calls[0][0]).toBe('my clean query '); @@ -45,7 +64,8 @@ describe('', () => { const onBlur = jest.fn(); const onRun = jest.fn(); const wrapper = shallow( - ', () => { portalOrigin="mock-origin" /> ); - const field = wrapper.instance() as QueryField; + const field = wrapper.instance() as UnThemedQueryField; expect(onBlur.mock.calls.length).toBe(0); expect(onRun.mock.calls.length).toBe(0); field.handleBlur(new Event('bogus'), new Editor({}), () => {}); @@ -62,14 +82,18 @@ describe('', () => { }); describe('syntaxLoaded', () => { it('should re-render the editor after syntax has fully loaded', () => { - const wrapper: any = shallow(); + const wrapper: any = shallow( + + ); const spyOnChange = jest.spyOn(wrapper.instance(), 'onChange').mockImplementation(jest.fn()); wrapper.instance().editor = { insertText: () => ({ deleteBackward: () => ({ value: 'fooo' }) }) }; wrapper.setProps({ syntaxLoaded: true }); expect(spyOnChange).toHaveBeenCalledWith('fooo', true); }); it('should not re-render the editor if syntax is already loaded', () => { - const wrapper: any = shallow(); + const wrapper: any = shallow( + + ); const spyOnChange = jest.spyOn(wrapper.instance(), 'onChange').mockImplementation(jest.fn()); wrapper.setProps({ syntaxLoaded: true }); wrapper.instance().editor = {}; @@ -77,14 +101,18 @@ describe('', () => { expect(spyOnChange).not.toBeCalled(); }); it('should not re-render the editor if editor itself is not defined', () => { - const wrapper: any = shallow(); + const wrapper: any = shallow( + + ); const spyOnChange = jest.spyOn(wrapper.instance(), 'onChange').mockImplementation(jest.fn()); wrapper.setProps({ syntaxLoaded: true }); expect(wrapper.instance().editor).toBeFalsy(); expect(spyOnChange).not.toBeCalled(); }); it('should not re-render the editor twice once syntax is fully loaded', () => { - const wrapper: any = shallow(); + const wrapper: any = shallow( + + ); const spyOnChange = jest.spyOn(wrapper.instance(), 'onChange').mockImplementation(jest.fn()); wrapper.instance().editor = { insertText: () => ({ deleteBackward: () => ({ value: 'fooo' }) }) }; wrapper.setProps({ syntaxLoaded: true }); diff --git a/packages/grafana-ui/src/components/QueryField/QueryField.tsx b/packages/grafana-ui/src/components/QueryField/QueryField.tsx index 1191a70380e..bf22dac6fe0 100644 --- a/packages/grafana-ui/src/components/QueryField/QueryField.tsx +++ b/packages/grafana-ui/src/components/QueryField/QueryField.tsx @@ -16,10 +16,22 @@ import { SuggestionsPlugin, } from '../../slate-plugins'; -import { makeValue, SCHEMA, CompletionItemGroup, TypeaheadOutput, TypeaheadInput, SuggestionsState } from '../..'; +import { + makeValue, + SCHEMA, + CompletionItemGroup, + TypeaheadOutput, + TypeaheadInput, + SuggestionsState, + Themeable2, +} from '../..'; import { selectors } from '@grafana/e2e-selectors'; +import { css, cx } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { withTheme2 } from '../../themes'; +import { getFocusStyles } from '../../themes/mixins'; -export interface QueryFieldProps { +export interface QueryFieldProps extends Themeable2 { additionalPlugins?: Plugin[]; cleanText?: (text: string) => string; disabled?: boolean; @@ -38,6 +50,7 @@ export interface QueryFieldProps { portalOrigin: string; syntax?: string; syntaxLoaded?: boolean; + theme: GrafanaTheme2; } export interface QueryFieldState { @@ -54,7 +67,7 @@ export interface QueryFieldState { * This component can only process strings. Internally it uses Slate Value. * Implement props.onTypeahead to use suggestions, see PromQueryField.tsx as an example. */ -export class QueryField extends React.PureComponent { +export class UnThemedQueryField extends React.PureComponent { plugins: Plugin[]; runOnChangeDebounced: Function; lastExecutedValue: Value | null = null; @@ -197,13 +210,14 @@ export class QueryField extends React.PureComponent +
(this.editor = editor!)} @@ -227,4 +241,15 @@ export class QueryField extends React.PureComponent { + const focusStyles = getFocusStyles(theme); + return { + wrapper: css` + &:focus-within { + ${focusStyles} + } + `, + }; +}; diff --git a/packages/grafana-ui/src/components/SecretInput/SecretInput.story.tsx b/packages/grafana-ui/src/components/SecretInput/SecretInput.story.tsx new file mode 100644 index 00000000000..60da75ef7de --- /dev/null +++ b/packages/grafana-ui/src/components/SecretInput/SecretInput.story.tsx @@ -0,0 +1,60 @@ +import React, { useState, ChangeEvent } from 'react'; +import { Story, Meta } from '@storybook/react'; +import { SecretInput, Props } from './SecretInput'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; + +export default { + title: 'Forms/SecretInput', + component: SecretInput, + decorators: [withCenteredStory], + parameters: { + controls: { + exclude: [ + 'prefix', + 'suffix', + 'addonBefore', + 'addonAfter', + 'type', + 'disabled', + 'invalid', + 'loading', + 'before', + 'after', + ], + }, + }, + args: { + width: 50, + placeholder: 'Enter your secret...', + }, + argTypes: { + width: { control: { type: 'range', min: 10, max: 200, step: 10 } }, + }, +} as Meta; + +const Template: Story = (args) => { + const [secret, setSecret] = useState(''); + + return ( + ) => setSecret(event.target.value.trim())} + onReset={() => setSecret('')} + /> + ); +}; + +export const basic = Template.bind({}); + +basic.args = { + isConfigured: false, +}; + +export const secretIsConfigured = Template.bind({}); + +secretIsConfigured.args = { + isConfigured: true, +}; diff --git a/packages/grafana-ui/src/components/SecretInput/SecretInput.test.tsx b/packages/grafana-ui/src/components/SecretInput/SecretInput.test.tsx new file mode 100644 index 00000000000..010ed2fdf59 --- /dev/null +++ b/packages/grafana-ui/src/components/SecretInput/SecretInput.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SecretInput, RESET_BUTTON_TEXT, CONFIGURED_TEXT } from './SecretInput'; + +const PLACEHOLDER_TEXT = 'Your secret...'; + +describe('', () => { + it('should render an input if the secret is not configured', () => { + render( {}} onReset={() => {}} placeholder={PLACEHOLDER_TEXT} />); + + const input = screen.getByPlaceholderText(PLACEHOLDER_TEXT); + + // Should show an enabled input + expect(input).toBeInTheDocument(); + expect(input).not.toBeDisabled(); + + // Should not show a "Reset" button + expect(screen.queryByRole('button', { name: RESET_BUTTON_TEXT })).not.toBeInTheDocument(); + }); + + it('should render a disabled input with a reset button if the secret is already configured', () => { + render( {}} onReset={() => {}} placeholder={PLACEHOLDER_TEXT} />); + + const input = screen.getByPlaceholderText(PLACEHOLDER_TEXT); + + // Should show a disabled input + expect(input).toBeInTheDocument(); + expect(input).toBeDisabled(); + expect(input).toHaveValue(CONFIGURED_TEXT); + + // Should show a reset button + expect(screen.queryByRole('button', { name: RESET_BUTTON_TEXT })).toBeInTheDocument(); + }); + + it('should be possible to reset a configured secret', () => { + const onReset = jest.fn(); + + render( {}} onReset={onReset} placeholder={PLACEHOLDER_TEXT} />); + + // Should show a reset button and a disabled input + expect(screen.queryByPlaceholderText(PLACEHOLDER_TEXT)).toBeDisabled(); + expect(screen.queryByRole('button', { name: RESET_BUTTON_TEXT })).toBeInTheDocument(); + + // Click on "Reset" + userEvent.click(screen.getByRole('button', { name: RESET_BUTTON_TEXT })); + + expect(onReset).toHaveBeenCalledTimes(1); + }); + + it('should be possible to change the value of the secret', () => { + const onChange = jest.fn(); + + render( {}} placeholder={PLACEHOLDER_TEXT} />); + + const input = screen.getByPlaceholderText(PLACEHOLDER_TEXT); + + expect(input).toHaveValue(''); + + userEvent.type(input, 'Foo'); + + expect(onChange).toHaveBeenCalled(); + expect(input).toHaveValue('Foo'); + }); +}); diff --git a/packages/grafana-ui/src/components/SecretInput/SecretInput.tsx b/packages/grafana-ui/src/components/SecretInput/SecretInput.tsx new file mode 100644 index 00000000000..d46a70903a1 --- /dev/null +++ b/packages/grafana-ui/src/components/SecretInput/SecretInput.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { Input } from '../Input/Input'; +import { HorizontalGroup } from '../Layout/Layout'; +import { Button } from '../Button'; + +export type Props = React.ComponentProps & { + /** TRUE if the secret was already configured. (It is needed as often the backend doesn't send back the actual secret, only the information that it was configured) */ + isConfigured: boolean; + /** Called when the user clicks on the "Reset" button in order to clear the secret */ + onReset: () => void; +}; + +export const CONFIGURED_TEXT = 'configured'; +export const RESET_BUTTON_TEXT = 'Reset'; + +export const SecretInput = ({ isConfigured, onReset, ...props }: Props) => ( + + {!isConfigured && } + {isConfigured && } + {isConfigured && ( + + )} + +); diff --git a/packages/grafana-ui/src/components/SecretInput/index.tsx b/packages/grafana-ui/src/components/SecretInput/index.tsx new file mode 100644 index 00000000000..ff621221c96 --- /dev/null +++ b/packages/grafana-ui/src/components/SecretInput/index.tsx @@ -0,0 +1 @@ +export { SecretInput } from './SecretInput'; diff --git a/packages/grafana-ui/src/components/Table/CellActions.tsx b/packages/grafana-ui/src/components/Table/CellActions.tsx new file mode 100644 index 00000000000..bf140fca42d --- /dev/null +++ b/packages/grafana-ui/src/components/Table/CellActions.tsx @@ -0,0 +1,70 @@ +import React, { useCallback, useState } from 'react'; +import { IconSize } from '../../types/icon'; +import { IconButton } from '../IconButton/IconButton'; +import { HorizontalGroup } from '../Layout/Layout'; +import { TooltipPlacement } from '../Tooltip'; +import { TableCellInspectModal } from './TableCellInspectModal'; +import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, TableCellProps, TableFieldOptions } from './types'; +import { getTextAlign } from './utils'; + +interface CellActionProps extends TableCellProps { + previewMode: 'text' | 'code'; +} + +export function CellActions({ field, cell, previewMode, onCellFilterAdded }: CellActionProps) { + const [isInspecting, setIsInspecting] = useState(false); + + const isRightAligned = getTextAlign(field) === 'flex-end'; + const showFilters = Boolean(field.config.filterable) && cell.value !== undefined; + const inspectEnabled = Boolean((field.config.custom as TableFieldOptions)?.inspect); + const commonButtonProps = { + size: 'sm' as IconSize, + tooltipPlacement: 'top' as TooltipPlacement, + }; + + const onFilterFor = useCallback( + (event: React.MouseEvent) => + onCellFilterAdded({ key: field.name, operator: FILTER_FOR_OPERATOR, value: cell.value }), + [cell, field, onCellFilterAdded] + ); + const onFilterOut = useCallback( + (event: React.MouseEvent) => + onCellFilterAdded({ key: field.name, operator: FILTER_OUT_OPERATOR, value: cell.value }), + [cell, field, onCellFilterAdded] + ); + + return ( + <> +
+ + {inspectEnabled && ( + { + setIsInspecting(true); + }} + {...commonButtonProps} + /> + )} + {showFilters && ( + + )} + {showFilters && ( + + )} + +
+ + {isInspecting && ( + { + setIsInspecting(false); + }} + /> + )} + + ); +} diff --git a/packages/grafana-ui/src/components/Table/DefaultCell.tsx b/packages/grafana-ui/src/components/Table/DefaultCell.tsx index 503dbcd4da3..476cd60b492 100644 --- a/packages/grafana-ui/src/components/Table/DefaultCell.tsx +++ b/packages/grafana-ui/src/components/Table/DefaultCell.tsx @@ -1,15 +1,16 @@ import React, { FC, ReactElement } from 'react'; import { DisplayValue, Field, formattedValueToString } from '@grafana/data'; -import { TableCellDisplayMode, TableCellProps } from './types'; +import { TableCellDisplayMode, TableCellProps, TableFieldOptions } from './types'; import tinycolor from 'tinycolor2'; import { TableStyles } from './styles'; -import { FilterActions } from './FilterActions'; import { getTextColorForBackground, getCellLinks } from '../../utils'; +import { CellActions } from './CellActions'; export const DefaultCell: FC = (props) => { const { field, cell, tableStyles, row, cellProps } = props; + const inspectEnabled = Boolean((field.config.custom as TableFieldOptions)?.inspect); const displayValue = field.display!(cell.value); let value: string | ReactElement; @@ -19,8 +20,9 @@ export const DefaultCell: FC = (props) => { value = formattedValueToString(displayValue); } - const cellStyle = getCellStyle(tableStyles, field, displayValue); const showFilters = field.config.filterable; + const showActions = (showFilters && cell.value !== undefined) || inspectEnabled; + const cellStyle = getCellStyle(tableStyles, field, displayValue, inspectEnabled); const { link, onClick } = getCellLinks(field, row); @@ -32,20 +34,25 @@ export const DefaultCell: FC = (props) => { {value} )} - {showFilters && cell.value !== undefined && } + {showActions && }
); }; -function getCellStyle(tableStyles: TableStyles, field: Field, displayValue: DisplayValue) { +function getCellStyle( + tableStyles: TableStyles, + field: Field, + displayValue: DisplayValue, + disableOverflowOnHover = false +) { if (field.config.custom?.displayMode === TableCellDisplayMode.ColorText) { - return tableStyles.buildCellContainerStyle(displayValue.color); + return tableStyles.buildCellContainerStyle(displayValue.color, undefined, !disableOverflowOnHover); } if (field.config.custom?.displayMode === TableCellDisplayMode.ColorBackgroundSolid) { const bgColor = tinycolor(displayValue.color); const textColor = getTextColorForBackground(displayValue.color!); - return tableStyles.buildCellContainerStyle(textColor, bgColor.toRgbString()); + return tableStyles.buildCellContainerStyle(textColor, bgColor.toRgbString(), !disableOverflowOnHover); } if (field.config.custom?.displayMode === TableCellDisplayMode.ColorBackground) { @@ -59,9 +66,10 @@ function getCellStyle(tableStyles: TableStyles, field: Field, displayValue: Disp return tableStyles.buildCellContainerStyle( textColor, - `linear-gradient(120deg, ${bgColor2}, ${displayValue.color})` + `linear-gradient(120deg, ${bgColor2}, ${displayValue.color})`, + !disableOverflowOnHover ); } - return tableStyles.cellContainer; + return disableOverflowOnHover ? tableStyles.cellContainerNoOverflow : tableStyles.cellContainer; } diff --git a/packages/grafana-ui/src/components/Table/FilterActions.tsx b/packages/grafana-ui/src/components/Table/FilterActions.tsx deleted file mode 100644 index 21dc07dab90..00000000000 --- a/packages/grafana-ui/src/components/Table/FilterActions.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { FC, useCallback } from 'react'; -import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, TableCellProps } from './types'; -import { Icon, Tooltip } from '..'; - -export const FilterActions: FC = ({ cell, field, tableStyles, onCellFilterAdded }) => { - const onFilterFor = useCallback( - (event: React.MouseEvent) => - onCellFilterAdded({ key: field.name, operator: FILTER_FOR_OPERATOR, value: cell.value }), - [cell, field, onCellFilterAdded] - ); - const onFilterOut = useCallback( - (event: React.MouseEvent) => - onCellFilterAdded({ key: field.name, operator: FILTER_OUT_OPERATOR, value: cell.value }), - [cell, field, onCellFilterAdded] - ); - - return ( -
-
- - - -
-
- - - -
-
- ); -}; diff --git a/packages/grafana-ui/src/components/Table/JSONViewCell.tsx b/packages/grafana-ui/src/components/Table/JSONViewCell.tsx index e2cfe1d87f1..57fda2b325c 100644 --- a/packages/grafana-ui/src/components/Table/JSONViewCell.tsx +++ b/packages/grafana-ui/src/components/Table/JSONViewCell.tsx @@ -1,15 +1,12 @@ import React from 'react'; import { css, cx } from '@emotion/css'; import { isString } from 'lodash'; -import { Tooltip } from '../Tooltip/Tooltip'; -import { JSONFormatter } from '../JSONFormatter/JSONFormatter'; -import { useStyles2 } from '../../themes'; -import { TableCellProps } from './types'; -import { GrafanaTheme2 } from '@grafana/data'; +import { TableCellProps, TableFieldOptions } from './types'; +import { CellActions } from './CellActions'; export function JSONViewCell(props: TableCellProps): JSX.Element { - const { cell, tableStyles, cellProps } = props; - + const { cell, tableStyles, cellProps, field } = props; + const inspectEnabled = Boolean((field.config.custom as TableFieldOptions)?.inspect); const txt = css` cursor: pointer; font-family: monospace; @@ -26,41 +23,10 @@ export function JSONViewCell(props: TableCellProps): JSX.Element { displayValue = JSON.stringify(value, null, ' '); } - const content = ; - return ( - -
-
{displayValue}
-
-
- ); -} - -interface PopupProps { - value: any; -} - -function JSONTooltip(props: PopupProps): JSX.Element { - const styles = useStyles2(getStyles); - return ( -
-
- -
+
+
{displayValue}
+ {inspectEnabled && }
); } - -function getStyles(theme: GrafanaTheme2) { - return { - container: css` - padding: ${theme.spacing(0.5)}; - `, - json: css` - width: fit-content; - max-height: 70vh; - overflow-y: auto; - `, - }; -} diff --git a/packages/grafana-ui/src/components/Table/TableCellInspectModal.tsx b/packages/grafana-ui/src/components/Table/TableCellInspectModal.tsx new file mode 100644 index 00000000000..a5d204fe058 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableCellInspectModal.tsx @@ -0,0 +1,75 @@ +import { isString } from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { ClipboardButton } from '../ClipboardButton/ClipboardButton'; +import { Icon } from '../Icon/Icon'; +import { Modal } from '../Modal/Modal'; +import { CodeEditor } from '../Monaco/CodeEditor'; + +interface TableCellInspectModalProps { + value: any; + onDismiss: () => void; + mode: 'code' | 'text'; +} + +export function TableCellInspectModal({ value, onDismiss, mode }: TableCellInspectModalProps) { + const [isInClipboard, setIsInClipboard] = useState(false); + const timeoutRef = React.useRef(); + + useEffect(() => { + if (isInClipboard) { + timeoutRef.current = window.setTimeout(() => { + setIsInClipboard(false); + }, 2000); + } + + return () => { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + }; + }, [isInClipboard]); + + let displayValue = value; + if (isString(value)) { + try { + value = JSON.parse(value); + } catch {} // ignore errors + } else { + displayValue = JSON.stringify(value, null, ' '); + } + let text = displayValue; + + if (mode === 'code') { + text = JSON.stringify(value, null, ' '); + } + + return ( + + {mode === 'code' ? ( + 100} + value={text} + readOnly={true} + /> + ) : ( +
{text}
+ )} + + text} onClipboardCopy={() => setIsInClipboard(true)}> + {!isInClipboard ? ( + 'Copy to Clipboard' + ) : ( + <> + + Copied to clipboard + + )} + + +
+ ); +} diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index 76adbf40729..d11a186dd58 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -1,4 +1,4 @@ -import { css, cx } from '@emotion/css'; +import { css, CSSObject } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { getScrollbarWidth } from '../../utils'; @@ -14,8 +14,27 @@ export const getTableStyles = (theme: GrafanaTheme2) => { const rowHoverBg = theme.colors.emphasize(theme.colors.background.primary, 0.03); const lastChildExtraPadding = Math.max(getScrollbarWidth(), cellPadding); - const buildCellContainerStyle = (color?: string, background?: string) => { + const buildCellContainerStyle = (color?: string, background?: string, overflowOnHover?: boolean) => { + const cellActionsOverflow: CSSObject = { + margin: theme.spacing(0, -0.5, 0, 0.5), + }; + const cellActionsNoOverflow: CSSObject = { + position: 'absolute', + top: 0, + right: 0, + margin: 'auto', + }; + + const onHoverOverflow: CSSObject = { + overflow: 'visible', + width: 'auto !important', + boxShadow: `0 0 2px ${theme.colors.primary.main}`, + background: background ?? rowHoverBg, + zIndex: 1, + }; + return css` + label: ${overflowOnHover ? 'cellContainerOverflow' : 'cellContainerNoOverflow'}; padding: ${cellPadding}px; width: 100%; height: 100%; @@ -33,19 +52,42 @@ export const getTableStyles = (theme: GrafanaTheme2) => { } &:hover { - overflow: visible; - width: auto !important; - box-shadow: 0 0 2px ${theme.colors.primary.main}; - background: ${background ?? rowHoverBg}; - z-index: 1; - - .cell-filter-actions  { - display: inline-flex; + ${overflowOnHover && onHoverOverflow}; + .cellActions { + visibility: visible; + opacity: 1; + width: auto; } } + a { color: inherit; } + + .cellActions { + display: flex; + ${overflowOnHover ? cellActionsOverflow : cellActionsNoOverflow} + visibility: hidden; + opacity: 0; + width: 0; + align-items: center; + height: 100%; + padding: ${theme.spacing(1, 0.5, 1, 0.5)}; + background: ${background ? 'none' : theme.colors.emphasize(theme.colors.background.primary, 0.03)}; + + svg { + color: ${color}; + } + } + + .cellActionsLeft { + right: auto !important; + left: 0; + } + + .cellActionsTransparent { + background: none; + } `; }; @@ -102,7 +144,8 @@ export const getTableStyles = (theme: GrafanaTheme2) => { display: flex; margin-right: ${theme.spacing(0.5)}; `, - cellContainer: buildCellContainerStyle(), + cellContainer: buildCellContainerStyle(undefined, undefined, true), + cellContainerNoOverflow: buildCellContainerStyle(undefined, undefined, false), cellText: css` overflow: hidden; text-overflow: ellipsis; @@ -161,22 +204,6 @@ export const getTableStyles = (theme: GrafanaTheme2) => { opacity: 1; } `, - filterWrapper: cx( - css` - label: filterWrapper; - display: none; - justify-content: flex-end; - flex-grow: 1; - opacity: 0.6; - padding-left: ${theme.spacing(0.25)}; - `, - 'cell-filter-actions' - ), - filterItem: css` - label: filterItem; - cursor: pointer; - padding: 0 ${theme.spacing(0.025)}; - `, typeIcon: css` margin-right: ${theme.spacing(1)}; color: ${theme.colors.text.secondary}; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.tsx index 34fc50f4750..f48b86a243b 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.tsx @@ -14,16 +14,15 @@ import React from 'react'; import { css } from '@emotion/css'; -import { stylesFactory, Tooltip } from '@grafana/ui'; +import { Tooltip, useStyles2 } from '@grafana/ui'; import { TraceSpanReference } from '../types/trace'; import ReferenceLink from '../url/ReferenceLink'; -export const getStyles = stylesFactory(() => { +export const getStyles = () => { return { MultiParent: css` padding: 0 5px; - color: #000; & ~ & { margin-left: 5px; } @@ -39,7 +38,7 @@ export const getStyles = stylesFactory(() => { max-width: none; `, }; -}); +}; type TReferencesButtonProps = { references: TraceSpanReference[]; @@ -48,19 +47,19 @@ type TReferencesButtonProps = { focusSpan: (spanID: string) => void; }; -export default class ReferencesButton extends React.PureComponent { - render() { - const { references, children, tooltipText, focusSpan } = this.props; - const styles = getStyles(); +const ReferencesButton = (props: TReferencesButtonProps) => { + const { references, children, tooltipText, focusSpan } = props; + const styles = useStyles2(getStyles); - // TODO: handle multiple items with some dropdown - const ref = references[0]; - return ( - - - {children} - - - ); - } -} + // TODO: handle multiple items with some dropdown + const ref = references[0]; + return ( + + + {children} + + + ); +}; + +export default ReferencesButton; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js index 5cbc0735b95..6c6c2ff78c3 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js @@ -54,6 +54,7 @@ describe('', () => { }, spanID, logs: [], + references: [], }, }; @@ -84,29 +85,27 @@ describe('', () => { }); it('render references button', () => { - const span = Object.assign( - { - references: [ - { - refType: 'CHILD_OF', - traceID: 'trace1', + const newSpan = Object.assign({}, props.span); + const span = Object.assign(newSpan, { + references: [ + { + refType: 'CHILD_OF', + traceID: 'trace1', + spanID: 'span0', + span: { spanID: 'span0', - span: { - spanID: 'span0', - }, }, - { - refType: 'CHILD_OF', - traceID: 'otherTrace', + }, + { + refType: 'CHILD_OF', + traceID: 'otherTrace', + spanID: 'span1', + span: { spanID: 'span1', - span: { - spanID: 'span1', - }, }, - ], - }, - props.span - ); + }, + ], + }); const spanRow = shallow() .dive() diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx index 0a5caf1dce4..0244e410252 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx @@ -13,13 +13,13 @@ // limitations under the License. import * as React from 'react'; + import IoAlert from 'react-icons/lib/io/alert'; import IoArrowRightA from 'react-icons/lib/io/arrow-right-a'; -import IoNetwork from 'react-icons/lib/io/network'; import MdFileUpload from 'react-icons/lib/md/file-upload'; import { css, keyframes } from '@emotion/css'; import cx from 'classnames'; -import { stylesFactory, withTheme2 } from '@grafana/ui'; +import { Icon, stylesFactory, withTheme2 } from '@grafana/ui'; import { GrafanaTheme2 } from '@grafana/data'; import ReferencesButton from './ReferencesButton'; @@ -510,7 +510,7 @@ export class UnthemedSpanBarRow extends React.PureComponent { tooltipText="Contains multiple references" focusSpan={focusSpan} > - + )} {span.subsidiarilyReferencedBy && span.subsidiarilyReferencedBy.length > 0 && ( diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.test.js index 774b0d57a0d..b69fda5e9e2 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.test.js +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.test.js @@ -104,7 +104,7 @@ describe('', () => { expect(serviceName).toBe(span.process.serviceName); expect(endpointName).toBe(span.operationName); } else { - expect(serviceName).toBe('< span in another trace >'); + expect(serviceName).toBe('View Linked Span '); } }); }); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx index 5adf26e802e..8796d0d60b5 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx @@ -14,17 +14,51 @@ import * as React from 'react'; import { css } from '@emotion/css'; -import cx from 'classnames'; -import { useStyles2 } from '@grafana/ui'; +import { Icon, useStyles2 } from '@grafana/ui'; +import AccordianKeyValues from './AccordianKeyValues'; import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down'; import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right'; import { TraceSpanReference } from '../../types/trace'; import ReferenceLink from '../../url/ReferenceLink'; -import { uAlignIcon } from '../../uberUtilityStyles'; +import { uAlignIcon, ubMb1 } from '../../uberUtilityStyles'; +import { GrafanaTheme2 } from '@grafana/data'; +import { autoColor } from '../../Theme'; -const getStyles = () => { +const getStyles = (theme: GrafanaTheme2) => { return { + AccordianReferenceItem: css` + border-bottom: 1px solid ${autoColor(theme, '#d8d8d8')}; + `, + AccordianKeyValues: css` + margin-left: 10px; + `, + AccordianReferences: css` + label: AccordianReferences; + border: 1px solid ${autoColor(theme, '#d8d8d8')}; + position: relative; + margin-bottom: 0.25rem; + `, + AccordianReferencesHeader: css` + label: AccordianReferencesHeader; + background: ${autoColor(theme, '#e4e4e4')}; + color: inherit; + display: block; + padding: 0.25rem 0.5rem; + &:hover { + background: ${autoColor(theme, '#dadada')}; + } + `, + AccordianReferencesContent: css` + label: AccordianReferencesContent; + background: ${autoColor(theme, '#f0f0f0')}; + border-top: 1px solid ${autoColor(theme, '#d8d8d8')}; + padding: 0.5rem 0.5rem 0.25rem 0.5rem; + `, + AccordianReferencesFooter: css` + label: AccordianReferencesFooter; + color: ${autoColor(theme, '#999')}; + `, ReferencesList: css` background: #fff; border: 1px solid #ddd; @@ -53,6 +87,9 @@ const getStyles = () => { debugInfo: css` letter-spacing: 0.25px; margin: 0.5em 0 0; + flex-wrap: wrap; + display: flex; + justify-content: flex-end; `, debugLabel: css` margin: 0 5px 0 5px; @@ -69,86 +106,117 @@ type AccordianReferencesProps = { highContrast?: boolean; interactive?: boolean; isOpen: boolean; + openedItems?: Set; + onItemToggle?: (reference: TraceSpanReference) => void; onToggle?: null | (() => void); focusSpan: (uiFind: string) => void; }; type ReferenceItemProps = { data: TraceSpanReference[]; + interactive?: boolean; + openedItems?: Set; + onItemToggle?: (reference: TraceSpanReference) => void; focusSpan: (uiFind: string) => void; }; // export for test export function References(props: ReferenceItemProps) { - const { data, focusSpan } = props; + const { data, focusSpan, openedItems, onItemToggle, interactive } = props; const styles = useStyles2(getStyles); return ( -
-
    - {data.map((reference) => { - return ( -
  • - - - {reference.span ? ( - - {reference.span.process.serviceName} - {reference.span.operationName} - - ) : ( - < span in another trace > - )} - - - {reference.refType} - - - {reference.spanID} - - - - -
  • - ); - })} -
+
+ {data.map((reference, i) => ( +
+
+ + + {reference.span ? ( + + {reference.span.process.serviceName} + {reference.span.operationName} + + ) : ( + + View Linked Span + + )} + + + {reference.traceID} + + + {reference.spanID} + + + + +
+ {!!reference.tags?.length && ( +
+ onItemToggle(reference) : null} + /> +
+ )} +
+ ))}
); } -export default class AccordianReferences extends React.PureComponent { - static defaultProps: Partial = { - highContrast: false, - interactive: true, - onToggle: null, - }; - - render() { - const { data, interactive, isOpen, onToggle, focusSpan } = this.props; - const isEmpty = !Array.isArray(data) || !data.length; - const iconCls = uAlignIcon; - let arrow: React.ReactNode | null = null; - let headerProps: {} | null = null; - if (interactive) { - arrow = isOpen ? : ; - headerProps = { - 'aria-checked': isOpen, - onClick: isEmpty ? null : onToggle, - role: 'switch', - }; - } - return ( -
-
- {arrow} - - References - {' '} - ({data.length}) -
- {isOpen && } -
- ); +const AccordianReferences: React.FC = ({ + data, + interactive = true, + isOpen, + onToggle, + onItemToggle, + openedItems, + focusSpan, +}) => { + const isEmpty = !Array.isArray(data) || !data.length; + let arrow: React.ReactNode | null = null; + let HeaderComponent: 'span' | 'a' = 'span'; + let headerProps: {} | null = null; + if (interactive) { + arrow = isOpen ? : ; + HeaderComponent = 'a'; + headerProps = { + 'aria-checked': isOpen, + onClick: isEmpty ? null : onToggle, + role: 'switch', + }; } -} + + const styles = useStyles2(getStyles); + return ( +
+ + {arrow} + + References + {' '} + ({data.length}) + + {isOpen && ( + + )} +
+ ); +}; + +export default React.memo(AccordianReferences); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/DetailState.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/DetailState.tsx index 22459037132..da040f4d2dc 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/DetailState.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/DetailState.tsx @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TraceLog } from '../../types/trace'; +import { TraceLog, TraceSpanReference } from '../../types/trace'; /** * Which items of a {@link SpanDetail} component are expanded. @@ -21,6 +21,7 @@ export default class DetailState { isTagsOpen: boolean; isProcessOpen: boolean; logs: { isOpen: boolean; openedItems: Set }; + references: { isOpen: boolean; openedItems: Set }; isWarningsOpen: boolean; isStackTracesOpen: boolean; isReferencesOpen: boolean; @@ -33,6 +34,7 @@ export default class DetailState { isWarningsOpen, isStackTracesOpen, logs, + references, }: DetailState | Record = oldState || {}; this.isTagsOpen = Boolean(isTagsOpen); this.isProcessOpen = Boolean(isProcessOpen); @@ -43,6 +45,10 @@ export default class DetailState { isOpen: Boolean(logs && logs.isOpen), openedItems: logs && logs.openedItems ? new Set(logs.openedItems) : new Set(), }; + this.references = { + isOpen: Boolean(references && references.isOpen), + openedItems: references && references.openedItems ? new Set(references.openedItems) : new Set(), + }; } toggleTags() { @@ -59,7 +65,17 @@ export default class DetailState { toggleReferences() { const next = new DetailState(this); - next.isReferencesOpen = !this.isReferencesOpen; + next.references.isOpen = !this.references.isOpen; + return next; + } + + toggleReferenceItem(reference: TraceSpanReference) { + const next = new DetailState(this); + if (next.references.openedItems.has(reference)) { + next.references.openedItems.delete(reference); + } else { + next.references.openedItems.add(reference); + } return next; } diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx index 6555b51bc84..729b645e095 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx @@ -26,23 +26,24 @@ import DetailState from './DetailState'; import { formatDuration } from '../utils'; import LabeledList from '../../common/LabeledList'; import { SpanLinkFunc, TNil } from '../../types'; -import { TraceKeyValuePair, TraceLink, TraceLog, TraceSpan } from '../../types/trace'; +import { TraceKeyValuePair, TraceLink, TraceLog, TraceSpan, TraceSpanReference } from '../../types/trace'; import AccordianReferences from './AccordianReferences'; import { autoColor } from '../../Theme'; +import { uAlignIcon, ubM0, ubMb1, ubMy1, ubTxRightAlign } from '../../uberUtilityStyles'; import { Divider } from '../../common/Divider'; -import { - uAlignIcon, - ubFlex, - ubFlexAuto, - ubItemsCenter, - ubM0, - ubMb1, - ubMy1, - ubTxRightAlign, -} from '../../uberUtilityStyles'; const getStyles = (theme: GrafanaTheme2) => { return { + header: css` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0 1rem; + margin-bottom: 0.25rem; + `, + listWrapper: css` + overflow: hidden; + `, debugInfo: css` label: debugInfo; display: block; @@ -110,6 +111,7 @@ type SpanDetailProps = { traceStartTime: number; warningsToggle: (spanID: string) => void; stackTracesToggle: (spanID: string) => void; + referenceItemToggle: (spanID: string, reference: TraceSpanReference) => void; referencesToggle: (spanID: string) => void; focusSpan: (uiFind: string) => void; createSpanLink?: SpanLinkFunc; @@ -130,6 +132,7 @@ export default function SpanDetail(props: SpanDetailProps) { warningsToggle, stackTracesToggle, referencesToggle, + referenceItemToggle, focusSpan, createSpanLink, createFocusSpanLink, @@ -139,7 +142,7 @@ export default function SpanDetail(props: SpanDetailProps) { isProcessOpen, logs: logsState, isWarningsOpen, - isReferencesOpen, + references: referencesState, isStackTracesOpen, } = detailState; const { @@ -171,6 +174,15 @@ export default function SpanDetail(props: SpanDetailProps) { label: 'Start Time:', value: formatDuration(relativeStartTime), }, + ...(span.childSpanCount > 0 + ? [ + { + key: 'child_count', + label: 'Child Count:', + value: span.childSpanCount, + }, + ] + : []), ]; const styles = useStyles2(getStyles); const link = createSpanLink?.(span); @@ -178,9 +190,11 @@ export default function SpanDetail(props: SpanDetailProps) { return (
-
-

{operationName}

- +
+

{operationName}

+
+ +
{link ? ( @@ -258,8 +272,10 @@ export default function SpanDetail(props: SpanDetailProps) { {references && references.length > 0 && (references.length > 1 || references[0].refType !== 'CHILD_OF') && ( referencesToggle(spanID)} + onItemToggle={(reference) => referenceItemToggle(spanID, reference)} focusSpan={focusSpan} /> )} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.tsx index d254f34d554..c82ca42087f 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.tsx @@ -23,7 +23,7 @@ import { autoColor } from '../Theme'; import { stylesFactory, withTheme2 } from '@grafana/ui'; import { GrafanaTheme2, LinkModel } from '@grafana/data'; -import { TraceLog, TraceSpan, TraceKeyValuePair, TraceLink } from '../types/trace'; +import { TraceLog, TraceSpan, TraceKeyValuePair, TraceLink, TraceSpanReference } from '../types/trace'; import { SpanLinkFunc } from '../types'; const getStyles = stylesFactory((theme: GrafanaTheme2) => { @@ -77,6 +77,7 @@ type SpanDetailRowProps = { logItemToggle: (spanID: string, log: TraceLog) => void; logsToggle: (spanID: string) => void; processToggle: (spanID: string) => void; + referenceItemToggle: (spanID: string, reference: TraceSpanReference) => void; referencesToggle: (spanID: string) => void; warningsToggle: (spanID: string) => void; stackTracesToggle: (spanID: string) => void; @@ -111,6 +112,7 @@ export class UnthemedSpanDetailRow extends React.PureComponent void; detailStackTracesToggle: (spanID: string) => void; detailReferencesToggle: (spanID: string) => void; + detailReferenceItemToggle: (spanID: string, reference: TraceSpanReference) => void; detailProcessToggle: (spanID: string) => void; detailTagsToggle: (spanID: string) => void; detailToggle: (spanID: string) => void; @@ -440,6 +441,7 @@ export class UnthemedVirtualizedTraceView extends React.Component void; detailStackTracesToggle: (spanID: string) => void; detailReferencesToggle: (spanID: string) => void; + detailReferenceItemToggle: (spanID: string, reference: TraceSpanReference) => void; detailProcessToggle: (spanID: string) => void; detailTagsToggle: (spanID: string) => void; detailToggle: (spanID: string) => void; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/utils.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/utils.test.js index c74238fcb16..a8ad75f111f 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/utils.test.js +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/utils.test.js @@ -20,6 +20,7 @@ import { isServerSpan, spanContainsErredSpan, spanHasTag, + formatNumber, } from './utils'; import traceGenerator from '../demo/trace-generators'; diff --git a/packages/jaeger-ui-components/src/common/LabeledList.tsx b/packages/jaeger-ui-components/src/common/LabeledList.tsx index 2adb283d0f7..c03cb9482d3 100644 --- a/packages/jaeger-ui-components/src/common/LabeledList.tsx +++ b/packages/jaeger-ui-components/src/common/LabeledList.tsx @@ -17,20 +17,31 @@ import { css } from '@emotion/css'; import cx from 'classnames'; import { useStyles2 } from '@grafana/ui'; import { GrafanaTheme2 } from '@grafana/data'; +import { autoColor } from '../Theme'; -import { Divider } from './Divider'; - -const getStyles = (theme: GrafanaTheme2) => { +const getStyles = (divider: boolean) => (theme: GrafanaTheme2) => { return { LabeledList: css` label: LabeledList; list-style: none; margin: 0; padding: 0; + ${divider === true && + ` + margin-right: -8px; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + `} `, LabeledListItem: css` label: LabeledListItem; display: inline-block; + ${divider === true && + ` + border-right: 1px solid ${autoColor(theme, '#ddd')}; + padding: 0 8px; + `} `, LabeledListLabel: css` label: LabeledListLabel; @@ -42,27 +53,23 @@ const getStyles = (theme: GrafanaTheme2) => { type LabeledListProps = { className?: string; + divider?: boolean; items: Array<{ key: string; label: React.ReactNode; value: React.ReactNode }>; }; export default function LabeledList(props: LabeledListProps) { - const { className, items } = props; - const styles = useStyles2(getStyles); + const { className, divider = false, items } = props; + const styles = useStyles2(getStyles(divider)); + return (
    - {items.map(({ key, label, value }, i) => { - const divider = i < items.length - 1 && ( -
  • - -
  • - ); - return [ -
  • + {items.map(({ key, label, value }) => { + return ( +
  • {label} {value} -
  • , - divider, - ]; + + ); })}
); diff --git a/packages/jaeger-ui-components/src/model/transform-trace-data.tsx b/packages/jaeger-ui-components/src/model/transform-trace-data.tsx index 6652239bba1..1174a2e260b 100644 --- a/packages/jaeger-ui-components/src/model/transform-trace-data.tsx +++ b/packages/jaeger-ui-components/src/model/transform-trace-data.tsx @@ -138,6 +138,7 @@ export default function transformTraceData(data: TraceResponse | undefined): Tra span.relativeStartTime = span.startTime - traceStartTime; span.depth = depth - 1; span.hasChildren = node.children.length > 0; + span.childSpanCount = node.children.length; span.warnings = span.warnings || []; span.tags = span.tags || []; span.references = span.references || []; diff --git a/packages/jaeger-ui-components/src/types/trace.ts b/packages/jaeger-ui-components/src/types/trace.ts index 0b54d267cff..4e180189a4d 100644 --- a/packages/jaeger-ui-components/src/types/trace.ts +++ b/packages/jaeger-ui-components/src/types/trace.ts @@ -44,6 +44,7 @@ export type TraceSpanReference = { span?: TraceSpan | null | undefined; spanID: string; traceID: string; + tags?: TraceKeyValuePair[]; }; export type TraceSpanData = { @@ -67,6 +68,7 @@ export type TraceSpanData = { export type TraceSpan = TraceSpanData & { depth: number; hasChildren: boolean; + childSpanCount: number; process: TraceProcess; relativeStartTime: number; tags: NonNullable; diff --git a/packages/jaeger-ui-components/src/uberUtilityStyles.ts b/packages/jaeger-ui-components/src/uberUtilityStyles.ts index f2c7457f2d5..d95db6e9c8c 100644 --- a/packages/jaeger-ui-components/src/uberUtilityStyles.ts +++ b/packages/jaeger-ui-components/src/uberUtilityStyles.ts @@ -34,6 +34,10 @@ export const ubItemsCenter = css` align-items: center; `; +export const ubItemsStart = css` + align-items: start; +`; + export const ubFlexAuto = css` flex: 1 1 auto; min-width: 0; /* 1 */ diff --git a/packages/jaeger-ui-components/src/url/ReferenceLink.tsx b/packages/jaeger-ui-components/src/url/ReferenceLink.tsx index b6149269ff7..631cd86e2b1 100644 --- a/packages/jaeger-ui-components/src/url/ReferenceLink.tsx +++ b/packages/jaeger-ui-components/src/url/ReferenceLink.tsx @@ -41,6 +41,7 @@ export default function ReferenceLink(props: ReferenceLinkProps) { if (!createLinkToExternalSpan) { throw new Error("ExternalLinkContext does not have a value, you probably forgot to setup it's provider"); } + return ( 0 - thisCondNoData := nilReducedCount > 0 + thisCondNoData := len(querySeriesSet.Values) == nilReducedCount if i == 0 { firing = thisCondFiring @@ -144,7 +144,7 @@ func (ccc *ConditionsCmd) Execute(ctx context.Context, vars mathexp.Vars) (mathe noDataFound = noDataFound && thisCondNoData } - if len(querySeriesSet.Values) == nilReducedCount { + if thisCondNoData { matches = append(matches, EvalMatch{ Metric: "NoData", }) diff --git a/pkg/expr/classic/classic_test.go b/pkg/expr/classic/classic_test.go index 5e96826f1d8..623ae19f3bc 100644 --- a/pkg/expr/classic/classic_test.go +++ b/pkg/expr/classic/classic_test.go @@ -169,6 +169,31 @@ func TestConditionsCmdExecute(t *testing.T) { return v }, }, + { + name: "single query and single condition - empty series and not empty series", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + valBasedSeries(), + valBasedSeries(ptr.Float64(3)), + }, + }, + }, + conditionsCmd: &ConditionsCmd{ + Conditions: []condition{ + { + QueryRefID: "A", + Reducer: classicReducer("avg"), + Operator: "and", + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: .5}, + }, + }}, + resultNumber: func() mathexp.Number { + v := valBasedNumber(ptr.Float64(1)) + v.SetMeta([]EvalMatch{{Value: ptr.Float64(3)}}) + return v + }, + }, { name: "single query and two conditions", vars: mathexp.Vars{ diff --git a/pkg/expr/classic/reduce_test.go b/pkg/expr/classic/reduce_test.go index 50871dd61cd..ea2bc7cbbb3 100644 --- a/pkg/expr/classic/reduce_test.go +++ b/pkg/expr/classic/reduce_test.go @@ -102,6 +102,12 @@ func TestReducer(t *testing.T) { inputSeries: valBasedSeries(nil, nil, ptr.Float64(3), ptr.Float64(4)), expectedNumber: valBasedNumber(ptr.Float64(2)), }, + { + name: "count_non_null with mixed null/real values", + reducer: classicReducer("count_non_null"), + inputSeries: valBasedSeries(nil, nil, ptr.Float64(3), ptr.Float64(4)), + expectedNumber: valBasedNumber(ptr.Float64(2)), + }, { name: "count_non_null with no values", reducer: classicReducer("count_non_null"), diff --git a/pkg/infra/filestorage/api.go b/pkg/infra/filestorage/api.go new file mode 100644 index 00000000000..b89f42c46bb --- /dev/null +++ b/pkg/infra/filestorage/api.go @@ -0,0 +1,101 @@ +package filestorage + +import ( + "context" + "errors" + "strings" + "time" +) + +type StorageName string + +const ( + StorageNamePublic StorageName = "public" +) + +var ( + ErrRelativePath = errors.New("path cant be relative") + ErrNonCanonicalPath = errors.New("path must be canonical") + ErrPathTooLong = errors.New("path is too long") + ErrPathInvalid = errors.New("path is invalid") + ErrPathEndsWithDelimiter = errors.New("path can not end with delimiter") + Delimiter = "/" +) + +func Join(parts ...string) string { + return Delimiter + strings.Join(parts, Delimiter) +} + +func belongsToStorage(path string, storageName StorageName) bool { + return strings.HasPrefix(path, Delimiter+string(storageName)) +} + +type File struct { + Contents []byte + FileMetadata +} + +type FileMetadata struct { + Name string + FullPath string + MimeType string + Modified time.Time + Created time.Time + Size int64 + Properties map[string]string +} + +type ListFilesResponse struct { + Files []FileMetadata + HasMore bool + LastPath string +} + +type Paging struct { + After string + First int +} + +type UpsertFileCommand struct { + Path string + MimeType string + Contents *[]byte + Properties map[string]string +} + +type PathFilters struct { + allowedPrefixes []string +} + +func (f *PathFilters) isAllowed(path string) bool { + if f == nil || f.allowedPrefixes == nil { + return true + } + + for i := range f.allowedPrefixes { + if strings.HasPrefix(path, strings.ToLower(f.allowedPrefixes[i])) { + return true + } + } + + return false +} + +type ListOptions struct { + Recursive bool + PathFilters +} + +type FileStorage interface { + Get(ctx context.Context, path string) (*File, error) + Delete(ctx context.Context, path string) error + Upsert(ctx context.Context, command *UpsertFileCommand) error + + ListFiles(ctx context.Context, folderPath string, paging *Paging, options *ListOptions) (*ListFilesResponse, error) + ListFolders(ctx context.Context, folderPath string, options *ListOptions) ([]FileMetadata, error) + + CreateFolder(ctx context.Context, path string) error + DeleteFolder(ctx context.Context, path string) error + + close() error +} diff --git a/pkg/infra/filestorage/api_test.go b/pkg/infra/filestorage/api_test.go new file mode 100644 index 00000000000..a15d1f5edfe --- /dev/null +++ b/pkg/infra/filestorage/api_test.go @@ -0,0 +1,75 @@ +package filestorage + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFilestorageApi_Join(t *testing.T) { + var tests = []struct { + name string + parts []string + expected string + }{ + { + name: "multiple parts", + parts: []string{"prefix", "p1", "p2"}, + expected: "/prefix/p1/p2", + }, + { + name: "no parts", + parts: []string{}, + expected: "/", + }, + { + name: "a single part", + parts: []string{"prefix"}, + expected: "/prefix", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, Join(tt.parts...)) + }) + } +} + +func TestFilestorageApi_belongToStorage(t *testing.T) { + var tests = []struct { + name string + path string + storage StorageName + expected bool + }{ + { + name: "should return true if path is prefixed with delimiter and the storage name", + path: "/public/abc/d", + storage: StorageNamePublic, + expected: true, + }, + { + name: "should return true if path consists just of the delimiter and the storage name", + path: "/public", + storage: StorageNamePublic, + expected: true, + }, + { + name: "should return false if path is not prefixed with delimiter", + path: "public/abc/d", + storage: StorageNamePublic, + expected: false, + }, + { + name: "should return false if storage name does not match", + path: "/notpublic/abc/d", + storage: StorageNamePublic, + expected: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, belongsToStorage(tt.path, tt.storage)) + }) + } +} diff --git a/pkg/infra/filestorage/cdk_blob_filestorage.go b/pkg/infra/filestorage/cdk_blob_filestorage.go new file mode 100644 index 00000000000..61691e7ea48 --- /dev/null +++ b/pkg/infra/filestorage/cdk_blob_filestorage.go @@ -0,0 +1,491 @@ +package filestorage + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/grafana/grafana/pkg/infra/log" + "gocloud.dev/blob" + "gocloud.dev/gcerrors" +) + +const ( + originalPathAttributeKey = "__gf_original_path__" +) + +type cdkBlobStorage struct { + log log.Logger + bucket *blob.Bucket + rootFolder string +} + +func NewCdkBlobStorage(log log.Logger, bucket *blob.Bucket, rootFolder string, pathFilters *PathFilters) FileStorage { + return &wrapper{ + log: log, + wrapped: &cdkBlobStorage{ + log: log, + bucket: bucket, + rootFolder: rootFolder, + }, + pathFilters: pathFilters, + } +} + +func (c cdkBlobStorage) Get(ctx context.Context, filePath string) (*File, error) { + contents, err := c.bucket.ReadAll(ctx, strings.ToLower(filePath)) + if err != nil { + if gcerrors.Code(err) == gcerrors.NotFound { + return nil, nil + } + return nil, err + } + attributes, err := c.bucket.Attributes(ctx, strings.ToLower(filePath)) + if err != nil { + return nil, err + } + + var originalPath string + var props map[string]string + if attributes.Metadata != nil { + props = attributes.Metadata + if path, ok := attributes.Metadata[originalPathAttributeKey]; ok { + originalPath = path + delete(props, originalPathAttributeKey) + } + } else { + props = make(map[string]string) + originalPath = filePath + } + + return &File{ + Contents: contents, + FileMetadata: FileMetadata{ + Name: getName(originalPath), + FullPath: originalPath, + Created: attributes.CreateTime, + Properties: props, + Modified: attributes.ModTime, + Size: attributes.Size, + MimeType: detectContentType(originalPath, attributes.ContentType), + }, + }, nil +} + +func (c cdkBlobStorage) Delete(ctx context.Context, filePath string) error { + exists, err := c.bucket.Exists(ctx, strings.ToLower(filePath)) + if err != nil { + return err + } + + if !exists { + return nil + } + + err = c.bucket.Delete(ctx, strings.ToLower(filePath)) + return err +} + +func (c cdkBlobStorage) Upsert(ctx context.Context, command *UpsertFileCommand) error { + existing, err := c.Get(ctx, command.Path) + if err != nil { + return err + } + + var contents []byte + var metadata map[string]string + + if existing == nil { + if command.Contents == nil { + contents = make([]byte, 0) + } else { + contents = *command.Contents + } + + metadata = make(map[string]string) + if command.Properties != nil { + for k, v := range command.Properties { + metadata[k] = v + } + } + metadata[originalPathAttributeKey] = command.Path + return c.bucket.WriteAll(ctx, strings.ToLower(command.Path), contents, &blob.WriterOptions{ + Metadata: metadata, + }) + } + + contents = existing.Contents + if command.Contents != nil { + contents = *command.Contents + } + + if command.Properties != nil { + metadata = make(map[string]string) + for k, v := range command.Properties { + metadata[k] = v + } + } else { + metadata = existing.FileMetadata.Properties + } + + metadata[originalPathAttributeKey] = existing.FullPath + return c.bucket.WriteAll(ctx, strings.ToLower(command.Path), contents, &blob.WriterOptions{ + Metadata: metadata, + }) +} + +func (c cdkBlobStorage) listFiles(ctx context.Context, folderPath string, paging *Paging, options *ListOptions) (*ListFilesResponse, error) { + iterator := c.bucket.List(&blob.ListOptions{ + Prefix: strings.ToLower(folderPath), + Delimiter: Delimiter, + }) + + recursive := options.Recursive + + pageSize := paging.First + + foundCursor := true + if paging.After != "" { + foundCursor = false + } + + hasMore := true + files := make([]FileMetadata, 0) + for { + obj, err := iterator.Next(ctx) + if obj != nil && strings.HasSuffix(obj.Key, directoryMarker) { + continue + } + + if errors.Is(err, io.EOF) { + hasMore = false + break + } else { + hasMore = true + } + + if err != nil { + c.log.Error("Failed while iterating over files", "err", err) + return nil, err + } + + if len(files) >= pageSize { + break + } + + path := obj.Key + + allowed := options.isAllowed(obj.Key) + if obj.IsDir && recursive { + newPaging := &Paging{ + First: pageSize - len(files), + } + if paging != nil { + newPaging.After = paging.After + } + + resp, err := c.listFiles(ctx, path, newPaging, options) + + if err != nil { + return nil, err + } + + if len(files) > 0 { + foundCursor = true + } + + files = append(files, resp.Files...) + if len(files) >= pageSize { + //nolint: staticcheck + hasMore = resp.HasMore + } + } else if !obj.IsDir && allowed { + if !foundCursor { + res := strings.Compare(obj.Key, paging.After) + if res < 0 { + continue + } else if res == 0 { + foundCursor = true + continue + } else { + foundCursor = true + } + } + + attributes, err := c.bucket.Attributes(ctx, strings.ToLower(path)) + if err != nil { + c.log.Error("Failed while retrieving attributes", "path", path, "err", err) + return nil, err + } + + var originalPath string + var props map[string]string + if attributes.Metadata != nil { + props = attributes.Metadata + if path, ok := attributes.Metadata[originalPathAttributeKey]; ok { + originalPath = path + delete(props, originalPathAttributeKey) + } + } else { + props = make(map[string]string) + originalPath = fixPath(path) + } + + files = append(files, FileMetadata{ + Name: getName(originalPath), + FullPath: originalPath, + Created: attributes.CreateTime, + Properties: props, + Modified: attributes.ModTime, + Size: attributes.Size, + MimeType: detectContentType(originalPath, attributes.ContentType), + }) + } + } + + lastPath := "" + if len(files) > 0 { + lastPath = files[len(files)-1].FullPath + } + + return &ListFilesResponse{ + Files: files, + HasMore: hasMore, + LastPath: lastPath, + }, nil +} + +func (c cdkBlobStorage) fixInputPrefix(path string) string { + if path == Delimiter || path == "" { + return c.rootFolder + } + if strings.HasPrefix(path, Delimiter) { + path = fmt.Sprintf("%s%s", c.rootFolder, strings.TrimPrefix(path, Delimiter)) + } + + return path +} + +func (c cdkBlobStorage) convertFolderPathToPrefix(path string) string { + if path == Delimiter || path == "" { + return c.rootFolder + } + if strings.HasPrefix(path, Delimiter) { + path = fmt.Sprintf("%s%s", c.rootFolder, strings.TrimPrefix(path, Delimiter)) + } + return fmt.Sprintf("%s%s", path, Delimiter) +} + +func fixPath(path string) string { + newPath := strings.TrimSuffix(path, Delimiter) + if !strings.HasPrefix(newPath, Delimiter) { + newPath = fmt.Sprintf("%s%s", Delimiter, newPath) + } + return newPath +} + +func (c cdkBlobStorage) convertListOptions(options *ListOptions) *ListOptions { + if options == nil || options.allowedPrefixes == nil || len(options.allowedPrefixes) == 0 { + return options + } + + newPrefixes := make([]string, len(options.allowedPrefixes)) + for i, prefix := range options.allowedPrefixes { + newPrefixes[i] = c.fixInputPrefix(prefix) + } + + options.PathFilters.allowedPrefixes = newPrefixes + return options +} + +func (c cdkBlobStorage) ListFiles(ctx context.Context, folderPath string, paging *Paging, options *ListOptions) (*ListFilesResponse, error) { + paging.After = c.fixInputPrefix(paging.After) + return c.listFiles(ctx, c.convertFolderPathToPrefix(folderPath), paging, c.convertListOptions(options)) +} + +func (c cdkBlobStorage) listFolderPaths(ctx context.Context, parentFolderPath string, options *ListOptions) ([]string, error) { + iterator := c.bucket.List(&blob.ListOptions{ + Prefix: strings.ToLower(parentFolderPath), + Delimiter: Delimiter, + }) + + recursive := options.Recursive + + currentDirPath := "" + foundPaths := make([]string, 0) + for { + obj, err := iterator.Next(ctx) + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + c.log.Error("Failed while iterating over files", "err", err) + return nil, err + } + + if currentDirPath == "" && !obj.IsDir && options.isAllowed(obj.Key) { + attributes, err := c.bucket.Attributes(ctx, obj.Key) + if err != nil { + c.log.Error("Failed while retrieving attributes", "path", obj.Key, "err", err) + return nil, err + } + + if attributes.Metadata != nil { + if path, ok := attributes.Metadata[originalPathAttributeKey]; ok { + currentDirPath = getParentFolderPath(path) + } + } + } + + if obj.IsDir && recursive { + resp, err := c.listFolderPaths(ctx, obj.Key, options) + + if err != nil { + return nil, err + } + + if len(resp) > 0 { + foundPaths = append(foundPaths, resp...) + } + continue + } + } + + if currentDirPath != "" { + foundPaths = append(foundPaths, fixPath(currentDirPath)) + } + return foundPaths, nil +} + +func (c cdkBlobStorage) ListFolders(ctx context.Context, prefix string, options *ListOptions) ([]FileMetadata, error) { + foundPaths, err := c.listFolderPaths(ctx, c.convertFolderPathToPrefix(prefix), c.convertListOptions(options)) + if err != nil { + return nil, err + } + + folders := make([]FileMetadata, 0) + mem := make(map[string]bool) + for i := 0; i < len(foundPaths); i++ { + path := foundPaths[i] + parts := strings.Split(path, Delimiter) + acc := parts[0] + j := 1 + for { + acc = fmt.Sprintf("%s%s%s", acc, Delimiter, parts[j]) + + comparison := strings.Compare(acc, prefix) + if !mem[acc] && comparison > 0 { + folders = append(folders, FileMetadata{ + Name: getName(acc), + FullPath: acc, + }) + } + mem[acc] = true + + j += 1 + if j >= len(parts) { + break + } + } + } + + return folders, err +} + +func precedingFolders(path string) []string { + parts := strings.Split(path, Delimiter) + if len(parts) == 0 { + return []string{} + } + + if len(parts) == 1 { + return []string{path} + } + + currentDirPath := "" + firstPart := 0 + if parts[0] == "" { + firstPart = 1 + currentDirPath = Delimiter + } + + res := make([]string, 0) + for i := firstPart; i < len(parts); i++ { + res = append(res, currentDirPath+parts[i]) + currentDirPath += parts[i] + Delimiter + } + + return res +} + +func (c cdkBlobStorage) CreateFolder(ctx context.Context, path string) error { + c.log.Info("Creating folder", "path", path) + + precedingFolders := precedingFolders(path) + folderToOriginalCasing := make(map[string]string) + foundFolderIndex := -1 + + for i := len(precedingFolders) - 1; i >= 0; i-- { + currentFolder := precedingFolders[i] + att, err := c.bucket.Attributes(ctx, strings.ToLower(currentFolder+Delimiter+directoryMarker)) + if err != nil { + if gcerrors.Code(err) != gcerrors.NotFound { + return err + } + folderToOriginalCasing[currentFolder] = currentFolder + continue + } + + if path, ok := att.Metadata[originalPathAttributeKey]; ok { + folderToOriginalCasing[currentFolder] = getParentFolderPath(path) + foundFolderIndex = i + break + } else { + folderToOriginalCasing[currentFolder] = currentFolder + } + } + + for i := foundFolderIndex + 1; i < len(precedingFolders); i++ { + currentFolder := precedingFolders[i] + + previousFolderOriginalCasing := "" + if i > 0 { + previousFolderOriginalCasing = folderToOriginalCasing[precedingFolders[i-1]] + } + + metadata := make(map[string]string) + currentFolderWithOriginalCasing := previousFolderOriginalCasing + Delimiter + getName(currentFolder) + metadata[originalPathAttributeKey] = currentFolderWithOriginalCasing + Delimiter + directoryMarker + if err := c.bucket.WriteAll(ctx, strings.ToLower(metadata[originalPathAttributeKey]), make([]byte, 0), &blob.WriterOptions{ + Metadata: metadata, + }); err != nil { + return err + } + c.log.Info("Created folder", "path", currentFolderWithOriginalCasing, "marker", metadata[originalPathAttributeKey]) + } + + return nil +} + +func (c cdkBlobStorage) DeleteFolder(ctx context.Context, folderPath string) error { + directoryMarkerPath := fmt.Sprintf("%s%s%s", folderPath, Delimiter, directoryMarker) + exists, err := c.bucket.Exists(ctx, strings.ToLower(directoryMarkerPath)) + + if err != nil { + return err + } + + if !exists { + return nil + } + + err = c.bucket.Delete(ctx, strings.ToLower(directoryMarkerPath)) + return err +} + +func (c cdkBlobStorage) close() error { + return c.bucket.Close() +} diff --git a/pkg/infra/filestorage/db_filestorage.go b/pkg/infra/filestorage/db_filestorage.go new file mode 100644 index 00000000000..ff7ba4ecba2 --- /dev/null +++ b/pkg/infra/filestorage/db_filestorage.go @@ -0,0 +1,449 @@ +package filestorage + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/util/errutil" +) + +type file struct { + Path string `xorm:"path"` + ParentFolderPath string `xorm:"parent_folder_path"` + Contents []byte `xorm:"contents"` + Updated time.Time `xorm:"updated"` + Created time.Time `xorm:"created"` + Size int64 `xorm:"size"` + MimeType string `xorm:"mime_type"` +} + +type fileMeta struct { + Path string `xorm:"path"` + Key string `xorm:"key"` + Value string `xorm:"value"` +} + +type dbFileStorage struct { + db *sqlstore.SQLStore + log log.Logger +} + +func NewDbStorage(log log.Logger, db *sqlstore.SQLStore, pathFilters *PathFilters) FileStorage { + return &wrapper{ + log: log, + wrapped: &dbFileStorage{ + log: log, + db: db, + }, + pathFilters: pathFilters, + } +} + +func (s dbFileStorage) getProperties(sess *sqlstore.DBSession, lowerCasePaths []string) (map[string]map[string]string, error) { + attributesByPath := make(map[string]map[string]string) + + entities := make([]*fileMeta, 0) + if err := sess.Table("file_meta").In("path", lowerCasePaths).Find(&entities); err != nil { + return nil, err + } + + for _, entity := range entities { + if _, ok := attributesByPath[entity.Path]; !ok { + attributesByPath[entity.Path] = make(map[string]string) + } + attributesByPath[entity.Path][entity.Key] = entity.Value + } + + return attributesByPath, nil +} + +func (s dbFileStorage) Get(ctx context.Context, filePath string) (*File, error) { + var result *File + err := s.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + table := &file{} + exists, err := sess.Table("file").Where("LOWER(path) = ?", strings.ToLower(filePath)).Get(table) + if !exists { + return nil + } + + var meta = make([]*fileMeta, 0) + if err := sess.Table("file_meta").Where("path = ?", strings.ToLower(filePath)).Find(&meta); err != nil { + return err + } + + var metaProperties = make(map[string]string, len(meta)) + + for i := range meta { + metaProperties[meta[i].Key] = meta[i].Value + } + + contents := table.Contents + if contents == nil { + contents = make([]byte, 0) + } + + result = &File{ + Contents: contents, + FileMetadata: FileMetadata{ + Name: getName(table.Path), + FullPath: table.Path, + Created: table.Created, + Properties: metaProperties, + Modified: table.Updated, + Size: table.Size, + MimeType: table.MimeType, + }, + } + return err + }) + + return result, err +} + +func (s dbFileStorage) Delete(ctx context.Context, filePath string) error { + err := s.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + table := &file{} + exists, innerErr := sess.Table("file").Where("LOWER(path) = ?", strings.ToLower(filePath)).Get(table) + if innerErr != nil { + return innerErr + } + + if !exists { + return nil + } + + number, innerErr := sess.Table("file").Where("LOWER(path) = ?", strings.ToLower(filePath)).Delete(table) + if innerErr != nil { + return innerErr + } + s.log.Info("Deleted file", "path", filePath, "affectedRecords", number) + + metaTable := &fileMeta{} + number, innerErr = sess.Table("file_meta").Where("path = ?", strings.ToLower(filePath)).Delete(metaTable) + if innerErr != nil { + return innerErr + } + s.log.Info("Deleted metadata", "path", filePath, "affectedRecords", number) + return innerErr + }) + + return err +} + +func (s dbFileStorage) Upsert(ctx context.Context, cmd *UpsertFileCommand) error { + now := time.Now() + err := s.db.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + existing := &file{} + exists, err := sess.Table("file").Where("LOWER(path) = ?", strings.ToLower(cmd.Path)).Get(existing) + if err != nil { + return err + } + + if exists { + existing.Updated = now + if cmd.Contents != nil { + contents := *cmd.Contents + existing.Contents = contents + existing.MimeType = cmd.MimeType + existing.Size = int64(len(contents)) + } + + _, err = sess.Where("LOWER(path) = ?", strings.ToLower(cmd.Path)).Update(existing) + if err != nil { + return err + } + } else { + contentsToInsert := make([]byte, 0) + if cmd.Contents != nil { + contentsToInsert = *cmd.Contents + } + + file := &file{ + Path: cmd.Path, + ParentFolderPath: getParentFolderPath(cmd.Path), + Contents: contentsToInsert, + MimeType: cmd.MimeType, + Size: int64(len(contentsToInsert)), + Updated: now, + Created: now, + } + _, err := sess.Insert(file) + if err != nil { + return err + } + } + + if len(cmd.Properties) != 0 { + if err = upsertProperties(sess, now, cmd); err != nil { + if rollbackErr := sess.Rollback(); rollbackErr != nil { + s.log.Error("failed while rolling back upsert", "path", cmd.Path) + } + return err + } + } + + return err + }) + + return err +} + +func upsertProperties(sess *sqlstore.DBSession, now time.Time, cmd *UpsertFileCommand) error { + fileMeta := &fileMeta{} + _, err := sess.Table("file_meta").Where("path = ?", strings.ToLower(cmd.Path)).Delete(fileMeta) + if err != nil { + return err + } + + for key, val := range cmd.Properties { + if err := upsertProperty(sess, now, cmd.Path, key, val); err != nil { + return err + } + } + return nil +} + +func upsertProperty(sess *sqlstore.DBSession, now time.Time, path string, key string, val string) error { + existing := &fileMeta{} + exists, err := sess.Table("file_meta").Where("path = ? AND key = ?", strings.ToLower(path), key).Get(existing) + if err != nil { + return err + } + + if exists { + existing.Value = val + _, err = sess.Where("path = ? AND key = ?", strings.ToLower(path), key).Update(existing) + } else { + _, err = sess.Insert(&fileMeta{ + Path: strings.ToLower(path), + Key: key, + Value: val, + }) + } + return err +} + +func (s dbFileStorage) ListFiles(ctx context.Context, folderPath string, paging *Paging, options *ListOptions) (*ListFilesResponse, error) { + var resp *ListFilesResponse + + err := s.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + var foundFiles = make([]*file, 0) + + sess.Table("file") + lowerFolderPath := strings.ToLower(folderPath) + if options.Recursive { + var nestedFolders string + if folderPath == Delimiter { + nestedFolders = "%" + } else { + nestedFolders = fmt.Sprintf("%s%s%s", lowerFolderPath, Delimiter, "%") + } + sess.Where("(LOWER(parent_folder_path) = ?) OR (LOWER(parent_folder_path) LIKE ?)", lowerFolderPath, nestedFolders) + } else { + sess.Where("LOWER(parent_folder_path) = ?", lowerFolderPath) + } + sess.Where("LOWER(path) NOT LIKE ?", fmt.Sprintf("%s%s%s", "%", Delimiter, directoryMarker)) + + for _, prefix := range options.PathFilters.allowedPrefixes { + sess.Where("LOWER(path) LIKE ?", fmt.Sprintf("%s%s", strings.ToLower(prefix), "%")) + } + + sess.OrderBy("path") + + pageSize := paging.First + sess.Limit(pageSize + 1) + + if paging != nil && paging.After != "" { + sess.Where("path > ?", paging.After) + } + + if err := sess.Find(&foundFiles); err != nil { + return err + } + + foundLength := len(foundFiles) + if foundLength > pageSize { + foundLength = pageSize + } + + lowerCasePaths := make([]string, 0) + for i := 0; i < foundLength; i++ { + lowerCasePaths = append(lowerCasePaths, strings.ToLower(foundFiles[i].Path)) + } + propertiesByLowerPath, err := s.getProperties(sess, lowerCasePaths) + if err != nil { + return err + } + + files := make([]FileMetadata, 0) + for i := 0; i < foundLength; i++ { + var props map[string]string + path := foundFiles[i].Path + if foundProps, ok := propertiesByLowerPath[strings.ToLower(path)]; ok { + props = foundProps + } else { + props = make(map[string]string) + } + + files = append(files, FileMetadata{ + Name: getName(path), + FullPath: path, + Created: foundFiles[i].Created, + Properties: props, + Modified: foundFiles[i].Updated, + Size: foundFiles[i].Size, + MimeType: foundFiles[i].MimeType, + }) + } + + lastPath := "" + if len(files) > 0 { + lastPath = files[len(files)-1].FullPath + } + + resp = &ListFilesResponse{ + Files: files, + LastPath: lastPath, + HasMore: len(foundFiles) == pageSize+1, + } + return nil + }) + + return resp, err +} + +func (s dbFileStorage) ListFolders(ctx context.Context, parentFolderPath string, options *ListOptions) ([]FileMetadata, error) { + folders := make([]FileMetadata, 0) + err := s.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + var foundPaths []string + + sess.Table("file") + sess.Distinct("parent_folder_path") + + if options.Recursive { + sess.Where("LOWER(parent_folder_path) > ?", strings.ToLower(parentFolderPath)) + } else { + sess.Where("LOWER(parent_folder_path) = ?", strings.ToLower(parentFolderPath)) + } + + for _, prefix := range options.PathFilters.allowedPrefixes { + sess.Where("LOWER(parent_folder_path) LIKE ?", fmt.Sprintf("%s%s", strings.ToLower(prefix), "%")) + } + + sess.OrderBy("parent_folder_path") + sess.Cols("parent_folder_path") + + if err := sess.Find(&foundPaths); err != nil { + return err + } + + mem := make(map[string]bool) + for i := 0; i < len(foundPaths); i++ { + path := foundPaths[i] + parts := strings.Split(path, Delimiter) + acc := parts[0] + j := 1 + for { + acc = fmt.Sprintf("%s%s%s", acc, Delimiter, parts[j]) + comparison := strings.Compare(acc, parentFolderPath) + if !mem[acc] && comparison > 0 { + folders = append(folders, FileMetadata{ + Name: getName(acc), + FullPath: acc, + }) + } + mem[acc] = true + + j += 1 + if j >= len(parts) { + break + } + } + } + + return nil + }) + + return folders, err +} + +func (s dbFileStorage) CreateFolder(ctx context.Context, path string) error { + now := time.Now() + precedingFolders := precedingFolders(path) + + err := s.db.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + var insertErr error + sess.MustLogSQL(true) + previousFolder := "" + for i := 0; i < len(precedingFolders); i++ { + existing := &file{} + directoryMarkerParentPath := previousFolder + Delimiter + getName(precedingFolders[i]) + previousFolder = directoryMarkerParentPath + directoryMarkerPath := fmt.Sprintf("%s%s%s", directoryMarkerParentPath, Delimiter, directoryMarker) + lower := strings.ToLower(directoryMarkerPath) + exists, err := sess.Table("file").Where("LOWER(path) = ?", lower).Get(existing) + if err != nil { + insertErr = err + break + } + + if exists { + previousFolder = existing.ParentFolderPath + continue + } + + file := &file{ + Path: strings.ToLower(directoryMarkerPath), + ParentFolderPath: directoryMarkerParentPath, + Contents: make([]byte, 0), + Updated: now, + Created: now, + } + _, err = sess.Insert(file) + if err != nil { + insertErr = err + break + } + s.log.Info("Created folder", "markerPath", file.Path, "parent", file.ParentFolderPath) + } + + if insertErr != nil { + if rollErr := sess.Rollback(); rollErr != nil { + return errutil.Wrapf(insertErr, "Rolling back transaction due to error failed: %s", rollErr) + } + return insertErr + } + + return sess.Commit() + }) + + return err +} + +func (s dbFileStorage) DeleteFolder(ctx context.Context, folderPath string) error { + err := s.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + existing := &file{} + directoryMarkerPath := fmt.Sprintf("%s%s%s", folderPath, Delimiter, directoryMarker) + exists, err := sess.Table("file").Where("LOWER(path) = ?", strings.ToLower(directoryMarkerPath)).Get(existing) + if err != nil { + return err + } + + if !exists { + return nil + } + + _, err = sess.Table("file").Where("LOWER(path) = ?", strings.ToLower(directoryMarkerPath)).Delete(existing) + return err + }) + + return err +} + +func (s dbFileStorage) close() error { + return nil +} diff --git a/pkg/infra/filestorage/dummy.go b/pkg/infra/filestorage/dummy.go new file mode 100644 index 00000000000..73db7a92bfe --- /dev/null +++ b/pkg/infra/filestorage/dummy.go @@ -0,0 +1,51 @@ +package filestorage + +import ( + "context" + + _ "gocloud.dev/blob/fileblob" + _ "gocloud.dev/blob/memblob" +) + +var ( + _ FileStorage = (*dummyFileStorage)(nil) // dummyFileStorage implements FileStorage +) + +type dummyFileStorage struct { +} + +func (d dummyFileStorage) Get(ctx context.Context, path string) (*File, error) { + return nil, nil +} + +func (d dummyFileStorage) Delete(ctx context.Context, path string) error { + return nil +} + +func (d dummyFileStorage) Upsert(ctx context.Context, file *UpsertFileCommand) error { + return nil +} + +func (d dummyFileStorage) ListFiles(ctx context.Context, path string, cursor *Paging, options *ListOptions) (*ListFilesResponse, error) { + return nil, nil +} + +func (d dummyFileStorage) ListFolders(ctx context.Context, path string, options *ListOptions) ([]FileMetadata, error) { + return nil, nil +} + +func (d dummyFileStorage) CreateFolder(ctx context.Context, path string) error { + return nil +} + +func (d dummyFileStorage) DeleteFolder(ctx context.Context, path string) error { + return nil +} + +func (d dummyFileStorage) IsFolderEmpty(ctx context.Context, path string) (bool, error) { + return true, nil +} + +func (d dummyFileStorage) close() error { + return nil +} diff --git a/pkg/infra/filestorage/filestorage.go b/pkg/infra/filestorage/filestorage.go new file mode 100644 index 00000000000..5c9e1ae952f --- /dev/null +++ b/pkg/infra/filestorage/filestorage.go @@ -0,0 +1,160 @@ +package filestorage + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + "gocloud.dev/blob" + + _ "gocloud.dev/blob/fileblob" + _ "gocloud.dev/blob/memblob" +) + +const ( + ServiceName = "FileStorage" +) + +func ProvideService(features featuremgmt.FeatureToggles, cfg *setting.Cfg) (FileStorage, error) { + grafanaDsStorageLogger := log.New("grafanaDsStorage") + + path := fmt.Sprintf("file://%s", cfg.StaticRootPath) + grafanaDsStorageLogger.Info("Initializing grafana ds storage", "path", path) + bucket, err := blob.OpenBucket(context.Background(), path) + if err != nil { + currentDir, _ := os.Getwd() + grafanaDsStorageLogger.Error("Failed to initialize grafana ds storage", "path", path, "error", err, "cwd", currentDir) + return nil, err + } + + prefixes := []string{ + "testdata/", + "img/icons/", + "img/bg/", + "gazetteer/", + "maps/", + "upload/", + } + + var grafanaDsStorage FileStorage + if features.IsEnabled(featuremgmt.FlagFileStoreApi) { + grafanaDsStorage = &wrapper{ + log: grafanaDsStorageLogger, + wrapped: cdkBlobStorage{ + log: grafanaDsStorageLogger, + bucket: bucket, + rootFolder: "", + }, + pathFilters: &PathFilters{allowedPrefixes: prefixes}, + } + } else { + grafanaDsStorage = &dummyFileStorage{} + } + + return &service{ + grafanaDsStorage: grafanaDsStorage, + log: log.New("fileStorageService"), + }, nil +} + +type service struct { + log log.Logger + grafanaDsStorage FileStorage +} + +func (b service) Get(ctx context.Context, path string) (*File, error) { + var filestorage FileStorage + if belongsToStorage(path, StorageNamePublic) { + filestorage = b.grafanaDsStorage + path = removeStoragePrefix(path) + } + + if err := validatePath(path); err != nil { + return nil, err + } + + return filestorage.Get(ctx, path) +} + +func removeStoragePrefix(path string) string { + path = strings.TrimPrefix(path, Delimiter) + if path == Delimiter || path == "" { + return Delimiter + } + + if !strings.Contains(path, Delimiter) { + return Delimiter + } + + split := strings.Split(path, Delimiter) + + // root of storage + if len(split) == 2 && split[1] == "" { + return Delimiter + } + + // replace storage + split[0] = "" + return strings.Join(split, Delimiter) +} + +func (b service) Delete(ctx context.Context, path string) error { + return errors.New("not implemented") +} + +func (b service) Upsert(ctx context.Context, file *UpsertFileCommand) error { + return errors.New("not implemented") +} + +func (b service) ListFiles(ctx context.Context, path string, cursor *Paging, options *ListOptions) (*ListFilesResponse, error) { + var filestorage FileStorage + if belongsToStorage(path, StorageNamePublic) { + filestorage = b.grafanaDsStorage + path = removeStoragePrefix(path) + } else { + return nil, errors.New("not implemented") + } + + if err := validatePath(path); err != nil { + return nil, err + } + + return filestorage.ListFiles(ctx, path, cursor, options) +} + +func (b service) ListFolders(ctx context.Context, path string, options *ListOptions) ([]FileMetadata, error) { + var filestorage FileStorage + if belongsToStorage(path, StorageNamePublic) { + filestorage = b.grafanaDsStorage + path = removeStoragePrefix(path) + } else { + return nil, errors.New("not implemented") + } + + if err := validatePath(path); err != nil { + return nil, err + } + + return filestorage.ListFolders(ctx, path, options) +} + +func (b service) CreateFolder(ctx context.Context, path string) error { + return errors.New("not implemented") +} + +func (b service) DeleteFolder(ctx context.Context, path string) error { + return errors.New("not implemented") +} + +func (b service) IsFolderEmpty(ctx context.Context, path string) (bool, error) { + return true, errors.New("not implemented") +} + +func (b service) close() error { + return b.grafanaDsStorage.close() +} diff --git a/pkg/infra/filestorage/filestorage_test.go b/pkg/infra/filestorage/filestorage_test.go new file mode 100644 index 00000000000..f432c849195 --- /dev/null +++ b/pkg/infra/filestorage/filestorage_test.go @@ -0,0 +1,46 @@ +package filestorage + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFilestorage_removeStoragePrefix(t *testing.T) { + var tests = []struct { + name string + path string + expected string + }{ + { + name: "should return root if path is empty", + path: "", + expected: Delimiter, + }, + { + name: "should remove prefix folder from path with multiple parts", + path: "public/abc/d", + expected: "/abc/d", + }, + { + name: "should return root path if path is just the storage name", + path: "public", + expected: Delimiter, + }, + { + name: "should return root path if path is the prefix of storage", + path: "public/", + expected: Delimiter, + }, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%s%s", "absolute: ", tt.name), func(t *testing.T) { + require.Equal(t, tt.expected, removeStoragePrefix(Delimiter+tt.path)) + }) + + t.Run(fmt.Sprintf("%s%s", "relative: ", tt.name), func(t *testing.T) { + require.Equal(t, tt.expected, removeStoragePrefix(tt.path)) + }) + } +} diff --git a/pkg/infra/filestorage/fs_integration_test.go b/pkg/infra/filestorage/fs_integration_test.go new file mode 100644 index 00000000000..2440ad34463 --- /dev/null +++ b/pkg/infra/filestorage/fs_integration_test.go @@ -0,0 +1,857 @@ +//go:build integration +// +build integration + +package filestorage + +import ( + "context" + "encoding/base64" + "fmt" + "io/ioutil" + "os" + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "gocloud.dev/blob" +) + +const ( + pngImageBase64 = "iVBORw0KGgoNAANSUhEUgAAAC4AAAAmCAYAAAC76qlaAAAABHNCSVQICAgIfAhkiAAAABl0RVh0U29mdHdhcmUAZ25vbWUtc2NyZWVuc2hvdO8Dvz4AAABFSURBVFiF7c5BDQAhEACx4/x7XjzwGELSKuiamfke9N8OnBKvidfEa+I18Zp4TbwmXhOvidfEa+I18Zp4TbwmXhOvidc2lcsESD1LGnUAAAAASUVORK5CYII=" +) + +type fsTestCase struct { + name string + skip *bool + steps []interface{} +} + +func runTestCase(t *testing.T, testCase fsTestCase, ctx context.Context, filestorage FileStorage) { + if testCase.skip != nil { + return + } + for i, step := range testCase.steps { + executeTestStep(t, ctx, step, i, filestorage) + } +} + +func runTests(createCases func() []fsTestCase, t *testing.T) { + var testLogger log.Logger + //var sqlStore *sqlstore.SQLStore + var filestorage FileStorage + var ctx context.Context + var tempDir string + + commonSetup := func() { + testLogger = log.New("testStorageLogger") + ctx = context.Background() + } + + cleanUp := func() { + testLogger = nil + //sqlStore = nil + if filestorage != nil { + _ = filestorage.close() + filestorage = nil + } + + ctx = nil + _ = os.RemoveAll(tempDir) + } + + setupInMemFS := func() { + commonSetup() + bucket, _ := blob.OpenBucket(context.Background(), "mem://") + filestorage = NewCdkBlobStorage(testLogger, bucket, Delimiter, nil) + } + + //setupSqlFS := func() { + // commonSetup() + // sqlStore = sqlstore.InitTestDB(t) + // filestorage = NewDbStorage(testLogger, sqlStore, nil) + //} + + setupLocalFs := func() { + commonSetup() + tmpDir, err := ioutil.TempDir("", "") + tempDir = tmpDir + if err != nil { + t.Fatal(err) + } + + bucket, err := blob.OpenBucket(context.Background(), fmt.Sprintf("file://%s", tmpDir)) + if err != nil { + t.Fatal(err) + } + filestorage = NewCdkBlobStorage(testLogger, bucket, "", nil) + } + + backends := []struct { + setup func() + name string + }{ + { + setup: setupLocalFs, + name: "Local FS", + }, + { + setup: setupInMemFS, + name: "In-mem FS", + }, + //{ + // setup: setupSqlFS, + // name: "SQL FS", + //}, + } + + for _, backend := range backends { + for _, tt := range createCases() { + t.Run(fmt.Sprintf("%s: %s", backend.name, tt.name), func(t *testing.T) { + backend.setup() + defer cleanUp() + runTestCase(t, tt, ctx, filestorage) + }) + } + } +} + +func TestFsStorage(t *testing.T) { + //skipTest := true + emptyFileBytes := make([]byte, 0) + pngImage, _ := base64.StdEncoding.DecodeString(pngImageBase64) + pngImageSize := int64(len(pngImage)) + + createListFilesTests := func() []fsTestCase { + return []fsTestCase{ + { + name: "listing files", + steps: []interface{}{ + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/folder1/folder2/file.jpg", + Contents: &[]byte{}, + Properties: map[string]string{"prop1": "val1", "prop2": "val"}, + }, + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/folder1/file-inner.jpg", + Contents: &[]byte{}, + Properties: map[string]string{"prop1": "val1", "prop2": "val"}, + }, + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/folder1/file-inner2.jpg", + Contents: &[]byte{}, + }, + }, + queryListFiles{ + input: queryListFilesInput{path: "/folder1", options: &ListOptions{Recursive: true}}, + list: checks(listSize(3), listHasMore(false), listLastPath("/folder1/folder2/file.jpg")), + files: [][]interface{}{ + checks(fPath("/folder1/file-inner.jpg"), fProperties(map[string]string{"prop1": "val1", "prop2": "val"})), + checks(fPath("/folder1/file-inner2.jpg"), fProperties(map[string]string{})), + checks(fPath("/folder1/folder2/file.jpg"), fProperties(map[string]string{"prop1": "val1", "prop2": "val"})), + }, + }, + queryListFiles{ + input: queryListFilesInput{path: "/", options: &ListOptions{Recursive: false}}, + list: checks(listSize(0), listHasMore(false), listLastPath("")), + files: [][]interface{}{}, + }, + queryListFiles{ + input: queryListFilesInput{path: "/folder1", options: &ListOptions{Recursive: false}}, + list: checks(listSize(2), listHasMore(false), listLastPath("/folder1/file-inner2.jpg")), + files: [][]interface{}{ + checks(fPath("/folder1/file-inner.jpg"), fProperties(map[string]string{"prop1": "val1", "prop2": "val"})), + checks(fPath("/folder1/file-inner2.jpg"), fProperties(map[string]string{})), + }, + }, + queryListFiles{ + input: queryListFilesInput{path: "/folder1/folder2", options: &ListOptions{Recursive: false}}, + list: checks(listSize(1), listHasMore(false), listLastPath("/folder1/folder2/file.jpg")), + files: [][]interface{}{ + checks(fPath("/folder1/folder2/file.jpg"), fProperties(map[string]string{"prop1": "val1", "prop2": "val"})), + }, + }, + queryListFiles{ + input: queryListFilesInput{path: "/folder1/folder2", options: &ListOptions{Recursive: false}, paging: &Paging{After: "/folder1/folder2/file.jpg"}}, + list: checks(listSize(0), listHasMore(false), listLastPath("")), + files: [][]interface{}{}, + }, + }, + }, + { + name: "path passed to listing files is a folder path, not a prefix", + steps: []interface{}{ + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/ab/a.jpg", + Contents: &[]byte{}, + }, + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/ab/a/a.jpg", + Contents: &[]byte{}, + }, + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/ac/a.jpg", + Contents: &[]byte{}, + }, + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/aba/a.jpg", + Contents: &[]byte{}, + }, + }, + queryListFiles{ + input: queryListFilesInput{path: "/ab", options: &ListOptions{Recursive: true}}, + list: checks(listSize(2), listHasMore(false), listLastPath("/ab/a/a.jpg")), + files: [][]interface{}{ + checks(fPath("/ab/a.jpg")), + checks(fPath("/ab/a/a.jpg")), + }, + }, + }, + }, + { + name: "listing files with prefix filter", + steps: []interface{}{ + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/folder1/folder2/file.jpg", + Contents: &[]byte{}, + }, + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/folder1/file-inner.jpg", + Contents: &[]byte{}, + }, + }, + queryListFiles{ + input: queryListFilesInput{path: "/folder1", options: &ListOptions{Recursive: true, PathFilters: PathFilters{allowedPrefixes: []string{"/folder2"}}}}, + list: checks(listSize(0), listHasMore(false), listLastPath("")), + }, + queryListFiles{ + input: queryListFilesInput{path: "/folder1", options: &ListOptions{Recursive: true, PathFilters: PathFilters{allowedPrefixes: []string{"/folder1/folder"}}}}, + list: checks(listSize(1), listHasMore(false)), + files: [][]interface{}{ + checks(fPath("/folder1/folder2/file.jpg")), + }, + }, + }, + }, + { + name: "listing files with pagination", + steps: []interface{}{ + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/folder1/a", + Contents: &[]byte{}, + }, + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/folder1/b", + Contents: &[]byte{}, + }, + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/folder2/c", + Contents: &[]byte{}, + }, + }, + queryListFiles{ + input: queryListFilesInput{path: "/", options: &ListOptions{Recursive: true}, paging: &Paging{First: 1, After: ""}}, + list: checks(listSize(1), listHasMore(true), listLastPath("/folder1/a")), + files: [][]interface{}{ + checks(fPath("/folder1/a")), + }, + }, + queryListFiles{ + input: queryListFilesInput{path: "/", options: &ListOptions{Recursive: true}, paging: &Paging{First: 1, After: "/folder1/a"}}, + list: checks(listSize(1), listHasMore(true), listLastPath("/folder1/b")), + files: [][]interface{}{ + checks(fPath("/folder1/b")), + }, + }, + queryListFiles{ + input: queryListFilesInput{path: "/", options: &ListOptions{Recursive: true}, paging: &Paging{First: 1, After: "/folder1/b"}}, + list: checks(listSize(1), listHasMore(false), listLastPath("/folder2/c")), + files: [][]interface{}{ + checks(fPath("/folder2/c")), + }, + }, + queryListFiles{ + input: queryListFilesInput{path: "/", options: &ListOptions{Recursive: true}, paging: &Paging{First: 5, After: ""}}, + list: checks(listSize(3), listHasMore(false), listLastPath("/folder2/c")), + files: [][]interface{}{ + checks(fPath("/folder1/a")), + checks(fPath("/folder1/b")), + checks(fPath("/folder2/c")), + }, + }, + queryListFiles{ + input: queryListFilesInput{path: "/", options: &ListOptions{Recursive: true}, paging: &Paging{First: 5, After: "/folder2"}}, + list: checks(listSize(1), listHasMore(false)), + }, + queryListFiles{ + input: queryListFilesInput{path: "/", options: &ListOptions{Recursive: true}, paging: &Paging{First: 5, After: "/folder2/c"}}, + list: checks(listSize(0), listHasMore(false)), + }, + }, + }, + } + } + + createListFoldersTests := func() []fsTestCase { + return []fsTestCase{ + { + name: "listing folders", + steps: []interface{}{ + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/folder1/folder2/file.jpg", + Contents: &[]byte{}, + }, + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/folder1/file-inner.jpg", + Contents: &[]byte{}, + }, + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/folderX/folderZ/file.txt", + Contents: &[]byte{}, + }, + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/folderA/folderB/file.txt", + Contents: &[]byte{}, + }, + }, + queryListFolders{ + input: queryListFoldersInput{path: "/", options: &ListOptions{Recursive: true}}, + checks: [][]interface{}{ + checks(fPath("/folder1")), + checks(fPath("/folder1/folder2")), + checks(fPath("/folderA")), + checks(fPath("/folderA/folderB")), + checks(fPath("/folderX")), + checks(fPath("/folderX/folderZ")), + }, + }, + }, + }, + } + } + + createFileCRUDTests := func() []fsTestCase { + return []fsTestCase{ + { + name: "getting a non-existent file", + steps: []interface{}{ + queryGet{ + input: queryGetInput{ + path: "/folder/a.png", + }, + }, + }, + }, + { + name: "inserting a file", + steps: []interface{}{ + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/folder/a.png", + Contents: &pngImage, + Properties: map[string]string{"prop1": "val1", "prop2": "val"}, + }, + }, + queryGet{ + input: queryGetInput{ + path: "/folder/a.png", + }, + checks: checks( + fPath("/folder/a.png"), + fName("a.png"), + fMimeType("image/png"), + fProperties(map[string]string{"prop1": "val1", "prop2": "val"}), + fSize(pngImageSize), + fContents(pngImage), + ), + }, + }, + }, + { + name: "preserved original path/name casing when getting a file", + steps: []interface{}{ + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/Folder/A.png", + Contents: &emptyFileBytes, + }, + }, + queryGet{ + input: queryGetInput{ + path: "/fOlder/a.png", + }, + checks: checks( + fPath("/Folder/A.png"), + fName("A.png"), + ), + }, + }, + }, + { + name: "modifying file metadata", + steps: []interface{}{ + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/a.png", + Contents: &pngImage, + Properties: map[string]string{"a": "av", "b": "bv"}, + }, + }, + queryGet{ + input: queryGetInput{ + path: "/a.png", + }, + checks: checks( + fContents(pngImage), + fProperties(map[string]string{"a": "av", "b": "bv"}), + ), + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/a.png", + Properties: map[string]string{"b": "bv2", "c": "cv"}, + }, + }, + queryGet{ + input: queryGetInput{ + path: "/a.png", + }, + checks: checks( + fContents(pngImage), + fProperties(map[string]string{"b": "bv2", "c": "cv"}), + ), + }, + }, + }, + { + name: "modifying file metadata preserves original path casing", + steps: []interface{}{ + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/aB.png", + Contents: &emptyFileBytes, + Properties: map[string]string{"a": "av", "b": "bv"}, + }, + }, + queryGet{ + input: queryGetInput{ + path: "/ab.png", + }, + checks: checks( + fPath("/aB.png"), + fName("aB.png"), + ), + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/ab.png", + Properties: map[string]string{"b": "bv2", "c": "cv"}, + }, + }, + queryGet{ + input: queryGetInput{ + path: "/ab.png", + }, + checks: checks( + fPath("/aB.png"), + fName("aB.png"), + fProperties(map[string]string{"b": "bv2", "c": "cv"}), + ), + }, + }, + }, + { + name: "modifying file contents", + steps: []interface{}{ + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/FILE.png", + Contents: &emptyFileBytes, + Properties: map[string]string{"a": "av", "b": "bv"}, + }, + }, + queryGet{ + input: queryGetInput{ + path: "/file.png", + }, + checks: checks( + fName("FILE.png"), + fProperties(map[string]string{"a": "av", "b": "bv"}), + fSize(0), + fContents(emptyFileBytes), + ), + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/file.png", + Contents: &pngImage, + }, + }, + queryGet{ + input: queryGetInput{ + path: "/file.png", + }, + checks: checks( + fName("FILE.png"), + fMimeType("image/png"), + fProperties(map[string]string{"a": "av", "b": "bv"}), + fSize(pngImageSize), + fContents(pngImage), + ), + }, + }, + }, + { + name: "deleting a file", + steps: []interface{}{ + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/FILE.png", + Contents: &emptyFileBytes, + Properties: map[string]string{"a": "av", "b": "bv"}, + }, + }, + queryGet{ + input: queryGetInput{ + path: "/file.png", + }, + checks: checks( + fPath("/FILE.png"), + ), + }, + cmdDelete{ + path: "/file.png", + }, + queryGet{ + input: queryGetInput{ + path: "/file.png", + }, + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/file.png", + Contents: &emptyFileBytes, + Properties: map[string]string{"a": "av", "b": "bv"}, + }, + }, + queryGet{ + input: queryGetInput{ + path: "/file.png", + }, + checks: checks( + fPath("/file.png"), + ), + }, + }, + }, + { + name: "deleting a non-existent file should be no-op", + steps: []interface{}{ + cmdDelete{ + path: "/file.png", + }, + }, + }, + } + } + + createFolderCrudCases := func() []fsTestCase { + return []fsTestCase{ + { + name: "recreating a folder after it was already created via upserting a file is a no-op", + steps: []interface{}{ + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/aB/cD/eF/file.jpg", + Contents: &[]byte{}, + }, + }, + queryListFolders{ + input: queryListFoldersInput{ + path: "/", + }, + checks: [][]interface{}{ + checks(fPath("/aB")), + checks(fPath("/aB/cD")), + checks(fPath("/aB/cD/eF")), + }, + }, + cmdCreateFolder{ + path: "/ab/cd/ef", + }, + queryListFolders{ + input: queryListFoldersInput{ + path: "/", + }, + checks: [][]interface{}{ + checks(fPath("/aB")), + checks(fPath("/aB/cD")), + checks(fPath("/aB/cD/eF")), + }, + }, + cmdCreateFolder{ + path: "/ab/cd/ef/GH", + }, + queryListFolders{ + input: queryListFoldersInput{ + path: "/", + }, + checks: [][]interface{}{ + checks(fPath("/aB")), + checks(fPath("/aB/cD")), + checks(fPath("/aB/cD/eF")), + checks(fPath("/aB/cD/eF/GH")), + }, + }, + }, + }, + { + name: "creating a folder with the same name or same name but different casing is a no-op", + steps: []interface{}{ + cmdCreateFolder{ + path: "/aB", + }, + cmdCreateFolder{ + path: "/ab", + }, + cmdCreateFolder{ + path: "/aB", + }, + queryListFolders{ + input: queryListFoldersInput{ + path: "/", + }, + checks: [][]interface{}{ + checks(fPath("/aB")), + }, + }, + cmdCreateFolder{ + path: "/Ab", + }, + queryListFolders{ + input: queryListFoldersInput{ + path: "/", + }, + checks: [][]interface{}{ + checks(fPath("/aB")), + }, + }, + }, + }, + { + name: "creating folder is recursive", + steps: []interface{}{ + cmdCreateFolder{ + path: "/a/b/c", + }, + queryListFolders{ + input: queryListFoldersInput{ + path: "/", + }, + checks: [][]interface{}{ + checks(fPath("/a")), + checks(fPath("/a/b")), + checks(fPath("/a/b/c")), + }, + }, + }, + }, + { + name: "deleting a leaf directory does not delete parent directories even if they are empty - folders created directly", + steps: []interface{}{ + cmdCreateFolder{ + path: "/a/b/c", + }, + cmdDeleteFolder{ + path: "/a/b/c", + }, + queryListFolders{ + input: queryListFoldersInput{ + path: "/", + }, + checks: [][]interface{}{ + checks(fPath("/a")), + checks(fPath("/a/b")), + }, + }, + }, + }, + { + name: "deleting a leaf directory does not delete parent directories even if they are empty - folders created via file upsert", + steps: []interface{}{ + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/a/b/c/file.jpg", + Contents: &[]byte{}, + }, + }, + queryListFolders{ + input: queryListFoldersInput{ + path: "/", + }, + checks: [][]interface{}{ + checks(fPath("/a")), + checks(fPath("/a/b")), + checks(fPath("/a/b/c")), + }, + }, + cmdDelete{ + path: "/a/b/c/file.jpg", + error: nil, + }, + queryListFolders{ + input: queryListFoldersInput{ + path: "/", + }, + checks: [][]interface{}{ + checks(fPath("/a")), + checks(fPath("/a/b")), + checks(fPath("/a/b/c")), + }, + }, + cmdDeleteFolder{ + path: "/a/b/c", + }, + queryListFolders{ + input: queryListFoldersInput{ + path: "/", + }, + checks: [][]interface{}{ + checks(fPath("/a")), + checks(fPath("/a/b")), + }, + }, + }, + }, + { + name: "folders preserve their original casing", + steps: []interface{}{ + cmdCreateFolder{ + path: "/aB/cD/e", + }, + cmdCreateFolder{ + path: "/ab/cd/f", + }, + queryListFolders{ + input: queryListFoldersInput{ + path: "/", + }, + checks: [][]interface{}{ + checks(fPath("/aB")), + checks(fPath("/aB/cD")), + checks(fPath("/aB/cD/e")), + checks(fPath("/aB/cD/f")), + }, + }, + }, + }, + { + name: "folders can't be deleted through the `delete` method", + steps: []interface{}{ + cmdCreateFolder{ + path: "/folder/dashboards/myNewFolder", + }, + queryListFolders{ + input: queryListFoldersInput{path: "/", options: &ListOptions{Recursive: true}}, + checks: [][]interface{}{ + checks(fPath("/folder")), + checks(fPath("/folder/dashboards")), + checks(fPath("/folder/dashboards/myNewFolder")), + }, + }, + cmdDelete{ + path: "/folder/dashboards/myNewFolder", + }, + queryListFolders{ + input: queryListFoldersInput{path: "/", options: &ListOptions{Recursive: true}}, + checks: [][]interface{}{ + checks(fPath("/folder")), + checks(fPath("/folder/dashboards")), + checks(fPath("/folder/dashboards/myNewFolder")), + }, + }, + }, + }, + { + name: "folders can not be retrieved through the `get` method", + steps: []interface{}{ + cmdCreateFolder{ + path: "/folder/dashboards/myNewFolder", + }, + queryGet{ + input: queryGetInput{ + path: "/folder/dashboards/myNewFolder", + }, + }, + }, + }, + { + name: "should not be able to delete folders with files", + steps: []interface{}{ + cmdCreateFolder{ + path: "/folder/dashboards/myNewFolder", + }, + cmdUpsert{ + cmd: UpsertFileCommand{ + Path: "/folder/dashboards/myNewFolder/file.jpg", + Contents: &[]byte{}, + }, + }, + cmdDeleteFolder{ + path: "/folder/dashboards/myNewFolder", + error: &cmdErrorOutput{ + message: "folder %s is not empty - cant remove it", + args: []interface{}{"/folder/dashboards/myNewFolder"}, + }, + }, + queryListFolders{ + input: queryListFoldersInput{path: "/", options: &ListOptions{Recursive: true}}, + checks: [][]interface{}{ + checks(fPath("/folder")), + checks(fPath("/folder/dashboards")), + checks(fPath("/folder/dashboards/myNewFolder")), + }, + }, + queryGet{ + input: queryGetInput{ + path: "/folder/dashboards/myNewFolder/file.jpg", + }, + checks: checks( + fName("file.jpg"), + ), + }, + }, + }, + } + } + + runTests(createListFoldersTests, t) + runTests(createListFilesTests, t) + runTests(createFileCRUDTests, t) + runTests(createFolderCrudCases, t) +} diff --git a/pkg/infra/filestorage/test_utils.go b/pkg/infra/filestorage/test_utils.go new file mode 100644 index 00000000000..2ecbb1054aa --- /dev/null +++ b/pkg/infra/filestorage/test_utils.go @@ -0,0 +1,346 @@ +//go:build integration +// +build integration + +package filestorage + +import ( + "context" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type cmdErrorOutput struct { + message string + args []interface{} + instance error +} + +type cmdDelete struct { + path string + error *cmdErrorOutput +} + +type cmdUpsert struct { + cmd UpsertFileCommand + error *cmdErrorOutput +} + +type cmdCreateFolder struct { + path string + error *cmdErrorOutput +} + +type cmdDeleteFolder struct { + path string + error *cmdErrorOutput +} + +type queryGetInput struct { + path string +} + +type fileNameCheck struct { + v string +} + +type filePropertiesCheck struct { + v map[string]string +} + +type fileContentsCheck struct { + v []byte +} + +type fileSizeCheck struct { + v int64 +} + +type fileMimeTypeCheck struct { + v string +} + +type filePathCheck struct { + v string +} + +type listSizeCheck struct { + v int +} + +type listHasMoreCheck struct { + v bool +} + +type listLastPathCheck struct { + v string +} + +func fContents(contents []byte) interface{} { + return fileContentsCheck{v: contents} +} + +func fName(name string) interface{} { + return fileNameCheck{v: name} +} + +func fPath(path string) interface{} { + return filePathCheck{v: path} +} + +func fProperties(properties map[string]string) interface{} { + return filePropertiesCheck{v: properties} +} +func fSize(size int64) interface{} { + return fileSizeCheck{v: size} +} + +func fMimeType(mimeType string) interface{} { + return fileMimeTypeCheck{v: mimeType} +} + +func listSize(size int) interface{} { + return listSizeCheck{v: size} +} + +func listHasMore(hasMore bool) interface{} { + return listHasMoreCheck{v: hasMore} +} + +func listLastPath(path string) interface{} { + return listLastPathCheck{v: path} +} + +func checks(c ...interface{}) []interface{} { + return c +} + +type queryGet struct { + input queryGetInput + checks []interface{} +} + +type queryListFilesInput struct { + path string + paging *Paging + options *ListOptions +} + +type queryListFiles struct { + input queryListFilesInput + list []interface{} + files [][]interface{} +} + +type queryListFoldersInput struct { + path string + options *ListOptions +} + +type queryListFolders struct { + input queryListFoldersInput + checks [][]interface{} +} + +func interfaceName(myvar interface{}) string { + if t := reflect.TypeOf(myvar); t.Kind() == reflect.Ptr { + return "*" + t.Elem().Name() + } else { + return t.Name() + } +} + +func handleCommand(t *testing.T, ctx context.Context, cmd interface{}, cmdName string, fs FileStorage) { + t.Helper() + + var err error + var expectedErr *cmdErrorOutput + switch c := cmd.(type) { + case cmdDelete: + err = fs.Delete(ctx, c.path) + if c.error == nil { + require.NoError(t, err, "%s: should be able to delete %s", cmdName, c.path) + } + expectedErr = c.error + case cmdUpsert: + err = fs.Upsert(ctx, &c.cmd) + if c.error == nil { + require.NoError(t, err, "%s: should be able to upsert file %s", cmdName, c.cmd.Path) + } + expectedErr = c.error + case cmdCreateFolder: + err = fs.CreateFolder(ctx, c.path) + if c.error == nil { + require.NoError(t, err, "%s: should be able to create folder %s", cmdName, c.path) + } + expectedErr = c.error + case cmdDeleteFolder: + err = fs.DeleteFolder(ctx, c.path) + if c.error == nil { + require.NoError(t, err, "%s: should be able to delete %s", cmdName, c.path) + } + expectedErr = c.error + default: + t.Fatalf("unrecognized command %s", cmdName) + } + + if expectedErr != nil && err != nil { + if expectedErr.instance != nil { + require.ErrorIs(t, err, expectedErr.instance) + } + + if expectedErr.message != "" { + require.Errorf(t, err, expectedErr.message, expectedErr.args...) + } + } +} + +func runChecks(t *testing.T, stepName string, path string, output interface{}, checks []interface{}) { + if checks == nil || len(checks) == 0 { + return + } + + runFileMetadataCheck := func(file FileMetadata, check interface{}, checkName string) { + switch c := check.(type) { + case filePropertiesCheck: + require.Equal(t, c.v, file.Properties, "%s-%s %s", stepName, checkName, path) + case fileNameCheck: + require.Equal(t, c.v, file.Name, "%s-%s %s", stepName, checkName, path) + case fileSizeCheck: + require.Equal(t, c.v, file.Size, "%s-%s %s", stepName, checkName, path) + case fileMimeTypeCheck: + require.Equal(t, c.v, file.MimeType, "%s-%s %s", stepName, checkName, path) + case filePathCheck: + require.Equal(t, c.v, file.FullPath, "%s-%s %s", stepName, checkName, path) + default: + t.Fatalf("unrecognized file check %s", checkName) + } + } + + switch o := output.(type) { + case File: + for _, check := range checks { + checkName := interfaceName(check) + if fileContentsCheck, ok := check.(fileContentsCheck); ok { + require.Equal(t, fileContentsCheck.v, o.Contents, "%s-%s %s", stepName, checkName, path) + } else { + runFileMetadataCheck(o.FileMetadata, check, checkName) + } + } + case FileMetadata: + for _, check := range checks { + runFileMetadataCheck(o, check, interfaceName(check)) + } + case ListFilesResponse: + for _, check := range checks { + c := check + checkName := interfaceName(c) + switch c := check.(type) { + case listSizeCheck: + require.Equal(t, c.v, len(o.Files), "%s %s", stepName, path) + case listHasMoreCheck: + require.Equal(t, c.v, o.HasMore, "%s %s", stepName, path) + case listLastPathCheck: + require.Equal(t, c.v, o.LastPath, "%s %s", stepName, path) + default: + t.Fatalf("unrecognized list check %s", checkName) + } + } + default: + t.Fatalf("unrecognized output %s", interfaceName(output)) + } + +} + +func formatPathStructure(files []FileMetadata) string { + if len(files) == 0 { + return "<>" + } + res := "\n" + for _, f := range files { + res = fmt.Sprintf("%s%s\n", res, f.FullPath) + } + return res +} + +func handleQuery(t *testing.T, ctx context.Context, query interface{}, queryName string, fs FileStorage) { + t.Helper() + + switch q := query.(type) { + case queryGet: + inputPath := q.input.path + file, err := fs.Get(ctx, inputPath) + require.NoError(t, err, "%s: should be able to get file %s", queryName, inputPath) + + if q.checks != nil && len(q.checks) > 0 { + require.NotNil(t, file, "%s %s", queryName, inputPath) + require.Equal(t, strings.ToLower(inputPath), strings.ToLower(file.FullPath), "%s %s", queryName, inputPath) + runChecks(t, queryName, inputPath, *file, q.checks) + } else { + require.Nil(t, file, "%s %s", queryName, inputPath) + } + case queryListFiles: + inputPath := q.input.path + resp, err := fs.ListFiles(ctx, inputPath, q.input.paging, q.input.options) + require.NoError(t, err, "%s: should be able to list files in %s", queryName, inputPath) + require.NotNil(t, resp) + if q.list != nil && len(q.list) > 0 { + runChecks(t, queryName, inputPath, *resp, q.list) + } else { + require.NotNil(t, resp, "%s %s", queryName, inputPath) + require.Equal(t, false, resp.HasMore, "%s %s", queryName, inputPath) + require.Equal(t, 0, len(resp.Files), "%s %s", queryName, inputPath) + require.Equal(t, "", resp.LastPath, "%s %s", queryName, inputPath) + } + + if q.files != nil { + require.Equal(t, len(resp.Files), len(q.files), "%s expected a check for each actual file at path: \"%s\". actual: %s", queryName, inputPath, formatPathStructure(resp.Files)) + for i, file := range resp.Files { + runChecks(t, queryName, inputPath, file, q.files[i]) + } + } + case queryListFolders: + inputPath := q.input.path + resp, err := fs.ListFolders(ctx, inputPath, q.input.options) + require.NotNil(t, resp) + require.NoError(t, err, "%s: should be able to list folders in %s", queryName, inputPath) + + if q.checks != nil { + require.Equal(t, len(resp), len(q.checks), "%s: expected a check for each actual folder at path: \"%s\". actual: %s", queryName, inputPath, formatPathStructure(resp)) + for i, file := range resp { + runChecks(t, queryName, inputPath, file, q.checks[i]) + } + } else { + require.Equal(t, 0, len(resp), "%s %s", queryName, inputPath) + } + default: + t.Fatalf("unrecognized query %s", queryName) + } +} + +func executeTestStep(t *testing.T, ctx context.Context, step interface{}, stepNumber int, fs FileStorage) { + name := fmt.Sprintf("[%d]%s", stepNumber, interfaceName(step)) + + switch s := step.(type) { + case queryGet: + handleQuery(t, ctx, s, name, fs) + case queryListFiles: + handleQuery(t, ctx, s, name, fs) + case queryListFolders: + handleQuery(t, ctx, s, name, fs) + case cmdUpsert: + handleCommand(t, ctx, s, name, fs) + case cmdDelete: + handleCommand(t, ctx, s, name, fs) + case cmdCreateFolder: + handleCommand(t, ctx, s, name, fs) + case cmdDeleteFolder: + handleCommand(t, ctx, s, name, fs) + default: + t.Fatalf("unrecognized step %s", name) + } + +} diff --git a/pkg/infra/filestorage/wrapper.go b/pkg/infra/filestorage/wrapper.go new file mode 100644 index 00000000000..10e769bc95a --- /dev/null +++ b/pkg/infra/filestorage/wrapper.go @@ -0,0 +1,257 @@ +package filestorage + +import ( + "context" + "fmt" + "mime" + "path/filepath" + "regexp" + "strings" + + "github.com/grafana/grafana/pkg/infra/log" + _ "gocloud.dev/blob/fileblob" + _ "gocloud.dev/blob/memblob" +) + +var ( + directoryMarker = ".___gf_dir_marker___" + pathRegex = regexp.MustCompile(`(^/$)|(^(/[A-Za-z0-9!\-_.*'()]+)+$)`) +) + +type wrapper struct { + log log.Logger + wrapped FileStorage + pathFilters *PathFilters +} + +var ( + _ FileStorage = (*wrapper)(nil) // wrapper implements FileStorage +) + +func getParentFolderPath(path string) string { + if path == Delimiter || path == "" { + return Delimiter + } + + if !strings.Contains(path, Delimiter) { + return Delimiter + } + + split := strings.Split(path, Delimiter) + splitWithoutLastPart := split[:len(split)-1] + if len(splitWithoutLastPart) == 1 && split[0] == "" { + return Delimiter + } + return strings.Join(splitWithoutLastPart, Delimiter) +} + +func getName(path string) string { + if path == Delimiter || path == "" { + return "" + } + + split := strings.Split(path, Delimiter) + return split[len(split)-1] +} + +func validatePath(path string) error { + if !filepath.IsAbs(path) { + return ErrRelativePath + } + + if path == Delimiter { + return nil + } + + if filepath.Clean(path) != path { + return ErrNonCanonicalPath + } + + if strings.HasSuffix(path, Delimiter) { + return ErrPathEndsWithDelimiter + } + + if len(path) > 1000 { + return ErrPathTooLong + } + + matches := pathRegex.MatchString(path) + if !matches { + return ErrPathInvalid + } + + return nil +} + +func (b wrapper) validatePath(path string) error { + if err := validatePath(path); err != nil { + b.log.Error("Path failed validation", "path", path, "error", err) + return err + } + return nil +} + +func (b wrapper) Get(ctx context.Context, path string) (*File, error) { + if err := b.validatePath(path); err != nil { + return nil, err + } + + if !b.pathFilters.isAllowed(path) { + return nil, nil + } + + return b.wrapped.Get(ctx, path) +} +func (b wrapper) Delete(ctx context.Context, path string) error { + if err := b.validatePath(path); err != nil { + return err + } + + if !b.pathFilters.isAllowed(path) { + return nil + } + + return b.wrapped.Delete(ctx, path) +} + +func detectContentType(path string, originalGuess string) string { + if originalGuess == "application/octet-stream" || originalGuess == "" { + mimeTypeBasedOnExt := mime.TypeByExtension(filepath.Ext(path)) + if mimeTypeBasedOnExt == "" { + return "application/octet-stream" + } + return mimeTypeBasedOnExt + } + return originalGuess +} + +func (b wrapper) Upsert(ctx context.Context, file *UpsertFileCommand) error { + if err := b.validatePath(file.Path); err != nil { + return err + } + + if !b.pathFilters.isAllowed(file.Path) { + return nil + } + + path := getParentFolderPath(file.Path) + b.log.Info("Creating folder before upserting file", "file", file.Path, "folder", path) + if err := b.CreateFolder(ctx, path); err != nil { + return err + } + + if file.Contents != nil && file.MimeType == "" { + file.MimeType = detectContentType(file.Path, "") + } + + return b.wrapped.Upsert(ctx, file) +} + +func (b wrapper) withDefaults(options *ListOptions, folderQuery bool) *ListOptions { + if options == nil { + options = &ListOptions{} + options.Recursive = folderQuery + if b.pathFilters != nil && b.pathFilters.allowedPrefixes != nil { + options.PathFilters = *b.pathFilters + } + + return options + } + + if b.pathFilters != nil && b.pathFilters.allowedPrefixes != nil { + if options.allowedPrefixes != nil { + options.allowedPrefixes = append(options.allowedPrefixes, b.pathFilters.allowedPrefixes...) + } else { + copiedPrefixes := make([]string, len(b.pathFilters.allowedPrefixes)) + copy(copiedPrefixes, b.pathFilters.allowedPrefixes) + options.allowedPrefixes = copiedPrefixes + } + } + + return options +} + +func (b wrapper) ListFiles(ctx context.Context, path string, paging *Paging, options *ListOptions) (*ListFilesResponse, error) { + if err := b.validatePath(path); err != nil { + return nil, err + } + + if paging == nil { + paging = &Paging{ + First: 100, + } + } else if paging.First <= 0 { + paging.First = 100 + } + + return b.wrapped.ListFiles(ctx, path, paging, b.withDefaults(options, false)) +} + +func (b wrapper) ListFolders(ctx context.Context, path string, options *ListOptions) ([]FileMetadata, error) { + if err := b.validatePath(path); err != nil { + return nil, err + } + + return b.wrapped.ListFolders(ctx, path, b.withDefaults(options, true)) +} + +func (b wrapper) CreateFolder(ctx context.Context, path string) error { + if err := b.validatePath(path); err != nil { + return err + } + + if !b.pathFilters.isAllowed(path) { + return nil + } + + return b.wrapped.CreateFolder(ctx, path) +} + +func (b wrapper) DeleteFolder(ctx context.Context, path string) error { + if err := b.validatePath(path); err != nil { + return err + } + + if !b.pathFilters.isAllowed(path) { + return nil + } + + isEmpty, err := b.isFolderEmpty(ctx, path) + if err != nil { + return err + } + + if !isEmpty { + return fmt.Errorf("folder %s is not empty - cant remove it", path) + } + + return b.wrapped.DeleteFolder(ctx, path) +} + +func (b wrapper) isFolderEmpty(ctx context.Context, path string) (bool, error) { + filesInFolder, err := b.ListFiles(ctx, path, &Paging{First: 1}, &ListOptions{Recursive: true}) + if err != nil { + return false, err + } + + if len(filesInFolder.Files) > 0 { + return false, nil + } + + folders, err := b.ListFolders(ctx, path, &ListOptions{ + Recursive: true, + }) + if err != nil { + return false, err + } + + if len(folders) > 0 { + return false, nil + } + + return true, nil +} + +func (b wrapper) close() error { + return b.wrapped.close() +} diff --git a/pkg/infra/usagestats/service/service.go b/pkg/infra/usagestats/service/service.go index 4f79d07d746..ceb8a321fbc 100644 --- a/pkg/infra/usagestats/service/service.go +++ b/pkg/infra/usagestats/service/service.go @@ -6,7 +6,6 @@ import ( "time" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/usagestats" @@ -33,7 +32,7 @@ type UsageStats struct { sendReportCallbacks []usagestats.SendReportCallbackFunc } -func ProvideService(cfg *setting.Cfg, bus bus.Bus, sqlStore *sqlstore.SQLStore, pluginStore plugins.Store, +func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore plugins.Store, socialService social.Service, kvStore kvstore.KVStore, routeRegister routing.RouteRegister, ) *UsageStats { s := &UsageStats{ diff --git a/pkg/middleware/csrf.go b/pkg/middleware/csrf.go index bc70d09779d..7bce53f5666 100644 --- a/pkg/middleware/csrf.go +++ b/pkg/middleware/csrf.go @@ -4,10 +4,12 @@ import ( "errors" "net/http" "net/url" - "strings" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/util" ) -func CSRF(loginCookieName string) func(http.Handler) http.Handler { +func CSRF(loginCookieName string, logger log.Logger) func(http.Handler) http.Handler { // As per RFC 7231/4.2.2 these methods are idempotent: // (GET is excluded because it may have side effects in some APIs) safeMethods := []string{"HEAD", "OPTIONS", "TRACE"} @@ -27,12 +29,21 @@ func CSRF(loginCookieName string) func(http.Handler) http.Handler { } } // Otherwise - verify that Origin matches the server origin - host := strings.Split(r.Host, ":")[0] + netAddr, err := util.SplitHostPortDefault(r.Host, "", "0") // we ignore the port + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + origin, err := url.Parse(r.Header.Get("Origin")) - if err != nil || (origin.String() != "" && origin.Hostname() != host) { + if err != nil { + logger.Error("error parsing Origin header", "err", err) + } + if err != nil || netAddr.Host == "" || (origin.String() != "" && origin.Hostname() != netAddr.Host) { http.Error(w, "origin not allowed", http.StatusForbidden) return } + next.ServeHTTP(w, r) }) } diff --git a/pkg/middleware/csrf_test.go b/pkg/middleware/csrf_test.go new file mode 100644 index 00000000000..312356cce3f --- /dev/null +++ b/pkg/middleware/csrf_test.go @@ -0,0 +1,124 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/stretchr/testify/require" +) + +func TestMiddlewareCSRF(t *testing.T) { + tests := []struct { + name string + cookieName string + method string + origin string + host string + code int + }{ + { + name: "mismatched origin and host is forbidden", + cookieName: "foo", + method: "GET", + origin: "http://notLocalhost", + host: "localhost", + code: http.StatusForbidden, + }, + { + name: "mismatched origin and host is NOT forbidden with a 'Safe Method'", + cookieName: "foo", + method: "TRACE", + origin: "http://notLocalhost", + host: "localhost", + code: http.StatusOK, + }, + { + name: "mismatched origin and host is NOT forbidden without a cookie", + cookieName: "", + method: "GET", + origin: "http://notLocalhost", + host: "localhost", + code: http.StatusOK, + }, + { + name: "malformed host is a bad request", + cookieName: "foo", + method: "GET", + host: "localhost:80:80", + code: http.StatusBadRequest, + }, + { + name: "host works without port", + cookieName: "foo", + method: "GET", + host: "localhost", + origin: "http://localhost", + code: http.StatusOK, + }, + { + name: "port does not have to match", + cookieName: "foo", + method: "GET", + host: "localhost:80", + origin: "http://localhost:3000", + code: http.StatusOK, + }, + { + name: "IPv6 host works with port", + cookieName: "foo", + method: "GET", + host: "[::1]:3000", + origin: "http://[::1]:3000", + code: http.StatusOK, + }, + { + name: "IPv6 host (with longer address) works with port", + cookieName: "foo", + method: "GET", + host: "[2001:db8::1]:3000", + origin: "http://[2001:db8::1]:3000", + code: http.StatusOK, + }, + { + name: "IPv6 host (with longer address) works without port", + cookieName: "foo", + method: "GET", + host: "[2001:db8::1]", + origin: "http://[2001:db8::1]", + code: http.StatusOK, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rr := csrfScenario(t, tt.cookieName, tt.method, tt.origin, tt.host) + require.Equal(t, tt.code, rr.Code) + }) + } +} + +func csrfScenario(t *testing.T, cookieName, method, origin, host string) *httptest.ResponseRecorder { + req, err := http.NewRequest(method, "/", nil) + if err != nil { + t.Fatal(err) + } + req.AddCookie(&http.Cookie{ + Name: cookieName, + }) + + // Note: Not sure where host header populates req.Host, or how that works. + req.Host = host + req.Header.Set("HOST", host) + + req.Header.Set("ORIGIN", origin) + + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + }) + + rr := httptest.NewRecorder() + handler := CSRF(cookieName, log.New())(testHandler) + handler.ServeHTTP(rr, req) + return rr +} diff --git a/pkg/models/alert.go b/pkg/models/alert.go index 452e0fa44c6..013abe82932 100644 --- a/pkg/models/alert.go +++ b/pkg/models/alert.go @@ -28,6 +28,7 @@ const ( ) const ( + ExecutionErrorSetOk ExecutionErrorOption = "ok" ExecutionErrorSetAlerting ExecutionErrorOption = "alerting" ExecutionErrorKeepState ExecutionErrorOption = "keep_state" ) @@ -55,7 +56,7 @@ func (s NoDataOption) ToAlertState() AlertStateType { } func (s ExecutionErrorOption) IsValid() bool { - return s == ExecutionErrorSetAlerting || s == ExecutionErrorKeepState + return s == ExecutionErrorSetAlerting || s == ExecutionErrorKeepState || s == ExecutionErrorSetOk } func (s ExecutionErrorOption) ToAlertState() AlertStateType { diff --git a/pkg/models/apikey.go b/pkg/models/apikey.go index 29f0f14b2bd..430dddf311f 100644 --- a/pkg/models/apikey.go +++ b/pkg/models/apikey.go @@ -27,13 +27,12 @@ type ApiKey struct { // --------------------- // COMMANDS type AddApiKeyCommand struct { - Name string `json:"name" binding:"Required"` - Role RoleType `json:"role" binding:"Required"` - OrgId int64 `json:"-"` - Key string `json:"-"` - SecondsToLive int64 `json:"secondsToLive"` - ServiceAccountId *int64 `json:"-"` - Result *ApiKey `json:"-"` + Name string `json:"name" binding:"Required"` + Role RoleType `json:"role" binding:"Required"` + OrgId int64 `json:"-"` + Key string `json:"-"` + SecondsToLive int64 `json:"secondsToLive"` + Result *ApiKey `json:"-"` } type DeleteApiKeyCommand struct { diff --git a/pkg/plugins/manager/dashboards_test.go b/pkg/plugins/manager/dashboards_test.go index d801c65e78b..55397f0bff2 100644 --- a/pkg/plugins/manager/dashboards_test.go +++ b/pkg/plugins/manager/dashboards_test.go @@ -11,9 +11,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/backendplugin/provider" "github.com/grafana/grafana/pkg/plugins/manager/loader" "github.com/grafana/grafana/pkg/plugins/manager/signature" - "github.com/grafana/grafana/pkg/services/dashboards/database" - service "github.com/grafana/grafana/pkg/services/dashboards/manager" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" ) @@ -27,9 +24,8 @@ func TestGetPluginDashboards(t *testing.T) { }, } pmCfg := plugins.FromGrafanaCfg(cfg) - dashboardService := service.ProvideDashboardService(database.ProvideDashboardStore(&sqlstore.SQLStore{})) pm, err := ProvideService(cfg, loader.New(pmCfg, nil, - signature.NewUnsignedAuthorizer(pmCfg), &provider.Service{}), dashboardService) + signature.NewUnsignedAuthorizer(pmCfg), &provider.Service{})) require.NoError(t, err) bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardQuery) error { diff --git a/pkg/plugins/manager/manager.go b/pkg/plugins/manager/manager.go index d74bad4f744..5e2a4ef1cf6 100644 --- a/pkg/plugins/manager/manager.go +++ b/pkg/plugins/manager/manager.go @@ -14,7 +14,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/instrumentation" "github.com/grafana/grafana/pkg/plugins/manager/installer" - "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -30,38 +29,35 @@ var _ plugins.StaticRouteResolver = (*PluginManager)(nil) var _ plugins.RendererManager = (*PluginManager)(nil) type PluginManager struct { - cfg *plugins.Cfg - store map[string]*plugins.Plugin - pluginInstaller plugins.Installer - pluginLoader plugins.Loader - pluginsMu sync.RWMutex - pluginPaths map[plugins.Class][]string - dashboardService dashboards.DashboardService - log log.Logger + cfg *plugins.Cfg + store map[string]*plugins.Plugin + pluginInstaller plugins.Installer + pluginLoader plugins.Loader + pluginsMu sync.RWMutex + pluginPaths map[plugins.Class][]string + log log.Logger } -func ProvideService(grafanaCfg *setting.Cfg, pluginLoader plugins.Loader, dashboardService dashboards.DashboardService) (*PluginManager, error) { +func ProvideService(grafanaCfg *setting.Cfg, pluginLoader plugins.Loader) (*PluginManager, error) { pm := New(plugins.FromGrafanaCfg(grafanaCfg), map[plugins.Class][]string{ plugins.Core: corePluginPaths(grafanaCfg), plugins.Bundled: {grafanaCfg.BundledPluginsPath}, plugins.External: append([]string{grafanaCfg.PluginsPath}, pluginSettingPaths(grafanaCfg)...), - }, pluginLoader, dashboardService) + }, pluginLoader) if err := pm.Init(); err != nil { return nil, err } return pm, nil } -func New(cfg *plugins.Cfg, pluginPaths map[plugins.Class][]string, pluginLoader plugins.Loader, - dashboardService dashboards.DashboardService) *PluginManager { +func New(cfg *plugins.Cfg, pluginPaths map[plugins.Class][]string, pluginLoader plugins.Loader) *PluginManager { return &PluginManager{ - cfg: cfg, - pluginLoader: pluginLoader, - pluginPaths: pluginPaths, - store: make(map[string]*plugins.Plugin), - log: log.New("plugin.manager"), - pluginInstaller: installer.New(false, cfg.BuildVersion, newInstallerLogger("plugin.installer", true)), - dashboardService: dashboardService, + cfg: cfg, + pluginLoader: pluginLoader, + pluginPaths: pluginPaths, + store: make(map[string]*plugins.Plugin), + log: log.New("plugin.manager"), + pluginInstaller: installer.New(false, cfg.BuildVersion, newInstallerLogger("plugin.installer", true)), } } diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go index e0c871f0428..1bb2651f8d2 100644 --- a/pkg/plugins/manager/manager_integration_test.go +++ b/pkg/plugins/manager/manager_integration_test.go @@ -14,7 +14,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/backendplugin/provider" "github.com/grafana/grafana/pkg/plugins/manager/loader" "github.com/grafana/grafana/pkg/plugins/manager/signature" - service "github.com/grafana/grafana/pkg/services/dashboards/manager" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/searchV2" @@ -94,7 +93,7 @@ func TestPluginManager_int_init(t *testing.T) { pmCfg := plugins.FromGrafanaCfg(cfg) pm, err := ProvideService(cfg, loader.New(pmCfg, license, signature.NewUnsignedAuthorizer(pmCfg), - provider.ProvideService(coreRegistry)), &service.DashboardServiceImpl{}) + provider.ProvideService(coreRegistry))) require.NoError(t, err) verifyCorePluginCatalogue(t, pm) diff --git a/pkg/plugins/manager/manager_test.go b/pkg/plugins/manager/manager_test.go index 6a35bd463d0..d0b5d9dc813 100644 --- a/pkg/plugins/manager/manager_test.go +++ b/pkg/plugins/manager/manager_test.go @@ -12,9 +12,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" - "github.com/grafana/grafana/pkg/services/dashboards/database" - service "github.com/grafana/grafana/pkg/services/dashboards/manager" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -469,8 +466,7 @@ func TestPluginManager_lifecycle_unmanaged(t *testing.T) { func createManager(t *testing.T, cbs ...func(*PluginManager)) *PluginManager { t.Helper() - dashboardService := service.ProvideDashboardService(database.ProvideDashboardStore(&sqlstore.SQLStore{})) - pm := New(&plugins.Cfg{}, nil, &fakeLoader{}, dashboardService) + pm := New(&plugins.Cfg{}, nil, &fakeLoader{}) for _, cb := range cbs { cb(pm) @@ -524,8 +520,7 @@ func newScenario(t *testing.T, managed bool, fn func(t *testing.T, ctx *managerS cfg.Azure.ManagedIdentityClientId = "client-id" loader := &fakeLoader{} - dashboardService := service.ProvideDashboardService(database.ProvideDashboardStore(&sqlstore.SQLStore{})) - manager := New(cfg, nil, loader, dashboardService) + manager := New(cfg, nil, loader) manager.pluginLoader = loader ctx := &managerScenarioCtx{ manager: manager, diff --git a/pkg/plugins/manager/signature/manifest.go b/pkg/plugins/manager/signature/manifest.go index 3a852046421..43e474af440 100644 --- a/pkg/plugins/manager/signature/manifest.go +++ b/pkg/plugins/manager/signature/manifest.go @@ -15,7 +15,10 @@ import ( "path/filepath" "strings" + // TODO: replace deprecated `golang.org/x/crypto` package https://github.com/grafana/grafana/issues/46050 + // nolint:staticcheck "golang.org/x/crypto/openpgp" + // nolint:staticcheck "golang.org/x/crypto/openpgp/clearsign" "github.com/grafana/grafana/pkg/infra/log" diff --git a/pkg/plugins/plugincontext/plugincontext.go b/pkg/plugins/plugincontext/plugincontext.go index 7dc16d586fe..30157455b6a 100644 --- a/pkg/plugins/plugincontext/plugincontext.go +++ b/pkg/plugins/plugincontext/plugincontext.go @@ -22,7 +22,7 @@ import ( func ProvideService(bus bus.Bus, cacheService *localcache.CacheService, pluginStore plugins.Store, dataSourceCache datasources.CacheService, secretsService secrets.Service, - pluginSettingsService *pluginsettings.Service) *Provider { + pluginSettingsService *pluginsettings.ServiceImpl) *Provider { return &Provider{ Bus: bus, CacheService: cacheService, @@ -40,7 +40,7 @@ type Provider struct { pluginStore plugins.Store DataSourceCache datasources.CacheService SecretsService secrets.Service - PluginSettingsService *pluginsettings.Service + PluginSettingsService *pluginsettings.ServiceImpl logger log.Logger } @@ -114,7 +114,7 @@ func (p *Provider) getCachedPluginSettings(ctx context.Context, pluginID string, } query := models.GetPluginSettingByIdQuery{PluginId: pluginID, OrgId: user.OrgId} - if err := p.Bus.Dispatch(ctx, &query); err != nil { + if err := p.PluginSettingsService.GetPluginSettingById(ctx, &query); err != nil { return nil, err } diff --git a/pkg/server/backgroundsvcs/background_services.go b/pkg/server/backgroundsvcs/background_services.go index 1cc420ff98f..e77f4c17c46 100644 --- a/pkg/server/backgroundsvcs/background_services.go +++ b/pkg/server/backgroundsvcs/background_services.go @@ -35,7 +35,7 @@ func ProvideBackgroundServiceRegistry( metrics *metrics.InternalMetricsService, secretsService *secretsManager.SecretsService, remoteCache *remotecache.RemoteCache, thumbnailsService thumbs.Service, // Need to make sure these are initialized, is there a better place to put them? - _ *plugindashboards.Service, _ *dashboardsnapshots.Service, _ *pluginsettings.Service, + _ *plugindashboards.Service, _ *dashboardsnapshots.Service, _ *pluginsettings.ServiceImpl, _ *alerting.AlertNotificationService, _ serviceaccounts.Service, ) *BackgroundServiceRegistry { return NewBackgroundServiceRegistry( diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 586c86db521..530d0f43806 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/infra/filestorage" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/httpclient/httpclientprovider" "github.com/grafana/grafana/pkg/infra/kvstore" @@ -146,6 +147,7 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), quota.ProvideService, remotecache.ProvideService, + filestorage.ProvideService, loginservice.ProvideService, wire.Bind(new(login.Service), new(*loginservice.Implementation)), authinfoservice.ProvideAuthInfoService, @@ -192,6 +194,7 @@ var wireBasicSet = wire.NewSet( datasourceservice.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*datasourceservice.Service)), pluginsettings.ProvideService, + wire.Bind(new(pluginsettings.Service), new(*pluginsettings.ServiceImpl)), alerting.ProvideService, serviceaccountsmanager.ProvideServiceAccountsService, wire.Bind(new(serviceaccounts.Service), new(*serviceaccountsmanager.ServiceAccountsService)), @@ -212,6 +215,8 @@ var wireBasicSet = wire.NewSet( dashboardimportservice.ProvideService, wire.Bind(new(dashboardimport.Service), new(*dashboardimportservice.ImportDashboardService)), plugindashboards.ProvideService, + alerting.ProvideDashAlertExtractorService, + wire.Bind(new(alerting.DashAlertExtractor), new(*alerting.DashAlertExtractorService)), comments.ProvideService, ) diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 292b666c3f5..37c66778ded 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -5,7 +5,6 @@ package server import ( "github.com/google/wire" - "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/provider" @@ -18,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/datasources/permissions" datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/encryption" "github.com/grafana/grafana/pkg/services/encryption/ossencryption" @@ -75,8 +75,8 @@ var wireExtsBasicSet = wire.NewSet( wire.Bind(new(kmsproviders.Service), new(osskmsproviders.Service)), ldap.ProvideGroupsService, wire.Bind(new(ldap.Groups), new(*ldap.OSSGroups)), - api.ProvideDatasourcePermissionsService, - wire.Bind(new(api.DatasourcePermissionsService), new(*api.OSSDatasourcePermissionsService)), + permissions.ProvideDatasourcePermissionsService, + wire.Bind(new(permissions.DatasourcePermissionsService), new(*permissions.OSSDatasourcePermissionsService)), ossaccesscontrol.ProvidePermissionsServices, wire.Bind(new(accesscontrol.PermissionsServices), new(*ossaccesscontrol.PermissionsService)), ) diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index 03ae32a23a2..015dcd2ab90 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -44,7 +44,7 @@ type PermissionsServices interface { type PermissionsService interface { // GetPermissions returns all permissions for given resourceID - GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]ResourcePermission, error) + GetPermissions(ctx context.Context, user *models.SignedInUser, resourceID string) ([]ResourcePermission, error) // SetUserPermission sets permission on resource for a user SetUserPermission(ctx context.Context, orgID int64, user User, resourceID, permission string) (*ResourcePermission, error) // SetTeamPermission sets permission on resource for a team diff --git a/pkg/services/accesscontrol/database/resource_permissions.go b/pkg/services/accesscontrol/database/resource_permissions.go index 68c266db351..ff2c3b39b93 100644 --- a/pkg/services/accesscontrol/database/resource_permissions.go +++ b/pkg/services/accesscontrol/database/resource_permissions.go @@ -359,16 +359,28 @@ func (s *AccessControlStore) getResourcesPermissions(sess *sqlstore.DBSession, o args = append(args, a) } - // Need args x3 due to union initialLength := len(args) - args = append(args, args[:initialLength]...) - args = append(args, args[:initialLength]...) - user := userSelect + userFrom + where - team := teamSelect + teamFrom + where + userFilter, err := accesscontrol.Filter(context.Background(), "u.id", "users", accesscontrol.ActionOrgUsersRead, query.User) + if err != nil { + return nil, err + } + user := userSelect + userFrom + where + " AND " + userFilter.Where + args = append(args, userFilter.Args...) + + teamFilter, err := accesscontrol.Filter(context.Background(), "t.id", "teams", accesscontrol.ActionTeamsRead, query.User) + if err != nil { + return nil, err + } + + team := teamSelect + teamFrom + where + " AND " + teamFilter.Where + args = append(args, args[:initialLength]...) + args = append(args, teamFilter.Args...) + builtin := builtinSelect + builtinFrom + where - sql := user + "UNION" + team + "UNION" + builtin + args = append(args, args[:initialLength]...) + sql := user + " UNION " + team + " UNION " + builtin queryResults := make([]flatResourcePermission, 0) if err := sess.SQL(sql, args...).Find(&queryResults); err != nil { return nil, err diff --git a/pkg/services/accesscontrol/database/resource_permissions_test.go b/pkg/services/accesscontrol/database/resource_permissions_test.go index aa2ac0e35be..415a0b621d7 100644 --- a/pkg/services/accesscontrol/database/resource_permissions_test.go +++ b/pkg/services/accesscontrol/database/resource_permissions_test.go @@ -313,6 +313,7 @@ func TestAccessControlStore_SetResourcePermissions(t *testing.T) { type getResourcesPermissionsTest struct { desc string + user *models.SignedInUser numUsers int actions []string resource string @@ -323,14 +324,24 @@ type getResourcesPermissionsTest struct { func TestAccessControlStore_GetResourcesPermissions(t *testing.T) { tests := []getResourcesPermissionsTest{ { - desc: "should return permissions for all resource ids", + desc: "should return permissions for all resource ids", + user: &models.SignedInUser{ + OrgId: 1, + Permissions: map[int64]map[string][]string{ + 1: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}, + }}, numUsers: 3, actions: []string{"datasources:query"}, resource: "datasources", resourceIDs: []string{"1", "2"}, }, { - desc: "should return manage permissions for all resource ids", + desc: "should return manage permissions for all resource ids", + user: &models.SignedInUser{ + OrgId: 1, + Permissions: map[int64]map[string][]string{ + 1: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}, + }}, numUsers: 3, actions: []string{"datasources:query"}, resource: "datasources", @@ -345,7 +356,7 @@ func TestAccessControlStore_GetResourcesPermissions(t *testing.T) { err := sql.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { role := &accesscontrol.Role{ - OrgID: 1, + OrgID: test.user.OrgId, UID: "seeded", Name: "seeded", Updated: time.Now(), @@ -382,7 +393,8 @@ func TestAccessControlStore_GetResourcesPermissions(t *testing.T) { seedResourcePermissions(t, store, sql, test.actions, test.resource, id, test.numUsers) } - permissions, err := store.GetResourcesPermissions(context.Background(), 1, types.GetResourcesPermissionsQuery{ + permissions, err := store.GetResourcesPermissions(context.Background(), test.user.OrgId, types.GetResourcesPermissionsQuery{ + User: test.user, Actions: test.actions, Resource: test.resource, ResourceIDs: test.resourceIDs, diff --git a/pkg/services/accesscontrol/filter.go b/pkg/services/accesscontrol/filter.go index 26d18d731a5..01e2a83184a 100644 --- a/pkg/services/accesscontrol/filter.go +++ b/pkg/services/accesscontrol/filter.go @@ -12,7 +12,9 @@ import ( var sqlIDAcceptList = map[string]struct{}{ "org_user.user_id": {}, "role.id": {}, + "t.id": {}, "team.id": {}, + "u.id": {}, "\"user\".\"id\"": {}, // For Postgres "`user`.`id`": {}, // For MySQL and SQLite } diff --git a/pkg/services/accesscontrol/mock/service_mock.go b/pkg/services/accesscontrol/mock/service_mock.go index c5f57caf056..fae65edeb72 100644 --- a/pkg/services/accesscontrol/mock/service_mock.go +++ b/pkg/services/accesscontrol/mock/service_mock.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/mock" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" ) @@ -14,8 +15,8 @@ type MockPermissionsService struct { mock.Mock } -func (m *MockPermissionsService) GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]accesscontrol.ResourcePermission, error) { - mockedArgs := m.Called(ctx, orgID, resourceID) +func (m *MockPermissionsService) GetPermissions(ctx context.Context, user *models.SignedInUser, resourceID string) ([]accesscontrol.ResourcePermission, error) { + mockedArgs := m.Called(ctx, user, resourceID) return mockedArgs.Get(0).([]accesscontrol.ResourcePermission), mockedArgs.Error(1) } diff --git a/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go index 578819c5cfa..a0babb86346 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go @@ -113,7 +113,7 @@ var _ accesscontrol.PermissionsService = new(emptyPermissionsService) type emptyPermissionsService struct{} -func (e emptyPermissionsService) GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]accesscontrol.ResourcePermission, error) { +func (e emptyPermissionsService) GetPermissions(ctx context.Context, user *models.SignedInUser, resourceID string) ([]accesscontrol.ResourcePermission, error) { return nil, nil } diff --git a/pkg/services/accesscontrol/resourcepermissions/api.go b/pkg/services/accesscontrol/resourcepermissions/api.go index 0f90577282d..78b6f6c0a34 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api.go +++ b/pkg/services/accesscontrol/resourcepermissions/api.go @@ -82,7 +82,7 @@ type resourcePermissionDTO struct { func (a *api) getPermissions(c *models.ReqContext) response.Response { resourceID := web.Params(c.Req)[":resourceID"] - permissions, err := a.service.GetPermissions(c.Req.Context(), c.OrgId, resourceID) + permissions, err := a.service.GetPermissions(c.Req.Context(), c.SignedInUser, resourceID) if err != nil { return response.Error(http.StatusInternalServerError, "failed to get permissions", err) } diff --git a/pkg/services/accesscontrol/resourcepermissions/api_test.go b/pkg/services/accesscontrol/resourcepermissions/api_test.go index 515b37f6e75..b64a9b0bcb5 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/api_test.go @@ -136,9 +136,13 @@ type getPermissionsTestCase struct { func TestApi_getPermissions(t *testing.T) { tests := []getPermissionsTestCase{ { - desc: "expect permissions for resource with id 1", - resourceID: "1", - permissions: []*accesscontrol.Permission{{Action: "dashboards.permissions:read", Scope: "dashboards:id:1"}}, + desc: "expect permissions for resource with id 1", + resourceID: "1", + permissions: []*accesscontrol.Permission{ + {Action: "dashboards.permissions:read", Scope: "dashboards:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, + {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll}, + }, expectedStatus: 200, }, { @@ -152,7 +156,7 @@ func TestApi_getPermissions(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { service, sql := setupTestEnvironment(t, tt.permissions, testOptions) - server := setupTestServer(t, &models.SignedInUser{OrgId: 1}, service) + server := setupTestServer(t, &models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}}, service) seedPermissions(t, tt.resourceID, sql, service) @@ -195,6 +199,8 @@ func TestApi_setBuiltinRolePermission(t *testing.T) { permissions: []*accesscontrol.Permission{ {Action: "dashboards.permissions:read", Scope: "dashboards:id:1"}, {Action: "dashboards.permissions:write", Scope: "dashboards:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, + {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll}, }, }, { @@ -206,6 +212,8 @@ func TestApi_setBuiltinRolePermission(t *testing.T) { permissions: []*accesscontrol.Permission{ {Action: "dashboards.permissions:read", Scope: "dashboards:id:1"}, {Action: "dashboards.permissions:write", Scope: "dashboards:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, + {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll}, }, }, { @@ -234,7 +242,7 @@ func TestApi_setBuiltinRolePermission(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { service, _ := setupTestEnvironment(t, tt.permissions, testOptions) - server := setupTestServer(t, &models.SignedInUser{OrgId: 1}, service) + server := setupTestServer(t, &models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}}, service) recorder := setPermission(t, server, testOptions.Resource, tt.resourceID, tt.permission, "builtInRoles", tt.builtInRole) assert.Equal(t, tt.expectedStatus, recorder.Code) @@ -269,6 +277,8 @@ func TestApi_setTeamPermission(t *testing.T) { permissions: []*accesscontrol.Permission{ {Action: "dashboards.permissions:read", Scope: "dashboards:id:1"}, {Action: "dashboards.permissions:write", Scope: "dashboards:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, + {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll}, }, }, { @@ -280,6 +290,8 @@ func TestApi_setTeamPermission(t *testing.T) { permissions: []*accesscontrol.Permission{ {Action: "dashboards.permissions:read", Scope: "dashboards:id:1"}, {Action: "dashboards.permissions:write", Scope: "dashboards:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, + {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll}, }, }, { @@ -308,7 +320,7 @@ func TestApi_setTeamPermission(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { service, sql := setupTestEnvironment(t, tt.permissions, testOptions) - server := setupTestServer(t, &models.SignedInUser{OrgId: 1}, service) + server := setupTestServer(t, &models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}}, service) // seed team _, err := sql.CreateTeam("test", "test@test.com", 1) @@ -348,6 +360,8 @@ func TestApi_setUserPermission(t *testing.T) { permissions: []*accesscontrol.Permission{ {Action: "dashboards.permissions:read", Scope: "dashboards:id:1"}, {Action: "dashboards.permissions:write", Scope: "dashboards:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, + {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll}, }, }, { @@ -359,6 +373,8 @@ func TestApi_setUserPermission(t *testing.T) { permissions: []*accesscontrol.Permission{ {Action: "dashboards.permissions:read", Scope: "dashboards:id:1"}, {Action: "dashboards.permissions:write", Scope: "dashboards:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, + {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll}, }, }, { @@ -387,7 +403,7 @@ func TestApi_setUserPermission(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { service, sql := setupTestEnvironment(t, tt.permissions, testOptions) - server := setupTestServer(t, &models.SignedInUser{OrgId: 1}, service) + server := setupTestServer(t, &models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}}, service) // seed user _, err := sql.CreateUser(context.Background(), models.CreateUserCommand{Login: "test", OrgId: 1}) @@ -432,9 +448,17 @@ func TestApi_UidSolver(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - userPermissions := []*accesscontrol.Permission{{Action: "dashboards.permissions:read", Scope: "dashboards:id:1"}} + userPermissions := []*accesscontrol.Permission{ + {Action: "dashboards.permissions:read", Scope: "dashboards:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, + {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll}, + } + service, sql := setupTestEnvironment(t, userPermissions, withSolver(testOptions, testSolver)) - server := setupTestServer(t, &models.SignedInUser{OrgId: 1}, service) + server := setupTestServer(t, &models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{ + 1: accesscontrol.GroupScopesByAction(userPermissions), + }}, service) + seedPermissions(t, tt.resourceID, sql, service) permissions, recorder := getPermission(t, server, testOptions.Resource, tt.uid) diff --git a/pkg/services/accesscontrol/resourcepermissions/service.go b/pkg/services/accesscontrol/resourcepermissions/service.go index 191799af0c5..f0acd494b67 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service.go +++ b/pkg/services/accesscontrol/resourcepermissions/service.go @@ -98,8 +98,9 @@ type Service struct { sqlStore *sqlstore.SQLStore } -func (s *Service) GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]accesscontrol.ResourcePermission, error) { - return s.store.GetResourcesPermissions(ctx, orgID, types.GetResourcesPermissionsQuery{ +func (s *Service) GetPermissions(ctx context.Context, user *models.SignedInUser, resourceID string) ([]accesscontrol.ResourcePermission, error) { + return s.store.GetResourcesPermissions(ctx, user.OrgId, types.GetResourcesPermissionsQuery{ + User: user, Actions: s.actions, Resource: s.options.Resource, ResourceIDs: []string{resourceID}, diff --git a/pkg/services/accesscontrol/resourcepermissions/types/models.go b/pkg/services/accesscontrol/resourcepermissions/types/models.go index 36f4072dde0..7f3b662d9ad 100644 --- a/pkg/services/accesscontrol/resourcepermissions/types/models.go +++ b/pkg/services/accesscontrol/resourcepermissions/types/models.go @@ -1,6 +1,9 @@ package types -import "github.com/grafana/grafana/pkg/services/accesscontrol" +import ( + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" +) type SetResourcePermissionCommand struct { Actions []string @@ -22,4 +25,5 @@ type GetResourcesPermissionsQuery struct { Resource string ResourceIDs []string OnlyManaged bool + User *models.SignedInUser } diff --git a/pkg/services/alerting/engine.go b/pkg/services/alerting/engine.go index 54b5e50010d..cb39ff67448 100644 --- a/pkg/services/alerting/engine.go +++ b/pkg/services/alerting/engine.go @@ -45,16 +45,17 @@ type AlertEngine struct { DataService legacydata.RequestHandler Cfg *setting.Cfg - execQueue chan *Job - ticker *Ticker - scheduler scheduler - evalHandler evalHandler - ruleReader ruleReader - log log.Logger - resultHandler resultHandler - usageStatsService usagestats.Service - tracer tracing.Tracer - sqlStore AlertStore + execQueue chan *Job + ticker *Ticker + scheduler scheduler + evalHandler evalHandler + ruleReader ruleReader + log log.Logger + resultHandler resultHandler + usageStatsService usagestats.Service + tracer tracing.Tracer + sqlStore AlertStore + dashAlertExtractor DashAlertExtractor } // IsDisabled returns true if the alerting service is disabled for this instance. @@ -65,16 +66,18 @@ func (e *AlertEngine) IsDisabled() bool { // ProvideAlertEngine returns a new AlertEngine. func ProvideAlertEngine(renderer rendering.Service, bus bus.Bus, requestValidator models.PluginRequestValidator, dataService legacydata.RequestHandler, usageStatsService usagestats.Service, encryptionService encryption.Internal, - notificationService *notifications.NotificationService, tracer tracing.Tracer, sqlStore AlertStore, cfg *setting.Cfg) *AlertEngine { + notificationService *notifications.NotificationService, tracer tracing.Tracer, sqlStore AlertStore, cfg *setting.Cfg, + dashAlertExtractor DashAlertExtractor) *AlertEngine { e := &AlertEngine{ - Cfg: cfg, - RenderService: renderer, - Bus: bus, - RequestValidator: requestValidator, - DataService: dataService, - usageStatsService: usageStatsService, - tracer: tracer, - sqlStore: sqlStore, + Cfg: cfg, + RenderService: renderer, + Bus: bus, + RequestValidator: requestValidator, + DataService: dataService, + usageStatsService: usageStatsService, + tracer: tracer, + sqlStore: sqlStore, + dashAlertExtractor: dashAlertExtractor, } e.ticker = NewTicker(time.Now(), time.Second*0, clock.New(), 1) e.execQueue = make(chan *Job, 1000) diff --git a/pkg/services/alerting/engine_integration_test.go b/pkg/services/alerting/engine_integration_test.go index f8a308ac5f2..05725068215 100644 --- a/pkg/services/alerting/engine_integration_test.go +++ b/pkg/services/alerting/engine_integration_test.go @@ -24,7 +24,7 @@ func TestEngineTimeouts(t *testing.T) { usMock := &usagestats.UsageStatsMock{T: t} tracer, err := tracing.InitializeTracerForTest() require.NoError(t, err) - engine := ProvideAlertEngine(nil, nil, nil, nil, usMock, ossencryption.ProvideService(), nil, tracer, nil, setting.NewCfg()) + engine := ProvideAlertEngine(nil, nil, nil, nil, usMock, ossencryption.ProvideService(), nil, tracer, nil, setting.NewCfg(), nil) setting.AlertingNotificationTimeout = 30 * time.Second setting.AlertingMaxAttempts = 3 engine.resultHandler = &FakeResultHandler{} diff --git a/pkg/services/alerting/engine_test.go b/pkg/services/alerting/engine_test.go index 771970efd95..6e7fb674d68 100644 --- a/pkg/services/alerting/engine_test.go +++ b/pkg/services/alerting/engine_test.go @@ -102,7 +102,7 @@ func TestEngineProcessJob(t *testing.T) { require.NoError(t, err) store := &AlertStoreMock{} - engine := ProvideAlertEngine(nil, bus, nil, nil, usMock, ossencryption.ProvideService(), nil, tracer, store, setting.NewCfg()) + engine := ProvideAlertEngine(nil, bus, nil, nil, usMock, ossencryption.ProvideService(), nil, tracer, store, setting.NewCfg(), nil) setting.AlertingEvaluationTimeout = 30 * time.Second setting.AlertingNotificationTimeout = 30 * time.Second setting.AlertingMaxAttempts = 3 diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go index c3273ed6ca6..3238b5b60ac 100644 --- a/pkg/services/alerting/extractor.go +++ b/pkg/services/alerting/extractor.go @@ -6,31 +6,34 @@ import ( "errors" "fmt" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/datasources/permissions" ) -// DashAlertExtractor extracts alerts from the dashboard json. -type DashAlertExtractor struct { - User *models.SignedInUser - Dash *models.Dashboard - OrgID int64 - log log.Logger +type DashAlertExtractor interface { + GetAlerts(ctx context.Context, dashAlertInfo DashAlertInfo) ([]*models.Alert, error) + ValidateAlerts(ctx context.Context, dashAlertInfo DashAlertInfo) error } -// NewDashAlertExtractor returns a new DashAlertExtractor. -func NewDashAlertExtractor(dash *models.Dashboard, orgID int64, user *models.SignedInUser) *DashAlertExtractor { - return &DashAlertExtractor{ - User: user, - Dash: dash, - OrgID: orgID, - log: log.New("alerting.extractor"), +// DashAlertExtractorService extracts alerts from the dashboard json. +type DashAlertExtractorService struct { + datasourcePermissionsService permissions.DatasourcePermissionsService + datasourceService datasources.DataSourceService + log log.Logger +} + +func ProvideDashAlertExtractorService(datasourcePermissionsService permissions.DatasourcePermissionsService, datasourceService datasources.DataSourceService) *DashAlertExtractorService { + return &DashAlertExtractorService{ + datasourcePermissionsService: datasourcePermissionsService, + datasourceService: datasourceService, + log: log.New("alerting.extractor"), } } -func (e *DashAlertExtractor) lookupQueryDataSource(ctx context.Context, panel *simplejson.Json, panelQuery *simplejson.Json) (*models.DataSource, error) { +func (e *DashAlertExtractorService) lookupQueryDataSource(ctx context.Context, panel *simplejson.Json, panelQuery *simplejson.Json, orgID int64) (*models.DataSource, error) { dsName := "" dsUid := "" @@ -47,15 +50,15 @@ func (e *DashAlertExtractor) lookupQueryDataSource(ctx context.Context, panel *s } if dsName == "" && dsUid == "" { - query := &models.GetDefaultDataSourceQuery{OrgId: e.OrgID} - if err := bus.Dispatch(ctx, query); err != nil { + query := &models.GetDefaultDataSourceQuery{OrgId: orgID} + if err := e.datasourceService.GetDefaultDataSource(ctx, query); err != nil { return nil, err } return query.Result, nil } - query := &models.GetDataSourceQuery{Name: dsName, Uid: dsUid, OrgId: e.OrgID} - if err := bus.Dispatch(ctx, query); err != nil { + query := &models.GetDataSourceQuery{Name: dsName, Uid: dsUid, OrgId: orgID} + if err := e.datasourceService.GetDataSource(ctx, query); err != nil { return nil, err } @@ -101,7 +104,7 @@ func UAEnabled(ctx context.Context) bool { return enabled } -func (e *DashAlertExtractor) getAlertFromPanels(ctx context.Context, jsonWithPanels *simplejson.Json, validateAlertFunc func(*models.Alert) bool, logTranslationFailures bool) ([]*models.Alert, error) { +func (e *DashAlertExtractorService) getAlertFromPanels(ctx context.Context, jsonWithPanels *simplejson.Json, validateAlertFunc func(*models.Alert) bool, logTranslationFailures bool, dashAlertInfo DashAlertInfo) ([]*models.Alert, error) { alerts := make([]*models.Alert, 0) for _, panelObj := range jsonWithPanels.Get("panels").MustArray() { @@ -111,7 +114,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(ctx context.Context, jsonWithPan // check if the panel is collapsed if collapsed && collapsedJSON.MustBool() { // extract alerts from sub panels for collapsed panels - alertSlice, err := e.getAlertFromPanels(ctx, panel, validateAlertFunc, logTranslationFailures) + alertSlice, err := e.getAlertFromPanels(ctx, panel, validateAlertFunc, logTranslationFailures, dashAlertInfo) if err != nil { return nil, err } @@ -143,8 +146,8 @@ func (e *DashAlertExtractor) getAlertFromPanels(ctx context.Context, jsonWithPan Err: validationErr.Err, PanelID: panelID, } - if e.Dash != nil { - ve.DashboardID = e.Dash.Id + if dashAlertInfo.Dash != nil { + ve.DashboardID = dashAlertInfo.Dash.Id } return ve } @@ -170,8 +173,8 @@ func (e *DashAlertExtractor) getAlertFromPanels(ctx context.Context, jsonWithPan } alert := &models.Alert{ - DashboardId: e.Dash.Id, - OrgId: e.OrgID, + DashboardId: dashAlertInfo.Dash.Id, + OrgId: dashAlertInfo.OrgID, PanelId: panelID, Id: jsonAlert.Get("id").MustInt64(), Name: jsonAlert.Get("name").MustString(), @@ -198,24 +201,22 @@ func (e *DashAlertExtractor) getAlertFromPanels(ctx context.Context, jsonWithPan return nil, ValidationError{Reason: reason} } - datasource, err := e.lookupQueryDataSource(ctx, panel, panelQuery) + datasource, err := e.lookupQueryDataSource(ctx, panel, panelQuery, dashAlertInfo.OrgID) if err != nil { return nil, err } dsFilterQuery := models.DatasourcesPermissionFilterQuery{ - User: e.User, + User: dashAlertInfo.User, Datasources: []*models.DataSource{datasource}, } - if err := bus.Dispatch(ctx, &dsFilterQuery); err != nil { - if !errors.Is(err, bus.ErrHandlerNotFound) { + if err := e.datasourcePermissionsService.FilterDatasourcesBasedOnQueryPermissions(ctx, &dsFilterQuery); err != nil { + if !errors.Is(err, permissions.ErrNotImplemented) { return nil, err } - } else { - if len(dsFilterQuery.Result) == 0 { - return nil, models.ErrDataSourceAccessDenied - } + } else if len(dsFilterQuery.Result) == 0 { + return nil, models.ErrDataSourceAccessDenied } jsonQuery.SetPath([]string{"datasourceId"}, datasource.Id) @@ -250,12 +251,12 @@ func validateAlertRule(alert *models.Alert) bool { } // GetAlerts extracts alerts from the dashboard json and does full validation on the alert json data. -func (e *DashAlertExtractor) GetAlerts(ctx context.Context) ([]*models.Alert, error) { - return e.extractAlerts(ctx, validateAlertRule, true) +func (e *DashAlertExtractorService) GetAlerts(ctx context.Context, dashAlertInfo DashAlertInfo) ([]*models.Alert, error) { + return e.extractAlerts(ctx, validateAlertRule, true, dashAlertInfo) } -func (e *DashAlertExtractor) extractAlerts(ctx context.Context, validateFunc func(alert *models.Alert) bool, logTranslationFailures bool) ([]*models.Alert, error) { - dashboardJSON, err := copyJSON(e.Dash.Data) +func (e *DashAlertExtractorService) extractAlerts(ctx context.Context, validateFunc func(alert *models.Alert) bool, logTranslationFailures bool, dashAlertInfo DashAlertInfo) ([]*models.Alert, error) { + dashboardJSON, err := copyJSON(dashAlertInfo.Dash.Data) if err != nil { return nil, err } @@ -268,7 +269,7 @@ func (e *DashAlertExtractor) extractAlerts(ctx context.Context, validateFunc fun if len(rows) > 0 { for _, rowObj := range rows { row := simplejson.NewFromAny(rowObj) - a, err := e.getAlertFromPanels(ctx, row, validateFunc, logTranslationFailures) + a, err := e.getAlertFromPanels(ctx, row, validateFunc, logTranslationFailures, dashAlertInfo) if err != nil { return nil, err } @@ -276,7 +277,7 @@ func (e *DashAlertExtractor) extractAlerts(ctx context.Context, validateFunc fun alerts = append(alerts, a...) } } else { - a, err := e.getAlertFromPanels(ctx, dashboardJSON, validateFunc, logTranslationFailures) + a, err := e.getAlertFromPanels(ctx, dashboardJSON, validateFunc, logTranslationFailures, dashAlertInfo) if err != nil { return nil, err } @@ -290,9 +291,9 @@ func (e *DashAlertExtractor) extractAlerts(ctx context.Context, validateFunc fun // ValidateAlerts validates alerts in the dashboard json but does not require a valid dashboard id // in the first validation pass. -func (e *DashAlertExtractor) ValidateAlerts(ctx context.Context) error { +func (e *DashAlertExtractorService) ValidateAlerts(ctx context.Context, dashAlertInfo DashAlertInfo) error { _, err := e.extractAlerts(ctx, func(alert *models.Alert) bool { return alert.OrgId != 0 && alert.PanelId != 0 - }, false) + }, false, dashAlertInfo) return err } diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go index 68ee6cc3567..90c7499d9e3 100644 --- a/pkg/services/alerting/extractor_test.go +++ b/pkg/services/alerting/extractor_test.go @@ -2,14 +2,17 @@ package alerting import ( "context" + "errors" "io/ioutil" "testing" "time" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/datasources/permissions" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -21,40 +24,24 @@ func TestAlertRuleExtraction(t *testing.T) { // mock data defaultDs := &models.DataSource{Id: 12, OrgId: 1, Name: "I am default", IsDefault: true, Uid: "def-uid"} graphite2Ds := &models.DataSource{Id: 15, OrgId: 1, Name: "graphite2", Uid: "graphite2-uid"} - influxDBDs := &models.DataSource{Id: 16, OrgId: 1, Name: "InfluxDB", Uid: "InfluxDB-uid"} - prom := &models.DataSource{Id: 17, OrgId: 1, Name: "Prometheus", Uid: "Prometheus-uid"} - - bus.AddHandler("test", func(ctx context.Context, query *models.GetDefaultDataSourceQuery) error { - query.Result = defaultDs - return nil - }) - - bus.AddHandler("test", func(ctx context.Context, query *models.GetDataSourceQuery) error { - if query.Name == defaultDs.Name || query.Uid == defaultDs.Uid { - query.Result = defaultDs - } - if query.Name == graphite2Ds.Name || query.Uid == graphite2Ds.Uid { - query.Result = graphite2Ds - } - if query.Name == influxDBDs.Name || query.Uid == influxDBDs.Uid { - query.Result = influxDBDs - } - if query.Name == prom.Name || query.Uid == prom.Uid { - query.Result = prom - } - - return nil - }) json, err := ioutil.ReadFile("./testdata/graphite-alert.json") require.Nil(t, err) + dsPermissions := permissions.NewMockDatasourcePermissionService() + dsPermissions.DsResult = []*models.DataSource{ + { + Id: 1, + }, + } + + dsService := &fakeDatasourceService{ExpectedDatasource: defaultDs} + extractor := ProvideDashAlertExtractorService(dsPermissions, dsService) + t.Run("Parsing alert rules from dashboard json", func(t *testing.T) { dashJSON, err := simplejson.NewJson(json) require.Nil(t, err) - dash := models.NewDashboardFromJson(dashJSON) - getTarget := func(j *simplejson.Json) string { rowObj := j.Get("rows").MustArray()[0] row := simplejson.NewFromAny(rowObj) @@ -67,8 +54,11 @@ func TestAlertRuleExtraction(t *testing.T) { require.Equal(t, getTarget(dashJSON), "") - extractor := NewDashAlertExtractor(dash, 1, nil) - _, _ = extractor.GetAlerts(context.Background()) + _, _ = extractor.GetAlerts(context.Background(), DashAlertInfo{ + User: nil, + Dash: models.NewDashboardFromJson(dashJSON), + OrgID: 1, + }) require.Equal(t, getTarget(dashJSON), "") }) @@ -77,10 +67,12 @@ func TestAlertRuleExtraction(t *testing.T) { dashJSON, err := simplejson.NewJson(json) require.Nil(t, err) - dash := models.NewDashboardFromJson(dashJSON) - extractor := NewDashAlertExtractor(dash, 1, nil) - - alerts, err := extractor.GetAlerts(context.Background()) + dsService.ExpectedDatasource = &models.DataSource{Id: 12} + alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{ + User: nil, + Dash: models.NewDashboardFromJson(dashJSON), + OrgID: 1, + }) require.Nil(t, err) @@ -127,10 +119,12 @@ func TestAlertRuleExtraction(t *testing.T) { dashJSON, err := simplejson.NewJson(panelWithoutID) require.Nil(t, err) - dash := models.NewDashboardFromJson(dashJSON) - extractor := NewDashAlertExtractor(dash, 1, nil) - _, err = extractor.GetAlerts(context.Background()) + _, err = extractor.GetAlerts(context.Background(), DashAlertInfo{ + User: nil, + Dash: models.NewDashboardFromJson(dashJSON), + OrgID: 1, + }) require.NotNil(t, err) }) @@ -141,10 +135,12 @@ func TestAlertRuleExtraction(t *testing.T) { dashJSON, err := simplejson.NewJson(panelWithIDZero) require.Nil(t, err) - dash := models.NewDashboardFromJson(dashJSON) - extractor := NewDashAlertExtractor(dash, 1, nil) - _, err = extractor.GetAlerts(context.Background()) + _, err = extractor.GetAlerts(context.Background(), DashAlertInfo{ + User: nil, + Dash: models.NewDashboardFromJson(dashJSON), + OrgID: 1, + }) require.NotNil(t, err) }) @@ -154,10 +150,12 @@ func TestAlertRuleExtraction(t *testing.T) { require.Nil(t, err) dashJSON, err := simplejson.NewJson(panelWithQuery) require.Nil(t, err) - dash := models.NewDashboardFromJson(dashJSON) - extractor := NewDashAlertExtractor(dash, 1, nil) - _, err = extractor.GetAlerts(WithUAEnabled(context.Background(), true)) + _, err = extractor.GetAlerts(WithUAEnabled(context.Background(), true), DashAlertInfo{ + User: nil, + Dash: models.NewDashboardFromJson(dashJSON), + OrgID: 1, + }) require.Equal(t, "alert validation error: Alert on PanelId: 2 refers to query(B) that cannot be found. Legacy alerting queries are not able to be removed at this time in order to preserve the ability to rollback to previous versions of Grafana", err.Error()) }) @@ -167,10 +165,13 @@ func TestAlertRuleExtraction(t *testing.T) { dashJSON, err := simplejson.NewJson(panelWithoutSpecifiedDatasource) require.Nil(t, err) - dash := models.NewDashboardFromJson(dashJSON) - extractor := NewDashAlertExtractor(dash, 1, nil) - alerts, err := extractor.GetAlerts(context.Background()) + dsService.ExpectedDatasource = &models.DataSource{Id: 12} + alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{ + User: nil, + Dash: models.NewDashboardFromJson(dashJSON), + OrgID: 1, + }) require.Nil(t, err) condition := simplejson.NewFromAny(alerts[0].Settings.Get("conditions").MustArray()[0]) @@ -184,10 +185,12 @@ func TestAlertRuleExtraction(t *testing.T) { dashJSON, err := simplejson.NewJson(json) require.Nil(t, err) - dash := models.NewDashboardFromJson(dashJSON) - extractor := NewDashAlertExtractor(dash, 1, nil) - alerts, err := extractor.GetAlerts(context.Background()) + alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{ + User: nil, + Dash: models.NewDashboardFromJson(dashJSON), + OrgID: 1, + }) require.Nil(t, err) require.Len(t, alerts, 2) @@ -209,10 +212,12 @@ func TestAlertRuleExtraction(t *testing.T) { dashJSON, err := simplejson.NewJson(json) require.Nil(t, err) - dash := models.NewDashboardFromJson(dashJSON) - extractor := NewDashAlertExtractor(dash, 1, nil) - alerts, err := extractor.GetAlerts(context.Background()) + alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{ + User: nil, + Dash: models.NewDashboardFromJson(dashJSON), + OrgID: 1, + }) require.Nil(t, err) require.Len(t, alerts, 1) @@ -235,9 +240,12 @@ func TestAlertRuleExtraction(t *testing.T) { require.Nil(t, err) dash := models.NewDashboardFromJson(dashJSON) - extractor := NewDashAlertExtractor(dash, 1, nil) - alerts, err := extractor.GetAlerts(context.Background()) + alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{ + User: nil, + Dash: dash, + OrgID: 1, + }) require.Nil(t, err) require.Len(t, alerts, 4) @@ -249,14 +257,17 @@ func TestAlertRuleExtraction(t *testing.T) { dashJSON, err := simplejson.NewJson(json) require.Nil(t, err) - dash := models.NewDashboardFromJson(dashJSON) - extractor := NewDashAlertExtractor(dash, 1, nil) - err = extractor.ValidateAlerts(context.Background()) + dashAlertInfo := DashAlertInfo{ + User: nil, + Dash: models.NewDashboardFromJson(dashJSON), + OrgID: 1, + } + err = extractor.ValidateAlerts(context.Background(), dashAlertInfo) require.Nil(t, err) - _, err = extractor.GetAlerts(context.Background()) + _, err = extractor.GetAlerts(context.Background(), dashAlertInfo) require.Equal(t, err.Error(), "alert validation error: Panel id is not correct, alertName=Influxdb, panelId=1") }) @@ -266,14 +277,18 @@ func TestAlertRuleExtraction(t *testing.T) { dashJSON, err := simplejson.NewJson(json) require.Nil(t, err) - dash := models.NewDashboardFromJson(dashJSON) - extractor := NewDashAlertExtractor(dash, 1, nil) - err = extractor.ValidateAlerts(context.Background()) + dsService.ExpectedDatasource = graphite2Ds + dashAlertInfo := DashAlertInfo{ + User: nil, + Dash: models.NewDashboardFromJson(dashJSON), + OrgID: 1, + } + err = extractor.ValidateAlerts(context.Background(), dashAlertInfo) require.Nil(t, err) - alerts, err := extractor.GetAlerts(context.Background()) + alerts, err := extractor.GetAlerts(context.Background(), dashAlertInfo) require.Nil(t, err) condition := simplejson.NewFromAny(alerts[0].Settings.Get("conditions").MustArray()[0]) @@ -281,3 +296,81 @@ func TestAlertRuleExtraction(t *testing.T) { require.EqualValues(t, 15, query.Get("datasourceId").MustInt64()) }) } + +func TestFilterPermissionsErrors(t *testing.T) { + RegisterCondition("query", func(model *simplejson.Json, index int) (Condition, error) { + return &FakeCondition{}, nil + }) + + // mock data + defaultDs := &models.DataSource{Id: 12, OrgId: 1, Name: "I am default", IsDefault: true, Uid: "def-uid"} + + json, err := ioutil.ReadFile("./testdata/graphite-alert.json") + require.Nil(t, err) + dashJSON, err := simplejson.NewJson(json) + require.Nil(t, err) + + dsPermissions := permissions.NewMockDatasourcePermissionService() + dsService := &fakeDatasourceService{ExpectedDatasource: defaultDs} + extractor := ProvideDashAlertExtractorService(dsPermissions, dsService) + + tc := []struct { + name string + result []*models.DataSource + err error + expectedErr error + }{ + { + "Data sources are filtered and return results don't return an error", + []*models.DataSource{defaultDs}, + nil, + nil, + }, + { + "Data sources are filtered but return empty results should return error", + nil, + nil, + models.ErrDataSourceAccessDenied, + }, + { + "Using default OSS implementation doesn't return an error", + nil, + permissions.ErrNotImplemented, + nil, + }, + { + "Returning an error different from ErrNotImplemented should fails", + nil, + errors.New("random error"), + errors.New("random error"), + }, + } + + for _, test := range tc { + t.Run(test.name, func(t *testing.T) { + dsPermissions.DsResult = test.result + dsPermissions.ErrResult = test.err + _, err = extractor.GetAlerts(WithUAEnabled(context.Background(), true), DashAlertInfo{ + User: nil, + Dash: models.NewDashboardFromJson(dashJSON), + OrgID: 1, + }) + assert.Equal(t, err, test.expectedErr) + }) + } +} + +type fakeDatasourceService struct { + ExpectedDatasource *models.DataSource + datasources.DataSourceService +} + +func (f *fakeDatasourceService) GetDefaultDataSource(ctx context.Context, query *models.GetDefaultDataSourceQuery) error { + query.Result = f.ExpectedDatasource + return nil +} + +func (f *fakeDatasourceService) GetDataSource(ctx context.Context, query *models.GetDataSourceQuery) error { + query.Result = f.ExpectedDatasource + return nil +} diff --git a/pkg/services/alerting/models.go b/pkg/services/alerting/models.go index 0085418ac74..d4cb0bf84b5 100644 --- a/pkg/services/alerting/models.go +++ b/pkg/services/alerting/models.go @@ -4,6 +4,7 @@ import ( "sync" "github.com/grafana/grafana/pkg/components/null" + "github.com/grafana/grafana/pkg/models" ) // Job holds state about when the alert rule should be evaluated. @@ -42,3 +43,9 @@ type EvalMatch struct { Metric string `json:"metric"` Tags map[string]string `json:"tags"` } + +type DashAlertInfo struct { + User *models.SignedInUser + Dash *models.Dashboard + OrgID int64 +} diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go index 946820e8d51..3df0c8abfef 100644 --- a/pkg/services/alerting/notifier.go +++ b/pkg/services/alerting/notifier.go @@ -45,6 +45,7 @@ type NotifierOption struct { Required bool `json:"required"` ValidationRule string `json:"validationRule"` Secure bool `json:"secure"` + DependsOn string `json:"dependsOn"` } // InputType is the type of input that can be rendered in the frontend. diff --git a/pkg/services/alerting/test_rule.go b/pkg/services/alerting/test_rule.go index 3ccee726f6c..d04e052672f 100644 --- a/pkg/services/alerting/test_rule.go +++ b/pkg/services/alerting/test_rule.go @@ -11,9 +11,12 @@ import ( // AlertTest makes a test alert. func (e *AlertEngine) AlertTest(orgID int64, dashboard *simplejson.Json, panelID int64, user *models.SignedInUser) (*EvalContext, error) { dash := models.NewDashboardFromJson(dashboard) - - extractor := NewDashAlertExtractor(dash, orgID, user) - alerts, err := extractor.GetAlerts(context.Background()) + dashInfo := DashAlertInfo{ + User: user, + Dash: dash, + OrgID: orgID, + } + alerts, err := e.dashAlertExtractor.GetAlerts(context.Background(), dashInfo) if err != nil { return nil, err } diff --git a/pkg/services/dashboards/manager/dashboard_service.go b/pkg/services/dashboards/manager/dashboard_service.go index 93457ffa7bc..a48829ee8c8 100644 --- a/pkg/services/dashboards/manager/dashboard_service.go +++ b/pkg/services/dashboards/manager/dashboard_service.go @@ -19,14 +19,16 @@ import ( ) type DashboardServiceImpl struct { - dashboardStore m.Store - log log.Logger + dashboardStore m.Store + dashAlertExtractor alerting.DashAlertExtractor + log log.Logger } -func ProvideDashboardService(store m.Store) *DashboardServiceImpl { +func ProvideDashboardService(store m.Store, dashAlertExtractor alerting.DashAlertExtractor) *DashboardServiceImpl { return &DashboardServiceImpl{ - dashboardStore: store, - log: log.New("dashboard-service"), + dashboardStore: store, + dashAlertExtractor: dashAlertExtractor, + log: log.New("dashboard-service"), } } @@ -74,7 +76,8 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d } if shouldValidateAlerts { - if err := validateAlerts(ctx, dash, dto.User); err != nil { + dashAlertInfo := alerting.DashAlertInfo{Dash: dash, User: dto.User, OrgID: dash.OrgId} + if err := dr.dashAlertExtractor.ValidateAlerts(ctx, dashAlertInfo); err != nil { return nil, err } } @@ -139,11 +142,6 @@ func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context. return dr.dashboardStore.DeleteOrphanedProvisionedDashboards(ctx, cmd) } -var validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error { - extractor := alerting.NewDashAlertExtractor(dash, dash.OrgId, user) - return extractor.ValidateAlerts(ctx) -} - func validateDashboardRefreshInterval(dash *models.Dashboard) error { if setting.MinRefreshInterval == "" { return nil @@ -171,19 +169,6 @@ func validateDashboardRefreshInterval(dash *models.Dashboard) error { return nil } -// UpdateAlerting updates alerting. -// -// Stubbable by tests. -var UpdateAlerting = func(ctx context.Context, store m.Store, orgID int64, dashboard *models.Dashboard, user *models.SignedInUser) error { - extractor := alerting.NewDashAlertExtractor(dashboard, orgID, user) - alerts, err := extractor.GetAlerts(ctx) - if err != nil { - return err - } - - return store.SaveAlerts(ctx, dashboard.Id, alerts) -} - func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dto *m.SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { if err := validateDashboardRefreshInterval(dto.Dashboard); err != nil { @@ -210,7 +195,19 @@ func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt } // alerts - if err := UpdateAlerting(ctx, dr.dashboardStore, dto.OrgId, dash, dto.User); err != nil { + dashAlertInfo := alerting.DashAlertInfo{ + User: dto.User, + Dash: dash, + OrgID: dto.OrgId, + } + + alerts, err := dr.dashAlertExtractor.GetAlerts(ctx, dashAlertInfo) + if err != nil { + return nil, err + } + + err = dr.dashboardStore.SaveAlerts(ctx, dash.Id, alerts) + if err != nil { return nil, err } @@ -232,7 +229,19 @@ func (dr *DashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.C return nil, err } - if err := UpdateAlerting(ctx, dr.dashboardStore, dto.OrgId, dash, dto.User); err != nil { + dashAlertInfo := alerting.DashAlertInfo{ + User: dto.User, + Dash: dash, + OrgID: dto.OrgId, + } + + alerts, err := dr.dashAlertExtractor.GetAlerts(ctx, dashAlertInfo) + if err != nil { + return nil, err + } + + err = dr.dashboardStore.SaveAlerts(ctx, dash.Id, alerts) + if err != nil { return nil, err } @@ -258,7 +267,19 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *m.SaveDa return nil, fmt.Errorf("saving dashboard failed: %w", err) } - if err := UpdateAlerting(ctx, dr.dashboardStore, dto.OrgId, dash, dto.User); err != nil { + dashAlertInfo := alerting.DashAlertInfo{ + User: dto.User, + Dash: dash, + OrgID: dto.OrgId, + } + + alerts, err := dr.dashAlertExtractor.GetAlerts(ctx, dashAlertInfo) + if err != nil { + return nil, err + } + + err = dr.dashboardStore.SaveAlerts(ctx, dash.Id, alerts) + if err != nil { return nil, err } diff --git a/pkg/services/dashboards/manager/dashboard_service_integration_test.go b/pkg/services/dashboards/manager/dashboard_service_integration_test.go index 00bc7ca305b..713b56aedd5 100644 --- a/pkg/services/dashboards/manager/dashboard_service_integration_test.go +++ b/pkg/services/dashboards/manager/dashboard_service_integration_test.go @@ -5,6 +5,7 @@ package service import ( "context" + "github.com/grafana/grafana/pkg/services/alerting" "testing" "github.com/grafana/grafana/pkg/components/simplejson" @@ -21,14 +22,6 @@ const testOrgID int64 = 1 func TestIntegratedDashboardService(t *testing.T) { t.Run("Given saved folders and dashboards in organization A", func(t *testing.T) { - origUpdateAlerting := UpdateAlerting - t.Cleanup(func() { - UpdateAlerting = origUpdateAlerting - }) - UpdateAlerting = func(ctx context.Context, store dashbboardservice.Store, orgID int64, dashboard *models.Dashboard, user *models.SignedInUser) error { - return nil - } - // Basic validation tests permissionScenario(t, "When saving a dashboard with non-existing id", true, @@ -861,7 +854,7 @@ func callSaveWithResult(t *testing.T, cmd models.SaveDashboardCommand, sqlStore dto := toSaveDashboardDto(cmd) dashboardStore := database.ProvideDashboardStore(sqlStore) - res, err := ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), &dto, false) + res, err := ProvideDashboardService(dashboardStore, &dummyDashAlertExtractor{}).SaveDashboard(context.Background(), &dto, false) require.NoError(t, err) return res @@ -870,7 +863,7 @@ func callSaveWithResult(t *testing.T, cmd models.SaveDashboardCommand, sqlStore func callSaveWithError(cmd models.SaveDashboardCommand, sqlStore *sqlstore.SQLStore) error { dto := toSaveDashboardDto(cmd) dashboardStore := database.ProvideDashboardStore(sqlStore) - _, err := ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), &dto, false) + _, err := ProvideDashboardService(dashboardStore, &dummyDashAlertExtractor{}).SaveDashboard(context.Background(), &dto, false) return err } @@ -897,7 +890,7 @@ func saveTestDashboard(t *testing.T, title string, orgID, folderID int64, sqlSto } dashboardStore := database.ProvideDashboardStore(sqlStore) - res, err := ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), &dto, false) + res, err := ProvideDashboardService(dashboardStore, &dummyDashAlertExtractor{}).SaveDashboard(context.Background(), &dto, false) require.NoError(t, err) return res @@ -925,7 +918,7 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore *sqlstore. } dashboardStore := database.ProvideDashboardStore(sqlStore) - res, err := ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), &dto, false) + res, err := ProvideDashboardService(dashboardStore, &dummyDashAlertExtractor{}).SaveDashboard(context.Background(), &dto, false) require.NoError(t, err) return res @@ -942,3 +935,14 @@ func toSaveDashboardDto(cmd models.SaveDashboardCommand) dashbboardservice.SaveD Overwrite: cmd.Overwrite, } } + +type dummyDashAlertExtractor struct { +} + +func (d *dummyDashAlertExtractor) GetAlerts(ctx context.Context, dashAlertInfo alerting.DashAlertInfo) ([]*models.Alert, error) { + return nil, nil +} + +func (d *dummyDashAlertExtractor) ValidateAlerts(ctx context.Context, dashAlertInfo alerting.DashAlertInfo) error { + return nil +} diff --git a/pkg/services/dashboards/manager/dashboard_service_test.go b/pkg/services/dashboards/manager/dashboard_service_test.go index 1fb721e2e9f..dba479c8160 100644 --- a/pkg/services/dashboards/manager/dashboard_service_test.go +++ b/pkg/services/dashboards/manager/dashboard_service_test.go @@ -27,8 +27,9 @@ func TestDashboardService(t *testing.T) { fakeStore := database.FakeDashboardStore{} defer fakeStore.AssertExpectations(t) service := &DashboardServiceImpl{ - log: log.New("test.logger"), - dashboardStore: &fakeStore, + log: log.New("test.logger"), + dashboardStore: &fakeStore, + dashAlertExtractor: &dummyDashAlertExtractor{}, } origNewDashboardGuardian := guardian.New diff --git a/pkg/services/dashboards/manager/folder_service_test.go b/pkg/services/dashboards/manager/folder_service_test.go index 962e0fe88de..9b199959694 100644 --- a/pkg/services/dashboards/manager/folder_service_test.go +++ b/pkg/services/dashboards/manager/folder_service_test.go @@ -25,7 +25,7 @@ func TestFolderService(t *testing.T) { store := &database.FakeDashboardStore{} defer store.AssertExpectations(t) service := ProvideFolderService( - &dashboards.FakeDashboardService{DashboardService: ProvideDashboardService(store)}, + &dashboards.FakeDashboardService{DashboardService: ProvideDashboardService(store, nil)}, store, nil, ) diff --git a/pkg/api/datasource_permissions.go b/pkg/services/datasources/permissions/datasource_permissions.go similarity index 83% rename from pkg/api/datasource_permissions.go rename to pkg/services/datasources/permissions/datasource_permissions.go index 5035b8da6c9..906ef83713d 100644 --- a/pkg/api/datasource_permissions.go +++ b/pkg/services/datasources/permissions/datasource_permissions.go @@ -1,18 +1,21 @@ -package api +package permissions import ( "context" + "errors" "github.com/grafana/grafana/pkg/models" ) +var ErrNotImplemented = errors.New("not implemented") + type DatasourcePermissionsService interface { FilterDatasourcesBasedOnQueryPermissions(ctx context.Context, cmd *models.DatasourcesPermissionFilterQuery) error } // dummy method func (hs *OSSDatasourcePermissionsService) FilterDatasourcesBasedOnQueryPermissions(ctx context.Context, cmd *models.DatasourcesPermissionFilterQuery) error { - return nil + return ErrNotImplemented } type OSSDatasourcePermissionsService struct{} diff --git a/pkg/api/datasource_permissions_mocks.go b/pkg/services/datasources/permissions/datasource_permissions_mocks.go similarity index 65% rename from pkg/api/datasource_permissions_mocks.go rename to pkg/services/datasources/permissions/datasource_permissions_mocks.go index 1fd921fa6ac..69896c8398f 100644 --- a/pkg/api/datasource_permissions_mocks.go +++ b/pkg/services/datasources/permissions/datasource_permissions_mocks.go @@ -1,4 +1,4 @@ -package api +package permissions import ( "context" @@ -7,14 +7,15 @@ import ( ) type mockDatasourcePermissionService struct { - dsResult []*models.DataSource + DsResult []*models.DataSource + ErrResult error } func (m *mockDatasourcePermissionService) FilterDatasourcesBasedOnQueryPermissions(ctx context.Context, cmd *models.DatasourcesPermissionFilterQuery) error { - cmd.Result = m.dsResult - return nil + cmd.Result = m.DsResult + return m.ErrResult } -func newMockDatasourcePermissionService() *mockDatasourcePermissionService { +func NewMockDatasourcePermissionService() *mockDatasourcePermissionService { return &mockDatasourcePermissionService{} } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 8aba04eda6a..1d02d449c7e 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -132,6 +132,11 @@ var ( State: FeatureStateAlpha, RequiresDevMode: true, }, + { + Name: "lokiLive", + Description: "support websocket streaming for loki (early prototype)", + State: FeatureStateAlpha, + }, { Name: "swaggerUi", Description: "Serves swagger UI", @@ -157,5 +162,11 @@ var ( Description: "Lock database during migrations", State: FeatureStateBeta, }, + { + Name: "fileStoreApi", + Description: "Simple API for managing files", + State: FeatureStateAlpha, + RequiresDevMode: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 70f7b58c3eb..51aeb9bec63 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -99,6 +99,10 @@ const ( // only execute the query saved in a panel FlagValidatedQueries = "validatedQueries" + // FlagLokiLive + // support websocket streaming for loki (early prototype) + FlagLokiLive = "lokiLive" + // FlagSwaggerUi // Serves swagger UI FlagSwaggerUi = "swaggerUi" @@ -118,4 +122,8 @@ const ( // FlagMigrationLocking // Lock database during migrations FlagMigrationLocking = "migrationLocking" + + // FlagFileStoreApi + // Simple API for managing files + FlagFileStoreApi = "fileStoreApi" ) diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 8c1df027581..273b90ad2d8 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/database" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager" @@ -195,7 +196,8 @@ func createDashboard(t *testing.T, sqlStore *sqlstore.SQLStore, user models.Sign } dashboardStore := database.ProvideDashboardStore(sqlStore) - dashboard, err := dashboardservice.ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), dashItem, true) + dashAlertExtractor := alerting.ProvideDashAlertExtractorService(nil, nil) + dashboard, err := dashboardservice.ProvideDashboardService(dashboardStore, dashAlertExtractor).SaveDashboard(context.Background(), dashItem, true) require.NoError(t, err) return dashboard @@ -206,7 +208,7 @@ func createFolderWithACL(t *testing.T, sqlStore *sqlstore.SQLStore, title string t.Helper() dashboardStore := database.ProvideDashboardStore(sqlStore) - d := dashboardservice.ProvideDashboardService(dashboardStore) + d := dashboardservice.ProvideDashboardService(dashboardStore, nil) s := dashboardservice.ProvideFolderService(d, dashboardStore, nil) t.Logf("Creating folder with title and UID %q", title) folder, err := s.CreateFolder(context.Background(), &user, user.OrgId, title, title) @@ -292,7 +294,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo role := models.ROLE_ADMIN sqlStore := sqlstore.InitTestDB(t) dashboardStore := database.ProvideDashboardStore(sqlStore) - dashboardService := dashboardservice.ProvideDashboardService(dashboardStore) + dashboardService := dashboardservice.ProvideDashboardService(dashboardStore, &alerting.DashAlertExtractorService{}) service := LibraryElementService{ Cfg: setting.NewCfg(), SQLStore: sqlStore, diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 7a101269709..51c5e7e957c 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/database" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager" @@ -1416,7 +1417,8 @@ func createDashboard(t *testing.T, sqlStore *sqlstore.SQLStore, user *models.Sig } dashboadStore := database.ProvideDashboardStore(sqlStore) - dashboard, err := dashboardservice.ProvideDashboardService(dashboadStore).SaveDashboard(context.Background(), dashItem, true) + dashAlertService := alerting.ProvideDashAlertExtractorService(nil, nil) + dashboard, err := dashboardservice.ProvideDashboardService(dashboadStore, dashAlertService).SaveDashboard(context.Background(), dashItem, true) require.NoError(t, err) return dashboard @@ -1427,7 +1429,7 @@ func createFolderWithACL(t *testing.T, sqlStore *sqlstore.SQLStore, title string t.Helper() dashboardStore := database.ProvideDashboardStore(sqlStore) - d := dashboardservice.ProvideDashboardService(dashboardStore) + d := dashboardservice.ProvideDashboardService(dashboardStore, nil) s := dashboardservice.ProvideFolderService(d, dashboardStore, nil) t.Logf("Creating folder with title and UID %q", title) folder, err := s.CreateFolder(context.Background(), user, user.OrgId, title, title) @@ -1516,7 +1518,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo role := models.ROLE_ADMIN sqlStore := sqlstore.InitTestDB(t) dashboardStore := database.ProvideDashboardStore(sqlStore) - folderService := dashboardservice.ProvideFolderService(dashboardservice.ProvideDashboardService(dashboardStore), dashboardStore, nil) + folderService := dashboardservice.ProvideFolderService(dashboardservice.ProvideDashboardService(dashboardStore, &alerting.DashAlertExtractorService{}), dashboardStore, nil) elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService) service := LibraryPanelService{ diff --git a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go index 5707b4d01f2..451c5456fee 100644 --- a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go +++ b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go @@ -354,6 +354,7 @@ const ( type ExecutionErrorState string const ( + OkErrState ExecutionErrorState = "OK" AlertingErrState ExecutionErrorState = "Alerting" ErrorErrState ExecutionErrorState = "Error" ) diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 641089a1746..9a875b709ea 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -874,11 +874,12 @@ }, "exec_err_state": { "enum": [ + "OK", "Alerting", "Error" ], "type": "string", - "x-go-enum-desc": "Alerting AlertingErrState\nError ErrorErrState", + "x-go-enum-desc": "OK OkErrState\nAlerting AlertingErrState\nError ErrorErrState", "x-go-name": "ExecErrState" }, "id": { @@ -1836,11 +1837,12 @@ }, "exec_err_state": { "enum": [ + "OK", "Alerting", "Error" ], "type": "string", - "x-go-enum-desc": "Alerting AlertingErrState\nError ErrorErrState", + "x-go-enum-desc": "OK OkErrState\nAlerting AlertingErrState\nError ErrorErrState", "x-go-name": "ExecErrState" }, "no_data_state": { diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 811d0e52854..b99f7224990 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -2627,10 +2627,11 @@ "exec_err_state": { "type": "string", "enum": [ + "OK", "Alerting", "Error" ], - "x-go-enum-desc": "Alerting AlertingErrState\nError ErrorErrState", + "x-go-enum-desc": "OK OkErrState\nAlerting AlertingErrState\nError ErrorErrState", "x-go-name": "ExecErrState" }, "id": { @@ -3590,10 +3591,11 @@ "exec_err_state": { "type": "string", "enum": [ + "OK", "Alerting", "Error" ], - "x-go-enum-desc": "Alerting AlertingErrState\nError ErrorErrState", + "x-go-enum-desc": "OK OkErrState\nAlerting AlertingErrState\nError ErrorErrState", "x-go-name": "ExecErrState" }, "no_data_state": { diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 4e59f55c6d6..3216961469c 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -1,9 +1,15 @@ package models import ( + "encoding/json" "errors" "fmt" "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/grafana/grafana/pkg/util/cmputil" ) var ( @@ -104,6 +110,24 @@ type AlertRule struct { Labels map[string]string } +// Diff calculates diff between two alert rules. Returns nil if two rules are equal. Otherwise, returns cmputil.DiffReport +func (alertRule *AlertRule) Diff(rule *AlertRule, ignore ...string) cmputil.DiffReport { + var reporter cmputil.DiffReporter + ops := make([]cmp.Option, 0, 4) + + // json.RawMessage is a slice of bytes and therefore cmp's default behavior is to compare it by byte, which is not really useful + var jsonCmp = cmp.Transformer("", func(in json.RawMessage) string { + return string(in) + }) + ops = append(ops, cmp.Reporter(&reporter), cmpopts.IgnoreFields(AlertQuery{}, "modelProps"), jsonCmp) + + if len(ignore) > 0 { + ops = append(ops, cmpopts.IgnoreFields(AlertRule{}, ignore...)) + } + cmp.Equal(alertRule, rule, ops...) + return reporter.Diffs +} + // AlertRuleKey is the alert definition identifier type AlertRuleKey struct { OrgID int64 diff --git a/pkg/services/ngalert/models/alert_rule_test.go b/pkg/services/ngalert/models/alert_rule_test.go index a5106fb7063..37681c06bed 100644 --- a/pkg/services/ngalert/models/alert_rule_test.go +++ b/pkg/services/ngalert/models/alert_rule_test.go @@ -1,12 +1,14 @@ package models import ( + "encoding/json" "math/rand" "strings" "testing" "time" "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/util" @@ -225,3 +227,294 @@ func TestPatchPartialAlertRule(t *testing.T) { } }) } + +func TestDiff(t *testing.T) { + t.Run("should return nil if there is no diff", func(t *testing.T) { + rule1 := AlertRuleGen()() + rule2 := CopyRule(rule1) + result := rule1.Diff(rule2) + require.Emptyf(t, result, "expected diff to be empty. rule1: %#v, rule2: %#v\ndiff: %s", rule1, rule2, result) + }) + + t.Run("should respect fields to ignore", func(t *testing.T) { + rule1 := AlertRuleGen()() + rule2 := CopyRule(rule1) + rule2.ID = rule1.ID/2 + 1 + rule2.Version = rule1.Version/2 + 1 + rule2.Updated = rule1.Updated.Add(1 * time.Second) + result := rule1.Diff(rule2, "ID", "Version", "Updated") + require.Emptyf(t, result, "expected diff to be empty. rule1: %#v, rule2: %#v\ndiff: %s", rule1, rule2, result) + }) + + t.Run("should find diff in simple fields", func(t *testing.T) { + rule1 := AlertRuleGen()() + rule2 := AlertRuleGen()() + + diffs := rule1.Diff(rule2, "Data", "Annotations", "Labels") // these fields will be tested separately + + difCnt := 0 + if rule1.ID != rule2.ID { + diff := diffs.GetDiffsForField("ID") + assert.Len(t, diff, 1) + assert.Equal(t, rule1.ID, diff[0].Left.Int()) + assert.Equal(t, rule2.ID, diff[0].Right.Int()) + difCnt++ + } + if rule1.OrgID != rule2.OrgID { + diff := diffs.GetDiffsForField("OrgID") + assert.Len(t, diff, 1) + assert.Equal(t, rule1.OrgID, diff[0].Left.Int()) + assert.Equal(t, rule2.OrgID, diff[0].Right.Int()) + difCnt++ + } + if rule1.Title != rule2.Title { + diff := diffs.GetDiffsForField("Title") + assert.Len(t, diff, 1) + assert.Equal(t, rule1.Title, diff[0].Left.String()) + assert.Equal(t, rule2.Title, diff[0].Right.String()) + difCnt++ + } + if rule1.Condition != rule2.Condition { + diff := diffs.GetDiffsForField("Condition") + assert.Len(t, diff, 1) + assert.Equal(t, rule1.Condition, diff[0].Left.String()) + assert.Equal(t, rule2.Condition, diff[0].Right.String()) + difCnt++ + } + if rule1.Updated != rule2.Updated { + diff := diffs.GetDiffsForField("Updated") + assert.Len(t, diff, 1) + assert.Equal(t, rule1.Updated, diff[0].Left.Interface()) + assert.Equal(t, rule2.Updated, diff[0].Right.Interface()) + difCnt++ + } + if rule1.IntervalSeconds != rule2.IntervalSeconds { + diff := diffs.GetDiffsForField("IntervalSeconds") + assert.Len(t, diff, 1) + assert.Equal(t, rule1.IntervalSeconds, diff[0].Left.Int()) + assert.Equal(t, rule2.IntervalSeconds, diff[0].Right.Int()) + difCnt++ + } + if rule1.Version != rule2.Version { + diff := diffs.GetDiffsForField("Version") + assert.Len(t, diff, 1) + assert.Equal(t, rule1.Version, diff[0].Left.Int()) + assert.Equal(t, rule2.Version, diff[0].Right.Int()) + difCnt++ + } + if rule1.UID != rule2.UID { + diff := diffs.GetDiffsForField("UID") + assert.Len(t, diff, 1) + assert.Equal(t, rule1.UID, diff[0].Left.String()) + assert.Equal(t, rule2.UID, diff[0].Right.String()) + difCnt++ + } + if rule1.NamespaceUID != rule2.NamespaceUID { + diff := diffs.GetDiffsForField("NamespaceUID") + assert.Len(t, diff, 1) + assert.Equal(t, rule1.NamespaceUID, diff[0].Left.String()) + assert.Equal(t, rule2.NamespaceUID, diff[0].Right.String()) + difCnt++ + } + if rule1.DashboardUID != rule2.DashboardUID { + diff := diffs.GetDiffsForField("DashboardUID") + assert.Len(t, diff, 1) + difCnt++ + } + if rule1.PanelID != rule2.PanelID { + diff := diffs.GetDiffsForField("PanelID") + assert.Len(t, diff, 1) + difCnt++ + } + if rule1.RuleGroup != rule2.RuleGroup { + diff := diffs.GetDiffsForField("RuleGroup") + assert.Len(t, diff, 1) + assert.Equal(t, rule1.RuleGroup, diff[0].Left.String()) + assert.Equal(t, rule2.RuleGroup, diff[0].Right.String()) + difCnt++ + } + if rule1.NoDataState != rule2.NoDataState { + diff := diffs.GetDiffsForField("NoDataState") + assert.Len(t, diff, 1) + assert.Equal(t, rule1.NoDataState, diff[0].Left.Interface()) + assert.Equal(t, rule2.NoDataState, diff[0].Right.Interface()) + difCnt++ + } + if rule1.ExecErrState != rule2.ExecErrState { + diff := diffs.GetDiffsForField("ExecErrState") + assert.Len(t, diff, 1) + assert.Equal(t, rule1.ExecErrState, diff[0].Left.Interface()) + assert.Equal(t, rule2.ExecErrState, diff[0].Right.Interface()) + difCnt++ + } + if rule1.For != rule2.For { + diff := diffs.GetDiffsForField("For") + assert.Len(t, diff, 1) + assert.Equal(t, rule1.For, diff[0].Left.Interface()) + assert.Equal(t, rule2.For, diff[0].Right.Interface()) + difCnt++ + } + + require.Lenf(t, diffs, difCnt, "Got some unexpected diffs. Either add to ignore or add assert to it") + + if t.Failed() { + t.Logf("rule1: %#v, rule2: %#v\ndiff: %s", rule1, rule2, diffs) + } + }) + + t.Run("should detect changes in Annotations", func(t *testing.T) { + rule1 := AlertRuleGen()() + rule2 := CopyRule(rule1) + + rule1.Annotations = map[string]string{ + "key1": "value1", + "key2": "value2", + } + + rule2.Annotations = map[string]string{ + "key2": "value22", + "key3": "value3", + } + diff := rule1.Diff(rule2) + + assert.Len(t, diff, 3) + + d := diff.GetDiffsForField("Annotations[key1]") + assert.Len(t, d, 1) + assert.Equal(t, "value1", d[0].Left.String()) + assert.False(t, d[0].Right.IsValid()) + + d = diff.GetDiffsForField("Annotations[key2]") + assert.Len(t, d, 1) + assert.Equal(t, "value2", d[0].Left.String()) + assert.Equal(t, "value22", d[0].Right.String()) + + d = diff.GetDiffsForField("Annotations[key3]") + assert.Len(t, d, 1) + assert.False(t, d[0].Left.IsValid()) + assert.Equal(t, "value3", d[0].Right.String()) + + if t.Failed() { + t.Logf("rule1: %#v, rule2: %#v\ndiff: %v", rule1, rule2, diff) + } + }) + + t.Run("should detect changes in Labels", func(t *testing.T) { + rule1 := AlertRuleGen()() + rule2 := CopyRule(rule1) + + rule1.Labels = map[string]string{ + "key1": "value1", + "key2": "value2", + } + + rule2.Labels = map[string]string{ + "key2": "value22", + "key3": "value3", + } + diff := rule1.Diff(rule2) + + assert.Len(t, diff, 3) + + d := diff.GetDiffsForField("Labels[key1]") + assert.Len(t, d, 1) + assert.Equal(t, "value1", d[0].Left.String()) + assert.False(t, d[0].Right.IsValid()) + + d = diff.GetDiffsForField("Labels[key2]") + assert.Len(t, d, 1) + assert.Equal(t, "value2", d[0].Left.String()) + assert.Equal(t, "value22", d[0].Right.String()) + + d = diff.GetDiffsForField("Labels[key3]") + assert.Len(t, d, 1) + assert.False(t, d[0].Left.IsValid()) + assert.Equal(t, "value3", d[0].Right.String()) + + if t.Failed() { + t.Logf("rule1: %#v, rule2: %#v\ndiff: %s", rule1, rule2, d) + } + }) + + t.Run("should detect changes in Data", func(t *testing.T) { + rule1 := AlertRuleGen()() + rule2 := CopyRule(rule1) + + query1 := AlertQuery{ + RefID: "A", + QueryType: util.GenerateShortUID(), + RelativeTimeRange: RelativeTimeRange{ + From: Duration(5 * time.Hour), + To: 0, + }, + DatasourceUID: util.GenerateShortUID(), + Model: json.RawMessage(`{ "test": "data"}`), + modelProps: map[string]interface{}{ + "test": 1, + }, + } + + rule1.Data = []AlertQuery{query1} + + t.Run("should ignore modelProps", func(t *testing.T) { + query2 := query1 + query2.modelProps = map[string]interface{}{ + "some": "other value", + } + rule2.Data = []AlertQuery{query2} + + diff := rule1.Diff(rule2) + + assert.Nil(t, diff) + + if t.Failed() { + t.Logf("rule1: %#v, rule2: %#v\ndiff: %v", rule1, rule2, diff) + } + }) + + t.Run("should detect changes inside the query", func(t *testing.T) { + query2 := query1 + query2.QueryType = "test" + query2.RefID = "test" + rule2.Data = []AlertQuery{query2} + + diff := rule1.Diff(rule2) + + assert.Len(t, diff, 2) + + d := diff.GetDiffsForField("Data[0].QueryType") + assert.Len(t, d, 1) + d = diff.GetDiffsForField("Data[0].RefID") + assert.Len(t, d, 1) + if t.Failed() { + t.Logf("rule1: %#v, rule2: %#v\ndiff: %v", rule1, rule2, diff) + } + }) + + t.Run("should detect new changes in array if too many fields changed", func(t *testing.T) { + query2 := query1 + query2.QueryType = "test" + query2.RefID = "test" + query2.DatasourceUID = "test" + query2.Model = json.RawMessage(`{ "test": "da2ta"}`) + + rule2.Data = []AlertQuery{query2} + + diff := rule1.Diff(rule2) + + assert.Len(t, diff, 2) + + for _, d := range diff { + assert.Equal(t, "Data", d.Path) + if d.Left.IsValid() { + assert.Equal(t, query1, d.Left.Interface()) + } else { + assert.Equal(t, query2, d.Right.Interface()) + } + } + if t.Failed() { + t.Logf("rule1: %#v, rule2: %#v\ndiff: %v", rule1, rule2, diff) + } + }) + }) +} diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go index 3428086fc6a..8c05872ba1d 100644 --- a/pkg/services/ngalert/models/testing.go +++ b/pkg/services/ngalert/models/testing.go @@ -2,6 +2,7 @@ package models import ( "encoding/json" + "fmt" "math/rand" "time" @@ -60,24 +61,11 @@ func AlertRuleGen(mutators ...func(*AlertRule)) func() *AlertRule { } rule := &AlertRule{ - ID: rand.Int63(), - OrgID: rand.Int63(), - Title: "TEST-ALERT-" + util.GenerateShortUID(), - Condition: "A", - Data: []AlertQuery{ - { - DatasourceUID: "-100", - Model: json.RawMessage(`{ - "datasourceUid": "-100", - "type":"math", - "expression":"2 + 1 < 1" - }`), - RelativeTimeRange: RelativeTimeRange{ - From: Duration(5 * time.Hour), - To: Duration(3 * time.Hour), - }, - RefID: "A", - }}, + ID: rand.Int63(), + OrgID: rand.Int63(), + Title: "TEST-ALERT-" + util.GenerateShortUID(), + Condition: "A", + Data: []AlertQuery{GenerateAlertQuery()}, Updated: time.Now().Add(-time.Duration(rand.Intn(100) + 1)), IntervalSeconds: rand.Int63n(60) + 1, Version: rand.Int63(), @@ -100,6 +88,25 @@ func AlertRuleGen(mutators ...func(*AlertRule)) func() *AlertRule { } } +func GenerateAlertQuery() AlertQuery { + f := rand.Intn(10) + 5 + t := rand.Intn(f) + + return AlertQuery{ + DatasourceUID: util.GenerateShortUID(), + Model: json.RawMessage(fmt.Sprintf(`{ + "%s": "%s", + "%s":"%d" + }`, util.GenerateShortUID(), util.GenerateShortUID(), util.GenerateShortUID(), rand.Int())), + RelativeTimeRange: RelativeTimeRange{ + From: Duration(time.Duration(f) * time.Minute), + To: Duration(time.Duration(t) * time.Minute), + }, + RefID: util.GenerateShortUID(), + QueryType: util.GenerateShortUID(), + } +} + // GenerateUniqueAlertRules generates many random alert rules and makes sure that they have unique UID. // It returns a tuple where first element is a map where keys are UID of alert rule and the second element is a slice of the same rules func GenerateUniqueAlertRules(count int, f func() *AlertRule) (map[string]*AlertRule, []*AlertRule) { @@ -125,3 +132,59 @@ func GenerateAlertRules(count int, f func() *AlertRule) []*AlertRule { } return result } + +// CopyRule creates a deep copy of AlertRule +func CopyRule(r *AlertRule) *AlertRule { + result := AlertRule{ + ID: r.ID, + OrgID: r.OrgID, + Title: r.Title, + Condition: r.Condition, + Updated: r.Updated, + IntervalSeconds: r.IntervalSeconds, + Version: r.Version, + UID: r.UID, + NamespaceUID: r.NamespaceUID, + RuleGroup: r.RuleGroup, + NoDataState: r.NoDataState, + ExecErrState: r.ExecErrState, + For: r.For, + } + + if r.DashboardUID != nil { + dash := *r.DashboardUID + result.DashboardUID = &dash + } + if r.PanelID != nil { + p := *r.PanelID + result.PanelID = &p + } + + for _, d := range r.Data { + q := AlertQuery{ + RefID: d.RefID, + QueryType: d.QueryType, + RelativeTimeRange: d.RelativeTimeRange, + DatasourceUID: d.DatasourceUID, + } + q.Model = make([]byte, 0, cap(d.Model)) + q.Model = append(q.Model, d.Model...) + result.Data = append(result.Data, q) + } + + if r.Annotations != nil { + result.Annotations = make(map[string]string, len(r.Annotations)) + for s, s2 := range r.Annotations { + result.Annotations[s] = s2 + } + } + + if r.Labels != nil { + result.Labels = make(map[string]string, len(r.Labels)) + for s, s2 := range r.Labels { + result.Labels[s] = s2 + } + } + + return &result +} diff --git a/pkg/services/ngalert/notifier/available_channels.go b/pkg/services/ngalert/notifier/available_channels.go index 521548e53b5..65c52ff48e1 100644 --- a/pkg/services/ngalert/notifier/available_channels.go +++ b/pkg/services/ngalert/notifier/available_channels.go @@ -382,6 +382,8 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin { InputType: alerting.InputTypeText, Description: "Specify channel, private group, or IM channel (can be an encoded ID or a name) - required unless you provide a webhook", PropertyName: "recipient", + Required: true, + DependsOn: "secureSettings.url", }, // Logically, this field should be required when not using a webhook, since the Slack API needs a token. // However, since the UI doesn't allow to say that a field is required or not depending on another field, @@ -394,6 +396,8 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin { Description: "Provide a Slack API token (starts with \"xoxb\") - required unless you provide a webhook", PropertyName: "token", Secure: true, + Required: true, + DependsOn: "secureSettings.url", }, { Label: "Username", @@ -458,6 +462,8 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin { Placeholder: "Slack incoming webhook URL", PropertyName: "url", Secure: true, + Required: true, + DependsOn: "secureSettings.token", }, { // New in 8.4. Label: "Endpoint URL", @@ -597,7 +603,7 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin { }, { Type: "webhook", - Name: "webhook", + Name: "Webhook", Description: "Sends HTTP POST request to a URL", Heading: "Webhook settings", Options: []alerting.NotifierOption{ diff --git a/pkg/services/ngalert/notifier/channels/default_template_test.go b/pkg/services/ngalert/notifier/channels/default_template_test.go index 4e61ac59015..e2eee8533e0 100644 --- a/pkg/services/ngalert/notifier/channels/default_template_test.go +++ b/pkg/services/ngalert/notifier/channels/default_template_test.go @@ -99,7 +99,7 @@ Labels: Annotations: - ann1 = annv1 Source: http://localhost/alert1 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1 Dashboard: http://localhost/grafana/d/dbuid123 Panel: http://localhost/grafana/d/dbuid123?viewPanel=puid123 @@ -110,7 +110,7 @@ Labels: Annotations: - ann1 = annv2 Source: http://localhost/alert2 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2 **Resolved** @@ -122,7 +122,7 @@ Labels: Annotations: - ann1 = annv3 Source: http://localhost/alert3 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval3 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval3 Dashboard: http://localhost/grafana/d/dbuid456 Panel: http://localhost/grafana/d/dbuid456?viewPanel=puid456 @@ -133,7 +133,7 @@ Labels: Annotations: - ann1 = annv4 Source: http://localhost/alert4 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval4 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval4 `, }, { @@ -150,7 +150,7 @@ Annotations: Source: http://localhost/alert1 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1 Dashboard: http://localhost/grafana/d/dbuid123 @@ -168,7 +168,7 @@ Annotations: Source: http://localhost/alert2 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2 @@ -185,7 +185,7 @@ Annotations: Source: http://localhost/alert3 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval3 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval3 Dashboard: http://localhost/grafana/d/dbuid456 @@ -203,7 +203,7 @@ Annotations: Source: http://localhost/alert4 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval4 +Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval4 `, diff --git a/pkg/services/ngalert/notifier/channels/dingding_test.go b/pkg/services/ngalert/notifier/channels/dingding_test.go index a868eca502e..64df838fdcf 100644 --- a/pkg/services/ngalert/notifier/channels/dingding_test.go +++ b/pkg/services/ngalert/notifier/channels/dingding_test.go @@ -44,7 +44,7 @@ func TestDingdingNotifier(t *testing.T) { "msgtype": "link", "link": map[string]interface{}{ "messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist", - "text": "**Firing**\n\nValue: 1234\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "text": "**Firing**\n\nValue: 1234\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\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "title": "[FIRING:1] (val1)", }, }, diff --git a/pkg/services/ngalert/notifier/channels/discord_test.go b/pkg/services/ngalert/notifier/channels/discord_test.go index 51a89589e64..95454a1f79e 100644 --- a/pkg/services/ngalert/notifier/channels/discord_test.go +++ b/pkg/services/ngalert/notifier/channels/discord_test.go @@ -43,7 +43,7 @@ func TestDiscordNotifier(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "content": "**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\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "embeds": []interface{}{map[string]interface{}{ "color": 1.4037554e+07, "footer": map[string]interface{}{ @@ -123,7 +123,7 @@ func TestDiscordNotifier(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "content": "**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\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "embeds": []interface{}{map[string]interface{}{ "color": 1.4037554e+07, "footer": map[string]interface{}{ diff --git a/pkg/services/ngalert/notifier/channels/email_test.go b/pkg/services/ngalert/notifier/channels/email_test.go index 42a738ba196..9cd438cfd66 100644 --- a/pkg/services/ngalert/notifier/channels/email_test.go +++ b/pkg/services/ngalert/notifier/channels/email_test.go @@ -10,7 +10,10 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/notifications" + "github.com/grafana/grafana/pkg/setting" ) func TestEmailNotifier(t *testing.T) { @@ -87,7 +90,7 @@ func TestEmailNotifier(t *testing.T) { Labels: template.KV{"alertname": "AlwaysFiring", "severity": "warning"}, Annotations: template.KV{"runbook_url": "http://fix.me"}, Fingerprint: "15a37193dce72bab", - SilenceURL: "http://localhost/base/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DAlwaysFiring%2Cseverity%3Dwarning", + SilenceURL: "http://localhost/base/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DAlwaysFiring&matcher=severity%3Dwarning", DashboardURL: "http://localhost/base/d/abc", PanelURL: "http://localhost/base/d/abc?viewPanel=5", }, @@ -102,3 +105,202 @@ func TestEmailNotifier(t *testing.T) { }, expected) }) } + +func TestEmailNotifierIntegration(t *testing.T) { + ns := createCoreEmailService(t) + + emailTmpl := templateForTests(t) + externalURL, err := url.Parse("http://localhost/base") + require.NoError(t, err) + emailTmpl.ExternalURL = externalURL + + cases := []struct { + name string + alerts []*types.Alert + messageTmpl string + expSubject string + expSnippets []string + }{ + { + name: "single alert with templated message", + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "AlwaysFiring", "severity": "warning"}, + Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"}, + }, + }, + }, + messageTmpl: `Hi, this is a custom template. + {{ if gt (len .Alerts.Firing) 0 }} + You have {{ len .Alerts.Firing }} alerts firing. + {{ range .Alerts.Firing }} Firing: {{ .Labels.alertname }} at {{ .Labels.severity }} {{ end }} + {{ end }}`, + expSubject: "[FIRING:1] (AlwaysFiring warning)", + expSnippets: []string{ + "Hi, this is a custom template.", + "You have 1 alerts firing.", + "Firing: AlwaysFiring at warning", + }, + }, + { + name: "multiple alerts with templated message", + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "FiringOne", "severity": "warning"}, + Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"}, + }, + }, + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "FiringTwo", "severity": "critical"}, + Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"}, + }, + }, + }, + messageTmpl: `Hi, this is a custom template. + {{ if gt (len .Alerts.Firing) 0 }} + You have {{ len .Alerts.Firing }} alerts firing. + {{ range .Alerts.Firing }} Firing: {{ .Labels.alertname }} at {{ .Labels.severity }} {{ end }} + {{ end }}`, + expSubject: "[FIRING:2] ", + expSnippets: []string{ + "Hi, this is a custom template.", + "You have 2 alerts firing.", + "Firing: FiringOne at warning", + "Firing: FiringTwo at critical", + }, + }, + { + name: "empty message with alerts uses default template content", + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "FiringOne", "severity": "warning"}, + Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"}, + }, + }, + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "FiringTwo", "severity": "critical"}, + Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"}, + }, + }, + }, + messageTmpl: "", + expSubject: "[FIRING:2] ", + expSnippets: []string{ + "Firing: 2 alerts", + "
  • alertname: FiringOne
  • severity: warning
  • ", + "
  • alertname: FiringTwo
  • severity: critical
  • ", + "
    Hi, this is a custom template. + {{ if gt (len .Alerts.Firing) 0 }} +
      + {{range .Alerts.Firing }}
    1. Firing: {{ .Labels.alertname }} at {{ .Labels.severity }}
    2. {{ end }} +
    + {{ end }}`, + expSubject: "[FIRING:1] (AlwaysFiring warning)", + expSnippets: []string{ + "<marquee>Hi, this is a custom template.</marquee>", + "<li>Firing: AlwaysFiring at warning </li>", + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + emailNotifier := createSut(t, c.messageTmpl, emailTmpl, ns) + + ok, err := emailNotifier.Notify(context.Background(), c.alerts...) + require.NoError(t, err) + require.True(t, ok) + + sentMsg := getSingleSentMessage(t, ns) + + require.NotNil(t, sentMsg) + + require.Equal(t, "\"Grafana Admin\" ", sentMsg.From) + require.Equal(t, sentMsg.To[0], "someops@example.com") + + require.Equal(t, c.expSubject, sentMsg.Subject) + + require.Contains(t, sentMsg.Body, "text/html") + html := sentMsg.Body["text/html"] + require.NotNil(t, html) + + for _, s := range c.expSnippets { + require.Contains(t, html, s) + } + }) + } +} + +func createCoreEmailService(t *testing.T) *notifications.NotificationService { + t.Helper() + + bus := bus.New() + cfg := setting.NewCfg() + cfg.StaticRootPath = "../../../../../public/" + cfg.BuildVersion = "4.0.0" + cfg.Smtp.Enabled = true + cfg.Smtp.TemplatesPatterns = []string{"emails/*.html", "emails/*.txt"} + cfg.Smtp.FromAddress = "from@address.com" + cfg.Smtp.FromName = "Grafana Admin" + cfg.Smtp.ContentTypes = []string{"text/html", "text/plain"} + cfg.Smtp.Host = "localhost:1234" + mailer := notifications.NewFakeMailer() + + ns, err := notifications.ProvideService(bus, cfg, mailer) + require.NoError(t, err) + + return ns +} + +func createSut(t *testing.T, messageTmpl string, emailTmpl *template.Template, ns notifications.EmailSender) *EmailNotifier { + t.Helper() + + json := `{ + "addresses": "someops@example.com;somedev@example.com", + "singleEmail": true + }` + settingsJSON, err := simplejson.NewJson([]byte(json)) + if messageTmpl != "" { + settingsJSON.Set("message", messageTmpl) + } + require.NoError(t, err) + + emailNotifier, err := NewEmailNotifier(&NotificationChannelConfig{ + Name: "ops", + Type: "email", + Settings: settingsJSON, + }, ns, emailTmpl) + require.NoError(t, err) + + return emailNotifier +} + +func getSingleSentMessage(t *testing.T, ns *notifications.NotificationService) *notifications.Message { + t.Helper() + + mailer := ns.GetMailer().(*notifications.FakeMailer) + require.Len(t, mailer.Sent, 1) + sent := mailer.Sent[0] + mailer.Sent = []*notifications.Message{} + return sent +} diff --git a/pkg/services/ngalert/notifier/channels/googlechat_test.go b/pkg/services/ngalert/notifier/channels/googlechat_test.go index 15a70034dfe..527d77be16f 100644 --- a/pkg/services/ngalert/notifier/channels/googlechat_test.go +++ b/pkg/services/ngalert/notifier/channels/googlechat_test.go @@ -58,7 +58,7 @@ func TestGoogleChatNotifier(t *testing.T) { Widgets: []widget{ textParagraphWidget{ Text: text{ - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + Text: "**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\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", }, }, buttonWidget{ @@ -117,7 +117,7 @@ func TestGoogleChatNotifier(t *testing.T) { Widgets: []widget{ textParagraphWidget{ Text: text{ - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + Text: "**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", }, }, buttonWidget{ diff --git a/pkg/services/ngalert/notifier/channels/kafka_test.go b/pkg/services/ngalert/notifier/channels/kafka_test.go index 9e30bfebdd1..34bf944e592 100644 --- a/pkg/services/ngalert/notifier/channels/kafka_test.go +++ b/pkg/services/ngalert/notifier/channels/kafka_test.go @@ -51,7 +51,7 @@ func TestKafkaNotifier(t *testing.T) { "client": "Grafana", "client_url": "http://localhost/alerting/list", "description": "[FIRING:1] (val1)", - "details": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "details": "**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\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "incident_key": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733" } } @@ -86,7 +86,7 @@ func TestKafkaNotifier(t *testing.T) { "client": "Grafana", "client_url": "http://localhost/alerting/list", "description": "[FIRING:2] ", - "details": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + "details": "**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", "incident_key": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733" } } diff --git a/pkg/services/ngalert/notifier/channels/line_test.go b/pkg/services/ngalert/notifier/channels/line_test.go index 7d6a50cfd81..467e6bf3621 100644 --- a/pkg/services/ngalert/notifier/channels/line_test.go +++ b/pkg/services/ngalert/notifier/channels/line_test.go @@ -46,7 +46,7 @@ func TestLineNotifier(t *testing.T) { "Authorization": "Bearer sometoken", "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", }, - expMsg: "message=%5BFIRING%3A1%5D++%28val1%29%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A", + expMsg: "message=%5BFIRING%3A1%5D++%28val1%29%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A", expMsgError: nil, }, { name: "Multiple alerts", @@ -68,7 +68,7 @@ func TestLineNotifier(t *testing.T) { "Authorization": "Bearer sometoken", "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", }, - expMsg: "message=%5BFIRING%3A2%5D++%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval2%0A", + expMsg: "message=%5BFIRING%3A2%5D++%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval1%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval2%0A", expMsgError: nil, }, { name: "Token missing", diff --git a/pkg/services/ngalert/notifier/channels/opsgenie_test.go b/pkg/services/ngalert/notifier/channels/opsgenie_test.go index c45ad66ff6b..5dbb8db481a 100644 --- a/pkg/services/ngalert/notifier/channels/opsgenie_test.go +++ b/pkg/services/ngalert/notifier/channels/opsgenie_test.go @@ -44,7 +44,7 @@ func TestOpsgenieNotifier(t *testing.T) { }, expMsg: `{ "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**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\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "details": { "url": "http://localhost/alerting/list" }, @@ -69,7 +69,7 @@ func TestOpsgenieNotifier(t *testing.T) { }, expMsg: `{ "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n", + "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**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", "details": { "url": "http://localhost/alerting/list" }, @@ -94,7 +94,7 @@ func TestOpsgenieNotifier(t *testing.T) { }, expMsg: `{ "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n", + "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**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", "details": { "alertname": "alert1", "lbl1": "val1", @@ -126,7 +126,7 @@ func TestOpsgenieNotifier(t *testing.T) { }, expMsg: `{ "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:2] \nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + "description": "[FIRING:2] \nhttp://localhost/alerting/list\n\n**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 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", "details": { "alertname": "alert1", "url": "http://localhost/alerting/list" diff --git a/pkg/services/ngalert/notifier/channels/pagerduty_test.go b/pkg/services/ngalert/notifier/channels/pagerduty_test.go index 3703b83910f..44e1e3bffc6 100644 --- a/pkg/services/ngalert/notifier/channels/pagerduty_test.go +++ b/pkg/services/ngalert/notifier/channels/pagerduty_test.go @@ -59,7 +59,7 @@ func TestPagerdutyNotifier(t *testing.T) { Component: "Grafana", Group: "default", CustomDetails: map[string]string{ - "firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "firing": "\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\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "num_firing": "1", "num_resolved": "0", "resolved": "", @@ -105,7 +105,7 @@ func TestPagerdutyNotifier(t *testing.T) { Component: "My Grafana", Group: "my_group", CustomDetails: map[string]string{ - "firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + "firing": "\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", "num_firing": "2", "num_resolved": "0", "resolved": "", diff --git a/pkg/services/ngalert/notifier/channels/pushover_test.go b/pkg/services/ngalert/notifier/channels/pushover_test.go index 2b81a43079d..b9543720ff2 100644 --- a/pkg/services/ngalert/notifier/channels/pushover_test.go +++ b/pkg/services/ngalert/notifier/channels/pushover_test.go @@ -59,7 +59,7 @@ func TestPushoverNotifier(t *testing.T) { "title": "[FIRING:1] (val1)", "url": "http://localhost/alerting/list", "url_title": "Show alert rule", - "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&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "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\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "html": "1", }, expMsgError: nil, diff --git a/pkg/services/ngalert/notifier/channels/sensugo_test.go b/pkg/services/ngalert/notifier/channels/sensugo_test.go index 2277f09aed5..271265c1d30 100644 --- a/pkg/services/ngalert/notifier/channels/sensugo_test.go +++ b/pkg/services/ngalert/notifier/channels/sensugo_test.go @@ -60,7 +60,7 @@ func TestSensuGoNotifier(t *testing.T) { "ruleURL": "http://localhost/alerting/list", }, }, - "output": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "output": "**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\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "issued": timeNow().Unix(), "interval": 86400, "status": 2, diff --git a/pkg/services/ngalert/notifier/channels/slack_test.go b/pkg/services/ngalert/notifier/channels/slack_test.go index c30b87bf64b..62419671619 100644 --- a/pkg/services/ngalert/notifier/channels/slack_test.go +++ b/pkg/services/ngalert/notifier/channels/slack_test.go @@ -59,7 +59,7 @@ func TestSlackNotifier(t *testing.T) { { Title: "[FIRING:1] (val1)", TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + Text: "**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\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", Fallback: "[FIRING:1] (val1)", Fields: nil, Footer: "Grafana v" + setting.BuildVersion, @@ -94,7 +94,7 @@ func TestSlackNotifier(t *testing.T) { { Title: "[FIRING:1] (val1)", TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n", + Text: "**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", Fallback: "[FIRING:1] (val1)", Fields: nil, Footer: "Grafana v" + setting.BuildVersion, @@ -136,7 +136,7 @@ func TestSlackNotifier(t *testing.T) { { Title: "2 firing, 0 resolved", TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + Text: "**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", Fallback: "2 firing, 0 resolved", Fields: nil, Footer: "Grafana v" + setting.BuildVersion, @@ -184,7 +184,7 @@ func TestSlackNotifier(t *testing.T) { { Title: "[FIRING:1] (val1)", TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n", + Text: "**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", Fallback: "[FIRING:1] (val1)", Fields: nil, Footer: "Grafana v" + setting.BuildVersion, diff --git a/pkg/services/ngalert/notifier/channels/teams_test.go b/pkg/services/ngalert/notifier/channels/teams_test.go index 92bf8e15fdd..7f743f6fe91 100644 --- a/pkg/services/ngalert/notifier/channels/teams_test.go +++ b/pkg/services/ngalert/notifier/channels/teams_test.go @@ -49,7 +49,7 @@ func TestTeamsNotifier(t *testing.T) { "sections": []map[string]interface{}{ { "title": "Details", - "text": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "text": "**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\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", }, }, "potentialAction": []map[string]interface{}{ diff --git a/pkg/services/ngalert/notifier/channels/telegram_test.go b/pkg/services/ngalert/notifier/channels/telegram_test.go index 1e7d5f24a20..c3d7c9ba921 100644 --- a/pkg/services/ngalert/notifier/channels/telegram_test.go +++ b/pkg/services/ngalert/notifier/channels/telegram_test.go @@ -48,7 +48,7 @@ func TestTelegramNotifier(t *testing.T) { expMsg: map[string]string{ "chat_id": "someid", "parse_mode": "html", - "text": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "text": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", }, expMsgError: nil, }, { @@ -75,7 +75,7 @@ func TestTelegramNotifier(t *testing.T) { expMsg: map[string]string{ "chat_id": "someid", "parse_mode": "html", - "text": "__Custom Firing__\n2 Firing\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + "text": "__Custom Firing__\n2 Firing\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\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, }, { diff --git a/pkg/services/ngalert/notifier/channels/template_data.go b/pkg/services/ngalert/notifier/channels/template_data.go index 1533866b90a..10cd9c6bc47 100644 --- a/pkg/services/ngalert/notifier/channels/template_data.go +++ b/pkg/services/ngalert/notifier/channels/template_data.go @@ -99,7 +99,15 @@ func extendAlert(alert template.Alert, externalURL string, logger log.Logger) *E } sort.Strings(matchers) u.Path = path.Join(externalPath, "/alerting/silence/new") - u.RawQuery = "alertmanager=grafana&matchers=" + url.QueryEscape(strings.Join(matchers, ",")) + + query := make(url.Values) + query.Add("alertmanager", "grafana") + for _, matcher := range matchers { + query.Add("matcher", matcher) + } + + u.RawQuery = query.Encode() + extended.SilenceURL = u.String() return extended diff --git a/pkg/services/ngalert/notifier/channels/threema_test.go b/pkg/services/ngalert/notifier/channels/threema_test.go index 01e4f530411..47364e677f2 100644 --- a/pkg/services/ngalert/notifier/channels/threema_test.go +++ b/pkg/services/ngalert/notifier/channels/threema_test.go @@ -45,7 +45,7 @@ func TestThreemaNotifier(t *testing.T) { }, }, }, - expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A1%5D++%28val1%29%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A&to=87654321", + expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A1%5D++%28val1%29%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A&to=87654321", expMsgError: nil, }, { name: "Multiple alerts", @@ -67,7 +67,7 @@ func TestThreemaNotifier(t *testing.T) { }, }, }, - expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A2%5D++%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval2%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A&to=87654321", + expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A2%5D++%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval1%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval2%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A&to=87654321", expMsgError: nil, }, { name: "Invalid gateway id", diff --git a/pkg/services/ngalert/notifier/channels/victorops_test.go b/pkg/services/ngalert/notifier/channels/victorops_test.go index 2db53055964..3f11e25429b 100644 --- a/pkg/services/ngalert/notifier/channels/victorops_test.go +++ b/pkg/services/ngalert/notifier/channels/victorops_test.go @@ -47,7 +47,7 @@ func TestVictoropsNotifier(t *testing.T) { "entity_id": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", "message_type": "CRITICAL", "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&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "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\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", }, expMsgError: nil, }, { @@ -72,7 +72,7 @@ func TestVictoropsNotifier(t *testing.T) { "entity_id": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", "message_type": "CRITICAL", "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&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + "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, }, { diff --git a/pkg/services/ngalert/notifier/channels/webhook_test.go b/pkg/services/ngalert/notifier/channels/webhook_test.go index e043abc4757..97de78fe33c 100644 --- a/pkg/services/ngalert/notifier/channels/webhook_test.go +++ b/pkg/services/ngalert/notifier/channels/webhook_test.go @@ -68,7 +68,7 @@ func TestWebhookNotifier(t *testing.T) { Fingerprint: "fac0861a85de433a", DashboardURL: "http://localhost/d/abcd", PanelURL: "http://localhost/d/abcd?viewPanel=efgh", - SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1", + SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1", }, }, GroupLabels: template.KV{ @@ -87,7 +87,7 @@ func TestWebhookNotifier(t *testing.T) { GroupKey: "alertname", Title: "[FIRING:1] (val1)", State: "alerting", - 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&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + 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\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", OrgID: orgID, }, expMsgError: nil, @@ -137,7 +137,7 @@ func TestWebhookNotifier(t *testing.T) { "ann1": "annv1", }, Fingerprint: "fac0861a85de433a", - SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1", + SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1", }, { Status: "firing", Labels: template.KV{ @@ -148,7 +148,7 @@ func TestWebhookNotifier(t *testing.T) { "ann1": "annv2", }, Fingerprint: "fab6861a85d5eeb5", - SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2", + SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2", }, }, GroupLabels: template.KV{ @@ -165,7 +165,7 @@ func TestWebhookNotifier(t *testing.T) { TruncatedAlerts: 1, Title: "[FIRING:2] ", State: "alerting", - 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&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + 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", OrgID: orgID, }, expMsgError: nil, diff --git a/pkg/services/ngalert/notifier/channels/wecom_test.go b/pkg/services/ngalert/notifier/channels/wecom_test.go index 46676e9b656..77b045107b9 100644 --- a/pkg/services/ngalert/notifier/channels/wecom_test.go +++ b/pkg/services/ngalert/notifier/channels/wecom_test.go @@ -45,7 +45,7 @@ func TestWeComNotifier(t *testing.T) { }, expMsg: map[string]interface{}{ "markdown": map[string]interface{}{ - "content": "# [FIRING:1] (val1)\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n\n", + "content": "# [FIRING:1] (val1)\n**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\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n\n", }, "msgtype": "markdown", }, diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go index dd72ee0db9f..7d607778021 100644 --- a/pkg/services/ngalert/schedule/schedule.go +++ b/pkg/services/ngalert/schedule/schedule.go @@ -61,7 +61,7 @@ type schedule struct { clock clock.Clock - heartbeat *alerting.Ticker + ticker *alerting.Ticker // evalApplied is only used for tests: test code can set it to non-nil // function, and then it'll be called from the event loop whenever the @@ -130,7 +130,7 @@ func NewScheduler(cfg SchedulerCfg, expressionService *expr.Service, appURL *url clock: cfg.C, baseInterval: cfg.BaseInterval, log: cfg.Logger, - heartbeat: ticker, + ticker: ticker, evalAppliedFunc: cfg.EvalAppliedFunc, stopAppliedFunc: cfg.StopAppliedFunc, evaluator: cfg.Evaluator, @@ -157,7 +157,7 @@ func (sch *schedule) Pause() error { if sch == nil { return fmt.Errorf("scheduler is not initialised") } - sch.heartbeat.Pause() + sch.ticker.Pause() sch.log.Info("alert rule scheduler paused", "now", sch.clock.Now()) return nil } @@ -166,7 +166,7 @@ func (sch *schedule) Unpause() error { if sch == nil { return fmt.Errorf("scheduler is not initialised") } - sch.heartbeat.Unpause() + sch.ticker.Unpause() sch.log.Info("alert rule scheduler unpaused", "now", sch.clock.Now()) return nil } @@ -367,8 +367,12 @@ func (sch *schedule) schedulePeriodic(ctx context.Context) error { dispatcherGroup, ctx := errgroup.WithContext(ctx) for { select { - case tick := <-sch.heartbeat.C: - start := time.Now() + case tick := <-sch.ticker.C: + // We use Round(0) on the start time to remove the monotonic clock. + // This is required as ticks from the ticker and time.Now() can have + // a monotonic clock that when subtracted do not represent the delta + // in wall clock time. + start := time.Now().Round(0) sch.metrics.BehindSeconds.Set(start.Sub(tick).Seconds()) tickNum := tick.Unix() / int64(sch.baseInterval.Seconds()) @@ -468,7 +472,7 @@ func (sch *schedule) schedulePeriodic(ctx context.Context) error { } } -func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key models.AlertRuleKey, evalCh <-chan *evalContext, updateCh <-chan struct{}) error { +func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key models.AlertRuleKey, evalCh <-chan *evaluation, updateCh <-chan struct{}) error { logger := sch.log.New("uid", key.UID, "org", key.OrgID) logger.Debug("alert rule routine started") @@ -541,16 +545,16 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key models.AlertRul return q.Result, nil } - evaluate := func(ctx context.Context, alertRule *models.AlertRule, attempt int64, evalCtx *evalContext) error { - logger := logger.New("version", alertRule.Version, "attempt", attempt, "now", evalCtx.now) + evaluate := func(ctx context.Context, r *models.AlertRule, attempt int64, e *evaluation) error { + logger := logger.New("version", r.Version, "attempt", attempt, "now", e.scheduledAt) start := sch.clock.Now() condition := models.Condition{ - Condition: alertRule.Condition, - OrgID: alertRule.OrgID, - Data: alertRule.Data, + Condition: r.Condition, + OrgID: r.OrgID, + Data: r.Data, } - results, err := sch.evaluator.ConditionEval(&condition, evalCtx.now, sch.expressionService) + results, err := sch.evaluator.ConditionEval(&condition, e.scheduledAt, sch.expressionService) dur := sch.clock.Now().Sub(start) evalTotal.Inc() evalDuration.Observe(dur.Seconds()) @@ -562,7 +566,7 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key models.AlertRul } logger.Debug("alert rule evaluated", "results", results, "duration", dur) - processedStates := sch.stateManager.ProcessEvalResults(ctx, alertRule, results) + processedStates := sch.stateManager.ProcessEvalResults(ctx, r, results) sch.saveAlertStates(ctx, processedStates) alerts := FromAlertStateToPostableAlerts(processedStates, sch.stateManager, sch.appURL) @@ -616,7 +620,7 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key models.AlertRul evalRunning = true defer func() { evalRunning = false - sch.evalApplied(key, ctx.now) + sch.evalApplied(key, ctx.scheduledAt) }() err := retryIfError(func(attempt int64) error { @@ -741,7 +745,7 @@ func (r *alertRuleRegistry) keyMap() map[models.AlertRuleKey]struct{} { } type alertRuleInfo struct { - evalCh chan *evalContext + evalCh chan *evaluation updateCh chan struct{} ctx context.Context stop context.CancelFunc @@ -749,15 +753,15 @@ type alertRuleInfo struct { func newAlertRuleInfo(parent context.Context) *alertRuleInfo { ctx, cancel := context.WithCancel(parent) - return &alertRuleInfo{evalCh: make(chan *evalContext), updateCh: make(chan struct{}), ctx: ctx, stop: cancel} + return &alertRuleInfo{evalCh: make(chan *evaluation), updateCh: make(chan struct{}), ctx: ctx, stop: cancel} } // eval signals the rule evaluation routine to perform the evaluation of the rule. Does nothing if the loop is stopped func (a *alertRuleInfo) eval(t time.Time, version int64) bool { select { - case a.evalCh <- &evalContext{ - now: t, - version: version, + case a.evalCh <- &evaluation{ + scheduledAt: t, + version: version, }: return true case <-a.ctx.Done(): @@ -775,16 +779,16 @@ func (a *alertRuleInfo) update() bool { } } -type evalContext struct { - now time.Time - version int64 +type evaluation struct { + scheduledAt time.Time + version int64 } // overrideCfg is only used on tests. func (sch *schedule) overrideCfg(cfg SchedulerCfg) { sch.clock = cfg.C sch.baseInterval = cfg.BaseInterval - sch.heartbeat = alerting.NewTicker(cfg.C.Now(), time.Second*0, cfg.C, int64(cfg.BaseInterval.Seconds())) + sch.ticker = alerting.NewTicker(cfg.C.Now(), time.Second*0, cfg.C, int64(cfg.BaseInterval.Seconds())) sch.evalAppliedFunc = cfg.EvalAppliedFunc sch.stopAppliedFunc = cfg.StopAppliedFunc } diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go index 8b18a939e97..6d23f950ca8 100644 --- a/pkg/services/ngalert/schedule/schedule_unit_test.go +++ b/pkg/services/ngalert/schedule/schedule_unit_test.go @@ -362,7 +362,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { for _, evalState := range normalStates { // TODO rewrite when we are able to mock/fake state manager t.Run(fmt.Sprintf("when rule evaluation happens (evaluation state %s)", evalState), func(t *testing.T) { - evalChan := make(chan *evalContext) + evalChan := make(chan *evaluation) evalAppliedChan := make(chan time.Time) sch, ruleStore, instanceStore, _, reg := createSchedule(evalAppliedChan) @@ -376,9 +376,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { expectedTime := time.UnixMicro(rand.Int63()) - evalChan <- &evalContext{ - now: expectedTime, - version: rule.Version, + evalChan <- &evaluation{ + scheduledAt: expectedTime, + version: rule.Version, } actualTime := waitForTimeChannel(t, evalAppliedChan) @@ -468,7 +468,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) go func() { - err := sch.ruleRoutine(ctx, models.AlertRuleKey{}, make(chan *evalContext), make(chan struct{})) + err := sch.ruleRoutine(ctx, models.AlertRuleKey{}, make(chan *evaluation), make(chan struct{})) stoppedChan <- err }() @@ -479,7 +479,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { }) t.Run("should fetch rule from database only if new version is greater than current", func(t *testing.T) { - evalChan := make(chan *evalContext) + evalChan := make(chan *evaluation) evalAppliedChan := make(chan time.Time) ctx := context.Background() @@ -494,9 +494,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { }() expectedTime := time.UnixMicro(rand.Int63()) - evalChan <- &evalContext{ - now: expectedTime, - version: rule.Version, + evalChan <- &evaluation{ + scheduledAt: expectedTime, + version: rule.Version, } actualTime := waitForTimeChannel(t, evalAppliedChan) @@ -509,9 +509,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { // and call with new version expectedTime = expectedTime.Add(time.Duration(rand.Intn(10)) * time.Second) - evalChan <- &evalContext{ - now: expectedTime, - version: newRule.Version, + evalChan <- &evaluation{ + scheduledAt: expectedTime, + version: newRule.Version, } actualTime = waitForTimeChannel(t, evalAppliedChan) @@ -532,7 +532,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { }) t.Run("should not fetch rule if version is equal or less than current", func(t *testing.T) { - evalChan := make(chan *evalContext) + evalChan := make(chan *evaluation) evalAppliedChan := make(chan time.Time) sch, ruleStore, _, _, _ := createSchedule(evalAppliedChan) @@ -546,9 +546,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { }() expectedTime := time.UnixMicro(rand.Int63()) - evalChan <- &evalContext{ - now: expectedTime, - version: rule.Version, + evalChan <- &evaluation{ + scheduledAt: expectedTime, + version: rule.Version, } actualTime := waitForTimeChannel(t, evalAppliedChan) @@ -556,17 +556,17 @@ func TestSchedule_ruleRoutine(t *testing.T) { // try again with the same version expectedTime = expectedTime.Add(time.Duration(rand.Intn(10)) * time.Second) - evalChan <- &evalContext{ - now: expectedTime, - version: rule.Version, + evalChan <- &evaluation{ + scheduledAt: expectedTime, + version: rule.Version, } actualTime = waitForTimeChannel(t, evalAppliedChan) require.Equal(t, expectedTime, actualTime) expectedTime = expectedTime.Add(time.Duration(rand.Intn(10)) * time.Second) - evalChan <- &evalContext{ - now: expectedTime, - version: rule.Version - 1, + evalChan <- &evaluation{ + scheduledAt: expectedTime, + version: rule.Version - 1, } actualTime = waitForTimeChannel(t, evalAppliedChan) require.Equal(t, expectedTime, actualTime) @@ -583,7 +583,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { t.Run("when update channel is not empty", func(t *testing.T) { t.Run("should fetch the alert rule from database", func(t *testing.T) { - evalChan := make(chan *evalContext) + evalChan := make(chan *evaluation) evalAppliedChan := make(chan time.Time) updateChan := make(chan struct{}) @@ -613,9 +613,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { require.Equal(t, rule.OrgID, m.OrgID) // now call evaluation loop to make sure that the rule was persisted - evalChan <- &evalContext{ - now: time.UnixMicro(rand.Int63()), - version: rule.Version, + evalChan <- &evaluation{ + scheduledAt: time.UnixMicro(rand.Int63()), + version: rule.Version, } waitForTimeChannel(t, evalAppliedChan) @@ -638,7 +638,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { go func() { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - _ = sch.ruleRoutine(ctx, rule.GetKey(), make(chan *evalContext), updateChan) + _ = sch.ruleRoutine(ctx, rule.GetKey(), make(chan *evaluation), updateChan) }() ruleStore.Hook = func(cmd interface{}) error { @@ -678,7 +678,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { return len(s.Alertmanagers()) == 1 }, 20*time.Second, 200*time.Millisecond, "external Alertmanager was not discovered.") - evalChan := make(chan *evalContext) + evalChan := make(chan *evaluation) evalAppliedChan := make(chan time.Time) updateChan := make(chan struct{}) @@ -784,7 +784,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { return len(s.Alertmanagers()) == 1 }, 20*time.Second, 200*time.Millisecond, "external Alertmanager was not discovered.") - evalChan := make(chan *evalContext) + evalChan := make(chan *evaluation) evalAppliedChan := make(chan time.Time) sch, ruleStore, _, _, _ := createSchedule(evalAppliedChan) @@ -798,9 +798,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan, make(chan struct{})) }() - evalChan <- &evalContext{ - now: time.Now(), - version: rule.Version, + evalChan <- &evaluation{ + scheduledAt: time.Now(), + version: rule.Version, } waitForTimeChannel(t, evalAppliedChan) @@ -844,7 +844,7 @@ func TestSchedule_alertRuleInfo(t *testing.T) { select { case ctx := <-r.evalCh: require.Equal(t, version, ctx.version) - require.Equal(t, expected, ctx.now) + require.Equal(t, expected, ctx.scheduledAt) require.True(t, <-resultCh) case <-time.After(5 * time.Second): t.Fatal("No message was received on eval channel") diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index 6695cf16a94..edc26ce2399 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -5,18 +5,19 @@ import ( "fmt" "net/url" "strconv" + "strings" "time" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/annotations" - "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/infra/log" - + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/sqlstore" ) var ResendDelay = 30 * time.Second @@ -188,7 +189,7 @@ func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRu st.set(currentState) if oldState != currentState.State { - go st.createAlertAnnotation(ctx, currentState.State, alertRule, result, oldState) + go st.annotateState(ctx, alertRule, currentState.Labels, result.EvaluatedAt, currentState.State, oldState) } return currentState } @@ -236,18 +237,19 @@ func translateInstanceState(state ngModels.InstanceStateType) eval.State { } } -func (st *Manager) createAlertAnnotation(ctx context.Context, new eval.State, alertRule *ngModels.AlertRule, result eval.Result, oldState eval.State) { - st.log.Debug("alert state changed creating annotation", "alertRuleUID", alertRule.UID, "newState", new.String(), "oldState", oldState.String()) +func (st *Manager) annotateState(ctx context.Context, alertRule *ngModels.AlertRule, labels data.Labels, evaluatedAt time.Time, state eval.State, previousState eval.State) { + st.log.Debug("alert state changed creating annotation", "alertRuleUID", alertRule.UID, "newState", state.String(), "oldState", previousState.String()) - annotationText := fmt.Sprintf("%s {%s} - %s", alertRule.Title, result.Instance.String(), new.String()) + labels = removePrivateLabels(labels) + annotationText := fmt.Sprintf("%s {%s} - %s", alertRule.Title, labels.String(), state.String()) item := &annotations.Item{ AlertId: alertRule.ID, OrgId: alertRule.OrgID, - PrevState: oldState.String(), - NewState: new.String(), + PrevState: previousState.String(), + NewState: state.String(), Text: annotationText, - Epoch: result.EvaluatedAt.UnixNano() / int64(time.Millisecond), + Epoch: evaluatedAt.UnixNano() / int64(time.Millisecond), } dashUid, ok := alertRule.Annotations[ngModels.DashboardUIDAnnotation] @@ -298,6 +300,10 @@ func (st *Manager) staleResultsHandler(ctx context.Context, alertRule *ngModels. if err = st.instanceStore.DeleteAlertInstance(ctx, s.OrgID, s.AlertRuleUID, labelsHash); err != nil { st.log.Error("unable to delete stale instance from database", "error", err.Error(), "orgID", s.OrgID, "alertRuleUID", s.AlertRuleUID, "cacheID", s.CacheId) } + + if s.State == eval.Alerting { + st.annotateState(ctx, alertRule, s.Labels, time.Now(), eval.Normal, s.State) + } } } } @@ -305,3 +311,13 @@ func (st *Manager) staleResultsHandler(ctx context.Context, alertRule *ngModels. func isItStale(lastEval time.Time, intervalSeconds int64) bool { return lastEval.Add(2 * time.Duration(intervalSeconds) * time.Second).Before(time.Now()) } + +func removePrivateLabels(labels data.Labels) data.Labels { + result := make(data.Labels) + for k, v := range labels { + if !strings.HasPrefix(k, "__") && !strings.HasSuffix(k, "__") { + result[k] = v + } + } + return result +} diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index 02374646775..8fcc8cf943b 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sort" "testing" "time" @@ -28,6 +29,53 @@ import ( var testMetrics = metrics.NewNGAlert(prometheus.NewPedanticRegistry()) +func TestDashboardAnnotations(t *testing.T) { + evaluationTime, err := time.Parse("2006-01-02", "2022-01-01") + require.NoError(t, err) + + ctx := context.Background() + _, dbstore := tests.SetupTestEnv(t, 1) + + sqlStore := mockstore.NewSQLStoreMock() + st := state.NewManager(log.New("test_stale_results_handler"), testMetrics.GetStateMetrics(), nil, dbstore, dbstore, sqlStore) + + fakeAnnoRepo := store.NewFakeAnnotationsRepo() + annotations.SetRepository(fakeAnnoRepo) + + const mainOrgID int64 = 1 + + rule := tests.CreateTestAlertRuleWithLabels(t, ctx, dbstore, 600, mainOrgID, map[string]string{ + "test1": "testValue1", + "test2": "{{ $labels.instance_label }}", + }) + + st.Warm(ctx) + _ = st.ProcessEvalResults(ctx, rule, eval.Results{{ + Instance: data.Labels{"instance_label": "testValue2"}, + State: eval.Alerting, + EvaluatedAt: evaluationTime, + }}) + + expected := []string{rule.Title + " {alertname=" + rule.Title + ", instance_label=testValue2, test1=testValue1, test2=testValue2} - Alerting"} + sort.Strings(expected) + require.Eventuallyf(t, func() bool { + var actual []string + for _, next := range fakeAnnoRepo.Items { + actual = append(actual, next.Text) + } + sort.Strings(actual) + if len(expected) != len(actual) { + return false + } + for i := 0; i < len(expected); i++ { + if expected[i] != actual[i] { + return false + } + } + return true + }, time.Second, 100*time.Millisecond, "unexpected annotations") +} + func TestProcessEvalResults(t *testing.T) { evaluationTime, err := time.Parse("2006-01-02", "2021-03-25") if err != nil { @@ -504,7 +552,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, - expectedAnnotations: 2, + expectedAnnotations: 3, 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", @@ -530,7 +578,7 @@ func TestProcessEvalResults(t *testing.T) { Values: make(map[string]*float64), }, }, - StartsAt: evaluationTime, + StartsAt: evaluationTime.Add(20 * time.Second), EndsAt: evaluationTime.Add(30 * time.Second).Add(state.ResendDelay * 3), LastEvaluationTime: evaluationTime.Add(30 * time.Second), EvaluationDuration: evaluationDuration, diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index 2947f789761..6b06eb1a453 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -64,7 +64,7 @@ func (a *State) resultAlerting(alertRule *ngModels.AlertRule, result eval.Result case eval.Alerting: a.setEndsAt(alertRule, result) case eval.Pending: - if result.EvaluatedAt.Sub(a.StartsAt) > alertRule.For { + if result.EvaluatedAt.Sub(a.StartsAt) >= alertRule.For { a.State = eval.Alerting a.StartsAt = result.EvaluatedAt a.setEndsAt(alertRule, result) diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index d8ab0e11a85..826fe6e1f64 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -41,7 +41,7 @@ func SetupTestEnv(t *testing.T, baseInterval time.Duration) (*ngalert.AlertNG, * sqlStore := sqlstore.InitTestDB(t) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) dashboardStore := databasestore.ProvideDashboardStore(sqlStore) - folderService := dashboardservice.ProvideFolderService(dashboardservice.ProvideDashboardService(dashboardStore), dashboardStore, nil) + folderService := dashboardservice.ProvideFolderService(dashboardservice.ProvideDashboardService(dashboardStore, nil), dashboardStore, nil) ng, err := ngalert.ProvideService( cfg, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, nil, secretsService, nil, m, folderService, @@ -56,6 +56,10 @@ func SetupTestEnv(t *testing.T, baseInterval time.Duration) (*ngalert.AlertNG, * // CreateTestAlertRule creates a dummy alert definition to be used by the tests. func CreateTestAlertRule(t *testing.T, ctx context.Context, dbstore *store.DBstore, intervalSeconds int64, orgID int64) *models.AlertRule { + return CreateTestAlertRuleWithLabels(t, ctx, dbstore, intervalSeconds, orgID, nil) +} + +func CreateTestAlertRuleWithLabels(t *testing.T, ctx context.Context, dbstore *store.DBstore, intervalSeconds int64, orgID int64, labels map[string]string) *models.AlertRule { ruleGroup := fmt.Sprintf("ruleGroup-%s", util.GenerateShortUID()) err := dbstore.UpsertAlertRules(ctx, []store.UpsertRule{ { @@ -78,6 +82,7 @@ func CreateTestAlertRule(t *testing.T, ctx context.Context, dbstore *store.DBsto RefID: "A", }, }, + Labels: labels, Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, IntervalSeconds: intervalSeconds, NamespaceUID: "namespace", diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go index 3ed26249234..9d13c0404c3 100644 --- a/pkg/services/notifications/notifications.go +++ b/pkg/services/notifications/notifications.go @@ -118,6 +118,10 @@ func (ns *NotificationService) Run(ctx context.Context) error { } } +func (ns *NotificationService) GetMailer() Mailer { + return ns.mailer +} + func (ns *NotificationService) SendWebhookSync(ctx context.Context, cmd *models.SendWebhookSync) error { return ns.sendWebRequestSync(ctx, &Webhook{ Url: cmd.Url, diff --git a/pkg/services/plugindashboards/plugindashboards.go b/pkg/services/plugindashboards/plugindashboards.go index 3f468c8c1e0..28fb7b1dffb 100644 --- a/pkg/services/plugindashboards/plugindashboards.go +++ b/pkg/services/plugindashboards/plugindashboards.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/dashboardimport" + "github.com/grafana/grafana/pkg/services/pluginsettings" "github.com/grafana/grafana/pkg/services/sqlstore" ) @@ -17,14 +18,16 @@ type pluginSettingsStore interface { } func ProvideService(sqlStore *sqlstore.SQLStore, bus bus.Bus, pluginStore plugins.Store, - pluginDashboardManager plugins.PluginDashboardManager, dashboardImportService dashboardimport.Service) *Service { - s := new(sqlStore, bus, pluginStore, pluginDashboardManager, dashboardImportService) + pluginDashboardManager plugins.PluginDashboardManager, dashboardImportService dashboardimport.Service, + pluginSettingsStore *pluginsettings.ServiceImpl) *Service { + s := new(sqlStore, bus, pluginStore, pluginDashboardManager, dashboardImportService, pluginSettingsStore) s.updateAppDashboards() return s } func new(pluginSettingsStore pluginSettingsStore, bus bus.Bus, pluginStore plugins.Store, - pluginDashboardManager plugins.PluginDashboardManager, dashboardImportService dashboardimport.Service) *Service { + pluginDashboardManager plugins.PluginDashboardManager, dashboardImportService dashboardimport.Service, + pluginSettings pluginsettings.Service) *Service { s := &Service{ pluginSettingsStore: pluginSettingsStore, bus: bus, @@ -32,6 +35,7 @@ func new(pluginSettingsStore pluginSettingsStore, bus bus.Bus, pluginStore plugi pluginDashboardManager: pluginDashboardManager, dashboardImportService: dashboardImportService, logger: log.New("plugindashboards"), + pluginSettings: pluginSettings, } bus.AddEventListener(s.handlePluginStateChanged) @@ -45,6 +49,7 @@ type Service struct { pluginDashboardManager plugins.PluginDashboardManager dashboardImportService dashboardimport.Service logger log.Logger + pluginSettings pluginsettings.Service } func (s *Service) updateAppDashboards() { @@ -106,7 +111,7 @@ func (s *Service) syncPluginDashboards(ctx context.Context, plugin plugins.Plugi // update version in plugin_setting table to mark that we have processed the update query := models.GetPluginSettingByIdQuery{PluginId: plugin.ID, OrgId: orgID} - if err := s.bus.Dispatch(ctx, &query); err != nil { + if err := s.pluginSettings.GetPluginSettingById(ctx, &query); err != nil { s.logger.Error("Failed to read plugin setting by ID", "error", err) return } @@ -118,7 +123,7 @@ func (s *Service) syncPluginDashboards(ctx context.Context, plugin plugins.Plugi PluginVersion: plugin.Info.Version, } - if err := s.bus.Dispatch(ctx, &cmd); err != nil { + if err := s.pluginSettings.UpdatePluginSettingVersion(ctx, &cmd); err != nil { s.logger.Error("Failed to update plugin setting version", "error", err) } } diff --git a/pkg/services/plugindashboards/plugindashboards_test.go b/pkg/services/plugindashboards/plugindashboards_test.go index 204d1cb3959..4ba48d18f2a 100644 --- a/pkg/services/plugindashboards/plugindashboards_test.go +++ b/pkg/services/plugindashboards/plugindashboards_test.go @@ -190,15 +190,6 @@ func TestService(t *testing.T) { require.Equal(t, models.ROLE_ADMIN, ctx.importDashboardArgs[0].User.OrgRole) require.Equal(t, int64(0), ctx.importDashboardArgs[0].FolderId) require.True(t, ctx.importDashboardArgs[0].Overwrite) - - require.Len(t, ctx.getPluginSettingsByIdArgs, 1) - require.Equal(t, int64(2), ctx.getPluginSettingsByIdArgs[0].OrgId) - require.Equal(t, "test", ctx.getPluginSettingsByIdArgs[0].PluginId) - - require.Len(t, ctx.updatePluginSettingVersionArgs, 1) - require.Equal(t, int64(2), ctx.updatePluginSettingVersionArgs[0].OrgId) - require.Equal(t, "test", ctx.updatePluginSettingVersionArgs[0].PluginId) - require.Equal(t, "1.0.1", ctx.updatePluginSettingVersionArgs[0].PluginVersion) }) }) @@ -340,15 +331,6 @@ func TestService(t *testing.T) { require.Equal(t, models.ROLE_ADMIN, ctx.importDashboardArgs[2].User.OrgRole) require.Equal(t, int64(0), ctx.importDashboardArgs[2].FolderId) require.True(t, ctx.importDashboardArgs[2].Overwrite) - - require.Len(t, ctx.getPluginSettingsByIdArgs, 1) - require.Equal(t, int64(2), ctx.getPluginSettingsByIdArgs[0].OrgId) - require.Equal(t, "test", ctx.getPluginSettingsByIdArgs[0].PluginId) - - require.Len(t, ctx.updatePluginSettingVersionArgs, 1) - require.Equal(t, int64(2), ctx.updatePluginSettingVersionArgs[0].OrgId) - require.Equal(t, "test", ctx.updatePluginSettingVersionArgs[0].PluginId) - require.Equal(t, "1.0.0", ctx.updatePluginSettingVersionArgs[0].PluginVersion) }) } @@ -516,24 +498,13 @@ func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenar return nil }) - sCtx.bus.AddHandler(func(ctx context.Context, query *models.GetPluginSettingByIdQuery) error { - for _, p := range input.storedPluginSettings { - if p.PluginId == query.PluginId { - query.Result = &models.PluginSetting{ - PluginId: p.PluginId, - OrgId: p.OrgId, - } - } + mock := &mockPluginsSettingsService{} + for _, p := range input.storedPluginSettings { + mock.pluginSetting = &models.PluginSetting{ + PluginId: p.PluginId, + OrgId: p.OrgId, } - - sCtx.getPluginSettingsByIdArgs = append(sCtx.getPluginSettingsByIdArgs, query) - return nil - }) - - sCtx.bus.AddHandler(func(ctx context.Context, cmd *models.UpdatePluginSettingVersionCmd) error { - sCtx.updatePluginSettingVersionArgs = append(sCtx.updatePluginSettingVersionArgs, cmd) - return nil - }) + } sCtx.bus.AddHandler(func(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error { sCtx.getDashboardsByPluginIdQueryArgs = append(sCtx.getDashboardsByPluginIdQueryArgs, query) @@ -565,7 +536,7 @@ func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenar return nil }) - sCtx.s = new(sCtx.pluginSettingsStore, sCtx.bus, sCtx.pluginStore, sCtx.pluginDashboardManager, sCtx.importDashboardService) + sCtx.s = new(sCtx.pluginSettingsStore, sCtx.bus, sCtx.pluginStore, sCtx.pluginDashboardManager, sCtx.importDashboardService, mock) t.Cleanup(bus.ClearBusHandlers) @@ -573,3 +544,21 @@ func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenar f(sCtx) }) } + +type mockPluginsSettingsService struct { + pluginSetting *models.PluginSetting + err error +} + +func (s *mockPluginsSettingsService) GetPluginSettingById(ctx context.Context, query *models.GetPluginSettingByIdQuery) error { + query.Result = s.pluginSetting + return s.err +} + +func (s *mockPluginsSettingsService) UpdatePluginSettingVersion(ctx context.Context, cmd *models.UpdatePluginSettingVersionCmd) error { + return s.err +} + +func (s *mockPluginsSettingsService) UpdatePluginSetting(ctx context.Context, cmd *models.UpdatePluginSettingCmd) error { + return s.err +} diff --git a/pkg/services/pluginsettings/service.go b/pkg/services/pluginsettings/service.go index de85c726a13..032b0c99c8e 100644 --- a/pkg/services/pluginsettings/service.go +++ b/pkg/services/pluginsettings/service.go @@ -12,7 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" ) -type Service struct { +type ServiceImpl struct { Bus bus.Bus SQLStore *sqlstore.SQLStore SecretsService secrets.Service @@ -21,6 +21,12 @@ type Service struct { pluginSettingDecryptionCache secureJSONDecryptionCache } +type Service interface { + GetPluginSettingById(ctx context.Context, query *models.GetPluginSettingByIdQuery) error + UpdatePluginSetting(ctx context.Context, cmd *models.UpdatePluginSettingCmd) error + UpdatePluginSettingVersion(ctx context.Context, cmd *models.UpdatePluginSettingVersionCmd) error +} + type cachedDecryptedJSON struct { updated time.Time json map[string]string @@ -31,8 +37,8 @@ type secureJSONDecryptionCache struct { sync.Mutex } -func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, secretsService secrets.Service) *Service { - s := &Service{ +func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, secretsService secrets.Service) *ServiceImpl { + s := &ServiceImpl{ Bus: bus, SQLStore: store, SecretsService: secretsService, @@ -49,11 +55,11 @@ func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, secretsService secret return s } -func (s *Service) GetPluginSettingById(ctx context.Context, query *models.GetPluginSettingByIdQuery) error { +func (s *ServiceImpl) GetPluginSettingById(ctx context.Context, query *models.GetPluginSettingByIdQuery) error { return s.SQLStore.GetPluginSettingById(ctx, query) } -func (s *Service) UpdatePluginSetting(ctx context.Context, cmd *models.UpdatePluginSettingCmd) error { +func (s *ServiceImpl) UpdatePluginSetting(ctx context.Context, cmd *models.UpdatePluginSettingCmd) error { var err error cmd.EncryptedSecureJsonData, err = s.SecretsService.EncryptJsonData(ctx, cmd.SecureJsonData, secrets.WithoutScope()) if err != nil { @@ -63,11 +69,11 @@ func (s *Service) UpdatePluginSetting(ctx context.Context, cmd *models.UpdatePlu return s.SQLStore.UpdatePluginSetting(ctx, cmd) } -func (s *Service) UpdatePluginSettingVersion(ctx context.Context, cmd *models.UpdatePluginSettingVersionCmd) error { +func (s *ServiceImpl) UpdatePluginSettingVersion(ctx context.Context, cmd *models.UpdatePluginSettingVersionCmd) error { return s.SQLStore.UpdatePluginSettingVersion(ctx, cmd) } -func (s *Service) DecryptedValues(ps *models.PluginSetting) map[string]string { +func (s *ServiceImpl) DecryptedValues(ps *models.PluginSetting) map[string]string { s.pluginSettingDecryptionCache.Lock() defer s.pluginSettingDecryptionCache.Unlock() diff --git a/pkg/services/provisioning/notifiers/alert_notifications.go b/pkg/services/provisioning/notifiers/alert_notifications.go index 3195837c72b..aac68649cda 100644 --- a/pkg/services/provisioning/notifiers/alert_notifications.go +++ b/pkg/services/provisioning/notifiers/alert_notifications.go @@ -8,38 +8,50 @@ import ( "golang.org/x/net/context" ) -type Store interface { - GetOrgById(c context.Context, cmd *models.GetOrgByIdQuery) error - GetOrgByNameHandler(ctx context.Context, query *models.GetOrgByNameQuery) error +type Manager interface { + GetAlertNotifications(ctx context.Context, query *models.GetAlertNotificationsQuery) error + CreateAlertNotificationCommand(ctx context.Context, cmd *models.CreateAlertNotificationCommand) error + UpdateAlertNotification(ctx context.Context, cmd *models.UpdateAlertNotificationCommand) error + DeleteAlertNotification(ctx context.Context, cmd *models.DeleteAlertNotificationCommand) error + GetAllAlertNotifications(ctx context.Context, query *models.GetAllAlertNotificationsQuery) error + GetOrCreateAlertNotificationState(ctx context.Context, cmd *models.GetOrCreateNotificationStateQuery) error + SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToCompleteCommand) error + SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToPendingCommand) error GetAlertNotificationsWithUid(ctx context.Context, query *models.GetAlertNotificationsWithUidQuery) error DeleteAlertNotificationWithUid(ctx context.Context, cmd *models.DeleteAlertNotificationWithUidCommand) error - CreateAlertNotificationCommand(ctx context.Context, cmd *models.CreateAlertNotificationCommand) error + GetAlertNotificationsWithUidToSend(ctx context.Context, query *models.GetAlertNotificationsWithUidToSendQuery) error UpdateAlertNotificationWithUid(ctx context.Context, cmd *models.UpdateAlertNotificationWithUidCommand) error } +type SQLStore interface { + GetOrgById(c context.Context, cmd *models.GetOrgByIdQuery) error + GetOrgByNameHandler(ctx context.Context, query *models.GetOrgByNameQuery) error +} // Provision alert notifiers -func Provision(ctx context.Context, configDirectory string, store Store, encryptionService encryption.Internal, notificationService *notifications.NotificationService) error { - dc := newNotificationProvisioner(store, encryptionService, notificationService, log.New("provisioning.notifiers")) +func Provision(ctx context.Context, configDirectory string, alertingService Manager, sqlstore SQLStore, encryptionService encryption.Internal, notificationService *notifications.NotificationService) error { + dc := newNotificationProvisioner(sqlstore, alertingService, encryptionService, notificationService, log.New("provisioning.notifiers")) return dc.applyChanges(ctx, configDirectory) } // NotificationProvisioner is responsible for provsioning alert notifiers type NotificationProvisioner struct { - log log.Logger - cfgProvider *configReader - store Store + log log.Logger + cfgProvider *configReader + alertingManager Manager + sqlstore SQLStore } -func newNotificationProvisioner(store Store, encryptionService encryption.Internal, notifiationService *notifications.NotificationService, log log.Logger) NotificationProvisioner { +func newNotificationProvisioner(store SQLStore, alertingManager Manager, encryptionService encryption.Internal, notifiationService *notifications.NotificationService, log log.Logger) NotificationProvisioner { return NotificationProvisioner{ - log: log, - store: store, + log: log, + alertingManager: alertingManager, cfgProvider: &configReader{ encryptionService: encryptionService, notificationService: notifiationService, log: log, orgStore: store, }, + sqlstore: store, } } @@ -61,7 +73,7 @@ func (dc *NotificationProvisioner) deleteNotifications(ctx context.Context, noti if notification.OrgID == 0 && notification.OrgName != "" { getOrg := &models.GetOrgByNameQuery{Name: notification.OrgName} - if err := dc.store.GetOrgByNameHandler(ctx, getOrg); err != nil { + if err := dc.sqlstore.GetOrgByNameHandler(ctx, getOrg); err != nil { return err } notification.OrgID = getOrg.Result.Id @@ -71,13 +83,13 @@ func (dc *NotificationProvisioner) deleteNotifications(ctx context.Context, noti getNotification := &models.GetAlertNotificationsWithUidQuery{Uid: notification.UID, OrgId: notification.OrgID} - if err := dc.store.GetAlertNotificationsWithUid(ctx, getNotification); err != nil { + if err := dc.alertingManager.GetAlertNotificationsWithUid(ctx, getNotification); err != nil { return err } if getNotification.Result != nil { cmd := &models.DeleteAlertNotificationWithUidCommand{Uid: getNotification.Result.Uid, OrgId: getNotification.OrgId} - if err := dc.store.DeleteAlertNotificationWithUid(ctx, cmd); err != nil { + if err := dc.alertingManager.DeleteAlertNotificationWithUid(ctx, cmd); err != nil { return err } } @@ -90,7 +102,7 @@ func (dc *NotificationProvisioner) mergeNotifications(ctx context.Context, notif for _, notification := range notificationToMerge { if notification.OrgID == 0 && notification.OrgName != "" { getOrg := &models.GetOrgByNameQuery{Name: notification.OrgName} - if err := dc.store.GetOrgByNameHandler(ctx, getOrg); err != nil { + if err := dc.sqlstore.GetOrgByNameHandler(ctx, getOrg); err != nil { return err } notification.OrgID = getOrg.Result.Id @@ -99,7 +111,7 @@ func (dc *NotificationProvisioner) mergeNotifications(ctx context.Context, notif } cmd := &models.GetAlertNotificationsWithUidQuery{OrgId: notification.OrgID, Uid: notification.UID} - err := dc.store.GetAlertNotificationsWithUid(ctx, cmd) + err := dc.alertingManager.GetAlertNotificationsWithUid(ctx, cmd) if err != nil { return err } @@ -119,7 +131,7 @@ func (dc *NotificationProvisioner) mergeNotifications(ctx context.Context, notif SendReminder: notification.SendReminder, } - if err := dc.store.CreateAlertNotificationCommand(ctx, insertCmd); err != nil { + if err := dc.alertingManager.CreateAlertNotificationCommand(ctx, insertCmd); err != nil { return err } } else { @@ -137,7 +149,7 @@ func (dc *NotificationProvisioner) mergeNotifications(ctx context.Context, notif SendReminder: notification.SendReminder, } - if err := dc.store.UpdateAlertNotificationWithUid(ctx, updateCmd); err != nil { + if err := dc.alertingManager.UpdateAlertNotificationWithUid(ctx, updateCmd); err != nil { return err } } diff --git a/pkg/services/provisioning/notifiers/config_reader_test.go b/pkg/services/provisioning/notifiers/config_reader_test.go index 7e8a0dd760b..c8cae8b6886 100644 --- a/pkg/services/provisioning/notifiers/config_reader_test.go +++ b/pkg/services/provisioning/notifiers/config_reader_test.go @@ -6,7 +6,6 @@ import ( "os" "testing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" @@ -37,7 +36,6 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("Testing notification as configuration", func(t *testing.T) { setup := func() { sqlStore = sqlstore.InitTestDB(t) - setupBusHandlers(sqlStore) for i := 1; i < 5; i++ { orgCommand := models.CreateOrgCommand{Name: fmt.Sprintf("Main Org. %v", i)} @@ -140,17 +138,14 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("One configured notification", func(t *testing.T) { t.Run("no notification in database", func(t *testing.T) { setup() - dc := newNotificationProvisioner(sqlStore, ossencryption.ProvideService(), nil, logger) + fakeAlertNotification := &fakeAlertNotification{} + fakeAlertNotification.ExpectedAlertNotification = &models.AlertNotification{OrgId: 1} + dc := newNotificationProvisioner(sqlStore, fakeAlertNotification, ossencryption.ProvideService(), nil, logger) err := dc.applyChanges(context.Background(), twoNotificationsConfig) if err != nil { t.Fatalf("applyChanges return an error %v", err) } - notificationsQuery := models.GetAllAlertNotificationsQuery{OrgId: 1} - err = sqlStore.GetAllAlertNotifications(context.Background(), ¬ificationsQuery) - require.NoError(t, err) - require.NotNil(t, notificationsQuery.Result) - require.Equal(t, len(notificationsQuery.Result), 2) }) t.Run("One notification in database with same name and uid", func(t *testing.T) { @@ -171,42 +166,19 @@ func TestNotificationAsConfig(t *testing.T) { require.Equal(t, len(notificationsQuery.Result), 1) t.Run("should update one notification", func(t *testing.T) { - dc := newNotificationProvisioner(sqlStore, ossencryption.ProvideService(), nil, logger) + dc := newNotificationProvisioner(sqlStore, &fakeAlertNotification{}, ossencryption.ProvideService(), nil, logger) err = dc.applyChanges(context.Background(), twoNotificationsConfig) if err != nil { t.Fatalf("applyChanges return an error %v", err) } - err = sqlStore.GetAllAlertNotifications(context.Background(), ¬ificationsQuery) - require.NoError(t, err) - require.NotNil(t, notificationsQuery.Result) - require.Equal(t, len(notificationsQuery.Result), 2) - - nts := notificationsQuery.Result - nt1 := nts[0] - require.Equal(t, nt1.Type, "email") - require.Equal(t, nt1.Name, "channel1") - require.Equal(t, nt1.Uid, "notifier1") - - nt2 := nts[1] - require.Equal(t, nt2.Type, "slack") - require.Equal(t, nt2.Name, "channel2") - require.Equal(t, nt2.Uid, "notifier2") }) }) t.Run("Two notifications with is_default", func(t *testing.T) { setup() - dc := newNotificationProvisioner(sqlStore, ossencryption.ProvideService(), nil, logger) + dc := newNotificationProvisioner(sqlStore, &fakeAlertNotification{}, ossencryption.ProvideService(), nil, logger) err := dc.applyChanges(context.Background(), doubleNotificationsConfig) t.Run("should both be inserted", func(t *testing.T) { require.NoError(t, err) - notificationsQuery := models.GetAllAlertNotificationsQuery{OrgId: 1} - err = sqlStore.GetAllAlertNotifications(context.Background(), ¬ificationsQuery) - require.NoError(t, err) - require.NotNil(t, notificationsQuery.Result) - require.Equal(t, len(notificationsQuery.Result), 2) - - require.True(t, notificationsQuery.Result[0].IsDefault) - require.True(t, notificationsQuery.Result[1].IsDefault) }) }) }) @@ -238,16 +210,11 @@ func TestNotificationAsConfig(t *testing.T) { require.Equal(t, len(notificationsQuery.Result), 2) t.Run("should have two new notifications", func(t *testing.T) { - dc := newNotificationProvisioner(sqlStore, ossencryption.ProvideService(), nil, logger) + dc := newNotificationProvisioner(sqlStore, &fakeAlertNotification{}, ossencryption.ProvideService(), nil, logger) err := dc.applyChanges(context.Background(), twoNotificationsConfig) if err != nil { t.Fatalf("applyChanges return an error %v", err) } - notificationsQuery = models.GetAllAlertNotificationsQuery{OrgId: 1} - err = sqlStore.GetAllAlertNotifications(context.Background(), ¬ificationsQuery) - require.NoError(t, err) - require.NotNil(t, notificationsQuery.Result) - require.Equal(t, len(notificationsQuery.Result), 4) }) }) }) @@ -272,26 +239,16 @@ func TestNotificationAsConfig(t *testing.T) { err = sqlStore.CreateAlertNotificationCommand(context.Background(), &existingNotificationCmd) require.NoError(t, err) - dc := newNotificationProvisioner(sqlStore, ossencryption.ProvideService(), nil, logger) + dc := newNotificationProvisioner(sqlStore, &fakeAlertNotification{}, ossencryption.ProvideService(), nil, logger) err = dc.applyChanges(context.Background(), correctPropertiesWithOrgName) if err != nil { t.Fatalf("applyChanges return an error %v", err) } - - notificationsQuery := models.GetAllAlertNotificationsQuery{OrgId: existingOrg2.Result.Id} - err = sqlStore.GetAllAlertNotifications(context.Background(), ¬ificationsQuery) - require.NoError(t, err) - require.NotNil(t, notificationsQuery.Result) - require.Equal(t, len(notificationsQuery.Result), 1) - - nt := notificationsQuery.Result[0] - require.Equal(t, nt.Name, "default-notification-create") - require.Equal(t, nt.OrgId, existingOrg2.Result.Id) }) t.Run("Config doesn't contain required field", func(t *testing.T) { setup() - dc := newNotificationProvisioner(sqlStore, ossencryption.ProvideService(), nil, logger) + dc := newNotificationProvisioner(sqlStore, &fakeAlertNotification{}, ossencryption.ProvideService(), nil, logger) err := dc.applyChanges(context.Background(), noRequiredFields) require.NotNil(t, err) @@ -305,7 +262,7 @@ func TestNotificationAsConfig(t *testing.T) { t.Run("Empty yaml file", func(t *testing.T) { t.Run("should have not changed repo", func(t *testing.T) { setup() - dc := newNotificationProvisioner(sqlStore, ossencryption.ProvideService(), nil, logger) + dc := newNotificationProvisioner(sqlStore, &fakeAlertNotification{}, ossencryption.ProvideService(), nil, logger) err := dc.applyChanges(context.Background(), emptyFile) if err != nil { t.Fatalf("applyChanges return an error %v", err) @@ -366,32 +323,45 @@ func TestNotificationAsConfig(t *testing.T) { }) } -func setupBusHandlers(sqlStore *sqlstore.SQLStore) { - bus.AddHandler("getOrg", func(ctx context.Context, q *models.GetOrgByNameQuery) error { - return sqlStore.GetOrgByNameHandler(ctx, q) - }) +type fakeAlertNotification struct { + ExpectedAlertNotification *models.AlertNotification +} - bus.AddHandler("getAlertNotifications", func(ctx context.Context, q *models.GetAlertNotificationsWithUidQuery) error { - return sqlStore.GetAlertNotificationsWithUid(ctx, q) - }) +func (f *fakeAlertNotification) GetAlertNotifications(ctx context.Context, query *models.GetAlertNotificationsQuery) error { + query.Result = f.ExpectedAlertNotification + return nil +} +func (f *fakeAlertNotification) CreateAlertNotificationCommand(ctx context.Context, cmd *models.CreateAlertNotificationCommand) error { + return nil +} +func (f *fakeAlertNotification) UpdateAlertNotification(ctx context.Context, cmd *models.UpdateAlertNotificationCommand) error { + return nil +} +func (f *fakeAlertNotification) DeleteAlertNotification(ctx context.Context, cmd *models.DeleteAlertNotificationCommand) error { + return nil +} +func (f *fakeAlertNotification) GetAllAlertNotifications(ctx context.Context, query *models.GetAllAlertNotificationsQuery) error { + return nil +} +func (f *fakeAlertNotification) GetOrCreateAlertNotificationState(ctx context.Context, cmd *models.GetOrCreateNotificationStateQuery) error { + return nil +} +func (f *fakeAlertNotification) SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToCompleteCommand) error { + return nil +} +func (f *fakeAlertNotification) SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToPendingCommand) error { + return nil +} +func (f *fakeAlertNotification) GetAlertNotificationsWithUid(ctx context.Context, query *models.GetAlertNotificationsWithUidQuery) error { + return nil +} +func (f *fakeAlertNotification) DeleteAlertNotificationWithUid(ctx context.Context, cmd *models.DeleteAlertNotificationWithUidCommand) error { + return nil +} +func (f *fakeAlertNotification) GetAlertNotificationsWithUidToSend(ctx context.Context, query *models.GetAlertNotificationsWithUidToSendQuery) error { + return nil +} - bus.AddHandler("createAlertNotification", func(ctx context.Context, cmd *models.CreateAlertNotificationCommand) error { - return sqlStore.CreateAlertNotificationCommand(ctx, cmd) - }) - - bus.AddHandler("updateAlertNotification", func(ctx context.Context, cmd *models.UpdateAlertNotificationCommand) error { - return sqlStore.UpdateAlertNotification(ctx, cmd) - }) - - bus.AddHandler("updateAlertNotification", func(ctx context.Context, cmd *models.UpdateAlertNotificationWithUidCommand) error { - return sqlStore.UpdateAlertNotificationWithUid(ctx, cmd) - }) - - bus.AddHandler("deleteAlertNotification", func(ctx context.Context, cmd *models.DeleteAlertNotificationCommand) error { - return sqlStore.DeleteAlertNotification(ctx, cmd) - }) - - bus.AddHandler("deleteAlertNotification", func(ctx context.Context, cmd *models.DeleteAlertNotificationWithUidCommand) error { - return sqlStore.DeleteAlertNotificationWithUid(ctx, cmd) - }) +func (f *fakeAlertNotification) UpdateAlertNotificationWithUid(ctx context.Context, cmd *models.UpdateAlertNotificationWithUidCommand) error { + return nil } diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index 2621fac3eb8..ab0678583c3 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -8,7 +8,9 @@ import ( "github.com/grafana/grafana/pkg/infra/log" plugifaces "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/alerting" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards" + datasourceservice "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/encryption" "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/provisioning/dashboards" @@ -24,6 +26,8 @@ import ( func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore plugifaces.Store, encryptionService encryption.Internal, notificatonService *notifications.NotificationService, dashboardService dashboardservice.DashboardProvisioningService, + datasourceService datasourceservice.DataSourceService, + alertingService *alerting.AlertNotificationService, ) (*ProvisioningServiceImpl, error) { s := &ProvisioningServiceImpl{ Cfg: cfg, @@ -37,6 +41,8 @@ func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore p provisionDatasources: datasources.Provision, provisionPlugins: plugins.Provision, dashboardService: dashboardService, + datasourceService: datasourceService, + alertingService: alertingService, } return s, nil } @@ -66,7 +72,7 @@ func NewProvisioningServiceImpl() *ProvisioningServiceImpl { // Used for testing purposes func newProvisioningServiceImpl( newDashboardProvisioner dashboards.DashboardProvisionerFactory, - provisionNotifiers func(context.Context, string, notifiers.Store, encryption.Internal, *notifications.NotificationService) error, + provisionNotifiers func(context.Context, string, notifiers.Manager, notifiers.SQLStore, encryption.Internal, *notifications.NotificationService) error, provisionDatasources func(context.Context, string, datasources.Store, utils.OrgStore) error, provisionPlugins func(context.Context, string, plugins.Store, plugifaces.Store) error, ) *ProvisioningServiceImpl { @@ -89,11 +95,13 @@ type ProvisioningServiceImpl struct { pollingCtxCancel context.CancelFunc newDashboardProvisioner dashboards.DashboardProvisionerFactory dashboardProvisioner dashboards.DashboardProvisioner - provisionNotifiers func(context.Context, string, notifiers.Store, encryption.Internal, *notifications.NotificationService) error + provisionNotifiers func(context.Context, string, notifiers.Manager, notifiers.SQLStore, encryption.Internal, *notifications.NotificationService) error provisionDatasources func(context.Context, string, datasources.Store, utils.OrgStore) error provisionPlugins func(context.Context, string, plugins.Store, plugifaces.Store) error mutex sync.Mutex dashboardService dashboardservice.DashboardProvisioningService + datasourceService datasourceservice.DataSourceService + alertingService *alerting.AlertNotificationService } func (ps *ProvisioningServiceImpl) RunInitProvisioners(ctx context.Context) error { @@ -146,7 +154,7 @@ func (ps *ProvisioningServiceImpl) Run(ctx context.Context) error { func (ps *ProvisioningServiceImpl) ProvisionDatasources(ctx context.Context) error { datasourcePath := filepath.Join(ps.Cfg.ProvisioningPath, "datasources") - if err := ps.provisionDatasources(ctx, datasourcePath, ps.SQLStore, ps.SQLStore); err != nil { + if err := ps.provisionDatasources(ctx, datasourcePath, ps.datasourceService, ps.SQLStore); err != nil { err = errutil.Wrap("Datasource provisioning error", err) ps.log.Error("Failed to provision data sources", "error", err) return err @@ -166,7 +174,7 @@ func (ps *ProvisioningServiceImpl) ProvisionPlugins(ctx context.Context) error { func (ps *ProvisioningServiceImpl) ProvisionNotifications(ctx context.Context) error { alertNotificationsPath := filepath.Join(ps.Cfg.ProvisioningPath, "notifiers") - if err := ps.provisionNotifiers(ctx, alertNotificationsPath, ps.SQLStore, ps.EncryptionService, ps.NotificationService); err != nil { + if err := ps.provisionNotifiers(ctx, alertNotificationsPath, ps.alertingService, ps.SQLStore, ps.EncryptionService, ps.NotificationService); err != nil { err = errutil.Wrap("Alert notification provisioning error", err) ps.log.Error("Failed to provision alert notifications", "error", err) return err diff --git a/pkg/services/searchusers/searchusers.go b/pkg/services/searchusers/searchusers.go index c6fbfbc9402..79dc1b59196 100644 --- a/pkg/services/searchusers/searchusers.go +++ b/pkg/services/searchusers/searchusers.go @@ -3,8 +3,8 @@ package searchusers import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" ) type Service interface { @@ -13,12 +13,12 @@ type Service interface { } type OSSService struct { - bus bus.Bus + sqlStore sqlstore.Store searchUserFilter models.SearchUserFilter } -func ProvideUsersService(bus bus.Bus, searchUserFilter models.SearchUserFilter) *OSSService { - return &OSSService{bus: bus, searchUserFilter: searchUserFilter} +func ProvideUsersService(sqlStore sqlstore.Store, searchUserFilter models.SearchUserFilter) *OSSService { + return &OSSService{sqlStore: sqlStore, searchUserFilter: searchUserFilter} } func (s *OSSService) SearchUsers(c *models.ReqContext) response.Response { @@ -60,7 +60,7 @@ func (s *OSSService) SearchUser(c *models.ReqContext) (*models.SearchUsersQuery, } query := &models.SearchUsersQuery{Query: searchQuery, Filters: filters, Page: page, Limit: perPage} - if err := s.bus.Dispatch(c.Req.Context(), query); err != nil { + if err := s.sqlStore.SearchUsers(c.Req.Context(), query); err != nil { return nil, err } diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index 7fba7661d61..628597878ff 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -1,7 +1,6 @@ package api import ( - "context" "errors" "net/http" "strconv" @@ -20,19 +19,12 @@ import ( "github.com/grafana/grafana/pkg/web" ) -type APIKeyStore interface { - AddAPIKey(ctx context.Context, cmd *models.AddApiKeyCommand) error - GetApiKeyById(ctx context.Context, query *models.GetApiKeyByIdQuery) error - DeleteApiKey(ctx context.Context, cmd *models.DeleteApiKeyCommand) error -} - type ServiceAccountsAPI struct { cfg *setting.Cfg service serviceaccounts.Service accesscontrol accesscontrol.AccessControl RouterRegister routing.RouteRegister store serviceaccounts.Store - apiKeyStore APIKeyStore log log.Logger } @@ -47,7 +39,6 @@ func NewServiceAccountsAPI( accesscontrol accesscontrol.AccessControl, routerRegister routing.RouteRegister, store serviceaccounts.Store, - apiKeyStore APIKeyStore, ) *ServiceAccountsAPI { return &ServiceAccountsAPI{ cfg: cfg, @@ -55,7 +46,6 @@ func NewServiceAccountsAPI( accesscontrol: accesscontrol, RouterRegister: routerRegister, store: store, - apiKeyStore: apiKeyStore, log: log.New("serviceaccounts.api"), } } @@ -119,12 +109,12 @@ func (api *ServiceAccountsAPI) DeleteServiceAccount(ctx *models.ReqContext) resp if err != nil { return response.Error(http.StatusInternalServerError, "Service account deletion error", err) } - return response.Success("service account deleted") + return response.Success("Service account deleted") } func (api *ServiceAccountsAPI) UpgradeServiceAccounts(ctx *models.ReqContext) response.Response { if err := api.store.UpgradeServiceAccounts(ctx.Req.Context()); err == nil { - return response.Success("service accounts upgraded") + return response.Success("Service accounts upgraded") } else { return response.Error(http.StatusInternalServerError, "Internal server error", err) } @@ -133,10 +123,10 @@ func (api *ServiceAccountsAPI) UpgradeServiceAccounts(ctx *models.ReqContext) re func (api *ServiceAccountsAPI) ConvertToServiceAccount(ctx *models.ReqContext) response.Response { keyId, err := strconv.ParseInt(web.Params(ctx.Req)[":keyId"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "keyId is invalid", err) + return response.Error(http.StatusBadRequest, "Key ID is invalid", err) } if err := api.store.ConvertToServiceAccounts(ctx.Req.Context(), []int64{keyId}); err == nil { - return response.Success("service accounts converted") + return response.Success("Service accounts converted") } else { return response.Error(500, "Internal server error", err) } @@ -184,7 +174,7 @@ func (api *ServiceAccountsAPI) getAccessControlMetadata(c *models.ReqContext, sa func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) response.Response { scopeID, err := strconv.ParseInt(web.Params(ctx.Req)[":serviceAccountId"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "serviceAccountId is invalid", err) + return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err) } serviceAccount, err := api.store.RetrieveServiceAccount(ctx.Req.Context(), ctx.OrgId, scopeID) @@ -197,20 +187,22 @@ func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) re } } + saIDString := strconv.FormatInt(serviceAccount.Id, 10) + metadata := api.getAccessControlMetadata(ctx, map[string]bool{saIDString: true}) serviceAccount.AvatarUrl = dtos.GetGravatarUrlWithDefault("", serviceAccount.Name) - + serviceAccount.AccessControl = metadata[saIDString] return response.JSON(http.StatusOK, serviceAccount) } func (api *ServiceAccountsAPI) updateServiceAccount(c *models.ReqContext) response.Response { scopeID, err := strconv.ParseInt(web.Params(c.Req)[":serviceAccountId"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "serviceAccountId is invalid", err) + return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err) } cmd := &serviceaccounts.UpdateServiceAccountForm{} if err := web.Bind(c.Req, &cmd); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) + return response.Error(http.StatusBadRequest, "Bad request data", err) } if cmd.Role != nil && !cmd.Role.IsValid() { diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go index bff5d52cb61..37687b32147 100644 --- a/pkg/services/serviceaccounts/api/api_test.go +++ b/pkg/services/serviceaccounts/api/api_test.go @@ -102,7 +102,7 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, routerRegister routing.RouteRegister, acmock *accesscontrolmock.Mock, sqlStore *sqlstore.SQLStore, saStore serviceaccounts.Store) *web.Mux { - a := NewServiceAccountsAPI(setting.NewCfg(), svc, acmock, routerRegister, saStore, sqlStore) + a := NewServiceAccountsAPI(setting.NewCfg(), svc, acmock, routerRegister, saStore) a.RegisterAPIEndpoints(featuremgmt.WithFeatures(featuremgmt.FlagServiceAccounts)) a.cfg.ApiKeyMaxSecondsToLive = -1 // disable api key expiration diff --git a/pkg/services/serviceaccounts/api/token.go b/pkg/services/serviceaccounts/api/token.go index a66bcc226ff..787d06fdadb 100644 --- a/pkg/services/serviceaccounts/api/token.go +++ b/pkg/services/serviceaccounts/api/token.go @@ -39,7 +39,7 @@ const sevenDaysAhead = 7 * 24 * time.Hour func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Response { saID, err := strconv.ParseInt(web.Params(ctx.Req)[":serviceAccountId"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "serviceAccountId is invalid", err) + return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err) } if saTokens, err := api.store.ListTokens(ctx.Req.Context(), ctx.OrgId, saID); err == nil { @@ -78,7 +78,7 @@ func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Respo func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Response { saID, err := strconv.ParseInt(web.Params(c.Req)[":serviceAccountId"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "serviceAccountId is invalid", err) + return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err) } // confirm service account exists @@ -93,11 +93,10 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon cmd := models.AddApiKeyCommand{} if err := web.Bind(c.Req, &cmd); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) + return response.Error(http.StatusBadRequest, "Bad request data", err) } // Force affected service account to be the one referenced in the URL - cmd.ServiceAccountId = &saID cmd.OrgId = c.OrgId if !cmd.Role.IsValid() { @@ -120,7 +119,7 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon cmd.Key = newKeyInfo.HashedKey - if err := api.apiKeyStore.AddAPIKey(c.Req.Context(), &cmd); err != nil { + if err := api.store.AddServiceAccountToken(c.Req.Context(), saID, &cmd); err != nil { if errors.Is(err, models.ErrInvalidApiKeyExpiration) { return response.Error(http.StatusBadRequest, err.Error(), nil) } @@ -143,7 +142,7 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon func (api *ServiceAccountsAPI) DeleteToken(c *models.ReqContext) response.Response { saID, err := strconv.ParseInt(web.Params(c.Req)[":serviceAccountId"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "serviceAccountId is invalid", err) + return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err) } // confirm service account exists @@ -158,29 +157,10 @@ func (api *ServiceAccountsAPI) DeleteToken(c *models.ReqContext) response.Respon tokenID, err := strconv.ParseInt(web.Params(c.Req)[":tokenId"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "serviceAccountId is invalid", err) + return response.Error(http.StatusBadRequest, "Token ID is invalid", err) } - // confirm API key belongs to service account. TODO: refactor get & delete to single call - cmdGet := &models.GetApiKeyByIdQuery{ApiKeyId: tokenID} - if err = api.apiKeyStore.GetApiKeyById(c.Req.Context(), cmdGet); err != nil { - status := http.StatusNotFound - if err != nil && !errors.Is(err, models.ErrApiKeyNotFound) { - status = http.StatusInternalServerError - } else { - err = models.ErrApiKeyNotFound - } - - return response.Error(status, failedToDeleteMsg, err) - } - - // verify service account ID matches the URL - if *cmdGet.Result.ServiceAccountId != saID { - return response.Error(http.StatusNotFound, failedToDeleteMsg, err) - } - - cmdDel := &models.DeleteApiKeyCommand{Id: tokenID, OrgId: c.OrgId} - if err = api.apiKeyStore.DeleteApiKey(c.Req.Context(), cmdDel); err != nil { + if err = api.store.DeleteServiceAccountToken(c.Req.Context(), c.OrgId, saID, tokenID); err != nil { status := http.StatusNotFound if err != nil && !errors.Is(err, models.ErrApiKeyNotFound) { status = http.StatusInternalServerError diff --git a/pkg/services/serviceaccounts/api/token_test.go b/pkg/services/serviceaccounts/api/token_test.go index a90af8ec96f..959d7fc0e9a 100644 --- a/pkg/services/serviceaccounts/api/token_test.go +++ b/pkg/services/serviceaccounts/api/token_test.go @@ -12,7 +12,6 @@ import ( "time" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/apikeygen" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -31,19 +30,20 @@ const ( serviceaccountIDTokensDetailPath = "/api/serviceaccounts/%v/tokens/%v" // #nosec G101 ) -func createTokenforSA(t *testing.T, keyName string, orgID int64, saID int64, secondsToLive int64) *models.ApiKey { +func createTokenforSA(t *testing.T, store serviceaccounts.Store, keyName string, orgID int64, saID int64, secondsToLive int64) *models.ApiKey { key, err := apikeygen.New(orgID, keyName) require.NoError(t, err) + cmd := models.AddApiKeyCommand{ - Name: keyName, - Role: "Viewer", - OrgId: orgID, - Key: key.HashedKey, - SecondsToLive: secondsToLive, - ServiceAccountId: &saID, - Result: &models.ApiKey{}, + Name: keyName, + Role: "Viewer", + OrgId: orgID, + Key: key.HashedKey, + SecondsToLive: secondsToLive, + Result: &models.ApiKey{}, } - err = bus.Dispatch(context.Background(), &cmd) + + err = store.AddServiceAccountToken(context.Background(), saID, &cmd) require.NoError(t, err) return cmd.Result } @@ -156,7 +156,8 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) { func TestServiceAccountsAPI_DeleteToken(t *testing.T) { store := sqlstore.InitTestDB(t) - svcmock := tests.ServiceAccountMock{} + svcMock := &tests.ServiceAccountMock{} + saStore := database.NewServiceAccountsStore(store) sa := tests.SetupUserServiceAccount(t, store, tests.TestUser{Login: "sa", IsServiceAccount: true}) type testCreateSAToken struct { @@ -216,11 +217,11 @@ func TestServiceAccountsAPI_DeleteToken(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - token := createTokenforSA(t, tc.keyName, sa.OrgId, sa.Id, 1) + token := createTokenforSA(t, saStore, tc.keyName, sa.OrgId, sa.Id, 1) endpoint := fmt.Sprintf(serviceaccountIDTokensDetailPath, sa.Id, token.Id) bodyString := "" - server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store)) + server := setupTestServer(t, svcMock, routing.NewRouteRegister(), tc.acmock, store, saStore) actual := requestResponse(server, http.MethodDelete, endpoint, strings.NewReader(bodyString)) actualCode := actual.Code diff --git a/pkg/services/serviceaccounts/database/database.go b/pkg/services/serviceaccounts/database/database.go index 0b0da8e5d0b..c83abb94db9 100644 --- a/pkg/services/serviceaccounts/database/database.go +++ b/pkg/services/serviceaccounts/database/database.go @@ -4,6 +4,7 @@ package database import ( "context" "fmt" + "strings" "time" "github.com/google/uuid" @@ -169,43 +170,84 @@ func (s *ServiceAccountsStoreImpl) ListServiceAccounts(ctx context.Context, orgI // RetrieveServiceAccountByID returns a service account by its ID func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*serviceaccounts.ServiceAccountProfileDTO, error) { - query := models.GetOrgUsersQuery{UserID: serviceAccountID, OrgId: orgID, IsServiceAccount: true} - err := s.sqlStore.GetOrgUsers(ctx, &query) + serviceAccount := &serviceaccounts.ServiceAccountProfileDTO{} + + err := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + sess := dbSession.Table("org_user") + sess.Join("INNER", s.sqlStore.Dialect.Quote("user"), + fmt.Sprintf("org_user.user_id=%s.id", s.sqlStore.Dialect.Quote("user"))) + + whereConditions := make([]string, 0, 3) + whereParams := make([]interface{}, 0) + + whereConditions = append(whereConditions, "org_user.org_id = ?") + whereParams = append(whereParams, orgID) + + whereConditions = append(whereConditions, "org_user.user_id = ?") + whereParams = append(whereParams, serviceAccountID) + + whereConditions = append(whereConditions, + fmt.Sprintf("%s.is_service_account = %s", + s.sqlStore.Dialect.Quote("user"), + s.sqlStore.Dialect.BooleanStr(true))) + + sess.Where(strings.Join(whereConditions, " AND "), whereParams...) + + sess.Cols( + "org_user.user_id", + "org_user.org_id", + "org_user.role", + "user.email", + "user.name", + "user.login", + "user.created", + "user.updated", + "user.is_disabled", + ) + + if ok, err := sess.Get(serviceAccount); err != nil { + return err + } else if !ok { + return serviceaccounts.ErrServiceAccountNotFound + } + + return nil + }) + if err != nil { return nil, err } - if len(query.Result) != 1 { - return nil, serviceaccounts.ErrServiceAccountNotFound + + // Get Teams of service account. Can be optimized by combining with the query above + // in refactor + getTeamQuery := models.GetTeamsByUserQuery{UserId: serviceAccountID, OrgId: orgID} + if err := s.sqlStore.GetTeamsByUser(ctx, &getTeamQuery); err != nil { + return nil, err + } + teams := make([]string, len(getTeamQuery.Result)) + + for i := range getTeamQuery.Result { + teams[i] = getTeamQuery.Result[i].Name } - saProfile := &serviceaccounts.ServiceAccountProfileDTO{ - Id: query.Result[0].UserId, - Name: query.Result[0].Name, - Login: query.Result[0].Login, - OrgId: query.Result[0].OrgId, - UpdatedAt: query.Result[0].Updated, - CreatedAt: query.Result[0].Created, - } - return saProfile, nil + serviceAccount.Teams = teams + + return serviceAccount, nil } func (s *ServiceAccountsStoreImpl) UpdateServiceAccount(ctx context.Context, orgID, serviceAccountID int64, - saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { - updatedUser := &models.OrgUserDTO{} + saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountProfileDTO, error) { + updatedUser := &serviceaccounts.ServiceAccountProfileDTO{} err := s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { - query := models.GetOrgUsersQuery{UserID: serviceAccountID, OrgId: orgID, IsServiceAccount: true} - if err := s.sqlStore.GetOrgUsers(ctx, &query); err != nil { + var err error + updatedUser, err = s.RetrieveServiceAccount(ctx, orgID, serviceAccountID) + if err != nil { return err } - if len(query.Result) != 1 { - return serviceaccounts.ErrServiceAccountNotFound - } - updatedUser = query.Result[0] - - if saForm.Name == nil && saForm.Role == nil { + if saForm.Name == nil && saForm.Role == nil && saForm.IsDisabled == nil { return nil } @@ -222,29 +264,31 @@ func (s *ServiceAccountsStoreImpl) UpdateServiceAccount(ctx context.Context, updatedUser.Role = string(*saForm.Role) } - if saForm.Name != nil { + if saForm.Name != nil || saForm.IsDisabled != nil { user := models.User{ - Name: *saForm.Name, Updated: updateTime, } + if saForm.IsDisabled != nil { + user.IsDisabled = *saForm.IsDisabled + updatedUser.IsDisabled = *saForm.IsDisabled + sess.UseBool("is_disabled") + } + + if saForm.Name != nil { + user.Name = *saForm.Name + updatedUser.Name = *saForm.Name + } + if _, err := sess.ID(serviceAccountID).Update(&user); err != nil { return err } - - updatedUser.Name = *saForm.Name } return nil }) - return &serviceaccounts.ServiceAccountDTO{ - Id: updatedUser.UserId, - Name: updatedUser.Name, - Login: updatedUser.Login, - Role: updatedUser.Role, - OrgId: updatedUser.OrgId, - }, err + return updatedUser, err } func contains(s []int64, e int64) bool { diff --git a/pkg/services/serviceaccounts/database/database_test.go b/pkg/services/serviceaccounts/database/database_test.go index 799cc1ac8ce..dcf74047c7d 100644 --- a/pkg/services/serviceaccounts/database/database_test.go +++ b/pkg/services/serviceaccounts/database/database_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -76,7 +77,25 @@ func TestStore_RetrieveServiceAccount(t *testing.T) { } else { require.NoError(t, err) require.Equal(t, c.user.Login, dto.Login) + require.Len(t, dto.Teams, 0) } }) } } +func TestStore_RetrieveServiceAccountWithTeams(t *testing.T) { + userToCreate := tests.TestUser{Login: "servicetestwithTeam@admin", IsServiceAccount: true} + db, store := setupTestDatabase(t) + user := tests.SetupUserServiceAccount(t, db, userToCreate) + + team, err := store.sqlStore.CreateTeam("serviceTeam", "serviceTeam", user.OrgId) + require.NoError(t, err) + + err = store.sqlStore.AddTeamMember(user.Id, user.OrgId, team.Id, false, models.PERMISSION_VIEW) + require.NoError(t, err) + + dto, err := store.RetrieveServiceAccount(context.Background(), user.OrgId, user.Id) + require.NoError(t, err) + require.Equal(t, userToCreate.Login, dto.Login) + require.Len(t, dto.Teams, 1) + require.Equal(t, "serviceTeam", dto.Teams[0]) +} diff --git a/pkg/services/serviceaccounts/database/errors.go b/pkg/services/serviceaccounts/database/errors.go new file mode 100644 index 00000000000..09eb550c4a3 --- /dev/null +++ b/pkg/services/serviceaccounts/database/errors.go @@ -0,0 +1,41 @@ +package database + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/models" +) + +type ErrMisingSAToken struct { +} + +func (e *ErrMisingSAToken) Error() string { + return "service account token not found" +} + +func (e *ErrMisingSAToken) Unwrap() error { + return models.ErrApiKeyNotFound +} + +type ErrInvalidExpirationSAToken struct { +} + +func (e *ErrInvalidExpirationSAToken) Error() string { + return "service account token not found" +} + +func (e *ErrInvalidExpirationSAToken) Unwrap() error { + return models.ErrInvalidApiKeyExpiration +} + +type ErrDuplicateSAToken struct { + name string +} + +func (e *ErrDuplicateSAToken) Error() string { + return fmt.Sprintf("service account token %s already exists", e.name) +} + +func (e *ErrDuplicateSAToken) Unwrap() error { + return models.ErrDuplicateApiKey +} diff --git a/pkg/services/serviceaccounts/database/token_store.go b/pkg/services/serviceaccounts/database/token_store.go new file mode 100644 index 00000000000..e36ab862e46 --- /dev/null +++ b/pkg/services/serviceaccounts/database/token_store.go @@ -0,0 +1,63 @@ +package database + +import ( + "context" + "time" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +func (s *ServiceAccountsStoreImpl) AddServiceAccountToken(ctx context.Context, saID int64, cmd *models.AddApiKeyCommand) error { + return s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + key := models.ApiKey{OrgId: cmd.OrgId, Name: cmd.Name} + exists, _ := sess.Get(&key) + if exists { + return &ErrDuplicateSAToken{cmd.Name} + } + + updated := time.Now() + var expires *int64 = nil + if cmd.SecondsToLive > 0 { + v := updated.Add(time.Second * time.Duration(cmd.SecondsToLive)).Unix() + expires = &v + } else if cmd.SecondsToLive < 0 { + return &ErrInvalidExpirationSAToken{} + } + + t := models.ApiKey{ + OrgId: cmd.OrgId, + Name: cmd.Name, + Role: cmd.Role, + Key: cmd.Key, + Created: updated, + Updated: updated, + Expires: expires, + ServiceAccountId: &saID, + } + + if _, err := sess.Insert(&t); err != nil { + return err + } + cmd.Result = &t + return nil + }) +} + +func (s *ServiceAccountsStoreImpl) DeleteServiceAccountToken(ctx context.Context, orgID, serviceAccountID, tokenID int64) error { + rawSQL := "DELETE FROM api_key WHERE id=? and org_id=? and service_account_id=?" + + return s.sqlStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + result, err := sess.Exec(rawSQL, tokenID, orgID, serviceAccountID) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } else if n == 0 { + return &ErrMisingSAToken{} + } + return nil + }) +} diff --git a/pkg/services/serviceaccounts/database/token_store_test.go b/pkg/services/serviceaccounts/database/token_store_test.go new file mode 100644 index 00000000000..2e24caa9c37 --- /dev/null +++ b/pkg/services/serviceaccounts/database/token_store_test.go @@ -0,0 +1,115 @@ +package database + +import ( + "context" + "testing" + + "github.com/grafana/grafana/pkg/components/apikeygen" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" + "github.com/stretchr/testify/require" +) + +func TestStore_AddServiceAccountToken(t *testing.T) { + userToCreate := tests.TestUser{Login: "servicetestwithTeam@admin", IsServiceAccount: true} + db, store := setupTestDatabase(t) + user := tests.SetupUserServiceAccount(t, db, userToCreate) + + type testCasesAdd struct { + secondsToLive int64 + desc string + } + + testCases := []testCasesAdd{{-10, "invalid"}, {0, "no expiry"}, {10, "valid"}} + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + keyName := t.Name() + key, err := apikeygen.New(user.OrgId, keyName) + require.NoError(t, err) + + cmd := models.AddApiKeyCommand{ + Name: keyName, + Role: "Viewer", + OrgId: user.OrgId, + Key: key.HashedKey, + SecondsToLive: tc.secondsToLive, + Result: &models.ApiKey{}, + } + + err = store.AddServiceAccountToken(context.Background(), user.Id, &cmd) + if tc.secondsToLive < 0 { + require.Error(t, err) + return + } + + require.NoError(t, err) + newKey := cmd.Result + require.Equal(t, t.Name(), newKey.Name) + + // Verify against DB + keys, errT := store.ListTokens(context.Background(), user.OrgId, user.Id) + + require.NoError(t, errT) + + found := false + for _, k := range keys { + if k.Name == keyName { + found = true + require.Equal(t, key.HashedKey, newKey.Key) + if tc.secondsToLive == 0 { + require.Nil(t, k.Expires) + } else { + require.NotNil(t, k.Expires) + } + } + } + + require.True(t, found, "Key not found") + }) + } +} + +func TestStore_DeleteServiceAccountToken(t *testing.T) { + userToCreate := tests.TestUser{Login: "servicetestwithTeam@admin", IsServiceAccount: true} + db, store := setupTestDatabase(t) + user := tests.SetupUserServiceAccount(t, db, userToCreate) + + keyName := t.Name() + key, err := apikeygen.New(user.OrgId, keyName) + require.NoError(t, err) + + cmd := models.AddApiKeyCommand{ + Name: keyName, + Role: "Viewer", + OrgId: user.OrgId, + Key: key.HashedKey, + SecondsToLive: 0, + Result: &models.ApiKey{}, + } + + err = store.AddServiceAccountToken(context.Background(), user.Id, &cmd) + require.NoError(t, err) + newKey := cmd.Result + + // Delete key from wrong service account + err = store.DeleteServiceAccountToken(context.Background(), user.OrgId, user.Id+2, newKey.Id) + require.Error(t, err) + + // Delete key from wrong org + err = store.DeleteServiceAccountToken(context.Background(), user.OrgId+2, user.Id, newKey.Id) + require.Error(t, err) + + err = store.DeleteServiceAccountToken(context.Background(), user.OrgId, user.Id, newKey.Id) + require.NoError(t, err) + + // Verify against DB + keys, errT := store.ListTokens(context.Background(), user.OrgId, user.Id) + require.NoError(t, errT) + + for _, k := range keys { + if k.Name == keyName { + require.Fail(t, "Key not deleted") + } + } +} diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index e74aaa5a02c..f5b066cad2b 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -43,7 +43,7 @@ func ProvideServiceAccountsService( } } - serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, routeRegister, s.store, store) + serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, routeRegister, s.store) serviceaccountsAPI.RegisterAPIEndpoints(features) return s, nil diff --git a/pkg/services/serviceaccounts/models.go b/pkg/services/serviceaccounts/models.go index 81ad0337b4d..7bd167dad25 100644 --- a/pkg/services/serviceaccounts/models.go +++ b/pkg/services/serviceaccounts/models.go @@ -24,8 +24,9 @@ type ServiceAccount struct { } type UpdateServiceAccountForm struct { - Name *string `json:"name"` - Role *models.RoleType `json:"role"` + Name *string `json:"name"` + Role *models.RoleType `json:"role"` + IsDisabled *bool `json:"isDisabled"` } type CreateServiceAccountForm struct { @@ -45,13 +46,15 @@ type ServiceAccountDTO struct { } type ServiceAccountProfileDTO struct { - Id int64 `json:"id"` - Name string `json:"name"` - Login string `json:"login"` - OrgId int64 `json:"orgId"` - IsDisabled bool `json:"isDisabled"` - UpdatedAt time.Time `json:"updatedAt"` - CreatedAt time.Time `json:"createdAt"` - AvatarUrl string `json:"avatarUrl"` - AccessControl map[string]bool `json:"accessControl,omitempty"` + Id int64 `json:"id" xorm:"user_id"` + Name string `json:"name" xorm:"name"` + Login string `json:"login" xorm:"login"` + OrgId int64 `json:"orgId" xorm:"org_id"` + IsDisabled bool `json:"isDisabled" xorm:"is_disabled"` + Created time.Time `json:"createdAt" xorm:"created"` + Updated time.Time `json:"updatedAt" xorm:"updated"` + AvatarUrl string `json:"avatarUrl" xorm:"-"` + Role string `json:"role" xorm:"role"` + Teams []string `json:"teams" xorm:"-"` + AccessControl map[string]bool `json:"accessControl,omitempty" xorm:"-"` } diff --git a/pkg/services/serviceaccounts/serviceaccounts.go b/pkg/services/serviceaccounts/serviceaccounts.go index 3aa6d895169..7ba3b1b8fc3 100644 --- a/pkg/services/serviceaccounts/serviceaccounts.go +++ b/pkg/services/serviceaccounts/serviceaccounts.go @@ -15,10 +15,12 @@ type Service interface { type Store interface { CreateServiceAccount(ctx context.Context, saForm *CreateServiceAccountForm) (*ServiceAccountDTO, error) ListServiceAccounts(ctx context.Context, orgID, serviceAccountID int64) ([]*ServiceAccountDTO, error) - UpdateServiceAccount(ctx context.Context, orgID, serviceAccountID int64, saForm *UpdateServiceAccountForm) (*ServiceAccountDTO, error) + UpdateServiceAccount(ctx context.Context, orgID, serviceAccountID int64, saForm *UpdateServiceAccountForm) (*ServiceAccountProfileDTO, error) RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*ServiceAccountProfileDTO, error) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error UpgradeServiceAccounts(ctx context.Context) error ConvertToServiceAccounts(ctx context.Context, keys []int64) error ListTokens(ctx context.Context, orgID int64, serviceAccount int64) ([]*models.ApiKey, error) + DeleteServiceAccountToken(ctx context.Context, orgID, serviceAccountID, tokenID int64) error + AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *models.AddApiKeyCommand) error } diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index 17e9f16d9b2..0321741ce25 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -67,14 +67,16 @@ func SetupMockAccesscontrol(t *testing.T, var _ serviceaccounts.Store = new(ServiceAccountsStoreMock) type Calls struct { - CreateServiceAccount []interface{} - ListServiceAccounts []interface{} - RetrieveServiceAccount []interface{} - DeleteServiceAccount []interface{} - UpgradeServiceAccounts []interface{} - ConvertServiceAccounts []interface{} - ListTokens []interface{} - UpdateServiceAccount []interface{} + CreateServiceAccount []interface{} + ListServiceAccounts []interface{} + RetrieveServiceAccount []interface{} + DeleteServiceAccount []interface{} + UpgradeServiceAccounts []interface{} + ConvertServiceAccounts []interface{} + ListTokens []interface{} + DeleteServiceAccountToken []interface{} + UpdateServiceAccount []interface{} + AddServiceAccountToken []interface{} } type ServiceAccountsStoreMock struct { @@ -119,8 +121,18 @@ func (s *ServiceAccountsStoreMock) RetrieveServiceAccount(ctx context.Context, o func (s *ServiceAccountsStoreMock) UpdateServiceAccount(ctx context.Context, orgID, serviceAccountID int64, - saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { + saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountProfileDTO, error) { s.Calls.UpdateServiceAccount = append(s.Calls.UpdateServiceAccount, []interface{}{ctx, orgID, serviceAccountID, saForm}) return nil, nil } + +func (s *ServiceAccountsStoreMock) DeleteServiceAccountToken(ctx context.Context, orgID, serviceAccountID, tokenID int64) error { + s.Calls.DeleteServiceAccountToken = append(s.Calls.DeleteServiceAccountToken, []interface{}{ctx, orgID, serviceAccountID, tokenID}) + return nil +} + +func (s *ServiceAccountsStoreMock) AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *models.AddApiKeyCommand) error { + s.Calls.AddServiceAccountToken = append(s.Calls.AddServiceAccountToken, []interface{}{ctx, cmd}) + return nil +} diff --git a/pkg/services/sqlstore/apikey.go b/pkg/services/sqlstore/apikey.go index 72e4474b7fc..61037c291d9 100644 --- a/pkg/services/sqlstore/apikey.go +++ b/pkg/services/sqlstore/apikey.go @@ -34,6 +34,8 @@ func (ss *SQLStore) GetAPIKeys(ctx context.Context, query *models.GetApiKeysQuer Asc("name") } + sess = sess.Where("service_account_id IS NULL") + query.Result = make([]*models.ApiKey, 0) return sess.Find(&query.Result) }) @@ -61,7 +63,7 @@ func (ss *SQLStore) DeleteApiKey(ctx context.Context, cmd *models.DeleteApiKeyCo } func deleteAPIKey(sess *DBSession, id, orgID int64) error { - rawSQL := "DELETE FROM api_key WHERE id=? and org_id=?" + rawSQL := "DELETE FROM api_key WHERE id=? and org_id=? and service_account_id IS NULL" result, err := sess.Exec(rawSQL, id, orgID) if err != nil { return err @@ -101,7 +103,7 @@ func (ss *SQLStore) AddAPIKey(ctx context.Context, cmd *models.AddApiKeyCommand) Created: updated, Updated: updated, Expires: expires, - ServiceAccountId: cmd.ServiceAccountId, + ServiceAccountId: nil, } if _, err := sess.Insert(&t); err != nil { diff --git a/pkg/services/sqlstore/apikey_test.go b/pkg/services/sqlstore/apikey_test.go index 3e69a3c6498..06a17378f2c 100644 --- a/pkg/services/sqlstore/apikey_test.go +++ b/pkg/services/sqlstore/apikey_test.go @@ -34,20 +34,7 @@ func TestApiKeyDataAccess(t *testing.T) { }) t.Run("Add non expiring key", func(t *testing.T) { - cmd := models.AddApiKeyCommand{OrgId: 1, Name: "non-expiring", Key: "asd1", SecondsToLive: 0, ServiceAccountId: nil} - err := ss.AddAPIKey(context.Background(), &cmd) - assert.Nil(t, err) - - query := models.GetApiKeyByNameQuery{KeyName: "non-expiring", OrgId: 1} - err = ss.GetApiKeyByName(context.Background(), &query) - assert.Nil(t, err) - - assert.Nil(t, query.Result.Expires) - }) - - t.Run("Add key for service account", func(t *testing.T) { - var one int64 = 1 - cmd := models.AddApiKeyCommand{OrgId: 1, Name: "non-expiring-SA", Key: "sa1-key", ServiceAccountId: &one} + cmd := models.AddApiKeyCommand{OrgId: 1, Name: "non-expiring", Key: "asd1", SecondsToLive: 0} err := ss.AddAPIKey(context.Background(), &cmd) assert.Nil(t, err) diff --git a/pkg/services/sqlstore/migrations/db_file_storage.go b/pkg/services/sqlstore/migrations/db_file_storage.go new file mode 100644 index 00000000000..c5cd1bc30bc --- /dev/null +++ b/pkg/services/sqlstore/migrations/db_file_storage.go @@ -0,0 +1,41 @@ +package migrations + +import "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +// TODO: remove nolint as part of https://github.com/grafana/grafana/issues/45498 +// nolint:unused,deadcode +func addDbFileStorageMigration(mg *migrator.Migrator) { + filesTable := migrator.Table{ + Name: "file", + Columns: []*migrator.Column{ + {Name: "path", Type: migrator.DB_NVarchar, Length: 1024, Nullable: false}, + {Name: "parent_folder_path", Type: migrator.DB_NVarchar, Length: 1024, Nullable: false}, + {Name: "contents", Type: migrator.DB_Blob, Nullable: false}, + {Name: "updated", Type: migrator.DB_DateTime, Nullable: false}, + {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, + {Name: "size", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "mime_type", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"path"}, Type: migrator.UniqueIndex}, + }, + } + + mg.AddMigration("create file table", migrator.NewAddTableMigration(filesTable)) + mg.AddMigration("file table idx: path natural pk", migrator.NewAddIndexMigration(filesTable, filesTable.Indices[0])) + + fileMetaTable := migrator.Table{ + Name: "file_meta", + Columns: []*migrator.Column{ + {Name: "path", Type: migrator.DB_NVarchar, Length: 1024, Nullable: false}, + {Name: "key", Type: migrator.DB_NVarchar, Length: 1024, Nullable: false}, + {Name: "value", Type: migrator.DB_NVarchar, Length: 1024, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"path", "key"}, Type: migrator.UniqueIndex}, + }, + } + + mg.AddMigration("create file_meta table", migrator.NewAddTableMigration(fileMetaTable)) + mg.AddMigration("file table idx: path key", migrator.NewAddIndexMigration(fileMetaTable, fileMetaTable.Indices[0])) +} diff --git a/pkg/services/sqlstore/migrations/ualert/alert_rule.go b/pkg/services/sqlstore/migrations/ualert/alert_rule.go index 57aeefeb2db..9846d6630d7 100644 --- a/pkg/services/sqlstore/migrations/ualert/alert_rule.go +++ b/pkg/services/sqlstore/migrations/ualert/alert_rule.go @@ -6,6 +6,7 @@ import ( "time" "github.com/grafana/grafana/pkg/expr" + legacymodels "github.com/grafana/grafana/pkg/models" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/tsdb/graphite" "github.com/grafana/grafana/pkg/util" @@ -241,29 +242,29 @@ func ruleAdjustInterval(freq int64) int64 { } func transNoData(s string) (string, error) { - switch s { - case "ok": - return "OK", nil // values from ngalert/models/rule - case "", "no_data": - return "NoData", nil - case "alerting": - return "Alerting", nil - case "keep_state": - return "NoData", nil // "keep last state" translates to no data because we now emit a special alert when the state is "noData". The result is that the evaluation will not return firing and instead we'll raise the special alert. + switch legacymodels.NoDataOption(s) { + case legacymodels.NoDataSetOK: + return string(ngmodels.OK), nil // values from ngalert/models/rule + case "", legacymodels.NoDataSetNoData: + return string(ngmodels.NoData), nil + case legacymodels.NoDataSetAlerting: + return string(ngmodels.Alerting), nil + case legacymodels.NoDataKeepState: + return string(ngmodels.NoData), nil // "keep last state" translates to no data because we now emit a special alert when the state is "noData". The result is that the evaluation will not return firing and instead we'll raise the special alert. } return "", fmt.Errorf("unrecognized No Data setting %v", s) } func transExecErr(s string) (string, error) { - switch s { - case "", "alerting": - return "Alerting", nil - case "keep_state": + switch legacymodels.ExecutionErrorOption(s) { + case "", legacymodels.ExecutionErrorSetAlerting: + return string(ngmodels.AlertingErrState), nil + case legacymodels.ExecutionErrorKeepState: // Keep last state is translated to error as we now emit a // DatasourceError alert when the state is error - return "Error", nil - case "ok": - return "OK", nil + return string(ngmodels.ErrorErrState), nil + case legacymodels.ExecutionErrorSetOk: + return string(ngmodels.OkErrState), nil } return "", fmt.Errorf("unrecognized Execution Error setting %v", s) } diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index 61b19b0a53c..2490035c0df 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -30,6 +30,7 @@ type SQLStoreMock struct { ExpectedDashboardSnapshot *models.DashboardSnapshot ExpectedTeamsByUser []*models.TeamDTO ExpectedSearchOrgList []*models.OrgDTO + ExpectedSearchUsers models.SearchUserQueryResult ExpectedDatasources []*models.DataSource ExpectedOrg *models.Org ExpectedSystemStats *models.SystemStats @@ -193,6 +194,11 @@ func (m *SQLStoreMock) GetSignedInUser(ctx context.Context, query *models.GetSig return m.ExpectedError } +func (m *SQLStoreMock) SearchUsers(ctx context.Context, query *models.SearchUsersQuery) error { + query.Result = m.ExpectedSearchUsers + return m.ExpectedError +} + func (m *SQLStoreMock) DisableUser(ctx context.Context, cmd *models.DisableUserCommand) error { m.LatestUserId = cmd.UserId return m.ExpectedError @@ -503,6 +509,7 @@ func (m *SQLStoreMock) GetDataSourcesByType(ctx context.Context, query *models.G } func (m *SQLStoreMock) GetDefaultDataSource(ctx context.Context, query *models.GetDefaultDataSourceQuery) error { + query.Result = m.ExpectedDatasource return m.ExpectedError } diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go index 82c6f104820..2e2dde3e988 100644 --- a/pkg/services/sqlstore/store.go +++ b/pkg/services/sqlstore/store.go @@ -42,6 +42,7 @@ type Store interface { GetUserOrgList(ctx context.Context, query *models.GetUserOrgListQuery) error GetSignedInUserWithCacheCtx(ctx context.Context, query *models.GetSignedInUserQuery) error GetSignedInUser(ctx context.Context, query *models.GetSignedInUserQuery) error + SearchUsers(ctx context.Context, query *models.SearchUsersQuery) error DisableUser(ctx context.Context, cmd *models.DisableUserCommand) error BatchDisableUsers(ctx context.Context, cmd *models.BatchDisableUsersCommand) error DeleteUser(ctx context.Context, cmd *models.DeleteUserCommand) error diff --git a/pkg/services/thumbs/crawler.go b/pkg/services/thumbs/crawler.go index a17b48c4ae0..38a106d9b9b 100644 --- a/pkg/services/thumbs/crawler.go +++ b/pkg/services/thumbs/crawler.go @@ -120,7 +120,7 @@ func (r *simpleCrawler) Run(ctx context.Context, authOpts rendering.AuthOpts, mo r.opts = rendering.Opts{ AuthOpts: authOpts, TimeoutOpts: rendering.TimeoutOpts{ - Timeout: 10 * time.Second, + Timeout: 20 * time.Second, RequestTimeoutMultiplier: 3, }, Theme: theme, diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index 402add9ca2c..686de9827a3 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -2099,7 +2099,7 @@ var expEmailNotifications = []*models.SendEmailCommandSync{ EndsAt: time.Time{}, GeneratorURL: "http://localhost:3000/alerting/UID_EmailAlert/edit", Fingerprint: "08c220aa26cd0cf5", - SilenceURL: "http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DEmailAlert", + SilenceURL: "http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DEmailAlert", DashboardURL: "", PanelURL: "", ValueString: "[ var='A' labels={} value=1 ]", @@ -2157,7 +2157,7 @@ var expNonEmailNotifications = map[string][]string{ { "title": "[FIRING:1] SlackAlert2 ", "title_link": "http://localhost:3000/alerting/list", - "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = SlackAlert2\nAnnotations:\nSource: http://localhost:3000/alerting/UID_SlackAlert2/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DSlackAlert2\n", + "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = SlackAlert2\nAnnotations:\nSource: http://localhost:3000/alerting/UID_SlackAlert2/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DSlackAlert2\n", "fallback": "[FIRING:1] SlackAlert2 ", "footer": "Grafana v", "footer_icon": "https://grafana.com/assets/img/fav32.png", @@ -2190,7 +2190,7 @@ var expNonEmailNotifications = map[string][]string{ "component": "Integration Test", "group": "testgroup", "custom_details": { - "firing": "\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = PagerdutyAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_PagerdutyAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DPagerdutyAlert\n", + "firing": "\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = PagerdutyAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_PagerdutyAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DPagerdutyAlert\n", "num_firing": "1", "num_resolved": "0", "resolved": "" @@ -2210,7 +2210,7 @@ var expNonEmailNotifications = map[string][]string{ `{ "link": { "messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%3A3000%2Falerting%2Flist", - "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = DingDingAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_DingDingAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DDingDingAlert\n", + "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = DingDingAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_DingDingAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DDingDingAlert\n", "title": "[FIRING:1] DingDingAlert " }, "msgtype": "link" @@ -2235,7 +2235,7 @@ var expNonEmailNotifications = map[string][]string{ ], "sections": [ { - "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = TeamsAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_TeamsAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DTeamsAlert\n", + "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = TeamsAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_TeamsAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DTeamsAlert\n", "title": "Details" } ], @@ -2261,7 +2261,7 @@ var expNonEmailNotifications = map[string][]string{ "endsAt": "0001-01-01T00:00:00Z", "generatorURL": "http://localhost:3000/alerting/UID_WebhookAlert/edit", "fingerprint": "929467973978d053", - "silenceURL": "http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DWebhookAlert", + "silenceURL": "http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DWebhookAlert", "dashboardURL": "", "panelURL": "" } @@ -2279,12 +2279,12 @@ var expNonEmailNotifications = map[string][]string{ "truncatedAlerts": 0, "title": "[FIRING:1] WebhookAlert ", "state": "alerting", - "message": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = WebhookAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_WebhookAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DWebhookAlert\n" + "message": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = WebhookAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_WebhookAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DWebhookAlert\n" }`, }, "discord_recv/discord_test": { `{ - "content": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = DiscordAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_DiscordAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DDiscordAlert\n", + "content": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = DiscordAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_DiscordAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DDiscordAlert\n", "embeds": [ { "color": 14037554, @@ -2312,7 +2312,7 @@ var expNonEmailNotifications = map[string][]string{ }, "name": "default" }, - "output": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = SensuGoAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_SensuGoAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DSensuGoAlert\n", + "output": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = SensuGoAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_SensuGoAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DSensuGoAlert\n", "status": 2 }, "entity": { @@ -2325,10 +2325,10 @@ var expNonEmailNotifications = map[string][]string{ }`, }, "pushover_recv/pushover_test": { - "--abcd\r\nContent-Disposition: form-data; name=\"user\"\r\n\r\nmysecretkey\r\n--abcd\r\nContent-Disposition: form-data; name=\"token\"\r\n\r\nmysecrettoken\r\n--abcd\r\nContent-Disposition: form-data; name=\"priority\"\r\n\r\n0\r\n--abcd\r\nContent-Disposition: form-data; name=\"sound\"\r\n\r\n\r\n--abcd\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\n[FIRING:1] PushoverAlert \r\n--abcd\r\nContent-Disposition: form-data; name=\"url\"\r\n\r\nhttp://localhost:3000/alerting/list\r\n--abcd\r\nContent-Disposition: form-data; name=\"url_title\"\r\n\r\nShow alert rule\r\n--abcd\r\nContent-Disposition: form-data; name=\"message\"\r\n\r\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = PushoverAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_PushoverAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DPushoverAlert\n\r\n--abcd\r\nContent-Disposition: form-data; name=\"html\"\r\n\r\n1\r\n--abcd--\r\n", + "--abcd\r\nContent-Disposition: form-data; name=\"user\"\r\n\r\nmysecretkey\r\n--abcd\r\nContent-Disposition: form-data; name=\"token\"\r\n\r\nmysecrettoken\r\n--abcd\r\nContent-Disposition: form-data; name=\"priority\"\r\n\r\n0\r\n--abcd\r\nContent-Disposition: form-data; name=\"sound\"\r\n\r\n\r\n--abcd\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\n[FIRING:1] PushoverAlert \r\n--abcd\r\nContent-Disposition: form-data; name=\"url\"\r\n\r\nhttp://localhost:3000/alerting/list\r\n--abcd\r\nContent-Disposition: form-data; name=\"url_title\"\r\n\r\nShow alert rule\r\n--abcd\r\nContent-Disposition: form-data; name=\"message\"\r\n\r\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = PushoverAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_PushoverAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DPushoverAlert\n\r\n--abcd\r\nContent-Disposition: form-data; name=\"html\"\r\n\r\n1\r\n--abcd--\r\n", }, "telegram_recv/bot6sh027hs034h": { - "--abcd\r\nContent-Disposition: form-data; name=\"chat_id\"\r\n\r\ntelegram_chat_id\r\n--abcd\r\nContent-Disposition: form-data; name=\"parse_mode\"\r\n\r\nhtml\r\n--abcd\r\nContent-Disposition: form-data; name=\"text\"\r\n\r\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = TelegramAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_TelegramAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DTelegramAlert\n\r\n--abcd--\r\n", + "--abcd\r\nContent-Disposition: form-data; name=\"chat_id\"\r\n\r\ntelegram_chat_id\r\n--abcd\r\nContent-Disposition: form-data; name=\"parse_mode\"\r\n\r\nhtml\r\n--abcd\r\nContent-Disposition: form-data; name=\"text\"\r\n\r\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = TelegramAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_TelegramAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DTelegramAlert\n\r\n--abcd--\r\n", }, "googlechat_recv/googlechat_test": { `{ @@ -2344,7 +2344,7 @@ var expNonEmailNotifications = map[string][]string{ "widgets": [ { "textParagraph": { - "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = GoogleChatAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_GoogleChatAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DGoogleChatAlert\n" + "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = GoogleChatAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_GoogleChatAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DGoogleChatAlert\n" } }, { @@ -2382,7 +2382,7 @@ var expNonEmailNotifications = map[string][]string{ "client": "Grafana", "client_url": "http://localhost:3000/alerting/list", "description": "[FIRING:1] KafkaAlert ", - "details": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = KafkaAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_KafkaAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DKafkaAlert\n", + "details": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = KafkaAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_KafkaAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DKafkaAlert\n", "incident_key": "35c0bdb1715f9162a20d7b2a01cb2e3a4c5b1dc663571701e3f67212b696332f" } } @@ -2390,10 +2390,10 @@ var expNonEmailNotifications = map[string][]string{ }`, }, "line_recv/line_test": { - `message=%5BFIRING%3A1%5D+LineAlert+%0Ahttp%3A%2Flocalhost%3A3000%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5B+var%3D%27A%27+labels%3D%7B%7D+value%3D1+%5D%0ALabels%3A%0A+-+alertname+%3D+LineAlert%0AAnnotations%3A%0ASource%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2FUID_LineAlert%2Fedit%0ASilence%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253DLineAlert%0A`, + `message=%5BFIRING%3A1%5D+LineAlert+%0Ahttp%3A%2Flocalhost%3A3000%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5B+var%3D%27A%27+labels%3D%7B%7D+value%3D1+%5D%0ALabels%3A%0A+-+alertname+%3D+LineAlert%0AAnnotations%3A%0ASource%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2FUID_LineAlert%2Fedit%0ASilence%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253DLineAlert%0A`, }, "threema_recv/threema_test": { - `from=%2A1234567&secret=myapisecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A1%5D+ThreemaAlert+%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5B+var%3D%27A%27+labels%3D%7B%7D+value%3D1+%5D%0ALabels%3A%0A+-+alertname+%3D+ThreemaAlert%0AAnnotations%3A%0ASource%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2FUID_ThreemaAlert%2Fedit%0ASilence%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253DThreemaAlert%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%3A3000%2Falerting%2Flist%0A&to=abcdefgh`, + `from=%2A1234567&secret=myapisecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A1%5D+ThreemaAlert+%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5B+var%3D%27A%27+labels%3D%7B%7D+value%3D1+%5D%0ALabels%3A%0A+-+alertname+%3D+ThreemaAlert%0AAnnotations%3A%0ASource%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2FUID_ThreemaAlert%2Fedit%0ASilence%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253DThreemaAlert%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%3A3000%2Falerting%2Flist%0A&to=abcdefgh`, }, "victorops_recv/victorops_test": { `{ @@ -2402,14 +2402,14 @@ var expNonEmailNotifications = map[string][]string{ "entity_id": "633ae988fa7074bcb51f3d1c5fef2ba1c5c4ccb45b3ecbf681f7d507b078b1ae", "message_type": "CRITICAL", "monitoring_tool": "Grafana v", - "state_message": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = VictorOpsAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_VictorOpsAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DVictorOpsAlert\n", + "state_message": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = VictorOpsAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_VictorOpsAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DVictorOpsAlert\n", "timestamp": %s }`, }, "opsgenie_recv/opsgenie_test": { `{ "alias": "47e92f0f6ef9fe99f3954e0d6155f8d09c4b9a038d8c3105e82c0cee4c62956e", - "description": "[FIRING:1] OpsGenieAlert \nhttp://localhost:3000/alerting/list\n\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = OpsGenieAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_OpsGenieAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DOpsGenieAlert\n", + "description": "[FIRING:1] OpsGenieAlert \nhttp://localhost:3000/alerting/list\n\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = OpsGenieAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_OpsGenieAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DOpsGenieAlert\n", "details": { "url": "http://localhost:3000/alerting/list" }, diff --git a/pkg/tsdb/azuremonitor/azlog/azlog.go b/pkg/tsdb/azuremonitor/azlog/azlog.go new file mode 100644 index 00000000000..bb946053175 --- /dev/null +++ b/pkg/tsdb/azuremonitor/azlog/azlog.go @@ -0,0 +1,23 @@ +package azlog + +import "github.com/grafana/grafana/pkg/infra/log" + +var ( + azlog = log.New("tsdb.azuremonitor") +) + +func Warn(msg string, args ...interface{}) { + azlog.Warn(msg, args) +} + +func Debug(msg string, args ...interface{}) { + azlog.Debug(msg, args) +} + +func Error(msg string, args ...interface{}) { + azlog.Error(msg, args) +} + +func Info(msg string, args ...interface{}) { + azlog.Info(msg, args) +} diff --git a/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go b/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go index 92ee30ec8cb..a07295868e5 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go +++ b/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go @@ -8,6 +8,9 @@ import ( "strings" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/deprecated" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) func getTarget(original string) (target string, err error) { @@ -63,16 +66,16 @@ func (s *httpServiceProxy) Do(rw http.ResponseWriter, req *http.Request, cli *ht return rw } -func (s *Service) getDataSourceFromHTTPReq(req *http.Request) (datasourceInfo, error) { +func (s *Service) getDataSourceFromHTTPReq(req *http.Request) (types.DatasourceInfo, error) { ctx := req.Context() pluginContext := httpadapter.PluginConfigFromContext(ctx) i, err := s.im.Get(pluginContext) if err != nil { - return datasourceInfo{}, nil + return types.DatasourceInfo{}, nil } - ds, ok := i.(datasourceInfo) + ds, ok := i.(types.DatasourceInfo) if !ok { - return datasourceInfo{}, fmt.Errorf("unable to convert datasource from service instance") + return types.DatasourceInfo{}, fmt.Errorf("unable to convert datasource from service instance") } return ds, nil } @@ -111,7 +114,7 @@ func (s *Service) handleResourceReq(subDataSource string) func(rw http.ResponseW req.URL.Host = serviceURL.Host req.URL.Scheme = serviceURL.Scheme - s.executors[subDataSource].resourceRequest(rw, req, service.HTTPClient) + s.executors[subDataSource].ResourceRequest(rw, req, service.HTTPClient) } } @@ -120,8 +123,9 @@ func (s *Service) handleResourceReq(subDataSource string) func(rw http.ResponseW func (s *Service) newResourceMux() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/azuremonitor/", s.handleResourceReq(azureMonitor)) - mux.HandleFunc("/appinsights/", s.handleResourceReq(appInsights)) mux.HandleFunc("/loganalytics/", s.handleResourceReq(azureLogAnalytics)) mux.HandleFunc("/resourcegraph/", s.handleResourceReq(azureResourceGraph)) + // Remove with Grafana 9 + mux.HandleFunc("/appinsights/", s.handleResourceReq(deprecated.AppInsights)) return mux } diff --git a/pkg/tsdb/azuremonitor/azuremonitor-resource-handler_test.go b/pkg/tsdb/azuremonitor/azuremonitor-resource-handler_test.go index 849bb3c9981..efe20cc8f30 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-resource-handler_test.go +++ b/pkg/tsdb/azuremonitor/azuremonitor-resource-handler_test.go @@ -7,6 +7,8 @@ import ( "testing" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/metrics" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/stretchr/testify/require" ) @@ -95,7 +97,7 @@ func Test_handleResourceReq(t *testing.T) { proxy := &fakeProxy{} s := Service{ im: &fakeInstance{ - services: map[string]datasourceService{ + services: map[string]types.DatasourceService{ azureMonitor: { URL: routes[setting.AzurePublic][azureMonitor].URL, HTTPClient: &http.Client{}, @@ -103,8 +105,8 @@ func Test_handleResourceReq(t *testing.T) { }, }, executors: map[string]azDatasourceExecutor{ - azureMonitor: &AzureMonitorDatasource{ - proxy: proxy, + azureMonitor: &metrics.AzureMonitorDatasource{ + Proxy: proxy, }, }, } diff --git a/pkg/tsdb/azuremonitor/azuremonitor.go b/pkg/tsdb/azuremonitor/azuremonitor.go index b40ecda8695..10d54716027 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor.go +++ b/pkg/tsdb/azuremonitor/azuremonitor.go @@ -5,39 +5,38 @@ import ( "encoding/json" "fmt" "net/http" - "regexp" + "github.com/Masterminds/semver" "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/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" -) - -const ( - timeSeries = "time_series" -) - -var ( - azlog = log.New("tsdb.azuremonitor") - legendKeyFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`) + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/deprecated" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/loganalytics" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/metrics" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/resourcegraph" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) func ProvideService(cfg *setting.Cfg, httpClientProvider *httpclient.Provider, tracer tracing.Tracer) *Service { proxy := &httpServiceProxy{} executors := map[string]azDatasourceExecutor{ - azureMonitor: &AzureMonitorDatasource{proxy: proxy}, - appInsights: &ApplicationInsightsDatasource{proxy: proxy}, - azureLogAnalytics: &AzureLogAnalyticsDatasource{proxy: proxy}, - insightsAnalytics: &InsightsAnalyticsDatasource{proxy: proxy}, - azureResourceGraph: &AzureResourceGraphDatasource{proxy: proxy}, + azureMonitor: &metrics.AzureMonitorDatasource{Proxy: proxy}, + azureLogAnalytics: &loganalytics.AzureLogAnalyticsDatasource{Proxy: proxy}, + azureResourceGraph: &resourcegraph.AzureResourceGraphDatasource{Proxy: proxy}, } + + // Insights Analytics and Application Insights were deprecated in Grafana 8.x and + // will be finally removed with Grafana 9 + if setting.BuildVersion != "" && semver.MustParse(setting.BuildVersion).Compare(semver.MustParse("9.0.0-beta1")) < 0 { + executors[deprecated.InsightsAnalytics] = &deprecated.InsightsAnalyticsDatasource{Proxy: proxy} + executors[deprecated.AppInsights] = &deprecated.ApplicationInsightsDatasource{Proxy: proxy} + } + im := datasource.NewInstanceManager(NewInstanceSettings(cfg, *httpClientProvider, executors)) s := &Service{ @@ -60,10 +59,6 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq return s.resourceHandler.CallResource(ctx, req, sender) } -type serviceProxy interface { - Do(rw http.ResponseWriter, req *http.Request, cli *http.Client) http.ResponseWriter -} - type Service struct { im instancemgmt.InstanceManager executors map[string]azDatasourceExecutor @@ -73,37 +68,13 @@ type Service struct { tracer tracing.Tracer } -type azureMonitorSettings struct { - SubscriptionId string `json:"subscriptionId"` - LogAnalyticsDefaultWorkspace string `json:"logAnalyticsDefaultWorkspace"` - AppInsightsAppId string `json:"appInsightsAppId"` -} - -type datasourceInfo struct { - Cloud string - Credentials azcredentials.AzureCredentials - Settings azureMonitorSettings - Routes map[string]azRoute - Services map[string]datasourceService - - JSONData map[string]interface{} - DecryptedSecureJSONData map[string]string - DatasourceID int64 - OrgID int64 -} - -type datasourceService struct { - URL string - HTTPClient *http.Client -} - -func getDatasourceService(cfg *setting.Cfg, clientProvider httpclient.Provider, dsInfo datasourceInfo, routeName string) (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 { - return datasourceService{}, err + return types.DatasourceService{}, err } - return datasourceService{ + return types.DatasourceService{ URL: dsInfo.Routes[routeName].URL, HTTPClient: client, }, nil @@ -122,7 +93,7 @@ func NewInstanceSettings(cfg *setting.Cfg, clientProvider httpclient.Provider, e return nil, fmt.Errorf("error reading settings: %w", err) } - azMonitorSettings := azureMonitorSettings{} + azMonitorSettings := types.AzureMonitorSettings{} err = json.Unmarshal(settings.JSONData, &azMonitorSettings) if err != nil { return nil, fmt.Errorf("error reading settings: %w", err) @@ -138,7 +109,7 @@ func NewInstanceSettings(cfg *setting.Cfg, clientProvider httpclient.Provider, e return nil, fmt.Errorf("error getting credentials: %w", err) } - model := datasourceInfo{ + model := types.DatasourceInfo{ Cloud: cloud, Credentials: credentials, Settings: azMonitorSettings, @@ -146,7 +117,7 @@ func NewInstanceSettings(cfg *setting.Cfg, clientProvider httpclient.Provider, e DecryptedSecureJSONData: settings.DecryptedSecureJSONData, DatasourceID: settings.ID, Routes: routes[cloud], - Services: map[string]datasourceService{}, + Services: map[string]types.DatasourceService{}, } for routeName := range executors { @@ -162,18 +133,18 @@ func NewInstanceSettings(cfg *setting.Cfg, clientProvider httpclient.Provider, e } type azDatasourceExecutor interface { - executeTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo datasourceInfo, client *http.Client, url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) - resourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) + ExecuteTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) + ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) } -func (s *Service) getDataSourceFromPluginReq(req *backend.QueryDataRequest) (datasourceInfo, error) { +func (s *Service) getDataSourceFromPluginReq(req *backend.QueryDataRequest) (types.DatasourceInfo, error) { i, err := s.im.Get(req.PluginContext) if err != nil { - return datasourceInfo{}, err + return types.DatasourceInfo{}, err } - dsInfo, ok := i.(datasourceInfo) + dsInfo, ok := i.(types.DatasourceInfo) if !ok { - return datasourceInfo{}, fmt.Errorf("unable to convert datasource from service instance") + return types.DatasourceInfo{}, fmt.Errorf("unable to convert datasource from service instance") } dsInfo.OrgID = req.PluginContext.OrgID return dsInfo, nil @@ -194,7 +165,7 @@ func (s *Service) newQueryMux() *datasource.QueryTypeMux { if !ok { return nil, fmt.Errorf("missing service for %s", dst) } - return executor.executeTimeSeriesQuery(ctx, req.Queries, dsInfo, service.HTTPClient, service.URL, s.tracer) + return executor.ExecuteTimeSeriesQuery(ctx, req.Queries, dsInfo, service.HTTPClient, service.URL, s.tracer) }) } return mux diff --git a/pkg/tsdb/azuremonitor/azuremonitor_test.go b/pkg/tsdb/azuremonitor/azuremonitor_test.go index 5b556242295..656e221d3d0 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor_test.go +++ b/pkg/tsdb/azuremonitor/azuremonitor_test.go @@ -12,14 +12,45 @@ import ( "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" ) +func TestProvideService(t *testing.T) { + t.Run("it should skip insight analytics and app insights with Grafana 9", func(t *testing.T) { + currentV := setting.BuildVersion + t.Cleanup(func() { + setting.BuildVersion = currentV + }) + versions := []struct { + version string + shouldIncludeInsights bool + }{ + {"8.5.0", true}, + {"9.0.0-beta1", false}, + {"9.0.0", false}, + } + for _, v := range versions { + setting.BuildVersion = v.version + s := ProvideService(setting.NewCfg(), httpclient.NewProvider(), nil) + if v.shouldIncludeInsights { + assert.NotNil(t, s.executors[deprecated.InsightsAnalytics]) + assert.NotNil(t, s.executors[deprecated.AppInsights]) + } else { + assert.Nil(t, s.executors[deprecated.InsightsAnalytics]) + assert.Nil(t, s.executors[deprecated.AppInsights]) + } + } + }) +} + func TestNewInstanceSettings(t *testing.T) { tests := []struct { name string settings backend.DataSourceInstanceSettings - expectedModel datasourceInfo + expectedModel types.DatasourceInfo Err require.ErrorAssertionFunc }{ { @@ -29,15 +60,15 @@ func TestNewInstanceSettings(t *testing.T) { DecryptedSecureJSONData: map[string]string{"key": "value"}, ID: 40, }, - expectedModel: datasourceInfo{ + expectedModel: types.DatasourceInfo{ Cloud: setting.AzurePublic, Credentials: &azcredentials.AzureManagedIdentityCredentials{}, - Settings: azureMonitorSettings{}, + Settings: types.AzureMonitorSettings{}, Routes: routes[setting.AzurePublic], JSONData: map[string]interface{}{"azureAuthType": "msi"}, DatasourceID: 40, DecryptedSecureJSONData: map[string]string{"key": "value"}, - Services: map[string]datasourceService{}, + Services: map[string]types.DatasourceService{}, }, Err: require.NoError, }, @@ -62,12 +93,12 @@ func TestNewInstanceSettings(t *testing.T) { } type fakeInstance struct { - routes map[string]azRoute - services map[string]datasourceService + routes map[string]types.AzRoute + services map[string]types.DatasourceService } func (f *fakeInstance) Get(pluginContext backend.PluginContext) (instancemgmt.Instance, error) { - return datasourceInfo{ + return types.DatasourceInfo{ Routes: f.routes, Services: f.services, }, nil @@ -83,10 +114,10 @@ type fakeExecutor struct { expectedURL string } -func (f *fakeExecutor) resourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) { +func (f *fakeExecutor) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) { } -func (f *fakeExecutor) executeTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo datasourceInfo, client *http.Client, +func (f *fakeExecutor) ExecuteTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { if client == nil { f.t.Errorf("The HTTP client for %s is missing", f.queryType) @@ -124,7 +155,7 @@ func Test_newMux(t *testing.T) { s := &Service{ im: &fakeInstance{ routes: routes[azureMonitorPublic], - services: map[string]datasourceService{ + services: map[string]types.DatasourceService{ tt.queryType: { URL: routes[azureMonitorPublic][tt.queryType].URL, HTTPClient: &http.Client{}, diff --git a/pkg/tsdb/azuremonitor/applicationinsights-datasource.go b/pkg/tsdb/azuremonitor/deprecated/applicationinsights-datasource.go similarity index 90% rename from pkg/tsdb/azuremonitor/applicationinsights-datasource.go rename to pkg/tsdb/azuremonitor/deprecated/applicationinsights-datasource.go index 26905bfe81e..c75b79ea252 100644 --- a/pkg/tsdb/azuremonitor/applicationinsights-datasource.go +++ b/pkg/tsdb/azuremonitor/deprecated/applicationinsights-datasource.go @@ -1,4 +1,4 @@ -package azuremonitor +package deprecated import ( "context" @@ -15,6 +15,9 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" + azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/grafana/grafana/pkg/util/errutil" "go.opentelemetry.io/otel/attribute" "golang.org/x/net/context/ctxhttp" @@ -22,7 +25,7 @@ import ( // ApplicationInsightsDatasource calls the application insights query API. type ApplicationInsightsDatasource struct { - proxy serviceProxy + Proxy types.ServiceProxy } // ApplicationInsightsQuery is the model that holds the information @@ -44,12 +47,12 @@ type ApplicationInsightsQuery struct { aggregation string } -func (e *ApplicationInsightsDatasource) resourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) { - e.proxy.Do(rw, req, cli) +func (e *ApplicationInsightsDatasource) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) { + e.Proxy.Do(rw, req, cli) } -func (e *ApplicationInsightsDatasource) executeTimeSeriesQuery(ctx context.Context, - originalQueries []backend.DataQuery, dsInfo datasourceInfo, client *http.Client, +func (e *ApplicationInsightsDatasource) ExecuteTimeSeriesQuery(ctx context.Context, + originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { result := backend.NewQueryDataResponse() @@ -93,7 +96,7 @@ func (e *ApplicationInsightsDatasource) buildQueries(queries []backend.DataQuery // Previous versions of the query model don't specify a time grain, so we // need to fallback to a default value if timeGrain == "auto" || timeGrain == "" { - timeGrain, err = setAutoTimeGrain(query.Interval.Milliseconds(), timeGrains) + timeGrain, err = azTime.SetAutoTimeGrain(query.Interval.Milliseconds(), timeGrains) if err != nil { return nil, err } @@ -130,7 +133,7 @@ func (e *ApplicationInsightsDatasource) buildQueries(queries []backend.DataQuery return applicationInsightsQueries, nil } -func (e *ApplicationInsightsDatasource) executeQuery(ctx context.Context, query *ApplicationInsightsQuery, dsInfo datasourceInfo, client *http.Client, url string, tracer tracing.Tracer) ( +func (e *ApplicationInsightsDatasource) executeQuery(ctx context.Context, query *ApplicationInsightsQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) ( backend.DataResponse, error) { dataResponse := backend.DataResponse{} @@ -194,7 +197,7 @@ func (e *ApplicationInsightsDatasource) executeQuery(ctx context.Context, query return dataResponse, nil } -func (e *ApplicationInsightsDatasource) createRequest(ctx context.Context, dsInfo datasourceInfo, url string) (*http.Request, error) { +func (e *ApplicationInsightsDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) { appInsightsAppID := dsInfo.Settings.AppInsightsAppId req, err := http.NewRequest(http.MethodGet, url, nil) @@ -221,7 +224,7 @@ func formatApplicationInsightsLegendKey(alias string, metricName string, labels } keys = sort.StringSlice(keys) - result := legendKeyFormat.ReplaceAllFunc([]byte(alias), func(in []byte) []byte { + result := types.LegendKeyFormat.ReplaceAllFunc([]byte(alias), func(in []byte) []byte { metaPartName := strings.Replace(string(in), "{{", "", 1) metaPartName = strings.Replace(metaPartName, "}}", "", 1) metaPartName = strings.ToLower(strings.TrimSpace(metaPartName)) diff --git a/pkg/tsdb/azuremonitor/applicationinsights-datasource_test.go b/pkg/tsdb/azuremonitor/deprecated/applicationinsights-datasource_test.go similarity index 97% rename from pkg/tsdb/azuremonitor/applicationinsights-datasource_test.go rename to pkg/tsdb/azuremonitor/deprecated/applicationinsights-datasource_test.go index 6084036404a..6728c6b721e 100644 --- a/pkg/tsdb/azuremonitor/applicationinsights-datasource_test.go +++ b/pkg/tsdb/azuremonitor/deprecated/applicationinsights-datasource_test.go @@ -1,4 +1,4 @@ -package azuremonitor +package deprecated import ( "context" @@ -7,6 +7,7 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/stretchr/testify/require" ) @@ -204,8 +205,8 @@ func TestInsightsDimensionsUnmarshalJSON(t *testing.T) { func TestAppInsightsCreateRequest(t *testing.T) { ctx := context.Background() url := "http://ds" - dsInfo := datasourceInfo{ - Settings: azureMonitorSettings{AppInsightsAppId: "foo"}, + dsInfo := types.DatasourceInfo{ + Settings: types.AzureMonitorSettings{AppInsightsAppId: "foo"}, DecryptedSecureJSONData: map[string]string{ "appInsightsApiKey": "key", }, diff --git a/pkg/tsdb/azuremonitor/applicationinsights-metrics.go b/pkg/tsdb/azuremonitor/deprecated/applicationinsights-metrics.go similarity index 99% rename from pkg/tsdb/azuremonitor/applicationinsights-metrics.go rename to pkg/tsdb/azuremonitor/deprecated/applicationinsights-metrics.go index f6f547f3eb4..316151ab643 100644 --- a/pkg/tsdb/azuremonitor/applicationinsights-metrics.go +++ b/pkg/tsdb/azuremonitor/deprecated/applicationinsights-metrics.go @@ -1,4 +1,4 @@ -package azuremonitor +package deprecated import ( "encoding/json" diff --git a/pkg/tsdb/azuremonitor/applicationinsights-metrics_test.go b/pkg/tsdb/azuremonitor/deprecated/applicationinsights-metrics_test.go similarity index 99% rename from pkg/tsdb/azuremonitor/applicationinsights-metrics_test.go rename to pkg/tsdb/azuremonitor/deprecated/applicationinsights-metrics_test.go index 9b9bb3b6718..a56c2bff4c3 100644 --- a/pkg/tsdb/azuremonitor/applicationinsights-metrics_test.go +++ b/pkg/tsdb/azuremonitor/deprecated/applicationinsights-metrics_test.go @@ -1,4 +1,4 @@ -package azuremonitor +package deprecated import ( "encoding/json" @@ -173,7 +173,7 @@ func TestInsightsMetricsResultToFrame(t *testing.T) { func loadInsightsMetricsResponse(t *testing.T, name string) MetricsResult { t.Helper() - path := filepath.Join("testdata", name) + path := filepath.Join("../testdata", name) // Ignore gosec warning G304 since it's a test // nolint:gosec f, err := os.Open(path) diff --git a/pkg/tsdb/azuremonitor/deprecated/httpclient.go b/pkg/tsdb/azuremonitor/deprecated/httpclient.go new file mode 100644 index 00000000000..39eecf9458e --- /dev/null +++ b/pkg/tsdb/azuremonitor/deprecated/httpclient.go @@ -0,0 +1,20 @@ +package deprecated + +import ( + "net/http" + + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" +) + +func GetAppInsightsMiddleware(url, appInsightsApiKey string) httpclient.Middleware { + if appInsightsApiKey != "" && url == AzAppInsights.URL || url == AzChinaAppInsights.URL { + // Inject API-Key for AppInsights + return httpclient.MiddlewareFunc(func(opts httpclient.Options, next http.RoundTripper) http.RoundTripper { + return httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + req.Header.Set("X-API-Key", appInsightsApiKey) + return next.RoundTrip(req) + }) + }) + } + return nil +} diff --git a/pkg/tsdb/azuremonitor/insights-analytics-datasource.go b/pkg/tsdb/azuremonitor/deprecated/insights-analytics-datasource.go similarity index 82% rename from pkg/tsdb/azuremonitor/insights-analytics-datasource.go rename to pkg/tsdb/azuremonitor/deprecated/insights-analytics-datasource.go index 4b4ee49edb6..27a6c866e67 100644 --- a/pkg/tsdb/azuremonitor/insights-analytics-datasource.go +++ b/pkg/tsdb/azuremonitor/deprecated/insights-analytics-datasource.go @@ -1,4 +1,4 @@ -package azuremonitor +package deprecated import ( "bytes" @@ -13,13 +13,17 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/loganalytics" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/macros" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/grafana/grafana/pkg/util/errutil" "go.opentelemetry.io/otel/attribute" "golang.org/x/net/context/ctxhttp" ) type InsightsAnalyticsDatasource struct { - proxy serviceProxy + Proxy types.ServiceProxy } type InsightsAnalyticsQuery struct { @@ -34,12 +38,12 @@ type InsightsAnalyticsQuery struct { Target string } -func (e *InsightsAnalyticsDatasource) resourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) { - e.proxy.Do(rw, req, cli) +func (e *InsightsAnalyticsDatasource) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) { + e.Proxy.Do(rw, req, cli) } -func (e *InsightsAnalyticsDatasource) executeTimeSeriesQuery(ctx context.Context, - originalQueries []backend.DataQuery, dsInfo datasourceInfo, client *http.Client, +func (e *InsightsAnalyticsDatasource) ExecuteTimeSeriesQuery(ctx context.Context, + originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { result := backend.NewQueryDataResponse() @@ -55,7 +59,7 @@ func (e *InsightsAnalyticsDatasource) executeTimeSeriesQuery(ctx context.Context return result, nil } -func (e *InsightsAnalyticsDatasource) buildQueries(queries []backend.DataQuery, dsInfo datasourceInfo) ([]*InsightsAnalyticsQuery, error) { +func (e *InsightsAnalyticsDatasource) buildQueries(queries []backend.DataQuery, dsInfo types.DatasourceInfo) ([]*InsightsAnalyticsQuery, error) { iaQueries := []*InsightsAnalyticsQuery{} for _, query := range queries { @@ -74,7 +78,7 @@ func (e *InsightsAnalyticsDatasource) buildQueries(queries []backend.DataQuery, return nil, fmt.Errorf("query is missing query string property") } - qm.InterpolatedQuery, err = KqlInterpolate(query, dsInfo, qm.RawQuery) + qm.InterpolatedQuery, err = macros.KqlInterpolate(query, dsInfo, qm.RawQuery) if err != nil { return nil, err } @@ -88,7 +92,7 @@ func (e *InsightsAnalyticsDatasource) buildQueries(queries []backend.DataQuery, return iaQueries, nil } -func (e *InsightsAnalyticsDatasource) executeQuery(ctx context.Context, query *InsightsAnalyticsQuery, dsInfo datasourceInfo, client *http.Client, +func (e *InsightsAnalyticsDatasource) executeQuery(ctx context.Context, query *InsightsAnalyticsQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) backend.DataResponse { dataResponse := backend.DataResponse{} @@ -136,7 +140,7 @@ func (e *InsightsAnalyticsDatasource) executeQuery(ctx context.Context, query *I azlog.Debug("Request failed", "status", res.Status, "body", string(body)) return dataResponseError(fmt.Errorf("request failed, status: %s, body: %s", res.Status, body)) } - var logResponse AzureLogAnalyticsResponse + var logResponse loganalytics.AzureLogAnalyticsResponse d := json.NewDecoder(bytes.NewReader(body)) d.UseNumber() err = d.Decode(&logResponse) @@ -149,12 +153,12 @@ func (e *InsightsAnalyticsDatasource) executeQuery(ctx context.Context, query *I return dataResponseError(err) } - frame, err := ResponseTableToFrame(t) + frame, err := loganalytics.ResponseTableToFrame(t) if err != nil { return dataResponseError(err) } - if query.ResultFormat == timeSeries { + if query.ResultFormat == types.TimeSeries { tsSchema := frame.TimeSeriesSchema() if tsSchema.Type == data.TimeSeriesTypeLong { wideFrame, err := data.LongToWide(frame, nil) @@ -173,7 +177,7 @@ func (e *InsightsAnalyticsDatasource) executeQuery(ctx context.Context, query *I return dataResponse } -func (e *InsightsAnalyticsDatasource) createRequest(ctx context.Context, dsInfo datasourceInfo, url string) (*http.Request, error) { +func (e *InsightsAnalyticsDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) { appInsightsAppID := dsInfo.Settings.AppInsightsAppId req, err := http.NewRequest(http.MethodGet, url, nil) diff --git a/pkg/tsdb/azuremonitor/insights-analytics-datasource_test.go b/pkg/tsdb/azuremonitor/deprecated/insights-analytics-datasource_test.go similarity index 82% rename from pkg/tsdb/azuremonitor/insights-analytics-datasource_test.go rename to pkg/tsdb/azuremonitor/deprecated/insights-analytics-datasource_test.go index 8b0e118e987..959c8cfe9c1 100644 --- a/pkg/tsdb/azuremonitor/insights-analytics-datasource_test.go +++ b/pkg/tsdb/azuremonitor/deprecated/insights-analytics-datasource_test.go @@ -1,18 +1,19 @@ -package azuremonitor +package deprecated import ( "context" "net/http" "testing" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/stretchr/testify/require" ) func TestInsightsAnalyticsCreateRequest(t *testing.T) { ctx := context.Background() url := "http://ds" - dsInfo := datasourceInfo{ - Settings: azureMonitorSettings{AppInsightsAppId: "foo"}, + dsInfo := types.DatasourceInfo{ + Settings: types.AzureMonitorSettings{AppInsightsAppId: "foo"}, DecryptedSecureJSONData: map[string]string{ "appInsightsApiKey": "key", }, diff --git a/pkg/tsdb/azuremonitor/deprecated/routes.go b/pkg/tsdb/azuremonitor/deprecated/routes.go new file mode 100644 index 00000000000..9b2d5e58823 --- /dev/null +++ b/pkg/tsdb/azuremonitor/deprecated/routes.go @@ -0,0 +1,23 @@ +package deprecated + +import ( + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" +) + +// Azure cloud query types +const ( + AppInsights = "Application Insights" + InsightsAnalytics = "Insights Analytics" +) + +var AzAppInsights = types.AzRoute{ + URL: "https://api.applicationinsights.io", + Scopes: []string{}, + Headers: map[string]string{"x-ms-app": "Grafana"}, +} + +var AzChinaAppInsights = types.AzRoute{ + URL: "https://api.applicationinsights.azure.cn", + Scopes: []string{}, + Headers: map[string]string{"x-ms-app": "Grafana"}, +} diff --git a/pkg/tsdb/azuremonitor/deprecated/types.go b/pkg/tsdb/azuremonitor/deprecated/types.go new file mode 100644 index 00000000000..487f28bfdfc --- /dev/null +++ b/pkg/tsdb/azuremonitor/deprecated/types.go @@ -0,0 +1,72 @@ +package deprecated + +import ( + "encoding/json" + "fmt" + "strings" +) + +// insightsJSONQuery is the frontend JSON query model for an Azure Application Insights query. +type insightsJSONQuery struct { + AppInsights struct { + Aggregation string `json:"aggregation"` + Alias string `json:"alias"` + AllowedTimeGrainsMs []int64 `json:"allowedTimeGrainsMs"` + Dimensions InsightsDimensions `json:"dimension"` + DimensionFilter string `json:"dimensionFilter"` + MetricName string `json:"metricName"` + TimeGrain string `json:"timeGrain"` + } `json:"appInsights"` + Raw *bool `json:"raw"` +} + +// InsightsDimensions will unmarshal from a JSON string, or an array of strings, +// into a string array. This exists to support an older query format which is updated +// when a user saves the query or it is sent from the front end, but may not be when +// alerting fetches the model. +type InsightsDimensions []string + +// UnmarshalJSON fulfills the json.Unmarshaler interface type. +func (s *InsightsDimensions) UnmarshalJSON(data []byte) error { + *s = InsightsDimensions{} + if string(data) == "null" || string(data) == "" { + return nil + } + if strings.ToLower(string(data)) == `"none"` { + return nil + } + if data[0] == '[' { + var sa []string + err := json.Unmarshal(data, &sa) + if err != nil { + return err + } + dimensions := []string{} + for _, v := range sa { + if v == "none" || v == "None" { + continue + } + dimensions = append(dimensions, v) + } + *s = InsightsDimensions(dimensions) + return nil + } + + var str string + err := json.Unmarshal(data, &str) + if err != nil { + return fmt.Errorf("could not parse %q as string or array: %w", string(data), err) + } + if str != "" { + *s = InsightsDimensions{str} + return nil + } + return nil +} + +type insightsAnalyticsJSONQuery struct { + InsightsAnalytics struct { + Query string `json:"query"` + ResultFormat string `json:"resultFormat"` + } `json:"insightsAnalytics"` +} diff --git a/pkg/tsdb/azuremonitor/httpclient.go b/pkg/tsdb/azuremonitor/httpclient.go index af615680ae5..ad031264f89 100644 --- a/pkg/tsdb/azuremonitor/httpclient.go +++ b/pkg/tsdb/azuremonitor/httpclient.go @@ -6,9 +6,11 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/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 azRoute, model datasourceInfo, cfg *setting.Cfg) ([]httpclient.Middleware, error) { +func getMiddlewares(route types.AzRoute, model types.DatasourceInfo, cfg *setting.Cfg) ([]httpclient.Middleware, error) { middlewares := []httpclient.Middleware{} if len(route.Scopes) > 0 { @@ -19,21 +21,15 @@ func getMiddlewares(route azRoute, model datasourceInfo, cfg *setting.Cfg) ([]ht middlewares = append(middlewares, aztokenprovider.AuthMiddleware(tokenProvider, route.Scopes)) } - if _, ok := model.DecryptedSecureJSONData["appInsightsApiKey"]; ok && (route.URL == azAppInsights.URL || route.URL == azChinaAppInsights.URL) { - // Inject API-Key for AppInsights - apiKeyMiddleware := httpclient.MiddlewareFunc(func(opts httpclient.Options, next http.RoundTripper) http.RoundTripper { - return httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { - req.Header.Set("X-API-Key", model.DecryptedSecureJSONData["appInsightsApiKey"]) - return next.RoundTrip(req) - }) - }) + // Remove with Grafana 9 + if apiKeyMiddleware := deprecated.GetAppInsightsMiddleware(route.URL, model.DecryptedSecureJSONData["appInsightsApiKey"]); apiKeyMiddleware != nil { middlewares = append(middlewares, apiKeyMiddleware) } return middlewares, nil } -func newHTTPClient(route azRoute, model datasourceInfo, cfg *setting.Cfg, clientProvider httpclient.Provider) (*http.Client, error) { +func newHTTPClient(route types.AzRoute, model types.DatasourceInfo, cfg *setting.Cfg, clientProvider httpclient.Provider) (*http.Client, error) { m, err := getMiddlewares(route, model, cfg) if err != nil { return nil, err diff --git a/pkg/tsdb/azuremonitor/httpclient_test.go b/pkg/tsdb/azuremonitor/httpclient_test.go index 895d2fd7722..5679529459c 100644 --- a/pkg/tsdb/azuremonitor/httpclient_test.go +++ b/pkg/tsdb/azuremonitor/httpclient_test.go @@ -5,6 +5,8 @@ import ( "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/require" ) @@ -12,18 +14,18 @@ func Test_httpCliProvider(t *testing.T) { cfg := &setting.Cfg{} tests := []struct { name string - route azRoute - model datasourceInfo + route types.AzRoute + model types.DatasourceInfo expectedMiddlewares int Err require.ErrorAssertionFunc }{ { name: "creates an HTTP client with a middleware due to the scope", - route: azRoute{ + route: types.AzRoute{ URL: "http://route", Scopes: []string{"http://route/.default"}, }, - model: datasourceInfo{ + model: types.DatasourceInfo{ Credentials: &azcredentials.AzureClientSecretCredentials{}, }, expectedMiddlewares: 1, @@ -31,11 +33,11 @@ func Test_httpCliProvider(t *testing.T) { }, { name: "creates an HTTP client with a middleware due to an app key", - route: azRoute{ - URL: azAppInsights.URL, + route: types.AzRoute{ + URL: deprecated.AzAppInsights.URL, Scopes: []string{}, }, - model: datasourceInfo{ + model: types.DatasourceInfo{ Credentials: &azcredentials.AzureClientSecretCredentials{}, DecryptedSecureJSONData: map[string]string{ "appInsightsApiKey": "foo", @@ -46,11 +48,11 @@ func Test_httpCliProvider(t *testing.T) { }, { name: "creates an HTTP client without a middleware", - route: azRoute{ + route: types.AzRoute{ URL: "http://route", Scopes: []string{}, }, - model: datasourceInfo{ + model: types.DatasourceInfo{ Credentials: &azcredentials.AzureClientSecretCredentials{}, }, expectedMiddlewares: 0, diff --git a/pkg/tsdb/azuremonitor/azure-log-analytics-datasource.go b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go similarity index 87% rename from pkg/tsdb/azuremonitor/azure-log-analytics-datasource.go rename to pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go index 964ec995c78..cc96a88ebac 100644 --- a/pkg/tsdb/azuremonitor/azure-log-analytics-datasource.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go @@ -1,4 +1,4 @@ -package azuremonitor +package loganalytics import ( "bytes" @@ -17,6 +17,9 @@ import ( "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/tsdb/azuremonitor/azlog" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/macros" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/grafana/grafana/pkg/util/errutil" "go.opentelemetry.io/otel/attribute" "golang.org/x/net/context/ctxhttp" @@ -24,7 +27,7 @@ import ( // AzureLogAnalyticsDatasource calls the Azure Log Analytics API's type AzureLogAnalyticsDatasource struct { - proxy serviceProxy + Proxy types.ServiceProxy } // AzureLogAnalyticsQuery is the query request that is built from the saved values for @@ -39,15 +42,15 @@ type AzureLogAnalyticsQuery struct { TimeRange backend.TimeRange } -func (e *AzureLogAnalyticsDatasource) resourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) { - e.proxy.Do(rw, req, cli) +func (e *AzureLogAnalyticsDatasource) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) { + e.Proxy.Do(rw, req, cli) } // executeTimeSeriesQuery does the following: // 1. build the AzureMonitor url and querystring for each query // 2. executes each query by calling the Azure Monitor API // 3. parses the responses for each query into data frames -func (e *AzureLogAnalyticsDatasource) executeTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo datasourceInfo, client *http.Client, +func (e *AzureLogAnalyticsDatasource) ExecuteTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { result := backend.NewQueryDataResponse() @@ -63,7 +66,7 @@ func (e *AzureLogAnalyticsDatasource) executeTimeSeriesQuery(ctx context.Context return result, nil } -func getApiURL(queryJSONModel logJSONQuery) string { +func getApiURL(queryJSONModel types.LogJSONQuery) string { // Legacy queries only specify a Workspace GUID, which we need to use the old workspace-centric // API URL for, and newer queries specifying a resource URI should use resource-centric API. // However, legacy workspace queries using a `workspaces()` template variable will be resolved @@ -86,11 +89,11 @@ func getApiURL(queryJSONModel logJSONQuery) string { } } -func (e *AzureLogAnalyticsDatasource) buildQueries(queries []backend.DataQuery, dsInfo datasourceInfo) ([]*AzureLogAnalyticsQuery, error) { +func (e *AzureLogAnalyticsDatasource) buildQueries(queries []backend.DataQuery, dsInfo types.DatasourceInfo) ([]*AzureLogAnalyticsQuery, error) { azureLogAnalyticsQueries := []*AzureLogAnalyticsQuery{} for _, query := range queries { - queryJSONModel := logJSONQuery{} + queryJSONModel := types.LogJSONQuery{} err := json.Unmarshal(query.JSON, &queryJSONModel) if err != nil { return nil, fmt.Errorf("failed to decode the Azure Log Analytics query object from JSON: %w", err) @@ -101,13 +104,13 @@ func (e *AzureLogAnalyticsDatasource) buildQueries(queries []backend.DataQuery, resultFormat := azureLogAnalyticsTarget.ResultFormat if resultFormat == "" { - resultFormat = timeSeries + resultFormat = types.TimeSeries } apiURL := getApiURL(queryJSONModel) params := url.Values{} - rawQuery, err := KqlInterpolate(query, dsInfo, azureLogAnalyticsTarget.Query, "TimeGenerated") + rawQuery, err := macros.KqlInterpolate(query, dsInfo, azureLogAnalyticsTarget.Query, "TimeGenerated") if err != nil { return nil, err } @@ -127,7 +130,7 @@ func (e *AzureLogAnalyticsDatasource) buildQueries(queries []backend.DataQuery, return azureLogAnalyticsQueries, nil } -func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *AzureLogAnalyticsQuery, dsInfo datasourceInfo, client *http.Client, +func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *AzureLogAnalyticsQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) backend.DataResponse { dataResponse := backend.DataResponse{} @@ -204,7 +207,7 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A azlog.Warn("failed to add custom metadata to azure log analytics response", err) } - if query.ResultFormat == timeSeries { + if query.ResultFormat == types.TimeSeries { tsSchema := frame.TimeSeriesSchema() if tsSchema.Type == data.TimeSeriesTypeLong { wideFrame, err := data.LongToWide(frame, nil) @@ -220,7 +223,7 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A return dataResponse } -func (e *AzureLogAnalyticsDatasource) createRequest(ctx context.Context, dsInfo datasourceInfo, url string) (*http.Request, error) { +func (e *AzureLogAnalyticsDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { azlog.Debug("Failed to create request", "error", err) @@ -232,9 +235,14 @@ func (e *AzureLogAnalyticsDatasource) createRequest(ctx context.Context, dsInfo return req, nil } +// AzureLogAnalyticsResponse is the json response object from the Azure Log Analytics API. +type AzureLogAnalyticsResponse struct { + Tables []types.AzureResponseTable `json:"tables"` +} + // GetPrimaryResultTable returns the first table in the response named "PrimaryResult", or an // error if there is no table by that name. -func (ar *AzureLogAnalyticsResponse) GetPrimaryResultTable() (*AzureResponseTable, error) { +func (ar *AzureLogAnalyticsResponse) GetPrimaryResultTable() (*types.AzureResponseTable, error) { for _, t := range ar.Tables { if t.Name == "PrimaryResult" { return &t, nil diff --git a/pkg/tsdb/azuremonitor/azure-log-analytics-datasource_test.go b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go similarity index 91% rename from pkg/tsdb/azuremonitor/azure-log-analytics-datasource_test.go rename to pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go index 26001270be5..95b8c14f3af 100644 --- a/pkg/tsdb/azuremonitor/azure-log-analytics-datasource_test.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go @@ -1,4 +1,4 @@ -package azuremonitor +package loganalytics import ( "context" @@ -12,6 +12,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/stretchr/testify/require" ) @@ -37,7 +38,7 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) { "query": "query=Perf | where $__timeFilter() | where $__contains(Computer, 'comp1','comp2') | summarize avg(CounterValue) by bin(TimeGenerated, $__interval), Computer", "resultFormat": "%s" } - }`, timeSeries)), + }`, types.TimeSeries)), RefID: "A", TimeRange: timeRange, }, @@ -45,7 +46,7 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) { azureLogAnalyticsQueries: []*AzureLogAnalyticsQuery{ { RefID: "A", - ResultFormat: timeSeries, + ResultFormat: types.TimeSeries, URL: "v1/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.OperationalInsights/workspaces/AppInsightsTestDataWorkspace/query", JSON: []byte(fmt.Sprintf(`{ "queryType": "Azure Log Analytics", @@ -54,7 +55,7 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) { "query": "query=Perf | where $__timeFilter() | where $__contains(Computer, 'comp1','comp2') | summarize avg(CounterValue) by bin(TimeGenerated, $__interval), Computer", "resultFormat": "%s" } - }`, timeSeries)), + }`, types.TimeSeries)), Params: url.Values{"query": {"query=Perf | where ['TimeGenerated'] >= datetime('2018-03-15T13:00:00Z') and ['TimeGenerated'] <= datetime('2018-03-15T13:34:00Z') | where ['Computer'] in ('comp1','comp2') | summarize avg(CounterValue) by bin(TimeGenerated, 34000ms), Computer"}}, Target: "query=query%3DPerf+%7C+where+%5B%27TimeGenerated%27%5D+%3E%3D+datetime%28%272018-03-15T13%3A00%3A00Z%27%29+and+%5B%27TimeGenerated%27%5D+%3C%3D+datetime%28%272018-03-15T13%3A34%3A00Z%27%29+%7C+where+%5B%27Computer%27%5D+in+%28%27comp1%27%2C%27comp2%27%29+%7C+summarize+avg%28CounterValue%29+by+bin%28TimeGenerated%2C+34000ms%29%2C+Computer", TimeRange: timeRange, @@ -74,14 +75,14 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) { "query": "query=Perf", "resultFormat": "%s" } - }`, timeSeries)), + }`, types.TimeSeries)), RefID: "A", }, }, azureLogAnalyticsQueries: []*AzureLogAnalyticsQuery{ { RefID: "A", - ResultFormat: timeSeries, + ResultFormat: types.TimeSeries, URL: "v1/workspaces/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/query", JSON: []byte(fmt.Sprintf(`{ "queryType": "Azure Log Analytics", @@ -90,7 +91,7 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) { "query": "query=Perf", "resultFormat": "%s" } - }`, timeSeries)), + }`, types.TimeSeries)), Params: url.Values{"query": {"query=Perf"}}, Target: "query=query%3DPerf", }, @@ -109,14 +110,14 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) { "query": "query=Perf", "resultFormat": "%s" } - }`, timeSeries)), + }`, types.TimeSeries)), RefID: "A", }, }, azureLogAnalyticsQueries: []*AzureLogAnalyticsQuery{ { RefID: "A", - ResultFormat: timeSeries, + ResultFormat: types.TimeSeries, URL: "v1/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.OperationalInsights/workspaces/AppInsightsTestDataWorkspace/query", JSON: []byte(fmt.Sprintf(`{ "queryType": "Azure Log Analytics", @@ -125,7 +126,7 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) { "query": "query=Perf", "resultFormat": "%s" } - }`, timeSeries)), + }`, types.TimeSeries)), Params: url.Values{"query": {"query=Perf"}}, Target: "query=query%3DPerf", }, @@ -144,14 +145,14 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) { "query": "query=Perf", "resultFormat": "%s" } - }`, timeSeries)), + }`, types.TimeSeries)), RefID: "A", }, }, azureLogAnalyticsQueries: []*AzureLogAnalyticsQuery{ { RefID: "A", - ResultFormat: timeSeries, + ResultFormat: types.TimeSeries, URL: "v1/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/cloud-datasources/providers/Microsoft.OperationalInsights/workspaces/AppInsightsTestDataWorkspace/query", JSON: []byte(fmt.Sprintf(`{ "queryType": "Azure Log Analytics", @@ -160,7 +161,7 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) { "query": "query=Perf", "resultFormat": "%s" } - }`, timeSeries)), + }`, types.TimeSeries)), Params: url.Values{"query": {"query=Perf"}}, Target: "query=query%3DPerf", }, @@ -171,7 +172,7 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - queries, err := datasource.buildQueries(tt.queryModel, datasourceInfo{}) + queries, err := datasource.buildQueries(tt.queryModel, types.DatasourceInfo{}) tt.Err(t, err) if diff := cmp.Diff(tt.azureLogAnalyticsQueries[0], queries[0]); diff != "" { t.Errorf("Result mismatch (-want +got):\n%s", diff) @@ -183,7 +184,7 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) { func TestLogAnalyticsCreateRequest(t *testing.T) { ctx := context.Background() url := "http://ds" - dsInfo := datasourceInfo{} + dsInfo := types.DatasourceInfo{} tests := []struct { name string @@ -216,9 +217,9 @@ func TestLogAnalyticsCreateRequest(t *testing.T) { func Test_executeQueryErrorWithDifferentLogAnalyticsCreds(t *testing.T) { ds := AzureLogAnalyticsDatasource{} - dsInfo := datasourceInfo{ - Services: map[string]datasourceService{ - azureLogAnalytics: {URL: "http://ds"}, + dsInfo := types.DatasourceInfo{ + Services: map[string]types.DatasourceService{ + "Azure Log Analytics": {URL: "http://ds"}, }, JSONData: map[string]interface{}{ "azureLogAnalyticsSameAs": false, @@ -231,7 +232,7 @@ func Test_executeQueryErrorWithDifferentLogAnalyticsCreds(t *testing.T) { } tracer, err := tracing.InitializeTracerForTest() require.NoError(t, err) - res := ds.executeQuery(ctx, query, dsInfo, &http.Client{}, dsInfo.Services[azureLogAnalytics].URL, tracer) + res := ds.executeQuery(ctx, query, dsInfo, &http.Client{}, dsInfo.Services["Azure Log Analytics"].URL, tracer) if res.Error == nil { t.Fatal("expecting an error") } diff --git a/pkg/tsdb/azuremonitor/azure-response-table-frame.go b/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame.go similarity index 95% rename from pkg/tsdb/azuremonitor/azure-response-table-frame.go rename to pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame.go index 94b9508c1d0..e26b336c794 100644 --- a/pkg/tsdb/azuremonitor/azure-response-table-frame.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame.go @@ -1,4 +1,4 @@ -package azuremonitor +package loganalytics import ( "encoding/json" @@ -8,10 +8,11 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) // ResponseTableToFrame converts an AzureResponseTable to a data.Frame. -func ResponseTableToFrame(table *AzureResponseTable) (*data.Frame, error) { +func ResponseTableToFrame(table *types.AzureResponseTable) (*data.Frame, error) { converterFrame, err := converterFrameForTable(table) if err != nil { return nil, err @@ -27,7 +28,7 @@ func ResponseTableToFrame(table *AzureResponseTable) (*data.Frame, error) { return converterFrame.Frame, nil } -func converterFrameForTable(t *AzureResponseTable) (*data.FrameInputConverter, error) { +func converterFrameForTable(t *types.AzureResponseTable) (*data.FrameInputConverter, error) { converters := []data.FieldConverter{} colNames := make([]string, len(t.Columns)) colTypes := make([]string, len(t.Columns)) // for metadata diff --git a/pkg/tsdb/azuremonitor/azure-response-table-frame_test.go b/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame_test.go similarity index 98% rename from pkg/tsdb/azuremonitor/azure-response-table-frame_test.go rename to pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame_test.go index a6e0a797cc6..281111fbd3d 100644 --- a/pkg/tsdb/azuremonitor/azure-response-table-frame_test.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame_test.go @@ -1,4 +1,4 @@ -package azuremonitor +package loganalytics import ( "encoding/json" @@ -156,7 +156,7 @@ func TestLogTableToFrame(t *testing.T) { func loadLogAnalyticsTestFileWithNumber(t *testing.T, name string) AzureLogAnalyticsResponse { t.Helper() - path := filepath.Join("testdata", name) + path := filepath.Join("../testdata", name) // Ignore gosec warning G304 since it's a test // nolint:gosec f, err := os.Open(path) diff --git a/pkg/tsdb/azuremonitor/macros.go b/pkg/tsdb/azuremonitor/macros/macros.go similarity index 92% rename from pkg/tsdb/azuremonitor/macros.go rename to pkg/tsdb/azuremonitor/macros/macros.go index 1adaa115336..d83f746c046 100644 --- a/pkg/tsdb/azuremonitor/macros.go +++ b/pkg/tsdb/azuremonitor/macros/macros.go @@ -1,4 +1,4 @@ -package azuremonitor +package macros import ( "fmt" @@ -9,6 +9,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/grafana/grafana/pkg/tsdb/legacydata/interval" ) @@ -31,7 +33,7 @@ type kqlMacroEngine struct { // - $__escapeMulti('\\vm\eth0\Total','\\vm\eth2\Total') -> @'\\vm\eth0\Total',@'\\vm\eth2\Total' // KqlInterpolate interpolates macros for Kusto Query Language (KQL) queries -func KqlInterpolate(query backend.DataQuery, dsInfo datasourceInfo, kql string, defaultTimeField ...string) (string, error) { +func KqlInterpolate(query backend.DataQuery, dsInfo types.DatasourceInfo, kql string, defaultTimeField ...string) (string, error) { engine := kqlMacroEngine{} defaultTimeFieldForAllDatasources := "timestamp" @@ -41,7 +43,7 @@ func KqlInterpolate(query backend.DataQuery, dsInfo datasourceInfo, kql string, return engine.Interpolate(query, dsInfo, kql, defaultTimeFieldForAllDatasources) } -func (m *kqlMacroEngine) Interpolate(query backend.DataQuery, dsInfo datasourceInfo, kql string, defaultTimeField string) (string, error) { +func (m *kqlMacroEngine) Interpolate(query backend.DataQuery, dsInfo types.DatasourceInfo, kql string, defaultTimeField string) (string, error) { m.timeRange = query.TimeRange m.query = query rExp, _ := regexp.Compile(sExpr) @@ -86,7 +88,7 @@ func (m *kqlMacroEngine) Interpolate(query backend.DataQuery, dsInfo datasourceI return kql, nil } -func (m *kqlMacroEngine) evaluateMacro(name string, defaultTimeField string, args []string, dsInfo datasourceInfo) (string, error) { +func (m *kqlMacroEngine) evaluateMacro(name string, defaultTimeField string, args []string, dsInfo types.DatasourceInfo) (string, error) { switch name { case "timeFilter": timeColumn := defaultTimeField diff --git a/pkg/tsdb/azuremonitor/macros_test.go b/pkg/tsdb/azuremonitor/macros/macros_test.go similarity index 96% rename from pkg/tsdb/azuremonitor/macros_test.go rename to pkg/tsdb/azuremonitor/macros/macros_test.go index 866d270627f..7fa92c15067 100644 --- a/pkg/tsdb/azuremonitor/macros_test.go +++ b/pkg/tsdb/azuremonitor/macros/macros_test.go @@ -1,4 +1,4 @@ -package azuremonitor +package macros import ( "testing" @@ -7,6 +7,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/stretchr/testify/require" ) @@ -125,7 +126,7 @@ func TestAzureLogAnalyticsMacros(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { defaultTimeField := "TimeGenerated" - rawQuery, err := KqlInterpolate(tt.query, datasourceInfo{}, tt.kql, defaultTimeField) + rawQuery, err := KqlInterpolate(tt.query, types.DatasourceInfo{}, tt.kql, defaultTimeField) tt.Err(t, err) if diff := cmp.Diff(tt.expected, rawQuery, cmpopts.EquateNaNs()); diff != "" { t.Errorf("Result mismatch (-want +got):\n%s", diff) diff --git a/pkg/tsdb/azuremonitor/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go similarity index 87% rename from pkg/tsdb/azuremonitor/azuremonitor-datasource.go rename to pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go index ff16e0b55e6..df190981bab 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-datasource.go +++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go @@ -1,4 +1,4 @@ -package azuremonitor +package metrics import ( "context" @@ -16,6 +16,10 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/resourcegraph" + azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/grafana/grafana/pkg/util/errutil" "go.opentelemetry.io/otel/attribute" "golang.org/x/net/context/ctxhttp" @@ -23,28 +27,25 @@ import ( // AzureMonitorDatasource calls the Azure Monitor API - one of the four API's supported type AzureMonitorDatasource struct { - proxy serviceProxy + Proxy types.ServiceProxy } var ( - // 1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d in milliseconds - defaultAllowedIntervalsMS = []int64{60000, 300000, 900000, 1800000, 3600000, 21600000, 43200000, 86400000} - // Used to convert the aggregation value to the Azure enum for deep linking aggregationTypeMap = map[string]int{"None": 0, "Total": 1, "Minimum": 2, "Maximum": 3, "Average": 4, "Count": 7} ) const azureMonitorAPIVersion = "2018-01-01" -func (e *AzureMonitorDatasource) resourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) { - e.proxy.Do(rw, req, cli) +func (e *AzureMonitorDatasource) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) { + e.Proxy.Do(rw, req, cli) } // executeTimeSeriesQuery does the following: // 1. build the AzureMonitor url and querystring for each query // 2. executes each query by calling the Azure Monitor API // 3. parses the responses for each query into data frames -func (e *AzureMonitorDatasource) executeTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo datasourceInfo, client *http.Client, +func (e *AzureMonitorDatasource) ExecuteTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { result := backend.NewQueryDataResponse() @@ -60,12 +61,12 @@ func (e *AzureMonitorDatasource) executeTimeSeriesQuery(ctx context.Context, ori return result, nil } -func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInfo datasourceInfo) ([]*AzureMonitorQuery, error) { - azureMonitorQueries := []*AzureMonitorQuery{} +func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInfo types.DatasourceInfo) ([]*types.AzureMonitorQuery, error) { + azureMonitorQueries := []*types.AzureMonitorQuery{} for _, query := range queries { var target string - queryJSONModel := azureMonitorJSONQuery{} + queryJSONModel := types.AzureMonitorJSONQuery{} err := json.Unmarshal(query.JSON, &queryJSONModel) if err != nil { return nil, fmt.Errorf("failed to decode the Azure Monitor query object from JSON: %w", err) @@ -93,7 +94,7 @@ func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInf timeGrain := azJSONModel.TimeGrain timeGrains := azJSONModel.AllowedTimeGrainsMs if timeGrain == "auto" { - timeGrain, err = setAutoTimeGrain(query.Interval.Milliseconds(), timeGrains) + timeGrain, err = azTime.SetAutoTimeGrain(query.Interval.Milliseconds(), timeGrains) if err != nil { return nil, err } @@ -135,7 +136,7 @@ func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInf azlog.Debug("Azuremonitor request", "params", params) } - azureMonitorQueries = append(azureMonitorQueries, &AzureMonitorQuery{ + azureMonitorQueries = append(azureMonitorQueries, &types.AzureMonitorQuery{ URL: azureURL, UrlComponents: urlComponents, Target: target, @@ -149,7 +150,7 @@ func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInf return azureMonitorQueries, nil } -func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *AzureMonitorQuery, dsInfo datasourceInfo, cli *http.Client, +func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *types.AzureMonitorQuery, dsInfo types.DatasourceInfo, cli *http.Client, url string, tracer tracing.Tracer) backend.DataResponse { dataResponse := backend.DataResponse{} @@ -191,7 +192,7 @@ func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *AzureM return dataResponse } - azurePortalUrl, err := getAzurePortalUrl(dsInfo.Cloud) + azurePortalUrl, err := resourcegraph.GetAzurePortalUrl(dsInfo.Cloud) if err != nil { dataResponse.Error = err return dataResponse @@ -206,7 +207,7 @@ func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *AzureM return dataResponse } -func (e *AzureMonitorDatasource) createRequest(ctx context.Context, dsInfo datasourceInfo, url string) (*http.Request, error) { +func (e *AzureMonitorDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { azlog.Debug("Failed to create request", "error", err) @@ -218,28 +219,28 @@ func (e *AzureMonitorDatasource) createRequest(ctx context.Context, dsInfo datas return req, nil } -func (e *AzureMonitorDatasource) unmarshalResponse(res *http.Response) (AzureMonitorResponse, error) { +func (e *AzureMonitorDatasource) unmarshalResponse(res *http.Response) (types.AzureMonitorResponse, error) { body, err := ioutil.ReadAll(res.Body) if err != nil { - return AzureMonitorResponse{}, err + return types.AzureMonitorResponse{}, err } if res.StatusCode/100 != 2 { azlog.Debug("Request failed", "status", res.Status, "body", string(body)) - return AzureMonitorResponse{}, fmt.Errorf("request failed, status: %s", res.Status) + return types.AzureMonitorResponse{}, fmt.Errorf("request failed, status: %s", res.Status) } - var data AzureMonitorResponse + var data types.AzureMonitorResponse err = json.Unmarshal(body, &data) if err != nil { azlog.Debug("Failed to unmarshal AzureMonitor response", "error", err, "status", res.Status, "body", string(body)) - return AzureMonitorResponse{}, err + return types.AzureMonitorResponse{}, err } return data, nil } -func (e *AzureMonitorDatasource) parseResponse(amr AzureMonitorResponse, query *AzureMonitorQuery, azurePortalUrl string) (data.Frames, error) { +func (e *AzureMonitorDatasource) parseResponse(amr types.AzureMonitorResponse, query *types.AzureMonitorQuery, azurePortalUrl string) (data.Frames, error) { if len(amr.Value) == 0 { return nil, nil } @@ -303,7 +304,7 @@ func (e *AzureMonitorDatasource) parseResponse(amr AzureMonitorResponse, query * frame.SetRow(i, point.TimeStamp, value) } - frameWithLink := addConfigLinks(*frame, queryUrl) + frameWithLink := resourcegraph.AddConfigLinks(*frame, queryUrl) frames = append(frames, &frameWithLink) } @@ -311,7 +312,7 @@ func (e *AzureMonitorDatasource) parseResponse(amr AzureMonitorResponse, query * } // Gets the deep link for the given query -func getQueryUrl(query *AzureMonitorQuery, azurePortalUrl string) (string, error) { +func getQueryUrl(query *types.AzureMonitorQuery, azurePortalUrl string) (string, error) { aggregationType := aggregationTypeMap["Average"] aggregation := query.Params.Get("aggregation") if aggregation != "" { @@ -343,7 +344,7 @@ func getQueryUrl(query *AzureMonitorQuery, azurePortalUrl string) (string, error chartDef, err := json.Marshal(map[string]interface{}{ "v2charts": []interface{}{ map[string]interface{}{ - "metrics": []metricChartDefinition{ + "metrics": []types.MetricChartDefinition{ { ResourceMetadata: map[string]string{ "id": id, @@ -351,7 +352,7 @@ func getQueryUrl(query *AzureMonitorQuery, azurePortalUrl string) (string, error Name: query.Params.Get("metricnames"), AggregationType: aggregationType, Namespace: query.Params.Get("metricnamespace"), - MetricVisualization: metricVisualization{ + MetricVisualization: types.MetricVisualization{ DisplayName: query.Params.Get("metricnames"), ResourceDisplayName: query.UrlComponents["resourceName"], }, @@ -387,7 +388,7 @@ func formatAzureMonitorLegendKey(alias string, resourceName string, metricName s } keys = sort.StringSlice(keys) - result := legendKeyFormat.ReplaceAllFunc([]byte(alias), func(in []byte) []byte { + result := types.LegendKeyFormat.ReplaceAllFunc([]byte(alias), func(in []byte) []byte { metaPartName := strings.Replace(string(in), "{{", "", 1) metaPartName = strings.Replace(metaPartName, "}}", "", 1) metaPartName = strings.ToLower(strings.TrimSpace(metaPartName)) diff --git a/pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go similarity index 94% rename from pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go rename to pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go index 7afca8e6d7d..1081b21a619 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go +++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go @@ -1,4 +1,4 @@ -package azuremonitor +package metrics import ( "context" @@ -16,14 +16,16 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/components/simplejson" + azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/stretchr/testify/require" ptr "github.com/xorcare/pointer" ) func TestAzureMonitorBuildQueries(t *testing.T) { datasource := &AzureMonitorDatasource{} - dsInfo := datasourceInfo{ - Settings: azureMonitorSettings{ + dsInfo := types.DatasourceInfo{ + Settings: types.AzureMonitorSettings{ SubscriptionId: "default-subscription", }, } @@ -96,7 +98,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { name: "has dimensionFilter*s* property with one dimension", azureMonitorVariedProperties: map[string]interface{}{ "timeGrain": "PT1M", - "dimensionFilters": []azureMonitorDimensionFilter{{"blob", "eq", "*"}}, + "dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "eq", Filter: "*"}}, "top": "30", }, queryInterval: duration, @@ -107,7 +109,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { name: "has dimensionFilter*s* property with two dimensions", azureMonitorVariedProperties: map[string]interface{}{ "timeGrain": "PT1M", - "dimensionFilters": []azureMonitorDimensionFilter{{"blob", "eq", "*"}, {"tier", "eq", "*"}}, + "dimensionFilters": []types.AzureMonitorDimensionFilter{{Dimension: "blob", Operator: "eq", Filter: "*"}, {Dimension: "tier", Operator: "eq", Filter: "*"}}, "top": "30", }, queryInterval: duration, @@ -149,7 +151,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { }, } - azureMonitorQuery := &AzureMonitorQuery{ + azureMonitorQuery := &types.AzureMonitorQuery{ URL: "12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana/providers/microsoft.insights/metrics", UrlComponents: map[string]string{ "metricDefinition": "Microsoft.Compute/virtualMachines", @@ -168,7 +170,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { queries, err := datasource.buildQueries(tsdbQuery, dsInfo) require.NoError(t, err) - if diff := cmp.Diff(azureMonitorQuery, queries[0], cmpopts.IgnoreUnexported(simplejson.Json{}), cmpopts.IgnoreFields(AzureMonitorQuery{}, "Params")); diff != "" { + if diff := cmp.Diff(azureMonitorQuery, queries[0], cmpopts.IgnoreUnexported(simplejson.Json{}), cmpopts.IgnoreFields(types.AzureMonitorQuery{}, "Params")); diff != "" { t.Errorf("Result mismatch (-want +got):\n%s", diff) } @@ -219,14 +221,14 @@ func TestAzureMonitorParseResponse(t *testing.T) { tests := []struct { name string responseFile string - mockQuery *AzureMonitorQuery + mockQuery *types.AzureMonitorQuery expectedFrames data.Frames queryIntervalMS int64 }{ { name: "average aggregate time series response", responseFile: "1-azure-monitor-response-avg.json", - mockQuery: &AzureMonitorQuery{ + mockQuery: &types.AzureMonitorQuery{ UrlComponents: map[string]string{ "resourceName": "grafana", }, @@ -247,7 +249,7 @@ func TestAzureMonitorParseResponse(t *testing.T) { { name: "total aggregate time series response", responseFile: "2-azure-monitor-response-total.json", - mockQuery: &AzureMonitorQuery{ + mockQuery: &types.AzureMonitorQuery{ UrlComponents: map[string]string{ "resourceName": "grafana", }, @@ -268,7 +270,7 @@ func TestAzureMonitorParseResponse(t *testing.T) { { name: "maximum aggregate time series response", responseFile: "3-azure-monitor-response-maximum.json", - mockQuery: &AzureMonitorQuery{ + mockQuery: &types.AzureMonitorQuery{ UrlComponents: map[string]string{ "resourceName": "grafana", }, @@ -289,7 +291,7 @@ func TestAzureMonitorParseResponse(t *testing.T) { { name: "minimum aggregate time series response", responseFile: "4-azure-monitor-response-minimum.json", - mockQuery: &AzureMonitorQuery{ + mockQuery: &types.AzureMonitorQuery{ UrlComponents: map[string]string{ "resourceName": "grafana", }, @@ -310,7 +312,7 @@ func TestAzureMonitorParseResponse(t *testing.T) { { name: "count aggregate time series response", responseFile: "5-azure-monitor-response-count.json", - mockQuery: &AzureMonitorQuery{ + mockQuery: &types.AzureMonitorQuery{ UrlComponents: map[string]string{ "resourceName": "grafana", }, @@ -331,7 +333,7 @@ func TestAzureMonitorParseResponse(t *testing.T) { { name: "single dimension time series response", responseFile: "6-azure-monitor-response-single-dimension.json", - mockQuery: &AzureMonitorQuery{ + mockQuery: &types.AzureMonitorQuery{ UrlComponents: map[string]string{ "resourceName": "grafana", }, @@ -365,7 +367,7 @@ func TestAzureMonitorParseResponse(t *testing.T) { { name: "with alias patterns in the query", responseFile: "2-azure-monitor-response-total.json", - mockQuery: &AzureMonitorQuery{ + mockQuery: &types.AzureMonitorQuery{ Alias: "custom {{resourcegroup}} {{namespace}} {{resourceName}} {{metric}}", UrlComponents: map[string]string{ "resourceName": "grafana", @@ -387,7 +389,7 @@ func TestAzureMonitorParseResponse(t *testing.T) { { name: "single dimension with alias", responseFile: "6-azure-monitor-response-single-dimension.json", - mockQuery: &AzureMonitorQuery{ + mockQuery: &types.AzureMonitorQuery{ Alias: "{{dimensionname}}={{DimensionValue}}", UrlComponents: map[string]string{ "resourceName": "grafana", @@ -424,7 +426,7 @@ func TestAzureMonitorParseResponse(t *testing.T) { { name: "multiple dimension time series response with label alias", responseFile: "7-azure-monitor-response-multi-dimension.json", - mockQuery: &AzureMonitorQuery{ + mockQuery: &types.AzureMonitorQuery{ Alias: "{{resourcegroup}} {Blob Type={{blobtype}}, Tier={{Tier}}}", UrlComponents: map[string]string{ "resourceName": "grafana", @@ -462,7 +464,7 @@ func TestAzureMonitorParseResponse(t *testing.T) { { name: "unspecified unit with alias should not panic", responseFile: "8-azure-monitor-response-unspecified-unit.json", - mockQuery: &AzureMonitorQuery{ + mockQuery: &types.AzureMonitorQuery{ Alias: "custom", UrlComponents: map[string]string{ "resourceName": "grafana", @@ -540,22 +542,22 @@ func TestFindClosestAllowIntervalMS(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - interval := findClosestAllowedIntervalMS(tt.inputInterval, tt.allowedTimeGrains) + interval := azTime.FindClosestAllowedIntervalMS(tt.inputInterval, tt.allowedTimeGrains) require.Equal(t, tt.expectedInterval, interval) }) } } -func loadTestFile(t *testing.T, name string) AzureMonitorResponse { +func loadTestFile(t *testing.T, name string) types.AzureMonitorResponse { t.Helper() - path := filepath.Join("testdata", name) + path := filepath.Join("../testdata", name) // Ignore gosec warning G304 since it's a test // nolint:gosec jsonBody, err := ioutil.ReadFile(path) require.NoError(t, err) - var azData AzureMonitorResponse + var azData types.AzureMonitorResponse err = json.Unmarshal(jsonBody, &azData) require.NoError(t, err) return azData @@ -563,7 +565,7 @@ func loadTestFile(t *testing.T, name string) AzureMonitorResponse { func TestAzureMonitorCreateRequest(t *testing.T) { ctx := context.Background() - dsInfo := datasourceInfo{} + dsInfo := types.DatasourceInfo{} url := "http://ds/" tests := []struct { diff --git a/pkg/tsdb/azuremonitor/url-builder.go b/pkg/tsdb/azuremonitor/metrics/url-builder.go similarity index 98% rename from pkg/tsdb/azuremonitor/url-builder.go rename to pkg/tsdb/azuremonitor/metrics/url-builder.go index a595daf8bad..53bee4f2269 100644 --- a/pkg/tsdb/azuremonitor/url-builder.go +++ b/pkg/tsdb/azuremonitor/metrics/url-builder.go @@ -1,4 +1,4 @@ -package azuremonitor +package metrics import ( "fmt" diff --git a/pkg/tsdb/azuremonitor/url-builder_test.go b/pkg/tsdb/azuremonitor/metrics/url-builder_test.go similarity index 99% rename from pkg/tsdb/azuremonitor/url-builder_test.go rename to pkg/tsdb/azuremonitor/metrics/url-builder_test.go index a2e6e58ed55..f6bb94a4664 100644 --- a/pkg/tsdb/azuremonitor/url-builder_test.go +++ b/pkg/tsdb/azuremonitor/metrics/url-builder_test.go @@ -1,4 +1,4 @@ -package azuremonitor +package metrics import ( "testing" diff --git a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go similarity index 82% rename from pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go rename to pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go index c501fa9e057..39ce41b8b22 100644 --- a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go +++ b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go @@ -1,4 +1,4 @@ -package azuremonitor +package resourcegraph import ( "bytes" @@ -17,14 +17,23 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/loganalytics" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/macros" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/grafana/grafana/pkg/util/errutil" "go.opentelemetry.io/otel/attribute" "golang.org/x/net/context/ctxhttp" ) +// AzureResourceGraphResponse is the json response object from the Azure Resource Graph Analytics API. +type AzureResourceGraphResponse struct { + Data types.AzureResponseTable `json:"data"` +} + // AzureResourceGraphDatasource calls the Azure Resource Graph API's type AzureResourceGraphDatasource struct { - proxy serviceProxy + Proxy types.ServiceProxy } // AzureResourceGraphQuery is the query request that is built from the saved values for @@ -41,15 +50,15 @@ type AzureResourceGraphQuery struct { const argAPIVersion = "2021-06-01-preview" const argQueryProviderName = "/providers/Microsoft.ResourceGraph/resources" -func (e *AzureResourceGraphDatasource) resourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) { - e.proxy.Do(rw, req, cli) +func (e *AzureResourceGraphDatasource) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) { + e.Proxy.Do(rw, req, cli) } // executeTimeSeriesQuery does the following: // 1. builds the AzureMonitor url and querystring for each query // 2. executes each query by calling the Azure Monitor API // 3. parses the responses for each query into data frames -func (e *AzureResourceGraphDatasource) executeTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo datasourceInfo, client *http.Client, +func (e *AzureResourceGraphDatasource) ExecuteTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { result := &backend.QueryDataResponse{ Responses: map[string]backend.DataResponse{}, @@ -67,7 +76,14 @@ func (e *AzureResourceGraphDatasource) executeTimeSeriesQuery(ctx context.Contex return result, nil } -func (e *AzureResourceGraphDatasource) buildQueries(queries []backend.DataQuery, dsInfo datasourceInfo) ([]*AzureResourceGraphQuery, error) { +type argJSONQuery struct { + AzureResourceGraph struct { + Query string `json:"query"` + ResultFormat string `json:"resultFormat"` + } `json:"azureResourceGraph"` +} + +func (e *AzureResourceGraphDatasource) buildQueries(queries []backend.DataQuery, dsInfo types.DatasourceInfo) ([]*AzureResourceGraphQuery, error) { var azureResourceGraphQueries []*AzureResourceGraphQuery for _, query := range queries { @@ -85,7 +101,7 @@ func (e *AzureResourceGraphDatasource) buildQueries(queries []backend.DataQuery, resultFormat = "table" } - interpolatedQuery, err := KqlInterpolate(query, dsInfo, azureResourceGraphTarget.Query) + interpolatedQuery, err := macros.KqlInterpolate(query, dsInfo, azureResourceGraphTarget.Query) if err != nil { return nil, err @@ -103,7 +119,7 @@ func (e *AzureResourceGraphDatasource) buildQueries(queries []backend.DataQuery, return azureResourceGraphQueries, nil } -func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query *AzureResourceGraphQuery, dsInfo datasourceInfo, client *http.Client, +func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query *AzureResourceGraphQuery, dsInfo types.DatasourceInfo, client *http.Client, dsURL string, tracer tracing.Tracer) backend.DataResponse { dataResponse := backend.DataResponse{} @@ -173,18 +189,18 @@ func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query * return dataResponseErrorWithExecuted(err) } - frame, err := ResponseTableToFrame(&argResponse.Data) + frame, err := loganalytics.ResponseTableToFrame(&argResponse.Data) if err != nil { return dataResponseErrorWithExecuted(err) } - azurePortalUrl, err := getAzurePortalUrl(dsInfo.Cloud) + azurePortalUrl, err := GetAzurePortalUrl(dsInfo.Cloud) if err != nil { return dataResponseErrorWithExecuted(err) } url := azurePortalUrl + "/#blade/HubsExtension/ArgQueryBlade/query/" + url.PathEscape(query.InterpolatedQuery) - frameWithLink := addConfigLinks(*frame, url) + frameWithLink := AddConfigLinks(*frame, url) if frameWithLink.Meta == nil { frameWithLink.Meta = &data.FrameMeta{} } @@ -194,7 +210,7 @@ func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query * return dataResponse } -func addConfigLinks(frame data.Frame, dl string) data.Frame { +func AddConfigLinks(frame data.Frame, dl string) data.Frame { for i := range frame.Fields { if frame.Fields[i].Config == nil { frame.Fields[i].Config = &data.FieldConfig{} @@ -209,7 +225,7 @@ func addConfigLinks(frame data.Frame, dl string) data.Frame { return frame } -func (e *AzureResourceGraphDatasource) createRequest(ctx context.Context, dsInfo datasourceInfo, reqBody []byte, url string) (*http.Request, error) { +func (e *AzureResourceGraphDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, reqBody []byte, url string) (*http.Request, error) { req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(reqBody)) if err != nil { azlog.Debug("Failed to create request", "error", err) @@ -250,7 +266,7 @@ func (e *AzureResourceGraphDatasource) unmarshalResponse(res *http.Response) (Az return data, nil } -func getAzurePortalUrl(azureCloud string) (string, error) { +func GetAzurePortalUrl(azureCloud string) (string, error) { switch azureCloud { case setting.AzurePublic: return "https://portal.azure.com", nil diff --git a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go similarity index 94% rename from pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go rename to pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go index 42466777a9d..a742a066a8f 100644 --- a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go +++ b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go @@ -1,4 +1,4 @@ -package azuremonitor +package resourcegraph import ( "context" @@ -14,6 +14,7 @@ import ( "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" ) @@ -68,7 +69,7 @@ func TestBuildingAzureResourceGraphQueries(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - queries, err := datasource.buildQueries(tt.queryModel, datasourceInfo{}) + queries, err := datasource.buildQueries(tt.queryModel, types.DatasourceInfo{}) tt.Err(t, err) if diff := cmp.Diff(tt.azureResourceGraphQueries, queries, cmpopts.IgnoreUnexported(simplejson.Json{})); diff != "" { t.Errorf("Result mismatch (-want +got):\n%s", diff) @@ -80,7 +81,7 @@ func TestBuildingAzureResourceGraphQueries(t *testing.T) { func TestAzureResourceGraphCreateRequest(t *testing.T) { ctx := context.Background() url := "http://ds" - dsInfo := datasourceInfo{} + dsInfo := types.DatasourceInfo{} tests := []struct { name string @@ -120,7 +121,7 @@ func TestAddConfigData(t *testing.T) { frame := data.Frame{ Fields: []*data.Field{&field}, } - frameWithLink := addConfigLinks(frame, "http://ds") + frameWithLink := AddConfigLinks(frame, "http://ds") expectedFrameWithLink := data.Frame{ Fields: []*data.Field{ { @@ -145,7 +146,7 @@ func TestGetAzurePortalUrl(t *testing.T) { } for _, cloud := range clouds { - azurePortalUrl, err := getAzurePortalUrl(cloud) + azurePortalUrl, err := GetAzurePortalUrl(cloud) if err != nil { t.Errorf("The cloud not supported") } diff --git a/pkg/tsdb/azuremonitor/routes.go b/pkg/tsdb/azuremonitor/routes.go index 9c834f94955..e2a370a36dc 100644 --- a/pkg/tsdb/azuremonitor/routes.go +++ b/pkg/tsdb/azuremonitor/routes.go @@ -1,71 +1,55 @@ package azuremonitor -import "github.com/grafana/grafana/pkg/setting" +import ( + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/deprecated" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" +) // Azure cloud query types const ( azureMonitor = "Azure Monitor" - appInsights = "Application Insights" azureLogAnalytics = "Azure Log Analytics" - insightsAnalytics = "Insights Analytics" azureResourceGraph = "Azure Resource Graph" ) -type azRoute struct { - URL string - Scopes []string - Headers map[string]string -} - -var azManagement = azRoute{ +var azManagement = types.AzRoute{ URL: "https://management.azure.com", Scopes: []string{"https://management.azure.com/.default"}, Headers: map[string]string{"x-ms-app": "Grafana"}, } -var azUSGovManagement = azRoute{ +var azUSGovManagement = types.AzRoute{ URL: "https://management.usgovcloudapi.net", Scopes: []string{"https://management.usgovcloudapi.net/.default"}, Headers: map[string]string{"x-ms-app": "Grafana"}, } -var azGermanyManagement = azRoute{ +var azGermanyManagement = types.AzRoute{ URL: "https://management.microsoftazure.de", Scopes: []string{"https://management.microsoftazure.de/.default"}, Headers: map[string]string{"x-ms-app": "Grafana"}, } -var azChinaManagement = azRoute{ +var azChinaManagement = types.AzRoute{ URL: "https://management.chinacloudapi.cn", Scopes: []string{"https://management.chinacloudapi.cn/.default"}, Headers: map[string]string{"x-ms-app": "Grafana"}, } -var azAppInsights = azRoute{ - URL: "https://api.applicationinsights.io", - Scopes: []string{}, - Headers: map[string]string{"x-ms-app": "Grafana"}, -} - -var azChinaAppInsights = azRoute{ - URL: "https://api.applicationinsights.azure.cn", - Scopes: []string{}, - Headers: map[string]string{"x-ms-app": "Grafana"}, -} - -var azLogAnalytics = azRoute{ +var azLogAnalytics = types.AzRoute{ URL: "https://api.loganalytics.io", Scopes: []string{"https://api.loganalytics.io/.default"}, Headers: map[string]string{"x-ms-app": "Grafana", "Cache-Control": "public, max-age=60"}, } -var azChinaLogAnalytics = azRoute{ +var azChinaLogAnalytics = types.AzRoute{ URL: "https://api.loganalytics.azure.cn", Scopes: []string{"https://api.loganalytics.azure.cn/.default"}, Headers: map[string]string{"x-ms-app": "Grafana", "Cache-Control": "public, max-age=60"}, } -var azUSGovLogAnalytics = azRoute{ +var azUSGovLogAnalytics = types.AzRoute{ URL: "https://api.loganalytics.us", Scopes: []string{"https://api.loganalytics.us/.default"}, Headers: map[string]string{"x-ms-app": "Grafana", "Cache-Control": "public, max-age=60"}, @@ -74,13 +58,13 @@ var azUSGovLogAnalytics = azRoute{ 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]azRoute{ + routes = map[string]map[string]types.AzRoute{ setting.AzurePublic: { - azureMonitor: azManagement, - azureLogAnalytics: azLogAnalytics, - azureResourceGraph: azManagement, - appInsights: azAppInsights, - insightsAnalytics: azAppInsights, + azureMonitor: azManagement, + azureLogAnalytics: azLogAnalytics, + azureResourceGraph: azManagement, + deprecated.AppInsights: deprecated.AzAppInsights, + deprecated.InsightsAnalytics: deprecated.AzAppInsights, }, setting.AzureUSGovernment: { azureMonitor: azUSGovManagement, @@ -91,11 +75,11 @@ var ( azureMonitor: azGermanyManagement, }, setting.AzureChina: { - azureMonitor: azChinaManagement, - azureLogAnalytics: azChinaLogAnalytics, - azureResourceGraph: azChinaManagement, - appInsights: azChinaAppInsights, - insightsAnalytics: azChinaAppInsights, + azureMonitor: azChinaManagement, + azureLogAnalytics: azChinaLogAnalytics, + azureResourceGraph: azChinaManagement, + deprecated.AppInsights: deprecated.AzChinaAppInsights, + deprecated.InsightsAnalytics: deprecated.AzChinaAppInsights, }, } ) diff --git a/pkg/tsdb/azuremonitor/azuremonitor-time.go b/pkg/tsdb/azuremonitor/time/azuremonitor-time.go similarity index 66% rename from pkg/tsdb/azuremonitor/azuremonitor-time.go rename to pkg/tsdb/azuremonitor/time/azuremonitor-time.go index f631b864c9c..3a29f24f8dc 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-time.go +++ b/pkg/tsdb/azuremonitor/time/azuremonitor-time.go @@ -1,10 +1,15 @@ -package azuremonitor +package time -// setAutoTimeGrain tries to find the closest interval to the query's intervalMs value +var ( + // 1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d in milliseconds + defaultAllowedIntervalsMS = []int64{60000, 300000, 900000, 1800000, 3600000, 21600000, 43200000, 86400000} +) + +// SetAutoTimeGrain tries to find the closest interval to the query's intervalMs value // if the metric has a limited set of possible intervals/time grains then use those // instead of the default list of intervals -func setAutoTimeGrain(intervalMs int64, timeGrains []int64) (string, error) { - autoInterval := findClosestAllowedIntervalMS(intervalMs, timeGrains) +func SetAutoTimeGrain(intervalMs int64, timeGrains []int64) (string, error) { + autoInterval := FindClosestAllowedIntervalMS(intervalMs, timeGrains) tg := &TimeGrain{} autoTimeGrain, err := tg.createISO8601DurationFromIntervalMS(autoInterval) if err != nil { @@ -14,12 +19,12 @@ func setAutoTimeGrain(intervalMs int64, timeGrains []int64) (string, error) { return autoTimeGrain, nil } -// findClosestAllowedIntervalMs is used for the auto time grain setting. +// FindClosestAllowedIntervalMS is used for the auto time grain setting. // It finds the closest time grain from the list of allowed time grains for Azure Monitor // using the Grafana interval in milliseconds // Some metrics only allow a limited list of time grains. The allowedTimeGrains parameter // allows overriding the default list of allowed time grains. -func findClosestAllowedIntervalMS(intervalMs int64, allowedTimeGrains []int64) int64 { +func FindClosestAllowedIntervalMS(intervalMs int64, allowedTimeGrains []int64) int64 { allowedIntervals := defaultAllowedIntervalsMS if len(allowedTimeGrains) > 0 { diff --git a/pkg/tsdb/azuremonitor/time-grain.go b/pkg/tsdb/azuremonitor/time/time-grain.go similarity index 98% rename from pkg/tsdb/azuremonitor/time-grain.go rename to pkg/tsdb/azuremonitor/time/time-grain.go index 1e7ad5aff50..c7be8890fe5 100644 --- a/pkg/tsdb/azuremonitor/time-grain.go +++ b/pkg/tsdb/azuremonitor/time/time-grain.go @@ -1,4 +1,4 @@ -package azuremonitor +package time import ( "fmt" diff --git a/pkg/tsdb/azuremonitor/time-grain_test.go b/pkg/tsdb/azuremonitor/time/time-grain_test.go similarity index 98% rename from pkg/tsdb/azuremonitor/time-grain_test.go rename to pkg/tsdb/azuremonitor/time/time-grain_test.go index 58485342962..4ff6813bec7 100644 --- a/pkg/tsdb/azuremonitor/time-grain_test.go +++ b/pkg/tsdb/azuremonitor/time/time-grain_test.go @@ -1,4 +1,4 @@ -package azuremonitor +package time import ( "testing" diff --git a/pkg/tsdb/azuremonitor/types.go b/pkg/tsdb/azuremonitor/types/types.go similarity index 53% rename from pkg/tsdb/azuremonitor/types.go rename to pkg/tsdb/azuremonitor/types/types.go index 373955050a5..6d310f850fd 100644 --- a/pkg/tsdb/azuremonitor/types.go +++ b/pkg/tsdb/azuremonitor/types/types.go @@ -1,15 +1,54 @@ -package azuremonitor +package types import ( - "encoding/json" "fmt" + "net/http" "net/url" - "strings" + "regexp" "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" ) +const ( + TimeSeries = "time_series" +) + +var ( + LegendKeyFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`) +) + +type AzRoute struct { + URL string + Scopes []string + Headers map[string]string +} + +type AzureMonitorSettings struct { + SubscriptionId string `json:"subscriptionId"` + LogAnalyticsDefaultWorkspace string `json:"logAnalyticsDefaultWorkspace"` + AppInsightsAppId string `json:"appInsightsAppId"` +} + +type DatasourceService struct { + URL string + HTTPClient *http.Client +} + +type DatasourceInfo struct { + Cloud string + Credentials azcredentials.AzureCredentials + Settings AzureMonitorSettings + Routes map[string]AzRoute + Services map[string]DatasourceService + + JSONData map[string]interface{} + DecryptedSecureJSONData map[string]string + DatasourceID int64 + OrgID int64 +} + // AzureMonitorQuery is the query for all the services as they have similar queries // with a url, a querystring and an alias field type AzureMonitorQuery struct { @@ -57,16 +96,6 @@ type AzureMonitorResponse struct { Resourceregion string `json:"resourceregion"` } -// AzureLogAnalyticsResponse is the json response object from the Azure Log Analytics API. -type AzureLogAnalyticsResponse struct { - Tables []AzureResponseTable `json:"tables"` -} - -// AzureResourceGraphResponse is the json response object from the Azure Resource Graph Analytics API. -type AzureResourceGraphResponse struct { - Data AzureResponseTable `json:"data"` -} - // AzureResponseTable is the table format for Azure responses type AzureResponseTable struct { Name string `json:"name"` @@ -77,8 +106,8 @@ type AzureResponseTable struct { Rows [][]interface{} `json:"rows"` } -// azureMonitorJSONQuery is the frontend JSON query model for an Azure Monitor query. -type azureMonitorJSONQuery 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"` @@ -94,20 +123,20 @@ type azureMonitorJSONQuery struct { TimeGrain string `json:"timeGrain"` Top string `json:"top"` - DimensionFilters []azureMonitorDimensionFilter `json:"dimensionFilters"` // new model + DimensionFilters []AzureMonitorDimensionFilter `json:"dimensionFilters"` // new model } `json:"azureMonitor"` Subscription string `json:"subscription"` } -// azureMonitorDimensionFilter is the model for the frontend sent for azureMonitor metric +// AzureMonitorDimensionFilter is the model for the frontend sent for azureMonitor metric // queries like "BlobType", "eq", "*" -type azureMonitorDimensionFilter struct { +type AzureMonitorDimensionFilter struct { Dimension string `json:"dimension"` Operator string `json:"operator"` Filter string `json:"filter"` } -func (a azureMonitorDimensionFilter) String() string { +func (a AzureMonitorDimensionFilter) String() string { filter := "*" if a.Filter != "" { filter = a.Filter @@ -115,29 +144,8 @@ func (a azureMonitorDimensionFilter) String() string { return fmt.Sprintf("%v %v '%v'", a.Dimension, a.Operator, filter) } -// insightsJSONQuery is the frontend JSON query model for an Azure Application Insights query. -type insightsJSONQuery struct { - AppInsights struct { - Aggregation string `json:"aggregation"` - Alias string `json:"alias"` - AllowedTimeGrainsMs []int64 `json:"allowedTimeGrainsMs"` - Dimensions InsightsDimensions `json:"dimension"` - DimensionFilter string `json:"dimensionFilter"` - MetricName string `json:"metricName"` - TimeGrain string `json:"timeGrain"` - } `json:"appInsights"` - Raw *bool `json:"raw"` -} - -type insightsAnalyticsJSONQuery struct { - InsightsAnalytics struct { - Query string `json:"query"` - ResultFormat string `json:"resultFormat"` - } `json:"insightsAnalytics"` -} - -// logJSONQuery is the frontend JSON query model for an Azure Log Analytics query. -type logJSONQuery struct { +// LogJSONQuery is the frontend JSON query model for an Azure Log Analytics query. +type LogJSONQuery struct { AzureLogAnalytics struct { Query string `json:"query"` ResultFormat string `json:"resultFormat"` @@ -148,69 +156,22 @@ type logJSONQuery struct { } `json:"azureLogAnalytics"` } -type argJSONQuery struct { - AzureResourceGraph struct { - Query string `json:"query"` - ResultFormat string `json:"resultFormat"` - } `json:"azureResourceGraph"` -} - -// metricChartDefinition is the JSON model for a metrics chart definition -type metricChartDefinition struct { +// MetricChartDefinition is the JSON model for a metrics chart definition +type MetricChartDefinition struct { ResourceMetadata map[string]string `json:"resourceMetadata"` Name string `json:"name"` AggregationType int `json:"aggregationType"` Namespace string `json:"namespace"` - MetricVisualization metricVisualization `json:"metricVisualization"` + MetricVisualization MetricVisualization `json:"metricVisualization"` } -// metricVisualization is the JSON model for the visualization field of a +// MetricVisualization is the JSON model for the visualization field of a // metricChartDefinition -type metricVisualization struct { +type MetricVisualization struct { DisplayName string `json:"displayName"` ResourceDisplayName string `json:"resourceDisplayName"` } -// InsightsDimensions will unmarshal from a JSON string, or an array of strings, -// into a string array. This exists to support an older query format which is updated -// when a user saves the query or it is sent from the front end, but may not be when -// alerting fetches the model. -type InsightsDimensions []string - -// UnmarshalJSON fulfills the json.Unmarshaler interface type. -func (s *InsightsDimensions) UnmarshalJSON(data []byte) error { - *s = InsightsDimensions{} - if string(data) == "null" || string(data) == "" { - return nil - } - if strings.ToLower(string(data)) == `"none"` { - return nil - } - if data[0] == '[' { - var sa []string - err := json.Unmarshal(data, &sa) - if err != nil { - return err - } - dimensions := []string{} - for _, v := range sa { - if v == "none" || v == "None" { - continue - } - dimensions = append(dimensions, v) - } - *s = InsightsDimensions(dimensions) - return nil - } - - var str string - err := json.Unmarshal(data, &str) - if err != nil { - return fmt.Errorf("could not parse %q as string or array: %w", string(data), err) - } - if str != "" { - *s = InsightsDimensions{str} - return nil - } - return nil +type ServiceProxy interface { + Do(rw http.ResponseWriter, req *http.Request, cli *http.Client) http.ResponseWriter } diff --git a/pkg/tsdb/cloudwatch/annotation_query_test.go b/pkg/tsdb/cloudwatch/annotation_query_test.go index 6f3d7c71d6b..ad947c538c9 100644 --- a/pkg/tsdb/cloudwatch/annotation_query_test.go +++ b/pkg/tsdb/cloudwatch/annotation_query_test.go @@ -97,10 +97,3 @@ func TestQuery_AnnotationQuery(t *testing.T) { }, client.calls.describeAlarms[0]) }) } - -func pointerString(s string) *string { - return &s -} -func pointerInt64(i int64) *int64 { - return &i -} diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index a2d65a23bde..bbd68f269e7 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -169,6 +169,49 @@ func (e *cloudWatchExecutor) CallResource(ctx context.Context, req *backend.Call return e.resourceHandler.CallResource(ctx, req, sender) } +func (e *cloudWatchExecutor) checkHealthMetrics(pluginCtx backend.PluginContext) error { + namespace := "AWS/Billing" + metric := "EstimatedCharges" + params := &cloudwatch.ListMetricsInput{ + Namespace: &namespace, + MetricName: &metric, + } + _, err := e.listMetrics(pluginCtx, defaultRegion, params) + return err +} + +func (e *cloudWatchExecutor) checkHealthLogs(ctx context.Context, pluginCtx backend.PluginContext) error { + logsClient, err := e.getCWLogsClient(pluginCtx, defaultRegion) + if err != nil { + return err + } + _, err = e.handleDescribeLogGroups(ctx, logsClient, simplejson.NewFromAny(map[string]interface{}{"limit": "1"})) + return err +} + +func (e *cloudWatchExecutor) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + status := backend.HealthStatusOk + metricsTest := "Successfully queried the CloudWatch metrics API." + logsTest := "Successfully queried the CloudWatch logs API." + + err := e.checkHealthMetrics(req.PluginContext) + if err != nil { + status = backend.HealthStatusError + metricsTest = fmt.Sprintf("CloudWatch metrics query failed: %s", err.Error()) + } + + err = e.checkHealthLogs(ctx, req.PluginContext) + if err != nil { + status = backend.HealthStatusError + logsTest = fmt.Sprintf("CloudWatch logs query failed: %s", err.Error()) + } + + return &backend.CheckHealthResult{ + Status: status, + Message: fmt.Sprintf("1. %s\n2. %s", metricsTest, logsTest), + }, nil +} + func (e *cloudWatchExecutor) newSession(pluginCtx backend.PluginContext, region string) (*session.Session, error) { dsInfo, err := e.getDSInfo(pluginCtx) if err != nil { diff --git a/pkg/tsdb/cloudwatch/cloudwatch_test.go b/pkg/tsdb/cloudwatch/cloudwatch_test.go index 928667d61f9..b0d6a187da3 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch_test.go +++ b/pkg/tsdb/cloudwatch/cloudwatch_test.go @@ -1,12 +1,24 @@ package cloudwatch import ( + "context" + "fmt" "testing" + "github.com/aws/aws-sdk-go/aws" + awsrequest "github.com/aws/aws-sdk-go/aws/request" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/cloudwatch" + "github.com/aws/aws-sdk-go/service/cloudwatch/cloudwatchiface" + "github.com/aws/aws-sdk-go/service/cloudwatchlogs" + "github.com/aws/aws-sdk-go/service/cloudwatchlogs/cloudwatchlogsiface" "github.com/google/go-cmp/cmp" "github.com/grafana/grafana-aws-sdk/pkg/awsds" "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/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -72,3 +84,101 @@ func TestNewInstanceSettings(t *testing.T) { }) } } + +func Test_CheckHealth(t *testing.T) { + origNewCWClient := NewCWClient + origNewCWLogsClient := NewCWLogsClient + t.Cleanup(func() { + NewCWClient = origNewCWClient + NewCWLogsClient = origNewCWLogsClient + }) + + var client fakeCheckHealthClient + NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI { + return client + } + NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { + return client + } + + t.Run("successfully query metrics and logs", func(t *testing.T) { + client = fakeCheckHealthClient{} + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return datasourceInfo{}, nil + }) + executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) + + resp, err := executor.CheckHealth(context.Background(), &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, + }) + + assert.NoError(t, err) + assert.Equal(t, &backend.CheckHealthResult{ + Status: backend.HealthStatusOk, + Message: "1. Successfully queried the CloudWatch metrics API.\n2. Successfully queried the CloudWatch logs API.", + }, resp) + }) + + t.Run("successfully queries metrics, fails during logs query", func(t *testing.T) { + client = fakeCheckHealthClient{ + describeLogGroupsWithContext: func(ctx aws.Context, input *cloudwatchlogs.DescribeLogGroupsInput, + options ...awsrequest.Option) (*cloudwatchlogs.DescribeLogGroupsOutput, error) { + return nil, fmt.Errorf("some logs query error") + }} + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return datasourceInfo{}, nil + }) + executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) + + resp, err := executor.CheckHealth(context.Background(), &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, + }) + + assert.NoError(t, err) + assert.Equal(t, &backend.CheckHealthResult{ + Status: backend.HealthStatusError, + Message: "1. Successfully queried the CloudWatch metrics API.\n2. CloudWatch logs query failed: some logs query error", + }, resp) + }) + + t.Run("successfully queries logs, fails during metrics query", func(t *testing.T) { + client = fakeCheckHealthClient{ + listMetricsPages: func(input *cloudwatch.ListMetricsInput, fn func(*cloudwatch.ListMetricsOutput, bool) bool) error { + return fmt.Errorf("some list metrics error") + }} + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return datasourceInfo{}, nil + }) + executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) + + resp, err := executor.CheckHealth(context.Background(), &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, + }) + + assert.NoError(t, err) + assert.Equal(t, &backend.CheckHealthResult{ + Status: backend.HealthStatusError, + Message: "1. CloudWatch metrics query failed: some list metrics error\n2. Successfully queried the CloudWatch logs API.", + }, resp) + }) + + t.Run("fail to get clients", func(t *testing.T) { + client = fakeCheckHealthClient{} + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return datasourceInfo{}, nil + }) + executor := newExecutor(im, newTestConfig(), fakeSessionCache{getSession: func(c awsds.SessionConfig) (*session.Session, error) { + return nil, fmt.Errorf("some sessions error") + }}) + + resp, err := executor.CheckHealth(context.Background(), &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, + }) + + assert.NoError(t, err) + assert.Equal(t, &backend.CheckHealthResult{ + Status: backend.HealthStatusError, + Message: "1. CloudWatch metrics query failed: some sessions error\n2. CloudWatch logs query failed: some sessions error", + }, resp) + }) +} diff --git a/pkg/tsdb/cloudwatch/get_metric_data_executor_test.go b/pkg/tsdb/cloudwatch/get_metric_data_executor_test.go index 46612e4ed43..dc9c3893077 100644 --- a/pkg/tsdb/cloudwatch/get_metric_data_executor_test.go +++ b/pkg/tsdb/cloudwatch/get_metric_data_executor_test.go @@ -12,10 +12,10 @@ import ( "github.com/stretchr/testify/require" ) -var counter = 1 - type cloudWatchFakeClient struct { cloudwatchiface.CloudWatchAPI + + counterForGetMetricDataWithContext int } func (client *cloudWatchFakeClient) GetMetricDataWithContext(ctx aws.Context, input *cloudwatch.GetMetricDataInput, opts ...request.Option) (*cloudwatch.GetMetricDataOutput, error) { @@ -23,13 +23,13 @@ func (client *cloudWatchFakeClient) GetMetricDataWithContext(ctx aws.Context, in res := []*cloudwatch.MetricDataResult{{ Values: []*float64{aws.Float64(12.3), aws.Float64(23.5)}, }} - if counter == 0 { + if client.counterForGetMetricDataWithContext == 0 { nextToken = "" res = []*cloudwatch.MetricDataResult{{ Values: []*float64{aws.Float64(100)}, }} } - counter-- + client.counterForGetMetricDataWithContext-- return &cloudwatch.GetMetricDataOutput{ MetricDataResults: res, NextToken: aws.String(nextToken), @@ -39,7 +39,7 @@ func (client *cloudWatchFakeClient) GetMetricDataWithContext(ctx aws.Context, in func TestGetMetricDataExecutorTest(t *testing.T) { executor := &cloudWatchExecutor{} inputs := &cloudwatch.GetMetricDataInput{MetricDataQueries: []*cloudwatch.MetricDataQuery{}} - res, err := executor.executeRequest(context.Background(), &cloudWatchFakeClient{}, inputs) + res, err := executor.executeRequest(context.Background(), &cloudWatchFakeClient{counterForGetMetricDataWithContext: 1}, inputs) require.NoError(t, err) require.Len(t, res, 2) require.Len(t, res[0].MetricDataResults[0].Values, 2) diff --git a/pkg/tsdb/cloudwatch/log_actions_test.go b/pkg/tsdb/cloudwatch/log_actions_test.go index 4d3df038de3..bfe6780a52f 100644 --- a/pkg/tsdb/cloudwatch/log_actions_test.go +++ b/pkg/tsdb/cloudwatch/log_actions_test.go @@ -28,7 +28,7 @@ func TestQuery_DescribeLogGroups(t *testing.T) { var cli FakeCWLogsClient NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { - return cli + return &cli } t.Run("Empty log group name prefix", func(t *testing.T) { @@ -155,7 +155,7 @@ func TestQuery_GetLogGroupFields(t *testing.T) { var cli FakeCWLogsClient NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { - return cli + return &cli } cli = FakeCWLogsClient{ @@ -232,7 +232,7 @@ func TestQuery_StartQuery(t *testing.T) { var cli FakeCWLogsClient NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { - return cli + return &cli } t.Run("invalid time range", func(t *testing.T) { @@ -357,6 +357,108 @@ func TestQuery_StartQuery(t *testing.T) { }) } +func Test_executeStartQuery(t *testing.T) { + origNewCWLogsClient := NewCWLogsClient + t.Cleanup(func() { + NewCWLogsClient = origNewCWLogsClient + }) + + var cli FakeCWLogsClient + + NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { + return &cli + } + + t.Run("successfully parses information from JSON to StartQueryWithContext", func(t *testing.T) { + cli = FakeCWLogsClient{} + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return datasourceInfo{}, nil + }) + executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) + + _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{From: time.Unix(0, 0), To: time.Unix(1, 0)}, + JSON: json.RawMessage(`{ + "type": "logAction", + "subtype": "StartQuery", + "limit": 12, + "queryString":"fields @message", + "logGroupNames":["some name","another name"] + }`), + }, + }, + }) + + assert.NoError(t, err) + assert.Equal(t, []*cloudwatchlogs.StartQueryInput{ + { + StartTime: pointerInt64(0), + EndTime: pointerInt64(1), + Limit: pointerInt64(12), + QueryString: pointerString("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"), + LogGroupNames: []*string{pointerString("some name"), pointerString("another name")}, + }, + }, cli.calls.startQueryWithContext) + }) + + t.Run("cannot parse limit as float", func(t *testing.T) { + cli = FakeCWLogsClient{} + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return datasourceInfo{}, nil + }) + executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) + + _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{From: time.Unix(0, 0), To: time.Unix(1, 0)}, + JSON: json.RawMessage(`{ + "type": "logAction", + "subtype": "StartQuery", + "limit": 12.0 + }`), + }, + }, + }) + + assert.NoError(t, err) + require.Len(t, cli.calls.startQueryWithContext, 1) + assert.Nil(t, cli.calls.startQueryWithContext[0].Limit) + }) + + t.Run("does not populate StartQueryInput.limit when no limit provided", func(t *testing.T) { + cli = FakeCWLogsClient{} + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { + return datasourceInfo{}, nil + }) + executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) + + _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}}, + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{From: time.Unix(0, 0), To: time.Unix(1, 0)}, + JSON: json.RawMessage(`{ + "type": "logAction", + "subtype": "StartQuery" + }`), + }, + }, + }) + + assert.NoError(t, err) + require.Len(t, cli.calls.startQueryWithContext, 1) + assert.Nil(t, cli.calls.startQueryWithContext[0].Limit) + }) +} + func TestQuery_StopQuery(t *testing.T) { origNewCWLogsClient := NewCWLogsClient t.Cleanup(func() { @@ -366,7 +468,7 @@ func TestQuery_StopQuery(t *testing.T) { var cli FakeCWLogsClient NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { - return cli + return &cli } cli = FakeCWLogsClient{ @@ -438,7 +540,7 @@ func TestQuery_GetQueryResults(t *testing.T) { var cli FakeCWLogsClient NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI { - return cli + return &cli } const refID = "A" diff --git a/pkg/tsdb/cloudwatch/metrics.go b/pkg/tsdb/cloudwatch/metrics.go index 6234f013913..cf2779ab8e7 100644 --- a/pkg/tsdb/cloudwatch/metrics.go +++ b/pkg/tsdb/cloudwatch/metrics.go @@ -514,7 +514,7 @@ var dimensionsMap = map[string][]string{ // Known AWS regions. var knownRegions = []string{ "af-south-1", "ap-east-1", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", "ap-southeast-1", - "ap-southeast-2", "ca-central-1", "cn-north-1", "cn-northwest-1", "eu-central-1", "eu-north-1", "eu-south-1", "eu-west-1", + "ap-southeast-2", "ap-southeast-3", "ca-central-1", "cn-north-1", "cn-northwest-1", "eu-central-1", "eu-north-1", "eu-south-1", "eu-west-1", "eu-west-2", "eu-west-3", "me-south-1", "sa-east-1", "us-east-1", "us-east-2", "us-gov-east-1", "us-gov-west-1", "us-iso-east-1", "us-isob-east-1", "us-west-1", "us-west-2", } diff --git a/pkg/tsdb/cloudwatch/test_utils.go b/pkg/tsdb/cloudwatch/test_utils.go index 8eac803c83a..eeb34a5ad5c 100644 --- a/pkg/tsdb/cloudwatch/test_utils.go +++ b/pkg/tsdb/cloudwatch/test_utils.go @@ -20,32 +20,41 @@ import ( type FakeCWLogsClient struct { cloudwatchlogsiface.CloudWatchLogsAPI + + calls logsQueryCalls + logGroups cloudwatchlogs.DescribeLogGroupsOutput logGroupFields cloudwatchlogs.GetLogGroupFieldsOutput queryResults cloudwatchlogs.GetQueryResultsOutput } -func (m FakeCWLogsClient) GetQueryResultsWithContext(ctx context.Context, input *cloudwatchlogs.GetQueryResultsInput, option ...request.Option) (*cloudwatchlogs.GetQueryResultsOutput, error) { +type logsQueryCalls struct { + startQueryWithContext []*cloudwatchlogs.StartQueryInput +} + +func (m *FakeCWLogsClient) GetQueryResultsWithContext(ctx context.Context, input *cloudwatchlogs.GetQueryResultsInput, option ...request.Option) (*cloudwatchlogs.GetQueryResultsOutput, error) { return &m.queryResults, nil } -func (m FakeCWLogsClient) StartQueryWithContext(ctx context.Context, input *cloudwatchlogs.StartQueryInput, option ...request.Option) (*cloudwatchlogs.StartQueryOutput, error) { +func (m *FakeCWLogsClient) StartQueryWithContext(ctx context.Context, input *cloudwatchlogs.StartQueryInput, option ...request.Option) (*cloudwatchlogs.StartQueryOutput, error) { + m.calls.startQueryWithContext = append(m.calls.startQueryWithContext, input) + return &cloudwatchlogs.StartQueryOutput{ QueryId: aws.String("abcd-efgh-ijkl-mnop"), }, nil } -func (m FakeCWLogsClient) StopQueryWithContext(ctx context.Context, input *cloudwatchlogs.StopQueryInput, option ...request.Option) (*cloudwatchlogs.StopQueryOutput, error) { +func (m *FakeCWLogsClient) StopQueryWithContext(ctx context.Context, input *cloudwatchlogs.StopQueryInput, option ...request.Option) (*cloudwatchlogs.StopQueryOutput, error) { return &cloudwatchlogs.StopQueryOutput{ Success: aws.Bool(true), }, nil } -func (m FakeCWLogsClient) DescribeLogGroupsWithContext(ctx context.Context, input *cloudwatchlogs.DescribeLogGroupsInput, option ...request.Option) (*cloudwatchlogs.DescribeLogGroupsOutput, error) { +func (m *FakeCWLogsClient) DescribeLogGroupsWithContext(ctx context.Context, input *cloudwatchlogs.DescribeLogGroupsInput, option ...request.Option) (*cloudwatchlogs.DescribeLogGroupsOutput, error) { return &m.logGroups, nil } -func (m FakeCWLogsClient) GetLogGroupFieldsWithContext(ctx context.Context, input *cloudwatchlogs.GetLogGroupFieldsInput, option ...request.Option) (*cloudwatchlogs.GetLogGroupFieldsOutput, error) { +func (m *FakeCWLogsClient) GetLogGroupFieldsWithContext(ctx context.Context, input *cloudwatchlogs.GetLogGroupFieldsInput, option ...request.Option) (*cloudwatchlogs.GetLogGroupFieldsOutput, error) { return &m.logGroupFields, nil } @@ -163,6 +172,29 @@ func (c fakeRGTAClient) GetResourcesPages(in *resourcegroupstaggingapi.GetResour return nil } +type fakeCheckHealthClient struct { + cloudwatchiface.CloudWatchAPI + cloudwatchlogsiface.CloudWatchLogsAPI + + listMetricsPages func(input *cloudwatch.ListMetricsInput, fn func(*cloudwatch.ListMetricsOutput, bool) bool) error + describeLogGroupsWithContext func(ctx aws.Context, input *cloudwatchlogs.DescribeLogGroupsInput, + options ...request.Option) (*cloudwatchlogs.DescribeLogGroupsOutput, error) +} + +func (c fakeCheckHealthClient) ListMetricsPages(input *cloudwatch.ListMetricsInput, fn func(*cloudwatch.ListMetricsOutput, bool) bool) error { + if c.listMetricsPages != nil { + return c.listMetricsPages(input, fn) + } + return nil +} + +func (c fakeCheckHealthClient) DescribeLogGroupsWithContext(ctx aws.Context, input *cloudwatchlogs.DescribeLogGroupsInput, options ...request.Option) (*cloudwatchlogs.DescribeLogGroupsOutput, error) { + if c.describeLogGroupsWithContext != nil { + return c.describeLogGroupsWithContext(ctx, input, options...) + } + return nil, nil +} + func chunkSlice(slice []*cloudwatch.Metric, chunkSize int) [][]*cloudwatch.Metric { var chunks [][]*cloudwatch.Metric for { @@ -185,10 +217,21 @@ func newTestConfig() *setting.Cfg { } type fakeSessionCache struct { + getSession func(c awsds.SessionConfig) (*session.Session, error) } func (s fakeSessionCache) GetSession(c awsds.SessionConfig) (*session.Session, error) { + if s.getSession != nil { + return s.getSession(c) + } return &session.Session{ Config: &aws.Config{}, }, nil } + +func pointerString(s string) *string { + return &s +} +func pointerInt64(i int64) *int64 { + return &i +} diff --git a/pkg/tsdb/loki/api.go b/pkg/tsdb/loki/api.go index 9168f4e9cd1..92ffb697049 100644 --- a/pkg/tsdb/loki/api.go +++ b/pkg/tsdb/loki/api.go @@ -28,16 +28,41 @@ func newLokiAPI(client *http.Client, url string, log log.Logger) *LokiAPI { func makeRequest(ctx context.Context, lokiDsUrl string, query lokiQuery) (*http.Request, error) { qs := url.Values{} qs.Set("query", query.Expr) - qs.Set("step", query.Step.String()) - qs.Set("start", strconv.FormatInt(query.Start.UnixNano(), 10)) - qs.Set("end", strconv.FormatInt(query.End.UnixNano(), 10)) + + // MaxLines defaults to zero when not received, + // and Loki does not like limit=0, even when it is not needed + // (for example for metric queries), so we + // only send it when it's set + if query.MaxLines > 0 { + qs.Set("limit", fmt.Sprintf("%d", query.MaxLines)) + } lokiUrl, err := url.Parse(lokiDsUrl) if err != nil { return nil, err } - lokiUrl.Path = "/loki/api/v1/query_range" + switch query.QueryType { + case QueryTypeRange: + { + qs.Set("start", strconv.FormatInt(query.Start.UnixNano(), 10)) + qs.Set("end", strconv.FormatInt(query.End.UnixNano(), 10)) + // NOTE: technically for streams-producing queries `step` + // is ignored, so it would be nicer to not send it in such cases, + // but we cannot detect that situation, so we always send it. + // it should not break anything. + qs.Set("step", query.Step.String()) + lokiUrl.Path = "/loki/api/v1/query_range" + } + case QueryTypeInstant: + { + qs.Set("time", strconv.FormatInt(query.End.UnixNano(), 10)) + lokiUrl.Path = "/loki/api/v1/query" + } + default: + return nil, fmt.Errorf("invalid QueryType: %v", query.QueryType) + } + lokiUrl.RawQuery = qs.Encode() req, err := http.NewRequestWithContext(ctx, "GET", lokiUrl.String(), nil) @@ -51,11 +76,10 @@ func makeRequest(ctx context.Context, lokiDsUrl string, query lokiQuery) (*http. // so it is not a regression. // twe need to have that when we migrate to backend-queries. // - // 2. we will have to send a custom http header based on the VolumeQuery prop - // (again, not needed for the alerting scenario) - // if query.VolumeQuery { - // req.Header.Set("X-Query-Tags", "Source=logvolhist") - // } + + if query.VolumeQuery { + req.Header.Set("X-Query-Tags", "Source=logvolhist") + } return req, nil } @@ -103,7 +127,7 @@ func makeLokiError(body io.ReadCloser) error { return fmt.Errorf("%v", errorMessage) } -func (api *LokiAPI) QueryRange(ctx context.Context, query lokiQuery) (*loghttp.QueryResponse, error) { +func (api *LokiAPI) Query(ctx context.Context, query lokiQuery) (*loghttp.QueryResponse, error) { req, err := makeRequest(ctx, api.url, query) if err != nil { return nil, err diff --git a/pkg/tsdb/loki/frame.go b/pkg/tsdb/loki/frame.go new file mode 100644 index 00000000000..43cef9abd26 --- /dev/null +++ b/pkg/tsdb/loki/frame.go @@ -0,0 +1,113 @@ +package loki + +import ( + "fmt" + "sort" + "strings" + + "github.com/grafana/grafana-plugin-sdk-go/data" +) + +// we adjust the dataframes to be the way frontend & alerting +// wants them. +func adjustFrame(frame *data.Frame, query *lokiQuery) *data.Frame { + labels := getFrameLabels(frame) + + timeFields, nonTimeFields := partitionFields(frame) + + isMetricFrame := nonTimeFields[0].Type() != data.FieldTypeString + + isMetricRange := isMetricFrame && query.QueryType == QueryTypeRange + + name := formatName(labels, query) + frame.Name = name + + if frame.Meta == nil { + frame.Meta = &data.FrameMeta{} + } + + if isMetricRange { + frame.Meta.ExecutedQueryString = "Expr: " + query.Expr + "\n" + "Step: " + query.Step.String() + } else { + frame.Meta.ExecutedQueryString = "Expr: " + query.Expr + } + + for _, field := range timeFields { + field.Name = "time" + + if isMetricRange { + if field.Config == nil { + field.Config = &data.FieldConfig{} + } + field.Config.Interval = float64(query.Step.Milliseconds()) + } + } + + for _, field := range nonTimeFields { + field.Name = "value" + if field.Config == nil { + field.Config = &data.FieldConfig{} + } + field.Config.DisplayNameFromDS = name + } + + return frame +} + +func formatNamePrometheusStyle(labels map[string]string) string { + var parts []string + + for k, v := range labels { + parts = append(parts, fmt.Sprintf("%s=%q", k, v)) + } + + sort.Strings(parts) + + return fmt.Sprintf("{%s}", strings.Join(parts, ", ")) +} + +//If legend (using of name or pattern instead of time series name) is used, use that name/pattern for formatting +func formatName(labels map[string]string, query *lokiQuery) string { + if query.LegendFormat == "" { + return formatNamePrometheusStyle(labels) + } + + result := legendFormat.ReplaceAllFunc([]byte(query.LegendFormat), func(in []byte) []byte { + labelName := strings.Replace(string(in), "{{", "", 1) + labelName = strings.Replace(labelName, "}}", "", 1) + labelName = strings.TrimSpace(labelName) + if val, exists := labels[labelName]; exists { + return []byte(val) + } + return []byte{} + }) + + return string(result) +} + +func getFrameLabels(frame *data.Frame) map[string]string { + labels := make(map[string]string) + + for _, field := range frame.Fields { + for k, v := range field.Labels { + labels[k] = v + } + } + + return labels +} + +func partitionFields(frame *data.Frame) ([]*data.Field, []*data.Field) { + var timeFields []*data.Field + var nonTimeFields []*data.Field + + for _, field := range frame.Fields { + if field.Type() == data.FieldTypeTime { + timeFields = append(timeFields, field) + } else { + nonTimeFields = append(nonTimeFields, field) + } + } + + return timeFields, nonTimeFields +} diff --git a/pkg/tsdb/loki/frame_test.go b/pkg/tsdb/loki/frame_test.go new file mode 100644 index 00000000000..ee2d0a74637 --- /dev/null +++ b/pkg/tsdb/loki/frame_test.go @@ -0,0 +1,87 @@ +package loki + +import ( + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/stretchr/testify/require" +) + +func TestFormatName(t *testing.T) { + t.Run("converting metric name", func(t *testing.T) { + metric := map[string]string{ + "app": "backend", + "device": "mobile", + } + + query := &lokiQuery{ + LegendFormat: "legend {{app}} {{ device }} {{broken}}", + } + + require.Equal(t, "legend backend mobile ", formatName(metric, query)) + }) + + t.Run("build full series name", func(t *testing.T) { + metric := map[string]string{ + "app": "backend", + "device": "mobile", + } + + query := &lokiQuery{ + LegendFormat: "", + } + + require.Equal(t, `{app="backend", device="mobile"}`, formatName(metric, query)) + }) +} + +func TestAdjustFrame(t *testing.T) { + t.Run("response should be parsed normally", func(t *testing.T) { + field1 := data.NewField("", nil, make([]time.Time, 0)) + field2 := data.NewField("", nil, make([]float64, 0)) + field2.Labels = data.Labels{"app": "Application", "tag2": "tag2"} + + frame := data.NewFrame("test", field1, field2) + frame.SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMany}) + + query := &lokiQuery{ + Expr: "up(ALERTS)", + QueryType: QueryTypeRange, + LegendFormat: "legend {{app}}", + Step: time.Second * 42, + } + + adjustFrame(frame, query) + + require.Equal(t, frame.Name, "legend Application") + require.Equal(t, frame.Meta.ExecutedQueryString, "Expr: up(ALERTS)\nStep: 42s") + require.Equal(t, frame.Fields[0].Config.Interval, float64(42000)) + require.Equal(t, frame.Fields[1].Config.DisplayNameFromDS, "legend Application") + }) + + t.Run("should set interval-attribute in response", func(t *testing.T) { + query := &lokiQuery{ + Step: time.Second * 42, + QueryType: QueryTypeRange, + } + + field1 := data.NewField("", nil, make([]time.Time, 0)) + field2 := data.NewField("", nil, make([]float64, 0)) + + frame := data.NewFrame("test", field1, field2) + frame.SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMany}) + + adjustFrame(frame, query) + + // to keep the test simple, we assume the + // first field is the time-field + timeField := frame.Fields[0] + require.NotNil(t, timeField) + require.Equal(t, data.FieldTypeTime, timeField.Type()) + + timeFieldConfig := timeField.Config + require.NotNil(t, timeFieldConfig) + require.Equal(t, float64(42000), timeFieldConfig.Interval) + }) +} diff --git a/pkg/tsdb/loki/framing_test.go b/pkg/tsdb/loki/framing_test.go index a77ae17c54f..e6c862a91e7 100644 --- a/pkg/tsdb/loki/framing_test.go +++ b/pkg/tsdb/loki/framing_test.go @@ -22,17 +22,27 @@ import ( // but i wanted to test for all of them, to be sure. func TestSuccessResponse(t *testing.T) { + matrixQuery := lokiQuery{Expr: "up(ALERTS)", Step: time.Second * 42, QueryType: QueryTypeRange} + vectorQuery := lokiQuery{Expr: "query1", QueryType: QueryTypeInstant} + streamsQuery := lokiQuery{Expr: "query1", QueryType: QueryTypeRange} + tt := []struct { name string filepath string + query lokiQuery }{ - {name: "parse a simple matrix response", filepath: "matrix_simple"}, - {name: "parse a matrix response with a time-gap in the middle", filepath: "matrix_gap"}, + {name: "parse a simple matrix response", filepath: "matrix_simple", query: matrixQuery}, + {name: "parse a matrix response with a time-gap in the middle", filepath: "matrix_gap", query: matrixQuery}, // you can produce NaN by having a metric query and add ` / 0` to the end - {name: "parse a matrix response with NaN", filepath: "matrix_nan"}, + {name: "parse a matrix response with NaN", filepath: "matrix_nan", query: matrixQuery}, // you can produce Infinity by using `quantile_over_time(42,` (value larger than 1) - {name: "parse a matrix response with Infinity", filepath: "matrix_inf"}, - {name: "parse a matrix response with very small step value", filepath: "matrix_small_step"}, + {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}, + + {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}, + + {name: "parse a simple streams response", filepath: "streams_simple", query: streamsQuery}, } for _, test := range tt { @@ -43,7 +53,7 @@ func TestSuccessResponse(t *testing.T) { bytes, err := os.ReadFile(responseFileName) require.NoError(t, err) - frames, err := runQuery(context.Background(), makeMockedAPI(200, "application/json", bytes), &lokiQuery{Expr: "up(ALERTS)", Step: time.Second * 42}) + frames, err := runQuery(context.Background(), makeMockedAPI(200, "application/json", bytes), &test.query) require.NoError(t, err) dr := &backend.DataResponse{ @@ -103,7 +113,7 @@ func TestErrorResponse(t *testing.T) { for _, test := range tt { t.Run(test.name, func(t *testing.T) { - frames, err := runQuery(context.Background(), makeMockedAPI(400, test.contentType, test.body), &lokiQuery{}) + frames, err := runQuery(context.Background(), makeMockedAPI(400, test.contentType, test.body), &lokiQuery{QueryType: QueryTypeRange}) require.Len(t, frames, 0) require.Error(t, err) diff --git a/pkg/tsdb/loki/loki.go b/pkg/tsdb/loki/loki.go index 69db994b5d4..59cc30eb7e1 100644 --- a/pkg/tsdb/loki/loki.go +++ b/pkg/tsdb/loki/loki.go @@ -2,11 +2,11 @@ package loki import ( "context" + "encoding/json" "fmt" "net/http" "regexp" - "strings" - "time" + "sync" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" @@ -15,10 +15,7 @@ import ( "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/loki/pkg/loghttp" "go.opentelemetry.io/otel/attribute" - - "github.com/prometheus/common/model" ) type Service struct { @@ -27,6 +24,11 @@ type Service struct { tracer tracing.Tracer } +var ( + _ backend.QueryDataHandler = (*Service)(nil) + _ backend.StreamHandler = (*Service)(nil) +) + func ProvideService(httpClientProvider httpclient.Provider, tracer tracing.Tracer) *Service { return &Service{ im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)), @@ -42,15 +44,27 @@ var ( type datasourceInfo struct { HTTPClient *http.Client URL string + + // open streams + streams map[string]data.FrameJSONCache + streamsMu sync.RWMutex } -type QueryModel struct { +type QueryJSONModel struct { QueryType string `json:"queryType"` Expr string `json:"expr"` LegendFormat string `json:"legendFormat"` Interval string `json:"interval"` IntervalMS int `json:"intervalMS"` Resolution int64 `json:"resolution"` + MaxLines int `json:"maxLines"` + VolumeQuery bool `json:"volumeQuery"` +} + +func parseQueryModel(raw json.RawMessage) (*QueryJSONModel, error) { + model := &QueryJSONModel{} + err := json.Unmarshal(raw, model) + return model, err } func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.InstanceFactoryFunc { @@ -68,6 +82,7 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst model := &datasourceInfo{ HTTPClient: client, URL: settings.URL, + streams: make(map[string]data.FrameJSONCache), } return model, nil } @@ -111,67 +126,9 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return result, nil } -//If legend (using of name or pattern instead of time series name) is used, use that name/pattern for formatting -func formatLegend(metric model.Metric, query *lokiQuery) string { - if query.LegendFormat == "" { - return metric.String() - } - - result := legendFormat.ReplaceAllFunc([]byte(query.LegendFormat), func(in []byte) []byte { - labelName := strings.Replace(string(in), "{{", "", 1) - labelName = strings.Replace(labelName, "}}", "", 1) - labelName = strings.TrimSpace(labelName) - if val, exists := metric[model.LabelName(labelName)]; exists { - return []byte(val) - } - return []byte{} - }) - - return string(result) -} - -func parseResponse(value *loghttp.QueryResponse, query *lokiQuery) (data.Frames, error) { - frames := data.Frames{} - - //We are currently processing only matrix results (for alerting) - matrix, ok := value.Data.Result.(loghttp.Matrix) - if !ok { - return frames, fmt.Errorf("unsupported result format: %q", value.Data.ResultType) - } - - for _, v := range matrix { - name := formatLegend(v.Metric, query) - tags := make(map[string]string, len(v.Metric)) - timeVector := make([]time.Time, 0, len(v.Values)) - values := make([]float64, 0, len(v.Values)) - - for k, v := range v.Metric { - tags[string(k)] = string(v) - } - - for _, k := range v.Values { - timeVector = append(timeVector, k.Timestamp.Time().UTC()) - values = append(values, float64(k.Value)) - } - - timeField := data.NewField("time", nil, timeVector) - timeField.Config = &data.FieldConfig{Interval: float64(query.Step.Milliseconds())} - valueField := data.NewField("value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: name}) - - frame := data.NewFrame(name, timeField, valueField) - frame.SetMeta(&data.FrameMeta{ - ExecutedQueryString: "Expr: " + query.Expr + "\n" + "Step: " + query.Step.String(), - }) - - frames = append(frames, frame) - } - - return frames, nil -} - // we extracted this part of the functionality to make it easy to unit-test it func runQuery(ctx context.Context, api *LokiAPI, query *lokiQuery) (data.Frames, error) { - value, err := api.QueryRange(ctx, *query) + value, err := api.Query(ctx, *query) if err != nil { return data.Frames{}, err } diff --git a/pkg/tsdb/loki/parse_query.go b/pkg/tsdb/loki/parse_query.go index 8de9b75a01f..d62802e5f06 100644 --- a/pkg/tsdb/loki/parse_query.go +++ b/pkg/tsdb/loki/parse_query.go @@ -1,7 +1,7 @@ package loki import ( - "encoding/json" + "fmt" "math" "strconv" "strings" @@ -52,11 +52,25 @@ func interpolateVariables(expr string, interval time.Duration, timeRange time.Du return expr } +func parseQueryType(jsonValue string) (QueryType, error) { + switch jsonValue { + case "instant": + return QueryTypeInstant, nil + case "range": + return QueryTypeRange, nil + case "": + // there are older queries stored in alerting that did not have queryType, + // those were range-queries + return QueryTypeRange, nil + default: + return QueryTypeRange, fmt.Errorf("invalid queryType: %s", jsonValue) + } +} + func parseQuery(queryContext *backend.QueryDataRequest) ([]*lokiQuery, error) { qs := []*lokiQuery{} for _, query := range queryContext.Queries { - model := &QueryModel{} - err := json.Unmarshal(query.JSON, model) + model, err := parseQueryModel(query.JSON) if err != nil { return nil, err } @@ -76,13 +90,21 @@ func parseQuery(queryContext *backend.QueryDataRequest) ([]*lokiQuery, error) { expr := interpolateVariables(model.Expr, interval, timeRange) + queryType, err := parseQueryType(model.QueryType) + if err != nil { + return nil, err + } + qs = append(qs, &lokiQuery{ Expr: expr, + QueryType: queryType, Step: step, + MaxLines: model.MaxLines, LegendFormat: model.LegendFormat, Start: start, End: end, RefID: query.RefID, + VolumeQuery: model.VolumeQuery, }) } diff --git a/pkg/tsdb/loki/parse_response.go b/pkg/tsdb/loki/parse_response.go new file mode 100644 index 00000000000..d56627b6bec --- /dev/null +++ b/pkg/tsdb/loki/parse_response.go @@ -0,0 +1,114 @@ +package loki + +import ( + "fmt" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/loki/pkg/loghttp" +) + +func parseResponse(value *loghttp.QueryResponse, query *lokiQuery) (data.Frames, error) { + frames, err := lokiResponseToDataFrames(value, query) + + if err != nil { + return nil, err + } + + for _, frame := range frames { + adjustFrame(frame, query) + } + + return frames, nil +} + +func lokiResponseToDataFrames(value *loghttp.QueryResponse, query *lokiQuery) (data.Frames, error) { + switch res := value.Data.Result.(type) { + case loghttp.Matrix: + return lokiMatrixToDataFrames(res, query), nil + case loghttp.Vector: + return lokiVectorToDataFrames(res, query), nil + case loghttp.Streams: + return lokiStreamsToDataFrames(res, query), nil + default: + return nil, fmt.Errorf("resultType %T not supported{", res) + } +} + +func lokiMatrixToDataFrames(matrix loghttp.Matrix, query *lokiQuery) data.Frames { + frames := data.Frames{} + + for _, v := range matrix { + tags := make(map[string]string, len(v.Metric)) + timeVector := make([]time.Time, 0, len(v.Values)) + values := make([]float64, 0, len(v.Values)) + + for k, v := range v.Metric { + tags[string(k)] = string(v) + } + + for _, k := range v.Values { + timeVector = append(timeVector, k.Timestamp.Time().UTC()) + values = append(values, float64(k.Value)) + } + + timeField := data.NewField("", nil, timeVector) + valueField := data.NewField("", tags, values) + + frame := data.NewFrame("", timeField, valueField) + + frames = append(frames, frame) + } + + return frames +} + +func lokiVectorToDataFrames(vector loghttp.Vector, query *lokiQuery) data.Frames { + frames := data.Frames{} + + for _, v := range vector { + tags := make(map[string]string, len(v.Metric)) + timeVector := []time.Time{v.Timestamp.Time().UTC()} + values := []float64{float64(v.Value)} + + for k, v := range v.Metric { + tags[string(k)] = string(v) + } + timeField := data.NewField("", nil, timeVector) + valueField := data.NewField("", tags, values) + + frame := data.NewFrame("", timeField, valueField) + + frames = append(frames, frame) + } + + return frames +} + +func lokiStreamsToDataFrames(streams loghttp.Streams, query *lokiQuery) data.Frames { + frames := data.Frames{} + + for _, v := range streams { + tags := make(map[string]string, len(v.Labels)) + timeVector := make([]time.Time, 0, len(v.Entries)) + values := make([]string, 0, len(v.Entries)) + + for k, v := range v.Labels { + tags[k] = v + } + + for _, k := range v.Entries { + timeVector = append(timeVector, k.Timestamp.UTC()) + values = append(values, k.Line) + } + + timeField := data.NewField("", nil, timeVector) + valueField := data.NewField("", tags, values) + + frame := data.NewFrame("", timeField, valueField) + + frames = append(frames, frame) + } + + return frames +} diff --git a/pkg/tsdb/loki/loki_test.go b/pkg/tsdb/loki/parse_response_test.go similarity index 73% rename from pkg/tsdb/loki/loki_test.go rename to pkg/tsdb/loki/parse_response_test.go index f7f409f8e40..7e7309e179a 100644 --- a/pkg/tsdb/loki/loki_test.go +++ b/pkg/tsdb/loki/parse_response_test.go @@ -11,45 +11,15 @@ import ( "github.com/stretchr/testify/require" ) -func TestLoki(t *testing.T) { - t.Run("converting metric name", func(t *testing.T) { - metric := map[p.LabelName]p.LabelValue{ - p.LabelName("app"): p.LabelValue("backend"), - p.LabelName("device"): p.LabelValue("mobile"), - } - - query := &lokiQuery{ - LegendFormat: "legend {{app}} {{ device }} {{broken}}", - } - - require.Equal(t, "legend backend mobile ", formatLegend(metric, query)) - }) - - t.Run("build full series name", func(t *testing.T) { - metric := map[p.LabelName]p.LabelValue{ - p.LabelName(p.MetricNameLabel): p.LabelValue("http_request_total"), - p.LabelName("app"): p.LabelValue("backend"), - p.LabelName("device"): p.LabelValue("mobile"), - } - - query := &lokiQuery{ - LegendFormat: "", - } - - require.Equal(t, `http_request_total{app="backend", device="mobile"}`, formatLegend(metric, query)) - }) -} - func TestParseResponse(t *testing.T) { - t.Run("value is not of type matrix", func(t *testing.T) { - queryRes := data.Frames{} + t.Run("value is not of supported type", func(t *testing.T) { value := loghttp.QueryResponse{ Data: loghttp.QueryResponseData{ - Result: loghttp.Vector{}, + Result: loghttp.Scalar{}, }, } res, err := parseResponse(&value, nil) - require.Equal(t, queryRes, res) + require.Equal(t, len(res), 0) require.Error(t, err) }) @@ -74,6 +44,7 @@ func TestParseResponse(t *testing.T) { query := &lokiQuery{ Expr: "up(ALERTS)", + QueryType: QueryTypeRange, LegendFormat: "legend {{app}}", Step: time.Second * 42, } @@ -117,7 +88,8 @@ func TestParseResponse(t *testing.T) { } query := &lokiQuery{ - Step: time.Second * 42, + Step: time.Second * 42, + QueryType: QueryTypeRange, } frames, err := parseResponse(&value, query) diff --git a/pkg/tsdb/loki/streaming.go b/pkg/tsdb/loki/streaming.go new file mode 100644 index 00000000000..e2c6ff96180 --- /dev/null +++ b/pkg/tsdb/loki/streaming.go @@ -0,0 +1,200 @@ +package loki + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "time" + + "github.com/gorilla/websocket" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" +) + +func (s *Service) SubscribeStream(_ context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) { + dsInfo, err := s.getDSInfo(req.PluginContext) + if err != nil { + return &backend.SubscribeStreamResponse{ + Status: backend.SubscribeStreamStatusNotFound, + }, err + } + + // Expect tail/${key} + if !strings.HasPrefix(req.Path, "tail/") { + return &backend.SubscribeStreamResponse{ + Status: backend.SubscribeStreamStatusNotFound, + }, fmt.Errorf("expected tail in channel path") + } + + query, err := parseQueryModel(req.Data) + if err != nil { + return nil, err + } + if query.Expr == "" { + return &backend.SubscribeStreamResponse{ + Status: backend.SubscribeStreamStatusNotFound, + }, fmt.Errorf("missing expr in channel (subscribe)") + } + + dsInfo.streamsMu.RLock() + defer dsInfo.streamsMu.RUnlock() + + cache, ok := dsInfo.streams[req.Path] + if ok { + msg, err := backend.NewInitialData(cache.Bytes(data.IncludeAll)) + return &backend.SubscribeStreamResponse{ + Status: backend.SubscribeStreamStatusOK, + InitialData: msg, + }, err + } + + // nothing yet + return &backend.SubscribeStreamResponse{ + Status: backend.SubscribeStreamStatusOK, + }, err +} + +// Single instance for each channel (results are shared with all listeners) +func (s *Service) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error { + dsInfo, err := s.getDSInfo(req.PluginContext) + if err != nil { + return err + } + + query, err := parseQueryModel(req.Data) + if err != nil { + return err + } + if query.Expr == "" { + return fmt.Errorf("missing expr in cuannel") + } + + count := int64(0) + + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + + params := url.Values{} + params.Add("query", query.Expr) + + isV1 := false + wsurl, _ := url.Parse(dsInfo.URL) + + // Check if the v2alpha endpoint exists + wsurl.Path = "/loki/api/v2alpha/tail" + if !is400(dsInfo.HTTPClient, wsurl) { + isV1 = true + wsurl.Path = "/loki/api/v1/tail" + } + + if wsurl.Scheme == "https" { + wsurl.Scheme = "wss" + } else { + wsurl.Scheme = "ws" + } + wsurl.RawQuery = params.Encode() + + s.plog.Info("connecting to websocket", "url", wsurl) + c, r, err := websocket.DefaultDialer.Dial(wsurl.String(), nil) + if err != nil { + s.plog.Error("error connecting to websocket", "err", err) + return fmt.Errorf("error connecting to websocket") + } + + defer func() { + dsInfo.streamsMu.Lock() + delete(dsInfo.streams, req.Path) + dsInfo.streamsMu.Unlock() + if r != nil { + _ = r.Body.Close() + } + err = c.Close() + s.plog.Error("closing loki websocket", "err", err) + }() + + prev := data.FrameJSONCache{} + + // Read all messages + done := make(chan struct{}) + go func() { + defer close(done) + for { + _, message, err := c.ReadMessage() + if err != nil { + s.plog.Error("websocket read:", "err", err) + return + } + + frame := &data.Frame{} + if isV1 { + frame, err = lokiBytesToLabeledFrame(message) + } else { + err = json.Unmarshal(message, &frame) + } + + if err == nil && frame != nil { + next, _ := data.FrameToJSONCache(frame) + if next.SameSchema(&prev) { + err = sender.SendBytes(next.Bytes(data.IncludeDataOnly)) + } else { + err = sender.SendFrame(frame, data.IncludeAll) + } + prev = next + + // Cache the initial data + dsInfo.streamsMu.Lock() + dsInfo.streams[req.Path] = prev + dsInfo.streamsMu.Unlock() + } + + if err != nil { + s.plog.Error("websocket write:", "err", err, "raw", message) + return + } + } + }() + + ticker := time.NewTicker(time.Second * 60) //.Step) + defer ticker.Stop() + + for { + select { + case <-done: + s.plog.Info("socket done") + return nil + case <-ctx.Done(): + s.plog.Info("stop streaming (context canceled)") + return nil + case t := <-ticker.C: + count++ + s.plog.Error("loki websocket ping?", "time", t, "count", count) + } + } +} + +func (s *Service) PublishStream(_ context.Context, _ *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) { + return &backend.PublishStreamResponse{ + Status: backend.PublishStreamStatusPermissionDenied, + }, nil +} + +// if the v2 endpoint exists it will give a 400 rather than 404/500 +func is400(client *http.Client, url *url.URL) bool { + req, err := http.NewRequest("GET", url.String(), nil) + if err != nil { + return false + } + rsp, err := client.Do(req) + if err != nil { + return false + } + defer func() { + _ = rsp.Body.Close() + }() + return rsp.StatusCode == 400 // will be true +} diff --git a/pkg/tsdb/loki/streaming_frame.go b/pkg/tsdb/loki/streaming_frame.go new file mode 100644 index 00000000000..e03cd298b7d --- /dev/null +++ b/pkg/tsdb/loki/streaming_frame.go @@ -0,0 +1,52 @@ +package loki + +import ( + "encoding/json" + "strconv" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" +) + +type lokiResponse struct { + Streams []lokiStream `json:"streams"` +} + +type lokiStream struct { + Stream data.Labels `json:"stream"` + Values [][2]string `json:"values"` +} + +func lokiBytesToLabeledFrame(msg []byte) (*data.Frame, error) { + rsp := &lokiResponse{} + err := json.Unmarshal(msg, rsp) + if err != nil { + return nil, err + } + + labelField := data.NewFieldFromFieldType(data.FieldTypeString, 0) + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0) + lineField := data.NewFieldFromFieldType(data.FieldTypeString, 0) + + labelField.Name = "__labels" // for now, avoid automatically spreading this by labels + timeField.Name = "Time" + lineField.Name = "Line" + + for _, stream := range rsp.Streams { + label := stream.Stream.String() // TODO -- make it match prom labels! + for _, value := range stream.Values { + n, err := strconv.ParseInt(value[0], 10, 64) + if err != nil { + continue + } + ts := time.Unix(0, n) + line := value[1] + + labelField.Append(label) + timeField.Append(ts) + lineField.Append(line) + } + } + + return data.NewFrame("", labelField, timeField, lineField), nil +} diff --git a/pkg/tsdb/loki/streaming_frame_test.go b/pkg/tsdb/loki/streaming_frame_test.go new file mode 100644 index 00000000000..659bc8320db --- /dev/null +++ b/pkg/tsdb/loki/streaming_frame_test.go @@ -0,0 +1,40 @@ +package loki + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLokiFramer(t *testing.T) { + t.Run("converting metric name", func(t *testing.T) { + msg := []byte(`{"streams":[ + {"stream": + {"job":"node-exporter","metric":"go_memstats_heap_inuse_bytes"}, + "values":[ + ["1642091525267322910","line1"] + ]}, + {"stream": + {"job":"node-exporter","metric":"go_memstats_heap_inuse_bytes"}, + "values":[ + ["1642091525770585774","line2"], + ["1642091525770585775","line3"] + ]}, + {"stream": + {"metric":"go_memstats_heap_inuse_bytes","job":"node-exporter"}, + "values":[ + ["1642091526263785281","line4"] + ]} + ]}`) + + frame, err := lokiBytesToLabeledFrame(msg) + require.NoError(t, err) + + lines := frame.Fields[2] + require.Equal(t, 4, lines.Len()) + require.Equal(t, "line1", lines.At(0)) + require.Equal(t, "line2", lines.At(1)) + require.Equal(t, "line3", lines.At(2)) + require.Equal(t, "line4", lines.At(3)) + }) +} diff --git a/pkg/tsdb/loki/testdata/streams_simple.golden.txt b/pkg/tsdb/loki/testdata/streams_simple.golden.txt new file mode 100644 index 00000000000..c45d8de8db0 --- /dev/null +++ b/pkg/tsdb/loki/testdata/streams_simple.golden.txt @@ -0,0 +1,37 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] { + "executedQueryString": "Expr: query1" +} +Name: {level="error", location="moon"} +Dimensions: 2 Fields by 1 Rows ++----------------------------------------+------------------------------------+ +| Name: time | Name: value | +| Labels: | Labels: level=error, location=moon | +| Type: []time.Time | Type: []string | ++----------------------------------------+------------------------------------+ +| 2022-02-16 16:50:44.81075712 +0000 UTC | log line error 1 | ++----------------------------------------+------------------------------------+ + + + +Frame[1] { + "executedQueryString": "Expr: query1" +} +Name: {level="info", location="moon"} +Dimensions: 2 Fields by 4 Rows ++-----------------------------------------+-----------------------------------+ +| Name: time | Name: value | +| Labels: | Labels: level=info, location=moon | +| Type: []time.Time | Type: []string | ++-----------------------------------------+-----------------------------------+ +| 2022-02-16 16:50:47.02773504 +0000 UTC | log line info 1 | +| 2022-02-16 16:50:46.277587968 +0000 UTC | log line info 2 | +| 2022-02-16 16:50:45.539423744 +0000 UTC | log line info 3 | +| 2022-02-16 16:50:44.091700992 +0000 UTC | log line info 4 | ++-----------------------------------------+-----------------------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////cAIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAALgAAAADAAAAbAAAACgAAAAEAAAAIP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAABA/v//CAAAACwAAAAgAAAAe2xldmVsPSJlcnJvciIsIGxvY2F0aW9uPSJtb29uIn0AAAAABAAAAG5hbWUAAAAAgP7//wgAAAAwAAAAJgAAAHsiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHF1ZXJ5MSJ9AAAEAAAAbWV0YQAAAAACAAAAGAEAAAQAAAAC////FAAAAOAAAADkAAAAAAAABeAAAAADAAAAcAAAACwAAAAEAAAA+P7//wgAAAAQAAAABQAAAHZhbHVlAAAABAAAAG5hbWUAAAAAHP///wgAAAAsAAAAIwAAAHsibGV2ZWwiOiJlcnJvciIsImxvY2F0aW9uIjoibW9vbiJ9AAYAAABsYWJlbHMAAFz///8IAAAASAAAADwAAAB7ImRpc3BsYXlOYW1lRnJvbURTIjoie2xldmVsPVwiZXJyb3JcIiwgbG9jYXRpb249XCJtb29uXCJ9In0AAAAABgAAAGNvbmZpZwAAAAAAAAQABAAEAAAABQAAAHZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAAB0aW1lAAAAAP/////IAAAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAIAAAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAaAAAAAEAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAEAAAAAAAAAAQAAAAAAAAAAAAAAACAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAFLi6SlLUFgAAAAAQAAAAbG9nIGxpbmUgZXJyb3IgMRAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA8AAAAAAAEAAEAAACAAgAAAAAAANAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoADAAAAAgABAAKAAAACAAAALgAAAADAAAAbAAAACgAAAAEAAAAIP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAABA/v//CAAAACwAAAAgAAAAe2xldmVsPSJlcnJvciIsIGxvY2F0aW9uPSJtb29uIn0AAAAABAAAAG5hbWUAAAAAgP7//wgAAAAwAAAAJgAAAHsiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHF1ZXJ5MSJ9AAAEAAAAbWV0YQAAAAACAAAAGAEAAAQAAAAC////FAAAAOAAAADkAAAAAAAABeAAAAADAAAAcAAAACwAAAAEAAAA+P7//wgAAAAQAAAABQAAAHZhbHVlAAAABAAAAG5hbWUAAAAAHP///wgAAAAsAAAAIwAAAHsibGV2ZWwiOiJlcnJvciIsImxvY2F0aW9uIjoibW9vbiJ9AAYAAABsYWJlbHMAAFz///8IAAAASAAAADwAAAB7ImRpc3BsYXlOYW1lRnJvbURTIjoie2xldmVsPVwiZXJyb3JcIiwgbG9jYXRpb249XCJtb29uXCJ9In0AAAAABgAAAGNvbmZpZwAAAAAAAAQABAAEAAAABQAAAHZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAAB0aW1lAAAAAKACAABBUlJPVzE= +FRAME=QVJST1cxAAD/////aAIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAALQAAAADAAAAaAAAACgAAAAEAAAAKP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAABI/v//CAAAACgAAAAfAAAAe2xldmVsPSJpbmZvIiwgbG9jYXRpb249Im1vb24ifQAEAAAAbmFtZQAAAACE/v//CAAAADAAAAAmAAAAeyJleGVjdXRlZFF1ZXJ5U3RyaW5nIjoiRXhwcjogcXVlcnkxIn0AAAQAAABtZXRhAAAAAAIAAAAUAQAABAAAAAb///8UAAAA3AAAAOAAAAAAAAAF3AAAAAMAAABwAAAALAAAAAQAAAD8/v//CAAAABAAAAAFAAAAdmFsdWUAAAAEAAAAbmFtZQAAAAAg////CAAAACwAAAAiAAAAeyJsZXZlbCI6ImluZm8iLCJsb2NhdGlvbiI6Im1vb24ifQAABgAAAGxhYmVscwAAYP///wgAAABEAAAAOwAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJ7bGV2ZWw9XCJpbmZvXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAYAAABjb25maWcAAAAAAAAEAAQABAAAAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAAD/////yAAAABQAAAAAAAAADAAWABQAEwAMAAQADAAAAHgAAAAAAAAAFAAAAAAAAAMEAAoAGAAMAAgABAAKAAAAFAAAAGgAAAAEAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAUAAAAAAAAADgAAAAAAAAAPAAAAAAAAAAAAAAAAgAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAHrcPktS1BYAJCYSS1LUFgCmJuZKUtQWACfcj0pS1BYAAAAADwAAAB4AAAAtAAAAPAAAAAAAAABsb2cgbGluZSBpbmZvIDFsb2cgbGluZSBpbmZvIDJsb2cgbGluZSBpbmZvIDNsb2cgbGluZSBpbmZvIDQAAAAAEAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADwAAAAAAAQAAQAAAHgCAAAAAAAA0AAAAAAAAAB4AAAAAAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAtAAAAAMAAABoAAAAKAAAAAQAAAAo/v//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAEj+//8IAAAAKAAAAB8AAAB7bGV2ZWw9ImluZm8iLCBsb2NhdGlvbj0ibW9vbiJ9AAQAAABuYW1lAAAAAIT+//8IAAAAMAAAACYAAAB7ImV4ZWN1dGVkUXVlcnlTdHJpbmciOiJFeHByOiBxdWVyeTEifQAABAAAAG1ldGEAAAAAAgAAABQBAAAEAAAABv///xQAAADcAAAA4AAAAAAAAAXcAAAAAwAAAHAAAAAsAAAABAAAAPz+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAACD///8IAAAALAAAACIAAAB7ImxldmVsIjoiaW5mbyIsImxvY2F0aW9uIjoibW9vbiJ9AAAGAAAAbGFiZWxzAABg////CAAAAEQAAAA7AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImluZm9cIiwgbG9jYXRpb249XCJtb29uXCJ9In0ABgAAAGNvbmZpZwAAAAAAAAQABAAEAAAABQAAAHZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAAB0aW1lAAAAAJgCAABBUlJPVzE= diff --git a/pkg/tsdb/loki/testdata/streams_simple.json b/pkg/tsdb/loki/testdata/streams_simple.json new file mode 100644 index 00000000000..87fb82a3952 --- /dev/null +++ b/pkg/tsdb/loki/testdata/streams_simple.json @@ -0,0 +1,76 @@ +{ + "status": "success", + "data": { + "resultType": "streams", + "result": [ + { + "stream": { + "level": "error", + "location": "moon" + }, + "values": [ + [ + "1645030244810757120", + "log line error 1" + ] + ] + }, + { + "stream": { + "level": "info", + "location": "moon" + }, + "values": [ + [ + "1645030247027735040", + "log line info 1" + ], + [ + "1645030246277587968", + "log line info 2" + ], + [ + "1645030245539423744", + "log line info 3" + ], + [ + "1645030244091700992", + "log line info 4" + ] + ] + } + ], + "stats": { + "summary": { + "bytesProcessedPerSecond": 3507022, + "linesProcessedPerSecond": 24818, + "totalBytesProcessed": 7772, + "totalLinesProcessed": 55, + "execTime": 0.002216125 + }, + "store": { + "totalChunksRef": 2, + "totalChunksDownloaded": 2, + "chunksDownloadTime": 0.000390958, + "headChunkBytes": 0, + "headChunkLines": 0, + "decompressedBytes": 7772, + "decompressedLines": 55, + "compressedBytes": 31432, + "totalDuplicates": 0 + }, + "ingester": { + "totalReached": 0, + "totalChunksMatched": 0, + "totalBatches": 0, + "totalLinesSent": 0, + "headChunkBytes": 0, + "headChunkLines": 0, + "decompressedBytes": 0, + "decompressedLines": 0, + "compressedBytes": 0, + "totalDuplicates": 0 + } + } + } +} diff --git a/pkg/tsdb/loki/testdata/vector_simple.golden.txt b/pkg/tsdb/loki/testdata/vector_simple.golden.txt new file mode 100644 index 00000000000..dd55c72621c --- /dev/null +++ b/pkg/tsdb/loki/testdata/vector_simple.golden.txt @@ -0,0 +1,34 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] { + "executedQueryString": "Expr: query1" +} +Name: {level="error", location="moon"} +Dimensions: 2 Fields by 1 Rows ++-------------------------------+------------------------------------+ +| Name: time | Name: value | +| Labels: | Labels: level=error, location=moon | +| Type: []time.Time | Type: []float64 | ++-------------------------------+------------------------------------+ +| 2022-02-16 16:41:39 +0000 UTC | 23 | ++-------------------------------+------------------------------------+ + + + +Frame[1] { + "executedQueryString": "Expr: query1" +} +Name: {level="info", location="moon"} +Dimensions: 2 Fields by 1 Rows ++-------------------------------+-----------------------------------+ +| Name: time | Name: value | +| Labels: | Labels: level=info, location=moon | +| Type: []time.Time | Type: []float64 | ++-------------------------------+-----------------------------------+ +| 2022-02-16 16:41:39 +0000 UTC | 47 | ++-------------------------------+-----------------------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////cAIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAALgAAAADAAAAbAAAACgAAAAEAAAAIP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAABA/v//CAAAACwAAAAgAAAAe2xldmVsPSJlcnJvciIsIGxvY2F0aW9uPSJtb29uIn0AAAAABAAAAG5hbWUAAAAAgP7//wgAAAAwAAAAJgAAAHsiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHF1ZXJ5MSJ9AAAEAAAAbWV0YQAAAAACAAAAGAEAAAQAAAAC////FAAAAOAAAADgAAAAAAAAA+AAAAADAAAAcAAAACwAAAAEAAAA+P7//wgAAAAQAAAABQAAAHZhbHVlAAAABAAAAG5hbWUAAAAAHP///wgAAAAsAAAAIwAAAHsibGV2ZWwiOiJlcnJvciIsImxvY2F0aW9uIjoibW9vbiJ9AAYAAABsYWJlbHMAAFz///8IAAAASAAAADwAAAB7ImRpc3BsYXlOYW1lRnJvbURTIjoie2xldmVsPVwiZXJyb3JcIiwgbG9jYXRpb249XCJtb29uXCJ9In0AAAAABgAAAGNvbmZpZwAAAAAAAIr///8AAAIABQAAAHZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAAB0aW1lAAAAAP////+4AAAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAEAAAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAWAAAAAEAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAIAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAADe3KXLUdQWAAAAAAAAN0AQAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAPAAAAAAABAABAAAAgAIAAAAAAADAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAAC4AAAAAwAAAGwAAAAoAAAABAAAACD+//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAAQP7//wgAAAAsAAAAIAAAAHtsZXZlbD0iZXJyb3IiLCBsb2NhdGlvbj0ibW9vbiJ9AAAAAAQAAABuYW1lAAAAAID+//8IAAAAMAAAACYAAAB7ImV4ZWN1dGVkUXVlcnlTdHJpbmciOiJFeHByOiBxdWVyeTEifQAABAAAAG1ldGEAAAAAAgAAABgBAAAEAAAAAv///xQAAADgAAAA4AAAAAAAAAPgAAAAAwAAAHAAAAAsAAAABAAAAPj+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAABz///8IAAAALAAAACMAAAB7ImxldmVsIjoiZXJyb3IiLCJsb2NhdGlvbiI6Im1vb24ifQAGAAAAbGFiZWxzAABc////CAAAAEgAAAA8AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImVycm9yXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAAAAAYAAABjb25maWcAAAAAAACK////AAACAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAACgAgAAQVJST1cx +FRAME=QVJST1cxAAD/////aAIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAALQAAAADAAAAaAAAACgAAAAEAAAAKP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAABI/v//CAAAACgAAAAfAAAAe2xldmVsPSJpbmZvIiwgbG9jYXRpb249Im1vb24ifQAEAAAAbmFtZQAAAACE/v//CAAAADAAAAAmAAAAeyJleGVjdXRlZFF1ZXJ5U3RyaW5nIjoiRXhwcjogcXVlcnkxIn0AAAQAAABtZXRhAAAAAAIAAAAUAQAABAAAAAb///8UAAAA3AAAANwAAAAAAAAD3AAAAAMAAABwAAAALAAAAAQAAAD8/v//CAAAABAAAAAFAAAAdmFsdWUAAAAEAAAAbmFtZQAAAAAg////CAAAACwAAAAiAAAAeyJsZXZlbCI6ImluZm8iLCJsb2NhdGlvbiI6Im1vb24ifQAABgAAAGxhYmVscwAAYP///wgAAABEAAAAOwAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJ7bGV2ZWw9XCJpbmZvXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAYAAABjb25maWcAAAAAAACK////AAACAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAAD/////uAAAABQAAAAAAAAADAAWABQAEwAMAAQADAAAABAAAAAAAAAAFAAAAAAAAAMEAAoAGAAMAAgABAAKAAAAFAAAAFgAAAABAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAIAAAAAAAAAAAAAAACAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAA3tyly1HUFgAAAAAAgEdAEAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADwAAAAAAAQAAQAAAHgCAAAAAAAAwAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAtAAAAAMAAABoAAAAKAAAAAQAAAAo/v//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAEj+//8IAAAAKAAAAB8AAAB7bGV2ZWw9ImluZm8iLCBsb2NhdGlvbj0ibW9vbiJ9AAQAAABuYW1lAAAAAIT+//8IAAAAMAAAACYAAAB7ImV4ZWN1dGVkUXVlcnlTdHJpbmciOiJFeHByOiBxdWVyeTEifQAABAAAAG1ldGEAAAAAAgAAABQBAAAEAAAABv///xQAAADcAAAA3AAAAAAAAAPcAAAAAwAAAHAAAAAsAAAABAAAAPz+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAACD///8IAAAALAAAACIAAAB7ImxldmVsIjoiaW5mbyIsImxvY2F0aW9uIjoibW9vbiJ9AAAGAAAAbGFiZWxzAABg////CAAAAEQAAAA7AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImluZm9cIiwgbG9jYXRpb249XCJtb29uXCJ9In0ABgAAAGNvbmZpZwAAAAAAAIr///8AAAIABQAAAHZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAAB0aW1lAAAAAJgCAABBUlJPVzE= diff --git a/pkg/tsdb/loki/testdata/vector_simple.json b/pkg/tsdb/loki/testdata/vector_simple.json new file mode 100644 index 00000000000..7c7154c1b71 --- /dev/null +++ b/pkg/tsdb/loki/testdata/vector_simple.json @@ -0,0 +1,16 @@ +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { "level": "error", "location": "moon"}, + "value": [1645029699, "23"] + }, + { + "metric": { "level": "info", "location": "moon" }, + "value": [1645029699, "47"] + } + ] + } +} diff --git a/pkg/tsdb/loki/testdata/vector_special_values.golden.txt b/pkg/tsdb/loki/testdata/vector_special_values.golden.txt new file mode 100644 index 00000000000..30a928b38fb --- /dev/null +++ b/pkg/tsdb/loki/testdata/vector_special_values.golden.txt @@ -0,0 +1,50 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] { + "executedQueryString": "Expr: query1" +} +Name: {level="error", location="moon"} +Dimensions: 2 Fields by 1 Rows ++-------------------------------+------------------------------------+ +| Name: time | Name: value | +| Labels: | Labels: level=error, location=moon | +| Type: []time.Time | Type: []float64 | ++-------------------------------+------------------------------------+ +| 2022-02-16 16:41:39 +0000 UTC | +Inf | ++-------------------------------+------------------------------------+ + + + +Frame[1] { + "executedQueryString": "Expr: query1" +} +Name: {level="info", location="moon"} +Dimensions: 2 Fields by 1 Rows ++-------------------------------+-----------------------------------+ +| Name: time | Name: value | +| Labels: | Labels: level=info, location=moon | +| Type: []time.Time | Type: []float64 | ++-------------------------------+-----------------------------------+ +| 2022-02-16 16:41:39 +0000 UTC | -Inf | ++-------------------------------+-----------------------------------+ + + + +Frame[2] { + "executedQueryString": "Expr: query1" +} +Name: {level="debug", location="moon"} +Dimensions: 2 Fields by 1 Rows ++-------------------------------+------------------------------------+ +| Name: time | Name: value | +| Labels: | Labels: level=debug, location=moon | +| Type: []time.Time | Type: []float64 | ++-------------------------------+------------------------------------+ +| 2022-02-16 16:41:39 +0000 UTC | NaN | ++-------------------------------+------------------------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////cAIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAALgAAAADAAAAbAAAACgAAAAEAAAAIP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAABA/v//CAAAACwAAAAgAAAAe2xldmVsPSJlcnJvciIsIGxvY2F0aW9uPSJtb29uIn0AAAAABAAAAG5hbWUAAAAAgP7//wgAAAAwAAAAJgAAAHsiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHF1ZXJ5MSJ9AAAEAAAAbWV0YQAAAAACAAAAGAEAAAQAAAAC////FAAAAOAAAADgAAAAAAAAA+AAAAADAAAAcAAAACwAAAAEAAAA+P7//wgAAAAQAAAABQAAAHZhbHVlAAAABAAAAG5hbWUAAAAAHP///wgAAAAsAAAAIwAAAHsibGV2ZWwiOiJlcnJvciIsImxvY2F0aW9uIjoibW9vbiJ9AAYAAABsYWJlbHMAAFz///8IAAAASAAAADwAAAB7ImRpc3BsYXlOYW1lRnJvbURTIjoie2xldmVsPVwiZXJyb3JcIiwgbG9jYXRpb249XCJtb29uXCJ9In0AAAAABgAAAGNvbmZpZwAAAAAAAIr///8AAAIABQAAAHZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAAB0aW1lAAAAAP////+4AAAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAEAAAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAWAAAAAEAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAIAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAADe3KXLUdQWAAAAAAAA8H8QAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAPAAAAAAABAABAAAAgAIAAAAAAADAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAAC4AAAAAwAAAGwAAAAoAAAABAAAACD+//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAAQP7//wgAAAAsAAAAIAAAAHtsZXZlbD0iZXJyb3IiLCBsb2NhdGlvbj0ibW9vbiJ9AAAAAAQAAABuYW1lAAAAAID+//8IAAAAMAAAACYAAAB7ImV4ZWN1dGVkUXVlcnlTdHJpbmciOiJFeHByOiBxdWVyeTEifQAABAAAAG1ldGEAAAAAAgAAABgBAAAEAAAAAv///xQAAADgAAAA4AAAAAAAAAPgAAAAAwAAAHAAAAAsAAAABAAAAPj+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAABz///8IAAAALAAAACMAAAB7ImxldmVsIjoiZXJyb3IiLCJsb2NhdGlvbiI6Im1vb24ifQAGAAAAbGFiZWxzAABc////CAAAAEgAAAA8AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImVycm9yXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAAAAAYAAABjb25maWcAAAAAAACK////AAACAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAACgAgAAQVJST1cx +FRAME=QVJST1cxAAD/////aAIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAALQAAAADAAAAaAAAACgAAAAEAAAAKP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAABI/v//CAAAACgAAAAfAAAAe2xldmVsPSJpbmZvIiwgbG9jYXRpb249Im1vb24ifQAEAAAAbmFtZQAAAACE/v//CAAAADAAAAAmAAAAeyJleGVjdXRlZFF1ZXJ5U3RyaW5nIjoiRXhwcjogcXVlcnkxIn0AAAQAAABtZXRhAAAAAAIAAAAUAQAABAAAAAb///8UAAAA3AAAANwAAAAAAAAD3AAAAAMAAABwAAAALAAAAAQAAAD8/v//CAAAABAAAAAFAAAAdmFsdWUAAAAEAAAAbmFtZQAAAAAg////CAAAACwAAAAiAAAAeyJsZXZlbCI6ImluZm8iLCJsb2NhdGlvbiI6Im1vb24ifQAABgAAAGxhYmVscwAAYP///wgAAABEAAAAOwAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJ7bGV2ZWw9XCJpbmZvXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAYAAABjb25maWcAAAAAAACK////AAACAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAAD/////uAAAABQAAAAAAAAADAAWABQAEwAMAAQADAAAABAAAAAAAAAAFAAAAAAAAAMEAAoAGAAMAAgABAAKAAAAFAAAAFgAAAABAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAIAAAAAAAAAAAAAAACAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAA3tyly1HUFgAAAAAAAPD/EAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADwAAAAAAAQAAQAAAHgCAAAAAAAAwAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAtAAAAAMAAABoAAAAKAAAAAQAAAAo/v//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAEj+//8IAAAAKAAAAB8AAAB7bGV2ZWw9ImluZm8iLCBsb2NhdGlvbj0ibW9vbiJ9AAQAAABuYW1lAAAAAIT+//8IAAAAMAAAACYAAAB7ImV4ZWN1dGVkUXVlcnlTdHJpbmciOiJFeHByOiBxdWVyeTEifQAABAAAAG1ldGEAAAAAAgAAABQBAAAEAAAABv///xQAAADcAAAA3AAAAAAAAAPcAAAAAwAAAHAAAAAsAAAABAAAAPz+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAACD///8IAAAALAAAACIAAAB7ImxldmVsIjoiaW5mbyIsImxvY2F0aW9uIjoibW9vbiJ9AAAGAAAAbGFiZWxzAABg////CAAAAEQAAAA7AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImluZm9cIiwgbG9jYXRpb249XCJtb29uXCJ9In0ABgAAAGNvbmZpZwAAAAAAAIr///8AAAIABQAAAHZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAAB0aW1lAAAAAJgCAABBUlJPVzE= +FRAME=QVJST1cxAAD/////cAIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAALgAAAADAAAAbAAAACgAAAAEAAAAIP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAABA/v//CAAAACwAAAAgAAAAe2xldmVsPSJkZWJ1ZyIsIGxvY2F0aW9uPSJtb29uIn0AAAAABAAAAG5hbWUAAAAAgP7//wgAAAAwAAAAJgAAAHsiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHF1ZXJ5MSJ9AAAEAAAAbWV0YQAAAAACAAAAGAEAAAQAAAAC////FAAAAOAAAADgAAAAAAAAA+AAAAADAAAAcAAAACwAAAAEAAAA+P7//wgAAAAQAAAABQAAAHZhbHVlAAAABAAAAG5hbWUAAAAAHP///wgAAAAsAAAAIwAAAHsibGV2ZWwiOiJkZWJ1ZyIsImxvY2F0aW9uIjoibW9vbiJ9AAYAAABsYWJlbHMAAFz///8IAAAASAAAADwAAAB7ImRpc3BsYXlOYW1lRnJvbURTIjoie2xldmVsPVwiZGVidWdcIiwgbG9jYXRpb249XCJtb29uXCJ9In0AAAAABgAAAGNvbmZpZwAAAAAAAIr///8AAAIABQAAAHZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAAB0aW1lAAAAAP////+4AAAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAEAAAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAWAAAAAEAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAIAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAADe3KXLUdQWAQAAAAAA+H8QAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAPAAAAAAABAABAAAAgAIAAAAAAADAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAAC4AAAAAwAAAGwAAAAoAAAABAAAACD+//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAAQP7//wgAAAAsAAAAIAAAAHtsZXZlbD0iZGVidWciLCBsb2NhdGlvbj0ibW9vbiJ9AAAAAAQAAABuYW1lAAAAAID+//8IAAAAMAAAACYAAAB7ImV4ZWN1dGVkUXVlcnlTdHJpbmciOiJFeHByOiBxdWVyeTEifQAABAAAAG1ldGEAAAAAAgAAABgBAAAEAAAAAv///xQAAADgAAAA4AAAAAAAAAPgAAAAAwAAAHAAAAAsAAAABAAAAPj+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAABz///8IAAAALAAAACMAAAB7ImxldmVsIjoiZGVidWciLCJsb2NhdGlvbiI6Im1vb24ifQAGAAAAbGFiZWxzAABc////CAAAAEgAAAA8AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImRlYnVnXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAAAAAYAAABjb25maWcAAAAAAACK////AAACAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAACgAgAAQVJST1cx diff --git a/pkg/tsdb/loki/testdata/vector_special_values.json b/pkg/tsdb/loki/testdata/vector_special_values.json new file mode 100644 index 00000000000..18979ea3204 --- /dev/null +++ b/pkg/tsdb/loki/testdata/vector_special_values.json @@ -0,0 +1,20 @@ +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { "level": "error", "location": "moon"}, + "value": [1645029699, "+Inf"] + }, + { + "metric": { "level": "info", "location": "moon" }, + "value": [1645029699, "-Inf"] + }, + { + "metric": { "level": "debug", "location": "moon" }, + "value": [1645029699, "NaN"] + } + ] + } +} diff --git a/pkg/tsdb/loki/types.go b/pkg/tsdb/loki/types.go index f434fc70db1..309d2ef8246 100644 --- a/pkg/tsdb/loki/types.go +++ b/pkg/tsdb/loki/types.go @@ -2,11 +2,21 @@ package loki import "time" +type QueryType string + +const ( + QueryTypeRange QueryType = "range" + QueryTypeInstant QueryType = "instant" +) + type lokiQuery struct { Expr string + QueryType QueryType Step time.Duration + MaxLines int LegendFormat string Start time.Time End time.Time RefID string + VolumeQuery bool } diff --git a/pkg/tsdb/tempo/trace_transform.go b/pkg/tsdb/tempo/trace_transform.go index 6d6e30fb8b1..2b191dc59c1 100644 --- a/pkg/tsdb/tempo/trace_transform.go +++ b/pkg/tsdb/tempo/trace_transform.go @@ -22,6 +22,12 @@ type TraceLog struct { Fields []*KeyValue `json:"fields"` } +type TraceReference struct { + SpanID string `json:"spanID"` + TraceID string `json:"traceID"` + Tags []*KeyValue `json:"tags"` +} + func TraceToFrame(td pdata.Traces) (*data.Frame, error) { // In open telemetry format the spans are grouped first by resource/service they originated in and inside that // resource they are grouped by the instrumentation library which created them. @@ -44,6 +50,7 @@ func TraceToFrame(td pdata.Traces) (*data.Frame, error) { data.NewField("startTime", nil, []float64{}), data.NewField("duration", nil, []float64{}), data.NewField("logs", nil, []string{}), + data.NewField("references", nil, []string{}), data.NewField("tags", nil, []string{}), }, Meta: &data.FrameMeta{ @@ -127,6 +134,13 @@ func spanToSpanRow(span pdata.Span, libraryTags pdata.InstrumentationLibrary, re return nil, fmt.Errorf("failed to marshal span logs: %w", err) } + references, err := json.Marshal(spanLinksToReferences(span.Links())) + + if err != nil { + return nil, fmt.Errorf("failed to marshal span links: %w", err) + } + + // Order matters (look at dataframe order) return []interface{}{ traceID, spanID, @@ -137,6 +151,7 @@ func spanToSpanRow(span pdata.Span, libraryTags pdata.InstrumentationLibrary, re startTime, float64(span.EndTimestamp()-span.StartTimestamp()) / 1_000_000, toJSONString(logs), + toJSONString(references), toJSONString(spanTags), }, nil } @@ -321,3 +336,33 @@ func spanEventsToLogs(events pdata.SpanEventSlice) []*TraceLog { return logs } + +func spanLinksToReferences(links pdata.SpanLinkSlice) []*TraceReference { + if links.Len() == 0 { + return nil + } + + references := make([]*TraceReference, 0, links.Len()) + for i := 0; i < links.Len(); i++ { + link := links.At(i) + + traceId := link.TraceID().HexString() + traceId = strings.TrimLeft(traceId, "0") + + spanId := link.SpanID().HexString() + + tags := make([]*KeyValue, 0, link.Attributes().Len()) + link.Attributes().Range(func(key string, attr pdata.AttributeValue) bool { + tags = append(tags, &KeyValue{Key: key, Value: getAttributeVal(attr)}) + return true + }) + + references = append(references, &TraceReference{ + TraceID: traceId, + SpanID: spanId, + Tags: tags, + }) + } + + return references +} diff --git a/pkg/tsdb/tempo/trace_transform_test.go b/pkg/tsdb/tempo/trace_transform_test.go index 742890ef10a..0ee461a0b5e 100644 --- a/pkg/tsdb/tempo/trace_transform_test.go +++ b/pkg/tsdb/tempo/trace_transform_test.go @@ -106,5 +106,6 @@ var fields = []string{ "startTime", "duration", "logs", + "references", "tags", } diff --git a/pkg/util/cmputil/reporter.go b/pkg/util/cmputil/reporter.go new file mode 100644 index 00000000000..c9532d3cc0f --- /dev/null +++ b/pkg/util/cmputil/reporter.go @@ -0,0 +1,101 @@ +package cmputil + +import ( + "fmt" + "reflect" + "strings" + + "github.com/google/go-cmp/cmp" +) + +type DiffReport []Diff + +// GetDiffsForField returns subset of the diffs which path starts with the provided path +func (r DiffReport) GetDiffsForField(path string) DiffReport { + var result []Diff + for _, diff := range r { + if strings.HasPrefix(path, diff.Path) { + result = append(result, diff) + } + } + return result +} + +// DiffReporter is a simple custom reporter that only records differences +// detected during comparison. Implements an interface required by cmp.Reporter option +type DiffReporter struct { + path cmp.Path + Diffs DiffReport +} + +func (r *DiffReporter) PushStep(ps cmp.PathStep) { + r.path = append(r.path, ps) +} + +func (r *DiffReporter) PopStep() { + r.path = r.path[:len(r.path)-1] +} + +func (r *DiffReporter) Report(rs cmp.Result) { + if !rs.Equal() { + vx, vy := r.path.Last().Values() + r.Diffs = append(r.Diffs, Diff{ + Path: printPath(r.path), + Left: vx, + Right: vy, + }) + } +} + +func printPath(p cmp.Path) string { + ss := strings.Builder{} + for _, s := range p { + toAdd := "" + switch v := s.(type) { + case cmp.StructField: + toAdd = v.String() + case cmp.MapIndex: + toAdd = fmt.Sprintf("[%s]", v.Key()) + case cmp.SliceIndex: + if v.Key() >= 0 { + toAdd = fmt.Sprintf("[%d]", v.Key()) + } + } + if toAdd == "" { + continue + } + ss.WriteString(toAdd) + } + return strings.TrimPrefix(ss.String(), ".") +} + +func (r DiffReport) String() string { + b := strings.Builder{} + for _, diff := range r { + b.WriteString(diff.String()) + b.WriteByte('\n') + } + return b.String() +} + +type Diff struct { + // Path to the field that has difference separated by period. Array index and key are designated by square brackets. + // For example, Annotations[12345].Data.Fields[0].ID + Path string + Left reflect.Value + Right reflect.Value +} + +func (d *Diff) String() string { + left := d.Left.String() + // invalid reflect.Value is produced when two collections (slices\maps) are compared and one misses value. + // This way go-cmp indicates that an element was added\removed from a list. + if !d.Left.IsValid() { + left = "" + } + right := d.Right.String() + if !d.Right.IsValid() { + right = "" + } + return fmt.Sprintf("%v:\n\t-: %+v\n\t+: %+v", d.Path, left, right) +} diff --git a/public/app/core/components/Footer/Footer.tsx b/public/app/core/components/Footer/Footer.tsx index 895d0d93650..b3c045e2a37 100644 --- a/public/app/core/components/Footer/Footer.tsx +++ b/public/app/core/components/Footer/Footer.tsx @@ -5,7 +5,7 @@ import { Icon, IconName } from '@grafana/ui'; export interface FooterLink { text: string; id?: string; - icon?: string; + icon?: IconName; url?: string; target?: string; } @@ -77,7 +77,7 @@ export const Footer: FC = React.memo(() => { {links.map((link) => (
  • - {link.icon && } {link.text} + {link.icon && } {link.text}
  • ))} diff --git a/public/app/core/components/PageActionBar/PageActionBar.tsx b/public/app/core/components/PageActionBar/PageActionBar.tsx index 1c771a4e0f0..a3471875063 100644 --- a/public/app/core/components/PageActionBar/PageActionBar.tsx +++ b/public/app/core/components/PageActionBar/PageActionBar.tsx @@ -12,10 +12,10 @@ export interface Props { export default class PageActionBar extends PureComponent { render() { const { searchQuery, linkButton, setSearchQuery, target, placeholder = 'Search by name or type' } = this.props; - const linkProps = { href: linkButton?.href, disabled: linkButton?.disabled }; + const linkProps: typeof LinkButton.defaultProps = { href: linkButton?.href, disabled: linkButton?.disabled }; if (target) { - (linkProps as any).target = target; + linkProps.target = target; } return ( diff --git a/public/app/core/components/PermissionList/AddPermission.tsx b/public/app/core/components/PermissionList/AddPermission.tsx index dcdd9bb6628..3555c6ef4db 100644 --- a/public/app/core/components/PermissionList/AddPermission.tsx +++ b/public/app/core/components/PermissionList/AddPermission.tsx @@ -41,8 +41,8 @@ class AddPermissions extends Component { }; } - onTypeChanged = (item: any) => { - const type = item.value as AclTarget; + onTypeChanged = (item: SelectableValue) => { + const type = item.value; switch (type) { case AclTarget.User: diff --git a/public/app/core/components/RolePicker/RolePickerInput.tsx b/public/app/core/components/RolePicker/RolePickerInput.tsx index f0b2d52c24c..3108e2c0b2e 100644 --- a/public/app/core/components/RolePicker/RolePickerInput.tsx +++ b/public/app/core/components/RolePicker/RolePickerInput.tsx @@ -1,4 +1,4 @@ -import React, { FormEvent, HTMLProps, MutableRefObject, useEffect, useRef } from 'react'; +import React, { FormEvent, HTMLProps, useEffect, useRef } from 'react'; import { css, cx } from '@emotion/css'; import { useStyles2, getInputStyles, sharedInputStyle, styleMixins, Tooltip, Icon } from '@grafana/ui'; import { GrafanaTheme2 } from '@grafana/data'; @@ -36,7 +36,7 @@ export const RolePickerInput = ({ useEffect(() => { if (isFocused) { - (inputRef as MutableRefObject).current?.focus(); + inputRef.current?.focus(); } }); diff --git a/public/app/core/components/Select/SortPicker.tsx b/public/app/core/components/Select/SortPicker.tsx index 9ecdc6b6c60..e2f2a682939 100644 --- a/public/app/core/components/Select/SortPicker.tsx +++ b/public/app/core/components/Select/SortPicker.tsx @@ -1,6 +1,6 @@ import React, { FC } from 'react'; import { useAsync } from 'react-use'; -import { Icon, IconName, Select } from '@grafana/ui'; +import { Icon, Select } from '@grafana/ui'; import { SelectableValue } from '@grafana/data'; import { DEFAULT_SORT } from 'app/features/search/constants'; import { SearchSrv } from '../../services/search_srv'; @@ -36,7 +36,7 @@ export const SortPicker: FC = ({ onChange, value, placeholder, filter }) options={options} aria-label="Sort" placeholder={placeholder ?? `Sort (Default ${DEFAULT_SORT.label})`} - prefix={} + prefix={} /> ) : null; }; diff --git a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx index 18d143e6476..4efc15bc8da 100644 --- a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx +++ b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx @@ -19,7 +19,7 @@ interface Props { } export class SplitPaneWrapper extends PureComponent { - rafToken = createRef(); + rafToken: MutableRefObject = createRef(); static defaultProps = { rightPaneVisible: true, }; @@ -36,7 +36,7 @@ export class SplitPaneWrapper extends PureComponent { if (this.rafToken.current !== undefined) { window.cancelAnimationFrame(this.rafToken.current!); } - (this.rafToken as MutableRefObject).current = window.requestAnimationFrame(() => { + this.rafToken.current = window.requestAnimationFrame(() => { this.forceUpdate(); }); }; @@ -68,8 +68,7 @@ export class SplitPaneWrapper extends PureComponent { renderHorizontalSplit() { const { leftPaneComponents, uiState } = this.props; const styles = getStyles(config.theme); - const topPaneSize = - uiState.topPaneSize >= 1 ? (uiState.topPaneSize as number) : (uiState.topPaneSize as number) * window.innerHeight; + const topPaneSize = uiState.topPaneSize >= 1 ? uiState.topPaneSize : uiState.topPaneSize * window.innerHeight; /* Guesstimate the height of the browser window minus @@ -104,9 +103,7 @@ export class SplitPaneWrapper extends PureComponent { // Need to handle when width is relative. ie a percentage of the viewport const rightPaneSize = - uiState.rightPaneSize <= 1 - ? (uiState.rightPaneSize as number) * window.innerWidth - : (uiState.rightPaneSize as number); + uiState.rightPaneSize <= 1 ? uiState.rightPaneSize * window.innerWidth : uiState.rightPaneSize; if (!rightPaneVisible) { return this.renderHorizontalSplit(); diff --git a/public/app/core/history/RichHistoryLocalStorage.test.ts b/public/app/core/history/RichHistoryLocalStorage.test.ts index a7b18c317da..1bbc7f7b30f 100644 --- a/public/app/core/history/RichHistoryLocalStorage.test.ts +++ b/public/app/core/history/RichHistoryLocalStorage.test.ts @@ -151,12 +151,10 @@ describe('RichHistoryLocalStorage', () => { // one not starred replaced with a newly added starred item const removedNotStarredItems = extraItems + 1; // + 1 to make space for the new item - const newHistory = store.getObject(key); + const newHistory = store.getObject(key)!; expect(newHistory).toHaveLength(MAX_HISTORY_ITEMS); // starred item added - expect(newHistory.filter((h: RichHistoryQuery) => h.starred)).toHaveLength(starredItemsInHistory + 1); // starred item added - expect(newHistory.filter((h: RichHistoryQuery) => !h.starred)).toHaveLength( - starredItemsInHistory - removedNotStarredItems - ); + expect(newHistory.filter((h) => h.starred)).toHaveLength(starredItemsInHistory + 1); // starred item added + expect(newHistory.filter((h) => !h.starred)).toHaveLength(starredItemsInHistory - removedNotStarredItems); }); }); diff --git a/public/app/core/store.ts b/public/app/core/store.ts index d66444a0d6b..bcbb338dc23 100644 --- a/public/app/core/store.ts +++ b/public/app/core/store.ts @@ -16,7 +16,9 @@ export class Store { return window.localStorage[key] === 'true'; } - getObject(key: string, def?: any) { + getObject(key: string): T | undefined; + getObject(key: string, def: T): T; + getObject(key: string, def?: T) { let ret = def; if (this.exists(key)) { const json = window.localStorage[key]; diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index 42e85d45fcc..7b1a4d2b999 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -113,31 +113,6 @@ describe('state functions', () => { '{"expr":"super{foo=\\"x/z\\"}","refId":"B"}],"range":{"from":"now-5h","to":"now"}}' ); }); - - // TODO: remove in 9.0 - it('returns url parameter value for a state object', () => { - const state = { - ...DEFAULT_EXPLORE_STATE, - datasource: 'foo', - queries: [ - { - expr: 'metric{test="a/b"}', - refId: 'A', - }, - { - expr: 'super{foo="x/z"}', - refId: 'B', - }, - ], - range: { - from: 'now-5h', - to: 'now', - }, - }; - expect(serializeStateToUrlParam(state, true)).toBe( - '{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}","refId":"A"},{"expr":"super{foo=\\"x/z\\"}","refId":"B"}],"range":{"from":"now-5h","to":"now"}}' - ); - }); }); describe('interplay', () => { @@ -165,32 +140,6 @@ describe('state functions', () => { expect(state).toMatchObject(parsed); }); - // TODO: remove in 9.0 - it('can parse the compact serialized state into the original state', () => { - const state = { - ...DEFAULT_EXPLORE_STATE, - datasource: 'foo', - queries: [ - { - expr: 'metric{test="a/b"}', - refId: 'A', - }, - { - expr: 'super{foo="x/z"}', - refId: 'B', - }, - ], - range: { - from: 'now - 5h', - to: 'now', - }, - panelsState: undefined, - }; - const serialized = serializeStateToUrlParam(state, true); - const parsed = parseUrlState(serialized); - expect(state).toMatchObject(parsed); - }); - it('can parse serialized panelsState into the original state', () => { const state = { ...DEFAULT_EXPLORE_STATE, @@ -215,7 +164,7 @@ describe('state functions', () => { }, }, }; - const serialized = serializeStateToUrlParam(state, true); + const serialized = serializeStateToUrlParam(state); const parsed = parseUrlState(serialized); expect(state).toMatchObject(parsed); }); diff --git a/public/app/features/alerting/components/NotificationChannelForm.tsx b/public/app/features/alerting/components/NotificationChannelForm.tsx index a9abc49a523..e05cf8e5bbd 100644 --- a/public/app/features/alerting/components/NotificationChannelForm.tsx +++ b/public/app/features/alerting/components/NotificationChannelForm.tsx @@ -41,7 +41,7 @@ export const NotificationChannelForm: FC = ({ useEffect(() => { /* - Finds fields that have dependencies on other fields and removes duplicates. + Find fields that have dependencies on other fields and removes duplicates. Needs to be prefixed with settings. */ const fieldsToWatch = diff --git a/public/app/features/alerting/state/reducers.test.ts b/public/app/features/alerting/state/reducers.test.ts index 13b9accc730..5fe86af7b9c 100644 --- a/public/app/features/alerting/state/reducers.test.ts +++ b/public/app/features/alerting/state/reducers.test.ts @@ -255,6 +255,7 @@ describe('Notification channel', () => { required: true, validationRule: '', secure: false, + dependsOn: '', }, { element: 'select', @@ -271,6 +272,7 @@ describe('Notification channel', () => { required: false, validationRule: '', secure: false, + dependsOn: '', }, { element: 'input', @@ -283,6 +285,7 @@ describe('Notification channel', () => { required: false, validationRule: '', secure: false, + dependsOn: '', }, { element: 'input', @@ -295,6 +298,7 @@ describe('Notification channel', () => { required: false, validationRule: '', secure: true, + dependsOn: '', }, ], }, diff --git a/public/app/features/alerting/unified/AlertGroups.test.tsx b/public/app/features/alerting/unified/AlertGroups.test.tsx index 582b4a94a2b..890aa4aef50 100644 --- a/public/app/features/alerting/unified/AlertGroups.test.tsx +++ b/public/app/features/alerting/unified/AlertGroups.test.tsx @@ -147,4 +147,19 @@ describe('AlertGroups', () => { expect(groups[0]).toHaveTextContent('No grouping'); expect(groups[1]).toHaveTextContent('uniqueLabel=true'); }); + + it('should combine multiple ungrouped groups', async () => { + mocks.api.fetchAlertGroups.mockImplementation(() => { + const groups = [ + mockAlertGroup({ labels: {} }), + mockAlertGroup({ labels: {}, alerts: [mockAlertmanagerAlert({ labels: { foo: 'bar' } })] }), + ]; + return Promise.resolve(groups); + }); + renderAmNotifications(); + await waitFor(() => expect(mocks.api.fetchAlertGroups).toHaveBeenCalled()); + const groups = ui.group.getAll(); + + expect(groups).toHaveLength(1); + }); }); diff --git a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx index 01f91871397..782a7b8346b 100644 --- a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx +++ b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx @@ -33,16 +33,11 @@ export const AlertManagerPicker: FC = ({ onChange, current, disabled = fa ]; }, []); - // no need to show the picker if there's only one option - if (options.length === 1) { - return null; - } - return ( (option.validationRule !== '' ? validateOption(v, option.validationRule) : true), })} placeholder={option.placeholder} @@ -157,10 +157,27 @@ const OptionInput: FC = ({ option, invalid, id, pathPref const styles = { checkbox: css` - height: auto; // native chekbox has fixed height which does not take into account description + height: auto; // native checkbox has fixed height which does not take into account description `, }; const validateOption = (value: string, validationRule: string) => { return RegExp(validationRule).test(value) ? true : 'Invalid format'; }; + +const determineRequired = (option: NotificationChannelOption, getValues: any) => { + if (!option.dependsOn) { + return option.required ? 'Required' : false; + } + + const dependentOn = getValues(`items[0].${option.dependsOn}`); + return !dependentOn && option.required ? 'Required' : false; +}; + +const determineReadOnly = (option: NotificationChannelOption, getValues: any) => { + if (!option.dependsOn) { + return false; + } + + return getValues(`items[0].${option.dependsOn}`); +}; diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx new file mode 100644 index 00000000000..a9eafa81ed3 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx @@ -0,0 +1,111 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { configureStore } from 'app/store/configureStore'; +import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { byTestId, byText } from 'testing-library-selector'; +import { mockCombinedRule, mockDataSource } from '../../mocks'; +import { RulesGroup } from './RulesGroup'; + +const hasRulerMock = jest.fn(); +jest.mock('../../hooks/useHasRuler', () => ({ + useHasRuler: () => hasRulerMock, +})); + +beforeEach(() => hasRulerMock.mockReset()); + +const ui = { + editGroupButton: byTestId('edit-group'), + deleteGroupButton: byTestId('delete-group'), + confirmDeleteModal: { + header: byText('Delete group'), + confirmButton: byText('Delete'), + }, +}; + +describe('Rules group tests', () => { + const store = configureStore(); + + function renderRulesGroup(namespace: CombinedRuleNamespace, group: CombinedRuleGroup) { + return render( + + + + ); + } + + describe('When the datasource is grafana', () => { + const group: CombinedRuleGroup = { + name: 'TestGroup', + rules: [mockCombinedRule()], + }; + + const namespace: CombinedRuleNamespace = { + name: 'TestNamespace', + rulesSource: 'grafana', + groups: [group], + }; + + it('Should hide delete and edit group buttons', () => { + // Act + renderRulesGroup(namespace, group); + + // Assert + expect(ui.deleteGroupButton.query()).not.toBeInTheDocument(); + expect(ui.editGroupButton.query()).not.toBeInTheDocument(); + }); + }); + + describe('When the datasource is not grafana', () => { + const group: CombinedRuleGroup = { + name: 'TestGroup', + rules: [mockCombinedRule()], + }; + + const namespace: CombinedRuleNamespace = { + name: 'TestNamespace', + rulesSource: mockDataSource(), + groups: [group], + }; + + it('When ruler enabled should display delete and edit group buttons', () => { + // Arrange + hasRulerMock.mockReturnValue(true); + + // Act + renderRulesGroup(namespace, group); + + // Assert + expect(hasRulerMock).toHaveBeenCalled(); + expect(ui.deleteGroupButton.get()).toBeInTheDocument(); + expect(ui.editGroupButton.get()).toBeInTheDocument(); + }); + + it('When ruler disabled should hide delete and edit group buttons', () => { + // Arrange + hasRulerMock.mockReturnValue(false); + + // Act + renderRulesGroup(namespace, group); + + // Assert + expect(hasRulerMock).toHaveBeenCalled(); + expect(ui.deleteGroupButton.query()).not.toBeInTheDocument(); + expect(ui.editGroupButton.query()).not.toBeInTheDocument(); + }); + + it('Delete button click should display confirmation modal', () => { + // Arrange + hasRulerMock.mockReturnValue(true); + + // Act + renderRulesGroup(namespace, group); + userEvent.click(ui.deleteGroupButton.get()); + + // Assert + expect(ui.confirmDeleteModal.header.get()).toBeInTheDocument(); + expect(ui.confirmDeleteModal.confirmButton.get()).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index ab267eeb172..04c61de0f57 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -1,18 +1,21 @@ -import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; -import React, { FC, useState, useEffect } from 'react'; -import { HorizontalGroup, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; -import { GrafanaTheme2 } from '@grafana/data'; import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { ConfirmModal, HorizontalGroup, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; +import kbn from 'app/core/utils/kbn'; +import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import pluralize from 'pluralize'; +import React, { FC, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useFolder } from '../../hooks/useFolder'; +import { useHasRuler } from '../../hooks/useHasRuler'; +import { deleteRulesGroupAction } from '../../state/actions'; +import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource'; import { isGrafanaRulerRule } from '../../utils/rules'; import { CollapseToggle } from '../CollapseToggle'; -import { RulesTable } from './RulesTable'; -import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource'; import { ActionIcon } from './ActionIcon'; -import { useHasRuler } from '../../hooks/useHasRuler'; -import kbn from 'app/core/utils/kbn'; -import { useFolder } from '../../hooks/useFolder'; -import { RuleStats } from './RuleStats'; import { EditCloudGroupModal } from './EditCloudGroupModal'; +import { RulesTable } from './RulesTable'; +import { RuleStats } from './RuleStats'; interface Props { namespace: CombinedRuleNamespace; @@ -22,9 +25,11 @@ interface Props { export const RulesGroup: FC = React.memo(({ group, namespace, expandAll }) => { const { rulesSource } = namespace; + const dispatch = useDispatch(); const styles = useStyles2(getStyles); const [isEditingGroup, setIsEditingGroup] = useState(false); + const [isDeletingGroup, setIsDeletingGroup] = useState(false); const [isCollapsed, setIsCollapsed] = useState(!expandAll); useEffect(() => { @@ -39,6 +44,11 @@ export const RulesGroup: FC = React.memo(({ group, namespace, expandAll } // group "is deleting" if rules source has ruler, but this group has no rules that are in ruler const isDeleting = hasRuler(rulesSource) && !group.rules.find((rule) => !!rule.rulerRule); + const deleteGroup = () => { + dispatch(deleteRulesGroupAction(namespace, group)); + setIsDeletingGroup(false); + }; + const actionIcons: React.ReactNode[] = []; // for grafana, link to folder views @@ -88,6 +98,17 @@ export const RulesGroup: FC = React.memo(({ group, namespace, expandAll } onClick={() => setIsEditingGroup(true)} /> ); + + actionIcons.push( + setIsDeletingGroup(true)} + /> + ); } const groupName = isCloudRulesSource(rulesSource) ? `${namespace.name} > ${group.name}` : namespace.name; @@ -129,6 +150,22 @@ export const RulesGroup: FC = React.memo(({ group, namespace, expandAll } {isEditingGroup && ( setIsEditingGroup(false)} /> )} + + Deleting this group will permanently remove the group +
    + and {group.rules.length} alert {pluralize('rule', group.rules.length)} belonging to it. +
    + Are you sure you want to delete this group? +
    + } + onConfirm={deleteGroup} + onDismiss={() => setIsDeletingGroup(false)} + confirmText="Delete" + />
    ); }); diff --git a/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts b/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts index fb5f54ae78a..a47e9685979 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts @@ -10,7 +10,7 @@ export const useFilteredAmGroups = (groups: AlertmanagerGroup[]) => { const matchers = parseMatchers(filters.queryString || ''); return useMemo(() => { - return groups.reduce((filteredGroup, group) => { + return groups.reduce((filteredGroup: AlertmanagerGroup[], group) => { const alerts = group.alerts.filter(({ labels, status }) => { const labelsMatch = labelsMatchMatchers(labels, matchers); const filtersMatch = filters.alertState ? status.state === filters.alertState : true; @@ -25,6 +25,6 @@ export const useFilteredAmGroups = (groups: AlertmanagerGroup[]) => { } } return filteredGroup; - }, [] as AlertmanagerGroup[]); + }, []); }, [groups, filters, matchers]); }; diff --git a/public/app/features/alerting/unified/hooks/useGroupedAlerts.ts b/public/app/features/alerting/unified/hooks/useGroupedAlerts.ts index 278851fbfc5..72932587d2a 100644 --- a/public/app/features/alerting/unified/hooks/useGroupedAlerts.ts +++ b/public/app/features/alerting/unified/hooks/useGroupedAlerts.ts @@ -1,11 +1,30 @@ import { useMemo } from 'react'; import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types'; import { Labels } from '@grafana/data'; +import { uniqBy } from 'lodash'; -export const useGroupedAlerts = (groups: AlertmanagerGroup[], groupBy: string[]) => { +export const useGroupedAlerts = (groups: AlertmanagerGroup[], groupBy: string[]): AlertmanagerGroup[] => { return useMemo(() => { if (groupBy.length === 0) { - return groups; + const emptyGroupings = groups.filter((group) => Object.keys(group.labels).length === 0); + if (emptyGroupings.length > 1) { + // Merges multiple ungrouped grouping + return groups.reduce((combinedGroups, group) => { + if (Object.keys(group.labels).length === 0) { + const noGroupingGroup = combinedGroups.find(({ labels }) => Object.keys(labels)); + if (!noGroupingGroup) { + combinedGroups.push({ alerts: group.alerts, labels: {}, receiver: { name: 'NONE' } }); + } else { + noGroupingGroup.alerts = uniqBy([...noGroupingGroup.alerts, ...group.alerts], 'labels'); + } + } else { + combinedGroups.push(group); + } + return combinedGroups; + }, [] as AlertmanagerGroup[]); + } else { + return groups; + } } const alerts = groups.flatMap(({ alerts }) => alerts); return alerts.reduce((groupings, alert) => { diff --git a/public/app/features/alerting/unified/mocks/grafana-notifiers.ts b/public/app/features/alerting/unified/mocks/grafana-notifiers.ts index 3cd3063abe5..eec2843582a 100644 --- a/public/app/features/alerting/unified/mocks/grafana-notifiers.ts +++ b/public/app/features/alerting/unified/mocks/grafana-notifiers.ts @@ -20,79 +20,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: true, validationRule: '', secure: false, - }, - ], - }, - { - type: 'dingding', - name: 'DingDing', - heading: 'DingDing settings', - description: 'Sends HTTP POST request to DingDing', - info: '', - options: [ - { - element: 'input', - inputType: 'text', - label: 'Url', - description: '', - placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx', - propertyName: 'url', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: false, - }, - { - element: 'select', - inputType: '', - label: 'Message Type', - description: '', - placeholder: '', - propertyName: 'msgType', - selectOptions: [ - { value: 'link', label: 'Link' }, - { value: 'actionCard', label: 'ActionCard' }, - ], - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - ], - }, - { - type: 'googlechat', - name: 'Google Hangouts Chat', - heading: 'Google Hangouts Chat settings', - description: 'Sends notifications to Google Hangouts Chat via webhooks based on the official JSON message format', - info: '', - options: [ - { - element: 'input', - inputType: 'text', - label: 'Url', - description: '', - placeholder: 'Google Hangouts Chat incoming webhook url', - propertyName: 'url', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: false, - }, - { - element: 'textarea', - inputType: '', - label: 'Message', - description: '', - placeholder: '{{ template "default.message" . }}', - propertyName: 'message', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, + dependsOn: '', }, ], }, @@ -115,6 +43,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: true, validationRule: '', secure: false, + dependsOn: '', }, { element: 'input', @@ -128,6 +57,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: true, validationRule: '', secure: false, + dependsOn: '', }, { element: 'input', @@ -141,6 +71,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: false, validationRule: '', secure: false, + dependsOn: '', }, ], }, @@ -163,6 +94,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: true, validationRule: '', secure: false, + dependsOn: '', }, { element: 'select', @@ -179,6 +111,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: false, validationRule: '', secure: false, + dependsOn: '', }, { element: 'input', @@ -192,6 +125,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: false, validationRule: '', secure: false, + dependsOn: '', }, { element: 'input', @@ -205,537 +139,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: false, validationRule: '', secure: true, - }, - ], - }, - { - type: 'kafka', - name: 'Kafka REST Proxy', - heading: 'Kafka settings', - description: 'Sends notifications to Kafka Rest Proxy', - info: '', - options: [ - { - element: 'input', - inputType: 'text', - label: 'Kafka REST Proxy', - description: '', - placeholder: 'http://localhost:8082', - propertyName: 'kafkaRestProxy', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'text', - label: 'Topic', - description: '', - placeholder: 'topic1', - propertyName: 'kafkaTopic', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: false, - }, - ], - }, - { - type: 'sensu', - name: 'Sensu', - heading: 'Sensu settings', - description: 'Sends HTTP POST request to a Sensu API', - info: '', - options: [ - { - element: 'input', - inputType: 'text', - label: 'Url', - description: '', - placeholder: 'http://sensu-api.local:4567/results', - propertyName: 'url', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'text', - label: 'Source', - description: 'If empty rule id will be used', - placeholder: '', - propertyName: 'source', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'text', - label: 'Handler', - description: '', - placeholder: 'default', - propertyName: 'handler', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'text', - label: 'Username', - description: '', - placeholder: '', - propertyName: 'username', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'password', - label: 'Password', - description: '', - placeholder: '', - propertyName: 'passsword ', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: true, - }, - ], - }, - { - type: 'threema', - name: 'Threema Gateway', - heading: 'Threema Gateway settings', - description: 'Sends notifications to Threema using Threema Gateway (Basic IDs)', - info: 'Notifications can be configured for any Threema Gateway ID of type "Basic". End-to-End IDs are not currently supported.The Threema Gateway ID can be set up at https://gateway.threema.ch/.', - options: [ - { - element: 'input', - inputType: 'text', - label: 'Gateway ID', - description: 'Your 8 character Threema Gateway Basic ID (starting with a *).', - placeholder: '*3MAGWID', - propertyName: 'gateway_id', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '\\*[0-9A-Z]{7}', - secure: false, - }, - { - element: 'input', - inputType: 'text', - label: 'Recipient ID', - description: 'The 8 character Threema ID that should receive the alerts.', - placeholder: 'YOUR3MID', - propertyName: 'recipient_id', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '[0-9A-Z]{8}', - secure: false, - }, - { - element: 'input', - inputType: 'text', - label: 'API Secret', - description: 'Your Threema Gateway API secret.', - placeholder: '', - propertyName: 'api_secret', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: true, - }, - ], - }, - { - type: 'LINE', - name: 'LINE', - heading: 'LINE notify settings', - description: 'Send notifications to LINE notify', - info: '', - options: [ - { - element: 'input', - inputType: 'text', - label: 'Token', - description: '', - placeholder: 'LINE notify token key', - propertyName: 'token', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: true, - }, - ], - }, - { - type: 'opsgenie', - name: 'OpsGenie', - heading: 'OpsGenie settings', - description: 'Sends notifications to OpsGenie', - info: '', - options: [ - { - element: 'input', - inputType: 'text', - label: 'API Key', - description: '', - placeholder: 'OpsGenie API Key', - propertyName: 'apiKey', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: true, - }, - { - element: 'input', - inputType: 'text', - label: 'Alert API Url', - description: '', - placeholder: 'https://api.opsgenie.com/v2/alerts', - propertyName: 'apiUrl', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: false, - }, - { - element: 'checkbox', - inputType: '', - label: 'Auto close incidents', - description: 'Automatically close alerts in OpsGenie once the alert goes back to ok.', - placeholder: '', - propertyName: 'autoClose', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'checkbox', - inputType: '', - label: 'Override priority', - description: 'Allow the alert priority to be set using the og_priority tag', - placeholder: '', - propertyName: 'overridePriority', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'select', - inputType: '', - label: 'Send notification tags as', - description: 'Send the notification tags to Opsgenie as either Extra Properties, Tags or both', - placeholder: '', - propertyName: 'sendTagsAs', - selectOptions: [ - { value: 'tags', label: 'Tags' }, - { value: 'details', label: 'Extra Properties' }, - { value: 'both', label: 'Tags \u0026 Extra Properties' }, - ], - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - ], - }, - { - type: 'pushover', - name: 'Pushover', - heading: 'Pushover settings', - description: 'Sends HTTP POST request to the Pushover API', - info: '', - options: [ - { - element: 'input', - inputType: 'text', - label: 'API Token', - description: '', - placeholder: 'Application token', - propertyName: 'apiToken', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: true, - }, - { - element: 'input', - inputType: 'text', - label: 'User key(s)', - description: '', - placeholder: 'comma-separated list', - propertyName: 'userKey', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: true, - }, - { - element: 'input', - inputType: 'text', - label: 'Device(s) (optional)', - description: '', - placeholder: 'comma-separated list; leave empty to send to all devices', - propertyName: 'device', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'select', - inputType: '', - label: 'Alerting priority', - description: '', - placeholder: '', - propertyName: 'priority', - selectOptions: [ - { value: '2', label: 'Emergency' }, - { value: '1', label: 'High' }, - { value: '0', label: 'Normal' }, - { value: '-1', label: 'Low' }, - { value: '-2', label: 'Lowest' }, - ], - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'select', - inputType: '', - label: 'OK priority', - description: '', - placeholder: '', - propertyName: 'okPriority', - selectOptions: [ - { value: '2', label: 'Emergency' }, - { value: '1', label: 'High' }, - { value: '0', label: 'Normal' }, - { value: '-1', label: 'Low' }, - { value: '-2', label: 'Lowest' }, - ], - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'text', - label: 'Retry (Only used for Emergency Priority)', - description: - 'How often (in seconds) the Pushover servers will send the same alerting or OK notification to the user.', - placeholder: 'minimum 30 seconds', - propertyName: 'retry', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'text', - label: 'Expire (Only used for Emergency Priority)', - description: 'How many seconds the alerting or OK notification will continue to be retried.', - placeholder: 'maximum 86400 seconds', - propertyName: 'expire', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'select', - inputType: '', - label: 'Alerting sound', - description: '', - placeholder: '', - propertyName: 'sound', - selectOptions: [ - { value: 'default', label: 'Default' }, - { value: 'pushover', label: 'Pushover' }, - { value: 'bike', label: 'Bike' }, - { value: 'bugle', label: 'Bugle' }, - { value: 'cashregister', label: 'Cashregister' }, - { value: 'classical', label: 'Classical' }, - { value: 'cosmic', label: 'Cosmic' }, - { value: 'falling', label: 'Falling' }, - { value: 'gamelan', label: 'Gamelan' }, - { value: 'incoming', label: 'Incoming' }, - { value: 'intermission', label: 'Intermission' }, - { value: 'magic', label: 'Magic' }, - { value: 'mechanical', label: 'Mechanical' }, - { value: 'pianobar', label: 'Pianobar' }, - { value: 'siren', label: 'Siren' }, - { value: 'spacealarm', label: 'Spacealarm' }, - { value: 'tugboat', label: 'Tugboat' }, - { value: 'alien', label: 'Alien' }, - { value: 'climb', label: 'Climb' }, - { value: 'persistent', label: 'Persistent' }, - { value: 'echo', label: 'Echo' }, - { value: 'updown', label: 'Updown' }, - { value: 'none', label: 'None' }, - ], - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'select', - inputType: '', - label: 'OK sound', - description: '', - placeholder: '', - propertyName: 'okSound', - selectOptions: [ - { value: 'default', label: 'Default' }, - { value: 'pushover', label: 'Pushover' }, - { value: 'bike', label: 'Bike' }, - { value: 'bugle', label: 'Bugle' }, - { value: 'cashregister', label: 'Cashregister' }, - { value: 'classical', label: 'Classical' }, - { value: 'cosmic', label: 'Cosmic' }, - { value: 'falling', label: 'Falling' }, - { value: 'gamelan', label: 'Gamelan' }, - { value: 'incoming', label: 'Incoming' }, - { value: 'intermission', label: 'Intermission' }, - { value: 'magic', label: 'Magic' }, - { value: 'mechanical', label: 'Mechanical' }, - { value: 'pianobar', label: 'Pianobar' }, - { value: 'siren', label: 'Siren' }, - { value: 'spacealarm', label: 'Spacealarm' }, - { value: 'tugboat', label: 'Tugboat' }, - { value: 'alien', label: 'Alien' }, - { value: 'climb', label: 'Climb' }, - { value: 'persistent', label: 'Persistent' }, - { value: 'echo', label: 'Echo' }, - { value: 'updown', label: 'Updown' }, - { value: 'none', label: 'None' }, - ], - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - ], - }, - { - type: 'sensugo', - name: 'Sensu Go', - heading: 'Sensu Go Settings', - description: 'Sends HTTP POST request to a Sensu Go API', - info: '', - options: [ - { - element: 'input', - inputType: 'text', - label: 'Backend URL', - description: '', - placeholder: 'http://sensu-api.local:8080', - propertyName: 'url', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'password', - label: 'API Key', - description: 'API Key to auth to Sensu Go backend', - placeholder: '', - propertyName: 'apikey', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: true, - }, - { - element: 'input', - inputType: 'text', - label: 'Proxy entity name', - description: 'If empty, rule name will be used', - placeholder: '', - propertyName: 'entity', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'text', - label: 'Check name', - description: 'If empty, rule id will be used', - placeholder: '', - propertyName: 'check', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'text', - label: 'Handler', - description: '', - placeholder: '', - propertyName: 'handler', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'text', - label: 'Namespace', - description: '', - placeholder: 'default', - propertyName: 'namespace', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, + dependsOn: '', }, ], }, @@ -759,6 +163,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: true, validationRule: '', secure: false, + dependsOn: '', }, { element: 'input', @@ -772,6 +177,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: false, validationRule: '', secure: false, + dependsOn: '', }, { element: 'input', @@ -785,41 +191,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: false, validationRule: '', secure: true, - }, - ], - }, - { - type: 'discord', - name: 'Discord', - heading: 'Discord settings', - description: 'Sends notifications to Discord', - info: '', - options: [ - { - element: 'input', - inputType: 'text', - label: 'Message Content', - description: 'Mention a group using @ or a user using \u003c@ID\u003e when notifying in a channel', - placeholder: '', - propertyName: 'content', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'input', - inputType: 'text', - label: 'Webhook URL', - description: '', - placeholder: 'Discord webhook URL', - propertyName: 'url', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: false, + dependsOn: '', }, ], }, @@ -842,6 +214,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: false, validationRule: '', secure: false, + dependsOn: '', }, { element: 'textarea', @@ -855,108 +228,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: true, validationRule: '', secure: false, - }, - ], - }, - { - type: 'victorops', - name: 'VictorOps', - heading: 'VictorOps settings', - description: 'Sends notifications to VictorOps', - info: '', - options: [ - { - element: 'input', - inputType: 'text', - label: 'Url', - description: '', - placeholder: 'VictorOps url', - propertyName: 'url', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: false, - }, - { - element: 'checkbox', - inputType: '', - label: 'Auto resolve incidents', - description: 'Resolve incidents in VictorOps once the alert goes back to ok.', - placeholder: '', - propertyName: 'autoResolve', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - ], - }, - { - type: 'pagerduty', - name: 'PagerDuty', - heading: 'PagerDuty settings', - description: 'Sends notifications to PagerDuty', - info: '', - options: [ - { - element: 'input', - inputType: 'text', - label: 'Integration Key', - description: '', - placeholder: 'Pagerduty Integration Key', - propertyName: 'integrationKey', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: true, - }, - { - element: 'select', - inputType: '', - label: 'Severity', - description: '', - placeholder: '', - propertyName: 'severity', - selectOptions: [ - { value: 'critical', label: 'Critical' }, - { value: 'error', label: 'Error' }, - { value: 'warning', label: 'Warning' }, - { value: 'info', label: 'Info' }, - ], - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'checkbox', - inputType: '', - label: 'Auto resolve incidents', - description: 'Resolve incidents in pagerduty once the alert goes back to ok.', - placeholder: '', - propertyName: 'autoResolve', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, - }, - { - element: 'checkbox', - inputType: '', - label: 'Include message in details', - description: - 'Move the alert message from the PD summary into the custom details. This changes the custom details object and may break event rules you have configured', - placeholder: '', - propertyName: 'messageInDetails', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: false, + dependsOn: '', }, ], }, @@ -977,9 +249,10 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ propertyName: 'recipient', selectOptions: null, showWhen: { field: '', is: '' }, - required: false, + required: true, validationRule: '', secure: false, + dependsOn: 'secureSettings.url', }, { element: 'input', @@ -990,9 +263,10 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ propertyName: 'token', selectOptions: null, showWhen: { field: '', is: '' }, - required: false, + required: true, validationRule: '', secure: true, + dependsOn: 'secureSettings.url', }, { element: 'input', @@ -1006,6 +280,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: false, validationRule: '', secure: false, + dependsOn: '', }, { element: 'input', @@ -1019,6 +294,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: false, validationRule: '', secure: false, + dependsOn: '', }, { element: 'input', @@ -1032,6 +308,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: false, validationRule: '', secure: false, + dependsOn: '', }, { element: 'input', @@ -1046,6 +323,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: false, validationRule: '', secure: false, + dependsOn: '', }, { element: 'input', @@ -1060,6 +338,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: false, validationRule: '', secure: false, + dependsOn: '', }, { element: 'select', @@ -1077,6 +356,7 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ required: false, validationRule: '', secure: false, + dependsOn: '', }, { element: 'input', @@ -1088,44 +368,10 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ propertyName: 'url', selectOptions: null, showWhen: { field: '', is: '' }, - required: false, - validationRule: '', - secure: true, - }, - ], - }, - { - type: 'telegram', - name: 'Telegram', - heading: 'Telegram API settings', - description: 'Sends notifications to Telegram', - info: '', - options: [ - { - element: 'input', - inputType: 'text', - label: 'BOT API Token', - description: '', - placeholder: 'Telegram BOT API Token', - propertyName: 'bottoken', - selectOptions: null, - showWhen: { field: '', is: '' }, required: true, validationRule: '', secure: true, - }, - { - element: 'input', - inputType: 'text', - label: 'Chat ID', - description: 'Integer Telegram Chat Identifier', - placeholder: '', - propertyName: 'chatid', - selectOptions: null, - showWhen: { field: '', is: '' }, - required: true, - validationRule: '', - secure: false, + dependsOn: 'token', }, ], }, diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 38e4a4fdb40..c8c5113ef4a 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -12,7 +12,14 @@ import { TestReceiversAlert, } from 'app/plugins/datasource/alertmanager/types'; import { FolderDTO, NotifierDTO, ThunkResult } from 'app/types'; -import { RuleIdentifier, RuleNamespace, RuleWithLocation, StateHistoryItem } from 'app/types/unified-alerting'; +import { + CombinedRuleGroup, + CombinedRuleNamespace, + RuleIdentifier, + RuleNamespace, + RuleWithLocation, + StateHistoryItem, +} from 'app/types/unified-alerting'; import { PostableRulerRuleGroupDTO, RulerGrafanaRuleDTO, @@ -49,6 +56,7 @@ import { import { RuleFormType, RuleFormValues } from '../types/rule-form'; import { getAllRulesSourceNames, + getRulesSourceName, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource, isVanillaPrometheusAlertManagerDataSource, @@ -263,6 +271,24 @@ async function deleteRule(ruleWithLocation: RuleWithLocation): Promise { }); } +export function deleteRulesGroupAction( + namespace: CombinedRuleNamespace, + ruleGroup: CombinedRuleGroup +): ThunkResult { + return async (dispatch) => { + withAppEvents( + (async () => { + const sourceName = getRulesSourceName(namespace.rulesSource); + + await deleteRulerRulesGroup(sourceName, namespace.name, ruleGroup.name); + dispatch(fetchRulerRulesAction({ rulesSourceName: sourceName })); + dispatch(fetchPromRulesAction({ rulesSourceName: sourceName })); + })(), + { successMessage: 'Group deleted' } + ); + }; +} + export function deleteRuleAction( ruleIdentifier: RuleIdentifier, options: { navigateTo?: string } = {} diff --git a/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts b/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts index d1e3bfcfbc6..4dcf2e44fdd 100644 --- a/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts +++ b/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts @@ -17,6 +17,7 @@ function option( placeholder: '', validationRule: '', showWhen: { field: '', is: '' }, + dependsOn: '', ...rest, }; } diff --git a/public/app/features/canvas/runtime/root.tsx b/public/app/features/canvas/runtime/root.tsx index 25b45a8bba5..1106adddd93 100644 --- a/public/app/features/canvas/runtime/root.tsx +++ b/public/app/features/canvas/runtime/root.tsx @@ -27,12 +27,12 @@ export class RootElement extends GroupState { this.changeCallback(); } - getSaveModel() { + getSaveModel(): CanvasGroupOptions { const { placement, anchor, ...rest } = this.options; return { ...rest, // everything except placement & anchor elements: this.elements.map((v) => v.getSaveModel()), - } as CanvasGroupOptions; + }; } } diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index 2341977103f..89e5e1ce937 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -5,6 +5,25 @@ import { DashboardMeta } from 'app/types'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { saveDashboard } from 'app/features/manage-dashboards/state/actions'; import { RemovePanelEvent } from '../../../types/events'; +import { BackendSrvRequest } from '@grafana/runtime'; +import { lastValueFrom } from 'rxjs'; + +export interface SaveDashboardOptions { + /** The complete dashboard model. If `dashboard.id` is not set a new dashboard will be created. */ + dashboard: DashboardModel; + /** Set a commit message for the version history. */ + message?: string; + /** The id of the folder to save the dashboard in. */ + folderId?: number; + /** The UID of the folder to save the dashboard in. Overrides `folderId`. */ + folderUid?: string; + /** Set to `true` if you want to overwrite existing dashboard with newer version, + * same dashboard title in folder or same dashboard uid. */ + overwrite?: boolean; + /** Set the dashboard refresh interval. + * If this is lower than the minimum refresh interval, Grafana will ignore it and will enforce the minimum refresh interval. */ + refresh?: string; +} export class DashboardSrv { dashboard?: DashboardModel; @@ -43,6 +62,23 @@ export class DashboardSrv { }); } + saveDashboard( + data: SaveDashboardOptions, + requestOptions?: Pick + ) { + return lastValueFrom( + getBackendSrv().fetch({ + url: '/api/dashboards/db/', + method: 'POST', + data: { + ...data, + dashboard: data.dashboard.getSaveModelClone(), + }, + ...requestOptions, + }) + ); + } + starDashboard(dashboardId: string, isStarred: any) { const backendSrv = getBackendSrv(); let promise; diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index 0976bb4d69b..106f8cedd3f 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -57,6 +57,7 @@ export function TraceView(props: Props) { detailLogsToggle, detailProcessToggle, detailReferencesToggle, + detailReferenceItemToggle, detailTagsToggle, detailWarningsToggle, detailStackTracesToggle, @@ -87,6 +88,11 @@ export function TraceView(props: Props) { datasource, }); + const createLinkToExternalSpan = (traceId: string, spanId: string) => { + const link = createFocusSpanLink(traceId, spanId); + return link.href; + }; + const traceTimeline: TTraceTimeline = useMemo( () => ({ childrenHiddenIDs, @@ -144,7 +150,7 @@ export function TraceView(props: Props) { updateViewRangeTime={updateViewRangeTime} viewRange={viewRange} focusSpan={noop} - createLinkToExternalSpan={noop as any} + createLinkToExternalSpan={createLinkToExternalSpan} setSpanNameColumnWidth={setSpanNameColumnWidth} collapseAll={collapseAll} collapseOne={collapseOne} @@ -157,6 +163,7 @@ export function TraceView(props: Props) { detailWarningsToggle={detailWarningsToggle} detailStackTracesToggle={detailStackTracesToggle} detailReferencesToggle={detailReferencesToggle} + detailReferenceItemToggle={detailReferenceItemToggle} detailProcessToggle={detailProcessToggle} detailTagsToggle={detailTagsToggle} detailToggle={toggleDetail} @@ -203,13 +210,20 @@ function transformTraceDataFrame(frame: DataFrame): TraceResponse { 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: s.parentSpanID ? [{ refType: 'CHILD_OF', spanID: s.parentSpanID, traceID: s.traceID }] : undefined, + references, logs: s.logs?.map((l) => ({ ...l, timestamp: l.timestamp * 1000 })) || [], dataFrameRowIndex: index, }; diff --git a/public/app/features/explore/TraceView/useDetailState.test.ts b/public/app/features/explore/TraceView/useDetailState.test.ts index 6433dbd5f0d..0a641f461c8 100644 --- a/public/app/features/explore/TraceView/useDetailState.test.ts +++ b/public/app/features/explore/TraceView/useDetailState.test.ts @@ -44,7 +44,7 @@ describe('useDetailState', () => { const { result } = renderHook(() => useDetailState(sampleFrame)); act(() => result.current.toggleDetail('span1')); act(() => result.current.detailReferencesToggle('span1')); - expect(result.current.detailStates.get('span1')?.isReferencesOpen).toBe(true); + expect(result.current.detailStates.get('span1')?.references.isOpen).toBe(true); }); it('toggles processes', async () => { diff --git a/public/app/features/explore/TraceView/useDetailState.ts b/public/app/features/explore/TraceView/useDetailState.ts index 802ec4a74c3..c58e91f6d0d 100644 --- a/public/app/features/explore/TraceView/useDetailState.ts +++ b/public/app/features/explore/TraceView/useDetailState.ts @@ -1,7 +1,7 @@ import { useCallback, useState, useEffect } from 'react'; import { DataFrame } from '@grafana/data'; import { DetailState } from '@jaegertracing/jaeger-ui-components'; -import { TraceLog } from '@jaegertracing/jaeger-ui-components/src/types/trace'; +import { TraceLog, TraceSpanReference } from '@jaegertracing/jaeger-ui-components/src/types/trace'; /** * Keeps state of the span detail. This means whether span details are open but also state of each detail subitem @@ -42,6 +42,20 @@ export function useDetailState(frame: DataFrame) { [detailStates] ); + const detailReferenceItemToggle = useCallback( + function detailReferenceItemToggle(spanID: string, reference: TraceSpanReference) { + const old = detailStates.get(spanID); + if (!old) { + return; + } + const detailState = old.toggleReferenceItem(reference); + const newDetailStates = new Map(detailStates); + newDetailStates.set(spanID, detailState); + return setDetailStates(newDetailStates); + }, + [detailStates] + ); + return { detailStates, toggleDetail, @@ -58,6 +72,7 @@ export function useDetailState(frame: DataFrame) { (spanID: string) => makeDetailSubsectionToggle('stackTraces', detailStates, setDetailStates)(spanID), [detailStates] ), + detailReferenceItemToggle, detailReferencesToggle: useCallback( (spanID: string) => makeDetailSubsectionToggle('references', detailStates, setDetailStates)(spanID), [detailStates] diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index 9bb6b1d968e..49825d2a8d8 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -96,7 +96,7 @@ export async function loadAndInitDatasource( } const historyKey = `grafana.explore.history.${instance.meta?.id}`; - const history = store.getObject(historyKey, []); + const history = store.getObject(historyKey, []); // Save last-used datasource store.set(lastUsedDatasourceKeyForOrgId(orgId), instance.uid); diff --git a/public/app/features/live/centrifuge/channel.ts b/public/app/features/live/centrifuge/channel.ts index 92bae084747..0ad529a6598 100644 --- a/public/app/features/live/centrifuge/channel.ts +++ b/public/app/features/live/centrifuge/channel.ts @@ -61,7 +61,7 @@ export class CentrifugeLiveChannel { this.initalized = true; const events: SubscriptionEvents = { - // Called when a message is recieved from the socket + // Called when a message is received from the socket publish: (ctx: PublicationContext) => { try { if (ctx.data) { diff --git a/public/app/features/manage-dashboards/DashboardImportPage.tsx b/public/app/features/manage-dashboards/DashboardImportPage.tsx index a8744ae3291..378b9fe4cc5 100644 --- a/public/app/features/manage-dashboards/DashboardImportPage.tsx +++ b/public/app/features/manage-dashboards/DashboardImportPage.tsx @@ -3,6 +3,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { css } from '@emotion/css'; import { AppEvents, GrafanaTheme2, LoadingState } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { reportInteraction } from '@grafana/runtime'; import { Button, Field, @@ -33,6 +34,8 @@ type DashboardImportPageRouteSearchParams = { type OwnProps = Themeable2 & GrafanaRouteComponentProps<{}, DashboardImportPageRouteSearchParams>; +const IMPORT_STARTED_EVENT_NAME = 'dashboard_import_loaded'; + const mapStateToProps = (state: StoreState) => ({ navModel: getNavModel(state.navIndex, 'import', undefined, true), loadingState: state.importDashboard.state, @@ -63,6 +66,10 @@ class UnthemedDashboardImport extends PureComponent { } onFileUpload = (event: FormEvent) => { + reportInteraction(IMPORT_STARTED_EVENT_NAME, { + import_source: 'json_uploaded', + }); + const { importDashboardJson } = this.props; const file = event.currentTarget.files && event.currentTarget.files.length > 0 && event.currentTarget.files[0]; @@ -89,10 +96,18 @@ class UnthemedDashboardImport extends PureComponent { }; getDashboardFromJson = (formData: { dashboardJson: string }) => { + reportInteraction(IMPORT_STARTED_EVENT_NAME, { + import_source: 'json_pasted', + }); + this.props.importDashboardJson(JSON.parse(formData.dashboardJson)); }; getGcomDashboard = (formData: { gcomDashboard: string }) => { + reportInteraction(IMPORT_STARTED_EVENT_NAME, { + import_source: 'gcom', + }); + let dashboardId; const match = /(^\d+$)|dashboards\/(\d+)/.exec(formData.gcomDashboard); if (match && match[1]) { diff --git a/public/app/features/manage-dashboards/components/ImportDashboardOverview.tsx b/public/app/features/manage-dashboards/components/ImportDashboardOverview.tsx index c94f75c5b5b..275a12d18ac 100644 --- a/public/app/features/manage-dashboards/components/ImportDashboardOverview.tsx +++ b/public/app/features/manage-dashboards/components/ImportDashboardOverview.tsx @@ -6,7 +6,9 @@ import { ImportDashboardForm } from './ImportDashboardForm'; import { clearLoadedDashboard, importDashboard } from '../state/actions'; import { DashboardSource, ImportDashboardDTO } from '../state/reducers'; import { StoreState } from 'app/types'; -import { locationService } from '@grafana/runtime'; +import { locationService, reportInteraction } from '@grafana/runtime'; + +const IMPORT_FINISHED_EVENT_NAME = 'dashboard_import_imported'; const mapStateToProps = (state: StoreState) => { const searchObj = locationService.getSearchObject(); @@ -39,6 +41,8 @@ class ImportDashboardOverviewUnConnected extends PureComponent { }; onSubmit = (form: ImportDashboardDTO) => { + reportInteraction(IMPORT_FINISHED_EVENT_NAME); + this.props.importDashboard(form); }; diff --git a/public/app/features/serviceaccounts/CreateTokenModal.tsx b/public/app/features/serviceaccounts/CreateTokenModal.tsx index c0e7ac2eeb9..4cf8338a0d1 100644 --- a/public/app/features/serviceaccounts/CreateTokenModal.tsx +++ b/public/app/features/serviceaccounts/CreateTokenModal.tsx @@ -15,6 +15,7 @@ import { RadioButtonGroup, useStyles2, } from '@grafana/ui'; +import { ApiKey, OrgRole } from 'app/types'; const EXPIRATION_OPTIONS = [ { label: 'No expiration', value: false }, @@ -24,7 +25,7 @@ const EXPIRATION_OPTIONS = [ interface CreateTokenModalProps { isOpen: boolean; token: string; - onCreateToken: (name: string) => void; + onCreateToken: (token: ApiKey) => void; onClose: () => void; } @@ -87,7 +88,16 @@ export const CreateTokenModal = ({ isOpen, token, onCreateToken, onClose }: Crea )} - @@ -128,6 +138,13 @@ export const CreateTokenModal = ({ isOpen, token, onCreateToken, onClose }: Crea ); }; +const getSecondsToLive = (date: Date | string) => { + const dateAsDate = new Date(date); + const now = new Date(); + + return Math.ceil((dateAsDate.getTime() - now.getTime()) / 1000); +}; + const getStyles = (theme: GrafanaTheme2) => { return { modal: css` diff --git a/public/app/features/serviceaccounts/ServiceAccountPage.tsx b/public/app/features/serviceaccounts/ServiceAccountPage.tsx index db4a9e25666..918c54102e0 100644 --- a/public/app/features/serviceaccounts/ServiceAccountPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountPage.tsx @@ -3,24 +3,30 @@ import { connect, ConnectedProps } from 'react-redux'; import { getNavModel } from 'app/core/selectors/navModel'; import Page from 'app/core/components/Page/Page'; import { ServiceAccountProfile } from './ServiceAccountProfile'; -import { StoreState, ServiceAccountDTO, ApiKey } from 'app/types'; +import { StoreState, ServiceAccountDTO, ApiKey, Role } from 'app/types'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { deleteServiceAccountToken, loadServiceAccount, loadServiceAccountTokens, createServiceAccountToken, + fetchACOptions, + updateServiceAccount, + deleteServiceAccount, } from './state/actions'; import { ServiceAccountTokensTable } from './ServiceAccountTokensTable'; -import { getTimeZone, NavModel, OrgRole } from '@grafana/data'; +import { getTimeZone, NavModel } from '@grafana/data'; import { Button, VerticalGroup } from '@grafana/ui'; import { CreateTokenModal } from './CreateTokenModal'; +import { contextSrv } from 'app/core/core'; interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> { navModel: NavModel; serviceAccount?: ServiceAccountDTO; tokens: ApiKey[]; isLoading: boolean; + roleOptions: Role[]; + builtInRoles: Record; } function mapStateToProps(state: StoreState) { @@ -29,6 +35,8 @@ function mapStateToProps(state: StoreState) { serviceAccount: state.serviceAccountProfile.serviceAccount, tokens: state.serviceAccountProfile.tokens, isLoading: state.serviceAccountProfile.isLoading, + roleOptions: state.serviceAccounts.roleOptions, + builtInRoles: state.serviceAccounts.builtInRoles, timezone: getTimeZone(state.user), }; } @@ -37,6 +45,9 @@ const mapDispatchToProps = { loadServiceAccountTokens, createServiceAccountToken, deleteServiceAccountToken, + deleteServiceAccount, + updateServiceAccount, + fetchACOptions, }; const connector = connect(mapStateToProps, mapDispatchToProps); @@ -49,10 +60,15 @@ const ServiceAccountPageUnconnected = ({ tokens, timezone, isLoading, + roleOptions, + builtInRoles, loadServiceAccount, loadServiceAccountTokens, createServiceAccountToken, deleteServiceAccountToken, + deleteServiceAccount, + updateServiceAccount, + fetchACOptions, }: Props) => { const [isModalOpen, setIsModalOpen] = useState(false); const [newToken, setNewToken] = useState(''); @@ -61,21 +77,17 @@ const ServiceAccountPageUnconnected = ({ const serviceAccountId = parseInt(match.params.id, 10); loadServiceAccount(serviceAccountId); loadServiceAccountTokens(serviceAccountId); - }, [match, loadServiceAccount, loadServiceAccountTokens]); + if (contextSrv.accessControlEnabled()) { + fetchACOptions(); + } + }, [match, loadServiceAccount, loadServiceAccountTokens, fetchACOptions]); const onDeleteServiceAccountToken = (key: ApiKey) => { deleteServiceAccountToken(parseInt(match.params.id, 10), key.id!); }; - const onCreateToken = (name: string) => { - createServiceAccountToken( - serviceAccount.id, - { - name, - role: OrgRole.Viewer, - }, - setNewToken - ); + const onCreateToken = (token: ApiKey) => { + createServiceAccountToken(serviceAccount.id, token, setNewToken); }; const onModalClose = () => { @@ -91,18 +103,10 @@ const ServiceAccountPageUnconnected = ({ { - console.log(`not implemented`); - }} - onServiceAccountUpdate={() => { - console.log(`not implemented`); - }} - onServiceAccountDisable={() => { - console.log(`not implemented`); - }} - onServiceAccountEnable={() => { - console.log(`not implemented`); - }} + roleOptions={roleOptions} + builtInRoles={builtInRoles} + updateServiceAccount={updateServiceAccount} + deleteServiceAccount={deleteServiceAccount} /> )} diff --git a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx index 1059c302e33..98df4a391a9 100644 --- a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx @@ -1,27 +1,27 @@ import React, { PureComponent, useRef, useState } from 'react'; -import { ServiceAccountDTO } from 'app/types'; +import { Role, ServiceAccountDTO } from 'app/types'; import { css, cx } from '@emotion/css'; -import { config } from 'app/core/config'; -import { dateTimeFormat, GrafanaTheme, TimeZone } from '@grafana/data'; -import { Button, ConfirmButton, ConfirmModal, Input, LegacyInputStatus, stylesFactory } from '@grafana/ui'; +import { dateTimeFormat, GrafanaTheme2, OrgRole, TimeZone } from '@grafana/data'; +import { Button, ConfirmButton, ConfirmModal, Input, LegacyInputStatus, useStyles2 } from '@grafana/ui'; +import { ServiceAccountRoleRow } from './ServiceAccountRoleRow'; interface Props { serviceAccount: ServiceAccountDTO; timeZone: TimeZone; - onServiceAccountUpdate: (serviceAccount: ServiceAccountDTO) => void; - onServiceAccountDelete: (serviceAccountId: number) => void; - onServiceAccountDisable: (serviceAccountId: number) => void; - onServiceAccountEnable: (serviceAccountId: number) => void; + roleOptions: Role[]; + builtInRoles: Record; + deleteServiceAccount: (serviceAccountId: number) => void; + updateServiceAccount: (serviceAccount: ServiceAccountDTO) => void; } export function ServiceAccountProfile({ serviceAccount, timeZone, - onServiceAccountUpdate, - onServiceAccountDelete, - onServiceAccountDisable, - onServiceAccountEnable, + roleOptions, + builtInRoles, + deleteServiceAccount, + updateServiceAccount, }: Props) { const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDisableModal, setShowDisableModal] = useState(false); @@ -42,20 +42,27 @@ export function ServiceAccountProfile({ } }; - const handleServiceAccountDelete = () => onServiceAccountDelete(serviceAccount.id); - - const handleServiceAccountDisable = () => onServiceAccountDisable(serviceAccount.id); - - const handleServiceAccountEnable = () => onServiceAccountEnable(serviceAccount.id); - - const onServiceAccountNameChange = (newValue: string) => { - onServiceAccountUpdate({ - ...serviceAccount, - name: newValue, - }); + const handleServiceAccountDelete = () => { + deleteServiceAccount(serviceAccount.id); + }; + const handleServiceAccountDisable = () => { + updateServiceAccount({ ...serviceAccount, isDisabled: true }); + setShowDisableModal(false); }; - const styles = getStyles(config.theme); + const handleServiceAccountEnable = () => { + updateServiceAccount({ ...serviceAccount, isDisabled: false }); + }; + + const handleServiceAccountRoleChange = (role: OrgRole) => { + updateServiceAccount({ ...serviceAccount, role: role }); + }; + + const onServiceAccountNameChange = (newValue: string) => { + updateServiceAccount({ ...serviceAccount, name: newValue }); + }; + + const styles = useStyles2(getStyles); return ( <> @@ -73,9 +80,14 @@ export function ServiceAccountProfile({ onChange={onServiceAccountNameChange} /> - - - + +
    <> - - {serviceAccount.isDisabled && ( - - )} - {!serviceAccount.isDisabled && ( + ) : ( <> - { +const getStyles = (theme: GrafanaTheme2) => { return { buttonRow: css` - margin-top: 0.8rem; + margin-top: ${theme.spacing(1.5)}; > * { - margin-right: 16px; + margin-right: ${theme.spacing(2)}; } `, }; -}); +}; interface ServiceAccountProfileRowProps { label: string; diff --git a/public/app/features/serviceaccounts/ServiceAccountRoleRow.tsx b/public/app/features/serviceaccounts/ServiceAccountRoleRow.tsx new file mode 100644 index 00000000000..851d4b1298d --- /dev/null +++ b/public/app/features/serviceaccounts/ServiceAccountRoleRow.tsx @@ -0,0 +1,58 @@ +import React, { PureComponent } from 'react'; +import { css, cx } from '@emotion/css'; +import { AccessControlAction, OrgRole, Role, ServiceAccountDTO } from 'app/types'; +import { OrgRolePicker } from '../admin/OrgRolePicker'; +import { contextSrv } from 'app/core/core'; +import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; + +interface Props { + label: string; + serviceAccount: ServiceAccountDTO; + onRoleChange: (role: OrgRole) => void; + roleOptions: Role[]; + builtInRoles: Record; +} + +export class ServiceAccountRoleRow extends PureComponent { + render() { + const { label, serviceAccount, roleOptions, builtInRoles, onRoleChange } = this.props; + const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount); + const rolePickerDisabled = !canUpdateRole; + const labelClass = cx( + 'width-16', + css` + font-weight: 500; + ` + ); + + const inputId = `${label}-input`; + return ( + + + + + + {contextSrv.licensedAccessControlEnabled() ? ( + + ) : ( + + )} + + + + ); + } +} diff --git a/public/app/features/serviceaccounts/state/actions.ts b/public/app/features/serviceaccounts/state/actions.ts index a7135327a8e..f8dccf7d1d1 100644 --- a/public/app/features/serviceaccounts/state/actions.ts +++ b/public/app/features/serviceaccounts/state/actions.ts @@ -1,5 +1,5 @@ -import { ServiceAccountDTO, ThunkResult } from '../../../types'; -import { getBackendSrv } from '@grafana/runtime'; +import { ApiKey, ServiceAccountDTO, ThunkResult } from '../../../types'; +import { getBackendSrv, locationService } from '@grafana/runtime'; import { acOptionsLoaded, builtInRolesLoaded, @@ -39,7 +39,7 @@ export function setServiceAccountToRemove(serviceAccount: ServiceAccountDTO | nu export function loadServiceAccount(saID: number): ThunkResult { return async (dispatch) => { try { - const response = await getBackendSrv().get(`${BASE_URL}/${saID}`); + const response = await getBackendSrv().get(`${BASE_URL}/${saID}`, accessControlQueryParam()); dispatch(serviceAccountLoaded(response)); } catch (error) { console.error(error); @@ -49,11 +49,11 @@ export function loadServiceAccount(saID: number): ThunkResult { export function createServiceAccountToken( saID: number, - data: any, + token: ApiKey, onTokenCreated: (key: string) => void ): ThunkResult { return async (dispatch) => { - const result = await getBackendSrv().post(`${BASE_URL}/${saID}/tokens`, data); + const result = await getBackendSrv().post(`${BASE_URL}/${saID}/tokens`, token); onTokenCreated(result.key); dispatch(loadServiceAccountTokens(saID)); }; @@ -90,8 +90,8 @@ export function loadServiceAccounts(): ThunkResult { export function updateServiceAccount(serviceAccount: ServiceAccountDTO): ThunkResult { return async (dispatch) => { - await getBackendSrv().patch(`/api/org/users/${serviceAccount.id}`, { role: serviceAccount.role }); - dispatch(loadServiceAccounts()); + const response = await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.id}`, { ...serviceAccount }); + dispatch(serviceAccountLoaded(response)); }; } @@ -101,3 +101,10 @@ export function removeServiceAccount(serviceAccountId: number): ThunkResult { + return async (dispatch) => { + await getBackendSrv().delete(`${BASE_URL}/${serviceAccountId}`); + locationService.push('/org/serviceaccounts'); + }; +} diff --git a/public/app/features/transformers/editors/GroupingToMatrixTransformerEditor.tsx b/public/app/features/transformers/editors/GroupingToMatrixTransformerEditor.tsx new file mode 100644 index 00000000000..32cc311f61b --- /dev/null +++ b/public/app/features/transformers/editors/GroupingToMatrixTransformerEditor.tsx @@ -0,0 +1,85 @@ +import React, { useCallback } from 'react'; +import { + DataTransformerID, + SelectableValue, + standardTransformers, + TransformerRegistryItem, + TransformerUIProps, + GroupingToMatrixTransformerOptions, +} from '@grafana/data'; +import { InlineField, InlineFieldRow, Select } from '@grafana/ui'; +import { useAllFieldNamesFromDataFrames } from '../utils'; + +export const GroupingToMatrixTransformerEditor: React.FC> = ({ + input, + options, + onChange, +}) => { + const fieldNames = useAllFieldNamesFromDataFrames(input).map((item: string) => ({ label: item, value: item })); + + const onSelectColumn = useCallback( + (value: SelectableValue) => { + onChange({ + ...options, + columnField: value.value, + }); + }, + [onChange, options] + ); + + const onSelectRow = useCallback( + (value: SelectableValue) => { + onChange({ + ...options, + rowField: value.value, + }); + }, + [onChange, options] + ); + + const onSelectValue = useCallback( + (value: SelectableValue) => { + onChange({ + ...options, + valueField: value.value, + }); + }, + [onChange, options] + ); + + return ( + <> + + + + + +
    diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/InsightsConfig.test.tsx.snap b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/deprecated/components/__snapshots__/InsightsConfig.test.tsx.snap similarity index 96% rename from public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/InsightsConfig.test.tsx.snap rename to public/app/plugins/datasource/grafana-azure-monitor-datasource/components/deprecated/components/__snapshots__/InsightsConfig.test.tsx.snap index 6d19dc3246a..4685035e90f 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/InsightsConfig.test.tsx.snap +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/deprecated/components/__snapshots__/InsightsConfig.test.tsx.snap @@ -65,6 +65,7 @@ exports[`Render should disable insights api key input 1`] = ` >
    @@ -106,6 +107,7 @@ exports[`Render should enable insights api key input 1`] = ` > @@ -128,6 +130,7 @@ exports[`Render should enable insights api key input 1`] = ` >
    @@ -170,6 +173,7 @@ exports[`Render should render component 1`] = ` @@ -193,6 +197,7 @@ exports[`Render should render component 1`] = `
    diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/deprecated/utils.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/deprecated/utils.tsx new file mode 100644 index 00000000000..ddf958cb4db --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/deprecated/utils.tsx @@ -0,0 +1,13 @@ +import { config } from '@grafana/runtime'; +import { gt, valid } from 'semver'; + +import { AzureDataSourceSettings } from '../../types'; + +export function isAppInsightsConfigured(options: AzureDataSourceSettings) { + return !!(options.jsonData.appInsightsAppId && options.secureJsonFields.appInsightsApiKey); +} + +export function gtGrafana9() { + // AppInsights configuration will be removed with Grafana 9 + return valid(config.buildInfo.version) && gt(config.buildInfo.version, '9.0.0-beta1'); +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/credentials.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/credentials.ts index ad12b1da2ce..e92905fb035 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/credentials.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/credentials.ts @@ -1,4 +1,5 @@ import { config } from '@grafana/runtime'; + import { AzureAuthType, AzureCloud, @@ -165,7 +166,3 @@ export function updateCredentials( return options; } } - -export function isAppInsightsConfigured(options: AzureDataSourceSettings) { - return !!(options.jsonData.appInsightsAppId && options.secureJsonFields.appInsightsApiKey); -} diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index e9cff1ca03a..a1956b3d4db 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -22,7 +22,6 @@ import { TIME_SERIES_TIME_FIELD_NAME, TIME_SERIES_VALUE_FIELD_NAME, TimeSeries, - CoreApp, } from '@grafana/data'; import InfluxSeries from './influx_series'; import InfluxQueryModel from './influx_query_model'; @@ -165,7 +164,7 @@ export default class InfluxDatasource extends DataSourceWithBackend { if (res.error) { @@ -227,18 +226,21 @@ export default class InfluxDatasource extends DataSourceWithBackend { - // this only works in flux-mode, it should not be called in non-flux-mode - if (!this.isFlux) { - return query; - } - // We want to interpolate these variables on backend const { __interval, __interval_ms, ...rest } = scopedVars; - return { - ...query, - query: this.templateSrv.replace(query.query ?? '', rest), // The raw query text - }; + if (this.isFlux) { + return { + ...query, + query: this.templateSrv.replace(query.query ?? '', rest), // The raw query text + }; + } + + if (config.featureToggles.influxdbBackendMigration && this.access === 'proxy') { + query = this.applyVariables(query, scopedVars, rest); + } + + return query; } /** @@ -381,71 +383,72 @@ export default class InfluxDatasource extends DataSourceWithBackend 0) { - expandedQueries = queries.map((query) => { - if (this.isFlux) { - return { - ...query, - datasource: this.getRef(), - query: this.templateSrv.replace(query.query ?? '', scopedVars, 'regex'), - }; - } - - const expandedQuery = { + return queries.map((query) => { + if (this.isFlux) { + return { ...query, datasource: this.getRef(), - measurement: this.templateSrv.replace(query.measurement ?? '', scopedVars, 'regex'), - policy: this.templateSrv.replace(query.policy ?? '', scopedVars, 'regex'), - limit: this.templateSrv.replace(query.limit?.toString() ?? '', scopedVars, 'regex'), - slimit: this.templateSrv.replace(query.slimit?.toString() ?? '', scopedVars, 'regex'), - tz: this.templateSrv.replace(query.tz ?? '', scopedVars), + query: this.templateSrv.replace(query.query ?? '', scopedVars), // The raw query text }; + } - if (query.rawQuery) { - expandedQuery.query = this.templateSrv.replace(query.query ?? '', scopedVars, 'regex'); - } + return { + ...query, + datasource: this.getRef(), + ...this.applyVariables(query, scopedVars, scopedVars), + }; + }); + } - if (query.groupBy) { - expandedQuery.groupBy = query.groupBy.map((groupBy) => { - return { - ...groupBy, - params: groupBy.params?.map((param) => { - return this.templateSrv.replace(param.toString(), undefined, 'regex'); - }), - }; - }); - } - - if (query.select) { - expandedQuery.select = query.select.map((selects) => { - return selects.map((select: any) => { - return { - ...select, - params: select.params?.map((param: any) => { - return this.templateSrv.replace(param.toString(), undefined, 'regex'); - }), - }; - }); - }); - } - - if (query.tags) { - expandedQuery.tags = query.tags.map((tag) => { - return { - ...tag, - value: this.templateSrv.replace(tag.value, undefined, 'regex'), - }; - }); - } - return expandedQuery; + applyVariables(query: InfluxQuery, scopedVars: ScopedVars, rest: ScopedVars) { + const expandedQuery = { ...query }; + if (query.groupBy) { + expandedQuery.groupBy = query.groupBy.map((groupBy) => { + return { + ...groupBy, + params: groupBy.params?.map((param) => { + return this.templateSrv.replace(param.toString(), undefined, 'regex'); + }), + }; }); } - return expandedQueries; + + if (query.select) { + expandedQuery.select = query.select.map((selects) => { + return selects.map((select: any) => { + return { + ...select, + params: select.params?.map((param: any) => { + return this.templateSrv.replace(param.toString(), undefined, 'regex'); + }), + }; + }); + }); + } + + if (query.tags) { + expandedQuery.tags = query.tags.map((tag) => { + return { + ...tag, + value: this.templateSrv.replace(tag.value, undefined, 'regex'), + }; + }); + } + + return { + ...expandedQuery, + query: this.templateSrv.replace(query.query ?? '', rest), // The raw query text + alias: this.templateSrv.replace(query.alias ?? '', scopedVars), + limit: this.templateSrv.replace(query.limit?.toString() ?? '', scopedVars, 'regex'), + measurement: this.templateSrv.replace(query.measurement ?? '', scopedVars, 'regex'), + policy: this.templateSrv.replace(query.policy ?? '', scopedVars, 'regex'), + slimit: this.templateSrv.replace(query.slimit?.toString() ?? '', scopedVars, 'regex'), + tz: this.templateSrv.replace(query.tz ?? '', scopedVars), + }; } async metricFindQuery(query: string, options?: any): Promise { - if (this.isFlux || (config.featureToggles.influxdbBackendMigration && this.access === 'proxy')) { + if (this.isFlux || this.isMigrationToggleOnAndIsAccessProxy()) { const target: InfluxQuery = { refId: 'metricFindQuery', query, @@ -550,6 +553,33 @@ export default class InfluxDatasource extends DataSourceWithBackend { + if (!res || !res.data || res.state !== LoadingState.Done) { + return { + status: 'error', + message: 'Error reading InfluxDB.', + }; + } + if (res.data?.length) { + return { status: 'success', message: 'Data source is working.' }; + } + return { + status: 'error', + message: 'Successfully connected to InfluxDB, but no tags found.', + }; + }) + .catch((err: any) => { + return { status: 'error', message: err.message }; + }); + } + const queryBuilder = new InfluxQueryBuilder({ measurement: '', tags: [] }, this.database); const query = queryBuilder.buildExploreQuery('RETENTION POLICIES'); @@ -696,4 +726,8 @@ export default class InfluxDatasource extends DataSourceWithBackend { }); }); - describe('Interpolating query variables for dashboard->explore', () => { + describe('Variables should be interpolated correctly', () => { const templateSrv: any = { replace: jest.fn() }; const instanceSettings: any = {}; const ds = new InfluxDatasource(instanceSettings, templateSrv); const text = 'interpolationText'; + templateSrv.replace.mockReturnValue(text); - it('Should interpolate all variables', () => { - const query = { - refId: 'x', - measurement: '$interpolationVar', - policy: '$interpolationVar', - limit: '$interpolationVar', - slimit: '$interpolationVar', - tz: '$interpolationVar', - tags: [ - { - key: 'cpu', - operator: '=~', - value: '/^$interpolationVar$/', - }, - ], - groupBy: [ + const fluxQuery = { + refId: 'x', + query: '$interpolationVar', + }; + + const influxQuery = { + refId: 'x', + alias: '$interpolationVar', + measurement: '$interpolationVar', + policy: '$interpolationVar', + limit: '$interpolationVar', + slimit: '$interpolationVar', + tz: '$interpolationVar', + tags: [ + { + key: 'cpu', + operator: '=~', + value: '/^$interpolationVar$/', + }, + ], + groupBy: [ + { + params: ['$interpolationVar'], + type: 'tag', + }, + ], + select: [ + [ { params: ['$interpolationVar'], - type: 'tag', + type: 'field', }, ], - select: [ - [ - { - params: ['$interpolationVar'], - type: 'field', - }, - ], - ], - }; - templateSrv.replace.mockReturnValue(text); + ], + }; - const queries = ds.interpolateVariablesInQueries([query], { interpolationVar: { text: text, value: text } }); - expect(templateSrv.replace).toBeCalledTimes(8); - expect(queries[0].measurement).toBe(text); - expect(queries[0].policy).toBe(text); - expect(queries[0].limit).toBe(text); - expect(queries[0].slimit).toBe(text); - expect(queries[0].tz).toBe(text); - expect(queries[0].tags![0].value).toBe(text); - expect(queries[0].groupBy![0].params![0]).toBe(text); - expect(queries[0].select![0][0].params![0]).toBe(text); + function fluxChecks(query: any) { + expect(templateSrv.replace).toBeCalledTimes(1); + expect(query).toBe(text); + } + + function influxChecks(query: any) { + expect(templateSrv.replace).toBeCalledTimes(10); + expect(query.alias).toBe(text); + expect(query.measurement).toBe(text); + expect(query.policy).toBe(text); + expect(query.limit).toBe(text); + expect(query.slimit).toBe(text); + expect(query.tz).toBe(text); + expect(query.tags![0].value).toBe(text); + expect(query.groupBy![0].params![0]).toBe(text); + expect(query.select![0][0].params![0]).toBe(text); + } + + describe('when interpolating query variables for dashboard->explore', () => { + it('should interpolate all variables with Flux mode', () => { + ds.isFlux = true; + const queries = ds.interpolateVariablesInQueries([fluxQuery], { + interpolationVar: { text: text, value: text }, + }); + fluxChecks(queries[0].query); + }); + + it('should interpolate all variables with InfluxQL mode', () => { + ds.isFlux = false; + const queries = ds.interpolateVariablesInQueries([influxQuery], { + interpolationVar: { text: text, value: text }, + }); + influxChecks(queries[0]); + }); + }); + + describe('when interpolating template variables', () => { + it('should apply all template variables with Flux mode', () => { + ds.isFlux = true; + const query = ds.applyTemplateVariables(fluxQuery, { interpolationVar: { text: text, value: text } }); + fluxChecks(query.query); + }); + + it('should apply all template variables with InfluxQL mode', () => { + ds.isFlux = false; + ds.access = 'proxy'; + config.featureToggles.influxdbBackendMigration = true; + const query = ds.applyTemplateVariables(influxQuery, { interpolationVar: { text: text, value: text } }); + influxChecks(query); + }); }); }); }); diff --git a/public/app/plugins/datasource/jaeger/components/SearchForm.test.tsx b/public/app/plugins/datasource/jaeger/components/SearchForm.test.tsx new file mode 100644 index 00000000000..77d85ac8526 --- /dev/null +++ b/public/app/plugins/datasource/jaeger/components/SearchForm.test.tsx @@ -0,0 +1,154 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { createFetchResponse } from 'test/helpers/createFetchResponse'; +import { DataQueryRequest, DataSourceInstanceSettings, dateTime, PluginType } from '@grafana/data'; +import { of } from 'rxjs'; +import { JaegerDatasource, JaegerJsonData } from '../datasource'; +import { JaegerQuery } from '../types'; +import React from 'react'; +import SearchForm from './SearchForm'; +import { testResponse } from '../testResponse'; +import userEvent from '@testing-library/user-event'; + +describe('SearchForm', () => { + it('should call the `onChange` function on click of the Input', async () => { + const promise = Promise.resolve(); + const handleOnChange = jest.fn(() => promise); + const query = { + ...defaultQuery, + targets: [ + { + query: 'a/b', + refId: '1', + }, + ], + refId: '121314', + }; + const ds = { + async metadataRequest(url: string, params?: Record): Promise { + if (url === '/api/services') { + return Promise.resolve(['jaeger-query', 'service2', 'service3']); + } + }, + } as JaegerDatasource; + setupFetchMock({ data: [testResponse] }); + + render(); + + const asyncServiceSelect = await waitFor(() => screen.getByRole('combobox', { name: 'select-service-name' })); + expect(asyncServiceSelect).toBeInTheDocument(); + + userEvent.click(asyncServiceSelect); + + const jaegerService = await screen.findByText('jaeger-query'); + expect(jaegerService).toBeInTheDocument(); + }); + + it('should be able to select operation name if query.service exists', async () => { + const promise = Promise.resolve(); + const handleOnChange = jest.fn(() => promise); + const query2 = { + ...defaultQuery, + targets: [ + { + query: 'a/b', + refId: '1', + }, + ], + refId: '121314', + service: 'jaeger-query', + }; + setupFetchMock({ data: [testResponse] }); + + render(); + + const asyncOperationSelect2 = await waitFor(() => screen.getByRole('combobox', { name: 'select-operation-name' })); + expect(asyncOperationSelect2).toBeInTheDocument(); + }); +}); + +describe('SearchForm', () => { + it('should show loader if there is a delay fetching options', async () => { + const promise = Promise.resolve(); + const handleOnChange = jest.fn(() => { + setTimeout(() => { + return promise; + }, 3000); + }); + const query = { + ...defaultQuery, + targets: [ + { + query: 'a/b', + refId: '1', + }, + ], + refId: '121314', + service: 'jaeger-query', + }; + const ds = new JaegerDatasource(defaultSettings); + setupFetchMock({ data: [testResponse] }); + + render(); + + const asyncServiceSelect = screen.getByRole('combobox', { name: 'select-service-name' }); + userEvent.click(asyncServiceSelect); + const loader = screen.getByText('Loading options...'); + + expect(loader).toBeInTheDocument(); + await act(() => promise); + }); +}); + +function setupFetchMock(response: any, mock?: any) { + const defaultMock = () => mock ?? of(createFetchResponse(response)); + + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + fetchMock.mockImplementation(defaultMock); + return fetchMock; +} + +const defaultSettings: DataSourceInstanceSettings = { + id: 0, + uid: '0', + type: 'tracing', + name: 'jaeger', + url: 'http://grafana.com', + access: 'proxy', + meta: { + id: 'jaeger', + name: 'jaeger', + type: PluginType.datasource, + info: {} as any, + module: '', + baseUrl: '', + }, + jsonData: { + nodeGraph: { + enabled: true, + }, + }, +}; + +const defaultQuery: DataQueryRequest = { + requestId: '1', + dashboardId: 0, + interval: '0', + intervalMs: 10, + panelId: 0, + scopedVars: {}, + range: { + from: dateTime().subtract(1, 'h'), + to: dateTime(), + raw: { from: '1h', to: 'now' }, + }, + timezone: 'browser', + app: 'explore', + startTime: 0, + targets: [ + { + query: '12345', + refId: '1', + }, + ], +}; diff --git a/public/app/plugins/datasource/jaeger/components/SearchForm.tsx b/public/app/plugins/datasource/jaeger/components/SearchForm.tsx index 1c4e6de235b..c362314bbed 100644 --- a/public/app/plugins/datasource/jaeger/components/SearchForm.tsx +++ b/public/app/plugins/datasource/jaeger/components/SearchForm.tsx @@ -1,11 +1,14 @@ import { css } from '@emotion/css'; import { SelectableValue } from '@grafana/data'; -import { InlineField, InlineFieldRow, Input, Select } from '@grafana/ui'; -import React, { useEffect, useState } from 'react'; +import { AsyncSelect, InlineField, InlineFieldRow, Input } from '@grafana/ui'; +import React, { useCallback, useEffect, useState } from 'react'; import { JaegerDatasource } from '../datasource'; import { JaegerQuery } from '../types'; import { transformToLogfmt } from '../util'; import { AdvancedOptions } from './AdvancedOptions'; +import { dispatch } from 'app/store/store'; +import { notifyApp } from 'app/core/actions'; +import { createErrorNotification } from 'app/core/copy/appNotification'; type Props = { datasource: JaegerDatasource; @@ -22,69 +25,110 @@ const allOperationsOption: SelectableValue = { export function SearchForm({ datasource, query, onChange }: Props) { const [serviceOptions, setServiceOptions] = useState>>(); const [operationOptions, setOperationOptions] = useState>>(); + const [isLoading, setIsLoading] = useState<{ + services: boolean; + operations: boolean; + }>({ + services: false, + operations: false, + }); + + const loadServices = useCallback( + async (url: string, loaderOfType: string): Promise>> => { + setIsLoading((prevValue) => ({ ...prevValue, [loaderOfType]: true })); + + try { + const values: string[] | null = await datasource.metadataRequest(url); + if (!values) { + return [{ label: `No ${loaderOfType} found`, value: `No ${loaderOfType} found` }]; + } + + const serviceOptions: SelectableValue[] = values.sort().map((service) => ({ + label: service, + value: service, + })); + return serviceOptions; + } catch (error) { + dispatch(notifyApp(createErrorNotification('Error', error))); + return []; + } finally { + setIsLoading((prevValue) => ({ ...prevValue, [loaderOfType]: false })); + } + }, + [datasource] + ); useEffect(() => { const getServices = async () => { - const services = await loadServices({ - dataSource: datasource, - url: '/api/services', - notFoundLabel: 'No service found', - }); + const services = await loadServices('/api/services', 'services'); setServiceOptions(services); }; getServices(); - }, [datasource]); + }, [datasource, loadServices]); useEffect(() => { const getOperations = async () => { - const operations = await loadServices({ - dataSource: datasource, - url: `/api/services/${encodeURIComponent(query.service!)}/operations`, - notFoundLabel: 'No operation found', - }); + const operations = await loadServices( + `/api/services/${encodeURIComponent(query.service!)}/operations`, + 'operations' + ); setOperationOptions([allOperationsOption, ...operations]); }; if (query.service) { getOperations(); } - }, [datasource, query.service]); + }, [datasource, query.service, loadServices]); return (
    - + loadServices(`/api/services/${encodeURIComponent(query.service!)}/operations`, 'operations') + } + onOpenMenu={() => + loadServices(`/api/services/${encodeURIComponent(query.service!)}/operations`, 'operations') + } + isLoading={isLoading.operations} value={operationOptions?.find((v) => v.value === query.operation) || null} onChange={(v) => onChange({ ...query, - operation: v.value!, + operation: v?.value! || undefined, }) } menuPlacement="bottom" isClearable + defaultOptions + aria-label={'select-operation-name'} /> @@ -108,19 +152,4 @@ export function SearchForm({ datasource, query, onChange }: Props) { ); } -type Options = { dataSource: JaegerDatasource; url: string; notFoundLabel: string }; - -const loadServices = async ({ dataSource, url, notFoundLabel }: Options): Promise>> => { - const services: string[] | null = await dataSource.metadataRequest(url); - - if (!services) { - return [{ label: notFoundLabel, value: notFoundLabel }]; - } - - const serviceOptions: SelectableValue[] = services.sort().map((service) => ({ - label: service, - value: service, - })); - - return serviceOptions; -}; +export default SearchForm; diff --git a/public/app/plugins/datasource/jaeger/datasource.test.ts b/public/app/plugins/datasource/jaeger/datasource.test.ts index 9011f9f3895..6d7284f635c 100644 --- a/public/app/plugins/datasource/jaeger/datasource.test.ts +++ b/public/app/plugins/datasource/jaeger/datasource.test.ts @@ -159,6 +159,32 @@ describe('JaegerDatasource', () => { }); }); + it('should resolve templates in traceID', async () => { + const mock = setupFetchMock({ data: [testResponse] }); + const ds = new JaegerDatasource(defaultSettings, timeSrvStub); + + await lastValueFrom( + ds.query({ + ...defaultQuery, + scopedVars: { + $traceid: { + text: 'traceid', + value: '5311b0dd0ca8df3463df93c99cb805a6', + }, + }, + targets: [ + { + query: '$traceid', + refId: '1', + }, + ], + }) + ); + expect(mock).toBeCalledWith({ + url: `${defaultSettings.url}/api/traces/5311b0dd0ca8df3463df93c99cb805a6`, + }); + }); + it('should resolve templates in tags', async () => { const mock = setupFetchMock({ data: [testResponse] }); const ds = new JaegerDatasource(defaultSettings, timeSrvStub); diff --git a/public/app/plugins/datasource/jaeger/datasource.ts b/public/app/plugins/datasource/jaeger/datasource.ts index d95b2ace82e..83581fcc816 100644 --- a/public/app/plugins/datasource/jaeger/datasource.ts +++ b/public/app/plugins/datasource/jaeger/datasource.ts @@ -52,7 +52,9 @@ export class JaegerDatasource extends DataSourceApi } if (target.queryType !== 'search' && target.query) { - return this._request(`/api/traces/${encodeURIComponent(target.query)}`).pipe( + return this._request( + `/api/traces/${encodeURIComponent(getTemplateSrv().replace(target.query, options.scopedVars))}` + ).pipe( map((response) => { const traceData = response?.data?.data?.[0]; if (!traceData) { diff --git a/public/app/plugins/datasource/loki/backendResultTransformer.ts b/public/app/plugins/datasource/loki/backendResultTransformer.ts new file mode 100644 index 00000000000..5bd09ccb326 --- /dev/null +++ b/public/app/plugins/datasource/loki/backendResultTransformer.ts @@ -0,0 +1,99 @@ +import { DataQueryRequest, DataQueryResponse, DataFrame, isDataFrame, FieldType, QueryResultMeta } from '@grafana/data'; +import { LokiQuery, LokiQueryType } from './types'; +import { makeTableFrames } from './makeTableFrames'; +import { formatQuery, getHighlighterExpressionsFromQuery } from './query_utils'; + +function isMetricFrame(frame: DataFrame): boolean { + return frame.fields.every((field) => field.type === FieldType.time || field.type === FieldType.number); +} + +// returns a new frame, with meta merged with it's original meta +function setFrameMeta(frame: DataFrame, meta: QueryResultMeta): DataFrame { + const { meta: oldMeta, ...rest } = frame; + // meta maybe be undefined, we need to handle that + const newMeta = { ...oldMeta, ...meta }; + return { + ...rest, + meta: newMeta, + }; +} + +function processStreamsFrames(frames: DataFrame[], queryMap: Map): DataFrame[] { + return frames.map((frame) => { + const query = frame.refId !== undefined ? queryMap.get(frame.refId) : undefined; + const meta: QueryResultMeta = { + preferredVisualisationType: 'logs', + searchWords: query !== undefined ? getHighlighterExpressionsFromQuery(formatQuery(query.expr)) : undefined, + }; + return setFrameMeta(frame, meta); + }); +} + +function processMetricInstantFrames(frames: DataFrame[]): DataFrame[] { + return frames.length > 0 ? makeTableFrames(frames) : []; +} + +function processMetricRangeFrames(frames: DataFrame[]): DataFrame[] { + const meta: QueryResultMeta = { preferredVisualisationType: 'graph' }; + return frames.map((frame) => setFrameMeta(frame, meta)); +} + +// we split the frames into 3 groups, because we will handle +// each group slightly differently +function groupFrames( + frames: DataFrame[], + queryMap: Map +): { + streamsFrames: DataFrame[]; + metricInstantFrames: DataFrame[]; + metricRangeFrames: DataFrame[]; +} { + const streamsFrames: DataFrame[] = []; + const metricInstantFrames: DataFrame[] = []; + const metricRangeFrames: DataFrame[] = []; + + frames.forEach((frame) => { + if (!isMetricFrame(frame)) { + streamsFrames.push(frame); + } else { + const isInstantFrame = frame.refId != null && queryMap.get(frame.refId)?.queryType === LokiQueryType.Instant; + if (isInstantFrame) { + metricInstantFrames.push(frame); + } else { + metricRangeFrames.push(frame); + } + } + }); + + return { streamsFrames, metricInstantFrames, metricRangeFrames }; +} + +export function transformBackendResult( + response: DataQueryResponse, + request: DataQueryRequest +): DataQueryResponse { + const { data, ...rest } = response; + + // in the typescript type, data is an array of basically anything. + // we do know that they have to be dataframes, so we make a quick check, + // this way we can be sure, and also typescript is happy. + const dataFrames = data.map((d) => { + if (!isDataFrame(d)) { + throw new Error('transformation only supports dataframe responses'); + } + return d; + }); + + const queryMap = new Map(request.targets.map((query) => [query.refId, query])); + + const { streamsFrames, metricInstantFrames, metricRangeFrames } = groupFrames(dataFrames, queryMap); + + return { + ...rest, + data: [ + ...processMetricRangeFrames(metricRangeFrames), + ...processMetricInstantFrames(metricInstantFrames), + ...processStreamsFrames(streamsFrames, queryMap), + ], + }; +} diff --git a/public/app/plugins/datasource/loki/components/LokiOptionFields.tsx b/public/app/plugins/datasource/loki/components/LokiOptionFields.tsx index ae66ec73ab4..17854499e85 100644 --- a/public/app/plugins/datasource/loki/components/LokiOptionFields.tsx +++ b/public/app/plugins/datasource/loki/components/LokiOptionFields.tsx @@ -6,6 +6,7 @@ import { map } from 'lodash'; // Types import { InlineFormLabel, RadioButtonGroup, InlineField, Input, Select } from '@grafana/ui'; import { SelectableValue } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { LokiQuery, LokiQueryType } from '../types'; export interface LokiOptionFieldsProps { @@ -24,13 +25,16 @@ const queryTypeOptions: Array> = [ label: 'Instant', description: 'Run query against a single point in time. For this query, the "To" time is used.', }, - // { - // value: LokiQueryType.Stream, - // label: 'Stream', - // description: 'Run a query and keep sending results on an interval', - // }, ]; +if (config.featureToggles.lokiLive) { + queryTypeOptions.push({ + value: LokiQueryType.Stream, + label: 'Stream', + description: 'Run a query and keep sending results on an interval', + }); +} + export const DEFAULT_RESOLUTION: SelectableValue = { value: 1, label: '1/1', diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index a1bf47f4219..4136a4fc770 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -33,7 +33,7 @@ import { ScopedVars, TimeRange, } from '@grafana/data'; -import { BackendSrvRequest, FetchError, getBackendSrv, DataSourceWithBackend } from '@grafana/runtime'; +import { BackendSrvRequest, FetchError, getBackendSrv, config, DataSourceWithBackend } from '@grafana/runtime'; import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; import { addLabelToQuery } from './add_label_to_query'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; @@ -44,6 +44,7 @@ import { lokiStreamsToDataFrames, processRangeQueryResponse, } from './result_transformer'; +import { transformBackendResult } from './backendResultTransformer'; import { addParsedLabelToQuery, getNormalizedLokiQuery, queryHasPipeParser } from './query_utils'; import { @@ -62,7 +63,7 @@ import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContext import syntax from './syntax'; import { DEFAULT_RESOLUTION } from './components/LokiOptionFields'; import { queryLogsVolume } from 'app/core/logs_model'; -import config from 'app/core/config'; +import { doLokiChannelStream } from './streaming'; import { renderLegendFormat } from '../prometheus/legend'; export type RangeQueryOptions = DataQueryRequest | AnnotationQueryRequest; @@ -153,19 +154,7 @@ export class LokiDatasource ...this.getRangeScopedVars(request.range), }; - // if all these are true, run query through backend: - // - feature-flag is enabled - // - we are in explore-mode - // - for every query it is true that: - // - query is range query - // - and query is metric query - // - and query is not a log-volume-query (those need a custom http header) - const shouldRunBackendQuery = - config.featureToggles.lokiBackendMode && - request.app === CoreApp.Explore && - request.targets.every( - (query) => query.queryType === LokiQueryType.Range && isMetricsQuery(query.expr) && !query.volumeQuery - ); + const shouldRunBackendQuery = config.featureToggles.lokiBackendMode && request.app === CoreApp.Explore; if (shouldRunBackendQuery) { // we "fix" the loki queries to have `.queryType` and not have `.instant` and `.range` @@ -173,7 +162,7 @@ export class LokiDatasource ...request, targets: request.targets.map(getNormalizedLokiQuery), }; - return super.query(fixedRequest); + return super.query(fixedRequest).pipe(map((response) => transformBackendResult(response, fixedRequest))); } const filteredTargets = request.targets @@ -189,6 +178,12 @@ export class LokiDatasource for (const target of filteredTargets) { if (target.instant || target.queryType === LokiQueryType.Instant) { subQueries.push(this.runInstantQuery(target, request, filteredTargets.length)); + } else if ( + config.featureToggles.lokiLive && + target.queryType === LokiQueryType.Stream && + request.rangeRaw?.to === 'now' + ) { + subQueries.push(doLokiChannelStream(target, this, request)); } else { subQueries.push(this.runRangeQuery(target, request, filteredTargets.length)); } diff --git a/public/app/plugins/datasource/loki/makeTableFrames.test.ts b/public/app/plugins/datasource/loki/makeTableFrames.test.ts new file mode 100644 index 00000000000..ed3160f0da6 --- /dev/null +++ b/public/app/plugins/datasource/loki/makeTableFrames.test.ts @@ -0,0 +1,146 @@ +import { ArrayVector, DataFrame, FieldType } from '@grafana/data'; +import { makeTableFrames } from './makeTableFrames'; + +const frame1: DataFrame = { + name: 'frame1', + refId: 'A', + meta: { + executedQueryString: 'something1', + }, + fields: [ + { + name: 'Time', + type: FieldType.time, + config: {}, + values: new ArrayVector([1645029699311]), + }, + { + name: 'Value', + type: FieldType.number, + labels: { + level: 'error', + location: 'moon', + protocol: 'http', + }, + config: { + displayNameFromDS: '{level="error", location="moon", protocol="http"}', + }, + values: new ArrayVector([23]), + }, + ], + length: 1, +}; + +const frame2: DataFrame = { + name: 'frame1', + refId: 'A', + meta: { + executedQueryString: 'something1', + }, + fields: [ + { + name: 'Time', + type: FieldType.time, + config: {}, + values: new ArrayVector([1645029699311]), + }, + { + name: 'Value', + type: FieldType.number, + labels: { + level: 'info', + location: 'moon', + protocol: 'http', + }, + config: { + displayNameFromDS: '{level="info", location="moon", protocol="http"}', + }, + values: new ArrayVector([45]), + }, + ], + length: 1, +}; + +const frame3: DataFrame = { + name: 'frame1', + refId: 'B', + meta: { + executedQueryString: 'something1', + }, + fields: [ + { + name: 'Time', + type: FieldType.time, + config: {}, + values: new ArrayVector([1645029699311]), + }, + { + name: 'Value', + type: FieldType.number, + labels: { + level: 'error', + location: 'moon', + protocol: 'http', + }, + config: { + displayNameFromDS: '{level="error", location="moon", protocol="http"}', + }, + values: new ArrayVector([72]), + }, + ], + length: 1, +}; + +const outputSingle = [ + { + fields: [ + { config: {}, name: 'Time', type: 'time', values: new ArrayVector([1645029699311]) }, + { config: { filterable: true }, name: 'level', type: 'string', values: new ArrayVector(['error']) }, + { config: { filterable: true }, name: 'location', type: 'string', values: new ArrayVector(['moon']) }, + { config: { filterable: true }, name: 'protocol', type: 'string', values: new ArrayVector(['http']) }, + { config: {}, name: 'Value #A', type: 'number', values: new ArrayVector([23]) }, + ], + length: 1, + meta: { preferredVisualisationType: 'table' }, + refId: 'A', + }, +]; + +const outputMulti = [ + { + fields: [ + { config: {}, name: 'Time', type: 'time', values: new ArrayVector([1645029699311, 1645029699311]) }, + { config: { filterable: true }, name: 'level', type: 'string', values: new ArrayVector(['error', 'info']) }, + { config: { filterable: true }, name: 'location', type: 'string', values: new ArrayVector(['moon', 'moon']) }, + { config: { filterable: true }, name: 'protocol', type: 'string', values: new ArrayVector(['http', 'http']) }, + { config: {}, name: 'Value #A', type: 'number', values: new ArrayVector([23, 45]) }, + ], + length: 2, + meta: { preferredVisualisationType: 'table' }, + refId: 'A', + }, + { + fields: [ + { config: {}, name: 'Time', type: 'time', values: new ArrayVector([1645029699311]) }, + { config: { filterable: true }, name: 'level', type: 'string', values: new ArrayVector(['error']) }, + { config: { filterable: true }, name: 'location', type: 'string', values: new ArrayVector(['moon']) }, + { config: { filterable: true }, name: 'protocol', type: 'string', values: new ArrayVector(['http']) }, + { config: {}, name: 'Value #B', type: 'number', values: new ArrayVector([72]) }, + ], + length: 1, + meta: { preferredVisualisationType: 'table' }, + refId: 'B', + }, +]; + +describe('loki backendResultTransformer', () => { + it('converts a single instant metric dataframe to table dataframe', () => { + const result = makeTableFrames([frame1]); + expect(result).toEqual(outputSingle); + }); + + it('converts 3 instant metric dataframes into 2 tables', () => { + const result = makeTableFrames([frame1, frame2, frame3]); + expect(result).toEqual(outputMulti); + }); +}); diff --git a/public/app/plugins/datasource/loki/makeTableFrames.ts b/public/app/plugins/datasource/loki/makeTableFrames.ts new file mode 100644 index 00000000000..ac0fd971cfa --- /dev/null +++ b/public/app/plugins/datasource/loki/makeTableFrames.ts @@ -0,0 +1,75 @@ +import { DataFrame, Field, FieldType, ArrayVector } from '@grafana/data'; +import { groupBy } from 'lodash'; + +export function makeTableFrames(instantMetricFrames: DataFrame[]): DataFrame[] { + // first we remove frames that have no refId + // (we will group them by refId, so we need it to be set) + const framesWithRefId = instantMetricFrames.filter((f) => f.refId !== undefined); + + const framesByRefId = groupBy(framesWithRefId, (frame) => frame.refId); + + return Object.entries(framesByRefId).map(([refId, frames]) => makeTableFrame(frames, refId)); +} + +type NumberField = Field>; +type StringField = Field>; + +function makeTableFrame(instantMetricFrames: DataFrame[], refId: string): DataFrame { + const tableTimeField: NumberField = { name: 'Time', config: {}, values: new ArrayVector(), type: FieldType.time }; + const tableValueField: NumberField = { + name: `Value #${refId}`, + config: {}, + values: new ArrayVector(), + type: FieldType.number, + }; + + // Sort metric labels, create columns for them and record their index + const allLabelNames = new Set( + instantMetricFrames.map((frame) => frame.fields.map((field) => Object.keys(field.labels ?? {})).flat()).flat() + ); + + const sortedLabelNames = Array.from(allLabelNames).sort(); + + const labelFields: StringField[] = sortedLabelNames.map((labelName) => ({ + name: labelName, + config: { filterable: true }, + values: new ArrayVector(), + type: FieldType.string, + })); + + instantMetricFrames.forEach((frame) => { + const timeField = frame.fields.find((field) => field.type === FieldType.time); + const valueField = frame.fields.find((field) => field.type === FieldType.number); + if (timeField == null || valueField == null) { + return; + } + + const timeArray = timeField.values.toArray(); + const valueArray = valueField.values.toArray(); + + for (let x of timeArray) { + tableTimeField.values.add(x); + } + + for (let x of valueArray) { + tableValueField.values.add(x); + } + + const labels = valueField.labels ?? {}; + + for (let f of labelFields) { + const text = labels[f.name] ?? ''; + // we insert the labels as many times as we have values + for (let i = 0; i < valueArray.length; i++) { + f.values.add(text); + } + } + }); + + return { + fields: [tableTimeField, ...labelFields, tableValueField], + refId, + meta: { preferredVisualisationType: 'table' }, + length: tableTimeField.values.length, + }; +} diff --git a/public/app/plugins/datasource/loki/querybuilder/operations.ts b/public/app/plugins/datasource/loki/querybuilder/operations.ts index 06e6c7cd499..8efb0c4868a 100644 --- a/public/app/plugins/datasource/loki/querybuilder/operations.ts +++ b/public/app/plugins/datasource/loki/querybuilder/operations.ts @@ -134,7 +134,7 @@ function createRangeOperation(name: string): QueryBuilderOperationDef { id: name, name: getPromAndLokiOperationDisplayName(name), params: [getRangeVectorParamDef()], - defaultParams: ['auto'], + defaultParams: ['$__interval'], alternativesKey: 'range function', category: LokiVisualQueryOperationCategory.RangeFunctions, renderer: operationWithRangeVectorRenderer, @@ -142,7 +142,7 @@ function createRangeOperation(name: string): QueryBuilderOperationDef { explainHandler: (op, def) => { let opDocs = FUNCTIONS.find((x) => x.insertText === op.id)?.documentation ?? ''; - if (op.params[0] === 'auto' || op.params[0] === '$__interval') { + if (op.params[0] === '$__interval') { return `${opDocs} \`$__interval\` is variable that will be replaced with a calculated interval based on **Max data points**, **Min interval** and query time range. You find these options you find under **Query options** at the right of the data source select dropdown.`; } else { return `${opDocs} The [range vector](https://grafana.com/docs/loki/latest/logql/metric_queries/#range-vector-aggregation) is set to \`${op.params[0]}\`.`; @@ -170,9 +170,9 @@ function createAggregationOperation(name: string): QueryBuilderOperationDef { function getRangeVectorParamDef(): QueryBuilderOperationParamDef { return { - name: 'Range vector', + name: 'Range', type: 'string', - options: ['auto', '$__interval', '$__range', '1m', '5m', '10m', '1h', '24h'], + options: ['$__interval', '$__range', '1m', '5m', '10m', '1h', '24h'], }; } @@ -181,12 +181,7 @@ function operationWithRangeVectorRenderer( def: QueryBuilderOperationDef, innerExpr: string ) { - let rangeVector = (model.params ?? [])[0] ?? 'auto'; - - if (rangeVector === 'auto') { - rangeVector = '$__interval'; - } - + let rangeVector = (model.params ?? [])[0] ?? '$__interval'; return `${def.id}(${innerExpr} [${rangeVector}])`; } diff --git a/public/app/plugins/datasource/loki/streaming.ts b/public/app/plugins/datasource/loki/streaming.ts new file mode 100644 index 00000000000..08655ef76fd --- /dev/null +++ b/public/app/plugins/datasource/loki/streaming.ts @@ -0,0 +1,80 @@ +import { DataFrameJSON, DataQueryRequest, DataQueryResponse, LiveChannelScope, LoadingState } from '@grafana/data'; +import { getGrafanaLiveSrv } from '@grafana/runtime'; +import { map, Observable, defer, mergeMap } from 'rxjs'; +import LokiDatasource from './datasource'; +import { LokiQuery } from './types'; +import { StreamingDataFrame } from 'app/features/live/data/StreamingDataFrame'; + +/** + * Calculate a unique key for the query. The key is used to pick a channel and should + * be unique for each distinct query execution plan. This key is not secure and is only picked to avoid + * possible collisions + */ +export async function getLiveStreamKey(query: LokiQuery): Promise { + const str = JSON.stringify({ expr: query.expr }); + + const msgUint8 = new TextEncoder().encode(str); // encode as (utf-8) Uint8Array + const hashBuffer = await crypto.subtle.digest('SHA-1', msgUint8); // hash the message + const hashArray = Array.from(new Uint8Array(hashBuffer.slice(0, 8))); // first 8 bytes + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +// This will get both v1 and v2 result formats +export function doLokiChannelStream( + query: LokiQuery, + ds: LokiDatasource, + options: DataQueryRequest +): Observable { + // maximum time to keep values + const range = options.range; + const maxDelta = range.to.valueOf() - range.from.valueOf() + 1000; + let maxLength = options.maxDataPoints ?? 1000; + if (maxLength > 100) { + // for small buffers, keep them small + maxLength *= 2; + } + + let frame: StreamingDataFrame | undefined = undefined; + const updateFrame = (msg: any) => { + if (msg?.message) { + const p = msg.message as DataFrameJSON; + if (!frame) { + frame = StreamingDataFrame.fromDataFrameJSON(p, { + maxLength, + maxDelta, + displayNameFormat: query.legendFormat, + }); + } else { + frame.push(p); + } + } + return frame; + }; + + return defer(() => getLiveStreamKey(query)).pipe( + mergeMap((key) => { + return getGrafanaLiveSrv() + .getStream({ + scope: LiveChannelScope.DataSource, + namespace: ds.uid, + path: `tail/${key}`, + data: { + ...query, + timeRange: { + from: range.from.valueOf().toString(), + to: range.to.valueOf().toString(), + }, + }, + }) + .pipe( + map((evt) => { + const frame = updateFrame(evt); + return { + data: frame ? [frame] : [], + state: LoadingState.Streaming, + }; + }) + ); + }) + ); +} diff --git a/public/app/plugins/datasource/loki/syntax.ts b/public/app/plugins/datasource/loki/syntax.ts index abb521b02e5..f347c4699b6 100644 --- a/public/app/plugins/datasource/loki/syntax.ts +++ b/public/app/plugins/datasource/loki/syntax.ts @@ -239,9 +239,19 @@ export const lokiGrammar: Grammar = { }, }, ], + quote: { + pattern: /"(?:\\.|[^\\"])*"/, + alias: 'string', + greedy: true, + }, + backticks: { + pattern: /`(?:\\.|[^\\`])*`/, + alias: 'string', + greedy: true, + }, number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/, operator: /\s?(\|[=~]?|!=?|<(?:=>?|<|>)?|>[>=]?)\s?/i, - punctuation: /[{}()`,.]/, + punctuation: /[{}(),.]/, }; export default lokiGrammar; diff --git a/public/app/plugins/datasource/loki/types.ts b/public/app/plugins/datasource/loki/types.ts index 35e37bdad8f..c72954e7c38 100644 --- a/public/app/plugins/datasource/loki/types.ts +++ b/public/app/plugins/datasource/loki/types.ts @@ -27,7 +27,7 @@ export enum LokiResultType { export enum LokiQueryType { Range = 'range', Instant = 'instant', - // Stream = 'stream', + Stream = 'stream', } export interface LokiQuery extends DataQuery { diff --git a/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/situation.ts b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/situation.ts index cef1009353f..8b37ca2cd08 100644 --- a/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/situation.ts +++ b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/situation.ts @@ -1,5 +1,5 @@ import { parser } from 'lezer-promql'; -import type { Tree, SyntaxNode } from 'lezer-tree'; +import type { Tree, SyntaxNode } from '@lezer/common'; import { NeverCaseError } from './util'; type Direction = 'parent' | 'firstChild' | 'lastChild' | 'nextSibling'; diff --git a/public/app/plugins/datasource/prometheus/language_provider.test.ts b/public/app/plugins/datasource/prometheus/language_provider.test.ts index 950021b60fc..a9adfcd8950 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.test.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.test.ts @@ -382,6 +382,7 @@ describe('Language completion provider', () => { }); it('returns a refresher on label context and unavailable metric', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); const instance = new LanguageProvider(datasource); const value = Plain.deserialize('metric{}'); const ed = new SlateEditor({ value }); @@ -394,6 +395,7 @@ describe('Language completion provider', () => { }); expect(result.context).toBeUndefined(); expect(result.suggestions).toEqual([]); + expect(console.warn).toHaveBeenCalledWith('Server did not return any values for selector = {__name__="metric"}'); }); it('returns label values on label context when given a metric and a label key', async () => { @@ -598,6 +600,7 @@ describe('Language completion provider', () => { }); describe('disabled metrics lookup', () => { it('does not issue any metadata requests when lookup is disabled', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); const datasource: PrometheusDatasource = { metadataRequest: jest.fn(() => ({ data: { data: ['foo', 'bar'] as string[] } })), getTimeRangeParams: jest.fn(() => ({ start: '0', end: '1' })), @@ -619,6 +622,7 @@ describe('Language completion provider', () => { expect((datasource.metadataRequest as Mock).mock.calls.length).toBe(0); await instance.provideCompletionItems(args); expect((datasource.metadataRequest as Mock).mock.calls.length).toBe(0); + expect(console.warn).toHaveBeenCalledWith('Server did not return any values for selector = {}'); }); it('issues metadata requests when lookup is not disabled', async () => { const datasource: PrometheusDatasource = { diff --git a/public/app/plugins/datasource/prometheus/promql.ts b/public/app/plugins/datasource/prometheus/promql.ts index ae817cca80c..48bc2361ff7 100644 --- a/public/app/plugins/datasource/prometheus/promql.ts +++ b/public/app/plugins/datasource/prometheus/promql.ts @@ -18,6 +18,81 @@ export const RATE_RANGES: CompletionItem[] = [ export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without']; export const LOGICAL_OPERATORS = ['or', 'and', 'unless']; +const TRIGONOMETRIC_FUNCTIONS: CompletionItem[] = [ + { + label: 'acos', + insertText: 'acos', + detail: 'acos(v instant-vector)', + documentation: 'calculates the arccosine of all elements in v', + }, + { + label: 'acosh', + insertText: 'acosh', + detail: 'acosh(v instant-vector)', + documentation: 'calculates the inverse hyperbolic cosine of all elements in v', + }, + { + label: 'asin', + insertText: 'asin', + detail: 'asin(v instant-vector)', + documentation: 'calculates the arcsine of all elements in v', + }, + { + label: 'asinh', + insertText: 'asinh', + detail: 'asinh(v instant-vector)', + documentation: 'calculates the inverse hyperbolic sine of all elements in v', + }, + { + label: 'atan', + insertText: 'atan', + detail: 'atan(v instant-vector)', + documentation: 'calculates the arctangent of all elements in v', + }, + { + label: 'atanh', + insertText: 'atanh', + detail: 'atanh(v instant-vector)', + documentation: 'calculates the inverse hyperbolic tangent of all elements in v', + }, + { + label: 'cos', + insertText: 'cos', + detail: 'cos(v instant-vector)', + documentation: 'calculates the cosine of all elements in v', + }, + { + label: 'cosh', + insertText: 'cosh', + detail: 'cosh(v instant-vector)', + documentation: 'calculates the hyperbolic cosine of all elements in v', + }, + { + label: 'sin', + insertText: 'sin', + detail: 'sin(v instant-vector)', + documentation: 'calculates the sine of all elements in v', + }, + { + label: 'sinh', + insertText: 'sinh', + detail: 'sinh(v instant-vector)', + documentation: 'calculates the hyperbolic sine of all elements in v', + }, + { + label: 'tan', + insertText: 'tan', + detail: 'tan(v instant-vector)', + documentation: 'calculates the tangent of all elements in v', + }, + { + label: 'tanh', + insertText: 'tanh', + detail: 'tanh(v instant-vector)', + documentation: 'calculates the hyperbolic tangent of all elements in v', + }, +]; + const AGGREGATION_OPERATORS: CompletionItem[] = [ { label: 'sum', @@ -83,6 +158,7 @@ const AGGREGATION_OPERATORS: CompletionItem[] = [ export const FUNCTIONS = [ ...AGGREGATION_OPERATORS, + ...TRIGONOMETRIC_FUNCTIONS, { insertText: 'abs', label: 'abs', @@ -142,6 +218,12 @@ export const FUNCTIONS = [ documentation: 'Returns the number of elements in a time series vector as a scalar. This is in contrast to the `count()` aggregation operator, which always returns a vector (an empty one if the input vector is empty) and allows grouping by labels via a `by` clause.', }, + { + insertText: 'deg', + label: 'deg', + detail: 'deg(v instant-vector)', + documentation: 'Converts radians to degrees for all elements in v', + }, { insertText: 'day_of_month', label: 'day_of_month', @@ -286,6 +368,12 @@ export const FUNCTIONS = [ documentation: 'Returns the month of the year for each of the given times in UTC. Returned values are from 1 to 12, where 1 means January etc.', }, + { + insertText: 'pi', + label: 'pi', + detail: 'pi()', + documentation: 'Returns pi', + }, { insertText: 'predict_linear', label: 'predict_linear', @@ -293,6 +381,12 @@ export const FUNCTIONS = [ documentation: 'Predicts the value of time series `t` seconds from now, based on the range vector `v`, using simple linear regression.', }, + { + insertText: 'rad', + label: 'rad', + detail: 'rad(v instant-vector)', + documentation: 'Converts degrees to radians for all elements in v', + }, { insertText: 'rate', label: 'rate', diff --git a/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts index 1c2e18f8600..cdf8c5e4a50 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts @@ -105,7 +105,7 @@ describe('PromQueryModeller', () => { modeller.renderQuery({ metric: 'metric', labels: [{ label: 'pod', op: '=', value: 'A' }], - operations: [{ id: PromOperationId.Rate, params: ['auto'] }], + operations: [{ id: PromOperationId.Rate, params: ['$__rate_interval'] }], }) ).toBe('rate(metric{pod="A"}[$__rate_interval])'); }); @@ -115,9 +115,9 @@ describe('PromQueryModeller', () => { modeller.renderQuery({ metric: 'metric', labels: [{ label: 'pod', op: '=', value: 'A' }], - operations: [{ id: PromOperationId.Increase, params: ['auto'] }], + operations: [{ id: PromOperationId.Increase, params: ['$__interval'] }], }) - ).toBe('increase(metric{pod="A"}[$__rate_interval])'); + ).toBe('increase(metric{pod="A"}[$__interval])'); }); it('Can render rate with custom range-vector', () => { @@ -283,18 +283,18 @@ describe('PromQueryModeller', () => { modeller.renderQuery({ metric: 'metric_a', labels: [], - operations: [{ id: 'holt_winters', params: ['auto', 0.5, 0.5] }], + operations: [{ id: 'holt_winters', params: ['5m', 0.5, 0.5] }], }) - ).toBe('holt_winters(metric_a[$__rate_interval], 0.5, 0.5)'); + ).toBe('holt_winters(metric_a[5m], 0.5, 0.5)'); }); it('Can render functions that require parameters left of a range', () => { expect( modeller.renderQuery({ metric: 'metric_a', labels: [], - operations: [{ id: 'quantile_over_time', params: ['auto', 1] }], + operations: [{ id: 'quantile_over_time', params: ['5m', 1] }], }) - ).toBe('quantile_over_time(1, metric_a[$__rate_interval])'); + ).toBe('quantile_over_time(1, metric_a[5m])'); }); it('Can render the label_join function', () => { expect( diff --git a/public/app/plugins/datasource/prometheus/querybuilder/aggregations.ts b/public/app/plugins/datasource/prometheus/querybuilder/aggregations.ts index 4f0efddeeb9..6fc94bbc85e 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/aggregations.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/aggregations.ts @@ -5,6 +5,7 @@ import { defaultAddOperationHandler, functionRendererLeft, getPromAndLokiOperationDisplayName, + getRangeVectorParamDef, } from './shared/operationUtils'; import { QueryBuilderOperation, QueryBuilderOperationDef, QueryBuilderOperationParamDef } from './shared/types'; import { PromVisualQueryOperationCategory, PromOperationId } from './types'; @@ -25,6 +26,7 @@ export function getAggregationOperations(): QueryBuilderOperationDef[] { createAggregationOverTime(PromOperationId.CountOverTime), createAggregationOverTime(PromOperationId.LastOverTime), createAggregationOverTime(PromOperationId.PresentOverTime), + createAggregationOverTime(PromOperationId.AbsentOverTime), createAggregationOverTime(PromOperationId.StddevOverTime), ]; } @@ -175,8 +177,8 @@ function createAggregationOverTime(name: string): QueryBuilderOperationDef { return { id: name, name: getPromAndLokiOperationDisplayName(name), - params: [getAggregationOverTimeRangeVector()], - defaultParams: ['auto'], + params: [getRangeVectorParamDef()], + defaultParams: ['$__interval'], alternativesKey: 'overtime function', category: PromVisualQueryOperationCategory.RangeFunctions, renderer: operationWithRangeVectorRenderer, @@ -184,24 +186,11 @@ function createAggregationOverTime(name: string): QueryBuilderOperationDef { }; } -function getAggregationOverTimeRangeVector(): QueryBuilderOperationParamDef { - return { - name: 'Range vector', - type: 'string', - options: ['auto', '$__interval', '$__range', '1m', '5m', '10m', '1h', '24h'], - }; -} - function operationWithRangeVectorRenderer( model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string ) { - let rangeVector = (model.params ?? [])[0] ?? 'auto'; - - if (rangeVector === 'auto') { - rangeVector = '$__interval'; - } - + let rangeVector = (model.params ?? [])[0] ?? '$__interval'; return `${def.id}(${innerExpr}[${rangeVector}])`; } diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/NestedQuery.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/NestedQuery.tsx index 46f0e3cd5d0..5a7b92dcfa1 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/NestedQuery.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/NestedQuery.tsx @@ -1,9 +1,10 @@ import { css } from '@emotion/css'; import { GrafanaTheme2, toOption } from '@grafana/data'; import { EditorRows, FlexItem } from '@grafana/experimental'; -import { IconButton, Input, Select, useStyles2 } from '@grafana/ui'; +import { IconButton, Select, useStyles2 } from '@grafana/ui'; import React from 'react'; import { PrometheusDatasource } from '../../datasource'; +import { AutoSizeInput } from '../shared/AutoSizeInput'; import { PromVisualQueryBinary } from '../types'; import { PromQueryBuilder } from './PromQueryBuilder'; @@ -36,10 +37,10 @@ export const NestedQuery = React.memo(({ nestedQuery, index, datasource, />
    Vector matches
    - { + onCommitChange={(evt) => { onChange(index, { ...nestedQuery, vectorMatches: evt.currentTarget.value, diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx index ac6b5132951..dcc03f3e4aa 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { PromQuery } from '../../types'; -import { getQueryWithDefaults } from '../types'; +import { getQueryWithDefaults } from '../state'; import { CoreApp } from '@grafana/data'; import { PromQueryBuilderOptions } from './PromQueryBuilderOptions'; import { selectOptionInTest } from '@grafana/ui'; @@ -59,13 +59,19 @@ describe('PromQueryBuilderOptions', () => { legendFormat: '{{label_name}}', }); }); + + it('Handle defaults with undefined range', async () => { + setup(getQueryWithDefaults({ refId: 'A', expr: '', range: undefined, instant: true }, CoreApp.Dashboard)); + + expect(screen.getByText('Type: Instant')).toBeInTheDocument(); + }); }); function setup(queryOverrides: Partial = {}) { const props = { query: { ...getQueryWithDefaults({ refId: 'A' } as PromQuery, CoreApp.PanelEditor), - queryOverrides, + ...queryOverrides, }, onRunQuery: jest.fn(), onChange: jest.fn(), diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx index 33bcc9ae8e4..a9f63a62f57 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx @@ -1,12 +1,13 @@ import React, { SyntheticEvent } from 'react'; import { EditorRow, EditorField } from '@grafana/experimental'; import { CoreApp, SelectableValue } from '@grafana/data'; -import { Input, RadioButtonGroup, Select, Switch } from '@grafana/ui'; +import { RadioButtonGroup, Select, Switch } from '@grafana/ui'; import { QueryOptionGroup } from '../shared/QueryOptionGroup'; import { PromQuery } from '../../types'; import { FORMAT_OPTIONS, INTERVAL_FACTOR_OPTIONS } from '../../components/PromQueryEditor'; import { getQueryTypeChangeHandler, getQueryTypeOptions } from '../../components/PromExploreExtraField'; import { getLegendModeLabel, PromQueryLegendEditor } from './PromQueryLegendEditor'; +import { AutoSizeInput } from '../shared/AutoSizeInput'; export interface Props { query: PromQuery; @@ -21,7 +22,7 @@ export const PromQueryBuilderOptions = React.memo(({ query, app, onChange onRunQuery(); }; - const onChangeStep = (evt: React.FocusEvent) => { + const onChangeStep = (evt: React.FormEvent) => { onChange({ ...query, interval: evt.currentTarget.value }); onRunQuery(); }; @@ -57,12 +58,12 @@ export const PromQueryBuilderOptions = React.memo(({ query, app, onChange } > - diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx index 2062f6a71e4..49a98f1b025 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx @@ -13,7 +13,7 @@ import { buildVisualQueryFromString } from '../parsing'; import { PromQueryCodeEditor } from './PromQueryCodeEditor'; import { PromQueryBuilderContainer } from './PromQueryBuilderContainer'; import { PromQueryBuilderOptions } from './PromQueryBuilderOptions'; -import { getQueryWithDefaults } from '../types'; +import { changeEditorMode, getQueryWithDefaults } from '../state'; export const PromQueryEditorSelector = React.memo((props) => { const { onChange, onRunQuery, data } = props; @@ -24,7 +24,6 @@ export const PromQueryEditorSelector = React.memo((props) const onEditorModeChange = useCallback( (newMetricEditorMode: QueryEditorMode) => { - const change = { ...query, editorMode: newMetricEditorMode }; if (newMetricEditorMode === QueryEditorMode.Builder) { const result = buildVisualQueryFromString(query.expr || ''); // If there are errors, give user a chance to decide if they want to go to builder as that can loose some data. @@ -33,7 +32,7 @@ export const PromQueryEditorSelector = React.memo((props) return; } } - onChange(change); + changeEditorMode(query, newMetricEditorMode, onChange); }, [onChange, query] ); @@ -52,7 +51,7 @@ export const PromQueryEditorSelector = React.memo((props) body="There were errors while trying to parse the query. Continuing to visual builder may loose some parts of the query." confirmText="Continue" onConfirm={() => { - onChange({ ...query, editorMode: QueryEditorMode.Builder }); + changeEditorMode(query, QueryEditorMode.Builder, onChange); setParseModalOpen(false); }} onDismiss={() => setParseModalOpen(false)} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryLegendEditor.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryLegendEditor.tsx index 3a9ba54a9a4..f5c920a0a09 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryLegendEditor.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryLegendEditor.tsx @@ -1,8 +1,9 @@ import React, { useRef } from 'react'; import { EditorField } from '@grafana/experimental'; import { SelectableValue } from '@grafana/data'; -import { Input, Select } from '@grafana/ui'; +import { Select } from '@grafana/ui'; import { LegendFormatMode, PromQuery } from '../../types'; +import { AutoSizeInput } from '../shared/AutoSizeInput'; export interface Props { query: PromQuery; @@ -27,7 +28,7 @@ export const PromQueryLegendEditor = React.memo(({ query, onChange, onRun const mode = getLegendMode(query.legendFormat); const inputRef = useRef(null); - const onLegendFormatChanged = (evt: React.FocusEvent) => { + const onLegendFormatChanged = (evt: React.FormEvent) => { let legendFormat = evt.currentTarget.value; if (legendFormat.length === 0) { legendFormat = LegendFormatMode.Auto; @@ -62,12 +63,12 @@ export const PromQueryLegendEditor = React.memo(({ query, onChange, onRun > <> {mode === LegendFormatMode.Custom && ( - )} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/operations.ts b/public/app/plugins/datasource/prometheus/querybuilder/operations.ts index d1f6ecf22cf..59a037e4f22 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/operations.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/operations.ts @@ -4,13 +4,13 @@ import { functionRendererLeft, functionRendererRight, getPromAndLokiOperationDisplayName, + getRangeVectorParamDef, rangeRendererLeftWithParams, rangeRendererRightWithParams, } from './shared/operationUtils'; import { QueryBuilderOperation, QueryBuilderOperationDef, - QueryBuilderOperationParamDef, QueryWithOperations, VisualQueryModeller, } from './shared/types'; @@ -51,10 +51,45 @@ export function getOperationDefinitions(): QueryBuilderOperationDef[] { addOperationHandler: defaultAddOperationHandler, }, createRangeFunction(PromOperationId.Changes), - createRangeFunction(PromOperationId.Rate), + createRangeFunction(PromOperationId.Rate, true), createRangeFunction(PromOperationId.Irate), - createRangeFunction(PromOperationId.Increase), + createRangeFunction(PromOperationId.Increase, true), + createRangeFunction(PromOperationId.Idelta), createRangeFunction(PromOperationId.Delta), + createFunction({ + id: PromOperationId.HoltWinters, + params: [ + getRangeVectorParamDef(), + { name: 'Smoothing Factor', type: 'number' }, + { name: 'Trend Factor', type: 'number' }, + ], + defaultParams: ['$__interval', 0.5, 0.5], + alternativesKey: 'range function', + category: PromVisualQueryOperationCategory.RangeFunctions, + renderer: rangeRendererRightWithParams, + addOperationHandler: addOperationWithRangeVector, + changeTypeHandler: operationTypeChangedHandlerForRangeFunction, + }), + createFunction({ + id: PromOperationId.PredictLinear, + params: [getRangeVectorParamDef(), { name: 'Seconds from now', type: 'number' }], + defaultParams: ['$__interval', 60], + alternativesKey: 'range function', + category: PromVisualQueryOperationCategory.RangeFunctions, + renderer: rangeRendererRightWithParams, + addOperationHandler: addOperationWithRangeVector, + changeTypeHandler: operationTypeChangedHandlerForRangeFunction, + }), + createFunction({ + id: PromOperationId.QuantileOverTime, + params: [getRangeVectorParamDef(), { name: 'Quantile', type: 'number' }], + defaultParams: ['$__interval', 0.5], + alternativesKey: 'overtime function', + category: PromVisualQueryOperationCategory.RangeFunctions, + renderer: rangeRendererLeftWithParams, + addOperationHandler: addOperationWithRangeVector, + changeTypeHandler: operationTypeChangedHandlerForRangeFunction, + }), // Not sure about this one. It could also be a more generic 'Simple math operation' where user specifies // both the operator and the operand in a single input { @@ -85,7 +120,6 @@ export function getOperationDefinitions(): QueryBuilderOperationDef[] { addOperationHandler: addNestedQueryHandler, }, createFunction({ id: PromOperationId.Absent }), - createRangeFunction(PromOperationId.AbsentOverTime), createFunction({ id: PromOperationId.Acos, category: PromVisualQueryOperationCategory.Trigonometric, @@ -163,20 +197,7 @@ export function getOperationDefinitions(): QueryBuilderOperationDef[] { createFunction({ id: PromOperationId.Exp }), createFunction({ id: PromOperationId.Floor }), createFunction({ id: PromOperationId.Group }), - createFunction({ - id: PromOperationId.HoltWinters, - params: [ - getRangeVectorParamDef(), - { name: 'Smoothing Factor', type: 'number' }, - { name: 'Trend Factor', type: 'number' }, - ], - defaultParams: ['auto', 0.5, 0.5], - alternativesKey: 'range function', - category: PromVisualQueryOperationCategory.RangeFunctions, - renderer: rangeRendererRightWithParams, - }), createFunction({ id: PromOperationId.Hour }), - createRangeFunction(PromOperationId.Idelta), createFunction({ id: PromOperationId.LabelJoin, params: [ @@ -209,28 +230,12 @@ export function getOperationDefinitions(): QueryBuilderOperationDef[] { id: PromOperationId.Pi, renderer: (model) => `${model.id}()`, }), - createFunction({ - id: PromOperationId.PredictLinear, - params: [getRangeVectorParamDef(), { name: 'Seconds from now', type: 'number' }], - defaultParams: ['auto', 60], - alternativesKey: 'range function', - category: PromVisualQueryOperationCategory.RangeFunctions, - renderer: rangeRendererRightWithParams, - }), createFunction({ id: PromOperationId.Quantile, params: [{ name: 'Value', type: 'number' }], defaultParams: [1], renderer: functionRendererLeft, }), - createFunction({ - id: PromOperationId.QuantileOverTime, - params: [getRangeVectorParamDef(), { name: 'Quantile', type: 'number' }], - defaultParams: ['auto', 0.5], - alternativesKey: 'range function', - category: PromVisualQueryOperationCategory.RangeFunctions, - renderer: rangeRendererLeftWithParams, - }), createFunction({ id: PromOperationId.Rad }), createRangeFunction(PromOperationId.Resets), createFunction({ @@ -288,30 +293,40 @@ export function createFunction(definition: Partial): Q }; } -export function createRangeFunction(name: string): QueryBuilderOperationDef { +export function createRangeFunction(name: string, withRateInterval = false): QueryBuilderOperationDef { return { id: name, name: getPromAndLokiOperationDisplayName(name), - params: [getRangeVectorParamDef()], - defaultParams: ['auto'], + params: [getRangeVectorParamDef(withRateInterval)], + defaultParams: [withRateInterval ? '$__rate_interval' : '$__interval'], alternativesKey: 'range function', category: PromVisualQueryOperationCategory.RangeFunctions, renderer: operationWithRangeVectorRenderer, addOperationHandler: addOperationWithRangeVector, + changeTypeHandler: operationTypeChangedHandlerForRangeFunction, }; } +function operationTypeChangedHandlerForRangeFunction( + operation: QueryBuilderOperation, + newDef: QueryBuilderOperationDef +) { + // validate current parameter + if (operation.params[0] === '$__rate_interval' && newDef.defaultParams[0] !== '$__rate_interval') { + operation.params = newDef.defaultParams; + } else if (operation.params[0] === '$__interval' && newDef.defaultParams[0] !== '$__interval') { + operation.params = newDef.defaultParams; + } + + return operation; +} + export function operationWithRangeVectorRenderer( model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string ) { - let rangeVector = (model.params ?? [])[0] ?? 'auto'; - - if (rangeVector === 'auto') { - rangeVector = '$__rate_interval'; - } - + let rangeVector = (model.params ?? [])[0] ?? '5m'; return `${def.id}(${innerExpr}[${rangeVector}])`; } @@ -321,14 +336,6 @@ function getSimpleBinaryRenderer(operator: string) { }; } -function getRangeVectorParamDef(): QueryBuilderOperationParamDef { - return { - name: 'Range vector', - type: 'string', - options: ['auto', '$__rate_interval', '$__interval', '$__range', '1m', '5m', '10m', '1h', '24h'], - }; -} - /** * Since there can only be one operation with range vector this will replace the current one (if one was added ) */ @@ -337,28 +344,22 @@ export function addOperationWithRangeVector( query: PromVisualQuery, modeller: VisualQueryModeller ) { + const newOperation: QueryBuilderOperation = { + id: def.id, + params: def.defaultParams, + }; + if (query.operations.length > 0) { const firstOp = modeller.getOperationDef(query.operations[0].id); if (firstOp.addOperationHandler === addOperationWithRangeVector) { return { ...query, - operations: [ - { - ...query.operations[0], - id: def.id, - }, - ...query.operations.slice(1), - ], + operations: [newOperation, ...query.operations.slice(1)], }; } } - const newOperation: QueryBuilderOperation = { - id: def.id, - params: def.defaultParams, - }; - return { ...query, operations: [newOperation, ...query.operations], diff --git a/public/app/plugins/datasource/prometheus/querybuilder/parsing.test.ts b/public/app/plugins/datasource/prometheus/querybuilder/parsing.test.ts index 08e735d2f97..2ce889d3a4a 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/parsing.test.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/parsing.test.ts @@ -365,6 +365,17 @@ describe('buildVisualQueryFromString', () => { }, }); }); + + it('lone aggregation without params', () => { + expect(buildVisualQueryFromString('sum()')).toEqual({ + errors: [], + query: { + metric: '', + labels: [], + operations: [{ id: 'sum', params: [] }], + }, + }); + }); }); function noErrors(query: PromVisualQuery) { diff --git a/public/app/plugins/datasource/prometheus/querybuilder/parsing.ts b/public/app/plugins/datasource/prometheus/querybuilder/parsing.ts index 4953acc0dd8..e185475ff83 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/parsing.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/parsing.ts @@ -1,5 +1,5 @@ import { parser } from 'lezer-promql'; -import { SyntaxNode } from 'lezer-tree'; +import { SyntaxNode } from '@lezer/common'; import { QueryBuilderLabelFilter, QueryBuilderOperation } from './shared/types'; import { PromVisualQuery } from './types'; @@ -72,19 +72,27 @@ export function buildVisualQueryFromString(expr: string): Context { labels: [], operations: [], }; - const context = { + const context: Context = { query: visQuery, errors: [], }; - handleExpression(replacedExpr, node, context); + try { + handleExpression(replacedExpr, node, context); + } catch (err) { + // Not ideal to log it here, but otherwise we would lose the stack trace. + console.error(err); + context.errors.push({ + text: err.message, + }); + } return context; } interface ParsingError { text: string; - from: number; - to: number; + from?: number; + to?: number; parentType?: string; } @@ -262,7 +270,7 @@ function handleAggregation(expr: string, node: SyntaxNode, context: Context) { const op: QueryBuilderOperation = { id: funcName, params: [] }; visQuery.operations.unshift(op); - updateFunctionArgs(expr, callArgs!, context, op); + updateFunctionArgs(expr, callArgs, context, op); // We add labels after params in the visual query editor. op.params.push(...labels); } @@ -279,7 +287,10 @@ function handleAggregation(expr: string, node: SyntaxNode, context: Context) { * @param context * @param op - We need the operation to add the params to as an additional context. */ -function updateFunctionArgs(expr: string, node: SyntaxNode, context: Context, op: QueryBuilderOperation) { +function updateFunctionArgs(expr: string, node: SyntaxNode | null, context: Context, op: QueryBuilderOperation) { + if (!node) { + return; + } switch (node.name) { // In case we have an expression we don't know what kind so we have to look at the child as it can be anything. case 'Expr': diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/AutoSizeInput.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/AutoSizeInput.test.tsx new file mode 100644 index 00000000000..0e0ef42b541 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/AutoSizeInput.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { screen, render, fireEvent } from '@testing-library/react'; +import { AutoSizeInput } from './AutoSizeInput'; + +jest.mock('@grafana/ui', () => { + const original = jest.requireActual('@grafana/ui'); + const mockedUi = { ...original }; + + // Mocking measureText + mockedUi.measureText = (text: string, fontSize: number) => { + return { width: text.length * fontSize }; + }; + + return mockedUi; +}); + +describe('AutoSizeInput', () => { + it('should have default minWidth when empty', () => { + render(); + + const input: HTMLInputElement = screen.getByTestId('autosize-input'); + const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); + + fireEvent.change(input, { target: { value: '' } }); + + expect(input.value).toBe(''); + expect(getComputedStyle(inputWrapper).width).toBe('80px'); + }); + + it('should have default minWidth for short content', () => { + render(); + + const input: HTMLInputElement = screen.getByTestId('autosize-input'); + const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); + + fireEvent.change(input, { target: { value: 'foo' } }); + + expect(input.value).toBe('foo'); + expect(getComputedStyle(inputWrapper).width).toBe('80px'); + }); + + it('should change width for long content', () => { + render(); + + const input: HTMLInputElement = screen.getByTestId('autosize-input'); + const inputWrapper: HTMLDivElement = screen.getByTestId('input-wrapper'); + + fireEvent.change(input, { target: { value: 'very very long value' } }); + expect(getComputedStyle(inputWrapper).width).toBe('304px'); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/AutoSizeInput.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/AutoSizeInput.tsx new file mode 100644 index 00000000000..a0223f596c7 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/AutoSizeInput.tsx @@ -0,0 +1,71 @@ +import { Input, measureText } from '@grafana/ui'; +import { Props as InputProps } from '@grafana/ui/src/components/Input/Input'; +import React, { useEffect } from 'react'; +export interface Props extends InputProps { + /** Sets the min-width to a multiple of 8px. Default value is 10*/ + minWidth?: number; + /** Sets the max-width to a multiple of 8px.*/ + maxWidth?: number; + /** onChange function that will be run on onBlur and onKeyPress with enter*/ + onCommitChange?: (event: React.FormEvent) => void; +} + +export const AutoSizeInput = React.forwardRef((props, ref) => { + const { defaultValue = '', minWidth = 10, maxWidth, onCommitChange, onKeyDown, onBlur, ...restProps } = props; + const [value, setValue] = React.useState(defaultValue); + const [inputWidth, setInputWidth] = React.useState(minWidth); + + useEffect(() => { + setInputWidth(getWidthFor(value.toString(), minWidth, maxWidth)); + }, [value, maxWidth, minWidth]); + + return ( + { + setValue(event.currentTarget.value); + }} + width={inputWidth} + onBlur={(event) => { + if (onCommitChange) { + onCommitChange(event); + } + if (onBlur) { + onBlur(event); + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && onCommitChange) { + onCommitChange(event); + } + if (onKeyDown) { + onKeyDown(event); + } + }} + data-testid={'autosize-input'} + /> + ); +}); + +function getWidthFor(value: string, minWidth: number, maxWidth: number | undefined): number { + if (!value) { + return minWidth; + } + + const extraSpace = 3; + const realWidth = measureText(value.toString(), 14).width / 8 + extraSpace; + + if (minWidth && realWidth < minWidth) { + return minWidth; + } + + if (maxWidth && realWidth > maxWidth) { + return realWidth; + } + + return realWidth; +} + +AutoSizeInput.displayName = 'AutoSizeInput'; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationInfoButton.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationInfoButton.tsx index a0f9645d880..075dd63d5d6 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationInfoButton.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationInfoButton.tsx @@ -3,8 +3,7 @@ import { GrafanaTheme2, renderMarkdown } from '@grafana/data'; import { FlexItem } from '@grafana/experimental'; import { Button, Portal, useStyles2 } from '@grafana/ui'; import React, { useState } from 'react'; -import { usePopper } from 'react-popper'; -import { useToggle } from 'react-use'; +import { usePopperTooltip } from 'react-popper-tooltip'; import { QueryBuilderOperation, QueryBuilderOperationDef } from './types'; export interface Props { @@ -14,41 +13,39 @@ export interface Props { export const OperationInfoButton = React.memo(({ def, operation }) => { const styles = useStyles2(getStyles); - const [popperTrigger, setPopperTrigger] = useState(null); - const [popover, setPopover] = useState(null); - const [isOpen, toggleIsOpen] = useToggle(false); - - const popper = usePopper(popperTrigger, popover, { + const [show, setShow] = useState(false); + const { getTooltipProps, setTooltipRef, setTriggerRef, visible } = usePopperTooltip({ placement: 'top', - modifiers: [ - { name: 'arrow', enabled: true }, - { - name: 'preventOverflow', - enabled: true, - options: { - rootBoundary: 'viewport', - }, - }, - ], + visible: show, + offset: [0, 16], + onVisibleChange: setShow, + interactive: true, + trigger: ['click'], }); return ( <>
    { return { docBox: css({ overflow: 'hidden', - background: theme.colors.background.canvas, + background: theme.colors.background.primary, border: `1px solid ${theme.colors.border.strong}`, - boxShadow: theme.shadows.z2, + boxShadow: theme.shadows.z3, maxWidth: '600px', padding: theme.spacing(1), borderRadius: theme.shape.borderRadius(), diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationName.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationName.tsx index c8f234575ab..f91d5b70a64 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationName.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationName.tsx @@ -47,7 +47,7 @@ export const OperationName = React.memo(({ operation, def, index, onChang title={'Click to replace with alternative function'} > {nameElement} - + )} {state.isOpen && ( @@ -60,10 +60,9 @@ export const OperationName = React.memo(({ operation, def, index, onChang onCloseMenu={onToggleSwitcher} onChange={(value) => { if (value.value) { - onChange(index, { - ...operation, - id: value.value.id, - }); + const newDef = queryModeller.getOperationDef(value.value.id); + let changedOp = { ...operation, id: value.value.id }; + onChange(index, def.changeTypeHandler ? def.changeTypeHandler(changedOp, newDef) : changedOp); } }} /> diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationParamEditor.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationParamEditor.tsx index 168d880342c..0befd0b5760 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationParamEditor.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationParamEditor.tsx @@ -1,7 +1,8 @@ -import { toOption } from '@grafana/data'; -import { Input, Select } from '@grafana/ui'; +import { SelectableValue, toOption } from '@grafana/data'; +import { Select } from '@grafana/ui'; import React, { ComponentType } from 'react'; import { QueryBuilderOperationParamDef, QueryBuilderOperationParamEditorProps } from '../shared/types'; +import { AutoSizeInput } from './AutoSizeInput'; import { getOperationParamId } from './operationUtils'; export function getOperationParamEditor( @@ -20,18 +21,10 @@ export function getOperationParamEditor( function SimpleInputParamEditor(props: QueryBuilderOperationParamEditorProps) { return ( - { - if (evt.key === 'Enter') { - if (evt.currentTarget.value !== props.value) { - props.onChange(props.index, evt.currentTarget.value); - } - props.onRunQuery(); - } - }} - onBlur={(evt) => { + defaultValue={props.value} + onCommitChange={(evt) => { props.onChange(props.index, evt.currentTarget.value); }} /> @@ -45,16 +38,22 @@ function SelectInputParamEditor({ operationIndex, onChange, }: QueryBuilderOperationParamEditorProps) { - const selectOptions = paramDef.options!.map((option) => ({ - label: option as string, - value: option as string, - })); + let selectOptions = paramDef.options as Array>; + + if (!selectOptions[0]?.label) { + selectOptions = paramDef.options!.map((option) => ({ + label: option.toString(), + value: option as string, + })); + } + + let valueOption = selectOptions.find((x) => x.value === value) ?? toOption(value as string); return (