diff --git a/.betterer.results b/.betterer.results
index 57cc3ad8dce..e0cea5bc339 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -203,7 +203,7 @@ exports[`no enzyme tests`] = {
"public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx:809006195": [
[1, 35, 13, "RegExp match", "2409514259"]
],
- "public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx:1798654441": [
+ "public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx:3682134210": [
[1, 35, 13, "RegExp match", "2409514259"]
],
"public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.test.tsx:3293585799": [
@@ -230,7 +230,7 @@ exports[`no enzyme tests`] = {
"public/app/features/explore/LiveLogs.test.tsx:1667605379": [
[2, 17, 13, "RegExp match", "2409514259"]
],
- "public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx:3948011811": [
+ "public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx:492930613": [
[1, 17, 13, "RegExp match", "2409514259"]
],
"public/app/features/explore/RunButton.test.tsx:138299098": [
diff --git a/.drone.yml b/.drone.yml
index 81b2be1109e..b28736013e1 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -9,22 +9,32 @@ platform:
os: linux
services: []
steps:
-- commands:
- - mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
- - chmod +x bin/grabpl
- image: byrnedo/alpine-curl:0.1.8
- name: grabpl
- commands:
- echo $DRONE_RUNNER_NAME
image: alpine:3.15
name: identify-runner
- commands:
- - make gen-go
+ - mkdir -p bin
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
+ - chmod +x bin/grabpl
+ image: byrnedo/alpine-curl:0.1.8
+ name: grabpl
+- commands:
- ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
- - yarn install --immutable
+ depends_on:
+ - grabpl
image: grafana/build-container:1.5.3
- name: initialize
+ name: gen-version
+- commands:
+ - make gen-go
+ image: grafana/build-container:1.5.3
+ name: wire-install
+- commands:
+ - yarn install --immutable
+ depends_on:
+ - grabpl
+ image: grafana/build-container:1.5.3
+ name: yarn-install
- commands:
- ./bin/grabpl verify-drone
depends_on:
@@ -41,20 +51,18 @@ steps:
wan" > words_to_ignore.txt
- codespell -I words_to_ignore.txt docs/
- rm words_to_ignore.txt
- depends_on:
- - initialize
image: grafana/build-container:1.5.3
name: codespell
- commands:
- ./bin/grabpl shellcheck
depends_on:
- - initialize
+ - grabpl
image: grafana/build-container:1.5.3
name: shellcheck
- commands:
- ./bin/grabpl lint-backend --edition oss
depends_on:
- - initialize
+ - wire-install
environment:
CGO_ENABLED: "1"
image: grafana/build-container:1.5.3
@@ -65,7 +73,7 @@ steps:
- yarn run i18n:compile
- yarn run typecheck
depends_on:
- - initialize
+ - yarn-install
environment:
TEST_MAX_WORKERS: 50%
image: grafana/build-container:1.5.3
@@ -73,19 +81,19 @@ steps:
- commands:
- ./bin/grabpl test-backend --edition oss
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend
- commands:
- ./bin/grabpl integration-tests --edition oss
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend-integration
- commands:
- yarn run ci:test-frontend
depends_on:
- - initialize
+ - yarn-install
environment:
TEST_MAX_WORKERS: 50%
image: grafana/build-container:1.5.3
@@ -112,34 +120,45 @@ platform:
os: linux
services: []
steps:
-- commands:
- - mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
- - chmod +x bin/grabpl
- image: byrnedo/alpine-curl:0.1.8
- name: grabpl
- commands:
- echo $DRONE_RUNNER_NAME
image: alpine:3.15
name: identify-runner
- commands:
- - make gen-go
+ - mkdir -p bin
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
+ - chmod +x bin/grabpl
+ image: byrnedo/alpine-curl:0.1.8
+ name: grabpl
+- commands:
- ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
- - yarn install --immutable
+ depends_on:
+ - grabpl
image: grafana/build-container:1.5.3
- name: initialize
+ name: gen-version
+- commands:
+ - make gen-go
+ image: grafana/build-container:1.5.3
+ name: wire-install
+- commands:
+ - yarn install --immutable
+ depends_on:
+ - grabpl
+ image: grafana/build-container:1.5.3
+ name: yarn-install
- commands:
- ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER}
--variants linux-amd64,linux-amd64-musl,darwin-amd64,windows-amd64,armv6
depends_on:
- - initialize
- environment: {}
+ - gen-version
+ - wire-install
image: grafana/build-container:1.5.3
name: build-backend
- commands:
- ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER}
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
@@ -147,7 +166,8 @@ steps:
- commands:
- ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER}
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
@@ -155,7 +175,8 @@ steps:
- commands:
- ./bin/grabpl build-plugins --jobs 8 --edition oss
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment: null
image: grafana/build-container:1.5.3
name: build-plugins
@@ -356,10 +377,14 @@ services:
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
+- commands:
+ - echo $DRONE_RUNNER_NAME
+ image: alpine:3.15
+ name: identify-runner
- commands:
- apt-get update
- apt-get install -yq postgresql-client
@@ -420,7 +445,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -429,11 +454,17 @@ steps:
image: alpine:3.15
name: identify-runner
- commands:
- - make gen-go
- ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
- - yarn install --immutable
+ depends_on:
+ - grabpl
image: grafana/build-container:1.5.3
- name: initialize
+ name: gen-version
+- commands:
+ - yarn install --immutable
+ depends_on:
+ - grabpl
+ image: grafana/build-container:1.5.3
+ name: yarn-install
- commands:
- |-
echo -e "unknwon
@@ -444,14 +475,12 @@ steps:
wan" > words_to_ignore.txt
- codespell -I words_to_ignore.txt docs/
- rm words_to_ignore.txt
- depends_on:
- - initialize
image: grafana/build-container:1.5.3
name: codespell
- commands:
- yarn run prettier:checkDocs
depends_on:
- - initialize
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
@@ -459,7 +488,8 @@ steps:
- commands:
- ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER}
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
@@ -503,7 +533,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -512,11 +542,17 @@ steps:
image: alpine:3.15
name: identify-runner
- commands:
- - make gen-go
- ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
- - yarn install --immutable
+ depends_on:
+ - grabpl
image: grafana/build-container:1.5.3
- name: initialize
+ name: gen-version
+- commands:
+ - yarn install --immutable
+ depends_on:
+ - grabpl
+ image: grafana/build-container:1.5.3
+ name: yarn-install
- commands:
- |-
echo -e "unknwon
@@ -527,14 +563,12 @@ steps:
wan" > words_to_ignore.txt
- codespell -I words_to_ignore.txt docs/
- rm words_to_ignore.txt
- depends_on:
- - initialize
image: grafana/build-container:1.5.3
name: codespell
- commands:
- yarn run prettier:checkDocs
depends_on:
- - initialize
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
@@ -542,7 +576,8 @@ steps:
- commands:
- ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER}
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
@@ -581,22 +616,32 @@ platform:
os: linux
services: []
steps:
-- commands:
- - mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
- - chmod +x bin/grabpl
- image: byrnedo/alpine-curl:0.1.8
- name: grabpl
- commands:
- echo $DRONE_RUNNER_NAME
image: alpine:3.15
name: identify-runner
- commands:
- - make gen-go
+ - mkdir -p bin
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
+ - chmod +x bin/grabpl
+ image: byrnedo/alpine-curl:0.1.8
+ name: grabpl
+- commands:
- ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
- - yarn install --immutable
+ depends_on:
+ - grabpl
image: grafana/build-container:1.5.3
- name: initialize
+ name: gen-version
+- commands:
+ - make gen-go
+ image: grafana/build-container:1.5.3
+ name: wire-install
+- commands:
+ - yarn install --immutable
+ depends_on:
+ - grabpl
+ image: grafana/build-container:1.5.3
+ name: yarn-install
- commands:
- ./bin/grabpl verify-drone
depends_on:
@@ -613,20 +658,18 @@ steps:
wan" > words_to_ignore.txt
- codespell -I words_to_ignore.txt docs/
- rm words_to_ignore.txt
- depends_on:
- - initialize
image: grafana/build-container:1.5.3
name: codespell
- commands:
- ./bin/grabpl shellcheck
depends_on:
- - initialize
+ - grabpl
image: grafana/build-container:1.5.3
name: shellcheck
- commands:
- ./bin/grabpl lint-backend --edition oss
depends_on:
- - initialize
+ - wire-install
environment:
CGO_ENABLED: "1"
image: grafana/build-container:1.5.3
@@ -637,7 +680,7 @@ steps:
- yarn run i18n:compile
- yarn run typecheck
depends_on:
- - initialize
+ - yarn-install
environment:
TEST_MAX_WORKERS: 50%
image: grafana/build-container:1.5.3
@@ -645,19 +688,19 @@ steps:
- commands:
- ./bin/grabpl test-backend --edition oss
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend
- commands:
- ./bin/grabpl integration-tests --edition oss
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend-integration
- commands:
- yarn run ci:test-frontend
depends_on:
- - initialize
+ - yarn-install
environment:
TEST_MAX_WORKERS: 50%
image: grafana/build-container:1.5.3
@@ -682,22 +725,32 @@ platform:
os: linux
services: []
steps:
-- commands:
- - mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
- - chmod +x bin/grabpl
- image: byrnedo/alpine-curl:0.1.8
- name: grabpl
- commands:
- echo $DRONE_RUNNER_NAME
image: alpine:3.15
name: identify-runner
- commands:
- - make gen-go
+ - mkdir -p bin
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
+ - chmod +x bin/grabpl
+ image: byrnedo/alpine-curl:0.1.8
+ name: grabpl
+- commands:
- ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
- - yarn install --immutable
+ depends_on:
+ - grabpl
image: grafana/build-container:1.5.3
- name: initialize
+ name: gen-version
+- commands:
+ - make gen-go
+ image: grafana/build-container:1.5.3
+ name: wire-install
+- commands:
+ - yarn install --immutable
+ depends_on:
+ - grabpl
+ image: grafana/build-container:1.5.3
+ name: yarn-install
- commands:
- git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
--depth=1
@@ -739,14 +792,15 @@ steps:
- commands:
- ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER}
depends_on:
- - initialize
- environment: {}
+ - gen-version
+ - wire-install
image: grafana/build-container:1.5.3
name: build-backend
- commands:
- ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER}
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
@@ -754,7 +808,8 @@ steps:
- commands:
- ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER}
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
@@ -762,7 +817,8 @@ steps:
- commands:
- ./bin/grabpl build-plugins --jobs 8 --edition oss --sign --signing-admin
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
GRAFANA_API_KEY:
from_secret: grafana_api_key
@@ -795,8 +851,6 @@ steps:
- build-frontend
- build-frontend-packages
environment:
- GITHUB_TOKEN:
- from_secret: github_token
GPG_KEY_PASSWORD:
from_secret: gpg_key_password
GPG_PRIV_KEY:
@@ -899,10 +953,7 @@ steps:
image: grafana/build-container:1.5.3
name: build-storybook
- commands:
- - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json
- - gcloud auth activate-service-account --key-file=/tmp/gcpkey.json
- - gsutil -m rm -r gs://$${PRERELEASE_BUCKET}/artifacts/storybook/canary || true
- && gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${PRERELEASE_BUCKET}/artifacts/storybook/canary
+ - ./bin/grabpl store-storybook --deployment canary --src-bucket grafana-storybook
depends_on:
- build-storybook
- end-to-end-tests-dashboards-suite
@@ -1112,10 +1163,14 @@ services:
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
+- commands:
+ - echo $DRONE_RUNNER_NAME
+ image: alpine:3.15
+ name: identify-runner
- commands:
- apt-get update
- apt-get install -yq postgresql-client
@@ -1180,10 +1235,10 @@ steps:
name: identify-runner
- commands:
- $$ProgressPreference = "SilentlyContinue"
- - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe
+ - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/windows/grabpl.exe
-OutFile grabpl.exe
image: grafana/ci-wix:0.1.1
- name: initialize
+ name: windows-init
- commands:
- $$gcpKey = $$env:GCP_KEY
- '[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey))
@@ -1199,7 +1254,7 @@ steps:
- gsutil cp $$fname gs://grafana-downloads/oss/main/
- gsutil cp "$$fname.sha256" gs://grafana-downloads/oss/main/
depends_on:
- - initialize
+ - windows-init
environment:
GCP_KEY:
from_secret: gcp_key
@@ -1267,7 +1322,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -1275,10 +1330,6 @@ steps:
- echo $DRONE_RUNNER_NAME
image: alpine:3.15
name: identify-runner
-- commands:
- - make gen-go
- image: grafana/build-container:1.5.3
- name: initialize
- commands:
- ./bin/grabpl store-packages --edition oss --gcp-key /tmp/gcpkey.json --build-id
${DRONE_BUILD_NUMBER}
@@ -1349,47 +1400,53 @@ platform:
os: linux
services: []
steps:
-- commands:
- - mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
- - chmod +x bin/grabpl
- image: byrnedo/alpine-curl:0.1.8
- name: grabpl
- commands:
- echo $DRONE_RUNNER_NAME
image: alpine:3.15
name: identify-runner
- commands:
- - make gen-go
- - ./bin/grabpl verify-version ${DRONE_TAG}
- - ./bin/grabpl gen-version ${DRONE_TAG}
- - yarn install --immutable
- image: grafana/build-container:1.5.3
- name: initialize
+ - mkdir -p bin
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
+ - chmod +x bin/grabpl
+ image: byrnedo/alpine-curl:0.1.8
+ name: grabpl
- commands:
- - ./bin/grabpl build-backend --jobs 8 --edition oss --github-token $${GITHUB_TOKEN}
- ${DRONE_TAG}
+ - ./bin/grabpl gen-version ${DRONE_TAG}
depends_on:
- - initialize
- environment:
- GITHUB_TOKEN:
- from_secret: github_token
+ - grabpl
+ image: grafana/build-container:1.5.3
+ name: gen-version
+- commands:
+ - make gen-go
+ image: grafana/build-container:1.5.3
+ name: wire-install
+- commands:
+ - yarn install --immutable
+ depends_on:
+ - grabpl
+ image: grafana/build-container:1.5.3
+ name: yarn-install
+- commands:
+ - ./bin/grabpl build-backend --jobs 8 --edition oss ${DRONE_TAG}
+ depends_on:
+ - gen-version
+ - wire-install
image: grafana/build-container:1.5.3
name: build-backend
- commands:
- - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --edition
- oss ${DRONE_TAG}
+ - ./bin/grabpl build-frontend --jobs 8 --edition oss ${DRONE_TAG}
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
name: build-frontend
- commands:
- - ./bin/grabpl build-frontend-packages --jobs 8 --github-token $${GITHUB_TOKEN}
- --edition oss ${DRONE_TAG}
+ - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss ${DRONE_TAG}
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
@@ -1397,7 +1454,8 @@ steps:
- commands:
- ./bin/grabpl build-plugins --jobs 8 --edition oss --sign --signing-admin
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
GRAFANA_API_KEY:
from_secret: grafana_api_key
@@ -1423,16 +1481,13 @@ steps:
image: grafana/build-container:1.5.3
name: ensure-cuetsified
- commands:
- - ./bin/grabpl package --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --sign
- ${DRONE_TAG}
+ - ./bin/grabpl package --jobs 8 --edition oss --sign ${DRONE_TAG}
depends_on:
- build-plugins
- build-backend
- build-frontend
- build-frontend-packages
environment:
- GITHUB_TOKEN:
- from_secret: github_token
GPG_KEY_PASSWORD:
from_secret: gpg_key_password
GPG_PRIV_KEY:
@@ -1592,12 +1647,10 @@ steps:
image: grafana/grafana-ci-deploy:1.3.1
name: upload-packages
- commands:
- - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json
- - gcloud auth activate-service-account --key-file=/tmp/gcpkey.json
- - gsutil -m rm -r gs://$${PRERELEASE_BUCKET}/artifacts/storybook/latest || true
- && gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${PRERELEASE_BUCKET}/artifacts/storybook/latest
- - gsutil -m rm -r gs://$${PRERELEASE_BUCKET}/artifacts/storybook/${DRONE_TAG} ||
- true && gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${PRERELEASE_BUCKET}/artifacts/storybook/${DRONE_TAG}
+ - ./bin/grabpl store-storybook --deployment latest --src-bucket grafana-prerelease
+ --src-dir artifacts/storybook
+ - ./bin/grabpl store-storybook --deployment ${DRONE_TAG} --src-bucket grafana-prerelease
+ --src-dir artifacts/storybook
depends_on:
- build-storybook
- end-to-end-tests-dashboards-suite
@@ -1653,23 +1706,38 @@ platform:
os: linux
services: []
steps:
-- commands:
- - mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
- - chmod +x bin/grabpl
- image: byrnedo/alpine-curl:0.1.8
- name: grabpl
- commands:
- echo $DRONE_RUNNER_NAME
image: alpine:3.15
name: identify-runner
- commands:
- - make gen-go
- - ./bin/grabpl verify-version ${DRONE_TAG}
+ - mkdir -p bin
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
+ - chmod +x bin/grabpl
+ image: byrnedo/alpine-curl:0.1.8
+ name: grabpl
+- commands:
- ./bin/grabpl gen-version ${DRONE_TAG}
- - yarn install --immutable
+ depends_on:
+ - grabpl
image: grafana/build-container:1.5.3
- name: initialize
+ name: gen-version
+- commands:
+ - make gen-go
+ image: grafana/build-container:1.5.3
+ name: wire-install
+- commands:
+ - yarn install --immutable
+ depends_on:
+ - grabpl
+ image: grafana/build-container:1.5.3
+ name: yarn-install
+- commands:
+ - ./bin/grabpl shellcheck
+ depends_on:
+ - grabpl
+ image: grafana/build-container:1.5.3
+ name: shellcheck
- commands:
- |-
echo -e "unknwon
@@ -1680,20 +1748,12 @@ steps:
wan" > words_to_ignore.txt
- codespell -I words_to_ignore.txt docs/
- rm words_to_ignore.txt
- depends_on:
- - initialize
image: grafana/build-container:1.5.3
name: codespell
-- commands:
- - ./bin/grabpl shellcheck
- depends_on:
- - initialize
- image: grafana/build-container:1.5.3
- name: shellcheck
- commands:
- ./bin/grabpl lint-backend --edition oss
depends_on:
- - initialize
+ - wire-install
environment:
CGO_ENABLED: "1"
image: grafana/build-container:1.5.3
@@ -1704,7 +1764,7 @@ steps:
- yarn run i18n:compile
- yarn run typecheck
depends_on:
- - initialize
+ - yarn-install
environment:
TEST_MAX_WORKERS: 50%
image: grafana/build-container:1.5.3
@@ -1712,19 +1772,19 @@ steps:
- commands:
- ./bin/grabpl test-backend --edition oss
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend
- commands:
- ./bin/grabpl integration-tests --edition oss
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend-integration
- commands:
- yarn run ci:test-frontend
depends_on:
- - initialize
+ - yarn-install
environment:
TEST_MAX_WORKERS: 50%
image: grafana/build-container:1.5.3
@@ -1776,7 +1836,7 @@ services:
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -1784,13 +1844,6 @@ steps:
- echo $DRONE_RUNNER_NAME
image: alpine:3.15
name: identify-runner
-- commands:
- - make gen-go
- - ./bin/grabpl verify-version ${DRONE_TAG}
- - ./bin/grabpl gen-version ${DRONE_TAG}
- - yarn install --immutable
- image: grafana/build-container:1.5.3
- name: initialize
- commands:
- apt-get update
- apt-get install -yq postgresql-client
@@ -1860,10 +1913,10 @@ steps:
name: identify-runner
- commands:
- $$ProgressPreference = "SilentlyContinue"
- - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe
+ - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/windows/grabpl.exe
-OutFile grabpl.exe
image: grafana/ci-wix:0.1.1
- name: initialize
+ name: windows-init
- commands:
- $$gcpKey = $$env:GCP_KEY
- '[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey))
@@ -1878,7 +1931,7 @@ steps:
- gsutil cp $$fname gs://%PRERELEASE_BUCKET%/artifacts/downloads/${DRONE_TAG}/oss/release/
- gsutil cp "$$fname.sha256" gs://%PRERELEASE_BUCKET%/artifacts/downloads/${DRONE_TAG}/oss/release/
depends_on:
- - initialize
+ - windows-init
environment:
GCP_KEY:
from_secret: gcp_key
@@ -1919,7 +1972,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -1945,41 +1998,52 @@ steps:
- mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json
- mkdir bin
- mv /tmp/grabpl bin/
- - make gen-go
- - ./bin/grabpl verify-version ${DRONE_TAG}
- - ./bin/grabpl gen-version ${DRONE_TAG}
- - yarn install --immutable
depends_on:
- clone-enterprise
environment:
GITHUB_TOKEN:
from_secret: github_token
image: grafana/build-container:1.5.3
- name: initialize
+ name: init-enterprise
- commands:
- - ./bin/grabpl build-backend --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN}
- ${DRONE_TAG}
+ - make gen-go
depends_on:
- - initialize
- environment:
- GITHUB_TOKEN:
- from_secret: github_token
+ - init-enterprise
+ image: grafana/build-container:1.5.3
+ name: wire-install
+- commands:
+ - yarn install --immutable
+ depends_on:
+ - init-enterprise
+ image: grafana/build-container:1.5.3
+ name: yarn-install
+- commands:
+ - ./bin/grabpl gen-version ${DRONE_TAG}
+ depends_on:
+ - init-enterprise
+ image: grafana/build-container:1.5.3
+ name: gen-version
+- commands:
+ - ./bin/grabpl build-backend --jobs 8 --edition enterprise ${DRONE_TAG}
+ depends_on:
+ - gen-version
+ - wire-install
image: grafana/build-container:1.5.3
name: build-backend
- commands:
- - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --edition
- enterprise ${DRONE_TAG}
+ - ./bin/grabpl build-frontend --jobs 8 --edition enterprise ${DRONE_TAG}
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
name: build-frontend
- commands:
- - ./bin/grabpl build-frontend-packages --jobs 8 --github-token $${GITHUB_TOKEN}
- --edition enterprise ${DRONE_TAG}
+ - ./bin/grabpl build-frontend-packages --jobs 8 --edition enterprise ${DRONE_TAG}
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
@@ -1987,7 +2051,8 @@ steps:
- commands:
- ./bin/grabpl build-plugins --jobs 8 --edition enterprise --sign --signing-admin
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
GRAFANA_API_KEY:
from_secret: grafana_api_key
@@ -2013,18 +2078,14 @@ steps:
image: grafana/build-container:1.5.3
name: ensure-cuetsified
- commands:
- - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN}
- ${DRONE_TAG}
+ - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 ${DRONE_TAG}
depends_on:
- - initialize
- environment:
- GITHUB_TOKEN:
- from_secret: github_token
+ - gen-version
+ - wire-install
image: grafana/build-container:1.5.3
name: build-backend-enterprise2
- commands:
- - ./bin/grabpl package --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN}
- --sign ${DRONE_TAG}
+ - ./bin/grabpl package --jobs 8 --edition enterprise --sign ${DRONE_TAG}
depends_on:
- build-plugins
- build-backend
@@ -2032,8 +2093,6 @@ steps:
- build-frontend-packages
- build-backend-enterprise2
environment:
- GITHUB_TOKEN:
- from_secret: github_token
GPG_KEY_PASSWORD:
from_secret: gpg_key_password
GPG_PRIV_KEY:
@@ -2192,8 +2251,7 @@ steps:
image: grafana/grafana-ci-deploy:1.3.1
name: store-npm-packages
- commands:
- - ./bin/grabpl package --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN}
- --sign ${DRONE_TAG}
+ - ./bin/grabpl package --jobs 8 --edition enterprise2 --sign ${DRONE_TAG}
depends_on:
- build-plugins
- build-backend
@@ -2201,8 +2259,6 @@ steps:
- build-frontend-packages
- build-backend-enterprise2
environment:
- GITHUB_TOKEN:
- from_secret: github_token
GPG_KEY_PASSWORD:
from_secret: gpg_key_password
GPG_PRIV_KEY:
@@ -2273,7 +2329,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2299,17 +2355,31 @@ steps:
- mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json
- mkdir bin
- mv /tmp/grabpl bin/
- - make gen-go
- - ./bin/grabpl verify-version ${DRONE_TAG}
- - ./bin/grabpl gen-version ${DRONE_TAG}
- - yarn install --immutable
depends_on:
- clone-enterprise
environment:
GITHUB_TOKEN:
from_secret: github_token
image: grafana/build-container:1.5.3
- name: initialize
+ name: init-enterprise
+- commands:
+ - make gen-go
+ depends_on:
+ - init-enterprise
+ image: grafana/build-container:1.5.3
+ name: wire-install
+- commands:
+ - yarn install --immutable
+ depends_on:
+ - init-enterprise
+ image: grafana/build-container:1.5.3
+ name: yarn-install
+- commands:
+ - ./bin/grabpl gen-version ${DRONE_TAG}
+ depends_on:
+ - init-enterprise
+ image: grafana/build-container:1.5.3
+ name: gen-version
- commands:
- |-
echo -e "unknwon
@@ -2320,20 +2390,12 @@ steps:
wan" > words_to_ignore.txt
- codespell -I words_to_ignore.txt docs/
- rm words_to_ignore.txt
- depends_on:
- - initialize
image: grafana/build-container:1.5.3
name: codespell
-- commands:
- - ./bin/grabpl shellcheck
- depends_on:
- - initialize
- image: grafana/build-container:1.5.3
- name: shellcheck
- commands:
- ./bin/grabpl lint-backend --edition enterprise
depends_on:
- - initialize
+ - wire-install
environment:
CGO_ENABLED: "1"
image: grafana/build-container:1.5.3
@@ -2344,7 +2406,7 @@ steps:
- yarn run i18n:compile
- yarn run typecheck
depends_on:
- - initialize
+ - yarn-install
environment:
TEST_MAX_WORKERS: 50%
image: grafana/build-container:1.5.3
@@ -2352,19 +2414,19 @@ steps:
- commands:
- ./bin/grabpl test-backend --edition enterprise
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend
- commands:
- ./bin/grabpl integration-tests --edition enterprise
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend-integration
- commands:
- yarn run ci:test-frontend
depends_on:
- - initialize
+ - yarn-install
environment:
TEST_MAX_WORKERS: 50%
image: grafana/build-container:1.5.3
@@ -2372,7 +2434,7 @@ steps:
- commands:
- ./bin/grabpl lint-backend --edition enterprise2
depends_on:
- - initialize
+ - wire-install
environment:
CGO_ENABLED: "1"
image: grafana/build-container:1.5.3
@@ -2380,13 +2442,13 @@ steps:
- commands:
- ./bin/grabpl test-backend --edition enterprise2
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend-enterprise2
- commands:
- ./bin/grabpl integration-tests --edition enterprise2
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend-integration-enterprise2
trigger:
@@ -2446,7 +2508,7 @@ services:
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2472,17 +2534,13 @@ steps:
- mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json
- mkdir bin
- mv /tmp/grabpl bin/
- - make gen-go
- - ./bin/grabpl verify-version ${DRONE_TAG}
- - ./bin/grabpl gen-version ${DRONE_TAG}
- - yarn install --immutable
depends_on:
- clone-enterprise
environment:
GITHUB_TOKEN:
from_secret: github_token
image: grafana/build-container:1.5.3
- name: initialize
+ name: init-enterprise
- commands:
- apt-get update
- apt-get install -yq postgresql-client
@@ -2491,7 +2549,7 @@ steps:
- go clean -testcache
- ./bin/grabpl integration-tests --database postgres
depends_on:
- - initialize
+ - init-enterprise
environment:
GRAFANA_TEST_DB: postgres
PGPASSWORD: grafanatest
@@ -2507,7 +2565,7 @@ steps:
- go clean -testcache
- ./bin/grabpl integration-tests --database mysql
depends_on:
- - initialize
+ - init-enterprise
environment:
GRAFANA_TEST_DB: mysql
MYSQL_HOST: mysql
@@ -2517,7 +2575,7 @@ steps:
- dockerize -wait tcp://redis:6379/0 -timeout 120s
- ./bin/grabpl integration-tests
depends_on:
- - initialize
+ - init-enterprise
environment:
REDIS_URL: redis://redis:6379/0
image: grafana/build-container:1.5.3
@@ -2526,7 +2584,7 @@ steps:
- dockerize -wait tcp://memcached:11211 -timeout 120s
- ./bin/grabpl integration-tests
depends_on:
- - initialize
+ - init-enterprise
environment:
MEMCACHED_HOSTS: memcached:11211
image: grafana/build-container:1.5.3
@@ -2574,7 +2632,7 @@ steps:
name: identify-runner
- commands:
- $$ProgressPreference = "SilentlyContinue"
- - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe
+ - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/windows/grabpl.exe
-OutFile grabpl.exe
- git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git"
- cd grafana-enterprise
@@ -2597,7 +2655,7 @@ steps:
GITHUB_TOKEN:
from_secret: github_token
image: grafana/ci-wix:0.1.1
- name: initialize
+ name: windows-init
- commands:
- $$gcpKey = $$env:GCP_KEY
- '[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey))
@@ -2612,7 +2670,7 @@ steps:
- gsutil cp $$fname gs://%PRERELEASE_BUCKET%/artifacts/downloads/${DRONE_TAG}/enterprise/release/
- gsutil cp "$$fname.sha256" gs://%PRERELEASE_BUCKET%/artifacts/downloads/${DRONE_TAG}/enterprise/release/
depends_on:
- - initialize
+ - windows-init
environment:
GCP_KEY:
from_secret: gcp_key
@@ -2649,7 +2707,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2727,7 +2785,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2788,7 +2846,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2867,7 +2925,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2929,7 +2987,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2967,7 +3025,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -3003,27 +3061,16 @@ platform:
os: linux
services: []
steps:
-- commands:
- - echo $DRONE_RUNNER_NAME
- image: alpine:3.15
- name: identify-runner
-- commands:
- - make gen-go
- - ./bin/grabpl verify-version ${DRONE_TAG}
- - ./bin/grabpl gen-version ${DRONE_TAG}
- - yarn install --immutable
- image: grafana/build-container:1.5.3
- name: initialize
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
- commands:
- ./bin/grabpl artifacts npm retrieve --tag v${TAG}
depends_on:
- - initialize
+ - grabpl
environment:
GCP_KEY:
from_secret: gcp_key
@@ -3066,7 +3113,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -3127,33 +3174,44 @@ platform:
os: linux
services: []
steps:
-- commands:
- - mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
- - chmod +x bin/grabpl
- image: byrnedo/alpine-curl:0.1.8
- name: grabpl
- commands:
- echo $DRONE_RUNNER_NAME
image: alpine:3.15
name: identify-runner
- commands:
- - make gen-go
+ - mkdir -p bin
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
+ - chmod +x bin/grabpl
+ image: byrnedo/alpine-curl:0.1.8
+ name: grabpl
+- commands:
- ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
- - yarn install --immutable
+ depends_on:
+ - grabpl
image: grafana/build-container:1.5.3
- name: initialize
+ name: gen-version
+- commands:
+ - make gen-go
+ image: grafana/build-container:1.5.3
+ name: wire-install
+- commands:
+ - yarn install --immutable
+ depends_on:
+ - grabpl
+ image: grafana/build-container:1.5.3
+ name: yarn-install
- commands:
- ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER}
depends_on:
- - initialize
- environment: {}
+ - gen-version
+ - wire-install
image: grafana/build-container:1.5.3
name: build-backend
- commands:
- ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER}
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
@@ -3161,7 +3219,8 @@ steps:
- commands:
- ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER}
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
@@ -3169,7 +3228,8 @@ steps:
- commands:
- ./bin/grabpl build-plugins --jobs 8 --edition oss --sign --signing-admin
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
GRAFANA_API_KEY:
from_secret: grafana_api_key
@@ -3202,8 +3262,6 @@ steps:
- build-frontend
- build-frontend-packages
environment:
- GITHUB_TOKEN:
- from_secret: github_token
GPG_KEY_PASSWORD:
from_secret: gpg_key_password
GPG_PRIV_KEY:
@@ -3392,22 +3450,38 @@ platform:
os: linux
services: []
steps:
-- commands:
- - mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
- - chmod +x bin/grabpl
- image: byrnedo/alpine-curl:0.1.8
- name: grabpl
- commands:
- echo $DRONE_RUNNER_NAME
image: alpine:3.15
name: identify-runner
- commands:
- - make gen-go
+ - mkdir -p bin
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
+ - chmod +x bin/grabpl
+ image: byrnedo/alpine-curl:0.1.8
+ name: grabpl
+- commands:
- ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
- - yarn install --immutable
+ depends_on:
+ - grabpl
image: grafana/build-container:1.5.3
- name: initialize
+ name: gen-version
+- commands:
+ - make gen-go
+ image: grafana/build-container:1.5.3
+ name: wire-install
+- commands:
+ - yarn install --immutable
+ depends_on:
+ - grabpl
+ image: grafana/build-container:1.5.3
+ name: yarn-install
+- commands:
+ - ./bin/grabpl shellcheck
+ depends_on:
+ - grabpl
+ image: grafana/build-container:1.5.3
+ name: shellcheck
- commands:
- |-
echo -e "unknwon
@@ -3418,20 +3492,12 @@ steps:
wan" > words_to_ignore.txt
- codespell -I words_to_ignore.txt docs/
- rm words_to_ignore.txt
- depends_on:
- - initialize
image: grafana/build-container:1.5.3
name: codespell
-- commands:
- - ./bin/grabpl shellcheck
- depends_on:
- - initialize
- image: grafana/build-container:1.5.3
- name: shellcheck
- commands:
- ./bin/grabpl lint-backend --edition oss
depends_on:
- - initialize
+ - wire-install
environment:
CGO_ENABLED: "1"
image: grafana/build-container:1.5.3
@@ -3442,7 +3508,7 @@ steps:
- yarn run i18n:compile
- yarn run typecheck
depends_on:
- - initialize
+ - yarn-install
environment:
TEST_MAX_WORKERS: 50%
image: grafana/build-container:1.5.3
@@ -3450,19 +3516,19 @@ steps:
- commands:
- ./bin/grabpl test-backend --edition oss
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend
- commands:
- ./bin/grabpl integration-tests --edition oss
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend-integration
- commands:
- yarn run ci:test-frontend
depends_on:
- - initialize
+ - yarn-install
environment:
TEST_MAX_WORKERS: 50%
image: grafana/build-container:1.5.3
@@ -3508,7 +3574,7 @@ services:
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -3516,12 +3582,6 @@ steps:
- echo $DRONE_RUNNER_NAME
image: alpine:3.15
name: identify-runner
-- commands:
- - make gen-go
- - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
- - yarn install --immutable
- image: grafana/build-container:1.5.3
- name: initialize
- commands:
- apt-get update
- apt-get install -yq postgresql-client
@@ -3585,10 +3645,10 @@ steps:
name: identify-runner
- commands:
- $$ProgressPreference = "SilentlyContinue"
- - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe
+ - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/windows/grabpl.exe
-OutFile grabpl.exe
image: grafana/ci-wix:0.1.1
- name: initialize
+ name: windows-init
- commands:
- $$gcpKey = $$env:GCP_KEY
- '[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey))
@@ -3598,7 +3658,7 @@ steps:
- rm gcpkey.json
- cp C:\App\nssm-2.24.zip .
depends_on:
- - initialize
+ - windows-init
environment:
GCP_KEY:
from_secret: gcp_key
@@ -3633,7 +3693,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -3658,25 +3718,41 @@ steps:
- mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json
- mkdir bin
- mv /tmp/grabpl bin/
- - make gen-go
- - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
- - yarn install --immutable
depends_on:
- clone-enterprise
environment: {}
image: grafana/build-container:1.5.3
- name: initialize
+ name: init-enterprise
+- commands:
+ - make gen-go
+ depends_on:
+ - init-enterprise
+ image: grafana/build-container:1.5.3
+ name: wire-install
+- commands:
+ - yarn install --immutable
+ depends_on:
+ - init-enterprise
+ image: grafana/build-container:1.5.3
+ name: yarn-install
+- commands:
+ - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
+ depends_on:
+ - init-enterprise
+ image: grafana/build-container:1.5.3
+ name: gen-version
- commands:
- ./bin/grabpl build-backend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER}
depends_on:
- - initialize
- environment: {}
+ - gen-version
+ - wire-install
image: grafana/build-container:1.5.3
name: build-backend
- commands:
- ./bin/grabpl build-frontend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER}
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
@@ -3685,7 +3761,8 @@ steps:
- ./bin/grabpl build-frontend-packages --jobs 8 --edition enterprise --build-id
${DRONE_BUILD_NUMBER}
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: grafana/build-container:1.5.3
@@ -3693,7 +3770,8 @@ steps:
- commands:
- ./bin/grabpl build-plugins --jobs 8 --edition enterprise --sign --signing-admin
depends_on:
- - initialize
+ - gen-version
+ - yarn-install
environment:
GRAFANA_API_KEY:
from_secret: grafana_api_key
@@ -3722,8 +3800,8 @@ steps:
- ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER}
--variants linux-amd64
depends_on:
- - initialize
- environment: {}
+ - gen-version
+ - wire-install
image: grafana/build-container:1.5.3
name: build-backend-enterprise2
- commands:
@@ -3736,8 +3814,6 @@ steps:
- build-frontend-packages
- build-backend-enterprise2
environment:
- GITHUB_TOKEN:
- from_secret: github_token
GPG_KEY_PASSWORD:
from_secret: gpg_key_password
GPG_PRIV_KEY:
@@ -3909,8 +3985,6 @@ steps:
- build-frontend-packages
- build-backend-enterprise2
environment:
- GITHUB_TOKEN:
- from_secret: github_token
GPG_KEY_PASSWORD:
from_secret: gpg_key_password
GPG_PRIV_KEY:
@@ -3974,7 +4048,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -3999,14 +4073,29 @@ steps:
- mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json
- mkdir bin
- mv /tmp/grabpl bin/
- - make gen-go
- - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
- - yarn install --immutable
depends_on:
- clone-enterprise
environment: {}
image: grafana/build-container:1.5.3
- name: initialize
+ name: init-enterprise
+- commands:
+ - make gen-go
+ depends_on:
+ - init-enterprise
+ image: grafana/build-container:1.5.3
+ name: wire-install
+- commands:
+ - yarn install --immutable
+ depends_on:
+ - init-enterprise
+ image: grafana/build-container:1.5.3
+ name: yarn-install
+- commands:
+ - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
+ depends_on:
+ - init-enterprise
+ image: grafana/build-container:1.5.3
+ name: gen-version
- commands:
- |-
echo -e "unknwon
@@ -4017,20 +4106,12 @@ steps:
wan" > words_to_ignore.txt
- codespell -I words_to_ignore.txt docs/
- rm words_to_ignore.txt
- depends_on:
- - initialize
image: grafana/build-container:1.5.3
name: codespell
-- commands:
- - ./bin/grabpl shellcheck
- depends_on:
- - initialize
- image: grafana/build-container:1.5.3
- name: shellcheck
- commands:
- ./bin/grabpl lint-backend --edition enterprise
depends_on:
- - initialize
+ - wire-install
environment:
CGO_ENABLED: "1"
image: grafana/build-container:1.5.3
@@ -4041,7 +4122,7 @@ steps:
- yarn run i18n:compile
- yarn run typecheck
depends_on:
- - initialize
+ - yarn-install
environment:
TEST_MAX_WORKERS: 50%
image: grafana/build-container:1.5.3
@@ -4049,19 +4130,19 @@ steps:
- commands:
- ./bin/grabpl test-backend --edition enterprise
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend
- commands:
- ./bin/grabpl integration-tests --edition enterprise
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend-integration
- commands:
- yarn run ci:test-frontend
depends_on:
- - initialize
+ - yarn-install
environment:
TEST_MAX_WORKERS: 50%
image: grafana/build-container:1.5.3
@@ -4069,7 +4150,7 @@ steps:
- commands:
- ./bin/grabpl lint-backend --edition enterprise2
depends_on:
- - initialize
+ - wire-install
environment:
CGO_ENABLED: "1"
image: grafana/build-container:1.5.3
@@ -4077,13 +4158,13 @@ steps:
- commands:
- ./bin/grabpl test-backend --edition enterprise2
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend-enterprise2
- commands:
- ./bin/grabpl integration-tests --edition enterprise2
depends_on:
- - initialize
+ - wire-install
image: grafana/build-container:1.5.3
name: test-backend-integration-enterprise2
trigger:
@@ -4137,7 +4218,7 @@ services:
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -4162,14 +4243,11 @@ steps:
- mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json
- mkdir bin
- mv /tmp/grabpl bin/
- - make gen-go
- - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
- - yarn install --immutable
depends_on:
- clone-enterprise
environment: {}
image: grafana/build-container:1.5.3
- name: initialize
+ name: init-enterprise
- commands:
- apt-get update
- apt-get install -yq postgresql-client
@@ -4178,7 +4256,7 @@ steps:
- go clean -testcache
- ./bin/grabpl integration-tests --database postgres
depends_on:
- - initialize
+ - init-enterprise
environment:
GRAFANA_TEST_DB: postgres
PGPASSWORD: grafanatest
@@ -4194,7 +4272,7 @@ steps:
- go clean -testcache
- ./bin/grabpl integration-tests --database mysql
depends_on:
- - initialize
+ - init-enterprise
environment:
GRAFANA_TEST_DB: mysql
MYSQL_HOST: mysql
@@ -4204,7 +4282,7 @@ steps:
- dockerize -wait tcp://redis:6379/0 -timeout 120s
- ./bin/grabpl integration-tests
depends_on:
- - initialize
+ - init-enterprise
environment:
REDIS_URL: redis://redis:6379/0
image: grafana/build-container:1.5.3
@@ -4213,7 +4291,7 @@ steps:
- dockerize -wait tcp://memcached:11211 -timeout 120s
- ./bin/grabpl integration-tests
depends_on:
- - initialize
+ - init-enterprise
environment:
MEMCACHED_HOSTS: memcached:11211
image: grafana/build-container:1.5.3
@@ -4255,7 +4333,7 @@ steps:
name: identify-runner
- commands:
- $$ProgressPreference = "SilentlyContinue"
- - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.32/windows/grabpl.exe
+ - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.37/windows/grabpl.exe
-OutFile grabpl.exe
- git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git"
- cd grafana-enterprise
@@ -4278,7 +4356,7 @@ steps:
GITHUB_TOKEN:
from_secret: github_token
image: grafana/ci-wix:0.1.1
- name: initialize
+ name: windows-init
- commands:
- $$gcpKey = $$env:GCP_KEY
- '[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey))
@@ -4288,7 +4366,7 @@ steps:
- rm gcpkey.json
- cp C:\App\nssm-2.24.zip .
depends_on:
- - initialize
+ - windows-init
environment:
GCP_KEY:
from_secret: gcp_key
@@ -4452,6 +4530,6 @@ kind: secret
name: gcp_upload_artifacts_key
---
kind: signature
-hmac: 9b87d4bb17d422973023c6ad1a59980c37832ae60ba6bb0d11ee024ea1e35b1a
+hmac: 803ec2a67b1758ce82ddda8ba1fb276fd23e4cfbfc3c5736bf2fa9e98edc2e6b
...
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index cc67825fdb8..fe6e7f3b57c 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -28,6 +28,7 @@ go.sum @grafana/backend-platform
.drone.yml @grafana/grafana-release-eng
.drone.star @grafana/grafana-release-eng
/scripts/drone/ @grafana/grafana-release-eng
+/pkg/build/ @grafana/grafana-release-eng
# Cloud Datasources backend code
/pkg/tsdb/cloudwatch @grafana/cloud-datasources
diff --git a/.github/workflows/cloud-data-sources-code-coverage.yml b/.github/workflows/cloud-data-sources-code-coverage.yml
index a1af9904e89..f49cd30e3a2 100644
--- a/.github/workflows/cloud-data-sources-code-coverage.yml
+++ b/.github/workflows/cloud-data-sources-code-coverage.yml
@@ -10,7 +10,7 @@ on:
- 'public/app/plugins/datasource/cloud-monitoring/**'
branches-ignore:
- dependabot/**
- - backport-**
+ - backport-*
jobs:
workflow-call:
diff --git a/.yarnrc.yml b/.yarnrc.yml
index 98678568a76..d14926ae5f9 100644
--- a/.yarnrc.yml
+++ b/.yarnrc.yml
@@ -9,28 +9,28 @@ packageExtensions:
"@mdx-js/loader@1.6.22":
peerDependencies:
react: 17.0.1
- "@storybook/addon-docs@6.4.15":
+ "@storybook/addon-docs@6.4.21":
peerDependencies:
- "@storybook/manager-webpack5": 6.4.15
- "@storybook/addon-essentials@6.4.15":
+ "@storybook/manager-webpack5": 6.4.21
+ "@storybook/addon-essentials@6.4.21":
peerDependencies:
- "@storybook/components": 6.4.15
- "@storybook/core-events": 6.4.15
- "@storybook/manager-webpack5": 6.4.15
- "@storybook/theming": 6.4.15
- "@storybook/core-server@6.4.15":
+ "@storybook/components": 6.4.21
+ "@storybook/core-events": 6.4.21
+ "@storybook/manager-webpack5": 6.4.21
+ "@storybook/theming": 6.4.21
+ "@storybook/core-server@6.4.21":
peerDependencies:
"@babel/core": ^7.0.0
- "@storybook/core@6.4.15":
+ "@storybook/core@6.4.21":
peerDependencies:
"@babel/core": ^7.0.0
- "@storybook/manager-webpack5": 6.4.15
- "@storybook/csf-tools@6.4.15":
+ "@storybook/manager-webpack5": 6.4.21
+ "@storybook/csf-tools@6.4.21":
peerDependencies:
"@babel/core": ^7.0.0
- "@storybook/react@6.4.15":
+ "@storybook/react@6.4.21":
peerDependencies:
- "@storybook/manager-webpack5": 6.4.15
+ "@storybook/manager-webpack5": 6.4.21
doctrine@3.0.0:
dependencies:
assert: 2.0.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index db42f22ca94..508cabd500c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,13 @@ When user is using Github OAuth, GitHub login is showed as both Grafana login an
The meaning of the default data source has now changed from being a persisted property in a panel. Before when you selected the default data source for a panel and later changed the default data source to another data source it would change all panels who were configured to use the default data source. From now on the default data source is just the default for new panels and changing the default will not impact any currently saved dashboards. Issue [#45132](https://github.com/grafana/grafana/issues/45132)
+
+
+# 8.4.6 (2022-04-12)
+
+- **Security:** Fixes CVE-2022-24812. For more information, see our [blog](https://grafana.com/blog/2022/04/12/grafana-enterprise-8.4.6-released-with-high-severity-security-fix/)
+
+
# 8.4.5 (2022-03-31)
diff --git a/conf/defaults.ini b/conf/defaults.ini
index d0ab0c1a16c..904719d5620 100644
--- a/conf/defaults.ini
+++ b/conf/defaults.ini
@@ -230,6 +230,9 @@ application_insights_connection_string =
# Optional. Specifies an Application Insights endpoint URL where the endpoint string is wrapped in backticks ``.
application_insights_endpoint_url =
+# Controls if the UI contains any links to user feedback forms
+feedback_links_enabled = true
+
#################################### Security ############################
[security]
# disable creation of admin user on first start of grafana
@@ -1131,9 +1134,12 @@ license_path =
# enable = feature1,feature2
enable =
-# The new prometheus visual query builder
+# The new prometheus visual query builder
promQueryBuilder = true
+# Experimental Explore to Dashboard workflow
+explore2Dashboard = true
+
# feature1 = true
# feature2 = false
diff --git a/conf/sample.ini b/conf/sample.ini
index f20d03f7461..fbe7b6b7f8d 100644
--- a/conf/sample.ini
+++ b/conf/sample.ini
@@ -230,6 +230,9 @@
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
;rudderstack_config_url =
+# Controls if the UI contains any links to user feedback forms
+;feedback_links_enabled = true
+
#################################### Security ####################################
[security]
# disable creation of admin user on first start of grafana
diff --git a/devenv/dev-dashboards/dashboards.go b/devenv/dev-dashboards/dashboards.go
new file mode 100644
index 00000000000..4531431bc7a
--- /dev/null
+++ b/devenv/dev-dashboards/dashboards.go
@@ -0,0 +1,6 @@
+package dev_dashboards
+
+import "embed"
+
+//go:embed *.json */*.json
+var DevDashboardFS embed.FS
diff --git a/devenv/dev-dashboards/dashboards_test.go b/devenv/dev-dashboards/dashboards_test.go
new file mode 100644
index 00000000000..e330b53bb0f
--- /dev/null
+++ b/devenv/dev-dashboards/dashboards_test.go
@@ -0,0 +1,84 @@
+package dev_dashboards
+
+import (
+ "encoding/json"
+ "io"
+ "io/fs"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "cuelang.org/go/cue/errors"
+ "github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/coremodel/dashboard"
+ "github.com/grafana/grafana/pkg/cuectx"
+)
+
+func TestDevenvDashboardValidity(t *testing.T) {
+ m, err := themaTestableDashboards()
+ require.NoError(t, err)
+ cm, err := dashboard.ProvideCoremodel(cuectx.ProvideThemaLibrary())
+ require.NoError(t, err)
+
+ for path, b := range m {
+ t.Run(path, func(t *testing.T) {
+ // The path arg here only matters for error output
+ cv, err := cuectx.JSONtoCUE(path, b)
+ require.NoError(t, err, "error while decoding dashboard JSON into a CUE value")
+
+ _, err = cm.CurrentSchema().Validate(cv)
+ if err != nil {
+ // Testify trims errors to short length. We want the full text
+ errstr := errors.Details(err, nil)
+ t.Log(errstr)
+ if strings.Contains(errstr, "null") {
+ t.Log("validation failure appears to involve nulls - see if scripts/stripnulls.sh has any effect?")
+ }
+ t.FailNow()
+ }
+ })
+ }
+}
+
+func themaTestableDashboards() (map[string][]byte, error) {
+ m := make(map[string][]byte)
+ in := DevDashboardFS
+
+ err := fs.WalkDir(in, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if d.IsDir() || filepath.Ext(d.Name()) != ".json" {
+ return nil
+ }
+
+ // nolint:gosec
+ f, err := in.Open(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close() // nolint: errcheck
+
+ b, err := io.ReadAll(f)
+ if err != nil {
+ return err
+ }
+
+ jtree := make(map[string]interface{})
+ json.Unmarshal(b, &jtree)
+ if oldschemav, has := jtree["schemaVersion"]; !has || !(oldschemav.(float64) > dashboard.HandoffSchemaVersion-1) {
+ return nil
+ }
+
+ m[path] = b
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ return m, nil
+}
diff --git a/devenv/dev-dashboards/panel-graph/graph-ng-stacking2.json b/devenv/dev-dashboards/panel-graph/graph-ng-stacking2.json
new file mode 100644
index 00000000000..410540966f0
--- /dev/null
+++ b/devenv/dev-dashboards/panel-graph/graph-ng-stacking2.json
@@ -0,0 +1,2930 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "target": {
+ "limit": 100,
+ "matchAny": false,
+ "tags": [],
+ "type": "dashboard"
+ },
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "liveNow": false,
+ "panels": [
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 20,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 0,
+ "y": 0
+ },
+ "id": 6,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "0,2,2,3,4,5"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "5,4,3,2,2,0"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "C",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,10,25,5,0"
+ }
+ ],
+ "title": "Pos Only",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 20,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "normal"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 4,
+ "y": 0
+ },
+ "id": 7,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 6,
+ "refId": "A"
+ }
+ ],
+ "title": "Stack Normal",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 20,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "percent"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 8,
+ "y": 0
+ },
+ "id": 8,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 6,
+ "refId": "A"
+ }
+ ],
+ "title": "Stack 100%",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 20,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 12,
+ "y": 0
+ },
+ "id": 3,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "alias": "+100",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "hide": false,
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "100,100,100,100,100,100,100,100"
+ },
+ {
+ "alias": "+50",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "50,50,50,50,50,50,50,50"
+ },
+ {
+ "alias": "+25",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "C",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "25,25,25,25,25,25,25,25"
+ },
+ {
+ "alias": "-50",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "D",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "-50,-50,-50,-50,-50,-50,-50,-50"
+ },
+ {
+ "alias": "-100",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "E",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "-100,-100,-100,-100,-100,-100,-100,-100"
+ }
+ ],
+ "title": "Pos & Neg",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 20,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "normal"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 16,
+ "y": 0
+ },
+ "id": 4,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 3,
+ "refId": "A"
+ }
+ ],
+ "title": "Stack Normal",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 20,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "percent"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 20,
+ "y": 0
+ },
+ "id": 12,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 3,
+ "refId": "A"
+ }
+ ],
+ "title": "Stacked 100%",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 20,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "NegY"
+ },
+ "properties": [
+ {
+ "id": "custom.transform",
+ "value": "negative-Y"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 0,
+ "y": 9
+ },
+ "id": 9,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "alias": "NegY",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "C",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,10,25,5,0"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "0,2,2,3,4,5"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "5,4,3,2,2,0"
+ },
+ {
+ "alias": "Neg",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "D",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "-15,-5,-15,-5,-15,-5"
+ }
+ ],
+ "title": "Pos & Neg & NegY",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 20,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "normal"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "NegY"
+ },
+ "properties": [
+ {
+ "id": "custom.transform",
+ "value": "negative-Y"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 4,
+ "y": 9
+ },
+ "id": 10,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 9,
+ "refId": "A"
+ }
+ ],
+ "title": "Stack Normal",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 20,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "percent"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "NegY"
+ },
+ "properties": [
+ {
+ "id": "custom.transform",
+ "value": "negative-Y"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 8,
+ "y": 9
+ },
+ "id": 11,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 9,
+ "refId": "A"
+ }
+ ],
+ "title": "Stack 100%",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "axisSoftMin": 0,
+ "fillOpacity": 80,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineWidth": 0,
+ "scaleDistribution": {
+ "type": "linear"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 12,
+ "y": 9
+ },
+ "id": 17,
+ "options": {
+ "barRadius": 0,
+ "barWidth": 1,
+ "groupWidth": 0.89,
+ "legend": {
+ "calcs": [
+ "max"
+ ],
+ "displayMode": "hidden",
+ "placement": "right"
+ },
+ "orientation": "auto",
+ "showValue": "auto",
+ "stacking": "none",
+ "text": {},
+ "tooltip": {
+ "mode": "single",
+ "sort": "none"
+ },
+ "xTickLabelRotation": 0,
+ "xTickLabelSpacing": 0
+ },
+ "targets": [
+ {
+ "csvContent": "Name,Stat1,Stat2,Stat3,Stat4,Stat5,Stat6,Stat7,Stat8\nA, 10, 15,8,3,4,12,14,1\nB, 19, 5,8,3,4,12,14,6\nC, 15, 5,8,3,4,10,4,6\n",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "A",
+ "scenarioId": "csv_content"
+ }
+ ],
+ "title": "Barchart",
+ "type": "barchart"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "axisSoftMin": 0,
+ "fillOpacity": 80,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineWidth": 0,
+ "scaleDistribution": {
+ "type": "linear"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 16,
+ "y": 9
+ },
+ "id": 18,
+ "options": {
+ "barRadius": 0,
+ "barWidth": 1,
+ "groupWidth": 0.89,
+ "legend": {
+ "calcs": [
+ "max"
+ ],
+ "displayMode": "hidden",
+ "placement": "right"
+ },
+ "orientation": "auto",
+ "showValue": "auto",
+ "stacking": "normal",
+ "text": {},
+ "tooltip": {
+ "mode": "single",
+ "sort": "none"
+ },
+ "xTickLabelRotation": 0,
+ "xTickLabelSpacing": 0
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 17,
+ "refId": "A"
+ }
+ ],
+ "title": "Stack Normal",
+ "type": "barchart"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "axisSoftMin": 0,
+ "fillOpacity": 80,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineWidth": 0,
+ "scaleDistribution": {
+ "type": "linear"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 20,
+ "y": 9
+ },
+ "id": 19,
+ "options": {
+ "barRadius": 0,
+ "barWidth": 1,
+ "groupWidth": 0.89,
+ "legend": {
+ "calcs": [
+ "max"
+ ],
+ "displayMode": "hidden",
+ "placement": "right"
+ },
+ "orientation": "auto",
+ "showValue": "auto",
+ "stacking": "percent",
+ "text": {},
+ "tooltip": {
+ "mode": "single",
+ "sort": "none"
+ },
+ "xTickLabelRotation": 0,
+ "xTickLabelSpacing": 0
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 17,
+ "refId": "A"
+ }
+ ],
+ "title": "Stack 100%",
+ "type": "barchart"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "bars",
+ "fillOpacity": 50,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 0,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "NegY"
+ },
+ "properties": [
+ {
+ "id": "custom.transform",
+ "value": "negative-Y"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 0,
+ "y": 18
+ },
+ "id": 13,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 9,
+ "refId": "A"
+ }
+ ],
+ "title": "Neg & NegY Bars",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "bars",
+ "fillOpacity": 50,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 0,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "normal"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "NegY"
+ },
+ "properties": [
+ {
+ "id": "custom.transform",
+ "value": "negative-Y"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 4,
+ "y": 18
+ },
+ "id": 14,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 9,
+ "refId": "A"
+ }
+ ],
+ "title": "Stack Normal",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "bars",
+ "fillOpacity": 50,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 0,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "percent"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "NegY"
+ },
+ "properties": [
+ {
+ "id": "custom.transform",
+ "value": "negative-Y"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 8,
+ "y": 18
+ },
+ "id": 15,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 9,
+ "refId": "A"
+ }
+ ],
+ "title": "Stack 100%",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 40,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byRegexp",
+ "options": "(A|B)-series.*"
+ },
+ "properties": [
+ {
+ "id": "custom.drawStyle",
+ "value": "bars"
+ },
+ {
+ "id": "custom.lineWidth",
+ "value": 0
+ },
+ {
+ "id": "custom.fillOpacity",
+ "value": 100
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 12,
+ "y": 18
+ },
+ "id": 21,
+ "maxDataPoints": 20,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "18,25,43,11,5"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "41,29,20,23,27"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "C",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "169,109,170,150,116"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "D",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "160,131,117,134,139"
+ }
+ ],
+ "title": "Mixed Styles",
+ "transformations": [],
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 40,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "normal"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byRegexp",
+ "options": "(A|B)-series.*"
+ },
+ "properties": [
+ {
+ "id": "custom.drawStyle",
+ "value": "bars"
+ },
+ {
+ "id": "custom.lineWidth",
+ "value": 0
+ },
+ {
+ "id": "custom.fillOpacity",
+ "value": 100
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 16,
+ "y": 18
+ },
+ "id": 22,
+ "maxDataPoints": 20,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 21,
+ "refId": "A"
+ }
+ ],
+ "title": "Stack Normal Auto-grouped",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 40,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "percent"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byRegexp",
+ "options": "(A|B)-series.*"
+ },
+ "properties": [
+ {
+ "id": "custom.drawStyle",
+ "value": "bars"
+ },
+ {
+ "id": "custom.lineWidth",
+ "value": 0
+ },
+ {
+ "id": "custom.fillOpacity",
+ "value": 100
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 20,
+ "y": 18
+ },
+ "id": 23,
+ "maxDataPoints": 20,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 21,
+ "refId": "A"
+ }
+ ],
+ "title": "Stack 100% Auto-grouped",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 20,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 0,
+ "y": 27
+ },
+ "id": 24,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "alias": "+100",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "hide": false,
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "100,100,100,100,100,null,100,100"
+ },
+ {
+ "alias": "+50",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "50,50,50,50,50,null,50,50"
+ },
+ {
+ "alias": "+25",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "C",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "25,25,25,25,25,null,25,25"
+ },
+ {
+ "alias": "-50",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "D",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "-50,-50,null,-50,-50,-50,-50,-50"
+ },
+ {
+ "alias": "-100",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "E",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "-100,-100,null,-100,-100,-100,-100,-100"
+ }
+ ],
+ "title": "Null through all",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 20,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "normal"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 4,
+ "y": 27
+ },
+ "id": 25,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 24,
+ "refId": "A"
+ }
+ ],
+ "title": "Stack Normal",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 10,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "NegY"
+ },
+ "properties": [
+ {
+ "id": "custom.transform",
+ "value": "negative-Y"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 8,
+ "y": 27
+ },
+ "id": 2,
+ "maxDataPoints": 100,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "hide": false,
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "50,50,50,null,50,50,50,50"
+ },
+ {
+ "alias": "NegY",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "50,50,50,50,null,50,50"
+ }
+ ],
+ "title": "NegY nulls",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 8,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 12,
+ "y": 27
+ },
+ "id": 26,
+ "maxDataPoints": 20,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,2,1,2,1"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "3,3,3,3,3"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "C",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "2,2,2,2,2"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "D",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "4,4,4,4,4"
+ }
+ ],
+ "title": "1,2,3,4 Unstacked",
+ "transformations": [],
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 8,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "normal"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byRegexp",
+ "options": "(A|B)-series.*"
+ },
+ "properties": [
+ {
+ "id": "custom.stacking",
+ "value": {
+ "group": "B",
+ "mode": "normal"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 16,
+ "y": 27
+ },
+ "id": 27,
+ "maxDataPoints": 20,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 26,
+ "refId": "A"
+ }
+ ],
+ "title": "Stack Normal Groups A + B / C + D",
+ "transformations": [],
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 8,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "percent"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byRegexp",
+ "options": "(A|B)-series.*"
+ },
+ "properties": [
+ {
+ "id": "custom.stacking",
+ "value": {
+ "group": "B",
+ "mode": "percent"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 4,
+ "x": 20,
+ "y": 27
+ },
+ "id": 28,
+ "maxDataPoints": 20,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 26,
+ "refId": "A"
+ }
+ ],
+ "title": "Stack 100% Groups A + B / C + D",
+ "transformations": [],
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 0,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byFrameRefID",
+ "options": "B"
+ },
+ "properties": [
+ {
+ "id": "custom.drawStyle",
+ "value": "bars"
+ },
+ {
+ "id": "color",
+ "value": {
+ "mode": "palette-classic"
+ }
+ },
+ {
+ "id": "custom.gradientMode",
+ "value": "none"
+ },
+ {
+ "id": "custom.barAlignment",
+ "value": 1
+ },
+ {
+ "id": "custom.stacking",
+ "value": {
+ "group": "A",
+ "mode": "normal"
+ }
+ },
+ {
+ "id": "custom.spanNulls",
+ "value": 3600000
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 0,
+ "y": 36
+ },
+ "id": 30,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "hidden",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "csvContent": "\"Time\",\"Sum daily.cost_per_kwh\"\n1639612800000,\n1639872000000,0\n1639958400000,272\n1640044800000,272\n1640131200000,327\n1640217600000,290\n1640304000000,286\n1640390400000,256\n1640476800000,397\n1640563200000,295\n1640649600000,310\n1640736000000,224\n1640822400000,291\n1640908800000,265\n1640995200000,256\n1641081600000,399\n1641168000000,471\n1641254400000,325\n1641340800000,363\n1641427200000,375\n1641513600000,365\n1641600000000,492\n1641686400000,409\n1641772800000,371\n1641859200000,448\n1641945600000,372\n1642032000000,0\n1642118400000,0\n1642204800000,0\n1642291200000,0\n1642377600000,0\n1642464000000,0\n\n",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "hide": false,
+ "refId": "A",
+ "scenarioId": "csv_content"
+ },
+ {
+ "csvContent": "\"Time\",\"Sum morning.cost_per_kwh\",\"Sum afternoon.cost_per_kwh\",\"Sum evening.cost_per_kwh\",\"Sum night.cost_per_kwh\",\"Sum late.cost_per_kwh\"\n1639612800000,0,0,0,0,0\n1639872000000,,,,,\n1639958400000,,,,,\n1640044800000,,,,,\n1640131200000,,,,,\n1640217600000,32,28,62,63,32\n1640304000000,,,,,\n1640390400000,,,,,\n1640476800000,,,,,\n1640563200000,,,,,\n1640649600000,,,,,\n1640736000000,,,,,\n1640822400000,47,53,68,96,45\n1640908800000,,,,,\n1640995200000,,,,,\n1641081600000,,,,,\n1641168000000,,,,,\n1641254400000,,,,,\n1641340800000,,,,,\n1641427200000,112,130,147,152,115\n1641513600000,,,,,\n1641600000000,,,,,\n1641686400000,,,,,\n1641772800000,,,,,\n1641859200000,,,,,\n1641945600000,,,,,\n1642032000000,0,0,0,0,0\n1642118400000,,,,,\n1642204800000,,,,,\n1642291200000,,,,,\n1642377600000,,,,,\n1642464000000,,,,,\n\n",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "hide": false,
+ "refId": "B",
+ "scenarioId": "csv_content"
+ }
+ ],
+ "title": "https://github.com/grafana/grafana/pull/44359",
+ "transformations": [],
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 0,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byFrameRefID",
+ "options": "B"
+ },
+ "properties": [
+ {
+ "id": "custom.drawStyle",
+ "value": "line"
+ },
+ {
+ "id": "color",
+ "value": {
+ "mode": "palette-classic"
+ }
+ },
+ {
+ "id": "custom.gradientMode",
+ "value": "none"
+ },
+ {
+ "id": "custom.barAlignment",
+ "value": 1
+ },
+ {
+ "id": "custom.stacking",
+ "value": {
+ "group": "A",
+ "mode": "normal"
+ }
+ },
+ {
+ "id": "custom.spanNulls",
+ "value": true
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 8,
+ "y": 36
+ },
+ "id": 32,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "hidden",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "csvContent": "\"Time\",\"Sum daily.cost_per_kwh\"\n1639612800000,\n1639872000000,0\n1639958400000,272\n1640044800000,272\n1640131200000,327\n1640217600000,290\n1640304000000,286\n1640390400000,256\n1640476800000,397\n1640563200000,295\n1640649600000,310\n1640736000000,224\n1640822400000,291\n1640908800000,265\n1640995200000,256\n1641081600000,399\n1641168000000,471\n1641254400000,325\n1641340800000,363\n1641427200000,375\n1641513600000,365\n1641600000000,492\n1641686400000,409\n1641772800000,371\n1641859200000,448\n1641945600000,372\n1642032000000,0\n1642118400000,0\n1642204800000,0\n1642291200000,0\n1642377600000,0\n1642464000000,0\n\n",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "hide": false,
+ "refId": "A",
+ "scenarioId": "csv_content"
+ },
+ {
+ "csvContent": "\"Time\",\"Sum morning.cost_per_kwh\",\"Sum afternoon.cost_per_kwh\",\"Sum evening.cost_per_kwh\",\"Sum night.cost_per_kwh\",\"Sum late.cost_per_kwh\"\n1639612800000,0,0,0,0,0\n1639872000000,,,,,\n1639958400000,,,,,\n1640044800000,,,,,\n1640131200000,,,,,\n1640217600000,32,28,62,63,32\n1640304000000,,,,,\n1640390400000,,,,,\n1640476800000,,,,,\n1640563200000,,,,,\n1640649600000,,,,,\n1640736000000,,,,,\n1640822400000,47,53,68,96,45\n1640908800000,,,,,\n1640995200000,,,,,\n1641081600000,,,,,\n1641168000000,,,,,\n1641254400000,,,,,\n1641340800000,,,,,\n1641427200000,112,130,147,152,115\n1641513600000,,,,,\n1641600000000,,,,,\n1641686400000,,,,,\n1641772800000,,,,,\n1641859200000,,,,,\n1641945600000,,,,,\n1642032000000,0,0,0,0,0\n1642118400000,,,,,\n1642204800000,,,,,\n1642291200000,,,,,\n1642377600000,,,,,\n1642464000000,,,,,\n\n",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "hide": false,
+ "refId": "B",
+ "scenarioId": "csv_content"
+ }
+ ],
+ "title": "Connect null values = always",
+ "transformations": [],
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 0,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green"
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byFrameRefID",
+ "options": "B"
+ },
+ "properties": [
+ {
+ "id": "custom.drawStyle",
+ "value": "line"
+ },
+ {
+ "id": "color",
+ "value": {
+ "mode": "palette-classic"
+ }
+ },
+ {
+ "id": "custom.gradientMode",
+ "value": "none"
+ },
+ {
+ "id": "custom.barAlignment",
+ "value": 1
+ },
+ {
+ "id": "custom.stacking",
+ "value": {
+ "group": "A",
+ "mode": "normal"
+ }
+ },
+ {
+ "id": "custom.spanNulls",
+ "value": false
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 16,
+ "y": 36
+ },
+ "id": 34,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "hidden",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "targets": [
+ {
+ "csvContent": "\"Time\",\"Sum daily.cost_per_kwh\"\n1639612800000,\n1639872000000,0\n1639958400000,272\n1640044800000,272\n1640131200000,327\n1640217600000,290\n1640304000000,286\n1640390400000,256\n1640476800000,397\n1640563200000,295\n1640649600000,310\n1640736000000,224\n1640822400000,291\n1640908800000,265\n1640995200000,256\n1641081600000,399\n1641168000000,471\n1641254400000,325\n1641340800000,363\n1641427200000,375\n1641513600000,365\n1641600000000,492\n1641686400000,409\n1641772800000,371\n1641859200000,448\n1641945600000,372\n1642032000000,0\n1642118400000,0\n1642204800000,0\n1642291200000,0\n1642377600000,0\n1642464000000,0\n\n",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "hide": false,
+ "refId": "A",
+ "scenarioId": "csv_content"
+ },
+ {
+ "csvContent": "\"Time\",\"Sum morning.cost_per_kwh\",\"Sum afternoon.cost_per_kwh\",\"Sum evening.cost_per_kwh\",\"Sum night.cost_per_kwh\",\"Sum late.cost_per_kwh\"\n1639612800000,0,0,0,0,0\n1639872000000,,,,,\n1639958400000,,,,,\n1640044800000,,,,,\n1640131200000,,,,,\n1640217600000,32,28,62,63,32\n1640304000000,,,,,\n1640390400000,,,,,\n1640476800000,,,,,\n1640563200000,,,,,\n1640649600000,,,,,\n1640736000000,,,,,\n1640822400000,47,53,68,96,45\n1640908800000,,,,,\n1640995200000,,,,,\n1641081600000,,,,,\n1641168000000,,,,,\n1641254400000,,,,,\n1641340800000,,,,,\n1641427200000,112,130,147,152,115\n1641513600000,,,,,\n1641600000000,,,,,\n1641686400000,,,,,\n1641772800000,,,,,\n1641859200000,,,,,\n1641945600000,,,,,\n1642032000000,0,0,0,0,0\n1642118400000,,,,,\n1642204800000,,,,,\n1642291200000,,,,,\n1642377600000,,,,,\n1642464000000,,,,,\n\n",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "hide": false,
+ "refId": "B",
+ "scenarioId": "csv_content"
+ }
+ ],
+ "title": "Connect null values = never",
+ "transformations": [],
+ "type": "timeseries"
+ }
+ ],
+ "refresh": false,
+ "schemaVersion": 36,
+ "style": "dark",
+ "tags": [
+ "gdev",
+ "panel-tests",
+ "graph-ng"
+ ],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "2021-12-14T04:26:06.000Z",
+ "to": "2022-01-19T08:44:05.000Z"
+ },
+ "timepicker": {},
+ "timezone": "",
+ "title": "TimeSeries & BarChart Stacking",
+ "uid": "1KxMUdE7k",
+ "version": 1,
+ "weekStart": ""
+}
diff --git a/devenv/dev-dashboards/panel-table/table_pagination.json b/devenv/dev-dashboards/panel-table/table_pagination.json
new file mode 100644
index 00000000000..da1765cd937
--- /dev/null
+++ b/devenv/dev-dashboards/panel-table/table_pagination.json
@@ -0,0 +1,2683 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "target": {
+ "limit": 100,
+ "matchAny": false,
+ "tags": [],
+ "type": "dashboard"
+ },
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "id": 1110,
+ "links": [],
+ "liveNow": false,
+ "panels": [
+ {
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "custom": {
+ "align": "auto",
+ "displayMode": "auto",
+ "inspect": false
+ },
+ "mappings": [
+ {
+ "options": {
+ "rent": {
+ "color": "yellow",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "property_type"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background"
+ },
+ {
+ "id": "mappings",
+ "value": [
+ {
+ "options": {
+ "store": {
+ "color": "blue",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "title"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 205
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Number of rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 66
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Description"
+ },
+ "properties": [
+ {
+ "id": "custom.inspect",
+ "value": true
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "URL"
+ },
+ "properties": [
+ {
+ "id": "links",
+ "value": [
+ {
+ "targetBlank": true,
+ "title": "Show details",
+ "url": "${__value.raw}"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Surface"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background-solid"
+ },
+ {
+ "id": "thresholds",
+ "value": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "dark-red",
+ "value": null
+ },
+ {
+ "color": "orange",
+ "value": 100
+ },
+ {
+ "color": "yellow",
+ "value": 200
+ },
+ {
+ "color": "green",
+ "value": 300
+ },
+ {
+ "color": "#EAB839",
+ "value": 500
+ },
+ {
+ "color": "#6ED0E0",
+ "value": 1000
+ },
+ {
+ "color": "#EF843C",
+ "value": 2000
+ }
+ ]
+ }
+ },
+ {
+ "id": "unit",
+ "value": "areaM2"
+ },
+ {
+ "id": "custom.width",
+ "value": 93
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Type"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 94
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "City"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 120
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 77
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Price"
+ },
+ "properties": [
+ {
+ "id": "unit",
+ "value": "currencyUSD"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 0,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "footer": {
+ "enablePagination": false,
+ "fields": "",
+ "reducer": [
+ "sum"
+ ],
+ "show": false
+ },
+ "showHeader": true,
+ "sortBy": [
+ {
+ "desc": true,
+ "displayName": "Surface"
+ }
+ ]
+ },
+ "pluginVersion": "9.0.0-pre",
+ "targets": [
+ {
+ "csvContent": "\"id\",\"created_on\",\"operation\",\"property_type\",\"place_name\",\"place_with_parent_names\",\"country_name\",\"state_name\",\"geonames_id\",\"lat_lon\",\"lat\",\"lon\",\"price\",\"currency\",\"price_aprox_local_currency\",\"price_aprox_usd\",\"surface_total_in_m2\",\"surface_covered_in_m2\",\"price_usd_per_m2\",\"price_per_m2\",\"floor\",\"rooms\",\"expenses\",\"properati_url\",\"description\",\"title\",\"image_thumbnail\"\nf73c373e0588bf568164a057dd94203ac87d47b1,2014-08-07,rent,store,Catu,|Brasil|Bahia|Catu|,Brasil,Bahia,,,,,0,,0,0,,,,,,,,http://www.properati.com.br/mfye_alugar_outros_catu,\"Otimo Imovel com o melhor valor da regiao, confira as fotos - \",Exclusividade! - Loja Em Catu,https://thumbs4.properati.com/3/jA8isZw8Xju7z3-V2I-ED8x5D2o=/trim/198x0/smart/filters:strip_icc()/galeriadefotos.net.br/backup/upload/15989/1598966287bc7/zd533ba5e829b0543093a45e1d8398bd7.jpg\nd6d377927ab49671acdd38aa6f661a460601a88b,2015-01-20,rent,apartment,Pernambuco,|Brasil|Pernambuco|,Brasil,Pernambuco,,\"-8.126271,-34.903793\",-8.126271,-34.903793,1200,BRL,1184.32,383.55,,63,6.088095238095239,19.047619047619047,1,2,,http://www.properati.com.br/w71k_alugar_apartamentos_pernambuco,\"CONTATOS: Claro: 81./ Tim: 81./ Oi: 81./ Vivo: 81.Apartamento de 2 quartos mais Dependência completa de empregada, 1.º andar de edifício localizado na Rua Dr. Gaspar Regueira da Costa, 99, Boa Viagem.Este apartamento é constituído por: 1 Sala, 2 Quartos, 2 Banheiros, Cozinha, Área de serviço, Dependência completa de empregada e 1 lugar de estacionamento.O Valor do aluguer já inclui taxas.Agende já a sua visita.Nota: O Edifício irá ser pintado exteriormente, em breve.ISI-120-T2 #ref:ISI-120-T2 - ISI-120-T2 - idalécio Soares\",\"Apartamento de 2 quartos, DCE, 1.º Andar, 63 m2, Boa Viagem - Recife\",https://thumbs4.properati.com/0/B9BCwP1HK7NvZIYINGkFNnNfOZ4=/trim/198x0/smart/filters:strip_icc()/feedmedia.egorealestate.com/Zfeed/S5/C4123/P2270979/Tphoto/ID03a72200-0000-0500-0000-00000141a7a5.JPG\nc6826a9508436920fd59b46a202354f3e4823d22,2014-10-29,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.1546253,-45.7906242\",-23.1546253,-45.7906242,0,BRL,0,0,,3800,,,,,,http://www.properati.com.br/s41f_alugar_comercial_cacapava,\"CondomÃnio com 22 Módulos de Galpões; 3.800m2 a 12.600m2; 12m pé-direito médio; Piso 6 TON/m2; Ã�rea total de Terreno : 324.000 m2; Ã�rea Total construÃda: 133.200 m2; 293 vagas para automóveis; 103 vagas para caminhões; Refeitório para 256 pessoas por turno;BenefÃcios Fiscais no municÃpio de Caçapava conforme Lei Lei 5055/211; Isenção de Taxas;Isenção de IPTU; Redução de ISS; Reversão de ICMS.Valores locação = R$ 15,00 o m2Tx Cond. = R$ 1,50 o m2Valores poderão ser alterados sem prévio aviso.A Heron Imóveis oferece este(a) Comercial em Caçapava, no bairro Caçapava. Gostou desse(a) Comercial em Caçapava? Então veja mais informações! Acesse o nosso site clicando no botão Contate o anunciante ou ligue - ou ainda entre em contato conosco pelo email contato@ Obrigado!\",\"Comercial em Caçapava, no bairro Caçapava\",https://thumbs4.properati.com/1/J2n8nXVlvduy6EJjyLwPneEoc28=/trim/198x0/smart/filters:strip_icc()/www.heronimoveis.com.br/fotos/F00/35/23500-DSC06177.JPG\nba957ad3cb2d12d55d1f0466eeffe788a35a6f4f,2014-08-04,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7780086,-49.3450562\",-20.7780086,-49.3450562,950,BRL,751.1,243.25,,,,,,1,,http://www.properati.com.br/lxp9_alugar_casas_sao-jose-do-rio-preto,\"Ref.: CA95876 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Garden Village II..:Casa Condomnio Garden Village II, Casa Condominio, Dormitrios: 03, Tipo Apto.: 01, Wc. Social: 01, Sala de Visita: 01, Copa: 01, Cozinha: 01, Cozinha Planejada, Despensa, rea de Servio, Ar Condicionado, Alarme, Acabamento Em Gesso, Sanca, Campainha, Cerca Eletrica, Forro de Laje, Piso Dormitrios: Frio, Piso Salas: Frio, M2/ Terreno: 145, M2 rea Construida: 70, Garagem Descoberta: 01, Salo de Festa, Detalhes: R$ 950,00 + Iptu + Condomnio\", Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Garden Village II,https://thumbs4.properati.com/9/xcrfHjSnXdhGcOK1m1wg1jHJlsw=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/803/8860a8e8-6d62-49e6-a992-54ab8f360f35.jpg\n0999b33908c71af3b8be282ab35ab61b38a46353,2014-07-16,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,4300,BRL,4078.98,1321,,319,4.141065830721003,13.47962382445141,,4,,http://www.properati.com.br/ifuj_alugar_apartamentos_vila-olimpia,\"Apartamento na Vila Olímpia, com 4 quartos, sendo 2 suítes,Ampla sala com terraí§o, piso de madeira corrida, 1 dos quartos pode ser revertido para sala de tv ou escritório.Quartos com carpete de madeira, as suítes com armí¡rios embutidos, uma delas com ar condicionado.Armí¡rio corredor.Cozinha com armí¡rio, Piso de granito, na cozinha e lavanderia. Referíªncia: AP34\",\"Apartamento no(a) Vila OlÃmpia , São Paulo - SP\",https://thumbs4.properati.com/8/nxrbzTDipvKnqdx1wDABEyIbSes=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/34/c789b808-bf30-460d-9376-16299ce5abbb.jpg\n011d5618639ce229b012022a32b22eff9abd213e,2014-11-19,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.9648026,-46.3855723\",-23.9648026,-46.3855723,1300,BRL,1323.12,428.5,,60,7.141666666666667,21.666666666666668,4,1,,http://www.properati.com.br/tl4s_alugar_apartamentos_sao-paulo,\"PACOTE R$ 1.300,00 OTIMO APARTAMENTO MOBILIADO COM TERRAÇO PROXIMO SHOPPING , PRAIA E COMERCIOS ACEITA FIADOR / SEG. FIANÇA A 3 Milenio oferece este(a) Apartamento em Sao Vicente, no bairro Gonzaguinha - 1 dormitórios. Gostou desse(a) Apartamento em Sao Vicente? Então veja mais informações! Acesse o nosso site clicando no botão Contate o anunciante ou ligue - ou ainda entre em contato conosco pelo email vendas@ Obrigado!\",\"Apartamento em Sao Vicente, no bairro Gonzaguinha - 1 dormitórios\",https://thumbs4.properati.com/4/x8BqTbd304ntTBY5pTf5Yi_nNMw=/trim/198x0/smart/filters:strip_icc()/www.imobiliariasdesantos.com.br/fotos/F00/74/757400-FOTOS%20HELANE%20001.jpg\n6f274a2d23ffa89cb0301f3277585beda7723ce8,2014-09-01,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8343086,-49.3805559\",-20.8343086,-49.3805559,5000,BRL,3953.34,1280.31,,,,,,,,http://www.properati.com.br/o23s_alugar_comercial_sao-jose-do-rio-preto_rua-nuno-alvares-pereira,\"Ref.: SA95668Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Pq. EstorilM2 rea Construida: 170, Detalhes: Salo de 170 M, Com Mezanino. J Com Acessibilidade. Tem Uma Linda Fachada e Estacionamento Para Clientes.\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Pq. Estoril,https://thumbs4.properati.com/8/7VqZVGV6Hl3paOrhYXv_ksowyEw=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1875/TG5yXGj9EJPaa5o0dWgL.jpg\ndb6f890847bcacc6ec3e78a0e6613132174e4c70,2014-12-05,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8178411,-49.387605\",-20.8178411,-49.387605,10000,BRL,7906.68,2560.62,,,,,,1,,http://www.properati.com.br/ue07_alugar_casas_sao-jose-do-rio-preto_rua-cila,\"Ref.: CO95026Tipo: Casa Comercial, Predio ComercialCidade: So Jos do Rio Preto - SPBairro: RedentoraSobrado, Dormitrios: 4, Tipo Apto.: 4, C/ A/e: 4, A/e C/ Interior: 4, Wc. Social: 1, Copa/cozinha: 1, Cozinha C/ A/e: 1, Despensa, Salas : 1, Sala P/ Ambientes : 4, M2 rea Construida: 330, Entrada Lateral: 1, Piso Dormitrios: Frio\",\"Ref.: CO95026 Tipo: Casa Comercial, Predio Comercial Cidade: So Jos do Rio Preto - SP Bairro: Redentora\",https://thumbs4.properati.com/2/DNREjVLgpGPzTlv8aIvhpd7KZkI=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2461/02ea33d3-3ebd-4139-a380-b60eb84c87d1.jpg\nf7d3243419c85cf439189481819f13cb22ab0498,2014-10-01,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8126181,-49.3404534\",-20.8126181,-49.3404534,3800,BRL,3004.52,973.03,,,,,,,,http://www.properati.com.br/qz8t_alugar_casas_sao-jose-do-rio-preto_avenida-miguel-damha,\"Ref.: CA95902Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Damha III..: Casa Condomnio Damha III, Casa Condominio, Dormitrios: 03, Suite: 01, Closet , Tipo Apto.: 02, C/ A/e: 02, Home, Copa/cozinha: 01, Cozinha Planejada, Piscina, Varanda, rea de Servio, Wc. de Empregada/servio, Forro de Laje, M2/ Terreno: 450, M2 rea Construida: 240, rea de Lazer, Garagem Descoberta: 02, Garagem Coberta: 02, Churrasqueira, Valor Condominio R$-: 420,00\",Ref.: CA95902 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Damha III,https://thumbs4.properati.com/8/cqu5q3tBJOddQ526fWf2puVwINI=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2060/6064cf7e-f8ed-44f3-8a80-b6576de27d62.jpg\ncbfc4c495b3578c995363825a85a821217fab4ec,2014-10-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,14000,BRL,11069.36,3584.87,,,,,,,,http://www.properati.com.br/ri3p_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95918Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Mini Dist. Industrial Adail Vetorasso..: Salo Comercial No Mini Distrito Industrial Adail Vetorasso, Salo Comercial, Wcs.: 02, Cozinha: 01, Escritrio, M2/ Terreno: 2.100, M2 rea Construida: 1.350, Detalhes: Cobertura Metlica.p Direito Com 7 Metros.corredor Lateral.\",Ref.: SA95918 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Mini Dist. Industrial Adail Vetorasso,https://thumbs4.properati.com/9/INPTa4YGuWfsekPfC8ez-rqd3PI=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2285/Mhd9hMU2ZwU9YXAC9Hzi.jpg\nb4dab15f9d4411d0847d1871e0a62e107b492fdc,2014-08-02,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.8773135,-46.7592613\",-23.8773135,-46.7592613,28000,BRL,22138.72,7169.74,,,,,,,,http://www.properati.com.br/lud3_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95869Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Eng. Schimidt..: Salo Comercial Em Engenheiro Schimidt, Salo Comercial, Obs: Salo Ideal Para Transportadora Com Frente Para 03 Marginal, Esquina Com Rua Projetada 01 e Rodovia W. Luiz, M2/ Terreno: 5.500, M2 rea Construida: 2.200\",Ref.: SA95869 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Eng. Schimidt,https://thumbs4.properati.com/3/oJm3M_UtBn2PVRsZ2O4n48IvhuA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/780/60d090bc-4a05-4929-88ae-aca5b9873a76.jpg\nd3769eb1b41c780c83f2d176adae8ad9a81b7ba3,2014-10-08,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.818728,-49.4212608\",-20.818728,-49.4212608,16000,BRL,12650.68,4096.99,,,,,,,,http://www.properati.com.br/r6d9_alugar_comercial_sao-jose-do-rio-preto_avenida-joao-batista-vetorasso,\"Ref.: SA95810Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial Waldemar VerdiM2/ Terreno: 5.000, M2 rea Construida: 1.400, Detalhes: Com Recepo e 8 Salas. P Direito Com 7 M.\",Salo,https://thumbs4.properati.com/6/GFkI3LbwZQAhB8USv5fTltHxz0E=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2179/QEWBOD1T8GCOjBi0H6WI.jpg\nf51aebdb554e9b5dfa16c3538d3376040f5f2b5c,2014-08-20,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-22.1423284,-51.3750141\",-22.1423284,-51.3750141,16000,BRL,12650.68,4096.99,,,,,,,,http://www.properati.com.br/np97_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95723Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Moyss Miguel HaddadM2 rea Construida: 322, Obs: Salo Ideal Para Loja, 217m Terreo e 105m Mezanino. Estacionamento Na Frente.imvel Novo.\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Moyss Miguel Haddad,https://thumbs4.properati.com/2/viy2bV22HhxBkY_449PiH9sLQPw=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1548/9242514f-75f0-4bdc-a5b1-68a958cbda17.jpg\n7266f270625a52c84adaf69ac08a53b397617247,2014-09-01,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,6000,BRL,4744,1536.37,,,,,,,,http://www.properati.com.br/o20x_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95742Tipo: Salo IndustrialCidade: So Jos do Rio Preto - SPBairro: Quinta das PaineirasWcs.: 02, Cozinha: 01, M2/ Terreno: 720, M2 rea Construida: 458,90, Obs: Salo Industrial Grande Com 458m, Com Boa Acessibilidade e tima Localizao, 02 Quadras da Avenida Potirendaba, Fcil Acesso e Entrada Para Caminhes.\",Tipo: Salo Industrial Cidade: So Jos do Rio Preto - SP Bairro: Quinta das Paineiras,https://thumbs4.properati.com/3/0rnMsn1G6rWTjCQ9lnrLn6WR7JA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1771/KET9NdQ0WNrtQlhGEVEq.jpg\nc1e10a8afe111ba5caa5034df1ecb56b25b772f9,2014-08-19,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-22.1423284,-51.3750141\",-22.1423284,-51.3750141,12000,BRL,9488,3072.74,,,,,,,,http://www.properati.com.br/muvz_alugar_casas_sao-jose-do-rio-preto,\"Ref.: CA95766Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Moyss Miguel HaddadWc. Social: 04, M2/ Terreno: 980, M2 rea Construida: 500, Obs: Entrada Lateral Com Acesso Para Caminho, Com 04 Wcs. Sendo 02 Wc P/ Deficiente.\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Moyss Miguel Haddad,https://thumbs4.properati.com/3/GvRImi73Q9koPTqVulTG5pg1J2s=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1519/T181YBWZd62P9tMfgs1F.jpg\nd156f7cc95141ef1fca4de81d6dc65b143c9988f,2014-08-19,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-22.1423284,-51.3750141\",-22.1423284,-51.3750141,12000,BRL,9488,3072.74,,,,,,,,http://www.properati.com.br/muwd_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95749Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Moyss Miguel HaddadM2 rea Construida: 280, Obs: Salo Ideal Para Loja, 217mterreo + 68m Mezanino.estacionamento Na Frente,\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Moyss Miguel Haddad,https://thumbs4.properati.com/5/A5TB5m6yLugDjIdQVsXy9br70YQ=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1533/eeec7b18-daa6-422b-aac1-82894f366e60.jpg\n90ed612641d0db18967ee9c07edc8b0647c97976,2014-08-19,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-22.1423284,-51.3750141\",-22.1423284,-51.3750141,12000,BRL,9488,3072.74,,,,,,,,http://www.properati.com.br/muwc_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95725Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Moyss Miguel HaddadM2 rea Construida: 280, Obs: Salo Ideal Para Loja, 217m Terreo, + 68m Mezanino.estacionamento Na Frente, Imvel Novo\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Moyss Miguel Haddad,https://thumbs4.properati.com/2/8rbyOC1iGXRWRZqh5Z6pcOsdsfQ=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1532/a142f7a0-ff97-4794-84d8-4144253429c3.jpg\nc69e1ca4e7cd3346bc510240893bf2c2c7437e8e,2014-08-19,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-22.1423284,-51.3750141\",-22.1423284,-51.3750141,12000,BRL,9488,3072.74,,,,,,,,http://www.properati.com.br/muvh_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95724Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Moyss Miguel HaddadM2/ Terreno: 280, Obs: Salo Ideal Para Loja, 217m Terreo e + 68m Mezanino.Estacionamento Na Frente,imvel Novo.\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Moyss Miguel Haddad,https://thumbs4.properati.com/1/3gyCoSFzrSme_NcQhKWIEnuoeaY=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1501/CeAfrlKKrQIRE9uzNxoa.jpg\n0c5e391bac4d007585efb02466681c760a758423,2014-09-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8379345,-49.3370139\",-20.8379345,-49.3370139,8000,BRL,6325.32,2048.49,,,,,,,,http://www.properati.com.br/q4jv_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95899Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Ch. Estancia Jockey Clube..: Salo Comercial , Salo Comercial, Wcs.: 04, Wcs. C/ A/e, Cozinha: 01, Obs: P Direito 9 Metros, M2 rea Construida: 1.000, Detalhes: Salo Novo, Sendo Os Wcs Piso Porcelanato, Cozinha Porcelanato e Piso Liso, Com Telhado Vulcanizado.\",Ref.: SA95899 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Ch. Estancia Jockey Clube,https://thumbs4.properati.com/8/OM-MkRbrVgdcQa163wlgXjWDHqU=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2007/8541c9f9-4477-448d-a193-d2b00dc5eed4.jpg\n01bdf823f7827d745574bb7a180a39e4db359463,2014-08-19,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,8000,BRL,6325.32,2048.49,,,,,,,,http://www.properati.com.br/muux_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95894Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Ch. Jockey Clube..: Salo Comercial Na Chcara Jockey Clube, Salo Comercial, M2/ Terreno: 5.000, M2 rea Construida: 1.150, Detalhes: Galpo Com Piso Cimento Polido, Estrutura Metlica, Entrada Lateral Para Caminho.p Direito Alto Com 6,/2 M. \",Ref.: SA95894 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Ch. Jockey Clube,https://thumbs4.properati.com/1/HZjM3Xdq2xWgpQ5gSrCVIASVnJA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1481/Tz9VYOVWXC1ZVU5fUHwL.jpg\n050a20b55332403b962575877b81d609a5988a15,2014-09-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8379345,-49.3370139\",-20.8379345,-49.3370139,8000,BRL,6325.32,2048.49,,,,,,,,http://www.properati.com.br/q4ju_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95900Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Ch. Estancia Jockey Clube..: Salo Comercial , Salo Comercial, Wcs.: 04, Wcs. C/ A/e, Cozinha: 01, Obs: P Direito 9 Metros, M2 rea Construida: 1.000, Detalhes: Salo Novo, Sendo Os Wcs Piso Porcelanato, Cozinha Porcelanato e Piso Liso, Com Telhado Vulcanizado.\",Ref.: SA95900 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Ch. Estancia Jockey Clube,https://thumbs4.properati.com/3/ZL10NlMLKOM5C49dt-MjpOTaaMo=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2006/9fbe5c67-00ba-4e7b-9904-08316f433dd4.jpg\nfeb178373d5eab7df3b6c546a5d1e6a192156c7f,2014-08-19,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8457948,-49.353464\",-20.8457948,-49.353464,8000,BRL,6325.32,2048.49,,,,,,,,http://www.properati.com.br/muuv_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95892Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Ch. Jockey Clube..: Salo Comercial Na Chcara Jockey Clube, Salo Comercial, M2/ Terreno: 5.000, M2 rea Construida: 1.150, Detalhes: Galpo Com Piso Cimento Polido, Estrutura Metlica, Entrada Lateral Para Caminho.p Direito Alto Com 6,/2 M.\",Ref.: SA95892 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Ch. Jockey Clube ,https://thumbs4.properati.com/5/xwV26hopilS3ZK6xLE-O3iy4rE4=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1479/vT0mPYs7U2dAsti8LWGQ.jpg\n882a8542b44e551782d3126a1d43d907cdba854b,2014-08-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8442587,-49.3291753\",-20.8442587,-49.3291753,7000,BRL,5534.66,1792.43,,,,,,,,http://www.properati.com.br/mt5y_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95817Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Marginal Eng. SchimidtWc. Social: 02, Cozinha, Mesanino, Escritrio, M2/ Terreno: 2.000, M2 rea Construida: 1.100, Pintura Nova, Piso Dormitrios: Cimentado;, Piso Salas: Polido;, Detalhes: Salo Novo, Com Estrutura Metlica e Banheiro C/ Acessibilidade.\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Marginal Eng. Schimidt,https://thumbs4.properati.com/4/FPT3HYl7BCrZMA5Okj6iNxUgPRA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1432/MaEwnQ1cL6HLcrscPFb8.jpg\n201e0ccc9fc63a78f68391bdd74ac5407e07da9b,2014-08-04,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,1000,BRL,790.66,256.06,,,,,,1,,http://www.properati.com.br/lxpd_alugar_casas_sao-jose-do-rio-preto,\"Ref.: CA95884Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Parque Liberdade III..:Casa Condomnio Parque da Liberdade III, Casa Condominio, Dormitrios: 03, C/ A/e: 02, Wc. Social: 01, Wcs. C/ A/e, Sala de Visita: 01, Cozinha: 01, Cozinha Planejada, Interfone, Acabamento Em Gesso, Sanca, Campainha, Forro de Laje, Obs: Imvel Semi Novo, Com Projeto da Arquiteta Kenia Miranda, o Imvel Tem Papel de Parede, Cortinas Nos Dormitrios e Sala, Luminrias Na Cozinha., M2/ Terreno: 143, Garagem Coberta: 02, Churrasqueira, Detalhes: R$ 1.000,00 + Iptu + Condominio\",Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Parque Liberdade III,https://thumbs4.properati.com/2/v319TrEqOc-5Hy9xmTQXh1KL2UE=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/807/7331be4a-3945-41cd-b761-4322696192f5.jpg\n05cf0728d4ba6016469b6b4a864542a1824dc25c,2014-08-15,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.7688712,-46.7100661\",-23.7688712,-46.7100661,2000,BRL,1581.32,512.12,,,,,,,,http://www.properati.com.br/mqtt_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95463Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Jardim So MarcosWc. Social: 2, Cozinha: 1, Mesanino, Recepo, M2/ Terreno: 406, M2 rea Construida: 110, Pintura Nova, Piso Salas: Piso Frio\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Jardim So Marcos,https://thumbs4.properati.com/9/EMUy3dbQiGwd8LmfHH92qsmCQ7Q=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1375/srbDQgpudCyI2lewxvjS.jpg\nf5942694fe76bfe705275acfe74148c588367f86,2014-09-01,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.7688712,-46.7100661\",-23.7688712,-46.7100661,4000,BRL,3162.64,1024.24,,,,,,8,,http://www.properati.com.br/o215_alugar_casas_sao-jose-do-rio-preto_rua-antonio-dias,\"Ref.: CO95681Tipo: Casa ComercialCidade: So Jos do Rio Preto - SPBairro: Jardim So MarcosWc. Social: 04, Sala(s): 08, M2/ Terreno: 280, Garagem Coberta: 02, Forro de Laje, Piso Salas: Frio, Iptu Valor: 58,00, Detalhes: timo Local Para Clincas.\",Tipo: Casa Comercial Cidade: So Jos do Rio Preto - SP Bairro: Jardim So Marcos,https://thumbs4.properati.com/4/F58YgAYlsu22NFL9RPPH5RURZJ8=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1779/sr0aS0NcXQGZ7HsOtyan.jpg\n1b093694ca05135b213162c0bd499e9adb708309,2014-12-10,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.6302071,-46.5818983\",-23.6302071,-46.5818983,4000,BRL,3162.64,1024.24,,,,,,,,http://www.properati.com.br/ufsq_alugar_comercial_sao-jose-do-rio-preto_avenida-marginal,\"Ref.: SA95942Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Pq. Res. Manor Daud..: Salo Comercial No Parque Residencial Manor Daud, Salo Comercial, Detalhes: Salo Com 340m, Entrada Para Caminho.frente Para Br153\",Ref.: SA95942 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Pq. Res. Manor Daud,https://thumbs4.properati.com/4/4e8bVPqS0g1ezrFKP9fcdzvSn0I=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2493/rJ3aboE1bZnwuqJHy4Lr.jpg\n50bd70c45a9a9b087c404cac6b67c491f64c211e,2014-10-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,2500,BRL,1976.65,640.15,,,,,,,,http://www.properati.com.br/ri3l_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95916Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Res. Caetano..: Salo Comercial No Residencial Caetano, Salo Comercial, Wcs.: 02, Cozinha: 01, M2 rea Construida: 350, Detalhes: Cozinha2 Wc Com Acessibilidade;1 Porta Na Frente1 Porta Na Lateral;barraco Novo.cobertura Com Estrutura Metlica. P Direito de 6 Metros.\",\"Ref.: SA95916 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Res. Caetano ..: Salo Comercial No Residencial Caetano, Salo Comercial, Wcs.: 02, Cozinha: 01, M2 rea Construida: 350, Detalhes: Cozinha 2 Wc Com Acessibilidade\",https://thumbs4.properati.com/9/l325GXwN3fD9xS0dglH5hmaB83A=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2281/FjKJBqBJFxhvJ4atJVuO.jpg\n50a4a96d3ec01b7b1032e5eec4a3496b38f35c4f,2014-10-09,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7780086,-49.3450562\",-20.7780086,-49.3450562,2500,BRL,1976.65,640.15,,,,,,,,http://www.properati.com.br/r9j7_alugar_casas_sao-jose-do-rio-preto_avenida-belvedere,\"Ref.: CA95904Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Village Damha Rio Preto I..: Casa Em Condomnio Village Damha Rio Preto I, Casa Condominio, Dormitrios: 03, Tipo Apto.: 01, C/ A/e: 03, Cozinha: 01, Cozinha Planejada, Varanda, rea de Servio, Wc. de Empregada/servio, Garagem Coberta: 02, Churrasqueira\",Ref.: CA95904 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Village Damha Rio Preto I,https://thumbs4.properati.com/1/CVM0xMvGlg_DX1BluR3vWpRkhMU=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2206/7f2a5d99-2465-48fb-859c-a52311ba0c18.jpg\nf5be311dcee4a66dcb26f463f76c49b6084de8ab,2014-11-11,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,1600,BRL,1411.74,457.2,,,,,2,1,,http://www.properati.com.br/t8nu_alugar_apartamentos_tatuape_rua-serra-de-braganca,\"APTO DE 40M/2 COM 01 DORMITRIO, SALA AMPLA COM SACADA, COZINHA PLANEJADA, WC SOCIAL, REA DE SERVIO, 01 VAGA DE GARAGEM LIVRE, LAZER COMPLETO COM PISCINA, PLAYGROUD, SALO DE FESTA, CHURRASQUEIRA, SAUNA, SALA DE FITINESS, SALA DE LEITURA, SALO DE JOGOS, EXCELENTE LOCALIZAO. O APTO ESTA COM FOGO, GELADEIRA, MICROONDAS, SUGGAR, BALCO ESTILO COZINHA AMERICANA, 02 BANQUETAS, 02 MESINHAS DE CANTO, 01 HOME-OFFICER, CAMA BOX COM COLCHO, VENTILADOR DE TETO, ARMRIO NO DORMITRIO.\",TOLEDO CONSULTORIA JURIDICA E IMOBILIARIA,https://thumbs4.properati.com/6/-9lTZIOGIGHwHaddpPoJu6s92hY=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/toledo/photos/2/6c5e21eb-1c7b-41d6-9257-b6e7994ea93c.jpg\na031b9b0ccce57678aab134025713cdb4ad7ed19,2014-10-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7630911,-49.3810839\",-20.7630911,-49.3810839,1500,BRL,1185.99,384.09,,,,,,,,http://www.properati.com.br/ri3h_alugar_comercial_sao-jose-do-rio-preto_rua-gumercindo-tomas-de-aquino,\"Ref.: SA95914Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial Centenrio da Emancipao..: Salo Comercial No Distrito Industrial Centenrio da Emancipao, Salo Comercial, Wcs.: 02, Cozinha: 01, M2/ Terreno: 200, M2 rea Construida: 100, Detalhes: Salo Em Construo, Com 2 Wc Com Acessibilidade e Cozinha.porta Frente e Lateral.estrutura Metlica, P Direito 5 Metros.\",Ref.: SA95914 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Dist. Industrial Centenrio da Emancipao,https://thumbs4.properati.com/5/cuTILpWk120n1EtpTTP7CuHqFTI=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2277/xOFbBdd0MAPLxuFIMMMc.jpg\n8198126233f30e45a7ec723640ecd09fc933f35f,2014-10-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7630911,-49.3810839\",-20.7630911,-49.3810839,1500,BRL,1185.99,384.09,,,,,,,,http://www.properati.com.br/ri3j_alugar_comercial_sao-jose-do-rio-preto_rua-gumercindo-tomas-de-aquino,\"Ref.: SA95915Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial Centenrio da Emancipao..: Salo Comercial No Distrito Industrial Centenrio da Emancipao, Salo Comercial, Wcs.: 02, Cozinha: 01, M2/ Terreno: 200, M2 rea Construida: 100, Detalhes: Salo Em Construo, Com 2 Wc Com Acessibilidade e Cozinha.porta Frente e Lateral.estrutura Metlica, P Direito 5 Metros.\",Ref.: SA95915 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Dist. Industrial Centenrio da Emancipao,https://thumbs4.properati.com/0/LWklCEKjfuTI94hwh7U-tpkiQxw=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2279/YBRlngu70KNBBuf3pXju.jpg\ncfe8aedbfe7496306c9ee598fdb0799d1a4d43a7,2014-08-14,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8178411,-49.387605\",-20.8178411,-49.387605,3000,BRL,2371.98,768.18,,,,,,,,http://www.properati.com.br/mpsw_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95860Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: RedentoraSalo Comercial, Terrea, Acabamento Em Gesso, Forro de Laje, Piso Salas: Frio, M2 rea Construida: 160\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Redentora,https://thumbs4.properati.com/1/w-odT7rMEUYcr3TVS4fo2ausAAI=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1278/Kp7YsojHwZdQDjK4JvMz.jpg\n9002dc7d32f4f097a4f9c789ad93b7f5304396cf,2014-08-14,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8178411,-49.387605\",-20.8178411,-49.387605,3000,BRL,2371.98,768.18,,,,,,,,http://www.properati.com.br/mpsz_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95862Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: RedentoraM2 rea Construida: 190, Garagem Descoberta: 02, Forro de Laje, Piso Salas: Frio\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Redentora,https://thumbs4.properati.com/7/xW33i6TXrwRgMFrpyW_syKKKlPs=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1281/UKC5P4P7lZPyQSY9l8xC.jpg\n2128d47314a494ddecda6b6e981c46afbd3fae51,2014-10-30,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,3000,BRL,2371.98,768.18,,,,,,,,http://www.properati.com.br/s9we_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95921Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Redentora..: Salo Comercial Na Redentora, Salo Comercial, M2/ Terreno: 150, Detalhes: tima Localizao!\",Ref.: SA95921 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Redentora,https://thumbs4.properati.com/6/HVrSVpwKgIJpnnFxGcMzyHIeUx4=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2347/abTB8PxHKedvADO9yyhF.jpg\n0329fa7a5536c99eee743612010897cafed7f340,2014-10-15,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8030634,-49.4324803\",-20.8030634,-49.4324803,3000,BRL,2371.98,768.18,,,,,,,,http://www.properati.com.br/re15_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95910Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Res. Palestra..: Salo Comercial No Residencial Palestra, Salo Comercial, Wc. Social: 02, Forro de Laje, Obs: timo Salo, Novo., M2/ Terreno: 250, M2 rea Construida: 200\",Ref.: SA95910 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Res. Palestra,https://thumbs4.properati.com/8/MBuRdEvx5x-V-zpeycnxFNg-n70=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2238/NOqLX7BtA7KEH0SUs5rV.jpg\nc822a8819d8874916877cc6b3c91194cbf5968ca,2014-10-09,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7780086,-49.3450562\",-20.7780086,-49.3450562,3000,BRL,2371.98,768.18,,,,,,,,http://www.properati.com.br/r9j6_alugar_casas_sao-jose-do-rio-preto_avenida-belvedere,\"Ref.: CA95903Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Village Damha Rio Preto I..: Casa Condomnio Village Damha Rio Preto I, Casa Condominio, Dormitrios: 03, Tipo Apto.: 03, C/ A/e: 03, Cozinha: 01, Cozinha Planejada, Fogo, Lavabo, Salas : 01, Sala P/ Ambientes : 02, Piscina, Ducha Externa, Varanda, rea de Servio, Wc. de Empregada/servio, Garagem Coberta: 02, Churrasqueira\",Ref.: CA95903 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Village Damha Rio Preto I,https://thumbs4.properati.com/2/595nD1yJ6OYSKSxIr0D1iMhNilQ=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2205/060de194-4e66-4f1e-b0cc-dd05e276539f.jpg\n2568b8aa05b9d3cb3e9e8ef74c4461c34d2d57b5,2014-10-05,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8350772,-49.369111\",-20.8350772,-49.369111,3000,BRL,2371.98,768.18,,,,,,,,http://www.properati.com.br/r1q8_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95905Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Quinta das Paineiras..: Salo Comercial No Quinta das Paineiras, Salo Comercial, Terrea, Cozinha: 01, Escritrio, M2 rea Construida: 225\",Ref.: SA95905 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Quinta das Paineiras,https://thumbs4.properati.com/6/2tu7W_u475v3n8Ubll7avmjLXPI=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2130/0y7ijJHaZqFbrC0LSLFy.jpg\n0dff14f277324eb9f04d59b6c26758ccdb744e27,2014-12-05,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8126181,-49.3404534\",-20.8126181,-49.3404534,3700,BRL,2925.47,947.43,,,,,,,,http://www.properati.com.br/udzy_alugar_casas_sao-jose-do-rio-preto_avenida-miguel-damha,\"Ref.: CA95938Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Damha III..: Linda Casa Tipo Sobrado No Condomnio Damha Iii, Casa Condominio, Sobrado, Dormitrios: 03, Suite Master: 01, C/ Hidro, Tipo Apto.: 02, C/ A/e, Ducha Dupla, Lavatrio Duplo, Cozinha: 01, Cozinha Planejada, Despensa, Escritrio, Lavabo, Salas : 01, Sala P/ Ambientes : 02, Varanda, rea de Servio, Lavanderia, Quarto de Empregada, Wc. de Empregada/servio, Forro de Laje, Garagem Descoberta: 02, Garagem Coberta: 02, Churrasqueira\",Ref.: CA95938 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Damha III,https://thumbs4.properati.com/0/FGZdTzG9TG6qqioIMRxZ7WLDQWY=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2452/bVU2c6naoVF7KCpphJqL.jpg\n6b3bff7cc70a79dfed41fc0ae514a7ad16a328b8,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,7000,BRL,6640.22,2150.47,,88,24.437159090909088,79.54545454545455,,,,http://www.properati.com.br/ifvc_alugar_comercial_vila-olimpia,\"\tEdifício comercial de excelente padrão, localizado em ponto nobre da Vila Olímpia, próximo da Av. dos Bandeirantes e Shopping Vila Olímpia.\t Prédio conta com 3 elevadores de alta velocidade, sistema de proteção contra incêndio, ar condicionado central, próximo a bancos. Referíªncia: CO65\",\"Comercio no(a) Vila OlÃmpia , São Paulo - SP\",\nc26e111d703af6f65be91df94c274bbd41585a40,2014-08-21,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.6357809,-46.5324419\",-23.6357809,-46.5324419,50000,BRL,39533.44,12803.11,,,,,,,,http://www.properati.com.br/nqbl_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95307Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Rodovia Assis ChateaubriandWcs.: 4, Escritrio, M2/ Terreno: 4.000, Detalhes: Barraco Com 4.000 M\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Rodovia Assis Chateaubriand,https://thumbs4.properati.com/0/lJ2ffPr7gtS2vlQuGTg_zDX6844=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1592/WQmB3JTS7Y2N72aUI5Ud.jpg\n2e8ffab1b55b5245e917b3597009338cb92b6586,2014-10-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7807632,-49.3985157\",-20.7807632,-49.3985157,50000,BRL,39533.44,12803.11,,,,,,,,http://www.properati.com.br/rgyw_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95804Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial Waldemar VerdiM2/ Terreno: 6.000, M2 rea Construida: 3.200\",Salo,https://thumbs4.properati.com/3/X38EXdJIkvCXPQCdWURfrgMMMWQ=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2270/OTptGk75zRPMzZ1pL1on.jpg\nc522d5f57ea8e14d18fa3ba746cb581cd379330a,2014-08-20,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,100000,BRL,79066.88,25606.22,,,,,,,,http://www.properati.com.br/np98_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95721Tipo: Salo IndustrialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial IWcs.: Vrios, M2/ Terreno: 13.000, M2 rea Construida: 8.500, Detalhes: tima Localizao\",Tipo: Salo Industrial Cidade: So Jos do Rio Preto - SP Bairro: Dist. Industrial I,https://thumbs4.properati.com/6/jARKWDWV0rGe-HzGUtUeE0X3knw=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1549/ZaCxToSHCaeCL7GxZ08g.jpg\n07383fafc72a9aa9f0286b841b782ca718485de8,2014-09-01,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8178198,-49.3468294\",-20.8178198,-49.3468294,3500,BRL,2767.31,896.21,,,,,,1,,http://www.properati.com.br/o246_alugar_casas_sao-jose-do-rio-preto_avenida-nadima-damha,\"Ref.: CA95827Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Damha II (Res. Jardins)Dormitrios: 03, Suite: 03, C/ A/e: 03, Sacada, Sala de Visita, Copa, Cozinha, Cozinha Planejada, Despensa, Piscina, rea de Servio, Lavanderia, Wc. de Empregada/servio, Garagem Descoberta: 02, Garagem Coberta: 02, Churrasqueira, Acabamento Em Gesso, Sanca, Forro de Laje, Piso Salas: Frio;, Documentao: Em Dia.\",Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Damha II (Res. Jardins),https://thumbs4.properati.com/6/Bq9TbjreWNnjd2VrvJ5oieGegeQ=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1889/db5caa0b-ec86-44f4-9c0f-ab26a6816b62.jpg\n179f70a47a9ce50e161ea483ffee741a8e1c7fb2,2014-08-14,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8178411,-49.387605\",-20.8178411,-49.387605,3500,BRL,2767.31,896.21,,,,,,,,http://www.properati.com.br/mpsx_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95861Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Redentora TerreaM2 rea Construida: 190, Acabamento Em Gesso, Forro de Laje, Pintura Nova, Piso Salas: Frio\",Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Redentora Terrea,https://thumbs4.properati.com/1/VQPzYdCL1X2L1IkzU17WHDuej9s=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1279/nGCLTL2QhwMJSHGtxR7y.jpg\ne48c41f6ad3dba93e0ac6038a5aa5cda39d651d0,2014-09-01,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-22.1423284,-51.3750141\",-22.1423284,-51.3750141,3500,BRL,2767.31,896.21,,,,,,,,http://www.properati.com.br/o2m6_alugar_casas_sao-jose-do-rio-preto,\"Ref.: CA95898Tipo: Casa Comercial, Casa ResidencialCidade: So Jos do Rio Preto - SPBairro: Moyss Miguel Haddad..: Casa Residencial No Moyss Miguel Haddad, Casa Residencial, Dormitrios: 03, Suite: 03, C/ Hidro: 01, C/ A/e: 03, Sacada, Wc. Social: 01, Wcs. C/ A/e, Sala de Visita: 01, Copa: 01, Cozinha: 01, Cozinha Planejada, Despensa, Escritrio, Salas : 01, Sala P/ Ambientes : 03, Piscina, Varanda, rea de Servio, Lavanderia, Quarto de Empregada, Wc. de Empregada/servio, Ar Split, Ventilador, Interfone, Acabamento Em Gesso, Sanca, Tv Cabo, Campainha, Quintal, Grade, Murado, Forro de Laje, Piso Salas: Frio, Obs: Imvel Prximo Ao Shopping Iguatemi, M2 rea Construida: 450, Garagem Descoberta: 04, Garagem Coberta: 02, Churrasqueira, Jardim de Inverno, Jardim Interno, Iptu Valor: 199,00\",\"Ref.: CA95898 Tipo: Casa Comercial, Casa Residencial Cidade: So Jos do Rio Preto - SP Bairro: Moyss Miguel Haddad\",https://thumbs4.properati.com/5/fZwdpPACH02ObP9aj7-UFvVm0lE=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1937/a7WmbhKqWo7JqFgAHcOD.jpg\n9ea36b0e269fbf2581eef1510f0148594919b330,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,3150,BRL,2988.09,967.71,,45,21.50466666666667,70,,,,http://www.properati.com.br/ifxf_alugar_comercial_chacara-santo-antonio,\"\tNovAmérica Inside Park, o novo marco imobiliário de São Paulo. É um novo espaço, um novo bairro, uma nova opção de vida. É a expansão urbana mais consciente e mais urbana. Um mundo para morar, trabalhar e viver em plena harmonia. Uma região onde a vida social e a vida familiar se equilibram. Um lugar que concretiza o prazer de viver em uma grande metrópole. 1 torre comercial, escritórios de vila e lojas, 5 condomínios residenciais independentes e 2 parques em mais de 70.000 m² de terreno.\t\tNovAmérica Office Park é um conceito totalmente novo de local de trabalho, que valoriza eficiência e a qualidade de vida de seus ocupantes. EDSA em parceria com Neusa Nakata tiveram a missão de conceber um produto inusitado, diferenciado e particular. O resultado é um cenário privilegiado para que você e seus clientes tenham bem-estar em horário comercial.\t\tA região do NovAmérica reúne as principais empresas nacionais e multinacionais, que chegaram por aqui atraídas pela infraestrutura e pelas importantes e fáceis vias de acesso. O que confirma que a região é o novo pólo econômico de São Paulo.\t\tConheça os diferenciais do NovAmérica Office Park:\t\tÚnico na Nações Unidas a integrar um complexo formado por 1 torre comercial, escritórios de vila, lojas e 5 condomínios residenciais independentes.\tConceito consagrado de bem-estar em horário comercial.\tAcesso ao parque com mais de 10 mil m² destinado à cidade.\tPotencial de clientes vindos dos 5 condomínios residenciais.\tA oportunidade de morar e trabalhar de forma integrada e independente.\tServiços by Facilities Office.\tArquitetura e decoração inspirados nas ultimas tendências mundiais.\tProjeto paisagístico assinado por um dos mais conceituados escritórios do mundo, a EDSA. Referíªncia: CO150\",\"Comercio no(a) Chácara Santo Antônio , São Paulo - SP\",https://thumbs4.properati.com/2/2S3OsO4umzIVXFHbHKTrb7dk-x8=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/150/c0d9b8fc-3da8-4a27-ac1f-4e1684d35312.jpg\na5c14fe842664d12fe4bdfad313d5af251121d89,2014-10-01,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5882535,-46.6167776\",-23.5882535,-46.6167776,1400,BRL,1106.91,358.48,,,,,,,,http://www.properati.com.br/qz98_alugar_comercial_sao-jose-do-rio-preto_rua-da-imprensa,Ref.: SA95836Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Vila DinizDetalhes: Salo Comercial Com Aproximadamente 50 M e 02 Banheiros Com Acessibilidade.,Ref.: SA95836 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Vila Diniz,https://thumbs4.properati.com/9/CRgjdJjcRp1Lz-pPZChI7eL8ZIY=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2075/fOuQlJsX61PBHGGwhPSh.jpg\nb64a9bd212d9e91821e5e892eeff464bbc0202cd,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,9600,BRL,9106.57,2949.21,,120,24.57675,80,,,,http://www.properati.com.br/ifwi_alugar_comercial_vila-olimpia,\"\tConjunto Comercial com 120m² de área útil, 3 vagas, 3 banheiros, 1 copa. Referíªncia: CO112\",\"Comercio no(a) Vila OlÃmpia , São Paulo - SP\",https://thumbs4.properati.com/0/3pwYR5bzXi_fuQfUVbzkPbFkst4=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/112/93f2b4dc-86f7-4544-803f-62dfec9dc967.jpg\n59a2898d29cb6860a70355c1b2028b9ab38d89d9,2015-01-13,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5456177,-46.563645\",-23.5456177,-46.563645,1801,BRL,1459.35,472.62,,,,,,2,,http://www.properati.com.br/vyso_alugar_apartamentos_tatuape,\"APTO DE 75M/2, COM 03 DORMITRIOS, SALA AMPLA COM SACADA, COZINHA PLANEJADA, WC SOCIAL, REA DE SERVIO COM WC, REPLETOS DE ARMRIOS, 01 VAGA DE GARAGEM FIXA, EXCELENTE ESTRUTURA DE LAZER COM PISCINA, PLAYGROUD, SALO DE FESTA, CHURRASQUEIRA, SALA DE GINASTICA, EXCELENTE LOCALIZAO, A 100 METROS DO METR.\",LOCAO TATUAPE CONDOMINIO ILHAS GREGAS,https://thumbs4.properati.com/6/-xzzzVWk3lxFITvW285d3YmP00c=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/toledo/photos/13/e7c88a20-dcac-49b2-ae42-e782447d3f07.jpg\n1925426e5575ecd98f1e63422a187081754e0f5c,2015-01-13,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5444872,-46.5657875\",-23.5444872,-46.5657875,1499,BRL,1214.64,393.37,,,,,2,1,,http://www.properati.com.br/vysp_alugar_apartamentos_tatuape_rua-serra-de-braganca,\"APTO DE 40M/2, COM 01 DORMITRIO, SALA AMPLA COM SACADA, COZINHA, WC SOCIAL, SEMI-MOBILIADO COM FOGO, SUGGAR, GELADEIRA, MICRO-ONDAS, ARMRIOS PLANEJADOS NA COZINHA, CAMA BOX, GUARDA ROUPA, VENTILADOR DE TETO, RACK DE TV, 02 BANQUETAS, 02 MESINHAS DE CANTO, EXCELENTE ESTRUTURA DE LAZER COM PISCINA, PLAYGROUD, SALO DE FESTA, CHURRASQUEIRA, SAUNA, SALA DE FITINESS, BRINQUEDOTECA, SALO DE JOGOS, SALA DE LEITURA. EXCELENTE LOCALIZAO, PRXIMO DO SUPERMERCADO PO DE ACAR, FCIL ACESSO PARA O METR.\",NEW CONCEPT 22 A,https://thumbs4.properati.com/5/SXF7T5Zfxal6lcQifCYOufBKjYA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/toledo/photos/14/0522b83e-c37e-4736-ac81-ca8540d2d806.jpg\nfda45d95b6fe8daf9215dcc80ad057531879eda4,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,4575,BRL,4339.84,1405.48,,120,11.712333333333333,38.125,,,,http://www.properati.com.br/ifxz_alugar_comercial_vila-olimpia,\"\tConjunto Comercial na Vila Olimpia, com 61m², podendo aumentar a área para 117m². Referíªncia: CO179\",\"Comercio no(a) Vila OlÃmpia , São Paulo - SP\",https://thumbs4.properati.com/5/cZY_8L_8bfKOV9ay-F-6DsnRZ-c=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/179/0df8ffa9-49a9-49c0-9968-37c13719d904.jpg\n7eb9fb8ad21ff1da490b75fd04643939fd72edf1,2015-01-13,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5508578,-46.5679013\",-23.5508578,-46.5679013,1550,BRL,1255.99,406.76,,,,,11,1,,http://www.properati.com.br/vysn_alugar_apartamentos_tatuape,\"APTO DE 57M/2, COM 02 DORMITRIOS, SALA AMPLA COM SACADA, WC SOCIAL, COZINHA PLANEJADA, AMPLA REA DE SERVIO COM WC, 01 VAGA DE GARAGEM, SALO DE FESTA, CHURRASQUEIRA, PLAYGROUD, QUADRA DE FUTEBOL, EXCELENTE LOCALIZAO, PRXIMO AO SHOPP/METR BOULEVAR TATUAP.\",Apartamento,https://thumbs4.properati.com/4/mwisK4iz0H0viKmBVOGr8B40c1o=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/toledo/photos/12/612ab301-0ac4-4c4c-9691-7278200c2b62.jpg\n59a2934bc5a4c3530613fae471a210ae63498d80,2014-09-02,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.826094,-49.376396\",-20.826094,-49.376396,1200,BRL,960.76,311.15,,,,,,1,,http://www.properati.com.br/ob16_alugar_apartamentos_sao-jose-do-rio-preto,\"Apartamento mobiliado, pronto para morar e com excelente localizao, na zona sul.2 dormitrios sendo 1 tipo apto todos com armrio, ventilador de teto e ar-condicionadosala com sacada para 2 ambientes com sofa e estantecozinha planejada com mesa com 4 cadeiras e fogo2 banheiros com box blindex, gabinete e chuveirorea de servio com armrio, varal e mquina de lavar1 vaga de garagem pilotisCondomnio: Porto eletrnico, portaria 24h, elevador, churrasqueira e piscina.\",Apartamento com 2 dormitrios em Rio Preto,https://thumbs4.properati.com/5/VXcYDJRN6VtNxvgVw-mxJOlL_sA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/premier/photos/111/91c65753-86b6-40fd-8673-7f8f107a8459.jpg\nd08581768058f5286ca4f209222d59a3f10af84d,2015-01-13,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-22.8877586,-47.081265\",-22.8877586,-47.081265,2401,BRL,1945.56,630.08,,,,,19,2,,http://www.properati.com.br/vysm_alugar_apartamentos_tatuape,\"APTO DE 93M/2, COM 03 DORMITRIOS, SENDO UMA SUTE, SALA AMPLA COM SACADA E DOIS AMBIENTES, WC SOCIAL, COZINHA, AMPLA REA DE SERVIO, REPLETO DE ARMRIOS, 02 VAGAS DE GARAGEM, LAZER COMPLETO COM PISCINA, PLAYGROUD, SALO DE FESTA, SALO DE JOGOS, SAUNA, CHURRASQUEIRA, EXCELENTE LOCALIZAO, PRXIMO DA PRAA SILVIO ROMERO.\",APTO LOCAO TATUAP,https://thumbs4.properati.com/3/d45ivnQIryA-xDQvwZrOc1pBPlE=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/toledo/photos/11/85e5aee8-90fa-4295-bcb5-dce1ed7e789c.jpg\n7739326a3915df1654866f5c27d42e40f0038365,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,2200,BRL,2086.92,675.86,,102,6.626078431372549,21.568627450980394,,,,http://www.properati.com.br/ifwp_alugar_comercial_vila-olimpia,\"\tConjunto Comercial com 46m², 2 banheiros, 3 salas, 1 vaga. Referíªncia: CO122\",\"Comercio no(a) Vila OlÃmpia , São Paulo - SP\",https://thumbs4.properati.com/6/QhZrO4ITAg_PJagrwPKR0ywe4GI=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/122/80e20053-aaba-4f3b-a8f7-9dc2cdcb44a7.jpg\n4d949c2fcb78e76cf0cb2376ae3a3acc3b27994a,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,2200,BRL,2086.92,675.86,,46,14.692608695652174,47.82608695652174,,,,http://www.properati.com.br/ifut_alugar_comercial_vila-olimpia,\"Conjunto Comercial na Rua Helena, com 46m², 1 vaga. Referíªncia: CO44\",\"Comercio no(a) Vila OlÃmpia , São Paulo - SP\",https://thumbs4.properati.com/7/rah-1WLiSRF_nrLl-U2mEY27Gus=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/44/3d3a55f5-dd0c-4969-bb57-19f9a554c1f2.jpg\nc98dc531eb6cb68bcec0e60d7dc042bd663caa4d,2014-08-02,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.815191,-49.389435\",-20.815191,-49.389435,5000,BRL,4003.24,1296.47,,,,,,,,http://www.properati.com.br/ludd_alugar_casas_sao-jose-do-rio-preto,\"Imvel com excelente localizao, com recepo, 1 sala com banheiro privativo, 3 salas amplas + 1 interna, todos os ambientes com ar condicionado, cozinha, despensa, garagem interna para vrios veculos. IMVEL SER ENTREGUE REFORMADO E COM AS ACESSIBILIDADES EXIGIDAS!\",Casa Comercial,https://thumbs4.properati.com/8/3TeTIOIgyjtraKZz08buZLIU8W8=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/premier/photos/80/6a0fcb3e-3e78-434f-9624-a5621798b2b2.jpg\ne85c67a2bc55f4834be8d4cf1fd96340ef4ce129,2014-08-04,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8379877,-49.385659\",-20.8379877,-49.385659,800,BRL,632.5,204.84,,,,,,1,,http://www.properati.com.br/lxpc_alugar_casas_higienopolis,\"Ref.: AP95874 Tipo: ApartamentoCidade: So Jos do Rio Preto - SPBairro: Higienpolis..: Apartamento Novo No Jardim Higienopolis, Apartamento, rea Util M2: 50, Dormitrios: 01, C/ A/e, Sacada, Wc. Social: 01, Cozinha C/ A/e, Lavanderia, Elevador, Piso Dormitrios: Porcelanato, Piso Salas: Porcelanato, Obs: Prdio Novo.apto Nunca Habitado., rea de Lazer, Garagem Coberta: 01, Churrasqueira, Espao Gourmet\",Tipo: Apartamento Cidade: So Jos do Rio Preto - SP Bairro: Higienpolis,https://thumbs4.properati.com/6/8tmxgf5PTdg11vI4rVdPwXy39gw=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/806/2077f853-c360-4cd7-b5c8-a33f76415c40.jpg\n33b22b30a043e5a868df61862bb97698b453df89,2014-12-05,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7910821,-49.3781115\",-20.7910821,-49.3781115,800,BRL,632.5,204.84,,,,,,1,,http://www.properati.com.br/ue05_alugar_apartamentos_sao-jose-do-rio-preto_rua-pedro-mulati,\"Ref.: AP95816Tipo: ApartamentoCidade: So Jos do Rio Preto - SPBairro: Jardim ConceioDormitrios: 02, C/ A/e, Wc. Social, Box Wcs., Sala de Estar, Cozinha, Piscina, rea de Servio, Garagem Coberta: 01, rea Util M2: 60, Condominio C/ rea de Lazer, Piscina Adulto, Playground, Valor Condominio R$-: 190,00\",Ref.: AP95816 Tipo: Apartamento Cidade: So Jos do Rio Preto - SP Bairro: Jardim Conceio,https://thumbs4.properati.com/2/4RA-EyT8LswOeQ6lcBbkzLlKjII=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2459/jWyxMNGlETMMxgj2uW9o.jpg\ne2235d97414fcc1a31865b1abe71c1cf85ae01fa,2014-11-17,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7780086,-49.3450562\",-20.7780086,-49.3450562,2700,BRL,2134.78,691.36,,,,,,,,http://www.properati.com.br/tdi5_alugar_casas_sao-jose-do-rio-preto_avenida-belvedere,\"Ref.: CA95926Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Village Damha Rio Preto I..: Casa Condomnio Village Damha Rio Preto, Casa Condominio, Terrea, Dormitrios: 3, Suite: 1, Tipo Apto.: 2, C/ A/e, Lavatrio Duplo: 1, Cozinha C/ A/e: 1, Cozinha Planejada, Escritrio, Lavabo, Salas : 1, Sala P/ Ambientes : 2, Varanda, rea de Servio, Wc. de Empregada/servio, Obs: Espao Para Colocar Piscina, M2 rea Construida: 180, Garagem Descoberta: 2, Garagem Coberta: 2, Churrasqueira, Detalhes: Com Armrios .\",Ref.: CA95926 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Village Damha Rio Preto I ,https://thumbs4.properati.com/3/ZZ3MVCiFvwDaZxqgfl5oQJ1Vp6c=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2390/5d2ae653-6549-4772-b486-cd9360af6ebd.jpg\n1334a26f10895f5a21b6db0adf96e2ff1dc83799,2014-09-16,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7780086,-49.3450562\",-20.7780086,-49.3450562,1250,BRL,988.31,320.07,,,,,,,,http://www.properati.com.br/q4jz_alugar_casas_sao-jose-do-rio-preto_avenida-belvedere,\"Ref.: CA95901Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Maria Stella..: Casa Condomnio Maria Stella, Casa Condominio, Terrea, Dormitrios: 3, Tipo Apto.: 1, Wc. Social: 1, Cozinha: 1, Cozinha Planejada, Salas : 01, Sala P/ Ambientes : 02, Varanda, rea de Servio, Lavanderia, Garagem Coberta: 02, Salo de Festa, Playground, Piscina Adulto, Quadras Poliesportivas: 1, Campo de Futebol: 1, Valor Condominio R$-: 260,00\",Ref.: CA95901 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Maria Stella,https://thumbs4.properati.com/3/nKZAghWN-90yQywuniXpPPLO9sk=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2011/e55aad60-d657-4c71-b732-96032067b76f.jpg\n40c34e74b932ff6e10ff49774a8f7fb6e982c1c2,2014-11-30,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8081017,-49.3505771\",-20.8081017,-49.3505771,4500,BRL,3558.01,1152.28,,,,,,,,http://www.properati.com.br/tz24_alugar_comercial_sao-jose-do-rio-preto_rua-valdomiro-nadotti,\"Ref.: SA95928Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Tres Maria..: Salo Comercial No Bairro Trs Maria, Salo Comercial, Detalhes: 160 Metros Mais 40 Metros de Mesanino.\",Ref.: SA95928 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Tres Maria,https://thumbs4.properati.com/3/Yg9eVpfZKwd2s12w6w1yQ4YYVhY=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2402/MTVaVFERWIxvf0IcXQaQ.jpg\n716428a7c9f46f98fd78e36a72109b0c0ec43753,2014-11-30,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8081017,-49.3505771\",-20.8081017,-49.3505771,4500,BRL,3558.01,1152.28,,,,,,,,http://www.properati.com.br/tz25_alugar_comercial_sao-jose-do-rio-preto_rua-valdomiro-nadotti,\"Ref.: SA95929Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Tres Maria..: Salo Comercial No Bairro Trs Maria, Salo Comercial, M2/ Terreno: 200, Detalhes: 140 Metros Mais 40 Metros de Mesanino.\",Ref.: SA95929 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Tres Maria,https://thumbs4.properati.com/9/a_rVYKLimoBl774T612e8o5Icfo=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2403/fhkihJbLNgNCmHo7eBRG.jpg\n2102860e4c561ee2afec1c15fb71f7c6cfafc28d,2014-08-19,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,9000,BRL,7116.02,2304.56,,,,,,,,http://www.properati.com.br/muuy_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95893Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Ch. Jockey Clube..: Salo Comercial Na Chcara Jockey Clube, Salo Comercial, M2/ Terreno: 5.000, M2 rea Construida: 1.200, Detalhes: Galpo Com Piso Cimento Polido, Estrutura Metlica, Escritrio, Cozinha, 02 Wc, Entrada Pela Frente e Lateral Para Caminho.p Direito Alto Com 6,/2 M.\",Ref.: SA95893 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Ch. Jockey Clube,https://thumbs4.properati.com/3/H-b0Ol05_NX0A5soGXBvD_dA3wk=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1482/gCB4KEDxOoIlG9HlGbCW.jpg\ndb16c4572d4f19875153b041d1dcb6b82115e6a7,2014-11-03,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8174527,-49.4306981\",-20.8174527,-49.4306981,18000,BRL,14232.04,4609.12,,,,,,,,http://www.properati.com.br/sjyy_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95923Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Parque Industrial..: Salo Comercial No Parque Industrial, Salo Comercial, M2/ Terreno: 3.000, M2 rea Construida: 1.780, Detalhes: 1.000m de Patio.escritrio Com Elevado Vrias Salas, Cozinha, Wc, Vestirio Mas/fem;04 Salas de Descanso Para Motorista Com Vestirio, 02 Chuveiros e 04 Wc;cozinha Externa Com Quiosque + 02 Wc Mas/fem;parte Operacional Com P Direito de 7 Metros, e A Lateral Toda Com Embarque e Desembarque;ptio Com Espao Para Carretas, Caminhes e Carros;portaria Na Entrada do Ptio.\",Ref.: SA95923 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Parque Industrial,https://thumbs4.properati.com/0/wtHJ8Xcu4pqyV7YSP06g_N1yrfg=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2356/rO0uXvqSM9ilLshNDz0f.jpg\nc8c81389391a237255bade4a0608212fa62e13b1,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,4200,BRL,3984.12,1290.28,,107,12.058691588785047,39.25233644859813,,,,http://www.properati.com.br/ifxy_alugar_comercial_vila-olimpia,\"\tConjunto Comercial na Vila Olimpia, com 56m² de área útil, excelente localização. Referíªncia: CO178\",\"Comercio no(a) Vila OlÃmpia , São Paulo - SP\",https://thumbs4.properati.com/7/YjqSlFDSnOcayBrp2UtD_X2xaJM=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/178/fa4df6b6-bb36-40a4-a46b-7b1438c77d03.jpg\ne1f129a3f1296a7e7a3b60d5b96d61c49159900c,2014-11-19,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.9295411,-46.3418105\",-23.9295411,-46.3418105,1600,BRL,1628.47,527.39,,32,16.4809375,50,,1,,http://www.properati.com.br/tkvf_alugar_apartamentos_sao-paulo,\"A 3 Milenio oferece este(a) Apartamento em Sao Vicente, no bairro Morro dos Barbosas - 1 dormitórios. Gostou desse(a) Apartamento em Sao Vicente? Então veja mais informações! Acesse o nosso site clicando no botão Contate o anunciante ou ligue - ou ainda entre em contato conosco pelo email vendas@ Obrigado!\",\"Apartamento em Sao Vicente, no bairro Morro dos Barbosas - 1 dormitórios\",https://thumbs4.properati.com/5/FvlCdG4KWDtvmzsszDUttj3AuAY=/trim/198x0/smart/filters:strip_icc()/www.imobiliariasdesantos.com.br/fotos/F00/72/807200-DSC03573.JPG\na8821d1c24cccd45d37ef69cf2b29ef3be744fad,2014-11-17,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8456698,-49.4030718\",-20.8456698,-49.4030718,3200,BRL,2530.11,819.39,,,,,,1,,http://www.properati.com.br/tdi2_alugar_apartamentos_sao-jose-do-rio-preto_avenida-anisio-haddad,\"Ref.: AP95925Tipo: ApartamentoCidade: So Jos do Rio Preto - SPBairro: Cond. Green Fields..: Apartamento No Condomnio Green Fields, Apartamento, rea Util M2: 152, Dormitrios: 03, Suite Master: 01, Closet , Tipo Apto.: 02, C/ A/e: 02, Wc. Social: 02, Sala de Visita: 01, Cozinha Planejada, Garagem Coberta: 02\",Ref.: AP95925 Tipo: Apartamento Cidade: So Jos do Rio Preto - SP Bairro: Cond. Green Fields,https://thumbs4.properati.com/4/oRIx16Fpw4LPkRsB59wlBS_mxVo=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2387/ae2a6d87-5760-44ff-a47f-d237a5321c3c.jpg\n08918f4a93c54f4505b783a8e31ef11842c55e56,2014-10-01,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5882535,-46.6167776\",-23.5882535,-46.6167776,80000,BRL,63253.52,20484.98,,,,,,,,http://www.properati.com.br/qz9a_alugar_comercial_sao-jose-do-rio-preto_rua-da-imprensa,\"Ref.: SA95835Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Vila DinizM2/ Terreno: 12.000, Detalhes: - Salo Com 5.000 M e Bastante rea Para Estacionamento Aberto.Ideal Para Atacadista, Hipermercado e Distribuidora, Entre Outros.\",Ref.: SA95835 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Vila Diniz,https://thumbs4.properati.com/1/PkKXX6cNiwu9krqBtdWDinLG89A=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2077/vSsX8s9IzXdbNCXPId0W.jpg\n1ce358d0b5c35f146d70dff4089a8b3d287cb7b9,2014-10-08,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8085627,-49.3452813\",-20.8085627,-49.3452813,5500,BRL,4348.67,1408.34,,,,,,,,http://www.properati.com.br/r6de_alugar_comercial_sao-jose-do-rio-preto_avenida-sebastiao-tavares-da-silva,\"Ref.: SA95809Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Vista AlegreM2/ Terreno: 360, M2 rea Construida: 360, Detalhes: Salo Com Vrias Divisrias.\",Salo,https://thumbs4.properati.com/8/dhtnMWfIFZ8mMCFVDpxpvjoF0LA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2184/l63oynNjEecn8u1Dzf3W.jpg\nb6ed51b61bb57831cc00d904741b4f544cafc28f,2014-08-04,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8178198,-49.3468294\",-20.8178198,-49.3468294,5500,BRL,4348.67,1408.34,,,,,,1,,http://www.properati.com.br/lxoz_alugar_casas_sao-jose-do-rio-preto,\"Ref.: CA95886Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Damha II(Res. Jardins)..: Casa Condomnio Damha II, Casa Condominio, Sobrado, Dormitrios: 03, Suite: 01, C/ Hidro, Tipo Apto.: 02, C/ A/e: 03, Roupeiro, Home, Cozinha: 01, Cozinha Planejada, Despensa, Escritrio, Lavabo, Salas : 01, Sala P/ Ambientes : 03, Piscina, Varanda, rea de Servio, Quarto de Empregada, Wc. de Empregada/servio, Aquecedor Solar, M2 rea Construida: 350, rea de Lazer, Garagem Descoberta: 03, Garagem Coberta: 03, Churrasqueira\", Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Damha II,https://thumbs4.properati.com/1/gEw1FIkmFLn2_zGBjqnuUzcifYU=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/793/e9d2d588-8d92-4070-b7d6-5b55e4484038.jpg\n1273c64fb8b9402dfad98d3984dec2998d8bdb68,2014-08-15,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8126181,-49.3404534\",-20.8126181,-49.3404534,5500,BRL,4348.67,1408.34,,,,,,1,,http://www.properati.com.br/mqtl_alugar_casas_sao-jose-do-rio-preto_avenida-miguel-damha,\"Ref.: CA95731Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Damha IVDormitrios: 03, Suite: 01, Closet : 01, C/ Hidro, Tipo Apto.: 02, C/ A/e: 02, Wcs.: 05, Wcs. C/ A/e: 04, Box Wcs.: 04, Copa: 01, Cozinha: 01, Cozinha C/ A/e, Despensa, Escritrio, Lavabo, Salas : 01, Sala P/ Ambientes : 02, Piscina, Varanda, rea de Servio, Wc. de Empregada/servio, M2/ Terreno: 420, M2 rea Construida: 380, rea de Lazer, Garagem Descoberta: 02, Garagem Coberta: 02, Churrasqueira, Piso Dormitrios: Porcelanatoq, Piso Salas: Porcelanato, Obs: Imvel Muito Bem Conservado, timo Acabamento., Valor Condominio R$-: 300,00, Detalhes: Toda Reformada.\",Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Damha IV,https://thumbs4.properati.com/6/H5vuIYMzk8pMuWJ-OjcdH4GItq8=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1367/9717e95d-fe6f-4cbc-bb5d-21c2fe92d294.jpg\n6aa6bf8a204f123441efb9f97699ee24cf8f9932,2014-08-15,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8178198,-49.3468294\",-20.8178198,-49.3468294,5500,BRL,4348.67,1408.34,,,,,,1,,http://www.properati.com.br/mqtn_alugar_casas_sao-jose-do-rio-preto_avenida-nadima-damha,\"Ref.: CA95797Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Damha ISobrado, Dormitrios: 03, Suite: 01, Tipo Apto.: 02, Wcs.: 01, Cozinha Planejada, Escritrio, Lavabo, Salas : 01, Sala P/ Ambientes : 03, Piscina, rea de Servio, Wc. de Empregada/servio, rea de Lazer, Garagem Coberta: 03, Churrasqueira, Ar Condicionado, Ventilador, Interfone, Piso Dormitrios: Taco, Piso Salas: Frio\",Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Damha I,https://thumbs4.properati.com/0/NAjAu7cPHQwzwT2j3k1G4X7-A3U=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1369/7b6ca8dd-648a-4e18-b9f5-272073bfbf69.jpg\n8a977c0c4b9ef700024cb2ee3e59de53bcf599c8,2014-07-16,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,2000,BRL,1897.2,614.42,,70,8.77742857142857,28.571428571428573,,2,,http://www.properati.com.br/ifzl_alugar_apartamentos_vila-olimpia,\"\tApartamento na Vila Olimpia, sala para 2 ambientes, 2 dormitórios com armários embutidos, cozinha, lavanderia, quarto na área de serviço, 2 banheiros, 1 vaga. Referíªncia: AP237\",\"Apartamento no(a) Vila OlÃmpia , São Paulo - SP\",https://thumbs4.properati.com/9/waPGo7zWMk2XTzN-br6B0h4Nrgo=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/237/75daca8f-ee37-4813-97af-b4fa00920d4f.jpg\nb48b18eca01fc2732d6afe024904eb1e900cc52a,2014-07-16,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,2000,BRL,1897.2,614.42,,54,11.378148148148147,37.03703703703704,,2,,http://www.properati.com.br/ifzn_alugar_apartamentos_saude,\"\tÓtimo apartamento proximo ao Metrê Praça da Arvore, sala para 2 ambientes, 2 dormitórios com armários embutidos, cozinha com armários, banheiro social e de serviço, 1 vaga na garagem, quadra, piscina e play.\tCom pintura nova, pronto para morar!! Referíªncia: AP239\",\"Apartamento no(a) Saúde , São Paulo - SP\",https://thumbs4.properati.com/0/Zof1-heqqvAgC3xdyYRYiQHIX2I=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/239/450071da-5288-4f26-bbb4-66bcacaef572.jpg\n636cbd951ef34773973cfdef37746b911d07a24c,2014-07-16,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,4000,BRL,3794.41,1228.84,,200,6.1442,20,,4,,http://www.properati.com.br/ifxa_alugar_casas_saude,\"\tAluga sobrado em zona mista, residencial e ou serviços, a uma quadra da Av. do Cursino e a 1000m, do Shopping Plaza Sul, 4 dormitórios, sendo uma suite, ampla sala, mais sala de jantar, cozinha, edicula com 3 quartos, mais lavanderia, garagem para 3 veiculos, casa de esquina, com portões automaticos. Referíªncia: CA145\",\"Casa no(a) Saúde , São Paulo - SP\",https://thumbs4.properati.com/3/0sie3N08lMquoZ655zNy1CeDUbU=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/145/9fef5234-2fa4-4974-b24c-2b3cf6c4c542.jpg\n0812a419c00dcbaaaa926ad4e8de47da7959f9d9,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,5700,BRL,5407.01,1751.09,,127,13.788110236220472,44.881889763779526,,,,http://www.properati.com.br/ifzp_alugar_comercial_saude,\"\tConjunto Comercial proximo do Metrô São Judas, com 109m² de área útil, dividido em 5 salas, com possibilidade de vão livre, 3 banheiro, 1 copa, 3 vagas, prédio 24 horas. Referíªncia: CO241\",\"Comercio no(a) Saúde , São Paulo - SP\",https://thumbs4.properati.com/1/0kl3BRCM4riJX3LhCObIu4wuBxQ=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/241/25ac4ee5-f867-4808-b226-837aefd7ba2c.jpg\n570fece17687d05345022d1567c79dd80bd1dcb0,2014-12-19,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8312527,-49.360102\",-20.8312527,-49.360102,15000,BRL,11860.02,3840.93,,,,,,,,http://www.properati.com.br/viin_alugar_comercial_sao-jose-do-rio-preto_avenida-marginal-lineu-alcantara-gil,\"Ref.: SA95950Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial Campo Verde..: Salo Comercial No Distrito Industrial Campo Verde, Salo Comercial, M2/ Terreno: 5.000, M2 rea Construida: 3.000, Detalhes: tima Localizao Com Asfalto Para Rua, Estacionamento Para 60 Carros.barraco 5.000m2c/ Escritrio Isolado Ao Lado do Galpo C/ 6 Salas Grandes Von Livre Cond. Port.24h.\",Ref.: SA95950 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Dist. Industrial Campo Verde,https://thumbs4.properati.com/6/7lTVZ92lzm8ap5amDGM_eb1EHOU=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2528/yS1CaXFW8XYwJdPuhgGn.jpg\n2ee8e3818d61fdc4e01a5d89be181974d74e4380,2014-08-15,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8424141,-49.3549387\",-20.8424141,-49.3549387,15000,BRL,11860.02,3840.93,,,,,,,,http://www.properati.com.br/mqtc_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95109Tipo: Salo Comercial, Salo IndustrialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial Tancredo Neves .Terrea M2/ Terreno: 2.100, M2 rea Construida: 936, Detalhes: Estacionamento Na Frente,entrada Lateral Para Carga e Descarga,prox. do Carrefour\",\"Tipo: Salo Comercial, Salo Industrial Cidade: So Jos do Rio Preto - SP Bairro: Dist. Industrial Tancredo Neves .\",https://thumbs4.properati.com/9/KVlFV9eR-3HhzpzA5EtDKJICEgY=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1358/k6zhNAyNgH9P1excuuTM.jpg\n0f0577616ba85404705aaead7f35ff6e8b4a91da,2014-08-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5733308,-46.544242\",-23.5733308,-46.544242,15000,BRL,11860.02,3840.93,,,,,,,,http://www.properati.com.br/mt62_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: CO95825 Tipo: Predio Comercial, Salo Comercial, Salo Industrial Cidade: So Jos do Rio Preto - SP Bairro: Pq. IndustrialWc. Social: 02, Salas : 06, M2/ Terreno: 1.113, Documentao: Em Dia., Detalhes: Banheiro Feminino e Masculino, 06 Salas Amplas, Sendo de 420 M a Maior.o Salo Possui Ambientes Para Reunies, Sala de Refeitrio de 93 M e Armrio Embutido, Alarme, Campainha, Interfone, Cerca Eltrica e Estacionamento de 258 M Com 25 Vagas Cobertas.\",\"Tipo: Predio Comercial, Salo Comercial, Salo Industrial Cidade: So Jos do Rio Preto - SP Bairro: Pq. Industrial\",https://thumbs4.properati.com/5/ug_ZifuqqZGKLp9_hQAm2sSvkgE=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/1436/Q14vgp5W0wZIU71vOSui.jpg\n8d6a13266a268bd8dd46f19df78ef71e3509ee5a,2014-10-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7700326,-49.3956468\",-20.7700326,-49.3956468,6500,BRL,5139.33,1664.4,,,,,,,,http://www.properati.com.br/ri3n_alugar_comercial_sao-jose-do-rio-preto_avenida-ada-marzochi-polachini,\"Ref.: SA95917Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Mini Dist. Industrial Adail Vetorasso..: Salo Comercial No Mini Destrito Industrial Adail Vetorasso, Salo Comercial, Wcs.: 02, Cozinha: 01, Escritrio, M2/ Terreno: 1.050, M2 rea Construida: 680, Detalhes: Barraco Novo, Com Pilar No Centro;porta Na Frente e Lateral;cobertura Metlica;p Direito 6 Metros.\",Ref.: SA95917 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Mini Dist. Industrial Adail Vetorasso,https://thumbs4.properati.com/2/3gLCbr6emuqSINA3fAYUW1zaIUM=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2283/grmYrDDM8HnJtcT4RzOD.jpg\ndac06563fc851c6596496ceaff68cbf177bb6dde,2014-10-01,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7863702,-49.3690389\",-20.7863702,-49.3690389,6500,BRL,5139.33,1664.4,,,,,,,,http://www.properati.com.br/qz9p_alugar_comercial_sao-jose-do-rio-preto_avenida-solon-da-silva-varginha,\"Ref.: SA95834Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Vila NovaesWc. Social: 03, Mesanino, M2/ Terreno: 530, M2 rea Construida: 480, Detalhes: Salo Comercial Com P Direito de 6m e Mezanino de 55 M.possui Aparelhagem de Ar Condicionado, Som Ambiente, Cmeras de Segurana e Estacionamento Para 18 Carros.local Ideal Para Lojas, Mercados, Igreja, Depsito, Entre Outros.\",Ref.: SA95834 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Vila Novaes,https://thumbs4.properati.com/8/qD9kCsVbiYyB0WGFw5WesI2Ny6s=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2092/d62f4d68-2ea5-4f83-bde9-fdc53bb504f2.jpg\n1ccba6f23edef69215ca9cfe74214a5276597714,2014-10-18,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,6500,BRL,5139.33,1664.4,,,,,,,,http://www.properati.com.br/ri3q_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: SA95919Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Dist. Industrial Centenrio da Emancipao..: Salo Comercial No Distrito Industrial Centenrio da Emancipao., Salo Comercial, Cozinha: 02, Mesanino, Escritrio, M2 rea Construida: 1.015, Detalhes: Escritrio, Cozinha, 02 Wc, Mezanino, 2 Salas, Entrada Lateral.salo 700 Metrosdiviso 315 Metros.\",Ref.: SA95919 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Dist. Industrial Centenrio da Emancipao,https://thumbs4.properati.com/0/FlEAiRaerT0pRZEeW7AhhCuJjJc=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2286/v22UnUeHl8YbhJaP8IaU.jpg\na9d04cd869b56cac7bcb3a54a27147996a973a3d,2015-01-20,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5418835,-46.5583192\",-23.5418835,-46.5583192,1900,BRL,1539.57,498.6,,,,,16,1,,http://www.properati.com.br/w730_alugar_apartamentos_tatuape,\"APTO DE 58M/2, COM 02 DORMITRIOS, SENDO UMA SUTE, SALA AMPLA COM SACADA, COZINHA, WC SOCIAL, REA DE SERVIO, COM ALGUMAS MOBILIAS, TUDO DE PRIMEIRA, 02 VAGAS DE GARAGEM, LAZER COMPLETE, EXCELENTE LOCALIZAO, A UMA QUADRA DO METR.\",MOBILIADO EXCLUSIVE,https://thumbs4.properati.com/2/KuQXATs4BGrSaZvfCzOSMN2pqWs=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/toledo/photos/19/30ad0c15-c9ef-48f7-a06e-2a0201bb7433.jpg\n5de8d0e3c8872b0af03bdc517a4a12021c48da59,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,24700,BRL,23430.5,7588.09,,289,26.25636678200692,85.46712802768167,,,,http://www.properati.com.br/ifvd_alugar_comercial_vila-olimpia,\"EDIFí�CIO COMERCIAL DE ALTí�SSIMO PADRí�O, LOCALIZADO NO PONTO MAIS NOBRE DA REGIí�O, EM FRENTE AOSHOPPING VILA OLí�MPIA, ENTRE AV. DOS BANDEIRANTES E JUSCELINO KUBITSCHEK. OPRí�DIO CONTA COM 6 ELEVADORES DE ALTA VELOCIDADE, SENDO 1 PANORí�MICO, ARCONDICIONADO CENTRAL, FACHADA EM CORTINA DE VIDRO. EDIFí�CIO INTELIGENTE COMSOFISTICADO SISTEMA DE CONTROLE DE INCí�NDIO, CONSUMO DE ENERGIA. Referíªncia: CO66\",\"Comercio no(a) Vila OlÃmpia , São Paulo - SP\",\n705fdcf676aca2c9f51fcc0a332fcff39ee1706c,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,19500,BRL,18497.74,5990.59,,260,23.04073076923077,75,,,,http://www.properati.com.br/ifyk_alugar_comercial_vila-olimpia,\"\tConjunto Comercial com 260m² de área útil, 6 vagas, Ar condicionado Central. Referíªncia: CO200\",\"Comercio no(a) Vila OlÃmpia , São Paulo - SP\",https://thumbs4.properati.com/0/-wzyHwNVYSdVIvzDSj7dz-RtyfY=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/200/f2ef8797-b051-4b62-b4fe-e8fdb7523a26.jpg\n179ce00d44c6fafbac93e139f052ab4ce304100d,2014-08-04,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,4200,BRL,3320.8,1075.46,,,,,,,,http://www.properati.com.br/lxpa_alugar_casas_sao-jose-do-rio-preto,\"Ref.: CA95877 Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SPBairro: Cond. Village La MontangneDormitrios: 3, Suite: 1, C/ Hidro: 1, Tipo Apto.: 2, C/ A/e: 3, Cozinha Planejada, Escritrio, Lavabo, Salas : 1, Sala P/ Ambientes : 3, Piscina, rea de Servio, M2/ Terreno: 360, M2 rea Construida: 250, Garagem Descoberta: 2, Garagem Coberta: 2\",Tipo: Casa Condominio Cidade: So Jos do Rio Preto - SP Bairro: Cond. Village La Montangne,https://thumbs4.properati.com/8/XTvYImGZcecKNC8xwzjnrrqWibg=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/804/ce485ed8-97e3-4af6-bc5e-3d347e06b31d.jpg\n868d3ff8ea898557e9819638705d2f823431d000,2015-01-13,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.521407,-46.5769202\",-23.521407,-46.5769202,1500,BRL,1215.45,393.63,,,,,13,1,,http://www.properati.com.br/vysr_alugar_apartamentos_tatuape,\"APTO DE 60M/2, COM 02 DORMITRIO, SALA AMPLA COM SACADA, WC SOCIAL, COZINHA, REA DE SERVIO, 01 VAGA DE GARAGEM, EXCELENTE ESTRUTURA DE LAZER COM PISCINA, PLAYGROUD, SALO DE FESTA, CHURRASQUEIRA, EXCELENTE LOCALIZAO, FCIL ACESSO AO SHOPPING METR/TATUAP.\",131 VITORIA,https://thumbs4.properati.com/0/yyioUci-ExAIuKlfIudXKNKaMAs=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/toledo/photos/16/ff90ae98-dbde-47dc-9f04-32cdb2d2d0d3.jpg\nb9de26b4b13ad0f58469677b72b5171c2b04ae1f,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,13500,BRL,12806.12,4147.33,,183,22.663005464480875,73.77049180327869,,,,http://www.properati.com.br/ifv7_alugar_comercial_vila-olimpia,CONJUNTO COMERCIAL - RECEPCAO - HD - 4 SALAS - 2 WC - COPA Referíªncia: CO60,\"Comercio no(a) Vila OlÃmpia , São Paulo - SP\",\n416be9056b69e83819532b65a04c6d1f7135f1ca,2014-12-11,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,2400,BRL,1897.57,614.54,,,,,,,,http://www.properati.com.br/uict_alugar_comercial_jardim-maracana,\"Ref.: SA95945Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Jardim Maracan..: Salo Comercial No Jardim Maracan, Salo Comercial, Wc. Social: 02, Cozinha: 01, Forro de Laje, Piso Salas: Frio, M2/ Terreno: 225, M2 rea Construida: 130\",Ref.: SA95945 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Jardim Maracan,https://thumbs4.properati.com/5/3964ltfTag6YXQlyxEM19akYy1s=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2500/f25a8d90-6759-426c-a4ed-d8aefc6b19a4.jpg\nc1aff8edd38c3254c5775927802a8b8fae45a1d8,2014-08-05,rent,house,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,2400,BRL,1897.57,614.54,,,,,,1,,http://www.properati.com.br/mel5_alugar_casas_bosque-da-saude,\"Ref.: CA95883Tipo: Casa ResidencialCidade: So Jos do Rio Preto - SPBairro: Bosque Da Saude..: Casa Residencial No Bosque da Saude, Casa Residencial, Dormitrios: 03, Tipo Apto.: 01, C/ A/e: 03, Wc. Social: 01, Copa: 01, Cozinha: 01, Despensa, Salas : 02, Varanda, Garagem Descoberta: 02, Garagem Coberta: 02\",Tipo: Casa Residencial Cidade: So Jos do Rio Preto - SP Bairro: Bosque Da Saude,https://thumbs4.properati.com/0/aAdm951DKitDcqkiJ8p68O6xdNA=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/848/083f9a71-b22a-4897-bf14-72fbe585dea5.jpg\ne4d569301fb8a21047d67f193929ecf360e9405e,2015-01-11,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,750,BRL,600.6,194.51,,,,,,1,,http://www.properati.com.br/vxnv_alugar_apartamentos_jardim-satelite,\"Excelente apartamento em frente ao condomnio Quinta das Flores totalmente reformado com 2 dormitrios, sala, cozinha planejada, rea de servio, lavabo com gabinete, wc com box blindex e 1 vaga de garagem.Ideal para casal sem filhos e sem animais\",Excelente apartamento no Jardim Satlite,https://thumbs4.properati.com/1/RhUtbG6YnK8FCa_yOduiEf3yyj0=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/tyrreno/photos/1074/71219c81-463f-4710-a46d-5e7e2fee9362.jpg\n6b59e27767f7570db49b2175077b1c059fa20b88,2014-10-01,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-23.5715766,-46.6697673\",-23.5715766,-46.6697673,3100,BRL,2451.06,793.79,,,,,,,,http://www.properati.com.br/qz9r_alugar_comercial_sao-jose-do-rio-preto_avenida-brasil,\"Ref.: SA95828Tipo: Salo ComercialCidade: So Jos do Rio Preto - SPBairro: Jardim MugnaniWc. Social: 02, Cozinha, M2/ Terreno: 350, M2 rea Construida: 210, Detalhes: - Salo Novo!\", Ref.: SA95828 Tipo: Salo Comercial Cidade: So Jos do Rio Preto - SP Bairro: Jardim Mugnani,https://thumbs4.properati.com/5/ShU0-2rvOmZQkWG3Ml57SSATz_s=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2094/VmmbZZ9w1FykW0ldwpGM.jpg\n3931096ab81c0a74496858654375447e859df8f2,2014-12-05,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.7910821,-49.3781115\",-20.7910821,-49.3781115,880,BRL,695.77,225.33,,,,,,1,,http://www.properati.com.br/ue04_alugar_apartamentos_sao-jose-do-rio-preto_rua-pedro-mulati,\"Ref.: AP95815Tipo: ApartamentoCidade: So Jos do Rio Preto - SPBairro: Jardim ConceioCobertura, Dormitrios: 02, C/ A/e, Wc. Social, Box Wcs., Sala de Estar, Cozinha, Piscina, rea de Servio, rea de Lazer, Garagem Coberta: 01, rea Util M2: 65, Playground\",Ref.: AP95815 Tipo: Apartamento Cidade: So Jos do Rio Preto - SP Bairro: Jardim Conceio,https://thumbs4.properati.com/3/0zqbkQ73LUnQeQMp0yntvzoZrq8=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2458/Dv0976z6fjHNbJf7oAX0.jpg\nc15e079e557ec6ad9d86baede07324e678686764,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,3800,BRL,3604.66,1167.39,,88,13.265795454545456,43.18181818181818,,,,http://www.properati.com.br/ifzr_alugar_comercial_saude,\"\tConjunto Comercial proximo do Metrô São Judas, com 76m², 2 banheiros, 2 vagas, 1 copa, predio 24 horas. Referíªncia: CO243\",\"Comercio no(a) Saúde , São Paulo - SP\",https://thumbs4.properati.com/4/h5_FesPCfkGnmz5ac8c3DvXy_cE=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/243/68c5314b-39b1-4ebd-af49-694d915540b0.jpg\n62cbd166c6e120af08202b4db367db83240ed7e1,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,8500,BRL,8063.11,2611.28,,300,8.704266666666667,28.333333333333332,,,,http://www.properati.com.br/ifw2_alugar_comercial_vila-olimpia,\"\t\t\tLindo(a) Comercio de 300 metros quadrados no bairro Vila Olímpia na cidade de São Paulo - SP.\t\t\t, 2 banheiro(s), 6 vagas(s) de garagem.\t \t\tCondições de Pagamento: \t \t \t \t\tCaracteristicas: Referíªncia: CO93\",\"Comercio no(a) Vila OlÃmpia , São Paulo - SP\",https://thumbs4.properati.com/1/gH6VwXVOHsfAy5JV8elaJg59lGA=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/93/4276fcba-994a-4296-9a35-65c580711457.jpg\n5a5bfb194fdbec1a633f5894a66c8bf45c8ae463,2015-01-06,rent,apartment,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,1100,BRL,869.7,281.66,,,,,,,,http://www.properati.com.br/vv2s_alugar_apartamentos_sao-jose-do-rio-preto,\"Ref.: AP95935Tipo: ApartamentoCidade: So Jos do Rio Preto - SPBairro: Vila Itlia..: Apartamento Residencial Na Vila Itlia, Apartamento, rea Util M2: 63, Dormitrios: 02, Closet , Tipo Apto.: 01, Wc. Social: 01, Sala de Tv, Cozinha: 01, Cozinha C/ A/e, Garagem Coberta: 01, Detalhes: Prdio Novo.\",Ref.: AP95935 Tipo: Apartamento Cidade: So Jos do Rio Preto - SP Bairro: Vila Itlia,https://thumbs4.properati.com/5/XPnfndqmnEKY04smTHRwdeanGd8=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2535/atPPaTZcVegFbIYjH45d.jpg\n8f0adde258e0b9ad5897ba55288b52005ca749a8,2014-07-16,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,,,,3200,BRL,3035.52,983.07,,100,9.8307,32,,,,http://www.properati.com.br/ifxp_alugar_comercial_republica,\"\tLoja/Galpão com mezanino, pé direito duplo, localizada na Av. Amaral Guegel, proximo da Consolação, 2 banheiro e 2 vagas demarcadas. Referíªncia: CO169\",\"Comercio no(a) República , São Paulo - SP\",https://thumbs4.properati.com/6/XJyKobKfgKlfwiO0SB2Tt2i0Y9w=/trim/198x0/smart/filters:strip_icc()/souzapinto.imobfusion01.com.br/accounts/souzapinto/data/photo/169/50b1ac29-6a0b-4488-aaa4-07bc131dd969.jpg\n480229dcb811d916f90a02ffd9c5b62e8a72ccbe,2014-10-15,rent,store,São Paulo,|Brasil|São Paulo|,Brasil,São Paulo,,\"-20.8168432,-49.3930989\",-20.8168432,-49.3930989,17000,BRL,13441.34,4353.05,,,,,,,,http://www.properati.com.br/re16_alugar_comercial_sao-jose-do-rio-preto,\"Ref.: CO95289Tipo: Predio ComercialCidade: So Jos do Rio Preto - SPBairro: Vila dos BancariosM2 rea Construida: 800, Garagem Descoberta: 20, Detalhes: Predio Comercial Com 10m Salas de 80 M Cada, Fica Em Um Pavimento Com Elevador, 20 Vagas Para Estacionar .\",Loja,https://thumbs4.properati.com/4/Z-A_sLSPQOFzDbPb8MkkcY9IwMU=/trim/198x0/smart/filters:strip_icc()/static.if2.com.br/acc/interplan/photos/2239/bsfsEYepcv9Cu4EHDvjU.jpg\n\n",
+ "datasource": {
+ "type": "testdata",
+ "uid": "PD8C576611E62080A"
+ },
+ "refId": "A",
+ "scenarioId": "csv_content"
+ }
+ ],
+ "title": "No pagination, no footer",
+ "transformations": [
+ {
+ "id": "organize",
+ "options": {
+ "excludeByName": {
+ "country_name": true,
+ "created_on": true,
+ "expenses": true,
+ "floor": true,
+ "geonames_id": true,
+ "id": true,
+ "image_thumbnail": true,
+ "lat": true,
+ "lat_lon": true,
+ "lon": true,
+ "operation": true,
+ "place_with_parent_names": true,
+ "price_aprox_local_currency": true,
+ "price_aprox_usd": true,
+ "state_name": true,
+ "surface_total_in_m2": true
+ },
+ "indexByName": {
+ "country_name": 13,
+ "created_on": 9,
+ "currency": 19,
+ "description": 5,
+ "expenses": 24,
+ "floor": 7,
+ "geonames_id": 15,
+ "id": 0,
+ "image_thumbnail": 26,
+ "lat": 17,
+ "lat_lon": 16,
+ "lon": 18,
+ "operation": 8,
+ "place_name": 4,
+ "place_with_parent_names": 12,
+ "price": 11,
+ "price_aprox_local_currency": 20,
+ "price_aprox_usd": 21,
+ "price_per_m2": 23,
+ "price_usd_per_m2": 22,
+ "properati_url": 25,
+ "property_type": 2,
+ "rooms": 6,
+ "state_name": 14,
+ "surface_covered_in_m2": 3,
+ "surface_total_in_m2": 10,
+ "title": 1
+ },
+ "renameByName": {
+ "description": "Description",
+ "place_name": "City",
+ "price": "Price",
+ "properati_url": "URL",
+ "property_type": "Type",
+ "rooms": "Rooms",
+ "surface_covered_in_m2": "Surface",
+ "title": "Property name"
+ }
+ }
+ }
+ ],
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "custom": {
+ "align": "auto",
+ "displayMode": "auto",
+ "inspect": false
+ },
+ "mappings": [
+ {
+ "options": {
+ "rent": {
+ "color": "yellow",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "property_type"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background"
+ },
+ {
+ "id": "mappings",
+ "value": [
+ {
+ "options": {
+ "store": {
+ "color": "blue",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "title"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 205
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Number of rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 66
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Description"
+ },
+ "properties": [
+ {
+ "id": "custom.inspect",
+ "value": true
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "URL"
+ },
+ "properties": [
+ {
+ "id": "links",
+ "value": [
+ {
+ "targetBlank": true,
+ "title": "Show details",
+ "url": "${__value.raw}"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Surface"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background-solid"
+ },
+ {
+ "id": "thresholds",
+ "value": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "dark-red",
+ "value": null
+ },
+ {
+ "color": "orange",
+ "value": 100
+ },
+ {
+ "color": "yellow",
+ "value": 200
+ },
+ {
+ "color": "green",
+ "value": 300
+ },
+ {
+ "color": "#EAB839",
+ "value": 500
+ },
+ {
+ "color": "#6ED0E0",
+ "value": 1000
+ },
+ {
+ "color": "#EF843C",
+ "value": 2000
+ }
+ ]
+ }
+ },
+ {
+ "id": "unit",
+ "value": "areaM2"
+ },
+ {
+ "id": "custom.width",
+ "value": 93
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Type"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 94
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "City"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 120
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 77
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Price"
+ },
+ "properties": [
+ {
+ "id": "unit",
+ "value": "currencyUSD"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 8,
+ "y": 0
+ },
+ "id": 6,
+ "options": {
+ "footer": {
+ "enablePagination": false,
+ "fields": "",
+ "reducer": [
+ "sum"
+ ],
+ "show": true
+ },
+ "showHeader": true,
+ "sortBy": [
+ {
+ "desc": true,
+ "displayName": "Surface"
+ }
+ ]
+ },
+ "pluginVersion": "9.0.0-pre",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 2,
+ "refId": "A"
+ }
+ ],
+ "title": "No pagination, footer",
+ "transformations": [
+ {
+ "id": "organize",
+ "options": {
+ "excludeByName": {
+ "country_name": true,
+ "created_on": true,
+ "expenses": true,
+ "floor": true,
+ "geonames_id": true,
+ "id": true,
+ "image_thumbnail": true,
+ "lat": true,
+ "lat_lon": true,
+ "lon": true,
+ "operation": true,
+ "place_with_parent_names": true,
+ "price_aprox_local_currency": true,
+ "price_aprox_usd": true,
+ "state_name": true,
+ "surface_total_in_m2": true
+ },
+ "indexByName": {
+ "country_name": 13,
+ "created_on": 9,
+ "currency": 19,
+ "description": 5,
+ "expenses": 24,
+ "floor": 7,
+ "geonames_id": 15,
+ "id": 0,
+ "image_thumbnail": 26,
+ "lat": 17,
+ "lat_lon": 16,
+ "lon": 18,
+ "operation": 8,
+ "place_name": 4,
+ "place_with_parent_names": 12,
+ "price": 11,
+ "price_aprox_local_currency": 20,
+ "price_aprox_usd": 21,
+ "price_per_m2": 23,
+ "price_usd_per_m2": 22,
+ "properati_url": 25,
+ "property_type": 2,
+ "rooms": 6,
+ "state_name": 14,
+ "surface_covered_in_m2": 3,
+ "surface_total_in_m2": 10,
+ "title": 1
+ },
+ "renameByName": {
+ "description": "Description",
+ "place_name": "City",
+ "price": "Price",
+ "properati_url": "URL",
+ "property_type": "Type",
+ "rooms": "Rooms",
+ "surface_covered_in_m2": "Surface",
+ "title": "Property name"
+ }
+ }
+ }
+ ],
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "custom": {
+ "align": "auto",
+ "displayMode": "auto",
+ "inspect": false
+ },
+ "mappings": [
+ {
+ "options": {
+ "rent": {
+ "color": "yellow",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "property_type"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background"
+ },
+ {
+ "id": "mappings",
+ "value": [
+ {
+ "options": {
+ "store": {
+ "color": "blue",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "title"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 205
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Number of rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 66
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Description"
+ },
+ "properties": [
+ {
+ "id": "custom.inspect",
+ "value": true
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "URL"
+ },
+ "properties": [
+ {
+ "id": "links",
+ "value": [
+ {
+ "targetBlank": true,
+ "title": "Show details",
+ "url": "${__value.raw}"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Surface"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background-solid"
+ },
+ {
+ "id": "thresholds",
+ "value": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "dark-red",
+ "value": null
+ },
+ {
+ "color": "orange",
+ "value": 100
+ },
+ {
+ "color": "yellow",
+ "value": 200
+ },
+ {
+ "color": "green",
+ "value": 300
+ },
+ {
+ "color": "#EAB839",
+ "value": 500
+ },
+ {
+ "color": "#6ED0E0",
+ "value": 1000
+ },
+ {
+ "color": "#EF843C",
+ "value": 2000
+ }
+ ]
+ }
+ },
+ {
+ "id": "unit",
+ "value": "areaM2"
+ },
+ {
+ "id": "custom.width",
+ "value": 93
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Type"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 94
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "City"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 120
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 77
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Price"
+ },
+ "properties": [
+ {
+ "id": "unit",
+ "value": "currencyUSD"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 16,
+ "y": 0
+ },
+ "id": 9,
+ "options": {
+ "footer": {
+ "enablePagination": false,
+ "fields": "",
+ "reducer": [
+ "sum"
+ ],
+ "show": false
+ },
+ "showHeader": false,
+ "sortBy": [
+ {
+ "desc": true,
+ "displayName": "Surface"
+ }
+ ]
+ },
+ "pluginVersion": "9.0.0-pre",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 2,
+ "refId": "A"
+ }
+ ],
+ "transformations": [
+ {
+ "id": "organize",
+ "options": {
+ "excludeByName": {
+ "country_name": true,
+ "created_on": true,
+ "expenses": true,
+ "floor": true,
+ "geonames_id": true,
+ "id": true,
+ "image_thumbnail": true,
+ "lat": true,
+ "lat_lon": true,
+ "lon": true,
+ "operation": true,
+ "place_with_parent_names": true,
+ "price_aprox_local_currency": true,
+ "price_aprox_usd": true,
+ "state_name": true,
+ "surface_total_in_m2": true
+ },
+ "indexByName": {
+ "country_name": 13,
+ "created_on": 9,
+ "currency": 19,
+ "description": 5,
+ "expenses": 24,
+ "floor": 7,
+ "geonames_id": 15,
+ "id": 0,
+ "image_thumbnail": 26,
+ "lat": 17,
+ "lat_lon": 16,
+ "lon": 18,
+ "operation": 8,
+ "place_name": 4,
+ "place_with_parent_names": 12,
+ "price": 11,
+ "price_aprox_local_currency": 20,
+ "price_aprox_usd": 21,
+ "price_per_m2": 23,
+ "price_usd_per_m2": 22,
+ "properati_url": 25,
+ "property_type": 2,
+ "rooms": 6,
+ "state_name": 14,
+ "surface_covered_in_m2": 3,
+ "surface_total_in_m2": 10,
+ "title": 1
+ },
+ "renameByName": {
+ "description": "Description",
+ "place_name": "City",
+ "price": "Price",
+ "properati_url": "URL",
+ "property_type": "Type",
+ "rooms": "Rooms",
+ "surface_covered_in_m2": "Surface",
+ "title": "Property name"
+ }
+ }
+ }
+ ],
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "custom": {
+ "align": "auto",
+ "displayMode": "auto",
+ "inspect": false
+ },
+ "mappings": [
+ {
+ "options": {
+ "rent": {
+ "color": "yellow",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "property_type"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background"
+ },
+ {
+ "id": "mappings",
+ "value": [
+ {
+ "options": {
+ "store": {
+ "color": "blue",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "title"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 205
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Number of rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 66
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Description"
+ },
+ "properties": [
+ {
+ "id": "custom.inspect",
+ "value": true
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "URL"
+ },
+ "properties": [
+ {
+ "id": "links",
+ "value": [
+ {
+ "targetBlank": true,
+ "title": "Show details",
+ "url": "${__value.raw}"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Surface"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background-solid"
+ },
+ {
+ "id": "thresholds",
+ "value": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "dark-red",
+ "value": null
+ },
+ {
+ "color": "orange",
+ "value": 100
+ },
+ {
+ "color": "yellow",
+ "value": 200
+ },
+ {
+ "color": "green",
+ "value": 300
+ },
+ {
+ "color": "#EAB839",
+ "value": 500
+ },
+ {
+ "color": "#6ED0E0",
+ "value": 1000
+ },
+ {
+ "color": "#EF843C",
+ "value": 2000
+ }
+ ]
+ }
+ },
+ {
+ "id": "unit",
+ "value": "areaM2"
+ },
+ {
+ "id": "custom.width",
+ "value": 93
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Type"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 94
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "City"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 120
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 77
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Price"
+ },
+ "properties": [
+ {
+ "id": "unit",
+ "value": "currencyUSD"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 8
+ },
+ "id": 7,
+ "options": {
+ "footer": {
+ "enablePagination": true,
+ "fields": "",
+ "reducer": [
+ "sum"
+ ],
+ "show": false
+ },
+ "showHeader": true,
+ "sortBy": [
+ {
+ "desc": true,
+ "displayName": "Surface"
+ }
+ ]
+ },
+ "pluginVersion": "9.0.0-pre",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 2,
+ "refId": "A"
+ }
+ ],
+ "title": "Pagination, no footer",
+ "transformations": [
+ {
+ "id": "organize",
+ "options": {
+ "excludeByName": {
+ "country_name": true,
+ "created_on": true,
+ "expenses": true,
+ "floor": true,
+ "geonames_id": true,
+ "id": true,
+ "image_thumbnail": true,
+ "lat": true,
+ "lat_lon": true,
+ "lon": true,
+ "operation": true,
+ "place_with_parent_names": true,
+ "price_aprox_local_currency": true,
+ "price_aprox_usd": true,
+ "state_name": true,
+ "surface_total_in_m2": true
+ },
+ "indexByName": {
+ "country_name": 13,
+ "created_on": 9,
+ "currency": 19,
+ "description": 5,
+ "expenses": 24,
+ "floor": 7,
+ "geonames_id": 15,
+ "id": 0,
+ "image_thumbnail": 26,
+ "lat": 17,
+ "lat_lon": 16,
+ "lon": 18,
+ "operation": 8,
+ "place_name": 4,
+ "place_with_parent_names": 12,
+ "price": 11,
+ "price_aprox_local_currency": 20,
+ "price_aprox_usd": 21,
+ "price_per_m2": 23,
+ "price_usd_per_m2": 22,
+ "properati_url": 25,
+ "property_type": 2,
+ "rooms": 6,
+ "state_name": 14,
+ "surface_covered_in_m2": 3,
+ "surface_total_in_m2": 10,
+ "title": 1
+ },
+ "renameByName": {
+ "description": "Description",
+ "place_name": "City",
+ "price": "Price",
+ "properati_url": "URL",
+ "property_type": "Type",
+ "rooms": "Rooms",
+ "surface_covered_in_m2": "Surface",
+ "title": "Property name"
+ }
+ }
+ }
+ ],
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "custom": {
+ "align": "auto",
+ "displayMode": "auto",
+ "inspect": false
+ },
+ "mappings": [
+ {
+ "options": {
+ "rent": {
+ "color": "yellow",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "property_type"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background"
+ },
+ {
+ "id": "mappings",
+ "value": [
+ {
+ "options": {
+ "store": {
+ "color": "blue",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "title"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 205
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Number of rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 66
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Description"
+ },
+ "properties": [
+ {
+ "id": "custom.inspect",
+ "value": true
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "URL"
+ },
+ "properties": [
+ {
+ "id": "links",
+ "value": [
+ {
+ "targetBlank": true,
+ "title": "Show details",
+ "url": "${__value.raw}"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Surface"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background-solid"
+ },
+ {
+ "id": "thresholds",
+ "value": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "dark-red",
+ "value": null
+ },
+ {
+ "color": "orange",
+ "value": 100
+ },
+ {
+ "color": "yellow",
+ "value": 200
+ },
+ {
+ "color": "green",
+ "value": 300
+ },
+ {
+ "color": "#EAB839",
+ "value": 500
+ },
+ {
+ "color": "#6ED0E0",
+ "value": 1000
+ },
+ {
+ "color": "#EF843C",
+ "value": 2000
+ }
+ ]
+ }
+ },
+ {
+ "id": "unit",
+ "value": "areaM2"
+ },
+ {
+ "id": "custom.width",
+ "value": 93
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Type"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 94
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "City"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 120
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 77
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Price"
+ },
+ "properties": [
+ {
+ "id": "unit",
+ "value": "currencyUSD"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "id": 8,
+ "options": {
+ "footer": {
+ "enablePagination": true,
+ "fields": "",
+ "reducer": [
+ "sum"
+ ],
+ "show": true
+ },
+ "showHeader": true,
+ "sortBy": [
+ {
+ "desc": true,
+ "displayName": "Surface"
+ }
+ ]
+ },
+ "pluginVersion": "9.0.0-pre",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 2,
+ "refId": "A"
+ }
+ ],
+ "title": "Pagination and footer",
+ "transformations": [
+ {
+ "id": "organize",
+ "options": {
+ "excludeByName": {
+ "country_name": true,
+ "created_on": true,
+ "expenses": true,
+ "floor": true,
+ "geonames_id": true,
+ "id": true,
+ "image_thumbnail": true,
+ "lat": true,
+ "lat_lon": true,
+ "lon": true,
+ "operation": true,
+ "place_with_parent_names": true,
+ "price_aprox_local_currency": true,
+ "price_aprox_usd": true,
+ "state_name": true,
+ "surface_total_in_m2": true
+ },
+ "indexByName": {
+ "country_name": 13,
+ "created_on": 9,
+ "currency": 19,
+ "description": 5,
+ "expenses": 24,
+ "floor": 7,
+ "geonames_id": 15,
+ "id": 0,
+ "image_thumbnail": 26,
+ "lat": 17,
+ "lat_lon": 16,
+ "lon": 18,
+ "operation": 8,
+ "place_name": 4,
+ "place_with_parent_names": 12,
+ "price": 11,
+ "price_aprox_local_currency": 20,
+ "price_aprox_usd": 21,
+ "price_per_m2": 23,
+ "price_usd_per_m2": 22,
+ "properati_url": 25,
+ "property_type": 2,
+ "rooms": 6,
+ "state_name": 14,
+ "surface_covered_in_m2": 3,
+ "surface_total_in_m2": 10,
+ "title": 1
+ },
+ "renameByName": {
+ "description": "Description",
+ "place_name": "City",
+ "price": "Price",
+ "properati_url": "URL",
+ "property_type": "Type",
+ "rooms": "Rooms",
+ "surface_covered_in_m2": "Surface",
+ "title": "Property name"
+ }
+ }
+ }
+ ],
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "custom": {
+ "align": "auto",
+ "displayMode": "auto",
+ "inspect": false
+ },
+ "mappings": [
+ {
+ "options": {
+ "rent": {
+ "color": "yellow",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "property_type"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background"
+ },
+ {
+ "id": "mappings",
+ "value": [
+ {
+ "options": {
+ "store": {
+ "color": "blue",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "title"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 205
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Number of rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 66
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Description"
+ },
+ "properties": [
+ {
+ "id": "custom.inspect",
+ "value": true
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "URL"
+ },
+ "properties": [
+ {
+ "id": "links",
+ "value": [
+ {
+ "targetBlank": true,
+ "title": "Show details",
+ "url": "${__value.raw}"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Surface"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background-solid"
+ },
+ {
+ "id": "thresholds",
+ "value": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "dark-red",
+ "value": null
+ },
+ {
+ "color": "orange",
+ "value": 100
+ },
+ {
+ "color": "yellow",
+ "value": 200
+ },
+ {
+ "color": "green",
+ "value": 300
+ },
+ {
+ "color": "#EAB839",
+ "value": 500
+ },
+ {
+ "color": "#6ED0E0",
+ "value": 1000
+ },
+ {
+ "color": "#EF843C",
+ "value": 2000
+ }
+ ]
+ }
+ },
+ {
+ "id": "unit",
+ "value": "areaM2"
+ },
+ {
+ "id": "custom.width",
+ "value": 93
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Type"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 94
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "City"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 120
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 77
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Price"
+ },
+ "properties": [
+ {
+ "id": "unit",
+ "value": "currencyUSD"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 0,
+ "y": 16
+ },
+ "id": 10,
+ "options": {
+ "footer": {
+ "enablePagination": true,
+ "fields": "",
+ "reducer": [
+ "sum"
+ ],
+ "show": false
+ },
+ "showHeader": false,
+ "sortBy": [
+ {
+ "desc": true,
+ "displayName": "Surface"
+ }
+ ]
+ },
+ "pluginVersion": "9.0.0-pre",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 2,
+ "refId": "A"
+ }
+ ],
+ "title": "Pagination, no footer, no header",
+ "transformations": [
+ {
+ "id": "organize",
+ "options": {
+ "excludeByName": {
+ "country_name": true,
+ "created_on": true,
+ "expenses": true,
+ "floor": true,
+ "geonames_id": true,
+ "id": true,
+ "image_thumbnail": true,
+ "lat": true,
+ "lat_lon": true,
+ "lon": true,
+ "operation": true,
+ "place_with_parent_names": true,
+ "price_aprox_local_currency": true,
+ "price_aprox_usd": true,
+ "state_name": true,
+ "surface_total_in_m2": true
+ },
+ "indexByName": {
+ "country_name": 13,
+ "created_on": 9,
+ "currency": 19,
+ "description": 5,
+ "expenses": 24,
+ "floor": 7,
+ "geonames_id": 15,
+ "id": 0,
+ "image_thumbnail": 26,
+ "lat": 17,
+ "lat_lon": 16,
+ "lon": 18,
+ "operation": 8,
+ "place_name": 4,
+ "place_with_parent_names": 12,
+ "price": 11,
+ "price_aprox_local_currency": 20,
+ "price_aprox_usd": 21,
+ "price_per_m2": 23,
+ "price_usd_per_m2": 22,
+ "properati_url": 25,
+ "property_type": 2,
+ "rooms": 6,
+ "state_name": 14,
+ "surface_covered_in_m2": 3,
+ "surface_total_in_m2": 10,
+ "title": 1
+ },
+ "renameByName": {
+ "description": "Description",
+ "place_name": "City",
+ "price": "Price",
+ "properati_url": "URL",
+ "property_type": "Type",
+ "rooms": "Rooms",
+ "surface_covered_in_m2": "Surface",
+ "title": "Property name"
+ }
+ }
+ }
+ ],
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "custom": {
+ "align": "auto",
+ "displayMode": "auto",
+ "inspect": false
+ },
+ "mappings": [
+ {
+ "options": {
+ "rent": {
+ "color": "yellow",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "property_type"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background"
+ },
+ {
+ "id": "mappings",
+ "value": [
+ {
+ "options": {
+ "store": {
+ "color": "blue",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "title"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 205
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Number of rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 66
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Description"
+ },
+ "properties": [
+ {
+ "id": "custom.inspect",
+ "value": true
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "URL"
+ },
+ "properties": [
+ {
+ "id": "links",
+ "value": [
+ {
+ "targetBlank": true,
+ "title": "Show details",
+ "url": "${__value.raw}"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Surface"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background-solid"
+ },
+ {
+ "id": "thresholds",
+ "value": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "dark-red",
+ "value": null
+ },
+ {
+ "color": "orange",
+ "value": 100
+ },
+ {
+ "color": "yellow",
+ "value": 200
+ },
+ {
+ "color": "green",
+ "value": 300
+ },
+ {
+ "color": "#EAB839",
+ "value": 500
+ },
+ {
+ "color": "#6ED0E0",
+ "value": 1000
+ },
+ {
+ "color": "#EF843C",
+ "value": 2000
+ }
+ ]
+ }
+ },
+ {
+ "id": "unit",
+ "value": "areaM2"
+ },
+ {
+ "id": "custom.width",
+ "value": 93
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Type"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 94
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "City"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 120
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 77
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Price"
+ },
+ "properties": [
+ {
+ "id": "unit",
+ "value": "currencyUSD"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 8,
+ "y": 16
+ },
+ "id": 11,
+ "options": {
+ "footer": {
+ "enablePagination": true,
+ "fields": "",
+ "reducer": [
+ "sum"
+ ],
+ "show": true
+ },
+ "showHeader": false,
+ "sortBy": [
+ {
+ "desc": true,
+ "displayName": "Surface"
+ }
+ ]
+ },
+ "pluginVersion": "9.0.0-pre",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 2,
+ "refId": "A"
+ }
+ ],
+ "title": "Pagination, footer, no header",
+ "transformations": [
+ {
+ "id": "organize",
+ "options": {
+ "excludeByName": {
+ "country_name": true,
+ "created_on": true,
+ "expenses": true,
+ "floor": true,
+ "geonames_id": true,
+ "id": true,
+ "image_thumbnail": true,
+ "lat": true,
+ "lat_lon": true,
+ "lon": true,
+ "operation": true,
+ "place_with_parent_names": true,
+ "price_aprox_local_currency": true,
+ "price_aprox_usd": true,
+ "state_name": true,
+ "surface_total_in_m2": true
+ },
+ "indexByName": {
+ "country_name": 13,
+ "created_on": 9,
+ "currency": 19,
+ "description": 5,
+ "expenses": 24,
+ "floor": 7,
+ "geonames_id": 15,
+ "id": 0,
+ "image_thumbnail": 26,
+ "lat": 17,
+ "lat_lon": 16,
+ "lon": 18,
+ "operation": 8,
+ "place_name": 4,
+ "place_with_parent_names": 12,
+ "price": 11,
+ "price_aprox_local_currency": 20,
+ "price_aprox_usd": 21,
+ "price_per_m2": 23,
+ "price_usd_per_m2": 22,
+ "properati_url": 25,
+ "property_type": 2,
+ "rooms": 6,
+ "state_name": 14,
+ "surface_covered_in_m2": 3,
+ "surface_total_in_m2": 10,
+ "title": 1
+ },
+ "renameByName": {
+ "description": "Description",
+ "place_name": "City",
+ "price": "Price",
+ "properati_url": "URL",
+ "property_type": "Type",
+ "rooms": "Rooms",
+ "surface_covered_in_m2": "Surface",
+ "title": "Property name"
+ }
+ }
+ }
+ ],
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "custom": {
+ "align": "auto",
+ "displayMode": "auto",
+ "inspect": false
+ },
+ "mappings": [
+ {
+ "options": {
+ "rent": {
+ "color": "yellow",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "property_type"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background"
+ },
+ {
+ "id": "mappings",
+ "value": [
+ {
+ "options": {
+ "store": {
+ "color": "blue",
+ "index": 0
+ }
+ },
+ "type": "value"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "title"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 205
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Number of rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 66
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Description"
+ },
+ "properties": [
+ {
+ "id": "custom.inspect",
+ "value": true
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "URL"
+ },
+ "properties": [
+ {
+ "id": "links",
+ "value": [
+ {
+ "targetBlank": true,
+ "title": "Show details",
+ "url": "${__value.raw}"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Surface"
+ },
+ "properties": [
+ {
+ "id": "custom.displayMode",
+ "value": "color-background-solid"
+ },
+ {
+ "id": "thresholds",
+ "value": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "dark-red",
+ "value": null
+ },
+ {
+ "color": "orange",
+ "value": 100
+ },
+ {
+ "color": "yellow",
+ "value": 200
+ },
+ {
+ "color": "green",
+ "value": 300
+ },
+ {
+ "color": "#EAB839",
+ "value": 500
+ },
+ {
+ "color": "#6ED0E0",
+ "value": 1000
+ },
+ {
+ "color": "#EF843C",
+ "value": 2000
+ }
+ ]
+ }
+ },
+ {
+ "id": "unit",
+ "value": "areaM2"
+ },
+ {
+ "id": "custom.width",
+ "value": 93
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Type"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 94
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "City"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 120
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Rooms"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 77
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Price"
+ },
+ "properties": [
+ {
+ "id": "unit",
+ "value": "currencyUSD"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 16,
+ "y": 16
+ },
+ "id": 12,
+ "options": {
+ "footer": {
+ "enablePagination": true,
+ "fields": "",
+ "reducer": [
+ "sum"
+ ],
+ "show": true
+ },
+ "showHeader": false,
+ "sortBy": [
+ {
+ "desc": true,
+ "displayName": "Surface"
+ }
+ ]
+ },
+ "pluginVersion": "9.0.0-pre",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "-- Dashboard --"
+ },
+ "panelId": 2,
+ "refId": "A"
+ }
+ ],
+ "transformations": [
+ {
+ "id": "organize",
+ "options": {
+ "excludeByName": {
+ "country_name": true,
+ "created_on": true,
+ "expenses": true,
+ "floor": true,
+ "geonames_id": true,
+ "id": true,
+ "image_thumbnail": true,
+ "lat": true,
+ "lat_lon": true,
+ "lon": true,
+ "operation": true,
+ "place_with_parent_names": true,
+ "price_aprox_local_currency": true,
+ "price_aprox_usd": true,
+ "state_name": true,
+ "surface_total_in_m2": true
+ },
+ "indexByName": {
+ "country_name": 13,
+ "created_on": 9,
+ "currency": 19,
+ "description": 5,
+ "expenses": 24,
+ "floor": 7,
+ "geonames_id": 15,
+ "id": 0,
+ "image_thumbnail": 26,
+ "lat": 17,
+ "lat_lon": 16,
+ "lon": 18,
+ "operation": 8,
+ "place_name": 4,
+ "place_with_parent_names": 12,
+ "price": 11,
+ "price_aprox_local_currency": 20,
+ "price_aprox_usd": 21,
+ "price_per_m2": 23,
+ "price_usd_per_m2": 22,
+ "properati_url": 25,
+ "property_type": 2,
+ "rooms": 6,
+ "state_name": 14,
+ "surface_covered_in_m2": 3,
+ "surface_total_in_m2": 10,
+ "title": 1
+ },
+ "renameByName": {
+ "description": "Description",
+ "place_name": "City",
+ "price": "Price",
+ "properati_url": "URL",
+ "property_type": "Type",
+ "rooms": "Rooms",
+ "surface_covered_in_m2": "Surface",
+ "title": "Property name"
+ }
+ }
+ }
+ ],
+ "type": "table"
+ }
+ ],
+ "schemaVersion": 32,
+ "style": "dark",
+ "tags": [
+ "gdev",
+ "panel-tests"
+ ],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "",
+ "title": "Table panel - Pagination",
+ "uid": "1YGJxPUnz",
+ "version": 2,
+ "weekStart": ""
+}
\ No newline at end of file
diff --git a/devenv/docker/blocks/alert_webhook_listener/Dockerfile b/devenv/docker/blocks/alert_webhook_listener/Dockerfile
index a6bb87f15f5..84c2b31588e 100644
--- a/devenv/docker/blocks/alert_webhook_listener/Dockerfile
+++ b/devenv/docker/blocks/alert_webhook_listener/Dockerfile
@@ -2,6 +2,6 @@
FROM golang:latest
ADD main.go /
WORKDIR /
-RUN go build -o main .
+RUN go mod init alert_webhook_listener && go build -o main .
EXPOSE 3010
ENTRYPOINT ["/main"]
diff --git a/devenv/docker/blocks/alert_webhook_listener/docker-compose.yaml b/devenv/docker/blocks/alert_webhook_listener/docker-compose.yaml
index 3e6c5389f6c..087ce5bac00 100644
--- a/devenv/docker/blocks/alert_webhook_listener/docker-compose.yaml
+++ b/devenv/docker/blocks/alert_webhook_listener/docker-compose.yaml
@@ -1,5 +1,4 @@
alert_webhook_listener:
build: docker/blocks/alert_webhook_listener
- network_mode: host
ports:
- "3010:3010"
diff --git a/docs/sources/administration/_index.md b/docs/sources/administration/_index.md
index 5c5b6606b18..deb198775b5 100644
--- a/docs/sources/administration/_index.md
+++ b/docs/sources/administration/_index.md
@@ -12,3 +12,4 @@ This section includes information for Grafana administrators, team administrator
- [Configuration]({{< relref "configuration" >}})
- [Configure Docker image]({{< relref "configure-docker" >}})
- [Security]({{< relref "security" >}})
+- [Service accounts]({{< relref "service-accounts" >}})
diff --git a/docs/sources/administration/api-keys/_index.md b/docs/sources/administration/api-keys/_index.md
new file mode 100644
index 00000000000..3ed45a97d02
--- /dev/null
+++ b/docs/sources/administration/api-keys/_index.md
@@ -0,0 +1,17 @@
+---
+title: 'API keys in Grafana'
+menuTitle: 'API keys'
+description: 'This section contains information about API keys in Grafana'
+weight: 300
+keywords:
+ - API keys
+ - Service accounts
+---
+
+# API keys in Grafana
+
+API Keys can be used to interact with Grafana HTTP APIs.
+
+We recommend using service accounts instead of API keys if you are on Grafana 8.5+, for more information refer to [About service accounts]({{< relref "../service-accounts/about-service-accounts.md#">}}).
+
+{{< section >}}
diff --git a/docs/sources/administration/api-keys/about-api-keys.md b/docs/sources/administration/api-keys/about-api-keys.md
new file mode 100644
index 00000000000..67ecd4282fd
--- /dev/null
+++ b/docs/sources/administration/api-keys/about-api-keys.md
@@ -0,0 +1,12 @@
+---
+title: About API keys in Grafana
+menuTitle: About API keys
+description: 'Learn about using API keys in Grafana'
+weight: 30
+---
+
+# About API keys in Grafana
+
+An API key is a randomly generated string that external systems use to interact with Grafana HTTP APIs.
+
+When you create an API key, you specify a **Role** that determines the permissions associated with the API key. Role permissions control that actions the API key can perform on Grafana resources. For more information about creating API keys, refer to [Create an API key]({{< relref "./create-api-key.md#">}}).
diff --git a/docs/sources/administration/api-keys/create-api-key.md b/docs/sources/administration/api-keys/create-api-key.md
new file mode 100644
index 00000000000..1f18da21e34
--- /dev/null
+++ b/docs/sources/administration/api-keys/create-api-key.md
@@ -0,0 +1,34 @@
+---
+title: Create an API key in Grafana
+menuTitle: Create an API key
+description: 'How to create an API key in Grafana'
+weight: 50
+keywords:
+ - API keys
+ - Service accounts
+---
+
+# Create an API key in Grafana
+
+Create an API key when you want to manage your computed workload with a user.
+
+For more information about API keys, refer to [About API keys in Grafana]({{< relref "./about-api-keys.md">}}).
+
+This topic shows you how to create an API key using the Grafana UI. You can also create an API key using the Grafana HTTP API. For more information about creating API keys via the API, refer to [Create API key via API]({{< relref "../../http_api/create-api-tokens-for-org.md#how-to-create-a-new-organization-and-an-api-token">}}).
+
+## Before you begin:
+
+- Ensure you have permission to create and edit API keys. For more information about permissions, refer to [About users and permissions]({{< relref "../manage-users-and-permissions/about-users-and-permissions.md#">}}).
+
+**To create an API key:**
+
+1. Sign in to Grafana, hover your cursor over **Configuration** (the gear icon), and click **API Keys**.
+1. Click **New API key**.
+1. Enter a unique name for the key.
+1. In the **Role** field, select one of the following access levels you want to assign to the key.
+ - **Admin**: Enables a user to use APIs at the broadest, most powerful administrative level.
+ - **Editor** or **Viewer** to limit the key's users to those levels of power.
+1. In the **Time to live** field, specify how long you want the key to be valid.
+ - The maximum length of time is 30 days (one month). You enter a number and a letter. Valid letters include `s` for seconds,`m` for minutes, `h` for hours, `d `for days, `w` for weeks, and `M `for month. For example, `12h` is 12 hours and `1M` is 1 month (30 days).
+ - If you are unsure about how long an API key should be valid, we recommend that you choose a short duration, such as a few hours. This approach limits the risk of having API keys that are valid for a long time.
+1. Click **Add**.
diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md
index 58cdcc98e3b..4a9106482d3 100644
--- a/docs/sources/administration/configuration.md
+++ b/docs/sources/administration/configuration.md
@@ -509,6 +509,10 @@ If you want to track Grafana usage via Azure Application Insights, then specify
+### enable_feedback_links
+
+If set to false will remove all feedback links from the UI. Defaults to true.
+
## [security]
### disable_initial_admin_creation
diff --git a/docs/sources/administration/service-accounts/_index.md b/docs/sources/administration/service-accounts/_index.md
new file mode 100644
index 00000000000..9c2ead7f1a4
--- /dev/null
+++ b/docs/sources/administration/service-accounts/_index.md
@@ -0,0 +1,15 @@
+---
+title: 'Service accounts in Grafana'
+menuTitle: 'Service accounts'
+description: 'This page contains information about service accounts in Grafana'
+weight: 300
+keywords:
+ - API keys
+ - Service accounts
+---
+
+# Service accounts in Grafana
+
+You can use service accounts to run automated or compute workloads.
+
+{{< section >}}
diff --git a/docs/sources/administration/service-accounts/about-service-accounts.md b/docs/sources/administration/service-accounts/about-service-accounts.md
new file mode 100644
index 00000000000..4eaa3f8e717
--- /dev/null
+++ b/docs/sources/administration/service-accounts/about-service-accounts.md
@@ -0,0 +1,50 @@
+---
+title: About service accounts
+menuTitle: About service accounts
+description: 'This page contains detailed information about service accounts in Grafana'
+weight: 30
+---
+
+# About service accounts in Grafana
+
+A service account can be used to run automated or compute workloads. Applications use service account tokens to authorize themselves as a service account.
+
+> **Note:** Service accounts are available in Grafana 8.5+ as a beta feature, to enable service accounts refer to [Enable service accounts]({{< relref "./enable-service-accounts.md#">}}) section.
+
+A common use case for creating a service account is to perform operations on automated or triggered tasks. You can use service accounts to:
+
+- Schedule reports for specific dashboards to be delivered on a daily/weekly/monthly basis
+- Define alerts in your system to be used in Grafana
+- Set up an external authentication provider to manage users and permissions across an organization
+- Establish machine-to-machine communication
+- Interact with Grafana without logging in as a user
+
+You can also use service accounts in combination with fine-grained access control to grant users specific scopes.
+
+You can associate a service account with multiple tokens. This is because a service account:
+
+- can be used by multiple team members and therefore can generate their own token each
+- can be used across multiple tenants and each tenant can have its own token
+
+We recommend the you begin by creating one service account for each use case.
+
+> **Note:** Service accounts can only act in the organization they are created for. If you have the same task that is needed for multiple organizations, we recommend creating service accounts in each organization.
+
+---
+
+## Service account tokens
+
+A service account token is a generated random string that are an alternative to using passwords for authentication with Grafana, to interact with the Grafana HTTP APIs.
+
+When you create a service account, you can associate one or more access tokens with it. You can use service access tokens the same way as API Keys, for example to access Grafana HTTP API programmatically.
+
+Service account access tokens inherit permissions from service account directly.
+
+### Service accounts benefits
+
+The added benefits of service accounts to API keys include:
+
+- Service accounts resemble Grafana users and can be enabled/disabled, granted specific permissions, and remain active until they are deleted or disabled. API keys are only valid until their expiry date.
+- Service accounts can be associated with multiple tokens.
+- Unlike API keys, service account tokens are not associated with a specific user, which means that applications can be authenticated even if a Grafana user is deleted.
+- You can grant granular permissions to service accounts by leveraging [fine-grained access control]({{< relref "../../enterprise/access-control">}}). For more information about permissions, refer to [About users and permissions]({{< relref "../manage-users-and-permissions/about-users-and-permissions.md#">}}).
diff --git a/docs/sources/administration/service-accounts/add-service-account-token.md b/docs/sources/administration/service-accounts/add-service-account-token.md
new file mode 100644
index 00000000000..a44a1a8d0db
--- /dev/null
+++ b/docs/sources/administration/service-accounts/add-service-account-token.md
@@ -0,0 +1,31 @@
+---
+title: 'Add a token to a service account in Grafana'
+menuTitle: 'Add a token to a service account'
+description: 'This topic shows you how to add a token to a service account'
+weight: 60
+---
+
+# Add a token to a service account in Grafana
+
+A service account token is a randomly generated string that external system use to authenticate into Grafana, and include specific permissions to interact with the Grafana HTTP APIs.
+For more information about service accounts, refer to [About service accounts in Grafana]({{< relref "./about-service-accounts.md">}}).
+
+You can create a service account token using the Grafana UI or via the API. For more information about creating a service account token via the API, refer to [HTTP API Create service account token]({{< relref "../../http_api/serviceaccount.md#create-service-account-tokens">}}).
+
+## Before you begin
+
+- Ensure you have added the `service-accounts` feature toggle to Grafana. For more information about adding the `service-accounts` feature toggle, refer to [Enable service accounts]({{< relref "./enable-service-accounts.md#">}}).
+- Ensure you have permission to create and edit service accounts. For more information about user roles, refer to [About users and permissions]({{< relref "../manage-users-and-permissions/about-users-and-permissions.md#">}}).
+- [Create a service account in Grafana]({{< relref "./create-service-account.md#">}}).
+
+**To add a token to a service account:**
+
+1. Sign in to Grafana and hover your cursor over the organization icon in the sidebar.
+1. Click **Service accounts**.
+1. Click the service account to which you want to add a token.
+1. Click **Add token**.
+1. Enter a name for the token.
+1. (recommended) Enter an expiry date and expiry date for the token or leave it on no expiry date option.
+ - The expiry date specifies how long you want the key to be valid.
+ - If you are unsure of an expiration date, we recommend that you set the token to expire after a short time, such as a few hours or less. This limits the risk associated with a token that is valid for a long time.
+1. Click **Generate service account token**.
diff --git a/docs/sources/administration/service-accounts/create-service-account.md b/docs/sources/administration/service-accounts/create-service-account.md
new file mode 100644
index 00000000000..bc2909967ba
--- /dev/null
+++ b/docs/sources/administration/service-accounts/create-service-account.md
@@ -0,0 +1,30 @@
+---
+title: Create a service account in Grafana
+menuTitle: Create a service account
+description: 'How to create a service account in Grafana'
+weight: 50
+keywords:
+ - Service accounts
+---
+
+# Create a service account in Grafana
+
+A service account is a user account that you can use to run automated or compute workloads. For more information about how you can use service accounts, refer to [About service accounts]({{< relref "../service-accounts/about-service-accounts.md#">}}).
+
+For more information about creating service accounts via the API, refer to [Create service account via API]({{< relref "../../http_api/serviceaccount.md#create-service-account">}}).
+
+## Before you begin
+
+- Ensure you have added the feature toggle for service accounts `service-accounts`. For more information about adding the `service-account` feature toggle, refer to [Enable service accounts]({{< relref "./enable-service-accounts.md#">}}).
+- Ensure you have permission to create and edit service accounts. For more information about user permissions, refer to [About users and permissions]({{< relref "../manage-users-and-permissions/about-users-and-permissions.md#">}}).
+
+**To create a service account:**
+
+1. Sign in to Grafana and hover your cursor over the organization icon in the sidebar.
+1. Click **Service accounts**.
+1. Click **New service account**.
+1. Enter a **Display name**.
+1. The display name must be unique as it determines the ID associated with the service account.
+ - We recommend that you use a consistent naming convention when you name service accounts. A consistent naming convention can help you scale and maintain service accounts in the future.
+ - You can change the display name at any time.
+1. Click **Create service account**.
diff --git a/docs/sources/administration/service-accounts/enable-service-accounts.md b/docs/sources/administration/service-accounts/enable-service-accounts.md
new file mode 100644
index 00000000000..63d66210939
--- /dev/null
+++ b/docs/sources/administration/service-accounts/enable-service-accounts.md
@@ -0,0 +1,43 @@
+---
+title: 'Enable service accounts in Grafana'
+menuTitle: 'Enable service accounts'
+description: 'This topic shows you how to to enable the service accounts feature in Grafana'
+weight: 40
+keywords:
+ - Feature toggle
+ - Service accounts
+---
+
+# Enable service accounts in Grafana
+
+Service accounts are available behind the `service-accounts` feature toggle available in Grafana 9.0+.
+
+You can enable service accounts by:
+
+- modifying the Grafana configuration file, or
+- configuring an environment variable
+
+## Enable service accounts with configuration file
+
+This topic shows you how to enable service accounts by modifying the Grafana configuration file.
+
+1. Sign in to the Grafana server and locate the configuration file. For more information about finding the configuration file, refer to LINK.
+1. Open the configuration file and locate the [feature toggles] section. In your [config file]({{< relref "../../administration/configuration.md#config-file-locations" >}}), add `service-accounts` as a [feature_toggle]({{< relref "../../administration/configuration.md#feature_toggle" >}}).
+
+```
+[feature_toggles]
+# enable features, separated by spaces
+enable = service-accounts
+```
+
+1. Save your changes, Grafana should recognize your changes; in case of any issues we recommend restarting the Grafana server.
+
+## Enable service accounts with an environment variable
+
+This topic shows you how to enable service accounts by setting environment variables before starting Grafana.
+
+> **Note:** Environment variables override any configuration file settings.
+
+You can use `GF_FEATURE_TOGGLES_ENABLE = service-accounts` environment variable.
+
+For more information regarding on how to setup environment variables refer to [Configuring with environment variables]({{< relref "../../administration/configuration.md#override-configuration-with-environment-variables" >}}).
diff --git a/docs/sources/alerting/old-alerting/add-notification-template.md b/docs/sources/alerting/old-alerting/add-notification-template.md
index e9c276b5833..bafab9a4f9e 100644
--- a/docs/sources/alerting/old-alerting/add-notification-template.md
+++ b/docs/sources/alerting/old-alerting/add-notification-template.md
@@ -9,7 +9,9 @@ aliases = ["/docs/grafana/latest/alerting/add-notification-template/"]
You can provide detailed information to alert notification recipients by injecting alert query data into an alert notification. This topic explains how you can use alert query labels in alert notifications.
-Labels that exist from the evaluation of the alert query can be used in the alert rule name and in the alert notification message fields. The alert label data is injected into the notification fields when the alert is in the alerting state. When there are multiple unique values for the same label, the values are comma-separated.
+You can use labels generated during an alerting query evaluation to create alert notification messages. For multiple unique values for the same label, the values are comma-separated.
+
+When an alert fires, the alerting data series indicates the violation. For resolved alerts, all data series are included in the resolved notification.
This topic explains how you can use alert query labels in alert notifications.
diff --git a/docs/sources/auth/_index.md b/docs/sources/auth/_index.md
index 719b5c758aa..16bdaacf067 100644
--- a/docs/sources/auth/_index.md
+++ b/docs/sources/auth/_index.md
@@ -12,14 +12,14 @@ Here is a table showing all supported authentication providers and the features
See also, [Grafana Authentication]({{< relref "grafana.md" >}}).
-| Provider | Support | Role mapping | Team sync _(Enterprise only)_ | Active sync _(Enterprise only)_ |
-| ---------------------------------------------------------------- | :-----: | :----------: | :-------------------------------: | :---------------------------------: |
-| [Auth Proxy]({{< relref "auth-proxy.md" >}}) | v2.1+ | - | v6.3+ | - |
-| [Azure AD OAuth]({{< relref "azuread.md" >}}) | v6.7+ | v6.7+ | v6.7+ | - |
-| [Generic OAuth]({{< relref "generic-oauth.md" >}}) | v4.0+ | v6.5+ | - | - |
-| [GitHub OAuth]({{< relref "github.md" >}}) | v2.0+ | - | v6.3+ | - |
-| [GitLab OAuth]({{< relref "gitlab.md" >}}) | v5.3+ | - | v6.4+ | - |
-| [Google OAuth]({{< relref "google.md" >}}) | v2.0+ | - | - | - |
-| [LDAP]({{< relref "ldap.md" >}}) | v2.1+ | v2.1+ | v5.3+ | v6.3+ |
-| [Okta OAuth]({{< relref "okta.md" >}}) | v7.0+ | v7.0+ | v7.0+ | - |
-| [SAML]({{< relref "../enterprise/saml.md" >}}) (Enterprise only) | v6.3+ | v7.0+ | v7.0+ | - |
+| Provider | Support | Role mapping | Team sync _(Enterprise only)_ | Active sync _(Enterprise only)_ |
+| -------------------------------------------------------------- | :-----: | :----------: | :-------------------------------: | :---------------------------------: |
+| [Auth Proxy]({{< relref "auth-proxy.md" >}}) | v2.1+ | - | v6.3+ | - |
+| [Azure AD OAuth]({{< relref "azuread.md" >}}) | v6.7+ | v6.7+ | v6.7+ | - |
+| [Generic OAuth]({{< relref "generic-oauth.md" >}}) | v4.0+ | v6.5+ | - | - |
+| [GitHub OAuth]({{< relref "github.md" >}}) | v2.0+ | - | v6.3+ | - |
+| [GitLab OAuth]({{< relref "gitlab.md" >}}) | v5.3+ | - | v6.4+ | - |
+| [Google OAuth]({{< relref "google.md" >}}) | v2.0+ | - | - | - |
+| [LDAP]({{< relref "ldap.md" >}}) | v2.1+ | v2.1+ | v5.3+ | v6.3+ |
+| [Okta OAuth]({{< relref "okta.md" >}}) | v7.0+ | v7.0+ | v7.0+ | - |
+| [SAML]({{< relref "../enterprise/saml/" >}}) (Enterprise only) | v6.3+ | v7.0+ | v7.0+ | - |
diff --git a/docs/sources/auth/overview.md b/docs/sources/auth/overview.md
index 61e0dfe54f2..f9cba2e119b 100644
--- a/docs/sources/auth/overview.md
+++ b/docs/sources/auth/overview.md
@@ -8,20 +8,20 @@ weight = 1
Grafana provides many ways to authenticate users. Some authentication integrations also enable syncing user permissions and org memberships.
-Here is a table showing all supported authentication providers and the features available for them. [Team sync]({{< relref "../enterprise/team-sync.md" >}}) and [active sync]({{< relref "../enterprise/enhanced_ldap.md#active-ldap-synchronization" >}}) are only available in Grafana Enterprise.
+The following table shows all supported authentication providers and the features available for them. [Team sync]({{< relref "../enterprise/team-sync.md" >}}) and [active sync]({{< relref "../enterprise/enhanced_ldap.md#active-ldap-synchronization" >}}) are only available in Grafana Enterprise.
-| Provider | Support | Role mapping | Team sync _(Enterprise only)_ | Active sync _(Enterprise only)_ |
-| ---------------------------------------------------------------- | :-----: | :----------: | :-------------------------------: | :---------------------------------: |
-| [Auth Proxy]({{< relref "auth-proxy.md" >}}) | v2.1+ | - | v6.3+ | - |
-| [Azure AD OAuth]({{< relref "azuread.md" >}}) | v6.7+ | v6.7+ | v6.7+ | - |
-| [Generic OAuth]({{< relref "generic-oauth.md" >}}) | v4.0+ | v6.5+ | - | - |
-| [GitHub OAuth]({{< relref "github.md" >}}) | v2.0+ | - | v6.3+ | - |
-| [GitLab OAuth]({{< relref "gitlab.md" >}}) | v5.3+ | - | v6.4+ | - |
-| [Google OAuth]({{< relref "google.md" >}}) | v2.0+ | - | - | - |
-| [JWT]({{< relref "jwt.md" >}}) | v8.0+ | - | - | - |
-| [LDAP]({{< relref "ldap.md" >}}) | v2.1+ | v2.1+ | v5.3+ | v6.3+ |
-| [Okta OAuth]({{< relref "okta.md" >}}) | v7.0+ | v7.0+ | v7.0+ | - |
-| [SAML]({{< relref "../enterprise/saml.md" >}}) (Enterprise only) | v6.3+ | v7.0+ | v7.0+ | - |
+| Provider | Support | Role mapping | Team sync _(Enterprise only)_ | Active sync _(Enterprise only)_ |
+| -------------------------------------------------------------- | :-----: | :----------: | :-------------------------------: | :---------------------------------: |
+| [Auth Proxy]({{< relref "auth-proxy.md" >}}) | v2.1+ | - | v6.3+ | - |
+| [Azure AD OAuth]({{< relref "azuread.md" >}}) | v6.7+ | v6.7+ | v6.7+ | - |
+| [Generic OAuth]({{< relref "generic-oauth.md" >}}) | v4.0+ | v6.5+ | - | - |
+| [GitHub OAuth]({{< relref "github.md" >}}) | v2.0+ | - | v6.3+ | - |
+| [GitLab OAuth]({{< relref "gitlab.md" >}}) | v5.3+ | - | v6.4+ | - |
+| [Google OAuth]({{< relref "google.md" >}}) | v2.0+ | - | - | - |
+| [JWT]({{< relref "jwt.md" >}}) | v8.0+ | - | - | - |
+| [LDAP]({{< relref "ldap.md" >}}) | v2.1+ | v2.1+ | v5.3+ | v6.3+ |
+| [Okta OAuth]({{< relref "okta.md" >}}) | v7.0+ | v7.0+ | v7.0+ | - |
+| [SAML]({{< relref "../enterprise/saml/" >}}) (Enterprise only) | v6.3+ | v7.0+ | v7.0+ | - |
## Grafana Auth
diff --git a/docs/sources/auth/saml.md b/docs/sources/auth/saml.md
index 923715756a5..57157ad9ca4 100644
--- a/docs/sources/auth/saml.md
+++ b/docs/sources/auth/saml.md
@@ -10,4 +10,4 @@ weight = 1100
The SAML authentication integration allows your Grafana users to log in by using an external SAML Identity Provider (IdP). To enable this, Grafana becomes a Service Provider (SP) in the authentication flow, interacting with the IdP to exchange user information.
-> SAML authentication integration is available in Grafana Cloud Pro and Advanced and in Grafana Enterprise. For more information, refer to [SAML authentication]({{< relref "../enterprise/saml.md" >}}) in [Grafana Enterprise]({{< relref "../enterprise" >}}).
+> SAML authentication integration is available in Grafana Cloud Pro and Advanced and in Grafana Enterprise. For more information, refer to [SAML authentication]({{< relref "../enterprise/saml/" >}}) in [Grafana Enterprise]({{< relref "../enterprise" >}}).
diff --git a/docs/sources/enterprise/_index.md b/docs/sources/enterprise/_index.md
index bc7d1ac99a4..958578d0820 100644
--- a/docs/sources/enterprise/_index.md
+++ b/docs/sources/enterprise/_index.md
@@ -29,7 +29,7 @@ Supported auth providers:
- [GitLab OAuth]({{< relref "../auth/gitlab.md#team-sync-enterprise-only" >}})
- [LDAP]({{< relref "enhanced_ldap.md#ldap-group-synchronization-for-teams" >}})
- [Okta]({{< relref "../auth/okta.md#team-sync-enterprise-only" >}})
-- [SAML]({{< relref "saml.md#configure-team-sync" >}})
+- [SAML]({{< relref "./saml/configure-saml.md#configure-team-sync" >}})
### Enhanced LDAP integration
@@ -37,7 +37,7 @@ With Grafana Enterprise [enhanced LDAP]({{< relref "enhanced_ldap.md" >}}), you
### SAML authentication
-[SAML authentication]({{< relref "saml.md" >}}) enables your Grafana Enterprise users to authenticate with SAML.
+[SAML authentication]({{< relref "./saml" >}}) enables your Grafana Enterprise users to authenticate with SAML.
## Enterprise features
diff --git a/docs/sources/enterprise/access-control/_index.md b/docs/sources/enterprise/access-control/_index.md
index 4c98feacf91..f9fa9d56690 100644
--- a/docs/sources/enterprise/access-control/_index.md
+++ b/docs/sources/enterprise/access-control/_index.md
@@ -36,6 +36,9 @@ Fine-grained access control is available for the following capabilities:
- [Provision Grafana]({{< relref "../../administration/provisioning/_index.md" >}})
- [Manage reports]({{< relref "../reporting.md" >}})
- [View server information]({{< relref "../../administration/view-server/_index.md" >}})
+- [Manage teams]({{< relref "../../administration/manage-users-and-permissions/manage-teams/_index.md" >}})
+- [Manage dashboards and folders]({{< relref "../../dashboards/_index.md" >}})
+- [Manage annotations]({{< relref "../../visualizations/annotations.md" >}})
To learn about specific endpoints where you can use fine-grained access control, refer to [Permissions]({{< relref "./permissions.md" >}}) and to the relevant [API]({{< relref "../../http_api/_index.md" >}}) documentation.
@@ -58,8 +61,13 @@ enable = accesscontrol
You can use `GF_FEATURE_TOGGLES_ENABLE = accesscontrol` environment variable to override the config file configuration and enable fine-grained access control.
-Refer to [Configuring with environment variables]({{< relref "../../administration/configuration.md#configure-with-environment-variables" >}}) for more information.
+Refer to [Configuring with environment variables]({{< relref "../../administration/configuration.md#/#override-configuration-with-environment-variables" >}}) for more information.
### Verify if enabled
You can verify if fine-grained access control is enabled or not by sending an HTTP request to the [Check endpoint]({{< relref "../../http_api/access_control.md#check-if-enabled" >}}).
+
+## Caveats
+
+If you have created a folder with unique identifier (uid) set to "general", you will not be able to manage its permissions with fine-grained access control.
+Any [folder permissions]({{< relref "../../administration/manage-users-and-permissions/manage-dashboard-permissions/_index.md" >}}) set for this folder will be disregarded when fine-grained access control is enabled.
diff --git a/docs/sources/enterprise/auditing.md b/docs/sources/enterprise/auditing.md
index 01982586f82..ab8bd49db70 100644
--- a/docs/sources/enterprise/auditing.md
+++ b/docs/sources/enterprise/auditing.md
@@ -122,7 +122,7 @@ pattern of the `requestUri` field is given.
\* Where `AUTH-MODULE` is the name of the authentication module: `grafana`, `saml`,
`ldap`, etc. \
-\*\* Includes manual log out, token expired/revoked, and [SAML Single Logout]({{< relref "saml.md#single-logout" >}}).
+\*\* Includes manual log out, token expired/revoked, and [SAML Single Logout]({{< relref "./saml/configure-saml.md#single-logout" >}}).
#### User management
diff --git a/docs/sources/enterprise/saml/_index.md b/docs/sources/enterprise/saml/_index.md
new file mode 100644
index 00000000000..3350533e54b
--- /dev/null
+++ b/docs/sources/enterprise/saml/_index.md
@@ -0,0 +1,15 @@
+---
+title: 'SAML authentication'
+description: 'Grafana SAML authentication'
+keywords: ['grafana', 'saml', 'documentation', 'saml-auth', 'enterprise']
+aliases: ['/docs/grafana/latest/auth/saml/']
+weight: 10
+---
+
+# SAML authentication
+
+SAML authentication integration enables your Grafana users to log in by using an external SAML 2.0 Identity Provider (IdP). To enable this, Grafana becomes a Service Provider (SP) in the authentication flow, interacting with the IdP to exchange user information.
+
+> Only available in Grafana Enterprise v6.3+. If you experience any issues with our implementation, contact our [Technical Support team](https://grafana.com/contact?plcmt=top-nav&cta=contactus)
+
+{{< section >}}
diff --git a/docs/sources/enterprise/saml/about-saml.md b/docs/sources/enterprise/saml/about-saml.md
new file mode 100644
index 00000000000..787c0552809
--- /dev/null
+++ b/docs/sources/enterprise/saml/about-saml.md
@@ -0,0 +1,74 @@
+---
+title: 'About SAML authentication in Grafana'
+menuTitle: 'About SAML authentication'
+description: 'SAML authentication'
+keywords: ['grafana', 'saml', 'documentation', 'saml-auth', 'enterprise']
+aliases: ['/docs/grafana/latest/auth/saml/']
+weight: 20
+---
+
+# About SAML authentication
+
+SAML authentication integration allows your Grafana users to log in by using an external SAML 2.0 Identity Provider (IdP). To enable this, Grafana becomes a Service Provider (SP) in the authentication flow, interacting with the IdP to exchange user information.
+
+The SAML single sign-on (SSO) standard is varied and flexible. Our implementation contains a subset of features needed to provide a smooth authentication experience into Grafana.
+
+> Only available in Grafana Enterprise v6.3+. If you encounter any problems with our implementation, please don't hesitate to contact us.
+
+## Supported SAML
+
+Grafana supports the following SAML 2.0 bindings:
+
+- From the Service Provider (SP) to the Identity Provider (IdP):
+
+ - `HTTP-POST` binding
+ - `HTTP-Redirect` binding
+
+- From the Identity Provider (IdP) to the Service Provider (SP):
+ - `HTTP-POST` binding
+
+In terms of security:
+
+- Grafana supports signed and encrypted assertions.
+- Grafana does not support signed or encrypted requests.
+
+In terms of initiation, Grafana supports:
+
+- SP-initiated requests
+- IdP-initiated requests
+
+By default, SP-initiated requests are enabled. For instructions on how to enable IdP-initiated logins, see https://grafana.com/docs/grafana/latest/enterprise/saml/#idp-initiated-single-sign-on-sso.
+
+### Edit SAML options in the Grafana config file
+
+Once you have enabled saml, you can configure Grafana to use it for SAML authentication. Refer to [Configuration]({{< relref "../../administration/configuration.md" >}}) to get more information about how to configure Grafana.
+
+**Edit SAML options in Grafana config file:**
+
+1. In the `[auth.saml]` section in the Grafana configuration file, set [`enabled`]({{< relref ".././enterprise-configuration.md#enabled" >}}) to `true`.
+1. Configure the [certificate and private key]({{< relref "#certificate-and-private-key" >}}).
+1. On the Okta application page where you have been redirected after application created, navigate to the **Sign On** tab and find **Identity Provider metadata** link in the **Settings** section.
+1. Set the [`idp_metadata_url`]({{< relref ".././enterprise-configuration.md#idp-metadata-url" >}}) to the URL obtained from the previous step. The URL should look like `https://.okta.com/app//sso/saml/metadata`.
+1. Set the following options to the attribute names configured at the **step 10** of the SAML integration setup. You can find this attributes on the **General** tab of the application page (**ATTRIBUTE STATEMENTS** and **GROUP ATTRIBUTE STATEMENTS** in the **SAML Settings** section).
+ - [`assertion_attribute_login`]({{< relref ".././enterprise-configuration.md#assertion-attribute-login" >}})
+ - [`assertion_attribute_email`]({{< relref ".././enterprise-configuration.md#assertion-attribute-email" >}})
+ - [`assertion_attribute_name`]({{< relref ".././enterprise-configuration.md#assertion-attribute-name" >}})
+ - [`assertion_attribute_groups`]({{< relref ".././enterprise-configuration.md#assertion-attribute-groups" >}})
+1. Save the configuration file and and then restart the Grafana server.
+
+When you are finished, the Grafana configuration might look like this example:
+
+```bash
+[server]
+root_url = https://grafana.example.com
+
+[auth.saml]
+enabled = true
+private_key_path = "/path/to/private_key.pem"
+certificate_path = "/path/to/certificate.cert"
+idp_metadata_url = "https://my-org.okta.com/app/my-application/sso/saml/metadata"
+assertion_attribute_name = DisplayName
+assertion_attribute_login = Login
+assertion_attribute_email = Email
+assertion_attribute_groups = Group
+```
diff --git a/docs/sources/enterprise/saml.md b/docs/sources/enterprise/saml/configure-saml.md
similarity index 59%
rename from docs/sources/enterprise/saml.md
rename to docs/sources/enterprise/saml/configure-saml.md
index 903af76fc9c..bf697c51377 100644
--- a/docs/sources/enterprise/saml.md
+++ b/docs/sources/enterprise/saml/configure-saml.md
@@ -1,51 +1,21 @@
-+++
-title = "SAML Authentication"
-description = "Grafana SAML Authentication"
-keywords = ["grafana", "saml", "documentation", "saml-auth"]
-aliases = ["/docs/grafana/latest/auth/saml/"]
-weight = 900
-+++
+---
+title: 'Configure SAML authentication in Grafana'
+menuTitle: 'Configure SAML'
+description: 'This contains information on how to configure SAML authentication in Grafana'
+keywords: ['grafana', 'saml', 'documentation', 'saml-configuration', 'enterprise']
+aliases: ['/docs/grafana/latest/auth/saml/']
+weight: 40
+---
-# SAML authentication
+# Configure SAML authentication in Grafana
-SAML authentication integration allows your Grafana users to log in by using an external SAML 2.0 Identity Provider (IdP). To enable this, Grafana becomes a Service Provider (SP) in the authentication flow, interacting with the IdP to exchange user information.
-
-The SAML single sign-on (SSO) standard is varied and flexible. Our implementation contains a subset of features needed to provide a smooth authentication experience into Grafana.
-
-> Only available in Grafana Enterprise v6.3+. If you encounter any problems with our implementation, please don't hesitate to contact us.
-
-## Supported SAML
-
-Grafana supports the following SAML 2.0 bindings:
-
-- From the Service Provider (SP) to the Identity Provider (IdP):
-
- - `HTTP-POST` binding
- - `HTTP-Redirect` binding
-
-- From the Identity Provider (IdP) to the Service Provider (SP):
- - `HTTP-POST` binding
-
-In terms of security:
-
-- Grafana supports signed and encrypted assertions.
-- Grafana does not support signed or encrypted requests.
-
-In terms of initiation, Grafana supports:
-
-- SP-initiated requests
-- IdP-initiated requests
-
-By default, SP-initiated requests are enabled. For instructions on how to enable IdP-initiated logins, see https://grafana.com/docs/grafana/latest/enterprise/saml/#idp-initiated-single-sign-on-sso.
-
-## Set up SAML authentication
-
-The table below describes all SAML configuration options. Continue reading below for details on specific options. Like any other Grafana configuration, you can apply these options as [environment variables]({{< relref "../administration/configuration.md#configure-with-environment-variables" >}}).
+The table below describes all SAML configuration options. Continue reading below for details on specific options. Like any other Grafana configuration, you can apply these options as [environment variables]({{< relref "../../administration/configuration.md#configure-with-environment-variables" >}}).
| Setting | Required | Description | Default |
| ---------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `enabled` | No | Whether SAML authentication is allowed | `false` |
| `single_logout` | No | Whether SAML Single Logout enabled | `false` |
+| `allow_sign_up` | No | Whether to allow new Grafana user creation through SAML login. If set to `false`, then only existing Grafana users can log in with SAML. | `true` |
| `allow_idp_initiated` | No | Whether SAML IdP-initiated login is allowed | `false` |
| `certificate` or `certificate_path` | Yes | Base64-encoded string or Path for the SP X.509 certificate | |
| `private_key` or `private_key_path` | Yes | Base64-encoded string or Path for the SP private key | |
@@ -64,24 +34,7 @@ The table below describes all SAML configuration options. Continue reading below
| `org_mapping` | No | List of comma- or space-separated Organization:OrgId:Role mappings. Organization can be `*` meaning "All users". Role is optional and can have the following values: `Viewer`, `Editor` or `Admin`. | |
| `role_values_editor` | No | List of comma- or space-separated roles which will be mapped into the Editor role | |
| `role_values_admin` | No | List of comma- or space-separated roles which will be mapped into the Admin role | |
-| `role_values_grafana_admin` | No | List of comma- or space-separated roles which will be mapped into the Grafana Admin (Super Admin) role | |
-
-### Enable SAML authentication
-
-To use the SAML integration, in the `auth.saml` section of in the Grafana custom configuration file, set `enabled` to `true`.
-
-Refer to [Configuration]({{< relref "../administration/configuration.md" >}}) for more information about configuring Grafana.
-
-### Certificate and private key
-
-The SAML SSO standard uses asymmetric encryption to exchange information between the SP (Grafana) and the IdP. To perform such encryption, you need a public part and a private part. In this case, the X.509 certificate provides the public part, while the private key provides the private part.
-
-Grafana supports two ways of specifying both the `certificate` and `private_key`.
-
-- Without a suffix (`certificate` or `private_key`), the configuration assumes you've supplied the base64-encoded file contents.
-- With the `_path` suffix (`certificate_path` or `private_key_path`), then Grafana treats the value entered as a file path and attempts to read the file from the file system.
-
-You can only use one form of each configuration option. Using multiple forms, such as both `certificate` and `certificate_path`, results in an error.
+| `role_values_grafana_admin` | No | List of comma- or space-separated roles which will be mapped into the Grafana Admin (Super Admin) role |
### Signature algorithm
@@ -142,28 +95,32 @@ For Grafana to map the user information, it looks at the individual attributes w
Grafana provides configuration options that let you modify which keys to look at for these values. The data we need to create the user in Grafana is Name, Login handle, and email.
+### Allow new user signups
+
+By default, new Grafana users using SAML authentication will have an account created for them automatically. To decouple authentication and account creation and ensure only users with existing accounts can log in with SAML, set the `allow_sign_up` option to false.
+
### Configure team sync
> Team sync support for SAML only available in Grafana v7.0+
-To use SAML Team sync, set [`assertion_attribute_groups`]({{< relref "./enterprise-configuration.md#assertion-attribute-groups" >}}) to the attribute name where you store user groups. Then Grafana will use attribute values extracted from SAML assertion to add user into the groups with the same name configured on the External group sync tab.
+To use SAML Team sync, set [`assertion_attribute_groups`]({{< relref ".././enterprise-configuration.md#assertion-attribute-groups" >}}) to the attribute name where you store user groups. Then Grafana will use attribute values extracted from SAML assertion to add user into the groups with the same name configured on the External group sync tab.
-[Learn more about Team Sync]({{< relref "../enterprise/team-sync.md" >}})
+[Learn more about Team Sync]({{< relref "../../enterprise/team-sync.md" >}})
### Configure role sync
> Only available in Grafana v7.0+
-Role sync allows you to map user roles from an identity provider to Grafana. To enable role sync, configure role attribute and possible values for the Editor, Admin, and Grafana Admin roles. For more information about user roles, refer to [About users and permissions]({{< relref "../administration/manage-users-and-permissions/about-users-and-permissions.md" >}}).
+Role sync allows you to map user roles from an identity provider to Grafana. To enable role sync, configure role attribute and possible values for the Editor, Admin, and Grafana Admin roles. For more information about user roles, refer to [About users and permissions]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions.md" >}}).
-1. In the configuration file, set [`assertion_attribute_role`]({{< relref "./enterprise-configuration.md#assertion-attribute-role" >}}) option to the attribute name where the role information will be extracted from.
-1. Set the [`role_values_editor`]({{< relref "./enterprise-configuration.md#role-values-editor" >}}) option to the values mapped to the `Editor` role.
-1. Set the [`role_values_admin`]({{< relref "./enterprise-configuration.md#role-values-admin" >}}) option to the values mapped to the organization `Admin` role.
-1. Set the [`role_values_grafana_admin`]({{< relref "./enterprise-configuration.md#role-values-grafana-admin" >}}) option to the values mapped to the `Grafana Admin` role.
+1. In the configuration file, set [`assertion_attribute_role`]({{< relref ".././enterprise-configuration.md#assertion-attribute-role" >}}) option to the attribute name where the role information will be extracted from.
+1. Set the [`role_values_editor`]({{< relref ".././enterprise-configuration.md#role-values-editor" >}}) option to the values mapped to the `Editor` role.
+1. Set the [`role_values_admin`]({{< relref ".././enterprise-configuration.md#role-values-admin" >}}) option to the values mapped to the organization `Admin` role.
+1. Set the [`role_values_grafana_admin`]({{< relref ".././enterprise-configuration.md#role-values-grafana-admin" >}}) option to the values mapped to the `Grafana Admin` role.
If a user role doesn't match any of configured values, then the `Viewer` role will be assigned.
-Refer to [About users and permissions]({{< relref "../administration/manage-users-and-permissions/about-users-and-permissions.md" >}}) for more information about roles and permissions in Grafana.
+Refer to [About users and permissions]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions.md" >}}) for more information about roles and permissions in Grafana.
Example configuration:
@@ -183,8 +140,8 @@ role_values_grafana_admin = superadmin
Organization mapping allows you to assign users to particular organization in Grafana depending on attribute value obtained from identity provider.
-1. In configuration file, set [`assertion_attribute_org`]({{< relref "./enterprise-configuration.md#assertion-attribute-org" >}}) to the attribute name you store organization info in. This attribute can be an array if you want a user to be in multiple organizations.
-1. Set [`org_mapping`]({{< relref "./enterprise-configuration.md#org-mapping" >}}) option to the comma-separated list of `Organization:OrgId` pairs to map organization from IdP to Grafana organization specified by id. If you want users to have different roles in multiple organizations, you can set this option to a comma-separated list of `Organization:OrgId:Role` mappings.
+1. In configuration file, set [`assertion_attribute_org`]({{< relref ".././enterprise-configuration.md#assertion-attribute-org" >}}) to the attribute name you store organization info in. This attribute can be an array if you want a user to be in multiple organizations.
+1. Set [`org_mapping`]({{< relref ".././enterprise-configuration.md#org-mapping" >}}) option to the comma-separated list of `Organization:OrgId` pairs to map organization from IdP to Grafana organization specified by id. If you want users to have different roles in multiple organizations, you can set this option to a comma-separated list of `Organization:OrgId:Role` mappings.
For example, use following configuration to assign users from `Engineering` organization to the Grafana organization with id `2` as Editor and users from `Sales` - to the org with id `3` as Admin, based on `Org` assertion attribute value:
@@ -207,7 +164,7 @@ You can use `*` as an Organization if you want all your users to be in some orga
> Only available in Grafana v7.0+
-With the [`allowed_organizations`]({{< relref "./enterprise-configuration.md#allowed-organizations" >}}) option you can specify a list of organizations where the user must be a member of at least one of them to be able to log in to Grafana.
+With the [`allowed_organizations`]({{< relref ".././enterprise-configuration.md#allowed-organizations" >}}) option you can specify a list of organizations where the user must be a member of at least one of them to be able to log in to Grafana.
## Example SAML configuration
@@ -232,79 +189,3 @@ role_values_grafana_admin = superadmin
org_mapping = Engineering:2:Editor, Engineering:3:Viewer, Sales:3:Editor, *:1:Editor
allowed_organizations = Engineering, Sales
```
-
-## Set up SAML with Okta
-
-This guide will follow you through the steps of configuring SAML authentication in Grafana with [Okta](https://okta.com/). You need to be an admin in your Okta organization to access Admin Console and create SAML integration. You also need permissions to edit Grafana config file and restart Grafana server.
-
-### Configure the SAML integration in Okta
-
-To configure SAML integration with Okta, create integration inside the Okta organization first.
-
-1. Log in to the [Okta portal](https://login.okta.com/).
-1. Go to the Admin Console in your Okta organization by clicking **Admin** in the upper-right corner. If you are in the Developer Console, then click **Developer Console** in the upper-left corner and then click **Classic UI** to switch over to the Admin Console.
-1. In the Admin Console, navigate to **Applications** > **Applications**.
-1. Click **Add Application**.
-1. Click **Create New App** to start the Application Integration Wizard.
-1. Choose **Web** as a platform.
-1. Select **SAML 2.0** in the Sign on method section.
-1. Click **Create**.
-1. On the **General Settings** tab, enter a name for your Grafana integration. You can also upload a logo.
-1. On the **Configure SAML** tab, enter the SAML information related to your Grafana instance:
-
- - In the **Single sign on URL** field, use the `/saml/acs` endpoint URL of your Grafana instance, for example, `https://grafana.example.com/saml/acs`.
- - In the **Audience URI (SP Entity ID)** field, use the `/saml/metadata` endpoint URL, for example, `https://grafana.example.com/saml/metadata`.
- - Leave the default values for **Name ID format** and **Application username**.
- - In the **ATTRIBUTE STATEMENTS (OPTIONAL)** section, enter the SAML attributes to be shared with Grafana, for example:
-
- | Attribute name (in Grafana) | Value (in Okta profile) |
- | --------------------------- | -------------------------------------- |
- | Login | `user.login` |
- | Email | `user.email` |
- | DisplayName | `user.firstName + " " + user.lastName` |
-
- - In the **GROUP ATTRIBUTE STATEMENTS (OPTIONAL)** section, enter a group attribute name (for example, `Group`) and set filter to `Matches regex .*` to return all user groups.
-
-1. Click **Next**.
-1. On the final Feedback tab, fill out the form and then click **Finish**.
-
-### Edit SAML options in the Grafana config file
-
-Once the application is created, configure Grafana to use it for SAML authentication. Refer to [Configuration]({{< relref "../administration/configuration.md" >}}) to get more information about how to configure Grafana.
-
-1. In the `[auth.saml]` section in the Grafana configuration file, set [`enabled`]({{< relref "./enterprise-configuration.md#enabled" >}}) to `true`.
-1. Configure the [certificate and private key]({{< relref "#certificate-and-private-key" >}}).
-1. On the Okta application page where you have been redirected after application created, navigate to the **Sign On** tab and find **Identity Provider metadata** link in the **Settings** section.
-1. Set the [`idp_metadata_url`]({{< relref "./enterprise-configuration.md#idp-metadata-url" >}}) to the URL obtained from the previous step. The URL should look like `https://.okta.com/app//sso/saml/metadata`.
-1. Set the following options to the attribute names configured at the **step 10** of the SAML integration setup. You can find this attributes on the **General** tab of the application page (**ATTRIBUTE STATEMENTS** and **GROUP ATTRIBUTE STATEMENTS** in the **SAML Settings** section).
- - [`assertion_attribute_login`]({{< relref "./enterprise-configuration.md#assertion-attribute-login" >}})
- - [`assertion_attribute_email`]({{< relref "./enterprise-configuration.md#assertion-attribute-email" >}})
- - [`assertion_attribute_name`]({{< relref "./enterprise-configuration.md#assertion-attribute-name" >}})
- - [`assertion_attribute_groups`]({{< relref "./enterprise-configuration.md#assertion-attribute-groups" >}})
-1. Save the configuration file and and then restart the Grafana server.
-
-When you are finished, the Grafana configuration might look like this example:
-
-```bash
-[server]
-root_url = https://grafana.example.com
-
-[auth.saml]
-enabled = true
-private_key_path = "/path/to/private_key.pem"
-certificate_path = "/path/to/certificate.cert"
-idp_metadata_url = "https://my-org.okta.com/app/my-application/sso/saml/metadata"
-assertion_attribute_name = DisplayName
-assertion_attribute_login = Login
-assertion_attribute_email = Email
-assertion_attribute_groups = Group
-```
-
-## Troubleshoot SAML authentication
-
-To troubleshoot and get more log information, enable SAML debug logging in the configuration file. Refer to [Configuration]({{< relref "../administration/configuration.md#filters" >}}) for more information.
-
-```bash
-[log]
-filters = saml.auth:debug
-```
diff --git a/docs/sources/enterprise/saml/enable-saml.md b/docs/sources/enterprise/saml/enable-saml.md
new file mode 100644
index 00000000000..22067f4d12d
--- /dev/null
+++ b/docs/sources/enterprise/saml/enable-saml.md
@@ -0,0 +1,65 @@
+---
+title: 'Enable SAML authentication in Grafana'
+menuTitle: 'Enable SAML authentication'
+description: 'This contains information to enable SAML authentication in Grafana'
+keywords: ['grafana', 'saml', 'documentation', 'saml-auth', 'enterprise']
+aliases: ['/docs/grafana/latest/auth/saml/']
+weight: 30
+---
+
+# Enable SAML authentication in Grafana
+
+To use the SAML integration, in the `auth.saml` section of in the Grafana custom configuration file, set `enabled` to `true`.
+
+Refer to [Configuration]({{< relref "../../administration/configuration.md" >}}) for more information about configuring Grafana.
+
+## Certificate and private key
+
+The SAML SSO standard uses asymmetric encryption to exchange information between the SP (Grafana) and the IdP. To perform such encryption, you need a public part and a private part. In this case, the X.509 certificate provides the public part, while the private key provides the private part. The private key needs to be issued in a [PKCS#8](https://en.wikipedia.org/wiki/PKCS_8) format.
+
+Grafana supports two ways of specifying both the `certificate` and `private_key`.
+
+- Without a suffix (`certificate` or `private_key`), the configuration assumes you've supplied the base64-encoded file contents.
+- With the `_path` suffix (`certificate_path` or `private_key_path`), then Grafana treats the value entered as a file path and attempts to read the file from the file system.
+
+> **Note:** You can only use one form of each configuration option. Using multiple forms, such as both `certificate` and `certificate_path`, results in an error.
+
+---
+
+### **Example** of how to generate SAML credentials:
+
+An example of how to generate a self-signed certificate and private key that's valid for one year:
+
+```sh
+$ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
+```
+
+Base64-encode the cert.pem and key.pem files:
+(-w0 switch is not needed on Mac, only for Linux)
+
+```sh
+$ base64 -w0 key.pem > key.pem.base64
+$ base64 -w0 cert.pem > cert.pem.base64
+```
+
+The base64-encoded values (`key.pem.base64, cert.pem.base64` files) are then used for certificate and private_key.
+
+The keys you provide should look like:
+
+It should look like:
+
+```
+-----BEGIN PRIVATE KEY-----
+...
+...
+-----END PRIVATE KEY-----
+```
+
+If you have a key that looks like:
+
+```
+-----BEGIN CERTIFICATE-----
+...
+...
+-----END CERTIFICATE-----
+```
diff --git a/docs/sources/enterprise/saml/setup-saml-with-okta.md b/docs/sources/enterprise/saml/setup-saml-with-okta.md
new file mode 100644
index 00000000000..6daa4655f50
--- /dev/null
+++ b/docs/sources/enterprise/saml/setup-saml-with-okta.md
@@ -0,0 +1,45 @@
+---
+title: 'Setup SAML authentication with Okta in Grafana'
+menuTitle: 'SAML authentication with Okta'
+description: 'This is a guide to setup SAML authentication with Okta in Grafana'
+keywords: ['grafana', 'saml', 'documentation', 'saml-auth', 'enterprise']
+weight: 30
+---
+
+# Setup SAML with Okta
+
+Grafana supports user authentication through Okta, which is useful when you want your users to access Grafana using single sign on. This guide will follow you through the steps of configuring SAML authentication in Grafana with [Okta](https://okta.com/). You need to be an admin in your Okta organization to access Admin Console and create SAML integration. You also need permissions to edit Grafana config file and restart Grafana server.
+
+## Before you begin
+
+- To configure SAML integration with Okta, create integration inside the Okta organization first. [Add integration in Okta](https://help.okta.com/en/prod/Content/Topics/Apps/apps-overview-add-apps.htm)
+- Ensure you have permission to administer SAML authentication. For more information about permissions, refer to [About users and permissions]({{< relref "../manage-users-and-permissions/about-users-and-permissions.md#">}}).
+
+**To setup SAML with Okta:**
+
+1. Log in to the [Okta portal](https://login.okta.com/).
+1. Go to the Admin Console in your Okta organization by clicking **Admin** in the upper-right corner. If you are in the Developer Console, then click **Developer Console** in the upper-left corner and then click **Classic UI** to switch over to the Admin Console.
+1. In the Admin Console, navigate to **Applications** > **Applications**.
+1. Click **Add Application**.
+1. Click **Create New App** to start the Application Integration Wizard.
+1. Choose **Web** as a platform.
+1. Select **SAML 2.0** in the Sign on method section.
+1. Click **Create**.
+1. On the **General Settings** tab, enter a name for your Grafana integration. You can also upload a logo.
+1. On the **Configure SAML** tab, enter the SAML information related to your Grafana instance:
+
+ - In the **Single sign on URL** field, use the `/saml/acs` endpoint URL of your Grafana instance, for example, `https://grafana.example.com/saml/acs`.
+ - In the **Audience URI (SP Entity ID)** field, use the `/saml/metadata` endpoint URL, for example, `https://grafana.example.com/saml/metadata`.
+ - Leave the default values for **Name ID format** and **Application username**.
+ - In the **ATTRIBUTE STATEMENTS (OPTIONAL)** section, enter the SAML attributes to be shared with Grafana, for example:
+
+ | Attribute name (in Grafana) | Value (in Okta profile) |
+ | --------------------------- | -------------------------------------- |
+ | Login | `user.login` |
+ | Email | `user.email` |
+ | DisplayName | `user.firstName + " " + user.lastName` |
+
+ - In the **GROUP ATTRIBUTE STATEMENTS (OPTIONAL)** section, enter a group attribute name (for example, `Group`) and set filter to `Matches regex .*` to return all user groups.
+
+1. Click **Next**.
+1. On the final Feedback tab, fill out the form and then click **Finish**.
diff --git a/docs/sources/enterprise/saml/troubleshoot-saml.md b/docs/sources/enterprise/saml/troubleshoot-saml.md
new file mode 100644
index 00000000000..0c59e148046
--- /dev/null
+++ b/docs/sources/enterprise/saml/troubleshoot-saml.md
@@ -0,0 +1,31 @@
+---
+title: 'Troubleshoot SAML Authentication in Grafana'
+menuTitle: 'Troubleshoot SAML Authentication'
+description: 'This contains information on how to troubleshoot SAML authentication in Grafana'
+keywords: ['grafana', 'saml', 'documentation', 'saml-auth', 'enterprise']
+aliases: ['/docs/grafana/latest/auth/saml/']
+weight: 50
+---
+
+# Troubleshoot SAML authentication in Grafana
+
+To troubleshoot and get more log information, enable SAML debug logging in the configuration file. Refer to [Configuration]({{< relref "../../administration/configuration.md#filters" >}}) for more information.
+
+```bash
+[log]
+filters = saml.auth:debug
+```
+
+## Known issues
+
+### SAML authentication fails with error:
+
+- `asn1: structure error: tags don't match`
+
+We only support one private key format: PKCS#8.
+
+The keys may be in a different format (PKCS#1 or PKCS#12); in that case, it may be necessary to convert the private key format.
+
+```bash
+$ openssl pkcs8 -topk8 -nocrypt -in -out private.pem
+```
diff --git a/docs/sources/enterprise/team-sync.md b/docs/sources/enterprise/team-sync.md
index 735cb260c6e..93888a7ed39 100644
--- a/docs/sources/enterprise/team-sync.md
+++ b/docs/sources/enterprise/team-sync.md
@@ -29,7 +29,7 @@ This mechanism allows Grafana to remove an existing synchronized user from a tea
- [GitLab OAuth]({{< relref "../auth/gitlab.md#team-sync-enterprise-only" >}})
- [LDAP]({{< relref "enhanced_ldap.md#ldap-group-synchronization-for-teams" >}})
- [Okta]({{< relref "../auth/okta.md#team-sync-enterprise-only" >}})
-- [SAML]({{< relref "saml.md#configure-team-sync" >}})
+- [SAML]({{< relref "./saml/configure-saml.md#configure-team-sync" >}})
## Synchronize a Grafana team with an external group
diff --git a/docs/sources/explore/_index.md b/docs/sources/explore/_index.md
index 610fb46f60d..b9552d5bc3b 100644
--- a/docs/sources/explore/_index.md
+++ b/docs/sources/explore/_index.md
@@ -55,16 +55,16 @@ In split view, timepickers for both panels can be linked (if you change one, the
To close the newly created query, click on the Close Split button.
-## Navigate between Explore and a dashboard
-
-To help accelerate workflows that involve regularly switching from Explore to a dashboard and vice-versa, Grafana provides you with the ability to return to the origin dashboard after navigating to Explore from the panel's dropdown.
-
-After you've navigated to Explore, you should notice a "Back" button in the Explore toolbar. Simply click it to return to the origin dashboard. To bring changes you make in Explore back to the dashboard, click the arrow next to the button to reveal a "Return to panel with changes" menu item.
-
-{{< figure src="/static/img/docs/explore/explore_return_dropdown-7-4.png" class="docs-image--no-shadow" max-width= "400px" caption="Screenshot of the expanded explore return dropdown" >}}
-
## Share shortened link
> **Note:** Available in Grafana 7.3 and later versions.
The Share shortened link capability allows you to create smaller and simpler URLs of the format /goto/:uid instead of using longer URLs with query parameters. To create a shortened link to the executed query, click the **Share** option in the Explore toolbar. A shortened link that is never used will automatically get deleted after seven (7) days.
+
+## Available feature toggles
+
+### explore2Dashboard
+
+> **Note:** Available in Grafana 8.5.0 and later versions.
+
+Enabled by default, allows users to create panels in dashboards from within Explore.
diff --git a/docs/sources/explore/logs-integration.md b/docs/sources/explore/logs-integration.md
index 3e19c75196f..824faec1c20 100644
--- a/docs/sources/explore/logs-integration.md
+++ b/docs/sources/explore/logs-integration.md
@@ -97,6 +97,17 @@ You can change the order of received logs from the default descending order (new
Each log row has an extendable area with its labels and detected fields, for more robust interaction. For all labels we have added the ability to filter for (positive filter) and filter out (negative filter) selected labels. Each field or label also has a stats icon to display ad-hoc statistics in relation to all displayed logs.
+### Escaping newlines
+
+Explore automatically detects some incorrectly escaped sequences in log lines, such as newlines (`\n`, `\r`) or tabs (`\t`). When it detects such sequences, Explore provides an "Escape newlines" option.
+
+To automatically fix incorrectly escaped sequences that Explore has detected:
+
+1. Click "Escape newlines" to replace the sequences.
+2. Manually review the replacements to confirm their correctness.
+
+Explore replaces these sequences. When it does so, the option will change from "Escape newlines" to "Remove escaping". Evaluate the changes as the parsing may not be accurate based on the input received. You can revert the replacements by clicking "Remove escaping".
+
#### Derived fields links
By using Derived fields, you can turn any part of a log message into an internal or external link. The created link is visible as a button next to the Detected field in the Log details view.
diff --git a/docs/sources/http_api/annotations.md b/docs/sources/http_api/annotations.md
index e784f6b1841..a925a6d6b53 100644
--- a/docs/sources/http_api/annotations.md
+++ b/docs/sources/http_api/annotations.md
@@ -20,9 +20,9 @@ This is the API documentation for the new Grafana Annotations feature released i
See note in the [introduction]({{< ref "#annotations-api" >}}) for an explanation.
-| Action | Scope |
-| ---------------- | -------------- |
-| annotations:read | annotations:\* |
+| Action | Scope |
+| ---------------- | ----------------------- |
+| annotations:read | annotations:type: |
**Example Request**:
diff --git a/docs/sources/http_api/serviceaccount.md b/docs/sources/http_api/serviceaccount.md
new file mode 100644
index 00000000000..d169db92ad9
--- /dev/null
+++ b/docs/sources/http_api/serviceaccount.md
@@ -0,0 +1,338 @@
++++
+title = "Service account HTTP API "
+description = "Grafana service account HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "serviceaccount"]
+aliases = ["/docs/grafana/latest/http_api/serviceaccount/"]
++++
+
+# Service account API
+
+> If you are running Grafana Enterprise and have [Fine-grained access control]({{< relref "../enterprise/access-control/_index.md" >}}) enabled, for some endpoints you would need to have relevant permissions.
+> Refer to specific resources to understand what permissions are required.
+
+## Search service accounts with Paging
+
+`GET /api/serviceaccounts/search?perpage=10&page=1&query=myserviceaccount`
+
+#### Required permissions
+
+See note in the [introduction]({{< ref "#user-api" >}}) for an explanation.
+
+| Action | Scope |
+| -------------------- | ------------------------- |
+| serviceaccounts:read | global:serviceaccounts:\* |
+
+**Example Request**:
+
+```http
+GET /api/serviceaccounts/search?perpage=10&page=1&query=mygraf HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Basic YWRtaW46YWRtaW4=
+```
+
+Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. The `totalCount` field in the response can be used for pagination of the user list E.g. if `totalCount` is equal to 100 users and the `perpage` parameter is set to 10 then there are 10 pages of users. The `query` parameter is optional and it will return results where the query value is contained in one of the `name`. Query values with spaces need to be URL encoded e.g. `query=Jane%20Doe`.
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+{
+ "totalCount": 2,
+ "serviceAccounts": [
+ {
+ "id": 1,
+ "name": "grafana",
+ "login": "sa-grafana",
+ "orgId": 1,
+ "isDisabled": false,
+ "role": "Viewer",
+ "tokens": 0,
+ "avatarUrl": "/avatar/85ec38023d90823d3e5b43ef35646af9",
+ "accessControl": {
+ "serviceaccounts:delete": true,
+ "serviceaccounts:read": true,
+ "serviceaccounts:write": true
+ }
+ },
+ {
+ "id": 2,
+ "name": "test",
+ "login": "sa-test",
+ "orgId": 1,
+ "isDisabled": false,
+ "role": "Viewer",
+ "tokens": 0,
+ "avatarUrl": "/avatar/8ea890a677d6a223c591a1beea6ea9d2",
+ "accessControl": {
+ "serviceaccounts:delete": true,
+ "serviceaccounts:read": true,
+ "serviceaccounts:write": true
+ }
+ }
+ ],
+ "page": 1,
+ "perPage": 10
+}
+```
+
+## Create service account
+
+`POST /api/serviceaccounts`
+
+#### Required permissions
+
+See note in the [introduction]({{< ref "#serviceaccount-api" >}}) for an explanation.
+
+| Action | Scope |
+| --------------------- | ------------------ |
+| serviceaccounts:write | serviceaccounts:\* |
+
+**Example Request**:
+
+```http
+POST /api/serviceaccounts HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Basic YWRtaW46YWRtaW4=
+
+{
+ "name": "grafana",
+ "role": "Admin",
+}
+```
+
+Requires basic authentication and that the authenticated user is a Grafana Admin.
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+ "id": 1,
+ "name": "test",
+ "login": "sa-test",
+ "orgId": 1,
+ "isDisabled": false,
+ "createdAt": "2022-03-21T14:35:33Z",
+ "updatedAt": "2022-03-21T14:35:33Z",
+ "avatarUrl": "/avatar/8ea890a677d6a223c591a1beea6ea9d2",
+ "role": "Viewer",
+ "teams": []
+}
+```
+
+## Get single serviceaccount by Id
+
+`GET /api/serviceaccounts/:id`
+
+#### Required permissions
+
+See note in the [introduction]({{< ref "#serviceaccount-api" >}}) for an explanation.
+
+| Action | Scope |
+| -------------------- | ------------------ |
+| serviceaccounts:read | serviceaccounts:\* |
+
+**Example Request**:
+
+```http
+GET /api/serviceaccounts/1 HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Basic YWRtaW46YWRtaW4=
+```
+
+Requires basic authentication and that the authenticated user is a Grafana Admin.
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+ "id": 1,
+ "name": "test",
+ "login": "sa-test",
+ "orgId": 1,
+ "isDisabled": false,
+ "createdAt": "2022-03-21T14:35:33Z",
+ "updatedAt": "2022-03-21T14:35:33Z",
+ "avatarUrl": "/avatar/8ea890a677d6a223c591a1beea6ea9d2",
+ "role": "Viewer",
+ "teams": []
+}
+```
+
+## Update service account
+
+`PATCH /api/serviceaccounts/:id`
+
+#### Required permissions
+
+See note in the [introduction]({{< ref "#serviceaccount-api" >}}) for an explanation.
+
+| Action | Scope |
+| --------------------- | ------------------ |
+| serviceaccounts:write | serviceaccounts:\* |
+
+**Example Request**:
+
+```http
+PUT /api/serviceaccounts/2 HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Basic YWRtaW46YWRtaW4=
+
+{
+ "name": "test",
+ "role": "Editor"
+}
+```
+
+Requires basic authentication and that the authenticated user is a Grafana Admin.
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+ "id": 2,
+ "name": "test",
+ "login": "sa-grafana",
+ "orgId": 1,
+ "isDisabled": false,
+ "createdAt": "2022-03-21T14:35:44Z",
+ "updatedAt": "2022-03-21T14:35:44Z",
+ "avatarUrl": "/avatar/8ea890a677d6a223c591a1beea6ea9d2",
+ "role": "Editor",
+ "teams": []
+}
+```
+
+---
+
+## Service account tokens
+
+## Get service account tokens
+
+`GET /api/serviceaccounts/:id/tokens`
+
+#### Required permissions
+
+See note in the [introduction]({{< ref "#serviceaccount-api" >}}) for an explanation.
+
+| Action | Scope |
+| -------------------- | ------------------ |
+| serviceaccounts:read | serviceaccounts:\* |
+
+**Example Request**:
+
+```http
+GET /api/serviceaccounts/2/tokens HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Basic YWRtaW46YWRtaW4=
+```
+
+Requires basic authentication and that the authenticated user is a Grafana Admin.
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+[
+ {
+ "id": 1,
+ "name": "grafana",
+ "role": "Viewer",
+ "created": "2022-03-23T10:31:02Z",
+ "expiration": null,
+ "secondsUntilExpiration": 0,
+ "hasExpired": false
+ }
+]
+```
+
+## Create service account tokens
+
+`POST /api/serviceaccounts/:id/tokens`
+
+#### Required permissions
+
+See note in the [introduction]({{< ref "#serviceaccount-api" >}}) for an explanation.
+
+| Action | Scope |
+| --------------------- | ------------------ |
+| serviceaccounts:write | serviceaccounts:\* |
+
+**Example Request**:
+
+```http
+POST /api/serviceaccounts/2/tokens HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Basic YWRtaW46YWRtaW4=
+
+{
+ "name": "grafana",
+ "role": "Viewer"
+}
+```
+
+Requires basic authentication and that the authenticated user is a Grafana Admin.
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+ "id": 7,
+ "name": "grafana",
+ "key": "eyJrIjoiVjFxTHZ6dGdPSjg5Um92MjN1RlhjMkNqYkZUbm9jYkwiLCJuIjoiZ3JhZmFuYSIsImlkIjoxfQ=="
+}
+```
+
+## Delete service account tokens
+
+`DELETE /api/serviceaccounts/:id/tokens/:tokenId`
+
+#### Required permissions
+
+See note in the [introduction]({{< ref "#serviceaccount-api" >}}) for an explanation.
+
+| Action | Scope |
+| --------------------- | ------------------ |
+| serviceaccounts:write | serviceaccounts:\* |
+
+**Example Request**:
+
+```http
+DELETE /api/serviceaccounts/2/tokens/1 HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Basic YWRtaW46YWRtaW4=
+```
+
+Requires basic authentication and that the authenticated user is a Grafana Admin.
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+ "message": "API key deleted"
+}
+```
diff --git a/docs/sources/installation/docker.md b/docs/sources/installation/docker.md
index fbb80fa3eed..c5206ec0ee7 100755
--- a/docs/sources/installation/docker.md
+++ b/docs/sources/installation/docker.md
@@ -16,6 +16,8 @@ You can install and run Grafana using the official Docker images. Our docker ima
Each edition is available in two variants: Alpine and Ubuntu. See below.
+For documentation regarding the configuration of a docker image, refer to [configure a Grafana Docker image](https://grafana.com/docs/grafana/latest/administration/configure-docker/).
+
This topic also contains important information about [migrating from earlier Docker image versions](#migrate-from-previous-docker-containers-versions).
> **Note:** You can use [Grafana Cloud](https://grafana.com/products/cloud/features/#cloud-logs) to avoid the overhead of installing, maintaining, and scaling your observability stack. The free forever plan includes Grafana, 10K Prometheus series, 50 GB logs, and more.[Create a free account to get started](https://grafana.com/auth/sign-up/create-user?pg=docs-grafana-install&plcmt=in-text).
diff --git a/docs/sources/release-notes/_index.md b/docs/sources/release-notes/_index.md
index 0c8f2f85b44..df1de760538 100644
--- a/docs/sources/release-notes/_index.md
+++ b/docs/sources/release-notes/_index.md
@@ -9,6 +9,7 @@ Here you can find detailed release notes that list everything that is included i
about deprecations, breaking changes as well as changes that relate to plugin development.
- [Release notes for 8.5.0-beta1]({{< relref "release-notes-8-5-0-beta1" >}})
+- [Release notes for 8.4.6]({{< relref "release-notes-8-4-6" >}})
- [Release notes for 8.4.5]({{< relref "release-notes-8-4-5" >}})
- [Release notes for 8.4.4]({{< relref "release-notes-8-4-4" >}})
- [Release notes for 8.4.3]({{< relref "release-notes-8-4-3" >}})
diff --git a/docs/sources/release-notes/release-notes-8-4-6.md b/docs/sources/release-notes/release-notes-8-4-6.md
new file mode 100644
index 00000000000..05a0b6f4bc4
--- /dev/null
+++ b/docs/sources/release-notes/release-notes-8-4-6.md
@@ -0,0 +1,10 @@
++++
+title = "Release notes for Grafana 8.4.6"
+hide_menu = true
++++
+
+
+
+# Release notes for Grafana 8.4.6
+
+- **Security:** Fixes CVE-2022-24812. For more information, see our [blog](https://grafana.com/blog/2022/04/12/grafana-enterprise-8.4.6-released-with-high-severity-security-fix/)
diff --git a/docs/sources/visualizations/node-graph.md b/docs/sources/visualizations/node-graph.md
index 2545d108325..e0fcc69fed5 100644
--- a/docs/sources/visualizations/node-graph.md
+++ b/docs/sources/visualizations/node-graph.md
@@ -88,14 +88,14 @@ Required fields:
Optional fields:
-| Field name | Type | Description |
-| ------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| title | string | Name of the node visible in just under the node. |
-| subTitle | string | Additional, name, type or other identifier that will be shown right under the title. |
-| mainStat | string/number | First stat shown inside the node itself. Can be either string in which case the value will be shown as it is or it can be a number in which case any unit associated with that field will be also shown. |
-| secondaryStat | string/number | Same as mainStat but shown right under it inside the node. |
-| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
-| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
+| Field name | Type | Description |
+| ------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| title | string | Name of the node visible in just under the node. |
+| subtitle | string | Additional, name, type or other identifier shown under the title. |
+| mainstat | string/number | First stat shown inside the node itself. It can either be a string showing the value as is or a number. If it is a number, any unit associated with that field is also shown. |
+| secondarystat | string/number | Same as mainStat, but shown under it inside the node. |
+| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
+| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
### Edge parameters
@@ -109,8 +109,8 @@ Required fields:
Optional fields:
-| Field name | Type | Description |
-| ------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| mainStat | string/number | First stat shown in the overlay when hovering over the edge. Can be either string in which case the value will be shown as it is or it can be a number in which case any unit associated with that field will be also shown |
-| secondaryStat | string/number | Same as mainStat but shown right under it. |
-| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the edge. Use `config.displayName` for more human readable label. |
+| Field name | Type | Description |
+| ------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| mainstat | string/number | First stat shown in the overlay when hovering over the edge. It can be a string showing the value as is or it can be a number. If it is a number, any unit associated with that field is also shown |
+| secondarystat | string/number | Same as mainStat, but shown right under it. |
+| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the edge. Use `config.displayName` for more human readable label. |
diff --git a/go.mod b/go.mod
index d0646277b24..c5e28247313 100644
--- a/go.mod
+++ b/go.mod
@@ -14,14 +14,14 @@ replace k8s.io/client-go => k8s.io/client-go v0.22.1
replace github.com/russellhaering/goxmldsig@v1.1.0 => github.com/russellhaering/goxmldsig v1.1.1
require (
- cloud.google.com/go/storage v1.18.2
- cuelang.org/go v0.4.0
+ cloud.google.com/go/storage v1.21.0
+ cuelang.org/go v0.4.1
github.com/Azure/azure-sdk-for-go v59.3.0+incompatible
github.com/Azure/go-autorest/autorest v0.11.22
github.com/BurntSushi/toml v0.3.1
github.com/Masterminds/semver v1.5.0
github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f
- github.com/aws/aws-sdk-go v1.42.8
+ github.com/aws/aws-sdk-go v1.43.31
github.com/beevik/etree v1.1.0
github.com/benbjohnson/clock v1.1.0
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b
@@ -29,9 +29,9 @@ require (
github.com/cortexproject/cortex v1.10.1-0.20211014125347-85c378182d0d
github.com/crewjam/saml v0.4.6-0.20210521115923-29c6295245bd
github.com/davecgh/go-spew v1.1.1
- github.com/denisenkom/go-mssqldb v0.11.0
+ github.com/denisenkom/go-mssqldb v0.12.0
github.com/dop251/goja v0.0.0-20210804101310-32956a348b49
- github.com/fatih/color v1.10.0
+ github.com/fatih/color v1.13.0
github.com/gchaincl/sqlhooks v1.3.0
github.com/getsentry/sentry-go v0.10.0
github.com/go-kit/kit v0.11.0 // indirect
@@ -53,10 +53,10 @@ require (
github.com/grafana/cuetsy v0.0.0-20211119211437-8c25464cc9bf
github.com/grafana/grafana-aws-sdk v0.10.1
github.com/grafana/grafana-azure-sdk-go v1.1.0
- github.com/grafana/grafana-plugin-sdk-go v0.129.0
+ github.com/grafana/grafana-plugin-sdk-go v0.131.0
github.com/grafana/loki v1.6.2-0.20211015002020-7832783b1caa
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0
- github.com/hashicorp/go-hclog v0.16.1
+ github.com/hashicorp/go-hclog v1.0.0
github.com/hashicorp/go-plugin v1.4.3
github.com/hashicorp/go-version v1.3.0
github.com/influxdata/influxdb-client-go/v2 v2.6.0
@@ -69,7 +69,7 @@ require (
github.com/linkedin/goavro/v2 v2.10.0
github.com/m3db/prometheus_remote_client_golang v0.4.4
github.com/magefile/mage v1.12.1
- github.com/mattn/go-isatty v0.0.12
+ github.com/mattn/go-isatty v0.0.14
github.com/mattn/go-sqlite3 v1.14.7
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f
@@ -86,35 +86,35 @@ require (
github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967
github.com/robfig/cron/v3 v3.0.1
github.com/russellhaering/goxmldsig v1.1.1
- github.com/stretchr/testify v1.7.0
+ github.com/stretchr/testify v1.7.1
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf
github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f
github.com/uber/jaeger-client-go v2.29.1+incompatible
github.com/unknwon/com v1.0.1
github.com/urfave/cli/v2 v2.3.0
github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae
- github.com/weaveworks/common v0.0.0-20210913144402-035033b78a78
+ github.com/weaveworks/common v0.0.0-20210913144402-035033b78a78 // indirect
github.com/xorcare/pointer v1.1.0
github.com/yudai/gojsondiff v1.0.0
go.opentelemetry.io/collector v0.31.0
go.opentelemetry.io/collector/model v0.31.0
- go.opentelemetry.io/otel v1.2.0
+ go.opentelemetry.io/otel v1.6.3
go.opentelemetry.io/otel/exporters/jaeger v1.0.0
go.opentelemetry.io/otel/sdk v1.0.0
- go.opentelemetry.io/otel/trace v1.2.0
- golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871
+ go.opentelemetry.io/otel/trace v1.6.3
+ golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29
golang.org/x/exp v0.0.0-20210220032938-85be41e4509f
- golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4
- golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
+ golang.org/x/net v0.0.0-20220401154927-543a649e0bdd // indirect
+ golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
- golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
+ golang.org/x/time v0.0.0-20220224211638-0e9765cccd65
golang.org/x/tools v0.1.9
gonum.org/v1/gonum v0.11.0
- google.golang.org/api v0.60.0
- google.golang.org/grpc v1.42.0
- google.golang.org/protobuf v1.27.1
+ google.golang.org/api v0.74.0
+ google.golang.org/grpc v1.45.0
+ google.golang.org/protobuf v1.28.0
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
- gopkg.in/ini.v1 v1.62.0
+ gopkg.in/ini.v1 v1.66.2
gopkg.in/ldap.v3 v3.1.0
gopkg.in/mail.v2 v2.3.1
gopkg.in/square/go-jose.v2 v2.5.1
@@ -126,7 +126,6 @@ require (
)
require (
- cloud.google.com/go v0.97.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
@@ -141,7 +140,6 @@ require (
github.com/alecthomas/units v0.0.0-20210912230133-d1bdfacee922 // indirect
github.com/andybalholm/brotli v1.0.3
github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40 // indirect
- github.com/armon/go-metrics v0.3.8 // indirect
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
@@ -151,7 +149,6 @@ require (
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/cheekybits/genny v1.0.0 // indirect
github.com/cockroachdb/apd/v2 v2.0.2 // indirect
- github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/deepmap/oapi-codegen v1.8.2 // indirect
github.com/dennwc/varint v1.0.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
@@ -174,7 +171,7 @@ require (
github.com/go-openapi/validate v0.20.2 // indirect
github.com/gogo/googleapis v1.4.1 // indirect
github.com/gogo/status v1.1.0 // indirect
- github.com/golang-jwt/jwt/v4 v4.2.0 // indirect
+ github.com/golang-jwt/jwt/v4 v4.4.1 // indirect
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
@@ -182,17 +179,15 @@ require (
github.com/gomodule/redigo v2.0.0+incompatible // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/flatbuffers v2.0.0+incompatible // indirect
- github.com/googleapis/gax-go/v2 v2.1.1
+ github.com/googleapis/gax-go/v2 v2.2.0
github.com/gorilla/mux v1.8.0 // indirect
github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
- github.com/hashicorp/go-immutable-radix v1.3.0 // indirect
github.com/hashicorp/go-msgpack v0.5.5 // indirect
github.com/hashicorp/go-multierror v1.1.0 // indirect
github.com/hashicorp/go-sockaddr v1.0.2 // indirect
github.com/hashicorp/golang-lru v0.5.4
- github.com/hashicorp/memberlist v0.2.4 // indirect
github.com/hashicorp/yamux v0.0.0-20210826001029-26ff87cf9493 // indirect
github.com/igm/sockjs-go/v3 v3.0.1 // indirect
github.com/jessevdk/go-flags v1.5.0 // indirect
@@ -202,11 +197,9 @@ require (
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
github.com/mattetti/filebuffer v1.0.1 // indirect
- github.com/mattn/go-colorable v0.1.8 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/miekg/dns v1.1.43 // indirect
github.com/mitchellh/go-testing-interface v1.14.0 // indirect
- github.com/mitchellh/mapstructure v1.4.2 // indirect
github.com/mna/redisc v1.3.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -224,13 +217,11 @@ require (
github.com/protocolbuffers/txtpbfmt v0.0.0-20201118171849-f6a6b3f636fc // indirect
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
github.com/rs/cors v1.8.0 // indirect
- github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
github.com/segmentio/encoding v0.3.2
github.com/sercand/kuberesolver v2.4.0+incompatible // indirect
github.com/sergi/go-diff v1.0.0 // indirect
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect
- github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/stretchr/objx v0.2.0 // indirect
@@ -241,42 +232,53 @@ require (
go.mongodb.org/mongo-driver v1.7.0 // indirect
go.opencensus.io v0.23.0 // indirect
go.uber.org/atomic v1.9.0
- go.uber.org/goleak v1.1.11-0.20210813005559-691160354723 // indirect
- golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect
+ go.uber.org/goleak v1.1.11 // indirect
+ golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect
- google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1
+ google.golang.org/genproto v0.0.0-20220401170504-314d38edb7de
gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect
)
require (
- cloud.google.com/go/kms v1.1.0
+ cloud.google.com/go/kms v1.4.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.13.2
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.4.0
github.com/Azure/go-autorest/autorest/adal v0.9.17
github.com/golang-migrate/migrate/v4 v4.7.0
github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f
- gocloud.dev v0.24.0
+ github.com/grafana/thema v0.0.0-20220413232647-fc54c169b508
+ gocloud.dev v0.25.0
)
require (
+ cloud.google.com/go v0.100.2 // indirect
+ github.com/armon/go-metrics v0.3.10 // indirect
+ github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
+ github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
+ github.com/hashicorp/memberlist v0.3.0 // indirect
+ github.com/mattn/go-colorable v0.1.12 // indirect
+ github.com/mitchellh/mapstructure v1.4.3 // indirect
+ github.com/russross/blackfriday/v2 v2.1.0 // indirect
+)
+
+require (
+ cloud.google.com/go/compute v1.5.0 // indirect
+ cloud.google.com/go/iam v0.3.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v0.22.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.2.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 // indirect
- github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2 // indirect
- github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
- github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 // indirect
github.com/containerd/containerd v1.5.9 // indirect
github.com/elazarl/goproxy v0.0.0-20220115173737-adb46da277ac // indirect
- github.com/envoyproxy/go-control-plane v0.10.1 // indirect
- github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
- github.com/getkin/kin-openapi v0.91.0 // indirect
+ github.com/getkin/kin-openapi v0.94.0 // indirect
github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 // indirect
+ github.com/go-logr/logr v1.2.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
github.com/imdario/mergo v0.3.12 // indirect
- github.com/klauspost/compress v1.13.6 // indirect
+ github.com/klauspost/compress v1.15.1 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/pierrec/lz4/v4 v4.1.8 // indirect
@@ -290,6 +292,7 @@ replace github.com/apache/thrift => github.com/apache/thrift v0.14.1
replace github.com/hashicorp/consul => github.com/hashicorp/consul v1.10.2
-// TODO: remove once gocloud.dev releases 0.25.x
-// `fileblob` implementation has buggy key ordering in 0.24.0
-replace gocloud.dev v0.24.0 => github.com/google/go-cloud v0.24.1-0.20220209172924-99801bbb523a
+// Thema's thema CLI requires cobra, which eventually works its way down to go-hclog@v1.0.0.
+// Upgrading affects backend plugins: https://github.com/grafana/grafana/pull/47653#discussion_r850508593
+// No harm to Thema because it's only a dependency in its main package.
+replace github.com/hashicorp/go-hclog => github.com/hashicorp/go-hclog v0.16.1
diff --git a/go.sum b/go.sum
index 707fb9c42b0..291c9de9a53 100644
--- a/go.sum
+++ b/go.sum
@@ -30,8 +30,12 @@ cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWc
cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
-cloud.google.com/go v0.97.0 h1:3DXvAyifywvq64LfkKaMOmkWPS1CikIQdMe2lY9vxU8=
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
+cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM=
+cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
+cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U=
+cloud.google.com/go v0.100.2 h1:t9Iw5QH5v4XtlEQaCtUY7x6sCABps8sW0acw7e2WQ6Y=
+cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
@@ -41,29 +45,41 @@ cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM7
cloud.google.com/go/bigtable v1.1.0/go.mod h1:B6ByKcIdYmhoyDzmOnQxyOhN6r05qnewYIxxG6L0/b4=
cloud.google.com/go/bigtable v1.2.0/go.mod h1:JcVAOl45lrTmQfLj7T6TxyMzIN/3FGGcFm+2xVAli2o=
cloud.google.com/go/bigtable v1.3.0/go.mod h1:z5EyKrPE8OQmeg4h5MNdKvuSnI9CCT49Ki3f23aBzio=
+cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
+cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw=
+cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
+cloud.google.com/go/compute v1.5.0 h1:b1zWmYuuHz7gO9kDcM/EpHGr06UgsYNRpNJzI2kFiLM=
+cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
+cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c=
+cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
+cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc=
+cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
cloud.google.com/go/kms v1.0.0/go.mod h1:nhUehi+w7zht2XrUfvTRNpxrfayBHqP4lu2NSywui/0=
-cloud.google.com/go/kms v1.1.0 h1:1yc4rLqCkVDS9Zvc7m+3mJ47kw0Uo5Q5+sMjcmUVUeM=
cloud.google.com/go/kms v1.1.0/go.mod h1:WdbppnCDMDpOvoYBMn1+gNmOeEoZYqAv+HeuKARGCXI=
+cloud.google.com/go/kms v1.4.0 h1:iElbfoE61VeLhnZcGOltqL8HIly8Nhbe5t6JlH9GXjo=
+cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA=
cloud.google.com/go/monitoring v1.1.0/go.mod h1:L81pzz7HKn14QCMaCs6NTQkdBnE87TElyanS95vIcl4=
+cloud.google.com/go/monitoring v1.4.0/go.mod h1:y6xnxfwI3hTFWOdkOaD7nfJVlwuC3/mS/5kvtT131p4=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
-cloud.google.com/go/pubsub v1.17.1/go.mod h1:4qDxMr1WsM9+aQAz36ltDwCIM+R0QdlseyFjBuNvnss=
-cloud.google.com/go/secretmanager v1.0.0/go.mod h1:+Qkm5qxIJ5mk74xxIXA+87fseaY1JLYBcFPQoc/GQxg=
+cloud.google.com/go/pubsub v1.19.0/go.mod h1:/O9kmSe9bb9KRnIAWkzmqhPjHo6LtzGOBYd/kr06XSs=
+cloud.google.com/go/secretmanager v1.3.0/go.mod h1:+oLTkouyiYiabAQNugCeTS3PAArGiMJuBqvJnJsyH+U=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.3.0/go.mod h1:9IAwXhoyBJ7z9LcAwkj0/7NnPzYaPeZxxVp3zm+5IqA=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
-cloud.google.com/go/storage v1.18.2 h1:5NQw6tOn3eMm0oE8vTkfjau18kjL79FlMjy/CHTpmoY=
-cloud.google.com/go/storage v1.18.2/go.mod h1:AiIj7BWXyhO5gGVmYJ+S8tbkCx3yb0IMjua8Aw4naVM=
+cloud.google.com/go/storage v1.21.0 h1:HwnT2u2D309SFDHQII6m18HlrCi3jAXhUMTLOWXYH14=
+cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA=
cloud.google.com/go/trace v1.0.0/go.mod h1:4iErSByzxkyHWzzlAj63/Gmjz0NH1ASqhJguHpGcr6A=
+cloud.google.com/go/trace v1.2.0/go.mod h1:Wc8y/uYyOhPy12KEnXG9XGrvfMz5F5SrYecQlbW1rwM=
code.cloudfoundry.org/clock v1.0.0/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8=
collectd.org v0.3.0/go.mod h1:A/8DzQBkF6abtvrT2j/AU/4tiBgJWYyh0y/oB/4MlWE=
contrib.go.opencensus.io/exporter/aws v0.0.0-20200617204711-c478e41e60e9/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA=
@@ -71,8 +87,9 @@ contrib.go.opencensus.io/exporter/ocagent v0.6.0/go.mod h1:zmKjrJcdo0aYcVS7bmEeS
contrib.go.opencensus.io/exporter/prometheus v0.3.0/go.mod h1:rpCPVQKhiyH8oomWgm34ZmgIdZa8OVYO5WAIygPbBBE=
contrib.go.opencensus.io/exporter/stackdriver v0.13.10/go.mod h1:I5htMbyta491eUxufwwZPQdcKvvgzMB4O9ni41YnIM8=
contrib.go.opencensus.io/integrations/ocsql v0.1.7/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE=
-cuelang.org/go v0.4.0 h1:GLJblw6m2WGGCA3k1v6Wbk9gTOt2qto48ahO2MmSd6I=
cuelang.org/go v0.4.0/go.mod h1:tz/edkPi+T37AZcb5GlPY+WJkL6KiDlDVupKwL3vvjs=
+cuelang.org/go v0.4.1 h1:rxG2cyZzdAymrNICI0L/wagK2EitoR8Xsiiq2D7qkaQ=
+cuelang.org/go v0.4.1/go.mod h1:P09/R4UfAEzLkV9DXxwlxQnIZbkaT4uIhiEgs6Vsz2Q=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
@@ -172,7 +189,6 @@ github.com/Azure/go-autorest/autorest/azure/auth v0.5.8/go.mod h1:kxyKZTSfKh8OVF
github.com/Azure/go-autorest/autorest/azure/auth v0.5.9/go.mod h1:hg3/1yw0Bq87O3KvvnJoAh34/0zbP7SFizX/qN5JvjU=
github.com/Azure/go-autorest/autorest/azure/cli v0.3.1/go.mod h1:ZG5p860J94/0kI9mNJVoIoLgXcirM2gF5i2kWloofxw=
github.com/Azure/go-autorest/autorest/azure/cli v0.4.2/go.mod h1:7qkJkT+j6b+hIpzMOwPChJhTqS8VbsqqgULzMNRugoM=
-github.com/Azure/go-autorest/autorest/azure/cli v0.4.4/go.mod h1:yAQ2b6eP/CmLPnmLvxtT1ALIY3OR1oFcCqVBi8vHiTc=
github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA=
github.com/Azure/go-autorest/autorest/date v0.2.0/go.mod h1:vcORJHLJEh643/Ioh9+vPmf1Ij9AEBM5FuBIXLmIy0g=
github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw=
@@ -215,7 +231,7 @@ github.com/FZambia/eagle v0.0.1 h1:FN1yTkPihMb5nE8SrlRjoCf7T9H9bTKJFQOm6ach2YU=
github.com/FZambia/eagle v0.0.1/go.mod h1:xq6u/JeNZ5/8mrAQ76MMhzNTodASh9FavQlCgg4j48w=
github.com/FZambia/sentinel v1.1.0 h1:qrCBfxc8SvJihYNjBWgwUI93ZCvFe/PJIPTHKmlp8a8=
github.com/FZambia/sentinel v1.1.0/go.mod h1:ytL1Am/RLlAoAXG6Kj5LNuw/TRRQrv2rt2FT26vP5gI=
-github.com/GoogleCloudPlatform/cloudsql-proxy v1.27.0/go.mod h1:bn9iHmAjogMoIPkqBGyJ9R1m9cXGCjBE/cuhBs3oEsQ=
+github.com/GoogleCloudPlatform/cloudsql-proxy v1.29.0/go.mod h1:spvB9eLJH9dutlbPSRmHvSXXHOwGRyeXh1jVdquA2G8=
github.com/HdrHistogram/hdrhistogram-go v0.9.0/go.mod h1:nxrse8/Tzg2tg3DZcZjm6qEclQKK70g0KxO61gFFZD4=
github.com/HdrHistogram/hdrhistogram-go v1.0.1/go.mod h1:BWJ+nMSHY3L41Zj7CA3uXnloDp7xxV0YvstAE7nKTaM=
github.com/HdrHistogram/hdrhistogram-go v1.1.0 h1:6dpdDPTRoo78HxAJ6T1HfMiKSnqhgRRqzCuPshRkQ7I=
@@ -327,8 +343,9 @@ github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQh
github.com/armon/go-metrics v0.3.0/go.mod h1:zXjbSimjXTd7vOpY8B0/2LpvNvDoXBuplAD+gJD3GYs=
github.com/armon/go-metrics v0.3.3/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
github.com/armon/go-metrics v0.3.6/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
-github.com/armon/go-metrics v0.3.8 h1:oOxq3KPj0WhCuy50EhzwiyMyG2ovRQZpZLXQuOh2a/M=
github.com/armon/go-metrics v0.3.8/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
+github.com/armon/go-metrics v0.3.10 h1:FR+drcQStOe+32sYyJYyZ7FIdgoGGBnwLl+flodp8Uo=
+github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
@@ -369,49 +386,51 @@ github.com/aws/aws-sdk-go v1.38.60/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2z
github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go v1.40.11/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go v1.40.37/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
-github.com/aws/aws-sdk-go v1.42.8 h1:Tj2RP4Fas1mYchwbmw0qWLJIEATAseyp5iTa1D+LWYQ=
-github.com/aws/aws-sdk-go v1.42.8/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
+github.com/aws/aws-sdk-go v1.43.31 h1:yJZIr8nMV1hXjAvvOLUFqZRJcHV7udPQBfhJqawDzI0=
+github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
-github.com/aws/aws-sdk-go-v2 v1.11.0 h1:HxyD62DyNhCfiFGUHqJ/xITD6rAjJ7Dm/2nLxLmO4Ag=
-github.com/aws/aws-sdk-go-v2 v1.11.0/go.mod h1:SQfA+m2ltnu1cA0soUkj4dRSsmITiVQUJvBIZjzfPyQ=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.0.0 h1:yVUAwvJC/0WNPbyl0nA3j1L6CW1CN8wBubCRqtG7JLI=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.0.0/go.mod h1:Xn6sxgRuIDflLRJFj5Ev7UxABIkNbccFPV/p8itDReM=
-github.com/aws/aws-sdk-go-v2/config v1.10.1 h1:z/ViqIjW6ZeuLWgTWMTSyZzaVWo/1cWeVf1Uu+RF01E=
-github.com/aws/aws-sdk-go-v2/config v1.10.1/go.mod h1:auIv5pIIn3jIBHNRcVQcsczn6Pfa6Dyv80Fai0ueoJU=
-github.com/aws/aws-sdk-go-v2/credentials v1.6.1 h1:A39JYth2fFCx+omN/gib/jIppx3rRnt2r7UKPq7Mh5Y=
-github.com/aws/aws-sdk-go-v2/credentials v1.6.1/go.mod h1:QyvQk1IYTqBWSi1T6UgT/W8DMxBVa5pVuLFSRLLhGf8=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.8.0 h1:OpZjuUy8Jt3CA1WgJgBC5Bz+uOjE5Ppx4NFTRaooUuA=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.8.0/go.mod h1:5E1J3/TTYy6z909QNR0QnXGBpfESYGDqd3O0zqONghU=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.7.1 h1:p9Dys1g2YdaqMalnp6AwCA+tpMMdJNGw5YYKP/u3sUk=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.7.1/go.mod h1:wN/mvkow08GauDwJ70jnzJ1e+hE+Q3Q7TwpYLXOe9oI=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.0 h1:zY8cNmbBXt3pzjgWgdIbzpQ6qxoCwt+Nx9JbrAf2mbY=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.0/go.mod h1:NO3Q5ZTTQtO2xIg2+xTXYDiT7knSejfeDm7WGDaOo0U=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.0.0 h1:Z3aR/OXBnkYK9zXkNkfitHX6SmUBzSsx8VMHbH4Lvhw=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.0.0/go.mod h1:anlUzBoEWglcUxUQwZA7HQOEVEnQALVZsizAapB2hq8=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.3.0 h1:c10Z7fWxtJCoyc8rv06jdh9xrKnu7bAJiRaKWvTb2mU=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.3.0/go.mod h1:6oXGy4GLpypD3uCh8wcqztigGgmhLToMfjavgh+VySg=
+github.com/aws/aws-sdk-go-v2 v1.16.2 h1:fqlCk6Iy3bnCumtrLz9r3mJ/2gUT0pJ0wLFVIdWh+JA=
+github.com/aws/aws-sdk-go-v2 v1.16.2/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.1 h1:SdK4Ppk5IzLs64ZMvr6MrSficMtjY2oS0WOORXTlxwU=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.1/go.mod h1:n8Bs1ElDD2wJ9kCRTczA83gYbBmjSwZp3umc6zF4EeM=
+github.com/aws/aws-sdk-go-v2/config v1.15.3 h1:5AlQD0jhVXlGzwo+VORKiUuogkG7pQcLJNzIzK7eodw=
+github.com/aws/aws-sdk-go-v2/config v1.15.3/go.mod h1:9YL3v07Xc/ohTsxFXzan9ZpFpdTOFl4X65BAKYaz8jg=
+github.com/aws/aws-sdk-go-v2/credentials v1.11.2 h1:RQQ5fzclAKJyY5TvF+fkjJEwzK4hnxQCLOu5JXzDmQo=
+github.com/aws/aws-sdk-go-v2/credentials v1.11.2/go.mod h1:j8YsY9TXTm31k4eFhspiQicfXPLZ0gYXA50i4gxPE8g=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.3 h1:LWPg5zjHV9oz/myQr4wMs0gi4CjnDN/ILmyZUFYXZsU=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.3/go.mod h1:uk1vhHHERfSVCUnqSqz8O48LBYDSC+k6brng09jcMOk=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.3 h1:ir7iEq78s4txFGgwcLqD6q9IIPzTQNRJXulJd9h/zQo=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.3/go.mod h1:0dHuD2HZZSiwfJSy1FO5bX1hQ1TxVV1QXXjpn3XUE44=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9 h1:onz/VaaxZ7Z4V+WIN9Txly9XLTmoOh1oJ8XcAC3pako=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9/go.mod h1:AnVH5pvai0pAF4lXRq0bmhbes1u9R8wTE+g+183bZNM=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.3 h1:9stUQR/u2KXU6HkFJYlqnZEjBnbgrVbG6I5HN09xZh0=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.3/go.mod h1:ssOhaLpRlh88H3UmEcsBoVKq309quMvm3Ds8e9d4eJM=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.3.10 h1:by9P+oy3P/CwggN4ClnW2D4oL91QV7pBzBICi1chZvQ=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.3.10/go.mod h1:8DcYQcz0+ZJaSxANlHIsbbi6S+zMwjwdDqwW3r9AzaE=
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.5.0/go.mod h1:acH3+MQoiMzozT/ivU+DbRg7Ooo2298RdRaWcOv+4vM=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.5.0 h1:lPLbw4Gn59uoKqvOfSnkJr54XWk5Ak1NK20ZEiSWb3U=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.5.0/go.mod h1:80NaCIH9YU3rzTTs/J/ECATjXuRqzo/wB6ukO6MZ0XY=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.5.0 h1:qGZWS/WgiFY+Zgad2u0gwBHpJxz6Ne401JE7iQI1nKs=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.5.0/go.mod h1:Mq6AEc+oEjCUlBuLiK5YwW4shSOAKCQ3tXN0sQeYoBA=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.9.0 h1:0BOlTqnNnrEO04oYKzDxMMe68t107pmIotn18HtVonY=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.9.0/go.mod h1:xKCZ4YFSF2s4Hnb/J0TLeOsKuGzICzcElaOKNGrVnx4=
-github.com/aws/aws-sdk-go-v2/service/kms v1.10.0/go.mod h1:ZkHWL8m5Nw1g9yMXqpCjnIJtSDToAmNbXXZ9gj0bO7s=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.19.0 h1:5mRAms4TjSTOGYsqKYte5kHr1PzpMJSyLThjF3J+hw0=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.19.0/go.mod h1:Gwz3aVctJe6mUY9T//bcALArPUaFmNAy2rTB9qN4No8=
-github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.10.0/go.mod h1:qAgsrzF3Z2vvV01j79fs7D75ofCMQe81/OKBJx0rjFY=
-github.com/aws/aws-sdk-go-v2/service/sns v1.11.0/go.mod h1:LIPf3BTbSY5UeVli+x/1y2Qw1w8T9DYyp7p18Qt8Zc8=
-github.com/aws/aws-sdk-go-v2/service/sqs v1.12.0/go.mod h1:TDqDmQnsbgL2ZMIGUf3z9xTzCMqFX7FP1geAgIlYqvA=
-github.com/aws/aws-sdk-go-v2/service/ssm v1.15.0/go.mod h1:kJa2uHklY03rKsNSbEsToeUgWJ1PambXBtRNacorRhg=
-github.com/aws/aws-sdk-go-v2/service/sso v1.6.0 h1:JDgKIUZOmLFu/Rv6zXLrVTWCmzA0jcTdvsT8iFIKrAI=
-github.com/aws/aws-sdk-go-v2/service/sso v1.6.0/go.mod h1:Q/l0ON1annSU+mc0JybDy1Gy6dnJxIcWjphO6qJPzvM=
-github.com/aws/aws-sdk-go-v2/service/sts v1.10.0 h1:1jh8J+JjYRp+QWKOsaZt7rGUgoyrqiiVwIm+w0ymeUw=
-github.com/aws/aws-sdk-go-v2/service/sts v1.10.0/go.mod h1:jLKCFqS+1T4i7HDqCP9GM4Uk75YW1cS0o82LdxpMyOE=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.1 h1:T4pFel53bkHjL2mMo+4DKE6r6AuoZnM0fg7k1/ratr4=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.1/go.mod h1:GeUru+8VzrTXV/83XyMJ80KpH8xO89VPoUileyNQ+tc=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.3 h1:I0dcwWitE752hVSMrsLCxqNQ+UdEp3nACx2bYNMQq+k=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.3/go.mod h1:Seb8KNmD6kVTjwRjVEgOT5hPin6sq+v4C2ycJQDwuH8=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.3 h1:Gh1Gpyh01Yvn7ilO/b/hr01WgNpaszfbKMUgqM186xQ=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.3/go.mod h1:wlY6SVjuwvh3TVRpTqdy4I1JpBFLX4UGeKZdWntaocw=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.3 h1:BKjwCJPnANbkwQ8vzSbaZDKawwagDubrH/z/c0X+kbQ=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.3/go.mod h1:Bm/v2IaN6rZ+Op7zX+bOUMdL4fsrYZiD0dsjLhNKwZc=
+github.com/aws/aws-sdk-go-v2/service/kms v1.16.3/go.mod h1:QuiHPBqlOFCi4LqdSskYYAWpQlx3PKmohy+rE2F+o5g=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.26.3 h1:rMPtwA7zzkSQZhhz9U3/SoIDz/NZ7Q+iRn4EIO8rSyU=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.26.3/go.mod h1:g1qvDuRsJY+XghsV6zg00Z4KJ7DtFFCx8fJD2a491Ak=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.4/go.mod h1:PJc8s+lxyU8rrre0/4a0pn2wgwiDvOEzoOjcJUBr67o=
+github.com/aws/aws-sdk-go-v2/service/sns v1.17.4/go.mod h1:kElt+uCcXxcqFyc+bQqZPFD9DME/eC6oHBXvFzQ9Bcw=
+github.com/aws/aws-sdk-go-v2/service/sqs v1.18.3/go.mod h1:skmQo0UPvsjsuYYSYMVmrPc1HWCbHUJyrCEp+ZaLzqM=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.24.1/go.mod h1:NR/xoKjdbRJ+qx0pMR4mI+N/H1I1ynHwXnO6FowXJc0=
+github.com/aws/aws-sdk-go-v2/service/sso v1.11.3 h1:frW4ikGcxfAEDfmQqWgMLp+F1n4nRo9sF39OcIb5BkQ=
+github.com/aws/aws-sdk-go-v2/service/sso v1.11.3/go.mod h1:7UQ/e69kU7LDPtY40OyoHYgRmgfGM4mgsLYtcObdveU=
+github.com/aws/aws-sdk-go-v2/service/sts v1.16.3 h1:cJGRyzCSVwZC7zZZ1xbx9m32UnrKydRYhOvcD1NYP9Q=
+github.com/aws/aws-sdk-go-v2/service/sts v1.16.3/go.mod h1:bfBj0iVmsUyUg4weDB4NxktD9rDGeKSVWnjTnwbx9b8=
github.com/aws/smithy-go v1.5.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
-github.com/aws/smithy-go v1.9.0 h1:c7FUdEqrQA1/UVKKCNDFQPNKGp4FQg3YW4Ck5SLTG58=
-github.com/aws/smithy-go v1.9.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
+github.com/aws/smithy-go v1.11.2 h1:eG/N+CcUMAvsdffgMvjMKwfyDzIkjM6pfxMJ8Mzc6mE=
+github.com/aws/smithy-go v1.11.2/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM=
github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc=
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
@@ -472,7 +491,6 @@ github.com/cenkalti/backoff/v4 v4.1.0/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInq
github.com/cenkalti/backoff/v4 v4.1.1 h1:G2HAfAmvm/GcKan2oOQpBXOd2tT2G57ZnZGWa1PxPBQ=
github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/census-instrumentation/opencensus-proto v0.3.0 h1:t/LhUZLVitR1Ow2YOnduCsavhwFUklBMoGVYUCqmCqk=
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/centrifugal/centrifuge v0.19.0 h1:YHws0dRpgsBiI73tRl1wwaB13gzuaI1AM4IFcQQQqcw=
github.com/centrifugal/centrifuge v0.19.0/go.mod h1:O2elf8q3Qkie3z97wkqVqxB52pnOpPsfFUa7L88Lpy0=
@@ -513,14 +531,14 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
github.com/cncf/udpa/go v0.0.0-20200313221541-5f7e5dd04533/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 h1:hzAQntlaYRkVSFEfj9OTWlVV1H155FMD8BTKktLv0QI=
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 h1:zH8ljVhhq7yC0MIeUL/IviMtY8hx2mK8cN9wEYb8ggw=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 h1:KwaoQzs/WeUxxJqiJsZ4euOly1Az/IgZXXSxlD/UBNk=
+github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/cockroachdb/apd/v2 v2.0.1/go.mod h1:DDxRlzC2lo3/vSlmSoS7JkqbbrARPuFOGr0B9pvN3Gw=
@@ -638,6 +656,7 @@ github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7
github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk=
github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk=
@@ -662,8 +681,9 @@ github.com/couchbase/goutils v0.0.0-20180530154633-e865a1461c8a/go.mod h1:BQwMFl
github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
+github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -832,8 +852,8 @@ github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
-github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
-github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
+github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
+github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fatih/structtag v1.1.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
@@ -868,8 +888,9 @@ github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo
github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM=
github.com/getkin/kin-openapi v0.53.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4=
github.com/getkin/kin-openapi v0.61.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4=
-github.com/getkin/kin-openapi v0.91.0 h1:mOSAljTAQONM0YVtI3+LvIQaa0zPwa3SH6UuiyEnbYQ=
github.com/getkin/kin-openapi v0.91.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg=
+github.com/getkin/kin-openapi v0.94.0 h1:bAxg2vxgnHHHoeefVdmGbR+oxtJlcv5HsJJa3qmAHuo=
+github.com/getkin/kin-openapi v0.94.0/go.mod h1:LWZfzOd7PRy8GJ1dJ6mCU6tNdSfOwRac1BUPam4aw6Q=
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
github.com/getsentry/sentry-go v0.10.0 h1:6gwY+66NHKqyZrdi6O2jGdo7wGdo9b3B69E01NFgT5g=
github.com/getsentry/sentry-go v0.10.0/go.mod h1:kELm/9iCblqUYh+ZRML7PNdCvEuw24wBvJPYyi86cws=
@@ -921,8 +942,12 @@ github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNV
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU=
-github.com/go-logr/logr v0.4.0 h1:K7/B1jt6fIBQVd4Owv2MqGQClcgf0R266+7C/QjRcLc=
github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
+github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
github.com/go-ole/go-ole v1.2.1/go.mod h1:7FAglXiTm7HKlQRDeOQ6ZNUHidzCWXuZWq/1dTyBNF8=
github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM=
@@ -1152,9 +1177,9 @@ github.com/gogo/status v1.1.0/go.mod h1:BFv9nrluPLmrS0EmGVvLaPNmRosr9KapBYd5/hpY
github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c=
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
-github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
-github.com/golang-jwt/jwt/v4 v4.2.0 h1:besgBTC8w8HjP6NzQdxwKH9Z5oQMZ24ThTrHp3cZ8eU=
github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
+github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ=
+github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-migrate/migrate/v4 v4.7.0 h1:gONcHxHApDTKXDyLH/H97gEHmpu1zcnnbAaq2zgrPrs=
github.com/golang-migrate/migrate/v4 v4.7.0/go.mod h1:Qvut3N4xKWjoH3sokBccML6WyHSnggXm/DvMMnTsQIc=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
@@ -1224,8 +1249,6 @@ github.com/google/flatbuffers v1.11.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv
github.com/google/flatbuffers v1.12.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v2.0.0+incompatible h1:dicJ2oXwypfwUGnB2/TYWYEKiuk9eYQlQO/AnOHl5mI=
github.com/google/flatbuffers v2.0.0+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
-github.com/google/go-cloud v0.24.1-0.20220209172924-99801bbb523a h1:Kw18HR30Firrm4cB1BzcVVaFrrdDlAtnrCKgHxemgMI=
-github.com/google/go-cloud v0.24.1-0.20220209172924-99801bbb523a/go.mod h1:TqAL9Q5Q8jFUfIDNbDiFbU3w0z3MQ7q4r1wm57tBhmI=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -1246,7 +1269,7 @@ github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135/go.mod h1:od
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6AwLM6miiXUQwP1S+I9icmhk=
-github.com/google/go-replayers/httpreplay v1.0.0/go.mod h1:LJhKoTwS5Wy5Ld/peq8dFFG5OfJyHEz7ft+DsTUv25M=
+github.com/google/go-replayers/httpreplay v1.1.1/go.mod h1:gN9GeLIs7l6NUoVaSSnv2RiqK1NiwAmD0MrKeC9IIks=
github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -1257,8 +1280,9 @@ github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible h1:x
github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ=
github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
+github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
+github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190723021845-34ac40c74b70/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@@ -1285,6 +1309,7 @@ github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210827144239-02619b876842/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/tcpproxy v0.0.0-20180808230851-dfa16c61dad2/go.mod h1:DavVbd41y+b7ukKDmlnPR4nGYmkWXR6vHUkjQNiHPBs=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -1301,8 +1326,9 @@ github.com/googleapis/gax-go v2.0.2+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
-github.com/googleapis/gax-go/v2 v2.1.1 h1:dp3bWCh+PPO1zjRRiCSczJav13sBvG4UhNyVTa1KqdU=
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
+github.com/googleapis/gax-go/v2 v2.2.0 h1:s7jOdKSaksJVOxE0Y/S32otcfiP+UQ0cL8/GTKaONwE=
+github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
github.com/googleapis/gnostic v0.0.0-20170426233943-68f4ded48ba9/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
github.com/googleapis/gnostic v0.1.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
@@ -1366,13 +1392,16 @@ github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58/go.m
github.com/grafana/grafana-plugin-sdk-go v0.94.0/go.mod h1:3VXz4nCv6wH5SfgB3mlW39s+c+LetqSCjFj7xxPC5+M=
github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk=
github.com/grafana/grafana-plugin-sdk-go v0.125.0/go.mod h1:9YiJ5GUxIsIEUC0qR9+BJVP5M7mCSP6uc6Ne62YKkgc=
-github.com/grafana/grafana-plugin-sdk-go v0.129.0 h1:apmA8x59QvW5Wov+FhAfM0IiNGjMi8V2Ou7xyMk1leE=
github.com/grafana/grafana-plugin-sdk-go v0.129.0/go.mod h1:4edtosZepfQF9jkQwRywJsNSJzXTHmzbmcVcAl8MEQc=
+github.com/grafana/grafana-plugin-sdk-go v0.131.0 h1:8M+Qfch4WNi3PPpRhWtmcLFTCq8zlIjnxrc8iRigAY0=
+github.com/grafana/grafana-plugin-sdk-go v0.131.0/go.mod h1:jmrxelOJKrIK0yrsIzcotS8pbqPZozbmJgGy7k3hK1k=
github.com/grafana/loki v1.6.2-0.20211015002020-7832783b1caa h1:+pXjAxavVR2FKKNsuuCXGCWEj8XGc1Af6SPiyBpzU2A=
github.com/grafana/loki v1.6.2-0.20211015002020-7832783b1caa/go.mod h1:0O8o/juxNSKN/e+DzWDTRkl7Zm8CkZcz0NDqEdojlrk=
github.com/grafana/saml v0.0.0-20211007135653-aed1b2edd86b h1:YiSGp34F4V0G08HHx1cJBf2GVgwYAkXQjzuVs1t8jYk=
github.com/grafana/saml v0.0.0-20211007135653-aed1b2edd86b/go.mod h1:q83kyQoMD0vhy+RzFLlbw0UgHJ6TAihQpuXvdFmm4s4=
github.com/grafana/sqlds/v2 v2.3.2/go.mod h1:34uyqPBWsEvg4V/xxh6V4uIqwu1qLfOfsmScll/ukrk=
+github.com/grafana/thema v0.0.0-20220413232647-fc54c169b508 h1:6k0scTj6kRDjn/qLigsLc9OTiMMaWkRUxjKBFowWEWg=
+github.com/grafana/thema v0.0.0-20220413232647-fc54c169b508/go.mod h1:KuqTKX9lfM87uu9vt9DS/q+REqSrAm2xYMnBBvlmevA=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
@@ -1411,8 +1440,9 @@ github.com/hashicorp/consul/api v1.7.0/go.mod h1:1NSuaUUkFaJzMasbfq/11wKYWSR67Xn
github.com/hashicorp/consul/api v1.8.1/go.mod h1:sDjTOq0yUyv5G4h+BqSea7Fn6BU+XbolEz1952UB+mk=
github.com/hashicorp/consul/api v1.9.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
github.com/hashicorp/consul/api v1.10.0/go.mod h1:sDjTOq0yUyv5G4h+BqSea7Fn6BU+XbolEz1952UB+mk=
-github.com/hashicorp/consul/api v1.10.1 h1:MwZJp86nlnL+6+W1Zly4JUuVn9YHhMggBirMpHGD7kw=
github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
+github.com/hashicorp/consul/api v1.11.0 h1:Hw/G8TtRvOElqxVIhBzXciiSTbapq8hZ2XKZsXk5ZCE=
+github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/consul/sdk v0.4.0/go.mod h1:fY08Y9z5SvJqevyZNy6WWPXiG3KwBPAvlcdx16zZ0fM=
@@ -1426,24 +1456,19 @@ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv
github.com/hashicorp/go-bexpr v0.1.2/go.mod h1:ANbpTX1oAql27TZkKVeW8p1w8NTdnyzPe/0qqPCKohU=
github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
-github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-connlimit v0.3.0/go.mod h1:OUj9FGL1tPIhl/2RCfzYHrIiWj+VVPGNyVPnUX8AqS0=
github.com/hashicorp/go-discover v0.0.0-20200501174627-ad1e96bde088/go.mod h1:vZu6Opqf49xX5lsFAu7iFNewkcVF1sn/wyapZh5ytlg=
-github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI=
-github.com/hashicorp/go-hclog v0.8.0/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
-github.com/hashicorp/go-hclog v0.9.1/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
-github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
-github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
-github.com/hashicorp/go-hclog v0.12.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
-github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-hclog v0.16.1 h1:IVQwpTGNRRIHafnTs2dQLIk4ENtneRIEEJWOVDqz99o=
github.com/hashicorp/go-hclog v0.16.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.1.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.2.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
-github.com/hashicorp/go-immutable-radix v1.3.0 h1:8exGP7ego3OmkfksihtSouGMZ+hQrhxx+FVELeXpVPE=
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
+github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-kms-wrapping/entropy v0.1.0/go.mod h1:d1g9WGtAunDNpek8jUIEJnBlbgKS1N2Q61QkHiZyR1g=
github.com/hashicorp/go-memdb v1.3.1/go.mod h1:Mluclgwib3R93Hk5fxEfiRhB+6Dar64wWh71LpNSe3g=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
@@ -1499,8 +1524,9 @@ github.com/hashicorp/memberlist v0.1.5/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p
github.com/hashicorp/memberlist v0.2.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/memberlist v0.2.3/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
-github.com/hashicorp/memberlist v0.2.4 h1:OOhYzSvFnkFQXm1ysE8RjXTHsqSRDyP4emusC9K7DYg=
github.com/hashicorp/memberlist v0.2.4/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
+github.com/hashicorp/memberlist v0.3.0 h1:8+567mCcFDnS5ADl7lrpxPMWiFCElyUEeW0gtj34fMA=
+github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/net-rpc-msgpackrpc v0.0.0-20151116020338-a14192a58a69/go.mod h1:/z+jUGRBlwVpUZfjute9jWaF6/HuhjuFQuL1YXzVD1Q=
github.com/hashicorp/raft v1.1.1/go.mod h1:vPAJM8Asw6u8LxC3eJCUZmRP/E4QmUGE1R7g7k8sG/8=
github.com/hashicorp/raft v1.2.0/go.mod h1:vPAJM8Asw6u8LxC3eJCUZmRP/E4QmUGE1R7g7k8sG/8=
@@ -1513,8 +1539,9 @@ github.com/hashicorp/serf v0.8.3/go.mod h1:UpNcs7fFbpKIyZaUuSW6EPiH+eZC7OuyFD+wc
github.com/hashicorp/serf v0.8.5/go.mod h1:UpNcs7fFbpKIyZaUuSW6EPiH+eZC7OuyFD+wc1oal+k=
github.com/hashicorp/serf v0.9.0/go.mod h1:YL0HO+FifKOW2u1ke99DGVu1zhcpZzNwrLIqBC7vbYU=
github.com/hashicorp/serf v0.9.3/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
-github.com/hashicorp/serf v0.9.5 h1:EBWvyu9tcRszt3Bxp3KNssBMP1KuHWyO51lz9+786iM=
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
+github.com/hashicorp/serf v0.9.6 h1:uuEX1kLR6aoda1TBttmJQKDLZE1Ob7KN0NPdE7EtCDc=
+github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoIospckxBxk6Q=
github.com/hashicorp/vault/api v1.0.5-0.20200717191844-f687267c8086/go.mod h1:R3Umvhlxi2TN7Ex2hzOowyeNb+SfbVWI973N+ctaFMk=
github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M=
@@ -1599,10 +1626,48 @@ github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7Ua
github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g=
github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
github.com/j-keck/arping v0.0.0-20160618110441-2cf9dc699c56/go.mod h1:ymszkNOg6tORTn+6F6j+Jc8TOr5osrynvN6ivFWZ2GA=
+github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
+github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
+github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ=
+github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
+github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
+github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
+github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
+github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
+github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
+github.com/jackc/pgconn v1.11.0/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
+github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
+github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
+github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
+github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
+github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
+github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
+github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
+github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
+github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+github.com/jackc/pgproto3/v2 v2.2.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
+github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
+github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
+github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
+github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
+github.com/jackc/pgtype v1.10.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx v3.2.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
github.com/jackc/pgx v3.3.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
github.com/jackc/pgx v3.6.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
+github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
+github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
+github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
+github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
+github.com/jackc/pgx/v4 v4.15.0/go.mod h1:D/zyOyXiaM1TmVWnOM18p0xdDtdakRBa0RsVGI3U3bw=
+github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jaegertracing/jaeger v1.24.0/go.mod h1:mqdtFDA447va5j0UewDaAWyNlGreGQyhGxXVhbF58gQ=
github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da/go.mod h1:ks+b9deReOc7jgqp+e7LuFiCBH6Rm5hL32cLcEAArb4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
@@ -1693,8 +1758,8 @@ github.com/klauspost/compress v1.11.12/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdY
github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.13.1/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
-github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc=
-github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
+github.com/klauspost/compress v1.15.1 h1:y9FcTHGyrebwfP0ZZqFiaxTaiDnUrGkJkI+f583BL1A=
+github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/cpuid v0.0.0-20170728055534-ae7887de9fa5/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
@@ -1743,10 +1808,11 @@ github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgx
github.com/leodido/ragel-machinery v0.0.0-20181214104525-299bdde78165/go.mod h1:WZxr2/6a/Ar9bMDc2rN/LJrE/hF6bXE4LPyDSIxwAfg=
github.com/leoluk/perflib_exporter v0.1.0/go.mod h1:rpV0lYj7lemdTm31t7zpCqYqPnw7xs86f+BaaNBVYFM=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
-github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk=
github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
@@ -1795,25 +1861,31 @@ github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxq
github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM=
github.com/mattetti/filebuffer v1.0.1/go.mod h1:YdMURNDOttIiruleeVr6f56OrMc+MydEnTcXwtkxNVs=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
+github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-ieproxy v0.0.0-20190610004146-91bb50d98149/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc=
github.com/mattn/go-ieproxy v0.0.0-20190702010315-6dee0af9227d/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc=
github.com/mattn/go-ieproxy v0.0.0-20191113090002-7c0f6868bffe/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E=
github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E=
+github.com/mattn/go-ieproxy v0.0.3/go.mod h1:6ZpRmhBaYuBX1U2za+9rC9iCGLsSp2tftelZne7CPko=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
-github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
@@ -1890,8 +1962,8 @@ github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR
github.com/mitchellh/mapstructure v1.4.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.4.1-0.20210112042008-8ebf2d61a8b4/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/mitchellh/mapstructure v1.4.2 h1:6h7AQ0yhTcIsmFmnAwQls75jp2Gzs4iB8W7pjMO+rqo=
-github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
+github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A=
github.com/mitchellh/pointerstructure v1.0.0/go.mod h1:k4XwG94++jLVsSiTxo7qdIfXA9pj9EAeo0QsNNJOLZ8=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
@@ -2069,6 +2141,7 @@ github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUr
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
+github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/peterh/liner v1.0.1-0.20180619022028-8c1271fcf47f/go.mod h1:xIteQHvHuaLYG9IFj6mSxM0fCKrs34IrEQUhOYuGPHc=
@@ -2266,13 +2339,16 @@ github.com/rs/cors v1.8.0 h1:P2KMzcFwrPoSjkF1WLRPsp3UMLyql8L4v9hQpVeK5so=
github.com/rs/cors v1.8.0/go.mod h1:EBwu+T5AvHOcXwvZIkQFjUN6s8Czyqw12GL/Y0tUyRM=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.4.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
+github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
+github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/russellhaering/goxmldsig v1.1.0/go.mod h1:QK8GhXPB3+AfuCrfo0oRISa9NfzeCpWmxeGnqEpDF9o=
github.com/russellhaering/goxmldsig v1.1.1 h1:vI0r2osGF1A9PLvsGdPUAGwEIrKa4Pj5sesSBsebIxM=
github.com/russellhaering/goxmldsig v1.1.1/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
-github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
@@ -2280,6 +2356,7 @@ github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFo
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
github.com/safchain/ethtool v0.0.0-20200218184317-f459e2d13664/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
+github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
github.com/samuel/go-zookeeper v0.0.0-20180130194729-c4fab1ac1bec/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
github.com/samuel/go-zookeeper v0.0.0-20190810000440-0ceca61e4d75/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
@@ -2322,7 +2399,6 @@ github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFR
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 h1:bUGsEnyNbVPw06Bs80sCeARAlK8lhwqGyi6UT8ymuGk=
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
-github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/shurcooL/vfsgen v0.0.0-20180825020608-02ddb050ef6b/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
@@ -2368,6 +2444,7 @@ github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
+github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
@@ -2375,6 +2452,7 @@ github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHN
github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI=
github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
+github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
@@ -2387,6 +2465,7 @@ github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DM
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
+github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM=
github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8=
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
github.com/streadway/amqp v0.0.0-20180528204448-e5adc2ada8b8/go.mod h1:1WNBiOZtZQLpVAyu0iTduoJL9hEsMloAK5XWrtW0xdY=
@@ -2409,8 +2488,9 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
@@ -2563,6 +2643,7 @@ github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da/go.mod h1:E1AXubJB
github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA=
github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg=
+github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
@@ -2587,9 +2668,12 @@ go.etcd.io/etcd v0.5.0-alpha.5.0.20200910180754-dd1b699fc489/go.mod h1:yVHk9ub3C
go.etcd.io/etcd v3.3.25+incompatible/go.mod h1:yaeTdrJi5lOmYerz05bd8+V7KubZs8YSFZfzsF9A6aI=
go.etcd.io/etcd/api/v3 v3.5.0-alpha.0/go.mod h1:mPcW6aZJukV6Aa81LSKpBjQXTWlXB5r74ymPoSWa3Sw=
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
+go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
+go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.0-alpha.0/go.mod h1:kdV+xzCJ3luEBSIeQyB/OEKkWKd8Zkux4sbDeANrosU=
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
+go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs=
go.etcd.io/etcd/client/v3 v3.5.0-alpha.0/go.mod h1:wKt7jgDgf/OfKiYmCq5WFGxOFAkVMLxiiXgLDFhECr8=
go.etcd.io/etcd/client/v3 v3.5.0-alpha.0.0.20210225194612-fa82d11a958a/go.mod h1:wKt7jgDgf/OfKiYmCq5WFGxOFAkVMLxiiXgLDFhECr8=
go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0=
@@ -2634,8 +2718,8 @@ go.opentelemetry.io/contrib/zpages v0.0.0-20210722161726-7668016acb73/go.mod h1:
go.opentelemetry.io/otel v0.11.0/go.mod h1:G8UCk+KooF2HLkgo8RHX9epABH/aRGYET7gQOqBVdB0=
go.opentelemetry.io/otel v1.0.0-RC1/go.mod h1:x9tRa9HK4hSSq7jf2TKbqFbtt58/TGk0f9XiEYISI1I=
go.opentelemetry.io/otel v1.0.0/go.mod h1:AjRVh9A5/5DE7S+mZtTR6t8vpKKryam+0lREnfmS4cg=
-go.opentelemetry.io/otel v1.2.0 h1:YOQDvxO1FayUcT9MIhJhgMyNO1WqoduiyvQHzGN0kUQ=
-go.opentelemetry.io/otel v1.2.0/go.mod h1:aT17Fk0Z1Nor9e0uisf98LrntPGMnk4frBO9+dkf69I=
+go.opentelemetry.io/otel v1.6.3 h1:FLOfo8f9JzFVFVyU+MSRJc2HdEAXQgm7pIv2uFKRSZE=
+go.opentelemetry.io/otel v1.6.3/go.mod h1:7BgNga5fNlF/iZjG06hM3yofffp0ofKCDwSXx1GC4dI=
go.opentelemetry.io/otel/exporters/jaeger v1.0.0 h1:cLhx8llHw02h5JTqGqaRbYn+QVKHmrzD9vEbKnSPk5U=
go.opentelemetry.io/otel/exporters/jaeger v1.0.0/go.mod h1:q10N1AolE1JjqKrFJK2tYw0iZpmX+HBaXBtuCzRnBGQ=
go.opentelemetry.io/otel/internal/metric v0.21.0/go.mod h1:iOfAaY2YycsXfYD4kaRSbLx2LKmfpKObWBEv9QK5zFo=
@@ -2646,8 +2730,8 @@ go.opentelemetry.io/otel/sdk v1.0.0 h1:BNPMYUONPNbLneMttKSjQhOTlFLOD9U22HNG1KrIN
go.opentelemetry.io/otel/sdk v1.0.0/go.mod h1:PCrDHlSy5x1kjezSdL37PhbFUMjrsLRshJ2zCzeXwbM=
go.opentelemetry.io/otel/trace v1.0.0-RC1/go.mod h1:86UHmyHWFEtWjfWPSbu0+d0Pf9Q6e1U+3ViBOc+NXAg=
go.opentelemetry.io/otel/trace v1.0.0/go.mod h1:PXTWqayeFUlJV1YDNhsJYB184+IvAH814St6o6ajzIs=
-go.opentelemetry.io/otel/trace v1.2.0 h1:Ys3iqbqZhcf28hHzrm5WAquMkDHNZTUkw7KHbuNjej0=
-go.opentelemetry.io/otel/trace v1.2.0/go.mod h1:N5FLswTubnxKxOJHM7XZC074qpeEdLy3CgAVsdMucK0=
+go.opentelemetry.io/otel/trace v1.6.3 h1:IqN4L+5b0mPNjdXIiZ90Ni4Bl5BRkDQywePLWemd9bc=
+go.opentelemetry.io/otel/trace v1.6.3/go.mod h1:GNJQusJlUgZl9/TQBPKU/Y/ty+0iVB5fjhKeJGZPGFs=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.starlark.net v0.0.0-20200901195727-6e684ef5eeee/go.mod h1:f0znQkUKRrkk36XxWbGjMqQM8wGv/xHBVE2qc3B5oFU=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
@@ -2663,14 +2747,14 @@ go.uber.org/automaxprocs v1.2.0/go.mod h1:YfO3fm683kQpzETxlTGZhGIVmXAhaw3gxeBADb
go.uber.org/automaxprocs v1.4.0/go.mod h1:/mTEdr7LvHhs0v7mjdxDreTz1OG5zdZGqgOnhWiR/+Q=
go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
-go.uber.org/goleak v1.1.11-0.20210813005559-691160354723 h1:sHOAIxRGBp443oHZIPB+HsUGaksVCXVQENPxwTfQdH4=
-go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
+go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
+go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
-go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
+go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
@@ -2680,10 +2764,12 @@ go.uber.org/zap v1.14.1/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc=
go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
-go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
+go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
go4.org/intern v0.0.0-20210108033219-3eb7198706b2/go.mod h1:vLqJ+12kCw61iCWsPto0EOHhBS+o4rO5VIucbc9g2Cc=
go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222175341-b30ae309168e/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E=
go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222180813-1025295fd063/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E=
+gocloud.dev v0.25.0 h1:Y7vDq8xj7SyM848KXf32Krda2e6jQ4CLh/mTeCSqXtk=
+gocloud.dev v0.25.0/go.mod h1:7HegHVCYZrMiU3IE1qtnzf/vRrDwLYnRNR3EhWX8x9Y=
golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20180505025534-4ec37c66abab/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20180608092829-8ac0e0d97ce4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -2695,6 +2781,7 @@ golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnf
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190418165655-df01cb2cc480/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -2725,6 +2812,7 @@ golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
@@ -2732,10 +2820,12 @@ golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI=
-golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 h1:tkVvjkPTB7pnW3jnid7kNyAMPVWllTNOf/qKDze4p9o=
+golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -2885,8 +2975,12 @@ golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0=
-golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220401154927-543a649e0bdd h1:zYlwaUHTmxuf6H7hwO2dgwqozQmH7zf4x+/qql4oVWc=
+golang.org/x/net v0.0.0-20220401154927-543a649e0bdd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -2909,8 +3003,10 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg=
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
+golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM=
+golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -3079,19 +3175,29 @@ golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211110154304-99a53858aa08/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8pY/4yfcXrddB8qAbU0=
+golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f h1:rlezHXNlxYWvBCzNses9Dlc7nGFaNMJeqLolcmQSSZY=
+golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -3115,8 +3221,9 @@ golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxb
golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 h1:GZokNIeuVkl3aZHJchRrr13WCsols02MLUcz1U9is6M=
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs=
+golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -3140,6 +3247,7 @@ golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3
golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190425222832-ad9eeb80039a/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
@@ -3152,6 +3260,7 @@ golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgw
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190813034749-528a2984e271/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190918214516-5a1a30219888/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -3226,6 +3335,8 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.9 h1:j9KsMiaP1c3B0OTQGth0/k+miLGTgLsAFUCrF2vLcF8=
golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
+golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -3286,8 +3397,18 @@ google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqiv
google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E=
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
-google.golang.org/api v0.60.0 h1:eq/zs5WPH4J9undYM9IP1O7dSr7Yh8Y0GtSCpzGzIUk=
-google.golang.org/api v0.60.0/go.mod h1:d7rl65NZAkEQ90JFzqBjcRq1TVeG5ZoGV3sSpEnnVb4=
+google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
+google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw=
+google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
+google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM=
+google.golang.org/api v0.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M=
+google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
+google.golang.org/api v0.68.0/go.mod h1:sOM8pTpwgflXRhz+oC8H2Dr+UcbMqkPPWNJo88Q7TH8=
+google.golang.org/api v0.69.0/go.mod h1:boanBiw+h5c3s+tBPgEzLDRHfFLWV0qXxRHz3ws7C80=
+google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
+google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
+google.golang.org/api v0.74.0 h1:ExR2D+5TYIrMphWgs5JCgwRhEDlPDXXrLwHHMgPHTXE=
+google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -3384,13 +3505,30 @@ google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEc
google.golang.org/genproto v0.0.0-20210921142501-181ce0d877f6/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211018162055-cf77aa76bad2/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211019152133-63b7e35f4404/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 h1:b9mVrqYfq3P4bCdaLg1qtBnPzUYgglsIdjZkL/fQVOE=
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220204002441-d6cc3cc0770e/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
+google.golang.org/genproto v0.0.0-20220401170504-314d38edb7de h1:9Ti5SG2U4cAcluryUo/sFay3TQKoxiFMfaT0pbizU7k=
+google.golang.org/genproto v0.0.0-20220401170504-314d38edb7de/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
@@ -3430,9 +3568,12 @@ google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQ
google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
+google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k=
-google.golang.org/grpc v1.42.0 h1:XT2/MFpuPFsEX2fWh3YQtHkZ+WYZFQRfaUgLZYj/p6A=
google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
+google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
+google.golang.org/grpc v1.45.0 h1:NEpgUqV3Z+ZjkqMsxMg11IaDrXY4RY6CQukSGK0uI1M=
+google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v0.0.0-20200910201057-6591123024b3/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
@@ -3447,8 +3588,9 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
+google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
@@ -3477,6 +3619,7 @@ gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/gorethink/gorethink.v3 v3.0.5/go.mod h1:+3yIIHJUGMBK+wyPH+iN5TP+88ikFDfZdqTlK3Y9q8I=
+gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/inf.v0 v0.9.0/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
@@ -3484,8 +3627,9 @@ gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ini.v1 v1.66.2 h1:XfR1dOYubytKy4Shzc2LHrrGhU0lDCfDGG1yLPmpgsI=
+gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/jcmturner/aescts.v1 v1.0.1/go.mod h1:nsR8qBOg+OucoIW+WMhB3GspUQXq9XorLnQb9XtvcOo=
gopkg.in/jcmturner/dnsutils.v1 v1.0.1/go.mod h1:m3v+5svpVOhtFAP/wSz+yzh4Mc0Fg7eRhxkJMWSIz9Q=
gopkg.in/jcmturner/goidentity.v3 v3.0.0/go.mod h1:oG2kH0IvSYNIu80dVAyu/yoefjq1mNfM5bm88whjWx4=
diff --git a/latest.json b/latest.json
index f1c9e890e90..9a36ea00bc2 100644
--- a/latest.json
+++ b/latest.json
@@ -1,4 +1,4 @@
{
- "stable": "8.4.5",
+ "stable": "8.4.6",
"testing": "8.5.0-beta1"
}
diff --git a/package.json b/package.json
index 37f76582ca8..2706e103b50 100644
--- a/package.json
+++ b/package.json
@@ -72,7 +72,7 @@
"*pkg/**/*.go": [
"gofmt -w -s"
],
- "*.{star}": [
+ "*.star": [
"make drone"
]
},
@@ -89,9 +89,9 @@
"@babel/preset-env": "7.16.11",
"@babel/preset-react": "7.16.7",
"@babel/preset-typescript": "7.16.7",
- "@betterer/betterer": "5.1.7",
- "@betterer/cli": "5.1.7",
- "@betterer/regexp": "5.1.7",
+ "@betterer/betterer": "5.3.0",
+ "@betterer/cli": "5.3.0",
+ "@betterer/regexp": "5.3.0",
"@emotion/eslint-plugin": "11.7.0",
"@grafana/api-documenter": "7.11.2",
"@grafana/e2e": "workspace:*",
@@ -142,7 +142,7 @@
"@types/papaparse": "5.3.2",
"@types/pluralize": "^0.0.29",
"@types/prismjs": "1.26.0",
- "@types/rc-time-picker": "^3",
+ "@types/rc-time-picker": "3.4.1",
"@types/react": "17.0.42",
"@types/react-beautiful-dnd": "13.1.2",
"@types/react-dom": "17.0.14",
@@ -219,7 +219,7 @@
"prettier": "2.6.0",
"raw-loader": "4.0.2",
"react-refresh": "0.11.0",
- "react-select-event": "^5.1.0",
+ "react-select-event": "5.5.0",
"react-test-renderer": "17.0.2",
"redux-mock-store": "1.5.4",
"rimraf": "3.0.2",
@@ -257,7 +257,7 @@
"@grafana/slate-react": "0.22.10-grafana",
"@grafana/ui": "workspace:*",
"@jaegertracing/jaeger-ui-components": "workspace:*",
- "@kusto/monaco-kusto": "4.1.6",
+ "@kusto/monaco-kusto": "5.0.0",
"@lezer/common": "0.15.12",
"@lezer/lr": "0.15.8",
"@lingui/core": "3.13.2",
@@ -325,7 +325,7 @@
"lru-cache": "7.7.1",
"memoize-one": "6.0.0",
"minisearch": "5.0.0-beta1",
- "moment": "2.29.1",
+ "moment": "2.29.2",
"moment-timezone": "0.5.34",
"monaco-editor": "^0.31.1",
"monaco-promql": "^1.7.2",
diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json
index 2f761e9a3f3..b4252fa7bb3 100644
--- a/packages/grafana-data/package.json
+++ b/packages/grafana-data/package.json
@@ -29,7 +29,7 @@
"eventemitter3": "4.0.7",
"lodash": "4.17.21",
"marked": "4.0.12",
- "moment": "2.29.1",
+ "moment": "2.29.2",
"moment-timezone": "0.5.34",
"ol": "6.14.1",
"papaparse": "5.3.2",
diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts
index bb003374d92..8231bb4c252 100644
--- a/packages/grafana-data/src/types/config.ts
+++ b/packages/grafana-data/src/types/config.ts
@@ -180,4 +180,5 @@ export interface GrafanaConfig {
geomapDisableCustomBaseLayer?: boolean;
unifiedAlertingEnabled: boolean;
angularSupportEnabled: boolean;
+ feedbackLinksEnabled: boolean;
}
diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts
index 69717854e2b..1396140c41e 100644
--- a/packages/grafana-data/src/types/datasource.ts
+++ b/packages/grafana-data/src/types/datasource.ts
@@ -468,7 +468,7 @@ export interface DataQueryError {
error?: string;
};
message?: string;
- status?: string;
+ status?: number;
statusText?: string;
refId?: string;
type?: DataQueryErrorType;
diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts
index a9cfee1a4e1..e706dde235f 100644
--- a/packages/grafana-data/src/types/navModel.ts
+++ b/packages/grafana-data/src/types/navModel.ts
@@ -30,6 +30,7 @@ export interface NavModelItem extends NavLinkDTO {
highlightId?: string;
tabSuffix?: ComponentType<{ className?: string }>;
hideFromNavbar?: boolean;
+ showIconInNavbar?: boolean;
}
export enum NavSection {
diff --git a/packages/grafana-data/src/utils/nodeGraph.ts b/packages/grafana-data/src/utils/nodeGraph.ts
index 03a1c48eda9..caccc59e5ef 100644
--- a/packages/grafana-data/src/utils/nodeGraph.ts
+++ b/packages/grafana-data/src/utils/nodeGraph.ts
@@ -1,9 +1,9 @@
export enum NodeGraphDataFrameFieldNames {
id = 'id',
title = 'title',
- subTitle = 'subTitle',
- mainStat = 'mainStat',
- secondaryStat = 'secondaryStat',
+ subTitle = 'subtitle',
+ mainStat = 'mainstat',
+ secondaryStat = 'secondarystat',
source = 'source',
target = 'target',
detail = 'detail__',
diff --git a/packages/grafana-data/tsconfig.json b/packages/grafana-data/tsconfig.json
index 39d54db4906..d56edaaf559 100644
--- a/packages/grafana-data/tsconfig.json
+++ b/packages/grafana-data/tsconfig.json
@@ -5,9 +5,7 @@
"rootDirs": ["."],
"paths": {
"@grafana/data": ["."]
- },
- "incremental": true,
- "tsBuildInfoFile": "./tsconfig.tsbuildinfo"
+ }
},
"exclude": ["dist", "node_modules"],
"extends": "@grafana/tsconfig",
diff --git a/packages/grafana-e2e-selectors/tsconfig.json b/packages/grafana-e2e-selectors/tsconfig.json
index e2299dee4fe..8a293c016ad 100644
--- a/packages/grafana-e2e-selectors/tsconfig.json
+++ b/packages/grafana-e2e-selectors/tsconfig.json
@@ -2,9 +2,7 @@
"compilerOptions": {
"declarationDir": "dist",
"outDir": "compiled",
- "rootDirs": ["."],
- "incremental": true,
- "tsBuildInfoFile": "./tsconfig.tsbuildinfo"
+ "rootDirs": ["."]
},
"exclude": ["dist", "node_modules"],
"extends": "@grafana/tsconfig",
diff --git a/packages/grafana-e2e/tsconfig.json b/packages/grafana-e2e/tsconfig.json
index a3cb5c5b14f..9936ed1fd49 100644
--- a/packages/grafana-e2e/tsconfig.json
+++ b/packages/grafana-e2e/tsconfig.json
@@ -3,9 +3,7 @@
"declarationDir": "dist",
"outDir": "compiled",
"rootDirs": ["."],
- "types": ["cypress"],
- "incremental": true,
- "tsBuildInfoFile": "./tsconfig.tsbuildinfo"
+ "types": ["cypress"]
},
"exclude": ["dist", "node_modules"],
"extends": "@grafana/tsconfig",
diff --git a/packages/grafana-runtime/src/components/PanelDataErrorView.tsx b/packages/grafana-runtime/src/components/PanelDataErrorView.tsx
index 25e577d6712..2e3907c9264 100644
--- a/packages/grafana-runtime/src/components/PanelDataErrorView.tsx
+++ b/packages/grafana-runtime/src/components/PanelDataErrorView.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { PanelData } from '@grafana/data';
+import { FieldConfigSource, PanelData } from '@grafana/data';
/**
* Describes the properties that can be passed to the PanelDataErrorView.
@@ -10,6 +10,7 @@ export interface PanelDataErrorViewProps {
message?: string;
panelId: number;
data: PanelData;
+ fieldConfig?: FieldConfigSource;
needsTimeField?: boolean;
needsNumberField?: boolean;
needsStringField?: boolean;
diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts
index f2599a42894..d5076e5ae6a 100644
--- a/packages/grafana-runtime/src/config.ts
+++ b/packages/grafana-runtime/src/config.ts
@@ -36,6 +36,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
externalUserMngLinkName = '';
externalUserMngInfo = '';
allowOrgCreate = false;
+ feedbackLinksEnabled = true;
disableLoginForm = false;
defaultDatasource = ''; // UID
alertingEnabled = false;
diff --git a/packages/grafana-runtime/tsconfig.json b/packages/grafana-runtime/tsconfig.json
index 444da147886..984ef9a09ed 100644
--- a/packages/grafana-runtime/tsconfig.json
+++ b/packages/grafana-runtime/tsconfig.json
@@ -3,9 +3,7 @@
"baseUrl": ".",
"declarationDir": "dist",
"outDir": "compiled",
- "rootDirs": ["."],
- "incremental": true,
- "tsBuildInfoFile": "./tsconfig.tsbuildinfo"
+ "rootDirs": ["."]
},
"exclude": ["dist", "node_modules"],
"extends": "@grafana/tsconfig",
diff --git a/packages/grafana-schema/src/scuemata/dashboard/dashboard.cue b/packages/grafana-schema/src/scuemata/dashboard/dashboard.cue
index cb7e767ca01..9040f0a28c3 100644
--- a/packages/grafana-schema/src/scuemata/dashboard/dashboard.cue
+++ b/packages/grafana-schema/src/scuemata/dashboard/dashboard.cue
@@ -1,434 +1,441 @@
package dashboard
import (
- "list"
+ "list"
- "github.com/grafana/grafana/cue/scuemata"
+ "github.com/grafana/grafana/cue/scuemata"
)
Family: scuemata.#Family & {
lineages: [
[
- { // 0.0
- // Unique numeric identifier for the dashboard.
- // TODO must isolate or remove identifiers local to a Grafana instance...?
- id?: number
- // Unique dashboard identifier that can be generated by anyone. string (8-40)
- uid?: string
- // Title of dashboard.
- title?: string
- // Description of dashboard.
- description?: string
+ {// 0.0
+ // Unique numeric identifier for the dashboard.
+ // TODO must isolate or remove identifiers local to a Grafana instance...?
+ id?: number
+ // Unique dashboard identifier that can be generated by anyone. string (8-40)
+ uid?: string
+ // Title of dashboard.
+ title?: string
+ // Description of dashboard.
+ description?: string
- gnetId?: string
- // Tags associated with dashboard.
- tags?: [...string]
- // Theme of dashboard.
- style: *"light" | "dark"
- // Timezone of dashboard,
- timezone?: *"browser" | "utc" | ""
- // Whether a dashboard is editable or not.
- editable: bool | *true
- // 0 for no shared crosshair or tooltip (default).
- // 1 for shared crosshair.
- // 2 for shared crosshair AND shared tooltip.
- graphTooltip: >=0 & <=2 | *0
- // Time range for dashboard, e.g. last 6 hours, last 7 days, etc
- time?: {
- from: string | *"now-6h"
- to: string | *"now"
- }
- // Timepicker metadata.
- timepicker?: {
- // Whether timepicker is collapsed or not.
- collapse: bool | *false
- // Whether timepicker is enabled or not.
- enable: bool | *true
- // Whether timepicker is visible or not.
- hidden: bool | *false
- // Selectable intervals for auto-refresh.
- refresh_intervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
- }
- // Templating.
- templating?: list: [...{...}]
- // Annotations.
- annotations?: list: [...{
- builtIn: number | *0
- // Datasource to use for annotation.
- datasource: string
- // Whether annotation is enabled.
- enable: bool | *true
- // Whether to hide annotation.
- hide?: bool | *false
- // Annotation icon color.
- iconColor?: string
- // Name of annotation.
- name?: string
- type: string | *"dashboard"
- // Query for annotation data.
- rawQuery?: string
- showIn: number | *0
- }]
- // Auto-refresh interval.
- refresh?: string | false
- // Version of the JSON schema, incremented each time a Grafana update brings
- // changes to said schema.
- // FIXME this is the old schema numbering system, and will be replaced by scuemata
- schemaVersion: number | *30
- // Version of the dashboard, incremented each time the dashboard is updated.
- version?: number
- panels?: [...(#Panel | #GraphPanel | #RowPanel)]
+ gnetId?: string
+ // Tags associated with dashboard.
+ tags?: [...string]
+ // Theme of dashboard.
+ style: *"light" | "dark"
+ // Timezone of dashboard,
+ timezone?: *"browser" | "utc" | ""
+ // Whether a dashboard is editable or not.
+ editable: bool | *true
+ // 0 for no shared crosshair or tooltip (default).
+ // 1 for shared crosshair.
+ // 2 for shared crosshair AND shared tooltip.
+ graphTooltip: >=0 & <=2 | *0
+ // Time range for dashboard, e.g. last 6 hours, last 7 days, etc
+ time?: {
+ from: string | *"now-6h"
+ to: string | *"now"
+ }
+ // Timepicker metadata.
+ timepicker?: {
+ // Whether timepicker is collapsed or not.
+ collapse: bool | *false
+ // Whether timepicker is enabled or not.
+ enable: bool | *true
+ // Whether timepicker is visible or not.
+ hidden: bool | *false
+ // Selectable intervals for auto-refresh.
+ refresh_intervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
+ }
+ // Templating.
+ templating?: list: [...{...}]
+ // Annotations.
+ annotations?: list: [...{
+ builtIn: number | *0
+ // Datasource to use for annotation.
+ datasource: {
+ type?: string
+ uid?: string
+ }
+ // Whether annotation is enabled.
+ enable: bool | *true
+ // Whether to hide annotation.
+ hide?: bool | *false
+ // Annotation icon color.
+ iconColor?: string
+ // Name of annotation.
+ name?: string
+ type: string | *"dashboard"
+ // Query for annotation data.
+ rawQuery?: string
+ showIn: number | *0
+ }]
+ // Auto-refresh interval.
+ refresh?: string | false
+ // Version of the JSON schema, incremented each time a Grafana update brings
+ // changes to said schema.
+ // FIXME this is the old schema numbering system, and will be replaced by scuemata
+ schemaVersion: number | *30
+ // Version of the dashboard, incremented each time the dashboard is updated.
+ version?: number
+ panels?: [...(#Panel | #GraphPanel | #RowPanel)]
- // TODO docs
- #FieldColorModeId: "thresholds" | "palette-classic" | "palette-saturated" | "continuous-GrYlRd" | "fixed" @cuetsy(kind="enum")
+ // TODO docs
+ #FieldColorModeId: "thresholds" | "palette-classic" | "palette-saturated" | "continuous-GrYlRd" | "fixed" @cuetsy(kind="enum")
- // TODO docs
- #FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type")
+ // TODO docs
+ #FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type")
- // TODO docs
- #FieldColor: {
- // The main color scheme mode
- mode: #FieldColorModeId | string
- // Stores the fixed color value if mode is fixed
- fixedColor?: string
- // Some visualizations need to know how to assign a series color from by value color schemes
- seriesBy?: #FieldColorSeriesByMode
- } @cuetsy(kind="interface")
+ // TODO docs
+ #FieldColor: {
+ // The main color scheme mode
+ mode: #FieldColorModeId | string
+ // Stores the fixed color value if mode is fixed
+ fixedColor?: string
+ // Some visualizations need to know how to assign a series color from by value color schemes
+ seriesBy?: #FieldColorSeriesByMode
+ } @cuetsy(kind="interface")
- // TODO docs
- #Threshold: {
- // TODO docs
- // FIXME the corresponding typescript field is required/non-optional, but nulls currently appear here when serializing -Infinity to JSON
- value?: number
- // TODO docs
- color: string
- // TODO docs
- // TODO are the values here enumerable into a disjunction?
- // Some seem to be listed in typescript comment
- state?: string
- } @cuetsy(kind="interface")
+ // TODO docs
+ #Threshold: {
+ // TODO docs
+ // FIXME the corresponding typescript field is required/non-optional, but nulls currently appear here when serializing -Infinity to JSON
+ value?: number
+ // TODO docs
+ color: string
+ // TODO docs
+ // TODO are the values here enumerable into a disjunction?
+ // Some seem to be listed in typescript comment
+ state?: string
+ } @cuetsy(kind="interface")
- #ThresholdsMode: "absolute" | "percentage" @cuetsy(kind="enum")
+ #ThresholdsMode: "absolute" | "percentage" @cuetsy(kind="enum")
- #ThresholdsConfig: {
- mode: #ThresholdsMode
+ #ThresholdsConfig: {
+ mode: #ThresholdsMode
- // Must be sorted by 'value', first value is always -Infinity
- steps: [...#Threshold]
- } @cuetsy(kind="interface")
+ // Must be sorted by 'value', first value is always -Infinity
+ steps: [...#Threshold]
+ } @cuetsy(kind="interface")
- // TODO docs
- // FIXME this is extremely underspecfied; wasn't obvious which typescript types corresponded to it
- #Transformation: {
- id: string
- options: {...}
- }
+ // TODO docs
+ // FIXME this is extremely underspecfied; wasn't obvious which typescript types corresponded to it
+ #Transformation: {
+ id: string
+ options: {...}
+ }
- // Schema for panel targets is specified by datasource
- // plugins. We use a placeholder definition, which the Go
- // schema loader either left open/as-is with the Base
- // variant of the Dashboard and Panel families, or filled
- // with types derived from plugins in the Instance variant.
- // When working directly from CUE, importers can extend this
- // type directly to achieve the same effect.
- #Target: {...}
+ // Schema for panel targets is specified by datasource
+ // plugins. We use a placeholder definition, which the Go
+ // schema loader either left open/as-is with the Base
+ // variant of the Dashboard and Panel families, or filled
+ // with types derived from plugins in the Instance variant.
+ // When working directly from CUE, importers can extend this
+ // type directly to achieve the same effect.
+ #Target: {...}
- // Dashboard panels. Panels are canonically defined inline
- // because they share a version timeline with the dashboard
- // schema; they do not evolve independently.
- #Panel: {
- // The panel plugin type id.
- type: !=""
+ // Dashboard panels. Panels are canonically defined inline
+ // because they share a version timeline with the dashboard
+ // schema; they do not evolve independently.
+ #Panel: {
+ // The panel plugin type id.
+ type: !=""
- // TODO docs
- id?: number
+ // TODO docs
+ id?: number
- // FIXME this almost certainly has to be changed in favor of scuemata versions
- pluginVersion?: string
+ // FIXME this almost certainly has to be changed in favor of scuemata versions
+ pluginVersion?: string
- // TODO docs
- tags?: [...string]
+ // TODO docs
+ tags?: [...string]
- // Internal - the exact major and minor versions of the panel plugin
- // schema. Hidden and therefore not a part of the data model, but
- // expected to be filled with panel plugin schema versions so that it's
- // possible to figure out which schema version matched on a successful
- // unification.
- // _pv: { maj: int, min: int }
- // The major and minor versions of the panel plugin for this schema.
- // TODO 2-tuple list instead of struct?
- // panelSchema?: { maj: number, min: number }
- panelSchema?: [number, number]
+ // Internal - the exact major and minor versions of the panel plugin
+ // schema. Hidden and therefore not a part of the data model, but
+ // expected to be filled with panel plugin schema versions so that it's
+ // possible to figure out which schema version matched on a successful
+ // unification.
+ // _pv: { maj: int, min: int }
+ // The major and minor versions of the panel plugin for this schema.
+ // TODO 2-tuple list instead of struct?
+ // panelSchema?: { maj: number, min: number }
+ panelSchema?: [number, number]
- // TODO docs
- targets?: [...#Target]
+ // TODO docs
+ targets?: [...#Target]
- // Panel title.
- title?: string
- // Description.
- description?: string
- // Whether to display the panel without a background.
- transparent: bool | *false
- // The datasource used in all targets.
- datasource?: {
- type?: string
- uid?: string
- }
- // Grid position.
- gridPos?: {
- // Panel
- h: number & >0 | *9
- // Panel
- w: number & >0 & <=24 | *12
- // Panel x
- x: number & >=0 & <24 | *0
- // Panel y
- y: number & >=0 | *0
- // true if fixed
- static?: bool
- }
- // Panel links.
- // FIXME this is temporarily specified as a closed list so
- // that validation will pass when no links are present, but
- // to force a failure as soon as it's checked against there
- // being anything in the list so it can be fixed in
- // accordance with that object
- links?: []
+ // Panel title.
+ title?: string
+ // Description.
+ description?: string
+ // Whether to display the panel without a background.
+ transparent: bool | *false
+ // The datasource used in all targets.
+ datasource?: {
+ type?: string
+ uid?: string
+ }
+ // Grid position.
+ gridPos?: {
+ // Panel
+ h: number & >0 | *9
+ // Panel
+ w: number & >0 & <=24 | *12
+ // Panel x
+ x: number & >=0 & <24 | *0
+ // Panel y
+ y: number & >=0 | *0
+ // true if fixed
+ static?: bool
+ }
+ // Panel links.
+ // FIXME this is temporarily specified as a closed list so
+ // that validation will pass when no links are present, but
+ // to force a failure as soon as it's checked against there
+ // being anything in the list so it can be fixed in
+ // accordance with that object
+ links?: []
- // Name of template variable to repeat for.
- repeat?: string
- // Direction to repeat in if 'repeat' is set.
- // "h" for horizontal, "v" for vertical.
- repeatDirection: *"h" | "v"
+ // Name of template variable to repeat for.
+ repeat?: string
+ // Direction to repeat in if 'repeat' is set.
+ // "h" for horizontal, "v" for vertical.
+ repeatDirection: *"h" | "v"
- // TODO docs
- maxDataPoints?: number
+ // TODO docs
+ maxDataPoints?: number
- // TODO docs
- thresholds?: [...]
+ // TODO docs
+ thresholds?: [...]
- // TODO docs
- timeRegions?: [...]
+ // TODO docs
+ timeRegions?: [...]
- transformations: [...#Transformation]
+ transformations: [...#Transformation]
- // TODO docs
- // TODO tighter constraint
- interval?: string
+ // TODO docs
+ // TODO tighter constraint
+ interval?: string
- // TODO docs
- // TODO tighter constraint
- timeFrom?: string
+ // TODO docs
+ // TODO tighter constraint
+ timeFrom?: string
- // TODO docs
- // TODO tighter constraint
- timeShift?: string
+ // TODO docs
+ // TODO tighter constraint
+ timeShift?: string
- // options is specified by the PanelOptions field in panel
- // plugin schemas.
- options: {}
+ // options is specified by the PanelOptions field in panel
+ // plugin schemas.
+ options: {}
- fieldConfig: {
- defaults: {
- // The display value for this field. This supports template variables blank is auto
- displayName?: string
+ fieldConfig: {
+ defaults: {
+ // The display value for this field. This supports template variables blank is auto
+ displayName?: string
- // This can be used by data sources that return and explicit naming structure for values and labels
- // When this property is configured, this value is used rather than the default naming strategy.
- displayNameFromDS?: string
+ // This can be used by data sources that return and explicit naming structure for values and labels
+ // When this property is configured, this value is used rather than the default naming strategy.
+ displayNameFromDS?: string
- // Human readable field metadata
- description?: string
+ // Human readable field metadata
+ description?: string
- // An explict path to the field in the datasource. When the frame meta includes a path,
- // This will default to `${frame.meta.path}/${field.name}
- //
- // When defined, this value can be used as an identifier within the datasource scope, and
- // may be used to update the results
- path?: string
+ // An explict path to the field in the datasource. When the frame meta includes a path,
+ // This will default to `${frame.meta.path}/${field.name}
+ //
+ // When defined, this value can be used as an identifier within the datasource scope, and
+ // may be used to update the results
+ path?: string
- // True if data source can write a value to the path. Auth/authz are supported separately
- writeable?: bool
+ // True if data source can write a value to the path. Auth/authz are supported separately
+ writeable?: bool
- // True if data source field supports ad-hoc filters
- filterable?: bool
+ // True if data source field supports ad-hoc filters
+ filterable?: bool
- // Numeric Options
- unit?: string
+ // Numeric Options
+ unit?: string
- // Significant digits (for display)
- decimals?: number
+ // Significant digits (for display)
+ decimals?: number
- min?: number
- max?: number
+ min?: number
+ max?: number
- // Convert input values into a display string
- //
- // TODO this one corresponds to a complex type with
- // generics on the typescript side. Ouch. Will
- // either need special care, or we'll just need to
- // accept a very loosely specified schema. It's very
- // unlikely we'll be able to translate cue to
- // typescript generics in the general case, though
- // this particular one *may* be able to work.
- mappings?: [...{...}]
+ // Convert input values into a display string
+ //
+ // TODO this one corresponds to a complex type with
+ // generics on the typescript side. Ouch. Will
+ // either need special care, or we'll just need to
+ // accept a very loosely specified schema. It's very
+ // unlikely we'll be able to translate cue to
+ // typescript generics in the general case, though
+ // this particular one *may* be able to work.
+ mappings?: [...{...}]
- // Map numeric values to states
- thresholds?: #ThresholdsConfig
+ // Map numeric values to states
+ thresholds?: #ThresholdsConfig
- // // Map values to a display color
- color?: #FieldColor
+ // // Map values to a display color
+ color?: #FieldColor
- // // Used when reducing field values
- // nullValueMode?: NullValueMode
+ // // Used when reducing field values
+ // nullValueMode?: NullValueMode
- // // The behavior when clicking on a result
- links?: [...]
+ // // The behavior when clicking on a result
+ links?: [...]
- // Alternative to empty string
- noValue?: string
+ // Alternative to empty string
+ noValue?: string
- // custom is specified by the PanelFieldConfig field
- // in panel plugin schemas.
- custom?: {}
- }
- overrides: [...{
- matcher: {
- id: string | *""
- options?: _
- }
- properties: [...{
- id: string | *""
- value?: _
- }]
- }]
- }
- // Embed the disjunction of all injected panel schema, if any were injected.
- if len(compose._panelSchemas) > 0 {
- or(compose._panelSchemas) // TODO try to stick graph in here
- }
+ // custom is specified by the PanelFieldConfig field
+ // in panel plugin schemas.
+ custom?: {}
+ }
+ overrides: [...{
+ matcher: {
+ id: string | *""
+ options?: _
+ }
+ properties: [...{
+ id: string | *""
+ value?: _
+ }]
+ }]
+ }
+ // Embed the disjunction of all injected panel schema, if any were injected.
+ if len(compose._panelSchemas) > 0 {
+ or(compose._panelSchemas)// TODO try to stick graph in here
+ }
- // Make the plugin-composed subtrees open if the panel is
- // of unknown types. This is important in every possible case:
- // - Base (this file only): no real dashboard json
- // containing any panels would ever validate
- // - Dist (this file + core plugin schema): dashboard json containing
- // panels with any third-party panel plugins would fail to validate,
- // as well as any core plugins lacking a models.cue. The latter case
- // is not normally expected, but this is not the appropriate place
- // to enforce the invariant, anyway.
- // - Instance (this file + core + third-party plugin schema): dashboard
- // json containing panels with a third-party plugin that exists but
- // is not currently installed would fail to validate.
- if !list.Contains(compose._panelTypes, type) {
- options: {...}
- fieldConfig: defaults: custom: {...}
- ...
- }
- }
+ // Make the plugin-composed subtrees open if the panel is
+ // of unknown types. This is important in every possible case:
+ // - Base (this file only): no real dashboard json
+ // containing any panels would ever validate
+ // - Dist (this file + core plugin schema): dashboard json containing
+ // panels with any third-party panel plugins would fail to validate,
+ // as well as any core plugins lacking a models.cue. The latter case
+ // is not normally expected, but this is not the appropriate place
+ // to enforce the invariant, anyway.
+ // - Instance (this file + core + third-party plugin schema): dashboard
+ // json containing panels with a third-party plugin that exists but
+ // is not currently installed would fail to validate.
+ if !list.Contains(compose._panelTypes, type) {
+ options: {...}
+ fieldConfig: defaults: custom: {...}
+ ...
+ }
+ }
- // Row panel
- #RowPanel: {
- type: "row"
- collapsed: bool | *false
- title?: string
+ // Row panel
+ #RowPanel: {
+ type: "row"
+ collapsed: bool | *false
+ title?: string
- // Name of default datasource.
- datasource?: string
+ // Name of default datasource.
+ datasource: {
+ type?: string
+ uid?: string
+ }
- gridPos?: {
- // Panel
- h: number & >0 | *9
- // Panel
- w: number & >0 & <=24 | *12
- // Panel x
- x: number & >=0 & <24 | *0
- // Panel y
- y: number & >=0 | *0
- // true if fixed
- static?: bool
- }
- id: number
- panels: [...(#Panel | #GraphPanel)]
- // Name of template variable to repeat for.
- repeat?: string
- }
- // Support for legacy graph panels.
- #GraphPanel: {
- ...
- type: "graph"
- thresholds: [...{...}]
- timeRegions?: [...{...}]
- seriesOverrides: [...{...}]
- aliasColors?: [string]: string
- bars: bool | *false
- dashes: bool | *false
- dashLength: number | *10
- fill?: number
- fillGradient?: number
- hiddenSeries: bool | *false
- legend: {...}
- lines: bool | *false
- linewidth?: number
- nullPointMode: *"null" | "connected" | "null as zero"
- percentage: bool | *false
- points: bool | *false
- pointradius?: number
- renderer: string
- spaceLength: number | *10
- stack: bool | *false
- steppedLine: bool | *false
- tooltip?: {
- shared?: bool
- sort: number | *0
- value_type: *"individual" | "cumulative"
- }
- }
- }
- ]
+ gridPos?: {
+ // Panel
+ h: number & >0 | *9
+ // Panel
+ w: number & >0 & <=24 | *12
+ // Panel x
+ x: number & >=0 & <24 | *0
+ // Panel y
+ y: number & >=0 | *0
+ // true if fixed
+ static?: bool
+ }
+ id: number
+ panels: [...(#Panel | #GraphPanel)]
+ // Name of template variable to repeat for.
+ repeat?: string
+ }
+
+ // Support for legacy graph panels.
+ #GraphPanel: {
+ ...
+ type: "graph"
+ thresholds: [...{...}]
+ timeRegions?: [...{...}]
+ seriesOverrides: [...{...}]
+ aliasColors?: [string]: string
+ bars: bool | *false
+ dashes: bool | *false
+ dashLength: number | *10
+ fill?: number
+ fillGradient?: number
+ hiddenSeries: bool | *false
+ legend: {...}
+ lines: bool | *false
+ linewidth?: number
+ nullPointMode: *"null" | "connected" | "null as zero"
+ percentage: bool | *false
+ points: bool | *false
+ pointradius?: number
+ renderer: string
+ spaceLength: number | *10
+ stack: bool | *false
+ steppedLine: bool | *false
+ tooltip?: {
+ shared?: bool
+ sort: number | *0
+ value_type: *"individual" | "cumulative"
+ }
+ }
+ },
+ ],
]
- compose: {
- // Scuemata families for all panel types that should be composed into the
- // dashboard schema.
- Panel: [string]: scuemata.#PanelFamily
+ compose: {
+ // Scuemata families for all panel types that should be composed into the
+ // dashboard schema.
+ Panel: [string]: scuemata.#PanelFamily
- // _panelTypes: [for typ, _ in Panel {typ}]
- _panelTypes: [for typ, _ in Panel {typ}, "graph", "row"]
- _panelSchemas: [for typ, scue in Panel {
- for lv, lin in scue.lineages {
- for sv, sch in lin {
- (_mapPanel & {arg: {
- type: typ
- v: [lv, sv] // TODO add optionality for exact, at least, at most, any
- model: sch // TODO Does this need to be close()d?
- }}).out
- }
- }
- }, { type: string }]
- _mapPanel: {
- arg: {
- type: string & !=""
- v: [number, number]
- model: {...}
- }
- // Until CUE introduces the must() constraint, we have to enforce
- // that the input model is as expected by checking for unification
- // in this hidden property (see https://github.com/cue-lang/cue/issues/575).
- // If we unified arg.model with the scuemata.#PanelSchema
- // meta-schema directly, the struct openness (PanelOptions: {...})
- // would be applied to the actual schema instance in the arg. Here,
- // where we're actually putting those in the dashboard schema, want
- // those to be closed, or at least preserve closed-ness.
- _checkSchema: scuemata.#PanelSchema & arg.model
- out: {
- type: arg.type
- panelSchema: arg.v // TODO add optionality for exact, at least, at most, any
- options: arg.model.PanelOptions
- fieldConfig: defaults: custom: {}
- if arg.model.PanelFieldConfig != _|_ {
- fieldConfig: defaults: custom: arg.model.PanelFieldConfig
- }
- }
- }
- }
+ // _panelTypes: [for typ, _ in Panel {typ}]
+ _panelTypes: [ for typ, _ in Panel {typ}, "graph", "row"]
+ _panelSchemas: [ for typ, scue in Panel {
+ for lv, lin in scue.lineages {
+ for sv, sch in lin {
+ (_mapPanel & {arg: {
+ type: typ
+ v: [lv, sv] // TODO add optionality for exact, at least, at most, any
+ model: sch // TODO Does this need to be close()d?
+ }}).out
+ }
+ }
+ }, {type: string}]
+ _mapPanel: {
+ arg: {
+ type: string & !=""
+ v: [number, number]
+ model: {...}
+ }
+ // Until CUE introduces the must() constraint, we have to enforce
+ // that the input model is as expected by checking for unification
+ // in this hidden property (see https://github.com/cue-lang/cue/issues/575).
+ // If we unified arg.model with the scuemata.#PanelSchema
+ // meta-schema directly, the struct openness (PanelOptions: {...})
+ // would be applied to the actual schema instance in the arg. Here,
+ // where we're actually putting those in the dashboard schema, want
+ // those to be closed, or at least preserve closed-ness.
+ _checkSchema: scuemata.#PanelSchema & arg.model
+ out: {
+ type: arg.type
+ panelSchema: arg.v // TODO add optionality for exact, at least, at most, any
+ options: arg.model.PanelOptions
+ fieldConfig: defaults: custom: {}
+ if arg.model.PanelFieldConfig != _|_ {
+ fieldConfig: defaults: custom: arg.model.PanelFieldConfig
+ }
+ }
+ }
+ }
}
diff --git a/packages/grafana-schema/tsconfig.json b/packages/grafana-schema/tsconfig.json
index 35b2c110cf6..7bad1e46a47 100644
--- a/packages/grafana-schema/tsconfig.json
+++ b/packages/grafana-schema/tsconfig.json
@@ -2,9 +2,7 @@
"compilerOptions": {
"declarationDir": "dist",
"outDir": "compiled",
- "rootDirs": ["."],
- "incremental": true,
- "tsBuildInfoFile": "./tsconfig.tsbuildinfo"
+ "rootDirs": ["."]
},
"exclude": ["dist", "node_modules"],
"extends": "@grafana/tsconfig",
diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json
index 10130b923a7..d73b877eb70 100644
--- a/packages/grafana-ui/package.json
+++ b/packages/grafana-ui/package.json
@@ -58,7 +58,7 @@
"jquery": "3.6.0",
"lodash": "4.17.21",
"memoize-one": "6.0.0",
- "moment": "2.29.1",
+ "moment": "2.29.2",
"monaco-editor": "^0.31.1",
"ol": "6.14.1",
"prismjs": "1.27.0",
@@ -98,22 +98,21 @@
"@grafana/tsconfig": "^1.2.0-rc1",
"@mdx-js/react": "1.6.22",
"@rollup/plugin-commonjs": "21.0.2",
- "@rollup/plugin-image": "2.1.1",
"@rollup/plugin-node-resolve": "13.1.3",
- "@storybook/addon-a11y": "6.4.15",
- "@storybook/addon-actions": "6.4.15",
- "@storybook/addon-docs": "6.4.15",
- "@storybook/addon-essentials": "6.4.15",
+ "@storybook/addon-a11y": "6.4.21",
+ "@storybook/addon-actions": "6.4.21",
+ "@storybook/addon-docs": "6.4.21",
+ "@storybook/addon-essentials": "6.4.21",
"@storybook/addon-knobs": "6.4.0",
- "@storybook/addon-storysource": "6.4.15",
- "@storybook/addons": "6.4.15",
- "@storybook/api": "6.4.15",
- "@storybook/builder-webpack5": "6.4.15",
- "@storybook/components": "6.4.15",
- "@storybook/core-events": "6.4.15",
- "@storybook/manager-webpack5": "6.4.15",
- "@storybook/react": "6.4.15",
- "@storybook/theming": "6.4.15",
+ "@storybook/addon-storysource": "6.4.21",
+ "@storybook/addons": "6.4.21",
+ "@storybook/api": "6.4.21",
+ "@storybook/builder-webpack5": "6.4.21",
+ "@storybook/components": "6.4.21",
+ "@storybook/core-events": "6.4.21",
+ "@storybook/manager-webpack5": "6.4.21",
+ "@storybook/react": "6.4.21",
+ "@storybook/theming": "6.4.21",
"@swc/helpers": "0.3.8",
"@testing-library/dom": "8.13.0",
"@testing-library/jest-dom": "5.16.2",
@@ -168,6 +167,7 @@
"rimraf": "3.0.2",
"rollup": "2.70.1",
"rollup-plugin-sourcemaps": "0.6.3",
+ "rollup-plugin-svg-import": "^1.6.0",
"rollup-plugin-terser": "7.0.2",
"sass-loader": "12.6.0",
"storybook-dark-mode": "1.0.9",
diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts
index d40c5776084..71f712df14f 100644
--- a/packages/grafana-ui/rollup.config.ts
+++ b/packages/grafana-ui/rollup.config.ts
@@ -1,6 +1,6 @@
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
-import image from '@rollup/plugin-image';
+import svg from 'rollup-plugin-svg-import';
import { terser } from 'rollup-plugin-terser';
const pkg = require('./package.json');
@@ -41,7 +41,7 @@ const buildCjsPackage = ({ env }) => {
include: /node_modules/,
}),
resolve(),
- image(),
+ svg({ stringify: true }),
env === 'production' && terser(),
],
};
diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx
index 9758bfcb61a..e02a291226a 100755
--- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx
+++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx
@@ -82,7 +82,7 @@ function sameProps(prevProps: any, nextProps: any, propsToDiff: Array {
constructor(props: GraphNGProps) {
super(props);
- this.state = this.prepState(props);
+ let state = this.prepState(props);
+ state.alignedData = state.config!.prepData!([state.alignedFrame]) as AlignedData;
+ this.state = state;
this.plotInstance = React.createRef();
}
@@ -131,7 +133,6 @@ export class GraphNG extends React.Component {
state = {
alignedFrame,
- alignedData: config!.prepData!([alignedFrame]) as AlignedData,
config,
};
@@ -229,12 +230,13 @@ export class GraphNG extends React.Component {
if (shouldReconfig) {
newState.config = this.props.prepConfig(newState.alignedFrame, this.props.frames, this.getTimeRange);
- newState.alignedData = newState.config.prepData!([newState.alignedFrame]) as AlignedData;
pluginLog('GraphNG', false, 'config recreated', newState.config);
}
- }
- newState && this.setState(newState);
+ newState.alignedData = newState.config!.prepData!([newState.alignedFrame]) as AlignedData;
+
+ this.setState(newState);
+ }
}
}
@@ -255,7 +257,7 @@ export class GraphNG extends React.Component {
{(vizWidth: number, vizHeight: number) => (
) {
- const { children, className, root: portalRoot = document.body, forwardedRef } = props;
+ const { children, className, root, forwardedRef } = props;
const theme = useTheme2();
const node = useRef(null);
+ const portalRoot = root ?? getPortalContainer();
+
if (!node.current) {
node.current = document.createElement('div');
if (className) {
@@ -25,6 +27,7 @@ export function Portal(props: PropsWithChildren) {
if (node.current) {
portalRoot.appendChild(node.current);
}
+
return () => {
if (node.current) {
portalRoot.removeChild(node.current);
@@ -35,7 +38,18 @@ export function Portal(props: PropsWithChildren) {
return ReactDOM.createPortal({children}
, node.current);
}
+/** @internal */
+export function getPortalContainer() {
+ return window.document.getElementById('grafana-portal-container') ?? document.body;
+}
+
+/** @internal */
+export function PortalContainer() {
+ return
;
+}
+
export const RefForwardingPortal = React.forwardRef((props, ref) => {
return ;
});
+
RefForwardingPortal.displayName = 'RefForwardingPortal';
diff --git a/packages/grafana-ui/src/components/Select/Select.tsx b/packages/grafana-ui/src/components/Select/Select.tsx
index b6219969017..ad94830f156 100644
--- a/packages/grafana-ui/src/components/Select/Select.tsx
+++ b/packages/grafana-ui/src/components/Select/Select.tsx
@@ -13,7 +13,7 @@ export function MultiSelect(props: MultiSelectCommonProps) {
return ;
}
-interface AsyncSelectProps extends Omit, 'options'>, SelectAsyncProps {
+export interface AsyncSelectProps extends Omit, 'options'>, SelectAsyncProps {
// AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options
value?: SelectableValue | null;
invalid?: boolean;
diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx
index bd313b231e7..b74dc5258d0 100644
--- a/packages/grafana-ui/src/components/Select/SelectBase.tsx
+++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx
@@ -128,6 +128,7 @@ export function SelectBase({
onInputChange,
onKeyDown,
onOpenMenu,
+ onFocus,
openMenuOnFocus = false,
options = [],
placeholder = 'Choose',
@@ -235,6 +236,7 @@ export function SelectBase({
onKeyDown,
onMenuClose: onCloseMenu,
onMenuOpen: onOpenMenu,
+ onFocus,
formatOptionLabel,
openMenuOnFocus,
options,
diff --git a/packages/grafana-ui/src/components/Select/types.ts b/packages/grafana-ui/src/components/Select/types.ts
index 4b93366dc86..12606c768c0 100644
--- a/packages/grafana-ui/src/components/Select/types.ts
+++ b/packages/grafana-ui/src/components/Select/types.ts
@@ -61,6 +61,7 @@ export interface SelectCommonProps {
onInputChange?: (value: string, actionMeta: InputActionMeta) => void;
onKeyDown?: (event: React.KeyboardEvent) => void;
onOpenMenu?: () => void;
+ onFocus?: () => void;
openMenuOnFocus?: boolean;
options?: Array>;
placeholder?: string;
diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx
index dcd271f5d44..c956b401e0e 100755
--- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx
+++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx
@@ -20,7 +20,7 @@ import {
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { UPlotChart } from '../uPlot/Plot';
import { Themeable2 } from '../../types';
-import { preparePlotData } from '../uPlot/utils';
+import { preparePlotData2, getStackingGroups } from '../uPlot/utils';
import { preparePlotFrame } from './utils';
import { isEqual } from 'lodash';
@@ -51,7 +51,7 @@ export class Sparkline extends PureComponent {
const alignedDataFrame = preparePlotFrame(props.sparkline, props.config);
this.state = {
- data: preparePlotData([alignedDataFrame]),
+ data: preparePlotData2(alignedDataFrame, getStackingGroups(alignedDataFrame)),
alignedDataFrame,
configBuilder: this.prepareConfig(alignedDataFrame),
};
@@ -65,7 +65,7 @@ export class Sparkline extends PureComponent {
return {
...state,
- data: preparePlotData([frame]),
+ data: preparePlotData2(frame, getStackingGroups(frame)),
alignedDataFrame: frame,
};
}
diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx
index 1366744fd91..1097fad630f 100644
--- a/packages/grafana-ui/src/components/Table/Table.tsx
+++ b/packages/grafana-ui/src/components/Table/Table.tsx
@@ -304,9 +304,9 @@ export const Table: FC = memo((props: Props) => {
totalColumnsWidth={totalColumnsWidth}
/>
)}
- {paginationEl}
+ {paginationEl}
);
});
diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts
index c70f0c9e199..3682b5da17e 100644
--- a/packages/grafana-ui/src/components/Table/styles.ts
+++ b/packages/grafana-ui/src/components/Table/styles.ts
@@ -184,6 +184,9 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
div:not(:only-child):first-child {
flex-grow: 0.6;
}
+ position: absolute;
+ bottom: 0;
+ left: 0;
`,
paginationSummary: css`
color: ${theme.colors.text.secondary};
@@ -191,11 +194,16 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
margin-left: auto;
`,
- tableContentWrapper: (totalColumnsWidth: number) => css`
- width: ${totalColumnsWidth ?? '100%'};
- display: flex;
- flex-direction: column;
- `,
+ tableContentWrapper: (totalColumnsWidth: number) => {
+ const width = totalColumnsWidth !== undefined ? `${totalColumnsWidth}px` : '100%';
+
+ return css`
+ label: tableContentWrapper;
+ width: ${width};
+ display: flex;
+ flex-direction: column;
+ `;
+ },
row: css`
label: row;
border-bottom: 1px solid ${borderColor};
diff --git a/packages/grafana-ui/src/components/Tabs/Tab.tsx b/packages/grafana-ui/src/components/Tabs/Tab.tsx
index 562eb2669ee..d45f33cc436 100644
--- a/packages/grafana-ui/src/components/Tabs/Tab.tsx
+++ b/packages/grafana-ui/src/components/Tabs/Tab.tsx
@@ -38,18 +38,20 @@ export const Tab = React.forwardRef(
const linkClass = cx(tabsStyles.link, active ? tabsStyles.activeStyle : tabsStyles.notActive);
return (
-
+
);
}
);
diff --git a/packages/grafana-ui/src/components/Tabs/TabsBar.tsx b/packages/grafana-ui/src/components/Tabs/TabsBar.tsx
index b5eb2a07206..13fccb9223f 100644
--- a/packages/grafana-ui/src/components/Tabs/TabsBar.tsx
+++ b/packages/grafana-ui/src/components/Tabs/TabsBar.tsx
@@ -32,7 +32,9 @@ export const TabsBar = React.forwardRef(({ children, clas
return (
);
});
diff --git a/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx b/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx
index 2b0813d2ee4..e3d54e93ce5 100644
--- a/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx
+++ b/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx
@@ -19,7 +19,7 @@ export class UnthemedTimeSeries extends React.Component {
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
const { eventBus, sync } = this.context as PanelContext;
- const { theme, timeZone, legend, renderers, tweakAxis, tweakScale } = this.props;
+ const { theme, timeZone, renderers, tweakAxis, tweakScale } = this.props;
return preparePlotConfigBuilder({
frame: alignedFrame,
@@ -29,7 +29,6 @@ export class UnthemedTimeSeries extends React.Component {
eventBus,
sync,
allFrames,
- legend,
renderers,
tweakScale,
tweakAxis,
diff --git a/packages/grafana-ui/src/components/TimeSeries/utils.ts b/packages/grafana-ui/src/components/TimeSeries/utils.ts
index d812a8dc93d..ed9e084759e 100644
--- a/packages/grafana-ui/src/components/TimeSeries/utils.ts
+++ b/packages/grafana-ui/src/components/TimeSeries/utils.ts
@@ -23,10 +23,9 @@ import {
VisibilityMode,
ScaleDirection,
ScaleOrientation,
- VizLegendOptions,
StackingMode,
} from '@grafana/schema';
-import { collectStackingGroups, INTERNAL_NEGATIVE_Y_PREFIX, orderIdsByCalcs, preparePlotData } from '../uPlot/utils';
+import { getStackingGroups, preparePlotData2 } from '../uPlot/utils';
import uPlot from 'uplot';
import { buildScaleKey } from '../GraphNG/utils';
@@ -40,7 +39,6 @@ const defaultConfig: GraphFieldConfig = {
export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
sync?: () => DashboardCursorSync;
- legend?: VizLegendOptions;
}> = ({
frame,
theme,
@@ -50,13 +48,12 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
sync,
allFrames,
renderers,
- legend,
tweakScale = (opts) => opts,
tweakAxis = (opts) => opts,
}) => {
const builder = new UPlotConfigBuilder(timeZone);
- builder.setPrepData((prepData) => preparePlotData(prepData, undefined, legend));
+ builder.setPrepData((frames) => preparePlotData2(frames[0], builder.getStackingGroups()));
// X is the first field in the aligned frame
const xField = frame.fields[0];
@@ -122,8 +119,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
let customRenderedFields =
renderers?.flatMap((r) => Object.values(r.fieldMap).filter((name) => r.indicesOnly.indexOf(name) === -1)) ?? [];
- const stackingGroups: Map = new Map();
-
let indexByName: Map | undefined;
for (let i = 1; i < frame.fields.length; i++) {
@@ -328,20 +323,11 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
});
}
}
- collectStackingGroups(field, stackingGroups, seriesIndex);
}
- if (stackingGroups.size !== 0) {
- for (const [group, seriesIds] of stackingGroups.entries()) {
- const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame });
- for (let j = seriesIdxs.length - 1; j > 0; j--) {
- builder.addBand({
- series: [seriesIdxs[j], seriesIdxs[j - 1]],
- dir: group.startsWith(INTERNAL_NEGATIVE_Y_PREFIX) ? 1 : -1,
- });
- }
- }
- }
+ let stackingGroups = getStackingGroups(frame);
+
+ builder.setStackingGroups(stackingGroups);
// hook up custom/composite renderers
renderers?.forEach((r) => {
diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts
index 3d04933efe2..f4733b6ff7d 100644
--- a/packages/grafana-ui/src/components/index.ts
+++ b/packages/grafana-ui/src/components/index.ts
@@ -6,7 +6,7 @@ export { Tooltip } from './Tooltip/Tooltip';
export { PopoverContent } from './Tooltip/types';
export { PopoverController } from './Tooltip/PopoverController';
export { Popover } from './Tooltip/Popover';
-export { Portal } from './Portal/Portal';
+export { Portal, getPortalContainer, PortalContainer } from './Portal/Portal';
export { CustomScrollbar, ScrollbarPosition } from './CustomScrollbar/CustomScrollbar';
export { TabbedContainer, TabConfig } from './TabbedContainer/TabbedContainer';
diff --git a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx
index 26640d5b532..7505c6cb26f 100644
--- a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx
+++ b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx
@@ -6,7 +6,7 @@ import { GraphFieldConfig, GraphDrawStyle } from '@grafana/schema';
import uPlot from 'uplot';
import createMockRaf from 'mock-raf';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
-import { preparePlotData } from './utils';
+import { preparePlotData2, getStackingGroups } from './utils';
import { SeriesProps } from './config/UPlotSeriesBuilder';
const mockRaf = createMockRaf();
@@ -55,7 +55,7 @@ const mockData = () => {
const config = new UPlotConfigBuilder();
config.addSeries({} as SeriesProps);
- return { data: [data], timeRange, config };
+ return { data: data, timeRange, config };
};
describe('UPlotChart', () => {
@@ -75,7 +75,7 @@ describe('UPlotChart', () => {
const { unmount } = render(
{
const { rerender } = render(
{
expect(uPlot).toBeCalledTimes(1);
- data[0].fields[1].values.set(0, 1);
+ data.fields[1].values.set(0, 1);
rerender(
{
it('skips uPlot intialization for width and height equal 0', async () => {
const { data, timeRange, config } = mockData();
const { queryAllByTestId } = render(
-
+
);
expect(queryAllByTestId('uplot-main-div')).toHaveLength(1);
@@ -136,7 +142,7 @@ describe('UPlotChart', () => {
const { rerender } = render(
{
nextConfig.addSeries({} as SeriesProps);
rerender(
-
+
);
expect(destroyMock).toBeCalledTimes(1);
@@ -162,7 +174,7 @@ describe('UPlotChart', () => {
const { rerender } = render(
{
// we wait 1 frame for plugins initialisation logic to finish
rerender(
AlignedData | FacetedData;
+type PreDataStacked = (frames: DataFrame[], stackingGroups: StackingGroup[]) => AlignedData | FacetedData;
export class UPlotConfigBuilder {
private series: UPlotSeriesBuilder[] = [];
private axes: Record = {};
private scales: UPlotScaleBuilder[] = [];
private bands: Band[] = [];
+ private stackingGroups: StackingGroup[] = [];
private cursor: Cursor | undefined;
private select: uPlot.Select | undefined;
private hasLeftAxis = false;
@@ -143,6 +145,14 @@ export class UPlotConfigBuilder {
this.bands.push(band);
}
+ setStackingGroups(groups: StackingGroup[]) {
+ this.stackingGroups = groups;
+ }
+
+ getStackingGroups() {
+ return this.stackingGroups;
+ }
+
setTooltipInterpolator(interpolator: PlotTooltipInterpolator) {
this.tooltipInterpolator = interpolator;
}
@@ -151,10 +161,10 @@ export class UPlotConfigBuilder {
return this.tooltipInterpolator;
}
- setPrepData(prepData: PrepData) {
+ setPrepData(prepData: PreDataStacked) {
this.prepData = (frames) => {
this.frames = frames;
- return prepData(frames);
+ return prepData(frames, this.getStackingGroups());
};
}
@@ -221,6 +231,14 @@ export class UPlotConfigBuilder {
config.tzDate = this.tzDate;
config.padding = this.padding;
+ if (this.stackingGroups.length) {
+ this.stackingGroups.forEach((group) => {
+ getStackingBands(group).forEach((band) => {
+ this.addBand(band);
+ });
+ });
+ }
+
if (this.bands.length) {
config.bands = this.bands;
}
diff --git a/packages/grafana-ui/src/components/uPlot/utils.test.ts b/packages/grafana-ui/src/components/uPlot/utils.test.ts
index 8cf0e7c1445..3ccb288de24 100644
--- a/packages/grafana-ui/src/components/uPlot/utils.test.ts
+++ b/packages/grafana-ui/src/components/uPlot/utils.test.ts
@@ -1,6 +1,7 @@
-import { orderIdsByCalcs, preparePlotData, timeFormatToTemplate } from './utils';
+import { getStackingGroups, preparePlotData2, timeFormatToTemplate } from './utils';
import { FieldType, MutableDataFrame } from '@grafana/data';
-import { GraphTransform, StackingMode } from '@grafana/schema';
+import { BarAlignment, GraphDrawStyle, GraphTransform, LineInterpolation, StackingMode } from '@grafana/schema';
+import Units from 'ol/proj/Units';
describe('timeFormatToTemplate', () => {
it.each`
@@ -16,7 +17,7 @@ describe('timeFormatToTemplate', () => {
});
});
-describe('preparePlotData', () => {
+describe('preparePlotData2', () => {
const df = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [9997, 9998, 9999] },
@@ -27,7 +28,7 @@ describe('preparePlotData', () => {
});
it('creates array from DataFrame', () => {
- expect(preparePlotData([df])).toMatchInlineSnapshot(`
+ expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@@ -63,7 +64,7 @@ describe('preparePlotData', () => {
{ name: 'c', values: [20, 20, 20], config: { custom: { transform: GraphTransform.NegativeY } } },
],
});
- expect(preparePlotData([df])).toMatchInlineSnapshot(`
+ expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@@ -104,7 +105,7 @@ describe('preparePlotData', () => {
{ name: 'i', values: [20, undefined, 20, 20], config: { custom: { transform: GraphTransform.NegativeY } } },
],
});
- expect(preparePlotData([df])).toMatchInlineSnapshot(`
+ expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@@ -178,7 +179,7 @@ describe('preparePlotData', () => {
{ name: 'c', values: [20, 20, 20] },
],
});
- expect(preparePlotData([df])).toMatchInlineSnapshot(`
+ expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@@ -226,7 +227,7 @@ describe('preparePlotData', () => {
},
],
});
- expect(preparePlotData([df])).toMatchInlineSnapshot(`
+ expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@@ -273,7 +274,7 @@ describe('preparePlotData', () => {
},
],
});
- expect(preparePlotData([df])).toMatchInlineSnapshot(`
+ expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@@ -329,7 +330,7 @@ describe('preparePlotData', () => {
},
],
});
- expect(preparePlotData([df])).toMatchInlineSnapshot(`
+ expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@@ -397,7 +398,7 @@ describe('preparePlotData', () => {
],
});
- expect(preparePlotData([df])).toMatchInlineSnapshot(`
+ expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@@ -472,7 +473,7 @@ describe('preparePlotData', () => {
],
});
- expect(preparePlotData([df])).toMatchInlineSnapshot(`
+ expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@@ -507,239 +508,330 @@ describe('preparePlotData', () => {
]
`);
});
-
- describe('ignores nullish-only stacks', () => {
- test('single stacking group', () => {
- const df = new MutableDataFrame({
- fields: [
- { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] },
- {
- name: 'a',
- values: [-10, null, 10],
- config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
- },
- {
- name: 'b',
- values: [10, null, null],
- config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
- },
- {
- name: 'c',
- values: [20, undefined, 20],
- config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
- },
- ],
- });
-
- expect(preparePlotData([df])).toMatchInlineSnapshot(`
- Array [
- Array [
- 9997,
- 9998,
- 9999,
- ],
- Array [
- -10,
- null,
- 10,
- ],
- Array [
- 0,
- null,
- 10,
- ],
- Array [
- 20,
- null,
- 30,
- ],
- ]
- `);
- });
- test('multiple stacking groups', () => {
- const df = new MutableDataFrame({
- fields: [
- { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] },
- {
- name: 'a',
- values: [-10, undefined, 10],
- config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
- },
- {
- name: 'b',
- values: [10, undefined, 10],
- config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
- },
- {
- name: 'c',
- values: [20, undefined, 20],
- config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
- },
- {
- name: 'd',
- values: [1, 2, null],
- config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackB' } } },
- },
- {
- name: 'e',
- values: [1, 2, null],
- config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackB' } } },
- },
- {
- name: 'f',
- values: [1, 2, null],
- config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackB' } } },
- },
- ],
- });
-
- expect(preparePlotData([df])).toMatchInlineSnapshot(`
- Array [
- Array [
- 9997,
- 9998,
- 9999,
- ],
- Array [
- -10,
- null,
- 10,
- ],
- Array [
- 0,
- null,
- 20,
- ],
- Array [
- 20,
- null,
- 40,
- ],
- Array [
- 1,
- 2,
- null,
- ],
- Array [
- 2,
- 4,
- null,
- ],
- Array [
- 3,
- 6,
- null,
- ],
- ]
- `);
- });
- });
- describe('with legend sorted', () => {
- it('should affect when single group', () => {
- const df = new MutableDataFrame({
- fields: [
- { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] },
- {
- name: 'a',
- values: [-10, 20, 10],
- state: { calcs: { max: 20 } },
- config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
- },
- {
- name: 'b',
- values: [10, 10, 10],
- state: { calcs: { max: 10 } },
- config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
- },
- {
- name: 'c',
- values: [20, 20, 20],
- state: { calcs: { max: 20 } },
- config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
- },
- ],
- });
-
- expect(preparePlotData([df], undefined, { sortBy: 'Max', sortDesc: false } as any)).toMatchInlineSnapshot(`
- Array [
- Array [
- 9997,
- 9998,
- 9999,
- ],
- Array [
- 0,
- 30,
- 20,
- ],
- Array [
- 10,
- 10,
- 10,
- ],
- Array [
- 20,
- 50,
- 40,
- ],
- ]
- `);
- expect(preparePlotData([df], undefined, { sortBy: 'Max', sortDesc: true } as any)).toMatchInlineSnapshot(`
- Array [
- Array [
- 9997,
- 9998,
- 9999,
- ],
- Array [
- -10,
- 20,
- 10,
- ],
- Array [
- 20,
- 50,
- 40,
- ],
- Array [
- 10,
- 40,
- 30,
- ],
- ]
- `);
- });
- });
});
});
-describe('orderIdsByCalcs', () => {
- const ids = [1, 2, 3, 4];
- const frame = new MutableDataFrame({
- fields: [
- { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] },
- { name: 'a', values: [-10, 20, 10], state: { calcs: { min: -10 } } },
- { name: 'b', values: [20, 20, 20], state: { calcs: { min: 20 } } },
- { name: 'c', values: [10, 10, 10], state: { calcs: { min: 10 } } },
- { name: 'd', values: [30, 30, 30] },
- ],
+describe('auto stacking groups', () => {
+ test('split on stacking mode', () => {
+ const df = new MutableDataFrame({
+ fields: [
+ { name: 'time', type: FieldType.time, values: [0, 1, 2] },
+ {
+ name: 'b',
+ values: [1, 2, 3],
+ config: { custom: { stacking: { mode: StackingMode.Percent } } },
+ },
+ {
+ name: 'c',
+ values: [4, 5, 6],
+ config: { custom: { stacking: { mode: StackingMode.Normal } } },
+ },
+ ],
+ });
+
+ expect(getStackingGroups(df)).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "dir": 1,
+ "series": Array [
+ 1,
+ ],
+ },
+ Object {
+ "dir": 1,
+ "series": Array [
+ 2,
+ ],
+ },
+ ]
+ `);
});
- it.each([
- { legend: undefined },
- { legend: { sortBy: 'Min' } },
- { legend: { sortDesc: false } },
- { legend: {} },
- { sortBy: 'Mik', sortDesc: true },
- ])('should return without ordering if legend option is %o', (legend: any) => {
- const result = orderIdsByCalcs({ ids, frame, legend });
- expect(result).toEqual([1, 2, 3, 4]);
+ test('split pos/neg', () => {
+ // since we expect most series to be Pos, we try to bail early when scanning all values
+ // as soon as we find a value >= 0, it's assumed Pos, else Neg
+
+ const df = new MutableDataFrame({
+ fields: [
+ { name: 'time', type: FieldType.time, values: [0, 1, 2] },
+ {
+ name: 'a',
+ values: [-1, null, -3],
+ config: { custom: { stacking: { mode: StackingMode.Normal } } },
+ },
+ {
+ name: 'b',
+ values: [1, 2, 3],
+ config: { custom: { stacking: { mode: StackingMode.Normal } } },
+ },
+ {
+ name: 'c',
+ values: [0, 0, 0],
+ config: { custom: { stacking: { mode: StackingMode.Normal } } },
+ },
+ ],
+ });
+
+ expect(getStackingGroups(df)).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "dir": -1,
+ "series": Array [
+ 1,
+ 3,
+ ],
+ },
+ Object {
+ "dir": 1,
+ "series": Array [
+ 2,
+ ],
+ },
+ ]
+ `);
});
- it('should order the ids based on the frame stat', () => {
- const resultDesc = orderIdsByCalcs({ ids, frame, legend: { sortBy: 'Min', sortDesc: true } as any });
- expect(resultDesc).toEqual([4, 2, 3, 1]);
- const resultAsc = orderIdsByCalcs({ ids, frame, legend: { sortBy: 'Min', sortDesc: false } as any });
- expect(resultAsc).toEqual([1, 3, 2, 4]);
+ test('split pos/neg with NegY', () => {
+ const df = new MutableDataFrame({
+ fields: [
+ { name: 'time', type: FieldType.time, values: [0, 1, 2] },
+ {
+ name: 'a',
+ values: [-1, null, -3],
+ config: { custom: { stacking: { mode: StackingMode.Normal }, transform: GraphTransform.NegativeY } },
+ },
+ {
+ name: 'b',
+ values: [1, 2, 3],
+ config: { custom: { stacking: { mode: StackingMode.Normal } } },
+ },
+ {
+ name: 'c',
+ values: [0, 0, 0],
+ config: { custom: { stacking: { mode: StackingMode.Normal } } },
+ },
+ ],
+ });
+
+ expect(getStackingGroups(df)).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "dir": 1,
+ "series": Array [
+ 1,
+ 2,
+ ],
+ },
+ Object {
+ "dir": -1,
+ "series": Array [
+ 3,
+ ],
+ },
+ ]
+ `);
+ });
+
+ test('split on drawStyle, lineInterpolation, barAlignment', () => {
+ const df = new MutableDataFrame({
+ fields: [
+ { name: 'time', type: FieldType.time, values: [0, 1, 2] },
+ {
+ name: 'a',
+ values: [1, 2, 3],
+ config: {
+ custom: {
+ drawStyle: GraphDrawStyle.Bars,
+ barAlignment: BarAlignment.After,
+ stacking: { mode: StackingMode.Normal },
+ },
+ },
+ },
+ {
+ name: 'b',
+ values: [1, 2, 3],
+ config: {
+ custom: {
+ drawStyle: GraphDrawStyle.Bars,
+ barAlignment: BarAlignment.Before,
+ stacking: { mode: StackingMode.Normal },
+ },
+ },
+ },
+ {
+ name: 'c',
+ values: [1, 2, 3],
+ config: {
+ custom: {
+ drawStyle: GraphDrawStyle.Line,
+ lineInterpolation: LineInterpolation.Linear,
+ stacking: { mode: StackingMode.Normal },
+ },
+ },
+ },
+ {
+ name: 'd',
+ values: [1, 2, 3],
+ config: {
+ custom: {
+ drawStyle: GraphDrawStyle.Line,
+ lineInterpolation: LineInterpolation.Smooth,
+ stacking: { mode: StackingMode.Normal },
+ },
+ },
+ },
+ {
+ name: 'e',
+ values: [1, 2, 3],
+ config: { custom: { drawStyle: GraphDrawStyle.Points, stacking: { mode: StackingMode.Normal } } },
+ },
+ ],
+ });
+
+ expect(getStackingGroups(df)).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "dir": 1,
+ "series": Array [
+ 1,
+ ],
+ },
+ Object {
+ "dir": 1,
+ "series": Array [
+ 2,
+ ],
+ },
+ Object {
+ "dir": 1,
+ "series": Array [
+ 3,
+ ],
+ },
+ Object {
+ "dir": 1,
+ "series": Array [
+ 4,
+ ],
+ },
+ Object {
+ "dir": 1,
+ "series": Array [
+ 5,
+ ],
+ },
+ ]
+ `);
+ });
+
+ test('split on axis & units (scaleKey)', () => {
+ const df = new MutableDataFrame({
+ fields: [
+ { name: 'time', type: FieldType.time, values: [0, 1, 2] },
+ {
+ name: 'a',
+ values: [1, 2, 3],
+ config: { custom: { stacking: { mode: StackingMode.Normal } }, unit: Units.FEET },
+ },
+ {
+ name: 'b',
+ values: [1, 2, 3],
+ config: { custom: { stacking: { mode: StackingMode.Normal } }, unit: Units.DEGREES },
+ },
+ ],
+ });
+
+ expect(getStackingGroups(df)).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "dir": 1,
+ "series": Array [
+ 1,
+ ],
+ },
+ Object {
+ "dir": 1,
+ "series": Array [
+ 2,
+ ],
+ },
+ ]
+ `);
+ });
+
+ test('split on explicit stacking group & mode & pos/neg w/NegY', () => {
+ const df = new MutableDataFrame({
+ fields: [
+ { name: 'time', type: FieldType.time, values: [0, 1, 2] },
+ {
+ name: 'a',
+ values: [1, 2, 3],
+ config: { custom: { stacking: { mode: StackingMode.Normal, group: 'A' } } },
+ },
+ {
+ name: 'b',
+ values: [1, 2, 3],
+ config: { custom: { stacking: { mode: StackingMode.Normal, group: 'A' } } },
+ },
+ {
+ name: 'c',
+ values: [1, 2, 3],
+ config: { custom: { stacking: { mode: StackingMode.Percent, group: 'A' } } },
+ },
+ {
+ name: 'd',
+ values: [1, 2, 3],
+ config: { custom: { stacking: { mode: StackingMode.Normal, group: 'B' } } },
+ },
+ {
+ name: 'e',
+ values: [1, 2, 3],
+ config: { custom: { stacking: { mode: StackingMode.Percent, group: 'B' } } },
+ },
+ {
+ name: 'e',
+ values: [1, 2, 3],
+ config: {
+ custom: { stacking: { mode: StackingMode.Percent, group: 'B' }, transform: GraphTransform.NegativeY },
+ },
+ },
+ ],
+ });
+
+ expect(getStackingGroups(df)).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "dir": 1,
+ "series": Array [
+ 1,
+ 2,
+ ],
+ },
+ Object {
+ "dir": 1,
+ "series": Array [
+ 3,
+ ],
+ },
+ Object {
+ "dir": 1,
+ "series": Array [
+ 4,
+ ],
+ },
+ Object {
+ "dir": 1,
+ "series": Array [
+ 5,
+ ],
+ },
+ Object {
+ "dir": -1,
+ "series": Array [
+ 6,
+ ],
+ },
+ ]
+ `);
});
});
diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts
index 96ceaf2eb5d..4bdaadc8b8d 100755
--- a/packages/grafana-ui/src/components/uPlot/utils.ts
+++ b/packages/grafana-ui/src/components/uPlot/utils.ts
@@ -1,12 +1,11 @@
-import { DataFrame, ensureTimeField, Field, FieldType } from '@grafana/data';
-import { GraphFieldConfig, GraphTransform, StackingMode, VizLegendOptions } from '@grafana/schema';
-import { orderBy } from 'lodash';
+import { DataFrame, ensureTimeField, FieldType } from '@grafana/data';
+import { BarAlignment, GraphDrawStyle, GraphTransform, LineInterpolation, StackingMode } from '@grafana/schema';
import uPlot, { AlignedData, Options, PaddingSide } from 'uplot';
import { attachDebugger } from '../../utils';
import { createLogger } from '../../utils/logger';
+import { buildScaleKey } from '../GraphNG/utils';
const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g;
-export const INTERNAL_NEGATIVE_Y_PREFIX = '__internalNegY';
export function timeFormatToTemplate(f: string) {
return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, (match) => `{${match}}`);
@@ -41,115 +40,225 @@ interface StackMeta {
}
/** @internal */
-export function preparePlotData(
- frames: DataFrame[],
- onStackMeta?: (meta: StackMeta) => void,
- legend?: VizLegendOptions
-): AlignedData {
- const frame = frames[0];
- const result: any[] = [];
- const stackingGroups: Map = new Map();
- let seriesIndex = 0;
-
- for (let i = 0; i < frame.fields.length; i++) {
- const f = frame.fields[i];
-
- if (f.type === FieldType.time) {
- result.push(ensureTimeField(f).values.toArray());
- seriesIndex++;
- continue;
- }
-
- collectStackingGroups(f, stackingGroups, seriesIndex);
- const customConfig: GraphFieldConfig = f.config.custom || {};
-
- const values = f.values.toArray();
-
- if (customConfig.transform === GraphTransform.NegativeY) {
- result.push(values.map((v) => (v == null ? v : v * -1)));
- } else if (customConfig.transform === GraphTransform.Constant) {
- result.push(new Array(values.length).fill(values[0]));
- } else {
- result.push(values);
- }
- seriesIndex++;
- }
-
- // Stacking
- if (stackingGroups.size !== 0) {
- const byPct = frame.fields[1].config.custom?.stacking?.mode === StackingMode.Percent;
- const dataLength = result[0].length;
- const alignedTotals = Array(stackingGroups.size);
- alignedTotals[0] = null;
-
- // array or stacking groups
- for (const [_, seriesIds] of stackingGroups.entries()) {
- const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame });
- const noValueStack = Array(dataLength).fill(true);
- const groupTotals = byPct ? Array(dataLength).fill(0) : null;
-
- if (byPct) {
- for (let j = 0; j < seriesIdxs.length; j++) {
- const currentlyStacking = result[seriesIdxs[j]];
-
- for (let k = 0; k < dataLength; k++) {
- const v = currentlyStacking[k];
- groupTotals![k] += v == null ? 0 : +v;
- }
- }
- }
-
- const acc = Array(dataLength).fill(0);
-
- for (let j = 0; j < seriesIdxs.length; j++) {
- let seriesIdx = seriesIdxs[j];
-
- alignedTotals[seriesIdx] = groupTotals;
-
- const currentlyStacking = result[seriesIdx];
-
- for (let k = 0; k < dataLength; k++) {
- const v = currentlyStacking[k];
- if (v != null && noValueStack[k]) {
- noValueStack[k] = false;
- }
- acc[k] += v == null ? 0 : v / (byPct ? groupTotals![k] : 1);
- }
-
- result[seriesIdx] = acc.slice().map((v, i) => (noValueStack[i] ? null : v));
- }
- }
-
- onStackMeta &&
- onStackMeta({
- totals: alignedTotals as AlignedData,
- });
- }
-
- return result as AlignedData;
+export interface StackingGroup {
+ series: number[];
+ dir: StackDirection;
}
-export function collectStackingGroups(f: Field, groups: Map, seriesIdx: number) {
- const customConfig = f.config.custom;
- if (!customConfig) {
- return;
- }
- if (
- customConfig.stacking?.mode !== StackingMode.None &&
- customConfig.stacking?.group &&
- !customConfig.hideFrom?.viz
- ) {
- const group =
- customConfig.transform === GraphTransform.NegativeY
- ? `${INTERNAL_NEGATIVE_Y_PREFIX}-${customConfig.stacking.group}`
- : customConfig.stacking.group;
+/** @internal */
+const enum StackDirection {
+ Pos = 1,
+ Neg = -1,
+}
- if (!groups.has(group)) {
- groups.set(group, [seriesIdx]);
- } else {
- groups.set(group, groups.get(group)!.concat(seriesIdx));
+// generates bands between adjacent group series
+/** @internal */
+export function getStackingBands(group: StackingGroup) {
+ let bands: uPlot.Band[] = [];
+ let { series, dir } = group;
+ let lastIdx = series.length - 1;
+
+ let rSeries = series.slice().reverse();
+
+ rSeries.forEach((si, i) => {
+ if (i !== lastIdx) {
+ let nextIdx = rSeries[i + 1];
+ bands.push({
+ series: [si, nextIdx],
+ // fill direction is inverted from stack direction
+ dir: (-1 * dir) as 1 | -1,
+ });
}
+ });
+
+ return bands;
+}
+
+// expects an AlignedFrame
+/** @internal */
+export function getStackingGroups(frame: DataFrame) {
+ let groups: Map = new Map();
+
+ frame.fields.forEach(({ config, values }, i) => {
+ // skip x or time field
+ if (i === 0) {
+ return;
+ }
+
+ let { custom } = config;
+
+ if (custom == null) {
+ return;
+ }
+
+ // TODO: currently all AlignedFrame fields end up in uplot series & data, even custom.hideFrom?.viz
+ // ideally hideFrom.viz fields would be excluded so we can remove this
+ if (custom.hideFrom?.viz) {
+ return;
+ }
+
+ let { stacking } = custom;
+
+ if (stacking == null) {
+ return;
+ }
+
+ let { mode: stackingMode, group: stackingGroup } = stacking;
+
+ // not stacking
+ if (stackingMode === StackingMode.None) {
+ return;
+ }
+
+ // will this be stacked up or down after any transforms applied
+ let vals = values.toArray();
+ let transform = custom.transform;
+ let stackDir =
+ transform === GraphTransform.Constant
+ ? vals[0] > 0
+ ? StackDirection.Pos
+ : StackDirection.Neg
+ : transform === GraphTransform.NegativeY
+ ? vals.some((v) => v > 0)
+ ? StackDirection.Neg
+ : StackDirection.Pos
+ : vals.some((v) => v > 0)
+ ? StackDirection.Pos
+ : StackDirection.Neg;
+
+ let drawStyle = custom.drawStyle as GraphDrawStyle;
+ let drawStyle2 =
+ drawStyle === GraphDrawStyle.Bars
+ ? (custom.barAlignment as BarAlignment)
+ : drawStyle === GraphDrawStyle.Line
+ ? (custom.lineInterpolation as LineInterpolation)
+ : null;
+
+ let stackKey = `${stackDir}|${stackingMode}|${stackingGroup}|${buildScaleKey(config)}|${drawStyle}|${drawStyle2}`;
+
+ let group = groups.get(stackKey);
+
+ if (group == null) {
+ group = {
+ series: [],
+ dir: stackDir,
+ };
+
+ groups.set(stackKey, group);
+ }
+
+ group.series.push(i);
+ });
+
+ return [...groups.values()];
+}
+
+/** @internal */
+export function preparePlotData2(
+ frame: DataFrame,
+ stackingGroups: StackingGroup[],
+ onStackMeta?: (meta: StackMeta) => void
+) {
+ let data = Array(frame.fields.length) as AlignedData;
+
+ let dataLen = frame.length;
+ let zeroArr = Array(dataLen).fill(0);
+ let accums = Array.from({ length: stackingGroups.length }, () => zeroArr.slice());
+
+ frame.fields.forEach((field, i) => {
+ let vals = field.values.toArray();
+
+ if (i === 0) {
+ if (field.type === FieldType.time) {
+ data[i] = ensureTimeField(field).values.toArray();
+ } else {
+ data[i] = vals;
+ }
+ return;
+ }
+
+ let { custom } = field.config;
+
+ if (!custom || custom.hideFrom?.viz) {
+ data[i] = vals;
+ return;
+ }
+
+ // apply transforms
+ if (custom.transform === GraphTransform.Constant) {
+ vals = Array(vals.length).fill(vals[0]);
+ } else {
+ vals = vals.slice();
+
+ if (custom.transform === GraphTransform.NegativeY) {
+ for (let i = 0; i < vals.length; i++) {
+ if (vals[i] != null) {
+ vals[i] *= -1;
+ }
+ }
+ }
+ }
+
+ let stackingMode = custom.stacking?.mode;
+
+ if (!stackingMode || stackingMode === StackingMode.None) {
+ data[i] = vals;
+ } else {
+ let stackIdx = stackingGroups.findIndex((group) => group.series.indexOf(i) > -1);
+
+ let accum = accums[stackIdx];
+ let stacked = (data[i] = Array(dataLen));
+
+ for (let i = 0; i < dataLen; i++) {
+ let v = vals[i];
+
+ if (v != null) {
+ stacked[i] = accum[i] += v;
+ } else {
+ stacked[i] = v; // we may want to coerce to 0 here
+ }
+ }
+ }
+ });
+
+ if (onStackMeta) {
+ let accumsBySeriesIdx = data.map((vals, i) => {
+ let stackIdx = stackingGroups.findIndex((group) => group.series.indexOf(i) > -1);
+ return stackIdx !== -1 ? accums[stackIdx] : vals;
+ });
+
+ onStackMeta({
+ totals: accumsBySeriesIdx as AlignedData,
+ });
}
+
+ // re-compute by percent
+ frame.fields.forEach((field, i) => {
+ if (i === 0 || field.config.custom?.hideFrom?.viz) {
+ return;
+ }
+
+ let stackingMode = field.config.custom?.stacking?.mode;
+
+ if (stackingMode === StackingMode.Percent) {
+ let stackIdx = stackingGroups.findIndex((group) => group.series.indexOf(i) > -1);
+ let accum = accums[stackIdx];
+ let group = stackingGroups[stackIdx];
+
+ let stacked = data[i];
+
+ for (let i = 0; i < dataLen; i++) {
+ let v = stacked[i];
+
+ if (v != null) {
+ // v / accum will always be pos, so properly (re)sign by group stacking dir
+ stacked[i] = group.dir * (v / accum[i]);
+ }
+ }
+ }
+ });
+
+ return data;
}
/**
@@ -215,23 +324,3 @@ export const pluginLogger = createLogger('uPlot');
export const pluginLog = pluginLogger.logger;
// pluginLogger.enable();
attachDebugger('graphng', undefined, pluginLogger);
-
-type OrderIdsByCalcsOptions = {
- legend?: VizLegendOptions;
- ids: number[];
- frame: DataFrame;
-};
-export function orderIdsByCalcs({ legend, ids, frame }: OrderIdsByCalcsOptions) {
- if (!legend?.sortBy || legend.sortDesc == null) {
- return ids;
- }
- const orderedIds = orderBy(
- ids,
- (id) => {
- return frame.fields[id].state?.calcs?.[legend.sortBy!.toLowerCase()];
- },
- legend.sortDesc ? 'desc' : 'asc'
- );
-
- return orderedIds;
-}
diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts
index de315cb978e..870cf8b2d82 100644
--- a/packages/grafana-ui/src/types/icon.ts
+++ b/packages/grafana-ui/src/types/icon.ts
@@ -69,6 +69,7 @@ export const getAvailableIcons = () =>
'envelope',
'exchange-alt',
'exclamation-triangle',
+ 'exclamation-circle',
'external-link-alt',
'eye',
'eye-slash',
diff --git a/packages/grafana-ui/src/utils/standardEditors.tsx b/packages/grafana-ui/src/utils/standardEditors.tsx
index 8ddbb3cf528..065c1812113 100644
--- a/packages/grafana-ui/src/utils/standardEditors.tsx
+++ b/packages/grafana-ui/src/utils/standardEditors.tsx
@@ -134,7 +134,7 @@ export const getStandardFieldConfigs = () => {
const noValue: FieldConfigPropertyItem = {
id: 'noValue',
path: 'noValue',
- name: 'No Value',
+ name: 'No value',
description: 'What to show when there is no value',
editor: standardEditorsRegistry.get('text').editor as any,
diff --git a/packages/grafana-ui/tsconfig.json b/packages/grafana-ui/tsconfig.json
index f8ba5270586..d89cdc42449 100644
--- a/packages/grafana-ui/tsconfig.json
+++ b/packages/grafana-ui/tsconfig.json
@@ -3,9 +3,7 @@
"baseUrl": "./",
"declarationDir": "dist",
"outDir": "compiled",
- "rootDirs": [".", "stories"],
- "incremental": true,
- "tsBuildInfoFile": "./tsconfig.tsbuildinfo"
+ "rootDirs": [".", "stories"]
},
"exclude": ["dist", "node_modules"],
"extends": "@grafana/tsconfig",
diff --git a/packages/jaeger-ui-components/package.json b/packages/jaeger-ui-components/package.json
index b69f93aad83..1ede766e773 100644
--- a/packages/jaeger-ui-components/package.json
+++ b/packages/jaeger-ui-components/package.json
@@ -39,7 +39,7 @@
"lodash": "4.17.21",
"lru-memoize": "^1.1.0",
"memoize-one": "6.0.0",
- "moment": "2.29.1",
+ "moment": "2.29.2",
"moment-timezone": "0.5.34",
"prop-types": "15.8.1",
"react": "17.0.2",
diff --git a/pkg/api/accesscontrol.go b/pkg/api/accesscontrol.go
index 6f08cfd6382..e1c3eed6acd 100644
--- a/pkg/api/accesscontrol.go
+++ b/pkg/api/accesscontrol.go
@@ -5,6 +5,7 @@ import (
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
+ "github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/setting"
)
@@ -149,6 +150,23 @@ func (hs *HTTPServer) declareFixedRoles() error {
Grants: []string{string(models.ROLE_VIEWER)},
}
+ apikeyReaderRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
+ Version: 1,
+ Name: "fixed:apikeys:reader",
+ DisplayName: "APIKeys reader",
+ Description: "Gives access to read api keys.",
+ Group: "API Keys",
+ Permissions: []ac.Permission{
+ {
+ Action: ac.ActionAPIKeyRead,
+ Scope: ac.ScopeAPIKeysAll,
+ },
+ },
+ },
+ Grants: []string{string(models.ROLE_ADMIN)},
+ }
+
apikeyWriterRole := ac.RoleRegistration{
Role: ac.RoleDTO{
Version: 1,
@@ -410,7 +428,7 @@ func (hs *HTTPServer) declareFixedRoles() error {
orgMaintainerRole, teamsCreatorRole, teamsWriterRole, datasourcesExplorerRole,
annotationsReaderRole, dashboardAnnotationsWriterRole, annotationsWriterRole,
dashboardsCreatorRole, dashboardsReaderRole, dashboardsWriterRole,
- foldersCreatorRole, foldersReaderRole, foldersWriterRole, apikeyWriterRole,
+ foldersCreatorRole, foldersReaderRole, foldersWriterRole, apikeyReaderRole, apikeyWriterRole,
)
}
@@ -462,6 +480,12 @@ var teamsEditAccessEvaluator = ac.EvalAll(
),
)
+// apiKeyAccessEvaluator is used to protect the "Configuration > API keys" page access
+var apiKeyAccessEvaluator = ac.EvalPermission(ac.ActionAPIKeyRead)
+
+// serviceAccountAccessEvaluator is used to protect the "Configuration > Service accounts" page access
+var serviceAccountAccessEvaluator = ac.EvalPermission(serviceaccounts.ActionRead)
+
// Metadata helpers
// getAccessControlMetadata returns the accesscontrol metadata associated with a given resource
func (hs *HTTPServer) getAccessControlMetadata(c *models.ReqContext,
diff --git a/pkg/api/admin.go b/pkg/api/admin.go
index 453db021900..2ebe625d398 100644
--- a/pkg/api/admin.go
+++ b/pkg/api/admin.go
@@ -25,7 +25,7 @@ func (hs *HTTPServer) AdminGetStats(c *models.ReqContext) response.Response {
return response.Error(500, "Failed to get admin stats from database", err)
}
- return response.JSON(200, statsQuery.Result)
+ return response.JSON(http.StatusOK, statsQuery.Result)
}
func (hs *HTTPServer) getAuthorizedSettings(ctx context.Context, user *models.SignedInUser, bag setting.SettingsBag) (setting.SettingsBag, error) {
diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go
index c9fa2407533..78393beb864 100644
--- a/pkg/api/admin_users.go
+++ b/pkg/api/admin_users.go
@@ -58,7 +58,7 @@ func (hs *HTTPServer) AdminCreateUser(c *models.ReqContext) response.Response {
Id: user.Id,
}
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
func (hs *HTTPServer) AdminUpdateUserPassword(c *models.ReqContext) response.Response {
diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go
index 0ba11679d3c..1ab6c368b38 100644
--- a/pkg/api/admin_users_test.go
+++ b/pkg/api/admin_users_test.go
@@ -7,7 +7,6 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth"
@@ -269,7 +268,6 @@ func putAdminScenario(t *testing.T, desc string, url string, routePattern string
func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, sqlStore sqlstore.Store) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
hs := HTTPServer{
- Bus: bus.GetBus(),
AuthTokenService: auth.NewFakeUserAuthTokenService(),
SQLStore: sqlStore,
}
@@ -297,7 +295,6 @@ func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, rou
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
- Bus: bus.GetBus(),
AuthTokenService: fakeAuthTokenService,
SQLStore: sqlStore,
}
@@ -326,7 +323,6 @@ func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, route
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
- Bus: bus.GetBus(),
AuthTokenService: fakeAuthTokenService,
SQLStore: sqlStore,
}
@@ -355,7 +351,6 @@ func adminDisableUserScenario(t *testing.T, desc string, action string, url stri
authInfoService := &logintest.AuthInfoServiceFake{}
hs := HTTPServer{
- Bus: bus.GetBus(),
SQLStore: mockstore.NewSQLStoreMock(),
AuthTokenService: fakeAuthTokenService,
authInfoService: authInfoService,
@@ -405,7 +400,6 @@ func adminDeleteUserScenario(t *testing.T, desc string, url string, routePattern
func adminCreateUserScenario(t *testing.T, desc string, url string, routePattern string, cmd dtos.AdminCreateUserForm, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
hs := HTTPServer{
- Bus: bus.GetBus(),
Login: loginservice.LoginServiceMock{
ExpectedUserForm: cmd,
NoExistingOrgId: nonExistingOrgID,
diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go
index 487d4ac9905..4fa1589a9c7 100644
--- a/pkg/api/alerting.go
+++ b/pkg/api/alerting.go
@@ -54,7 +54,7 @@ func (hs *HTTPServer) GetAlertStatesForDashboard(c *models.ReqContext) response.
return response.Error(500, "Failed to fetch alert states", err)
}
- return response.JSON(200, query.Result)
+ return response.JSON(http.StatusOK, query.Result)
}
// GET /api/alerts
@@ -106,7 +106,7 @@ func (hs *HTTPServer) GetAlerts(c *models.ReqContext) response.Response {
// if we didn't find any dashboards, return empty result
if len(dashboardIDs) == 0 {
- return response.JSON(200, []*models.AlertListItemDTO{})
+ return response.JSON(http.StatusOK, []*models.AlertListItemDTO{})
}
}
@@ -132,7 +132,7 @@ func (hs *HTTPServer) GetAlerts(c *models.ReqContext) response.Response {
alert.Url = models.GetDashboardUrl(alert.DashboardUid, alert.DashboardSlug)
}
- return response.JSON(200, query.Result)
+ return response.JSON(http.StatusOK, query.Result)
}
// POST /api/alerts/test
@@ -176,7 +176,7 @@ func (hs *HTTPServer) AlertTest(c *models.ReqContext) response.Response {
dtoRes.TimeMs = fmt.Sprintf("%1.3fms", res.GetDurationMs())
- return response.JSON(200, dtoRes)
+ return response.JSON(http.StatusOK, dtoRes)
}
// GET /api/alerts/:id
@@ -191,17 +191,17 @@ func (hs *HTTPServer) GetAlert(c *models.ReqContext) response.Response {
return response.Error(500, "List alerts failed", err)
}
- return response.JSON(200, &query.Result)
+ return response.JSON(http.StatusOK, &query.Result)
}
func (hs *HTTPServer) GetAlertNotifiers(ngalertEnabled bool) func(*models.ReqContext) response.Response {
return func(_ *models.ReqContext) response.Response {
if ngalertEnabled {
- return response.JSON(200, notifier.GetAvailableNotifiers())
+ return response.JSON(http.StatusOK, notifier.GetAvailableNotifiers())
}
// TODO(codesome): This wont be required in 8.0 since ngalert
// will be enabled by default with no disabling. This is to be removed later.
- return response.JSON(200, alerting.GetNotifiers())
+ return response.JSON(http.StatusOK, alerting.GetNotifiers())
}
}
@@ -217,7 +217,7 @@ func (hs *HTTPServer) GetAlertNotificationLookup(c *models.ReqContext) response.
result = append(result, dtos.NewAlertNotificationLookup(notification))
}
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
func (hs *HTTPServer) GetAlertNotifications(c *models.ReqContext) response.Response {
@@ -232,7 +232,7 @@ func (hs *HTTPServer) GetAlertNotifications(c *models.ReqContext) response.Respo
result = append(result, dtos.NewAlertNotification(notification))
}
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
func (hs *HTTPServer) getAlertNotificationsInternal(c *models.ReqContext) ([]*models.AlertNotification, error) {
@@ -267,7 +267,7 @@ func (hs *HTTPServer) GetAlertNotificationByID(c *models.ReqContext) response.Re
return response.Error(404, "Alert notification not found", nil)
}
- return response.JSON(200, dtos.NewAlertNotification(query.Result))
+ return response.JSON(http.StatusOK, dtos.NewAlertNotification(query.Result))
}
func (hs *HTTPServer) GetAlertNotificationByUID(c *models.ReqContext) response.Response {
@@ -288,7 +288,7 @@ func (hs *HTTPServer) GetAlertNotificationByUID(c *models.ReqContext) response.R
return response.Error(404, "Alert notification not found", nil)
}
- return response.JSON(200, dtos.NewAlertNotification(query.Result))
+ return response.JSON(http.StatusOK, dtos.NewAlertNotification(query.Result))
}
func (hs *HTTPServer) CreateAlertNotification(c *models.ReqContext) response.Response {
@@ -309,7 +309,7 @@ func (hs *HTTPServer) CreateAlertNotification(c *models.ReqContext) response.Res
return response.Error(500, "Failed to create alert notification", err)
}
- return response.JSON(200, dtos.NewAlertNotification(cmd.Result))
+ return response.JSON(http.StatusOK, dtos.NewAlertNotification(cmd.Result))
}
func (hs *HTTPServer) UpdateAlertNotification(c *models.ReqContext) response.Response {
@@ -344,7 +344,7 @@ func (hs *HTTPServer) UpdateAlertNotification(c *models.ReqContext) response.Res
return response.Error(500, "Failed to get alert notification", err)
}
- return response.JSON(200, dtos.NewAlertNotification(query.Result))
+ return response.JSON(http.StatusOK, dtos.NewAlertNotification(query.Result))
}
func (hs *HTTPServer) UpdateAlertNotificationByUID(c *models.ReqContext) response.Response {
@@ -376,7 +376,7 @@ func (hs *HTTPServer) UpdateAlertNotificationByUID(c *models.ReqContext) respons
return response.Error(500, "Failed to get alert notification", err)
}
- return response.JSON(200, dtos.NewAlertNotification(query.Result))
+ return response.JSON(http.StatusOK, dtos.NewAlertNotification(query.Result))
}
func (hs *HTTPServer) fillWithSecureSettingsData(ctx context.Context, cmd *models.UpdateAlertNotificationCommand) error {
@@ -469,7 +469,7 @@ func (hs *HTTPServer) DeleteAlertNotificationByUID(c *models.ReqContext) respons
return response.Error(500, "Failed to delete alert notification", err)
}
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": "Notification deleted",
"id": cmd.DeletedAlertNotificationId,
})
@@ -536,11 +536,11 @@ func (hs *HTTPServer) PauseAlert(c *models.ReqContext) response.Response {
if query.Result.State != models.AlertStatePaused && !dto.Paused {
result["state"] = "un-paused"
result["message"] = "Alert is already un-paused"
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
} else if query.Result.State == models.AlertStatePaused && dto.Paused {
result["state"] = models.AlertStatePaused
result["message"] = "Alert is already paused"
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
cmd := models.PauseAlertCommand{
@@ -553,7 +553,7 @@ func (hs *HTTPServer) PauseAlert(c *models.ReqContext) response.Response {
return response.Error(500, "", err)
}
- var resp models.AlertStateType = models.AlertStateUnknown
+ resp := models.AlertStateUnknown
pausedState := "un-paused"
if cmd.Paused {
resp = models.AlertStatePaused
@@ -562,7 +562,7 @@ func (hs *HTTPServer) PauseAlert(c *models.ReqContext) response.Response {
result["state"] = resp
result["message"] = "Alert " + pausedState
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
// POST /api/admin/pause-all-alerts
@@ -579,7 +579,7 @@ func (hs *HTTPServer) PauseAllAlerts(c *models.ReqContext) response.Response {
return response.Error(500, "Failed to pause alerts", err)
}
- var resp models.AlertStateType = models.AlertStatePending
+ resp := models.AlertStatePending
pausedState := "un paused"
if updateCmd.Paused {
resp = models.AlertStatePaused
@@ -592,5 +592,5 @@ func (hs *HTTPServer) PauseAllAlerts(c *models.ReqContext) response.Response {
"alertsAffected": updateCmd.ResultCount,
}
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go
index 5221332c741..195c931fe76 100644
--- a/pkg/api/annotations.go
+++ b/pkg/api/annotations.go
@@ -20,17 +20,18 @@ import (
func (hs *HTTPServer) GetAnnotations(c *models.ReqContext) response.Response {
query := &annotations.ItemQuery{
- From: c.QueryInt64("from"),
- To: c.QueryInt64("to"),
- OrgId: c.OrgId,
- UserId: c.QueryInt64("userId"),
- AlertId: c.QueryInt64("alertId"),
- DashboardId: c.QueryInt64("dashboardId"),
- PanelId: c.QueryInt64("panelId"),
- Limit: c.QueryInt64("limit"),
- Tags: c.QueryStrings("tags"),
- Type: c.Query("type"),
- MatchAny: c.QueryBool("matchAny"),
+ From: c.QueryInt64("from"),
+ To: c.QueryInt64("to"),
+ OrgId: c.OrgId,
+ UserId: c.QueryInt64("userId"),
+ AlertId: c.QueryInt64("alertId"),
+ DashboardId: c.QueryInt64("dashboardId"),
+ PanelId: c.QueryInt64("panelId"),
+ Limit: c.QueryInt64("limit"),
+ Tags: c.QueryStrings("tags"),
+ Type: c.Query("type"),
+ MatchAny: c.QueryBool("matchAny"),
+ SignedInUser: c.SignedInUser,
}
repo := annotations.GetRepository()
@@ -46,7 +47,7 @@ func (hs *HTTPServer) GetAnnotations(c *models.ReqContext) response.Response {
}
}
- return response.JSON(200, items)
+ return response.JSON(http.StatusOK, items)
}
type AnnotationError struct {
@@ -110,7 +111,7 @@ func (hs *HTTPServer) PostAnnotation(c *models.ReqContext) response.Response {
startID := item.Id
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": "Annotation added",
"id": startID,
})
@@ -173,7 +174,7 @@ func (hs *HTTPServer) PostGraphiteAnnotation(c *models.ReqContext) response.Resp
return response.Error(500, "Failed to save Graphite annotation", err)
}
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": "Graphite annotation added",
"id": item.Id,
})
@@ -192,7 +193,7 @@ func (hs *HTTPServer) UpdateAnnotation(c *models.ReqContext) response.Response {
repo := annotations.GetRepository()
- annotation, resp := findAnnotationByID(c.Req.Context(), repo, annotationID, c.OrgId)
+ annotation, resp := findAnnotationByID(c.Req.Context(), repo, annotationID, c.SignedInUser)
if resp != nil {
return resp
}
@@ -239,7 +240,7 @@ func (hs *HTTPServer) PatchAnnotation(c *models.ReqContext) response.Response {
repo := annotations.GetRepository()
- annotation, resp := findAnnotationByID(c.Req.Context(), repo, annotationID, c.OrgId)
+ annotation, resp := findAnnotationByID(c.Req.Context(), repo, annotationID, c.SignedInUser)
if resp != nil {
return resp
}
@@ -310,7 +311,7 @@ func (hs *HTTPServer) MassDeleteAnnotations(c *models.ReqContext) response.Respo
var dashboardId int64
if cmd.AnnotationId != 0 {
- annotation, respErr := findAnnotationByID(c.Req.Context(), repo, cmd.AnnotationId, c.OrgId)
+ annotation, respErr := findAnnotationByID(c.Req.Context(), repo, cmd.AnnotationId, c.SignedInUser)
if respErr != nil {
return respErr
}
@@ -358,7 +359,7 @@ func (hs *HTTPServer) DeleteAnnotationByID(c *models.ReqContext) response.Respon
repo := annotations.GetRepository()
- annotation, resp := findAnnotationByID(c.Req.Context(), repo, annotationID, c.OrgId)
+ annotation, resp := findAnnotationByID(c.Req.Context(), repo, annotationID, c.SignedInUser)
if resp != nil {
return resp
}
@@ -400,8 +401,13 @@ func canSaveOrganizationAnnotation(c *models.ReqContext) bool {
return c.SignedInUser.HasRole(models.ROLE_EDITOR)
}
-func findAnnotationByID(ctx context.Context, repo annotations.Repository, annotationID int64, orgID int64) (*annotations.ItemDTO, response.Response) {
- items, err := repo.Find(ctx, &annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgID})
+func findAnnotationByID(ctx context.Context, repo annotations.Repository, annotationID int64, user *models.SignedInUser) (*annotations.ItemDTO, response.Response) {
+ query := &annotations.ItemQuery{
+ AnnotationId: annotationID,
+ OrgId: user.OrgId,
+ SignedInUser: user,
+ }
+ items, err := repo.Find(ctx, query)
if err != nil {
return nil, response.Error(500, "Failed to find annotation", err)
@@ -427,7 +433,7 @@ func (hs *HTTPServer) GetAnnotationTags(c *models.ReqContext) response.Response
return response.Error(500, "Failed to find annotation tags", err)
}
- return response.JSON(200, annotations.GetAnnotationTagsResponse{Result: result})
+ return response.JSON(http.StatusOK, annotations.GetAnnotationTagsResponse{Result: result})
}
// AnnotationTypeScopeResolver provides an AttributeScopeResolver able to
@@ -446,9 +452,21 @@ func AnnotationTypeScopeResolver() (string, accesscontrol.AttributeScopeResolveF
return "", accesscontrol.ErrInvalidScope
}
- annotation, resp := findAnnotationByID(ctx, annotations.GetRepository(), int64(annotationId), orgID)
+ // tempUser is used to resolve annotation type.
+ // The annotation doesn't get returned to the real user, so real user's permissions don't matter here.
+ tempUser := &models.SignedInUser{
+ OrgId: orgID,
+ Permissions: map[int64]map[string][]string{
+ orgID: {
+ accesscontrol.ActionDashboardsRead: {accesscontrol.ScopeDashboardsAll},
+ accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsAll},
+ },
+ },
+ }
+
+ annotation, resp := findAnnotationByID(ctx, annotations.GetRepository(), int64(annotationId), tempUser)
if resp != nil {
- return "", err
+ return "", errors.New("could not resolve annotation type")
}
if annotation.GetType() == annotations.Organization {
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 12c7dbd4a8c..a7de6111fab 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/web"
)
@@ -62,9 +63,9 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/org/teams", authorize(reqCanAccessTeams, ac.EvalPermission(ac.ActionTeamsRead)), hs.Index)
r.Get("/org/teams/edit/*", authorize(reqCanAccessTeams, teamsEditAccessEvaluator), hs.Index)
r.Get("/org/teams/new", authorize(reqCanAccessTeams, ac.EvalPermission(ac.ActionTeamsCreate)), hs.Index)
- r.Get("/org/serviceaccounts", middleware.ReqOrgAdmin, hs.Index)
- r.Get("/org/serviceaccounts/:serviceAccountId", middleware.ReqOrgAdmin, hs.Index)
- r.Get("/org/apikeys/", reqOrgAdmin, hs.Index)
+ r.Get("/org/serviceaccounts", authorize(reqOrgAdmin, ac.EvalPermission(serviceaccounts.ActionRead)), hs.Index)
+ r.Get("/org/serviceaccounts/:serviceAccountId", authorize(reqOrgAdmin, ac.EvalPermission(serviceaccounts.ActionRead)), hs.Index)
+ r.Get("/org/apikeys/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyRead)), hs.Index)
r.Get("/dashboard/import/", reqSignedIn, hs.Index)
r.Get("/configuration", reqGrafanaAdmin, hs.Index)
r.Get("/admin", reqGrafanaAdmin, hs.Index)
@@ -279,7 +280,7 @@ func (hs *HTTPServer) registerRoutes() {
keysRoute.Get("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyRead, ac.ScopeAPIKeysAll)), routing.Wrap(hs.GetAPIKeys))
keysRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyCreate)), quota("api_key"), routing.Wrap(hs.AddAPIKey))
keysRoute.Delete("/:id", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyDelete, apikeyIDScope)), routing.Wrap(hs.DeleteAPIKey))
- }, reqOrgAdmin)
+ })
// Preferences
apiRoute.Group("/preferences", func(prefRoute routing.RouteRegister) {
@@ -322,7 +323,9 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings)
apiRoute.Any("/datasources/proxy/:id/*", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequest)
+ apiRoute.Any("/datasources/proxy/uid/:uid/*", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequestWithUID)
apiRoute.Any("/datasources/proxy/:id", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequest)
+ apiRoute.Any("/datasources/proxy/uid/:uid", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequestWithUID)
apiRoute.Any("/datasources/:id/resources", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), hs.CallDatasourceResource)
apiRoute.Any("/datasources/:id/resources/*", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), hs.CallDatasourceResource)
apiRoute.Any("/datasources/:id/health", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), routing.Wrap(hs.CheckDatasourceHealth))
@@ -446,7 +449,7 @@ func (hs *HTTPServer) registerRoutes() {
orgRoute.Get("/lookup", routing.Wrap(hs.GetAlertNotificationLookup))
})
- apiRoute.Get("/annotations", authorize(reqSignedIn, ac.EvalPermission(ac.ActionAnnotationsRead, ac.ScopeAnnotationsAll)), routing.Wrap(hs.GetAnnotations))
+ apiRoute.Get("/annotations", authorize(reqSignedIn, ac.EvalPermission(ac.ActionAnnotationsRead)), routing.Wrap(hs.GetAnnotations))
apiRoute.Post("/annotations/mass-delete", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAnnotationsDelete)), routing.Wrap(hs.MassDeleteAnnotations))
apiRoute.Group("/annotations", func(annotationsRoute routing.RouteRegister) {
diff --git a/pkg/api/apikey.go b/pkg/api/apikey.go
index a282ee2f257..9d1aab24bf3 100644
--- a/pkg/api/apikey.go
+++ b/pkg/api/apikey.go
@@ -36,7 +36,7 @@ func (hs *HTTPServer) GetAPIKeys(c *models.ReqContext) response.Response {
}
}
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
// DeleteAPIKey deletes an API key
@@ -70,6 +70,9 @@ func (hs *HTTPServer) AddAPIKey(c *models.ReqContext) response.Response {
if !cmd.Role.IsValid() {
return response.Error(400, "Invalid role specified", nil)
}
+ if !c.OrgRole.Includes(cmd.Role) {
+ return response.Error(http.StatusForbidden, "Cannot assign a role higher than user's role", nil)
+ }
if hs.Cfg.ApiKeyMaxSecondsToLive != -1 {
if cmd.SecondsToLive == 0 {
@@ -104,5 +107,5 @@ func (hs *HTTPServer) AddAPIKey(c *models.ReqContext) response.Response {
Key: newKeyInfo.ClientSecret,
}
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go
index 49b95f0e93f..c855edde892 100644
--- a/pkg/api/app_routes.go
+++ b/pkg/api/app_routes.go
@@ -27,10 +27,10 @@ func (hs *HTTPServer) initAppPluginRoutes(r *web.Mux) {
Renegotiation: tls.RenegotiateFreelyAsClient,
},
Proxy: http.ProxyFromEnvironment,
- Dial: (&net.Dialer{
+ DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
- }).Dial,
+ }).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
}
@@ -65,6 +65,7 @@ func AppPluginRoute(route *plugins.Route, appID string, hs *HTTPServer) web.Hand
proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID, hs.Cfg, hs.PluginSettings, hs.SecretsService)
proxy.Transport = pluginProxyTransport
+
proxy.ServeHTTP(c.Resp, c.Req)
}
}
diff --git a/pkg/api/comments.go b/pkg/api/comments.go
index d84d4a8cd8f..7f32d64ac08 100644
--- a/pkg/api/comments.go
+++ b/pkg/api/comments.go
@@ -23,7 +23,7 @@ func (hs *HTTPServer) commentsGet(c *models.ReqContext) response.Response {
}
return response.Error(http.StatusInternalServerError, "internal error", err)
}
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"comments": items,
})
}
@@ -43,7 +43,7 @@ func (hs *HTTPServer) commentsCreate(c *models.ReqContext) response.Response {
}
return response.Error(http.StatusInternalServerError, "internal error", err)
}
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"comment": comment,
})
}
diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go
index 41ccf6ddd1c..18986610b82 100644
--- a/pkg/api/common_test.go
+++ b/pkg/api/common_test.go
@@ -13,7 +13,6 @@ import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/fs"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/remotecache"
@@ -236,7 +235,6 @@ func setupAccessControlScenarioContext(t *testing.T, cfg *setting.Cfg, url strin
store := sqlstore.InitTestDB(t)
hs := &HTTPServer{
Cfg: cfg,
- Bus: bus.GetBus(),
Live: newTestLive(t, store),
Features: features,
QuotaService: "a.QuotaService{Cfg: cfg},
@@ -327,7 +325,6 @@ func setupSimpleHTTPServer(features *featuremgmt.FeatureManager) *HTTPServer {
return &HTTPServer{
Cfg: cfg,
Features: features,
- Bus: bus.GetBus(),
AccessControl: accesscontrolmock.New().WithDisabled(),
}
}
@@ -378,7 +375,6 @@ func setupHTTPServerWithCfgDb(t *testing.T, useFakeAccessControl, enableAccessCo
hs := &HTTPServer{
Cfg: cfg,
Features: features,
- Bus: bus.GetBus(),
Live: newTestLive(t, db),
QuotaService: "a.QuotaService{Cfg: cfg},
RouteRegister: routeRegister,
@@ -405,7 +401,7 @@ func setupHTTPServerWithCfgDb(t *testing.T, useFakeAccessControl, enableAccessCo
// Perform role registration
err := hs.declareFixedRoles()
require.NoError(t, err)
- err = ac.RegisterFixedRoles()
+ err = ac.RegisterFixedRoles(context.Background())
require.NoError(t, err)
teamPermissionService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, routeRegister, db, ac, database.ProvideService(db))
require.NoError(t, err)
diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go
index cb71e7eec9a..791a30172ce 100644
--- a/pkg/api/dashboard.go
+++ b/pkg/api/dashboard.go
@@ -72,7 +72,7 @@ func (hs *HTTPServer) TrimDashboard(c *models.ReqContext) response.Response {
}
c.TimeRequest(metrics.MApiDashboardGet)
- return response.JSON(200, dto)
+ return response.JSON(http.StatusOK, dto)
}
func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response {
@@ -196,7 +196,7 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response {
}
c.TimeRequest(metrics.MApiDashboardGet)
- return response.JSON(200, dto)
+ return response.JSON(http.StatusOK, dto)
}
func (hs *HTTPServer) getAnnotationPermissionsByScope(c *models.ReqContext, actions *dtos.AnnotationActions, scope string) {
@@ -282,7 +282,7 @@ func (hs *HTTPServer) deleteDashboard(c *models.ReqContext) response.Response {
hs.log.Error("Failed to broadcast delete info", "dashboard", dash.Uid, "error", err)
}
}
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"title": dash.Title,
"message": fmt.Sprintf("Dashboard %s deleted", dash.Title),
"id": dash.Id,
@@ -396,7 +396,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa
}
c.TimeRequest(metrics.MApiDashboardSave)
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"status": "success",
"slug": dashboard.Slug,
"version": dashboard.Version,
@@ -417,7 +417,7 @@ func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) response.Response {
if prefsQuery.Result.HomeDashboardId == 0 && len(homePage) > 0 {
homePageRedirect := dtos.DashboardRedirect{RedirectUri: homePage}
- return response.JSON(200, &homePageRedirect)
+ return response.JSON(http.StatusOK, &homePageRedirect)
}
if prefsQuery.Result.HomeDashboardId != 0 {
@@ -426,7 +426,7 @@ func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) response.Response {
if err == nil {
url := models.GetDashboardUrl(slugQuery.Result.Uid, slugQuery.Result.Slug)
dashRedirect := dtos.DashboardRedirect{RedirectUri: url}
- return response.JSON(200, &dashRedirect)
+ return response.JSON(http.StatusOK, &dashRedirect)
}
hs.log.Warn("Failed to get slug from database", "err", err)
}
@@ -453,15 +453,16 @@ func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) response.Response {
dash.Meta.IsHome = true
dash.Meta.CanEdit = c.SignedInUser.HasRole(models.ROLE_EDITOR)
dash.Meta.FolderTitle = "General"
+ dash.Dashboard = simplejson.New()
jsonParser := json.NewDecoder(file)
- if err := jsonParser.Decode(&dash.Dashboard); err != nil {
+ if err := jsonParser.Decode(dash.Dashboard); err != nil {
return response.Error(500, "Failed to load home dashboard", err)
}
hs.addGettingStartedPanelToHomeDashboard(c, dash.Dashboard)
- return response.JSON(200, &dash)
+ return response.JSON(http.StatusOK, &dash)
}
func (hs *HTTPServer) addGettingStartedPanelToHomeDashboard(c *models.ReqContext, dash *simplejson.Json) {
@@ -529,7 +530,7 @@ func (hs *HTTPServer) GetDashboardVersions(c *models.ReqContext) response.Respon
}
}
- return response.JSON(200, query.Result)
+ return response.JSON(http.StatusOK, query.Result)
}
// GetDashboardVersion returns the dashboard version with the given ID.
@@ -572,7 +573,7 @@ func (hs *HTTPServer) GetDashboardVersion(c *models.ReqContext) response.Respons
CreatedBy: creator,
}
- return response.JSON(200, dashVersionMeta)
+ return response.JSON(http.StatusOK, dashVersionMeta)
}
// POST /api/dashboards/calculate-diff performs diffs on two dashboards
@@ -647,10 +648,10 @@ func (hs *HTTPServer) CalculateDashboardDiff(c *models.ReqContext) response.Resp
}
if options.DiffType == dashdiffs.DiffDelta {
- return response.Respond(200, result.Delta).SetHeader("Content-Type", "application/json")
+ return response.Respond(http.StatusOK, result.Delta).SetHeader("Content-Type", "application/json")
}
- return response.Respond(200, result.Delta).SetHeader("Content-Type", "text/html")
+ return response.Respond(http.StatusOK, result.Delta).SetHeader("Content-Type", "text/html")
}
// RestoreDashboardVersion restores a dashboard to the given version.
@@ -702,5 +703,5 @@ func (hs *HTTPServer) GetDashboardTags(c *models.ReqContext) {
return
}
- c.JSON(200, query.Result)
+ c.JSON(http.StatusOK, query.Result)
}
diff --git a/pkg/api/dashboard_permission.go b/pkg/api/dashboard_permission.go
index 34ea0b797f8..0415fe0262e 100644
--- a/pkg/api/dashboard_permission.go
+++ b/pkg/api/dashboard_permission.go
@@ -56,7 +56,7 @@ func (hs *HTTPServer) GetDashboardPermissionList(c *models.ReqContext) response.
filteredAcls = append(filteredAcls, perm)
}
- return response.JSON(200, filteredAcls)
+ return response.JSON(http.StatusOK, filteredAcls)
}
func (hs *HTTPServer) UpdateDashboardPermissions(c *models.ReqContext) response.Response {
diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go
index 1d5c45cc5f7..93fa34cd385 100644
--- a/pkg/api/dashboard_snapshot.go
+++ b/pkg/api/dashboard_snapshot.go
@@ -24,7 +24,7 @@ var client = &http.Client{
}
func GetSharingOptions(c *models.ReqContext) {
- c.JSON(200, util.DynMap{
+ c.JSON(http.StatusOK, util.DynMap{
"externalSnapshotURL": setting.ExternalSnapshotUrl,
"externalSnapshotName": setting.ExternalSnapshotName,
"externalEnabled": setting.ExternalEnabled,
@@ -138,7 +138,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res
return nil
}
- c.JSON(200, util.DynMap{
+ c.JSON(http.StatusOK, util.DynMap{
"key": cmd.Key,
"deleteKey": cmd.DeleteKey,
"url": url,
@@ -181,7 +181,7 @@ func (hs *HTTPServer) GetDashboardSnapshot(c *models.ReqContext) response.Respon
metrics.MApiDashboardSnapshotGet.Inc()
- return response.JSON(200, dto).SetHeader("Cache-Control", "public, max-age=3600")
+ return response.JSON(http.StatusOK, dto).SetHeader("Cache-Control", "public, max-age=3600")
}
func deleteExternalDashboardSnapshot(externalUrl string) error {
@@ -242,7 +242,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r
return response.Error(500, "Failed to delete dashboard snapshot", err)
}
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.",
"id": query.Result.Id,
})
@@ -290,7 +290,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res
return response.Error(500, "Failed to delete dashboard snapshot", err)
}
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.",
"id": query.Result.Id,
})
@@ -333,5 +333,5 @@ func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Re
}
}
- return response.JSON(200, dtos)
+ return response.JSON(http.StatusOK, dtos)
}
diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go
index 24ec9479b2d..0a3735e284a 100644
--- a/pkg/api/dashboard_test.go
+++ b/pkg/api/dashboard_test.go
@@ -16,7 +16,6 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/models"
@@ -46,7 +45,7 @@ func TestGetHomeDashboard(t *testing.T) {
cfg.StaticRootPath = "../../public/"
hs := &HTTPServer{
- Cfg: cfg, Bus: bus.New(),
+ Cfg: cfg,
pluginStore: &fakePluginStore{},
SQLStore: mockstore.NewSQLStoreMock(),
}
@@ -96,7 +95,7 @@ func newTestLive(t *testing.T, store *sqlstore.SQLStore) *live.GrafanaLive {
nil,
&usagestats.UsageStatsMock{T: t},
nil,
- features, nil)
+ features, accesscontrolmock.New())
require.NoError(t, err)
return gLive
}
@@ -991,7 +990,6 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
cfg := setting.NewCfg()
hs := HTTPServer{
- Bus: bus.GetBus(),
Cfg: cfg,
ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()),
Live: newTestLive(t, sqlstore.InitTestDB(t)),
@@ -1027,7 +1025,6 @@ func postDiffScenario(t *testing.T, desc string, url string, routePattern string
cfg := setting.NewCfg()
hs := HTTPServer{
Cfg: cfg,
- Bus: bus.GetBus(),
ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()),
Live: newTestLive(t, sqlstore.InitTestDB(t)),
QuotaService: "a.QuotaService{Cfg: cfg},
@@ -1062,7 +1059,6 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout
mockSQLStore := mockstore.NewSQLStoreMock()
hs := HTTPServer{
Cfg: cfg,
- Bus: bus.GetBus(),
ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()),
Live: newTestLive(t, sqlstore.InitTestDB(t)),
QuotaService: "a.QuotaService{Cfg: cfg},
@@ -1095,8 +1091,8 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout
}
func (sc *scenarioContext) ToJSON() *simplejson.Json {
- var result *simplejson.Json
- err := json.NewDecoder(sc.resp.Body).Decode(&result)
+ result := simplejson.New()
+ err := json.NewDecoder(sc.resp.Body).Decode(result)
require.NoError(sc.t, err)
return result
}
diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go
index 5a39872d20e..af006072019 100644
--- a/pkg/api/dataproxy.go
+++ b/pkg/api/dataproxy.go
@@ -5,3 +5,7 @@ import "github.com/grafana/grafana/pkg/models"
func (hs *HTTPServer) ProxyDataSourceRequest(c *models.ReqContext) {
hs.DataProxy.ProxyDataSourceRequest(c)
}
+
+func (hs *HTTPServer) ProxyDataSourceRequestWithUID(c *models.ReqContext) {
+ hs.DataProxy.ProxyDatasourceRequestWithUID(c)
+}
diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go
index 1370d3a02e5..c93151501be 100644
--- a/pkg/api/datasources.go
+++ b/pkg/api/datasources.go
@@ -68,7 +68,7 @@ func (hs *HTTPServer) GetDataSources(c *models.ReqContext) response.Response {
sort.Sort(result)
- return response.JSON(200, &result)
+ return response.JSON(http.StatusOK, &result)
}
// GET /api/datasources/:id
@@ -102,7 +102,7 @@ func (hs *HTTPServer) GetDataSourceById(c *models.ReqContext) response.Response
// Add accesscontrol metadata
dto.AccessControl = hs.getAccessControlMetadata(c, c.OrgId, datasources.ScopePrefix, dto.UID)
- return response.JSON(200, &dto)
+ return response.JSON(http.StatusOK, &dto)
}
// DELETE /api/datasources/:id
@@ -161,7 +161,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *models.ReqContext) response.Response
// Add accesscontrol metadata
dto.AccessControl = hs.getAccessControlMetadata(c, c.OrgId, datasources.ScopePrefix, dto.UID)
- return response.JSON(200, &dto)
+ return response.JSON(http.StatusOK, &dto)
}
// DELETE /api/datasources/uid/:uid
@@ -193,7 +193,7 @@ func (hs *HTTPServer) DeleteDataSourceByUID(c *models.ReqContext) response.Respo
hs.Live.HandleDatasourceDelete(c.OrgId, ds.Uid)
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": "Data source deleted",
"id": ds.Id,
})
@@ -227,7 +227,7 @@ func (hs *HTTPServer) DeleteDataSourceByName(c *models.ReqContext) response.Resp
hs.Live.HandleDatasourceDelete(c.OrgId, getCmd.Result.Uid)
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": "Data source deleted",
"id": getCmd.Result.Id,
})
@@ -266,7 +266,7 @@ func (hs *HTTPServer) AddDataSource(c *models.ReqContext) response.Response {
}
ds := convertModelToDtos(cmd.Result)
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": "Datasource added",
"id": cmd.Result.Id,
"name": cmd.Result.Name,
@@ -331,7 +331,7 @@ func (hs *HTTPServer) UpdateDataSource(c *models.ReqContext) response.Response {
hs.Live.HandleDatasourceUpdate(c.OrgId, datasourceDTO.UID)
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": "Datasource updated",
"id": cmd.Id,
"name": cmd.Name,
@@ -409,7 +409,7 @@ func (hs *HTTPServer) GetDataSourceByName(c *models.ReqContext) response.Respons
}
dto := convertModelToDtos(filtered[0])
- return response.JSON(200, &dto)
+ return response.JSON(http.StatusOK, &dto)
}
// Get /api/datasources/id/:name
@@ -428,7 +428,7 @@ func (hs *HTTPServer) GetDataSourceIdByName(c *models.ReqContext) response.Respo
Id: ds.Id,
}
- return response.JSON(200, &dtos)
+ return response.JSON(http.StatusOK, &dtos)
}
// /api/datasources/:id/resources/*
diff --git a/pkg/api/docs/definitions/datasources.go b/pkg/api/docs/definitions/datasources.go
index c5eb1d54752..e911644d0bb 100644
--- a/pkg/api/docs/definitions/datasources.go
+++ b/pkg/api/docs/definitions/datasources.go
@@ -167,6 +167,20 @@ import (
// 404: notFoundError
// 500: internalServerError
+// swagger:route GET /datasources/proxy/uid/{datasource_uid}/{datasource_proxy_route} datasources datasourceProxyGETByUIDcalls
+//
+// Data source proxy GET calls.
+//
+// Proxies all calls to the actual data source.
+//
+// Responses:
+// 200:
+// 400: badRequestError
+// 401: unauthorisedError
+// 403: forbiddenError
+// 404: notFoundError
+// 500: internalServerError
+
// swagger:route POST /datasources/proxy/{datasource_id}/{datasource_proxy_route} datasources datasourceProxyPOSTcalls
//
// Data source proxy POST calls.
@@ -182,6 +196,21 @@ import (
// 404: notFoundError
// 500: internalServerError
+// swagger:route POST /datasources/proxy/uid/{datasource_uid}/{datasource_proxy_route} datasources datasourceProxyPOSTByUIDcalls
+//
+// Data source proxy POST calls.
+//
+// Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined
+//
+// Responses:
+// 201:
+// 202:
+// 400: badRequestError
+// 401: unauthorisedError
+// 403: forbiddenError
+// 404: notFoundError
+// 500: internalServerError
+
// swagger:route DELETE /datasources/proxy/{datasource_id}/{datasource_proxy_route} datasources datasourceProxyDELETEcalls
//
// Data source proxy DELETE calls.
@@ -196,6 +225,20 @@ import (
// 404: notFoundError
// 500: internalServerError
+// swagger:route DELETE /datasources/proxy/uid/{datasource_uid}/{datasource_proxy_route} datasources datasourceProxyDELETEByUIDcalls
+//
+// Data source proxy DELETE calls.
+//
+// Proxies all calls to the actual data source.
+//
+// Responses:
+// 202:
+// 400: badRequestError
+// 401: unauthorisedError
+// 403: forbiddenError
+// 404: notFoundError
+// 500: internalServerError
+
// swagger:route POST /tsdb/query datasources queryDatasource
//
// Query metrics.
@@ -227,7 +270,7 @@ type DatasourceID struct {
DatasourceID string `json:"datasource_id"`
}
-// swagger:parameters deleteDatasourceByUID getDatasourceByUID
+// swagger:parameters deleteDatasourceByUID getDatasourceByUID datasourceProxyGETByUIDcalls datasourceProxyPOSTByUIDcalls datasourceProxyDELETEByUIDcalls
type DatasourceUID struct {
// in:path
// required:true
@@ -241,7 +284,8 @@ type DatasourceName struct {
DatasourceName string `json:"datasource_name"`
}
-// swagger:parameters datasourceProxyGETcalls datasourceProxyPOSTcalls datasourceProxyDELETEcalls
+// swagger:parameters datasourceProxyGETcalls datasourceProxyPOSTcalls datasourceProxyDELETEcalls datasourceProxyGETByUIDcalls
+// swagger:parameters datasourceProxyPOSTByUIDcalls datasourceProxyDELETEByUIDcalls
type DatasourceProxyRouteParam struct {
// in:path
// required:true
diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go
index 141a7b2f026..caba326a2a8 100644
--- a/pkg/api/dtos/index.go
+++ b/pkg/api/dtos/index.go
@@ -56,22 +56,23 @@ const (
)
type NavLink struct {
- Id string `json:"id,omitempty"`
- Text string `json:"text"`
- Description string `json:"description,omitempty"`
- Section string `json:"section,omitempty"`
- SubTitle string `json:"subTitle,omitempty"`
- Icon string `json:"icon,omitempty"`
- Img string `json:"img,omitempty"`
- Url string `json:"url,omitempty"`
- Target string `json:"target,omitempty"`
- SortWeight int64 `json:"sortWeight,omitempty"`
- Divider bool `json:"divider,omitempty"`
- HideFromMenu bool `json:"hideFromMenu,omitempty"`
- HideFromTabs bool `json:"hideFromTabs,omitempty"`
- Children []*NavLink `json:"children,omitempty"`
- HighlightText string `json:"highlightText,omitempty"`
- HighlightID string `json:"highlightId,omitempty"`
+ Id string `json:"id,omitempty"`
+ Text string `json:"text"`
+ Description string `json:"description,omitempty"`
+ Section string `json:"section,omitempty"`
+ SubTitle string `json:"subTitle,omitempty"`
+ Icon string `json:"icon,omitempty"`
+ Img string `json:"img,omitempty"`
+ Url string `json:"url,omitempty"`
+ Target string `json:"target,omitempty"`
+ SortWeight int64 `json:"sortWeight,omitempty"`
+ Divider bool `json:"divider,omitempty"`
+ HideFromMenu bool `json:"hideFromMenu,omitempty"`
+ HideFromTabs bool `json:"hideFromTabs,omitempty"`
+ ShowIconInNavbar bool `json:"showIconInNavbar,omitempty"`
+ Children []*NavLink `json:"children,omitempty"`
+ HighlightText string `json:"highlightText,omitempty"`
+ HighlightID string `json:"highlightId,omitempty"`
}
// NavIDCfg is the id for org configuration navigation node
diff --git a/pkg/api/dtos/prefs.go b/pkg/api/dtos/prefs.go
index 96a83a94dcb..e8f929e6da4 100644
--- a/pkg/api/dtos/prefs.go
+++ b/pkg/api/dtos/prefs.go
@@ -3,11 +3,12 @@ package dtos
import "github.com/grafana/grafana/pkg/models"
type Prefs struct {
- Theme string `json:"theme"`
- HomeDashboardID int64 `json:"homeDashboardId"`
- Timezone string `json:"timezone"`
- WeekStart string `json:"weekStart"`
- Navbar models.NavbarPreference `json:"navbar,omitempty"`
+ Theme string `json:"theme"`
+ HomeDashboardID int64 `json:"homeDashboardId"`
+ Timezone string `json:"timezone"`
+ WeekStart string `json:"weekStart"`
+ Navbar models.NavbarPreference `json:"navbar,omitempty"`
+ QueryHistory models.QueryHistoryPreference `json:"queryHistory,omitempty"`
}
// swagger:model
@@ -18,9 +19,10 @@ type UpdatePrefsCmd struct {
// Default:0
HomeDashboardID int64 `json:"homeDashboardId"`
// Enum: utc,browser
- Timezone string `json:"timezone"`
- WeekStart string `json:"weekStart"`
- Navbar *models.NavbarPreference `json:"navbar,omitempty"`
+ Timezone string `json:"timezone"`
+ WeekStart string `json:"weekStart"`
+ Navbar *models.NavbarPreference `json:"navbar,omitempty"`
+ QueryHistory *models.QueryHistoryPreference `json:"queryHistory,omitempty"`
}
// swagger:model
@@ -31,7 +33,8 @@ type PatchPrefsCmd struct {
// Default:0
HomeDashboardID *int64 `json:"homeDashboardId,omitempty"`
// Enum: utc,browser
- Timezone *string `json:"timezone,omitempty"`
- WeekStart *string `json:"weekStart,omitempty"`
- Navbar *models.NavbarPreference `json:"navbar,omitempty"`
+ Timezone *string `json:"timezone,omitempty"`
+ WeekStart *string `json:"weekStart,omitempty"`
+ Navbar *models.NavbarPreference `json:"navbar,omitempty"`
+ QueryHistory *models.QueryHistoryPreference `json:"queryHistory,omitempty"`
}
diff --git a/pkg/api/folder.go b/pkg/api/folder.go
index faee784f8a8..3a14be06d33 100644
--- a/pkg/api/folder.go
+++ b/pkg/api/folder.go
@@ -34,7 +34,7 @@ func (hs *HTTPServer) GetFolders(c *models.ReqContext) response.Response {
})
}
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
func (hs *HTTPServer) GetFolderByUID(c *models.ReqContext) response.Response {
@@ -44,7 +44,7 @@ func (hs *HTTPServer) GetFolderByUID(c *models.ReqContext) response.Response {
}
g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser)
- return response.JSON(200, hs.toFolderDto(c.Req.Context(), g, folder))
+ return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, folder))
}
func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response {
@@ -58,7 +58,7 @@ func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response {
}
g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser)
- return response.JSON(200, hs.toFolderDto(c.Req.Context(), g, folder))
+ return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, folder))
}
func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response {
@@ -72,7 +72,7 @@ func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response {
}
g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser)
- return response.JSON(200, hs.toFolderDto(c.Req.Context(), g, folder))
+ return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, folder))
}
func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response {
@@ -86,7 +86,7 @@ func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response {
}
g := guardian.New(c.Req.Context(), cmd.Result.Id, c.OrgId, c.SignedInUser)
- return response.JSON(200, hs.toFolderDto(c.Req.Context(), g, cmd.Result))
+ return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, cmd.Result))
}
func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // temporarily adding this function to HTTPServer, will be removed from HTTPServer when librarypanels featuretoggle is removed
@@ -103,7 +103,7 @@ func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { //
return apierrors.ToFolderErrorResponse(err)
}
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"title": f.Title,
"message": fmt.Sprintf("Folder %s deleted", f.Title),
"id": f.Id,
diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go
index 548201c7ba2..830c71b7afd 100644
--- a/pkg/api/folder_permission.go
+++ b/pkg/api/folder_permission.go
@@ -55,7 +55,7 @@ func (hs *HTTPServer) GetFolderPermissionList(c *models.ReqContext) response.Res
filteredAcls = append(filteredAcls, perm)
}
- return response.JSON(200, filteredAcls)
+ return response.JSON(http.StatusOK, filteredAcls)
}
func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Response {
@@ -141,7 +141,7 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res
return response.Error(500, "Failed to create permission", err)
}
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": "Folder permissions updated",
"id": folder.Id,
"title": folder.Title,
diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go
index aa437ea2cf5..d62b4ad68c5 100644
--- a/pkg/api/folder_test.go
+++ b/pkg/api/folder_test.go
@@ -9,7 +9,6 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -139,7 +138,6 @@ func createFolderScenario(t *testing.T, desc string, url string, routePattern st
cmd models.CreateFolderCommand, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
hs := HTTPServer{
- Bus: bus.GetBus(),
Cfg: setting.NewCfg(),
folderService: folderService,
Features: featuremgmt.WithFeatures(),
diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go
index 1b1247a538e..3eff68dea68 100644
--- a/pkg/api/frontendsettings.go
+++ b/pkg/api/frontendsettings.go
@@ -2,6 +2,7 @@ package api
import (
"context"
+ "net/http"
"strconv"
"github.com/grafana/grafana/pkg/models"
@@ -21,7 +22,7 @@ func (hs *HTTPServer) GetFrontendSettings(c *models.ReqContext) {
return
}
- c.JSON(200, settings)
+ c.JSON(http.StatusOK, settings)
}
// getFrontendSettingsMap returns a json object with all the settings needed for front end initialisation.
@@ -113,6 +114,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i
"rudderstackDataPlaneUrl": setting.RudderstackDataPlaneUrl,
"rudderstackSdkUrl": setting.RudderstackSdkUrl,
"rudderstackConfigUrl": setting.RudderstackConfigUrl,
+ "feedbackLinksEnabled": hs.Cfg.FeedbackLinksEnabled,
"applicationInsightsConnectionString": hs.Cfg.ApplicationInsightsConnectionString,
"applicationInsightsEndpointUrl": hs.Cfg.ApplicationInsightsEndpointUrl,
"disableLoginForm": setting.DisableLoginForm,
diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go
index 707145f7378..26c3a939090 100644
--- a/pkg/api/frontendsettings_test.go
+++ b/pkg/api/frontendsettings_test.go
@@ -7,7 +7,6 @@ import (
"path/filepath"
"testing"
- "github.com/grafana/grafana/pkg/bus"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/licensing"
@@ -45,7 +44,6 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt.
hs := &HTTPServer{
Cfg: cfg,
Features: features,
- Bus: bus.GetBus(),
License: &licensing.OSSLicensingService{Cfg: cfg},
RenderService: &rendering.RenderingService{
Cfg: cfg,
diff --git a/pkg/api/grafana_com_proxy.go b/pkg/api/grafana_com_proxy.go
index 343a5ba9ed0..db974883d49 100644
--- a/pkg/api/grafana_com_proxy.go
+++ b/pkg/api/grafana_com_proxy.go
@@ -7,22 +7,24 @@ import (
"net/url"
"time"
+ "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
+ "github.com/grafana/grafana/pkg/util/proxyutil"
"github.com/grafana/grafana/pkg/web"
)
var grafanaComProxyTransport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
- Dial: (&net.Dialer{
+ DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
- }).Dial,
+ }).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
}
-func ReverseProxyGnetReq(proxyPath string, version string) *httputil.ReverseProxy {
+func ReverseProxyGnetReq(logger log.Logger, proxyPath string, version string) *httputil.ReverseProxy {
url, _ := url.Parse(setting.GrafanaComUrl)
director := func(req *http.Request) {
@@ -41,13 +43,12 @@ func ReverseProxyGnetReq(proxyPath string, version string) *httputil.ReverseProx
req.Header.Add("grafana-version", version)
}
- return &httputil.ReverseProxy{Director: director}
+ return proxyutil.NewReverseProxy(logger, director)
}
func (hs *HTTPServer) ProxyGnetRequest(c *models.ReqContext) {
proxyPath := web.Params(c.Req)["*"]
- proxy := ReverseProxyGnetReq(proxyPath, hs.Cfg.BuildVersion)
+ proxy := ReverseProxyGnetReq(c.Logger, proxyPath, hs.Cfg.BuildVersion)
proxy.Transport = grafanaComProxyTransport
proxy.ServeHTTP(c.Resp, c.Req)
- c.Resp.Header().Del("Set-Cookie")
}
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index fc87b642ca5..a2ff9d8e6d9 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -16,7 +16,6 @@ import (
"github.com/grafana/grafana/pkg/api/avatar"
"github.com/grafana/grafana/pkg/api/routing"
httpstatic "github.com/grafana/grafana/pkg/api/static"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
@@ -85,7 +84,6 @@ type HTTPServer struct {
PluginContextProvider *plugincontext.Provider
RouteRegister routing.RouteRegister
- Bus bus.Bus
RenderService rendering.Service
Cfg *setting.Cfg
Features *featuremgmt.FeatureManager
@@ -155,7 +153,7 @@ type ServerOptions struct {
Listener net.Listener
}
-func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routing.RouteRegister, bus bus.Bus,
+func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routing.RouteRegister,
renderService rendering.Service, licensing models.Licensing, hooksService *hooks.HooksService,
cacheService *localcache.CacheService, sqlStore *sqlstore.SQLStore, alertEngine *alerting.AlertEngine,
pluginRequestValidator models.PluginRequestValidator, pluginStaticRouteResolver plugins.StaticRouteResolver,
@@ -188,7 +186,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
hs := &HTTPServer{
Cfg: cfg,
RouteRegister: routeRegister,
- Bus: bus,
RenderService: renderService,
License: licensing,
HooksService: hooksService,
diff --git a/pkg/api/index.go b/pkg/api/index.go
index d4e0bc8ce3d..d7d8f5e133a 100644
--- a/pkg/api/index.go
+++ b/pkg/api/index.go
@@ -2,6 +2,7 @@ package api
import (
"fmt"
+ "net/http"
"path"
"sort"
"strings"
@@ -149,8 +150,11 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error)
}
func enableServiceAccount(hs *HTTPServer, c *models.ReqContext) bool {
- return (c.OrgRole == models.ROLE_ADMIN || (hs.Cfg.EditorsCanAdmin && c.OrgRole == models.ROLE_EDITOR)) &&
- hs.Features.IsEnabled(featuremgmt.FlagServiceAccounts)
+ if !hs.Features.IsEnabled(featuremgmt.FlagServiceAccounts) {
+ return false
+ }
+ hasAccess := ac.HasAccess(hs.AccessControl, c)
+ return hasAccess(ac.ReqOrgAdmin, serviceAccountAccessEvaluator)
}
func (hs *HTTPServer) ReqCanAdminTeams(c *models.ReqContext) bool {
@@ -230,7 +234,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
uaVisibleForOrg := hs.Cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg
if setting.AlertingEnabled != nil && *setting.AlertingEnabled || uaVisibleForOrg {
- navTree = append(navTree, hs.buildAlertNavLinks(c, uaVisibleForOrg)...)
+ navTree = append(navTree, hs.buildAlertNavLinks(c, uaVisibleForOrg, hasEditPerm)...)
}
appLinks, err := hs.getAppLinks(c)
@@ -291,7 +295,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
})
}
- if c.OrgRole == models.ROLE_ADMIN {
+ if hasAccess(ac.ReqOrgAdmin, apiKeyAccessEvaluator) {
configNodes = append(configNodes, &dtos.NavLink{
Text: "API keys",
Id: "apikeys",
@@ -460,23 +464,23 @@ func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
})
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
- Text: "New dashboard", Icon: "plus", Url: hs.Cfg.AppSubURL + "/dashboard/new", HideFromTabs: true, Id: "new-dashboard",
+ Text: "New dashboard", Icon: "plus", Url: hs.Cfg.AppSubURL + "/dashboard/new", HideFromTabs: true, Id: "new-dashboard", ShowIconInNavbar: true,
})
if c.OrgRole == models.ROLE_ADMIN || c.OrgRole == models.ROLE_EDITOR {
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "New folder", SubTitle: "Create a new folder to organize your dashboards", Id: "new-folder",
- Icon: "plus", Url: hs.Cfg.AppSubURL + "/dashboards/folder/new", HideFromTabs: true,
+ Icon: "plus", Url: hs.Cfg.AppSubURL + "/dashboards/folder/new", HideFromTabs: true, ShowIconInNavbar: true,
})
}
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "import", Icon: "plus",
- Url: hs.Cfg.AppSubURL + "/dashboard/import", HideFromTabs: true,
+ Url: hs.Cfg.AppSubURL + "/dashboard/import", HideFromTabs: true, ShowIconInNavbar: true,
})
}
return dashboardChildNavs
}
-func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext, uaVisibleForOrg bool) []*dtos.NavLink {
+func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext, uaVisibleForOrg bool, hasEditPerm bool) []*dtos.NavLink {
hasAccess := ac.HasAccess(hs.AccessControl, c)
var alertChildNavs []*dtos.NavLink
@@ -513,6 +517,19 @@ func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext, uaVisibleForOrg b
})
}
+ if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) {
+ if uaVisibleForOrg && hasEditPerm && hasAccess(ac.ReqSignedIn, ac.EvalPermission(ac.ActionAlertingRuleCreate)) {
+ alertChildNavs = append(alertChildNavs, &dtos.NavLink{
+ Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
+ })
+
+ alertChildNavs = append(alertChildNavs, &dtos.NavLink{
+ Text: "Alert rule", SubTitle: "Create an alert rule", Id: "alert",
+ Icon: "plus", Url: hs.Cfg.AppSubURL + "/alerting/new", HideFromTabs: true, ShowIconInNavbar: true,
+ })
+ }
+ }
+
if len(alertChildNavs) > 0 {
return []*dtos.NavLink{
{
@@ -734,7 +751,7 @@ func (hs *HTTPServer) Index(c *models.ReqContext) {
c.Handle(hs.Cfg, 500, "Failed to get settings", err)
return
}
- c.HTML(200, "index", data)
+ c.HTML(http.StatusOK, "index", data)
}
func (hs *HTTPServer) NotFoundHandler(c *models.ReqContext) {
diff --git a/pkg/api/ldap_debug.go b/pkg/api/ldap_debug.go
index a477bceedd6..97b1e5142fe 100644
--- a/pkg/api/ldap_debug.go
+++ b/pkg/api/ldap_debug.go
@@ -314,7 +314,7 @@ func (hs *HTTPServer) GetUserFromLDAP(c *models.ReqContext) response.Response {
return response.Error(http.StatusBadRequest, "Unable to find the teams for this user", err)
}
- return response.JSON(200, u)
+ return response.JSON(http.StatusOK, u)
}
// splitName receives the full name of a user and splits it into two parts: A name and a surname.
diff --git a/pkg/api/ldap_debug_test.go b/pkg/api/ldap_debug_test.go
index f9d796018bc..f7ed3473906 100644
--- a/pkg/api/ldap_debug_test.go
+++ b/pkg/api/ldap_debug_test.go
@@ -1,6 +1,7 @@
package api
import (
+ "encoding/json"
"errors"
"net/http"
"net/http/httptest"
@@ -146,13 +147,11 @@ func TestGetUserFromLDAPAPIEndpoint_OrgNotfound(t *testing.T) {
require.Equal(t, http.StatusBadRequest, sc.resp.Code)
- expected := `
- {
- "error": "unable to find organization with ID '2'",
- "message": "An organization was not found - Please verify your LDAP configuration"
- }
- `
- assert.JSONEq(t, expected, sc.resp.Body.String())
+ var res map[string]interface{}
+ err := json.Unmarshal(sc.resp.Body.Bytes(), &res)
+ assert.NoError(t, err)
+ assert.Equal(t, "unable to find organization with ID '2'", res["error"])
+ assert.Equal(t, "An organization was not found - Please verify your LDAP configuration", res["message"])
}
func TestGetUserFromLDAPAPIEndpoint(t *testing.T) {
@@ -470,14 +469,11 @@ func TestPostSyncUserWithLDAPAPIEndpoint_WhenGrafanaAdmin(t *testing.T) {
}, &sqlstoremock)
assert.Equal(t, http.StatusBadRequest, sc.resp.Code)
- expected := `
- {
- "error": "did not find a user",
- "message": "Refusing to sync grafana super admin \"ldap-daniel\" - it would be disabled"
- }
- `
-
- assert.JSONEq(t, expected, sc.resp.Body.String())
+ var res map[string]interface{}
+ err := json.Unmarshal(sc.resp.Body.Bytes(), &res)
+ assert.NoError(t, err)
+ assert.Equal(t, "did not find a user", res["error"])
+ assert.Equal(t, "Refusing to sync grafana super admin \"ldap-daniel\" - it would be disabled", res["message"])
}
func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotInLDAP(t *testing.T) {
diff --git a/pkg/api/login.go b/pkg/api/login.go
index 49a33fa5744..b158afcbef8 100644
--- a/pkg/api/login.go
+++ b/pkg/api/login.go
@@ -85,7 +85,7 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) {
urlParams := c.Req.URL.Query()
if _, disableAutoLogin := urlParams["disableAutoLogin"]; disableAutoLogin {
hs.log.Debug("Auto login manually disabled")
- c.HTML(200, getViewIndex(), viewData)
+ c.HTML(http.StatusOK, getViewIndex(), viewData)
return
}
@@ -109,7 +109,7 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) {
// to login again via OAuth and enter to a redirect loop
cookies.DeleteCookie(c.Resp, loginErrorCookieName, hs.CookieOptionsFromCfg)
viewData.Settings["loginError"] = loginError
- c.HTML(200, getViewIndex(), viewData)
+ c.HTML(http.StatusOK, getViewIndex(), viewData)
return
}
@@ -144,7 +144,7 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) {
return
}
- c.HTML(200, getViewIndex(), viewData)
+ c.HTML(http.StatusOK, getViewIndex(), viewData)
}
func (hs *HTTPServer) tryOAuthAutoLogin(c *models.ReqContext) bool {
@@ -167,7 +167,7 @@ func (hs *HTTPServer) tryOAuthAutoLogin(c *models.ReqContext) bool {
func (hs *HTTPServer) LoginAPIPing(c *models.ReqContext) response.Response {
if c.IsSignedIn || c.IsAnonymous {
- return response.JSON(200, "Logged in")
+ return response.JSON(http.StatusOK, "Logged in")
}
return response.Error(401, "Unauthorized", nil)
diff --git a/pkg/api/login_oauth_test.go b/pkg/api/login_oauth_test.go
index a9af0b79544..cc0c41f745d 100644
--- a/pkg/api/login_oauth_test.go
+++ b/pkg/api/login_oauth_test.go
@@ -13,7 +13,6 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/api/routing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/hooks"
"github.com/grafana/grafana/pkg/services/licensing"
@@ -34,7 +33,6 @@ func setupOAuthTest(t *testing.T, cfg *setting.Cfg) *web.Mux {
hs := &HTTPServer{
Cfg: cfg,
- Bus: bus.GetBus(),
License: &licensing.OSSLicensingService{Cfg: cfg},
SQLStore: sqlStore,
SocialService: social.ProvideService(cfg),
diff --git a/pkg/api/metrics_test.go b/pkg/api/metrics_test.go
index faa7631c523..552f83f3ae8 100644
--- a/pkg/api/metrics_test.go
+++ b/pkg/api/metrics_test.go
@@ -2,6 +2,7 @@ package api
import (
"context"
+ "encoding/json"
"fmt"
"net/http"
"strconv"
@@ -247,15 +248,14 @@ func TestAPIEndpoint_Metrics_QueryMetricsFromDashboard(t *testing.T) {
strings.NewReader(queryDatasourceInput),
t,
)
+
assert.Equal(t, http.StatusBadRequest, response.Code)
- assert.JSONEq(
- t,
- fmt.Sprintf(
- "{\"error\":\"%[1]s\",\"message\":\"%[1]s\"}",
- models.ErrDashboardOrPanelIdentifierNotSet,
- ),
- response.Body.String(),
- )
+
+ var res map[string]interface{}
+ err := json.Unmarshal(response.Body.Bytes(), &res)
+ assert.NoError(t, err)
+ assert.Equal(t, models.ErrDashboardOrPanelIdentifierNotSet.Error(), res["error"])
+ assert.Equal(t, models.ErrDashboardOrPanelIdentifierNotSet.Error(), res["message"])
})
t.Run("Cannot query without a valid orgid", func(t *testing.T) {
@@ -267,14 +267,10 @@ func TestAPIEndpoint_Metrics_QueryMetricsFromDashboard(t *testing.T) {
t,
)
assert.Equal(t, http.StatusBadRequest, response.Code)
- assert.JSONEq(
- t,
- fmt.Sprintf(
- "{\"error\":\"%[1]s\",\"message\":\"%[1]s\"}",
- models.ErrDashboardOrPanelIdentifierNotSet,
- ),
- response.Body.String(),
- )
+ var res map[string]interface{}
+ assert.NoError(t, json.Unmarshal(response.Body.Bytes(), &res))
+ assert.Equal(t, models.ErrDashboardOrPanelIdentifierNotSet.Error(), res["error"])
+ assert.Equal(t, models.ErrDashboardOrPanelIdentifierNotSet.Error(), res["message"])
})
t.Run("Cannot query without a valid dashboard or panel ID", func(t *testing.T) {
@@ -286,14 +282,11 @@ func TestAPIEndpoint_Metrics_QueryMetricsFromDashboard(t *testing.T) {
t,
)
assert.Equal(t, http.StatusBadRequest, response.Code)
- assert.JSONEq(
- t,
- fmt.Sprintf(
- "{\"error\":\"%[1]s\",\"message\":\"%[1]s\"}",
- models.ErrDashboardOrPanelIdentifierNotSet,
- ),
- response.Body.String(),
- )
+
+ var res map[string]interface{}
+ assert.NoError(t, json.Unmarshal(response.Body.Bytes(), &res))
+ assert.Equal(t, models.ErrDashboardOrPanelIdentifierNotSet.Error(), res["error"])
+ assert.Equal(t, models.ErrDashboardOrPanelIdentifierNotSet.Error(), res["message"])
})
t.Run("Cannot query when ValidatedQueries is disabled", func(t *testing.T) {
diff --git a/pkg/api/org.go b/pkg/api/org.go
index 21abb8fe5f0..d6cd5b3799b 100644
--- a/pkg/api/org.go
+++ b/pkg/api/org.go
@@ -54,7 +54,7 @@ func (hs *HTTPServer) GetOrgByName(c *models.ReqContext) response.Response {
},
}
- return response.JSON(200, &result)
+ return response.JSON(http.StatusOK, &result)
}
func (hs *HTTPServer) getOrgHelper(ctx context.Context, orgID int64) response.Response {
@@ -81,7 +81,7 @@ func (hs *HTTPServer) getOrgHelper(ctx context.Context, orgID int64) response.Re
},
}
- return response.JSON(200, &result)
+ return response.JSON(http.StatusOK, &result)
}
// POST /api/orgs
@@ -105,7 +105,7 @@ func (hs *HTTPServer) CreateOrg(c *models.ReqContext) response.Response {
metrics.MApiOrgCreate.Inc()
- return response.JSON(200, &util.DynMap{
+ return response.JSON(http.StatusOK, &util.DynMap{
"orgId": cmd.Result.Id,
"message": "Organization created",
})
@@ -226,5 +226,5 @@ func (hs *HTTPServer) SearchOrgs(c *models.ReqContext) response.Response {
return response.Error(500, "Failed to search orgs", err)
}
- return response.JSON(200, query.Result)
+ return response.JSON(http.StatusOK, query.Result)
}
diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go
index 957053455dd..312971cdedc 100644
--- a/pkg/api/org_invite.go
+++ b/pkg/api/org_invite.go
@@ -28,7 +28,7 @@ func (hs *HTTPServer) GetPendingOrgInvites(c *models.ReqContext) response.Respon
invite.Url = setting.ToAbsUrl("invite/" + invite.Code)
}
- return response.JSON(200, query.Result)
+ return response.JSON(http.StatusOK, query.Result)
}
func (hs *HTTPServer) AddOrgInvite(c *models.ReqContext) response.Response {
@@ -131,7 +131,7 @@ func (hs *HTTPServer) inviteExistingUserToOrg(c *models.ReqContext, user *models
}
}
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": fmt.Sprintf("Existing Grafana user %s added to org %s", user.NameOrFallback(), c.OrgName),
"userId": user.Id,
})
@@ -162,7 +162,7 @@ func (hs *HTTPServer) GetInviteInfoByCode(c *models.ReqContext) response.Respons
return response.Error(404, "Invite not found", nil)
}
- return response.JSON(200, dtos.InviteInfo{
+ return response.JSON(http.StatusOK, dtos.InviteInfo{
Email: invite.Email,
Name: invite.Name,
Username: invite.Email,
@@ -225,7 +225,7 @@ func (hs *HTTPServer) CompleteInvite(c *models.ReqContext) response.Response {
metrics.MApiUserSignUpCompleted.Inc()
metrics.MApiUserSignUpInvite.Inc()
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": "User created and logged in",
"id": user.Id,
})
diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go
index dcd45bbe523..b10aae80c45 100644
--- a/pkg/api/org_users.go
+++ b/pkg/api/org_users.go
@@ -64,7 +64,7 @@ func (hs *HTTPServer) addOrgUserHelper(ctx context.Context, cmd models.AddOrgUse
return response.Error(500, "Could not add user to organization", err)
}
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": "User added to organization",
"userId": cmd.UserId,
})
@@ -83,7 +83,7 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrg(c *models.ReqContext) response.Re
return response.Error(500, "Failed to get users for current organization", err)
}
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
// GET /api/org/users/lookup
@@ -109,7 +109,7 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrgLookup(c *models.ReqContext) respo
})
}
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
// GET /api/orgs/:orgId/users
@@ -130,7 +130,7 @@ func (hs *HTTPServer) GetOrgUsers(c *models.ReqContext) response.Response {
return response.Error(500, "Failed to get users for organization", err)
}
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
func (hs *HTTPServer) getOrgUsersHelper(c *models.ReqContext, query *models.GetOrgUsersQuery, signedInUser *models.SignedInUser) ([]*models.OrgUserDTO, error) {
@@ -201,7 +201,7 @@ func (hs *HTTPServer) SearchOrgUsersWithPaging(c *models.ReqContext) response.Re
query.Result.Page = page
query.Result.PerPage = perPage
- return response.JSON(200, query.Result)
+ return response.JSON(http.StatusOK, query.Result)
}
// PATCH /api/org/users/:userId
diff --git a/pkg/api/password.go b/pkg/api/password.go
index 487c1e1c33d..4454a486001 100644
--- a/pkg/api/password.go
+++ b/pkg/api/password.go
@@ -29,7 +29,7 @@ func (hs *HTTPServer) SendResetPasswordEmail(c *models.ReqContext) response.Resp
if err := hs.SQLStore.GetUserByLogin(c.Req.Context(), &userQuery); err != nil {
c.Logger.Info("Requested password reset for user that was not found", "user", userQuery.LoginOrEmail)
- return response.Error(200, "Email sent", err)
+ return response.Error(http.StatusOK, "Email sent", err)
}
emailCmd := models.SendResetPasswordEmailCommand{User: userQuery.Result}
diff --git a/pkg/api/playlist.go b/pkg/api/playlist.go
index 5e496c0fd0e..d30f8c58b23 100644
--- a/pkg/api/playlist.go
+++ b/pkg/api/playlist.go
@@ -54,7 +54,7 @@ func (hs *HTTPServer) SearchPlaylists(c *models.ReqContext) response.Response {
return response.Error(500, "Search failed", err)
}
- return response.JSON(200, searchQuery.Result)
+ return response.JSON(http.StatusOK, searchQuery.Result)
}
func (hs *HTTPServer) GetPlaylist(c *models.ReqContext) response.Response {
@@ -78,7 +78,7 @@ func (hs *HTTPServer) GetPlaylist(c *models.ReqContext) response.Response {
Items: playlistDTOs,
}
- return response.JSON(200, dto)
+ return response.JSON(http.StatusOK, dto)
}
func (hs *HTTPServer) LoadPlaylistItemDTOs(ctx context.Context, id int64) ([]models.PlaylistItemDTO, error) {
@@ -125,7 +125,7 @@ func (hs *HTTPServer) GetPlaylistItems(c *models.ReqContext) response.Response {
return response.Error(500, "Could not load playlist items", err)
}
- return response.JSON(200, playlistDTOs)
+ return response.JSON(http.StatusOK, playlistDTOs)
}
func (hs *HTTPServer) GetPlaylistDashboards(c *models.ReqContext) response.Response {
@@ -139,7 +139,7 @@ func (hs *HTTPServer) GetPlaylistDashboards(c *models.ReqContext) response.Respo
return response.Error(500, "Could not load dashboards", err)
}
- return response.JSON(200, playlists)
+ return response.JSON(http.StatusOK, playlists)
}
func (hs *HTTPServer) DeletePlaylist(c *models.ReqContext) response.Response {
@@ -153,7 +153,7 @@ func (hs *HTTPServer) DeletePlaylist(c *models.ReqContext) response.Response {
return response.Error(500, "Failed to delete playlist", err)
}
- return response.JSON(200, "")
+ return response.JSON(http.StatusOK, "")
}
func (hs *HTTPServer) CreatePlaylist(c *models.ReqContext) response.Response {
@@ -167,7 +167,7 @@ func (hs *HTTPServer) CreatePlaylist(c *models.ReqContext) response.Response {
return response.Error(500, "Failed to create playlist", err)
}
- return response.JSON(200, cmd.Result)
+ return response.JSON(http.StatusOK, cmd.Result)
}
func (hs *HTTPServer) UpdatePlaylist(c *models.ReqContext) response.Response {
@@ -192,5 +192,5 @@ func (hs *HTTPServer) UpdatePlaylist(c *models.ReqContext) response.Response {
}
cmd.Result.Items = playlistDTOs
- return response.JSON(200, cmd.Result)
+ return response.JSON(http.StatusOK, cmd.Result)
}
diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go
index 68bcbfb553c..75e93fd513b 100644
--- a/pkg/api/pluginproxy/ds_proxy.go
+++ b/pkg/api/pluginproxy/ds_proxy.go
@@ -5,9 +5,7 @@ import (
"errors"
"fmt"
"io/ioutil"
- "log"
"net/http"
- "net/http/httputil"
"net/url"
"strconv"
"strings"
@@ -46,35 +44,10 @@ type DataSourceProxy struct {
tracer tracing.Tracer
}
-type handleResponseTransport struct {
- transport http.RoundTripper
-}
-
-func (t *handleResponseTransport) RoundTrip(req *http.Request) (*http.Response, error) {
- res, err := t.transport.RoundTrip(req)
- if err != nil {
- return nil, err
- }
- res.Header.Del("Set-Cookie")
- proxyutil.SetProxyResponseHeaders(res.Header)
- return res, nil
-}
-
type httpClient interface {
Do(req *http.Request) (*http.Response, error)
}
-type logWrapper struct {
- logger glog.Logger
-}
-
-// Write writes log messages as bytes from proxy
-func (lw *logWrapper) Write(p []byte) (n int, err error) {
- withoutNewline := strings.TrimSuffix(string(p), "\n")
- lw.logger.Error("Data proxy error", "error", withoutNewline)
- return len(p), nil
-}
-
// NewDataSourceProxy creates a new Datasource proxy
func NewDataSourceProxy(ds *models.DataSource, pluginRoutes []*plugins.Route, ctx *models.ReqContext,
proxyPath string, cfg *setting.Cfg, clientProvider httpclient.Provider,
@@ -112,8 +85,14 @@ func (proxy *DataSourceProxy) HandleRequest() {
return
}
- proxyErrorLogger := logger.New("userId", proxy.ctx.UserId, "orgId", proxy.ctx.OrgId, "uname", proxy.ctx.Login,
- "path", proxy.ctx.Req.URL.Path, "remote_addr", proxy.ctx.RemoteAddr(), "referer", proxy.ctx.Req.Referer())
+ proxyErrorLogger := logger.New(
+ "userId", proxy.ctx.UserId,
+ "orgId", proxy.ctx.OrgId,
+ "uname", proxy.ctx.Login,
+ "path", proxy.ctx.Req.URL.Path,
+ "remote_addr", proxy.ctx.RemoteAddr(),
+ "referer", proxy.ctx.Req.Referer(),
+ )
transport, err := proxy.dataSourcesService.GetHTTPTransport(proxy.ctx.Req.Context(), proxy.ds, proxy.clientProvider)
if err != nil {
@@ -121,36 +100,36 @@ func (proxy *DataSourceProxy) HandleRequest() {
return
}
- reverseProxy := &httputil.ReverseProxy{
- Director: proxy.director,
- FlushInterval: time.Millisecond * 200,
- ErrorLog: log.New(&logWrapper{logger: proxyErrorLogger}, "", 0),
- Transport: &handleResponseTransport{
- transport: transport,
- },
- ModifyResponse: func(resp *http.Response) error {
- if resp.StatusCode == 401 {
- // The data source rejected the request as unauthorized, convert to 400 (bad request)
- body, err := ioutil.ReadAll(resp.Body)
- if err != nil {
- return fmt.Errorf("failed to read data source response body: %w", err)
- }
- _ = resp.Body.Close()
-
- proxyErrorLogger.Info("Authentication to data source failed", "body", string(body), "statusCode",
- resp.StatusCode)
- msg := "Authentication to data source failed"
- *resp = http.Response{
- StatusCode: 400,
- Status: "Bad Request",
- Body: ioutil.NopCloser(strings.NewReader(msg)),
- ContentLength: int64(len(msg)),
- }
+ modifyResponse := func(resp *http.Response) error {
+ if resp.StatusCode == 401 {
+ // The data source rejected the request as unauthorized, convert to 400 (bad request)
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read data source response body: %w", err)
}
- return nil
- },
+ _ = resp.Body.Close()
+
+ proxyErrorLogger.Info("Authentication to data source failed", "body", string(body), "statusCode",
+ resp.StatusCode)
+ msg := "Authentication to data source failed"
+ *resp = http.Response{
+ StatusCode: 400,
+ Status: "Bad Request",
+ Body: ioutil.NopCloser(strings.NewReader(msg)),
+ ContentLength: int64(len(msg)),
+ Header: http.Header{},
+ }
+ }
+ return nil
}
+ reverseProxy := proxyutil.NewReverseProxy(
+ proxyErrorLogger,
+ proxy.director,
+ proxyutil.WithTransport(transport),
+ proxyutil.WithModifyResponse(modifyResponse),
+ )
+
proxy.logRequest()
ctx, span := proxy.tracer.Start(proxy.ctx.Req.Context(), "datasource reverse proxy")
defer span.End()
@@ -249,14 +228,8 @@ func (proxy *DataSourceProxy) director(req *http.Request) {
}
proxyutil.ClearCookieHeader(req, keepCookieNames)
- proxyutil.PrepareProxyRequest(req)
-
req.Header.Set("User-Agent", fmt.Sprintf("Grafana/%s", setting.BuildVersion))
- // Clear Origin and Referer to avoid CORS issues
- req.Header.Del("Origin")
- req.Header.Del("Referer")
-
jsonData := make(map[string]interface{})
if proxy.ds.JsonData != nil {
jsonData, err = proxy.ds.JsonData.Map()
diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go
index 5d40f15812a..5f9d929c992 100644
--- a/pkg/api/pluginproxy/ds_proxy_test.go
+++ b/pkg/api/pluginproxy/ds_proxy_test.go
@@ -14,7 +14,6 @@ import (
"time"
"github.com/grafana/grafana/pkg/api/datasource"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -132,7 +131,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t.Run("When matching route path", func(t *testing.T) {
ctx, req := setUp()
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider,
&oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
@@ -145,7 +144,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t.Run("When matching route path and has dynamic url", func(t *testing.T) {
ctx, req := setUp()
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/common/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
proxy.matchedRoute = routes[3]
@@ -157,7 +156,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t.Run("When matching route path with no url", func(t *testing.T) {
ctx, req := setUp()
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
proxy.matchedRoute = routes[4]
@@ -168,7 +167,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t.Run("When matching route path and has dynamic body", func(t *testing.T) {
ctx, req := setUp()
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/body", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
proxy.matchedRoute = routes[5]
@@ -182,7 +181,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t.Run("Validating request", func(t *testing.T) {
t.Run("plugin route with valid role", func(t *testing.T) {
ctx, _ := setUp()
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
err = proxy.validateRequest()
@@ -191,7 +190,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t.Run("plugin route with admin role and user is editor", func(t *testing.T) {
ctx, _ := setUp()
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
err = proxy.validateRequest()
@@ -201,7 +200,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t.Run("plugin route with admin role and user is admin", func(t *testing.T) {
ctx, _ := setUp()
ctx.SignedInUser.OrgRole = models.ROLE_ADMIN
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
err = proxy.validateRequest()
@@ -291,7 +290,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
},
}
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg)
@@ -307,7 +306,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
req, err := http.NewRequest("GET", "http://localhost/asd", nil)
require.NoError(t, err)
client = newFakeHTTPClient(t, json2)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken2", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[1], dsInfo, cfg)
@@ -324,7 +323,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
require.NoError(t, err)
client = newFakeHTTPClient(t, []byte{})
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg)
@@ -346,7 +345,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
ctx := &models.ReqContext{}
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{BuildVersion: "5.3.0"}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
@@ -372,7 +371,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
ctx := &models.ReqContext{}
var routes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
@@ -396,7 +395,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
ctx := &models.ReqContext{}
var routes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
@@ -424,7 +423,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
ctx := &models.ReqContext{}
var pluginRoutes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, pluginRoutes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
@@ -447,7 +446,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
ctx := &models.ReqContext{}
var routes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
@@ -460,8 +459,6 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
assert.Equal(t, "http://host/root/path/to/folder/", req.URL.String())
- assert.Empty(t, req.Header.Get("Origin"))
- assert.Empty(t, req.Header.Get("Referer"))
assert.Equal(t, "stillthere", req.Header.Get("X-Canary"))
})
@@ -498,7 +495,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
var routes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &mockAuthToken, dsService, tracer)
require.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
@@ -632,7 +629,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) {
ctx, ds := setUp(t)
var routes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
@@ -650,7 +647,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) {
})
var routes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
@@ -664,7 +661,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) {
ctx, ds := setUp(t)
var routes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
@@ -686,7 +683,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) {
})
var routes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
@@ -711,7 +708,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) {
ctx.Req = httptest.NewRequest("GET", "/api/datasources/proxy/1/path/%2Ftest%2Ftest%2F?query=%2Ftest%2Ftest%2F", nil)
var routes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
@@ -735,7 +732,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) {
ctx.Req = httptest.NewRequest("GET", "/api/datasources/proxy/1/path/%2Ftest%2Ftest%2F?query=%2Ftest%2Ftest%2F", nil)
var routes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
@@ -760,7 +757,7 @@ func TestNewDataSourceProxy_InvalidURL(t *testing.T) {
require.NoError(t, err)
var routes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
_, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer)
require.Error(t, err)
assert.True(t, strings.HasPrefix(err.Error(), `validation of data source URL "://host/root" failed`))
@@ -781,7 +778,7 @@ func TestNewDataSourceProxy_ProtocolLessURL(t *testing.T) {
var routes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
_, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
@@ -824,7 +821,7 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) {
var routes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
p, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer)
if tc.err == nil {
require.NoError(t, err)
@@ -851,7 +848,7 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *models.ReqContext, cfg *sett
var routes []*plugins.Route
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
@@ -979,7 +976,7 @@ func runDatasourceAuthTest(t *testing.T, secretsStore kvstore.SecretsKVStore, cf
require.NoError(t, err)
var routes []*plugins.Route
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
@@ -1022,7 +1019,7 @@ func Test_PathCheck(t *testing.T) {
}
ctx, _ := setUp()
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(&models.DataSource{}, routes, ctx, "b", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer)
require.NoError(t, err)
diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go
index ca0d3092e3a..3735a34aa1a 100644
--- a/pkg/api/pluginproxy/pluginproxy.go
+++ b/pkg/api/pluginproxy/pluginproxy.go
@@ -1,7 +1,9 @@
package pluginproxy
import (
+ "bytes"
"encoding/json"
+ "io/ioutil"
"net/http"
"net/http/httputil"
"net/url"
@@ -22,7 +24,18 @@ type templateData struct {
// NewApiPluginProxy create a plugin proxy
func NewApiPluginProxy(ctx *models.ReqContext, proxyPath string, route *plugins.Route,
- appID string, cfg *setting.Cfg, pluginSettingsService pluginsettings.Service, secretsService secrets.Service) *httputil.ReverseProxy {
+ appID string, cfg *setting.Cfg, pluginSettingsService pluginsettings.Service,
+ secretsService secrets.Service) *httputil.ReverseProxy {
+ appProxyLogger := logger.New(
+ "userId", ctx.UserId,
+ "orgId", ctx.OrgId,
+ "uname", ctx.Login,
+ "app", appID,
+ "path", ctx.Req.URL.Path,
+ "remote_addr", ctx.RemoteAddr(),
+ "referer", ctx.Req.Referer(),
+ )
+
director := func(req *http.Request) {
query := pluginsettings.GetByPluginIDArgs{OrgID: ctx.OrgId, PluginID: appID}
ps, err := pluginSettingsService.GetPluginSettingByPluginID(ctx.Req.Context(), &query)
@@ -61,8 +74,6 @@ func NewApiPluginProxy(ctx *models.ReqContext, proxyPath string, route *plugins.
req.Header.Del("Cookie")
req.Header.Del("Set-Cookie")
- proxyutil.PrepareProxyRequest(req)
-
// Create a HTTP header with the context in it.
ctxJSON, err := json.Marshal(ctx.SignedInUser)
if err != nil {
@@ -80,15 +91,35 @@ func NewApiPluginProxy(ctx *models.ReqContext, proxyPath string, route *plugins.
}
if err := setBodyContent(req, route, data); err != nil {
- logger.Error("Failed to set plugin route body content", "error", err)
+ appProxyLogger.Error("Failed to set plugin route body content", "error", err)
}
}
- return &httputil.ReverseProxy{Director: director, ModifyResponse: modifyResponse}
+ logAppPluginProxyRequest(appID, cfg, ctx)
+
+ return proxyutil.NewReverseProxy(appProxyLogger, director)
}
-func modifyResponse(resp *http.Response) error {
- proxyutil.SetProxyResponseHeaders(resp.Header)
+func logAppPluginProxyRequest(appID string, cfg *setting.Cfg, c *models.ReqContext) {
+ if !cfg.DataProxyLogging {
+ return
+ }
- return nil
+ var body string
+ if c.Req.Body != nil {
+ buffer, err := ioutil.ReadAll(c.Req.Body)
+ if err == nil {
+ c.Req.Body = ioutil.NopCloser(bytes.NewBuffer(buffer))
+ body = string(buffer)
+ }
+ }
+
+ logger.Info("Proxying incoming request",
+ "userid", c.UserId,
+ "orgid", c.OrgId,
+ "username", c.Login,
+ "app", appID,
+ "uri", c.Req.RequestURI,
+ "method", c.Req.Method,
+ "body", body)
}
diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go
index 25a1dcbfe60..227a6d6fba1 100644
--- a/pkg/api/plugins.go
+++ b/pkg/api/plugins.go
@@ -111,7 +111,7 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response {
}
sort.Sort(result)
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
func (hs *HTTPServer) GetPluginSettingByID(c *models.ReqContext) response.Response {
@@ -163,7 +163,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *models.ReqContext) response.Respon
dto.HasUpdate = true
}
- return response.JSON(200, dto)
+ return response.JSON(http.StatusOK, dto)
}
func (hs *HTTPServer) UpdatePluginSetting(c *models.ReqContext) response.Response {
@@ -217,7 +217,7 @@ func (hs *HTTPServer) GetPluginMarkdown(c *models.ReqContext) response.Response
}
}
- resp := response.Respond(200, content)
+ resp := response.Respond(http.StatusOK, content)
resp.SetHeader("Content-Type", "text/plain; charset=utf-8")
return resp
}
@@ -343,7 +343,7 @@ func (hs *HTTPServer) CheckHealth(c *models.ReqContext) response.Response {
return response.JSON(503, payload)
}
- return response.JSON(200, payload)
+ return response.JSON(http.StatusOK, payload)
}
// CallResource passes a resource call from a plugin to the backend plugin.
@@ -354,7 +354,7 @@ func (hs *HTTPServer) CallResource(c *models.ReqContext) {
}
func (hs *HTTPServer) GetPluginErrorsList(_ *models.ReqContext) response.Response {
- return response.JSON(200, hs.pluginErrorResolver.PluginErrors())
+ return response.JSON(http.StatusOK, hs.pluginErrorResolver.PluginErrors())
}
func (hs *HTTPServer) InstallPlugin(c *models.ReqContext) response.Response {
diff --git a/pkg/api/preferences.go b/pkg/api/preferences.go
index 7d6de66a44f..3792bee5a90 100644
--- a/pkg/api/preferences.go
+++ b/pkg/api/preferences.go
@@ -53,9 +53,10 @@ func (hs *HTTPServer) getPreferencesFor(ctx context.Context, orgID, userID, team
if prefsQuery.Result.JsonData != nil {
dto.Navbar = prefsQuery.Result.JsonData.Navbar
+ dto.QueryHistory = prefsQuery.Result.JsonData.QueryHistory
}
- return response.JSON(200, &dto)
+ return response.JSON(http.StatusOK, &dto)
}
// PUT /api/user/preferences
@@ -110,6 +111,7 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te
WeekStart: dtoCmd.WeekStart,
HomeDashboardId: dtoCmd.HomeDashboardID,
Navbar: dtoCmd.Navbar,
+ QueryHistory: dtoCmd.QueryHistory,
}
if err := hs.SQLStore.PatchPreferences(ctx, &patchCmd); err != nil {
diff --git a/pkg/api/quota.go b/pkg/api/quota.go
index cdd8c9762d1..79d304dbf9e 100644
--- a/pkg/api/quota.go
+++ b/pkg/api/quota.go
@@ -32,7 +32,7 @@ func (hs *HTTPServer) getOrgQuotasHelper(c *models.ReqContext, orgID int64) resp
return response.Error(500, "Failed to get org quotas", err)
}
- return response.JSON(200, query.Result)
+ return response.JSON(http.StatusOK, query.Result)
}
func (hs *HTTPServer) UpdateOrgQuota(c *models.ReqContext) response.Response {
@@ -76,7 +76,7 @@ func (hs *HTTPServer) GetUserQuotas(c *models.ReqContext) response.Response {
return response.Error(500, "Failed to get org quotas", err)
}
- return response.JSON(200, query.Result)
+ return response.JSON(http.StatusOK, query.Result)
}
func (hs *HTTPServer) UpdateUserQuota(c *models.ReqContext) response.Response {
diff --git a/pkg/api/response/response.go b/pkg/api/response/response.go
index 57d751a407c..e08ed4eb974 100644
--- a/pkg/api/response/response.go
+++ b/pkg/api/response/response.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
jsoniter "github.com/json-iterator/go"
@@ -73,7 +74,15 @@ func (r *NormalResponse) ErrMessage() string {
func (r *NormalResponse) WriteTo(ctx *models.ReqContext) {
if r.err != nil {
- ctx.Logger.Error(r.errMessage, "error", r.err, "remote_addr", ctx.RemoteAddr())
+ v := map[string]interface{}{}
+ traceID := tracing.TraceIDFromContext(ctx.Req.Context(), false)
+ if err := json.Unmarshal(r.body.Bytes(), &v); err == nil {
+ v["traceID"] = traceID
+ if b, err := json.Marshal(v); err == nil {
+ r.body = bytes.NewBuffer(b)
+ }
+ }
+ ctx.Logger.Error(r.errMessage, "error", r.err, "remote_addr", ctx.RemoteAddr(), "traceID", traceID)
}
header := ctx.Resp.Header()
@@ -171,7 +180,7 @@ func JSONStreaming(status int, body interface{}) StreamingResponse {
func Success(message string) *NormalResponse {
resp := make(map[string]interface{})
resp["message"] = message
- return JSON(200, resp)
+ return JSON(http.StatusOK, resp)
}
// Error creates an error response.
diff --git a/pkg/api/search.go b/pkg/api/search.go
index 2f1a2cbf407..a981250d4d4 100644
--- a/pkg/api/search.go
+++ b/pkg/api/search.go
@@ -67,7 +67,7 @@ func (hs *HTTPServer) Search(c *models.ReqContext) response.Response {
}
c.TimeRequest(metrics.MApiDashboardSearch)
- return response.JSON(200, searchQuery.Result)
+ return response.JSON(http.StatusOK, searchQuery.Result)
}
func (hs *HTTPServer) ListSortOptions(c *models.ReqContext) response.Response {
diff --git a/pkg/api/short_url.go b/pkg/api/short_url.go
index 180aeedfd73..6e9d1614d1f 100644
--- a/pkg/api/short_url.go
+++ b/pkg/api/short_url.go
@@ -47,7 +47,7 @@ func (hs *HTTPServer) createShortURL(c *models.ReqContext) response.Response {
URL: url,
}
- return response.JSON(200, dto)
+ return response.JSON(http.StatusOK, dto)
}
func (hs *HTTPServer) redirectFromShortURL(c *models.ReqContext) {
diff --git a/pkg/api/signup.go b/pkg/api/signup.go
index f3568d03daf..69591220c93 100644
--- a/pkg/api/signup.go
+++ b/pkg/api/signup.go
@@ -18,7 +18,7 @@ import (
// GET /api/user/signup/options
func GetSignUpOptions(c *models.ReqContext) response.Response {
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"verifyEmailEnabled": setting.VerifyEmailEnabled,
"autoAssignOrg": setting.AutoAssignOrg,
})
@@ -64,7 +64,7 @@ func (hs *HTTPServer) SignUp(c *models.ReqContext) response.Response {
metrics.MApiUserSignUpStarted.Inc()
- return response.JSON(200, util.DynMap{"status": "SignUpCreated"})
+ return response.JSON(http.StatusOK, util.DynMap{"status": "SignUpCreated"})
}
func (hs *HTTPServer) SignUpStep2(c *models.ReqContext) response.Response {
@@ -135,7 +135,7 @@ func (hs *HTTPServer) SignUpStep2(c *models.ReqContext) response.Response {
metrics.MApiUserSignUpCompleted.Inc()
- return response.JSON(200, apiResponse)
+ return response.JSON(http.StatusOK, apiResponse)
}
func (hs *HTTPServer) verifyUserSignUpEmail(ctx context.Context, email string, code string) (bool, response.Response) {
diff --git a/pkg/api/swagger.go b/pkg/api/swagger.go
index 37d88541e72..7c96b576b9f 100644
--- a/pkg/api/swagger.go
+++ b/pkg/api/swagger.go
@@ -1,7 +1,11 @@
package api
-import "github.com/grafana/grafana/pkg/models"
+import (
+ "net/http"
+
+ "github.com/grafana/grafana/pkg/models"
+)
func swaggerUI(c *models.ReqContext) {
- c.HTML(200, "swagger", nil)
+ c.HTML(http.StatusOK, "swagger", nil)
}
diff --git a/pkg/api/team.go b/pkg/api/team.go
index 77416f5bce8..79c0eac9982 100644
--- a/pkg/api/team.go
+++ b/pkg/api/team.go
@@ -44,7 +44,7 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response {
c.Logger.Warn("Could not add creator to team because is not a real user")
}
}
- return response.JSON(200, &util.DynMap{
+ return response.JSON(http.StatusOK, &util.DynMap{
"teamId": team.Id,
"message": "Team created",
})
@@ -151,7 +151,7 @@ func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response {
query.Result.Page = page
query.Result.PerPage = perPage
- return response.JSON(200, query.Result)
+ return response.JSON(http.StatusOK, query.Result)
}
// UserFilter returns the user ID used in a filter when querying a team
@@ -198,7 +198,7 @@ func (hs *HTTPServer) GetTeamByID(c *models.ReqContext) response.Response {
query.Result.AccessControl = hs.getAccessControlMetadata(c, c.OrgId, "teams:id:", strconv.FormatInt(query.Result.Id, 10))
query.Result.AvatarUrl = dtos.GetGravatarUrlWithDefault(query.Result.Email, query.Result.Name)
- return response.JSON(200, &query.Result)
+ return response.JSON(http.StatusOK, &query.Result)
}
// GET /api/teams/:teamId/preferences
diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go
index e39028872e8..a8c44cde66a 100644
--- a/pkg/api/team_members.go
+++ b/pkg/api/team_members.go
@@ -54,7 +54,7 @@ func (hs *HTTPServer) GetTeamMembers(c *models.ReqContext) response.Response {
filteredMembers = append(filteredMembers, member)
}
- return response.JSON(200, filteredMembers)
+ return response.JSON(http.StatusOK, filteredMembers)
}
// POST /api/teams/:teamId/members
@@ -89,7 +89,7 @@ func (hs *HTTPServer) AddTeamMember(c *models.ReqContext) response.Response {
return response.Error(500, "Failed to add Member to Team", err)
}
- return response.JSON(200, &util.DynMap{
+ return response.JSON(http.StatusOK, &util.DynMap{
"message": "Member added to Team",
})
}
diff --git a/pkg/api/user.go b/pkg/api/user.go
index 95b69d0b462..8b0c59debf7 100644
--- a/pkg/api/user.go
+++ b/pkg/api/user.go
@@ -49,7 +49,7 @@ func (hs *HTTPServer) getUserUserProfile(c *models.ReqContext, userID int64) res
query.Result.AccessControl = hs.getAccessControlMetadata(c, c.OrgId, "global.users:id:", strconv.FormatInt(userID, 10))
query.Result.AvatarUrl = dtos.GetGravatarUrl(query.Result.Email)
- return response.JSON(200, query.Result)
+ return response.JSON(http.StatusOK, query.Result)
}
// GET /api/users/lookup
@@ -73,7 +73,7 @@ func (hs *HTTPServer) GetUserByLoginOrEmail(c *models.ReqContext) response.Respo
UpdatedAt: user.Updated,
CreatedAt: user.Created,
}
- return response.JSON(200, &result)
+ return response.JSON(http.StatusOK, &result)
}
// POST /api/user
@@ -176,7 +176,7 @@ func (hs *HTTPServer) getUserTeamList(ctx context.Context, orgID int64, userID i
for _, team := range query.Result {
team.AvatarUrl = dtos.GetGravatarUrlWithDefault(team.Email, team.Name)
}
- return response.JSON(200, query.Result)
+ return response.JSON(http.StatusOK, query.Result)
}
// GET /api/users/:id/orgs
@@ -195,7 +195,7 @@ func (hs *HTTPServer) getUserOrgList(ctx context.Context, userID int64) response
return response.Error(500, "Failed to get user organizations", err)
}
- return response.JSON(200, query.Result)
+ return response.JSON(http.StatusOK, query.Result)
}
func (hs *HTTPServer) validateUsingOrg(ctx context.Context, userID int64, orgID int64) bool {
@@ -321,7 +321,7 @@ func (hs *HTTPServer) SetHelpFlag(c *models.ReqContext) response.Response {
return response.Error(500, "Failed to update help flag", err)
}
- return response.JSON(200, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1})
+ return response.JSON(http.StatusOK, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1})
}
func (hs *HTTPServer) ClearHelpFlags(c *models.ReqContext) response.Response {
@@ -334,7 +334,7 @@ func (hs *HTTPServer) ClearHelpFlags(c *models.ReqContext) response.Response {
return response.Error(500, "Failed to update help flag", err)
}
- return response.JSON(200, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1})
+ return response.JSON(http.StatusOK, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1})
}
func GetAuthProviderLabel(authModule string) string {
diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go
index fbf77357ad7..4e59ac05d8c 100644
--- a/pkg/api/user_test.go
+++ b/pkg/api/user_test.go
@@ -13,7 +13,6 @@ import (
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/api/dtos"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
@@ -48,7 +47,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
loggedInUserScenario(t, "When calling GET on", "api/users/1", "api/users/:id", func(sc *scenarioContext) {
fakeNow := time.Date(2019, 2, 11, 17, 30, 40, 0, time.UTC)
secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore))
- authInfoStore := authinfostore.ProvideAuthInfoStore(sqlStore, bus.New(), secretsService)
+ authInfoStore := authinfostore.ProvideAuthInfoStore(sqlStore, secretsService)
srv := authinfoservice.ProvideAuthInfoService(&authinfoservice.OSSUserProtectionImpl{}, authInfoStore)
hs.authInfoService = srv
diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go
index 53b040bd531..edb5c34da41 100644
--- a/pkg/api/user_token.go
+++ b/pkg/api/user_token.go
@@ -43,7 +43,7 @@ func (hs *HTTPServer) logoutUserFromAllDevicesInternal(ctx context.Context, user
return response.Error(500, "Failed to logout user", err)
}
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": "User logged out",
})
}
@@ -112,7 +112,7 @@ func (hs *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int
})
}
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) response.Response {
@@ -144,7 +144,7 @@ func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID i
return response.Error(500, "Failed to revoke user auth token", err)
}
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"message": "User auth token revoked",
})
}
diff --git a/pkg/api/user_token_test.go b/pkg/api/user_token_test.go
index 4a07a6b737e..8131f9f28a5 100644
--- a/pkg/api/user_token_test.go
+++ b/pkg/api/user_token_test.go
@@ -8,7 +8,6 @@ import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/sqlstore"
@@ -151,7 +150,6 @@ func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePat
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
- Bus: bus.GetBus(),
AuthTokenService: fakeAuthTokenService,
SQLStore: sqlStore,
}
@@ -179,7 +177,6 @@ func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePatte
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
- Bus: bus.GetBus(),
AuthTokenService: fakeAuthTokenService,
SQLStore: sqlStore,
}
@@ -204,7 +201,6 @@ func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePatte
func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId int64, fn scenarioFunc, sqlStore sqlstore.Store) {
t.Run(desc, func(t *testing.T) {
hs := HTTPServer{
- Bus: bus.GetBus(),
AuthTokenService: auth.NewFakeUserAuthTokenService(),
SQLStore: sqlStore,
}
@@ -231,7 +227,6 @@ func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.R
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
- Bus: bus.GetBus(),
AuthTokenService: fakeAuthTokenService,
SQLStore: sqlStore,
}
@@ -257,7 +252,6 @@ func getUserAuthTokensInternalScenario(t *testing.T, desc string, token *models.
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
- Bus: bus.GetBus(),
AuthTokenService: fakeAuthTokenService,
SQLStore: sqlStore,
}
diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go
index 28fdc4b002e..4ca374e93b0 100644
--- a/pkg/bus/bus.go
+++ b/pkg/bus/bus.go
@@ -54,11 +54,6 @@ func New() *InProcBus {
return bus
}
-// Want to get rid of global bus
-func GetBus() Bus {
- return globalBus
-}
-
// PublishCtx function publish a message to the bus listener.
func (b *InProcBus) Publish(ctx context.Context, msg Msg) error {
var msgName = reflect.TypeOf(msg).Elem().Name()
diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go
index 82bcb8ffac3..37354ab98ae 100644
--- a/pkg/cmd/grafana-cli/commands/commands.go
+++ b/pkg/cmd/grafana-cli/commands/commands.go
@@ -4,7 +4,6 @@ import (
"strings"
"github.com/fatih/color"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/commands/datamigrations"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/commands/secretsmigrations"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
@@ -56,7 +55,7 @@ func runDbCommand(command func(commandLine utils.CommandLine, sqlStore *sqlstore
return errutil.Wrap("failed to initialize tracer service", err)
}
- sqlStore, err := sqlstore.ProvideService(cfg, nil, bus.GetBus(), &migrations.OSSMigrations{}, tracer)
+ sqlStore, err := sqlstore.ProvideService(cfg, nil, &migrations.OSSMigrations{}, tracer)
if err != nil {
return errutil.Wrap("failed to initialize SQL store", err)
}
diff --git a/pkg/components/imguploader/s3uploader.go b/pkg/components/imguploader/s3uploader.go
index 9cb0a7f7c57..7228ac85a75 100644
--- a/pkg/components/imguploader/s3uploader.go
+++ b/pkg/components/imguploader/s3uploader.go
@@ -114,6 +114,8 @@ func webIdentityProvider(sess client.ConfigProvider) credentials.Provider {
roleARN := os.Getenv("AWS_ROLE_ARN")
tokenFilepath := os.Getenv("AWS_WEB_IDENTITY_TOKEN_FILE")
roleSessionName := os.Getenv("AWS_ROLE_SESSION_NAME")
+
+ // nolint:staticcheck
return stscreds.NewWebIdentityRoleProvider(svc, roleARN, roleSessionName, tokenFilepath)
}
diff --git a/pkg/coremodel/dashboard/coremodel.go b/pkg/coremodel/dashboard/coremodel.go
new file mode 100644
index 00000000000..ed36c36dc7b
--- /dev/null
+++ b/pkg/coremodel/dashboard/coremodel.go
@@ -0,0 +1,40 @@
+package dashboard
+
+import (
+ "github.com/grafana/thema"
+)
+
+// Coremodel contains the foundational schema declaration for dashboards.
+type Coremodel struct {
+ lin thema.Lineage
+}
+
+// Lineage returns the canonical dashboard Lineage.
+func (c *Coremodel) Lineage() thema.Lineage {
+ return c.lin
+}
+
+func (c *Coremodel) CurrentSchema() thema.Schema {
+ sch, err := c.lin.Schema(currentVersion)
+ if err != nil {
+ // Only reachable if our own schema currentVersion does not exist, which
+ // can really only happen transitionally during development
+ panic(err)
+ }
+ return sch
+}
+
+func (c *Coremodel) GoType() interface{} {
+ return &model{}
+}
+
+func ProvideCoremodel(lib thema.Library) (*Coremodel, error) {
+ lin, err := Lineage(lib)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Coremodel{
+ lin: lin,
+ }, nil
+}
diff --git a/pkg/coremodel/dashboard/lineage.cue b/pkg/coremodel/dashboard/lineage.cue
new file mode 100644
index 00000000000..bd1c1db4f2e
--- /dev/null
+++ b/pkg/coremodel/dashboard/lineage.cue
@@ -0,0 +1,351 @@
+package dashboard
+
+import "github.com/grafana/thema"
+
+thema.#Lineage
+name: "dashboard"
+seqs: [
+ {
+ schemas: [
+ {// 0.0
+ // Unique numeric identifier for the dashboard.
+ // TODO must isolate or remove identifiers local to a Grafana instance...?
+ id?: int64
+ // Unique dashboard identifier that can be generated by anyone. string (8-40)
+ uid?: string
+ // Title of dashboard.
+ title?: string
+ // Description of dashboard.
+ description?: string
+
+ gnetId?: string
+ // Tags associated with dashboard.
+ tags?: [...string]
+ // Theme of dashboard.
+ style: *"light" | "dark"
+ // Timezone of dashboard,
+ timezone?: *"browser" | "utc" | ""
+ // Whether a dashboard is editable or not.
+ editable: bool | *true
+ // 0 for no shared crosshair or tooltip (default).
+ // 1 for shared crosshair.
+ // 2 for shared crosshair AND shared tooltip.
+ graphTooltip: uint8 & >=0 & <=2 | *0
+ // Time range for dashboard, e.g. last 6 hours, last 7 days, etc
+ time?: {
+ from: string | *"now-6h"
+ to: string | *"now"
+ }
+ // Timepicker metadata.
+ timepicker?: {
+ // Whether timepicker is collapsed or not.
+ collapse: bool | *false
+ // Whether timepicker is enabled or not.
+ enable: bool | *true
+ // Whether timepicker is visible or not.
+ hidden: bool | *false
+ // Selectable intervals for auto-refresh.
+ refresh_intervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
+ }
+ // Templating.
+ templating?: list: [...{...}]
+ // Annotations.
+ annotations?: list: [...{
+ builtIn: uint8 | *0
+ // Datasource to use for annotation.
+ datasource: {
+ type?: string
+ uid?: string
+ }
+ // Whether annotation is enabled.
+ enable: bool | *true
+ // Whether to hide annotation.
+ hide?: bool | *false
+ // Annotation icon color.
+ iconColor?: string
+ // Name of annotation.
+ name?: string
+ type: string | *"dashboard"
+ // Query for annotation data.
+ rawQuery?: string
+ showIn: uint8 | *0
+ }]
+ // Auto-refresh interval.
+ refresh?: string | false
+ // Version of the JSON schema, incremented each time a Grafana update brings
+ // changes to said schema.
+ // FIXME this is the old schema numbering system, and will be replaced by Thema's themaVersion
+ schemaVersion: uint16 | *33
+ // Version of the dashboard, incremented each time the dashboard is updated.
+ version?: uint32
+ panels?: [...(#Panel | #GraphPanel | #HeatmapPanel | #RowPanel)]
+
+ // TODO docs
+ #FieldColorModeId: "thresholds" | "palette-classic" | "palette-saturated" | "continuous-GrYlRd" | "fixed" @cuetsy(kind="enum")
+
+ // TODO docs
+ #FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type")
+
+ // TODO docs
+ #FieldColor: {
+ // The main color scheme mode
+ mode: #FieldColorModeId | string
+ // Stores the fixed color value if mode is fixed
+ fixedColor?: string
+ // Some visualizations need to know how to assign a series color from by value color schemes
+ seriesBy?: #FieldColorSeriesByMode
+ } @cuetsy(kind="interface")
+
+ // TODO docs
+ #Threshold: {
+ // TODO docs
+ // FIXME the corresponding typescript field is required/non-optional, but nulls currently appear here when serializing -Infinity to JSON
+ value?: number
+ // TODO docs
+ color: string
+ // TODO docs
+ // TODO are the values here enumerable into a disjunction?
+ // Some seem to be listed in typescript comment
+ state?: string
+ } @cuetsy(kind="interface")
+
+ #ThresholdsMode: "absolute" | "percentage" @cuetsy(kind="enum")
+
+ #ThresholdsConfig: {
+ mode: #ThresholdsMode
+
+ // Must be sorted by 'value', first value is always -Infinity
+ steps: [...#Threshold]
+ } @cuetsy(kind="interface")
+
+ // TODO docs
+ // FIXME this is extremely underspecfied; wasn't obvious which typescript types corresponded to it
+ #Transformation: {
+ id: string
+ options: {...}
+ }
+
+ // Schema for panel targets is specified by datasource
+ // plugins. We use a placeholder definition, which the Go
+ // schema loader either left open/as-is with the Base
+ // variant of the Dashboard and Panel families, or filled
+ // with types derived from plugins in the Instance variant.
+ // When working directly from CUE, importers can extend this
+ // type directly to achieve the same effect.
+ #Target: {...}
+
+ // Dashboard panels. Panels are canonically defined inline
+ // because they share a version timeline with the dashboard
+ // schema; they do not evolve independently.
+ #Panel: {
+ // The panel plugin type id.
+ type: !=""
+
+ // TODO docs
+ id?: uint32
+
+ // FIXME this almost certainly has to be changed in favor of scuemata versions
+ pluginVersion?: string
+
+ // TODO docs
+ tags?: [...string]
+
+ // Internal - the exact major and minor versions of the panel plugin
+ // schema. Hidden and therefore not a part of the data model, but
+ // expected to be filled with panel plugin schema versions so that it's
+ // possible to figure out which schema version matched on a successful
+ // unification.
+ // _pv: { maj: int, min: int }
+ // The major and minor versions of the panel plugin for this schema.
+ // TODO 2-tuple list instead of struct?
+ // panelSchema?: { maj: number, min: number }
+ panelSchema?: [number, number]
+
+ // TODO docs
+ targets?: [...#Target]
+
+ // Panel title.
+ title?: string
+ // Description.
+ description?: string
+ // Whether to display the panel without a background.
+ transparent: bool | *false
+ // The datasource used in all targets.
+ datasource?: {
+ type?: string
+ uid?: string
+ }
+ // Grid position.
+ gridPos?: {
+ // Panel
+ h: uint32 & >0 | *9
+ // Panel
+ w: uint32 & >0 & <=24 | *12
+ // Panel x
+ x: uint32 & >=0 & <24 | *0
+ // Panel y
+ y: uint32 & >=0 | *0
+ // true if fixed
+ static?: bool
+ }
+ // Panel links.
+ // FIXME this is temporarily specified as a closed list so
+ // that validation will pass when no links are present, but
+ // to force a failure as soon as it's checked against there
+ // being anything in the list so it can be fixed in
+ // accordance with that object
+ links?: []
+
+ // Name of template variable to repeat for.
+ repeat?: string
+ // Direction to repeat in if 'repeat' is set.
+ // "h" for horizontal, "v" for vertical.
+ repeatDirection: *"h" | "v"
+
+ // TODO docs
+ maxDataPoints?: number
+
+ // TODO docs
+ thresholds?: [...]
+
+ // TODO docs
+ timeRegions?: [...]
+
+ transformations: [...#Transformation]
+
+ // TODO docs
+ // TODO tighter constraint
+ interval?: string
+
+ // TODO docs
+ // TODO tighter constraint
+ timeFrom?: string
+
+ // TODO docs
+ // TODO tighter constraint
+ timeShift?: string
+
+ // options is specified by the PanelOptions field in panel
+ // plugin schemas.
+ options: {...}
+
+ fieldConfig: {
+ defaults: {
+ // The display value for this field. This supports template variables blank is auto
+ displayName?: string
+
+ // This can be used by data sources that return and explicit naming structure for values and labels
+ // When this property is configured, this value is used rather than the default naming strategy.
+ displayNameFromDS?: string
+
+ // Human readable field metadata
+ description?: string
+
+ // An explict path to the field in the datasource. When the frame meta includes a path,
+ // This will default to `${frame.meta.path}/${field.name}
+ //
+ // When defined, this value can be used as an identifier within the datasource scope, and
+ // may be used to update the results
+ path?: string
+
+ // True if data source can write a value to the path. Auth/authz are supported separately
+ writeable?: bool
+
+ // True if data source field supports ad-hoc filters
+ filterable?: bool
+
+ // Numeric Options
+ unit?: string
+
+ // Significant digits (for display)
+ decimals?: number
+
+ min?: number
+ max?: number
+
+ // Convert input values into a display string
+ //
+ // TODO this one corresponds to a complex type with
+ // generics on the typescript side. Ouch. Will
+ // either need special care, or we'll just need to
+ // accept a very loosely specified schema. It's very
+ // unlikely we'll be able to translate cue to
+ // typescript generics in the general case, though
+ // this particular one *may* be able to work.
+ mappings?: [...{...}]
+
+ // Map numeric values to states
+ thresholds?: #ThresholdsConfig
+
+ // // Map values to a display color
+ color?: #FieldColor
+
+ // // Used when reducing field values
+ // nullValueMode?: NullValueMode
+
+ // // The behavior when clicking on a result
+ links?: [...]
+
+ // Alternative to empty string
+ noValue?: string
+
+ // custom is specified by the PanelFieldConfig field
+ // in panel plugin schemas.
+ custom?: {...}
+ }
+ overrides: [...{
+ matcher: {
+ id: string | *""
+ options?: _
+ }
+ properties: [...{
+ id: string | *""
+ value?: _
+ }]
+ }]
+ }
+ }
+
+ // Row panel
+ #RowPanel: {
+ type: "row"
+ collapsed: bool | *false
+ title?: string
+
+ // Name of default datasource.
+ datasource?: {
+ type?: string
+ uid?: string
+ }
+
+ gridPos?: {
+ // Panel
+ h: uint32 & >0 | *9
+ // Panel
+ w: uint32 & >0 & <=24 | *12
+ // Panel x
+ x: uint32 & >=0 & <24 | *0
+ // Panel y
+ y: uint32 & >=0 | *0
+ // true if fixed
+ static?: bool
+ }
+ id: uint32
+ panels: [...(#Panel | #GraphPanel | #HeatmapPanel)]
+ // Name of template variable to repeat for.
+ repeat?: string
+ }
+
+ // Support for legacy graph and heatmap panels.
+ #GraphPanel: {
+ type: "graph"
+ ...
+ }
+ #HeatmapPanel: {
+ type: "heatmap"
+ ...
+ }
+ },
+ ]
+ },
+]
diff --git a/pkg/coremodel/dashboard/schema.go b/pkg/coremodel/dashboard/schema.go
new file mode 100644
index 00000000000..c7b7abc084a
--- /dev/null
+++ b/pkg/coremodel/dashboard/schema.go
@@ -0,0 +1,98 @@
+package dashboard
+
+import (
+ "embed"
+ "path/filepath"
+
+ "github.com/grafana/thema"
+
+ "github.com/grafana/grafana/pkg/cuectx"
+)
+
+var (
+ //go:embed lineage.cue
+ cueFS embed.FS
+
+ // TODO: this should be generated by Thema.
+ currentVersion = thema.SV(0, 0)
+)
+
+// HandoffSchemaVersion is the minimum schemaVersion for dashboards at which the
+// Thema-based dashboard schema is known to be valid.
+//
+// schemaVersion is the original version numbering system for dashboards. If a
+// dashboard is below this schemaVersion, it is necessary for the frontend
+// typescript dashboard migration logic to first run and get it past this
+// number, after which Thema can take over.
+const HandoffSchemaVersion = 36
+
+// Lineage returns the Thema lineage representing Grafana dashboards. The
+// lineage is the canonical specification of the current datasource schema, all
+// prior schema versions, and the mappings that allow migration between schema
+// versions.
+//
+// This is the base variant of the schema, which does not include any composed
+// plugin schemas.
+func Lineage(lib thema.Library, opts ...thema.BindOption) (thema.Lineage, error) {
+ return cuectx.LoadGrafanaInstancesWithThema(filepath.Join("pkg", "coremodel", "dashboard"), cueFS, lib, opts...)
+}
+
+// Model is a dummy struct stand-in for dashboards.
+//
+// It exists solely to trick compgen into accepting the dashboard coremodel as valid.
+type Model struct{}
+
+// model is a hacky Go struct representing a dashboard.
+//
+// This exists solely because the coremodel framework enforces that there is a Go struct to which
+// all valid Thema schema instances can be assigned, per Thema's assignability checker. See
+// https://github.com/grafana/thema/blob/main/docs/invariants.md#go-assignability for rules.
+//
+// DO NOT RELY ON THIS FOR ANYTHING REAL. It is unclear whether we will ever attempt to have a correct, complete
+// Go struct representation of dashboards, let alone compress it into a single struct.
+type model struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ GnetId string `json:"gnetId"`
+ Tags []string `json:"tags"`
+ Style string `json:"style"`
+ Timezone string `json:"timezone"`
+ Editable bool `json:"editable"`
+ GraphTooltip uint8 `json:"graphTooltip"`
+ Time struct {
+ From string `json:"from"`
+ To string `json:"to"`
+ } `json:"time"`
+ Timepicker struct {
+ Collapse bool `json:"collapse"`
+ Enable bool `json:"enable"`
+ Hidden bool `json:"hidden"`
+ RefreshIntervals []string `json:"refresh_intervals"`
+ } `json:"timepicker"`
+ Templating struct {
+ List []interface{} `json:"list"`
+ } `json:"templating"`
+ Annotations struct {
+ List []struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ BuiltIn uint8 `json:"builtIn"`
+ Datasource string `json:"datasource"`
+ Enable bool `json:"enable"`
+ Hide bool `json:"hide,omitempty"`
+ IconColor string `json:"iconColor"`
+ RawQuery string `json:"rawQuery,omitempty"`
+ ShowIn int `json:"showIn"`
+ } `json:"list"`
+ } `json:"annotations"`
+ Refresh interface{} `json:"refresh"` // (bool|string)
+ SchemaVersion int `json:"schemaVersion"`
+ Panels []interface{} `json:"panels"`
+
+ // //
+
+ Uid string `json:"uid"`
+ // OrgId int64 `json:"orgId"`
+ Id int64 `json:"id,omitempty"`
+ Version int `json:"version"`
+}
diff --git a/pkg/cuectx/ctx.go b/pkg/cuectx/ctx.go
new file mode 100644
index 00000000000..bddbe1ad1a0
--- /dev/null
+++ b/pkg/cuectx/ctx.go
@@ -0,0 +1,116 @@
+// Package cuectx provides a single, central CUE context (runtime) and Thema
+// library that can be used uniformly across Grafana, and related helper
+// functions for loading Thema lineages.
+
+package cuectx
+
+import (
+ "io"
+ "io/fs"
+ "path/filepath"
+ "testing/fstest"
+
+ "cuelang.org/go/cue"
+ "cuelang.org/go/cue/cuecontext"
+ "github.com/grafana/thema"
+ "github.com/grafana/thema/kernel"
+ "github.com/grafana/thema/load"
+)
+
+var ctx *cue.Context = cuecontext.New()
+var lib thema.Library = thema.NewLibrary(ctx)
+
+// ProvideCUEContext is a wire service provider of a central cue.Context.
+func ProvideCUEContext() *cue.Context {
+ return ctx
+}
+
+// ProvideThemaLibrary is a wire service provider of a central thema.Library.
+func ProvideThemaLibrary() thema.Library {
+ return lib
+}
+
+// JSONtoCUE attempts to decode the given []byte into a cue.Value, relying on
+// the central Grafana cue.Context provided in this package.
+//
+// The provided path argument determines the name given to the input bytes if
+// later CUE operations (e.g. Thema validation) produce errors related to the
+// returned cue.Value.
+//
+// This is a convenience function for one-off JSON decoding. It's wasteful to
+// call it repeatedly. Most use cases use cases should probably prefer making
+// their own Thema/CUE decoders.
+func JSONtoCUE(path string, b []byte) (cue.Value, error) {
+ return kernel.NewJSONDecoder(path)(ctx, b)
+}
+
+// LoadGrafanaInstancesWithThema loads CUE files containing a lineage
+// representing some Grafana core model schema. It is expected to be used when
+// implementing a thema.LineageFactory.
+//
+// This function primarily juggles paths to make CUE's loader happy. Provide the
+// path from the grafana root to the directory containing the lineage.cue. The
+// lineage.cue file must be the sole contents of the provided fs.FS.
+//
+// More details on underlying behavior can be found in the docs for github.com/grafana/thema/load.InstancesWithThema.
+func LoadGrafanaInstancesWithThema(
+ path string,
+ cueFS fs.FS,
+ lib thema.Library,
+ opts ...thema.BindOption,
+) (thema.Lineage, error) {
+ prefix := filepath.FromSlash(path)
+ fs, err := prefixWithGrafanaCUE(prefix, cueFS)
+ if err != nil {
+ return nil, err
+ }
+ inst, err := load.InstancesWithThema(fs, prefix)
+
+ // Need to trick loading by creating the embedded file and
+ // making it look like a module in the root dir.
+ if err != nil {
+ return nil, err
+ }
+
+ val := lib.Context().BuildInstance(inst)
+
+ lin, err := thema.BindLineage(val, lib, opts...)
+ if err != nil {
+ return nil, err
+ }
+
+ return lin, nil
+}
+
+func prefixWithGrafanaCUE(prefix string, inputfs fs.FS) (fs.FS, error) {
+ m := fstest.MapFS{
+ filepath.Join("cue.mod", "module.cue"): &fstest.MapFile{Data: []byte(`module: "github.com/grafana/grafana"`)},
+ }
+
+ prefix = filepath.FromSlash(prefix)
+ err := fs.WalkDir(inputfs, ".", (func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if d.IsDir() {
+ return nil
+ }
+
+ f, err := inputfs.Open(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close() // nolint: errcheck
+
+ b, err := io.ReadAll(f)
+ if err != nil {
+ return err
+ }
+
+ m[filepath.Join(prefix, path)] = &fstest.MapFile{Data: b}
+ return nil
+ }))
+
+ return m, err
+}
diff --git a/pkg/expr/transform.go b/pkg/expr/transform.go
index a3268f661f1..5e309d84c30 100644
--- a/pkg/expr/transform.go
+++ b/pkg/expr/transform.go
@@ -1,6 +1,7 @@
package expr
import (
+ "context"
"encoding/json"
"fmt"
"time"
@@ -8,7 +9,6 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/models"
"github.com/prometheus/client_golang/prometheus"
- "golang.org/x/net/context"
)
var (
diff --git a/pkg/framework/coremodel/helpers.go b/pkg/framework/coremodel/helpers.go
new file mode 100644
index 00000000000..d3f88a856d5
--- /dev/null
+++ b/pkg/framework/coremodel/helpers.go
@@ -0,0 +1 @@
+package coremodel
diff --git a/pkg/framework/coremodel/interface.go b/pkg/framework/coremodel/interface.go
new file mode 100644
index 00000000000..07957dd3b44
--- /dev/null
+++ b/pkg/framework/coremodel/interface.go
@@ -0,0 +1,25 @@
+package coremodel
+
+import (
+ "github.com/grafana/thema"
+)
+
+// Interface is the primary coremodel interface that must be implemented by all
+// Grafana coremodels. A coremodel is the foundational, canonical schema for
+// some known-at-compile-time Grafana object.
+//
+// Currently, all Coremodels are expressed as Thema lineages.
+type Interface interface {
+ // Lineage should return the canonical Thema lineage for the coremodel.
+ Lineage() thema.Lineage
+
+ // CurrentSchema should return the schema of the version that the Grafana backend
+ // is currently written against. (While Grafana can accept data from all
+ // older versions of the Thema schema, backend Go code is written against a
+ // single version for simplicity)
+ CurrentSchema() thema.Schema
+
+ // GoType should return a pointer to the Go struct type that corresponds to
+ // the Current() schema.
+ GoType() interface{}
+}
diff --git a/pkg/framework/coremodel/registry.go b/pkg/framework/coremodel/registry.go
new file mode 100644
index 00000000000..0fe44809d2f
--- /dev/null
+++ b/pkg/framework/coremodel/registry.go
@@ -0,0 +1,78 @@
+package coremodel
+
+import (
+ "errors"
+ "fmt"
+ "sync"
+
+ "github.com/grafana/thema"
+)
+
+var (
+ // ErrModelAlreadyRegistered is returned when trying to register duplicate model to Registry.
+ ErrModelAlreadyRegistered = errors.New("error registering duplicate model")
+)
+
+// Registry is a registry of coremodel instances.
+type Registry struct {
+ lock sync.RWMutex
+ models []Interface
+ modelIdx map[string]Interface
+}
+
+// NewRegistry returns a new Registry with the provided coremodel instances.
+func NewRegistry(models ...Interface) (*Registry, error) {
+ r := &Registry{
+ models: make([]Interface, 0, len(models)),
+ modelIdx: make(map[string]Interface, len(models)),
+ }
+
+ if err := r.addModels(models); err != nil {
+ return nil, err
+ }
+
+ return r, nil
+}
+
+// Register adds coremodels to the Registry.
+func (r *Registry) Register(models ...Interface) error {
+ return r.addModels(models)
+}
+
+// List returns all coremodels registered in this Registry.
+func (r *Registry) List() []Interface {
+ r.lock.RLock()
+ defer r.lock.RUnlock()
+
+ return r.models
+}
+
+func (r *Registry) addModels(models []Interface) error {
+ r.lock.Lock()
+ defer r.lock.Unlock()
+
+ // Update model index and return an error if trying to register a duplicate.
+ for _, m := range models {
+ k := m.Lineage().Name()
+
+ // Ensure assignability first. TODO will this blow up for dashboards?
+ if err := thema.AssignableTo(m.CurrentSchema(), m.GoType()); err != nil {
+ return fmt.Errorf("%s schema version %v not assignable to provided Go type: %w", k, m.CurrentSchema().Version(), err)
+ }
+
+ if _, ok := r.modelIdx[k]; ok {
+ return ErrModelAlreadyRegistered
+ }
+
+ r.modelIdx[k] = m
+ }
+
+ // Remake model list.
+ // TODO: this can be more performant (proper resizing, maybe single loop with index building, etc.).
+ r.models = r.models[:0]
+ for _, m := range r.modelIdx {
+ r.models = append(r.models, m)
+ }
+
+ return nil
+}
diff --git a/pkg/framework/coremodel/staticregistry/provide.go b/pkg/framework/coremodel/staticregistry/provide.go
new file mode 100644
index 00000000000..09a12bd3c77
--- /dev/null
+++ b/pkg/framework/coremodel/staticregistry/provide.go
@@ -0,0 +1,17 @@
+package staticregistry
+
+import (
+ "github.com/grafana/grafana/pkg/coremodel/dashboard"
+ "github.com/grafana/grafana/pkg/framework/coremodel"
+)
+
+// ProvideRegistry provides a simple static Registry for coremodels.
+// Coremodels have to be manually added.
+// TODO dynamism
+func ProvideRegistry(
+ dashboard *dashboard.Coremodel,
+) (*coremodel.Registry, error) {
+ return coremodel.NewRegistry(
+ dashboard,
+ )
+}
diff --git a/pkg/infra/filestorage/api.go b/pkg/infra/filestorage/api.go
index 3a4a5c285ba..93515f2c30e 100644
--- a/pkg/infra/filestorage/api.go
+++ b/pkg/infra/filestorage/api.go
@@ -51,9 +51,12 @@ type Paging struct {
}
type UpsertFileCommand struct {
- Path string
- MimeType string
- Contents *[]byte
+ Path string
+ MimeType string
+
+ // Contents of an existing file won't be modified if cmd.Contents is nil
+ Contents []byte
+ // Properties of an existing file won't be modified if cmd.Properties is nil
Properties map[string]string
}
diff --git a/pkg/infra/filestorage/cdk_blob_filestorage.go b/pkg/infra/filestorage/cdk_blob_filestorage.go
index 27a5f4aac03..53f165fac9a 100644
--- a/pkg/infra/filestorage/cdk_blob_filestorage.go
+++ b/pkg/infra/filestorage/cdk_blob_filestorage.go
@@ -98,7 +98,7 @@ func (c cdkBlobStorage) Upsert(ctx context.Context, command *UpsertFileCommand)
if command.Contents == nil {
contents = make([]byte, 0)
} else {
- contents = *command.Contents
+ contents = command.Contents
}
metadata = make(map[string]string)
@@ -115,7 +115,7 @@ func (c cdkBlobStorage) Upsert(ctx context.Context, command *UpsertFileCommand)
contents = existing.Contents
if command.Contents != nil {
- contents = *command.Contents
+ contents = command.Contents
}
if command.Properties != nil {
diff --git a/pkg/infra/filestorage/db_filestorage.go b/pkg/infra/filestorage/db_filestorage.go
index 06cfba9c1f7..ae4db90b713 100644
--- a/pkg/infra/filestorage/db_filestorage.go
+++ b/pkg/infra/filestorage/db_filestorage.go
@@ -141,7 +141,7 @@ func (s dbFileStorage) Upsert(ctx context.Context, cmd *UpsertFileCommand) error
if exists {
existing.Updated = now
if cmd.Contents != nil {
- contents := *cmd.Contents
+ contents := cmd.Contents
existing.Contents = contents
existing.MimeType = cmd.MimeType
existing.Size = int64(len(contents))
@@ -154,7 +154,7 @@ func (s dbFileStorage) Upsert(ctx context.Context, cmd *UpsertFileCommand) error
} else {
contentsToInsert := make([]byte, 0)
if cmd.Contents != nil {
- contentsToInsert = *cmd.Contents
+ contentsToInsert = cmd.Contents
}
file := &file{
diff --git a/pkg/infra/filestorage/fs_integration_test.go b/pkg/infra/filestorage/fs_integration_test.go
index 5b9268497ff..1a756545a2c 100644
--- a/pkg/infra/filestorage/fs_integration_test.go
+++ b/pkg/infra/filestorage/fs_integration_test.go
@@ -152,7 +152,7 @@ func runTests(createCases func() []fsTestCase, t *testing.T) {
func TestFsStorage(t *testing.T) {
//skipTest := true
- emptyFileBytes := make([]byte, 0)
+ emptyContents := make([]byte, 0)
pngImage, _ := base64.StdEncoding.DecodeString(pngImageBase64)
pngImageSize := int64(len(pngImage))
@@ -164,21 +164,21 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder1/folder2/file.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
Properties: map[string]string{"prop1": "val1", "prop2": "val"},
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder1/file-inner.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
Properties: map[string]string{"prop1": "val1", "prop2": "val"},
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder1/file-inner2.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
queryListFiles{
@@ -261,25 +261,25 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/ab/a.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/ab/a/a.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/ac/a.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/aba/a.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
queryListFiles{
@@ -307,14 +307,14 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder1/folder2/file.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
Properties: map[string]string{"prop1": "val1", "prop2": "val"},
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder1/file-inner.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
Properties: map[string]string{"prop1": "val1"},
},
},
@@ -370,13 +370,13 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder1/folder2/file.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder1/file-inner.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
queryListFiles{
@@ -411,19 +411,19 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder1/a",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder1/b",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder2/c",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
queryListFiles{
@@ -533,25 +533,25 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder1/folder2/file.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder1/file-inner.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folderX/folderZ/file.txt",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folderA/folderB/file.txt",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
queryListFolders{
@@ -585,25 +585,25 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder1/folder2/file.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder1/file-inner.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folderX/folderZ/file.txt",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folderA/folderB/file.txt",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
queryListFolders{
@@ -673,7 +673,7 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder/a.png",
- Contents: &pngImage,
+ Contents: pngImage,
Properties: map[string]string{"prop1": "val1", "prop2": "val"},
},
},
@@ -698,7 +698,7 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/Folder/A.png",
- Contents: &emptyFileBytes,
+ Contents: emptyContents,
},
},
queryGet{
@@ -718,7 +718,7 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/a.png",
- Contents: &pngImage,
+ Contents: pngImage,
Properties: map[string]string{"a": "av", "b": "bv"},
},
},
@@ -754,7 +754,7 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/aB.png",
- Contents: &emptyFileBytes,
+ Contents: emptyContents,
Properties: map[string]string{"a": "av", "b": "bv"},
},
},
@@ -791,7 +791,7 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/FILE.png",
- Contents: &emptyFileBytes,
+ Contents: emptyContents,
Properties: map[string]string{"a": "av", "b": "bv"},
},
},
@@ -803,13 +803,13 @@ func TestFsStorage(t *testing.T) {
fName("FILE.png"),
fProperties(map[string]string{"a": "av", "b": "bv"}),
fSize(0),
- fContents(emptyFileBytes),
+ fContents(emptyContents),
),
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/file.png",
- Contents: &pngImage,
+ Contents: pngImage,
},
},
queryGet{
@@ -832,7 +832,7 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/FILE.png",
- Contents: &emptyFileBytes,
+ Contents: emptyContents,
Properties: map[string]string{"a": "av", "b": "bv"},
},
},
@@ -855,7 +855,7 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/file.png",
- Contents: &emptyFileBytes,
+ Contents: emptyContents,
Properties: map[string]string{"a": "av", "b": "bv"},
},
},
@@ -888,7 +888,7 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/aB/cD/eF/file.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
queryListFolders{
@@ -1007,7 +1007,7 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/a/b/c/file.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
queryListFolders{
@@ -1119,7 +1119,7 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/folder/dashboards/myNewFolder/file.jpg",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdDeleteFolder{
@@ -1164,43 +1164,43 @@ func TestFsStorage(t *testing.T) {
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/s3/folder/dashboard.json",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/s3/folder/nested/dashboard.json",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/gitA/dashboard1.json",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/gitA/dashboard2.json",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/gitB/nested/dashboard.json",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/gitB/nested2/dashboard2.json",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
cmdUpsert{
cmd: UpsertFileCommand{
Path: "/gitC/nestedC/dashboardC.json",
- Contents: &[]byte{},
+ Contents: emptyContents,
},
},
queryListFiles{
diff --git a/pkg/infra/log/level/level.go b/pkg/infra/log/level/level.go
index cc3ef214491..5b1308d5a17 100644
--- a/pkg/infra/log/level/level.go
+++ b/pkg/infra/log/level/level.go
@@ -196,6 +196,14 @@ var (
debugValue = &levelValue{level: levelDebug, name: "dbug"}
)
+func SetLevelKeyAndValuesToGokitLog() {
+ key = "level"
+ errorValue = &levelValue{level: levelError, name: "error"}
+ warnValue = &levelValue{level: levelWarn, name: "warn"}
+ infoValue = &levelValue{level: levelInfo, name: "info"}
+ debugValue = &levelValue{level: levelDebug, name: "debug"}
+}
+
type level byte
const (
diff --git a/pkg/infra/log/log.go b/pkg/infra/log/log.go
index 21c11705e6a..efe85bbf84e 100644
--- a/pkg/infra/log/log.go
+++ b/pkg/infra/log/log.go
@@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"sort"
+ "strconv"
"strings"
"sync"
"time"
@@ -30,6 +31,7 @@ var (
loggersToReload []ReloadableHandler
root *logManager
now = time.Now
+ logTimeFormat = "2006-01-02T15:04:05.99-0700"
)
const (
@@ -53,9 +55,10 @@ func init() {
// logManager manage loggers
type logManager struct {
*ConcreteLogger
- loggersByName map[string]*ConcreteLogger
- logFilters []logWithFilters
- mutex sync.RWMutex
+ loggersByName map[string]*ConcreteLogger
+ logFilters []logWithFilters
+ mutex sync.RWMutex
+ gokitLogActivated bool
}
func newManager(logger gokitlog.Logger) *logManager {
@@ -69,6 +72,12 @@ func (lm *logManager) initialize(loggers []logWithFilters) {
lm.mutex.Lock()
defer lm.mutex.Unlock()
+ if lm.gokitLogActivated {
+ level.SetLevelKeyAndValuesToGokitLog()
+ term.SetTimeFormatGokitLog()
+ logTimeFormat = time.RFC3339Nano
+ }
+
defaultLoggers := make([]gokitlog.Logger, len(loggers))
for index, logger := range loggers {
defaultLoggers[index] = level.NewFilter(logger.val, logger.maxLevel)
@@ -181,7 +190,7 @@ func (cl *ConcreteLogger) Info(msg string, args ...interface{}) {
}
func (cl *ConcreteLogger) log(msg string, logLevel level.Value, args ...interface{}) error {
- logger := gokitlog.With(&cl.SwapLogger, "t", gokitlog.TimestampFormat(now, "2006-01-02T15:04:05.99-0700"))
+ logger := gokitlog.With(&cl.SwapLogger, "t", gokitlog.TimestampFormat(now, logTimeFormat))
args = append([]interface{}{level.Key(), logLevel, "msg", msg}, args...)
return logger.Log(args...)
@@ -436,9 +445,57 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) error {
configLoggers = append(configLoggers, handler)
}
+ var err error
+ isOldLoggerActivated, err := isOldLoggerActivated(cfg)
+ root.gokitLogActivated = !isOldLoggerActivated
+
+ if err != nil {
+ return err
+ }
if len(configLoggers) > 0 {
root.initialize(configLoggers)
}
return nil
}
+
+// This would be removed eventually, no need to make a fancy design.
+// For the sake of important cycle I just copied the function
+func isOldLoggerActivated(cfg *ini.File) (bool, error) {
+ section := cfg.Section("feature_toggles")
+ toggles, err := readFeatureTogglesFromInitFile(section)
+ if err != nil {
+ return false, err
+ }
+ return toggles["oldlog"], nil
+}
+
+func readFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]bool, error) {
+ featureToggles := make(map[string]bool, 10)
+
+ // parse the comma separated list in `enable`.
+ featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "")
+ for _, feature := range util.SplitString(featuresTogglesStr) {
+ featureToggles[feature] = true
+ }
+
+ // read all other settings under [feature_toggles]. If a toggle is
+ // present in both the value in `enable` is overridden.
+ for _, v := range featureTogglesSection.Keys() {
+ if v.Name() == "enable" {
+ continue
+ }
+
+ b, err := strconv.ParseBool(v.Value())
+ if err != nil {
+ return featureToggles, err
+ }
+
+ featureToggles[v.Name()] = b
+ }
+ return featureToggles, nil
+}
+
+func valueAsString(section *ini.Section, keyName string, defaultValue string) string {
+ return section.Key(keyName).MustString(defaultValue)
+}
diff --git a/pkg/infra/log/term/terminal_logger.go b/pkg/infra/log/term/terminal_logger.go
index 860ef27c7b6..b9aeac0e8a4 100644
--- a/pkg/infra/log/term/terminal_logger.go
+++ b/pkg/infra/log/term/terminal_logger.go
@@ -14,14 +14,21 @@ import (
"github.com/grafana/grafana/pkg/infra/log/level"
)
-const (
+var (
timeFormat = "2006-01-02T15:04:05-0700"
termTimeFormat = "01-02|15:04:05"
- floatFormat = 'f'
- termMsgJust = 40
- errorKey = "LOG15_ERROR"
)
+const (
+ floatFormat = 'f'
+ termMsgJust = 40
+ errorKey = "LOG15_ERROR"
+)
+
+func SetTimeFormatGokitLog() {
+ timeFormat = time.RFC3339Nano
+}
+
type terminalLogger struct {
w io.Writer
}
@@ -45,7 +52,10 @@ func (l terminalLogger) Log(keyvals ...interface{}) error {
r := getRecord(keyvals)
b := &bytes.Buffer{}
- lvl := strings.ToUpper(r.level.String())
+
+ // To make the log output more readable, we make all log levels 5 characters long
+ lvl := fmt.Sprintf("%-5s", strings.ToUpper(r.level.String()))
+
if r.color > 0 {
fmt.Fprintf(b, "\x1b[%dm%s\x1b[0m[%s] %s ", r.color, lvl, r.time.Format(termTimeFormat), r.msg) // lgtm[go/log-injection]
} else {
@@ -93,10 +103,16 @@ func getRecord(keyvals ...interface{}) *record {
if k == "t" {
t, ok := v.(fmt.Stringer)
if ok {
- time, err := time.Parse("2006-01-02T15:04:05.999999999-0700", t.String())
+ parsedTime, err := time.Parse("2006-01-02T15:04:05.999999999-0700", t.String())
if err == nil {
- r.time = time
+ r.time = parsedTime
continue
+ } else {
+ parsedTime, err := time.Parse(time.RFC3339Nano, t.String())
+ if err == nil {
+ r.time = parsedTime
+ continue
+ }
}
}
diff --git a/pkg/infra/tracing/opentelemetry_tracing.go b/pkg/infra/tracing/opentelemetry_tracing.go
index abe7fcb2fe2..61b62a850e0 100644
--- a/pkg/infra/tracing/opentelemetry_tracing.go
+++ b/pkg/infra/tracing/opentelemetry_tracing.go
@@ -126,6 +126,7 @@ func (ots *Opentelemetry) Start(ctx context.Context, spanName string, opts ...tr
opentelemetrySpan := OpentelemetrySpan{
span: span,
}
+ ctx = context.WithValue(ctx, traceKey{}, traceValue{span.SpanContext().TraceID().String(), span.SpanContext().IsSampled()})
return ctx, opentelemetrySpan
}
diff --git a/pkg/infra/tracing/tracing.go b/pkg/infra/tracing/tracing.go
index 95df49b4727..c13e353e94e 100644
--- a/pkg/infra/tracing/tracing.go
+++ b/pkg/infra/tracing/tracing.go
@@ -14,6 +14,7 @@ import (
opentracing "github.com/opentracing/opentracing-go"
"github.com/opentracing/opentracing-go/ext"
ol "github.com/opentracing/opentracing-go/log"
+ "github.com/uber/jaeger-client-go"
jaegercfg "github.com/uber/jaeger-client-go/config"
"github.com/uber/jaeger-client-go/zipkin"
"go.opentelemetry.io/otel/attribute"
@@ -52,6 +53,21 @@ func ProvideService(cfg *setting.Cfg) (Tracer, error) {
return ots, ots.initOpentelemetryTracer()
}
+type traceKey struct{}
+type traceValue struct {
+ ID string
+ IsSampled bool
+}
+
+func TraceIDFromContext(c context.Context, requireSampled bool) string {
+ v := c.Value(traceKey{})
+ // Return traceID if a) it is present and b) it is sampled when requireSampled param is true
+ if trace, ok := v.(traceValue); ok && (!requireSampled || trace.IsSampled) {
+ return trace.ID
+ }
+ return ""
+}
+
type Opentracing struct {
enabled bool
address string
@@ -172,6 +188,9 @@ func (ts *Opentracing) Run(ctx context.Context) error {
func (ts *Opentracing) Start(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, Span) {
span, ctx := opentracing.StartSpanFromContext(ctx, spanName)
opentracingSpan := OpentracingSpan{span: span}
+ if sctx, ok := span.Context().(jaeger.SpanContext); ok {
+ ctx = context.WithValue(ctx, traceKey{}, traceValue{sctx.TraceID().String(), sctx.IsSampled()})
+ }
return ctx, opentracingSpan
}
diff --git a/pkg/infra/usagestats/service/api_test.go b/pkg/infra/usagestats/service/api_test.go
index d95cc1dbd4a..450bfb83c0d 100644
--- a/pkg/infra/usagestats/service/api_test.go
+++ b/pkg/infra/usagestats/service/api_test.go
@@ -73,11 +73,11 @@ func getUsageStats(t *testing.T, server *web.Mux) (*models.SystemStats, *httptes
recorder := httptest.NewRecorder()
server.ServeHTTP(recorder, req)
- var usageStats *models.SystemStats
+ var usageStats models.SystemStats
if recorder.Code == http.StatusOK {
require.NoError(t, json.NewDecoder(recorder.Body).Decode(&usageStats))
}
- return usageStats, recorder
+ return &usageStats, recorder
}
func setupTestServer(t *testing.T, user *models.SignedInUser, service *UsageStats) *web.Mux {
diff --git a/pkg/middleware/logger.go b/pkg/middleware/logger.go
index 15174880807..c2f127319cb 100644
--- a/pkg/middleware/logger.go
+++ b/pkg/middleware/logger.go
@@ -19,10 +19,10 @@ import (
"net/http"
"time"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/contexthandler"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
- cw "github.com/weaveworks/common/tracing"
)
func Logger(cfg *setting.Cfg) web.Handler {
@@ -58,8 +58,8 @@ func Logger(cfg *setting.Cfg) web.Handler {
"referer", req.Referer(),
}
- traceID, exist := cw.ExtractTraceID(ctx.Req.Context())
- if exist {
+ traceID := tracing.TraceIDFromContext(ctx.Req.Context(), false)
+ if traceID != "" {
logParams = append(logParams, "traceID", traceID)
}
diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go
index 76dbd87b1be..a4fbd7c7629 100644
--- a/pkg/middleware/middleware_test.go
+++ b/pkg/middleware/middleware_test.go
@@ -111,7 +111,7 @@ func TestMiddlewareContext(t *testing.T) {
NavTree: []*dtos.NavLink{},
}
t.Log("Calling HTML", "data", data)
- c.HTML(200, "index-template", data)
+ c.HTML(http.StatusOK, "index-template", data)
t.Log("Returned HTML with code 200")
}
sc.fakeReq("GET", "/").exec()
@@ -616,7 +616,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func(
t.Log("Returning JSON OK")
resp := make(map[string]interface{})
resp["message"] = "OK"
- c.JSON(200, resp)
+ c.JSON(http.StatusOK, resp)
}
}
diff --git a/pkg/middleware/rate_limit_test.go b/pkg/middleware/rate_limit_test.go
index 641e385018a..88fb7e021c5 100644
--- a/pkg/middleware/rate_limit_test.go
+++ b/pkg/middleware/rate_limit_test.go
@@ -25,7 +25,7 @@ func rateLimiterScenario(t *testing.T, desc string, rps int, burst int, fn rateL
defaultHandler := func(c *models.ReqContext) {
resp := make(map[string]interface{})
resp["message"] = "OK"
- c.JSON(200, resp)
+ c.JSON(http.StatusOK, resp)
}
currentTime := time.Now()
diff --git a/pkg/middleware/request_metrics.go b/pkg/middleware/request_metrics.go
index d4923f233a6..6f9b2618993 100644
--- a/pkg/middleware/request_metrics.go
+++ b/pkg/middleware/request_metrics.go
@@ -7,10 +7,10 @@ import (
"time"
"github.com/grafana/grafana/pkg/infra/metrics"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/web"
"github.com/prometheus/client_golang/prometheus"
- cw "github.com/weaveworks/common/tracing"
)
var (
@@ -80,7 +80,7 @@ func RequestMetrics(features featuremgmt.FeatureToggles) web.Handler {
// since they dont make much sense. We should remove them later.
histogram := httpRequestDurationHistogram.
WithLabelValues(handler, code, req.Method)
- if traceID, ok := cw.ExtractSampledTraceID(c.Req.Context()); ok {
+ if traceID := tracing.TraceIDFromContext(c.Req.Context(), true); traceID != "" {
// Need to type-convert the Observer to an
// ExemplarObserver. This will always work for a
// HistogramVec.
diff --git a/pkg/models/context.go b/pkg/models/context.go
index dca3b2e5310..f31ea1197d6 100644
--- a/pkg/models/context.go
+++ b/pkg/models/context.go
@@ -4,6 +4,7 @@ import (
"strings"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
"github.com/prometheus/client_golang/prometheus"
@@ -51,9 +52,11 @@ func (ctx *ReqContext) IsApiRequest() bool {
func (ctx *ReqContext) JsonApiErr(status int, message string, err error) {
resp := make(map[string]interface{})
+ traceID := tracing.TraceIDFromContext(ctx.Req.Context(), false)
if err != nil {
- ctx.Logger.Error(message, "error", err)
+ resp["traceID"] = traceID
+ ctx.Logger.Error(message, "error", err, "traceID", traceID)
if setting.Env != setting.Prod {
resp["error"] = err.Error()
}
diff --git a/pkg/models/preferences.go b/pkg/models/preferences.go
index 1fbfb3f0992..f5a20048c27 100644
--- a/pkg/models/preferences.go
+++ b/pkg/models/preferences.go
@@ -49,8 +49,13 @@ type NavbarPreference struct {
SavedItems []NavLink `json:"savedItems"`
}
+type QueryHistoryPreference struct {
+ HomeTab string `json:"homeTab"`
+}
+
type PreferencesJsonData struct {
- Navbar NavbarPreference `json:"navbar"`
+ Navbar NavbarPreference `json:"navbar"`
+ QueryHistory QueryHistoryPreference `json:"queryHistory"`
}
// ---------------------
@@ -78,11 +83,12 @@ type SavePreferencesCommand struct {
OrgId int64
TeamId int64
- HomeDashboardId int64 `json:"homeDashboardId,omitempty"`
- Timezone string `json:"timezone,omitempty"`
- WeekStart string `json:"weekStart,omitempty"`
- Theme string `json:"theme,omitempty"`
- Navbar *NavbarPreference `json:"navbar,omitempty"`
+ HomeDashboardId int64 `json:"homeDashboardId,omitempty"`
+ Timezone string `json:"timezone,omitempty"`
+ WeekStart string `json:"weekStart,omitempty"`
+ Theme string `json:"theme,omitempty"`
+ Navbar *NavbarPreference `json:"navbar,omitempty"`
+ QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"`
}
type PatchPreferencesCommand struct {
@@ -90,9 +96,10 @@ type PatchPreferencesCommand struct {
OrgId int64
TeamId int64
- HomeDashboardId *int64 `json:"homeDashboardId,omitempty"`
- Timezone *string `json:"timezone,omitempty"`
- WeekStart *string `json:"weekStart,omitempty"`
- Theme *string `json:"theme,omitempty"`
- Navbar *NavbarPreference `json:"navbar,omitempty"`
+ HomeDashboardId *int64 `json:"homeDashboardId,omitempty"`
+ Timezone *string `json:"timezone,omitempty"`
+ WeekStart *string `json:"weekStart,omitempty"`
+ Theme *string `json:"theme,omitempty"`
+ Navbar *NavbarPreference `json:"navbar,omitempty"`
+ QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"`
}
diff --git a/pkg/models/user.go b/pkg/models/user.go
index 4d4b2bace22..b2419637364 100644
--- a/pkg/models/user.go
+++ b/pkg/models/user.go
@@ -182,6 +182,7 @@ type SignedInUser struct {
OrgCount int
IsGrafanaAdmin bool
IsAnonymous bool
+ IsDisabled bool
HelpFlags1 HelpFlags1
LastSeenAt time.Time
Teams []int64
diff --git a/pkg/plugins/manager/signature/manifest.go b/pkg/plugins/manager/signature/manifest.go
index 43e474af440..a7dcb19516d 100644
--- a/pkg/plugins/manager/signature/manifest.go
+++ b/pkg/plugins/manager/signature/manifest.go
@@ -82,7 +82,7 @@ func readPluginManifest(body []byte) (*pluginManifest, error) {
}
// Convert to a well typed object
- manifest := &pluginManifest{}
+ var manifest pluginManifest
err := json.Unmarshal(block.Plaintext, &manifest)
if err != nil {
return nil, errutil.Wrap("Error parsing manifest JSON", err)
@@ -99,7 +99,7 @@ func readPluginManifest(body []byte) (*pluginManifest, error) {
return nil, errutil.Wrap("failed to check signature", err)
}
- return manifest, nil
+ return &manifest, nil
}
func Calculate(mlog log.Logger, plugin *plugins.Plugin) (plugins.Signature, error) {
diff --git a/pkg/plugins/plugincontext/plugincontext.go b/pkg/plugins/plugincontext/plugincontext.go
index 92cea38ad46..e06a7821d4a 100644
--- a/pkg/plugins/plugincontext/plugincontext.go
+++ b/pkg/plugins/plugincontext/plugincontext.go
@@ -8,7 +8,6 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
@@ -19,11 +18,10 @@ import (
"github.com/grafana/grafana/pkg/util/errutil"
)
-func ProvideService(bus bus.Bus, cacheService *localcache.CacheService, pluginStore plugins.Store,
+func ProvideService(cacheService *localcache.CacheService, pluginStore plugins.Store,
dataSourceCache datasources.CacheService, dataSourceService datasources.DataSourceService,
pluginSettingsService pluginsettings.Service) *Provider {
return &Provider{
- bus: bus,
cacheService: cacheService,
pluginStore: pluginStore,
dataSourceCache: dataSourceCache,
@@ -34,7 +32,6 @@ func ProvideService(bus bus.Bus, cacheService *localcache.CacheService, pluginSt
}
type Provider struct {
- bus bus.Bus
cacheService *localcache.CacheService
pluginStore plugins.Store
dataSourceCache datasources.CacheService
diff --git a/pkg/schema/load/load_test.go b/pkg/schema/load/load_test.go
index 65d7655bac5..d9f96849cc0 100644
--- a/pkg/schema/load/load_test.go
+++ b/pkg/schema/load/load_test.go
@@ -19,6 +19,7 @@ import (
"cuelang.org/go/cue/errors"
"cuelang.org/go/cue/load"
cuejson "cuelang.org/go/pkg/encoding/json"
+ "github.com/grafana/grafana/pkg/coremodel/dashboard"
"github.com/grafana/grafana/pkg/schema"
"github.com/laher/mergefs"
"github.com/stretchr/testify/require"
@@ -48,7 +49,6 @@ var doTestAgainstDevenv = func(sch schema.VersionedCueSchema, validdir string, f
b, err := os.Open(path)
require.NoError(t, err, "failed to open dashboard file")
- // Only try to validate dashboards with schemaVersion >= 30
jtree := make(map[string]interface{})
byt, err := io.ReadAll(b)
if err != nil {
@@ -59,9 +59,9 @@ var doTestAgainstDevenv = func(sch schema.VersionedCueSchema, validdir string, f
t.Logf("no schemaVersion in %s", path)
return nil
} else {
- if !(oldschemav.(float64) > 32) {
+ if !(oldschemav.(float64) > dashboard.HandoffSchemaVersion-1) {
if testing.Verbose() {
- t.Logf("schemaVersion is %v, older than 33, skipping %s", oldschemav, path)
+ t.Logf("schemaVersion is %v, older than %v, skipping %s", oldschemav, dashboard.HandoffSchemaVersion-1, path)
}
return nil
}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index b1e8df32f94..d3c5ab9c88e 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -121,7 +121,7 @@ func (s *Server) init() error {
login.ProvideService(s.HTTPServer.SQLStore, s.HTTPServer.Login)
social.ProvideService(s.cfg)
- if err := s.roleRegistry.RegisterFixedRoles(); err != nil {
+ if err := s.roleRegistry.RegisterFixedRoles(s.context); err != nil {
return err
}
diff --git a/pkg/server/wire.go b/pkg/server/wire.go
index 0e145c18762..8d61c8df0c6 100644
--- a/pkg/server/wire.go
+++ b/pkg/server/wire.go
@@ -7,11 +7,15 @@ import (
"github.com/google/wire"
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
+
"github.com/grafana/grafana/pkg/api"
"github.com/grafana/grafana/pkg/api/avatar"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/bus"
+ "github.com/grafana/grafana/pkg/coremodel/dashboard"
+ "github.com/grafana/grafana/pkg/cuectx"
"github.com/grafana/grafana/pkg/expr"
+ cmreg "github.com/grafana/grafana/pkg/framework/coremodel/staticregistry"
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/infra/httpclient/httpclientprovider"
"github.com/grafana/grafana/pkg/infra/kvstore"
@@ -65,13 +69,13 @@ import (
plugindashboardsservice "github.com/grafana/grafana/pkg/services/plugindashboards/service"
"github.com/grafana/grafana/pkg/services/pluginsettings"
pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service"
+ "github.com/grafana/grafana/pkg/services/preference/prefimpl"
"github.com/grafana/grafana/pkg/services/query"
"github.com/grafana/grafana/pkg/services/queryhistory"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/rendering"
"github.com/grafana/grafana/pkg/services/schemaloader"
"github.com/grafana/grafana/pkg/services/search"
-
"github.com/grafana/grafana/pkg/services/searchV2"
"github.com/grafana/grafana/pkg/services/secrets"
secretsDatabase "github.com/grafana/grafana/pkg/services/secrets/database"
@@ -237,9 +241,14 @@ var wireBasicSet = wire.NewSet(
comments.ProvideService,
guardian.ProvideService,
secretsStore.ProvideService,
+ prefimpl.ProvideService,
avatar.ProvideAvatarCacheServer,
authproxy.ProvideAuthProxy,
statscollector.ProvideService,
+ dashboard.ProvideCoremodel,
+ cmreg.ProvideRegistry,
+ cuectx.ProvideCUEContext,
+ cuectx.ProvideThemaLibrary,
)
var wireSet = wire.NewSet(
diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go
index 81b21fad048..84903cdb4bd 100644
--- a/pkg/server/wireexts_oss.go
+++ b/pkg/server/wireexts_oss.go
@@ -31,6 +31,7 @@ import (
"github.com/grafana/grafana/pkg/services/searchusers"
"github.com/grafana/grafana/pkg/services/searchusers/filters"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations"
+ "github.com/grafana/grafana/pkg/services/thumbs"
"github.com/grafana/grafana/pkg/services/validations"
"github.com/grafana/grafana/pkg/setting"
)
@@ -46,6 +47,8 @@ var wireExtsBasicSet = wire.NewSet(
ossaccesscontrol.ProvideService,
wire.Bind(new(accesscontrol.RoleRegistry), new(*ossaccesscontrol.OSSAccessControlService)),
wire.Bind(new(accesscontrol.AccessControl), new(*ossaccesscontrol.OSSAccessControlService)),
+ thumbs.ProvideCrawlerAuthSetupService,
+ wire.Bind(new(thumbs.CrawlerAuthSetupService), new(*thumbs.OSSCrawlerAuthSetupService)),
validations.ProvideValidator,
wire.Bind(new(models.PluginRequestValidator), new(*validations.OSSPluginRequestValidator)),
provisioning.ProvideService,
diff --git a/pkg/services/accesscontrol/filter.go b/pkg/services/accesscontrol/filter.go
index 2fc40de2796..67eb5c49eaa 100644
--- a/pkg/services/accesscontrol/filter.go
+++ b/pkg/services/accesscontrol/filter.go
@@ -43,7 +43,7 @@ func Filter(user *models.SignedInUser, sqlID, prefix string, actions ...string)
wildcards := 0
result := make(map[interface{}]int)
for _, a := range actions {
- ids, hasWildcard := parseScopes(prefix, user.Permissions[user.OrgId][a])
+ ids, hasWildcard := ParseScopes(prefix, user.Permissions[user.OrgId][a])
if hasWildcard {
wildcards += 1
continue
@@ -84,7 +84,7 @@ func Filter(user *models.SignedInUser, sqlID, prefix string, actions ...string)
return SQLFilter{query.String(), ids}, nil
}
-func parseScopes(prefix string, scopes []string) (ids map[interface{}]struct{}, hasWildcard bool) {
+func ParseScopes(prefix string, scopes []string) (ids map[interface{}]struct{}, hasWildcard bool) {
ids = make(map[interface{}]struct{})
rootPrefix, attributePrefix, ok := extractPrefixes(prefix)
diff --git a/pkg/services/accesscontrol/mock/mock.go b/pkg/services/accesscontrol/mock/mock.go
index 870ebcadbc9..c9278d4c9e7 100644
--- a/pkg/services/accesscontrol/mock/mock.go
+++ b/pkg/services/accesscontrol/mock/mock.go
@@ -10,7 +10,7 @@ import (
type fullAccessControl interface {
accesscontrol.AccessControl
GetUserBuiltInRoles(user *models.SignedInUser) []string
- RegisterFixedRoles() error
+ RegisterFixedRoles(context.Context) error
}
type Calls struct {
@@ -165,7 +165,7 @@ func (m *Mock) GetUserBuiltInRoles(user *models.SignedInUser) []string {
// RegisterFixedRoles registers all roles declared to AccessControl
// This mock returns no error unless an override is provided.
-func (m *Mock) RegisterFixedRoles() error {
+func (m *Mock) RegisterFixedRoles(ctx context.Context) error {
m.Calls.RegisterFixedRoles = append(m.Calls.RegisterFixedRoles, []struct{}{})
// Use override if provided
if m.RegisterFixedRolesFunc != nil {
diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go
index d3df60664a5..0d4a2fc6f36 100644
--- a/pkg/services/accesscontrol/models.go
+++ b/pkg/services/accesscontrol/models.go
@@ -2,8 +2,12 @@ package accesscontrol
import (
"encoding/json"
+ "fmt"
"strings"
"time"
+
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/annotations"
)
// RoleRegistration stores a role and its assignments to built-in roles
@@ -29,15 +33,15 @@ type Role struct {
Created time.Time `json:"created"`
}
-func (r Role) Global() bool {
+func (r *Role) Global() bool {
return r.OrgID == GlobalOrgID
}
-func (r Role) IsFixed() bool {
+func (r *Role) IsFixed() bool {
return strings.HasPrefix(r.Name, FixedRolePrefix)
}
-func (r Role) GetDisplayName() string {
+func (r *Role) GetDisplayName() string {
if r.IsFixed() && r.DisplayName == "" {
r.DisplayName = fallbackDisplayName(r.Name)
}
@@ -75,11 +79,27 @@ type RoleDTO struct {
Created time.Time `json:"created"`
}
-func (r RoleDTO) Role() Role {
+func (r *RoleDTO) LogID() string {
+ var org string
+
+ if r.Global() {
+ org = "Global"
+ } else {
+ org = fmt.Sprintf("OrgId:%v", r.OrgID)
+ }
+
+ if r.UID != "" {
+ return fmt.Sprintf("[%s RoleUID:%v]", org, r.UID)
+ }
+ return fmt.Sprintf("[%s Role:%v]", org, r.Name)
+}
+
+func (r *RoleDTO) Role() Role {
return Role{
ID: r.ID,
OrgID: r.OrgID,
UID: r.UID,
+ Version: r.Version,
Name: r.Name,
DisplayName: r.DisplayName,
Group: r.Group,
@@ -90,15 +110,15 @@ func (r RoleDTO) Role() Role {
}
}
-func (r RoleDTO) Global() bool {
+func (r *RoleDTO) Global() bool {
return r.OrgID == GlobalOrgID
}
-func (r RoleDTO) IsFixed() bool {
+func (r *RoleDTO) IsFixed() bool {
return strings.HasPrefix(r.Name, FixedRolePrefix)
}
-func (r RoleDTO) GetDisplayName() string {
+func (r *RoleDTO) GetDisplayName() string {
if r.IsFixed() && r.DisplayName == "" {
r.DisplayName = fallbackDisplayName(r.Name)
}
@@ -242,6 +262,9 @@ type SetResourcePermissionCommand struct {
const (
GlobalOrgID = 0
+ FixedRolePrefix = "fixed:"
+ RoleGrafanaAdmin = "Grafana Admin"
+
GeneralFolderUID = "general"
// Permission actions
@@ -379,10 +402,21 @@ var (
ScopeAnnotationsProvider = NewScopeProvider(ScopeAnnotationsRoot)
ScopeAnnotationsAll = ScopeAnnotationsProvider.GetResourceAllScope()
ScopeAnnotationsID = Scope(ScopeAnnotationsRoot, "id", Parameter(":annotationId"))
- ScopeAnnotationsTypeDashboard = ScopeAnnotationsProvider.GetResourceScopeType("dashboard")
- ScopeAnnotationsTypeOrganization = ScopeAnnotationsProvider.GetResourceScopeType("organization")
+ ScopeAnnotationsTypeDashboard = ScopeAnnotationsProvider.GetResourceScopeType(annotations.Dashboard.String())
+ ScopeAnnotationsTypeOrganization = ScopeAnnotationsProvider.GetResourceScopeType(annotations.Organization.String())
)
-const RoleGrafanaAdmin = "Grafana Admin"
+func BuiltInRolesWithParents(builtInRoles []string) map[string]struct{} {
+ res := map[string]struct{}{}
-const FixedRolePrefix = "fixed:"
+ for _, br := range builtInRoles {
+ res[br] = struct{}{}
+ if br != RoleGrafanaAdmin {
+ for _, parent := range models.RoleType(br).Parents() {
+ res[string(parent)] = struct{}{}
+ }
+ }
+ }
+
+ return res
+}
diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go
index 36ed825e626..15fe9dce61c 100644
--- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go
+++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go
@@ -33,43 +33,6 @@ func ProvideService(features featuremgmt.FeatureToggles, usageStats usagestats.S
return s, errDeclareRoles
}
-func macroRoles() map[string]*accesscontrol.RoleDTO {
- return map[string]*accesscontrol.RoleDTO{
- string(models.ROLE_ADMIN): {
- Name: "fixed:builtins:admin",
- DisplayName: string(models.ROLE_ADMIN),
- Description: "Admin role",
- Group: "Basic",
- Version: 1,
- Permissions: []accesscontrol.Permission{},
- },
- string(models.ROLE_EDITOR): {
- Name: "fixed:builtins:editor",
- DisplayName: string(models.ROLE_EDITOR),
- Description: "Editor role",
- Group: "Basic",
- Version: 1,
- Permissions: []accesscontrol.Permission{},
- },
- string(models.ROLE_VIEWER): {
- Name: "fixed:builtins:viewer",
- DisplayName: string(models.ROLE_VIEWER),
- Description: "Viewer role",
- Group: "Basic",
- Version: 1,
- Permissions: []accesscontrol.Permission{},
- },
- accesscontrol.RoleGrafanaAdmin: {
- Name: "fixed:builtins:grafana_admin",
- DisplayName: accesscontrol.RoleGrafanaAdmin,
- Description: "Grafana Admin role",
- Group: "Basic",
- Version: 1,
- Permissions: []accesscontrol.Permission{},
- },
- }
-}
-
// ProvideOSSAccessControl creates an oss implementation of access control without usage stats registration
func ProvideOSSAccessControl(features featuremgmt.FeatureToggles, provider accesscontrol.PermissionsProvider) *OSSAccessControlService {
s := &OSSAccessControlService{
@@ -77,7 +40,7 @@ func ProvideOSSAccessControl(features featuremgmt.FeatureToggles, provider acces
provider: provider,
log: log.New("accesscontrol"),
scopeResolver: accesscontrol.NewScopeResolver(),
- roles: macroRoles(),
+ roles: accesscontrol.BuildMacroRoleDefinitions(),
}
return s
@@ -211,7 +174,7 @@ func (ac *OSSAccessControlService) GetUserBuiltInRoles(user *models.SignedInUser
}
// RegisterFixedRoles registers all declared roles in RAM
-func (ac *OSSAccessControlService) RegisterFixedRoles() error {
+func (ac *OSSAccessControlService) RegisterFixedRoles(ctx context.Context) error {
// If accesscontrol is disabled no need to register roles
if ac.IsDisabled() {
return nil
@@ -225,18 +188,7 @@ func (ac *OSSAccessControlService) RegisterFixedRoles() error {
// RegisterFixedRole saves a fixed role and assigns it to built-in roles
func (ac *OSSAccessControlService) registerFixedRole(role accesscontrol.RoleDTO, builtInRoles []string) {
- // Inheritance
- brs := map[string]struct{}{}
- for _, builtInRole := range builtInRoles {
- brs[builtInRole] = struct{}{}
- if builtInRole != accesscontrol.RoleGrafanaAdmin {
- for _, parent := range models.RoleType(builtInRole).Parents() {
- brs[string(parent)] = struct{}{}
- }
- }
- }
-
- for br := range brs {
+ for br := range accesscontrol.BuiltInRolesWithParents(builtInRoles) {
if macroRole, ok := ac.roles[br]; ok {
macroRole.Permissions = append(macroRole.Permissions, role.Permissions...)
} else {
diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go
index f26fb33788c..df799ad1a3d 100644
--- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go
+++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go
@@ -26,9 +26,9 @@ func setupTestEnv(t testing.TB) *OSSAccessControlService {
registrations: accesscontrol.RegistrationList{},
scopeResolver: accesscontrol.NewScopeResolver(),
provider: database.ProvideService(sqlstore.InitTestDB(t)),
- roles: macroRoles(),
+ roles: accesscontrol.BuildMacroRoleDefinitions(),
}
- require.NoError(t, ac.RegisterFixedRoles())
+ require.NoError(t, ac.RegisterFixedRoles(context.Background()))
return ac
}
@@ -94,7 +94,7 @@ func TestEvaluatingPermissions(t *testing.T) {
err := accesscontrol.DeclareFixedRoles(ac)
require.NoError(t, err)
- errRegisterRoles := ac.RegisterFixedRoles()
+ errRegisterRoles := ac.RegisterFixedRoles(context.Background())
require.NoError(t, errRegisterRoles)
user := &models.SignedInUser{
@@ -341,7 +341,7 @@ func TestOSSAccessControlService_RegisterFixedRoles(t *testing.T) {
ac.registrations.Append(tt.registrations...)
// Test
- err := ac.RegisterFixedRoles()
+ err := ac.RegisterFixedRoles(context.Background())
if tt.wantErr {
require.Error(t, err)
return
@@ -350,19 +350,8 @@ func TestOSSAccessControlService_RegisterFixedRoles(t *testing.T) {
// Check
for _, registration := range tt.registrations {
- // Prepare list of builtin roles to check
- brAndParents := map[string]struct{}{}
- for _, br := range registration.Grants {
- brAndParents[br] = struct{}{}
- if br != accesscontrol.RoleGrafanaAdmin {
- for _, parent := range models.RoleType(br).Parents() {
- brAndParents[string(parent)] = struct{}{}
- }
- }
- }
-
// Check builtin roles (parents included) have been granted with the permissions
- for br := range brAndParents {
+ for br := range accesscontrol.BuiltInRolesWithParents(registration.Grants) {
builtinRole, ok := ac.roles[br]
assert.True(t, ok)
for _, expectedPermission := range registration.Role.Permissions {
@@ -418,7 +407,7 @@ func TestOSSAccessControlService_GetUserPermissions(t *testing.T) {
err := ac.DeclareFixedRoles(registration)
require.NoError(t, err)
- err = ac.RegisterFixedRoles()
+ err = ac.RegisterFixedRoles(context.Background())
require.NoError(t, err)
// Test
@@ -499,7 +488,7 @@ func TestOSSAccessControlService_Evaluate(t *testing.T) {
err := ac.DeclareFixedRoles(registration)
require.NoError(t, err)
- err = ac.RegisterFixedRoles()
+ err = ac.RegisterFixedRoles(context.Background())
require.NoError(t, err)
// Test
diff --git a/pkg/services/accesscontrol/roles.go b/pkg/services/accesscontrol/roles.go
index 7561c836151..ac5082c3303 100644
--- a/pkg/services/accesscontrol/roles.go
+++ b/pkg/services/accesscontrol/roles.go
@@ -1,6 +1,7 @@
package accesscontrol
import (
+ "context"
"fmt"
"strings"
"sync"
@@ -10,7 +11,7 @@ import (
type RoleRegistry interface {
// RegisterFixedRoles registers all roles declared to AccessControl
- RegisterFixedRoles() error
+ RegisterFixedRoles(ctx context.Context) error
}
// Roles definition
@@ -276,3 +277,48 @@ func (m *RegistrationList) Range(f func(registration RoleRegistration) bool) {
}
}
}
+
+func BuildMacroRoleDefinitions() map[string]*RoleDTO {
+ return map[string]*RoleDTO{
+ string(models.ROLE_ADMIN): {
+ Name: "grafana:builtins:admin",
+ UID: "grafana_builtins_admin",
+ OrgID: GlobalOrgID,
+ Version: 1,
+ DisplayName: string(models.ROLE_ADMIN),
+ Description: "Admin role",
+ Group: "Basic",
+ Permissions: []Permission{},
+ },
+ string(models.ROLE_EDITOR): {
+ Name: "grafana:builtins:editor",
+ UID: "grafana_builtins_editor",
+ OrgID: GlobalOrgID,
+ Version: 1,
+ DisplayName: string(models.ROLE_EDITOR),
+ Description: "Editor role",
+ Group: "Basic",
+ Permissions: []Permission{},
+ },
+ string(models.ROLE_VIEWER): {
+ Name: "grafana:builtins:viewer",
+ UID: "grafana_builtins_viewer",
+ OrgID: GlobalOrgID,
+ Version: 1,
+ DisplayName: string(models.ROLE_VIEWER),
+ Description: "Viewer role",
+ Group: "Basic",
+ Permissions: []Permission{},
+ },
+ RoleGrafanaAdmin: {
+ Name: "grafana:builtins:grafana_admin",
+ UID: "grafana_builtins_grafana_admin",
+ OrgID: GlobalOrgID,
+ Version: 1,
+ DisplayName: RoleGrafanaAdmin,
+ Description: "Grafana Admin role",
+ Group: "Basic",
+ Permissions: []Permission{},
+ },
+ }
+}
diff --git a/pkg/services/alerting/conditions/query.go b/pkg/services/alerting/conditions/query.go
index 9aee49e5b1b..4eb86714dd6 100644
--- a/pkg/services/alerting/conditions/query.go
+++ b/pkg/services/alerting/conditions/query.go
@@ -56,7 +56,11 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext, requestHandler lega
emptySeriesCount := 0
evalMatchCount := 0
+
+ // matches represents all the series that violate the alert condition
var matches []*alerting.EvalMatch
+ // allMatches capture all evaluation matches irregardless on whether the condition is met or not
+ var allMatches []*alerting.EvalMatch
for _, series := range seriesList {
reducedValue := c.Reducer.Reduce(series)
@@ -72,14 +76,17 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext, requestHandler lega
})
}
+ em := alerting.EvalMatch{
+ Metric: series.Name,
+ Value: reducedValue,
+ Tags: series.Tags,
+ }
+
+ allMatches = append(allMatches, &em)
+
if evalMatch {
evalMatchCount++
-
- matches = append(matches, &alerting.EvalMatch{
- Metric: series.Name,
- Value: reducedValue,
- Tags: series.Tags,
- })
+ matches = append(matches, &em)
}
}
@@ -105,6 +112,7 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext, requestHandler lega
NoDataFound: emptySeriesCount == len(seriesList),
Operator: c.Operator,
EvalMatches: matches,
+ AllMatches: allMatches,
}, nil
}
diff --git a/pkg/services/alerting/engine.go b/pkg/services/alerting/engine.go
index 9566d7725b4..7fda9c47e85 100644
--- a/pkg/services/alerting/engine.go
+++ b/pkg/services/alerting/engine.go
@@ -10,7 +10,6 @@ import (
"go.opentelemetry.io/otel/attribute"
"golang.org/x/sync/errgroup"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/infra/usagestats"
@@ -41,7 +40,6 @@ type AlertStore interface {
// are sent.
type AlertEngine struct {
RenderService rendering.Service
- Bus bus.Bus
RequestValidator models.PluginRequestValidator
DataService legacydata.RequestHandler
Cfg *setting.Cfg
@@ -65,14 +63,13 @@ func (e *AlertEngine) IsDisabled() bool {
}
// ProvideAlertEngine returns a new AlertEngine.
-func ProvideAlertEngine(renderer rendering.Service, bus bus.Bus, requestValidator models.PluginRequestValidator,
+func ProvideAlertEngine(renderer rendering.Service, requestValidator models.PluginRequestValidator,
dataService legacydata.RequestHandler, usageStatsService usagestats.Service, encryptionService encryption.Internal,
notificationService *notifications.NotificationService, tracer tracing.Tracer, sqlStore AlertStore, cfg *setting.Cfg,
dashAlertExtractor DashAlertExtractor) *AlertEngine {
e := &AlertEngine{
Cfg: cfg,
RenderService: renderer,
- Bus: bus,
RequestValidator: requestValidator,
DataService: dataService,
usageStatsService: usageStatsService,
diff --git a/pkg/services/alerting/engine_integration_test.go b/pkg/services/alerting/engine_integration_test.go
index 05725068215..06df00a9d4d 100644
--- a/pkg/services/alerting/engine_integration_test.go
+++ b/pkg/services/alerting/engine_integration_test.go
@@ -24,7 +24,7 @@ func TestEngineTimeouts(t *testing.T) {
usMock := &usagestats.UsageStatsMock{T: t}
tracer, err := tracing.InitializeTracerForTest()
require.NoError(t, err)
- engine := ProvideAlertEngine(nil, nil, nil, nil, usMock, ossencryption.ProvideService(), nil, tracer, nil, setting.NewCfg(), nil)
+ engine := ProvideAlertEngine(nil, nil, nil, usMock, ossencryption.ProvideService(), nil, tracer, nil, setting.NewCfg(), nil)
setting.AlertingNotificationTimeout = 30 * time.Second
setting.AlertingMaxAttempts = 3
engine.resultHandler = &FakeResultHandler{}
diff --git a/pkg/services/alerting/engine_test.go b/pkg/services/alerting/engine_test.go
index 3ed3ee42362..e64f8a14dff 100644
--- a/pkg/services/alerting/engine_test.go
+++ b/pkg/services/alerting/engine_test.go
@@ -7,7 +7,6 @@ import (
"testing"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/infra/usagestats"
@@ -100,13 +99,12 @@ func (a *AlertStoreMock) SetAlertState(_ context.Context, _ *models.SetAlertStat
}
func TestEngineProcessJob(t *testing.T) {
- bus := bus.New()
usMock := &usagestats.UsageStatsMock{T: t}
tracer, err := tracing.InitializeTracerForTest()
require.NoError(t, err)
store := &AlertStoreMock{}
- engine := ProvideAlertEngine(nil, bus, nil, nil, usMock, ossencryption.ProvideService(), nil, tracer, store, setting.NewCfg(), nil)
+ engine := ProvideAlertEngine(nil, nil, nil, usMock, ossencryption.ProvideService(), nil, tracer, store, setting.NewCfg(), nil)
setting.AlertingEvaluationTimeout = 30 * time.Second
setting.AlertingNotificationTimeout = 30 * time.Second
setting.AlertingMaxAttempts = 3
diff --git a/pkg/services/alerting/eval_context.go b/pkg/services/alerting/eval_context.go
index de0d2d155ab..7eb3caad471 100644
--- a/pkg/services/alerting/eval_context.go
+++ b/pkg/services/alerting/eval_context.go
@@ -17,6 +17,7 @@ type EvalContext struct {
IsTestRun bool
IsDebug bool
EvalMatches []*EvalMatch
+ AllMatches []*EvalMatch
Logs []*ResultLogEntry
Error error
ConditionEvals string
@@ -47,6 +48,7 @@ func NewEvalContext(alertCtx context.Context, rule *Rule, requestValidator model
Rule: rule,
Logs: make([]*ResultLogEntry, 0),
EvalMatches: make([]*EvalMatch, 0),
+ AllMatches: make([]*EvalMatch, 0),
Log: log.New("alerting.evalContext"),
PrevAlertState: rule.State,
RequestValidator: requestValidator,
@@ -188,11 +190,13 @@ func getNewStateInternal(c *EvalContext) models.AlertStateType {
// evaluateNotificationTemplateFields will treat the alert evaluation rule's name and message fields as
// templates, and evaluate the templates using data from the alert evaluation's tags
func (c *EvalContext) evaluateNotificationTemplateFields() error {
- if len(c.EvalMatches) < 1 {
+ matches := c.getTemplateMatches()
+ if len(matches) < 1 {
+ // if there are no series to parse the templates with, return
return nil
}
- templateDataMap, err := buildTemplateDataMap(c.EvalMatches)
+ templateDataMap, err := buildTemplateDataMap(matches)
if err != nil {
return err
}
@@ -212,6 +216,18 @@ func (c *EvalContext) evaluateNotificationTemplateFields() error {
return nil
}
+// getTemplateMatches returns the values we should use to parse the templates
+func (c *EvalContext) getTemplateMatches() []*EvalMatch {
+ // EvalMatches represent series violating the rule threshold,
+ // if we have any, this means the alert is firing and we should use this data to parse the templates.
+ if len(c.EvalMatches) > 0 {
+ return c.EvalMatches
+ }
+
+ // If we don't have any alerting values, use all values to parse the templates.
+ return c.AllMatches
+}
+
func evaluateTemplate(s string, m map[string]string) (string, error) {
for k, v := range m {
re, err := regexp.Compile(fmt.Sprintf(`\${%s}`, regexp.QuoteMeta(k)))
diff --git a/pkg/services/alerting/eval_context_test.go b/pkg/services/alerting/eval_context_test.go
index 30c3114ed15..6ac5da6fab0 100644
--- a/pkg/services/alerting/eval_context_test.go
+++ b/pkg/services/alerting/eval_context_test.go
@@ -340,3 +340,66 @@ func TestEvaluateTemplate(t *testing.T) {
})
}
}
+
+func TestEvaluateNotificationTemplateFields(t *testing.T) {
+ tests := []struct {
+ name string
+ evalMatches []*EvalMatch
+ allMatches []*EvalMatch
+ expectedName string
+ expectedMessage string
+ }{
+ {
+ "with evaluation matches",
+ []*EvalMatch{{
+ Tags: map[string]string{"value1": "test1", "value2": "test2"},
+ }},
+ []*EvalMatch{{
+ Tags: map[string]string{"value1": "test1", "value2": "test2"},
+ }},
+ "Rule name: test1",
+ "Rule message: test2",
+ },
+ {
+ "missing key",
+ []*EvalMatch{{
+ Tags: map[string]string{"value1": "test1", "value3": "test2"},
+ }},
+ []*EvalMatch{{
+ Tags: map[string]string{"value1": "test1", "value3": "test2"},
+ }},
+ "Rule name: test1",
+ "Rule message: ${value2}",
+ },
+ {
+ "no evaluation matches, with series",
+ []*EvalMatch{},
+ []*EvalMatch{{
+ Tags: map[string]string{"value1": "test1", "value2": "test2"},
+ }},
+ "Rule name: test1",
+ "Rule message: test2",
+ },
+ {
+ "no evaluation matches, no series",
+ []*EvalMatch{},
+ []*EvalMatch{},
+ "Rule name: ${value1}",
+ "Rule message: ${value2}",
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(tt *testing.T) {
+ evalContext := NewEvalContext(context.Background(), &Rule{Name: "Rule name: ${value1}", Message: "Rule message: ${value2}", Conditions: []Condition{&conditionStub{firing: true}}}, &validations.OSSPluginRequestValidator{}, nil)
+ evalContext.EvalMatches = test.evalMatches
+ evalContext.AllMatches = test.allMatches
+
+ err := evalContext.evaluateNotificationTemplateFields()
+
+ require.NoError(tt, err)
+ require.Equal(tt, test.expectedName, evalContext.Rule.Name)
+ require.Equal(tt, test.expectedMessage, evalContext.Rule.Message)
+ })
+ }
+}
diff --git a/pkg/services/alerting/eval_handler.go b/pkg/services/alerting/eval_handler.go
index 88a609a714b..f516d0718e6 100644
--- a/pkg/services/alerting/eval_handler.go
+++ b/pkg/services/alerting/eval_handler.go
@@ -68,6 +68,7 @@ func (e *DefaultEvalHandler) Eval(context *EvalContext) {
}
context.EvalMatches = append(context.EvalMatches, cr.EvalMatches...)
+ context.AllMatches = append(context.AllMatches, cr.AllMatches...)
}
context.ConditionEvals = conditionEvals + " = " + strconv.FormatBool(firing)
diff --git a/pkg/services/alerting/interfaces.go b/pkg/services/alerting/interfaces.go
index 5d40116021b..b5f2fce922d 100644
--- a/pkg/services/alerting/interfaces.go
+++ b/pkg/services/alerting/interfaces.go
@@ -56,6 +56,7 @@ type ConditionResult struct {
NoDataFound bool
Operator string
EvalMatches []*EvalMatch
+ AllMatches []*EvalMatch
}
// Condition is responsible for evaluating an alert condition.
diff --git a/pkg/services/alerting/notifier_test.go b/pkg/services/alerting/notifier_test.go
index c921908c6b8..4e1ce660709 100644
--- a/pkg/services/alerting/notifier_test.go
+++ b/pkg/services/alerting/notifier_test.go
@@ -25,6 +25,7 @@ func TestNotificationService(t *testing.T) {
evalCtx := NewEvalContext(context.Background(), testRule, &validations.OSSPluginRequestValidator{}, store)
testRuleTemplated := &Rule{Name: "Test latency ${quantile}", Message: "Something is bad on instance ${instance}"}
+
evalCtxWithMatch := NewEvalContext(context.Background(), testRuleTemplated, &validations.OSSPluginRequestValidator{}, store)
evalCtxWithMatch.EvalMatches = []*EvalMatch{{
Tags: map[string]string{
diff --git a/pkg/services/alerting/notifiers/hipchat.go b/pkg/services/alerting/notifiers/hipchat.go
index 7a21903fb6c..78a141cea7e 100644
--- a/pkg/services/alerting/notifiers/hipchat.go
+++ b/pkg/services/alerting/notifiers/hipchat.go
@@ -55,9 +55,7 @@ const (
// for the HipChatNotifier
func NewHipChatNotifier(model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
url := model.Settings.Get("url").MustString()
- if strings.HasSuffix(url, "/") {
- url = url[:len(url)-1]
- }
+ url = strings.TrimSuffix(url, "/")
if url == "" {
return nil, alerting.ValidationError{Reason: "Could not find url property in settings"}
}
diff --git a/pkg/services/alerting/service.go b/pkg/services/alerting/service.go
index 8db32e18728..34e683561fc 100644
--- a/pkg/services/alerting/service.go
+++ b/pkg/services/alerting/service.go
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/encryption"
"github.com/grafana/grafana/pkg/services/notifications"
@@ -13,16 +12,14 @@ import (
)
type AlertNotificationService struct {
- Bus bus.Bus
SQLStore *sqlstore.SQLStore
EncryptionService encryption.Internal
NotificationService *notifications.NotificationService
}
-func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, encryptionService encryption.Internal,
+func ProvideService(store *sqlstore.SQLStore, encryptionService encryption.Internal,
notificationService *notifications.NotificationService) *AlertNotificationService {
s := &AlertNotificationService{
- Bus: bus,
SQLStore: store,
EncryptionService: encryptionService,
NotificationService: notificationService,
diff --git a/pkg/services/alerting/service_test.go b/pkg/services/alerting/service_test.go
index 020003ffacd..edd58a5da30 100644
--- a/pkg/services/alerting/service_test.go
+++ b/pkg/services/alerting/service_test.go
@@ -4,7 +4,6 @@ import (
"context"
"testing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/encryption/ossencryption"
@@ -20,7 +19,7 @@ func TestService(t *testing.T) {
nType := "test"
registerTestNotifier(nType)
- s := ProvideService(bus.New(), sqlStore, ossencryption.ProvideService(), nil)
+ s := ProvideService(sqlStore, ossencryption.ProvideService(), nil)
origSecret := setting.SecretKey
setting.SecretKey = "alert_notification_service_test"
diff --git a/pkg/services/annotations/annotations.go b/pkg/services/annotations/annotations.go
index 9b79c1cdeec..5dea39e5ccc 100644
--- a/pkg/services/annotations/annotations.go
+++ b/pkg/services/annotations/annotations.go
@@ -5,6 +5,7 @@ import (
"errors"
"github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
)
@@ -37,6 +38,7 @@ type ItemQuery struct {
Tags []string `json:"tags"`
Type string `json:"type"`
MatchAny bool `json:"matchAny"`
+ SignedInUser *models.SignedInUser
Limit int64 `json:"limit"`
}
@@ -152,6 +154,17 @@ const (
Dashboard
)
+func (a annotationType) String() string {
+ switch a {
+ case Organization:
+ return "organization"
+ case Dashboard:
+ return "dashboard"
+ default:
+ return ""
+ }
+}
+
func (annotation *ItemDTO) GetType() annotationType {
if annotation.DashboardId != 0 {
return Dashboard
diff --git a/pkg/services/comments/commentmodel/models.go b/pkg/services/comments/commentmodel/models.go
index 64526d124f2..bfaa1713591 100644
--- a/pkg/services/comments/commentmodel/models.go
+++ b/pkg/services/comments/commentmodel/models.go
@@ -56,9 +56,9 @@ func (s Settings) Value() (driver.Value, error) {
func (s *Settings) Scan(value interface{}) error {
switch v := value.(type) {
case string:
- return json.Unmarshal([]byte(v), &s)
+ return json.Unmarshal([]byte(v), s)
case []uint8:
- return json.Unmarshal(v, &s)
+ return json.Unmarshal(v, s)
default:
return fmt.Errorf("type assertion on scan failed: got %T", value)
}
diff --git a/pkg/services/comments/commentmodel/permissions.go b/pkg/services/comments/commentmodel/permissions.go
index bdfed834f44..b1bcbee82d1 100644
--- a/pkg/services/comments/commentmodel/permissions.go
+++ b/pkg/services/comments/commentmodel/permissions.go
@@ -4,21 +4,22 @@ import (
"context"
"strconv"
- "github.com/grafana/grafana/pkg/services/sqlstore"
-
"github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/guardian"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
)
type PermissionChecker struct {
- sqlStore *sqlstore.SQLStore
- features featuremgmt.FeatureToggles
+ sqlStore *sqlstore.SQLStore
+ features featuremgmt.FeatureToggles
+ accessControl accesscontrol.AccessControl
}
-func NewPermissionChecker(sqlStore *sqlstore.SQLStore, features featuremgmt.FeatureToggles) *PermissionChecker {
- return &PermissionChecker{sqlStore: sqlStore, features: features}
+func NewPermissionChecker(sqlStore *sqlstore.SQLStore, features featuremgmt.FeatureToggles, accessControl accesscontrol.AccessControl) *PermissionChecker {
+ return &PermissionChecker{sqlStore: sqlStore, features: features, accessControl: accessControl}
}
func (c *PermissionChecker) getDashboardByUid(ctx context.Context, orgID int64, uid string) (*models.Dashboard, error) {
@@ -62,7 +63,7 @@ func (c *PermissionChecker) CheckReadPermissions(ctx context.Context, orgId int6
if err != nil {
return false, nil
}
- items, err := repo.Find(ctx, &annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgId})
+ items, err := repo.Find(ctx, &annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgId, SignedInUser: signedInUser})
if err != nil || len(items) != 1 {
return false, nil
}
@@ -104,12 +105,18 @@ func (c *PermissionChecker) CheckWritePermissions(ctx context.Context, orgId int
if !c.features.IsEnabled(featuremgmt.FlagAnnotationComments) {
return false, nil
}
+ if c.features.IsEnabled(featuremgmt.FlagAccesscontrol) {
+ evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsWrite, accesscontrol.ScopeAnnotationsTypeDashboard)
+ if canEdit, err := c.accessControl.Evaluate(ctx, signedInUser, evaluator); err != nil || !canEdit {
+ return canEdit, err
+ }
+ }
repo := annotations.GetRepository()
annotationID, err := strconv.ParseInt(objectID, 10, 64)
if err != nil {
return false, nil
}
- items, err := repo.Find(ctx, &annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgId})
+ items, err := repo.Find(ctx, &annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgId, SignedInUser: signedInUser})
if err != nil || len(items) != 1 {
return false, nil
}
diff --git a/pkg/services/comments/service.go b/pkg/services/comments/service.go
index c2797fdaa87..a899af106ef 100644
--- a/pkg/services/comments/service.go
+++ b/pkg/services/comments/service.go
@@ -3,6 +3,7 @@ package comments
import (
"context"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/comments/commentmodel"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/live"
@@ -18,7 +19,7 @@ type Service struct {
permissions *commentmodel.PermissionChecker
}
-func ProvideService(cfg *setting.Cfg, store *sqlstore.SQLStore, live *live.GrafanaLive, features featuremgmt.FeatureToggles) *Service {
+func ProvideService(cfg *setting.Cfg, store *sqlstore.SQLStore, live *live.GrafanaLive, features featuremgmt.FeatureToggles, accessControl accesscontrol.AccessControl) *Service {
s := &Service{
cfg: cfg,
live: live,
@@ -26,7 +27,7 @@ func ProvideService(cfg *setting.Cfg, store *sqlstore.SQLStore, live *live.Grafa
storage: &sqlStorage{
sql: store,
},
- permissions: commentmodel.NewPermissionChecker(store, features),
+ permissions: commentmodel.NewPermissionChecker(store, features, accessControl),
}
return s
}
diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go
index 407b89fa326..d339ea5f09b 100644
--- a/pkg/services/contexthandler/contexthandler.go
+++ b/pkg/services/contexthandler/contexthandler.go
@@ -4,6 +4,7 @@ package contexthandler
import (
"context"
"errors"
+ "net/http"
"net/url"
"strconv"
"strings"
@@ -24,12 +25,12 @@ import (
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
- cw "github.com/weaveworks/common/tracing"
)
const (
InvalidUsernamePassword = "invalid username or password"
- InvalidAPIKey = "invalid API key"
+ /* #nosec */
+ InvalidAPIKey = "invalid API key"
)
const ServiceName = "ContextHandler"
@@ -96,8 +97,8 @@ func (h *ContextHandler) Middleware(mContext *web.Context) {
mContext.Req = mContext.Req.WithContext(context.WithValue(mContext.Req.Context(), reqContextKey{}, reqContext))
mContext.Map(mContext.Req)
- traceID, exists := cw.ExtractTraceID(mContext.Req.Context())
- if exists {
+ traceID := tracing.TraceIDFromContext(mContext.Req.Context(), false)
+ if traceID != "" {
reqContext.Logger = reqContext.Logger.New("traceID", traceID)
}
@@ -254,20 +255,26 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo
//There is a service account attached to the API key
//Use service account linked to API key as the signed in user
- query := models.GetSignedInUserQuery{UserId: *apikey.ServiceAccountId, OrgId: apikey.OrgId}
- if err := h.SQLStore.GetSignedInUserWithCacheCtx(reqContext.Req.Context(), &query); err != nil {
+ querySignedInUser := models.GetSignedInUserQuery{UserId: *apikey.ServiceAccountId, OrgId: apikey.OrgId}
+ if err := h.SQLStore.GetSignedInUserWithCacheCtx(reqContext.Req.Context(), &querySignedInUser); err != nil {
reqContext.Logger.Error(
"Failed to link API key to service account in",
- "id", query.UserId,
- "org", query.OrgId,
+ "id", querySignedInUser.UserId,
+ "org", querySignedInUser.OrgId,
"err", err,
)
- reqContext.JsonApiErr(500, "Unable to link API key to service account", err)
+ reqContext.JsonApiErr(http.StatusInternalServerError, "Unable to link API key to service account", err)
+ return true
+ }
+
+ // disabled service accounts are not allowed to access the API
+ if querySignedInUser.Result.IsDisabled {
+ reqContext.JsonApiErr(http.StatusUnauthorized, "Service account is disabled", nil)
return true
}
reqContext.IsSignedIn = true
- reqContext.SignedInUser = query.Result
+ reqContext.SignedInUser = querySignedInUser.Result
return true
}
diff --git a/pkg/services/dashboardsnapshots/dashboardsnapshots.go b/pkg/services/dashboardsnapshots/dashboardsnapshots.go
index 88745a181ed..51587cfa2dd 100644
--- a/pkg/services/dashboardsnapshots/dashboardsnapshots.go
+++ b/pkg/services/dashboardsnapshots/dashboardsnapshots.go
@@ -3,7 +3,6 @@ package dashboardsnapshots
import (
"context"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/secrets"
@@ -11,14 +10,12 @@ import (
)
type Service struct {
- Bus bus.Bus
SQLStore sqlstore.Store
SecretsService secrets.Service
}
-func ProvideService(bus bus.Bus, store sqlstore.Store, secretsService secrets.Service) *Service {
+func ProvideService(store sqlstore.Store, secretsService secrets.Service) *Service {
s := &Service{
- Bus: bus,
SQLStore: store,
SecretsService: secretsService,
}
diff --git a/pkg/services/datasourceproxy/datasourceproxy.go b/pkg/services/datasourceproxy/datasourceproxy.go
index d0df6af9123..c40c4360e06 100644
--- a/pkg/services/datasourceproxy/datasourceproxy.go
+++ b/pkg/services/datasourceproxy/datasourceproxy.go
@@ -18,6 +18,7 @@ import (
"github.com/grafana/grafana/pkg/services/oauthtoken"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
@@ -59,24 +60,47 @@ func (p *DataSourceProxyService) ProxyDataSourceRequest(c *models.ReqContext) {
p.ProxyDatasourceRequestWithID(c, id)
}
+func (p *DataSourceProxyService) ProxyDatasourceRequestWithUID(c *models.ReqContext) {
+ c.TimeRequest(metrics.MDataSourceProxyReqTimer)
+
+ dsUID := web.Params(c.Req)[":uid"]
+ if !util.IsValidShortUID(dsUID) {
+ c.JsonApiErr(http.StatusBadRequest, "UID is invalid", nil)
+ return
+ }
+
+ ds, err := p.DataSourceCache.GetDatasourceByUID(c.Req.Context(), dsUID, c.SignedInUser, c.SkipCache)
+ if err != nil {
+ toAPIError(c, err)
+ return
+ }
+ p.proxyDatasourceRequest(c, ds)
+}
+
func (p *DataSourceProxyService) ProxyDatasourceRequestWithID(c *models.ReqContext, dsID int64) {
c.TimeRequest(metrics.MDataSourceProxyReqTimer)
ds, err := p.DataSourceCache.GetDatasource(c.Req.Context(), dsID, c.SignedInUser, c.SkipCache)
if err != nil {
- if errors.Is(err, models.ErrDataSourceAccessDenied) {
- c.JsonApiErr(http.StatusForbidden, "Access denied to datasource", err)
- return
- }
- if errors.Is(err, models.ErrDataSourceNotFound) {
- c.JsonApiErr(http.StatusNotFound, "Unable to find datasource", err)
- return
- }
- c.JsonApiErr(http.StatusInternalServerError, "Unable to load datasource meta data", err)
+ toAPIError(c, err)
+ }
+ p.proxyDatasourceRequest(c, ds)
+}
+
+func toAPIError(c *models.ReqContext, err error) {
+ if errors.Is(err, models.ErrDataSourceAccessDenied) {
+ c.JsonApiErr(http.StatusForbidden, "Access denied to datasource", err)
return
}
+ if errors.Is(err, models.ErrDataSourceNotFound) {
+ c.JsonApiErr(http.StatusNotFound, "Unable to find datasource", err)
+ return
+ }
+ c.JsonApiErr(http.StatusInternalServerError, "Unable to load datasource meta data", err)
+}
- err = p.PluginRequestValidator.Validate(ds.Url, c.Req)
+func (p *DataSourceProxyService) proxyDatasourceRequest(c *models.ReqContext, ds *models.DataSource) {
+ err := p.PluginRequestValidator.Validate(ds.Url, c.Req)
if err != nil {
c.JsonApiErr(http.StatusForbidden, "Access denied", err)
return
@@ -103,7 +127,7 @@ func (p *DataSourceProxyService) ProxyDatasourceRequestWithID(c *models.ReqConte
proxy.HandleRequest()
}
-var proxyPathRegexp = regexp.MustCompile(`^\/api\/datasources\/proxy\/[\d]+\/?`)
+var proxyPathRegexp = regexp.MustCompile(`^\/api\/datasources\/proxy\/([\d]+|uid\/[\w]+)\/?`)
func extractProxyPath(originalRawPath string) string {
return proxyPathRegexp.ReplaceAllString(originalRawPath, "")
diff --git a/pkg/services/datasourceproxy/datasourceproxy_test.go b/pkg/services/datasourceproxy/datasourceproxy_test.go
index 4533fbf74d9..6567ba579c5 100644
--- a/pkg/services/datasourceproxy/datasourceproxy_test.go
+++ b/pkg/services/datasourceproxy/datasourceproxy_test.go
@@ -24,6 +24,18 @@ func TestDataProxy(t *testing.T) {
"/api/datasources/proxy/54/api/services/afsd%2Fafsd/operations",
"api/services/afsd%2Fafsd/operations",
},
+ {
+ "/api/datasources/proxy/uid/26MI0wZ7k",
+ "",
+ },
+ {
+ "/api/datasources/proxy/uid/26MI0wZ7k/some/thing",
+ "some/thing",
+ },
+ {
+ "/api/datasources/proxy/uid/26MI0wZ7k/api/services/afsd%2Fafsd/operations",
+ "api/services/afsd%2Fafsd/operations",
+ },
}
for _, tc := range testCases {
t.Run("Given raw path, should extract expected proxy path", func(t *testing.T) {
diff --git a/pkg/services/datasources/service/datasource_service.go b/pkg/services/datasources/service/datasource_service.go
index 623cd5ce048..2585dd34566 100644
--- a/pkg/services/datasources/service/datasource_service.go
+++ b/pkg/services/datasources/service/datasource_service.go
@@ -17,7 +17,6 @@ import (
"github.com/grafana/grafana-azure-sdk-go/azhttpclient"
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/models"
@@ -30,7 +29,6 @@ import (
)
type Service struct {
- Bus bus.Bus
SQLStore *sqlstore.SQLStore
SecretsStore kvstore.SecretsKVStore
cfg *setting.Cfg
@@ -51,11 +49,10 @@ type cachedRoundTripper struct {
}
func ProvideService(
- bus bus.Bus, store *sqlstore.SQLStore, secretsStore kvstore.SecretsKVStore, cfg *setting.Cfg, features featuremgmt.FeatureToggles,
+ store *sqlstore.SQLStore, secretsStore kvstore.SecretsKVStore, cfg *setting.Cfg, features featuremgmt.FeatureToggles,
ac accesscontrol.AccessControl, permissionsServices accesscontrol.PermissionsServices,
) *Service {
s := &Service{
- Bus: bus,
SQLStore: store,
SecretsStore: secretsStore,
ptc: proxyTransportCache{
diff --git a/pkg/services/datasources/service/datasource_service_test.go b/pkg/services/datasources/service/datasource_service_test.go
index 7fb6baaeb5e..d30b575ec51 100644
--- a/pkg/services/datasources/service/datasource_service_test.go
+++ b/pkg/services/datasources/service/datasource_service_test.go
@@ -12,7 +12,6 @@ import (
"github.com/grafana/grafana-azure-sdk-go/azsettings"
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/models"
@@ -193,7 +192,7 @@ func TestService_GetHttpTransport(t *testing.T) {
}
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
rt1, err := dsService.GetHTTPTransport(context.Background(), &ds, provider)
require.NoError(t, err)
@@ -226,7 +225,7 @@ func TestService_GetHttpTransport(t *testing.T) {
json.Set("tlsAuthWithCACert", true)
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
ds := models.DataSource{
Id: 1,
@@ -273,7 +272,7 @@ func TestService_GetHttpTransport(t *testing.T) {
json.Set("tlsAuth", true)
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
ds := models.DataSource{
Id: 1,
@@ -317,7 +316,7 @@ func TestService_GetHttpTransport(t *testing.T) {
json.Set("serverName", "server-name")
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
ds := models.DataSource{
Id: 1,
@@ -358,7 +357,7 @@ func TestService_GetHttpTransport(t *testing.T) {
json.Set("tlsSkipVerify", true)
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
ds := models.DataSource{
Id: 1,
@@ -389,7 +388,7 @@ func TestService_GetHttpTransport(t *testing.T) {
})
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
ds := models.DataSource{
Id: 1,
@@ -454,7 +453,7 @@ func TestService_GetHttpTransport(t *testing.T) {
})
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
ds := models.DataSource{
Id: 1,
@@ -487,7 +486,7 @@ func TestService_GetHttpTransport(t *testing.T) {
require.NoError(t, err)
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
ds := models.DataSource{
Type: models.DS_ES,
@@ -522,7 +521,7 @@ func TestService_getTimeout(t *testing.T) {
}
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
for _, tc := range testCases {
ds := &models.DataSource{
@@ -562,7 +561,7 @@ func TestService_HTTPClientOptions(t *testing.T) {
})
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock())
opts, err := dsService.httpClientOptions(context.Background(), &ds)
require.NoError(t, err)
@@ -579,7 +578,7 @@ func TestService_HTTPClientOptions(t *testing.T) {
})
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock())
opts, err := dsService.httpClientOptions(context.Background(), &ds)
require.NoError(t, err)
@@ -598,7 +597,7 @@ func TestService_HTTPClientOptions(t *testing.T) {
})
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock())
_, err := dsService.httpClientOptions(context.Background(), &ds)
assert.Error(t, err)
@@ -616,7 +615,7 @@ func TestService_HTTPClientOptions(t *testing.T) {
})
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock())
opts, err := dsService.httpClientOptions(context.Background(), &ds)
require.NoError(t, err)
@@ -634,7 +633,7 @@ func TestService_HTTPClientOptions(t *testing.T) {
})
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock())
opts, err := dsService.httpClientOptions(context.Background(), &ds)
require.NoError(t, err)
@@ -656,7 +655,7 @@ func TestService_HTTPClientOptions(t *testing.T) {
})
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, features, acmock.New(), acmock.NewPermissionsServicesMock())
_, err := dsService.httpClientOptions(context.Background(), &ds)
assert.Error(t, err)
@@ -676,7 +675,7 @@ func TestService_HTTPClientOptions(t *testing.T) {
})
secretsStore := kvstore.SetupTestService(t)
- dsService := ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
opts, err := dsService.httpClientOptions(context.Background(), &ds)
require.NoError(t, err)
diff --git a/pkg/services/featuremgmt/manager.go b/pkg/services/featuremgmt/manager.go
index 1b795d0348d..8593e6635cf 100644
--- a/pkg/services/featuremgmt/manager.go
+++ b/pkg/services/featuremgmt/manager.go
@@ -3,6 +3,7 @@ package featuremgmt
import (
"context"
"fmt"
+ "net/http"
"reflect"
"github.com/grafana/grafana/pkg/infra/log"
@@ -157,7 +158,7 @@ func (fm *FeatureManager) HandleGetSettings(c *models.ReqContext) {
res["info"] = vv
- response.JSON(200, res).WriteTo(c)
+ response.JSON(http.StatusOK, res).WriteTo(c)
}
// WithFeatures is used to define feature toggles for testing.
diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go
index 8babbe60046..f14c876b8e4 100644
--- a/pkg/services/libraryelements/api.go
+++ b/pkg/services/libraryelements/api.go
@@ -35,7 +35,7 @@ func (l *LibraryElementService) createHandler(c *models.ReqContext) response.Res
return toLibraryElementError(err, "Failed to create library element")
}
- return response.JSON(200, LibraryElementResponse{Result: element})
+ return response.JSON(http.StatusOK, LibraryElementResponse{Result: element})
}
// deleteHandler handles DELETE /api/library-elements/:uid.
@@ -45,7 +45,7 @@ func (l *LibraryElementService) deleteHandler(c *models.ReqContext) response.Res
return toLibraryElementError(err, "Failed to delete library element")
}
- return response.JSON(200, DeleteLibraryElementResponse{
+ return response.JSON(http.StatusOK, DeleteLibraryElementResponse{
Message: "Library element deleted",
ID: id,
})
@@ -58,7 +58,7 @@ func (l *LibraryElementService) getHandler(c *models.ReqContext) response.Respon
return toLibraryElementError(err, "Failed to get library element")
}
- return response.JSON(200, LibraryElementResponse{Result: element})
+ return response.JSON(http.StatusOK, LibraryElementResponse{Result: element})
}
// getAllHandler handles GET /api/library-elements/.
@@ -78,7 +78,7 @@ func (l *LibraryElementService) getAllHandler(c *models.ReqContext) response.Res
return toLibraryElementError(err, "Failed to get library elements")
}
- return response.JSON(200, LibraryElementSearchResponse{Result: elementsResult})
+ return response.JSON(http.StatusOK, LibraryElementSearchResponse{Result: elementsResult})
}
// patchHandler handles PATCH /api/library-elements/:uid
@@ -93,7 +93,7 @@ func (l *LibraryElementService) patchHandler(c *models.ReqContext) response.Resp
return toLibraryElementError(err, "Failed to update library element")
}
- return response.JSON(200, LibraryElementResponse{Result: element})
+ return response.JSON(http.StatusOK, LibraryElementResponse{Result: element})
}
// getConnectionsHandler handles GET /api/library-panels/:uid/connections/.
@@ -103,7 +103,7 @@ func (l *LibraryElementService) getConnectionsHandler(c *models.ReqContext) resp
return toLibraryElementError(err, "Failed to get connections")
}
- return response.JSON(200, LibraryElementConnectionsResponse{Result: connections})
+ return response.JSON(http.StatusOK, LibraryElementConnectionsResponse{Result: connections})
}
// getByNameHandler handles GET /api/library-elements/name/:name/.
@@ -113,7 +113,7 @@ func (l *LibraryElementService) getByNameHandler(c *models.ReqContext) response.
return toLibraryElementError(err, "Failed to get library element")
}
- return response.JSON(200, LibraryElementArrayResponse{Result: elements})
+ return response.JSON(http.StatusOK, LibraryElementArrayResponse{Result: elements})
}
func toLibraryElementError(err error, message string) response.Response {
diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go
index d03b6453076..1dba33d32b3 100644
--- a/pkg/services/live/live.go
+++ b/pkg/services/live/live.go
@@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"os"
+ "strconv"
"strings"
"sync"
"time"
@@ -16,7 +17,6 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/usagestats"
@@ -24,6 +24,7 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/plugincontext"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/comments/commentmodel"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -70,7 +71,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r
pluginStore plugins.Store, cacheService *localcache.CacheService,
dataSourceCache datasources.CacheService, sqlStore *sqlstore.SQLStore, secretsService secrets.Service,
usageStatsService usagestats.Service, queryDataService *query.Service, toggles featuremgmt.FeatureToggles,
- bus bus.Bus) (*GrafanaLive, error) {
+ accessControl accesscontrol.AccessControl) (*GrafanaLive, error) {
g := &GrafanaLive{
Cfg: cfg,
Features: toggles,
@@ -82,7 +83,6 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r
SQLStore: sqlStore,
SecretsService: secretsService,
queryDataService: queryDataService,
- bus: bus,
channels: make(map[string]models.ChannelHandler),
GrafanaScope: CoreGrafanaScope{
Features: make(map[string]models.ChannelHandlerFactory),
@@ -242,7 +242,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r
g.GrafanaScope.Dashboards = dash
g.GrafanaScope.Features["dashboard"] = dash
g.GrafanaScope.Features["broadcast"] = features.NewBroadcastRunner(g.storage)
- g.GrafanaScope.Features["comment"] = features.NewCommentHandler(commentmodel.NewPermissionChecker(g.SQLStore, g.Features))
+ g.GrafanaScope.Features["comment"] = features.NewCommentHandler(commentmodel.NewPermissionChecker(g.SQLStore, g.Features, accessControl))
g.surveyCaller = survey.NewCaller(managedStreamRunner, node)
err = g.surveyCaller.SetupHandlers()
@@ -407,7 +407,6 @@ type GrafanaLive struct {
SecretsService secrets.Service
pluginStore plugins.Store
queryDataService *query.Service
- bus bus.Bus
node *centrifuge.Node
surveyCaller *survey.Caller
@@ -1043,14 +1042,14 @@ func (g *GrafanaLive) HandleListHTTP(c *models.ReqContext) response.Response {
info := streamChannelListResponse{
Channels: channels,
}
- return response.JSONStreaming(200, info)
+ return response.JSONStreaming(http.StatusOK, info)
}
// HandleInfoHTTP special http response for
func (g *GrafanaLive) HandleInfoHTTP(ctx *models.ReqContext) response.Response {
path := web.Params(ctx.Req)["*"]
if path == "grafana/dashboards/gitops" {
- return response.JSON(200, util.DynMap{
+ return response.JSON(http.StatusOK, util.DynMap{
"active": g.GrafanaScope.Dashboards.HasGitOpsObserver(ctx.SignedInUser.OrgId),
})
}
@@ -1367,6 +1366,11 @@ func handleLog(msg centrifuge.LogEntry) {
func (g *GrafanaLive) sampleLiveStats() {
numClients := g.node.Hub().NumClients()
numUsers := g.node.Hub().NumUsers()
+ numChannels := g.node.Hub().NumChannels()
+ var numNodes int
+ if info, err := g.node.Info(); err == nil {
+ numNodes = len(info.Nodes)
+ }
g.usageStats.sampleCount++
g.usageStats.numClientsSum += numClients
@@ -1376,16 +1380,16 @@ func (g *GrafanaLive) sampleLiveStats() {
g.usageStats.numClientsMax = numClients
}
- if numClients < g.usageStats.numClientsMin {
- g.usageStats.numClientsMin = numClients
- }
-
if numUsers > g.usageStats.numUsersMax {
g.usageStats.numUsersMax = numUsers
}
- if numUsers < g.usageStats.numUsersMin {
- g.usageStats.numUsersMin = numUsers
+ if numNodes > g.usageStats.numNodesMax {
+ g.usageStats.numNodesMax = numNodes
+ }
+
+ if numChannels > g.usageStats.numChannelsMax {
+ g.usageStats.numChannelsMax = numChannels
}
}
@@ -1393,38 +1397,65 @@ func (g *GrafanaLive) resetLiveStats() {
g.usageStats = usageStats{}
}
+func getHistogramMetric(val int, bounds []int, metricPrefix string) string {
+ for _, bound := range bounds {
+ if val <= bound {
+ return metricPrefix + "le_" + strconv.Itoa(bound)
+ }
+ }
+ return metricPrefix + "le_inf"
+}
+
+func (g *GrafanaLive) collectLiveStats(_ context.Context) (map[string]interface{}, error) {
+ liveUsersAvg := 0
+ liveClientsAvg := 0
+
+ if g.usageStats.sampleCount > 0 {
+ liveUsersAvg = g.usageStats.numUsersSum / g.usageStats.sampleCount
+ liveClientsAvg = g.usageStats.numClientsSum / g.usageStats.sampleCount
+ }
+
+ var liveEnabled int
+ if g.Cfg.LiveMaxConnections != 0 {
+ liveEnabled = 1
+ }
+
+ var liveHAEnabled int
+ if g.Cfg.LiveHAEngine != "" {
+ liveHAEnabled = 1
+ }
+
+ metrics := map[string]interface{}{
+ "stats.live_enabled.count": liveEnabled,
+ "stats.live_ha_enabled.count": liveHAEnabled,
+ "stats.live_samples.count": g.usageStats.sampleCount,
+ "stats.live_users_max.count": g.usageStats.numUsersMax,
+ "stats.live_users_avg.count": liveUsersAvg,
+ "stats.live_clients_max.count": g.usageStats.numClientsMax,
+ "stats.live_clients_avg.count": liveClientsAvg,
+ "stats.live_channels_max.count": g.usageStats.numChannelsMax,
+ "stats.live_nodes_max.count": g.usageStats.numNodesMax,
+ }
+
+ metrics[getHistogramMetric(g.usageStats.numClientsMax, []int{0, 10, 100, 1000, 10000, 100000}, "stats.live_clients_")] = 1
+ metrics[getHistogramMetric(g.usageStats.numUsersMax, []int{0, 10, 100, 1000, 10000, 100000}, "stats.live_users_")] = 1
+ metrics[getHistogramMetric(g.usageStats.numChannelsMax, []int{0, 10, 100, 1000, 10000, 100000}, "stats.live_channels_")] = 1
+ metrics[getHistogramMetric(g.usageStats.numNodesMax, []int{1, 3, 9}, "stats.live_nodes_")] = 1
+
+ return metrics, nil
+}
+
func (g *GrafanaLive) registerUsageMetrics() {
g.usageStatsService.RegisterSendReportCallback(g.resetLiveStats)
-
- g.usageStatsService.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) {
- liveUsersAvg := 0
- liveClientsAvg := 0
-
- if g.usageStats.sampleCount > 0 {
- liveUsersAvg = g.usageStats.numUsersSum / g.usageStats.sampleCount
- liveClientsAvg = g.usageStats.numClientsSum / g.usageStats.sampleCount
- }
-
- metrics := map[string]interface{}{
- "stats.live_samples.count": g.usageStats.sampleCount,
- "stats.live_users_max.count": g.usageStats.numUsersMax,
- "stats.live_users_min.count": g.usageStats.numUsersMin,
- "stats.live_users_avg.count": liveUsersAvg,
- "stats.live_clients_max.count": g.usageStats.numClientsMax,
- "stats.live_clients_min.count": g.usageStats.numClientsMin,
- "stats.live_clients_avg.count": liveClientsAvg,
- }
-
- return metrics, nil
- })
+ g.usageStatsService.RegisterMetricsFunc(g.collectLiveStats)
}
type usageStats struct {
- numClientsMax int
- numClientsMin int
- numClientsSum int
- numUsersMax int
- numUsersMin int
- numUsersSum int
- sampleCount int
+ numClientsMax int
+ numClientsSum int
+ numUsersMax int
+ numUsersSum int
+ sampleCount int
+ numNodesMax int
+ numChannelsMax int
}
diff --git a/pkg/services/live/live_test.go b/pkg/services/live/live_test.go
index 9c738c0aa30..d4dff995fcd 100644
--- a/pkg/services/live/live_test.go
+++ b/pkg/services/live/live_test.go
@@ -157,3 +157,45 @@ func TestCheckOrigin(t *testing.T) {
})
}
}
+
+func Test_getHistogramMetric(t *testing.T) {
+ type args struct {
+ val int
+ bounds []int
+ metricPrefix string
+ }
+
+ tests := []struct {
+ name string
+ args args
+ want string
+ }{
+ {
+ "zero",
+ args{0, []int{0, 10, 100, 1000, 10000, 100000}, "live_users_"},
+ "live_users_le_0",
+ },
+ {
+ "equal_to_bound",
+ args{10, []int{0, 10, 100, 1000, 10000, 100000}, "live_users_"},
+ "live_users_le_10",
+ },
+ {
+ "in_the_middle",
+ args{30000, []int{0, 10, 100, 1000, 10000, 100000}, "live_users_"},
+ "live_users_le_100000",
+ },
+ {
+ "more_than_upper_bound",
+ args{300000, []int{0, 10, 100, 1000, 10000, 100000}, "live_users_"},
+ "live_users_le_inf",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := getHistogramMetric(tt.args.val, tt.args.bounds, tt.args.metricPrefix); got != tt.want {
+ t.Errorf("getHistogramMetric() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/pkg/services/login/authinfoservice/database/database.go b/pkg/services/login/authinfoservice/database/database.go
index 2ac7ad076e1..efd4f2cadac 100644
--- a/pkg/services/login/authinfoservice/database/database.go
+++ b/pkg/services/login/authinfoservice/database/database.go
@@ -5,7 +5,6 @@ import (
"encoding/base64"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/secrets"
@@ -16,15 +15,13 @@ var GetTime = time.Now
type AuthInfoStore struct {
sqlStore sqlstore.Store
- bus bus.Bus
secretsService secrets.Service
logger log.Logger
}
-func ProvideAuthInfoStore(sqlStore sqlstore.Store, bus bus.Bus, secretsService secrets.Service) *AuthInfoStore {
+func ProvideAuthInfoStore(sqlStore sqlstore.Store, secretsService secrets.Service) *AuthInfoStore {
store := &AuthInfoStore{
sqlStore: sqlStore,
- bus: bus,
secretsService: secretsService,
logger: log.New("login.authinfo.store"),
}
diff --git a/pkg/services/login/authinfoservice/user_auth_test.go b/pkg/services/login/authinfoservice/user_auth_test.go
index 1795714292a..f719366adb7 100644
--- a/pkg/services/login/authinfoservice/user_auth_test.go
+++ b/pkg/services/login/authinfoservice/user_auth_test.go
@@ -6,7 +6,6 @@ import (
"testing"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/login/authinfoservice/database"
secretstore "github.com/grafana/grafana/pkg/services/secrets/database"
@@ -20,7 +19,7 @@ import (
func TestUserAuth(t *testing.T) {
sqlStore := sqlstore.InitTestDB(t)
secretsService := secretsManager.SetupTestService(t, secretstore.ProvideSecretsStore(sqlStore))
- authInfoStore := database.ProvideAuthInfoStore(sqlStore, bus.New(), secretsService)
+ authInfoStore := database.ProvideAuthInfoStore(sqlStore, secretsService)
srv := ProvideAuthInfoService(&OSSUserProtectionImpl{}, authInfoStore)
t.Run("Given 5 users", func(t *testing.T) {
diff --git a/pkg/services/login/login.go b/pkg/services/login/login.go
index c698ee55dbe..18b5381e552 100644
--- a/pkg/services/login/login.go
+++ b/pkg/services/login/login.go
@@ -11,6 +11,7 @@ var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrUsersQuotaReached = errors.New("users quota reached")
ErrGettingUserQuota = errors.New("error getting user quota")
+ ErrSignupNotAllowed = errors.New("system administrator has disabled signup")
)
type TeamSyncFunc func(user *models.User, externalUser *models.ExternalUserInfo) error
diff --git a/pkg/services/login/loginservice/loginservice.go b/pkg/services/login/loginservice/loginservice.go
index a8f4f30837b..ea9338602b7 100644
--- a/pkg/services/login/loginservice/loginservice.go
+++ b/pkg/services/login/loginservice/loginservice.go
@@ -4,7 +4,6 @@ import (
"context"
"errors"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/login"
@@ -16,10 +15,9 @@ var (
logger = log.New("login.ext_user")
)
-func ProvideService(sqlStore sqlstore.Store, bus bus.Bus, quotaService *quota.QuotaService, authInfoService login.AuthInfoService) *Implementation {
+func ProvideService(sqlStore sqlstore.Store, quotaService *quota.QuotaService, authInfoService login.AuthInfoService) *Implementation {
s := &Implementation{
SQLStore: sqlStore,
- Bus: bus,
QuotaService: quotaService,
AuthInfoService: authInfoService,
}
@@ -28,7 +26,6 @@ func ProvideService(sqlStore sqlstore.Store, bus bus.Bus, quotaService *quota.Qu
type Implementation struct {
SQLStore sqlstore.Store
- Bus bus.Bus
AuthInfoService login.AuthInfoService
QuotaService *quota.QuotaService
TeamSync login.TeamSyncFunc
@@ -56,7 +53,7 @@ func (ls *Implementation) UpsertUser(ctx context.Context, cmd *models.UpsertUser
}
if !cmd.SignupAllowed {
cmd.ReqContext.Logger.Warn("Not allowing login, user not found in internal user database and allow signup = false", "authmode", extUser.AuthModule)
- return login.ErrInvalidCredentials
+ return login.ErrSignupNotAllowed
}
limitReached, err := ls.QuotaService.QuotaReached(cmd.ReqContext, "user")
diff --git a/pkg/services/login/loginservice/loginservice_test.go b/pkg/services/login/loginservice/loginservice_test.go
index 924506e5845..1e687de9e00 100644
--- a/pkg/services/login/loginservice/loginservice_test.go
+++ b/pkg/services/login/loginservice/loginservice_test.go
@@ -7,7 +7,6 @@ import (
"testing"
"github.com/go-kit/log"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log/level"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/login/logintest"
@@ -28,7 +27,6 @@ func Test_syncOrgRoles_doesNotBreakWhenTryingToRemoveLastOrgAdmin(t *testing.T)
}
login := Implementation{
- Bus: bus.New(),
QuotaService: "a.QuotaService{},
AuthInfoService: authInfoMock,
SQLStore: store,
@@ -53,7 +51,6 @@ func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) {
}
login := Implementation{
- Bus: bus.New(),
QuotaService: "a.QuotaService{},
AuthInfoService: authInfoMock,
SQLStore: store,
@@ -67,7 +64,6 @@ func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) {
func Test_teamSync(t *testing.T) {
authInfoMock := &logintest.AuthInfoServiceFake{}
login := Implementation{
- Bus: bus.New(),
QuotaService: "a.QuotaService{},
AuthInfoService: authInfoMock,
}
diff --git a/pkg/services/ngalert/CHANGELOG.md b/pkg/services/ngalert/CHANGELOG.md
index 0b6fff8a661..15d9aadcf60 100644
--- a/pkg/services/ngalert/CHANGELOG.md
+++ b/pkg/services/ngalert/CHANGELOG.md
@@ -46,4 +46,6 @@ Scopes must have an order to ensure consistency and ease of search, this helps u
## Grafana Alerting - main / unreleased
- [CHANGE] Prometheus Compatible API: Use float-like values for `api/prometheus/grafana/api/v1/alerts` and `api/prometheus/grafana/api/v1/rules` instead of the evaluation string #47216
-- [ENHANCEMENT] Templates: Enable the use of classic condition values in templates #46971
\ No newline at end of file
+- [BUGFIX] (Legacy) Templates: Parse notification templates using all the matches of the alert rule when going from `Alerting` to `OK` in legacy alerting #47355
+- [BUGFIX] Scheduler: Fix state manager to support OK option of `AlertRule.ExecErrState` #47670
+- [ENHANCEMENT] Templates: Enable the use of classic condition values in templates #46971
diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go
index 46f62d054db..0b6123b6603 100644
--- a/pkg/services/ngalert/api/api.go
+++ b/pkg/services/ngalert/api/api.go
@@ -77,6 +77,7 @@ type API struct {
SecretsService secrets.Service
AccessControl accesscontrol.AccessControl
Policies *provisioning.NotificationPolicyService
+ ContactPointService *provisioning.ContactPointService
}
// RegisterAPIEndpoints registers API handlers
@@ -90,13 +91,13 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
api.RegisterAlertmanagerApiEndpoints(NewForkedAM(
api.DatasourceCache,
NewLotexAM(proxy, logger),
- &AlertmanagerSrv{store: api.AlertingStore, mam: api.MultiOrgAlertmanager, secrets: api.SecretsService, log: logger, ac: api.AccessControl},
+ &AlertmanagerSrv{crypto: api.MultiOrgAlertmanager.Crypto, log: logger, ac: api.AccessControl, mam: api.MultiOrgAlertmanager},
), m)
// Register endpoints for proxying to Prometheus-compatible backends.
api.RegisterPrometheusApiEndpoints(NewForkedProm(
api.DatasourceCache,
NewLotexProm(proxy, logger),
- &PrometheusSrv{log: logger, manager: api.StateManager, store: api.RuleStore},
+ &PrometheusSrv{log: logger, manager: api.StateManager, store: api.RuleStore, ac: api.AccessControl},
), m)
// Register endpoints for proxying to Cortex Ruler-compatible backends.
api.RegisterRulerApiEndpoints(NewForkedRuler(
@@ -132,8 +133,9 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
if api.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagAlertProvisioning) {
api.RegisterProvisioningApiEndpoints(NewForkedProvisioningApi(&ProvisioningSrv{
- log: logger,
- policies: api.Policies,
+ log: logger,
+ policies: api.Policies,
+ contactPointService: api.ContactPointService,
}), m)
}
}
diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go
index 047a637c32c..ee22a4145ab 100644
--- a/pkg/services/ngalert/api/api_alertmanager.go
+++ b/pkg/services/ngalert/api/api_alertmanager.go
@@ -2,7 +2,6 @@ package api
import (
"context"
- "encoding/base64"
"errors"
"fmt"
"net/http"
@@ -15,10 +14,8 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
- ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
"github.com/grafana/grafana/pkg/services/ngalert/store"
- "github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
@@ -29,11 +26,10 @@ const (
)
type AlertmanagerSrv struct {
- mam *notifier.MultiOrgAlertmanager
- secrets secrets.Service
- store AlertingStore
- log log.Logger
- ac accesscontrol.AccessControl
+ log log.Logger
+ ac accesscontrol.AccessControl
+ mam *notifier.MultiOrgAlertmanager
+ crypto notifier.Crypto
}
type UnknownReceiverError struct {
@@ -44,79 +40,6 @@ func (e UnknownReceiverError) Error() string {
return fmt.Sprintf("unknown receiver: %s", e.UID)
}
-func (srv AlertmanagerSrv) loadSecureSettings(ctx context.Context, orgId int64, receivers []*apimodels.PostableApiReceiver) error {
- // Get the last known working configuration
- query := ngmodels.GetLatestAlertmanagerConfigurationQuery{OrgID: orgId}
- if err := srv.store.GetLatestAlertmanagerConfiguration(ctx, &query); err != nil {
- // If we don't have a configuration there's nothing for us to know and we should just continue saving the new one
- if !errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
- return fmt.Errorf("failed to get latest configuration: %w", err)
- }
- }
-
- currentReceiverMap := make(map[string]*apimodels.PostableGrafanaReceiver)
- if query.Result != nil {
- currentConfig, err := notifier.Load([]byte(query.Result.AlertmanagerConfiguration))
- if err != nil {
- return fmt.Errorf("failed to load latest configuration: %w", err)
- }
- currentReceiverMap = currentConfig.GetGrafanaReceiverMap()
- }
-
- // Copy the previously known secure settings
- for i, r := range receivers {
- for j, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers {
- if gr.UID == "" { // new receiver
- continue
- }
-
- cgmr, ok := currentReceiverMap[gr.UID]
- if !ok {
- // it tries to update a receiver that didn't previously exist
- return UnknownReceiverError{UID: gr.UID}
- }
-
- // frontend sends only the secure settings that have to be updated
- // therefore we have to copy from the last configuration only those secure settings not included in the request
- for key := range cgmr.SecureSettings {
- _, ok := gr.SecureSettings[key]
- if !ok {
- decryptedValue, err := srv.getDecryptedSecret(cgmr, key)
- if err != nil {
- return fmt.Errorf("failed to decrypt stored secure setting: %s: %w", key, err)
- }
-
- if receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings == nil {
- receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings = make(map[string]string, len(cgmr.SecureSettings))
- }
-
- receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings[key] = decryptedValue
- }
- }
- }
- }
- return nil
-}
-
-func (srv AlertmanagerSrv) getDecryptedSecret(r *apimodels.PostableGrafanaReceiver, key string) (string, error) {
- storedValue, ok := r.SecureSettings[key]
- if !ok {
- return "", nil
- }
-
- decodeValue, err := base64.StdEncoding.DecodeString(storedValue)
- if err != nil {
- return "", err
- }
-
- decryptedValue, err := srv.secrets.Decrypt(context.Background(), decodeValue)
- if err != nil {
- return "", err
- }
-
- return string(decryptedValue), nil
-}
-
func (srv AlertmanagerSrv) RouteGetAMStatus(c *models.ReqContext) response.Response {
am, errResp := srv.AlertmanagerFor(c.OrgId)
if errResp != nil {
@@ -190,59 +113,14 @@ func (srv AlertmanagerSrv) RouteDeleteSilence(c *models.ReqContext) response.Res
}
func (srv AlertmanagerSrv) RouteGetAlertingConfig(c *models.ReqContext) response.Response {
- query := ngmodels.GetLatestAlertmanagerConfigurationQuery{OrgID: c.OrgId}
- if err := srv.store.GetLatestAlertmanagerConfiguration(c.Req.Context(), &query); err != nil {
+ config, err := srv.mam.GetAlertmanagerConfiguration(c.Req.Context(), c.OrgId)
+ if err != nil {
if errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
return ErrResp(http.StatusNotFound, err, "")
}
- return ErrResp(http.StatusInternalServerError, err, "failed to get latest configuration")
+ return ErrResp(http.StatusInternalServerError, err, err.Error())
}
-
- cfg, err := notifier.Load([]byte(query.Result.AlertmanagerConfiguration))
- if err != nil {
- return ErrResp(http.StatusInternalServerError, err, "failed to unmarshal alertmanager configuration")
- }
-
- result := apimodels.GettableUserConfig{
- TemplateFiles: cfg.TemplateFiles,
- AlertmanagerConfig: apimodels.GettableApiAlertingConfig{
- Config: cfg.AlertmanagerConfig.Config,
- },
- }
- for _, recv := range cfg.AlertmanagerConfig.Receivers {
- receivers := make([]*apimodels.GettableGrafanaReceiver, 0, len(recv.PostableGrafanaReceivers.GrafanaManagedReceivers))
- for _, pr := range recv.PostableGrafanaReceivers.GrafanaManagedReceivers {
- secureFields := make(map[string]bool, len(pr.SecureSettings))
- for k := range pr.SecureSettings {
- decryptedValue, err := srv.getDecryptedSecret(pr, k)
- if err != nil {
- return ErrResp(http.StatusInternalServerError, err, "failed to decrypt stored secure setting: %s", k)
- }
- if decryptedValue == "" {
- continue
- }
- secureFields[k] = true
- }
- gr := apimodels.GettableGrafanaReceiver{
- UID: pr.UID,
- Name: pr.Name,
- Type: pr.Type,
- DisableResolveMessage: pr.DisableResolveMessage,
- Settings: pr.Settings,
- SecureFields: secureFields,
- }
- receivers = append(receivers, &gr)
- }
- gettableApiReceiver := apimodels.GettableApiReceiver{
- GettableGrafanaReceivers: apimodels.GettableGrafanaReceivers{
- GrafanaManagedReceivers: receivers,
- },
- }
- gettableApiReceiver.Name = recv.Name
- result.AlertmanagerConfig.Receivers = append(result.AlertmanagerConfig.Receivers, &gettableApiReceiver)
- }
-
- return response.JSON(http.StatusOK, result)
+ return response.JSON(http.StatusOK, config)
}
func (srv AlertmanagerSrv) RouteGetAMAlertGroups(c *models.ReqContext) response.Response {
@@ -332,41 +210,26 @@ func (srv AlertmanagerSrv) RouteGetSilences(c *models.ReqContext) response.Respo
}
func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *models.ReqContext, body apimodels.PostableUserConfig) response.Response {
- // Get the last known working configuration
- query := ngmodels.GetLatestAlertmanagerConfigurationQuery{OrgID: c.OrgId}
- if err := srv.store.GetLatestAlertmanagerConfiguration(c.Req.Context(), &query); err != nil {
- // If we don't have a configuration there's nothing for us to know and we should just continue saving the new one
- if !errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
- return ErrResp(http.StatusInternalServerError, err, "failed to get latest configuration")
- }
+ err := srv.mam.ApplyAlertmanagerConfiguration(c.Req.Context(), c.OrgId, body)
+ if err == nil {
+ return response.JSON(http.StatusAccepted, util.DynMap{"message": "configuration created"})
+ }
+ var unknownReceiverError notifier.UnknownReceiverError
+ if errors.As(err, &unknownReceiverError) {
+ return ErrResp(http.StatusBadRequest, unknownReceiverError, "")
+ }
+ var configRejectedError notifier.AlertmanagerConfigRejectedError
+ if errors.As(err, &configRejectedError) {
+ return ErrResp(http.StatusBadRequest, configRejectedError, "")
+ }
+ if errors.Is(err, notifier.ErrNoAlertmanagerForOrg) {
+ return response.Error(http.StatusNotFound, err.Error(), err)
+ }
+ if errors.Is(err, notifier.ErrAlertmanagerNotReady) {
+ return response.Error(http.StatusConflict, err.Error(), err)
}
- if err := srv.loadSecureSettings(c.Req.Context(), c.OrgId, body.AlertmanagerConfig.Receivers); err != nil {
- var unknownReceiverError UnknownReceiverError
- if errors.As(err, &unknownReceiverError) {
- return ErrResp(http.StatusBadRequest, err, "")
- }
- return ErrResp(http.StatusInternalServerError, err, "")
- }
-
- if err := body.ProcessConfig(srv.secrets.Encrypt); err != nil {
- return ErrResp(http.StatusInternalServerError, err, "failed to post process Alertmanager configuration")
- }
-
- am, errResp := srv.AlertmanagerFor(c.OrgId)
- if errResp != nil {
- // It's okay if the alertmanager isn't ready yet, we're changing its config anyway.
- if !errors.Is(errResp.Err(), notifier.ErrAlertmanagerNotReady) {
- return errResp
- }
- }
-
- if err := am.SaveAndApplyConfig(c.Req.Context(), &body); err != nil {
- srv.log.Error("unable to save and apply alertmanager configuration", "err", err)
- return ErrResp(http.StatusBadRequest, err, "failed to save and apply Alertmanager configuration")
- }
-
- return response.JSON(http.StatusAccepted, util.DynMap{"message": "configuration created"})
+ return ErrResp(http.StatusInternalServerError, err, "")
}
func (srv AlertmanagerSrv) RoutePostAMAlerts(_ *models.ReqContext, _ apimodels.PostableAlerts) response.Response {
@@ -374,7 +237,7 @@ func (srv AlertmanagerSrv) RoutePostAMAlerts(_ *models.ReqContext, _ apimodels.P
}
func (srv AlertmanagerSrv) RoutePostTestReceivers(c *models.ReqContext, body apimodels.TestReceiversConfigBodyParams) response.Response {
- if err := srv.loadSecureSettings(c.Req.Context(), c.OrgId, body.Receivers); err != nil {
+ if err := srv.crypto.LoadSecureSettings(c.Req.Context(), c.OrgId, body.Receivers); err != nil {
var unknownReceiverError UnknownReceiverError
if errors.As(err, &unknownReceiverError) {
return ErrResp(http.StatusBadRequest, err, "")
@@ -382,7 +245,7 @@ func (srv AlertmanagerSrv) RoutePostTestReceivers(c *models.ReqContext, body api
return ErrResp(http.StatusInternalServerError, err, "")
}
- if err := body.ProcessConfig(srv.secrets.Encrypt); err != nil {
+ if err := body.ProcessConfig(srv.crypto.Encrypt); err != nil {
return ErrResp(http.StatusInternalServerError, err, "failed to post process Alertmanager configuration")
}
@@ -512,7 +375,6 @@ func (srv AlertmanagerSrv) AlertmanagerFor(orgID int64) (Alertmanager, *response
if errors.Is(err, notifier.ErrNoAlertmanagerForOrg) {
return nil, response.Error(http.StatusNotFound, err.Error(), err)
}
-
if errors.Is(err, notifier.ErrAlertmanagerNotReady) {
return am, response.Error(http.StatusConflict, err.Error(), err)
}
diff --git a/pkg/services/ngalert/api/api_alertmanager_test.go b/pkg/services/ngalert/api/api_alertmanager_test.go
index 13943adc9ed..eec2b513e00 100644
--- a/pkg/services/ngalert/api/api_alertmanager_test.go
+++ b/pkg/services/ngalert/api/api_alertmanager_test.go
@@ -346,15 +346,19 @@ func createSut(t *testing.T, accessControl accesscontrol.AccessControl) Alertman
t.Helper()
mam := createMultiOrgAlertmanager(t)
- store := newFakeAlertingStore(t)
- store.Setup(1)
- store.Setup(2)
- store.Setup(3)
+ configs := map[int64]*ngmodels.AlertConfiguration{
+ 1: {AlertmanagerConfiguration: validConfig, OrgID: 1},
+ 2: {AlertmanagerConfiguration: validConfig, OrgID: 2},
+ 3: {AlertmanagerConfiguration: brokenConfig, OrgID: 3},
+ }
+ configStore := notifier.NewFakeConfigStore(t, configs)
secrets := fakes.NewFakeSecretsService()
if accessControl == nil {
accessControl = acMock.New().WithDisabled()
}
- return AlertmanagerSrv{mam: mam, store: store, secrets: secrets, ac: accessControl}
+ log := log.NewNopLogger()
+ crypto := notifier.NewCrypto(secrets, &configStore, log)
+ return AlertmanagerSrv{mam: mam, crypto: crypto, ac: accessControl, log: log}
}
func createAmConfigRequest(t *testing.T) apimodels.PostableUserConfig {
@@ -392,7 +396,7 @@ func createMultiOrgAlertmanager(t *testing.T) *notifier.MultiOrgAlertmanager {
}, // do not poll in tests.
}
- mam, err := notifier.NewMultiOrgAlertmanager(cfg, &configStore, &orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"))
+ mam, err := notifier.NewMultiOrgAlertmanager(cfg, &configStore, &orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService)
require.NoError(t, err)
err = mam.LoadAndSyncAlertmanagersForOrgs(context.Background())
require.NoError(t, err)
diff --git a/pkg/services/ngalert/api/api_prometheus.go b/pkg/services/ngalert/api/api_prometheus.go
index 37b84ac21bb..c7ee7b93b97 100644
--- a/pkg/services/ngalert/api/api_prometheus.go
+++ b/pkg/services/ngalert/api/api_prometheus.go
@@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
@@ -26,6 +27,7 @@ type PrometheusSrv struct {
log log.Logger
manager state.AlertInstanceManager
store store.RuleStore
+ ac accesscontrol.AccessControl
}
const queryIncludeInternalLabels = "includeInternalLabels"
@@ -101,6 +103,15 @@ func getPanelIDFromRequest(r *http.Request) (int64, error) {
}
func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Response {
+ dashboardUID := c.Query("dashboard_uid")
+ panelID, err := getPanelIDFromRequest(c.Req)
+ if err != nil {
+ return ErrResp(http.StatusBadRequest, err, "invalid panel_id")
+ }
+ if dashboardUID == "" && panelID != 0 {
+ return ErrResp(http.StatusBadRequest, errors.New("panel_id must be set with dashboard_uid"), "")
+ }
+
ruleResponse := apimodels.RuleResponse{
DiscoveryBase: apimodels.DiscoveryBase{
Status: "success",
@@ -130,65 +141,44 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res
namespaceUIDs = append(namespaceUIDs, k)
}
- dashboardUID := c.Query("dashboard_uid")
- panelID, err := getPanelIDFromRequest(c.Req)
- if err != nil {
- return ErrResp(http.StatusBadRequest, err, "invalid panel_id")
- }
- if dashboardUID == "" && panelID != 0 {
- return ErrResp(http.StatusBadRequest, errors.New("panel_id must be set with dashboard_uid"), "")
- }
-
- ruleGroupQuery := ngmodels.ListOrgRuleGroupsQuery{
+ alertRuleQuery := ngmodels.ListAlertRulesQuery{
OrgID: c.SignedInUser.OrgId,
NamespaceUIDs: namespaceUIDs,
DashboardUID: dashboardUID,
PanelID: panelID,
}
- if err := srv.store.GetOrgRuleGroups(c.Req.Context(), &ruleGroupQuery); err != nil {
- ruleResponse.DiscoveryBase.Status = "error"
- ruleResponse.DiscoveryBase.Error = fmt.Sprintf("failure getting rule groups: %s", err.Error())
- ruleResponse.DiscoveryBase.ErrorType = apiv1.ErrServer
- return response.JSON(http.StatusInternalServerError, ruleResponse)
- }
-
- alertRuleQuery := ngmodels.ListAlertRulesQuery{
- OrgID: c.SignedInUser.OrgId,
- DashboardUID: dashboardUID,
- PanelID: panelID,
- }
if err := srv.store.GetOrgAlertRules(c.Req.Context(), &alertRuleQuery); err != nil {
ruleResponse.DiscoveryBase.Status = "error"
ruleResponse.DiscoveryBase.Error = fmt.Sprintf("failure getting rules: %s", err.Error())
ruleResponse.DiscoveryBase.ErrorType = apiv1.ErrServer
return response.JSON(http.StatusInternalServerError, ruleResponse)
}
+ hasAccess := func(evaluator accesscontrol.Evaluator) bool {
+ return accesscontrol.HasAccess(srv.ac, c)(accesscontrol.ReqSignedIn, evaluator)
+ }
groupMap := make(map[string]*apimodels.RuleGroup)
- for _, r := range ruleGroupQuery.Result {
- if len(r) < 3 {
- continue
- }
- groupId, namespaceUID, namespace := r[0], r[1], r[2]
-
- newGroup := &apimodels.RuleGroup{
- Name: groupId,
- File: namespace, // file is what Prometheus uses for provisioning, we replace it with namespace.
- LastEvaluation: time.Time{},
- EvaluationTime: 0, // TODO: see if we are able to pass this along with evaluation results
- }
-
- groupMap[groupId+"-"+namespaceUID] = newGroup
-
- ruleResponse.Data.RuleGroups = append(ruleResponse.Data.RuleGroups, newGroup)
- }
-
for _, rule := range alertRuleQuery.Result {
- newGroup, ok := groupMap[rule.RuleGroup+"-"+rule.NamespaceUID]
- if !ok {
+ if !authorizeDatasourceAccessForRule(rule, hasAccess) {
continue
}
+ groupKey := rule.RuleGroup + "-" + rule.NamespaceUID
+ newGroup, ok := groupMap[groupKey]
+ if !ok {
+ folder := namespaceMap[rule.NamespaceUID]
+ if folder == nil {
+ srv.log.Warn("query returned rules that belong to folder the user does not have access to. The rule will not be added to the response", "folder_uid", rule.NamespaceUID, "rule_uid", rule.UID)
+ continue
+ }
+ newGroup = &apimodels.RuleGroup{
+ Name: rule.RuleGroup,
+ File: folder.Title, // file is what Prometheus uses for provisioning, we replace it with namespace.
+ }
+ groupMap[groupKey] = newGroup
+ ruleResponse.Data.RuleGroups = append(ruleResponse.Data.RuleGroups, newGroup)
+ }
+
alertingRule := apimodels.AlertingRule{
State: "inactive",
Name: rule.Title,
@@ -222,7 +212,6 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res
if alertState.LastEvaluationTime.After(newRule.LastEvaluation) {
newRule.LastEvaluation = alertState.LastEvaluationTime
- newGroup.LastEvaluation = alertState.LastEvaluationTime
}
newRule.EvaluationTime = alertState.EvaluationDuration.Seconds()
@@ -252,7 +241,10 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res
alertingRule.Rule = newRule
newGroup.Rules = append(newGroup.Rules, alertingRule)
newGroup.Interval = float64(rule.IntervalSeconds)
+ newGroup.EvaluationTime = newRule.EvaluationTime
+ newGroup.LastEvaluation = newRule.LastEvaluation
}
+
return response.JSON(http.StatusOK, ruleResponse)
}
diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go
index 74ec3dc9809..5558ee35c30 100644
--- a/pkg/services/ngalert/api/api_prometheus_test.go
+++ b/pkg/services/ngalert/api/api_prometheus_test.go
@@ -4,21 +4,25 @@ import (
"context"
"encoding/json"
"fmt"
+ "math/rand"
"net/http"
"testing"
"time"
+ "github.com/grafana/grafana-plugin-sdk-go/data"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
+ acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
+ apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/state"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
-
- "github.com/grafana/grafana-plugin-sdk-go/data"
- "github.com/stretchr/testify/require"
)
func Test_FormatValues(t *testing.T) {
@@ -83,7 +87,7 @@ func TestRouteGetAlertStatuses(t *testing.T) {
orgID := int64(1)
t.Run("with no alerts", func(t *testing.T) {
- _, _, api := setupAPI(t)
+ _, _, _, api := setupAPI(t)
req, err := http.NewRequest("GET", "/api/v1/alerts", nil)
require.NoError(t, err)
c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &models.SignedInUser{OrgId: orgID}}
@@ -101,7 +105,7 @@ func TestRouteGetAlertStatuses(t *testing.T) {
})
t.Run("with two alerts", func(t *testing.T) {
- _, fakeAIM, api := setupAPI(t)
+ _, fakeAIM, _, api := setupAPI(t)
fakeAIM.GenerateAlertInstances(1, util.GenerateShortUID(), 2)
req, err := http.NewRequest("GET", "/api/v1/alerts", nil)
require.NoError(t, err)
@@ -143,7 +147,7 @@ func TestRouteGetAlertStatuses(t *testing.T) {
})
t.Run("with two firing alerts", func(t *testing.T) {
- _, fakeAIM, api := setupAPI(t)
+ _, fakeAIM, _, api := setupAPI(t)
fakeAIM.GenerateAlertInstances(1, util.GenerateShortUID(), 2, withAlertingState())
req, err := http.NewRequest("GET", "/api/v1/alerts", nil)
require.NoError(t, err)
@@ -185,7 +189,7 @@ func TestRouteGetAlertStatuses(t *testing.T) {
})
t.Run("with the inclusion of internal labels", func(t *testing.T) {
- _, fakeAIM, api := setupAPI(t)
+ _, fakeAIM, _, api := setupAPI(t)
fakeAIM.GenerateAlertInstances(orgID, util.GenerateShortUID(), 2)
req, err := http.NewRequest("GET", "/api/v1/alerts?includeInternalLabels=true", nil)
require.NoError(t, err)
@@ -251,10 +255,10 @@ func TestRouteGetRuleStatuses(t *testing.T) {
req, err := http.NewRequest("GET", "/api/v1/rules", nil)
require.NoError(t, err)
- c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &models.SignedInUser{OrgId: orgID}}
+ c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &models.SignedInUser{OrgId: orgID}, IsSignedIn: true}
t.Run("with no rules", func(t *testing.T) {
- _, _, api := setupAPI(t)
+ _, _, _, api := setupAPI(t)
r := api.RouteGetRuleStatuses(c)
require.JSONEq(t, `
@@ -268,18 +272,19 @@ func TestRouteGetRuleStatuses(t *testing.T) {
})
t.Run("with a rule that only has one query", func(t *testing.T) {
- fakeStore, fakeAIM, api := setupAPI(t)
+ fakeStore, fakeAIM, _, api := setupAPI(t)
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery())
+ folder := fakeStore.Folders[orgID][0]
r := api.RouteGetRuleStatuses(c)
require.Equal(t, http.StatusOK, r.Status())
- require.JSONEq(t, `
+ require.JSONEq(t, fmt.Sprintf(`
{
"status": "success",
"data": {
"groups": [{
"name": "rule-group",
- "file": "namespaceUID",
+ "file": "%s",
"rules": [{
"state": "inactive",
"name": "AlwaysFiring",
@@ -306,30 +311,31 @@ func TestRouteGetRuleStatuses(t *testing.T) {
}],
"interval": 60,
"lastEvaluation": "2022-03-10T14:01:00Z",
- "evaluationTime": 0
+ "evaluationTime": 60
}]
}
}
-`, string(r.Body()))
+`, folder.Title), string(r.Body()))
})
t.Run("with the inclusion of internal Labels", func(t *testing.T) {
- fakeStore, fakeAIM, api := setupAPI(t)
+ fakeStore, fakeAIM, _, api := setupAPI(t)
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery())
+ folder := fakeStore.Folders[orgID][0]
req, err := http.NewRequest("GET", "/api/v1/rules?includeInternalLabels=true", nil)
require.NoError(t, err)
- c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &models.SignedInUser{OrgId: orgID}}
+ c := &models.ReqContext{Context: &web.Context{Req: req}, SignedInUser: &models.SignedInUser{OrgId: orgID}, IsSignedIn: true}
r := api.RouteGetRuleStatuses(c)
require.Equal(t, http.StatusOK, r.Status())
- require.JSONEq(t, `
+ require.JSONEq(t, fmt.Sprintf(`
{
"status": "success",
"data": {
"groups": [{
"name": "rule-group",
- "file": "namespaceUID",
+ "file": "%s",
"rules": [{
"state": "inactive",
"name": "AlwaysFiring",
@@ -359,26 +365,27 @@ func TestRouteGetRuleStatuses(t *testing.T) {
}],
"interval": 60,
"lastEvaluation": "2022-03-10T14:01:00Z",
- "evaluationTime": 0
+ "evaluationTime": 60
}]
}
}
-`, string(r.Body()))
+`, folder.Title), string(r.Body()))
})
t.Run("with a rule that has multiple queries", func(t *testing.T) {
- fakeStore, fakeAIM, api := setupAPI(t)
+ fakeStore, fakeAIM, _, api := setupAPI(t)
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withExpressionsMultiQuery())
+ folder := fakeStore.Folders[orgID][0]
r := api.RouteGetRuleStatuses(c)
require.Equal(t, http.StatusOK, r.Status())
- require.JSONEq(t, `
+ require.JSONEq(t, fmt.Sprintf(`
{
"status": "success",
"data": {
"groups": [{
"name": "rule-group",
- "file": "namespaceUID",
+ "file": "%s",
"rules": [{
"state": "inactive",
"name": "AlwaysFiring",
@@ -405,24 +412,65 @@ func TestRouteGetRuleStatuses(t *testing.T) {
}],
"interval": 60,
"lastEvaluation": "2022-03-10T14:01:00Z",
- "evaluationTime": 0
+ "evaluationTime": 60
}]
}
}
-`, string(r.Body()))
+`, folder.Title), string(r.Body()))
+ })
+
+ t.Run("when fine-grained access is enabled", func(t *testing.T) {
+ t.Run("should return only rules if the user can query all data sources", func(t *testing.T) {
+ ruleStore := store.NewFakeRuleStore(t)
+ fakeAIM := NewFakeAlertInstanceManager(t)
+
+ rules := ngmodels.GenerateAlertRules(rand.Intn(4)+2, ngmodels.AlertRuleGen(withOrgID(orgID)))
+ ruleStore.PutRule(context.Background(), rules...)
+ ruleStore.PutRule(context.Background(), ngmodels.GenerateAlertRules(rand.Intn(4)+2, ngmodels.AlertRuleGen(withOrgID(orgID)))...)
+
+ acMock := acmock.New().WithPermissions(createPermissionsForRules(rules))
+
+ api := PrometheusSrv{
+ log: log.NewNopLogger(),
+ manager: fakeAIM,
+ store: ruleStore,
+ ac: acMock,
+ }
+
+ response := api.RouteGetRuleStatuses(c)
+ require.Equal(t, http.StatusOK, response.Status())
+ result := &apimodels.RuleResponse{}
+ require.NoError(t, json.Unmarshal(response.Body(), result))
+ for _, group := range result.Data.RuleGroups {
+ grouploop:
+ for _, rule := range group.Rules {
+ for i, expected := range rules {
+ if rule.Name == expected.Title && group.Name == expected.RuleGroup {
+ rules = append(rules[:i], rules[i+1:]...)
+ continue grouploop
+ }
+ }
+ assert.Failf(t, "rule %s in a group %s was not found in expected", rule.Name, group.Name)
+ }
+ }
+ assert.Emptyf(t, rules, "not all expected rules were returned")
+ })
})
}
-func setupAPI(t *testing.T) (*store.FakeRuleStore, *fakeAlertInstanceManager, PrometheusSrv) {
+func setupAPI(t *testing.T) (*store.FakeRuleStore, *fakeAlertInstanceManager, *acmock.Mock, PrometheusSrv) {
fakeStore := store.NewFakeRuleStore(t)
fakeAIM := NewFakeAlertInstanceManager(t)
+ acMock := acmock.New().WithDisabled()
+
api := PrometheusSrv{
log: log.NewNopLogger(),
manager: fakeAIM,
store: fakeStore,
+ ac: acMock,
}
- return fakeStore, fakeAIM, api
+ return fakeStore, fakeAIM, acMock, api
}
func generateRuleAndInstanceWithQuery(t *testing.T, orgID int64, fakeAIM *fakeAlertInstanceManager, fakeStore *store.FakeRuleStore, query func(r *ngmodels.AlertRule)) {
diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go
index 679e432107c..2b6e632c58e 100644
--- a/pkg/services/ngalert/api/api_provisioning.go
+++ b/pkg/services/ngalert/api/api_provisioning.go
@@ -9,20 +9,29 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
- domain "github.com/grafana/grafana/pkg/services/ngalert/models"
+ alerting_models "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/util"
+ "github.com/grafana/grafana/pkg/web"
)
type ProvisioningSrv struct {
- log log.Logger
- policies NotificationPolicyService
+ log log.Logger
+ policies NotificationPolicyService
+ contactPointService ContactPointService
+}
+
+type ContactPointService interface {
+ GetContactPoints(ctx context.Context, orgID int64) ([]apimodels.EmbeddedContactPoint, error)
+ CreateContactPoint(ctx context.Context, orgID int64, contactPoint apimodels.EmbeddedContactPoint, p alerting_models.Provenance) (apimodels.EmbeddedContactPoint, error)
+ UpdateContactPoint(ctx context.Context, orgID int64, contactPoint apimodels.EmbeddedContactPoint, p alerting_models.Provenance) error
+ DeleteContactPoint(ctx context.Context, orgID int64, uid string) error
}
type NotificationPolicyService interface {
GetPolicyTree(ctx context.Context, orgID int64) (provisioning.EmbeddedRoutingTree, error)
- UpdatePolicyTree(ctx context.Context, orgID int64, tree apimodels.Route, p domain.Provenance) error
+ UpdatePolicyTree(ctx context.Context, orgID int64, tree apimodels.Route, p alerting_models.Provenance) error
}
func (srv *ProvisioningSrv) RouteGetPolicyTree(c *models.ReqContext) response.Response {
@@ -39,7 +48,7 @@ func (srv *ProvisioningSrv) RouteGetPolicyTree(c *models.ReqContext) response.Re
func (srv *ProvisioningSrv) RoutePostPolicyTree(c *models.ReqContext, tree apimodels.Route) response.Response {
// TODO: lift validation out of definitions.Rotue.UnmarshalJSON and friends into a dedicated validator.
- err := srv.policies.UpdatePolicyTree(c.Req.Context(), c.OrgId, tree, domain.ProvenanceApi)
+ err := srv.policies.UpdatePolicyTree(c.Req.Context(), c.OrgId, tree, alerting_models.ProvenanceAPI)
if errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
return ErrResp(http.StatusNotFound, err, "")
}
@@ -49,3 +58,37 @@ func (srv *ProvisioningSrv) RoutePostPolicyTree(c *models.ReqContext, tree apimo
return response.JSON(http.StatusAccepted, util.DynMap{"message": "policies updated"})
}
+
+func (srv *ProvisioningSrv) RouteGetContactPoints(c *models.ReqContext) response.Response {
+ cps, err := srv.contactPointService.GetContactPoints(c.Req.Context(), c.OrgId)
+ if err != nil {
+ return ErrResp(http.StatusInternalServerError, err, "")
+ }
+ return response.JSON(http.StatusOK, cps)
+}
+
+func (srv *ProvisioningSrv) RoutePostContactPoint(c *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response {
+ // TODO: provenance is hardcoded for now, change it later to make it more flexible
+ contactPoint, err := srv.contactPointService.CreateContactPoint(c.Req.Context(), c.OrgId, cp, alerting_models.ProvenanceAPI)
+ if err != nil {
+ return ErrResp(http.StatusInternalServerError, err, "")
+ }
+ return response.JSON(http.StatusAccepted, contactPoint)
+}
+
+func (srv *ProvisioningSrv) RoutePutContactPoint(c *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response {
+ err := srv.contactPointService.UpdateContactPoint(c.Req.Context(), c.OrgId, cp, alerting_models.ProvenanceAPI)
+ if err != nil {
+ return ErrResp(http.StatusInternalServerError, err, "")
+ }
+ return response.JSON(http.StatusAccepted, util.DynMap{"message": "contactpoint updated"})
+}
+
+func (srv *ProvisioningSrv) RouteDeleteContactPoint(c *models.ReqContext) response.Response {
+ cpID := web.Params(c.Req)[":ID"]
+ err := srv.contactPointService.DeleteContactPoint(c.Req.Context(), c.OrgId, cpID)
+ if err != nil {
+ return ErrResp(http.StatusInternalServerError, err, "")
+ }
+ return response.JSON(http.StatusAccepted, util.DynMap{"message": "contactpoint deleted"})
+}
diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go
index 14852ac0f34..963597e7e9c 100644
--- a/pkg/services/ngalert/api/api_ruler.go
+++ b/pkg/services/ngalert/api/api_ruler.go
@@ -128,17 +128,25 @@ func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *models.ReqContext) response.
return toNamespaceErrorResponse(err)
}
- q := ngmodels.ListNamespaceAlertRulesQuery{
+ q := ngmodels.GetAlertRulesQuery{
OrgID: c.SignedInUser.OrgId,
NamespaceUID: namespace.Uid,
}
- if err := srv.store.GetNamespaceAlertRules(c.Req.Context(), &q); err != nil {
+ if err := srv.store.GetAlertRules(c.Req.Context(), &q); err != nil {
return ErrResp(http.StatusInternalServerError, err, "failed to update rule group")
}
result := apimodels.NamespaceConfigResponse{}
ruleGroupConfigs := make(map[string]apimodels.GettableRuleGroupConfig)
+
+ hasAccess := func(evaluator accesscontrol.Evaluator) bool {
+ return accesscontrol.HasAccess(srv.ac, c)(accesscontrol.ReqSignedIn, evaluator)
+ }
+
for _, r := range q.Result {
+ if !authorizeDatasourceAccessForRule(r, hasAccess) {
+ continue
+ }
ruleGroupConfig, ok := ruleGroupConfigs[r.RuleGroup]
if !ok {
ruleGroupInterval := model.Duration(time.Duration(r.IntervalSeconds) * time.Second)
@@ -181,7 +189,15 @@ func (srv RulerSrv) RouteGetRulegGroupConfig(c *models.ReqContext) response.Resp
var ruleGroupInterval model.Duration
ruleNodes := make([]apimodels.GettableExtendedRuleNode, 0, len(q.Result))
+
+ hasAccess := func(evaluator accesscontrol.Evaluator) bool {
+ return accesscontrol.HasAccess(srv.ac, c)(accesscontrol.ReqSignedIn, evaluator)
+ }
+
for _, r := range q.Result {
+ if !authorizeDatasourceAccessForRule(r, hasAccess) {
+ continue
+ }
ruleGroupInterval = model.Duration(time.Duration(r.IntervalSeconds) * time.Second)
ruleNodes = append(ruleNodes, toGettableExtendedRuleNode(*r, namespace.Id))
}
@@ -234,7 +250,15 @@ func (srv RulerSrv) RouteGetRulesConfig(c *models.ReqContext) response.Response
}
configs := make(map[string]map[string]apimodels.GettableRuleGroupConfig)
+
+ hasAccess := func(evaluator accesscontrol.Evaluator) bool {
+ return accesscontrol.HasAccess(srv.ac, c)(accesscontrol.ReqSignedIn, evaluator)
+ }
+
for _, r := range q.Result {
+ if !authorizeDatasourceAccessForRule(r, hasAccess) {
+ continue
+ }
folder, ok := namespaceMap[r.NamespaceUID]
if !ok {
srv.log.Error("namespace not visible to the user", "user", c.SignedInUser.UserId, "namespace", r.NamespaceUID, "rule", r.UID)
@@ -331,23 +355,25 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, namespace *mod
logger.Debug("updating database with the authorized changes", "add", len(authorizedChanges.New), "update", len(authorizedChanges.New), "delete", len(authorizedChanges.Delete))
if len(authorizedChanges.Update) > 0 || len(authorizedChanges.New) > 0 {
- upsert := make([]store.UpsertRule, 0, len(authorizedChanges.Update)+len(authorizedChanges.New))
+ updates := make([]store.UpdateRule, 0, len(authorizedChanges.Update))
+ inserts := make([]ngmodels.AlertRule, 0, len(authorizedChanges.New))
for _, update := range authorizedChanges.Update {
logger.Debug("updating rule", "rule_uid", update.New.UID, "diff", update.Diff.String())
- upsert = append(upsert, store.UpsertRule{
+ updates = append(updates, store.UpdateRule{
Existing: update.Existing,
New: *update.New,
})
}
for _, rule := range authorizedChanges.New {
- upsert = append(upsert, store.UpsertRule{
- Existing: nil,
- New: *rule,
- })
+ inserts = append(inserts, *rule)
}
- err = srv.store.UpsertAlertRules(tranCtx, upsert)
+ err = srv.store.InsertAlertRules(tranCtx, inserts)
if err != nil {
- return fmt.Errorf("failed to add or update rules: %w", err)
+ return fmt.Errorf("failed to add rules: %w", err)
+ }
+ err = srv.store.UpdateAlertRules(tranCtx, updates)
+ if err != nil {
+ return fmt.Errorf("failed to update rules: %w", err)
}
}
diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go
index a23afee1774..9a40a93dfbd 100644
--- a/pkg/services/ngalert/api/api_ruler_test.go
+++ b/pkg/services/ngalert/api/api_ruler_test.go
@@ -2,12 +2,14 @@ package api
import (
"context"
+ "encoding/json"
"errors"
"math/rand"
"net/http"
"testing"
"time"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
@@ -16,6 +18,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
acMock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/datasources"
+ apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/schedule"
"github.com/grafana/grafana/pkg/services/ngalert/store"
@@ -264,19 +267,6 @@ func TestCalculateChanges(t *testing.T) {
}
func TestRouteDeleteAlertRules(t *testing.T) {
- createService := func(ac *acMock.Mock, store *store.FakeRuleStore, scheduler schedule.ScheduleService) *RulerSrv {
- return &RulerSrv{
- xactManager: store,
- store: store,
- DatasourceCache: nil,
- QuotaService: nil,
- scheduleService: scheduler,
- log: log.New("test"),
- cfg: nil,
- ac: ac,
- }
- }
-
getRecordedCommand := func(ruleStore *store.FakeRuleStore) []store.GenericRecordedQuery {
results := ruleStore.GetRecordedCommands(func(cmd interface{}) (interface{}, bool) {
c, ok := cmd.(store.GenericRecordedQuery)
@@ -421,15 +411,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
scheduler := &schedule.FakeScheduleService{}
scheduler.On("DeleteAlertRule", mock.Anything)
- var permissions []*accesscontrol.Permission
- for _, rule := range rulesInFolder {
- for _, query := range rule.Data {
- permissions = append(permissions, &accesscontrol.Permission{
- Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID),
- })
- }
- }
- ac := acMock.New().WithPermissions(permissions)
+ ac := acMock.New().WithPermissions(createPermissionsForRules(rulesInFolder))
request := createRequestContext(orgID, "None", map[string]string{
":Namespace": folder.Title,
})
@@ -454,15 +436,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
scheduler := &schedule.FakeScheduleService{}
scheduler.On("DeleteAlertRule", mock.Anything)
- var permissions []*accesscontrol.Permission
- for _, rule := range authorizedRulesInFolder {
- for _, query := range rule.Data {
- permissions = append(permissions, &accesscontrol.Permission{
- Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID),
- })
- }
- }
- ac := acMock.New().WithPermissions(permissions)
+ ac := acMock.New().WithPermissions(createPermissionsForRules(authorizedRulesInFolder))
request := createRequestContext(orgID, "None", map[string]string{
":Namespace": folder.Title,
})
@@ -489,15 +463,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
scheduler := &schedule.FakeScheduleService{}
scheduler.On("DeleteAlertRule", mock.Anything)
- var permissions []*accesscontrol.Permission
- for _, rule := range authorizedRulesInGroup {
- for _, query := range rule.Data {
- permissions = append(permissions, &accesscontrol.Permission{
- Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID),
- })
- }
- }
- ac := acMock.New().WithPermissions(permissions)
+ ac := acMock.New().WithPermissions(createPermissionsForRules(authorizedRulesInGroup))
request := createRequestContext(orgID, "None", map[string]string{
":Namespace": folder.Title,
":Groupname": groupName,
@@ -510,11 +476,101 @@ func TestRouteDeleteAlertRules(t *testing.T) {
})
}
+func TestRouteGetNamespaceRulesConfig(t *testing.T) {
+ t.Run("fine-grained access is enabled", func(t *testing.T) {
+ t.Run("should return rules for which user has access to data source", func(t *testing.T) {
+ orgID := rand.Int63()
+ folder := randFolder()
+ ruleStore := store.NewFakeRuleStore(t)
+ ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder)
+ expectedRules := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))
+ ruleStore.PutRule(context.Background(), expectedRules...)
+ ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...)
+ ac := acMock.New().WithPermissions(createPermissionsForRules(expectedRules))
+
+ response := createService(ac, ruleStore, nil).RouteGetNamespaceRulesConfig(createRequestContext(orgID, "", map[string]string{
+ ":Namespace": folder.Title,
+ }))
+
+ require.Equal(t, http.StatusAccepted, response.Status())
+ result := &apimodels.NamespaceConfigResponse{}
+ require.NoError(t, json.Unmarshal(response.Body(), result))
+ require.NotNil(t, result)
+ for namespace, groups := range *result {
+ require.Equal(t, folder.Title, namespace)
+ for _, group := range groups {
+ grouploop:
+ for _, actualRule := range group.Rules {
+ for i, expected := range expectedRules {
+ if actualRule.GrafanaManagedAlert.UID == expected.UID {
+ expectedRules = append(expectedRules[:i], expectedRules[i+1:]...)
+ continue grouploop
+ }
+ }
+ assert.Failf(t, "rule in a group was not found in expected", "rule %s group %s", actualRule.GrafanaManagedAlert.Title, group.Name)
+ }
+ }
+ }
+ assert.Emptyf(t, expectedRules, "not all expected rules were returned")
+ })
+ })
+ t.Run("fine-grained access is disabled", func(t *testing.T) {
+ t.Run("should return all rules from folder", func(t *testing.T) {
+ orgID := rand.Int63()
+ folder := randFolder()
+ ruleStore := store.NewFakeRuleStore(t)
+ ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder)
+ expectedRules := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))
+ ruleStore.PutRule(context.Background(), expectedRules...)
+ ac := acMock.New().WithDisabled()
+
+ response := createService(ac, ruleStore, nil).RouteGetNamespaceRulesConfig(createRequestContext(orgID, "", map[string]string{
+ ":Namespace": folder.Title,
+ }))
+
+ require.Equal(t, http.StatusAccepted, response.Status())
+ result := &apimodels.NamespaceConfigResponse{}
+ require.NoError(t, json.Unmarshal(response.Body(), result))
+ require.NotNil(t, result)
+ for namespace, groups := range *result {
+ require.Equal(t, folder.Title, namespace)
+ for _, group := range groups {
+ grouploop:
+ for _, actualRule := range group.Rules {
+ for i, expected := range expectedRules {
+ if actualRule.GrafanaManagedAlert.UID == expected.UID {
+ expectedRules = append(expectedRules[:i], expectedRules[i+1:]...)
+ continue grouploop
+ }
+ }
+ assert.Failf(t, "rule in a group was not found in expected", "rule %s group %s", actualRule.GrafanaManagedAlert.Title, group.Name)
+ }
+ }
+ }
+ assert.Emptyf(t, expectedRules, "not all expected rules were returned")
+ })
+ })
+}
+
+func createService(ac *acMock.Mock, store *store.FakeRuleStore, scheduler schedule.ScheduleService) *RulerSrv {
+ return &RulerSrv{
+ xactManager: store,
+ store: store,
+ DatasourceCache: nil,
+ QuotaService: nil,
+ scheduleService: scheduler,
+ log: log.New("test"),
+ cfg: nil,
+ ac: ac,
+ }
+}
+
func createRequestContext(orgID int64, role models2.RoleType, params map[string]string) *models2.ReqContext {
ctx := web.Context{Req: &http.Request{}}
ctx.Req = web.SetURLParams(ctx.Req, params)
return &models2.ReqContext{
+ IsSignedIn: true,
SignedInUser: &models2.SignedInUser{
OrgRole: role,
OrgId: orgID,
@@ -523,6 +579,18 @@ func createRequestContext(orgID int64, role models2.RoleType, params map[string]
}
}
+func createPermissionsForRules(rules []*models.AlertRule) []*accesscontrol.Permission {
+ var permissions []*accesscontrol.Permission
+ for _, rule := range rules {
+ for _, query := range rule.Data {
+ permissions = append(permissions, &accesscontrol.Permission{
+ Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID),
+ })
+ }
+ }
+ return permissions
+}
+
func withOrgID(orgId int64) func(rule *models.AlertRule) {
return func(rule *models.AlertRule) {
rule.OrgID = orgId
diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go
index 317c7b21037..6394ec50ac3 100644
--- a/pkg/services/ngalert/api/authorization.go
+++ b/pkg/services/ngalert/api/authorization.go
@@ -181,10 +181,14 @@ func (api *API) authorize(method, path string) web.Handler {
return middleware.ReqOrgAdmin
// Grafana-only Provisioning Read Paths
- case http.MethodGet + "/api/provisioning/policies":
+ case http.MethodGet + "/api/provisioning/policies",
+ http.MethodGet + "/api/provisioning/contact-points":
return middleware.ReqSignedIn
- case http.MethodPost + "/api/provisioning/policies":
+ case http.MethodPost + "/api/provisioning/policies",
+ http.MethodPost + "/api/provisioning/contact-points",
+ http.MethodPut + "/api/provisioning/contact-points",
+ http.MethodDelete + "/api/provisioning/contact-points/{ID}":
return middleware.ReqEditorRole
}
diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go
index b39597a9fb8..0f6579760e6 100644
--- a/pkg/services/ngalert/api/authorization_test.go
+++ b/pkg/services/ngalert/api/authorization_test.go
@@ -47,7 +47,7 @@ func TestAuthorize(t *testing.T) {
}
paths[p] = methods
}
- require.Len(t, paths, 30)
+ require.Len(t, paths, 32)
ac := acmock.New()
api := &API{AccessControl: ac}
diff --git a/pkg/services/ngalert/api/forked_provisioning.go b/pkg/services/ngalert/api/forked_provisioning.go
index 74213e3c783..a808d9eca3e 100644
--- a/pkg/services/ngalert/api/forked_provisioning.go
+++ b/pkg/services/ngalert/api/forked_provisioning.go
@@ -26,3 +26,19 @@ func (f *ForkedProvisioningApi) forkRouteGetPolicyTree(ctx *models.ReqContext) r
func (f *ForkedProvisioningApi) forkRoutePostPolicyTree(ctx *models.ReqContext, route apimodels.Route) response.Response {
return f.svc.RoutePostPolicyTree(ctx, route)
}
+
+func (f *ForkedProvisioningApi) forkRouteGetContactpoints(ctx *models.ReqContext) response.Response {
+ return f.svc.RouteGetContactPoints(ctx)
+}
+
+func (f *ForkedProvisioningApi) forkRoutePostContactpoints(ctx *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response {
+ return f.svc.RoutePostContactPoint(ctx, cp)
+}
+
+func (f *ForkedProvisioningApi) forkRoutePutContactpoints(ctx *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response {
+ return f.svc.RoutePutContactPoint(ctx, cp)
+}
+
+func (f *ForkedProvisioningApi) forkRouteDeleteContactpoints(ctx *models.ReqContext) response.Response {
+ return f.svc.RouteDeleteContactPoint(ctx)
+}
diff --git a/pkg/services/ngalert/api/generated_base_api_provisioning.go b/pkg/services/ngalert/api/generated_base_api_provisioning.go
index 2d5e72ae571..f310cdaddd5 100644
--- a/pkg/services/ngalert/api/generated_base_api_provisioning.go
+++ b/pkg/services/ngalert/api/generated_base_api_provisioning.go
@@ -20,14 +20,34 @@ import (
)
type ProvisioningApiForkingService interface {
+ RouteDeleteContactpoints(*models.ReqContext) response.Response
+ RouteGetContactpoints(*models.ReqContext) response.Response
RouteGetPolicyTree(*models.ReqContext) response.Response
+ RoutePostContactpoints(*models.ReqContext) response.Response
RoutePostPolicyTree(*models.ReqContext) response.Response
+ RoutePutContactpoints(*models.ReqContext) response.Response
+}
+
+func (f *ForkedProvisioningApi) RouteDeleteContactpoints(ctx *models.ReqContext) response.Response {
+ return f.forkRouteDeleteContactpoints(ctx)
+}
+
+func (f *ForkedProvisioningApi) RouteGetContactpoints(ctx *models.ReqContext) response.Response {
+ return f.forkRouteGetContactpoints(ctx)
}
func (f *ForkedProvisioningApi) RouteGetPolicyTree(ctx *models.ReqContext) response.Response {
return f.forkRouteGetPolicyTree(ctx)
}
+func (f *ForkedProvisioningApi) RoutePostContactpoints(ctx *models.ReqContext) response.Response {
+ conf := apimodels.EmbeddedContactPoint{}
+ if err := web.Bind(ctx.Req, &conf); err != nil {
+ return response.Error(http.StatusBadRequest, "bad request data", err)
+ }
+ return f.forkRoutePostContactpoints(ctx, conf)
+}
+
func (f *ForkedProvisioningApi) RoutePostPolicyTree(ctx *models.ReqContext) response.Response {
conf := apimodels.Route{}
if err := web.Bind(ctx.Req, &conf); err != nil {
@@ -36,8 +56,36 @@ func (f *ForkedProvisioningApi) RoutePostPolicyTree(ctx *models.ReqContext) resp
return f.forkRoutePostPolicyTree(ctx, conf)
}
+func (f *ForkedProvisioningApi) RoutePutContactpoints(ctx *models.ReqContext) response.Response {
+ conf := apimodels.EmbeddedContactPoint{}
+ if err := web.Bind(ctx.Req, &conf); err != nil {
+ return response.Error(http.StatusBadRequest, "bad request data", err)
+ }
+ return f.forkRoutePutContactpoints(ctx, conf)
+}
+
func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingService, m *metrics.API) {
api.RouteRegister.Group("", func(group routing.RouteRegister) {
+ group.Delete(
+ toMacaronPath("/api/provisioning/contact-points/{ID}"),
+ api.authorize(http.MethodDelete, "/api/provisioning/contact-points/{ID}"),
+ metrics.Instrument(
+ http.MethodDelete,
+ "/api/provisioning/contact-points/{ID}",
+ srv.RouteDeleteContactpoints,
+ m,
+ ),
+ )
+ group.Get(
+ toMacaronPath("/api/provisioning/contact-points"),
+ api.authorize(http.MethodGet, "/api/provisioning/contact-points"),
+ metrics.Instrument(
+ http.MethodGet,
+ "/api/provisioning/contact-points",
+ srv.RouteGetContactpoints,
+ m,
+ ),
+ )
group.Get(
toMacaronPath("/api/provisioning/policies"),
api.authorize(http.MethodGet, "/api/provisioning/policies"),
@@ -48,6 +96,16 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi
m,
),
)
+ group.Post(
+ toMacaronPath("/api/provisioning/contact-points"),
+ api.authorize(http.MethodPost, "/api/provisioning/contact-points"),
+ metrics.Instrument(
+ http.MethodPost,
+ "/api/provisioning/contact-points",
+ srv.RoutePostContactpoints,
+ m,
+ ),
+ )
group.Post(
toMacaronPath("/api/provisioning/policies"),
api.authorize(http.MethodPost, "/api/provisioning/policies"),
@@ -58,5 +116,15 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi
m,
),
)
+ group.Put(
+ toMacaronPath("/api/provisioning/contact-points"),
+ api.authorize(http.MethodPut, "/api/provisioning/contact-points"),
+ metrics.Instrument(
+ http.MethodPut,
+ "/api/provisioning/contact-points",
+ srv.RoutePutContactpoints,
+ m,
+ ),
+ )
}, middleware.ReqSignedIn)
}
diff --git a/pkg/services/ngalert/api/testing.go b/pkg/services/ngalert/api/testing.go
index 26344d66b64..68504135781 100644
--- a/pkg/services/ngalert/api/testing.go
+++ b/pkg/services/ngalert/api/testing.go
@@ -1,43 +1,17 @@
package api
import (
- "context"
"fmt"
"sync"
"testing"
"time"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
- "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/state"
- "github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana-plugin-sdk-go/data"
)
-type FakeAlertingStore struct {
- orgsWithConfig map[int64]bool
-}
-
-func newFakeAlertingStore(t *testing.T) FakeAlertingStore {
- t.Helper()
-
- return FakeAlertingStore{
- orgsWithConfig: map[int64]bool{},
- }
-}
-
-func (f FakeAlertingStore) Setup(orgID int64) {
- f.orgsWithConfig[orgID] = true
-}
-
-func (f FakeAlertingStore) GetLatestAlertmanagerConfiguration(_ context.Context, query *models.GetLatestAlertmanagerConfigurationQuery) error {
- if _, ok := f.orgsWithConfig[query.OrgID]; ok {
- return nil
- }
- return store.ErrNoAlertmanagerConfiguration
-}
-
type fakeAlertInstanceManager struct {
mtx sync.Mutex
// orgID -> RuleID -> States
diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go
index 94eb137d7b7..ad75f5215b3 100644
--- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go
+++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go
@@ -18,6 +18,7 @@ import (
"gopkg.in/yaml.v3"
"github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/util"
)
@@ -712,6 +713,8 @@ type Route struct {
GroupWait *model.Duration `yaml:"group_wait,omitempty" json:"group_wait,omitempty"`
GroupInterval *model.Duration `yaml:"group_interval,omitempty" json:"group_interval,omitempty"`
RepeatInterval *model.Duration `yaml:"repeat_interval,omitempty" json:"repeat_interval,omitempty"`
+
+ Provenance models.Provenance `yaml:"provenance,omitempty" json:"provenance,omitempty"`
}
// UnmarshalYAML implements the yaml.Unmarshaler interface for Route. This is a copy of alertmanager's upstream except it removes validation on the label key.
diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go
new file mode 100644
index 00000000000..3d86af76928
--- /dev/null
+++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go
@@ -0,0 +1,157 @@
+package definitions
+
+import (
+ "fmt"
+
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
+)
+
+// swagger:route GET /api/provisioning/contact-points provisioning RouteGetContactpoints
+//
+// Get all the contact points.
+//
+// Responses:
+// 200: Route
+// 400: ValidationError
+
+// swagger:route POST /api/provisioning/contact-points provisioning RoutePostContactpoints
+//
+// Create a contact point.
+//
+// Consumes:
+// - application/json
+//
+// Responses:
+// 202: Accepted
+// 400: ValidationError
+
+// swagger:route PUT /api/provisioning/contact-points provisioning RoutePutContactpoints
+//
+// Update an existing contact point.
+//
+// Consumes:
+// - application/json
+//
+// Responses:
+// 202: Accepted
+// 400: ValidationError
+
+// swagger:route DELETE /api/provisioning/contact-points/{ID} provisioning RouteDeleteContactpoints
+//
+// Delete a contact point.
+//
+// Consumes:
+// - application/json
+//
+// Responses:
+// 202: Accepted
+// 400: ValidationError
+
+// swagger:parameters RoutePostContactpoints RoutePutContactpoints
+type ContactPointPayload struct {
+ // in:body
+ Body EmbeddedContactPoint
+}
+
+// EmbeddedContactPoint is the contact point type that is used
+// by grafanas embedded alertmanager implementation.
+type EmbeddedContactPoint struct {
+ // UID is the unique identifier of the contact point. This will be
+ // automatically set be the Grafana.
+ UID string `json:"uid"`
+ // Name is used as grouping key in the UI. Contact points with the
+ // same name will be grouped in the UI.
+ Name string `json:"name" binding:"required"`
+ Type string `json:"type" binding:"required"`
+ Settings *simplejson.Json `json:"settings" binding:"required"`
+ DisableResolveMessage bool `json:"disableResolveMessage"`
+ Provenance string `json:"provanance"`
+}
+
+const RedactedValue = "[REDACTED]"
+
+func (e *EmbeddedContactPoint) Valid(decryptFunc channels.GetDecryptedValueFn) error {
+ if e.Type == "" {
+ return fmt.Errorf("type should not be an empty string")
+ }
+ if e.Settings == nil {
+ return fmt.Errorf("settings should not be empty")
+ }
+ factory, exists := channels.Factory(e.Type)
+ if !exists {
+ return fmt.Errorf("unknown type '%s'", e.Type)
+ }
+ cfg, _ := channels.NewFactoryConfig(&channels.NotificationChannelConfig{
+ Settings: e.Settings,
+ Type: e.Type,
+ }, nil, decryptFunc, nil)
+ if _, err := factory(cfg); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (e *EmbeddedContactPoint) SecretKeys() ([]string, error) {
+ switch e.Type {
+ case "alertmanager":
+ return []string{"basicAuthPassword"}, nil
+ case "dingding":
+ return []string{}, nil
+ case "discord":
+ return []string{}, nil
+ case "email":
+ return []string{}, nil
+ case "googlechat":
+ return []string{}, nil
+ case "kafka":
+ return []string{}, nil
+ case "line":
+ return []string{"token"}, nil
+ case "opsgenie":
+ return []string{"apiKey"}, nil
+ case "pagerduty":
+ return []string{"integrationKey"}, nil
+ case "pushover":
+ return []string{"userKey", "apiToken"}, nil
+ case "sensugo":
+ return []string{"apiKey"}, nil
+ case "slack":
+ return []string{"url", "token"}, nil
+ case "teams":
+ return []string{}, nil
+ case "telegram":
+ return []string{"bottoken"}, nil
+ case "threema":
+ return []string{"api_secret"}, nil
+ case "victorops":
+ return []string{}, nil
+ case "webhook":
+ return []string{}, nil
+ case "wecom":
+ return []string{"url"}, nil
+ }
+ return nil, fmt.Errorf("no secrets configured for type '%s'", e.Type)
+}
+
+func (e *EmbeddedContactPoint) ExtractSecrets() (map[string]string, error) {
+ secrets := map[string]string{}
+ secretKeys, err := e.SecretKeys()
+ if err != nil {
+ return nil, err
+ }
+ for _, secretKey := range secretKeys {
+ secretValue := e.Settings.Get(secretKey).MustString()
+ e.Settings.Del(secretKey)
+ secrets[secretKey] = secretValue
+ }
+ return secrets, nil
+}
+
+func (e *EmbeddedContactPoint) ResourceID() string {
+ return e.UID
+}
+
+func (e *EmbeddedContactPoint) ResourceType() string {
+ return "contactPoint"
+}
diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_policies.go
similarity index 100%
rename from pkg/services/ngalert/api/tooling/definitions/provisioning.go
rename to pkg/services/ngalert/api/tooling/definitions/provisioning_policies.go
diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json
index dd87745df0d..f9373171bf5 100644
--- a/pkg/services/ngalert/api/tooling/post.json
+++ b/pkg/services/ngalert/api/tooling/post.json
@@ -558,6 +558,36 @@
"type": "object",
"x-go-package": "github.com/prometheus/alertmanager/config"
},
+ "EmbeddedContactPoint": {
+ "description": "EmbeddedContactPoint is the contact point type that is used\nby grafanas embedded alertmanager implementation.",
+ "properties": {
+ "disableResolveMessage": {
+ "type": "boolean",
+ "x-go-name": "DisableResolveMessage"
+ },
+ "name": {
+ "type": "string",
+ "x-go-name": "Name"
+ },
+ "provanance": {
+ "type": "string",
+ "x-go-name": "Provenance"
+ },
+ "settings": {
+ "$ref": "#/definitions/Json"
+ },
+ "type": {
+ "type": "string",
+ "x-go-name": "Type"
+ },
+ "uid": {
+ "type": "string",
+ "x-go-name": "UID"
+ }
+ },
+ "type": "object",
+ "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
+ },
"ErrorType": {
"title": "ErrorType models the different API error types.",
"type": "string",
@@ -3034,6 +3064,7 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"alertGroup": {
+ "description": "AlertGroup alert group",
"properties": {
"alerts": {
"description": "alerts",
@@ -3055,17 +3086,14 @@
"labels",
"receiver"
],
- "type": "object",
- "x-go-name": "AlertGroup",
- "x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
+ "type": "object"
},
"alertGroups": {
+ "description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup"
},
- "type": "array",
- "x-go-name": "AlertGroups",
- "x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
+ "type": "array"
},
"alertStatus": {
"description": "AlertStatus alert status",
@@ -3255,6 +3283,7 @@
"type": "array"
},
"gettableSilence": {
+ "description": "GettableSilence gettable silence",
"properties": {
"comment": {
"description": "comment",
@@ -3306,17 +3335,14 @@
"status",
"updatedAt"
],
- "type": "object",
- "x-go-name": "GettableSilence",
- "x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
+ "type": "object"
},
"gettableSilences": {
+ "description": "GettableSilences gettable silences",
"items": {
"$ref": "#/definitions/gettableSilence"
},
- "type": "array",
- "x-go-name": "GettableSilences",
- "x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
+ "type": "array"
},
"labelSet": {
"additionalProperties": {
@@ -3445,7 +3471,6 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"postableSilence": {
- "description": "PostableSilence postable silence",
"properties": {
"comment": {
"description": "comment",
@@ -3485,7 +3510,9 @@
"matchers",
"startsAt"
],
- "type": "object"
+ "type": "object",
+ "x-go-name": "PostableSilence",
+ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"receiver": {
"properties": {
@@ -4733,6 +4760,112 @@
]
}
},
+ "/api/provisioning/contact-points": {
+ "get": {
+ "operationId": "RouteGetContactpoints",
+ "responses": {
+ "200": {
+ "description": "Route",
+ "schema": {
+ "$ref": "#/definitions/Route"
+ }
+ },
+ "400": {
+ "description": "ValidationError",
+ "schema": {
+ "$ref": "#/definitions/ValidationError"
+ }
+ }
+ },
+ "summary": "Get all the contact points.",
+ "tags": [
+ "provisioning"
+ ]
+ },
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "operationId": "RoutePostContactpoints",
+ "parameters": [
+ {
+ "in": "body",
+ "name": "Body",
+ "schema": {
+ "$ref": "#/definitions/EmbeddedContactPoint"
+ }
+ }
+ ],
+ "responses": {
+ "202": {
+ "$ref": "#/responses/Accepted"
+ },
+ "400": {
+ "description": "ValidationError",
+ "schema": {
+ "$ref": "#/definitions/ValidationError"
+ }
+ }
+ },
+ "summary": "Create a contact point.",
+ "tags": [
+ "provisioning"
+ ]
+ },
+ "put": {
+ "consumes": [
+ "application/json"
+ ],
+ "operationId": "RoutePutContactpoints",
+ "parameters": [
+ {
+ "in": "body",
+ "name": "Body",
+ "schema": {
+ "$ref": "#/definitions/EmbeddedContactPoint"
+ }
+ }
+ ],
+ "responses": {
+ "202": {
+ "$ref": "#/responses/Accepted"
+ },
+ "400": {
+ "description": "ValidationError",
+ "schema": {
+ "$ref": "#/definitions/ValidationError"
+ }
+ }
+ },
+ "summary": "Update an existing contact point.",
+ "tags": [
+ "provisioning"
+ ]
+ }
+ },
+ "/api/provisioning/contact-points/{ID}": {
+ "delete": {
+ "consumes": [
+ "application/json"
+ ],
+ "operationId": "RouteDeleteContactpoints",
+ "responses": {
+ "202": {
+ "$ref": "#/responses/Accepted"
+ },
+ "400": {
+ "description": "ValidationError",
+ "schema": {
+ "$ref": "#/definitions/ValidationError"
+ }
+ }
+ },
+ "summary": "Delete a contact point.",
+ "tags": [
+ "provisioning"
+ ]
+ }
+ },
"/api/provisioning/policies": {
"get": {
"operationId": "RouteGetPolicyTree",
diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json
index fff24ed4f08..188a5c4b885 100644
--- a/pkg/services/ngalert/api/tooling/spec.json
+++ b/pkg/services/ngalert/api/tooling/spec.json
@@ -1136,6 +1136,112 @@
}
}
},
+ "/api/provisioning/contact-points": {
+ "get": {
+ "tags": [
+ "provisioning"
+ ],
+ "summary": "Get all the contact points.",
+ "operationId": "RouteGetContactpoints",
+ "responses": {
+ "200": {
+ "description": "Route",
+ "schema": {
+ "$ref": "#/definitions/Route"
+ }
+ },
+ "400": {
+ "description": "ValidationError",
+ "schema": {
+ "$ref": "#/definitions/ValidationError"
+ }
+ }
+ }
+ },
+ "put": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "provisioning"
+ ],
+ "summary": "Update an existing contact point.",
+ "operationId": "RoutePutContactpoints",
+ "parameters": [
+ {
+ "name": "Body",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/EmbeddedContactPoint"
+ }
+ }
+ ],
+ "responses": {
+ "202": {
+ "$ref": "#/responses/Accepted"
+ },
+ "400": {
+ "description": "ValidationError",
+ "schema": {
+ "$ref": "#/definitions/ValidationError"
+ }
+ }
+ }
+ },
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "provisioning"
+ ],
+ "summary": "Create a contact point.",
+ "operationId": "RoutePostContactpoints",
+ "parameters": [
+ {
+ "name": "Body",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/EmbeddedContactPoint"
+ }
+ }
+ ],
+ "responses": {
+ "202": {
+ "$ref": "#/responses/Accepted"
+ },
+ "400": {
+ "description": "ValidationError",
+ "schema": {
+ "$ref": "#/definitions/ValidationError"
+ }
+ }
+ }
+ }
+ },
+ "/api/provisioning/contact-points/{ID}": {
+ "delete": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "provisioning"
+ ],
+ "summary": "Delete a contact point.",
+ "operationId": "RouteDeleteContactpoints",
+ "responses": {
+ "202": {
+ "$ref": "#/responses/Accepted"
+ },
+ "400": {
+ "description": "ValidationError",
+ "schema": {
+ "$ref": "#/definitions/ValidationError"
+ }
+ }
+ }
+ }
+ },
"/api/provisioning/policies": {
"get": {
"tags": [
@@ -2369,6 +2475,36 @@
},
"x-go-package": "github.com/prometheus/alertmanager/config"
},
+ "EmbeddedContactPoint": {
+ "description": "EmbeddedContactPoint is the contact point type that is used\nby grafanas embedded alertmanager implementation.",
+ "type": "object",
+ "properties": {
+ "disableResolveMessage": {
+ "type": "boolean",
+ "x-go-name": "DisableResolveMessage"
+ },
+ "name": {
+ "type": "string",
+ "x-go-name": "Name"
+ },
+ "provanance": {
+ "type": "string",
+ "x-go-name": "Provenance"
+ },
+ "settings": {
+ "$ref": "#/definitions/Json"
+ },
+ "type": {
+ "type": "string",
+ "x-go-name": "Type"
+ },
+ "uid": {
+ "type": "string",
+ "x-go-name": "UID"
+ }
+ },
+ "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
+ },
"ErrorType": {
"type": "string",
"title": "ErrorType models the different API error types.",
@@ -4848,6 +4984,7 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"alertGroup": {
+ "description": "AlertGroup alert group",
"type": "object",
"required": [
"alerts",
@@ -4870,17 +5007,14 @@
"$ref": "#/definitions/receiver"
}
},
- "x-go-name": "AlertGroup",
- "x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/alertGroup"
},
"alertGroups": {
+ "description": "AlertGroups alert groups",
"type": "array",
"items": {
"$ref": "#/definitions/alertGroup"
},
- "x-go-name": "AlertGroups",
- "x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/alertGroups"
},
"alertStatus": {
@@ -5073,6 +5207,7 @@
"$ref": "#/definitions/gettableAlerts"
},
"gettableSilence": {
+ "description": "GettableSilence gettable silence",
"type": "object",
"required": [
"comment",
@@ -5125,17 +5260,14 @@
"x-go-name": "UpdatedAt"
}
},
- "x-go-name": "GettableSilence",
- "x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/gettableSilence"
},
"gettableSilences": {
+ "description": "GettableSilences gettable silences",
"type": "array",
"items": {
"$ref": "#/definitions/gettableSilence"
},
- "x-go-name": "GettableSilences",
- "x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/gettableSilences"
},
"labelSet": {
@@ -5265,7 +5397,6 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"postableSilence": {
- "description": "PostableSilence postable silence",
"type": "object",
"required": [
"comment",
@@ -5306,6 +5437,8 @@
"x-go-name": "StartsAt"
}
},
+ "x-go-name": "PostableSilence",
+ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/postableSilence"
},
"receiver": {
diff --git a/pkg/services/ngalert/models/provisioning.go b/pkg/services/ngalert/models/provisioning.go
index b3ad9612ef5..c3655edfc5b 100644
--- a/pkg/services/ngalert/models/provisioning.go
+++ b/pkg/services/ngalert/models/provisioning.go
@@ -3,8 +3,10 @@ package models
type Provenance string
const (
+ // ProvenanceNone reflects the provenance when no provenance is stored
+ // for the requested object in the database.
ProvenanceNone Provenance = ""
- ProvenanceApi Provenance = "api"
+ ProvenanceAPI Provenance = "api"
ProvenanceFile Provenance = "file"
)
diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go
index fe070adf47a..49158bc52b5 100644
--- a/pkg/services/ngalert/ngalert.go
+++ b/pkg/services/ngalert/ngalert.go
@@ -99,7 +99,7 @@ func (ng *AlertNG) init() error {
decryptFn := ng.SecretsService.GetDecryptedValue
multiOrgMetrics := ng.Metrics.GetMultiOrgAlertmanagerMetrics()
- ng.MultiOrgAlertmanager, err = notifier.NewMultiOrgAlertmanager(ng.Cfg, store, store, ng.KVStore, decryptFn, multiOrgMetrics, ng.NotificationService, log.New("ngalert.multiorg.alertmanager"))
+ ng.MultiOrgAlertmanager, err = notifier.NewMultiOrgAlertmanager(ng.Cfg, store, store, ng.KVStore, decryptFn, multiOrgMetrics, ng.NotificationService, log.New("ngalert.multiorg.alertmanager"), ng.SecretsService)
if err != nil {
return err
}
@@ -139,6 +139,7 @@ func (ng *AlertNG) init() error {
// Provisioning
policyService := provisioning.NewNotificationPolicyService(store, store, store, ng.Log)
+ contactPointService := provisioning.NewContactPointService(store, ng.SecretsService, store, store, ng.Log)
api := api.API{
Cfg: ng.Cfg,
@@ -158,6 +159,7 @@ func (ng *AlertNG) init() error {
StateManager: ng.stateManager,
AccessControl: ng.accesscontrol,
Policies: policyService,
+ ContactPointService: contactPointService,
}
api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics())
diff --git a/pkg/services/ngalert/notifier/alertmanager_config.go b/pkg/services/ngalert/notifier/alertmanager_config.go
new file mode 100644
index 00000000000..7efd6561589
--- /dev/null
+++ b/pkg/services/ngalert/notifier/alertmanager_config.go
@@ -0,0 +1,119 @@
+package notifier
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
+ "github.com/grafana/grafana/pkg/services/ngalert/models"
+ "github.com/grafana/grafana/pkg/services/ngalert/store"
+)
+
+type UnknownReceiverError struct {
+ UID string
+}
+
+func (e UnknownReceiverError) Error() string {
+ return fmt.Sprintf("unknown receiver: %s", e.UID)
+}
+
+type AlertmanagerConfigRejectedError struct {
+ Inner error
+}
+
+func (e AlertmanagerConfigRejectedError) Error() string {
+ return fmt.Sprintf("failed to save and apply Alertmanager configuration: %s", e.Inner.Error())
+}
+
+type configurationStore interface {
+ GetLatestAlertmanagerConfiguration(ctx context.Context, query *models.GetLatestAlertmanagerConfigurationQuery) error
+}
+
+func (moa *MultiOrgAlertmanager) GetAlertmanagerConfiguration(ctx context.Context, org int64) (definitions.GettableUserConfig, error) {
+ query := models.GetLatestAlertmanagerConfigurationQuery{OrgID: org}
+ err := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, &query)
+ if err != nil {
+ return definitions.GettableUserConfig{}, fmt.Errorf("failed to get latest configuration: %w", err)
+ }
+ cfg, err := Load([]byte(query.Result.AlertmanagerConfiguration))
+ if err != nil {
+ return definitions.GettableUserConfig{}, fmt.Errorf("failed to unmarshal alertmanager configuration: %w", err)
+ }
+
+ result := definitions.GettableUserConfig{
+ TemplateFiles: cfg.TemplateFiles,
+ AlertmanagerConfig: definitions.GettableApiAlertingConfig{
+ Config: cfg.AlertmanagerConfig.Config,
+ },
+ }
+
+ for _, recv := range cfg.AlertmanagerConfig.Receivers {
+ receivers := make([]*definitions.GettableGrafanaReceiver, 0, len(recv.PostableGrafanaReceivers.GrafanaManagedReceivers))
+ for _, pr := range recv.PostableGrafanaReceivers.GrafanaManagedReceivers {
+ secureFields := make(map[string]bool, len(pr.SecureSettings))
+ for k := range pr.SecureSettings {
+ decryptedValue, err := moa.Crypto.getDecryptedSecret(pr, k)
+ if err != nil {
+ return definitions.GettableUserConfig{}, fmt.Errorf("failed to decrypt stored secure setting: %w", err)
+ }
+ if decryptedValue == "" {
+ continue
+ }
+ secureFields[k] = true
+ }
+ gr := definitions.GettableGrafanaReceiver{
+ UID: pr.UID,
+ Name: pr.Name,
+ Type: pr.Type,
+ DisableResolveMessage: pr.DisableResolveMessage,
+ Settings: pr.Settings,
+ SecureFields: secureFields,
+ }
+ receivers = append(receivers, &gr)
+ }
+ gettableApiReceiver := definitions.GettableApiReceiver{
+ GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{
+ GrafanaManagedReceivers: receivers,
+ },
+ }
+ gettableApiReceiver.Name = recv.Name
+ result.AlertmanagerConfig.Receivers = append(result.AlertmanagerConfig.Receivers, &gettableApiReceiver)
+ }
+
+ return result, nil
+}
+
+func (moa *MultiOrgAlertmanager) ApplyAlertmanagerConfiguration(ctx context.Context, org int64, config definitions.PostableUserConfig) error {
+ // Get the last known working configuration
+ query := models.GetLatestAlertmanagerConfigurationQuery{OrgID: org}
+ if err := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, &query); err != nil {
+ // If we don't have a configuration there's nothing for us to know and we should just continue saving the new one
+ if !errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
+ return fmt.Errorf("failed to get latest configuration %w", err)
+ }
+ }
+
+ if err := moa.Crypto.LoadSecureSettings(ctx, org, config.AlertmanagerConfig.Receivers); err != nil {
+ return err
+ }
+
+ if err := config.ProcessConfig(moa.Crypto.Encrypt); err != nil {
+ return fmt.Errorf("failed to post process Alertmanager configuration: %w", err)
+ }
+
+ am, err := moa.AlertmanagerFor(org)
+ if err != nil {
+ // It's okay if the alertmanager isn't ready yet, we're changing its config anyway.
+ if !errors.Is(err, ErrAlertmanagerNotReady) {
+ return err
+ }
+ }
+
+ if err := am.SaveAndApplyConfig(ctx, &config); err != nil {
+ moa.logger.Error("unable to save and apply alertmanager configuration", "err", err)
+ return AlertmanagerConfigRejectedError{err}
+ }
+
+ return nil
+}
diff --git a/pkg/services/ngalert/notifier/crypto.go b/pkg/services/ngalert/notifier/crypto.go
new file mode 100644
index 00000000000..0d5e06a55f1
--- /dev/null
+++ b/pkg/services/ngalert/notifier/crypto.go
@@ -0,0 +1,118 @@
+package notifier
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "fmt"
+
+ "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
+ "github.com/grafana/grafana/pkg/services/ngalert/models"
+ "github.com/grafana/grafana/pkg/services/ngalert/store"
+ "github.com/grafana/grafana/pkg/services/secrets"
+)
+
+// Crypto allows decryption of Alertmanager Configuration and encryption of arbitrary payloads.
+type Crypto interface {
+ LoadSecureSettings(ctx context.Context, orgId int64, receivers []*definitions.PostableApiReceiver) error
+ Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error)
+
+ getDecryptedSecret(r *definitions.PostableGrafanaReceiver, key string) (string, error)
+}
+
+// alertmanagerCrypto implements decryption of Alertmanager configuration and encryption of arbitrary payloads based on Grafana's encryptions.
+type alertmanagerCrypto struct {
+ secrets secrets.Service
+ configs configurationStore
+ log log.Logger
+}
+
+func NewCrypto(secrets secrets.Service, configs configurationStore, log log.Logger) Crypto {
+ return &alertmanagerCrypto{
+ secrets: secrets,
+ configs: configs,
+ log: log,
+ }
+}
+
+// LoadSecureSettings adds the corresponding unencrypted secrets stored to the list of input receivers.
+func (c *alertmanagerCrypto) LoadSecureSettings(ctx context.Context, orgId int64, receivers []*definitions.PostableApiReceiver) error {
+ // Get the last known working configuration.
+ query := models.GetLatestAlertmanagerConfigurationQuery{OrgID: orgId}
+ if err := c.configs.GetLatestAlertmanagerConfiguration(ctx, &query); err != nil {
+ // If we don't have a configuration there's nothing for us to know and we should just continue saving the new one.
+ if !errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
+ return fmt.Errorf("failed to get latest configuration: %w", err)
+ }
+ }
+
+ currentReceiverMap := make(map[string]*definitions.PostableGrafanaReceiver)
+ if query.Result != nil {
+ currentConfig, err := Load([]byte(query.Result.AlertmanagerConfiguration))
+ // If the current config is un-loadable, treat it as if it never existed. Providing a new, valid config should be able to "fix" this state.
+ if err != nil {
+ c.log.Warn("Last known alertmanager configuration was invalid. Overwriting...")
+ } else {
+ currentReceiverMap = currentConfig.GetGrafanaReceiverMap()
+ }
+ }
+
+ // Copy the previously known secure settings.
+ for i, r := range receivers {
+ for j, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers {
+ if gr.UID == "" { // new receiver
+ continue
+ }
+
+ cgmr, ok := currentReceiverMap[gr.UID]
+ if !ok {
+ // It tries to update a receiver that didn't previously exist
+ return UnknownReceiverError{UID: gr.UID}
+ }
+
+ // Frontend sends only the secure settings that have to be updated
+ // Therefore we have to copy from the last configuration only those secure settings not included in the request
+ for key := range cgmr.SecureSettings {
+ _, ok := gr.SecureSettings[key]
+ if !ok {
+ decryptedValue, err := c.getDecryptedSecret(cgmr, key)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt stored secure setting: %s: %w", key, err)
+ }
+
+ if receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings == nil {
+ receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings = make(map[string]string, len(cgmr.SecureSettings))
+ }
+
+ receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings[key] = decryptedValue
+ }
+ }
+ }
+ }
+ return nil
+}
+
+func (c *alertmanagerCrypto) getDecryptedSecret(r *definitions.PostableGrafanaReceiver, key string) (string, error) {
+ storedValue, ok := r.SecureSettings[key]
+ if !ok {
+ return "", nil
+ }
+
+ decodeValue, err := base64.StdEncoding.DecodeString(storedValue)
+ if err != nil {
+ return "", err
+ }
+
+ decryptedValue, err := c.secrets.Decrypt(context.Background(), decodeValue)
+ if err != nil {
+ return "", err
+ }
+
+ return string(decryptedValue), nil
+}
+
+// Encrypt delegates encryption to secrets.Service.
+func (c *alertmanagerCrypto) Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error) {
+ return c.secrets.Encrypt(ctx, payload, opt)
+}
diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager.go b/pkg/services/ngalert/notifier/multiorg_alertmanager.go
index 3c8a2f1d987..d34677c3e44 100644
--- a/pkg/services/ngalert/notifier/multiorg_alertmanager.go
+++ b/pkg/services/ngalert/notifier/multiorg_alertmanager.go
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
"github.com/grafana/grafana/pkg/services/notifications"
+ "github.com/grafana/grafana/pkg/services/secrets"
"github.com/prometheus/alertmanager/cluster"
"github.com/prometheus/client_golang/prometheus"
@@ -29,6 +30,8 @@ var (
)
type MultiOrgAlertmanager struct {
+ Crypto Crypto
+
alertmanagersMtx sync.RWMutex
alertmanagers map[int64]*Alertmanager
@@ -51,9 +54,11 @@ type MultiOrgAlertmanager struct {
func NewMultiOrgAlertmanager(cfg *setting.Cfg, configStore store.AlertingStore, orgStore store.OrgStore,
kvStore kvstore.KVStore, decryptFn channels.GetDecryptedValueFn, m *metrics.MultiOrgAlertmanager,
- ns notifications.Service, l log.Logger,
+ ns notifications.Service, l log.Logger, s secrets.Service,
) (*MultiOrgAlertmanager, error) {
moa := &MultiOrgAlertmanager{
+ Crypto: NewCrypto(s, configStore, l),
+
logger: l,
settings: cfg,
alertmanagers: map[int64]*Alertmanager{},
diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go b/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go
index 6dd2f020be0..56899182532 100644
--- a/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go
+++ b/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go
@@ -44,7 +44,7 @@ func TestMultiOrgAlertmanager_SyncAlertmanagersForOrgs(t *testing.T) {
DisabledOrgs: map[int64]struct{}{5: {}},
}, // do not poll in tests.
}
- mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"))
+ mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService)
require.NoError(t, err)
ctx := context.Background()
@@ -168,7 +168,7 @@ func TestMultiOrgAlertmanager_SyncAlertmanagersForOrgsWithFailures(t *testing.T)
DefaultConfiguration: setting.GetAlertmanagerDefaultConfiguration(),
}, // do not poll in tests.
}
- mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"))
+ mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService)
require.NoError(t, err)
ctx := context.Background()
@@ -218,7 +218,7 @@ func TestMultiOrgAlertmanager_AlertmanagerFor(t *testing.T) {
decryptFn := secretsService.GetDecryptedValue
reg := prometheus.NewPedanticRegistry()
m := metrics.NewNGAlert(reg)
- mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"))
+ mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService)
require.NoError(t, err)
ctx := context.Background()
diff --git a/pkg/services/ngalert/provisioning/contactpoints.go b/pkg/services/ngalert/provisioning/contactpoints.go
new file mode 100644
index 00000000000..00b1b60aba7
--- /dev/null
+++ b/pkg/services/ngalert/provisioning/contactpoints.go
@@ -0,0 +1,402 @@
+package provisioning
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "sort"
+
+ "github.com/grafana/grafana/pkg/infra/log"
+ apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
+ "github.com/grafana/grafana/pkg/services/ngalert/models"
+ "github.com/grafana/grafana/pkg/services/ngalert/store"
+ "github.com/grafana/grafana/pkg/services/secrets"
+ "github.com/grafana/grafana/pkg/util"
+ "github.com/prometheus/alertmanager/config"
+)
+
+type ContactPointService struct {
+ amStore AMConfigStore
+ encryptionService secrets.Service
+ provenanceStore ProvisioningStore
+ xact TransactionManager
+ log log.Logger
+}
+
+func NewContactPointService(store store.AlertingStore, encryptionService secrets.Service,
+ provenanceStore ProvisioningStore, xact TransactionManager, log log.Logger) *ContactPointService {
+ return &ContactPointService{
+ amStore: store,
+ encryptionService: encryptionService,
+ provenanceStore: provenanceStore,
+ xact: xact,
+ log: log,
+ }
+}
+
+func (ecp *ContactPointService) GetContactPoints(ctx context.Context, orgID int64) ([]apimodels.EmbeddedContactPoint, error) {
+ cfg, _, err := ecp.getCurrentConfig(ctx, orgID)
+ if err != nil {
+ return nil, err
+ }
+ provenances, err := ecp.provenanceStore.GetProvenances(ctx, orgID, "contactPoint")
+ if err != nil {
+ return nil, err
+ }
+ contactPoints := []apimodels.EmbeddedContactPoint{}
+ for _, contactPoint := range cfg.GetGrafanaReceiverMap() {
+ embeddedContactPoint := apimodels.EmbeddedContactPoint{
+ UID: contactPoint.UID,
+ Type: contactPoint.Type,
+ Name: contactPoint.Name,
+ DisableResolveMessage: contactPoint.DisableResolveMessage,
+ Settings: contactPoint.Settings,
+ }
+ if val, exists := provenances[embeddedContactPoint.UID]; exists && val != "" {
+ embeddedContactPoint.Provenance = string(val)
+ }
+ for k, v := range contactPoint.SecureSettings {
+ decryptedValue, err := ecp.decryptValue(v)
+ if err != nil {
+ ecp.log.Warn("decrypting value failed", "err", err.Error())
+ continue
+ }
+ if decryptedValue == "" {
+ continue
+ }
+ embeddedContactPoint.Settings.Set(k, apimodels.RedactedValue)
+ }
+ contactPoints = append(contactPoints, embeddedContactPoint)
+ }
+ sort.SliceStable(contactPoints, func(i, j int) bool {
+ return contactPoints[i].Name < contactPoints[j].Name
+ })
+ return contactPoints, nil
+}
+
+// internal only
+func (ecp *ContactPointService) getContactPointDecrypted(ctx context.Context, orgID int64, uid string) (apimodels.EmbeddedContactPoint, error) {
+ cfg, _, err := ecp.getCurrentConfig(ctx, orgID)
+ if err != nil {
+ return apimodels.EmbeddedContactPoint{}, err
+ }
+ for _, receiver := range cfg.GetGrafanaReceiverMap() {
+ if receiver.UID != uid {
+ continue
+ }
+ embeddedContactPoint := apimodels.EmbeddedContactPoint{
+ UID: receiver.UID,
+ Type: receiver.Type,
+ Name: receiver.Name,
+ DisableResolveMessage: receiver.DisableResolveMessage,
+ Settings: receiver.Settings,
+ }
+ for k, v := range receiver.SecureSettings {
+ decryptedValue, err := ecp.decryptValue(v)
+ if err != nil {
+ ecp.log.Warn("decrypting value failed", "err", err.Error())
+ continue
+ }
+ if decryptedValue == "" {
+ continue
+ }
+ embeddedContactPoint.Settings.Set(k, decryptedValue)
+ }
+ return embeddedContactPoint, nil
+ }
+ return apimodels.EmbeddedContactPoint{}, fmt.Errorf("contact point with uid '%s' not found", uid)
+}
+
+func (ecp *ContactPointService) CreateContactPoint(ctx context.Context, orgID int64,
+ contactPoint apimodels.EmbeddedContactPoint, provenance models.Provenance) (apimodels.EmbeddedContactPoint, error) {
+ if err := contactPoint.Valid(ecp.encryptionService.GetDecryptedValue); err != nil {
+ return apimodels.EmbeddedContactPoint{}, fmt.Errorf("contact point is not valid: %w", err)
+ }
+
+ cfg, fetchedHash, err := ecp.getCurrentConfig(ctx, orgID)
+ if err != nil {
+ return apimodels.EmbeddedContactPoint{}, err
+ }
+
+ extractedSecrets, err := contactPoint.ExtractSecrets()
+ if err != nil {
+ return apimodels.EmbeddedContactPoint{}, err
+ }
+
+ for k, v := range extractedSecrets {
+ encryptedValue, err := ecp.encryptValue(v)
+ if err != nil {
+ return apimodels.EmbeddedContactPoint{}, err
+ }
+ extractedSecrets[k] = encryptedValue
+ }
+
+ contactPoint.UID = util.GenerateShortUID()
+ grafanaReceiver := &apimodels.PostableGrafanaReceiver{
+ UID: contactPoint.UID,
+ Name: contactPoint.Name,
+ Type: contactPoint.Type,
+ DisableResolveMessage: contactPoint.DisableResolveMessage,
+ Settings: contactPoint.Settings,
+ SecureSettings: extractedSecrets,
+ }
+
+ receiverFound := false
+ for _, receiver := range cfg.AlertmanagerConfig.Receivers {
+ if receiver.Name == contactPoint.Name {
+ receiver.PostableGrafanaReceivers.GrafanaManagedReceivers = append(receiver.PostableGrafanaReceivers.GrafanaManagedReceivers, grafanaReceiver)
+ receiverFound = true
+ }
+ }
+
+ if !receiverFound {
+ cfg.AlertmanagerConfig.Receivers = append(cfg.AlertmanagerConfig.Receivers, &apimodels.PostableApiReceiver{
+ Receiver: config.Receiver{
+ Name: grafanaReceiver.Name,
+ },
+ PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{
+ GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{grafanaReceiver},
+ },
+ })
+ }
+
+ data, err := json.Marshal(cfg)
+ if err != nil {
+ return apimodels.EmbeddedContactPoint{}, err
+ }
+
+ err = ecp.xact.InTransaction(ctx, func(ctx context.Context) error {
+ err = ecp.amStore.UpdateAlertmanagerConfiguration(ctx, &models.SaveAlertmanagerConfigurationCmd{
+ AlertmanagerConfiguration: string(data),
+ FetchedConfigurationHash: fetchedHash,
+ ConfigurationVersion: "v1",
+ Default: false,
+ OrgID: orgID,
+ })
+ if err != nil {
+ return err
+ }
+ adapter := provenanceOrgAdapter{
+ inner: &contactPoint,
+ orgID: orgID,
+ }
+ err = ecp.provenanceStore.SetProvenance(ctx, adapter, provenance)
+ if err != nil {
+ return err
+ }
+ contactPoint.Provenance = string(provenance)
+ return nil
+ })
+ if err != nil {
+ return apimodels.EmbeddedContactPoint{}, err
+ }
+ for k := range extractedSecrets {
+ contactPoint.Settings.Set(k, apimodels.RedactedValue)
+ }
+ return contactPoint, nil
+}
+
+func (ecp *ContactPointService) UpdateContactPoint(ctx context.Context, orgID int64, contactPoint apimodels.EmbeddedContactPoint, provenance models.Provenance) error {
+ // set all redacted values with the latest known value from the store
+ rawContactPoint, err := ecp.getContactPointDecrypted(ctx, orgID, contactPoint.UID)
+ if err != nil {
+ return err
+ }
+ secretKeys, err := contactPoint.SecretKeys()
+ if err != nil {
+ return err
+ }
+ for _, secretKey := range secretKeys {
+ secretValue := contactPoint.Settings.Get(secretKey).MustString()
+ if secretValue == apimodels.RedactedValue {
+ contactPoint.Settings.Set(secretKey, rawContactPoint.Settings.Get(secretKey).MustString())
+ }
+ }
+ // validate merged values
+ if err := contactPoint.Valid(ecp.encryptionService.GetDecryptedValue); err != nil {
+ return err
+ }
+ // check that provenance is not changed in a invalid way
+ storedProvenance, err := ecp.provenanceStore.GetProvenance(ctx, provenanceOrgAdapter{
+ inner: &contactPoint,
+ orgID: orgID,
+ })
+ if err != nil {
+ return err
+ }
+ if storedProvenance != provenance && storedProvenance != models.ProvenanceNone {
+ return fmt.Errorf("cannot changed provenance from '%s' to '%s'", storedProvenance, provenance)
+ }
+ // transform to internal model
+ extractedSecrets, err := contactPoint.ExtractSecrets()
+ if err != nil {
+ return err
+ }
+ for k, v := range extractedSecrets {
+ encryptedValue, err := ecp.encryptValue(v)
+ if err != nil {
+ return err
+ }
+ extractedSecrets[k] = encryptedValue
+ }
+ mergedReceiver := &apimodels.PostableGrafanaReceiver{
+ UID: contactPoint.UID,
+ Name: contactPoint.Name,
+ Type: contactPoint.Type,
+ DisableResolveMessage: contactPoint.DisableResolveMessage,
+ Settings: contactPoint.Settings,
+ SecureSettings: extractedSecrets,
+ }
+ // save to store
+ cfg, fetchedHash, err := ecp.getCurrentConfig(ctx, orgID)
+ if err != nil {
+ return err
+ }
+ for _, receiver := range cfg.AlertmanagerConfig.Receivers {
+ if receiver.Name == contactPoint.Name {
+ receiverNotFound := true
+ for i, grafanaReceiver := range receiver.GrafanaManagedReceivers {
+ if grafanaReceiver.UID == mergedReceiver.UID {
+ receiverNotFound = false
+ receiver.GrafanaManagedReceivers[i] = mergedReceiver
+ break
+ }
+ }
+ if receiverNotFound {
+ return fmt.Errorf("contact point with uid '%s' not found", mergedReceiver.UID)
+ }
+ }
+ }
+ data, err := json.Marshal(cfg)
+ if err != nil {
+ return err
+ }
+ return ecp.xact.InTransaction(ctx, func(ctx context.Context) error {
+ err = ecp.amStore.UpdateAlertmanagerConfiguration(ctx, &models.SaveAlertmanagerConfigurationCmd{
+ AlertmanagerConfiguration: string(data),
+ FetchedConfigurationHash: fetchedHash,
+ ConfigurationVersion: "v1",
+ Default: false,
+ OrgID: orgID,
+ })
+ if err != nil {
+ return err
+ }
+ adapter := provenanceOrgAdapter{
+ inner: &contactPoint,
+ orgID: orgID,
+ }
+ err = ecp.provenanceStore.SetProvenance(ctx, adapter, provenance)
+ if err != nil {
+ return err
+ }
+ contactPoint.Provenance = string(provenance)
+ return nil
+ })
+}
+
+func (ecp *ContactPointService) DeleteContactPoint(ctx context.Context, orgID int64, uid string) error {
+ cfg, fetchedHash, err := ecp.getCurrentConfig(ctx, orgID)
+ if err != nil {
+ return err
+ }
+ // Indicates if the full contact point is removed or just one of the
+ // configurations, as a contactpoint can consist of any number of
+ // configurations.
+ fullRemoval := false
+ // Name of the contact point that will be removed, might be used if a
+ // full removal is done to check if it's referenced in any route.
+ name := ""
+ for i, receiver := range cfg.AlertmanagerConfig.Receivers {
+ for j, grafanaReceiver := range receiver.GrafanaManagedReceivers {
+ if grafanaReceiver.UID == uid {
+ name = grafanaReceiver.Name
+ receiver.GrafanaManagedReceivers = append(receiver.GrafanaManagedReceivers[:j], receiver.GrafanaManagedReceivers[j+1:]...)
+ // if this was the last receiver we removed, we remove the whole receiver
+ if len(receiver.GrafanaManagedReceivers) == 0 {
+ fullRemoval = true
+ cfg.AlertmanagerConfig.Receivers = append(cfg.AlertmanagerConfig.Receivers[:i], cfg.AlertmanagerConfig.Receivers[i+1:]...)
+ }
+ break
+ }
+ }
+ }
+ if fullRemoval && isContactPointInUse(name, []*apimodels.Route{cfg.AlertmanagerConfig.Route}) {
+ return fmt.Errorf("contact point '%s' is currently used by a notification policy", name)
+ }
+ data, err := json.Marshal(cfg)
+ if err != nil {
+ return err
+ }
+ return ecp.xact.InTransaction(ctx, func(ctx context.Context) error {
+ err := ecp.provenanceStore.DeleteProvenance(ctx, provenanceOrgAdapter{
+ inner: &apimodels.EmbeddedContactPoint{
+ UID: uid,
+ },
+ orgID: orgID,
+ })
+ if err != nil {
+ return err
+ }
+ return ecp.amStore.UpdateAlertmanagerConfiguration(ctx, &models.SaveAlertmanagerConfigurationCmd{
+ AlertmanagerConfiguration: string(data),
+ FetchedConfigurationHash: fetchedHash,
+ ConfigurationVersion: "v1",
+ Default: false,
+ OrgID: orgID,
+ })
+ })
+}
+
+func (ecp *ContactPointService) getCurrentConfig(ctx context.Context, orgID int64) (*apimodels.PostableUserConfig, string, error) {
+ query := &models.GetLatestAlertmanagerConfigurationQuery{
+ OrgID: orgID,
+ }
+ err := ecp.amStore.GetLatestAlertmanagerConfiguration(ctx, query)
+ if err != nil {
+ return nil, "", err
+ }
+ cfg, err := DeserializeAlertmanagerConfig([]byte(query.Result.AlertmanagerConfiguration))
+ if err != nil {
+ return nil, "", err
+ }
+ return cfg, query.Result.ConfigurationHash, nil
+}
+
+func isContactPointInUse(name string, routes []*apimodels.Route) bool {
+ if len(routes) == 0 {
+ return false
+ }
+ for _, route := range routes {
+ if route.Receiver == name {
+ return true
+ }
+ if isContactPointInUse(name, route.Routes) {
+ return true
+ }
+ }
+ return false
+}
+
+func (ecp *ContactPointService) decryptValue(value string) (string, error) {
+ decodeValue, err := base64.StdEncoding.DecodeString(value)
+ if err != nil {
+ return "", err
+ }
+
+ decryptedValue, err := ecp.encryptionService.Decrypt(context.Background(), decodeValue)
+ if err != nil {
+ return "", err
+ }
+
+ return string(decryptedValue), nil
+}
+
+func (ecp *ContactPointService) encryptValue(value string) (string, error) {
+ encryptedData, err := ecp.encryptionService.Encrypt(context.Background(), []byte(value), secrets.WithoutScope())
+ if err != nil {
+ return "", fmt.Errorf("failed to encrypt secure settings: %w", err)
+ }
+ return base64.StdEncoding.EncodeToString(encryptedData), nil
+}
diff --git a/pkg/services/ngalert/provisioning/contactpoints_test.go b/pkg/services/ngalert/provisioning/contactpoints_test.go
new file mode 100644
index 00000000000..cb70fe05baa
--- /dev/null
+++ b/pkg/services/ngalert/provisioning/contactpoints_test.go
@@ -0,0 +1,195 @@
+package provisioning
+
+import (
+ "context"
+ "testing"
+
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
+ "github.com/grafana/grafana/pkg/services/ngalert/models"
+ "github.com/grafana/grafana/pkg/services/secrets"
+ "github.com/grafana/grafana/pkg/services/secrets/database"
+ "github.com/grafana/grafana/pkg/services/secrets/manager"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/stretchr/testify/require"
+)
+
+func TestContactPointService(t *testing.T) {
+ sqlStore := sqlstore.InitTestDB(t)
+ secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore))
+ t.Run("service gets contact points from AM config", func(t *testing.T) {
+ sut := createContactPointServiceSut(secretsService)
+
+ cps, err := sut.GetContactPoints(context.Background(), 1)
+ require.NoError(t, err)
+
+ require.Len(t, cps, 1)
+ require.Equal(t, "email receiver", cps[0].Name)
+ })
+
+ t.Run("service stitches contact point into org's AM config", func(t *testing.T) {
+ sut := createContactPointServiceSut(secretsService)
+ newCp := createTestContactPoint()
+
+ _, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
+ require.NoError(t, err)
+
+ cps, err := sut.GetContactPoints(context.Background(), 1)
+ require.NoError(t, err)
+ require.Len(t, cps, 2)
+ require.Equal(t, "test-contact-point", cps[1].Name)
+ require.Equal(t, "slack", cps[1].Type)
+ })
+
+ t.Run("default provenance of contact points is none", func(t *testing.T) {
+ sut := createContactPointServiceSut(secretsService)
+
+ cps, err := sut.GetContactPoints(context.Background(), 1)
+ require.NoError(t, err)
+
+ require.Equal(t, models.ProvenanceNone, models.Provenance(cps[0].Provenance))
+ })
+
+ t.Run("it's possible to update provenance from none to API", func(t *testing.T) {
+ sut := createContactPointServiceSut(secretsService)
+ newCp := createTestContactPoint()
+
+ newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceNone)
+ require.NoError(t, err)
+
+ cps, err := sut.GetContactPoints(context.Background(), 1)
+ require.NoError(t, err)
+ require.Equal(t, newCp.UID, cps[1].UID)
+ require.Equal(t, models.ProvenanceNone, models.Provenance(cps[1].Provenance))
+
+ err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
+ require.NoError(t, err)
+
+ cps, err = sut.GetContactPoints(context.Background(), 1)
+ require.NoError(t, err)
+ require.Equal(t, newCp.UID, cps[1].UID)
+ require.Equal(t, models.ProvenanceAPI, models.Provenance(cps[1].Provenance))
+ })
+
+ t.Run("it's possible to update provenance from none to File", func(t *testing.T) {
+ sut := createContactPointServiceSut(secretsService)
+ newCp := createTestContactPoint()
+
+ newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceNone)
+ require.NoError(t, err)
+
+ cps, err := sut.GetContactPoints(context.Background(), 1)
+ require.NoError(t, err)
+ require.Equal(t, newCp.UID, cps[1].UID)
+ require.Equal(t, models.ProvenanceNone, models.Provenance(cps[1].Provenance))
+
+ err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceFile)
+ require.NoError(t, err)
+
+ cps, err = sut.GetContactPoints(context.Background(), 1)
+ require.NoError(t, err)
+ require.Equal(t, newCp.UID, cps[1].UID)
+ require.Equal(t, models.ProvenanceFile, models.Provenance(cps[1].Provenance))
+ })
+
+ t.Run("it's not possible to update provenance from File to API", func(t *testing.T) {
+ sut := createContactPointServiceSut(secretsService)
+ newCp := createTestContactPoint()
+
+ newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceFile)
+ require.NoError(t, err)
+
+ cps, err := sut.GetContactPoints(context.Background(), 1)
+ require.NoError(t, err)
+ require.Equal(t, newCp.UID, cps[1].UID)
+ require.Equal(t, models.ProvenanceFile, models.Provenance(cps[1].Provenance))
+
+ err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
+ require.Error(t, err)
+ })
+
+ t.Run("it's not possible to update provenance from API to File", func(t *testing.T) {
+ sut := createContactPointServiceSut(secretsService)
+ newCp := createTestContactPoint()
+
+ newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
+ require.NoError(t, err)
+
+ cps, err := sut.GetContactPoints(context.Background(), 1)
+ require.NoError(t, err)
+ require.Equal(t, newCp.UID, cps[1].UID)
+ require.Equal(t, models.ProvenanceAPI, models.Provenance(cps[1].Provenance))
+
+ err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceFile)
+ require.Error(t, err)
+ })
+
+ t.Run("service respects concurrency token when updating", func(t *testing.T) {
+ sut := createContactPointServiceSut(secretsService)
+ newCp := createTestContactPoint()
+ q := models.GetLatestAlertmanagerConfigurationQuery{
+ OrgID: 1,
+ }
+ err := sut.amStore.GetLatestAlertmanagerConfiguration(context.Background(), &q)
+ require.NoError(t, err)
+ expectedConcurrencyToken := q.Result.ConfigurationHash
+
+ _, err = sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
+ require.NoError(t, err)
+
+ fake := sut.amStore.(*fakeAMConfigStore)
+ intercepted := fake.lastSaveCommand
+ require.Equal(t, expectedConcurrencyToken, intercepted.FetchedConfigurationHash)
+ })
+}
+
+func TestContactPointInUse(t *testing.T) {
+ result := isContactPointInUse("test", []*definitions.Route{
+ {
+ Receiver: "not-test",
+ Routes: []*definitions.Route{
+ {
+ Receiver: "not-test",
+ },
+ {
+ Receiver: "test",
+ },
+ },
+ },
+ })
+ require.True(t, result)
+ result = isContactPointInUse("test", []*definitions.Route{
+ {
+ Receiver: "not-test",
+ Routes: []*definitions.Route{
+ {
+ Receiver: "not-test",
+ },
+ {
+ Receiver: "not-test",
+ },
+ },
+ },
+ })
+ require.False(t, result)
+}
+
+func createContactPointServiceSut(secretService secrets.Service) *ContactPointService {
+ return &ContactPointService{
+ amStore: newFakeAMConfigStore(),
+ provenanceStore: newFakeProvisioningStore(),
+ xact: newNopTransactionManager(),
+ encryptionService: secretService,
+ log: log.NewNopLogger(),
+ }
+}
+
+func createTestContactPoint() definitions.EmbeddedContactPoint {
+ settings, _ := simplejson.NewJson([]byte(`{"recipient":"value_recipient","token":"value_token"}`))
+ return definitions.EmbeddedContactPoint{
+ Name: "test-contact-point",
+ Type: "slack",
+ Settings: settings,
+ }
+}
diff --git a/pkg/services/ngalert/provisioning/notification_policies_test.go b/pkg/services/ngalert/provisioning/notification_policies_test.go
index 3df52d4bc81..2a27b6b3554 100644
--- a/pkg/services/ngalert/provisioning/notification_policies_test.go
+++ b/pkg/services/ngalert/provisioning/notification_policies_test.go
@@ -45,12 +45,12 @@ func TestNotificationPolicyService(t *testing.T) {
sut := createNotificationPolicyServiceSut()
newRoute := createTestRoutingTree()
- err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceApi)
+ err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceAPI)
require.NoError(t, err)
updated, err := sut.GetPolicyTree(context.Background(), 1)
require.NoError(t, err)
- require.Equal(t, models.ProvenanceApi, updated.Provenance)
+ require.Equal(t, models.ProvenanceAPI, updated.Provenance)
})
t.Run("service respects concurrency token when updating", func(t *testing.T) {
@@ -63,7 +63,7 @@ func TestNotificationPolicyService(t *testing.T) {
require.NoError(t, err)
expectedConcurrencyToken := q.Result.ConfigurationHash
- err = sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceApi)
+ err = sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceAPI)
require.NoError(t, err)
fake := sut.GetAMConfigStore().(*fakeAMConfigStore)
diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go
index 2a550db327f..eb428055a86 100644
--- a/pkg/services/ngalert/provisioning/persist.go
+++ b/pkg/services/ngalert/provisioning/persist.go
@@ -15,7 +15,9 @@ type AMConfigStore interface {
// ProvisioningStore is a store of provisioning data for arbitrary objects.
type ProvisioningStore interface {
GetProvenance(ctx context.Context, o models.Provisionable) (models.Provenance, error)
+ GetProvenances(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error)
SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error
+ DeleteProvenance(ctx context.Context, o models.Provisionable) error
}
// TransactionManager represents the ability to issue and close transactions through contexts.
diff --git a/pkg/services/ngalert/provisioning/testing.go b/pkg/services/ngalert/provisioning/testing.go
index 12c54cc02c7..aba31dfd513 100644
--- a/pkg/services/ngalert/provisioning/testing.go
+++ b/pkg/services/ngalert/provisioning/testing.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/md5"
"fmt"
+ "strings"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
@@ -99,19 +100,39 @@ func newFakeProvisioningStore() *fakeProvisioningStore {
func (f *fakeProvisioningStore) GetProvenance(ctx context.Context, o models.Provisionable) (models.Provenance, error) {
if val, ok := f.records[o.ResourceOrgID()]; ok {
- if prov, ok := val[o.ResourceID()]; ok {
+ if prov, ok := val[o.ResourceID()+o.ResourceType()]; ok {
return prov, nil
}
}
return models.ProvenanceNone, nil
}
+func (f *fakeProvisioningStore) GetProvenances(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) {
+ results := make(map[string]models.Provenance)
+ if val, ok := f.records[orgID]; ok {
+ for k, v := range val {
+ if strings.HasSuffix(k, resourceType) {
+ results[strings.TrimSuffix(k, resourceType)] = v
+ }
+ }
+ }
+ return results, nil
+}
+
func (f *fakeProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error {
orgID := o.ResourceOrgID()
if _, ok := f.records[orgID]; !ok {
f.records[orgID] = map[string]models.Provenance{}
}
- f.records[orgID][o.ResourceID()] = p
+ _ = f.DeleteProvenance(ctx, o) // delete old entries first
+ f.records[orgID][o.ResourceID()+o.ResourceType()] = p
+ return nil
+}
+
+func (f *fakeProvisioningStore) DeleteProvenance(ctx context.Context, o models.Provisionable) error {
+ if val, ok := f.records[o.ResourceOrgID()]; ok {
+ delete(val, o.ResourceID()+o.ResourceType())
+ }
return nil
}
diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go
index 4824f05d95f..7f5f8da4cac 100644
--- a/pkg/services/ngalert/schedule/schedule_unit_test.go
+++ b/pkg/services/ngalert/schedule/schedule_unit_test.go
@@ -1015,7 +1015,7 @@ func setupScheduler(t *testing.T, rs store.RuleStore, is store.InstanceStore, ac
m := metrics.NewNGAlert(registry)
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
decryptFn := secretsService.GetDecryptedValue
- moa, err := notifier.NewMultiOrgAlertmanager(&setting.Cfg{}, ¬ifier.FakeConfigStore{}, ¬ifier.FakeOrgStore{}, ¬ifier.FakeKVStore{}, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"))
+ moa, err := notifier.NewMultiOrgAlertmanager(&setting.Cfg{}, ¬ifier.FakeConfigStore{}, ¬ifier.FakeOrgStore{}, ¬ifier.FakeKVStore{}, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService)
require.NoError(t, err)
schedCfg := SchedulerCfg{
diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go
index df34f4b4f3d..7ef5a293282 100644
--- a/pkg/services/ngalert/state/manager_test.go
+++ b/pkg/services/ngalert/state/manager_test.go
@@ -1397,6 +1397,152 @@ func TestProcessEvalResults(t *testing.T) {
},
},
},
+ {
+ desc: "normal -> normal when result is Error and ExecErrState is OK",
+ alertRule: &models.AlertRule{
+ OrgID: 1,
+ Title: "test_title",
+ UID: "test_alert_rule_uid_2",
+ NamespaceUID: "test_namespace_uid",
+ Data: []models.AlertQuery{{
+ RefID: "A",
+ DatasourceUID: "datasource_uid_1",
+ }},
+ Annotations: map[string]string{"annotation": "test"},
+ Labels: map[string]string{"label": "test"},
+ IntervalSeconds: 10,
+ For: 1 * time.Minute,
+ ExecErrState: models.OkErrState,
+ },
+ evalResults: []eval.Results{
+ {
+ eval.Result{
+ Instance: data.Labels{"instance_label": "test"},
+ State: eval.Normal,
+ EvaluatedAt: evaluationTime,
+ EvaluationDuration: evaluationDuration,
+ },
+ },
+ {
+ eval.Result{
+ Instance: data.Labels{"instance_label": "test"},
+ Error: expr.QueryError{
+ RefID: "A",
+ Err: errors.New("this is an error"),
+ },
+ State: eval.Error,
+ EvaluatedAt: evaluationTime.Add(10 * time.Second),
+ EvaluationDuration: evaluationDuration,
+ },
+ },
+ },
+ expectedAnnotations: 0,
+ expectedStates: map[string]*state.State{
+ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: {
+ AlertRuleUID: "test_alert_rule_uid_2",
+ OrgID: 1,
+ CacheId: `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`,
+ Labels: data.Labels{
+ "__alert_rule_namespace_uid__": "test_namespace_uid",
+ "__alert_rule_uid__": "test_alert_rule_uid_2",
+ "alertname": "test_title",
+ "label": "test",
+ "instance_label": "test",
+ },
+ State: eval.Normal,
+ Error: nil,
+ Results: []state.Evaluation{
+ {
+ EvaluationTime: evaluationTime,
+ EvaluationState: eval.Normal,
+ Values: make(map[string]*float64),
+ },
+ {
+ EvaluationTime: evaluationTime.Add(10 * time.Second),
+ EvaluationState: eval.Error,
+ Values: make(map[string]*float64),
+ },
+ },
+ LastEvaluationTime: evaluationTime.Add(10 * time.Second),
+ EvaluationDuration: evaluationDuration,
+ Annotations: map[string]string{"annotation": "test"},
+ },
+ },
+ },
+ {
+ desc: "alerting -> normal when result is Error and ExecErrState is OK",
+ alertRule: &models.AlertRule{
+ OrgID: 1,
+ Title: "test_title",
+ UID: "test_alert_rule_uid_2",
+ NamespaceUID: "test_namespace_uid",
+ Data: []models.AlertQuery{{
+ RefID: "A",
+ DatasourceUID: "datasource_uid_1",
+ }},
+ Annotations: map[string]string{"annotation": "test"},
+ Labels: map[string]string{"label": "test"},
+ IntervalSeconds: 10,
+ For: 1 * time.Minute,
+ ExecErrState: models.OkErrState,
+ },
+ evalResults: []eval.Results{
+ {
+ eval.Result{
+ Instance: data.Labels{"instance_label": "test"},
+ State: eval.Alerting,
+ EvaluatedAt: evaluationTime,
+ EvaluationDuration: evaluationDuration,
+ },
+ },
+ {
+ eval.Result{
+ Instance: data.Labels{"instance_label": "test"},
+ Error: expr.QueryError{
+ RefID: "A",
+ Err: errors.New("this is an error"),
+ },
+ State: eval.Error,
+ EvaluatedAt: evaluationTime.Add(10 * time.Second),
+ EvaluationDuration: evaluationDuration,
+ },
+ },
+ },
+ expectedAnnotations: 2,
+ expectedStates: map[string]*state.State{
+ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: {
+ AlertRuleUID: "test_alert_rule_uid_2",
+ OrgID: 1,
+ CacheId: `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`,
+ Labels: data.Labels{
+ "__alert_rule_namespace_uid__": "test_namespace_uid",
+ "__alert_rule_uid__": "test_alert_rule_uid_2",
+ "alertname": "test_title",
+ "label": "test",
+ "instance_label": "test",
+ },
+ State: eval.Normal,
+ Error: nil,
+ Results: []state.Evaluation{
+ {
+ EvaluationTime: evaluationTime,
+ EvaluationState: eval.Alerting,
+ Values: make(map[string]*float64),
+ },
+ {
+ EvaluationTime: evaluationTime.Add(10 * time.Second),
+ EvaluationState: eval.Error,
+ Values: make(map[string]*float64),
+ },
+ },
+ StartsAt: evaluationTime.Add(10 * time.Second),
+ EndsAt: evaluationTime.Add(10 * time.Second),
+ LastEvaluationTime: evaluationTime.Add(10 * time.Second),
+ EvaluationDuration: evaluationDuration,
+ Annotations: map[string]string{"annotation": "test"},
+ },
+ },
+ },
{
desc: "normal -> alerting -> error -> alerting - it should clear the error",
alertRule: &models.AlertRule{
diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go
index 63f3b9d5930..cf5c5b849d2 100644
--- a/pkg/services/ngalert/state/state.go
+++ b/pkg/services/ngalert/state/state.go
@@ -2,6 +2,7 @@ package state
import (
"errors"
+ "fmt"
"strings"
"time"
@@ -51,8 +52,7 @@ func NewEvaluationValues(m map[string]eval.NumberValueCapture) map[string]*float
}
func (a *State) resultNormal(_ *ngModels.AlertRule, result eval.Result) {
- a.Error = result.Error // should be nil since state is not error
-
+ a.Error = nil // should be nil since state is not error
if a.State != eval.Normal {
a.EndsAt = result.EvaluatedAt
a.StartsAt = result.EvaluatedAt
@@ -88,9 +88,10 @@ func (a *State) resultError(alertRule *ngModels.AlertRule, result eval.Result) {
a.Error = result.Error
execErrState := eval.Error
- if alertRule.ExecErrState == ngModels.AlertingErrState {
+ switch alertRule.ExecErrState {
+ case ngModels.AlertingErrState:
execErrState = eval.Alerting
- } else if alertRule.ExecErrState == ngModels.ErrorErrState {
+ case ngModels.ErrorErrState:
// If the evaluation failed because a query returned an error then
// update the state with the Datasource UID as a label and the error
// message as an annotation so other code can use this metadata to
@@ -107,6 +108,11 @@ func (a *State) resultError(alertRule *ngModels.AlertRule, result eval.Result) {
a.Annotations["Error"] = queryError.Error()
}
execErrState = eval.Error
+ case ngModels.OkErrState:
+ a.resultNormal(alertRule, result)
+ return
+ default:
+ a.Error = fmt.Errorf("cannot map error to a state because option [%s] is not supported. evaluation error: %w", alertRule.ExecErrState, a.Error)
}
switch a.State {
diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go
index 06beea3a1d2..ff1df1709ce 100644
--- a/pkg/services/ngalert/store/alert_rule.go
+++ b/pkg/services/ngalert/store/alert_rule.go
@@ -27,24 +27,23 @@ type UpdateRuleGroupCmd struct {
RuleGroupConfig apimodels.PostableRuleGroupConfig
}
-type UpsertRule struct {
+type UpdateRule struct {
Existing *ngmodels.AlertRule
New ngmodels.AlertRule
}
-// Store is the interface for persisting alert rules and instances
+// RuleStore is the interface for persisting alert rules and instances
type RuleStore interface {
DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error
DeleteAlertInstancesByRuleUID(ctx context.Context, orgID int64, ruleUID string) error
GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAlertRuleByUIDQuery) error
GetAlertRulesForScheduling(ctx context.Context, query *ngmodels.ListAlertRulesQuery) error
GetOrgAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) error
- GetNamespaceAlertRules(ctx context.Context, query *ngmodels.ListNamespaceAlertRulesQuery) error
GetAlertRules(ctx context.Context, query *ngmodels.GetAlertRulesQuery) error
GetUserVisibleNamespaces(context.Context, int64, *models.SignedInUser) (map[string]*models.Folder, error)
GetNamespaceByTitle(context.Context, string, int64, *models.SignedInUser, bool) (*models.Folder, error)
- GetOrgRuleGroups(ctx context.Context, query *ngmodels.ListOrgRuleGroupsQuery) error
- UpsertAlertRules(ctx context.Context, rule []UpsertRule) error
+ InsertAlertRules(ctx context.Context, rule []ngmodels.AlertRule) error
+ UpdateAlertRules(ctx context.Context, rule []UpdateRule) error
}
func getAlertRuleByUID(sess *sqlstore.DBSession, alertRuleUID string, orgID int64) (*ngmodels.AlertRule, error) {
@@ -109,52 +108,87 @@ func (st DBstore) GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAler
})
}
-// UpsertAlertRules is a handler for creating/updating alert rules.
-func (st DBstore) UpsertAlertRules(ctx context.Context, rules []UpsertRule) error {
+// InsertAlertRules is a handler for creating/updating alert rules.
+func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRule) error {
+ return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
+ newRules := make([]ngmodels.AlertRule, 0, len(rules))
+ ruleVersions := make([]ngmodels.AlertRuleVersion, 0, len(rules))
+ for i := range rules {
+ r := rules[i]
+ uid, err := GenerateNewAlertRuleUID(sess, r.OrgID, r.Title)
+ if err != nil {
+ return fmt.Errorf("failed to generate UID for alert rule %q: %w", r.Title, err)
+ }
+ r.UID = uid
+ r.Version = 1
+ if err := st.validateAlertRule(r); err != nil {
+ return err
+ }
+ if err := (&r).PreSave(TimeNow); err != nil {
+ return err
+ }
+ newRules = append(newRules, r)
+ ruleVersions = append(ruleVersions, ngmodels.AlertRuleVersion{
+ RuleOrgID: r.OrgID,
+ RuleUID: r.UID,
+ RuleNamespaceUID: r.NamespaceUID,
+ RuleGroup: r.RuleGroup,
+ ParentVersion: 0,
+ Version: r.Version,
+ Created: r.Updated,
+ Condition: r.Condition,
+ Title: r.Title,
+ Data: r.Data,
+ IntervalSeconds: r.IntervalSeconds,
+ NoDataState: r.NoDataState,
+ ExecErrState: r.ExecErrState,
+ For: r.For,
+ Annotations: r.Annotations,
+ Labels: r.Labels,
+ })
+ }
+ if len(newRules) > 0 {
+ if _, err := sess.Insert(&newRules); err != nil {
+ if st.SQLStore.Dialect.IsUniqueConstraintViolation(err) {
+ return ngmodels.ErrAlertRuleUniqueConstraintViolation
+ }
+ return fmt.Errorf("failed to create new rules: %w", err)
+ }
+ }
+
+ if len(ruleVersions) > 0 {
+ if _, err := sess.Insert(&ruleVersions); err != nil {
+ return fmt.Errorf("failed to create new rule versions: %w", err)
+ }
+ }
+
+ return nil
+ })
+}
+
+// UpdateAlertRules is a handler for creating/updating alert rules.
+func (st DBstore) UpdateAlertRules(ctx context.Context, rules []UpdateRule) error {
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
newRules := make([]ngmodels.AlertRule, 0, len(rules))
ruleVersions := make([]ngmodels.AlertRuleVersion, 0, len(rules))
for _, r := range rules {
var parentVersion int64
- switch r.Existing {
- case nil: // new rule
- uid, err := GenerateNewAlertRuleUID(sess, r.New.OrgID, r.New.Title)
- if err != nil {
- return fmt.Errorf("failed to generate UID for alert rule %q: %w", r.New.Title, err)
- }
- r.New.UID = uid
- r.New.Version = 1
-
- if err := st.validateAlertRule(r.New); err != nil {
- return err
- }
-
- if err := (&r.New).PreSave(TimeNow); err != nil {
- return err
- }
- newRules = append(newRules, r.New)
- default:
- r.New.ID = r.Existing.ID
- r.New.Version = r.Existing.Version + 1
-
- if err := st.validateAlertRule(r.New); err != nil {
- return err
- }
-
- if err := (&r.New).PreSave(TimeNow); err != nil {
- return err
- }
-
- // no way to update multiple rules at once
- if _, err := sess.ID(r.Existing.ID).AllCols().Update(r.New); err != nil {
- if st.SQLStore.Dialect.IsUniqueConstraintViolation(err) {
- return ngmodels.ErrAlertRuleUniqueConstraintViolation
- }
- return fmt.Errorf("failed to update rule [%s] %s: %w", r.New.UID, r.New.Title, err)
- }
- parentVersion = r.Existing.Version
+ r.New.ID = r.Existing.ID
+ r.New.Version = r.Existing.Version + 1
+ if err := st.validateAlertRule(r.New); err != nil {
+ return err
}
-
+ if err := (&r.New).PreSave(TimeNow); err != nil {
+ return err
+ }
+ // no way to update multiple rules at once
+ if _, err := sess.ID(r.Existing.ID).AllCols().Update(r.New); err != nil {
+ if st.SQLStore.Dialect.IsUniqueConstraintViolation(err) {
+ return ngmodels.ErrAlertRuleUniqueConstraintViolation
+ }
+ return fmt.Errorf("failed to update rule [%s] %s: %w", r.New.UID, r.New.Title, err)
+ }
+ parentVersion = r.Existing.Version
ruleVersions = append(ruleVersions, ngmodels.AlertRuleVersion{
RuleOrgID: r.New.OrgID,
RuleUID: r.New.UID,
@@ -174,7 +208,6 @@ func (st DBstore) UpsertAlertRules(ctx context.Context, rules []UpsertRule) erro
Labels: r.New.Labels,
})
}
-
if len(newRules) > 0 {
if _, err := sess.Insert(&newRules); err != nil {
if st.SQLStore.Dialect.IsUniqueConstraintViolation(err) {
@@ -183,13 +216,11 @@ func (st DBstore) UpsertAlertRules(ctx context.Context, rules []UpsertRule) erro
return fmt.Errorf("failed to create new rules: %w", err)
}
}
-
if len(ruleVersions) > 0 {
if _, err := sess.Insert(&ruleVersions); err != nil {
return fmt.Errorf("failed to create new rule versions: %w", err)
}
}
-
return nil
})
}
@@ -230,21 +261,6 @@ func (st DBstore) GetOrgAlertRules(ctx context.Context, query *ngmodels.ListAler
})
}
-// GetNamespaceAlertRules is a handler for retrieving namespace alert rules of specific organisation.
-func (st DBstore) GetNamespaceAlertRules(ctx context.Context, query *ngmodels.ListNamespaceAlertRulesQuery) error {
- return st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
- alertRules := make([]*ngmodels.AlertRule, 0)
- // TODO rewrite using group by namespace_uid, rule_group
- q := "SELECT * FROM alert_rule WHERE org_id = ? and namespace_uid = ?"
- if err := sess.SQL(q, query.OrgID, query.NamespaceUID).Find(&alertRules); err != nil {
- return err
- }
-
- query.Result = alertRules
- return nil
- })
-}
-
// GetAlertRules is a handler for retrieving rule group alert rules of specific organisation.
func (st DBstore) GetAlertRules(ctx context.Context, query *ngmodels.GetAlertRulesQuery) error {
return st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
@@ -405,50 +421,3 @@ func (st DBstore) validateAlertRule(alertRule ngmodels.AlertRule) error {
return nil
}
-
-func (st DBstore) GetOrgRuleGroups(ctx context.Context, query *ngmodels.ListOrgRuleGroupsQuery) error {
- return st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
- var ruleGroups [][]string
- q := `
-SELECT DISTINCT
- rule_group,
- namespace_uid,
- (
- SELECT title
- FROM dashboard
- WHERE
- org_id = alert_rule.org_id AND
- uid = alert_rule.namespace_uid
- ) AS namespace_title
-FROM alert_rule
-WHERE org_id = ?`
- params := []interface{}{query.OrgID}
-
- if len(query.NamespaceUIDs) > 0 {
- placeholders := make([]string, 0, len(query.NamespaceUIDs))
- for _, folderUID := range query.NamespaceUIDs {
- params = append(params, folderUID)
- placeholders = append(placeholders, "?")
- }
- q = fmt.Sprintf(" %s AND namespace_uid IN (%s)", q, strings.Join(placeholders, ","))
- }
-
- if query.DashboardUID != "" {
- q = fmt.Sprintf("%s and dashboard_uid = ?", q)
- params = append(params, query.DashboardUID)
- if query.PanelID != 0 {
- q = fmt.Sprintf("%s and panel_id = ?", q)
- params = append(params, query.PanelID)
- }
- }
-
- q = fmt.Sprintf(" %s ORDER BY namespace_title", q)
-
- if err := sess.SQL(q, params...).Find(&ruleGroups); err != nil {
- return err
- }
-
- query.Result = ruleGroups
- return nil
- })
-}
diff --git a/pkg/services/ngalert/store/provisioning_store.go b/pkg/services/ngalert/store/provisioning_store.go
index 56c5d157606..6710b702a84 100644
--- a/pkg/services/ngalert/store/provisioning_store.go
+++ b/pkg/services/ngalert/store/provisioning_store.go
@@ -45,6 +45,23 @@ func (st DBstore) GetProvenance(ctx context.Context, o models.Provisionable) (mo
return provenance, nil
}
+// GetProvenance gets the provenance status for a provisionable object.
+func (st DBstore) GetProvenances(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) {
+ resultMap := make(map[string]models.Provenance)
+ err := st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
+ filter := "record_type = ? AND org_id = ?"
+ rawData, err := sess.Table(provenanceRecord{}).Where(filter, resourceType, orgID).Desc("id").Cols("record_key", "provenance").QueryString()
+ if err != nil {
+ return fmt.Errorf("failed to query for existing provenance status: %w", err)
+ }
+ for _, data := range rawData {
+ resultMap[data["record_key"]] = models.Provenance(data["provenance"])
+ }
+ return nil
+ })
+ return resultMap, err
+}
+
// SetProvenance changes the provenance status for a provisionable object.
func (st DBstore) SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error {
recordType := o.ResourceType()
@@ -76,3 +93,15 @@ func (st DBstore) SetProvenance(ctx context.Context, o models.Provisionable, p m
return nil
})
}
+
+// DeleteProvenance deletes the provenance record from the table
+func (st DBstore) DeleteProvenance(ctx context.Context, o models.Provisionable) error {
+ return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
+ _, err := sess.Delete(provenanceRecord{
+ RecordKey: o.ResourceID(),
+ RecordType: o.ResourceType(),
+ OrgID: o.ResourceOrgID(),
+ })
+ return err
+ })
+}
diff --git a/pkg/services/ngalert/store/provisioning_store_test.go b/pkg/services/ngalert/store/provisioning_store_test.go
index 815b799f080..886ca7f6884 100644
--- a/pkg/services/ngalert/store/provisioning_store_test.go
+++ b/pkg/services/ngalert/store/provisioning_store_test.go
@@ -73,16 +73,58 @@ func TestProvisioningStore(t *testing.T) {
err = store.SetProvenance(context.Background(), &ruleOrg3, models.ProvenanceFile)
require.NoError(t, err)
- err = store.SetProvenance(context.Background(), &ruleOrg2, models.ProvenanceApi)
+ err = store.SetProvenance(context.Background(), &ruleOrg2, models.ProvenanceAPI)
require.NoError(t, err)
p, err := store.GetProvenance(context.Background(), &ruleOrg2)
require.NoError(t, err)
- require.Equal(t, models.ProvenanceApi, p)
+ require.Equal(t, models.ProvenanceAPI, p)
p, err = store.GetProvenance(context.Background(), &ruleOrg3)
require.NoError(t, err)
require.Equal(t, models.ProvenanceFile, p)
})
+
+ t.Run("Store should return all provenances by type", func(t *testing.T) {
+ const orgID = 123
+ ruleOrg1 := models.AlertRule{
+ UID: "789",
+ OrgID: orgID,
+ }
+ ruleOrg2 := models.AlertRule{
+ UID: "790",
+ OrgID: orgID,
+ }
+ err := store.SetProvenance(context.Background(), &ruleOrg1, models.ProvenanceFile)
+ require.NoError(t, err)
+ err = store.SetProvenance(context.Background(), &ruleOrg2, models.ProvenanceAPI)
+ require.NoError(t, err)
+
+ p, err := store.GetProvenances(context.Background(), orgID, ruleOrg1.ResourceType())
+ require.NoError(t, err)
+ require.Len(t, p, 2)
+ require.Equal(t, models.ProvenanceFile, p[ruleOrg1.UID])
+ require.Equal(t, models.ProvenanceAPI, p[ruleOrg2.UID])
+ })
+
+ t.Run("Store should delete provenance correctly", func(t *testing.T) {
+ const orgID = 1234
+ ruleOrg := models.AlertRule{
+ UID: "7834539",
+ OrgID: orgID,
+ }
+ err := store.SetProvenance(context.Background(), &ruleOrg, models.ProvenanceFile)
+ require.NoError(t, err)
+ p, err := store.GetProvenance(context.Background(), &ruleOrg)
+ require.NoError(t, err)
+ require.Equal(t, models.ProvenanceFile, p)
+
+ err = store.DeleteProvenance(context.Background(), &ruleOrg)
+ require.NoError(t, err)
+
+ p, err = store.GetProvenance(context.Background(), &ruleOrg)
+ require.NoError(t, err)
+ require.Equal(t, models.ProvenanceNone, p)
+ })
}
func createProvisioningStoreSut(_ *ngalert.AlertNG, db *store.DBstore) provisioning.ProvisioningStore {
diff --git a/pkg/services/ngalert/store/testing.go b/pkg/services/ngalert/store/testing.go
index 527626c3e98..0c69fd5f522 100644
--- a/pkg/services/ngalert/store/testing.go
+++ b/pkg/services/ngalert/store/testing.go
@@ -178,12 +178,6 @@ func (f *FakeRuleStore) GetOrgAlertRules(_ context.Context, q *models.ListAlertR
q.Result = rules
return nil
}
-func (f *FakeRuleStore) GetNamespaceAlertRules(_ context.Context, q *models.ListNamespaceAlertRulesQuery) error {
- f.mtx.Lock()
- defer f.mtx.Unlock()
- f.RecordedOps = append(f.RecordedOps, *q)
- return nil
-}
func (f *FakeRuleStore) GetAlertRules(_ context.Context, q *models.GetAlertRulesQuery) error {
f.mtx.Lock()
defer f.mtx.Unlock()
@@ -219,8 +213,8 @@ func (f *FakeRuleStore) GetUserVisibleNamespaces(_ context.Context, orgID int64,
return namespacesMap, nil
}
- for _, rule := range f.Rules[orgID] {
- namespacesMap[rule.NamespaceUID] = &models2.Folder{}
+ for _, folder := range f.Folders[orgID] {
+ namespacesMap[folder.Uid] = folder
}
return namespacesMap, nil
}
@@ -233,36 +227,18 @@ func (f *FakeRuleStore) GetNamespaceByTitle(_ context.Context, title string, org
}
return nil, fmt.Errorf("not found")
}
-func (f *FakeRuleStore) GetOrgRuleGroups(_ context.Context, q *models.ListOrgRuleGroupsQuery) error {
+
+func (f *FakeRuleStore) UpdateAlertRules(_ context.Context, q []UpdateRule) error {
f.mtx.Lock()
defer f.mtx.Unlock()
- f.RecordedOps = append(f.RecordedOps, *q)
- if err := f.Hook(*q); err != nil {
+ f.RecordedOps = append(f.RecordedOps, q)
+ if err := f.Hook(q); err != nil {
return err
}
-
- // If we have namespaces, we want to try and retrieve the list of rules stored.
- if len(q.NamespaceUIDs) != 0 {
- rules, ok := f.Rules[q.OrgID]
- if !ok {
- return nil
- }
-
- var ruleGroups [][]string
- for _, rule := range rules {
- for _, namespace := range q.NamespaceUIDs {
- if rule.NamespaceUID == namespace { // if they match, they should go in.
- ruleGroups = append(ruleGroups, []string{rule.RuleGroup, rule.NamespaceUID, rule.NamespaceUID})
- }
- }
- }
-
- q.Result = ruleGroups
- }
return nil
}
-func (f *FakeRuleStore) UpsertAlertRules(_ context.Context, q []UpsertRule) error {
+func (f *FakeRuleStore) InsertAlertRules(_ context.Context, q []models.AlertRule) error {
f.mtx.Lock()
defer f.mtx.Unlock()
f.RecordedOps = append(f.RecordedOps, q)
diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go
index 35ef0930872..19de8f046a2 100644
--- a/pkg/services/ngalert/tests/util.go
+++ b/pkg/services/ngalert/tests/util.go
@@ -81,35 +81,34 @@ func CreateTestAlertRule(t *testing.T, ctx context.Context, dbstore *store.DBsto
func CreateTestAlertRuleWithLabels(t *testing.T, ctx context.Context, dbstore *store.DBstore, intervalSeconds int64, orgID int64, labels map[string]string) *models.AlertRule {
ruleGroup := fmt.Sprintf("ruleGroup-%s", util.GenerateShortUID())
- err := dbstore.UpsertAlertRules(ctx, []store.UpsertRule{
+ err := dbstore.InsertAlertRules(ctx, []models.AlertRule{
{
- New: models.AlertRule{
- ID: 0,
- OrgID: orgID,
- Title: fmt.Sprintf("an alert definition %s", util.GenerateShortUID()),
- Condition: "A",
- Data: []models.AlertQuery{
- {
- Model: json.RawMessage(`{
+
+ ID: 0,
+ OrgID: orgID,
+ Title: fmt.Sprintf("an alert definition %s", util.GenerateShortUID()),
+ Condition: "A",
+ Data: []models.AlertQuery{
+ {
+ Model: json.RawMessage(`{
"datasourceUid": "-100",
"type":"math",
"expression":"2 + 2 > 1"
}`),
- RelativeTimeRange: models.RelativeTimeRange{
- From: models.Duration(5 * time.Hour),
- To: models.Duration(3 * time.Hour),
- },
- RefID: "A",
+ RelativeTimeRange: models.RelativeTimeRange{
+ From: models.Duration(5 * time.Hour),
+ To: models.Duration(3 * time.Hour),
},
+ RefID: "A",
},
- Labels: labels,
- Annotations: map[string]string{"testAnnoKey": "testAnnoValue"},
- IntervalSeconds: intervalSeconds,
- NamespaceUID: "namespace",
- RuleGroup: ruleGroup,
- NoDataState: models.NoData,
- ExecErrState: models.AlertingErrState,
},
+ Labels: labels,
+ Annotations: map[string]string{"testAnnoKey": "testAnnoValue"},
+ IntervalSeconds: intervalSeconds,
+ NamespaceUID: "namespace",
+ RuleGroup: ruleGroup,
+ NoDataState: models.NoData,
+ ExecErrState: models.AlertingErrState,
},
})
require.NoError(t, err)
diff --git a/pkg/services/notifications/webhook.go b/pkg/services/notifications/webhook.go
index 2ea0ea19048..9c790c3c549 100644
--- a/pkg/services/notifications/webhook.go
+++ b/pkg/services/notifications/webhook.go
@@ -11,8 +11,6 @@ import (
"net/http"
"time"
- "golang.org/x/net/context/ctxhttp"
-
"github.com/grafana/grafana/pkg/util"
)
@@ -52,7 +50,7 @@ func (ns *NotificationService) sendWebRequestSync(ctx context.Context, webhook *
return fmt.Errorf("webhook only supports HTTP methods PUT or POST")
}
- request, err := http.NewRequest(webhook.HttpMethod, webhook.Url, bytes.NewReader([]byte(webhook.Body)))
+ request, err := http.NewRequestWithContext(ctx, webhook.HttpMethod, webhook.Url, bytes.NewReader([]byte(webhook.Body)))
if err != nil {
return err
}
@@ -72,7 +70,7 @@ func (ns *NotificationService) sendWebRequestSync(ctx context.Context, webhook *
request.Header.Set(k, v)
}
- resp, err := ctxhttp.Do(ctx, netClient, request)
+ resp, err := netClient.Do(request)
if err != nil {
return err
}
diff --git a/pkg/services/preference/model.go b/pkg/services/preference/model.go
new file mode 100644
index 00000000000..ae8b5ca665b
--- /dev/null
+++ b/pkg/services/preference/model.go
@@ -0,0 +1,100 @@
+package pref
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "time"
+)
+
+var ErrPrefNotFound = errors.New("preference not found")
+
+type Preference struct {
+ ID int64 `xorm:"pk autoincr 'id'"`
+ OrgID int64 `xorm:"org_id"`
+ UserID int64 `xorm:"user_id"`
+ TeamID int64 `xorm:"team_id"`
+ Teams []int64 `xorm:"extends"`
+ Version int
+ HomeDashboardID int64 `xorm:"home_dashboard_id"`
+ Timezone string
+ WeekStart string
+ Theme string
+ Created time.Time
+ Updated time.Time
+ JSONData *PreferenceJSONData `xorm:"json_data"`
+}
+
+type GetPreferenceWithDefaultsQuery struct {
+ Teams []int64
+ OrgID int64
+ UserID int64
+}
+
+type GetPreferenceQuery struct {
+ OrgID int64
+ UserID int64
+ TeamID int64
+}
+
+type SavePreferenceCommand struct {
+ UserID int64
+ OrgID int64
+ TeamID int64
+
+ HomeDashboardID int64 `json:"homeDashboardId,omitempty"`
+ Timezone string `json:"timezone,omitempty"`
+ WeekStart string `json:"weekStart,omitempty"`
+ Theme string `json:"theme,omitempty"`
+ Navbar *NavbarPreference `json:"navbar,omitempty"`
+ QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"`
+}
+
+type PatchPreferenceCommand struct {
+ UserID int64
+ OrgID int64
+ TeamID int64
+
+ HomeDashboardID *int64 `json:"homeDashboardId,omitempty"`
+ Timezone *string `json:"timezone,omitempty"`
+ WeekStart *string `json:"weekStart,omitempty"`
+ Theme *string `json:"theme,omitempty"`
+ Navbar *NavbarPreference `json:"navbar,omitempty"`
+ QueryHistory *QueryHistoryPreference `json:"queryHistory,omitempty"`
+}
+
+type NavLink struct {
+ ID string `json:"id,omitempty"`
+ Text string `json:"text,omitempty"`
+ Url string `json:"url,omitempty"`
+ Target string `json:"target,omitempty"`
+}
+
+type NavbarPreference struct {
+ SavedItems []NavLink `json:"savedItems"`
+}
+
+type PreferenceJSONData struct {
+ Navbar NavbarPreference `json:"navbar"`
+ QueryHistory QueryHistoryPreference `json:"queryHistory"`
+}
+
+type QueryHistoryPreference struct {
+ HomeTab string `json:"homeTab"`
+}
+
+func (j *PreferenceJSONData) FromDB(data []byte) error {
+ dec := json.NewDecoder(bytes.NewBuffer(data))
+ dec.UseNumber()
+ return dec.Decode(j)
+}
+
+func (j *PreferenceJSONData) ToDB() ([]byte, error) {
+ if j == nil {
+ return nil, nil
+ }
+
+ return json.Marshal(j)
+}
+
+func (p Preference) TableName() string { return "preferences" }
diff --git a/pkg/services/preference/pref.go b/pkg/services/preference/pref.go
new file mode 100644
index 00000000000..5cf17789a25
--- /dev/null
+++ b/pkg/services/preference/pref.go
@@ -0,0 +1,13 @@
+package pref
+
+import (
+ "context"
+)
+
+type Service interface {
+ GetWithDefaults(context.Context, *GetPreferenceWithDefaultsQuery) (*Preference, error)
+ Get(context.Context, *GetPreferenceQuery) (*Preference, error)
+ Save(context.Context, *SavePreferenceCommand) error
+ Patch(ctx context.Context, cmd *PatchPreferenceCommand) error
+ GetDefaults() *Preference
+}
diff --git a/pkg/services/preference/prefimpl/pref.go b/pkg/services/preference/prefimpl/pref.go
new file mode 100644
index 00000000000..c43fb7d98c5
--- /dev/null
+++ b/pkg/services/preference/prefimpl/pref.go
@@ -0,0 +1,203 @@
+package prefimpl
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ pref "github.com/grafana/grafana/pkg/services/preference"
+ "github.com/grafana/grafana/pkg/services/sqlstore/db"
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+type Service struct {
+ store store
+ cfg *setting.Cfg
+}
+
+func ProvideService(db db.DB, cfg *setting.Cfg) *Service {
+ return &Service{
+ store: &sqlStore{
+ db: db,
+ },
+ cfg: cfg,
+ }
+}
+
+func (s *Service) GetWithDefaults(ctx context.Context, query *pref.GetPreferenceWithDefaultsQuery) (*pref.Preference, error) {
+ listQuery := &pref.Preference{
+ Teams: query.Teams,
+ OrgID: query.OrgID,
+ UserID: query.UserID,
+ }
+ prefs, err := s.store.List(ctx, listQuery)
+ if err != nil {
+ return nil, err
+ }
+
+ res := s.GetDefaults()
+ for _, p := range prefs {
+ if p.Theme != "" {
+ res.Theme = p.Theme
+ }
+ if p.Timezone != "" {
+ res.Timezone = p.Timezone
+ }
+ if p.WeekStart != "" {
+ res.WeekStart = p.WeekStart
+ }
+ if p.HomeDashboardID != 0 {
+ res.HomeDashboardID = p.HomeDashboardID
+ }
+ if p.JSONData != nil {
+ res.JSONData = p.JSONData
+ }
+ }
+
+ return res, err
+}
+
+func (s *Service) Get(ctx context.Context, query *pref.GetPreferenceQuery) (*pref.Preference, error) {
+ getPref := &pref.Preference{
+ OrgID: query.OrgID,
+ UserID: query.UserID,
+ TeamID: query.TeamID,
+ }
+ prefs, err := s.store.Get(ctx, getPref)
+ if err != nil && !errors.Is(err, pref.ErrPrefNotFound) {
+ return nil, err
+ }
+ return prefs, nil
+}
+
+func (s *Service) Save(ctx context.Context, cmd *pref.SavePreferenceCommand) error {
+ preference, err := s.store.Get(ctx, &pref.Preference{
+ OrgID: cmd.OrgID,
+ UserID: cmd.UserID,
+ TeamID: cmd.TeamID,
+ })
+ if err != nil {
+ if errors.Is(err, pref.ErrPrefNotFound) {
+ preference := &pref.Preference{
+ UserID: cmd.UserID,
+ OrgID: cmd.OrgID,
+ TeamID: cmd.TeamID,
+ HomeDashboardID: cmd.HomeDashboardID,
+ Timezone: cmd.Timezone,
+ WeekStart: cmd.WeekStart,
+ Theme: cmd.Theme,
+ Created: time.Now(),
+ Updated: time.Now(),
+ }
+ _, err = s.store.Insert(ctx, preference)
+ if err != nil {
+ return err
+ }
+ }
+ return err
+ }
+ preference.Timezone = cmd.Timezone
+ preference.WeekStart = cmd.WeekStart
+ preference.Theme = cmd.Theme
+ preference.Updated = time.Now()
+ preference.Version += 1
+ preference.JSONData = &pref.PreferenceJSONData{}
+
+ if cmd.Navbar != nil {
+ preference.JSONData.Navbar = *cmd.Navbar
+ }
+ if cmd.QueryHistory != nil {
+ preference.JSONData.QueryHistory = *cmd.QueryHistory
+ }
+ return s.store.Update(ctx, preference)
+}
+
+func (s *Service) Patch(ctx context.Context, cmd *pref.PatchPreferenceCommand) error {
+ var exists bool
+ preference, err := s.store.Get(ctx, &pref.Preference{
+ OrgID: cmd.OrgID,
+ UserID: cmd.UserID,
+ TeamID: cmd.TeamID,
+ })
+ if err != nil && !errors.Is(err, pref.ErrPrefNotFound) {
+ return err
+ }
+
+ if errors.Is(err, pref.ErrPrefNotFound) {
+ preference = &pref.Preference{
+ UserID: cmd.UserID,
+ OrgID: cmd.OrgID,
+ TeamID: cmd.TeamID,
+ Created: time.Now(),
+ JSONData: &pref.PreferenceJSONData{},
+ }
+ } else {
+ exists = true
+ }
+
+ if cmd.Navbar != nil {
+ if preference.JSONData == nil {
+ preference.JSONData = &pref.PreferenceJSONData{}
+ }
+ if cmd.Navbar.SavedItems != nil {
+ preference.JSONData.Navbar.SavedItems = cmd.Navbar.SavedItems
+ }
+ }
+
+ if cmd.QueryHistory != nil {
+ if preference.JSONData == nil {
+ preference.JSONData = &pref.PreferenceJSONData{}
+ }
+ if cmd.QueryHistory.HomeTab != "" {
+ preference.JSONData.QueryHistory.HomeTab = cmd.QueryHistory.HomeTab
+ }
+ }
+
+ if cmd.HomeDashboardID != nil {
+ preference.HomeDashboardID = *cmd.HomeDashboardID
+ }
+
+ if cmd.Timezone != nil {
+ preference.Timezone = *cmd.Timezone
+ }
+
+ if cmd.WeekStart != nil {
+ preference.WeekStart = *cmd.WeekStart
+ }
+
+ if cmd.Theme != nil {
+ preference.Theme = *cmd.Theme
+ }
+
+ preference.Updated = time.Now()
+ preference.Version += 1
+
+ // Wrap this in an if statement to maintain backwards compatibility
+ if cmd.Navbar != nil {
+ if preference.JSONData == nil {
+ preference.JSONData = &pref.PreferenceJSONData{}
+ }
+ if cmd.Navbar.SavedItems != nil {
+ preference.JSONData.Navbar.SavedItems = cmd.Navbar.SavedItems
+ }
+ }
+
+ if exists {
+ err = s.store.Update(ctx, preference)
+ } else {
+ _, err = s.store.Insert(ctx, preference)
+ }
+ return err
+}
+
+func (s *Service) GetDefaults() *pref.Preference {
+ defaults := &pref.Preference{
+ Theme: s.cfg.DefaultTheme,
+ Timezone: s.cfg.DateFormats.DefaultTimezone,
+ WeekStart: s.cfg.DateFormats.DefaultWeekStart,
+ HomeDashboardID: 0,
+ JSONData: &pref.PreferenceJSONData{},
+ }
+
+ return defaults
+}
diff --git a/pkg/services/preference/prefimpl/pref_test.go b/pkg/services/preference/prefimpl/pref_test.go
new file mode 100644
index 00000000000..b1e439b6830
--- /dev/null
+++ b/pkg/services/preference/prefimpl/pref_test.go
@@ -0,0 +1,446 @@
+package prefimpl
+
+import (
+ "context"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ pref "github.com/grafana/grafana/pkg/services/preference"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPreferencesService(t *testing.T) {
+ prefStoreFake := newPreferenceStoreFake()
+ prefService := &Service{
+ store: prefStoreFake,
+ }
+
+ emptyNavbarPreferences := pref.NavbarPreference{}
+ userNavbarPreferences := pref.NavbarPreference{
+ SavedItems: []pref.NavLink{{
+ ID: "explore",
+ Text: "Explore",
+ Url: "/explore",
+ }},
+ }
+ orgNavbarPreferences := pref.NavbarPreference{
+ SavedItems: []pref.NavLink{{
+ ID: "alerting",
+ Text: "Alerting",
+ Url: "/alerting",
+ }},
+ }
+ team1NavbarPreferences := pref.NavbarPreference{
+ SavedItems: []pref.NavLink{{
+ ID: "dashboards",
+ Text: "Dashboards",
+ Url: "/dashboards",
+ }},
+ }
+ team2NavbarPreferences := pref.NavbarPreference{
+ SavedItems: []pref.NavLink{{
+ ID: "home",
+ Text: "Home",
+ Url: "/home",
+ }},
+ }
+
+ emptyQueryPreference := pref.QueryHistoryPreference{}
+
+ queryPreference := pref.QueryHistoryPreference{
+ HomeTab: "hometab",
+ }
+
+ queryPreference2 := pref.QueryHistoryPreference{
+ HomeTab: "hometab",
+ }
+
+ emptyPreferencesJsonData := pref.PreferenceJSONData{
+ Navbar: emptyNavbarPreferences,
+ }
+ userPreferencesJsonData := pref.PreferenceJSONData{
+ Navbar: userNavbarPreferences,
+ QueryHistory: queryPreference,
+ }
+ orgPreferencesJsonData := pref.PreferenceJSONData{
+ Navbar: orgNavbarPreferences,
+ }
+ team2PreferencesJsonData := pref.PreferenceJSONData{
+ Navbar: team2NavbarPreferences,
+ }
+ team1PreferencesJsonData := pref.PreferenceJSONData{
+ Navbar: team1NavbarPreferences,
+ }
+
+ t.Run("GetDefaults should return defaults", func(t *testing.T) {
+ prefService.cfg = setting.NewCfg()
+ prefService.cfg.DefaultTheme = "light"
+ prefService.cfg.DateFormats.DefaultTimezone = "UTC"
+
+ preferences := prefService.GetDefaults()
+ expected := &pref.Preference{
+ Theme: "light",
+ Timezone: "UTC",
+ HomeDashboardID: 0,
+ JSONData: &pref.PreferenceJSONData{},
+ }
+ if diff := cmp.Diff(expected, preferences); diff != "" {
+ t.Fatalf("Result mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("GetDefaults with no saved preferences should return defaults", func(t *testing.T) {
+ prefStoreFake.ExpectedPreference = &pref.Preference{
+ Theme: "light",
+ Timezone: "UTC",
+ }
+ query := &pref.GetPreferenceWithDefaultsQuery{}
+ preferences, err := prefService.GetWithDefaults(context.Background(), query)
+ require.NoError(t, err)
+ expected := &pref.Preference{
+ Theme: "light",
+ Timezone: "UTC",
+ HomeDashboardID: 0,
+ JSONData: &emptyPreferencesJsonData,
+ }
+ if diff := cmp.Diff(expected, preferences); diff != "" {
+ t.Fatalf("Result mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("GetWithDefaults with saved org and user home dashboard should return user home dashboard", func(t *testing.T) {
+ prefStoreFake.ExpectedPreference = &pref.Preference{}
+ prefStoreFake.ExpectedListPreferences = []*pref.Preference{
+ {
+ OrgID: 1,
+ HomeDashboardID: 1,
+ Theme: "dark",
+ Timezone: "UTC",
+ },
+ {
+ OrgID: 1,
+ UserID: 1,
+ HomeDashboardID: 4,
+ Theme: "light",
+ WeekStart: "1",
+ },
+ }
+ query := &pref.GetPreferenceWithDefaultsQuery{OrgID: 1, UserID: 1}
+ preferences, err := prefService.GetWithDefaults(context.Background(), query)
+ require.NoError(t, err)
+ expected := &pref.Preference{
+ Theme: "light",
+ Timezone: "UTC",
+ WeekStart: "1",
+ HomeDashboardID: 4,
+ JSONData: &pref.PreferenceJSONData{},
+ }
+ if diff := cmp.Diff(expected, preferences); diff != "" {
+ t.Fatalf("Result mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("GetWithDefaults with saved org and other user home dashboard should return org home dashboard", func(t *testing.T) {
+ prefStoreFake.ExpectedPreference = &pref.Preference{}
+ prefStoreFake.ExpectedListPreferences = []*pref.Preference{
+ {
+ OrgID: 1,
+ HomeDashboardID: 1,
+ Theme: "dark",
+ Timezone: "UTC",
+ WeekStart: "1",
+ },
+ {
+ OrgID: 1,
+ UserID: 1,
+ HomeDashboardID: 4,
+ Theme: "light",
+ Timezone: "browser",
+ WeekStart: "2",
+ },
+ }
+ prefService.GetDefaults().HomeDashboardID = 1
+ query := &pref.GetPreferenceWithDefaultsQuery{OrgID: 1, UserID: 2}
+ preferences, err := prefService.GetWithDefaults(context.Background(), query)
+ require.NoError(t, err)
+ expected := &pref.Preference{
+ Theme: "light",
+ Timezone: "browser",
+ WeekStart: "2",
+ HomeDashboardID: 4,
+ JSONData: &pref.PreferenceJSONData{},
+ }
+ if diff := cmp.Diff(expected, preferences); diff != "" {
+ t.Fatalf("Result mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("GetPreferencesWithDefaults with saved org and user json data should return user json data", func(t *testing.T) {
+ prefStoreFake.ExpectedPreference = &pref.Preference{}
+ prefStoreFake.ExpectedListPreferences = []*pref.Preference{
+ {
+ OrgID: 1,
+ JSONData: &orgPreferencesJsonData,
+ },
+ {
+ OrgID: 1,
+ UserID: 1,
+ JSONData: &userPreferencesJsonData,
+ },
+ }
+ query := &pref.GetPreferenceWithDefaultsQuery{OrgID: 1, UserID: 1}
+ preference, err := prefService.GetWithDefaults(context.Background(), query)
+ require.NoError(t, err)
+ require.Equal(t, &pref.Preference{
+ Theme: "light",
+ JSONData: &userPreferencesJsonData,
+ Timezone: "UTC",
+ }, preference)
+ })
+
+ t.Run("GetWithDefaults with saved org and teams json data should return last team json data", func(t *testing.T) {
+ prefStoreFake.ExpectedPreference = &pref.Preference{}
+ prefStoreFake.ExpectedListPreferences = []*pref.Preference{
+ {
+ OrgID: 1,
+ JSONData: &orgPreferencesJsonData,
+ },
+ {
+ OrgID: 1,
+ TeamID: 2,
+ JSONData: &team1PreferencesJsonData,
+ },
+ {
+ OrgID: 1,
+ TeamID: 3,
+ JSONData: &team2PreferencesJsonData,
+ },
+ }
+ query := &pref.GetPreferenceWithDefaultsQuery{
+ OrgID: 1, Teams: []int64{2, 3},
+ }
+ preference, err := prefService.GetWithDefaults(context.Background(), query)
+ require.NoError(t, err)
+ require.Equal(t, &pref.Preference{
+ Timezone: "UTC",
+ Theme: "light",
+ JSONData: &team2PreferencesJsonData,
+ }, preference)
+ })
+
+ t.Run("GetWithDefaults with saved org and teams home dashboard should return last team home dashboard", func(t *testing.T) {
+ prefStoreFake.ExpectedPreference = &pref.Preference{
+ Theme: "dark",
+ Timezone: "UTC",
+ }
+ prefStoreFake.ExpectedListPreferences = []*pref.Preference{
+ {
+ OrgID: 1,
+ HomeDashboardID: 1,
+ Theme: "light",
+ Timezone: "browser",
+ WeekStart: "1",
+ },
+ {
+ OrgID: 1,
+ UserID: 1,
+ HomeDashboardID: 4,
+ Theme: "light",
+ Timezone: "browser",
+ WeekStart: "2",
+ },
+ }
+
+ query := &pref.GetPreferenceWithDefaultsQuery{OrgID: 1, Teams: []int64{2, 3}}
+ preferences, err := prefService.GetWithDefaults(context.Background(), query)
+ require.NoError(t, err)
+ expected := &pref.Preference{
+ Theme: "light",
+ Timezone: "browser",
+ WeekStart: "2",
+ HomeDashboardID: 4,
+ JSONData: &pref.PreferenceJSONData{},
+ }
+ if diff := cmp.Diff(expected, preferences); diff != "" {
+ t.Fatalf("Result mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("SavePreferences for a user should store correct values", func(t *testing.T) {
+ prefStoreFake.ExpectedPreference = &pref.Preference{
+ ID: 1,
+ OrgID: 1,
+ UserID: 3,
+ TeamID: 6,
+ HomeDashboardID: 5,
+ Timezone: "browser",
+ WeekStart: "1",
+ Theme: "dark",
+ }
+ err := prefService.Save(context.Background(),
+ &pref.SavePreferenceCommand{
+ Theme: "dark",
+ Timezone: "browser",
+ HomeDashboardID: 5,
+ WeekStart: "1"},
+ )
+ require.NoError(t, err)
+ })
+
+ t.Run("SavePreferences for a user should store correct values, when preference not found", func(t *testing.T) {
+ prefStoreFake.ExpectedGetError = pref.ErrPrefNotFound
+ prefStoreFake.ExpectedPreference = &pref.Preference{
+ ID: 1,
+ OrgID: 1,
+ UserID: 3,
+ TeamID: 6,
+ HomeDashboardID: 5,
+ Timezone: "browser",
+ WeekStart: "1",
+ Theme: "dark",
+ }
+ err := prefService.Save(context.Background(),
+ &pref.SavePreferenceCommand{
+ Theme: "dark",
+ Timezone: "browser",
+ HomeDashboardID: 5,
+ WeekStart: "1",
+ },
+ )
+ require.NoError(t, err)
+ prefStoreFake.ExpectedGetError = nil
+ })
+
+ t.Run("SavePreferences for a user should store correct values with nav and query history", func(t *testing.T) {
+ prefStoreFake.ExpectedPreference = &pref.Preference{
+ ID: 1,
+ OrgID: 1,
+ UserID: 3,
+ TeamID: 6,
+ HomeDashboardID: 5,
+ Timezone: "browser",
+ WeekStart: "1",
+ Theme: "dark",
+ JSONData: &userPreferencesJsonData,
+ }
+ err := prefService.Save(context.Background(),
+ &pref.SavePreferenceCommand{
+ Theme: "dark",
+ Timezone: "browser",
+ HomeDashboardID: 5,
+ WeekStart: "1",
+ Navbar: &userNavbarPreferences,
+ QueryHistory: &emptyQueryPreference,
+ },
+ )
+ require.NoError(t, err)
+ })
+
+ t.Run("Get for a user should store correct values", func(t *testing.T) {
+ prefStoreFake.ExpectedPreference = &pref.Preference{
+ HomeDashboardID: 5,
+ Timezone: "browser",
+ WeekStart: "1",
+ Theme: "dark",
+ }
+ preference, err := prefService.Get(context.Background(), &pref.GetPreferenceQuery{})
+ require.NoError(t, err)
+
+ expected := &pref.Preference{
+ ID: preference.ID,
+ Version: preference.Version,
+ HomeDashboardID: 5,
+ Timezone: "browser",
+ WeekStart: "1",
+ Theme: "dark",
+ Created: preference.Created,
+ Updated: preference.Updated,
+ }
+ if diff := cmp.Diff(expected, preference); diff != "" {
+ t.Fatalf("Result mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("Patch for a user should store correct values", func(t *testing.T) {
+ darkTheme := "dark"
+ prefStoreFake.ExpectedPreference = &pref.Preference{
+ HomeDashboardID: 5,
+ Timezone: "browser",
+ WeekStart: "1",
+ Theme: "dark",
+ JSONData: &userPreferencesJsonData,
+ }
+ err := prefService.Patch(context.Background(),
+ &pref.PatchPreferenceCommand{
+ Theme: &darkTheme,
+ Navbar: &userNavbarPreferences,
+ QueryHistory: &queryPreference2,
+ })
+ require.NoError(t, err)
+ })
+
+ t.Run("Patch for a user should store correct values, without navbar and query history", func(t *testing.T) {
+ darkTheme := "dark"
+ prefStoreFake.ExpectedPreference = &pref.Preference{
+ HomeDashboardID: 5,
+ Timezone: "browser",
+ WeekStart: "1",
+ Theme: "dark",
+ }
+ err := prefService.Patch(context.Background(),
+ &pref.PatchPreferenceCommand{
+ Theme: &darkTheme,
+ Navbar: &userNavbarPreferences,
+ QueryHistory: &queryPreference2,
+ })
+ require.NoError(t, err)
+ })
+
+ t.Run("Patch for a user should store correct values, when preference not found", func(t *testing.T) {
+ timezone := "browser"
+ weekStart := "1"
+ homeDashboardID := int64(5)
+ prefStoreFake.ExpectedGetError = pref.ErrPrefNotFound
+ prefStoreFake.ExpectedPreference = nil
+
+ err := prefService.Patch(context.Background(),
+ &pref.PatchPreferenceCommand{
+ HomeDashboardID: &homeDashboardID,
+ Timezone: &timezone,
+ WeekStart: &weekStart,
+ Navbar: &emptyNavbarPreferences,
+ QueryHistory: &emptyQueryPreference,
+ })
+ require.NoError(t, err)
+ prefStoreFake.ExpectedGetError = nil
+ })
+}
+
+type FakePreferenceStore struct {
+ ExpectedPreference *pref.Preference
+ ExpectedListPreferences []*pref.Preference
+ ExpectedID int64
+ ExpectedError error
+ ExpectedGetError error
+}
+
+func newPreferenceStoreFake() *FakePreferenceStore {
+ return &FakePreferenceStore{}
+}
+
+func (f *FakePreferenceStore) List(ctx context.Context, query *pref.Preference) ([]*pref.Preference, error) {
+ return f.ExpectedListPreferences, f.ExpectedError
+}
+
+func (f *FakePreferenceStore) Get(ctx context.Context, query *pref.Preference) (*pref.Preference, error) {
+ return f.ExpectedPreference, f.ExpectedGetError
+}
+
+func (f *FakePreferenceStore) Insert(ctx context.Context, cmd *pref.Preference) (int64, error) {
+ return f.ExpectedID, f.ExpectedError
+}
+
+func (f *FakePreferenceStore) Update(ctx context.Context, cmd *pref.Preference) error {
+ return f.ExpectedError
+}
diff --git a/pkg/services/preference/prefimpl/store.go b/pkg/services/preference/prefimpl/store.go
new file mode 100644
index 00000000000..139b723363b
--- /dev/null
+++ b/pkg/services/preference/prefimpl/store.go
@@ -0,0 +1,88 @@
+package prefimpl
+
+import (
+ "context"
+ "strings"
+
+ pref "github.com/grafana/grafana/pkg/services/preference"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/services/sqlstore/db"
+)
+
+type store interface {
+ Get(context.Context, *pref.Preference) (*pref.Preference, error)
+ List(context.Context, *pref.Preference) ([]*pref.Preference, error)
+ Insert(context.Context, *pref.Preference) (int64, error)
+ Update(context.Context, *pref.Preference) error
+}
+
+type sqlStore struct {
+ db db.DB
+}
+
+func (s *sqlStore) Get(ctx context.Context, query *pref.Preference) (*pref.Preference, error) {
+ var prefs pref.Preference
+ err := s.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
+ exist, err := sess.Where("org_id=? AND user_id=? AND team_id=?", query.OrgID, query.UserID, query.TeamID).Get(&prefs)
+ if err != nil {
+ return err
+ }
+ if !exist {
+ return pref.ErrPrefNotFound
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &prefs, nil
+}
+
+func (s *sqlStore) List(ctx context.Context, query *pref.Preference) ([]*pref.Preference, error) {
+ prefs := make([]*pref.Preference, 0)
+ params := make([]interface{}, 0)
+ filter := ""
+
+ if len(query.Teams) > 0 {
+ filter = "(org_id=? AND team_id IN (?" + strings.Repeat(",?", len(query.Teams)-1) + ")) OR "
+ params = append(params, query.OrgID)
+ for _, v := range query.Teams {
+ params = append(params, v)
+ }
+ }
+
+ filter += "(org_id=? AND user_id=? AND team_id=0) OR (org_id=? AND team_id=0 AND user_id=0)"
+ params = append(params, query.OrgID)
+ params = append(params, query.UserID)
+ params = append(params, query.OrgID)
+
+ err := s.db.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
+ err := dbSession.Where(filter, params...).
+ OrderBy("user_id ASC, team_id ASC").
+ Find(&prefs)
+
+ if err != nil {
+ return err
+ }
+
+ return nil
+ })
+ return prefs, err
+}
+
+func (s *sqlStore) Update(ctx context.Context, cmd *pref.Preference) error {
+ return s.db.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
+ _, err := sess.ID(cmd.ID).AllCols().Update(cmd)
+ return err
+ })
+}
+
+func (s *sqlStore) Insert(ctx context.Context, cmd *pref.Preference) (int64, error) {
+ var ID int64
+ var err error
+ err = s.db.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
+ ID, err = sess.Insert(cmd)
+ return err
+ })
+ return ID, err
+}
diff --git a/pkg/services/preference/prefimpl/store_test.go b/pkg/services/preference/prefimpl/store_test.go
new file mode 100644
index 00000000000..773b6dea57b
--- /dev/null
+++ b/pkg/services/preference/prefimpl/store_test.go
@@ -0,0 +1,170 @@
+//go:build integration
+// +build integration
+
+package prefimpl
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/grafana/grafana/pkg/models"
+ pref "github.com/grafana/grafana/pkg/services/preference"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPreferencesDataAccess(t *testing.T) {
+ ss := sqlstore.InitTestDB(t)
+ prefStore := sqlStore{db: ss}
+ orgNavbarPreferences := pref.NavbarPreference{
+ SavedItems: []pref.NavLink{{
+ ID: "alerting",
+ Text: "Alerting",
+ Url: "/alerting",
+ }},
+ }
+
+ t.Run("Get with saved org and user home dashboard returns not found", func(t *testing.T) {
+ query := &pref.Preference{OrgID: 1, UserID: 1, TeamID: 2}
+ prefs, err := prefStore.Get(context.Background(), query)
+ require.EqualError(t, err, pref.ErrPrefNotFound.Error())
+ require.Nil(t, prefs)
+ })
+
+ t.Run("Get with saved org and user home dashboard should return user home dashboard", func(t *testing.T) {
+ _, err := prefStore.Insert(context.Background(),
+ &pref.Preference{
+ OrgID: 1,
+ UserID: 1,
+ HomeDashboardID: 4,
+ TeamID: 2,
+ Created: time.Now(),
+ Updated: time.Now(),
+ })
+ require.NoError(t, err)
+
+ query := &pref.Preference{OrgID: 1, UserID: 1, TeamID: 2}
+ prefs, err := prefStore.Get(context.Background(), query)
+ require.NoError(t, err)
+ require.Equal(t, int64(4), prefs.HomeDashboardID)
+ })
+
+ t.Run("List with saved org and user home dashboard should return user home dashboard", func(t *testing.T) {
+ _, err := prefStore.Insert(context.Background(),
+ &pref.Preference{
+ OrgID: 1,
+ UserID: 1,
+ TeamID: 3,
+ HomeDashboardID: 1,
+ Created: time.Now(),
+ Updated: time.Now(),
+ })
+ require.NoError(t, err)
+
+ query := &pref.Preference{OrgID: 1, UserID: 1, Teams: []int64{2}}
+ prefs, err := prefStore.List(context.Background(), query)
+ require.NoError(t, err)
+ require.Equal(t, int64(4), prefs[0].HomeDashboardID)
+ })
+
+ t.Run("List with saved org and other user home dashboard should return org home dashboard", func(t *testing.T) {
+ _, err := prefStore.Insert(context.Background(),
+ &pref.Preference{
+ OrgID: 1,
+ UserID: 2,
+ TeamID: 3,
+ HomeDashboardID: 1,
+ Created: time.Now(),
+ Updated: time.Now(),
+ })
+ require.NoError(t, err)
+
+ query := &pref.Preference{OrgID: 1, UserID: 1, Teams: []int64{3}}
+ prefs, err := prefStore.List(context.Background(), query)
+ require.NoError(t, err)
+ require.Equal(t, int64(1), prefs[0].HomeDashboardID)
+ require.Equal(t, int64(1), prefs[1].HomeDashboardID)
+ })
+
+ t.Run("List with saved org and teams home dashboard should return last team home dashboard", func(t *testing.T) {
+ query := &pref.Preference{
+ OrgID: 1, Teams: []int64{2, 3},
+ }
+ prefs, err := prefStore.List(context.Background(), query)
+ require.NoError(t, err)
+ require.Equal(t, int64(4), prefs[0].HomeDashboardID)
+ require.Equal(t, int64(1), prefs[1].HomeDashboardID)
+ require.Equal(t, int64(1), prefs[2].HomeDashboardID)
+ })
+
+ t.Run("List with saved org and other teams home dashboard should return org home dashboard", func(t *testing.T) {
+ _, err := prefStore.Insert(context.Background(), &pref.Preference{OrgID: 1, HomeDashboardID: 1, Created: time.Now(), Updated: time.Now()})
+ require.NoError(t, err)
+ _, err = prefStore.Insert(context.Background(), &pref.Preference{OrgID: 1, TeamID: 2, HomeDashboardID: 2, Created: time.Now(), Updated: time.Now()})
+ require.NoError(t, err)
+ _, err = prefStore.Insert(context.Background(), &pref.Preference{OrgID: 1, TeamID: 3, HomeDashboardID: 3, Created: time.Now(), Updated: time.Now()})
+ require.NoError(t, err)
+
+ query := &pref.Preference{OrgID: 1}
+ prefs, err := prefStore.List(context.Background(), query)
+ require.NoError(t, err)
+ require.Equal(t, int64(1), prefs[0].HomeDashboardID)
+ })
+
+ t.Run("Update for a user should only modify a single value", func(t *testing.T) {
+ ss := sqlstore.InitTestDB(t)
+ prefStore := sqlStore{db: ss}
+ id, err := prefStore.Insert(context.Background(), &pref.Preference{
+ UserID: models.SignedInUser{}.UserId,
+ Theme: "dark",
+ Timezone: "browser",
+ HomeDashboardID: 5,
+ WeekStart: "1",
+ JSONData: &pref.PreferenceJSONData{Navbar: orgNavbarPreferences},
+ Created: time.Now(),
+ Updated: time.Now(),
+ })
+ require.NoError(t, err)
+
+ err = prefStore.Update(context.Background(), &pref.Preference{
+ ID: id,
+ Theme: "dark",
+ HomeDashboardID: 5,
+ Timezone: "browser",
+ WeekStart: "1",
+ Created: time.Now(),
+ Updated: time.Now(),
+ JSONData: &pref.PreferenceJSONData{},
+ })
+ require.NoError(t, err)
+ query := &pref.Preference{}
+ prefs, err := prefStore.List(context.Background(), query)
+ require.NoError(t, err)
+ expected := &pref.Preference{
+ ID: prefs[0].ID,
+ Version: prefs[0].Version,
+ HomeDashboardID: 5,
+ Timezone: "browser",
+ WeekStart: "1",
+ Theme: "dark",
+ JSONData: prefs[0].JSONData,
+ Created: prefs[0].Created,
+ Updated: prefs[0].Updated,
+ }
+ if diff := cmp.Diff(expected, prefs[0]); diff != "" {
+ t.Fatalf("Result mismatch (-want +got):\n%s", diff)
+ }
+ })
+ t.Run("insert preference that does not exist", func(t *testing.T) {
+ _, err := prefStore.Insert(context.Background(),
+ &pref.Preference{
+ UserID: models.SignedInUser{}.UserId,
+ Created: time.Now(),
+ Updated: time.Now(),
+ JSONData: &pref.PreferenceJSONData{},
+ })
+ require.NoError(t, err)
+ })
+}
diff --git a/pkg/services/preference/preftest/fake.go b/pkg/services/preference/preftest/fake.go
new file mode 100644
index 00000000000..f52acea2047
--- /dev/null
+++ b/pkg/services/preference/preftest/fake.go
@@ -0,0 +1,32 @@
+package preftest
+
+import (
+ "context"
+
+ pref "github.com/grafana/grafana/pkg/services/preference"
+)
+
+type FakePreferenceService struct {
+ ExpectedPreference *pref.Preference
+ ExpectedError error
+}
+
+func NewPreferenceServiceFake() *FakePreferenceService {
+ return &FakePreferenceService{}
+}
+
+func (f *FakePreferenceService) GetWithDefaults(ctx context.Context, query *pref.GetPreferenceWithDefaultsQuery) (*pref.Preference, error) {
+ return f.ExpectedPreference, f.ExpectedError
+}
+
+func (f *FakePreferenceService) Get(ctx context.Context, query *pref.GetPreferenceQuery) (*pref.Preference, error) {
+ return f.ExpectedPreference, f.ExpectedError
+}
+
+func (f *FakePreferenceService) Save(ctx context.Context, cmd *pref.SavePreferenceCommand) error {
+ return f.ExpectedError
+}
+
+func (f *FakePreferenceService) GetDefaults() *pref.Preference {
+ return f.ExpectedPreference
+}
diff --git a/pkg/services/provisioning/notifiers/alert_notifications.go b/pkg/services/provisioning/notifiers/alert_notifications.go
index aac68649cda..de1621a7177 100644
--- a/pkg/services/provisioning/notifiers/alert_notifications.go
+++ b/pkg/services/provisioning/notifiers/alert_notifications.go
@@ -1,11 +1,12 @@
package notifiers
import (
+ "context"
+
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/encryption"
"github.com/grafana/grafana/pkg/services/notifications"
- "golang.org/x/net/context"
)
type Manager interface {
diff --git a/pkg/services/queryhistory/api.go b/pkg/services/queryhistory/api.go
index dc2245407f4..fd719cf7b9e 100644
--- a/pkg/services/queryhistory/api.go
+++ b/pkg/services/queryhistory/api.go
@@ -19,6 +19,8 @@ func (s *QueryHistoryService) registerAPIEndpoints() {
entities.Post("/star/:uid", middleware.ReqSignedIn, routing.Wrap(s.starHandler))
entities.Delete("/star/:uid", middleware.ReqSignedIn, routing.Wrap(s.unstarHandler))
entities.Patch("/:uid", middleware.ReqSignedIn, routing.Wrap(s.patchCommentHandler))
+ // Remove migrate endpoint in Grafana v10 as breaking change
+ entities.Post("/migrate", middleware.ReqSignedIn, routing.Wrap(s.migrateHandler))
})
}
@@ -117,3 +119,17 @@ func (s *QueryHistoryService) unstarHandler(c *models.ReqContext) response.Respo
return response.JSON(http.StatusOK, QueryHistoryResponse{Result: query})
}
+
+func (s *QueryHistoryService) migrateHandler(c *models.ReqContext) response.Response {
+ cmd := MigrateQueriesToQueryHistoryCommand{}
+ if err := web.Bind(c.Req, &cmd); err != nil {
+ return response.Error(http.StatusBadRequest, "bad request data", err)
+ }
+
+ totalCount, starredCount, err := s.MigrateQueriesToQueryHistory(c.Req.Context(), c.SignedInUser, cmd)
+ if err != nil {
+ return response.Error(http.StatusInternalServerError, "Failed to migrate query history", err)
+ }
+
+ return response.JSON(http.StatusOK, QueryHistoryMigrationResponse{Message: "Query history successfully migrated", TotalCount: totalCount, StarredCount: starredCount})
+}
diff --git a/pkg/services/queryhistory/database.go b/pkg/services/queryhistory/database.go
index edb16de741f..3f718a6b69a 100644
--- a/pkg/services/queryhistory/database.go
+++ b/pkg/services/queryhistory/database.go
@@ -3,6 +3,7 @@ package queryhistory
import (
"context"
"errors"
+ "fmt"
"time"
"github.com/grafana/grafana/pkg/models"
@@ -265,3 +266,61 @@ func (s QueryHistoryService) unstarQuery(ctx context.Context, user *models.Signe
return dto, nil
}
+
+func (s QueryHistoryService) migrateQueries(ctx context.Context, user *models.SignedInUser, cmd MigrateQueriesToQueryHistoryCommand) (int, int, error) {
+ queryHistories := make([]*QueryHistory, 0, len(cmd.Queries))
+ starredQueries := make([]*QueryHistoryStar, 0)
+
+ err := s.SQLStore.WithTransactionalDbSession(ctx, func(session *sqlstore.DBSession) error {
+ for _, query := range cmd.Queries {
+ uid := util.GenerateShortUID()
+ queryHistories = append(queryHistories, &QueryHistory{
+ OrgID: user.OrgId,
+ UID: uid,
+ Queries: query.Queries,
+ DatasourceUID: query.DatasourceUID,
+ CreatedBy: user.UserId,
+ CreatedAt: query.CreatedAt,
+ Comment: query.Comment,
+ })
+
+ if query.Starred {
+ starredQueries = append(starredQueries, &QueryHistoryStar{
+ UserID: user.UserId,
+ QueryUID: uid,
+ })
+ }
+ }
+
+ batchSize := 50
+ var err error
+ for i := 0; i < len(queryHistories); i += batchSize {
+ j := i + batchSize
+ if j > len(queryHistories) {
+ j = len(queryHistories)
+ }
+ _, err = session.InsertMulti(queryHistories[i:j])
+ if err != nil {
+ return err
+ }
+ }
+
+ for i := 0; i < len(starredQueries); i += batchSize {
+ j := i + batchSize
+ if j > len(starredQueries) {
+ j = len(starredQueries)
+ }
+ _, err = session.InsertMulti(starredQueries[i:j])
+ if err != nil {
+ return err
+ }
+ }
+ return err
+ })
+
+ if err != nil {
+ return 0, 0, fmt.Errorf("failed to migrate query history: %w", err)
+ }
+
+ return len(queryHistories), len(starredQueries), nil
+}
diff --git a/pkg/services/queryhistory/models.go b/pkg/services/queryhistory/models.go
index 7dfc9de69a0..19d7d77ad5c 100644
--- a/pkg/services/queryhistory/models.go
+++ b/pkg/services/queryhistory/models.go
@@ -78,3 +78,21 @@ type DeleteQueryFromQueryHistoryResponse struct {
ID int64 `json:"id"`
Message string `json:"message"`
}
+
+type MigrateQueriesToQueryHistoryCommand struct {
+ Queries []QueryToMigrate `json:"queries"`
+}
+
+type QueryToMigrate struct {
+ DatasourceUID string `json:"datasourceUid"`
+ Queries *simplejson.Json `json:"queries"`
+ CreatedAt int64 `json:"createdAt"`
+ Comment string `json:"comment"`
+ Starred bool `json:"starred"`
+}
+
+type QueryHistoryMigrationResponse struct {
+ Message string `json:"message"`
+ TotalCount int `json:"totalCount"`
+ StarredCount int `json:"starredCount"`
+}
diff --git a/pkg/services/queryhistory/queryhistory.go b/pkg/services/queryhistory/queryhistory.go
index bd8c88ab826..c3c1290cb00 100644
--- a/pkg/services/queryhistory/queryhistory.go
+++ b/pkg/services/queryhistory/queryhistory.go
@@ -33,6 +33,7 @@ type Service interface {
PatchQueryCommentInQueryHistory(ctx context.Context, user *models.SignedInUser, UID string, cmd PatchQueryCommentInQueryHistoryCommand) (QueryHistoryDTO, error)
StarQueryInQueryHistory(ctx context.Context, user *models.SignedInUser, UID string) (QueryHistoryDTO, error)
UnstarQueryInQueryHistory(ctx context.Context, user *models.SignedInUser, UID string) (QueryHistoryDTO, error)
+ MigrateQueriesToQueryHistory(ctx context.Context, user *models.SignedInUser, cmd MigrateQueriesToQueryHistoryCommand) (int, int, error)
}
type QueryHistoryService struct {
@@ -65,3 +66,7 @@ func (s QueryHistoryService) StarQueryInQueryHistory(ctx context.Context, user *
func (s QueryHistoryService) UnstarQueryInQueryHistory(ctx context.Context, user *models.SignedInUser, UID string) (QueryHistoryDTO, error) {
return s.unstarQuery(ctx, user, UID)
}
+
+func (s QueryHistoryService) MigrateQueriesToQueryHistory(ctx context.Context, user *models.SignedInUser, cmd MigrateQueriesToQueryHistoryCommand) (int, int, error) {
+ return s.migrateQueries(ctx, user, cmd)
+}
diff --git a/pkg/services/queryhistory/queryhistory_migrate_test.go b/pkg/services/queryhistory/queryhistory_migrate_test.go
new file mode 100644
index 00000000000..eb896dc9b33
--- /dev/null
+++ b/pkg/services/queryhistory/queryhistory_migrate_test.go
@@ -0,0 +1,120 @@
+//go:build integration
+// +build integration
+
+package queryhistory
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMigrateQueriesToQueryHistory(t *testing.T) {
+ testScenario(t, "When users tries to migrate 1 query in query history it should succeed",
+ func(t *testing.T, sc scenarioContext) {
+ command := MigrateQueriesToQueryHistoryCommand{
+ Queries: []QueryToMigrate{
+ {
+ DatasourceUID: "NCzh67i",
+ Queries: simplejson.NewFromAny(map[string]interface{}{
+ "expr": "test",
+ }),
+ Comment: "",
+ Starred: false,
+ CreatedAt: time.Now().Unix(),
+ },
+ },
+ }
+ sc.reqContext.Req.Body = mockRequestBody(command)
+ resp := sc.service.migrateHandler(sc.reqContext)
+ var response QueryHistoryMigrationResponse
+ err := json.Unmarshal(resp.Body(), &response)
+ require.NoError(t, err)
+ require.Equal(t, 200, resp.Status())
+ require.Equal(t, "Query history successfully migrated", response.Message)
+ require.Equal(t, 1, response.TotalCount)
+ require.Equal(t, 0, response.StarredCount)
+ })
+
+ testScenario(t, "When users tries to migrate multiple queries in query history it should succeed",
+ func(t *testing.T, sc scenarioContext) {
+ command := MigrateQueriesToQueryHistoryCommand{
+ Queries: []QueryToMigrate{
+ {
+ DatasourceUID: "NCzh67i",
+ Queries: simplejson.NewFromAny(map[string]interface{}{
+ "expr": "test1",
+ }),
+ Comment: "",
+ Starred: false,
+ CreatedAt: time.Now().Unix(),
+ },
+ {
+ DatasourceUID: "NCzh67i",
+ Queries: simplejson.NewFromAny(map[string]interface{}{
+ "expr": "test2",
+ }),
+ Comment: "",
+ Starred: false,
+ CreatedAt: time.Now().Unix() - int64(100),
+ },
+ {
+ DatasourceUID: "ABch68f",
+ Queries: simplejson.NewFromAny(map[string]interface{}{
+ "expr": "test3",
+ }),
+ Comment: "",
+ Starred: false,
+ CreatedAt: time.Now().Unix() - int64(1000),
+ },
+ },
+ }
+ sc.reqContext.Req.Body = mockRequestBody(command)
+ resp := sc.service.migrateHandler(sc.reqContext)
+ var response QueryHistoryMigrationResponse
+ err := json.Unmarshal(resp.Body(), &response)
+ require.NoError(t, err)
+ require.Equal(t, 200, resp.Status())
+ require.Equal(t, "Query history successfully migrated", response.Message)
+ require.Equal(t, 3, response.TotalCount)
+ require.Equal(t, 0, response.StarredCount)
+ })
+
+ testScenario(t, "When users tries to migrate starred and not starred query in query history it should succeed",
+ func(t *testing.T, sc scenarioContext) {
+ command := MigrateQueriesToQueryHistoryCommand{
+ Queries: []QueryToMigrate{
+ {
+ DatasourceUID: "NCzh67i",
+ Queries: simplejson.NewFromAny(map[string]interface{}{
+ "expr": "test1",
+ }),
+ Comment: "",
+ Starred: true,
+ CreatedAt: time.Now().Unix(),
+ },
+ {
+ DatasourceUID: "NCzh67i",
+ Queries: simplejson.NewFromAny(map[string]interface{}{
+ "expr": "test2",
+ }),
+ Comment: "",
+ Starred: false,
+ CreatedAt: time.Now().Unix() - int64(100),
+ },
+ },
+ }
+ sc.reqContext.Req.Body = mockRequestBody(command)
+ resp := sc.service.migrateHandler(sc.reqContext)
+ var response QueryHistoryMigrationResponse
+ err := json.Unmarshal(resp.Body(), &response)
+ require.NoError(t, err)
+ require.Equal(t, 200, resp.Status())
+ require.Equal(t, "Query history successfully migrated", response.Message)
+ require.Equal(t, 2, response.TotalCount)
+ require.Equal(t, 1, response.StarredCount)
+ })
+}
diff --git a/pkg/services/rendering/http_mode.go b/pkg/services/rendering/http_mode.go
index e8820b9e1f9..b086a36b787 100644
--- a/pkg/services/rendering/http_mode.go
+++ b/pkg/services/rendering/http_mode.go
@@ -46,7 +46,8 @@ func (rs *RenderingService) renderViaHTTP(ctx context.Context, renderKey string,
}
queryParams := rendererURL.Query()
- queryParams.Add("url", rs.getURL(opts.Path))
+ url := rs.getURL(opts.Path)
+ queryParams.Add("url", url)
queryParams.Add("renderKey", renderKey)
queryParams.Add("width", strconv.Itoa(opts.Width))
queryParams.Add("height", strconv.Itoa(opts.Height))
@@ -74,7 +75,7 @@ func (rs *RenderingService) renderViaHTTP(ctx context.Context, renderKey string,
}
}()
- err = rs.readFileResponse(reqContext, resp, filePath)
+ err = rs.readFileResponse(reqContext, resp, filePath, url)
if err != nil {
return nil, err
}
@@ -94,7 +95,8 @@ func (rs *RenderingService) renderCSVViaHTTP(ctx context.Context, renderKey stri
}
queryParams := rendererURL.Query()
- queryParams.Add("url", rs.getURL(opts.Path))
+ url := rs.getURL(opts.Path)
+ queryParams.Add("url", url)
queryParams.Add("renderKey", renderKey)
queryParams.Add("domain", rs.domain)
queryParams.Add("timezone", isoTimeOffsetToPosixTz(opts.Timezone))
@@ -125,7 +127,7 @@ func (rs *RenderingService) renderCSVViaHTTP(ctx context.Context, renderKey stri
}
downloadFileName := params["filename"]
- err = rs.readFileResponse(reqContext, resp, filePath)
+ err = rs.readFileResponse(reqContext, resp, filePath, url)
if err != nil {
return nil, err
}
@@ -156,7 +158,7 @@ func (rs *RenderingService) doRequest(ctx context.Context, url *url.URL, headers
return resp, nil
}
-func (rs *RenderingService) readFileResponse(ctx context.Context, resp *http.Response, filePath string) error {
+func (rs *RenderingService) readFileResponse(ctx context.Context, resp *http.Response, filePath string, url string) error {
// check for timeout first
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
rs.log.Info("Rendering timed out")
@@ -165,7 +167,7 @@ func (rs *RenderingService) readFileResponse(ctx context.Context, resp *http.Res
// if we didn't get a 200 response, something went wrong.
if resp.StatusCode != http.StatusOK {
- rs.log.Error("Remote rendering request failed", "error", resp.Status)
+ rs.log.Error("Remote rendering request failed", "error", resp.Status, "url", url)
return fmt.Errorf("remote rendering request failed, status code: %d, status: %s", resp.StatusCode,
resp.Status)
}
diff --git a/pkg/services/search/service.go b/pkg/services/search/service.go
index 1656f6ee123..809e5ba8c11 100644
--- a/pkg/services/search/service.go
+++ b/pkg/services/search/service.go
@@ -7,14 +7,12 @@ import (
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
)
-func ProvideService(cfg *setting.Cfg, bus bus.Bus, sqlstore *sqlstore.SQLStore) *SearchService {
+func ProvideService(cfg *setting.Cfg, sqlstore *sqlstore.SQLStore) *SearchService {
s := &SearchService{
Cfg: cfg,
- Bus: bus,
sortOptions: map[string]models.SortOption{
SortAlphaAsc.Name: SortAlphaAsc,
SortAlphaDesc.Name: SortAlphaDesc,
@@ -47,7 +45,6 @@ type Service interface {
}
type SearchService struct {
- Bus bus.Bus
Cfg *setting.Cfg
sortOptions map[string]models.SortOption
sqlstore sqlstore.Store
diff --git a/pkg/services/searchV2/extract/dashboard_test.go b/pkg/services/searchV2/extract/dashboard_test.go
index 34270bec827..0bc3a17090b 100644
--- a/pkg/services/searchV2/extract/dashboard_test.go
+++ b/pkg/services/searchV2/extract/dashboard_test.go
@@ -49,7 +49,7 @@ func TestReadDashboard(t *testing.T) {
require.NoError(t, err)
update := false
- savedPath := "testdata/" + input + "-info.json"
+ savedPath := filepath.Join("testdata/", input+"-info.json")
saved, err := os.ReadFile(savedPath)
if err != nil {
update = true
diff --git a/pkg/services/searchusers/searchusers.go b/pkg/services/searchusers/searchusers.go
index 79dc1b59196..fbed2051dae 100644
--- a/pkg/services/searchusers/searchusers.go
+++ b/pkg/services/searchusers/searchusers.go
@@ -1,6 +1,8 @@
package searchusers
import (
+ "net/http"
+
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
@@ -27,7 +29,7 @@ func (s *OSSService) SearchUsers(c *models.ReqContext) response.Response {
return response.Error(500, "Failed to fetch users", err)
}
- return response.JSON(200, query.Result.Users)
+ return response.JSON(http.StatusOK, query.Result.Users)
}
func (s *OSSService) SearchUsersWithPaging(c *models.ReqContext) response.Response {
@@ -36,7 +38,7 @@ func (s *OSSService) SearchUsersWithPaging(c *models.ReqContext) response.Respon
return response.Error(500, "Failed to fetch users", err)
}
- return response.JSON(200, query.Result)
+ return response.JSON(http.StatusOK, query.Result)
}
func (s *OSSService) SearchUser(c *models.ReqContext) (*models.SearchUsersQuery, error) {
diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go
index 90d4b4f2817..5acc84f090c 100644
--- a/pkg/services/serviceaccounts/api/api.go
+++ b/pkg/services/serviceaccounts/api/api.go
@@ -93,7 +93,7 @@ func (api *ServiceAccountsAPI) CreateServiceAccount(c *models.ReqContext) respon
serviceAccount, err := api.store.CreateServiceAccount(c.Req.Context(), c.OrgId, cmd.Name)
switch {
case errors.Is(err, &database.ErrSAInvalidName{}):
- return response.Error(http.StatusBadRequest, "Invalid service account name", err)
+ return response.Error(http.StatusBadRequest, "Failed due to %s", err)
case err != nil:
return response.Error(http.StatusInternalServerError, "Failed to create service account", err)
}
@@ -179,7 +179,7 @@ func (api *ServiceAccountsAPI) updateServiceAccount(c *models.ReqContext) respon
return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err)
}
- cmd := &serviceaccounts.UpdateServiceAccountForm{}
+ var cmd serviceaccounts.UpdateServiceAccountForm
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "Bad request data", err)
}
@@ -187,8 +187,11 @@ func (api *ServiceAccountsAPI) updateServiceAccount(c *models.ReqContext) respon
if cmd.Role != nil && !cmd.Role.IsValid() {
return response.Error(http.StatusBadRequest, "Invalid role specified", nil)
}
+ if cmd.Role != nil && !c.OrgRole.Includes(*cmd.Role) {
+ return response.Error(http.StatusForbidden, "Cannot assign a role higher than user's role", nil)
+ }
- resp, err := api.store.UpdateServiceAccount(c.Req.Context(), c.OrgId, scopeID, cmd)
+ resp, err := api.store.UpdateServiceAccount(c.Req.Context(), c.OrgId, scopeID, &cmd)
if err != nil {
switch {
case errors.Is(err, serviceaccounts.ErrServiceAccountNotFound):
diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go
index a5ed51dcde1..c017b3dee0d 100644
--- a/pkg/services/serviceaccounts/api/api_test.go
+++ b/pkg/services/serviceaccounts/api/api_test.go
@@ -227,7 +227,7 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock,
m := web.New()
signedUser := &models.SignedInUser{
OrgId: 1,
- OrgRole: models.ROLE_ADMIN,
+ OrgRole: models.ROLE_VIEWER,
}
m.Use(func(c *web.Context) {
@@ -344,13 +344,14 @@ func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) {
Id int
}
- role := models.ROLE_ADMIN
+ viewerRole := models.ROLE_VIEWER
+ editorRole := models.ROLE_EDITOR
var invalidRole models.RoleType = "InvalidRole"
testCases := []testUpdateSATestCase{
{
desc: "should be ok to update serviceaccount with permissions",
- user: &tests.TestUser{Login: "servicetest1@admin", IsServiceAccount: true, Role: "Editor", Name: "Unaltered"},
- body: &serviceaccounts.UpdateServiceAccountForm{Name: newString("New Name"), Role: &role},
+ user: &tests.TestUser{Login: "servicetest1@admin", IsServiceAccount: true, Role: "Viewer", Name: "Unaltered"},
+ body: &serviceaccounts.UpdateServiceAccountForm{Name: newString("New Name"), Role: &viewerRole},
acmock: tests.SetupMockAccesscontrol(
t,
func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) {
@@ -360,6 +361,19 @@ func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) {
),
expectedCode: http.StatusOK,
},
+ {
+ desc: "should be forbidden to set role higher than user's role",
+ user: &tests.TestUser{Login: "servicetest2@admin", IsServiceAccount: true, Role: "Viewer", Name: "Unaltered 2"},
+ body: &serviceaccounts.UpdateServiceAccountForm{Name: newString("New Name 2"), Role: &editorRole},
+ acmock: tests.SetupMockAccesscontrol(
+ t,
+ func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) {
+ return []*accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: serviceaccounts.ScopeAll}}, nil
+ },
+ false,
+ ),
+ expectedCode: http.StatusForbidden,
+ },
{
desc: "bad request when invalid role",
user: &tests.TestUser{Login: "servicetest3@admin", IsServiceAccount: true, Role: "Invalid", Name: "Unaltered"},
@@ -375,7 +389,7 @@ func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) {
},
{
desc: "should be forbidden to update serviceaccount if no permissions",
- user: &tests.TestUser{Login: "servicetest2@admin", IsServiceAccount: true},
+ user: &tests.TestUser{Login: "servicetest4@admin", IsServiceAccount: true},
body: nil,
acmock: tests.SetupMockAccesscontrol(
t,
diff --git a/pkg/services/serviceaccounts/api/token.go b/pkg/services/serviceaccounts/api/token.go
index 787d06fdadb..dec1d498280 100644
--- a/pkg/services/serviceaccounts/api/token.go
+++ b/pkg/services/serviceaccounts/api/token.go
@@ -17,13 +17,12 @@ import (
const failedToDeleteMsg = "Failed to delete API key"
type TokenDTO struct {
- Id int64 `json:"id"`
- Name string `json:"name"`
- Role models.RoleType `json:"role"`
- Created *time.Time `json:"created"`
- Expiration *time.Time `json:"expiration"`
- SecondsUntilExpiration *float64 `json:"secondsUntilExpiration"`
- HasExpired bool `json:"hasExpired"`
+ Id int64 `json:"id"`
+ Name string `json:"name"`
+ Created *time.Time `json:"created"`
+ Expiration *time.Time `json:"expiration"`
+ SecondsUntilExpiration *float64 `json:"secondsUntilExpiration"`
+ HasExpired bool `json:"hasExpired"`
}
func hasExpired(expiration *int64) bool {
@@ -60,7 +59,6 @@ func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Respo
result[i] = &TokenDTO{
Id: t.Id,
Name: t.Name,
- Role: t.Role,
Created: &t.Created,
Expiration: expiration,
SecondsUntilExpiration: &secondsUntilExpiration,
@@ -91,7 +89,7 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon
}
}
- cmd := models.AddApiKeyCommand{}
+ cmd := serviceaccounts.AddServiceAccountTokenCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "Bad request data", err)
}
@@ -99,10 +97,6 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon
// Force affected service account to be the one referenced in the URL
cmd.OrgId = c.OrgId
- if !cmd.Role.IsValid() {
- return response.Error(http.StatusBadRequest, "Invalid role specified", nil)
- }
-
if api.cfg.ApiKeyMaxSecondsToLive != -1 {
if cmd.SecondsToLive == 0 {
return response.Error(http.StatusBadRequest, "Number of seconds before expiration should be set", nil)
diff --git a/pkg/services/serviceaccounts/api/token_test.go b/pkg/services/serviceaccounts/api/token_test.go
index 38eb95de0bb..7f04cc8d1bb 100644
--- a/pkg/services/serviceaccounts/api/token_test.go
+++ b/pkg/services/serviceaccounts/api/token_test.go
@@ -34,9 +34,8 @@ func createTokenforSA(t *testing.T, store serviceaccounts.Store, keyName string,
key, err := apikeygen.New(orgID, keyName)
require.NoError(t, err)
- cmd := models.AddApiKeyCommand{
+ cmd := serviceaccounts.AddServiceAccountTokenCommand{
Name: keyName,
- Role: "Viewer",
OrgId: orgID,
Key: key.HashedKey,
SecondsToLive: secondsToLive,
diff --git a/pkg/services/serviceaccounts/database/database.go b/pkg/services/serviceaccounts/database/database.go
index 61647755700..aa11773c867 100644
--- a/pkg/services/serviceaccounts/database/database.go
+++ b/pkg/services/serviceaccounts/database/database.go
@@ -157,7 +157,7 @@ func (s *ServiceAccountsStoreImpl) ListTokens(ctx context.Context, orgID int64,
sess = dbSession.
Join("inner", quotedUser, quotedUser+".id = api_key.service_account_id").
Where(quotedUser+".org_id=? AND "+quotedUser+".id=?", orgID, serviceAccountID).
- Asc("name")
+ Asc("api_key.name")
return sess.Find(&result)
})
@@ -231,6 +231,47 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, o
return serviceAccount, nil
}
+func (s *ServiceAccountsStoreImpl) RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error) {
+ serviceAccount := &struct {
+ Id int64
+ }{}
+
+ err := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
+ sess := dbSession.Table("user")
+
+ whereConditions := []string{
+ fmt.Sprintf("%s.name = ?",
+ s.sqlStore.Dialect.Quote("user")),
+ fmt.Sprintf("%s.org_id = ?",
+ s.sqlStore.Dialect.Quote("user")),
+ fmt.Sprintf("%s.is_service_account = %s",
+ s.sqlStore.Dialect.Quote("user"),
+ s.sqlStore.Dialect.BooleanStr(true)),
+ }
+ whereParams := []interface{}{name, orgID}
+
+ sess.Where(strings.Join(whereConditions, " AND "), whereParams...)
+
+ sess.Cols(
+ "user.id",
+ )
+
+ if ok, err := sess.Get(serviceAccount); err != nil {
+ return err
+ } else if !ok {
+ return serviceaccounts.ErrServiceAccountNotFound
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ return 0, err
+ }
+
+ return serviceAccount.Id, nil
+}
+
func (s *ServiceAccountsStoreImpl) UpdateServiceAccount(ctx context.Context,
orgID, serviceAccountID int64,
saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountProfileDTO, error) {
diff --git a/pkg/services/serviceaccounts/database/database_test.go b/pkg/services/serviceaccounts/database/database_test.go
index d09aa46de4e..44c388034dd 100644
--- a/pkg/services/serviceaccounts/database/database_test.go
+++ b/pkg/services/serviceaccounts/database/database_test.go
@@ -15,17 +15,24 @@ import (
func TestStore_CreateServiceAccount(t *testing.T) {
_, store := setupTestDatabase(t)
t.Run("create service account", func(t *testing.T) {
- saDTO, err := store.CreateServiceAccount(context.Background(), 1, "new Service Account")
+ serviceAccountName := "new Service Account"
+ serviceAccountOrgId := int64(1)
+
+ saDTO, err := store.CreateServiceAccount(context.Background(), serviceAccountOrgId, serviceAccountName)
require.NoError(t, err)
assert.Equal(t, "sa-new-service-account", saDTO.Login)
- assert.Equal(t, "new Service Account", saDTO.Name)
+ assert.Equal(t, serviceAccountName, saDTO.Name)
assert.Equal(t, 0, int(saDTO.Tokens))
- retrieved, err := store.RetrieveServiceAccount(context.Background(), 1, saDTO.Id)
+ retrieved, err := store.RetrieveServiceAccount(context.Background(), serviceAccountOrgId, saDTO.Id)
require.NoError(t, err)
assert.Equal(t, "sa-new-service-account", retrieved.Login)
- assert.Equal(t, "new Service Account", retrieved.Name)
- assert.Equal(t, 1, int(retrieved.OrgId))
+ assert.Equal(t, serviceAccountName, retrieved.Name)
+ assert.Equal(t, serviceAccountOrgId, retrieved.OrgId)
+
+ retrievedId, err := store.RetrieveServiceAccountIdByName(context.Background(), serviceAccountOrgId, serviceAccountName)
+ require.NoError(t, err)
+ assert.Equal(t, saDTO.Id, retrievedId)
})
}
diff --git a/pkg/services/serviceaccounts/database/errors.go b/pkg/services/serviceaccounts/database/errors.go
index 3cd555480a6..8001b2bd889 100644
--- a/pkg/services/serviceaccounts/database/errors.go
+++ b/pkg/services/serviceaccounts/database/errors.go
@@ -17,14 +17,14 @@ func (e *ErrSAInvalidName) Unwrap() error {
return models.ErrUserAlreadyExists
}
-type ErrMisingSAToken struct {
+type ErrMissingSAToken struct {
}
-func (e *ErrMisingSAToken) Error() string {
+func (e *ErrMissingSAToken) Error() string {
return "service account token not found"
}
-func (e *ErrMisingSAToken) Unwrap() error {
+func (e *ErrMissingSAToken) Unwrap() error {
return models.ErrApiKeyNotFound
}
@@ -44,7 +44,7 @@ type ErrDuplicateSAToken struct {
}
func (e *ErrDuplicateSAToken) Error() string {
- return fmt.Sprintf("service account token %s already exists", e.name)
+ return fmt.Sprintf("service account token %s already exists in the organization", e.name)
}
func (e *ErrDuplicateSAToken) Unwrap() error {
diff --git a/pkg/services/serviceaccounts/database/stats_test.go b/pkg/services/serviceaccounts/database/stats_test.go
index 067bf756d9d..55405509c4b 100644
--- a/pkg/services/serviceaccounts/database/stats_test.go
+++ b/pkg/services/serviceaccounts/database/stats_test.go
@@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/components/apikeygen"
"github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/serviceaccounts/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -20,9 +21,8 @@ func TestStore_UsageStats(t *testing.T) {
key, err := apikeygen.New(sa.OrgId, keyName)
require.NoError(t, err)
- cmd := models.AddApiKeyCommand{
+ cmd := serviceaccounts.AddServiceAccountTokenCommand{
Name: keyName,
- Role: "Viewer",
OrgId: sa.OrgId,
Key: key.HashedKey,
SecondsToLive: 0,
diff --git a/pkg/services/serviceaccounts/database/token_store.go b/pkg/services/serviceaccounts/database/token_store.go
index e69317e55c0..4a4b04f9585 100644
--- a/pkg/services/serviceaccounts/database/token_store.go
+++ b/pkg/services/serviceaccounts/database/token_store.go
@@ -5,10 +5,11 @@ import (
"time"
"github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
-func (s *ServiceAccountsStoreImpl) AddServiceAccountToken(ctx context.Context, saID int64, cmd *models.AddApiKeyCommand) error {
+func (s *ServiceAccountsStoreImpl) AddServiceAccountToken(ctx context.Context, saID int64, cmd *serviceaccounts.AddServiceAccountTokenCommand) error {
return s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
key := models.ApiKey{OrgId: cmd.OrgId, Name: cmd.Name}
exists, _ := sess.Get(&key)
@@ -28,7 +29,7 @@ func (s *ServiceAccountsStoreImpl) AddServiceAccountToken(ctx context.Context, s
t := models.ApiKey{
OrgId: cmd.OrgId,
Name: cmd.Name,
- Role: cmd.Role,
+ Role: models.ROLE_VIEWER,
Key: cmd.Key,
Created: updated,
Updated: updated,
@@ -56,7 +57,7 @@ func (s *ServiceAccountsStoreImpl) DeleteServiceAccountToken(ctx context.Context
if err != nil {
return err
} else if n == 0 {
- return &ErrMisingSAToken{}
+ return &ErrMissingSAToken{}
}
return nil
})
diff --git a/pkg/services/serviceaccounts/database/token_store_test.go b/pkg/services/serviceaccounts/database/token_store_test.go
index 2e24caa9c37..1560e19ffcd 100644
--- a/pkg/services/serviceaccounts/database/token_store_test.go
+++ b/pkg/services/serviceaccounts/database/token_store_test.go
@@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/components/apikeygen"
"github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/serviceaccounts/tests"
"github.com/stretchr/testify/require"
)
@@ -28,9 +29,8 @@ func TestStore_AddServiceAccountToken(t *testing.T) {
key, err := apikeygen.New(user.OrgId, keyName)
require.NoError(t, err)
- cmd := models.AddApiKeyCommand{
+ cmd := serviceaccounts.AddServiceAccountTokenCommand{
Name: keyName,
- Role: "Viewer",
OrgId: user.OrgId,
Key: key.HashedKey,
SecondsToLive: tc.secondsToLive,
@@ -79,9 +79,8 @@ func TestStore_DeleteServiceAccountToken(t *testing.T) {
key, err := apikeygen.New(user.OrgId, keyName)
require.NoError(t, err)
- cmd := models.AddApiKeyCommand{
+ cmd := serviceaccounts.AddServiceAccountTokenCommand{
Name: keyName,
- Role: "Viewer",
OrgId: user.OrgId,
Key: key.HashedKey,
SecondsToLive: 0,
diff --git a/pkg/services/serviceaccounts/manager/roles.go b/pkg/services/serviceaccounts/manager/roles.go
index 656f27881ec..7c323043a95 100644
--- a/pkg/services/serviceaccounts/manager/roles.go
+++ b/pkg/services/serviceaccounts/manager/roles.go
@@ -1,12 +1,30 @@
package manager
import (
+ "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
)
func RegisterRoles(ac accesscontrol.AccessControl) error {
- role := accesscontrol.RoleRegistration{
+ saReader := accesscontrol.RoleRegistration{
+ Role: accesscontrol.RoleDTO{
+ Version: 1,
+ Name: "fixed:serviceaccounts:reader",
+ DisplayName: "Service accounts reader",
+ Description: "Read service accounts and service account tokens.",
+ Group: "Service accounts",
+ Permissions: []accesscontrol.Permission{
+ {
+ Action: serviceaccounts.ActionRead,
+ Scope: serviceaccounts.ScopeAll,
+ },
+ },
+ },
+ Grants: []string{string(models.ROLE_ADMIN)},
+ }
+
+ saWriter := accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Version: 4,
Name: "fixed:serviceaccounts:writer",
@@ -31,10 +49,10 @@ func RegisterRoles(ac accesscontrol.AccessControl) error {
},
},
},
- Grants: []string{"Admin"},
+ Grants: []string{string(models.ROLE_ADMIN)},
}
- if err := ac.DeclareFixedRoles(role); err != nil {
+ if err := ac.DeclareFixedRoles(saReader, saWriter); err != nil {
return err
}
diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go
index 9d8967488b5..71aba8ae775 100644
--- a/pkg/services/serviceaccounts/manager/service.go
+++ b/pkg/services/serviceaccounts/manager/service.go
@@ -68,3 +68,11 @@ func (sa *ServiceAccountsService) DeleteServiceAccount(ctx context.Context, orgI
}
return sa.store.DeleteServiceAccount(ctx, orgID, serviceAccountID)
}
+
+func (sa *ServiceAccountsService) RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error) {
+ if !sa.features.IsEnabled(featuremgmt.FlagServiceAccounts) {
+ sa.log.Debug(ServiceAccountFeatureToggleNotFound)
+ return 0, nil
+ }
+ return sa.store.RetrieveServiceAccountIdByName(ctx, orgID, name)
+}
diff --git a/pkg/services/serviceaccounts/models.go b/pkg/services/serviceaccounts/models.go
index f350f259a90..ffc354ce7f0 100644
--- a/pkg/services/serviceaccounts/models.go
+++ b/pkg/services/serviceaccounts/models.go
@@ -40,6 +40,15 @@ type ServiceAccountDTO struct {
AvatarUrl string `json:"avatarUrl"`
AccessControl map[string]bool `json:"accessControl,omitempty"`
}
+
+type AddServiceAccountTokenCommand struct {
+ Name string `json:"name" binding:"Required"`
+ OrgId int64 `json:"-"`
+ Key string `json:"-"`
+ SecondsToLive int64 `json:"secondsToLive"`
+ Result *models.ApiKey `json:"-"`
+}
+
type SearchServiceAccountsResult struct {
TotalCount int64 `json:"totalCount"`
ServiceAccounts []*ServiceAccountDTO `json:"serviceAccounts"`
diff --git a/pkg/services/serviceaccounts/serviceaccounts.go b/pkg/services/serviceaccounts/serviceaccounts.go
index e7b186e8397..632aa42a928 100644
--- a/pkg/services/serviceaccounts/serviceaccounts.go
+++ b/pkg/services/serviceaccounts/serviceaccounts.go
@@ -10,6 +10,7 @@ import (
type Service interface {
CreateServiceAccount(ctx context.Context, orgID int64, name string) (*ServiceAccountDTO, error)
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error
+ RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error)
}
type Store interface {
@@ -19,11 +20,12 @@ type Store interface {
UpdateServiceAccount(ctx context.Context, orgID, serviceAccountID int64,
saForm *UpdateServiceAccountForm) (*ServiceAccountProfileDTO, error)
RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*ServiceAccountProfileDTO, error)
+ RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error)
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error
UpgradeServiceAccounts(ctx context.Context) error
ConvertToServiceAccounts(ctx context.Context, keys []int64) error
ListTokens(ctx context.Context, orgID int64, serviceAccount int64) ([]*models.ApiKey, error)
DeleteServiceAccountToken(ctx context.Context, orgID, serviceAccountID, tokenID int64) error
- AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *models.AddApiKeyCommand) error
+ AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *AddServiceAccountTokenCommand) error
GetUsageMetrics(ctx context.Context) (map[string]interface{}, error)
}
diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go
index 6974ed1de14..67c30d20a10 100644
--- a/pkg/services/serviceaccounts/tests/common.go
+++ b/pkg/services/serviceaccounts/tests/common.go
@@ -38,6 +38,10 @@ func SetupUserServiceAccount(t *testing.T, sqlStore *sqlstore.SQLStore, testUser
// create mock for serviceaccountservice
type ServiceAccountMock struct{}
+func (s *ServiceAccountMock) RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error) {
+ return 0, nil
+}
+
func (s *ServiceAccountMock) CreateServiceAccount(ctx context.Context, orgID int64, name string) (*serviceaccounts.ServiceAccountDTO, error) {
return nil, nil
}
@@ -65,24 +69,31 @@ func SetupMockAccesscontrol(t *testing.T,
// this is a way to see
// that the Mock implements the store interface
var _ serviceaccounts.Store = new(ServiceAccountsStoreMock)
+var _ serviceaccounts.Service = new(ServiceAccountMock)
type Calls struct {
- CreateServiceAccount []interface{}
- RetrieveServiceAccount []interface{}
- DeleteServiceAccount []interface{}
- UpgradeServiceAccounts []interface{}
- ConvertServiceAccounts []interface{}
- ListTokens []interface{}
- DeleteServiceAccountToken []interface{}
- UpdateServiceAccount []interface{}
- AddServiceAccountToken []interface{}
- SearchOrgServiceAccounts []interface{}
+ CreateServiceAccount []interface{}
+ RetrieveServiceAccount []interface{}
+ DeleteServiceAccount []interface{}
+ UpgradeServiceAccounts []interface{}
+ ConvertServiceAccounts []interface{}
+ ListTokens []interface{}
+ DeleteServiceAccountToken []interface{}
+ UpdateServiceAccount []interface{}
+ AddServiceAccountToken []interface{}
+ SearchOrgServiceAccounts []interface{}
+ RetrieveServiceAccountIdByName []interface{}
}
type ServiceAccountsStoreMock struct {
Calls Calls
}
+func (s *ServiceAccountsStoreMock) RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error) {
+ s.Calls.RetrieveServiceAccountIdByName = append(s.Calls.RetrieveServiceAccountIdByName, []interface{}{ctx, orgID, name})
+ return 0, nil
+}
+
func (s *ServiceAccountsStoreMock) CreateServiceAccount(ctx context.Context, orgID int64, name string) (*serviceaccounts.ServiceAccountDTO, error) {
// now we can test that the mock has these calls when we call the function
s.Calls.CreateServiceAccount = append(s.Calls.CreateServiceAccount, []interface{}{ctx, orgID, name})
@@ -140,7 +151,7 @@ func (s *ServiceAccountsStoreMock) DeleteServiceAccountToken(ctx context.Context
return nil
}
-func (s *ServiceAccountsStoreMock) AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *models.AddApiKeyCommand) error {
+func (s *ServiceAccountsStoreMock) AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *serviceaccounts.AddServiceAccountTokenCommand) error {
s.Calls.AddServiceAccountToken = append(s.Calls.AddServiceAccountToken, []interface{}{ctx, cmd})
return nil
}
diff --git a/pkg/services/sqlstore/annotation.go b/pkg/services/sqlstore/annotation.go
index 633bc3bb10d..83f3e0f319f 100644
--- a/pkg/services/sqlstore/annotation.go
+++ b/pkg/services/sqlstore/annotation.go
@@ -9,7 +9,11 @@ import (
"time"
"github.com/grafana/grafana/pkg/models"
+ ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/annotations"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/services/sqlstore/permissions"
+ "github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
)
// Update the item so that EpochEnd >= Epoch
@@ -33,6 +37,10 @@ type SQLAnnotationRepo struct {
sql *SQLStore
}
+func NewSQLAnnotationRepo(sql *SQLStore) SQLAnnotationRepo {
+ return SQLAnnotationRepo{sql: sql}
+}
+
func (r *SQLAnnotationRepo) Save(item *annotations.Item) error {
return inTransaction(func(sess *DBSession) error {
tags := models.ParseTagPairs(item.Tags)
@@ -221,6 +229,15 @@ func (r *SQLAnnotationRepo) Find(ctx context.Context, query *annotations.ItemQue
}
}
+ if r.sql.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagAccesscontrol) {
+ acFilter, acArgs, err := getAccessControlFilter(query.SignedInUser)
+ if err != nil {
+ return err
+ }
+ sql.WriteString(fmt.Sprintf(" AND (%s)", acFilter))
+ params = append(params, acArgs...)
+ }
+
if query.Limit == 0 {
query.Limit = 100
}
@@ -239,6 +256,37 @@ func (r *SQLAnnotationRepo) Find(ctx context.Context, query *annotations.ItemQue
return items, err
}
+func getAccessControlFilter(user *models.SignedInUser) (string, []interface{}, error) {
+ if user == nil || user.Permissions[user.OrgId] == nil {
+ return "", nil, errors.New("missing permissions")
+ }
+ scopes, has := user.Permissions[user.OrgId][ac.ActionAnnotationsRead]
+ if !has {
+ return "", nil, errors.New("missing permissions")
+ }
+ types, hasWildcardScope := ac.ParseScopes(ac.ScopeAnnotationsProvider.GetResourceScopeType(""), scopes)
+ if hasWildcardScope {
+ types = map[interface{}]struct{}{annotations.Dashboard.String(): {}, annotations.Organization.String(): {}}
+ }
+
+ var filters []string
+ var params []interface{}
+ for t := range types {
+ // annotation read permission with scope annotations:type:organization allows listing annotations that are not associated with a dashboard
+ if t == annotations.Organization.String() {
+ filters = append(filters, "a.dashboard_id = 0")
+ }
+ // annotation read permission with scope annotations:type:dashboard allows listing annotations from dashboards which the user can view
+ if t == annotations.Dashboard.String() {
+ dashboardFilter, dashboardParams := permissions.NewAccessControlDashboardPermissionFilter(user, models.PERMISSION_VIEW, searchstore.TypeDashboard).Where()
+ filter := fmt.Sprintf("a.dashboard_id IN(SELECT id FROM dashboard WHERE %s)", dashboardFilter)
+ filters = append(filters, filter)
+ params = dashboardParams
+ }
+ }
+ return strings.Join(filters, " OR "), params, nil
+}
+
func (r *SQLAnnotationRepo) Delete(ctx context.Context, params *annotations.DeleteParams) error {
return r.sql.WithTransactionalDbSession(ctx, func(sess *DBSession) error {
var (
diff --git a/pkg/services/sqlstore/annotation_test.go b/pkg/services/sqlstore/annotation_test.go
index 4c508b7f0fc..6b66861d0cb 100644
--- a/pkg/services/sqlstore/annotation_test.go
+++ b/pkg/services/sqlstore/annotation_test.go
@@ -1,27 +1,32 @@
//go:build integration
// +build integration
-package sqlstore
+package sqlstore_test
import (
"context"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
+ "fmt"
"testing"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/annotations"
+ dashboardstore "github.com/grafana/grafana/pkg/services/dashboards/database"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
)
func TestAnnotations(t *testing.T) {
- mockTimeNow()
- defer resetTimeNow()
-
- repo := SQLAnnotationRepo{}
- repo.sql = InitTestDB(t)
+ sql := sqlstore.InitTestDB(t)
+ repo := sqlstore.NewSQLAnnotationRepo(sql)
t.Run("Testing annotation create, read, update and delete", func(t *testing.T) {
t.Cleanup(func() {
- err := repo.sql.WithDbSession(context.Background(), func(dbSession *DBSession) error {
+ err := sql.WithDbSession(context.Background(), func(dbSession *sqlstore.DBSession) error {
_, err := dbSession.Exec("DELETE FROM annotation WHERE 1=1")
if err != nil {
return err
@@ -331,3 +336,136 @@ func TestAnnotations(t *testing.T) {
})
})
}
+
+func TestAnnotationListingWithFGAC(t *testing.T) {
+ sql := sqlstore.InitTestDB(t)
+ sql.Cfg.IsFeatureToggleEnabled = func(key string) bool {
+ return key == featuremgmt.FlagAccesscontrol
+ }
+ repo := sqlstore.NewSQLAnnotationRepo(sql)
+ dashboardStore := dashboardstore.ProvideDashboardStore(sql)
+
+ testDashboard1 := models.SaveDashboardCommand{
+ UserId: 1,
+ OrgId: 1,
+ Dashboard: simplejson.NewFromAny(map[string]interface{}{
+ "title": "Dashboard 1",
+ }),
+ }
+ dashboard, err := dashboardStore.SaveDashboard(testDashboard1)
+ require.NoError(t, err)
+ dash1UID := dashboard.Uid
+
+ testDashboard2 := models.SaveDashboardCommand{
+ UserId: 1,
+ OrgId: 1,
+ Dashboard: simplejson.NewFromAny(map[string]interface{}{
+ "title": "Dashboard 2",
+ }),
+ }
+ _, err = dashboardStore.SaveDashboard(testDashboard2)
+ require.NoError(t, err)
+
+ dash1Annotation := &annotations.Item{
+ OrgId: 1,
+ DashboardId: 1,
+ Epoch: 10,
+ }
+ err = repo.Save(dash1Annotation)
+ require.NoError(t, err)
+
+ dash2Annotation := &annotations.Item{
+ OrgId: 1,
+ DashboardId: 2,
+ Epoch: 10,
+ }
+ err = repo.Save(dash2Annotation)
+ require.NoError(t, err)
+
+ organizationAnnotation := &annotations.Item{
+ OrgId: 1,
+ Epoch: 10,
+ }
+ err = repo.Save(organizationAnnotation)
+ require.NoError(t, err)
+
+ user := &models.SignedInUser{
+ UserId: 1,
+ OrgId: 1,
+ }
+
+ type testStruct struct {
+ description string
+ permissions map[string][]string
+ expectedAnnotationIds []int64
+ expectedError bool
+ }
+
+ testCases := []testStruct{
+ {
+ description: "Should find all annotations when has permissions to list all annotations and read all dashboards",
+ permissions: map[string][]string{
+ accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsAll},
+ accesscontrol.ActionDashboardsRead: {accesscontrol.ScopeDashboardsAll},
+ },
+ expectedAnnotationIds: []int64{dash1Annotation.Id, dash2Annotation.Id, organizationAnnotation.Id},
+ },
+ {
+ description: "Should find all dashboard annotations",
+ permissions: map[string][]string{
+ accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard},
+ accesscontrol.ActionDashboardsRead: {accesscontrol.ScopeDashboardsAll},
+ },
+ expectedAnnotationIds: []int64{dash1Annotation.Id, dash2Annotation.Id},
+ },
+ {
+ description: "Should find only annotations from dashboards that user can read",
+ permissions: map[string][]string{
+ accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard},
+ accesscontrol.ActionDashboardsRead: {fmt.Sprintf("dashboards:uid:%s", dash1UID)},
+ },
+ expectedAnnotationIds: []int64{dash1Annotation.Id},
+ },
+ {
+ description: "Should find no annotations if user can't view dashboards or organization annotations",
+ permissions: map[string][]string{
+ accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard},
+ },
+ expectedAnnotationIds: []int64{},
+ },
+ {
+ description: "Should find only organization annotations",
+ permissions: map[string][]string{
+ accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeOrganization},
+ accesscontrol.ActionDashboardsRead: {accesscontrol.ScopeDashboardsAll},
+ },
+ expectedAnnotationIds: []int64{organizationAnnotation.Id},
+ },
+ {
+ description: "Should error if user doesn't have annotation read permissions",
+ permissions: map[string][]string{
+ accesscontrol.ActionDashboardsRead: {accesscontrol.ScopeDashboardsAll},
+ },
+ expectedError: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.description, func(t *testing.T) {
+ user.Permissions = map[int64]map[string][]string{1: tc.permissions}
+ results, err := repo.Find(context.Background(), &annotations.ItemQuery{
+ OrgId: 1,
+ SignedInUser: user,
+ })
+ if tc.expectedError {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assert.Len(t, results, len(tc.expectedAnnotationIds))
+ for _, r := range results {
+ assert.Contains(t, tc.expectedAnnotationIds, r.Id)
+ }
+ })
+ }
+}
diff --git a/pkg/services/sqlstore/database_wrapper.go b/pkg/services/sqlstore/database_wrapper.go
index 81079412cd3..400f823a55e 100644
--- a/pkg/services/sqlstore/database_wrapper.go
+++ b/pkg/services/sqlstore/database_wrapper.go
@@ -16,7 +16,6 @@ import (
"github.com/lib/pq"
"github.com/mattn/go-sqlite3"
"github.com/prometheus/client_golang/prometheus"
- cw "github.com/weaveworks/common/tracing"
"xorm.io/core"
)
@@ -83,7 +82,7 @@ func (h *databaseQueryWrapper) instrument(ctx context.Context, status string, qu
elapsed := time.Since(begin)
histogram := databaseQueryHistogram.WithLabelValues(status)
- if traceID, ok := cw.ExtractSampledTraceID(ctx); ok {
+ if traceID := tracing.TraceIDFromContext(ctx, true); traceID != "" {
// Need to type-convert the Observer to an
// ExemplarObserver. This will always work for a
// HistogramVec.
diff --git a/pkg/services/sqlstore/preferences.go b/pkg/services/sqlstore/preferences.go
index da3a5388ef1..6d9a7fe84ef 100644
--- a/pkg/services/sqlstore/preferences.go
+++ b/pkg/services/sqlstore/preferences.go
@@ -109,6 +109,11 @@ func (ss *SQLStore) SavePreferences(ctx context.Context, cmd *models.SavePrefere
if cmd.Navbar != nil {
prefs.JsonData.Navbar = *cmd.Navbar
}
+
+ if cmd.QueryHistory != nil {
+ prefs.JsonData.QueryHistory = *cmd.QueryHistory
+ }
+
_, err = sess.Insert(&prefs)
return err
}
@@ -121,6 +126,16 @@ func (ss *SQLStore) SavePreferences(ctx context.Context, cmd *models.SavePrefere
prefs.JsonData.Navbar.SavedItems = cmd.Navbar.SavedItems
}
}
+
+ if cmd.QueryHistory != nil {
+ if prefs.JsonData == nil {
+ prefs.JsonData = &models.PreferencesJsonData{}
+ }
+ if cmd.QueryHistory.HomeTab != "" {
+ prefs.JsonData.QueryHistory.HomeTab = cmd.QueryHistory.HomeTab
+ }
+ }
+
prefs.HomeDashboardId = cmd.HomeDashboardId
prefs.Timezone = cmd.Timezone
prefs.WeekStart = cmd.WeekStart
@@ -159,6 +174,15 @@ func (ss *SQLStore) PatchPreferences(ctx context.Context, cmd *models.PatchPrefe
}
}
+ if cmd.QueryHistory != nil {
+ if prefs.JsonData == nil {
+ prefs.JsonData = &models.PreferencesJsonData{}
+ }
+ if cmd.QueryHistory.HomeTab != "" {
+ prefs.JsonData.QueryHistory.HomeTab = cmd.QueryHistory.HomeTab
+ }
+ }
+
if cmd.HomeDashboardId != nil {
prefs.HomeDashboardId = *cmd.HomeDashboardId
}
diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go
index ca6ec8d6135..7baa450cb99 100644
--- a/pkg/services/sqlstore/sqlstore.go
+++ b/pkg/services/sqlstore/sqlstore.go
@@ -15,7 +15,6 @@ import (
_ "github.com/lib/pq"
"xorm.io/xorm"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/fs"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
@@ -44,7 +43,6 @@ type ContextSessionKey struct{}
type SQLStore struct {
Cfg *setting.Cfg
- Bus bus.Bus
CacheService *localcache.CacheService
dbCfg DatabaseConfig
@@ -56,12 +54,12 @@ type SQLStore struct {
tracer tracing.Tracer
}
-func ProvideService(cfg *setting.Cfg, cacheService *localcache.CacheService, bus bus.Bus, migrations registry.DatabaseMigrator, tracer tracing.Tracer) (*SQLStore, error) {
+func ProvideService(cfg *setting.Cfg, cacheService *localcache.CacheService, migrations registry.DatabaseMigrator, tracer tracing.Tracer) (*SQLStore, error) {
// This change will make xorm use an empty default schema for postgres and
// by that mimic the functionality of how it was functioning before
// xorm's changes above.
xorm.DefaultPostgresSchema = ""
- s, err := newSQLStore(cfg, cacheService, bus, nil, migrations, tracer)
+ s, err := newSQLStore(cfg, cacheService, nil, migrations, tracer)
if err != nil {
return nil, err
}
@@ -81,11 +79,10 @@ func ProvideServiceForTests(migrations registry.DatabaseMigrator) (*SQLStore, er
return initTestDB(migrations, InitTestDBOpt{EnsureDefaultOrgAndUser: true})
}
-func newSQLStore(cfg *setting.Cfg, cacheService *localcache.CacheService, b bus.Bus, engine *xorm.Engine,
+func newSQLStore(cfg *setting.Cfg, cacheService *localcache.CacheService, engine *xorm.Engine,
migrations registry.DatabaseMigrator, tracer tracing.Tracer, opts ...InitTestDBOpt) (*SQLStore, error) {
ss := &SQLStore{
Cfg: cfg,
- Bus: b,
CacheService: cacheService,
log: log.New("sqlstore"),
skipEnsureDefaultOrgAndUser: false,
@@ -543,7 +540,7 @@ func initTestDB(migration registry.DatabaseMigrator, opts ...InitTestDBOpt) (*SQ
if err != nil {
return nil, err
}
- testSQLStore, err = newSQLStore(cfg, localcache.New(5*time.Minute, 10*time.Minute), bus.GetBus(), engine, migration, tracer, opts...)
+ testSQLStore, err = newSQLStore(cfg, localcache.New(5*time.Minute, 10*time.Minute), engine, migration, tracer, opts...)
if err != nil {
return nil, err
}
diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go
index 6e266fed697..46c88f8f0bc 100644
--- a/pkg/services/sqlstore/team.go
+++ b/pkg/services/sqlstore/team.go
@@ -21,7 +21,6 @@ type TeamStore interface {
RemoveTeamMember(ctx context.Context, cmd *models.RemoveTeamMemberCommand) error
GetTeamMembers(ctx context.Context, cmd *models.GetTeamMembersQuery) error
GetUserTeamMemberships(ctx context.Context, orgID, userID int64, external bool) ([]*models.TeamMemberDTO, error)
- AddOrUpdateTeamMember(userID, orgID, teamID int64, isExternal bool, permission models.PermissionType) error
}
func getFilteredUsers(signedInUser *models.SignedInUser, hiddenUsers map[string]struct{}) []string {
diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go
index b159dcd6bf8..d119422ce1c 100644
--- a/pkg/services/sqlstore/user.go
+++ b/pkg/services/sqlstore/user.go
@@ -535,6 +535,7 @@ func (ss *SQLStore) GetSignedInUser(ctx context.Context, query *models.GetSigned
u.email as email,
u.login as login,
u.name as name,
+ u.is_disabled as is_disabled,
u.help_flags1 as help_flags1,
u.last_seen_at as last_seen_at,
(SELECT COUNT(*) FROM org_user where org_user.user_id = u.id) as org_count,
diff --git a/pkg/services/store/http.go b/pkg/services/store/http.go
index 5d23128a8cf..7334049d395 100644
--- a/pkg/services/store/http.go
+++ b/pkg/services/store/http.go
@@ -1,6 +1,8 @@
package store
import (
+ "net/http"
+
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/web"
@@ -28,7 +30,7 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
action := "Upload"
scope, path := getPathAndScope(c)
- return response.JSON(200, map[string]string{
+ return response.JSON(http.StatusOK, map[string]string{
"action": action,
"scope": scope,
"path": path,
@@ -39,7 +41,7 @@ func (s *httpStorage) Read(c *models.ReqContext) response.Response {
action := "Read"
scope, path := getPathAndScope(c)
- return response.JSON(200, map[string]string{
+ return response.JSON(http.StatusOK, map[string]string{
"action": action,
"scope": scope,
"path": path,
@@ -50,7 +52,7 @@ func (s *httpStorage) Delete(c *models.ReqContext) response.Response {
action := "Delete"
scope, path := getPathAndScope(c)
- return response.JSON(200, map[string]string{
+ return response.JSON(http.StatusOK, map[string]string{
"action": action,
"scope": scope,
"path": path,
@@ -67,5 +69,5 @@ func (s *httpStorage) List(c *models.ReqContext) response.Response {
if frame == nil {
return response.Error(404, "not found", nil)
}
- return response.JSONStreaming(200, frame)
+ return response.JSONStreaming(http.StatusOK, frame)
}
diff --git a/pkg/services/store/storage_disk.go b/pkg/services/store/storage_disk.go
index e7dde055629..143a80b5e28 100644
--- a/pkg/services/store/storage_disk.go
+++ b/pkg/services/store/storage_disk.go
@@ -82,7 +82,7 @@ func (s *rootStorageDisk) Write(ctx context.Context, cmd *WriteValueRequest) (*W
}
err := s.store.Upsert(ctx, &filestorage.UpsertFileCommand{
Path: path,
- Contents: &byteAray,
+ Contents: byteAray,
})
if err != nil {
return nil, err
diff --git a/pkg/services/thumbs/crawler.go b/pkg/services/thumbs/crawler.go
index 74dcc5051a6..c641455728c 100644
--- a/pkg/services/thumbs/crawler.go
+++ b/pkg/services/thumbs/crawler.go
@@ -26,6 +26,7 @@ type simpleCrawler struct {
thumbnailRepo thumbnailRepo
mode CrawlerMode
thumbnailKind models.ThumbnailKind
+ auth CrawlerAuth
opts rendering.Opts
status crawlStatus
statusMutex sync.RWMutex
@@ -67,8 +68,8 @@ func (r *simpleCrawler) next(ctx context.Context) (*models.DashboardWithStaleThu
authOpts := rendering.AuthOpts{
OrgID: v.OrgId,
- UserID: r.opts.AuthOpts.UserID,
- OrgRole: r.opts.AuthOpts.OrgRole,
+ UserID: r.auth.GetUserId(v.OrgId),
+ OrgRole: r.auth.GetOrgRole(),
}
if renderingSession, ok := r.renderingSessionByOrgId[v.OrgId]; ok {
@@ -112,7 +113,7 @@ func (d byOrgId) Len() int { return len(d) }
func (d byOrgId) Less(i, j int) bool { return d[i].OrgId > d[j].OrgId }
func (d byOrgId) Swap(i, j int) { d[i], d[j] = d[j], d[i] }
-func (r *simpleCrawler) Run(ctx context.Context, authOpts rendering.AuthOpts, mode CrawlerMode, theme models.Theme, thumbnailKind models.ThumbnailKind) error {
+func (r *simpleCrawler) Run(ctx context.Context, auth CrawlerAuth, mode CrawlerMode, theme models.Theme, thumbnailKind models.ThumbnailKind) error {
res, err := r.renderService.HasCapability(rendering.ScalingDownImages)
if err != nil {
return err
@@ -150,8 +151,8 @@ func (r *simpleCrawler) Run(ctx context.Context, authOpts rendering.AuthOpts, mo
r.mode = mode
r.thumbnailKind = thumbnailKind
+ r.auth = auth
r.opts = rendering.Opts{
- AuthOpts: authOpts,
TimeoutOpts: rendering.TimeoutOpts{
Timeout: 20 * time.Second,
RequestTimeoutMultiplier: 3,
diff --git a/pkg/services/thumbs/crawler_auth.go b/pkg/services/thumbs/crawler_auth.go
new file mode 100644
index 00000000000..b8dd6ba334c
--- /dev/null
+++ b/pkg/services/thumbs/crawler_auth.go
@@ -0,0 +1,41 @@
+package thumbs
+
+import (
+ "context"
+
+ "github.com/grafana/grafana/pkg/models"
+)
+
+type CrawlerAuthSetupService interface {
+ Setup(ctx context.Context) (CrawlerAuth, error)
+}
+
+func ProvideCrawlerAuthSetupService() *OSSCrawlerAuthSetupService {
+ return &OSSCrawlerAuthSetupService{}
+}
+
+type OSSCrawlerAuthSetupService struct{}
+
+type CrawlerAuth interface {
+ GetUserId(orgId int64) int64
+ GetOrgRole() models.RoleType
+}
+
+type staticCrawlerAuth struct {
+ userId int64
+ orgRole models.RoleType
+}
+
+func (o *staticCrawlerAuth) GetOrgRole() models.RoleType {
+ return o.orgRole
+}
+
+func (o *staticCrawlerAuth) GetUserId(orgId int64) int64 {
+ return o.userId
+}
+
+func (o *OSSCrawlerAuthSetupService) Setup(ctx context.Context) (CrawlerAuth, error) {
+ // userId:0 and ROLE_ADMIN grants the crawler process permissions to view all dashboards in all folders & orgs
+ // the process doesn't and shouldn't actually need to edit/modify any resources from the UI
+ return &staticCrawlerAuth{userId: 0, orgRole: models.ROLE_ADMIN}, nil
+}
diff --git a/pkg/services/thumbs/dummy.go b/pkg/services/thumbs/dummy.go
index 6c984d412ad..769b70443a8 100644
--- a/pkg/services/thumbs/dummy.go
+++ b/pkg/services/thumbs/dummy.go
@@ -2,6 +2,7 @@ package thumbs
import (
"context"
+ "net/http"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
@@ -39,19 +40,19 @@ func (ds *dummyService) GetDashboardPreviewsSetupSettings(c *models.ReqContext)
func (ds *dummyService) StartCrawler(c *models.ReqContext) response.Response {
result := make(map[string]string)
result["error"] = "Not enabled"
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
func (ds *dummyService) StopCrawler(c *models.ReqContext) response.Response {
result := make(map[string]string)
result["error"] = "Not enabled"
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
func (ds *dummyService) CrawlerStatus(c *models.ReqContext) response.Response {
result := make(map[string]string)
result["error"] = "Not enabled"
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
func (ds *dummyService) Run(ctx context.Context) error {
diff --git a/pkg/services/thumbs/models.go b/pkg/services/thumbs/models.go
index a7c128d195c..78d30746c74 100644
--- a/pkg/services/thumbs/models.go
+++ b/pkg/services/thumbs/models.go
@@ -5,7 +5,6 @@ import (
"time"
"github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/services/rendering"
)
type CrawlerMode string
@@ -66,7 +65,7 @@ type dashboardPreviewsSetupConfig struct {
type dashRenderer interface {
// Run Assumes you have already authenticated as admin.
- Run(ctx context.Context, authOpts rendering.AuthOpts, mode CrawlerMode, theme models.Theme, kind models.ThumbnailKind) error
+ Run(ctx context.Context, auth CrawlerAuth, mode CrawlerMode, theme models.Theme, kind models.ThumbnailKind) error
// Assumes you have already authenticated as admin.
Stop() (crawlStatus, error)
diff --git a/pkg/services/thumbs/service.go b/pkg/services/thumbs/service.go
index 6979de50572..45cba2828aa 100644
--- a/pkg/services/thumbs/service.go
+++ b/pkg/services/thumbs/service.go
@@ -6,11 +6,13 @@ import (
"fmt"
"io"
"io/ioutil"
+ "net/http"
"time"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/serverlock"
+ "github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/guardian"
@@ -48,6 +50,7 @@ type thumbService struct {
store sqlstore.Store
crawlLockServiceActionName string
log log.Logger
+ usageStatsService usagestats.Service
}
type crawlerScheduleOptions struct {
@@ -56,23 +59,25 @@ type crawlerScheduleOptions struct {
maxCrawlDuration time.Duration
crawlerMode CrawlerMode
thumbnailKind models.ThumbnailKind
- auth rendering.AuthOpts
themes []models.Theme
+ auth CrawlerAuth
}
-func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, lockService *serverlock.ServerLockService, renderService rendering.Service, gl *live.GrafanaLive, store *sqlstore.SQLStore) Service {
+func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, lockService *serverlock.ServerLockService, renderService rendering.Service, gl *live.GrafanaLive, store *sqlstore.SQLStore, authSetupService CrawlerAuthSetupService, usageStatsService usagestats.Service) Service {
if !features.IsEnabled(featuremgmt.FlagDashboardPreviews) {
return &dummyService{}
}
+ logger := log.New("thumbnails_service")
thumbnailRepo := newThumbnailRepo(store)
- authOpts := rendering.AuthOpts{
- OrgID: 0,
- UserID: 0,
- OrgRole: models.ROLE_ADMIN,
+ crawlerAuth, err := authSetupService.Setup(context.Background())
+ if err != nil {
+ logger.Error("Failed to setup auth for the dashboard previews crawler", "err", err)
+ return &dummyService{}
}
- return &thumbService{
+ t := &thumbService{
+ usageStatsService: usageStatsService,
renderingService: renderService,
renderer: newSimpleCrawler(renderService, gl, thumbnailRepo),
thumbnailRepo: thumbnailRepo,
@@ -80,7 +85,7 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, lockS
features: features,
lockService: lockService,
crawlLockServiceActionName: "dashboard-crawler",
- log: log.New("thumbnails_service"),
+ log: logger,
scheduleOptions: crawlerScheduleOptions{
tickerInterval: time.Hour,
@@ -89,9 +94,30 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, lockS
crawlerMode: CrawlerModeThumbs,
thumbnailKind: models.ThumbnailKindDefault,
themes: []models.Theme{models.ThemeDark, models.ThemeLight},
- auth: authOpts,
+ auth: crawlerAuth,
},
}
+
+ t.registerUsageStats()
+ return t
+}
+
+func (hs *thumbService) registerUsageStats() {
+ hs.usageStatsService.RegisterMetricsFunc(func(ctx context.Context) (map[string]interface{}, error) {
+ s := hs.getDashboardPreviewsSetupSettings(ctx)
+
+ stats := make(map[string]interface{})
+
+ if s.SystemRequirements.Met {
+ stats["stats.dashboard_previews.system_req_met.count"] = 1
+ }
+
+ if s.ThumbnailsExist {
+ stats["stats.dashboard_previews.thumbnails_exist.count"] = 1
+ }
+
+ return stats, nil
+ })
}
func (hs *thumbService) Enabled() bool {
@@ -167,7 +193,7 @@ func (hs *thumbService) UpdateThumbnailState(c *models.ReqContext) {
}
hs.log.Info("Updated dashboard thumbnail state", "dashboardUid", req.UID, "theme", req.Theme, "newState", body.State)
- c.JSON(200, map[string]string{"success": "true"})
+ c.JSON(http.StatusOK, map[string]string{"success": "true"})
}
func (hs *thumbService) GetImage(c *models.ReqContext) {
@@ -201,8 +227,12 @@ func (hs *thumbService) GetImage(c *models.ReqContext) {
}
func (hs *thumbService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig {
+ return hs.getDashboardPreviewsSetupSettings(c.Req.Context())
+}
+
+func (hs *thumbService) getDashboardPreviewsSetupSettings(ctx context.Context) dashboardPreviewsSetupConfig {
systemRequirements := hs.getSystemRequirements()
- thumbnailsExist, err := hs.thumbnailRepo.doThumbnailsExist(c.Req.Context())
+ thumbnailsExist, err := hs.thumbnailRepo.doThumbnailsExist(ctx)
if err != nil {
return dashboardPreviewsSetupConfig{
@@ -291,7 +321,7 @@ func (hs *thumbService) SetImage(c *models.ReqContext) {
return
}
- c.JSON(200, map[string]int{"OK": len(fileBytes)})
+ c.JSON(http.StatusOK, map[string]int{"OK": len(fileBytes)})
}
func (hs *thumbService) StartCrawler(c *models.ReqContext) response.Response {
@@ -319,7 +349,7 @@ func (hs *thumbService) StartCrawler(c *models.ReqContext) response.Response {
return response.Error(500, "error starting", err)
}
- return response.JSON(200, status)
+ return response.JSON(http.StatusOK, status)
}
func (hs *thumbService) StopCrawler(c *models.ReqContext) response.Response {
@@ -327,7 +357,7 @@ func (hs *thumbService) StopCrawler(c *models.ReqContext) response.Response {
if err != nil {
return response.Error(500, "error starting", err)
}
- return response.JSON(200, msg)
+ return response.JSON(http.StatusOK, msg)
}
func (hs *thumbService) CrawlerStatus(c *models.ReqContext) response.Response {
@@ -335,7 +365,7 @@ func (hs *thumbService) CrawlerStatus(c *models.ReqContext) response.Response {
if err != nil {
return response.Error(500, "error starting", err)
}
- return response.JSON(200, msg)
+ return response.JSON(http.StatusOK, msg)
}
// Ideally this service would not require first looking up the full dashboard just to bet the id!
@@ -377,7 +407,7 @@ func (hs *thumbService) runOnDemandCrawl(parentCtx context.Context, theme models
// wait for at least a minute after the last completed run
interval := time.Minute
err := hs.lockService.LockAndExecute(crawlerCtx, hs.crawlLockServiceActionName, interval, func(ctx context.Context) {
- if err := hs.renderer.Run(crawlerCtx, authOpts, mode, theme, kind); err != nil {
+ if err := hs.renderer.Run(crawlerCtx, hs.scheduleOptions.auth, mode, theme, kind); err != nil {
hs.log.Error("On demand crawl error", "mode", mode, "theme", theme, "kind", kind, "userId", authOpts.UserID, "orgId", authOpts.OrgID, "orgRole", authOpts.OrgRole)
}
})
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go
index 10834bf4556..58d695f41fc 100644
--- a/pkg/setting/setting.go
+++ b/pkg/setting/setting.go
@@ -393,6 +393,7 @@ type Cfg struct {
ReportingEnabled bool
ApplicationInsightsConnectionString string
ApplicationInsightsEndpointUrl string
+ FeedbackLinksEnabled bool
// LDAP
LDAPEnabled bool
@@ -938,13 +939,17 @@ func (cfg *Cfg) Load(args CommandLineArgs) error {
RudderstackDataPlaneUrl = analytics.Key("rudderstack_data_plane_url").String()
RudderstackSdkUrl = analytics.Key("rudderstack_sdk_url").String()
RudderstackConfigUrl = analytics.Key("rudderstack_config_url").String()
+
cfg.ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true)
cfg.ReportingDistributor = analytics.Key("reporting_distributor").MustString("grafana-labs")
+
if len(cfg.ReportingDistributor) >= 100 {
cfg.ReportingDistributor = cfg.ReportingDistributor[:100]
}
+
cfg.ApplicationInsightsConnectionString = analytics.Key("application_insights_connection_string").String()
cfg.ApplicationInsightsEndpointUrl = analytics.Key("application_insights_endpoint_url").String()
+ cfg.FeedbackLinksEnabled = analytics.Key("feedback_links_enabled").MustBool(true)
if err := readAlertingSettings(iniFile); err != nil {
return err
diff --git a/pkg/tests/api/alerting/api_admin_configuration_test.go b/pkg/tests/api/alerting/api_admin_configuration_test.go
index 777ea9f05aa..3dca58e0d16 100644
--- a/pkg/tests/api/alerting/api_admin_configuration_test.go
+++ b/pkg/tests/api/alerting/api_admin_configuration_test.go
@@ -12,7 +12,6 @@ import (
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@@ -35,8 +34,6 @@ func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) {
})
grafanaListedAddr, s := testinfra.StartGrafana(t, dir, path)
- // override bus to get the GetSignedInUserQuery handler
- s.Bus = bus.GetBus()
// Create a user to make authenticated requests
userID := createUser(t, s, models.CreateUserCommand{
@@ -69,7 +66,10 @@ func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) {
resp := getRequest(t, alertsURL, http.StatusNotFound) // nolint
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
- require.JSONEq(t, `{"message": "no admin configuration available"}`, string(b))
+ var res map[string]interface{}
+ err = json.Unmarshal(b, &res)
+ require.NoError(t, err)
+ require.Equal(t, "no admin configuration available", res["message"])
}
// An invalid alertmanager choice should return an error.
diff --git a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go
index d51d3752b60..7405c32083c 100644
--- a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go
+++ b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go
@@ -3,12 +3,12 @@ package alerting
import (
"encoding/json"
"fmt"
+ "io/ioutil"
"net/http"
"regexp"
"testing"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@@ -34,9 +34,6 @@ func TestAlertmanagerConfigurationIsTransactional(t *testing.T) {
// editor from main organisation requests configuration
alertConfigURL := fmt.Sprintf("http://editor:editor@%s/api/alertmanager/grafana/config/api/v1/alerts", grafanaListedAddr)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
-
// create user under main organisation
userID := createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
@@ -93,9 +90,13 @@ func TestAlertmanagerConfigurationIsTransactional(t *testing.T) {
}
`
resp := postRequest(t, alertConfigURL, payload, http.StatusBadRequest) // nolint
- require.JSONEq(t, `{"message": "failed to save and apply Alertmanager configuration: failed to build integration map: the receiver is invalid: failed to validate receiver \"slack.receiver\" of type \"slack\": token must be specified when using the Slack chat API"}`, getBody(t, resp.Body))
-
+ b, err := ioutil.ReadAll(resp.Body)
+ require.NoError(t, err)
+ var res map[string]interface{}
+ require.NoError(t, json.Unmarshal(b, &res))
+ require.Equal(t, `failed to save and apply Alertmanager configuration: failed to build integration map: the receiver is invalid: failed to validate receiver "slack.receiver" of type "slack": token must be specified when using the Slack chat API`, res["message"])
resp = getRequest(t, alertConfigURL, http.StatusOK) // nolint
+
require.JSONEq(t, defaultAlertmanagerConfigJSON, getBody(t, resp.Body))
}
@@ -145,9 +146,6 @@ func TestAlertmanagerConfigurationPersistSecrets(t *testing.T) {
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
alertConfigURL := fmt.Sprintf("http://editor:editor@%s/api/alertmanager/grafana/config/api/v1/alerts", grafanaListedAddr)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
-
createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
Password: "editor",
@@ -217,7 +215,10 @@ func TestAlertmanagerConfigurationPersistSecrets(t *testing.T) {
`
resp := postRequest(t, alertConfigURL, payload, http.StatusBadRequest) // nolint
- require.JSONEq(t, `{"message": "unknown receiver: invalid"}`, getBody(t, resp.Body))
+ s := getBody(t, resp.Body)
+ var res map[string]interface{}
+ require.NoError(t, json.Unmarshal([]byte(s), &res))
+ require.Equal(t, "unknown receiver: invalid", res["message"])
}
// The secure settings must be present
diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go
index 1505ade9311..1ed1cde55c8 100644
--- a/pkg/tests/api/alerting/api_alertmanager_test.go
+++ b/pkg/tests/api/alerting/api_alertmanager_test.go
@@ -16,7 +16,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
@@ -40,8 +39,6 @@ func TestAMConfigAccess(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
// Create a users to make authenticated requests
createUser(t, store, models.CreateUserCommand{
@@ -403,8 +400,6 @@ func TestAlertAndGroupsQuery(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
// unauthenticated request to get the alerts should fail
{
@@ -442,7 +437,10 @@ func TestAlertAndGroupsQuery(t *testing.T) {
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
- require.JSONEq(t, `{"message": "invalid username or password"}`, string(b))
+
+ var res map[string]interface{}
+ require.NoError(t, json.Unmarshal(b, &res))
+ require.Equal(t, "invalid username or password", res["message"])
}
// When there are no alerts available, it returns an empty list.
@@ -574,8 +572,6 @@ func TestRulerAccess(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
// Create the namespace we'll save our alerts to.
_, err = createFolder(t, store, 0, "default")
@@ -704,8 +700,6 @@ func TestDeleteFolderWithRules(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
// Create the namespace we'll save our alerts to.
namespaceUID, err := createFolder(t, store, 0, "default")
@@ -866,8 +860,6 @@ func TestAlertRuleCRUD(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
@@ -908,7 +900,7 @@ func TestAlertRuleCRUD(t *testing.T) {
Data: []ngmodels.AlertQuery{},
},
},
- expectedResponse: `{"message": "invalid rule specification at index [0]: invalid alert rule: no queries or expressions are found"}`,
+ expectedResponse: `{"message": "invalid rule specification at index [0]: invalid alert rule: no queries or expressions are found", "traceID":"00000000000000000000000000000000"}`,
},
{
desc: "alert rule with empty title",
@@ -938,7 +930,7 @@ func TestAlertRuleCRUD(t *testing.T) {
},
},
},
- expectedResponse: `{"message": "invalid rule specification at index [0]: alert rule title cannot be empty"}`,
+ expectedResponse: `{"message": "invalid rule specification at index [0]: alert rule title cannot be empty", "traceID":"00000000000000000000000000000000"}`,
},
{
desc: "alert rule with too long name",
@@ -968,7 +960,7 @@ func TestAlertRuleCRUD(t *testing.T) {
},
},
},
- expectedResponse: `{"message": "invalid rule specification at index [0]: alert rule title is too long. Max length is 190"}`,
+ expectedResponse: `{"message": "invalid rule specification at index [0]: alert rule title is too long. Max length is 190", "traceID":"00000000000000000000000000000000"}`,
},
{
desc: "alert rule with too long rulegroup",
@@ -998,7 +990,7 @@ func TestAlertRuleCRUD(t *testing.T) {
},
},
},
- expectedResponse: `{"message": "rule group name is too long. Max length is 190"}`,
+ expectedResponse: `{"message": "rule group name is too long. Max length is 190", "traceID":"00000000000000000000000000000000"}`,
},
{
desc: "alert rule with invalid interval",
@@ -1029,7 +1021,8 @@ func TestAlertRuleCRUD(t *testing.T) {
},
},
},
- expectedResponse: `{"message": "rule evaluation interval (1 second) should be positive number that is multiple of the base interval of 10 seconds"}`,
+ expectedResponse: `{"message": "rule evaluation interval (1 second) should be positive ` +
+ `number that is multiple of the base interval of 10 seconds", "traceID":"00000000000000000000000000000000"}`,
},
{
desc: "alert rule with unknown datasource",
@@ -1059,7 +1052,8 @@ func TestAlertRuleCRUD(t *testing.T) {
},
},
},
- expectedResponse: `{"message": "invalid rule specification at index [0]: failed to validate condition of alert rule AlwaysFiring: invalid query A: data source not found: unknown"}`,
+ expectedResponse: `{"message": "invalid rule specification at index [0]: failed to validate condition of alert rule AlwaysFiring:` +
+ ` invalid query A: data source not found: unknown", "traceID":"00000000000000000000000000000000"}`,
},
{
desc: "alert rule with invalid condition",
@@ -1089,7 +1083,8 @@ func TestAlertRuleCRUD(t *testing.T) {
},
},
},
- expectedResponse: `{"message": "invalid rule specification at index [0]: failed to validate condition of alert rule AlwaysFiring: condition B not found in any query or expression: it should be one of: [A]"}`,
+ expectedResponse: `{"message": "invalid rule specification at index [0]: failed to validate condition of alert rule AlwaysFiring: ` +
+ `condition B not found in any query or expression: it should be one of: [A]", "traceID":"00000000000000000000000000000000"}`,
},
}
@@ -1379,7 +1374,9 @@ func TestAlertRuleCRUD(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
- require.JSONEq(t, `{"message": "failed to update rule group: failed to update rule with UID unknown because could not find alert rule"}`, string(b))
+ var res map[string]interface{}
+ assert.NoError(t, json.Unmarshal(b, &res))
+ require.Equal(t, "failed to update rule group: failed to update rule with UID unknown because could not find alert rule", res["message"])
// let's make sure that rule definitions are not affected by the failed POST request.
u = fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr)
@@ -1498,7 +1495,9 @@ func TestAlertRuleCRUD(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
- require.JSONEq(t, fmt.Sprintf(`{"message": "rule [1] has UID %s that is already assigned to another rule at index 0"}`, ruleUID), string(b))
+ var res map[string]interface{}
+ require.NoError(t, json.Unmarshal(b, &res))
+ require.Equal(t, fmt.Sprintf("rule [1] has UID %s that is already assigned to another rule at index 0", ruleUID), res["message"])
// let's make sure that rule definitions are not affected by the failed POST request.
u = fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr)
@@ -1904,7 +1903,9 @@ func TestAlertRuleCRUD(t *testing.T) {
require.NoError(t, err)
require.Equal(t, http.StatusAccepted, resp.StatusCode)
- require.JSONEq(t, `{"message":"rules deleted"}`, string(b))
+ var res map[string]interface{}
+ require.NoError(t, json.Unmarshal(b, &res))
+ require.Equal(t, "rules deleted", res["message"])
})
t.Run("succeed if the rule group name does exist", func(t *testing.T) {
@@ -2001,8 +2002,6 @@ func TestQuota(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
// Create the namespace we'll save our alerts to.
_, err = createFolder(t, store, 0, "default")
@@ -2116,7 +2115,9 @@ func TestQuota(t *testing.T) {
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
- require.JSONEq(t, `{"message": "quota has been exceeded"}`, string(b))
+ var res map[string]interface{}
+ require.NoError(t, json.Unmarshal(b, &res))
+ require.Equal(t, "quota has been exceeded", res["message"])
})
t.Run("when quota limit exceed updating existing rule should succeed", func(t *testing.T) {
@@ -2248,8 +2249,6 @@ func TestEval(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
createUser(t, store, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
@@ -2415,7 +2414,8 @@ func TestEval(t *testing.T) {
}
`,
expectedStatusCode: http.StatusBadRequest,
- expectedResponse: `{"message": "invalid condition: condition B not found in any query or expression: it should be one of: [A]"}`,
+ expectedResponse: `{"message": "invalid condition: condition B not found in any query or expression: it should be one of: [A]",` +
+ `"traceID": "00000000000000000000000000000000"}`,
},
{
desc: "unknown query datasource",
@@ -2440,7 +2440,7 @@ func TestEval(t *testing.T) {
}
`,
expectedStatusCode: http.StatusBadRequest,
- expectedResponse: `{"message": "invalid condition: invalid query A: data source not found: unknown"}`,
+ expectedResponse: `{"message": "invalid condition: invalid query A: data source not found: unknown", "traceID": "00000000000000000000000000000000"}`,
},
}
@@ -2596,7 +2596,8 @@ func TestEval(t *testing.T) {
}
`,
expectedStatusCode: http.StatusBadRequest,
- expectedResponse: `{"message": "invalid queries or expressions: invalid query A: data source not found: unknown"}`,
+ expectedResponse: `{"message": "invalid queries or expressions: invalid query A: data source not found: unknown",` +
+ `"traceID": "00000000000000000000000000000000"}`,
},
}
diff --git a/pkg/tests/api/alerting/api_available_channel_test.go b/pkg/tests/api/alerting/api_available_channel_test.go
index 3a53f2a1bfa..c54d1a1ae6b 100644
--- a/pkg/tests/api/alerting/api_available_channel_test.go
+++ b/pkg/tests/api/alerting/api_available_channel_test.go
@@ -9,7 +9,6 @@ import (
"github.com/stretchr/testify/require"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
@@ -28,7 +27,6 @@ func TestAvailableChannels(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
- store.Bus = bus.GetBus()
// Create a user to make authenticated requests
createUser(t, store, models.CreateUserCommand{
diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go
index 686de9827a3..495656e115e 100644
--- a/pkg/tests/api/alerting/api_notification_channel_test.go
+++ b/pkg/tests/api/alerting/api_notification_channel_test.go
@@ -20,7 +20,6 @@ import (
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@@ -43,7 +42,6 @@ func TestTestReceivers(t *testing.T) {
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
- env.SQLStore.Bus = bus.GetBus()
createUser(t, env.SQLStore, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
@@ -63,7 +61,7 @@ func TestTestReceivers(t *testing.T) {
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
- require.JSONEq(t, `{}`, string(b))
+ require.JSONEq(t, `{"traceID":"00000000000000000000000000000000"}`, string(b))
})
t.Run("assert working receiver returns OK", func(t *testing.T) {
@@ -77,7 +75,6 @@ func TestTestReceivers(t *testing.T) {
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
- env.SQLStore.Bus = bus.GetBus()
createUser(t, env.SQLStore, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
@@ -162,7 +159,6 @@ func TestTestReceivers(t *testing.T) {
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
- env.SQLStore.Bus = bus.GetBus()
createUser(t, env.SQLStore, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
@@ -242,7 +238,6 @@ func TestTestReceivers(t *testing.T) {
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
- env.SQLStore.Bus = bus.GetBus()
createUser(t, env.SQLStore, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
@@ -332,7 +327,6 @@ func TestTestReceivers(t *testing.T) {
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
- env.SQLStore.Bus = bus.GetBus()
createUser(t, env.SQLStore, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
@@ -448,7 +442,6 @@ func TestTestReceiversAlertCustomization(t *testing.T) {
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
- env.SQLStore.Bus = bus.GetBus()
createUser(t, env.SQLStore, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
@@ -544,7 +537,6 @@ func TestTestReceiversAlertCustomization(t *testing.T) {
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
- env.SQLStore.Bus = bus.GetBus()
createUser(t, env.SQLStore, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
@@ -635,7 +627,6 @@ func TestTestReceiversAlertCustomization(t *testing.T) {
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
- env.SQLStore.Bus = bus.GetBus()
createUser(t, env.SQLStore, models.CreateUserCommand{
DefaultOrgRole: string(models.ROLE_EDITOR),
@@ -727,7 +718,6 @@ func TestNotificationChannels(t *testing.T) {
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
- env.SQLStore.Bus = bus.GetBus()
mockChannel := newMockNotificationChannel(t, grafanaListedAddr)
amConfig := getAlertmanagerConfig(mockChannel.server.Addr)
diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go
index 8a834a19292..28a4aec99b5 100644
--- a/pkg/tests/api/alerting/api_prometheus_test.go
+++ b/pkg/tests/api/alerting/api_prometheus_test.go
@@ -14,7 +14,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
dashboardsstore "github.com/grafana/grafana/pkg/services/dashboards/database"
@@ -35,8 +34,6 @@ func TestPrometheusRules(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
// Create the namespace under default organisation (orgID = 1) where we'll save our alerts to.
_, err = createFolder(t, store, 0, "default")
@@ -209,7 +206,9 @@ func TestPrometheusRules(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
- require.JSONEq(t, `{"message": "invalid rule specification at index [0]: both annotations __dashboardUid__ and __panelId__ must be specified"}`, string(b))
+ var res map[string]interface{}
+ require.NoError(t, json.Unmarshal(b, &res))
+ require.Equal(t, "invalid rule specification at index [0]: both annotations __dashboardUid__ and __panelId__ must be specified", res["message"])
}
// Now, let's see how this looks like.
@@ -330,8 +329,6 @@ func TestPrometheusRulesFilterByDashboard(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
// Create the namespace under default organisation (orgID = 1) where we'll save our alerts to.
dashboardUID, err := createFolder(t, store, 0, "default")
@@ -592,7 +589,9 @@ func TestPrometheusRulesFilterByDashboard(t *testing.T) {
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
- require.JSONEq(t, `{"message": "invalid panel_id: strconv.ParseInt: parsing \"invalid\": invalid syntax"}`, string(b))
+ var res map[string]interface{}
+ require.NoError(t, json.Unmarshal(b, &res))
+ require.Equal(t, `invalid panel_id: strconv.ParseInt: parsing "invalid": invalid syntax`, res["message"])
}
// Now, let's check a panel_id without dashboard_uid returns a 400 Bad Request response
@@ -608,7 +607,9 @@ func TestPrometheusRulesFilterByDashboard(t *testing.T) {
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
- require.JSONEq(t, `{"message": "panel_id must be set with dashboard_uid"}`, string(b))
+ var res map[string]interface{}
+ require.NoError(t, json.Unmarshal(b, &res))
+ require.Equal(t, "panel_id must be set with dashboard_uid", res["message"])
}
}
@@ -625,8 +626,6 @@ func TestPrometheusRulesPermissions(t *testing.T) {
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
dashboardsStore := dashboardsstore.ProvideDashboardStore(store)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
// Create a user to make authenticated requests
createUser(t, store, models.CreateUserCommand{
diff --git a/pkg/tests/api/alerting/api_provisioning_test.go b/pkg/tests/api/alerting/api_provisioning_test.go
index 1cdcef6055b..38428d00ca5 100644
--- a/pkg/tests/api/alerting/api_provisioning_test.go
+++ b/pkg/tests/api/alerting/api_provisioning_test.go
@@ -7,7 +7,6 @@ import (
"net/http"
"testing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -28,8 +27,6 @@ func TestProvisioning(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
// Create a users to make authenticated requests
createUser(t, store, models.CreateUserCommand{
@@ -129,6 +126,98 @@ func TestProvisioning(t *testing.T) {
require.Equal(t, 202, resp.StatusCode)
})
+ t.Run("admin POST should succeed", func(t *testing.T) {
+ req := createTestRequest("POST", url, "admin", body)
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ require.NoError(t, resp.Body.Close())
+
+ require.Equal(t, 202, resp.StatusCode)
+ })
+ })
+ t.Run("when provisioning contactpoints", func(t *testing.T) {
+ url := fmt.Sprintf("http://%s/api/provisioning/contact-points", grafanaListedAddr)
+ body := `
+ {
+ "name": "my-contact-point",
+ "type": "slack",
+ "settings": {
+ "recipient": "value_recipient",
+ "token": "value_token"
+ }
+ }`
+
+ t.Run("un-authenticated GET should 401", func(t *testing.T) {
+ req := createTestRequest("GET", url, "", "")
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ require.NoError(t, resp.Body.Close())
+
+ require.Equal(t, 401, resp.StatusCode)
+ })
+
+ t.Run("viewer GET should succeed", func(t *testing.T) {
+ req := createTestRequest("GET", url, "viewer", "")
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ require.NoError(t, resp.Body.Close())
+
+ require.Equal(t, 200, resp.StatusCode)
+ })
+
+ t.Run("editor GET should succeed", func(t *testing.T) {
+ req := createTestRequest("GET", url, "editor", "")
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ require.NoError(t, resp.Body.Close())
+
+ require.Equal(t, 200, resp.StatusCode)
+ })
+
+ t.Run("admin GET should succeed", func(t *testing.T) {
+ req := createTestRequest("GET", url, "admin", "")
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ require.NoError(t, resp.Body.Close())
+
+ require.Equal(t, 200, resp.StatusCode)
+ })
+
+ t.Run("un-authenticated POST should 401", func(t *testing.T) {
+ req := createTestRequest("POST", url, "", body)
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ require.NoError(t, resp.Body.Close())
+
+ require.Equal(t, 401, resp.StatusCode)
+ })
+
+ t.Run("viewer POST should 403", func(t *testing.T) {
+ req := createTestRequest("POST", url, "viewer", body)
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ require.NoError(t, resp.Body.Close())
+
+ require.Equal(t, 403, resp.StatusCode)
+ })
+
+ t.Run("editor POST should succeed", func(t *testing.T) {
+ req := createTestRequest("POST", url, "editor", body)
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ require.NoError(t, resp.Body.Close())
+
+ require.Equal(t, 202, resp.StatusCode)
+ })
+
t.Run("admin POST should succeed", func(t *testing.T) {
req := createTestRequest("POST", url, "admin", body)
diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go
index 3e59d152ddd..b9556e26d2e 100644
--- a/pkg/tests/api/alerting/api_ruler_test.go
+++ b/pkg/tests/api/alerting/api_ruler_test.go
@@ -14,7 +14,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
dashboardsstore "github.com/grafana/grafana/pkg/services/dashboards/database"
@@ -37,8 +36,6 @@ func TestAlertRulePermissions(t *testing.T) {
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
dashboardsStore := dashboardsstore.ProvideDashboardStore(store)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
// Create a user to make authenticated requests
createUser(t, store, models.CreateUserCommand{
@@ -345,8 +342,6 @@ func TestAlertRuleConflictingTitle(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
// Create the namespace we'll save our alerts to.
_, err = createFolder(t, store, 0, "folder1")
@@ -361,41 +356,8 @@ func TestAlertRuleConflictingTitle(t *testing.T) {
Login: "admin",
})
- interval, err := model.ParseDuration("1m")
- require.NoError(t, err)
+ rules := newTestingRuleConfig(t)
- rules := apimodels.PostableRuleGroupConfig{
- Name: "arulegroup",
- Rules: []apimodels.PostableExtendedRuleNode{
- {
- ApiRuleNode: &apimodels.ApiRuleNode{
- For: interval,
- Labels: map[string]string{"label1": "val1"},
- Annotations: map[string]string{"annotation1": "val1"},
- },
- // this rule does not explicitly set no data and error states
- // therefore it should get the default values
- GrafanaManagedAlert: &apimodels.PostableGrafanaRule{
- Title: "AlwaysFiring",
- Condition: "A",
- Data: []ngmodels.AlertQuery{
- {
- RefID: "A",
- RelativeTimeRange: ngmodels.RelativeTimeRange{
- From: ngmodels.Duration(time.Duration(5) * time.Hour),
- To: ngmodels.Duration(time.Duration(3) * time.Hour),
- },
- DatasourceUID: "-100",
- Model: json.RawMessage(`{
- "type": "math",
- "expression": "2 + 3 > 1"
- }`),
- },
- },
- },
- },
- },
- }
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
err = enc.Encode(&rules)
@@ -415,7 +377,22 @@ func TestAlertRuleConflictingTitle(t *testing.T) {
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
require.JSONEq(t, `{"message":"rule group updated successfully"}`, string(b))
+ // fetch the created rules, so we can get the uid's and trigger
+ // and update by reusing the uid's
+ resp, err = http.Get(u + "/" + rules.Name)
+ require.NoError(t, err)
+
+ var createdRuleGroup apimodels.GettableRuleGroupConfig
+ data, err := ioutil.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ err = json.Unmarshal(data, &createdRuleGroup)
+ require.NoError(t, err)
+ require.Len(t, createdRuleGroup.Rules, 2)
+
t.Run("trying to create alert with same title under same folder should fail", func(t *testing.T) {
+ rules := newTestingRuleConfig(t)
+
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
err = enc.Encode(&rules)
@@ -433,10 +410,42 @@ func TestAlertRuleConflictingTitle(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
- require.JSONEq(t, `{"message": "failed to update rule group: failed to add or update rules: a conflicting alert rule is found: rule title under the same organisation and folder should be unique"}`, string(b))
+
+ var res map[string]interface{}
+ require.NoError(t, json.Unmarshal(b, &res))
+ require.Equal(t, "failed to update rule group: failed to add rules: a conflicting alert rule is found: rule title under the same organisation and folder should be unique", res["message"])
+ })
+
+ t.Run("trying to update an alert to the title of an existing alert in the same folder should fail", func(t *testing.T) {
+ rules := newTestingRuleConfig(t)
+ rules.Rules[0].GrafanaManagedAlert.UID = createdRuleGroup.Rules[0].GrafanaManagedAlert.UID
+ rules.Rules[1].GrafanaManagedAlert.UID = createdRuleGroup.Rules[1].GrafanaManagedAlert.UID
+ rules.Rules[1].GrafanaManagedAlert.Title = "AlwaysFiring"
+ buf := bytes.Buffer{}
+ enc := json.NewEncoder(&buf)
+ err = enc.Encode(&rules)
+ require.NoError(t, err)
+
+ u := fmt.Sprintf("http://admin:admin@%s/api/ruler/grafana/api/v1/rules/folder1", grafanaListedAddr)
+ // nolint:gosec
+ resp, err := http.Post(u, "application/json", &buf)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ err := resp.Body.Close()
+ require.NoError(t, err)
+ })
+ b, err := ioutil.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
+
+ var res map[string]interface{}
+ require.NoError(t, json.Unmarshal(b, &res))
+ require.Equal(t, "failed to update rule group: failed to update rules: a conflicting alert rule is found: rule title under the same organisation and folder should be unique", res["message"])
})
t.Run("trying to create alert with same title under another folder should succeed", func(t *testing.T) {
+ rules := newTestingRuleConfig(t)
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
err = enc.Encode(&rules)
@@ -469,8 +478,6 @@ func TestRulerRulesFilterByDashboard(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
- // override bus to get the GetSignedInUserQuery handler
- store.Bus = bus.GetBus()
// Create the namespace under default organisation (orgID = 1) where we'll save our alerts to.
dashboardUID, err := createFolder(t, store, 0, "default")
@@ -788,7 +795,9 @@ func TestRulerRulesFilterByDashboard(t *testing.T) {
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
- require.JSONEq(t, `{"message": "invalid panel_id: strconv.ParseInt: parsing \"invalid\": invalid syntax"}`, string(b))
+ var res map[string]interface{}
+ require.NoError(t, json.Unmarshal(b, &res))
+ require.Equal(t, `invalid panel_id: strconv.ParseInt: parsing "invalid": invalid syntax`, res["message"])
}
// Now, let's check a panel_id without dashboard_uid returns a 400 Bad Request response
@@ -804,6 +813,76 @@ func TestRulerRulesFilterByDashboard(t *testing.T) {
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
- require.JSONEq(t, `{"message": "panel_id must be set with dashboard_uid"}`, string(b))
+ var res map[string]interface{}
+ require.NoError(t, json.Unmarshal(b, &res))
+ require.Equal(t, "panel_id must be set with dashboard_uid", res["message"])
+ }
+}
+
+func newTestingRuleConfig(t *testing.T) apimodels.PostableRuleGroupConfig {
+ interval, err := model.ParseDuration("1m")
+ require.NoError(t, err)
+
+ firstRule := apimodels.PostableExtendedRuleNode{
+ ApiRuleNode: &apimodels.ApiRuleNode{
+ For: interval,
+ Labels: map[string]string{"label1": "val1"},
+ Annotations: map[string]string{"annotation1": "val1"},
+ },
+ // this rule does not explicitly set no data and error states
+ // therefore it should get the default values
+ GrafanaManagedAlert: &apimodels.PostableGrafanaRule{
+ Title: "AlwaysFiring",
+ Condition: "A",
+ Data: []ngmodels.AlertQuery{
+ {
+ RefID: "A",
+ RelativeTimeRange: ngmodels.RelativeTimeRange{
+ From: ngmodels.Duration(time.Duration(5) * time.Hour),
+ To: ngmodels.Duration(time.Duration(3) * time.Hour),
+ },
+ DatasourceUID: "-100",
+ Model: json.RawMessage(`{
+ "type": "math",
+ "expression": "2 + 3 > 1"
+ }`),
+ },
+ },
+ },
+ }
+ secondRule := apimodels.PostableExtendedRuleNode{
+ ApiRuleNode: &apimodels.ApiRuleNode{
+ For: interval,
+ Labels: map[string]string{"label1": "val1"},
+ Annotations: map[string]string{"annotation1": "val1"},
+ },
+ // this rule does not explicitly set no data and error states
+ // therefore it should get the default values
+ GrafanaManagedAlert: &apimodels.PostableGrafanaRule{
+ Title: "AlwaysFiring2",
+ Condition: "A",
+ Data: []ngmodels.AlertQuery{
+ {
+ RefID: "A",
+ RelativeTimeRange: ngmodels.RelativeTimeRange{
+ From: ngmodels.Duration(time.Duration(5) * time.Hour),
+ To: ngmodels.Duration(time.Duration(3) * time.Hour),
+ },
+ DatasourceUID: "-100",
+ Model: json.RawMessage(`{
+ "type": "math",
+ "expression": "2 + 3 > 1"
+ }`),
+ },
+ },
+ },
+ }
+
+ return apimodels.PostableRuleGroupConfig{
+ Name: "arulegroup",
+ Rules: []apimodels.PostableExtendedRuleNode{
+ firstRule,
+ secondRule,
+ },
}
}
diff --git a/pkg/tests/api/plugins/api_plugins_test.go b/pkg/tests/api/plugins/api_plugins_test.go
index 04073f43e1a..bf44d6202f4 100644
--- a/pkg/tests/api/plugins/api_plugins_test.go
+++ b/pkg/tests/api/plugins/api_plugins_test.go
@@ -10,7 +10,6 @@ import (
"path/filepath"
"testing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/tests/testinfra"
@@ -42,8 +41,6 @@ func TestPlugins(t *testing.T) {
}
t.Run("Install", func(t *testing.T) {
- store.Bus = bus.GetBus()
-
createUser(t, store, models.CreateUserCommand{Login: usernameNonAdmin, Password: defaultPassword, IsAdmin: false})
createUser(t, store, models.CreateUserCommand{Login: usernameAdmin, Password: defaultPassword, IsAdmin: true})
diff --git a/pkg/tsdb/azuremonitor/deprecated/applicationinsights-datasource.go b/pkg/tsdb/azuremonitor/deprecated/applicationinsights-datasource.go
index c75b79ea252..4520522531e 100644
--- a/pkg/tsdb/azuremonitor/deprecated/applicationinsights-datasource.go
+++ b/pkg/tsdb/azuremonitor/deprecated/applicationinsights-datasource.go
@@ -14,13 +14,13 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
+ "go.opentelemetry.io/otel/attribute"
+
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog"
azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/types"
"github.com/grafana/grafana/pkg/util/errutil"
- "go.opentelemetry.io/otel/attribute"
- "golang.org/x/net/context/ctxhttp"
)
// ApplicationInsightsDatasource calls the application insights query API.
@@ -158,7 +158,7 @@ func (e *ApplicationInsightsDatasource) executeQuery(ctx context.Context, query
tracer.Inject(ctx, req.Header, span)
azlog.Debug("ApplicationInsights", "Request URL", req.URL.String())
- res, err := ctxhttp.Do(ctx, client, req)
+ res, err := client.Do(req)
if err != nil {
dataResponse.Error = err
return dataResponse, nil
@@ -200,7 +200,7 @@ func (e *ApplicationInsightsDatasource) executeQuery(ctx context.Context, query
func (e *ApplicationInsightsDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) {
appInsightsAppID := dsInfo.Settings.AppInsightsAppId
- req, err := http.NewRequest(http.MethodGet, url, nil)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
azlog.Debug("Failed to create request", "error", err)
return nil, errutil.Wrap("Failed to create request", err)
diff --git a/pkg/tsdb/azuremonitor/deprecated/insights-analytics-datasource.go b/pkg/tsdb/azuremonitor/deprecated/insights-analytics-datasource.go
index 27a6c866e67..00d29951fca 100644
--- a/pkg/tsdb/azuremonitor/deprecated/insights-analytics-datasource.go
+++ b/pkg/tsdb/azuremonitor/deprecated/insights-analytics-datasource.go
@@ -12,14 +12,14 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
+ "go.opentelemetry.io/otel/attribute"
+
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/loganalytics"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/macros"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/types"
"github.com/grafana/grafana/pkg/util/errutil"
- "go.opentelemetry.io/otel/attribute"
- "golang.org/x/net/context/ctxhttp"
)
type InsightsAnalyticsDatasource struct {
@@ -121,7 +121,7 @@ func (e *InsightsAnalyticsDatasource) executeQuery(ctx context.Context, query *I
}
azlog.Debug("ApplicationInsights", "Request URL", req.URL.String())
- res, err := ctxhttp.Do(ctx, client, req)
+ res, err := client.Do(req)
if err != nil {
return dataResponseError(err)
}
@@ -180,7 +180,7 @@ func (e *InsightsAnalyticsDatasource) executeQuery(ctx context.Context, query *I
func (e *InsightsAnalyticsDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) {
appInsightsAppID := dsInfo.Settings.AppInsightsAppId
- req, err := http.NewRequest(http.MethodGet, url, nil)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
azlog.Debug("Failed to create request", "error", err)
return nil, errutil.Wrap("Failed to create request", err)
diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go
index cc96a88ebac..6370b36392d 100644
--- a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go
+++ b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go
@@ -15,14 +15,14 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
+ "go.opentelemetry.io/otel/attribute"
+
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/macros"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/types"
"github.com/grafana/grafana/pkg/util/errutil"
- "go.opentelemetry.io/otel/attribute"
- "golang.org/x/net/context/ctxhttp"
)
// AzureLogAnalyticsDatasource calls the Azure Log Analytics API's
@@ -173,7 +173,7 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A
tracer.Inject(ctx, req.Header, span)
azlog.Debug("AzureLogAnalytics", "Request ApiURL", req.URL.String())
- res, err := ctxhttp.Do(ctx, client, req)
+ res, err := client.Do(req)
if err != nil {
return dataResponseErrorWithExecuted(err)
}
@@ -224,7 +224,7 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A
}
func (e *AzureLogAnalyticsDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) {
- req, err := http.NewRequest(http.MethodGet, url, nil)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
azlog.Debug("Failed to create request", "error", err)
return nil, errutil.Wrap("failed to create request", err)
diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go
index 0fcd93f7fca..84279f4de67 100644
--- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go
+++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go
@@ -14,6 +14,8 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
+ "go.opentelemetry.io/otel/attribute"
+
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog"
@@ -21,8 +23,6 @@ import (
azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/types"
"github.com/grafana/grafana/pkg/util/errutil"
- "go.opentelemetry.io/otel/attribute"
- "golang.org/x/net/context/ctxhttp"
)
// AzureMonitorDatasource calls the Azure Monitor API - one of the four API's supported
@@ -179,7 +179,7 @@ func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *types.
azlog.Debug("AzureMonitor", "Request ApiURL", req.URL.String())
azlog.Debug("AzureMonitor", "Target", query.Target)
- res, err := ctxhttp.Do(ctx, cli, req)
+ res, err := cli.Do(req)
if err != nil {
dataResponse.Error = err
return dataResponse
@@ -212,7 +212,7 @@ func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *types.
}
func (e *AzureMonitorDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) {
- req, err := http.NewRequest(http.MethodGet, url, nil)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
azlog.Debug("Failed to create request", "error", err)
return nil, errutil.Wrap("Failed to create request", err)
diff --git a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go
index f99a837a6bc..ad26abfaa5e 100644
--- a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go
+++ b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go
@@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana-azure-sdk-go/azsettings"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
+ "go.opentelemetry.io/otel/attribute"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -23,8 +24,6 @@ import (
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/macros"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/types"
"github.com/grafana/grafana/pkg/util/errutil"
- "go.opentelemetry.io/otel/attribute"
- "golang.org/x/net/context/ctxhttp"
)
// AzureResourceGraphResponse is the json response object from the Azure Resource Graph Analytics API.
@@ -180,7 +179,7 @@ func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query *
tracer.Inject(ctx, req.Header, span)
azlog.Debug("AzureResourceGraph", "Request ApiURL", req.URL.String())
- res, err := ctxhttp.Do(ctx, client, req)
+ res, err := client.Do(req)
if err != nil {
return dataResponseErrorWithExecuted(err)
}
@@ -227,7 +226,7 @@ func AddConfigLinks(frame data.Frame, dl string) data.Frame {
}
func (e *AzureResourceGraphDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, reqBody []byte, url string) (*http.Request, error) {
- req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(reqBody))
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(reqBody))
if err != nil {
azlog.Debug("Failed to create request", "error", err)
return nil, errutil.Wrap("failed to create request", err)
diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go
index 223a0869c8d..520d52d60a7 100644
--- a/pkg/tsdb/cloudwatch/metric_find_query_test.go
+++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go
@@ -32,7 +32,7 @@ func TestQuery_Metrics(t *testing.T) {
var cwClient fakeCWClient
NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI {
- return cwClient
+ return &cwClient
}
t.Run("Custom metrics", func(t *testing.T) {
@@ -403,7 +403,7 @@ func TestQuery_GetDimensionKeys(t *testing.T) {
var client fakeCWClient
NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI {
- return client
+ return &client
}
metrics := []*cloudwatch.Metric{
@@ -527,7 +527,7 @@ func TestQuery_ListMetricsPagination(t *testing.T) {
var client fakeCWClient
NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI {
- return client
+ return &client
}
metrics := []*cloudwatch.Metric{
diff --git a/pkg/tsdb/cloudwatch/metrics.go b/pkg/tsdb/cloudwatch/metrics.go
index 6208d472b5d..79f2c371f92 100644
--- a/pkg/tsdb/cloudwatch/metrics.go
+++ b/pkg/tsdb/cloudwatch/metrics.go
@@ -355,6 +355,7 @@ var metricsMap = map[string][]string{
"AWS/MediaPackage": {"ActiveInput", "EgressBytes", "EgressRequestCount", "EgressResponseTime", "IngressBytes", "IngressResponseTime"},
"AWS/MediaStore": {"RequestCount", "4xxErrorCount", "5xxErrorCount", "BytesUploaded", "BytesDownloaded", "TotalTime", "TurnaroundTime"},
"AWS/MediaTailor": {"AdDecisionServer.Ads", "AdDecisionServer.Duration", "AdDecisionServer.Errors", "AdDecisionServer.FillRate", "AdDecisionServer.Timeouts", "AdNotReady", "Avails.Duration", "Avails.FillRate", "Avails.FilledDuration", "GetManifest.Errors", "Origin.Errors", "Origin.Timeouts"},
+ "AWS/MemoryDB": {"ActiveDefragHits", "AuthenticationFailures", "BytesUsedForMemoryDB", "CommandAuthorizationFailures", "CPUUtilization", "CurrConnections", "CurrItems", "DatabaseMemoryUsagePercentage", "DB0AverageTTL", "EngineCPUUtilization", "EvalBasedCmds", "Evictions", "FreeableMemory", "GeoSpatialBasedCmds", "GetTypeCmds", "HashBasedCmds", "HyperLogLogBasedCmds", "IsPrimary", "KeyAuthorizationFailures", "KeyBasedCmds", "KeyspaceHits", "KeyspaceMisses", "KeysTracked", "ListBasedCmds", "MaxReplicationThroughput", "MemoryFragmentationRatio", "NetworkBandwidthInAllowanceExceeded", "NetworkBandwidthOutAllowanceExceeded", "NetworkBytesIn", "NetworkBytesOut", "NetworkConntrackAllowanceExceeded", "NetworkPacketsIn", "NetworkPacketsOut", "NetworkPacketsPerSecondAllowanceExceeded", "NewConnections", "PrimaryLinkHealthStatus", "PubSubBasedCmds", "Reclaimed", "ReplicationBytes", "ReplicationDelayedWriteCommands", "ReplicationLag", "SetBasedCmds", "SetTypeCmds", "SortedSetBasedCmds", "StringBasedCmds", "StreamBasedCmds", "SwapUsage"},
"AWS/NATGateway": {"ActiveConnectionCount", "BytesInFromDestination", "BytesInFromSource", "BytesOutToDestination", "BytesOutToSource", "ConnectionAttemptCount", "ConnectionEstablishedCount", "ErrorPortAllocation", "IdleTimeoutCount", "PacketsDropCount", "PacketsInFromDestination", "PacketsInFromSource", "PacketsOutToDestination", "PacketsOutToSource"},
"AWS/Neptune": {"CPUUtilization", "ClusterReplicaLag", "ClusterReplicaLagMaximum", "ClusterReplicaLagMinimum", "EngineUptime", "FreeLocalStorage", "FreeableMemory", "GremlinErrors", "GremlinHttp1xx", "GremlinHttp2xx", "GremlinHttp4xx", "GremlinHttp5xx", "GremlinRequests", "GremlinRequestsPerSec", "GremlinWebSocketAvailableConnections", "GremlinWebSocketClientErrors", "GremlinWebSocketServerErrors", "GremlinWebSocketSuccess", "Http100", "Http101", "Http1xx", "Http200", "Http2xx", "Http400", "Http403", "Http405", "Http413", "Http429", "Http4xx", "Http500", "Http501", "Http5xx", "LoaderErrors", "LoaderRequests", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "SparqlErrors", "SparqlHttp1xx", "SparqlHttp2xx", "SparqlHttp4xx", "SparqlHttp5xx", "SparqlRequests", "SparqlRequestsPerSec", "StatusErrors", "StatusRequests", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs"},
"AWS/NetworkELB": {"ActiveFlowCount", "ActiveFlowCount_TLS", "ClientTLSNegotiationErrorCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "NewFlowCount_TLS", "ProcessedBytes", "ProcessedBytes_TLS", "TCP_Client_Reset_Count", "TCP_ELB_Reset_Count", "TCP_Target_Reset_Count", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"},
@@ -468,6 +469,7 @@ var dimensionsMap = map[string][]string{
"AWS/MediaPackage": {"Channel", "No Dimension", "OriginEndpoint", "StatusCodeRange"},
"AWS/MediaStore": {"ContainerName", "ObjectGroupName", "RequestType"},
"AWS/MediaTailor": {"ConfigurationName"},
+ "AWS/MemoryDB": {"ClusterName", "NodeName"},
"AWS/NATGateway": {"NatGatewayId"},
"AWS/Neptune": {"DBClusterIdentifier", "DatabaseClass", "EngineName", "Role"},
"AWS/NetworkELB": {"AvailabilityZone", "LoadBalancer", "TargetGroup"},
diff --git a/pkg/tsdb/cloudwatch/time_series_query_test.go b/pkg/tsdb/cloudwatch/time_series_query_test.go
index 7c55c4d2459..72c12e6dc17 100644
--- a/pkg/tsdb/cloudwatch/time_series_query_test.go
+++ b/pkg/tsdb/cloudwatch/time_series_query_test.go
@@ -30,7 +30,7 @@ func TestTimeSeriesQuery(t *testing.T) {
var cwClient fakeCWClient
NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI {
- return cwClient
+ return &cwClient
}
t.Run("Custom metrics", func(t *testing.T) {
diff --git a/pkg/tsdb/cloudwatch/utils_test.go b/pkg/tsdb/cloudwatch/utils_test.go
index e6bf1156535..9f1213ed435 100644
--- a/pkg/tsdb/cloudwatch/utils_test.go
+++ b/pkg/tsdb/cloudwatch/utils_test.go
@@ -67,11 +67,11 @@ type fakeCWClient struct {
MetricsPerPage int
}
-func (c fakeCWClient) GetMetricDataWithContext(aws.Context, *cloudwatch.GetMetricDataInput, ...request.Option) (*cloudwatch.GetMetricDataOutput, error) {
+func (c *fakeCWClient) GetMetricDataWithContext(aws.Context, *cloudwatch.GetMetricDataInput, ...request.Option) (*cloudwatch.GetMetricDataOutput, error) {
return &c.GetMetricDataOutput, nil
}
-func (c fakeCWClient) ListMetricsPages(input *cloudwatch.ListMetricsInput, fn func(*cloudwatch.ListMetricsOutput, bool) bool) error {
+func (c *fakeCWClient) ListMetricsPages(input *cloudwatch.ListMetricsInput, fn func(*cloudwatch.ListMetricsOutput, bool) bool) error {
if c.MetricsPerPage == 0 {
c.MetricsPerPage = 1000
}
diff --git a/pkg/tsdb/elasticsearch/client/client.go b/pkg/tsdb/elasticsearch/client/client.go
index 2371ddf0e54..3118693b2b7 100644
--- a/pkg/tsdb/elasticsearch/client/client.go
+++ b/pkg/tsdb/elasticsearch/client/client.go
@@ -20,8 +20,6 @@ import (
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/tsdb/intervalv2"
-
- "golang.org/x/net/context/ctxhttp"
)
type DatasourceInfo struct {
@@ -161,9 +159,9 @@ func (c *baseClientImpl) executeRequest(method, uriPath, uriQuery string, body [
var req *http.Request
if method == http.MethodPost {
- req, err = http.NewRequest(http.MethodPost, u.String(), bytes.NewBuffer(body))
+ req, err = http.NewRequestWithContext(c.ctx, http.MethodPost, u.String(), bytes.NewBuffer(body))
} else {
- req, err = http.NewRequest(http.MethodGet, u.String(), nil)
+ req, err = http.NewRequestWithContext(c.ctx, http.MethodGet, u.String(), nil)
}
if err != nil {
return nil, err
@@ -193,7 +191,7 @@ func (c *baseClientImpl) executeRequest(method, uriPath, uriQuery string, body [
clientLog.Debug("Executed request", "took", elapsed)
}()
//nolint:bodyclose
- resp, err := ctxhttp.Do(c.ctx, httpClient, req)
+ resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
diff --git a/pkg/tsdb/elasticsearch/elasticsearch.go b/pkg/tsdb/elasticsearch/elasticsearch.go
index 48cb5da2964..2f2b2ca8d8f 100644
--- a/pkg/tsdb/elasticsearch/elasticsearch.go
+++ b/pkg/tsdb/elasticsearch/elasticsearch.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "strconv"
"github.com/Masterminds/semver"
"github.com/grafana/grafana-plugin-sdk-go/backend"
@@ -95,8 +96,17 @@ func newInstanceSettings() datasource.InstanceFactoryFunc {
timeInterval = ""
}
- maxConcurrentShardRequests, ok := jsonData["maxConcurrentShardRequests"].(float64)
- if !ok {
+ var maxConcurrentShardRequests float64
+
+ switch v := jsonData["maxConcurrentShardRequests"].(type) {
+ case float64:
+ maxConcurrentShardRequests = v
+ case string:
+ maxConcurrentShardRequests, err = strconv.ParseFloat(v, 64)
+ if err != nil {
+ maxConcurrentShardRequests = 256
+ }
+ default:
maxConcurrentShardRequests = 256
}
diff --git a/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go
index bc3b61e9e9b..2844ac5ad70 100644
--- a/pkg/tsdb/graphite/graphite.go
+++ b/pkg/tsdb/graphite/graphite.go
@@ -15,7 +15,6 @@ import (
"time"
"go.opentelemetry.io/otel/attribute"
- "golang.org/x/net/context/ctxhttp"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
@@ -148,7 +147,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
s.logger.Debug("Graphite request", "params", formData)
}
- graphiteReq, err := s.createRequest(dsInfo, formData)
+ graphiteReq, err := s.createRequest(ctx, dsInfo, formData)
if err != nil {
return &result, err
}
@@ -163,7 +162,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
defer span.End()
s.tracer.Inject(ctx, graphiteReq.Header, span)
- res, err := ctxhttp.Do(ctx, dsInfo.HTTPClient, graphiteReq)
+ res, err := dsInfo.HTTPClient.Do(graphiteReq)
if err != nil {
return &result, err
}
@@ -252,14 +251,14 @@ func (s *Service) toDataFrames(response *http.Response) (frames data.Frames, err
return frames, nil
}
-func (s *Service) createRequest(dsInfo *datasourceInfo, data url.Values) (*http.Request, error) {
+func (s *Service) createRequest(ctx context.Context, dsInfo *datasourceInfo, data url.Values) (*http.Request, error) {
u, err := url.Parse(dsInfo.URL)
if err != nil {
return nil, err
}
u.Path = path.Join(u.Path, "render")
- req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(data.Encode()))
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(data.Encode()))
if err != nil {
s.logger.Info("Failed to create request", "error", err)
return nil, fmt.Errorf("failed to create request: %w", err)
diff --git a/pkg/tsdb/legacydata/service/service_test.go b/pkg/tsdb/legacydata/service/service_test.go
index 23af5c2a495..f4eea521006 100644
--- a/pkg/tsdb/legacydata/service/service_test.go
+++ b/pkg/tsdb/legacydata/service/service_test.go
@@ -5,7 +5,6 @@ import (
"testing"
"github.com/grafana/grafana-plugin-sdk-go/backend"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
@@ -39,7 +38,7 @@ func TestHandleRequest(t *testing.T) {
return backend.NewQueryDataResponse(), nil
}
secretsStore := kvstore.SetupTestService(t)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
+ dsService := datasourceservice.ProvideService(nil, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewPermissionsServicesMock())
s := ProvideService(client, nil, dsService)
ds := &models.DataSource{Id: 12, Type: "unregisteredType", JsonData: simplejson.New()}
diff --git a/pkg/tsdb/loki/api.go b/pkg/tsdb/loki/api.go
index 92ffb697049..08e3b1eb8c5 100644
--- a/pkg/tsdb/loki/api.go
+++ b/pkg/tsdb/loki/api.go
@@ -51,7 +51,13 @@ func makeRequest(ctx context.Context, lokiDsUrl string, query lokiQuery) (*http.
// is ignored, so it would be nicer to not send it in such cases,
// but we cannot detect that situation, so we always send it.
// it should not break anything.
- qs.Set("step", query.Step.String())
+ // NOTE2: we do this at millisecond precision for two reasons:
+ // a. Loki cannot do steps with better precision anyway,
+ // so the microsecond & nanosecond part can be ignored.
+ // b. having it always be number+'ms' makes it more robust and
+ // precise, as Loki does not support step with float number
+ // and time-specifier, like "1.5s"
+ qs.Set("step", fmt.Sprintf("%dms", query.Step.Milliseconds()))
lokiUrl.Path = "/loki/api/v1/query_range"
}
case QueryTypeInstant:
diff --git a/pkg/tsdb/loki/frame.go b/pkg/tsdb/loki/frame.go
index 35314e22e65..5593c59ae57 100644
--- a/pkg/tsdb/loki/frame.go
+++ b/pkg/tsdb/loki/frame.go
@@ -2,6 +2,7 @@ package loki
import (
"fmt"
+ "hash/fnv"
"sort"
"strings"
"time"
@@ -11,14 +12,42 @@ import (
// we adjust the dataframes to be the way frontend & alerting
// wants them.
-func adjustFrame(frame *data.Frame, query *lokiQuery) *data.Frame {
+func adjustFrame(frame *data.Frame, query *lokiQuery) error {
+ fields := frame.Fields
+
+ if len(fields) < 2 {
+ return fmt.Errorf("missing fields in frame")
+ }
+
+ // metric-fields have "timefield, valuefield"
+ // logs-fields have "labelsfield, timefield, ..."
+
+ secondField := fields[1]
+
+ if secondField.Type() == data.FieldTypeFloat64 {
+ return adjustMetricFrame(frame, query)
+ } else {
+ return adjustLogsFrame(frame, query)
+ }
+}
+
+func adjustMetricFrame(frame *data.Frame, query *lokiQuery) error {
+ fields := frame.Fields
+ // we check if the fields are of correct type
+ if len(fields) != 2 {
+ return fmt.Errorf("invalid fields in metric frame")
+ }
+
+ timeField := fields[0]
+ valueField := fields[1]
+
+ if (timeField.Type() != data.FieldTypeTime) || (valueField.Type() != data.FieldTypeFloat64) {
+ return fmt.Errorf("invalid fields in metric frame")
+ }
+
labels := getFrameLabels(frame)
- timeFields, nonTimeFields := partitionFields(frame)
-
- isMetricFrame := nonTimeFields[0].Type() != data.FieldTypeString
-
- isMetricRange := isMetricFrame && query.QueryType == QueryTypeRange
+ isMetricRange := query.QueryType == QueryTypeRange
name := formatName(labels, query)
frame.Name = name
@@ -33,48 +62,109 @@ func adjustFrame(frame *data.Frame, query *lokiQuery) *data.Frame {
frame.Meta.ExecutedQueryString = "Expr: " + query.Expr
}
- for _, field := range timeFields {
- field.Name = "time"
-
- if isMetricRange {
- if field.Config == nil {
- field.Config = &data.FieldConfig{}
- }
- field.Config.Interval = float64(query.Step.Milliseconds())
+ if isMetricRange {
+ if timeField.Config == nil {
+ timeField.Config = &data.FieldConfig{}
}
+ timeField.Config.Interval = float64(query.Step.Milliseconds())
}
- for _, field := range nonTimeFields {
- field.Name = "value"
- if field.Config == nil {
- field.Config = &data.FieldConfig{}
- }
- field.Config.DisplayNameFromDS = name
+ if valueField.Config == nil {
+ valueField.Config = &data.FieldConfig{}
+ }
+ valueField.Config.DisplayNameFromDS = name
+
+ return nil
+}
+
+func adjustLogsFrame(frame *data.Frame, query *lokiQuery) error {
+ // we check if the fields are of correct type and length
+ fields := frame.Fields
+ if len(fields) != 3 {
+ return fmt.Errorf("invalid fields in logs frame")
}
- // for streams-dataframes, we need to send to the browser the nanosecond-precision timestamp too.
+ labelsField := fields[0]
+ timeField := fields[1]
+ lineField := fields[2]
+
+ if (timeField.Type() != data.FieldTypeTime) || (lineField.Type() != data.FieldTypeString) || (labelsField.Type() != data.FieldTypeString) {
+ return fmt.Errorf("invalid fields in metric frame")
+ }
+
+ if (timeField.Len() != lineField.Len()) || (timeField.Len() != labelsField.Len()) {
+ return fmt.Errorf("invalid fields in metric frame")
+ }
+
+ if frame.Meta == nil {
+ frame.Meta = &data.FrameMeta{}
+ }
+
+ frame.Meta.ExecutedQueryString = "Expr: " + query.Expr
+
+ // we need to send to the browser the nanosecond-precision timestamp too.
// usually timestamps become javascript-date-objects in the browser automatically, which only
// have millisecond-precision.
// so we send a separate timestamp-as-string field too.
- if !isMetricFrame {
- stringTimeField := makeStringTimeField(timeFields[0])
- frame.Fields = append(frame.Fields, stringTimeField)
- }
+ stringTimeField := makeStringTimeField(timeField)
- return frame
+ idField, err := makeIdField(stringTimeField, lineField, labelsField, frame.RefID)
+ if err != nil {
+ return err
+ }
+ frame.Fields = append(frame.Fields, stringTimeField, idField)
+ return nil
}
-func makeStringTimeField(field *data.Field) *data.Field {
- length := field.Len()
+func makeStringTimeField(timeField *data.Field) *data.Field {
+ length := timeField.Len()
stringTimestamps := make([]string, length)
for i := 0; i < length; i++ {
- if v, ok := field.ConcreteAt(i); ok {
- nsNumber := v.(time.Time).UnixNano()
- stringTimestamps[i] = fmt.Sprintf("%d", nsNumber)
- }
+ nsNumber := timeField.At(i).(time.Time).UnixNano()
+ stringTimestamps[i] = fmt.Sprintf("%d", nsNumber)
}
- return data.NewField("tsNs", field.Labels.Copy(), stringTimestamps)
+ return data.NewField("tsNs", timeField.Labels.Copy(), stringTimestamps)
+}
+
+func calculateCheckSum(time string, line string, labels string) (string, error) {
+ input := []byte(line + "_" + labels)
+ hash := fnv.New32()
+ _, err := hash.Write(input)
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%s_%x", time, hash.Sum32()), nil
+}
+
+func makeIdField(stringTimeField *data.Field, lineField *data.Field, labelsField *data.Field, refId string) (*data.Field, error) {
+ length := stringTimeField.Len()
+
+ ids := make([]string, length)
+
+ checksums := make(map[string]int)
+
+ for i := 0; i < length; i++ {
+ time := stringTimeField.At(i).(string)
+ line := lineField.At(i).(string)
+ labels := labelsField.At(i).(string)
+
+ sum, err := calculateCheckSum(time, line, labels)
+ if err != nil {
+ return nil, err
+ }
+
+ sumCount := checksums[sum]
+ idSuffix := ""
+ if sumCount > 0 {
+ // we had this checksum already, we need to do something to make it unique
+ idSuffix = fmt.Sprintf("_%d", sumCount)
+ }
+ checksums[sum] = sumCount + 1
+
+ ids[i] = sum + idSuffix + "_" + refId
+ }
+ return data.NewField("id", nil, ids), nil
}
func formatNamePrometheusStyle(labels map[string]string) string {
@@ -119,18 +209,3 @@ func getFrameLabels(frame *data.Frame) map[string]string {
return labels
}
-
-func partitionFields(frame *data.Frame) ([]*data.Field, []*data.Field) {
- var timeFields []*data.Field
- var nonTimeFields []*data.Field
-
- for _, field := range frame.Fields {
- if field.Type() == data.FieldTypeTime {
- timeFields = append(timeFields, field)
- } else {
- nonTimeFields = append(nonTimeFields, field)
- }
- }
-
- return timeFields, nonTimeFields
-}
diff --git a/pkg/tsdb/loki/frame_test.go b/pkg/tsdb/loki/frame_test.go
index ee2d0a74637..0e4bf3ebcfc 100644
--- a/pkg/tsdb/loki/frame_test.go
+++ b/pkg/tsdb/loki/frame_test.go
@@ -37,7 +37,56 @@ func TestFormatName(t *testing.T) {
}
func TestAdjustFrame(t *testing.T) {
- t.Run("response should be parsed normally", func(t *testing.T) {
+ t.Run("logs-frame metadata should be set correctly", func(t *testing.T) {
+ frame := data.NewFrame("",
+ data.NewField("labels", nil, []string{
+ `{"level":"info"}`,
+ `{"level":"error"}`,
+ `{"level":"error"}`,
+ `{"level":"info"}`,
+ }),
+ data.NewField("time", nil, []time.Time{
+ time.Date(2022, 1, 2, 3, 4, 5, 6, time.UTC),
+ time.Date(2022, 1, 2, 3, 5, 5, 6, time.UTC),
+ time.Date(2022, 1, 2, 3, 5, 5, 6, time.UTC),
+ time.Date(2022, 1, 2, 3, 6, 5, 6, time.UTC),
+ }),
+ data.NewField("line", nil, []string{"line1", "line2", "line2", "line3"}),
+ )
+
+ frame.RefID = "A"
+
+ query := &lokiQuery{
+ Expr: `{type="important"}`,
+ QueryType: QueryTypeRange,
+ }
+
+ err := adjustFrame(frame, query)
+ require.NoError(t, err)
+
+ fields := frame.Fields
+
+ require.Equal(t, 5, len(fields))
+ tsNsField := fields[3]
+ require.Equal(t, "tsNs", tsNsField.Name)
+ require.Equal(t, data.FieldTypeString, tsNsField.Type())
+ require.Equal(t, 4, tsNsField.Len())
+ require.Equal(t, "1641092645000000006", tsNsField.At(0))
+ require.Equal(t, "1641092705000000006", tsNsField.At(1))
+ require.Equal(t, "1641092705000000006", tsNsField.At(2))
+ require.Equal(t, "1641092765000000006", tsNsField.At(3))
+
+ idField := fields[4]
+ require.Equal(t, "id", idField.Name)
+ require.Equal(t, data.FieldTypeString, idField.Type())
+ require.Equal(t, 4, idField.Len())
+ require.Equal(t, "1641092645000000006_a36f4e1b_A", idField.At(0))
+ require.Equal(t, "1641092705000000006_1d77c9ca_A", idField.At(1))
+ require.Equal(t, "1641092705000000006_1d77c9ca_1_A", idField.At(2))
+ require.Equal(t, "1641092765000000006_948c1a7d_A", idField.At(3))
+ })
+
+ t.Run("logs-frame id and string-time fields should be created", func(t *testing.T) {
field1 := data.NewField("", nil, make([]time.Time, 0))
field2 := data.NewField("", nil, make([]float64, 0))
field2.Labels = data.Labels{"app": "Application", "tag2": "tag2"}
@@ -52,7 +101,8 @@ func TestAdjustFrame(t *testing.T) {
Step: time.Second * 42,
}
- adjustFrame(frame, query)
+ err := adjustFrame(frame, query)
+ require.NoError(t, err)
require.Equal(t, frame.Name, "legend Application")
require.Equal(t, frame.Meta.ExecutedQueryString, "Expr: up(ALERTS)\nStep: 42s")
@@ -72,7 +122,8 @@ func TestAdjustFrame(t *testing.T) {
frame := data.NewFrame("test", field1, field2)
frame.SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMany})
- adjustFrame(frame, query)
+ err := adjustFrame(frame, query)
+ require.NoError(t, err)
// to keep the test simple, we assume the
// first field is the time-field
diff --git a/pkg/tsdb/loki/framing_test.go b/pkg/tsdb/loki/framing_test.go
index 78d8df2cfec..696cc5d3fb6 100644
--- a/pkg/tsdb/loki/framing_test.go
+++ b/pkg/tsdb/loki/framing_test.go
@@ -59,7 +59,7 @@ func TestSuccessResponse(t *testing.T) {
bytes, err := os.ReadFile(responseFileName)
require.NoError(t, err)
- frames, err := runQuery(context.Background(), makeMockedAPI(200, "application/json", bytes), &test.query)
+ frames, err := runQuery(context.Background(), makeMockedAPI(http.StatusOK, "application/json", bytes), &test.query)
require.NoError(t, err)
dr := &backend.DataResponse{
diff --git a/pkg/tsdb/loki/loki_bench_test.go b/pkg/tsdb/loki/loki_bench_test.go
index 331b8b533f1..de0b6d50a17 100644
--- a/pkg/tsdb/loki/loki_bench_test.go
+++ b/pkg/tsdb/loki/loki_bench_test.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"math/rand"
+ "net/http"
"strings"
"testing"
)
@@ -16,7 +17,7 @@ func BenchmarkMatrixJson(b *testing.B) {
b.ResetTimer()
for n := 0; n < b.N; n++ {
- _, _ = runQuery(context.Background(), makeMockedAPI(200, "application/json", bytes), &lokiQuery{})
+ _, _ = runQuery(context.Background(), makeMockedAPI(http.StatusOK, "application/json", bytes), &lokiQuery{})
}
}
diff --git a/pkg/tsdb/loki/parse_response.go b/pkg/tsdb/loki/parse_response.go
index 48eae74861e..73ed248c4d5 100644
--- a/pkg/tsdb/loki/parse_response.go
+++ b/pkg/tsdb/loki/parse_response.go
@@ -2,11 +2,13 @@ package loki
import (
"fmt"
+ "sort"
"time"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/loki/pkg/loghttp"
"github.com/grafana/loki/pkg/logqlmodel/stats"
+ jsoniter "github.com/json-iterator/go"
)
func parseResponse(value *loghttp.QueryResponse, query *lokiQuery) (data.Frames, error) {
@@ -17,7 +19,10 @@ func parseResponse(value *loghttp.QueryResponse, query *lokiQuery) (data.Frames,
}
for _, frame := range frames {
- adjustFrame(frame, query)
+ err = adjustFrame(frame, query)
+ if err != nil {
+ return nil, err
+ }
}
return frames, nil
@@ -31,7 +36,7 @@ func lokiResponseToDataFrames(value *loghttp.QueryResponse, query *lokiQuery) (d
case loghttp.Vector:
return lokiVectorToDataFrames(res, query, stats), nil
case loghttp.Streams:
- return lokiStreamsToDataFrames(res, query, stats), nil
+ return lokiStreamsToDataFrames(res, query, stats)
default:
return nil, fmt.Errorf("resultType %T not supported{", res)
}
@@ -54,8 +59,8 @@ func lokiMatrixToDataFrames(matrix loghttp.Matrix, query *lokiQuery, stats []dat
values = append(values, float64(k.Value))
}
- timeField := data.NewField("", nil, timeVector)
- valueField := data.NewField("", tags, values)
+ timeField := data.NewField("time", nil, timeVector)
+ valueField := data.NewField("value", tags, values)
frame := data.NewFrame("", timeField, valueField)
frame.SetMeta(&data.FrameMeta{
@@ -79,8 +84,8 @@ func lokiVectorToDataFrames(vector loghttp.Vector, query *lokiQuery, stats []dat
for k, v := range v.Metric {
tags[string(k)] = string(v)
}
- timeField := data.NewField("", nil, timeVector)
- valueField := data.NewField("", tags, values)
+ timeField := data.NewField("time", nil, timeVector)
+ valueField := data.NewField("value", tags, values)
frame := data.NewFrame("", timeField, valueField)
frame.SetMeta(&data.FrameMeta{
@@ -93,35 +98,61 @@ func lokiVectorToDataFrames(vector loghttp.Vector, query *lokiQuery, stats []dat
return frames
}
-func lokiStreamsToDataFrames(streams loghttp.Streams, query *lokiQuery, stats []data.QueryStat) data.Frames {
- frames := data.Frames{}
+// we serialize the labels as an ordered list of pairs
+func labelsToString(labels data.Labels) (string, error) {
+ keys := make([]string, 0, len(labels))
+ for k := range labels {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+
+ labelArray := make([][2]string, 0, len(labels))
+
+ for _, k := range keys {
+ pair := [2]string{k, labels[k]}
+ labelArray = append(labelArray, pair)
+ }
+
+ bytes, err := jsoniter.Marshal(labelArray)
+ if err != nil {
+ return "", err
+ }
+
+ return string(bytes), nil
+}
+
+func lokiStreamsToDataFrames(streams loghttp.Streams, query *lokiQuery, stats []data.QueryStat) (data.Frames, error) {
+ var timeVector []time.Time
+ var values []string
+ var labelsVector []string
for _, v := range streams {
- tags := make(map[string]string, len(v.Labels))
- timeVector := make([]time.Time, 0, len(v.Entries))
- values := make([]string, 0, len(v.Entries))
-
- for k, v := range v.Labels {
- tags[k] = v
+ labelsText, err := labelsToString(v.Labels.Map())
+ if err != nil {
+ return nil, err
}
for _, k := range v.Entries {
timeVector = append(timeVector, k.Timestamp.UTC())
values = append(values, k.Line)
+ labelsVector = append(labelsVector, labelsText)
}
-
- timeField := data.NewField("", nil, timeVector)
- valueField := data.NewField("", tags, values)
-
- frame := data.NewFrame("", timeField, valueField)
- frame.SetMeta(&data.FrameMeta{
- Stats: stats,
- })
-
- frames = append(frames, frame)
}
- return frames
+ timeField := data.NewField("ts", nil, timeVector)
+ valueField := data.NewField("line", nil, values)
+ labelsField := data.NewField("labels", nil, labelsVector)
+ labelsField.Config = &data.FieldConfig{
+ // we should have a native json-field-type
+ Custom: map[string]interface{}{"json": true},
+ }
+
+ frame := data.NewFrame("", labelsField, timeField, valueField)
+ frame.SetMeta(&data.FrameMeta{
+ Stats: stats,
+ })
+
+ return data.Frames{frame}, nil
}
func parseStats(result stats.Result) []data.QueryStat {
diff --git a/pkg/tsdb/loki/testdata/streams_simple.golden.txt b/pkg/tsdb/loki/testdata/streams_simple.golden.txt
index 76204b3a22f..dff97cb0e40 100644
--- a/pkg/tsdb/loki/testdata/streams_simple.golden.txt
+++ b/pkg/tsdb/loki/testdata/streams_simple.golden.txt
@@ -111,143 +111,21 @@ Frame[0] {
],
"executedQueryString": "Expr: query1"
}
-Name: {level="error", location="moon"}
-Dimensions: 3 Fields by 1 Rows
-+----------------------------------------+------------------------------------+---------------------+
-| Name: time | Name: value | Name: tsNs |
-| Labels: | Labels: level=error, location=moon | Labels: |
-| Type: []time.Time | Type: []string | Type: []string |
-+----------------------------------------+------------------------------------+---------------------+
-| 2022-02-16 16:50:44.81075712 +0000 UTC | log line error 1 | 1645030244810757120 |
-+----------------------------------------+------------------------------------+---------------------+
-
-
-
-Frame[1] {
- "stats": [
- {
- "displayName": "Summary: bytes processed per second",
- "unit": "Bps",
- "value": 3507022
- },
- {
- "displayName": "Summary: lines processed per second",
- "value": 24818
- },
- {
- "displayName": "Summary: total bytes processed",
- "unit": "decbytes",
- "value": 7772
- },
- {
- "displayName": "Summary: total lines processed",
- "value": 55
- },
- {
- "displayName": "Summary: exec time",
- "unit": "s",
- "value": 0.002216125
- },
- {
- "displayName": "Store: total chunks ref",
- "value": 2
- },
- {
- "displayName": "Store: total chunks downloaded",
- "value": 3
- },
- {
- "displayName": "Store: chunks download time",
- "unit": "s",
- "value": 0.000390958
- },
- {
- "displayName": "Store: head chunk bytes",
- "unit": "decbytes",
- "value": 4
- },
- {
- "displayName": "Store: head chunk lines",
- "value": 5
- },
- {
- "displayName": "Store: decompressed bytes",
- "unit": "decbytes",
- "value": 7772
- },
- {
- "displayName": "Store: decompressed lines",
- "value": 55
- },
- {
- "displayName": "Store: compressed bytes",
- "unit": "decbytes",
- "value": 31432
- },
- {
- "displayName": "Store: total duplicates",
- "value": 6
- },
- {
- "displayName": "Ingester: total reached",
- "value": 7
- },
- {
- "displayName": "Ingester: total chunks matched",
- "value": 8
- },
- {
- "displayName": "Ingester: total batches",
- "value": 9
- },
- {
- "displayName": "Ingester: total lines sent",
- "value": 10
- },
- {
- "displayName": "Ingester: head chunk bytes",
- "unit": "decbytes",
- "value": 11
- },
- {
- "displayName": "Ingester: head chunk lines",
- "value": 12
- },
- {
- "displayName": "Ingester: decompressed bytes",
- "unit": "decbytes",
- "value": 13
- },
- {
- "displayName": "Ingester: decompressed lines",
- "value": 14
- },
- {
- "displayName": "Ingester: compressed bytes",
- "unit": "decbytes",
- "value": 15
- },
- {
- "displayName": "Ingester: total duplicates",
- "value": 16
- }
- ],
- "executedQueryString": "Expr: query1"
-}
-Name: {level="info", location="moon"}
-Dimensions: 3 Fields by 4 Rows
-+-----------------------------------------+-----------------------------------+---------------------+
-| Name: time | Name: value | Name: tsNs |
-| Labels: | Labels: level=info, location=moon | Labels: |
-| Type: []time.Time | Type: []string | Type: []string |
-+-----------------------------------------+-----------------------------------+---------------------+
-| 2022-02-16 16:50:47.02773504 +0000 UTC | log line info 1 | 1645030247027735040 |
-| 2022-02-16 16:50:46.277587968 +0000 UTC | log line info 2 | 1645030246277587968 |
-| 2022-02-16 16:50:45.539423744 +0000 UTC | log line info 3 | 1645030245539423744 |
-| 2022-02-16 16:50:44.091700992 +0000 UTC | log line info 4 | 1645030244091700992 |
-+-----------------------------------------+-----------------------------------+---------------------+
+Name:
+Dimensions: 5 Fields by 6 Rows
++-------------------------------------------+-----------------------------------------+------------------+---------------------+---------------------------------+
+| Name: labels | Name: ts | Name: line | Name: tsNs | Name: id |
+| Labels: | Labels: | Labels: | Labels: | Labels: |
+| Type: []string | Type: []time.Time | Type: []string | Type: []string | Type: []string |
++-------------------------------------------+-----------------------------------------+------------------+---------------------+---------------------------------+
+| [["code","one\","],["location","moon🌙"]] | 2022-02-16 16:50:44.81075712 +0000 UTC | log line error 1 | 1645030244810757120 | 1645030244810757120_f5088206_ |
+| [["code","\",two"],["location","moon🌙"]] | 2022-02-16 16:50:47.02773504 +0000 UTC | log line info 1 | 1645030247027735040 | 1645030247027735040_46b225a4_ |
+| [["code","\",two"],["location","moon🌙"]] | 2022-02-16 16:50:46.277587968 +0000 UTC | log line info 2 | 1645030246277587968 | 1645030246277587968_c2a9f32b_ |
+| [["code","\",two"],["location","moon🌙"]] | 2022-02-16 16:50:46.277587968 +0000 UTC | log line info 2 | 1645030246277587968 | 1645030246277587968_c2a9f32b_1_ |
+| [["code","\",two"],["location","moon🌙"]] | 2022-02-16 16:50:45.539423744 +0000 UTC | log line info 3 | 1645030245539423744 | 1645030245539423744_86502e6_ |
+| [["code","\",two"],["location","moon🌙"]] | 2022-02-16 16:50:44.091700992 +0000 UTC | log line info 4 | 1645030244091700992 | 1645030244091700992_c6f5b60d_ |
++-------------------------------------------+-----------------------------------------+------------------+---------------------+---------------------------------+
====== TEST DATA RESPONSE (arrow base64) ======
-FRAME=QVJST1cxAAD/////AAkAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAMQGAAADAAAAbAAAACgAAAAEAAAAlPf//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAC09///CAAAACwAAAAgAAAAe2xldmVsPSJlcnJvciIsIGxvY2F0aW9uPSJtb29uIn0AAAAABAAAAG5hbWUAAAAA9Pf//wgAAAA8BgAAMgYAAHsic3RhdHMiOlt7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogYnl0ZXMgcHJvY2Vzc2VkIHBlciBzZWNvbmQiLCJ1bml0IjoiQnBzIiwidmFsdWUiOjM1MDcwMjJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBsaW5lcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInZhbHVlIjoyNDgxOH0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IHRvdGFsIGJ5dGVzIHByb2Nlc3NlZCIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo3NzcyfSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgbGluZXMgcHJvY2Vzc2VkIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogZXhlYyB0aW1lIiwidW5pdCI6InMiLCJ2YWx1ZSI6MC4wMDIyMTYxMjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIHJlZiIsInZhbHVlIjoyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGNodW5rcyBkb3dubG9hZGVkIiwidmFsdWUiOjN9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogY2h1bmtzIGRvd25sb2FkIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMDM5MDk1OH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjR9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjo1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGRlY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo3NzcyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGRlY29tcHJlc3NlZCBsaW5lcyIsInZhbHVlIjo1NX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjMxNDMyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGR1cGxpY2F0ZXMiLCJ2YWx1ZSI6Nn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCByZWFjaGVkIiwidmFsdWUiOjd9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgY2h1bmtzIG1hdGNoZWQiLCJ2YWx1ZSI6OH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBiYXRjaGVzIiwidmFsdWUiOjl9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgbGluZXMgc2VudCIsInZhbHVlIjoxMH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBoZWFkIGNodW5rIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjExfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgbGluZXMiLCJ2YWx1ZSI6MTJ9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjEzfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGRlY29tcHJlc3NlZCBsaW5lcyIsInZhbHVlIjoxNH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjE1fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGR1cGxpY2F0ZXMiLCJ2YWx1ZSI6MTZ9XSwiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHF1ZXJ5MSJ9AAAEAAAAbWV0YQAAAAADAAAAmAEAAIQAAAAEAAAAhv7//xQAAABgAAAAYAAAAAAAAAVcAAAAAgAAACwAAAAEAAAAeP7//wgAAAAQAAAABAAAAHRzTnMAAAAABAAAAG5hbWUAAAAAnP7//wgAAAAMAAAAAgAAAHt9AAAGAAAAbGFiZWxzAAAAAAAABP///wQAAAB0c05zAAAAAAL///8UAAAA4AAAAOQAAAAAAAAF4AAAAAMAAABwAAAALAAAAAQAAAD4/v//CAAAABAAAAAFAAAAdmFsdWUAAAAEAAAAbmFtZQAAAAAc////CAAAACwAAAAjAAAAeyJsZXZlbCI6ImVycm9yIiwibG9jYXRpb24iOiJtb29uIn0ABgAAAGxhYmVscwAAXP///wgAAABIAAAAPAAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJ7bGV2ZWw9XCJlcnJvclwiLCBsb2NhdGlvbj1cIm1vb25cIn0ifQAAAAAGAAAAY29uZmlnAAAAAAAABAAEAAQAAAAFAAAAdmFsdWUAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAAAApMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAAB0aW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAHRpbWUAAAAAAAAAAP////8IAQAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAQAAAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAmAAAAAEAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAEAAAAAAAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAgAAAAAAAAAKAAAAAAAAAATAAAAAAAAAAAAAAADAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAABS4ukpS1BYAAAAAEAAAAGxvZyBsaW5lIGVycm9yIDEAAAAAEwAAADE2NDUwMzAyNDQ4MTA3NTcxMjAAAAAAABAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA4AAAAAAAEAAEAAAAQCQAAAAAAABABAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAxAYAAAMAAABsAAAAKAAAAAQAAACU9///CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAALT3//8IAAAALAAAACAAAAB7bGV2ZWw9ImVycm9yIiwgbG9jYXRpb249Im1vb24ifQAAAAAEAAAAbmFtZQAAAAD09///CAAAADwGAAAyBgAAeyJzdGF0cyI6W3siZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBieXRlcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInVuaXQiOiJCcHMiLCJ2YWx1ZSI6MzUwNzAyMn0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGxpbmVzIHByb2Nlc3NlZCBwZXIgc2Vjb25kIiwidmFsdWUiOjI0ODE4fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgYnl0ZXMgcHJvY2Vzc2VkIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiB0b3RhbCBsaW5lcyBwcm9jZXNzZWQiLCJ2YWx1ZSI6NTV9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBleGVjIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMjIxNjEyNX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBjaHVua3MgcmVmIiwidmFsdWUiOjJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIGRvd25sb2FkZWQiLCJ2YWx1ZSI6M30seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjaHVua3MgZG93bmxvYWQgdGltZSIsInVuaXQiOiJzIiwidmFsdWUiOjAuMDAwMzkwOTU4fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6NH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGxpbmVzIiwidmFsdWUiOjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MzE0MzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjo2fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIHJlYWNoZWQiLCJ2YWx1ZSI6N30seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBjaHVua3MgbWF0Y2hlZCIsInZhbHVlIjo4fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGJhdGNoZXMiLCJ2YWx1ZSI6OX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBsaW5lcyBzZW50IiwidmFsdWUiOjEwfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTF9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjoxMn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBkZWNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTN9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjE0fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTV9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjoxNn1dLCJleGVjdXRlZFF1ZXJ5U3RyaW5nIjoiRXhwcjogcXVlcnkxIn0AAAQAAABtZXRhAAAAAAMAAACYAQAAhAAAAAQAAACG/v//FAAAAGAAAABgAAAAAAAABVwAAAACAAAALAAAAAQAAAB4/v//CAAAABAAAAAEAAAAdHNOcwAAAAAEAAAAbmFtZQAAAACc/v//CAAAAAwAAAACAAAAe30AAAYAAABsYWJlbHMAAAAAAAAE////BAAAAHRzTnMAAAAAAv///xQAAADgAAAA5AAAAAAAAAXgAAAAAwAAAHAAAAAsAAAABAAAAPj+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAABz///8IAAAALAAAACMAAAB7ImxldmVsIjoiZXJyb3IiLCJsb2NhdGlvbiI6Im1vb24ifQAGAAAAbGFiZWxzAABc////CAAAAEgAAAA8AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImVycm9yXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAAAAAYAAABjb25maWcAAAAAAAAEAAQABAAAAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAAAoCQAAQVJST1cx
-FRAME=QVJST1cxAAD/////+AgAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAMAGAAADAAAAaAAAACgAAAAEAAAAnPf//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAC89///CAAAACgAAAAfAAAAe2xldmVsPSJpbmZvIiwgbG9jYXRpb249Im1vb24ifQAEAAAAbmFtZQAAAAD49///CAAAADwGAAAyBgAAeyJzdGF0cyI6W3siZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBieXRlcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInVuaXQiOiJCcHMiLCJ2YWx1ZSI6MzUwNzAyMn0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGxpbmVzIHByb2Nlc3NlZCBwZXIgc2Vjb25kIiwidmFsdWUiOjI0ODE4fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgYnl0ZXMgcHJvY2Vzc2VkIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiB0b3RhbCBsaW5lcyBwcm9jZXNzZWQiLCJ2YWx1ZSI6NTV9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBleGVjIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMjIxNjEyNX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBjaHVua3MgcmVmIiwidmFsdWUiOjJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIGRvd25sb2FkZWQiLCJ2YWx1ZSI6M30seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjaHVua3MgZG93bmxvYWQgdGltZSIsInVuaXQiOiJzIiwidmFsdWUiOjAuMDAwMzkwOTU4fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6NH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGxpbmVzIiwidmFsdWUiOjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MzE0MzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjo2fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIHJlYWNoZWQiLCJ2YWx1ZSI6N30seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBjaHVua3MgbWF0Y2hlZCIsInZhbHVlIjo4fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGJhdGNoZXMiLCJ2YWx1ZSI6OX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBsaW5lcyBzZW50IiwidmFsdWUiOjEwfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTF9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjoxMn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBkZWNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTN9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjE0fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTV9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjoxNn1dLCJleGVjdXRlZFF1ZXJ5U3RyaW5nIjoiRXhwcjogcXVlcnkxIn0AAAQAAABtZXRhAAAAAAMAAACUAQAAhAAAAAQAAACK/v//FAAAAGAAAABgAAAAAAAABVwAAAACAAAALAAAAAQAAAB8/v//CAAAABAAAAAEAAAAdHNOcwAAAAAEAAAAbmFtZQAAAACg/v//CAAAAAwAAAACAAAAe30AAAYAAABsYWJlbHMAAAAAAAAI////BAAAAHRzTnMAAAAABv///xQAAADcAAAA4AAAAAAAAAXcAAAAAwAAAHAAAAAsAAAABAAAAPz+//8IAAAAEAAAAAUAAAB2YWx1ZQAAAAQAAABuYW1lAAAAACD///8IAAAALAAAACIAAAB7ImxldmVsIjoiaW5mbyIsImxvY2F0aW9uIjoibW9vbiJ9AAAGAAAAbGFiZWxzAABg////CAAAAEQAAAA7AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntsZXZlbD1cImluZm9cIiwgbG9jYXRpb249XCJtb29uXCJ9In0ABgAAAGNvbmZpZwAAAAAAAAQABAAEAAAABQAAAHZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAdGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAAB0aW1lAAAAAAAAAAD/////CAEAABQAAAAAAAAADAAWABQAEwAMAAQADAAAAOAAAAAAAAAAFAAAAAAAAAMEAAoAGAAMAAgABAAKAAAAFAAAAJgAAAAEAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAUAAAAAAAAADgAAAAAAAAAPAAAAAAAAAB4AAAAAAAAAAAAAAAAAAAAeAAAAAAAAAAUAAAAAAAAAJAAAAAAAAAATAAAAAAAAAAAAAAAAwAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAB63D5LUtQWACQmEktS1BYApibmSlLUFgAn3I9KUtQWAAAAAA8AAAAeAAAALQAAADwAAAAAAAAAbG9nIGxpbmUgaW5mbyAxbG9nIGxpbmUgaW5mbyAybG9nIGxpbmUgaW5mbyAzbG9nIGxpbmUgaW5mbyA0AAAAAAAAAAATAAAAJgAAADkAAABMAAAAAAAAADE2NDUwMzAyNDcwMjc3MzUwNDAxNjQ1MDMwMjQ2Mjc3NTg3OTY4MTY0NTAzMDI0NTUzOTQyMzc0NDE2NDUwMzAyNDQwOTE3MDA5OTIAAAAAEAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADgAAAAAAAQAAQAAAAgJAAAAAAAAEAEAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAADABgAAAwAAAGgAAAAoAAAABAAAAJz3//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAAvPf//wgAAAAoAAAAHwAAAHtsZXZlbD0iaW5mbyIsIGxvY2F0aW9uPSJtb29uIn0ABAAAAG5hbWUAAAAA+Pf//wgAAAA8BgAAMgYAAHsic3RhdHMiOlt7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogYnl0ZXMgcHJvY2Vzc2VkIHBlciBzZWNvbmQiLCJ1bml0IjoiQnBzIiwidmFsdWUiOjM1MDcwMjJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBsaW5lcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInZhbHVlIjoyNDgxOH0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IHRvdGFsIGJ5dGVzIHByb2Nlc3NlZCIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo3NzcyfSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgbGluZXMgcHJvY2Vzc2VkIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogZXhlYyB0aW1lIiwidW5pdCI6InMiLCJ2YWx1ZSI6MC4wMDIyMTYxMjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIHJlZiIsInZhbHVlIjoyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGNodW5rcyBkb3dubG9hZGVkIiwidmFsdWUiOjN9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogY2h1bmtzIGRvd25sb2FkIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMDM5MDk1OH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjR9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjo1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGRlY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo3NzcyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGRlY29tcHJlc3NlZCBsaW5lcyIsInZhbHVlIjo1NX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjMxNDMyfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGR1cGxpY2F0ZXMiLCJ2YWx1ZSI6Nn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCByZWFjaGVkIiwidmFsdWUiOjd9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgY2h1bmtzIG1hdGNoZWQiLCJ2YWx1ZSI6OH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBiYXRjaGVzIiwidmFsdWUiOjl9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgbGluZXMgc2VudCIsInZhbHVlIjoxMH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBoZWFkIGNodW5rIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjExfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgbGluZXMiLCJ2YWx1ZSI6MTJ9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjEzfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGRlY29tcHJlc3NlZCBsaW5lcyIsInZhbHVlIjoxNH0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjE1fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGR1cGxpY2F0ZXMiLCJ2YWx1ZSI6MTZ9XSwiZXhlY3V0ZWRRdWVyeVN0cmluZyI6IkV4cHI6IHF1ZXJ5MSJ9AAAEAAAAbWV0YQAAAAADAAAAlAEAAIQAAAAEAAAAiv7//xQAAABgAAAAYAAAAAAAAAVcAAAAAgAAACwAAAAEAAAAfP7//wgAAAAQAAAABAAAAHRzTnMAAAAABAAAAG5hbWUAAAAAoP7//wgAAAAMAAAAAgAAAHt9AAAGAAAAbGFiZWxzAAAAAAAACP///wQAAAB0c05zAAAAAAb///8UAAAA3AAAAOAAAAAAAAAF3AAAAAMAAABwAAAALAAAAAQAAAD8/v//CAAAABAAAAAFAAAAdmFsdWUAAAAEAAAAbmFtZQAAAAAg////CAAAACwAAAAiAAAAeyJsZXZlbCI6ImluZm8iLCJsb2NhdGlvbiI6Im1vb24ifQAABgAAAGxhYmVscwAAYP///wgAAABEAAAAOwAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJ7bGV2ZWw9XCJpbmZvXCIsIGxvY2F0aW9uPVwibW9vblwifSJ9AAYAAABjb25maWcAAAAAAAAEAAQABAAAAAUAAAB2YWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAAAgCQAAQVJST1cx
+FRAME=QVJST1cxAAD/////IAkAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAKQGAAADAAAATAAAACgAAAAEAAAAfPf//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAACc9///CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAALz3//8IAAAAPAYAADIGAAB7InN0YXRzIjpbeyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGJ5dGVzIHByb2Nlc3NlZCBwZXIgc2Vjb25kIiwidW5pdCI6IkJwcyIsInZhbHVlIjozNTA3MDIyfSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogbGluZXMgcHJvY2Vzc2VkIHBlciBzZWNvbmQiLCJ2YWx1ZSI6MjQ4MTh9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiB0b3RhbCBieXRlcyBwcm9jZXNzZWQiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6Nzc3Mn0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IHRvdGFsIGxpbmVzIHByb2Nlc3NlZCIsInZhbHVlIjo1NX0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGV4ZWMgdGltZSIsInVuaXQiOiJzIiwidmFsdWUiOjAuMDAyMjE2MTI1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IHRvdGFsIGNodW5rcyByZWYiLCJ2YWx1ZSI6Mn0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBjaHVua3MgZG93bmxvYWRlZCIsInZhbHVlIjozfSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGNodW5rcyBkb3dubG9hZCB0aW1lIiwidW5pdCI6InMiLCJ2YWx1ZSI6MC4wMDAzOTA5NTh9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogaGVhZCBjaHVuayBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjo0fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGhlYWQgY2h1bmsgbGluZXMiLCJ2YWx1ZSI6NX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBkZWNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6Nzc3Mn0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBkZWNvbXByZXNzZWQgbGluZXMiLCJ2YWx1ZSI6NTV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjozMTQzMn0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBkdXBsaWNhdGVzIiwidmFsdWUiOjZ9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgcmVhY2hlZCIsInZhbHVlIjo3fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGNodW5rcyBtYXRjaGVkIiwidmFsdWUiOjh9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgYmF0Y2hlcyIsInZhbHVlIjo5fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGxpbmVzIHNlbnQiLCJ2YWx1ZSI6MTB9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogaGVhZCBjaHVuayBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjoxMX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBoZWFkIGNodW5rIGxpbmVzIiwidmFsdWUiOjEyfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGRlY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjoxM30seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBkZWNvbXByZXNzZWQgbGluZXMiLCJ2YWx1ZSI6MTR9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogY29tcHJlc3NlZCBieXRlcyIsInVuaXQiOiJkZWNieXRlcyIsInZhbHVlIjoxNX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBkdXBsaWNhdGVzIiwidmFsdWUiOjE2fV0sImV4ZWN1dGVkUXVlcnlTdHJpbmciOiJFeHByOiBxdWVyeTEifQAABAAAAG1ldGEAAAAABQAAAKgBAAA0AQAA2AAAAFgAAAAEAAAAfv7//xQAAAA4AAAAOAAAAAAAAAU0AAAAAQAAAAQAAABE/v//CAAAAAwAAAACAAAAaWQAAAQAAABuYW1lAAAAAAAAAAAk/v//AgAAAGlkAADO/v//FAAAAGAAAABgAAAAAAAABVwAAAACAAAALAAAAAQAAACY/v//CAAAABAAAAAEAAAAdHNOcwAAAAAEAAAAbmFtZQAAAAC8/v//CAAAAAwAAAACAAAAe30AAAYAAABsYWJlbHMAAAAAAACc/v//BAAAAHRzTnMAAAAASv///xQAAAA8AAAAPAAAAAAAAAU4AAAAAQAAAAQAAAAQ////CAAAABAAAAAEAAAAbGluZQAAAAAEAAAAbmFtZQAAAAAAAAAA9P7//wQAAABsaW5lAAAAAKL///8UAAAAOAAAAEAAAAAAAAAKQAAAAAEAAAAEAAAAaP///wgAAAAMAAAAAgAAAHRzAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAIAAAB0cwAAAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAIAAAACEAAAAAAAABYAAAAACAAAANAAAAAQAAADc////CAAAABAAAAAGAAAAbGFiZWxzAAAEAAAAbmFtZQAAAAAIAAwACAAEAAgAAAAIAAAAJAAAABgAAAB7ImN1c3RvbSI6eyJqc29uIjp0cnVlfX0AAAAABgAAAGNvbmZpZwAAAAAAAAQABAAEAAAABgAAAGxhYmVscwAA/////4gBAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAABAAwAAAAAAABQAAAAAAAADBAAKABgADAAIAAQACgAAABQAAAD4AAAABgAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAAAAAAAAAIAAAAAAAAAACAQAAAAAAACgBAAAAAAAAAAAAAAAAAAAoAQAAAAAAADAAAAAAAAAAWAEAAAAAAAAAAAAAAAAAAFgBAAAAAAAAHAAAAAAAAAB4AQAAAAAAAFsAAAAAAAAA2AEAAAAAAAAAAAAAAAAAANgBAAAAAAAAHAAAAAAAAAD4AQAAAAAAAHIAAAAAAAAAcAIAAAAAAAAAAAAAAAAAAHACAAAAAAAAHAAAAAAAAACQAgAAAAAAAK8AAAAAAAAAAAAAAAUAAAAGAAAAAAAAAAAAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAAAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAArAAAAVgAAAIEAAACsAAAA1wAAAAIBAAAAAAAAW1siY29kZSIsIm9uZVwiLCJdLFsibG9jYXRpb24iLCJtb29u8J+MmSJdXVtbImNvZGUiLCJcIix0d28iXSxbImxvY2F0aW9uIiwibW9vbvCfjJkiXV1bWyJjb2RlIiwiXCIsdHdvIl0sWyJsb2NhdGlvbiIsIm1vb27wn4yZIl1dW1siY29kZSIsIlwiLHR3byJdLFsibG9jYXRpb24iLCJtb29u8J+MmSJdXVtbImNvZGUiLCJcIix0d28iXSxbImxvY2F0aW9uIiwibW9vbvCfjJkiXV1bWyJjb2RlIiwiXCIsdHdvIl0sWyJsb2NhdGlvbiIsIm1vb27wn4yZIl1dAAAAAAAAABS4ukpS1BYAetw+S1LUFgAkJhJLUtQWACQmEktS1BYApibmSlLUFgAn3I9KUtQWAAAAABAAAAAfAAAALgAAAD0AAABMAAAAWwAAAAAAAABsb2cgbGluZSBlcnJvciAxbG9nIGxpbmUgaW5mbyAxbG9nIGxpbmUgaW5mbyAybG9nIGxpbmUgaW5mbyAybG9nIGxpbmUgaW5mbyAzbG9nIGxpbmUgaW5mbyA0AAAAAAAAAAAAEwAAACYAAAA5AAAATAAAAF8AAAByAAAAAAAAADE2NDUwMzAyNDQ4MTA3NTcxMjAxNjQ1MDMwMjQ3MDI3NzM1MDQwMTY0NTAzMDI0NjI3NzU4Nzk2ODE2NDUwMzAyNDYyNzc1ODc5NjgxNjQ1MDMwMjQ1NTM5NDIzNzQ0MTY0NTAzMDI0NDA5MTcwMDk5MgAAAAAAAAAAAAAdAAAAOgAAAFcAAAB2AAAAkgAAAK8AAAAAAAAAMTY0NTAzMDI0NDgxMDc1NzEyMF9mNTA4ODIwNl8xNjQ1MDMwMjQ3MDI3NzM1MDQwXzQ2YjIyNWE0XzE2NDUwMzAyNDYyNzc1ODc5NjhfYzJhOWYzMmJfMTY0NTAzMDI0NjI3NzU4Nzk2OF9jMmE5ZjMyYl8xXzE2NDUwMzAyNDU1Mzk0MjM3NDRfODY1MDJlNl8xNjQ1MDMwMjQ0MDkxNzAwOTkyX2M2ZjViNjBkXwAQAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAPAAAAAAABAABAAAAMAkAAAAAAACQAQAAAAAAAEADAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAACkBgAAAwAAAEwAAAAoAAAABAAAAHz3//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAAnPf//wgAAAAMAAAAAAAAAAAAAAAEAAAAbmFtZQAAAAC89///CAAAADwGAAAyBgAAeyJzdGF0cyI6W3siZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBieXRlcyBwcm9jZXNzZWQgcGVyIHNlY29uZCIsInVuaXQiOiJCcHMiLCJ2YWx1ZSI6MzUwNzAyMn0seyJkaXNwbGF5TmFtZSI6IlN1bW1hcnk6IGxpbmVzIHByb2Nlc3NlZCBwZXIgc2Vjb25kIiwidmFsdWUiOjI0ODE4fSx7ImRpc3BsYXlOYW1lIjoiU3VtbWFyeTogdG90YWwgYnl0ZXMgcHJvY2Vzc2VkIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiB0b3RhbCBsaW5lcyBwcm9jZXNzZWQiLCJ2YWx1ZSI6NTV9LHsiZGlzcGxheU5hbWUiOiJTdW1tYXJ5OiBleGVjIHRpbWUiLCJ1bml0IjoicyIsInZhbHVlIjowLjAwMjIxNjEyNX0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiB0b3RhbCBjaHVua3MgcmVmIiwidmFsdWUiOjJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgY2h1bmtzIGRvd25sb2FkZWQiLCJ2YWx1ZSI6M30seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBjaHVua3MgZG93bmxvYWQgdGltZSIsInVuaXQiOiJzIiwidmFsdWUiOjAuMDAwMzkwOTU4fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6NH0seyJkaXNwbGF5TmFtZSI6IlN0b3JlOiBoZWFkIGNodW5rIGxpbmVzIiwidmFsdWUiOjV9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGJ5dGVzIiwidW5pdCI6ImRlY2J5dGVzIiwidmFsdWUiOjc3NzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjU1fSx7ImRpc3BsYXlOYW1lIjoiU3RvcmU6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MzE0MzJ9LHsiZGlzcGxheU5hbWUiOiJTdG9yZTogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjo2fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIHJlYWNoZWQiLCJ2YWx1ZSI6N30seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBjaHVua3MgbWF0Y2hlZCIsInZhbHVlIjo4fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IHRvdGFsIGJhdGNoZXMiLCJ2YWx1ZSI6OX0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiB0b3RhbCBsaW5lcyBzZW50IiwidmFsdWUiOjEwfSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGhlYWQgY2h1bmsgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTF9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogaGVhZCBjaHVuayBsaW5lcyIsInZhbHVlIjoxMn0seyJkaXNwbGF5TmFtZSI6IkluZ2VzdGVyOiBkZWNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTN9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogZGVjb21wcmVzc2VkIGxpbmVzIiwidmFsdWUiOjE0fSx7ImRpc3BsYXlOYW1lIjoiSW5nZXN0ZXI6IGNvbXByZXNzZWQgYnl0ZXMiLCJ1bml0IjoiZGVjYnl0ZXMiLCJ2YWx1ZSI6MTV9LHsiZGlzcGxheU5hbWUiOiJJbmdlc3RlcjogdG90YWwgZHVwbGljYXRlcyIsInZhbHVlIjoxNn1dLCJleGVjdXRlZFF1ZXJ5U3RyaW5nIjoiRXhwcjogcXVlcnkxIn0AAAQAAABtZXRhAAAAAAUAAACoAQAANAEAANgAAABYAAAABAAAAH7+//8UAAAAOAAAADgAAAAAAAAFNAAAAAEAAAAEAAAARP7//wgAAAAMAAAAAgAAAGlkAAAEAAAAbmFtZQAAAAAAAAAAJP7//wIAAABpZAAAzv7//xQAAABgAAAAYAAAAAAAAAVcAAAAAgAAACwAAAAEAAAAmP7//wgAAAAQAAAABAAAAHRzTnMAAAAABAAAAG5hbWUAAAAAvP7//wgAAAAMAAAAAgAAAHt9AAAGAAAAbGFiZWxzAAAAAAAAnP7//wQAAAB0c05zAAAAAEr///8UAAAAPAAAADwAAAAAAAAFOAAAAAEAAAAEAAAAEP///wgAAAAQAAAABAAAAGxpbmUAAAAABAAAAG5hbWUAAAAAAAAAAPT+//8EAAAAbGluZQAAAACi////FAAAADgAAABAAAAAAAAACkAAAAABAAAABAAAAGj///8IAAAADAAAAAIAAAB0cwAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwACAAAAdHMAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAACAAAAAhAAAAAAAAAWAAAAAAgAAADQAAAAEAAAA3P///wgAAAAQAAAABgAAAGxhYmVscwAABAAAAG5hbWUAAAAACAAMAAgABAAIAAAACAAAACQAAAAYAAAAeyJjdXN0b20iOnsianNvbiI6dHJ1ZX19AAAAAAYAAABjb25maWcAAAAAAAAEAAQABAAAAAYAAABsYWJlbHMAAFAJAABBUlJPVzE=
diff --git a/pkg/tsdb/loki/testdata/streams_simple.json b/pkg/tsdb/loki/testdata/streams_simple.json
index 8754d444ad0..71c0d4ab44b 100644
--- a/pkg/tsdb/loki/testdata/streams_simple.json
+++ b/pkg/tsdb/loki/testdata/streams_simple.json
@@ -5,8 +5,8 @@
"result": [
{
"stream": {
- "level": "error",
- "location": "moon"
+ "code": "one\",",
+ "location": "moon🌙"
},
"values": [
[
@@ -17,8 +17,8 @@
},
{
"stream": {
- "level": "info",
- "location": "moon"
+ "code": "\",two",
+ "location": "moon🌙"
},
"values": [
[
@@ -29,6 +29,10 @@
"1645030246277587968",
"log line info 2"
],
+ [
+ "1645030246277587968",
+ "log line info 2"
+ ],
[
"1645030245539423744",
"log line info 3"
diff --git a/pkg/tsdb/opentsdb/opentsdb.go b/pkg/tsdb/opentsdb/opentsdb.go
index 8dc65d0ff5c..6143fae3dbf 100644
--- a/pkg/tsdb/opentsdb/opentsdb.go
+++ b/pkg/tsdb/opentsdb/opentsdb.go
@@ -16,11 +16,11 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
"github.com/grafana/grafana-plugin-sdk-go/data"
+
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
- "golang.org/x/net/context/ctxhttp"
)
type Service struct {
@@ -86,12 +86,12 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
return nil, err
}
- request, err := s.createRequest(dsInfo, tsdbQuery)
+ request, err := s.createRequest(ctx, dsInfo, tsdbQuery)
if err != nil {
return &backend.QueryDataResponse{}, err
}
- res, err := ctxhttp.Do(ctx, dsInfo.HTTPClient, request)
+ res, err := dsInfo.HTTPClient.Do(request)
if err != nil {
return &backend.QueryDataResponse{}, err
}
@@ -104,7 +104,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
return result, nil
}
-func (s *Service) createRequest(dsInfo *datasourceInfo, data OpenTsdbQuery) (*http.Request, error) {
+func (s *Service) createRequest(ctx context.Context, dsInfo *datasourceInfo, data OpenTsdbQuery) (*http.Request, error) {
u, err := url.Parse(dsInfo.URL)
if err != nil {
return nil, err
@@ -117,7 +117,7 @@ func (s *Service) createRequest(dsInfo *datasourceInfo, data OpenTsdbQuery) (*ht
return nil, fmt.Errorf("failed to create request: %w", err)
}
- req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(string(postData)))
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(string(postData)))
if err != nil {
s.logger.Info("Failed to create request", "error", err)
return nil, fmt.Errorf("failed to create request: %w", err)
diff --git a/pkg/tsdb/opentsdb/opentsdb_test.go b/pkg/tsdb/opentsdb/opentsdb_test.go
index 7ef63ee7e54..19ba68bd5d1 100644
--- a/pkg/tsdb/opentsdb/opentsdb_test.go
+++ b/pkg/tsdb/opentsdb/opentsdb_test.go
@@ -1,6 +1,7 @@
package opentsdb
import (
+ "context"
"io/ioutil"
"net/http"
"strings"
@@ -10,9 +11,10 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
- "github.com/grafana/grafana/pkg/infra/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/infra/log"
)
func TestOpenTsdbExecutor(t *testing.T) {
@@ -21,7 +23,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
}
t.Run("create request", func(t *testing.T) {
- req, err := service.createRequest(&datasourceInfo{}, OpenTsdbQuery{})
+ req, err := service.createRequest(context.Background(), &datasourceInfo{}, OpenTsdbQuery{})
require.NoError(t, err)
assert.Equal(t, "POST", req.Method)
diff --git a/pkg/tsdb/postgres/postgres.go b/pkg/tsdb/postgres/postgres.go
index 527102831b6..37e69c09ebc 100644
--- a/pkg/tsdb/postgres/postgres.go
+++ b/pkg/tsdb/postgres/postgres.go
@@ -140,7 +140,7 @@ func (s *Service) generateConnectionString(dsInfo sqleng.DataSourceInfo) (string
}
} else {
if index == v6Index+1 {
- host = dsInfo.URL[0:index]
+ host = dsInfo.URL[1 : index-1]
var err error
port, err = strconv.Atoi(dsInfo.URL[index+1:])
if err != nil {
@@ -149,7 +149,7 @@ func (s *Service) generateConnectionString(dsInfo sqleng.DataSourceInfo) (string
logger.Debug("Generating ipv6 connection string with network host/port pair", "host", host, "port", port)
} else {
- host = dsInfo.URL
+ host = dsInfo.URL[1 : len(dsInfo.URL)-1]
logger.Debug("Generating ipv6 connection string with network host", "host", host)
}
}
diff --git a/pkg/tsdb/postgres/postgres_test.go b/pkg/tsdb/postgres/postgres_test.go
index eb7278d47ff..45b9a1d030c 100644
--- a/pkg/tsdb/postgres/postgres_test.go
+++ b/pkg/tsdb/postgres/postgres_test.go
@@ -74,7 +74,7 @@ func TestGenerateConnectionString(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
- expConnStr: "user='user' password='password' host='[::1]' dbname='database' sslmode='verify-full'",
+ expConnStr: "user='user' password='password' host='::1' dbname='database' sslmode='verify-full'",
},
{
desc: "Ipv6/port host",
@@ -83,7 +83,7 @@ func TestGenerateConnectionString(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
- expConnStr: "user='user' password='password' host='[::1]' dbname='database' port=1234 sslmode='verify-full'",
+ expConnStr: "user='user' password='password' host='::1' dbname='database' port=1234 sslmode='verify-full'",
},
{
desc: "Invalid port",
diff --git a/pkg/util/proxyutil/reverse_proxy.go b/pkg/util/proxyutil/reverse_proxy.go
new file mode 100644
index 00000000000..3ce5d3b4011
--- /dev/null
+++ b/pkg/util/proxyutil/reverse_proxy.go
@@ -0,0 +1,146 @@
+package proxyutil
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "net/http/httputil"
+ "strings"
+ "time"
+
+ glog "github.com/grafana/grafana/pkg/infra/log"
+)
+
+// StatusClientClosedRequest A non-standard status code introduced by nginx
+// for the case when a client closes the connection while nginx is processing
+// the request.
+// https://httpstatus.in/499/
+const StatusClientClosedRequest = 499
+
+// ReverseProxyOption reverse proxy option to configure a httputil.ReverseProxy.
+type ReverseProxyOption func(*httputil.ReverseProxy)
+
+// NewReverseProxy creates a new httputil.ReverseProxy with sane default configuration.
+func NewReverseProxy(logger glog.Logger, director func(*http.Request), opts ...ReverseProxyOption) *httputil.ReverseProxy {
+ if logger == nil {
+ panic("logger cannot be nil")
+ }
+
+ if director == nil {
+ panic("director cannot be nil")
+ }
+
+ p := &httputil.ReverseProxy{
+ FlushInterval: time.Millisecond * 200,
+ ErrorHandler: errorHandler(logger),
+ ErrorLog: log.New(&logWrapper{logger: logger}, "", 0),
+ Director: director,
+ }
+
+ for _, opt := range opts {
+ opt(p)
+ }
+
+ origDirector := p.Director
+ p.Director = wrapDirector(origDirector)
+
+ if p.ModifyResponse == nil {
+ // nolint:bodyclose
+ p.ModifyResponse = modifyResponse(logger)
+ } else {
+ modResponse := p.ModifyResponse
+ p.ModifyResponse = func(resp *http.Response) error {
+ if err := modResponse(resp); err != nil {
+ return err
+ }
+
+ // nolint:bodyclose
+ return modifyResponse(logger)(resp)
+ }
+ }
+
+ return p
+}
+
+// wrapDirector wraps a director and adds additional functionality.
+func wrapDirector(d func(*http.Request)) func(req *http.Request) {
+ return func(req *http.Request) {
+ d(req)
+ PrepareProxyRequest(req)
+
+ // Clear Origin and Referer to avoid CORS issues
+ req.Header.Del("Origin")
+ req.Header.Del("Referer")
+ }
+}
+
+// modifyResponse enforces certain constraints on http.Response.
+func modifyResponse(logger glog.Logger) func(resp *http.Response) error {
+ return func(resp *http.Response) error {
+ resp.Header.Del("Set-Cookie")
+ SetProxyResponseHeaders(resp.Header)
+ return nil
+ }
+}
+
+type timeoutError interface {
+ error
+ Timeout() bool
+}
+
+// errorHandler handles any errors happening while proxying a request and enforces
+// certain HTTP status based on the kind of error.
+// If client cancel/close the request we return 499 StatusClientClosedRequest.
+// If timeout happens while communicating with upstream server we return http.StatusGatewayTimeout.
+// If any other error we return http.StatusBadGateway.
+func errorHandler(logger glog.Logger) func(http.ResponseWriter, *http.Request, error) {
+ return func(w http.ResponseWriter, r *http.Request, err error) {
+ if errors.Is(err, context.Canceled) {
+ logger.Debug("Proxy request cancelled by client")
+ w.WriteHeader(StatusClientClosedRequest)
+ return
+ }
+
+ // nolint:errorlint
+ if timeoutErr, ok := err.(timeoutError); ok && timeoutErr.Timeout() {
+ logger.Error("Proxy request timed out", "err", err)
+ w.WriteHeader(http.StatusGatewayTimeout)
+ return
+ }
+
+ logger.Error("Proxy request failed", "err", err)
+ w.WriteHeader(http.StatusBadGateway)
+ }
+}
+
+type logWrapper struct {
+ logger glog.Logger
+}
+
+// Write writes log messages as bytes from proxy.
+func (lw *logWrapper) Write(p []byte) (n int, err error) {
+ withoutNewline := strings.TrimSuffix(string(p), "\n")
+ lw.logger.Error("Proxy request error", "error", withoutNewline)
+ return len(p), nil
+}
+
+func WithTransport(transport http.RoundTripper) ReverseProxyOption {
+ if transport == nil {
+ panic("transport cannot be nil")
+ }
+
+ return ReverseProxyOption(func(rp *httputil.ReverseProxy) {
+ rp.Transport = transport
+ })
+}
+
+func WithModifyResponse(fn func(*http.Response) error) ReverseProxyOption {
+ if fn == nil {
+ panic("fn cannot be nil")
+ }
+
+ return ReverseProxyOption(func(rp *httputil.ReverseProxy) {
+ rp.ModifyResponse = fn
+ })
+}
diff --git a/pkg/util/proxyutil/reverse_proxy_test.go b/pkg/util/proxyutil/reverse_proxy_test.go
new file mode 100644
index 00000000000..a886b928d8e
--- /dev/null
+++ b/pkg/util/proxyutil/reverse_proxy_test.go
@@ -0,0 +1,157 @@
+package proxyutil
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReverseProxy(t *testing.T) {
+ t.Run("When proxying a request should enforce request and response constraints", func(t *testing.T) {
+ var actualReq *http.Request
+ upstream := newUpstreamServer(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ actualReq = req
+ http.SetCookie(w, &http.Cookie{Name: "test"})
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(upstream.Close)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, upstream.URL, nil)
+ req.Header.Set("X-Forwarded-Host", "forwarded.host.com")
+ req.Header.Set("X-Forwarded-Port", "8080")
+ req.Header.Set("X-Forwarded-Proto", "https")
+ req.Header.Set("Origin", "test.com")
+ req.Header.Set("Referer", "https://test.com/api")
+ req.RemoteAddr = "10.0.0.1"
+
+ rp := NewReverseProxy(log.New("test"), func(req *http.Request) {
+ req.Header.Set("X-KEY", "value")
+ })
+ require.NotNil(t, rp)
+ require.NotNil(t, rp.ModifyResponse)
+ rp.ServeHTTP(rec, req)
+
+ require.NotNil(t, actualReq)
+ require.Empty(t, actualReq.Header.Get("X-Forwarded-Host"))
+ require.Empty(t, actualReq.Header.Get("X-Forwarded-Port"))
+ require.Empty(t, actualReq.Header.Get("X-Forwarded-Proto"))
+ require.Equal(t, "10.0.0.1", actualReq.Header.Get("X-Forwarded-For"))
+ require.Empty(t, actualReq.Header.Get("Origin"))
+ require.Empty(t, actualReq.Header.Get("Referer"))
+ require.Equal(t, "value", actualReq.Header.Get("X-KEY"))
+ resp := rec.Result()
+ require.Empty(t, resp.Cookies())
+ require.Equal(t, "sandbox", resp.Header.Get("Content-Security-Policy"))
+ require.NoError(t, resp.Body.Close())
+ })
+
+ t.Run("When proxying a request using WithModifyResponse should call it before default ModifyResponse func", func(t *testing.T) {
+ var actualReq *http.Request
+ upstream := newUpstreamServer(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ actualReq = req
+ http.SetCookie(w, &http.Cookie{Name: "test"})
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(upstream.Close)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, upstream.URL, nil)
+ rp := NewReverseProxy(
+ log.New("test"),
+ func(req *http.Request) {
+ req.Header.Set("X-KEY", "value")
+ },
+ WithModifyResponse(func(r *http.Response) error {
+ r.Header.Set("X-KEY2", "value2")
+ return nil
+ }),
+ )
+ require.NotNil(t, rp)
+ require.NotNil(t, rp.ModifyResponse)
+ rp.ServeHTTP(rec, req)
+
+ require.NotNil(t, actualReq)
+ require.Equal(t, "value", actualReq.Header.Get("X-KEY"))
+ resp := rec.Result()
+ require.Empty(t, resp.Cookies())
+ require.Equal(t, "sandbox", resp.Header.Get("Content-Security-Policy"))
+ require.Equal(t, "value2", resp.Header.Get("X-KEY2"))
+ require.NoError(t, resp.Body.Close())
+ })
+
+ t.Run("Error handling should convert status codes depending on what kind of error it is", func(t *testing.T) {
+ timedOutTransport := http.DefaultTransport.(*http.Transport)
+ timedOutTransport.ResponseHeaderTimeout = time.Nanosecond
+
+ testCases := []struct {
+ desc string
+ transport http.RoundTripper
+ expectedStatusCode int
+ }{
+ {
+ desc: "Cancelled request should return 499 Client closed request",
+ transport: &cancelledRoundTripper{},
+ expectedStatusCode: StatusClientClosedRequest,
+ },
+ {
+ desc: "Timed out request should return 504 Gateway timeout",
+ transport: timedOutTransport,
+ expectedStatusCode: http.StatusGatewayTimeout,
+ },
+ {
+ desc: "Failed request should return 502 Bad gateway",
+ transport: &failingRoundTripper{},
+ expectedStatusCode: http.StatusBadGateway,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.desc, func(t *testing.T) {
+ upstream := newUpstreamServer(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(upstream.Close)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, upstream.URL, nil)
+
+ rp := NewReverseProxy(
+ log.New("test"),
+ func(req *http.Request) {},
+ WithTransport(tc.transport),
+ )
+ require.NotNil(t, rp)
+ require.NotNil(t, rp.Transport)
+ require.Same(t, tc.transport, rp.Transport)
+ rp.ServeHTTP(rec, req)
+
+ resp := rec.Result()
+ require.Equal(t, tc.expectedStatusCode, resp.StatusCode)
+ require.NoError(t, resp.Body.Close())
+ })
+ }
+ })
+}
+
+func newUpstreamServer(t *testing.T, handler http.Handler) *httptest.Server {
+ t.Helper()
+
+ upstream := httptest.NewServer(handler)
+ return upstream
+}
+
+type cancelledRoundTripper struct{}
+
+func (cancelledRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
+ return nil, context.Canceled
+}
+
+type failingRoundTripper struct{}
+
+func (failingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
+ return nil, errors.New("some error")
+}
diff --git a/pkg/web/webtest/webtest_test.go b/pkg/web/webtest/webtest_test.go
index 0432b19e2d0..3bb55911ded 100644
--- a/pkg/web/webtest/webtest_test.go
+++ b/pkg/web/webtest/webtest_test.go
@@ -99,7 +99,7 @@ func TestServerClient(t *testing.T) {
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
- var user *models.SignedInUser
+ var user models.SignedInUser
err = json.Unmarshal(bytes, &user)
require.NoError(t, err)
require.NotNil(t, user)
@@ -121,7 +121,7 @@ func TestServerClient(t *testing.T) {
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
- var user *models.SignedInUser
+ var user models.SignedInUser
err = json.Unmarshal(bytes, &user)
require.NoError(t, err)
require.NotNil(t, user)
diff --git a/public/api-merged.json b/public/api-merged.json
index ff5e20db5e9..2a329b78226 100644
--- a/public/api-merged.json
+++ b/public/api-merged.json
@@ -506,6 +506,14 @@
"summary": "Add a user role assignment.",
"operationId": "addUserRole",
"parameters": [
+ {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "UserID",
+ "name": "user_id",
+ "in": "path",
+ "required": true
+ },
{
"x-go-name": "Body",
"name": "body",
@@ -514,14 +522,6 @@
"schema": {
"$ref": "#/definitions/AddUserRoleCommand"
}
- },
- {
- "type": "integer",
- "format": "int64",
- "x-go-name": "UserID",
- "name": "user_id",
- "in": "path",
- "required": true
}
],
"responses": {
@@ -547,6 +547,14 @@
"summary": "Remove a user role assignment.",
"operationId": "removeUserRole",
"parameters": [
+ {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "UserID",
+ "name": "user_id",
+ "in": "path",
+ "required": true
+ },
{
"type": "string",
"x-go-name": "RoleUID",
@@ -560,14 +568,6 @@
"description": "A flag indicating if the assignment is global or not. If set to false, the default org ID of the authenticated user will be used from the request to remove assignment.",
"name": "global",
"in": "query"
- },
- {
- "type": "integer",
- "format": "int64",
- "x-go-name": "UserID",
- "name": "user_id",
- "in": "path",
- "required": true
}
],
"responses": {
@@ -1025,7 +1025,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:delete` and scope `global:users:*`.",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:delete` and scope `global.users:*`.",
"tags": ["admin_users"],
"summary": "Delete global User.",
"operationId": "deleteUser",
@@ -1065,7 +1065,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:list` and scope `global:users:*`.",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:list` and scope `global.users:*`.",
"tags": ["admin_users"],
"summary": "Return a list of all auth tokens (devices) that the user currently have logged in from.",
"operationId": "getAuthTokens",
@@ -1102,7 +1102,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:disable` and scope `global:users:1` (userIDScope).",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:disable` and scope `global.users:1` (userIDScope).",
"tags": ["admin_users"],
"summary": "Disable user.",
"operationId": "disableUser",
@@ -1142,7 +1142,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:enable` and scope `global:users:1` (userIDScope).",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:enable` and scope `global.users:1` (userIDScope).",
"tags": ["admin_users"],
"summary": "Enable user.",
"operationId": "enableUser",
@@ -1182,7 +1182,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.logout` and scope `global:users:*`.",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.logout` and scope `global.users:*`.",
"tags": ["admin_users"],
"summary": "Logout user revokes all auth tokens (devices) for the user. User of issued auth tokens (devices) will no longer be logged in and will be required to authenticate again upon next activity.",
"operationId": "logoutUser",
@@ -1225,7 +1225,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.password:update` and scope `global:users:*`.",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.password:update` and scope `global.users:*`.",
"tags": ["admin_users"],
"summary": "Set password for user.",
"operationId": "setPassword",
@@ -1269,7 +1269,7 @@
},
"/admin/users/{user_id}/permissions": {
"put": {
- "description": "Only works with Basic Authentication (username and password). See introduction for an explanation.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.permissions:update` and scope `global:users:*`.",
+ "description": "Only works with Basic Authentication (username and password). See introduction for an explanation.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.permissions:update` and scope `global.users:*`.",
"tags": ["admin_users"],
"summary": "Set permissions for user.",
"operationId": "setPermissions",
@@ -1318,7 +1318,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:list` and scope `global:users:1` (userIDScope).",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:list` and scope `global.users:1` (userIDScope).",
"tags": ["admin_users"],
"summary": "Fetch user quota.",
"operationId": "getUserQuota",
@@ -1358,7 +1358,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:update` and scope `global:users:1` (userIDScope).",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:update` and scope `global.users:1` (userIDScope).",
"tags": ["admin_users"],
"summary": "Update user quota.",
"operationId": "updateUserQuota",
@@ -1414,7 +1414,7 @@
"basic": []
}
],
- "description": "Revokes the given auth token (device) for the user. User of issued auth token (device) will no longer be logged in and will be required to authenticate again upon next activity.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:update` and scope `global:users:*`.",
+ "description": "Revokes the given auth token (device) for the user. User of issued auth token (device) will no longer be logged in and will be required to authenticate again upon next activity.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:update` and scope `global.users:*`.",
"tags": ["admin_users"],
"summary": "Revoke auth token for user.",
"operationId": "revokeAuthToken",
@@ -3150,7 +3150,7 @@
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/DeleteAnnotationsCmd"
+ "$ref": "#/definitions/MassDeleteAnnotationsCmd"
}
}
],
@@ -4063,6 +4063,137 @@
}
}
},
+ "/datasources/proxy/uid/{datasource_uid}/{datasource_proxy_route}": {
+ "get": {
+ "description": "Proxies all calls to the actual data source.",
+ "tags": ["datasources"],
+ "summary": "Data source proxy GET calls.",
+ "operationId": "datasourceProxyGETByUIDcalls",
+ "parameters": [
+ {
+ "type": "string",
+ "x-go-name": "DatasourceUID",
+ "name": "datasource_uid",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "x-go-name": "DatasourceProxyRoute",
+ "name": "datasource_proxy_route",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": ""
+ },
+ "400": {
+ "$ref": "#/responses/badRequestError"
+ },
+ "401": {
+ "$ref": "#/responses/unauthorisedError"
+ },
+ "403": {
+ "$ref": "#/responses/forbiddenError"
+ },
+ "404": {
+ "$ref": "#/responses/notFoundError"
+ },
+ "500": {
+ "$ref": "#/responses/internalServerError"
+ }
+ }
+ },
+ "post": {
+ "description": "Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined",
+ "tags": ["datasources"],
+ "summary": "Data source proxy POST calls.",
+ "operationId": "datasourceProxyPOSTByUIDcalls",
+ "parameters": [
+ {
+ "type": "string",
+ "x-go-name": "DatasourceUID",
+ "name": "datasource_uid",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "x-go-name": "DatasourceProxyRoute",
+ "name": "datasource_proxy_route",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": ""
+ },
+ "202": {
+ "description": ""
+ },
+ "400": {
+ "$ref": "#/responses/badRequestError"
+ },
+ "401": {
+ "$ref": "#/responses/unauthorisedError"
+ },
+ "403": {
+ "$ref": "#/responses/forbiddenError"
+ },
+ "404": {
+ "$ref": "#/responses/notFoundError"
+ },
+ "500": {
+ "$ref": "#/responses/internalServerError"
+ }
+ }
+ },
+ "delete": {
+ "description": "Proxies all calls to the actual data source.",
+ "tags": ["datasources"],
+ "summary": "Data source proxy DELETE calls.",
+ "operationId": "datasourceProxyDELETEByUIDcalls",
+ "parameters": [
+ {
+ "type": "string",
+ "x-go-name": "DatasourceUID",
+ "name": "datasource_uid",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "x-go-name": "DatasourceProxyRoute",
+ "name": "datasource_proxy_route",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "202": {
+ "description": ""
+ },
+ "400": {
+ "$ref": "#/responses/badRequestError"
+ },
+ "401": {
+ "$ref": "#/responses/unauthorisedError"
+ },
+ "403": {
+ "$ref": "#/responses/forbiddenError"
+ },
+ "404": {
+ "$ref": "#/responses/notFoundError"
+ },
+ "500": {
+ "$ref": "#/responses/internalServerError"
+ }
+ }
+ }
+ },
"/datasources/proxy/{datasource_id}/{datasource_proxy_route}": {
"get": {
"description": "Proxies all calls to the actual data source.",
@@ -4273,7 +4404,7 @@
},
"/datasources/{datasource_id}": {
"get": {
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).",
"tags": ["datasources"],
"summary": "Get a single data source by Id.",
"operationId": "getDatasourceByID",
@@ -4308,7 +4439,7 @@
}
},
"put": {
- "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).",
+ "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).",
"tags": ["datasources"],
"summary": "Update an existing data source.",
"operationId": "updateDatasource",
@@ -4345,7 +4476,7 @@
}
},
"delete": {
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).",
"tags": ["datasources"],
"summary": "Delete an existing data source by id.",
"operationId": "deleteDatasourceByID",
@@ -4494,15 +4625,15 @@
"parameters": [
{
"type": "string",
- "x-go-name": "PermissionID",
- "name": "permissionId",
+ "x-go-name": "DatasourceID",
+ "name": "datasource_id",
"in": "path",
"required": true
},
{
"type": "string",
- "x-go-name": "DatasourceID",
- "name": "datasource_id",
+ "x-go-name": "PermissionID",
+ "name": "permissionId",
"in": "path",
"required": true
}
@@ -6469,6 +6600,145 @@
}
}
},
+ "/provisioning/contact-points": {
+ "get": {
+ "tags": ["provisioning"],
+ "summary": "Get all the contact points.",
+ "operationId": "RouteGetContactpoints",
+ "responses": {
+ "200": {
+ "description": "Route",
+ "schema": {
+ "$ref": "#/definitions/Route"
+ }
+ },
+ "400": {
+ "description": "ValidationError",
+ "schema": {
+ "$ref": "#/definitions/ValidationError"
+ }
+ }
+ }
+ },
+ "put": {
+ "consumes": ["application/json"],
+ "tags": ["provisioning"],
+ "summary": "Update an existing contact point.",
+ "operationId": "RoutePutContactpoints",
+ "parameters": [
+ {
+ "name": "Body",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/EmbeddedContactPoint"
+ }
+ }
+ ],
+ "responses": {
+ "202": {
+ "$ref": "#/responses/Accepted"
+ },
+ "400": {
+ "description": "ValidationError",
+ "schema": {
+ "$ref": "#/definitions/ValidationError"
+ }
+ }
+ }
+ },
+ "post": {
+ "consumes": ["application/json"],
+ "tags": ["provisioning"],
+ "summary": "Create a contact point.",
+ "operationId": "RoutePostContactpoints",
+ "parameters": [
+ {
+ "name": "Body",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/EmbeddedContactPoint"
+ }
+ }
+ ],
+ "responses": {
+ "202": {
+ "$ref": "#/responses/Accepted"
+ },
+ "400": {
+ "description": "ValidationError",
+ "schema": {
+ "$ref": "#/definitions/ValidationError"
+ }
+ }
+ }
+ }
+ },
+ "/provisioning/contact-points/{ID}": {
+ "delete": {
+ "consumes": ["application/json"],
+ "tags": ["provisioning"],
+ "summary": "Delete a contact point.",
+ "operationId": "RouteDeleteContactpoints",
+ "responses": {
+ "202": {
+ "$ref": "#/responses/Accepted"
+ },
+ "400": {
+ "description": "ValidationError",
+ "schema": {
+ "$ref": "#/definitions/ValidationError"
+ }
+ }
+ }
+ }
+ },
+ "/provisioning/policies": {
+ "get": {
+ "tags": ["provisioning"],
+ "summary": "Get the notification policy tree.",
+ "operationId": "RouteGetPolicyTree",
+ "responses": {
+ "200": {
+ "description": "Route",
+ "schema": {
+ "$ref": "#/definitions/Route"
+ }
+ },
+ "400": {
+ "description": "ValidationError",
+ "schema": {
+ "$ref": "#/definitions/ValidationError"
+ }
+ }
+ }
+ },
+ "post": {
+ "consumes": ["application/json"],
+ "tags": ["provisioning"],
+ "summary": "Sets the notification policy tree.",
+ "operationId": "RoutePostPolicyTree",
+ "parameters": [
+ {
+ "name": "Body",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/Route"
+ }
+ }
+ ],
+ "responses": {
+ "202": {
+ "$ref": "#/responses/Accepted"
+ },
+ "400": {
+ "description": "ValidationError",
+ "schema": {
+ "$ref": "#/definitions/ValidationError"
+ }
+ }
+ }
+ }
+ },
"/recording-rules": {
"get": {
"tags": ["recording_rules", "enterprise"],
@@ -10122,6 +10392,36 @@
},
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
},
+ "AnnotationActions": {
+ "type": "object",
+ "properties": {
+ "canAdd": {
+ "type": "boolean",
+ "x-go-name": "CanAdd"
+ },
+ "canDelete": {
+ "type": "boolean",
+ "x-go-name": "CanDelete"
+ },
+ "canEdit": {
+ "type": "boolean",
+ "x-go-name": "CanEdit"
+ }
+ },
+ "x-go-package": "github.com/grafana/grafana/pkg/api/dtos"
+ },
+ "AnnotationPermission": {
+ "type": "object",
+ "properties": {
+ "dashboard": {
+ "$ref": "#/definitions/AnnotationActions"
+ },
+ "organization": {
+ "$ref": "#/definitions/AnnotationActions"
+ }
+ },
+ "x-go-package": "github.com/grafana/grafana/pkg/api/dtos"
+ },
"ApiKeyDTO": {
"type": "object",
"properties": {
@@ -10240,7 +10540,7 @@
"x-go-name": "ReportLogo"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"CalculateDiffTarget": {
"type": "object",
@@ -10390,7 +10690,7 @@
"x-go-name": "UserID"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"CreateAlertNotificationCommand": {
"type": "object",
@@ -10576,7 +10876,7 @@
"x-go-name": "TemplateVars"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"CreateOrgCommand": {
"type": "object",
@@ -10836,6 +11136,9 @@
"DashboardMeta": {
"type": "object",
"properties": {
+ "annotationsPermissions": {
+ "$ref": "#/definitions/AnnotationPermission"
+ },
"canAdmin": {
"type": "boolean",
"x-go-name": "CanAdmin"
@@ -11499,32 +11802,6 @@
},
"x-go-package": "github.com/prometheus/alertmanager/timeinterval"
},
- "DeleteAnnotationsCmd": {
- "type": "object",
- "properties": {
- "alertId": {
- "type": "integer",
- "format": "int64",
- "x-go-name": "AlertId"
- },
- "annotationId": {
- "type": "integer",
- "format": "int64",
- "x-go-name": "AnnotationId"
- },
- "dashboardId": {
- "type": "integer",
- "format": "int64",
- "x-go-name": "DashboardId"
- },
- "panelId": {
- "type": "integer",
- "format": "int64",
- "x-go-name": "PanelId"
- }
- },
- "x-go-package": "github.com/grafana/grafana/pkg/api/dtos"
- },
"DeleteTokenCommand": {
"type": "object",
"properties": {
@@ -11631,6 +11908,36 @@
},
"x-go-package": "github.com/prometheus/alertmanager/config"
},
+ "EmbeddedContactPoint": {
+ "description": "EmbeddedContactPoint is the contact point type that is used\nby grafanas embedded alertmanager implementation.",
+ "type": "object",
+ "properties": {
+ "disableResolveMessage": {
+ "type": "boolean",
+ "x-go-name": "DisableResolveMessage"
+ },
+ "name": {
+ "type": "string",
+ "x-go-name": "Name"
+ },
+ "provanance": {
+ "type": "string",
+ "x-go-name": "Provenance"
+ },
+ "settings": {
+ "$ref": "#/definitions/Json"
+ },
+ "type": {
+ "type": "string",
+ "x-go-name": "Type"
+ },
+ "uid": {
+ "type": "string",
+ "x-go-name": "UID"
+ }
+ },
+ "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
+ },
"ErrorResponseBody": {
"type": "object",
"required": ["message"],
@@ -12479,18 +12786,18 @@
"x-go-name": "URL"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/services/search"
+ "x-go-package": "github.com/grafana/grafana/pkg/models"
},
"HitList": {
"type": "array",
"items": {
"$ref": "#/definitions/Hit"
},
- "x-go-package": "github.com/grafana/grafana/pkg/services/search"
+ "x-go-package": "github.com/grafana/grafana/pkg/models"
},
"HitType": {
"type": "string",
- "x-go-package": "github.com/grafana/grafana/pkg/services/search"
+ "x-go-package": "github.com/grafana/grafana/pkg/models"
},
"HostPort": {
"type": "object",
@@ -13033,6 +13340,27 @@
},
"x-go-package": "github.com/grafana/grafana/pkg/services/libraryelements"
},
+ "MassDeleteAnnotationsCmd": {
+ "type": "object",
+ "properties": {
+ "annotationId": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "AnnotationId"
+ },
+ "dashboardId": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "DashboardId"
+ },
+ "panelId": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "PanelId"
+ }
+ },
+ "x-go-package": "github.com/grafana/grafana/pkg/api/dtos"
+ },
"MatchRegexps": {
"type": "object",
"title": "MatchRegexps represents a map of Regexp.",
@@ -13691,6 +14019,9 @@
"navbar": {
"$ref": "#/definitions/NavbarPreference"
},
+ "queryHistory": {
+ "$ref": "#/definitions/QueryHistoryPreference"
+ },
"theme": {
"type": "string",
"enum": ["light", "dark"],
@@ -14153,6 +14484,9 @@
"navbar": {
"$ref": "#/definitions/NavbarPreference"
},
+ "queryHistory": {
+ "$ref": "#/definitions/QueryHistoryPreference"
+ },
"theme": {
"type": "string",
"x-go-name": "Theme"
@@ -14250,6 +14584,16 @@
},
"x-go-package": "github.com/grafana/grafana-plugin-sdk-go/backend"
},
+ "QueryHistoryPreference": {
+ "type": "object",
+ "properties": {
+ "homeTab": {
+ "type": "string",
+ "x-go-name": "HomeTab"
+ }
+ },
+ "x-go-package": "github.com/grafana/grafana/pkg/models"
+ },
"Receiver": {
"type": "object",
"title": "Receiver configuration provides configuration on how to contact a receiver.",
@@ -14427,7 +14771,7 @@
"x-go-name": "UseEmailsFromReport"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"ReportOptionsDTO": {
"type": "object",
@@ -14444,7 +14788,7 @@
"$ref": "#/definitions/TimeRangeDTO"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"ResponseDetails": {
"type": "object",
@@ -14900,7 +15244,7 @@
"x-go-name": "WorkdaysOnly"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"SearchTeamQueryResult": {
"type": "object",
@@ -15020,7 +15364,7 @@
"x-go-name": "UserID"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"SigV4Config": {
"description": "SigV4Config is the configuration for signing remote write requests with\nAWS's SigV4 verification process. Empty values will be retrieved using the\nAWS default credentials chain.",
@@ -15713,7 +16057,7 @@
"x-go-name": "To"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"Token": {
"type": "object",
@@ -15814,6 +16158,15 @@
"format": "int64",
"x-go-name": "TokenExpiresWarnDays"
},
+ "trial": {
+ "type": "boolean",
+ "x-go-name": "Trial"
+ },
+ "trial_exp": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "TrialExpires"
+ },
"update_days": {
"type": "integer",
"format": "int64",
@@ -15856,9 +16209,8 @@
"x-go-package": "github.com/grafana/grafana/pkg/api/dtos"
},
"URL": {
- "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.",
"type": "object",
- "title": "A URL represents a parsed URL (technically, a URI reference).",
+ "title": "URL is a custom URL type that allows validation at configuration load time.",
"properties": {
"ForceQuery": {
"type": "boolean"
@@ -15891,7 +16243,7 @@
"$ref": "#/definitions/Userinfo"
}
},
- "x-go-package": "net/url"
+ "x-go-package": "github.com/prometheus/common/config"
},
"UpdateAlertNotificationCommand": {
"type": "object",
@@ -16204,6 +16556,9 @@
"navbar": {
"$ref": "#/definitions/NavbarPreference"
},
+ "queryHistory": {
+ "$ref": "#/definitions/QueryHistoryPreference"
+ },
"theme": {
"type": "string",
"enum": ["light", "dark"],
@@ -16792,12 +17147,11 @@
}
},
"alertGroups": {
+ "description": "AlertGroups alert groups",
"type": "array",
"items": {
"$ref": "#/definitions/alertGroup"
- },
- "x-go-name": "AlertGroups",
- "x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
+ }
},
"alertStatus": {
"description": "AlertStatus alert status",
@@ -16900,7 +17254,6 @@
"$ref": "#/definitions/Duration"
},
"gettableAlert": {
- "description": "GettableAlert gettable alert",
"type": "object",
"required": ["labels", "annotations", "endsAt", "fingerprint", "receivers", "startsAt", "status", "updatedAt"],
"properties": {
@@ -16950,7 +17303,9 @@
"format": "date-time",
"x-go-name": "UpdatedAt"
}
- }
+ },
+ "x-go-name": "GettableAlert",
+ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
@@ -17006,12 +17361,11 @@
}
},
"gettableSilences": {
+ "description": "GettableSilences gettable silences",
"type": "array",
"items": {
"$ref": "#/definitions/gettableSilence"
- },
- "x-go-name": "GettableSilences",
- "x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
+ }
},
"labelSet": {
"description": "LabelSet label set",
diff --git a/public/api-spec.json b/public/api-spec.json
index 2d5f6cbbf2b..117b03382c3 100644
--- a/public/api-spec.json
+++ b/public/api-spec.json
@@ -506,6 +506,14 @@
"summary": "Add a user role assignment.",
"operationId": "addUserRole",
"parameters": [
+ {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "UserID",
+ "name": "user_id",
+ "in": "path",
+ "required": true
+ },
{
"x-go-name": "Body",
"name": "body",
@@ -514,14 +522,6 @@
"schema": {
"$ref": "#/definitions/AddUserRoleCommand"
}
- },
- {
- "type": "integer",
- "format": "int64",
- "x-go-name": "UserID",
- "name": "user_id",
- "in": "path",
- "required": true
}
],
"responses": {
@@ -547,6 +547,14 @@
"summary": "Remove a user role assignment.",
"operationId": "removeUserRole",
"parameters": [
+ {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "UserID",
+ "name": "user_id",
+ "in": "path",
+ "required": true
+ },
{
"type": "string",
"x-go-name": "RoleUID",
@@ -560,14 +568,6 @@
"description": "A flag indicating if the assignment is global or not. If set to false, the default org ID of the authenticated user will be used from the request to remove assignment.",
"name": "global",
"in": "query"
- },
- {
- "type": "integer",
- "format": "int64",
- "x-go-name": "UserID",
- "name": "user_id",
- "in": "path",
- "required": true
}
],
"responses": {
@@ -1025,7 +1025,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:delete` and scope `global:users:*`.",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:delete` and scope `global.users:*`.",
"tags": ["admin_users"],
"summary": "Delete global User.",
"operationId": "deleteUser",
@@ -1065,7 +1065,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:list` and scope `global:users:*`.",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:list` and scope `global.users:*`.",
"tags": ["admin_users"],
"summary": "Return a list of all auth tokens (devices) that the user currently have logged in from.",
"operationId": "getAuthTokens",
@@ -1102,7 +1102,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:disable` and scope `global:users:1` (userIDScope).",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:disable` and scope `global.users:1` (userIDScope).",
"tags": ["admin_users"],
"summary": "Disable user.",
"operationId": "disableUser",
@@ -1142,7 +1142,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:enable` and scope `global:users:1` (userIDScope).",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users:enable` and scope `global.users:1` (userIDScope).",
"tags": ["admin_users"],
"summary": "Enable user.",
"operationId": "enableUser",
@@ -1182,7 +1182,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.logout` and scope `global:users:*`.",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.logout` and scope `global.users:*`.",
"tags": ["admin_users"],
"summary": "Logout user revokes all auth tokens (devices) for the user. User of issued auth tokens (devices) will no longer be logged in and will be required to authenticate again upon next activity.",
"operationId": "logoutUser",
@@ -1225,7 +1225,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.password:update` and scope `global:users:*`.",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.password:update` and scope `global.users:*`.",
"tags": ["admin_users"],
"summary": "Set password for user.",
"operationId": "setPassword",
@@ -1269,7 +1269,7 @@
},
"/admin/users/{user_id}/permissions": {
"put": {
- "description": "Only works with Basic Authentication (username and password). See introduction for an explanation.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.permissions:update` and scope `global:users:*`.",
+ "description": "Only works with Basic Authentication (username and password). See introduction for an explanation.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.permissions:update` and scope `global.users:*`.",
"tags": ["admin_users"],
"summary": "Set permissions for user.",
"operationId": "setPermissions",
@@ -1318,7 +1318,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:list` and scope `global:users:1` (userIDScope).",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:list` and scope `global.users:1` (userIDScope).",
"tags": ["admin_users"],
"summary": "Fetch user quota.",
"operationId": "getUserQuota",
@@ -1358,7 +1358,7 @@
"basic": []
}
],
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:update` and scope `global:users:1` (userIDScope).",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.quotas:update` and scope `global.users:1` (userIDScope).",
"tags": ["admin_users"],
"summary": "Update user quota.",
"operationId": "updateUserQuota",
@@ -1414,7 +1414,7 @@
"basic": []
}
],
- "description": "Revokes the given auth token (device) for the user. User of issued auth token (device) will no longer be logged in and will be required to authenticate again upon next activity.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:update` and scope `global:users:*`.",
+ "description": "Revokes the given auth token (device) for the user. User of issued auth token (device) will no longer be logged in and will be required to authenticate again upon next activity.\nIf you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `users.authtoken:update` and scope `global.users:*`.",
"tags": ["admin_users"],
"summary": "Revoke auth token for user.",
"operationId": "revokeAuthToken",
@@ -2200,7 +2200,7 @@
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/DeleteAnnotationsCmd"
+ "$ref": "#/definitions/MassDeleteAnnotationsCmd"
}
}
],
@@ -3113,6 +3113,137 @@
}
}
},
+ "/datasources/proxy/uid/{datasource_uid}/{datasource_proxy_route}": {
+ "get": {
+ "description": "Proxies all calls to the actual data source.",
+ "tags": ["datasources"],
+ "summary": "Data source proxy GET calls.",
+ "operationId": "datasourceProxyGETByUIDcalls",
+ "parameters": [
+ {
+ "type": "string",
+ "x-go-name": "DatasourceUID",
+ "name": "datasource_uid",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "x-go-name": "DatasourceProxyRoute",
+ "name": "datasource_proxy_route",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": ""
+ },
+ "400": {
+ "$ref": "#/responses/badRequestError"
+ },
+ "401": {
+ "$ref": "#/responses/unauthorisedError"
+ },
+ "403": {
+ "$ref": "#/responses/forbiddenError"
+ },
+ "404": {
+ "$ref": "#/responses/notFoundError"
+ },
+ "500": {
+ "$ref": "#/responses/internalServerError"
+ }
+ }
+ },
+ "post": {
+ "description": "Proxies all calls to the actual data source. The data source should support POST methods for the specific path and role as defined",
+ "tags": ["datasources"],
+ "summary": "Data source proxy POST calls.",
+ "operationId": "datasourceProxyPOSTByUIDcalls",
+ "parameters": [
+ {
+ "type": "string",
+ "x-go-name": "DatasourceUID",
+ "name": "datasource_uid",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "x-go-name": "DatasourceProxyRoute",
+ "name": "datasource_proxy_route",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": ""
+ },
+ "202": {
+ "description": ""
+ },
+ "400": {
+ "$ref": "#/responses/badRequestError"
+ },
+ "401": {
+ "$ref": "#/responses/unauthorisedError"
+ },
+ "403": {
+ "$ref": "#/responses/forbiddenError"
+ },
+ "404": {
+ "$ref": "#/responses/notFoundError"
+ },
+ "500": {
+ "$ref": "#/responses/internalServerError"
+ }
+ }
+ },
+ "delete": {
+ "description": "Proxies all calls to the actual data source.",
+ "tags": ["datasources"],
+ "summary": "Data source proxy DELETE calls.",
+ "operationId": "datasourceProxyDELETEByUIDcalls",
+ "parameters": [
+ {
+ "type": "string",
+ "x-go-name": "DatasourceUID",
+ "name": "datasource_uid",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "x-go-name": "DatasourceProxyRoute",
+ "name": "datasource_proxy_route",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "202": {
+ "description": ""
+ },
+ "400": {
+ "$ref": "#/responses/badRequestError"
+ },
+ "401": {
+ "$ref": "#/responses/unauthorisedError"
+ },
+ "403": {
+ "$ref": "#/responses/forbiddenError"
+ },
+ "404": {
+ "$ref": "#/responses/notFoundError"
+ },
+ "500": {
+ "$ref": "#/responses/internalServerError"
+ }
+ }
+ }
+ },
"/datasources/proxy/{datasource_id}/{datasource_proxy_route}": {
"get": {
"description": "Proxies all calls to the actual data source.",
@@ -3323,7 +3454,7 @@
},
"/datasources/{datasource_id}": {
"get": {
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).",
"tags": ["datasources"],
"summary": "Get a single data source by Id.",
"operationId": "getDatasourceByID",
@@ -3358,7 +3489,7 @@
}
},
"put": {
- "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).",
+ "description": "Similar to creating a data source, `password` and `basicAuthPassword` should be defined under\nsecureJsonData in order to be stored securely as an encrypted blob in the database. Then, the\nencrypted fields are listed under secureJsonFields section in the response.\n\nIf you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:write` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).",
"tags": ["datasources"],
"summary": "Update an existing data source.",
"operationId": "updateDatasource",
@@ -3395,7 +3526,7 @@
}
},
"delete": {
- "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).",
+ "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:delete` and scopes: `datasources:*`, `datasources:uid:*` and `datasources:uid:1` (single data source).",
"tags": ["datasources"],
"summary": "Delete an existing data source by id.",
"operationId": "deleteDatasourceByID",
@@ -3544,15 +3675,15 @@
"parameters": [
{
"type": "string",
- "x-go-name": "PermissionID",
- "name": "permissionId",
+ "x-go-name": "DatasourceID",
+ "name": "datasource_id",
"in": "path",
"required": true
},
{
"type": "string",
- "x-go-name": "DatasourceID",
- "name": "datasource_id",
+ "x-go-name": "PermissionID",
+ "name": "permissionId",
"in": "path",
"required": true
}
@@ -8374,6 +8505,36 @@
},
"x-go-package": "github.com/grafana/grafana/pkg/api/dtos"
},
+ "AnnotationActions": {
+ "type": "object",
+ "properties": {
+ "canAdd": {
+ "type": "boolean",
+ "x-go-name": "CanAdd"
+ },
+ "canDelete": {
+ "type": "boolean",
+ "x-go-name": "CanDelete"
+ },
+ "canEdit": {
+ "type": "boolean",
+ "x-go-name": "CanEdit"
+ }
+ },
+ "x-go-package": "github.com/grafana/grafana/pkg/api/dtos"
+ },
+ "AnnotationPermission": {
+ "type": "object",
+ "properties": {
+ "dashboard": {
+ "$ref": "#/definitions/AnnotationActions"
+ },
+ "organization": {
+ "$ref": "#/definitions/AnnotationActions"
+ }
+ },
+ "x-go-package": "github.com/grafana/grafana/pkg/api/dtos"
+ },
"ApiKeyDTO": {
"type": "object",
"properties": {
@@ -8421,7 +8582,7 @@
"x-go-name": "ReportLogo"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"CalculateDiffTarget": {
"type": "object",
@@ -8537,7 +8698,7 @@
"x-go-name": "UserID"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"CreateAlertNotificationCommand": {
"type": "object",
@@ -8723,7 +8884,7 @@
"x-go-name": "TemplateVars"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"CreateOrgCommand": {
"type": "object",
@@ -8983,6 +9144,9 @@
"DashboardMeta": {
"type": "object",
"properties": {
+ "annotationsPermissions": {
+ "$ref": "#/definitions/AnnotationPermission"
+ },
"canAdmin": {
"type": "boolean",
"x-go-name": "CanAdmin"
@@ -9625,32 +9789,6 @@
},
"x-go-package": "github.com/grafana/grafana/pkg/tsdb/legacydata"
},
- "DeleteAnnotationsCmd": {
- "type": "object",
- "properties": {
- "alertId": {
- "type": "integer",
- "format": "int64",
- "x-go-name": "AlertId"
- },
- "annotationId": {
- "type": "integer",
- "format": "int64",
- "x-go-name": "AnnotationId"
- },
- "dashboardId": {
- "type": "integer",
- "format": "int64",
- "x-go-name": "DashboardId"
- },
- "panelId": {
- "type": "integer",
- "format": "int64",
- "x-go-name": "PanelId"
- }
- },
- "x-go-package": "github.com/grafana/grafana/pkg/api/dtos"
- },
"DeleteTokenCommand": {
"type": "object",
"properties": {
@@ -9952,18 +10090,18 @@
"x-go-name": "URL"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/services/search"
+ "x-go-package": "github.com/grafana/grafana/pkg/models"
},
"HitList": {
"type": "array",
"items": {
"$ref": "#/definitions/Hit"
},
- "x-go-package": "github.com/grafana/grafana/pkg/services/search"
+ "x-go-package": "github.com/grafana/grafana/pkg/models"
},
"HitType": {
"type": "string",
- "x-go-package": "github.com/grafana/grafana/pkg/services/search"
+ "x-go-package": "github.com/grafana/grafana/pkg/models"
},
"ImportDashboardInput": {
"type": "object",
@@ -10395,6 +10533,27 @@
},
"x-go-package": "github.com/grafana/grafana/pkg/services/libraryelements"
},
+ "MassDeleteAnnotationsCmd": {
+ "type": "object",
+ "properties": {
+ "annotationId": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "AnnotationId"
+ },
+ "dashboardId": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "DashboardId"
+ },
+ "panelId": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "PanelId"
+ }
+ },
+ "x-go-package": "github.com/grafana/grafana/pkg/api/dtos"
+ },
"Metadata": {
"description": "Metadata contains user accesses for a given resource\nEx: map[string]bool{\"create\":true, \"delete\": true}",
"type": "object",
@@ -10711,6 +10870,9 @@
"navbar": {
"$ref": "#/definitions/NavbarPreference"
},
+ "queryHistory": {
+ "$ref": "#/definitions/QueryHistoryPreference"
+ },
"theme": {
"type": "string",
"enum": ["light", "dark"],
@@ -10857,6 +11019,9 @@
"navbar": {
"$ref": "#/definitions/NavbarPreference"
},
+ "queryHistory": {
+ "$ref": "#/definitions/QueryHistoryPreference"
+ },
"theme": {
"type": "string",
"x-go-name": "Theme"
@@ -10901,6 +11066,16 @@
},
"x-go-package": "github.com/grafana/grafana-plugin-sdk-go/backend"
},
+ "QueryHistoryPreference": {
+ "type": "object",
+ "properties": {
+ "homeTab": {
+ "type": "string",
+ "x-go-name": "HomeTab"
+ }
+ },
+ "x-go-package": "github.com/grafana/grafana/pkg/models"
+ },
"RecordingRuleJSON": {
"description": "RecordingRuleJSON is the external representation of a recording rule",
"type": "object",
@@ -10984,7 +11159,7 @@
"x-go-name": "UseEmailsFromReport"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"ReportOptionsDTO": {
"type": "object",
@@ -11001,7 +11176,7 @@
"$ref": "#/definitions/TimeRangeDTO"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"Responses": {
"description": "The QueryData method the QueryDataHandler method will set the RefId\nproperty on the DataRespones' frames based on these RefIDs.",
@@ -11175,7 +11350,7 @@
"x-go-name": "WorkdaysOnly"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"SearchTeamQueryResult": {
"type": "object",
@@ -11286,7 +11461,7 @@
"x-go-name": "UserID"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"Status": {
"type": "object",
@@ -11562,7 +11737,7 @@
"x-go-name": "To"
}
},
- "x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
+ "x-go-package": "github.com/grafana/grafana/pkg/extensions/report/api"
},
"Token": {
"type": "object",
@@ -11663,6 +11838,15 @@
"format": "int64",
"x-go-name": "TokenExpiresWarnDays"
},
+ "trial": {
+ "type": "boolean",
+ "x-go-name": "Trial"
+ },
+ "trial_exp": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "TrialExpires"
+ },
"update_days": {
"type": "integer",
"format": "int64",
@@ -12015,6 +12199,9 @@
"navbar": {
"$ref": "#/definitions/NavbarPreference"
},
+ "queryHistory": {
+ "$ref": "#/definitions/QueryHistoryPreference"
+ },
"theme": {
"type": "string",
"enum": ["light", "dark"],
diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx
index 47c00a004b7..95c0477196a 100644
--- a/public/app/AppWrapper.tsx
+++ b/public/app/AppWrapper.tsx
@@ -3,7 +3,7 @@ import { Router, Route, Redirect, Switch } from 'react-router-dom';
import { config, locationService, navigationLogger } from '@grafana/runtime';
import { Provider } from 'react-redux';
import { store } from 'app/store/store';
-import { ErrorBoundaryAlert, GlobalStyles, ModalRoot, ModalsProvider } from '@grafana/ui';
+import { ErrorBoundaryAlert, GlobalStyles, ModalRoot, ModalsProvider, PortalContainer } from '@grafana/ui';
import { GrafanaApp } from './app';
import { getAppRoutes } from 'app/routes/routes';
import { ConfigContext, ThemeProvider } from './core/utils/ConfigProvider';
@@ -113,6 +113,7 @@ export class AppWrapper extends React.Component
+
diff --git a/public/app/core/components/NavBar/Next/NavBarItem.tsx b/public/app/core/components/NavBar/Next/NavBarItem.tsx
index fb4731ce773..844686b6e00 100644
--- a/public/app/core/components/NavBar/Next/NavBarItem.tsx
+++ b/public/app/core/components/NavBar/Next/NavBarItem.tsx
@@ -102,12 +102,13 @@ const NavBarItem = ({
const translationKey = item.id && menuItemTranslations[item.id];
const itemText = translationKey ? i18n._(translationKey) : item.text;
const isSection = item.menuItemType === NavMenuItemType.Section;
+ const icon = item.showIconInNavbar && !isSection ? (item.icon as IconName) : undefined;
return (
-
{element}
{state.isOpen && (
-
+
({
bottom: 0,
flexDirection: 'column',
left: 0,
- whiteSpace: 'nowrap',
paddingTop: theme.spacing(1),
marginRight: theme.spacing(1.5),
right: 0,
@@ -126,6 +125,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
itemList: css({
display: 'grid',
gridAutoRows: `minmax(${theme.spacing(6)}, auto)`,
+ minWidth: '300px',
}),
menuCollapseIcon: css({
position: 'absolute',
@@ -283,7 +283,6 @@ const getNavItemStyles = (theme: GrafanaTheme2) => ({
}),
item: css({
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
- whiteSpace: 'normal',
'&::before': {
display: 'none',
},
@@ -312,6 +311,7 @@ const getNavItemStyles = (theme: GrafanaTheme2) => ({
width: '100%',
}),
iconContainer: css({
+ display: 'flex',
placeContent: 'center',
}),
savedItemsMenuItemWrapper: css({
@@ -324,7 +324,7 @@ const getNavItemStyles = (theme: GrafanaTheme2) => ({
linkText: css({
fontSize: theme.typography.pxToRem(14),
justifySelf: 'start',
- paddingLeft: theme.spacing(0.5),
+ padding: theme.spacing(0.5, 4.25, 0.5, 0.5),
}),
});
@@ -356,6 +356,7 @@ function CollapsibleNavItem({
onClose();
}}
className={styles.collapsibleMenuItem}
+ elClassName={styles.collapsibleIcon}
>
{link.img && (
@@ -392,6 +393,9 @@ const getCollapsibleStyles = (theme: GrafanaTheme2) => ({
height: theme.spacing(6),
width: theme.spacing(7),
display: 'grid',
+ }),
+ collapsibleIcon: css({
+ display: 'grid',
placeContent: 'center',
}),
collapsibleSectionWrapper: css({
@@ -403,7 +407,8 @@ const getCollapsibleStyles = (theme: GrafanaTheme2) => ({
collapseWrapper: css({
paddingLeft: theme.spacing(0.5),
paddingRight: theme.spacing(4.25),
- height: theme.spacing(6),
+ minHeight: theme.spacing(6),
+ overflowWrap: 'anywhere',
alignItems: 'center',
color: theme.colors.text.secondary,
'&:hover, &:focus-within': {
diff --git a/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx b/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx
index eddb0de25b5..60caa5c074f 100644
--- a/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx
+++ b/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx
@@ -28,11 +28,11 @@ export function NavBarMenuItem({
isMobile = false,
}: Props) {
const theme = useTheme2();
- const styles = getStyles(theme, isActive);
+ const styles = getStyles(theme, isActive, Boolean(icon));
const elStyle = cx(styles.element, styleOverrides);
-
const linkContent = (
+ {icon &&
}
{text}
{target === '_blank' && (
@@ -76,7 +76,7 @@ export function NavBarMenuItem({
NavBarMenuItem.displayName = 'NavBarMenuItem';
-const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
+const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], hasIcon: boolean) => ({
linkContent: css({
display: 'grid',
placeItems: 'center',
@@ -96,9 +96,9 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
flex: 1,
fontSize: 'inherit',
height: '100%',
- padding: '5px 12px 5px 10px',
+ overflowWrap: 'anywhere',
+ padding: !hasIcon ? `${theme.spacing(0.5, 2)}` : '5px 12px 5px 10px',
textAlign: 'left',
- whiteSpace: 'nowrap',
'&:hover, &:focus-visible': {
backgroundColor: theme.colors.action.hover,
color: theme.colors.text.primary,
diff --git a/public/app/core/components/Select/DashboardPicker.tsx b/public/app/core/components/Select/DashboardPicker.tsx
new file mode 100644
index 00000000000..cc74304e69f
--- /dev/null
+++ b/public/app/core/components/Select/DashboardPicker.tsx
@@ -0,0 +1,89 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import debounce from 'debounce-promise';
+import { SelectableValue } from '@grafana/data';
+import { DashboardSearchHit } from 'app/features/search/types';
+import { backendSrv } from 'app/core/services/backend_srv';
+import { AsyncSelectProps, AsyncSelect } from '@grafana/ui';
+import { DashboardDTO } from 'app/types';
+
+interface Props
+ extends Omit
, 'value' | 'onChange' | 'loadOptions' | 'menuShouldPortal'> {
+ value?: DashboardPickerDTO['uid'];
+ onChange?: (value?: DashboardPickerDTO) => void;
+}
+
+export type DashboardPickerDTO = Pick &
+ Pick;
+
+const formatLabel = (folderTitle = 'General', dashboardTitle: string) => `${folderTitle}/${dashboardTitle}`;
+
+const getDashboards = debounce((query = ''): Promise>> => {
+ return backendSrv.search({ type: 'dash-db', query, limit: 100 }).then((result: DashboardSearchHit[]) => {
+ return result.map((item: DashboardSearchHit) => ({
+ value: {
+ // dashboards uid here is always defined as this endpoint does not return the default home dashboard
+ uid: item.uid!,
+ title: item.title,
+ folderTitle: item.folderTitle,
+ folderUid: item.folderUid,
+ },
+ label: formatLabel(item?.folderTitle, item.title),
+ }));
+ });
+}, 300);
+
+// TODO: this component should provide a way to apply different filters to the search APIs
+export const DashboardPicker = ({
+ value,
+ onChange,
+ placeholder = 'Select dashboard',
+ noOptionsMessage = 'No dashboards found',
+ ...props
+}: Props) => {
+ const [current, setCurrent] = useState>();
+
+ // This is required because the async select does not match the raw uid value
+ // We can not use a simple Select because the dashboard search should not return *everything*
+ useEffect(() => {
+ if (!value || value === current?.value?.uid) {
+ return;
+ }
+
+ (async () => {
+ // value was manually changed from outside or we are rendering for the first time.
+ // We need to fetch dashboard information.
+ const res = await backendSrv.getDashboardByUid(value);
+ setCurrent({
+ value: {
+ uid: res.dashboard.uid,
+ title: res.dashboard.title,
+ folderTitle: res.meta.folderTitle,
+ folderUid: res.meta.folderUid,
+ },
+ label: formatLabel(res.meta?.folderTitle, res.dashboard.title),
+ });
+ })();
+ // we don't need to rerun this effect every time `current` changes
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [value]);
+
+ const onPicked = useCallback(
+ (sel: SelectableValue) => {
+ setCurrent(sel);
+ onChange?.(sel?.value);
+ },
+ [onChange, setCurrent]
+ );
+
+ return (
+
+ );
+};
diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx
index c69fd5ac889..c419f6a8db7 100644
--- a/public/app/core/components/TagFilter/TagFilter.tsx
+++ b/public/app/core/components/TagFilter/TagFilter.tsx
@@ -1,11 +1,9 @@
-// Libraries
-import React, { FC } from 'react';
+import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { css } from '@emotion/css';
import { components } from 'react-select';
-import debounce from 'debounce-promise';
-import { stylesFactory, useTheme, Icon, AsyncMultiSelect } from '@grafana/ui';
-import { escapeStringForRegex, GrafanaTheme } from '@grafana/data';
-// Components
+import { Icon, MultiSelect, useStyles2 } from '@grafana/ui';
+import { escapeStringForRegex, GrafanaTheme2 } from '@grafana/data';
+
import { TagOption } from './TagOption';
import { TagBadge } from './TagBadge';
@@ -14,6 +12,12 @@ export interface TermCount {
count: number;
}
+interface TagSelectOption {
+ value: string;
+ label: string;
+ count: number;
+}
+
export interface Props {
allowCustomValue?: boolean;
formatCreateLabel?: (input: string) => string;
@@ -45,29 +49,70 @@ export const TagFilter: FC = ({
tags,
width,
}) => {
- const theme = useTheme();
- const styles = getStyles(theme);
+ const styles = useStyles2(getStyles);
- const onLoadOptions = async (query: string) => {
+ const currentlySelectedTags = tags.map((tag) => ({ value: tag, label: tag, count: 0 }));
+ const [options, setOptions] = useState(currentlySelectedTags);
+ const [isLoading, setIsLoading] = useState(false);
+ const [previousTags, setPreviousTags] = useState(tags);
+
+ // Necessary to force re-render to keep tag options up to date / relevant
+ const selectKey = useMemo(() => tags.join(), [tags]);
+
+ const onLoadOptions = useCallback(async () => {
const options = await tagOptions();
- return options.map((option) => ({
- value: option.term,
- label: option.term,
- count: option.count,
- }));
- };
+ return options.map((option) => {
+ if (tags.includes(option.term)) {
+ return {
+ value: option.term,
+ label: option.term,
+ count: 0,
+ };
+ } else {
+ return {
+ value: option.term,
+ label: option.term,
+ count: option.count,
+ };
+ }
+ });
+ }, [tagOptions, tags]);
- const debouncedLoadOptions = debounce(onLoadOptions, 300);
+ const onFocus = useCallback(async () => {
+ setIsLoading(true);
+ const results = await onLoadOptions();
+ setOptions(results);
+ setIsLoading(false);
+ }, [onLoadOptions]);
+
+ useEffect(() => {
+ // Load options when tag is selected externally
+ if (tags.length > 0 && options.length === 0) {
+ onFocus();
+ }
+ }, [onFocus, options.length, tags.length]);
+
+ useEffect(() => {
+ // Update selected tags to not include (counts) when selected externally
+ if (tags !== previousTags) {
+ setPreviousTags(tags);
+ onFocus();
+ }
+ }, [onFocus, previousTags, tags]);
const onTagChange = (newTags: any[]) => {
// On remove with 1 item returns null, so we need to make sure it's an empty array in that case
// https://github.com/JedWatson/react-select/issues/3632
+ newTags.forEach((tag) => (tag.count = 0));
+
onChange((newTags || []).map((tag) => tag.value));
};
- const value = tags.map((tag) => ({ value: tag, label: tag, count: 0 }));
-
const selectOptions = {
+ key: selectKey,
+ onFocus,
+ isLoading,
+ options,
allowCreateWhileLoading: true,
allowCustomValue,
formatCreateLabel,
@@ -77,12 +122,11 @@ export const TagFilter: FC = ({
getOptionValue: (i: any) => i.value,
inputId,
isMulti: true,
- loadOptions: debouncedLoadOptions,
loadingMessage: 'Loading...',
noOptionsMessage: 'No tags found',
onChange: onTagChange,
placeholder,
- value,
+ value: currentlySelectedTags,
width,
components: {
Option: TagOption,
@@ -109,37 +153,35 @@ export const TagFilter: FC = ({
Clear tags
)}
- } aria-label="Tag filter" />
+ } aria-label="Tag filter" />
);
};
TagFilter.displayName = 'TagFilter';
-const getStyles = stylesFactory((theme: GrafanaTheme) => {
- return {
- tagFilter: css`
- position: relative;
- min-width: 180px;
- flex-grow: 1;
+const getStyles = (theme: GrafanaTheme2) => ({
+ tagFilter: css`
+ position: relative;
+ min-width: 180px;
+ flex-grow: 1;
- .label-tag {
- margin-left: 6px;
- cursor: pointer;
- }
- `,
- clear: css`
- text-decoration: underline;
- font-size: 12px;
- position: absolute;
- top: -22px;
- right: 0;
+ .label-tag {
+ margin-left: 6px;
cursor: pointer;
- color: ${theme.colors.textWeak};
+ }
+ `,
+ clear: css`
+ text-decoration: underline;
+ font-size: 12px;
+ position: absolute;
+ top: -22px;
+ right: 0;
+ cursor: pointer;
+ color: ${theme.colors.text.secondary};
- &:hover {
- color: ${theme.colors.textStrong};
- }
- `,
- };
+ &:hover {
+ color: ${theme.colors.text.primary};
+ }
+ `,
});
diff --git a/public/app/core/components/editors/DashboardPicker.tsx b/public/app/core/components/editors/DashboardPicker.tsx
index f806d44b300..51192d9c0f8 100644
--- a/public/app/core/components/editors/DashboardPicker.tsx
+++ b/public/app/core/components/editors/DashboardPicker.tsx
@@ -1,63 +1,28 @@
-import React, { FC, useCallback, useState } from 'react';
-import debounce from 'debounce-promise';
+import React, { FC, useCallback } from 'react';
import { SelectableValue, StandardEditorProps } from '@grafana/data';
-import { DashboardSearchHit } from 'app/features/search/types';
-import { backendSrv } from 'app/core/services/backend_srv';
-import { AsyncSelect } from '@grafana/ui';
-import { useAsync } from 'react-use';
+import { DashboardPicker as BasePicker, DashboardPickerDTO } from 'app/core/components/Select/DashboardPicker';
export interface DashboardPickerOptions {
placeholder?: string;
isClearable?: boolean;
}
-const getDashboards = (query = '') => {
- return backendSrv.search({ type: 'dash-db', query, limit: 100 }).then((result: DashboardSearchHit[]) => {
- return result.map((item: DashboardSearchHit) => ({
- value: item.uid,
- label: `${item?.folderTitle ?? 'General'}/${item.title}`,
- }));
- });
-};
-
/** This will return the item UID */
-export const DashboardPicker: FC> = ({ value, onChange, item }) => {
- const [current, setCurrent] = useState>();
-
- // This is required because the async select does not match the raw uid value
- // We can not use a simple Select because the dashboard search should not return *everything*
- useAsync(async () => {
- if (!value) {
- setCurrent(undefined);
- return;
- }
- const res = await backendSrv.getDashboardByUid(value);
- setCurrent({
- value: res.dashboard.uid,
- label: `${res.meta?.folderTitle ?? 'General'}/${res.dashboard.title}`,
- });
- return undefined;
- }, [value]);
+export const DashboardPicker: FC> = ({
+ value,
+ onChange,
+ item,
+}) => {
+ const { placeholder, isClearable } = item?.settings ?? {};
const onPicked = useCallback(
- (sel: SelectableValue) => {
- onChange(sel?.value);
+ (sel?: SelectableValue) => {
+ onChange(sel?.value?.uid);
},
[onChange]
);
- const debouncedSearch = debounce(getDashboards, 300);
- const { placeholder, isClearable } = item?.settings ?? {};
return (
-
+
);
};
diff --git a/public/app/core/config.ts b/public/app/core/config.ts
index 3ff14355734..fcd399c9d30 100644
--- a/public/app/core/config.ts
+++ b/public/app/core/config.ts
@@ -18,5 +18,5 @@ export const updateConfig = (update: Partial) => {
};
};
-// The `enable_alpha` flag is no exposed directly, this is equivolant
+// The `enable_alpha` flag is not exposed directly, this is equivalent
export const hasAlphaPanels = Boolean(config.panels?.debug?.state === PluginState.alpha);
diff --git a/public/app/core/history/RichHistoryLocalStorage.test.ts b/public/app/core/history/RichHistoryLocalStorage.test.ts
index 1bbc7f7b30f..747b6720935 100644
--- a/public/app/core/history/RichHistoryLocalStorage.test.ts
+++ b/public/app/core/history/RichHistoryLocalStorage.test.ts
@@ -5,6 +5,7 @@ import { DataQuery } from '@grafana/data';
import { afterEach, beforeEach } from '../../../test/lib/common';
import { RichHistoryStorageWarning } from './RichHistoryStorage';
import { backendSrv } from '../services/backend_srv';
+import { RichHistorySettings } from '../utils/richHistoryTypes';
const key = 'grafana.explore.richHistory';
@@ -99,6 +100,19 @@ describe('RichHistoryLocalStorage', () => {
expect(await storage.getRichHistory()).toEqual([]);
expect(store.getObject(key)).toEqual([]);
});
+
+ it('should save and read settings', async () => {
+ const settings: RichHistorySettings = {
+ retentionPeriod: 2,
+ starredTabAsFirstTab: true,
+ activeDatasourceOnly: true,
+ lastUsedDatasourceFilters: [{ value: 'foobar' }],
+ };
+ await storage.updateSettings(settings);
+ const storageSettings = storage.getSettings();
+
+ expect(settings).toMatchObject(storageSettings);
+ });
});
describe('retention policy and max limits', () => {
diff --git a/public/app/core/history/RichHistoryLocalStorage.ts b/public/app/core/history/RichHistoryLocalStorage.ts
index b4d05c0c931..ca98b0c6a45 100644
--- a/public/app/core/history/RichHistoryLocalStorage.ts
+++ b/public/app/core/history/RichHistoryLocalStorage.ts
@@ -5,6 +5,7 @@ import { DataQuery } from '@grafana/data';
import { find, isEqual, omit } from 'lodash';
import { createRetentionPeriodBoundary, RICH_HISTORY_SETTING_KEYS } from './richHistoryLocalStorageUtils';
import { fromDTO, toDTO } from './localStorageConverter';
+import { RichHistorySettings } from '../utils/richHistoryTypes';
export const RICH_HISTORY_KEY = 'grafana.explore.richHistory';
export const MAX_HISTORY_ITEMS = 10000;
@@ -101,6 +102,22 @@ export default class RichHistoryLocalStorage implements RichHistoryStorage {
async updateComment(id: string, comment: string) {
return updateRichHistory(id, (richHistoryDTO) => (richHistoryDTO.comment = comment));
}
+
+ async getSettings() {
+ return {
+ activeDatasourceOnly: store.getObject(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, false),
+ retentionPeriod: store.getObject(RICH_HISTORY_SETTING_KEYS.retentionPeriod, 7),
+ starredTabAsFirstTab: store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false),
+ lastUsedDatasourceFilters: store.getObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, []),
+ };
+ }
+
+ async updateSettings(settings: RichHistorySettings) {
+ store.set(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, settings.activeDatasourceOnly);
+ store.set(RICH_HISTORY_SETTING_KEYS.retentionPeriod, settings.retentionPeriod);
+ store.set(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, settings.starredTabAsFirstTab);
+ store.setObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, settings.lastUsedDatasourceFilters);
+ }
}
function updateRichHistory(
diff --git a/public/app/core/history/RichHistoryStorage.ts b/public/app/core/history/RichHistoryStorage.ts
index 4242e13658f..5e833c73f44 100644
--- a/public/app/core/history/RichHistoryStorage.ts
+++ b/public/app/core/history/RichHistoryStorage.ts
@@ -1,4 +1,5 @@
import { RichHistoryQuery } from '../../types';
+import { RichHistorySettings } from '../utils/richHistoryTypes';
/**
* Errors are used when the operation on Rich History was not successful.
@@ -44,4 +45,7 @@ export default interface RichHistoryStorage {
deleteRichHistory(id: string): Promise;
updateStarred(id: string, starred: boolean): Promise;
updateComment(id: string, comment: string | undefined): Promise;
+
+ getSettings(): Promise;
+ updateSettings(settings: RichHistorySettings): Promise;
}
diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts
index d26fcbd4905..72e7c92b993 100644
--- a/public/app/core/services/backend_srv.ts
+++ b/public/app/core/services/backend_srv.ts
@@ -18,7 +18,7 @@ import { AppEvents, DataQueryErrorType } from '@grafana/data';
import appEvents from 'app/core/app_events';
import { getConfig } from 'app/core/config';
import { DashboardSearchHit } from 'app/features/search/types';
-import { FolderDTO } from 'app/types';
+import { DashboardDTO, FolderDTO } from 'app/types';
import { ContextSrv, contextSrv } from './context_srv';
import {
isContentTypeApplicationJson,
@@ -423,7 +423,7 @@ export class BackendSrv implements BackendService {
}
getDashboardByUid(uid: string) {
- return this.get(`/api/dashboards/uid/${uid}`);
+ return this.get(`/api/dashboards/uid/${uid}`);
}
getFolderByUid(uid: string) {
diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts
index feebda044e3..5e08a4fa046 100644
--- a/public/app/core/utils/richHistory.ts
+++ b/public/app/core/utils/richHistory.ts
@@ -23,7 +23,7 @@ import {
filterQueriesByTime,
sortQueries,
} from 'app/core/history/richHistoryLocalStorageUtils';
-import { SortOrder } from './richHistoryTypes';
+import { RichHistorySettings, SortOrder } from './richHistoryTypes';
export { SortOrder };
@@ -86,6 +86,14 @@ export async function getRichHistory(): Promise {
return await getRichHistoryStorage().getRichHistory();
}
+export async function updateRichHistorySettings(settings: RichHistorySettings): Promise {
+ await getRichHistoryStorage().updateSettings(settings);
+}
+
+export async function getRichHistorySettings(): Promise {
+ return await getRichHistoryStorage().getSettings();
+}
+
export async function deleteAllFromRichHistory(): Promise {
return getRichHistoryStorage().deleteAll();
}
diff --git a/public/app/core/utils/richHistoryTypes.ts b/public/app/core/utils/richHistoryTypes.ts
index 2322258de13..0f2030069ac 100644
--- a/public/app/core/utils/richHistoryTypes.ts
+++ b/public/app/core/utils/richHistoryTypes.ts
@@ -1,6 +1,23 @@
+import { SelectableValue } from '@grafana/data';
+
export enum SortOrder {
Descending = 'Descending',
Ascending = 'Ascending',
DatasourceAZ = 'Datasource A-Z',
DatasourceZA = 'Datasource Z-A',
}
+
+export interface RichHistorySettings {
+ retentionPeriod: number;
+ starredTabAsFirstTab: boolean;
+ activeDatasourceOnly: boolean;
+ lastUsedDatasourceFilters: SelectableValue[];
+}
+
+export type RichHistorySearchFilters = {
+ search: string;
+ sortOrder: SortOrder;
+ datasourceFilters: SelectableValue[];
+ from: number;
+ to: number;
+};
diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx
index ea93183aa40..d8eaa9cd338 100644
--- a/public/app/features/alerting/routes.tsx
+++ b/public/app/features/alerting/routes.tsx
@@ -1,12 +1,11 @@
-import React from 'react';
-import { Redirect } from 'react-router-dom';
+import { OrgRole } from '@grafana/data';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { config } from 'app/core/config';
import { RouteDescriptor } from 'app/core/navigation/types';
-import { uniq } from 'lodash';
-import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
-import { OrgRole } from '@grafana/data';
+import { uniq } from 'lodash';
+import React from 'react';
+import { Redirect } from 'react-router-dom';
import { evaluateAccess } from './unified/utils/access-control';
const commonRoutes: RouteDescriptor[] = [
@@ -101,11 +100,10 @@ const unifiedRoutes: RouteDescriptor[] = [
},
{
path: '/alerting/routes',
- roles: () =>
- contextSrv.evaluatePermission(config.unifiedAlertingEnabled ? () => ['Editor', 'Admin'] : () => [], [
- AccessControlAction.AlertingNotificationsRead,
- AccessControlAction.AlertingNotificationsExternalRead,
- ]),
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsExternalRead],
+ [OrgRole.Editor, OrgRole.Admin]
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertAmRoutes" */ 'app/features/alerting/unified/AmRoutes')
),
@@ -263,6 +261,10 @@ const unifiedRoutes: RouteDescriptor[] = [
{
path: '/alerting/:sourceName/:name/find',
pageClass: 'page-alerting',
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead],
+ [OrgRole.Viewer, OrgRole.Editor, OrgRole.Admin]
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertingRedirectToRule"*/ 'app/features/alerting/unified/RedirectToRuleViewer')
),
diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.tsx
index 7460af80219..6832a62b200 100644
--- a/public/app/features/alerting/unified/PanelAlertTabContent.tsx
+++ b/public/app/features/alerting/unified/PanelAlertTabContent.tsx
@@ -7,6 +7,8 @@ import { NewRuleFromPanelButton } from './components/panel-alerts-tab/NewRuleFro
import { RulesTable } from './components/rules/RulesTable';
import { usePanelCombinedRules } from './hooks/usePanelCombinedRules';
import { selectors } from '@grafana/e2e-selectors';
+import { getRulesPermissions } from './utils/access-control';
+import { contextSrv } from 'app/core/services/context_srv';
interface Props {
dashboard: DashboardModel;
@@ -20,6 +22,8 @@ export const PanelAlertTabContent: FC = ({ dashboard, panel }) => {
panel,
poll: true,
});
+ const permissions = getRulesPermissions('grafana');
+ const canCreateRules = contextSrv.hasPermission(permissions.create);
const alert = errors.length ? (
@@ -44,7 +48,7 @@ export const PanelAlertTabContent: FC = ({ dashboard, panel }) => {
{alert}
- {!!dashboard.meta.canSave && (
+ {!!dashboard.meta.canSave && canCreateRules && (
)}
@@ -58,7 +62,7 @@ export const PanelAlertTabContent: FC = ({ dashboard, panel }) => {
{!!dashboard.uid && (
<>
There are no alert rules linked to this panel.
- {!!dashboard.meta.canSave && }
+ {!!dashboard.meta.canSave && canCreateRules && }
>
)}
{!dashboard.uid && !!dashboard.meta.canSave && (
diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx
index 1b904541fdc..105e078bc4a 100644
--- a/public/app/features/alerting/unified/RuleViewer.tsx
+++ b/public/app/features/alerting/unified/RuleViewer.tsx
@@ -1,36 +1,37 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
-import { useObservable } from 'react-use';
import { css } from '@emotion/css';
import { GrafanaTheme2, LoadingState, PanelData } from '@grafana/data';
import {
- withErrorBoundary,
- useStyles2,
Alert,
+ Button,
+ Icon,
LoadingPlaceholder,
PanelChromeLoadingIndicator,
- Icon,
- Button,
+ useStyles2,
VerticalGroup,
+ withErrorBoundary,
} from '@grafana/ui';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
-import { AlertingQueryRunner } from './state/AlertingQueryRunner';
-import { useCombinedRule } from './hooks/useCombinedRule';
-import { alertRuleToQueries } from './utils/query';
-import { RuleState } from './components/rules/RuleState';
-import { getRulesSourceByName } from './utils/datasource';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useObservable } from 'react-use';
+import { AlertQuery } from '../../../types/unified-alerting-dto';
+import { AlertLabels } from './components/AlertLabels';
import { DetailsField } from './components/DetailsField';
-import { RuleHealth } from './components/rules/RuleHealth';
+import { RuleViewerLayout, RuleViewerLayoutContent } from './components/rule-viewer/RuleViewerLayout';
import { RuleViewerVisualization } from './components/rule-viewer/RuleViewerVisualization';
import { RuleDetailsActionButtons } from './components/rules/RuleDetailsActionButtons';
-import { RuleDetailsMatchingInstances } from './components/rules/RuleDetailsMatchingInstances';
-import { RuleDetailsDataSources } from './components/rules/RuleDetailsDataSources';
-import { RuleViewerLayout, RuleViewerLayoutContent } from './components/rule-viewer/RuleViewerLayout';
-import { AlertLabels } from './components/AlertLabels';
-import { RuleDetailsExpression } from './components/rules/RuleDetailsExpression';
import { RuleDetailsAnnotations } from './components/rules/RuleDetailsAnnotations';
-import * as ruleId from './utils/rule-id';
-import { AlertQuery } from '../../../types/unified-alerting-dto';
+import { RuleDetailsDataSources } from './components/rules/RuleDetailsDataSources';
+import { RuleDetailsExpression } from './components/rules/RuleDetailsExpression';
import { RuleDetailsFederatedSources } from './components/rules/RuleDetailsFederatedSources';
+import { RuleDetailsMatchingInstances } from './components/rules/RuleDetailsMatchingInstances';
+import { RuleHealth } from './components/rules/RuleHealth';
+import { RuleState } from './components/rules/RuleState';
+import { useAlertQueriesStatus } from './hooks/useAlertQueriesStatus';
+import { useCombinedRule } from './hooks/useCombinedRule';
+import { AlertingQueryRunner } from './state/AlertingQueryRunner';
+import { getRulesSourceByName } from './utils/datasource';
+import { alertRuleToQueries } from './utils/query';
+import * as ruleId from './utils/rule-id';
import { isFederatedRuleGroup } from './utils/rules';
type RuleViewerProps = GrafanaRouteComponentProps<{ id?: string; sourceName?: string }>;
@@ -49,19 +50,23 @@ export function RuleViewer({ match }: RuleViewerProps) {
const queries2 = useMemo(() => alertRuleToQueries(rule), [rule]);
const [queries, setQueries] = useState([]);
+ const { allDataSourcesAvailable } = useAlertQueriesStatus(queries2);
+
const onRunQueries = useCallback(() => {
- if (queries.length > 0) {
+ if (queries.length > 0 && allDataSourcesAvailable) {
runner.run(queries);
}
- }, [queries, runner]);
+ }, [queries, runner, allDataSourcesAvailable]);
useEffect(() => {
setQueries(queries2);
}, [queries2]);
useEffect(() => {
- onRunQueries();
- }, [onRunQueries]);
+ if (allDataSourcesAvailable) {
+ onRunQueries();
+ }
+ }, [onRunQueries, allDataSourcesAvailable]);
useEffect(() => {
return () => runner.destroy();
@@ -192,6 +197,11 @@ export function RuleViewer({ match }: RuleViewerProps) {
>
)}
+ {!isFederatedRule && !allDataSourcesAvailable && (
+
+ Cannot display the query preview. Some of the data sources used in the queries are not available.
+
+ )}
);
}
@@ -219,6 +229,9 @@ const getStyles = (theme: GrafanaTheme2) => {
border-bottom: 1px solid ${theme.colors.border.medium};
padding: ${theme.spacing(2)};
`,
+ queryWarning: css`
+ margin: ${theme.spacing(4, 0)};
+ `,
details: css`
display: flex;
flex-direction: row;
diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx
index 76ec7d4a250..ae7f9a1a142 100644
--- a/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx
+++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx
@@ -14,6 +14,8 @@ import {
Select,
Switch,
useStyles2,
+ Badge,
+ VerticalGroup,
} from '@grafana/ui';
import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes';
import {
@@ -56,73 +58,85 @@ export const AmRoutesExpandedForm: FC = ({ onCancel,
{({ fields, append, remove }) => (
<>
- Matching labels
-
+ )}
+ append(emptyArrayFieldMatcher)}
+ variant="secondary"
+ type="button"
+ >
+ Add matcher
+
+
>
)}
@@ -376,5 +390,8 @@ const getStyles = (theme: GrafanaTheme2) => {
margin-left: ${theme.spacing(1.5)};
}
`,
+ noMatchersWarning: css`
+ padding: ${theme.spacing(1)} ${theme.spacing(2)};
+ `,
};
};
diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx
index c25f812d57a..8719728235e 100644
--- a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx
+++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx
@@ -90,7 +90,13 @@ export const AmRoutesTable: FC = ({
id: 'matchingCriteria',
label: 'Matching labels',
// eslint-disable-next-line react/display-name
- renderCell: (item) => ,
+ renderCell: (item) => {
+ return item.data.object_matchers.length ? (
+
+ ) : (
+ Matches all alert instances
+ );
+ },
size: 10,
},
{
diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.test.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.test.tsx
new file mode 100644
index 00000000000..7eacd6a1fcb
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.test.tsx
@@ -0,0 +1,75 @@
+import { render } from '@testing-library/react';
+import { contextSrv } from 'app/core/services/context_srv';
+import { configureStore } from 'app/store/configureStore';
+import { AccessControlAction } from 'app/types';
+import React from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+import { Provider } from 'react-redux';
+import { byText } from 'testing-library-selector';
+import { AlertTypeStep } from './AlertTypeStep';
+
+const ui = {
+ ruleTypePicker: {
+ grafanaManagedButton: byText('Grafana managed alert'),
+ mimirOrLokiButton: byText('Mimir or Loki alert'),
+ mimirOrLokiRecordingButton: byText('Mimir or Loki recording rule'),
+ },
+};
+
+const FormProviderWrapper: React.FC = ({ children }) => {
+ const methods = useForm({});
+ return {children} ;
+};
+
+function renderAlertTypeStep() {
+ const store = configureStore();
+
+ render(
+
+
+ ,
+ { wrapper: FormProviderWrapper }
+ );
+}
+
+describe('RuleTypePicker', () => {
+ describe('FGAC', () => {
+ it('Should display grafana, mimir alert and mimir recording buttons when user has rule create and write permissions', async () => {
+ jest.spyOn(contextSrv, 'hasPermission').mockImplementation((action) => {
+ return [AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite].includes(
+ action as AccessControlAction
+ );
+ });
+
+ renderAlertTypeStep();
+
+ expect(ui.ruleTypePicker.grafanaManagedButton.get()).toBeInTheDocument();
+ expect(ui.ruleTypePicker.mimirOrLokiButton.get()).toBeInTheDocument();
+ expect(ui.ruleTypePicker.mimirOrLokiRecordingButton.get()).toBeInTheDocument();
+ });
+
+ it('Should hide grafana button when user does not have rule create permission', () => {
+ jest.spyOn(contextSrv, 'hasPermission').mockImplementation((action) => {
+ return [AccessControlAction.AlertingRuleExternalWrite].includes(action as AccessControlAction);
+ });
+
+ renderAlertTypeStep();
+
+ expect(ui.ruleTypePicker.grafanaManagedButton.query()).not.toBeInTheDocument();
+ expect(ui.ruleTypePicker.mimirOrLokiButton.get()).toBeInTheDocument();
+ expect(ui.ruleTypePicker.mimirOrLokiRecordingButton.get()).toBeInTheDocument();
+ });
+
+ it('Should hide mimir alert and mimir recording when user does not have rule external write permission', () => {
+ jest.spyOn(contextSrv, 'hasPermission').mockImplementation((action) => {
+ return [AccessControlAction.AlertingRuleCreate].includes(action as AccessControlAction);
+ });
+
+ renderAlertTypeStep();
+
+ expect(ui.ruleTypePicker.grafanaManagedButton.get()).toBeInTheDocument();
+ expect(ui.ruleTypePicker.mimirOrLokiButton.query()).not.toBeInTheDocument();
+ expect(ui.ruleTypePicker.mimirOrLokiRecordingButton.query()).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx
index b06e7e2497c..c0ebdaa73bf 100644
--- a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx
@@ -10,6 +10,8 @@ import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
import { CloudRulesSourcePicker } from './CloudRulesSourcePicker';
import { checkForPathSeparator } from './util';
import { RuleTypePicker } from './rule-types/RuleTypePicker';
+import { contextSrv } from 'app/core/services/context_srv';
+import { AccessControlAction } from 'app/types';
interface Props {
editingExistingRule: boolean;
@@ -24,6 +26,8 @@ const recordingRuleNameValidationPattern = {
export const AlertTypeStep: FC = ({ editingExistingRule }) => {
const styles = useStyles2(getStyles);
+ const { enabledRuleTypes, defaultRuleType } = getAvailableRuleTypes();
+
const {
register,
control,
@@ -44,8 +48,9 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => {
render={({ field: { onChange } }) => (
)}
name="type"
@@ -140,6 +145,22 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => {
);
};
+function getAvailableRuleTypes() {
+ const canCreateGrafanaRules = contextSrv.hasPermission(AccessControlAction.AlertingRuleCreate);
+ const canCreateCloudRules = contextSrv.hasPermission(AccessControlAction.AlertingRuleExternalWrite);
+ const defaultRuleType = canCreateGrafanaRules ? RuleFormType.grafana : RuleFormType.cloudAlerting;
+
+ const enabledRuleTypes: RuleFormType[] = [];
+ if (canCreateGrafanaRules) {
+ enabledRuleTypes.push(RuleFormType.grafana);
+ }
+ if (canCreateCloudRules) {
+ enabledRuleTypes.push(RuleFormType.cloudAlerting, RuleFormType.cloudRecording);
+ }
+
+ return { enabledRuleTypes, defaultRuleType };
+}
+
const getStyles = (theme: GrafanaTheme2) => ({
formInput: css`
width: 330px;
diff --git a/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx b/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx
index 9cac7d83d27..4b2bf6a9510 100644
--- a/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/PreviewRule.tsx
@@ -1,33 +1,44 @@
-import React, { useCallback, useState } from 'react';
import { css } from '@emotion/css';
-import { useFormContext } from 'react-hook-form';
-import { takeWhile } from 'rxjs/operators';
-import { useMountedState } from 'react-use';
-import { Button, HorizontalGroup, useStyles2 } from '@grafana/ui';
import { dateTimeFormatISO, GrafanaTheme2, LoadingState } from '@grafana/data';
-import { RuleFormType } from '../../types/rule-form';
-import { PreviewRuleRequest, PreviewRuleResponse } from '../../types/preview';
+import { Alert, Button, HorizontalGroup, useStyles2 } from '@grafana/ui';
+import React, { useCallback, useState } from 'react';
+import { useFormContext } from 'react-hook-form';
+import { useMountedState } from 'react-use';
+import { takeWhile } from 'rxjs/operators';
import { previewAlertRule } from '../../api/preview';
+import { useAlertQueriesStatus } from '../../hooks/useAlertQueriesStatus';
+import { PreviewRuleRequest, PreviewRuleResponse } from '../../types/preview';
+import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { PreviewRuleResult } from './PreviewRuleResult';
-const fields: string[] = ['type', 'dataSourceName', 'condition', 'queries', 'expression'];
+const fields: Array = ['type', 'dataSourceName', 'condition', 'queries', 'expression'];
export function PreviewRule(): React.ReactElement | null {
const styles = useStyles2(getStyles);
const [preview, onPreview] = usePreview();
- const { watch } = useFormContext();
- const [type, condition] = watch(['type', 'condition']);
+ const { watch } = useFormContext();
+ const [type, condition, queries] = watch(['type', 'condition', 'queries']);
+ const { allDataSourcesAvailable } = useAlertQueriesStatus(queries);
if (type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) {
return null;
}
+ const isPreviewAvailable = Boolean(condition) && allDataSourcesAvailable;
+
return (
-
- Preview alerts
-
+ {allDataSourcesAvailable && (
+
+ Preview alerts
+
+ )}
+ {!allDataSourcesAvailable && (
+
+ Cannot display the query preview. Some of the data sources used in the queries are not available.
+
+ )}
@@ -36,7 +47,7 @@ export function PreviewRule(): React.ReactElement | null {
function usePreview(): [PreviewRuleResponse | undefined, () => void] {
const [preview, setPreview] = useState();
- const { getValues } = useFormContext();
+ const { getValues } = useFormContext();
const isMounted = useMountedState();
const onPreview = useCallback(() => {
diff --git a/public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx b/public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx
index 85e3365cd7f..e9dcc0c3450 100644
--- a/public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx
@@ -1,3 +1,6 @@
+import { css } from '@emotion/css';
+import { GrafanaTheme2 } from '@grafana/data/src';
+import { Stack } from '@grafana/experimental';
import { useStyles2 } from '@grafana/ui';
import { isEmpty } from 'lodash';
import React, { FC } from 'react';
@@ -6,16 +9,14 @@ import { RuleFormType } from '../../../types/rule-form';
import { GrafanaManagedRuleType } from './GrafanaManagedAlert';
import { MimirFlavoredType } from './MimirOrLokiAlert';
import { RecordingRuleType } from './MimirOrLokiRecordingRule';
-import { css } from '@emotion/css';
-import { GrafanaTheme2 } from '@grafana/data/src';
-import { Stack } from '@grafana/experimental';
interface RuleTypePickerProps {
onChange: (value: RuleFormType) => void;
selected: RuleFormType;
+ enabledTypes: RuleFormType[];
}
-const RuleTypePicker: FC = ({ selected, onChange }) => {
+const RuleTypePicker: FC = ({ selected, onChange, enabledTypes }) => {
const rulesSourcesWithRuler = useRulesSourcesWithRuler();
const hasLotexDatasources = !isEmpty(rulesSourcesWithRuler);
@@ -24,22 +25,30 @@ const RuleTypePicker: FC = ({ selected, onChange }) => {
return (
<>
-
-
-
+ {enabledTypes.includes(RuleFormType.grafana) && (
+
+ )}
+ {enabledTypes.includes(RuleFormType.cloudAlerting) && (
+
+ )}
+ {enabledTypes.includes(RuleFormType.cloudRecording) && (
+
+ )}
-
- Select “Grafana managed” unless you have a Mimir, Loki or Cortex data source with the Ruler API
- enabled.
-
+ {enabledTypes.includes(RuleFormType.grafana) && (
+
+ Select “Grafana managed” unless you have a Mimir, Loki or Cortex data source with the Ruler API
+ enabled.
+
+ )}
>
);
};
diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx
index 4c78bd50443..6e5c6147bf0 100644
--- a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx
+++ b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx
@@ -28,13 +28,11 @@ const ui = {
jest.spyOn(contextSrv, 'accessControlEnabled').mockReturnValue(true);
describe('RuleDetails FGAC', () => {
- mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
-
describe('Grafana rules action buttons', () => {
const grafanaRule = getGrafanaRule({ name: 'Grafana' });
it('Should not render Edit button for users without the update permission', () => {
// Arrange
- jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
+ mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
// Act
renderRuleDetails(grafanaRule);
@@ -45,7 +43,7 @@ describe('RuleDetails FGAC', () => {
it('Should not render Delete button for users without the delete permission', () => {
// Arrange
- jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
+ mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false });
// Act
renderRuleDetails(grafanaRule);
@@ -56,9 +54,7 @@ describe('RuleDetails FGAC', () => {
it('Should render Edit button for users with the update permission', () => {
// Arrange
- jest
- .spyOn(contextSrv, 'hasPermission')
- .mockImplementation((action) => action === AccessControlAction.AlertingRuleUpdate);
+ mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
// Act
renderRuleDetails(grafanaRule);
@@ -69,9 +65,7 @@ describe('RuleDetails FGAC', () => {
it('Should render Delete button for users with the delete permission', () => {
// Arrange
- jest
- .spyOn(contextSrv, 'hasPermission')
- .mockImplementation((action) => action === AccessControlAction.AlertingRuleDelete);
+ mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
// Act
renderRuleDetails(grafanaRule);
@@ -109,7 +103,7 @@ describe('RuleDetails FGAC', () => {
const cloudRule = getCloudRule({ name: 'Cloud' });
it('Should not render Edit button for users without the update permission', () => {
// Arrange
- jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
+ mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
// Act
renderRuleDetails(cloudRule);
@@ -120,7 +114,7 @@ describe('RuleDetails FGAC', () => {
it('Should not render Delete button for users without the delete permission', () => {
// Arrange
- jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
+ mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false });
// Act
renderRuleDetails(cloudRule);
@@ -131,9 +125,7 @@ describe('RuleDetails FGAC', () => {
it('Should render Edit button for users with the update permission', () => {
// Arrange
- jest
- .spyOn(contextSrv, 'hasPermission')
- .mockImplementation((action) => action === AccessControlAction.AlertingRuleExternalWrite);
+ mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
// Act
renderRuleDetails(cloudRule);
@@ -144,9 +136,7 @@ describe('RuleDetails FGAC', () => {
it('Should render Delete button for users with the delete permission', () => {
// Arrange
- jest
- .spyOn(contextSrv, 'hasPermission')
- .mockImplementation((action) => action === AccessControlAction.AlertingRuleExternalWrite);
+ mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
// Act
renderRuleDetails(cloudRule);
diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx
index 7f26d28ba87..43604a68733 100644
--- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx
+++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx
@@ -4,7 +4,6 @@ import { config } from '@grafana/runtime';
import { Button, ClipboardButton, ConfirmModal, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/services/context_srv';
-import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control';
import { AccessControlAction } from 'app/types';
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
import { RulerGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
@@ -41,16 +40,13 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => {
: getAlertmanagerByUid(rulesSource.jsonData.alertmanagerUid)?.name;
const rulesSourceName = getRulesSourceName(rulesSource);
- const rulesPermissions = getRulesPermissions(rulesSourceName);
- const hasEditPermission = contextSrv.hasPermission(rulesPermissions.update);
- const hasDeletePermission = contextSrv.hasPermission(rulesPermissions.delete);
const hasExplorePermission = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore);
const leftButtons: JSX.Element[] = [];
const rightButtons: JSX.Element[] = [];
const isFederated = isFederatedRuleGroup(group);
- const { isEditable } = useIsRuleEditable(rulesSourceName, rulerRule);
+ const { isEditable, isRemovable } = useIsRuleEditable(rulesSourceName, rulerRule);
const returnTo = location.pathname + location.search;
const isViewMode = inViewMode(location.pathname);
@@ -187,7 +183,6 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => {
);
}
- // TODO Maybe there is a way to unify isEditable with FGAC permissions
if (isEditable && rulerRule && !isFederated) {
const sourceName = getRulesSourceName(rulesSource);
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
@@ -218,29 +213,29 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => {
);
}
- if (hasEditPermission) {
- rightButtons.push(
-
- Edit
-
- );
- }
- if (hasDeletePermission) {
- rightButtons.push(
- setRuleToDelete(rule)}
- >
- Delete
-
- );
- }
+ rightButtons.push(
+
+ Edit
+
+ );
}
+
+ if (isRemovable && rulerRule && !isFederated) {
+ rightButtons.push(
+ setRuleToDelete(rule)}
+ >
+ Delete
+
+ );
+ }
+
if (leftButtons.length || rightButtons.length) {
return (
<>
diff --git a/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx
new file mode 100644
index 00000000000..e7d2192e5d8
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx
@@ -0,0 +1,108 @@
+import { render } from '@testing-library/react';
+import { contextSrv } from 'app/core/services/context_srv';
+import { configureStore } from 'app/store/configureStore';
+import { AccessControlAction } from 'app/types';
+import { CombinedRuleNamespace } from 'app/types/unified-alerting';
+import React from 'react';
+import { Provider } from 'react-redux';
+import { byRole } from 'testing-library-selector';
+import { mockCombinedRule, mockDataSource } from '../../mocks';
+import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
+import { RuleListGroupView } from './RuleListGroupView';
+
+const ui = {
+ grafanaRulesHeading: byRole('heading', { name: 'Grafana' }),
+ cloudRulesHeading: byRole('heading', { name: 'Mimir / Cortex / Loki' }),
+};
+
+describe('RuleListGroupView', () => {
+ describe('FGAC', () => {
+ jest.spyOn(contextSrv, 'accessControlEnabled').mockReturnValue(true);
+
+ it('Should display Grafana rules when the user has the alert rule read permission', () => {
+ const grafanaNamespace = getGrafanaNamespace();
+ const namespaces: CombinedRuleNamespace[] = [grafanaNamespace];
+
+ jest
+ .spyOn(contextSrv, 'hasPermission')
+ .mockImplementation((action) => action === AccessControlAction.AlertingRuleRead);
+
+ renderRuleList(namespaces);
+
+ expect(ui.grafanaRulesHeading.get()).toBeInTheDocument();
+ });
+
+ it('Should display Cloud rules when the user has the external rules read permission', () => {
+ const cloudNamespace = getCloudNamespace();
+ const namespaces: CombinedRuleNamespace[] = [cloudNamespace];
+
+ jest
+ .spyOn(contextSrv, 'hasPermission')
+ .mockImplementation((action) => action === AccessControlAction.AlertingRuleExternalRead);
+
+ renderRuleList(namespaces);
+
+ expect(ui.cloudRulesHeading.get()).toBeInTheDocument();
+ });
+
+ it('Should not display Grafana rules when the user does not have alert rule read permission', () => {
+ const grafanaNamespace = getGrafanaNamespace();
+ const namespaces: CombinedRuleNamespace[] = [grafanaNamespace];
+
+ jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
+
+ renderRuleList(namespaces);
+
+ expect(ui.grafanaRulesHeading.query()).not.toBeInTheDocument();
+ });
+
+ it('Should not display Cloud rules when the user does not have the external rules read permission', () => {
+ const cloudNamespace = getCloudNamespace();
+
+ const namespaces: CombinedRuleNamespace[] = [cloudNamespace];
+ renderRuleList(namespaces);
+
+ jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
+
+ renderRuleList(namespaces);
+
+ expect(ui.cloudRulesHeading.query()).not.toBeInTheDocument();
+ });
+ });
+});
+
+function renderRuleList(namespaces: CombinedRuleNamespace[]) {
+ const store = configureStore();
+
+ render(
+
+
+
+ );
+}
+
+function getGrafanaNamespace(): CombinedRuleNamespace {
+ return {
+ name: 'Grafana Test Namespace',
+ rulesSource: GRAFANA_RULES_SOURCE_NAME,
+ groups: [
+ {
+ name: 'default',
+ rules: [mockCombinedRule()],
+ },
+ ],
+ };
+}
+
+function getCloudNamespace(): CombinedRuleNamespace {
+ return {
+ name: 'Cloud Test Namespace',
+ rulesSource: mockDataSource(),
+ groups: [
+ {
+ name: 'Prom group',
+ rules: [mockCombinedRule()],
+ },
+ ],
+ };
+}
diff --git a/public/app/features/alerting/unified/components/rules/RuleListGroupView.tsx b/public/app/features/alerting/unified/components/rules/RuleListGroupView.tsx
index e2a51b2b390..9bb64492da7 100644
--- a/public/app/features/alerting/unified/components/rules/RuleListGroupView.tsx
+++ b/public/app/features/alerting/unified/components/rules/RuleListGroupView.tsx
@@ -1,6 +1,8 @@
+import { AccessControlAction } from 'app/types';
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import React, { FC, useMemo } from 'react';
import { isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource';
+import { Authorize } from '../Authorize';
import { CloudRules } from './CloudRules';
import { GrafanaRules } from './GrafanaRules';
@@ -25,8 +27,12 @@ export const RuleListGroupView: FC = ({ namespaces, expandAll }) => {
return (
<>
-
-
+
+
+
+
+
+
>
);
};
diff --git a/public/app/features/alerting/unified/components/rules/StateHistory.test.tsx b/public/app/features/alerting/unified/components/rules/StateHistory.test.tsx
new file mode 100644
index 00000000000..31d0bc9e188
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rules/StateHistory.test.tsx
@@ -0,0 +1,40 @@
+import { StateHistoryItem } from 'app/types/unified-alerting';
+import { sortStateHistory } from './StateHistory';
+
+describe(sortStateHistory, () => {
+ describe('should stably sort', () => {
+ describe('when timeEnd is different', () => {
+ it('should not sort by rule id', () => {
+ let data: StateHistoryItem[] = [
+ { timeEnd: 23, time: 22, id: 1 } as StateHistoryItem,
+ { timeEnd: 22, time: 21, id: 3 } as StateHistoryItem,
+ { timeEnd: 22, time: 22, id: 2 } as StateHistoryItem,
+ { timeEnd: 24, id: 3 } as StateHistoryItem,
+ ];
+
+ data.sort(sortStateHistory);
+ expect(data[0].timeEnd).toBe(24);
+ expect(data[1].timeEnd).toBe(23);
+ expect(data[2].time).toBe(22);
+ expect(data[3].id).toBe(3);
+ });
+ });
+
+ describe('when only the rule id is different', () => {
+ it('should sort by rule id', () => {
+ let data: StateHistoryItem[] = [
+ { timeEnd: 23, time: 22, id: 1 } as StateHistoryItem,
+ { timeEnd: 23, time: 22, id: 3 } as StateHistoryItem,
+ { timeEnd: 23, time: 22, id: 2 } as StateHistoryItem,
+ { timeEnd: 23, time: 22, id: 6 } as StateHistoryItem,
+ ];
+
+ data.sort(sortStateHistory);
+ expect(data[0].id).toBe(6);
+ expect(data[1].id).toBe(3);
+ expect(data[2].id).toBe(2);
+ expect(data[3].id).toBe(1);
+ });
+ });
+ });
+});
diff --git a/public/app/features/alerting/unified/components/rules/StateHistory.tsx b/public/app/features/alerting/unified/components/rules/StateHistory.tsx
index f7050eb198c..75d17e883fe 100644
--- a/public/app/features/alerting/unified/components/rules/StateHistory.tsx
+++ b/public/app/features/alerting/unified/components/rules/StateHistory.tsx
@@ -24,6 +24,32 @@ interface RuleStateHistoryProps {
alertId: string;
}
+function sortStateHistory(a: StateHistoryItem, b: StateHistoryItem): number {
+ const compareDesc = (a: number, b: number): number => {
+ // Larger numbers first.
+ if (a > b) {
+ return -1;
+ }
+
+ if (b > a) {
+ return 1;
+ }
+ return 0;
+ };
+
+ const endNeq = compareDesc(a.timeEnd, b.timeEnd);
+ if (endNeq) {
+ return endNeq;
+ }
+
+ const timeNeq = compareDesc(a.time, b.time);
+ if (timeNeq) {
+ return timeNeq;
+ }
+
+ return compareDesc(a.id, b.id);
+}
+
const StateHistory: FC = ({ alertId }) => {
const { loading, error, result = [] } = useManagedAlertStateHistory(alertId);
@@ -42,6 +68,7 @@ const StateHistory: FC = ({ alertId }) => {
];
const items: StateHistoryRow[] = result
+ .sort(sortStateHistory)
.reduce((acc: StateHistoryRowItem[], item, index) => {
acc.push({
id: String(item.id),
@@ -123,4 +150,7 @@ function hasMatchingPrecedingState(index: number, items: StateHistoryItem[]): bo
return previousHistoryItem.newState === currentHistoryItem.prevState;
}
-export { StateHistory };
+export {
+ StateHistory,
+ sortStateHistory, // exported for testing.
+};
diff --git a/public/app/features/alerting/unified/hooks/useAlertQueriesStatus.ts b/public/app/features/alerting/unified/hooks/useAlertQueriesStatus.ts
new file mode 100644
index 00000000000..b5e3c61eb05
--- /dev/null
+++ b/public/app/features/alerting/unified/hooks/useAlertQueriesStatus.ts
@@ -0,0 +1,12 @@
+import { getDataSourceSrv } from '@grafana/runtime';
+import { AlertQuery } from 'app/types/unified-alerting-dto';
+import { useMemo } from 'react';
+
+export function useAlertQueriesStatus(queries: AlertQuery[]) {
+ const allDataSourcesAvailable = useMemo(
+ () => queries.every((query) => Boolean(getDataSourceSrv().getInstanceSettings(query.datasourceUid))),
+ [queries]
+ );
+
+ return { allDataSourcesAvailable };
+}
diff --git a/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx b/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx
new file mode 100644
index 00000000000..7925df6f9d6
--- /dev/null
+++ b/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx
@@ -0,0 +1,156 @@
+import React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import { contextSrv } from 'app/core/services/context_srv';
+import { configureStore } from 'app/store/configureStore';
+import { AccessControlAction, FolderDTO, StoreState } from 'app/types';
+import { Provider } from 'react-redux';
+import { mockFolder, mockRulerAlertingRule, mockRulerGrafanaRule } from '../mocks';
+import { useFolder } from './useFolder';
+import { useIsRuleEditable } from './useIsRuleEditable';
+import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
+
+jest.mock('./useFolder');
+
+const mocks = {
+ useFolder: jest.mocked(useFolder),
+ useUnifiedAlertingSelector: jest.mocked(useUnifiedAlertingSelector),
+};
+
+describe('useIsRuleEditable', () => {
+ describe('FGAC enabled', () => {
+ jest.spyOn(contextSrv, 'accessControlEnabled').mockReturnValue(true);
+ describe('Grafana rules', () => {
+ it('Should allow editing when the user has the alert rule update permission and folder permissions', () => {
+ mockPermissions([AccessControlAction.AlertingRuleUpdate]);
+ mockUseFolder({ canSave: true });
+ const wrapper = getProviderWrapper();
+
+ const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.isEditable).toBe(true);
+ });
+
+ it('Should allow deleting when the user has the alert rule delete permission and folder permissions', () => {
+ mockPermissions([AccessControlAction.AlertingRuleDelete]);
+ mockUseFolder({ canSave: true });
+ const wrapper = getProviderWrapper();
+
+ const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.isRemovable).toBe(true);
+ });
+
+ it('Should forbid editing when the user has no alert rule update permission and has folder permissions', () => {
+ mockPermissions([]);
+ mockUseFolder({ canSave: true });
+ const wrapper = getProviderWrapper();
+
+ const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.isEditable).toBe(false);
+ });
+
+ it('Should forbid deleting when the user has no alert rule delete permission and has folder permissions', () => {
+ mockPermissions([]);
+ mockUseFolder({ canSave: true });
+ const wrapper = getProviderWrapper();
+
+ const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.isRemovable).toBe(false);
+ });
+
+ it('Should forbid editing and deleting when the user has aler rule permissions but does not have folder permissions', () => {
+ mockPermissions([AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleDelete]);
+ mockUseFolder({ canSave: false });
+ const wrapper = getProviderWrapper();
+
+ const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.isEditable).toBe(false);
+ expect(result.current.isRemovable).toBe(false);
+ });
+ });
+
+ describe('Cloud rules', () => {
+ it('Should allow editing and deleting when the user has alert rule external write permission', () => {
+ mockPermissions([AccessControlAction.AlertingRuleExternalWrite]);
+ const wrapper = getProviderWrapper();
+
+ const { result } = renderHook(() => useIsRuleEditable('cortex', mockRulerAlertingRule()), { wrapper });
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.isEditable).toBe(true);
+ expect(result.current.isRemovable).toBe(true);
+ });
+
+ it('Should forbid editing and deleting when the user has no alert rule external write permission', () => {
+ mockPermissions([]);
+ const wrapper = getProviderWrapper();
+
+ const { result } = renderHook(() => useIsRuleEditable('cortex', mockRulerAlertingRule()), { wrapper });
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.isEditable).toBe(false);
+ expect(result.current.isRemovable).toBe(false);
+ });
+ });
+ });
+});
+
+function mockUseFolder(partial?: Partial) {
+ mocks.useFolder.mockReturnValue({ loading: false, folder: mockFolder(partial) });
+}
+
+function mockPermissions(grantedPermissions: AccessControlAction[]) {
+ jest
+ .spyOn(contextSrv, 'hasPermission')
+ .mockImplementation((action) => grantedPermissions.includes(action as AccessControlAction));
+}
+
+function getProviderWrapper() {
+ const dataSources = getMockedDataSources();
+ const store = mockStore({ dataSources });
+ const wrapper: React.FC = ({ children }) => {children} ;
+ return wrapper;
+}
+
+function getMockedDataSources(): StoreState['unifiedAlerting']['dataSources'] {
+ return {
+ grafana: {
+ loading: false,
+ dispatched: false,
+ result: {
+ id: 'grafana',
+ name: 'grafana',
+ rulerConfig: { dataSourceName: 'grafana', apiVersion: 'legacy' },
+ },
+ },
+ cortex: {
+ loading: false,
+ dispatched: false,
+ result: {
+ id: 'cortex',
+ name: 'Cortex',
+ rulerConfig: { dataSourceName: 'cortex', apiVersion: 'legacy' },
+ },
+ },
+ };
+}
+
+function mockStore(unifiedAlerting?: Partial) {
+ const defaultState = configureStore().getState();
+
+ return configureStore({
+ ...defaultState,
+ unifiedAlerting: {
+ ...defaultState.unifiedAlerting,
+ ...unifiedAlerting,
+ },
+ });
+}
diff --git a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts
index 97695319adf..5d42819b433 100644
--- a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts
+++ b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts
@@ -1,11 +1,13 @@
import { contextSrv } from 'app/core/services/context_srv';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
+import { getRulesPermissions } from '../utils/access-control';
import { isGrafanaRulerRule } from '../utils/rules';
import { useFolder } from './useFolder';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
interface ResultBag {
isEditable?: boolean;
+ isRemovable?: boolean;
loading: boolean;
}
@@ -13,10 +15,14 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO):
const dataSources = useUnifiedAlertingSelector((state) => state.dataSources);
const folderUID = rule && isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined;
+ const rulePermission = getRulesPermissions(rulesSourceName);
+ const hasEditPermission = contextSrv.hasAccess(rulePermission.update, contextSrv.isEditor);
+ const hasRemovePermission = contextSrv.hasAccess(rulePermission.delete, contextSrv.isEditor);
+
const { folder, loading } = useFolder(folderUID);
if (!rule) {
- return { isEditable: false, loading: false };
+ return { isEditable: false, isRemovable: false, loading: false };
}
// grafana rules can be edited if user can edit the folder they're in
@@ -27,14 +33,17 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO):
);
}
return {
- isEditable: folder?.canSave,
+ isEditable: hasEditPermission && folder?.canSave,
+ isRemovable: hasRemovePermission && folder?.canSave,
loading,
};
}
// prom rules are only editable by users with Editor role and only if rules source supports editing
+ const isRulerAvailable = Boolean(dataSources[rulesSourceName]?.result?.rulerConfig);
return {
- isEditable: contextSrv.isEditor && Boolean(dataSources[rulesSourceName]?.result?.rulerConfig),
+ isEditable: hasEditPermission && isRulerAvailable,
+ isRemovable: hasRemovePermission && isRulerAvailable,
loading: dataSources[rulesSourceName]?.loading,
};
}
diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts
index eca52ac0931..952037e6f44 100644
--- a/public/app/features/alerting/unified/mocks.ts
+++ b/public/app/features/alerting/unified/mocks.ts
@@ -29,6 +29,7 @@ import {
Silence,
SilenceState,
} from 'app/plugins/datasource/alertmanager/types';
+import { FolderDTO } from 'app/types';
let nextDataSourceId = 1;
@@ -449,3 +450,18 @@ export const mockCombinedRule = (partial?: Partial): CombinedRule
rulerRule: mockRulerAlertingRule(),
...partial,
});
+
+export const mockFolder = (partial?: Partial): FolderDTO => {
+ return {
+ id: 1,
+ uid: 'gdev-1',
+ title: 'Gdev',
+ version: 1,
+ url: '',
+ canAdmin: true,
+ canDelete: true,
+ canEdit: true,
+ canSave: true,
+ ...partial,
+ };
+};
diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx
index 9f61c323bf6..51d91021993 100644
--- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx
+++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx
@@ -256,7 +256,6 @@ export class PanelEditorUnconnected extends PureComponent {
lazy={false}
width={panelSize.width}
height={panelSize.height}
- skipStateCleanUp={true}
/>
diff --git a/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts b/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts
index a41ece9b4de..1e864f160ac 100644
--- a/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts
+++ b/public/app/features/dashboard/components/PanelEditor/state/actions.test.ts
@@ -1,7 +1,7 @@
import { thunkTester } from '../../../../../../test/core/thunk/thunkTester';
import { closeEditor, initialState, PanelEditorState } from './reducers';
import { exitPanelEditor, initPanelEditor, skipPanelUpdate } from './actions';
-import { cleanUpPanelState, panelModelAndPluginReady } from 'app/features/panel/state/reducers';
+import { panelModelAndPluginReady, removePanel } from 'app/features/panel/state/reducers';
import { DashboardModel, PanelModel } from '../../../state';
import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks';
@@ -48,6 +48,7 @@ describe('panelEditor actions', () => {
};
const dispatchedActions = await thunkTester({
+ panels: {},
panelEditor: state,
dashboard: {
getModel: () => dashboard,
@@ -57,7 +58,7 @@ describe('panelEditor actions', () => {
.whenThunkIsDispatched();
expect(dispatchedActions.length).toBe(2);
- expect(dispatchedActions[0].type).toBe(cleanUpPanelState.type);
+ expect(dispatchedActions[0].type).toBe(removePanel.type);
expect(dispatchedActions[1].type).toBe(closeEditor.type);
expect(sourcePanel.getOptions()).toEqual({ prop: true });
expect(sourcePanel.id).toEqual(12);
@@ -84,6 +85,7 @@ describe('panelEditor actions', () => {
const dispatchedActions = await thunkTester({
panelEditor: state,
+ panels: {},
dashboard: {
getModel: () => dashboard,
},
@@ -119,6 +121,7 @@ describe('panelEditor actions', () => {
const dispatchedActions = await thunkTester({
panelEditor: state,
+ panels: {},
dashboard: {
getModel: () => dashboard,
},
diff --git a/public/app/features/dashboard/components/PanelEditor/state/actions.ts b/public/app/features/dashboard/components/PanelEditor/state/actions.ts
index f604c29128f..7093dd47cdc 100644
--- a/public/app/features/dashboard/components/PanelEditor/state/actions.ts
+++ b/public/app/features/dashboard/components/PanelEditor/state/actions.ts
@@ -1,5 +1,8 @@
-import { DashboardModel, PanelModel } from '../../../state';
+import { pick } from 'lodash';
import { ThunkResult } from 'app/types';
+import store from 'app/core/store';
+import { panelModelAndPluginReady } from 'app/features/panel/state/reducers';
+import { cleanUpPanelState, initPanelState } from 'app/features/panel/state/actions';
import {
closeEditor,
PANEL_EDITOR_UI_STATE_STORAGE_KEY,
@@ -8,10 +11,7 @@ import {
setPanelEditorUIState,
updateEditorInitState,
} from './reducers';
-import { cleanUpPanelState, panelModelAndPluginReady } from 'app/features/panel/state/reducers';
-import store from 'app/core/store';
-import { pick } from 'lodash';
-import { initPanelState } from 'app/features/panel/state/actions';
+import { DashboardModel, PanelModel } from '../../../state';
export function initPanelEditor(sourcePanel: PanelModel, dashboard: DashboardModel): ThunkResult {
return async (dispatch) => {
@@ -63,9 +63,10 @@ export function updateDuplicateLibraryPanels(
panel.configRev++;
if (pluginChanged) {
+ const cleanUpKey = panel.key;
panel.generateNewKey();
- dispatch(panelModelAndPluginReady({ key: panel.key, plugin: panel.plugin! }));
+ dispatch(panelModelAndPluginReady({ key: panel.key, plugin: panel.plugin!, cleanUpKey }));
}
// Resend last query result on source panel query runner
@@ -125,9 +126,10 @@ export function exitPanelEditor(): ThunkResult {
if (panelTypeChanged) {
// Loaded plugin is not included in the persisted properties so is not handled by restoreModel
sourcePanel.plugin = panel.plugin;
+ const cleanUpKey = sourcePanel.key;
sourcePanel.generateNewKey();
- await dispatch(panelModelAndPluginReady({ key: sourcePanel.key, plugin: panel.plugin! }));
+ await dispatch(panelModelAndPluginReady({ key: sourcePanel.key, plugin: panel.plugin!, cleanUpKey }));
}
// Resend last query result on source panel query runner
@@ -138,7 +140,7 @@ export function exitPanelEditor(): ThunkResult {
}, 20);
}
- dispatch(cleanUpPanelState({ key: panel.key }));
+ dispatch(cleanUpPanelState(panel.key));
dispatch(closeEditor());
};
}
diff --git a/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts b/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts
index 5768728b784..0bab17d32f9 100644
--- a/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts
+++ b/public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts
@@ -2,6 +2,13 @@ import { getPanelEditorTabs } from './selectors';
import { PanelPlugin } from '@grafana/data';
import { PanelEditorTabId } from '../types';
import { updateConfig } from '../../../../../core/config';
+import { contextSrv } from 'app/core/services/context_srv';
+
+jest.mock('app/core/services/context_srv');
+
+const mocks = {
+ contextSrv: jest.mocked(contextSrv),
+};
describe('getPanelEditorTabs selector', () => {
it('return no tabs when no plugin provided', () => {
@@ -79,5 +86,37 @@ describe('getPanelEditorTabs selector', () => {
expect(tabs[1].id).toEqual(PanelEditorTabId.Transform);
});
});
+
+ describe('with unified alerting enabled', () => {
+ beforeAll(() => {
+ updateConfig({ unifiedAlertingEnabled: true });
+ });
+
+ it('shows the alert tab for users with read permission', () => {
+ mocks.contextSrv.hasPermission.mockReturnValue(true);
+
+ const tabs = getPanelEditorTabs(undefined, {
+ meta: {
+ id: 'graph',
+ },
+ } as PanelPlugin);
+
+ expect(tabs.length).toEqual(3);
+ expect(tabs[2].id).toEqual(PanelEditorTabId.Alert);
+ });
+
+ it('hides the alert tab for users with read permission', () => {
+ mocks.contextSrv.hasPermission.mockReturnValue(false);
+
+ const tabs = getPanelEditorTabs(undefined, {
+ meta: {
+ id: 'graph',
+ },
+ } as PanelPlugin);
+
+ expect(tabs.length).toEqual(2);
+ expect(tabs[1].id).toEqual(PanelEditorTabId.Transform);
+ });
+ });
});
});
diff --git a/public/app/features/dashboard/components/PanelEditor/state/selectors.ts b/public/app/features/dashboard/components/PanelEditor/state/selectors.ts
index 64ce72c531f..007945700da 100644
--- a/public/app/features/dashboard/components/PanelEditor/state/selectors.ts
+++ b/public/app/features/dashboard/components/PanelEditor/state/selectors.ts
@@ -2,6 +2,9 @@ import memoizeOne from 'memoize-one';
import { PanelPlugin } from '@grafana/data';
import { PanelEditorTab, PanelEditorTabId } from '../types';
import { getConfig } from 'app/core/config';
+import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control';
+import { contextSrv } from 'app/core/services/context_srv';
+import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
export const getPanelEditorTabs = memoizeOne((tab?: string, plugin?: PanelPlugin) => {
const tabs: PanelEditorTab[] = [];
@@ -34,10 +37,14 @@ export const getPanelEditorTabs = memoizeOne((tab?: string, plugin?: PanelPlugin
});
}
- if (
- ((getConfig().alertingEnabled || getConfig().unifiedAlertingEnabled) && plugin.meta.id === 'graph') ||
- plugin.meta.id === 'timeseries'
- ) {
+ const { alertingEnabled, unifiedAlertingEnabled } = getConfig();
+ const hasRuleReadPermissions = contextSrv.hasPermission(getRulesPermissions(GRAFANA_RULES_SOURCE_NAME).read);
+ const isAlertingAvailable = alertingEnabled || (unifiedAlertingEnabled && hasRuleReadPermissions);
+
+ const isGraph = plugin.meta.id === 'graph';
+ const isTimeseries = plugin.meta.id === 'timeseries';
+
+ if ((isAlertingAvailable && isGraph) || isTimeseries) {
tabs.push({
id: PanelEditorTabId.Alert,
text: 'Alert',
diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx
index 860e645f6bb..c73c0c6fb16 100644
--- a/public/app/features/dashboard/containers/DashboardPage.tsx
+++ b/public/app/features/dashboard/containers/DashboardPage.tsx
@@ -4,7 +4,6 @@ import { connect, ConnectedProps } from 'react-redux';
import { locationService } from '@grafana/runtime';
import { selectors } from '@grafana/e2e-selectors';
import { CustomScrollbar, stylesFactory, Themeable2, withTheme2 } from '@grafana/ui';
-
import { createErrorNotification } from 'app/core/copy/appNotification';
import { Branding } from 'app/core/components/Branding/Branding';
import { DashboardGrid } from '../dashgrid/DashboardGrid';
@@ -78,6 +77,7 @@ export interface State {
showLoadingState: boolean;
panelNotFound: boolean;
editPanelAccessDenied: boolean;
+ scrollElement?: HTMLDivElement;
}
export class UnthemedDashboardPage extends PureComponent {
@@ -224,14 +224,14 @@ export class UnthemedDashboardPage extends PureComponent {
}
if (dashboard.canEditPanel(panel)) {
- return { ...state, editPanel: panel };
+ return { ...state, editPanel: panel, rememberScrollTop: state.scrollElement?.scrollTop };
} else {
return { ...state, editPanelAccessDenied: true };
}
}
// Leaving edit mode
else if (state.editPanel && !urlEditPanelId) {
- return { ...state, editPanel: null };
+ return { ...state, editPanel: null, updateScrollTop: state.rememberScrollTop };
}
// Entering view mode
@@ -245,11 +245,7 @@ export class UnthemedDashboardPage extends PureComponent {
// Should move this state out of dashboard in the future
dashboard.initViewPanel(panel);
- return {
- ...state,
- viewPanel: panel,
- updateScrollTop: 0,
- };
+ return { ...state, viewPanel: panel, rememberScrollTop: state.scrollElement?.scrollTop, updateScrollTop: 0 };
}
// Leaving view mode
else if (state.viewPanel && !urlViewPanelId) {
@@ -290,6 +286,10 @@ export class UnthemedDashboardPage extends PureComponent {
this.setState({ updateScrollTop: 0 });
};
+ setScrollRef = (scrollElement: HTMLDivElement): void => {
+ this.setState({ scrollElement });
+ };
+
getInspectPanel() {
const { dashboard, queryParams } = this.props;
@@ -346,6 +346,7 @@ export class UnthemedDashboardPage extends PureComponent {
{
@@ -67,6 +67,7 @@ function dashboardGridScenario(description: string, scenarioFn: (ctx: ScenarioCo
editPanel: null,
viewPanel: null,
dashboard: getTestDashboard(),
+ cleanAndRemoveMany: jest.fn,
},
setProps: (props: Partial) => {
Object.assign(ctx.props, props);
diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx
index 34a5eeb6926..29d6578dc9a 100644
--- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx
+++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx
@@ -13,11 +13,13 @@ import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core
import { DashboardPanel } from './DashboardPanel';
import { DashboardModel, PanelModel } from '../state';
import { Subscription } from 'rxjs';
+import { connect, ConnectedProps } from 'react-redux';
import { DashboardPanelsChangedEvent } from 'app/types/events';
+import { cleanAndRemoveMany } from 'app/features/panel/state/actions';
import { GridPos } from '../state/PanelModel';
import { config } from '@grafana/runtime';
-export interface Props {
+export interface OwnProps {
dashboard: DashboardModel;
editPanel: PanelModel | null;
viewPanel: PanelModel | null;
@@ -27,7 +29,15 @@ export interface State {
isLayoutInitialized: boolean;
}
-export class DashboardGrid extends PureComponent {
+const mapDispatchToProps = {
+ cleanAndRemoveMany,
+};
+
+const connector = connect(null, mapDispatchToProps);
+
+export type Props = OwnProps & ConnectedProps;
+
+export class DashboardGridUnconnected extends PureComponent {
private panelMap: { [key: string]: PanelModel } = {};
private eventSubs = new Subscription();
private windowHeight = 1200;
@@ -51,6 +61,7 @@ export class DashboardGrid extends PureComponent {
componentWillUnmount() {
this.eventSubs.unsubscribe();
+ this.props.cleanAndRemoveMany(Object.keys(this.panelMap));
}
buildLayout() {
@@ -315,3 +326,5 @@ function translateGridHeightToScreenHeight(gridHeight: number): number {
}
GrafanaGridItem.displayName = 'GridItemWithDimensions';
+
+export const DashboardGrid = connector(DashboardGridUnconnected);
diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
index 668ed6e8d42..0401ecf9397 100644
--- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
+++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
@@ -5,7 +5,7 @@ import { PanelChromeAngular } from './PanelChromeAngular';
import { DashboardModel, PanelModel } from '../state';
import { StoreState } from 'app/types';
import { PanelPlugin } from '@grafana/data';
-import { cleanUpPanelState, setPanelInstanceState } from '../../panel/state/reducers';
+import { setPanelInstanceState } from '../../panel/state/reducers';
import { initPanelState } from '../../panel/state/actions';
import { LazyLoader } from './LazyLoader';
@@ -17,7 +17,6 @@ export interface OwnProps {
isViewing: boolean;
width: number;
height: number;
- skipStateCleanUp?: boolean;
lazy?: boolean;
}
@@ -35,7 +34,6 @@ const mapStateToProps = (state: StoreState, props: OwnProps) => {
const mapDispatchToProps = {
initPanelState,
- cleanUpPanelState,
setPanelInstanceState,
};
@@ -48,8 +46,6 @@ export class DashboardPanelUnconnected extends PureComponent {
lazy: true,
};
- specialPanels: { [key: string]: Function } = {};
-
componentDidMount() {
this.props.panel.isInView = !this.props.lazy;
if (!this.props.plugin) {
@@ -57,13 +53,6 @@ export class DashboardPanelUnconnected extends PureComponent {
}
}
- componentWillUnmount() {
- // Most of the time an unmount should result in cleanup but in PanelEdit it should not
- if (!this.props.skipStateCleanUp) {
- this.props.cleanUpPanelState({ key: this.props.stateKey });
- }
- }
-
onInstanceStateChange = (value: any) => {
this.props.setPanelInstanceState({ key: this.props.stateKey, value });
};
diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts
index 04776c6cbdc..874127c04d0 100644
--- a/public/app/features/dashboard/state/DashboardModel.ts
+++ b/public/app/features/dashboard/state/DashboardModel.ts
@@ -171,6 +171,7 @@ export class DashboardModel implements TimeModel {
this.links = data.links ?? [];
this.gnetId = data.gnetId || null;
this.panels = map(data.panels ?? [], (panelData: any) => new PanelModel(panelData));
+ this.ensurePanelsHaveIds();
this.formatDate = this.formatDate.bind(this);
this.resetOriginalVariables(true);
@@ -452,6 +453,22 @@ export class DashboardModel implements TimeModel {
this.panelsAffectedByVariableChange = null;
}
+ private ensurePanelsHaveIds() {
+ for (const panel of this.panels) {
+ if (!panel.id) {
+ panel.id = this.getNextPanelId();
+ }
+
+ if (panel.panels) {
+ for (const rowPanel of panel.panels) {
+ if (!rowPanel.id) {
+ rowPanel.id = this.getNextPanelId();
+ }
+ }
+ }
+ }
+ }
+
private ensureListExist(data: any) {
if (!data) {
data = {};
diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts
index eef4165468d..654bf8cdab1 100644
--- a/public/app/features/dashboard/state/initDashboard.ts
+++ b/public/app/features/dashboard/state/initDashboard.ts
@@ -19,6 +19,7 @@ import { config, locationService } from '@grafana/runtime';
import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner';
import { getIfExistsLastKey } from '../../variables/state/selectors';
import { toStateKey } from 'app/features/variables/utils';
+import store from 'app/core/store';
export interface InitDashboardArgs {
urlUid?: string;
@@ -34,6 +35,13 @@ async function fetchDashboard(
dispatch: ThunkDispatch,
getState: () => StoreState
): Promise {
+ // When creating new or adding panels to a dashboard from explore we load it from local storage
+ const model = store.getObject(DASHBOARD_FROM_LS_KEY);
+ if (model) {
+ removeDashboardToFetchFromLocalStorage();
+ return model;
+ }
+
try {
switch (args.routeName) {
case DashboardRoutes.Home: {
@@ -200,7 +208,7 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult {
};
}
-function getNewDashboardModelData(urlFolderId?: string | null): any {
+export function getNewDashboardModelData(urlFolderId?: string | null): any {
const data = {
meta: {
canStar: false,
@@ -226,3 +234,13 @@ function getNewDashboardModelData(urlFolderId?: string | null): any {
return data;
}
+
+const DASHBOARD_FROM_LS_KEY = 'DASHBOARD_FROM_LS_KEY';
+
+export function setDashboardToFetchFromLocalStorage(model: DashboardDTO) {
+ store.setObject(DASHBOARD_FROM_LS_KEY, model);
+}
+
+export function removeDashboardToFetchFromLocalStorage() {
+ store.delete(DASHBOARD_FROM_LS_KEY);
+}
diff --git a/public/app/features/dashboard/utils/panel.ts b/public/app/features/dashboard/utils/panel.ts
index 2c68b00cd5b..d92497a43f0 100644
--- a/public/app/features/dashboard/utils/panel.ts
+++ b/public/app/features/dashboard/utils/panel.ts
@@ -1,5 +1,6 @@
// Store
import store from 'app/core/store';
+import { dispatch } from 'app/store/store';
// Models
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
@@ -21,6 +22,7 @@ import { ShareModal } from 'app/features/dashboard/components/ShareModal';
import { ShowConfirmModalEvent, ShowModalReactEvent } from '../../../types/events';
import { AddLibraryPanelModal } from 'app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal';
import { UnlinkModal } from 'app/features/library-panels/components/UnlinkModal/UnlinkModal';
+import { cleanUpPanelState } from 'app/features/panel/state/actions';
export const removePanel = (dashboard: DashboardModel, panel: PanelModel, ask: boolean) => {
// confirm deletion
@@ -46,6 +48,7 @@ export const removePanel = (dashboard: DashboardModel, panel: PanelModel, ask: b
}
dashboard.removePanel(panel);
+ dispatch(cleanUpPanelState(panel.key));
};
export const duplicatePanel = (dashboard: DashboardModel, panel: PanelModel) => {
diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts
index b2dcdadee76..54fa40cc03d 100644
--- a/public/app/features/datasources/state/actions.ts
+++ b/public/app/features/datasources/state/actions.ts
@@ -24,6 +24,7 @@ import {
} from './reducers';
import { getDataSource, getDataSourceMeta } from './selectors';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
+import { contextSrv } from '../../../core/services/context_srv';
export interface DataSourceTypesLoadedPayload {
plugins: DataSourcePluginMeta[];
@@ -217,6 +218,9 @@ export function addDataSource(plugin: DataSourcePluginMeta): ThunkResult {
const result = await getBackendSrv().post('/api/datasources', newInstance);
await getDatasourceSrv().reload();
+
+ await contextSrv.fetchUserPermissions();
+
locationService.push(`/datasources/edit/${result.datasource.uid}`);
};
}
diff --git a/public/app/features/explore/AddToDashboard/AddToDashboardModal.test.tsx b/public/app/features/explore/AddToDashboard/AddToDashboardModal.test.tsx
deleted file mode 100644
index 5cc59ef43e6..00000000000
--- a/public/app/features/explore/AddToDashboard/AddToDashboardModal.test.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import React from 'react';
-import { act, render, screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { AddToDashboardModal } from './AddToDashboardModal';
-import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
-import * as dashboardApi from 'app/features/manage-dashboards/state/actions';
-
-const createFolder = (title: string, id: number): DashboardSearchHit => ({
- title,
- id,
- isStarred: false,
- type: DashboardSearchItemType.DashFolder,
- items: [],
- url: '',
- uri: '',
- tags: [],
-});
-
-describe('Add to Dashboard Modal', () => {
- const searchFoldersResponse = Promise.resolve([createFolder('Folder 1', 1), createFolder('Folder 2', 2)]);
-
- const waitForSearchFolderResponse = async () => {
- return act(async () => {
- // FolderPicker asynchronously sets its internal state based on search results, causing warnings when testing.
- // Given we are not aware of the component implementation to wait on certain element to appear or disappear (for example a loading indicator),
- // we wait for the mocked promise we know it internally uses.
- // This is less than ideal as we are relying on implementation details, but is a reasonable solution for this test's scope
- await searchFoldersResponse;
- });
- };
-
- beforeEach(() => {
- jest.spyOn(dashboardApi, 'searchFolders').mockReturnValue(searchFoldersResponse);
- });
-
- afterEach(() => {
- jest.restoreAllMocks();
- });
-
- describe('Save to new dashboard', () => {
- it('Does not submit if the form is invalid', async () => {
- const saveMock = jest.fn();
-
- render( {}} />);
-
- // there shouldn't be any alert in the modal
- expect(screen.queryByRole('alert')).not.toBeInTheDocument();
-
- const dashboardNameInput = screen.getByRole('textbox', { name: /dashboard name/i });
-
- // dashboard name is required
- userEvent.clear(dashboardNameInput);
-
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
-
- // The error message should appear
- await screen.findByRole('alert');
-
- // Create dashboard API is not invoked
- expect(saveMock).not.toHaveBeenCalled();
- });
-
- it('Correctly submits if the form is valid', async () => {
- const saveMock = jest.fn();
-
- render( {}} />);
- await waitForSearchFolderResponse();
-
- const dashboardNameInput = screen.getByRole('textbox', { name: /dashboard name/i });
-
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
-
- await waitFor(() => {
- expect(screen.getByRole('button', { name: /save and keep exploring/i })).toBeEnabled();
- });
-
- expect(saveMock).toHaveBeenCalledWith(
- {
- dashboardName: dashboardNameInput.value,
- folderId: 1,
- },
- expect.anything()
- );
- });
- });
-
- describe('Handling API errors', () => {
- it('Correctly handles name-exist API Error', async () => {
- // name-exists is triggered when trying to create a dashboard in a folder that already has a dashboard with the same name
- const saveMock = jest.fn().mockResolvedValue({ status: 'name-exists', message: 'name exists' });
-
- render( {}} />);
-
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
-
- expect(await screen.findByRole('alert')).toHaveTextContent(
- 'A dashboard with the same name already exists in this folder.'
- );
- });
-
- it('Correctly handles empty name API Error', async () => {
- // empty-name is triggered when trying to create a dashboard having an empty name.
- // FE validation usually avoids this use case, but can be triggered by using only whitespaces in
- // dashboard name field
- const saveMock = jest.fn().mockResolvedValue({ status: 'empty-name', message: 'empty name' });
-
- render( {}} />);
-
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
-
- expect(await screen.findByRole('alert')).toHaveTextContent('Dashboard name is required.');
- });
-
- it('Correctly handles name match API Error', async () => {
- // name-match, triggered when trying to create a dashboard in a folder that has the same name.
- // it doesn't seem to ever be triggered, but matches the error in
- // https://github.com/grafana/grafana/blob/44f1e381cbc7a5e236b543bc6bd06b00e3152d7f/pkg/models/dashboards.go#L71
- const saveMock = jest.fn().mockResolvedValue({ status: 'name-match', message: 'name match' });
-
- render( {}} />);
-
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
-
- expect(await screen.findByRole('alert')).toHaveTextContent('name match');
- });
-
- it('Correctly handles unknown API Errors', async () => {
- const saveMock = jest.fn().mockResolvedValue({ status: 'unknown-error', message: 'unknown error' });
-
- render( {}} />);
-
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
-
- expect(await screen.findByRole('alert')).toHaveTextContent('unknown error');
- });
- });
-});
diff --git a/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx b/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx
index 4ef879a9829..161f6b57443 100644
--- a/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx
+++ b/public/app/features/explore/AddToDashboard/AddToDashboardModal.tsx
@@ -1,136 +1,201 @@
-import React, { useState } from 'react';
-import { Alert, Button, Field, Input, InputControl, Modal } from '@grafana/ui';
-import { FolderPicker } from 'app/core/components/Select/FolderPicker';
-import { useForm } from 'react-hook-form';
-import { SaveToNewDashboardDTO } from './addToDashboard';
+import React, { useEffect, useState } from 'react';
+import { Alert, Button, Field, InputControl, Modal, RadioButtonGroup } from '@grafana/ui';
+import { locationUtil, SelectableValue } from '@grafana/data';
+import { setDashboardInLocalStorage, AddToDashboardError } from './addToDashboard';
+import { useSelector } from 'react-redux';
+import { ExploreId } from 'app/types';
+import { DashboardPicker } from 'app/core/components/Select/DashboardPicker';
+import { DeepMap, FieldError, useForm } from 'react-hook-form';
+import { config, locationService, reportInteraction } from '@grafana/runtime';
+import { getExploreItemSelector } from '../state/selectors';
+import { partial } from 'lodash';
+import { removeDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard';
-export interface ErrorResponse {
- status: string;
- message?: string;
+enum SaveTarget {
+ NewDashboard = 'new-dashboard',
+ ExistingDashboard = 'existing-dashboard',
}
-const ERRORS = {
- NAME_REQUIRED: 'Dashboard name is required.',
- NAME_EXISTS: 'A dashboard with the same name already exists in this folder.',
- INVALID_FIELD: 'This field is invalid.',
- UNKNOWN_ERROR: 'An unknown error occurred while saving the dashboard. Please try again.',
- INVALID_FOLDER: 'Select a valid folder to save your dashboard in.',
-};
+const SAVE_TARGETS: Array> = [
+ {
+ label: 'New Dashboard',
+ value: SaveTarget.NewDashboard,
+ },
+ {
+ label: 'Existing Dashboard',
+ value: SaveTarget.ExistingDashboard,
+ },
+];
-type FormDTO = SaveToNewDashboardDTO;
+interface SaveTargetDTO {
+ saveTarget: SaveTarget;
+}
+interface SaveToNewDashboardDTO extends SaveTargetDTO {
+ saveTarget: SaveTarget.NewDashboard;
+}
+
+interface SaveToExistingDashboard extends SaveTargetDTO {
+ saveTarget: SaveTarget.ExistingDashboard;
+ dashboardUid: string;
+}
+
+type FormDTO = SaveToNewDashboardDTO | SaveToExistingDashboard;
+
+function assertIsSaveToExistingDashboardError(
+ errors: DeepMap
+): asserts errors is DeepMap {
+ // the shape of the errors object is always compatible with the type above, but we need to
+ // explicitly assert its type so that TS can narrow down FormDTO to SaveToExistingDashboard
+ // when we use it in the form.
+}
+
+function getDashboardURL(dashboardUid?: string) {
+ return dashboardUid ? `d/${dashboardUid}` : 'dashboard/new';
+}
+
+enum GenericError {
+ UNKNOWN = 'unknown-error',
+ NAVIGATION = 'navigation-error',
+}
+
+interface SubmissionError {
+ error: AddToDashboardError | GenericError;
+ message: string;
+}
interface Props {
onClose: () => void;
- onSave: (data: FormDTO, redirect: boolean) => Promise;
+ exploreId: ExploreId;
}
-function withRedirect(fn: (redirect: boolean, ...args: T) => {}, redirect: boolean) {
- return async (...args: T) => fn(redirect, ...args);
-}
-
-export const AddToDashboardModal = ({ onClose, onSave }: Props) => {
- const [submissionError, setSubmissionError] = useState();
+export const AddToDashboardModal = ({ onClose, exploreId }: Props) => {
+ const exploreItem = useSelector(getExploreItemSelector(exploreId))!;
+ const [submissionError, setSubmissionError] = useState();
const {
- register,
handleSubmit,
control,
- formState: { errors, isSubmitting },
- setError,
- } = useForm();
+ formState: { errors },
+ watch,
+ } = useForm({
+ defaultValues: { saveTarget: SaveTarget.NewDashboard },
+ });
+ const saveTarget = watch('saveTarget');
- const onSubmit = async (withRedirect: boolean, data: FormDTO) => {
+ const onSubmit = async (openInNewTab: boolean, data: FormDTO) => {
setSubmissionError(undefined);
- const error = await onSave(data, withRedirect);
+ const dashboardUid = data.saveTarget === SaveTarget.ExistingDashboard ? data.dashboardUid : undefined;
- if (error) {
- switch (error.status) {
- case 'name-match':
- // error.message should always be defined here
- setError('dashboardName', { message: error.message ?? ERRORS.INVALID_FIELD });
+ reportInteraction('e2d_submit', {
+ newTab: openInNewTab,
+ saveTarget: data.saveTarget,
+ queries: exploreItem.queries.length,
+ });
+
+ try {
+ await setDashboardInLocalStorage({
+ dashboardUid,
+ datasource: exploreItem.datasourceInstance?.getRef(),
+ queries: exploreItem.queries,
+ queryResponse: exploreItem.queryResponse,
+ });
+ } catch (error) {
+ switch (error) {
+ case AddToDashboardError.FETCH_DASHBOARD:
+ setSubmissionError({ error, message: 'Could not fetch dashboard information. Please try again.' });
break;
- case 'empty-name':
- setError('dashboardName', { message: ERRORS.NAME_REQUIRED });
- break;
- case 'name-exists':
- setError('dashboardName', { message: ERRORS.NAME_EXISTS });
+ case AddToDashboardError.SET_DASHBOARD_LS:
+ setSubmissionError({ error, message: 'Could not add panel to dashboard. Please try again.' });
break;
default:
- setSubmissionError(error.message ?? ERRORS.UNKNOWN_ERROR);
+ setSubmissionError({ error: GenericError.UNKNOWN, message: 'Something went wrong. Please try again.' });
}
+ return;
}
+
+ const dashboardURL = getDashboardURL(dashboardUid);
+ if (!openInNewTab) {
+ onClose();
+ locationService.push(locationUtil.stripBaseFromUrl(dashboardURL));
+ return;
+ }
+
+ const didTabOpen = !!global.open(config.appUrl + dashboardURL, '_blank');
+ if (!didTabOpen) {
+ setSubmissionError({
+ error: GenericError.NAVIGATION,
+ message: 'Could not navigate to the selected dashboard. Please try again.',
+ });
+ removeDashboardToFetchFromLocalStorage();
+ return;
+ }
+ onClose();
};
+ useEffect(() => {
+ reportInteraction('e2d_open');
+ }, []);
+
return (
diff --git a/public/app/features/explore/AddToDashboard/addToDashboard.test.ts b/public/app/features/explore/AddToDashboard/addToDashboard.test.ts
new file mode 100644
index 00000000000..b81c7c9ca48
--- /dev/null
+++ b/public/app/features/explore/AddToDashboard/addToDashboard.test.ts
@@ -0,0 +1,146 @@
+import { DataQuery, MutableDataFrame } from '@grafana/data';
+import { ExplorePanelData } from 'app/types';
+import { createEmptyQueryResponse } from '../state/utils';
+import { setDashboardInLocalStorage } from './addToDashboard';
+import * as api from 'app/features/dashboard/state/initDashboard';
+import { backendSrv } from 'app/core/services/backend_srv';
+
+describe('addPanelToDashboard', () => {
+ let spy: jest.SpyInstance;
+ beforeAll(() => {
+ spy = jest.spyOn(api, 'setDashboardToFetchFromLocalStorage');
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('Correct datasource ref is used', async () => {
+ await setDashboardInLocalStorage({
+ queries: [],
+ queryResponse: createEmptyQueryResponse(),
+ datasource: { type: 'loki', uid: 'someUid' },
+ });
+ expect(spy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dashboard: expect.objectContaining({
+ panels: expect.arrayContaining([expect.objectContaining({ datasource: { type: 'loki', uid: 'someUid' } })]),
+ }),
+ })
+ );
+ });
+
+ it('All queries are correctly passed through', async () => {
+ const queries: DataQuery[] = [{ refId: 'A' }, { refId: 'B', hide: true }];
+
+ await setDashboardInLocalStorage({
+ queries,
+ queryResponse: createEmptyQueryResponse(),
+ });
+ expect(spy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dashboard: expect.objectContaining({
+ panels: expect.arrayContaining([expect.objectContaining({ targets: expect.arrayContaining(queries) })]),
+ }),
+ })
+ );
+ });
+
+ it('Previous panels should not be removed', async () => {
+ const queries: DataQuery[] = [{ refId: 'A' }];
+ const existingPanel = { prop: 'this should be kept' };
+ jest.spyOn(backendSrv, 'getDashboardByUid').mockResolvedValue({
+ dashboard: {
+ templating: { list: [] },
+ title: 'Previous panels should not be removed',
+ uid: 'someUid',
+ panels: [existingPanel],
+ },
+ meta: {},
+ });
+
+ await setDashboardInLocalStorage({
+ queries,
+ queryResponse: createEmptyQueryResponse(),
+ dashboardUid: 'someUid',
+ datasource: { type: '' },
+ });
+
+ expect(spy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dashboard: expect.objectContaining({
+ panels: expect.arrayContaining([
+ expect.objectContaining({ targets: expect.arrayContaining(queries) }),
+ existingPanel,
+ ]),
+ }),
+ })
+ );
+ });
+
+ describe('Setting visualization type', () => {
+ describe('Defaults to table', () => {
+ const cases: Array<[string, DataQuery[], ExplorePanelData]> = [
+ ['If response is empty', [{ refId: 'A' }], createEmptyQueryResponse()],
+ ['If no query is active', [{ refId: 'A', hide: true }], createEmptyQueryResponse()],
+ [
+ 'If no query is active, even when there is a response from a previous execution',
+ [{ refId: 'A', hide: true }],
+ { ...createEmptyQueryResponse(), logsFrames: [new MutableDataFrame({ refId: 'A', fields: [] })] },
+ ],
+ [
+ // trace view is not supported in dashboards, we expect to fallback to table panel
+ 'If there are trace frames',
+ [{ refId: 'A' }],
+ { ...createEmptyQueryResponse(), traceFrames: [new MutableDataFrame({ refId: 'A', fields: [] })] },
+ ],
+ ];
+
+ it.each(cases)('%s', async (_, queries, queryResponse) => {
+ await setDashboardInLocalStorage({ queries, queryResponse });
+ expect(spy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dashboard: expect.objectContaining({
+ panels: expect.arrayContaining([expect.objectContaining({ type: 'table' })]),
+ }),
+ })
+ );
+ });
+ });
+
+ describe('Correctly set visualization based on response', () => {
+ type TestArgs = {
+ framesType: string;
+ expectedPanel: string;
+ };
+ // Note: traceFrames test is "duplicated" in "Defaults to table" tests.
+ // This is intentional as a way to enforce explicit tests for that case whenever in the future we'll
+ // add support for creating traceview panels
+ it.each`
+ framesType | expectedPanel
+ ${'logsFrames'} | ${'logs'}
+ ${'graphFrames'} | ${'timeseries'}
+ ${'nodeGraphFrames'} | ${'nodeGraph'}
+ ${'traceFrames'} | ${'table'}
+ `(
+ 'Sets visualization to $expectedPanel if there are $frameType frames',
+ async ({ framesType, expectedPanel }: TestArgs) => {
+ const queries = [{ refId: 'A' }];
+ const queryResponse: ExplorePanelData = {
+ ...createEmptyQueryResponse(),
+ [framesType]: [new MutableDataFrame({ refId: 'A', fields: [] })],
+ };
+
+ await setDashboardInLocalStorage({ queries, queryResponse });
+ expect(spy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dashboard: expect.objectContaining({
+ panels: expect.arrayContaining([expect.objectContaining({ type: expectedPanel })]),
+ }),
+ })
+ );
+ }
+ );
+ });
+ });
+});
diff --git a/public/app/features/explore/AddToDashboard/addToDashboard.ts b/public/app/features/explore/AddToDashboard/addToDashboard.ts
index e27e041cf7d..6128f221de8 100644
--- a/public/app/features/explore/AddToDashboard/addToDashboard.ts
+++ b/public/app/features/explore/AddToDashboard/addToDashboard.ts
@@ -1,37 +1,81 @@
-import { DataQuery, DataSourceRef } from '@grafana/data';
-import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
+import { DataFrame, DataQuery, DataSourceRef } from '@grafana/data';
+import { backendSrv } from 'app/core/services/backend_srv';
+import {
+ getNewDashboardModelData,
+ setDashboardToFetchFromLocalStorage,
+} from 'app/features/dashboard/state/initDashboard';
+import { DashboardDTO, ExplorePanelData } from 'app/types';
-export interface SaveToNewDashboardDTO {
- dashboardName: string;
- folderId: number;
+export enum AddToDashboardError {
+ FETCH_DASHBOARD = 'fetch-dashboard',
+ SET_DASHBOARD_LS = 'set-dashboard-ls-error',
}
-interface SaveOptions {
+
+interface AddPanelToDashboardOptions {
queries: DataQuery[];
- panel: string;
- datasource: DataSourceRef;
+ queryResponse: ExplorePanelData;
+ datasource?: DataSourceRef;
+ dashboardUid?: string;
}
-const createDashboard = (
- dashboardName: string,
- folderId: number,
- queries: DataQuery[],
- datasource: DataSourceRef,
- panel: string
-) => {
- const dashboard = getDashboardSrv().create({ title: dashboardName }, { folderId });
+function createDashboard(): DashboardDTO {
+ const dto = getNewDashboardModelData();
- dashboard.addPanel({ targets: queries, type: panel, title: 'New Panel', datasource });
+ // getNewDashboardModelData adds by default the "add-panel" panel. We don't want that.
+ dto.dashboard.panels = [];
- return getDashboardSrv().saveDashboard({ dashboard, folderId }, { showErrorAlert: false, showSuccessAlert: false });
-};
+ return dto;
+}
-export const addToDashboard = async (data: SaveToNewDashboardDTO, options: SaveOptions): Promise => {
- const res = await createDashboard(
- data.dashboardName,
- data.folderId,
- options.queries,
- options.datasource,
- options.panel
- );
- return res.data.url;
-};
+export async function setDashboardInLocalStorage(options: AddPanelToDashboardOptions) {
+ const panelType = getPanelType(options.queries, options.queryResponse);
+ const panel = {
+ targets: options.queries,
+ type: panelType,
+ title: 'New Panel',
+ gridPos: { x: 0, y: 0, w: 12, h: 8 },
+ datasource: options.datasource,
+ };
+
+ let dto: DashboardDTO;
+
+ if (options.dashboardUid) {
+ try {
+ dto = await backendSrv.getDashboardByUid(options.dashboardUid);
+ } catch (e) {
+ throw AddToDashboardError.FETCH_DASHBOARD;
+ }
+ } else {
+ dto = createDashboard();
+ }
+
+ dto.dashboard.panels = [panel, ...(dto.dashboard.panels ?? [])];
+
+ try {
+ setDashboardToFetchFromLocalStorage(dto);
+ } catch {
+ throw AddToDashboardError.SET_DASHBOARD_LS;
+ }
+}
+
+const isVisible = (query: DataQuery) => !query.hide;
+const hasRefId = (refId: DataFrame['refId']) => (frame: DataFrame) => frame.refId === refId;
+
+function getPanelType(queries: DataQuery[], queryResponse: ExplorePanelData) {
+ for (const { refId } of queries.filter(isVisible)) {
+ // traceview is not supported in dashboards, skipping it for now.
+ const hasQueryRefId = hasRefId(refId);
+ if (queryResponse.graphFrames.some(hasQueryRefId)) {
+ return 'timeseries';
+ }
+ if (queryResponse.logsFrames.some(hasQueryRefId)) {
+ return 'logs';
+ }
+ if (queryResponse.nodeGraphFrames.some(hasQueryRefId)) {
+ return 'nodeGraph';
+ }
+ }
+
+ // falling back to table
+ return 'table';
+}
diff --git a/public/app/features/explore/AddToDashboard/index.test.tsx b/public/app/features/explore/AddToDashboard/index.test.tsx
index 415980c1249..62db02ba6b4 100644
--- a/public/app/features/explore/AddToDashboard/index.test.tsx
+++ b/public/app/features/explore/AddToDashboard/index.test.tsx
@@ -1,31 +1,25 @@
-import React from 'react';
-import { act, render, screen, waitForElementToBeRemoved } from '@testing-library/react';
-import { ExploreId, ExplorePanelData, ExploreState } from 'app/types';
-import { Provider } from 'react-redux';
-import { configureStore } from 'app/store/configureStore';
+import React, { ReactNode } from 'react';
+import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { DataQuery, MutableDataFrame } from '@grafana/data';
-import { createEmptyQueryResponse } from '../state/utils';
-import { locationService } from '@grafana/runtime';
-import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
-import * as api from './addToDashboard';
-import * as dashboardApi from 'app/features/manage-dashboards/state/actions';
+import { configureStore } from 'app/store/configureStore';
+import { ExploreId, ExploreState } from 'app/types';
+import { Provider } from 'react-redux';
import { AddToDashboard } from '.';
+import * as api from './addToDashboard';
+import { locationService, setEchoSrv } from '@grafana/runtime';
+import * as initDashboard from 'app/features/dashboard/state/initDashboard';
+import { DataQuery } from '@grafana/data';
+import { createEmptyQueryResponse } from '../state/utils';
+import { backendSrv } from 'app/core/services/backend_srv';
+import { DashboardSearchItemType } from 'app/features/search/types';
+import { Echo } from 'app/core/services/echo/Echo';
-const setup = (
- children: JSX.Element,
- queries: DataQuery[] = [],
- queryResponse: ExplorePanelData = createEmptyQueryResponse()
-) => {
+const setup = (children: ReactNode, queries: DataQuery[] = [{ refId: 'A' }]) => {
const store = configureStore({
explore: {
left: {
queries,
- queryResponse,
- datasourceInstance: {
- type: 'loki',
- uid: 'someuid',
- },
+ queryResponse: createEmptyQueryResponse(),
},
} as ExploreState,
});
@@ -33,269 +27,336 @@ const setup = (
return render({children} );
};
-const createFolder = (title: string, id: number): DashboardSearchHit => ({
- title,
- id,
- isStarred: false,
- type: DashboardSearchItemType.DashFolder,
- items: [],
- url: '',
- uri: '',
- tags: [],
-});
-
const openModal = async () => {
userEvent.click(screen.getByRole('button', { name: /add to dashboard/i }));
expect(await screen.findByRole('dialog', { name: 'Add panel to dashboard' })).toBeInTheDocument();
};
-describe('Add to Dashboard Button', () => {
- const searchFoldersResponse = Promise.resolve([createFolder('Folder 1', 1), createFolder('Folder 2', 2)]);
- const redirectURL = '/some/redirect/url';
- let addToDashboardMock: jest.SpyInstance<
- ReturnType,
- Parameters
- >;
-
- const waitForSearchFolderResponse = async () => {
- return act(async () => {
- await searchFoldersResponse;
- });
- };
-
- beforeEach(() => {
- jest.spyOn(dashboardApi, 'searchFolders').mockReturnValue(searchFoldersResponse);
- addToDashboardMock = jest.spyOn(api, 'addToDashboard').mockResolvedValue('/some/redirect/url');
+describe('AddToDashboardButton', () => {
+ beforeAll(() => {
+ setEchoSrv(new Echo());
});
- afterEach(() => {
- jest.restoreAllMocks();
- });
+ it('Is disabled if explore pane has no queries', async () => {
+ setup( , []);
- it('Opens and closes the modal correctly', async () => {
- setup( , [{ refId: 'A' }]);
+ const button = await screen.findByRole('button', { name: /add to dashboard/i });
+ expect(button).toBeDisabled();
- await openModal();
-
- userEvent.click(screen.getByRole('button', { name: /cancel/i }));
+ userEvent.click(button);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
- describe('navigation', () => {
- it('Navigates to dashboard when clicking on "Save and go to dashboard"', async () => {
- locationService.push = jest.fn();
+ describe('Success path', () => {
+ const addToDashboardResponse = Promise.resolve();
- setup( , [{ refId: 'A' }]);
+ const waitForAddToDashboardResponse = async () => {
+ return act(async () => {
+ await addToDashboardResponse;
+ });
+ };
- await openModal();
-
- userEvent.click(screen.getByRole('button', { name: /save and go to dashboard/i }));
-
- await waitForSearchFolderResponse();
-
- expect(locationService.push).toHaveBeenCalledWith(redirectURL);
+ beforeEach(() => {
+ jest.spyOn(api, 'setDashboardInLocalStorage').mockReturnValue(addToDashboardResponse);
});
- it('Does NOT navigate to dashboard when clicking on "Save and keep exploring"', async () => {
- locationService.push = jest.fn();
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
- setup( , [{ refId: 'A' }]);
+ it('Opens and closes the modal correctly', async () => {
+ setup( );
await openModal();
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
-
- await waitForSearchFolderResponse();
+ userEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
- expect(locationService.push).not.toHaveBeenCalled();
+ describe('navigation', () => {
+ it('Navigates to dashboard when clicking on "Open"', async () => {
+ // @ts-expect-error global.open should return a Window, but is not implemented in js-dom.
+ const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
+ const pushSpy = jest.spyOn(locationService, 'push');
+
+ setup( );
+
+ await openModal();
+
+ userEvent.click(screen.getByRole('button', { name: /open$/i }));
+
+ await waitForAddToDashboardResponse();
+
+ expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument();
+
+ expect(pushSpy).toHaveBeenCalled();
+ expect(openSpy).not.toHaveBeenCalled();
+ });
+
+ it('Navigates to dashboard in a new tab when clicking on "Open in a new tab"', async () => {
+ // @ts-expect-error global.open should return a Window, but is not implemented in js-dom.
+ const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
+ const pushSpy = jest.spyOn(locationService, 'push');
+
+ setup( );
+
+ await openModal();
+
+ userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
+
+ await waitForAddToDashboardResponse();
+
+ expect(openSpy).toHaveBeenCalledWith(expect.anything(), '_blank');
+ expect(pushSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Save to new dashboard', () => {
+ describe('Navigate to correct dashboard when saving', () => {
+ it('Opens the new dashboard in a new tab', async () => {
+ // @ts-expect-error global.open should return a Window, but is not implemented in js-dom.
+ const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
+
+ setup( );
+
+ await openModal();
+
+ userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
+
+ await waitForAddToDashboardResponse();
+
+ expect(openSpy).toHaveBeenCalledWith('dashboard/new', '_blank');
+ });
+
+ it('Navigates to the new dashboard', async () => {
+ const pushSpy = jest.spyOn(locationService, 'push');
+
+ setup( );
+
+ await openModal();
+
+ userEvent.click(screen.getByRole('button', { name: /open$/i }));
+
+ await waitForAddToDashboardResponse();
+
+ expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument();
+
+ expect(pushSpy).toHaveBeenCalledWith('dashboard/new');
+ });
+ });
+ });
+
+ describe('Save to existing dashboard', () => {
+ it('Renders the dashboard picker when switching to "Existing Dashboard"', async () => {
+ setup( );
+
+ await openModal();
+
+ expect(screen.queryByRole('combobox', { name: /dashboard/ })).not.toBeInTheDocument();
+
+ userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i }));
+ expect(screen.getByRole('combobox', { name: /dashboard/ })).toBeInTheDocument();
+ });
+
+ it('Does not submit if no dashboard is selected', async () => {
+ locationService.push = jest.fn();
+
+ setup( );
+
+ await openModal();
+
+ userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i }));
+
+ userEvent.click(screen.getByRole('button', { name: /open$/i }));
+ await waitForAddToDashboardResponse();
+
+ expect(locationService.push).not.toHaveBeenCalled();
+ });
+
+ describe('Navigate to correct dashboard when saving', () => {
+ it('Opens the selected dashboard in a new tab', async () => {
+ // @ts-expect-error global.open should return a Window, but is not implemented in js-dom.
+ const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
+
+ jest.spyOn(backendSrv, 'getDashboardByUid').mockResolvedValue({
+ dashboard: { templating: { list: [] }, title: 'Dashboard Title', uid: 'someUid' },
+ meta: {},
+ });
+ jest.spyOn(backendSrv, 'search').mockResolvedValue([
+ {
+ id: 1,
+ uid: 'someUid',
+ isStarred: false,
+ items: [],
+ title: 'Dashboard Title',
+ tags: [],
+ type: DashboardSearchItemType.DashDB,
+ uri: 'someUri',
+ url: 'someUrl',
+ },
+ ]);
+
+ setup( );
+
+ await openModal();
+
+ userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i }));
+
+ userEvent.click(screen.getByRole('combobox', { name: /dashboard/i }));
+
+ await waitFor(async () => {
+ await screen.findByLabelText('Select option');
+ });
+ userEvent.click(screen.getByLabelText('Select option'));
+
+ userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
+
+ await waitFor(async () => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ expect(openSpy).toBeCalledWith('d/someUid', '_blank');
+ });
+
+ it('Navigates to the selected dashboard', async () => {
+ const pushSpy = jest.spyOn(locationService, 'push');
+
+ jest.spyOn(backendSrv, 'getDashboardByUid').mockResolvedValue({
+ dashboard: { templating: { list: [] }, title: 'Dashboard Title', uid: 'someUid' },
+ meta: {},
+ });
+ jest.spyOn(backendSrv, 'search').mockResolvedValue([
+ {
+ id: 1,
+ uid: 'someUid',
+ isStarred: false,
+ items: [],
+ title: 'Dashboard Title',
+ tags: [],
+ type: DashboardSearchItemType.DashDB,
+ uri: 'someUri',
+ url: 'someUrl',
+ },
+ ]);
+
+ setup( );
+
+ await openModal();
+
+ userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i }));
+
+ userEvent.click(screen.getByRole('combobox', { name: /dashboard/i }));
+
+ await waitFor(async () => {
+ await screen.findByLabelText('Select option');
+ });
+ userEvent.click(screen.getByLabelText('Select option'));
+
+ userEvent.click(screen.getByRole('button', { name: /open$/i }));
+
+ await waitFor(async () => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ expect(pushSpy).toBeCalledWith('d/someUid');
+ });
+ });
});
});
- it('Correct datasource ref is useed', async () => {
- setup( , [{ refId: 'A' }]);
+ describe('Error handling', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
- await openModal();
+ it('Shows an error if opening a new tab fails', async () => {
+ jest.spyOn(global, 'open').mockReturnValue(null);
+ const removeDashboardSpy = jest.spyOn(initDashboard, 'removeDashboardToFetchFromLocalStorage');
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
+ setup( );
- await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
+ await openModal();
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
- expect(addToDashboardMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({
- datasource: {
- type: 'loki',
- uid: 'someuid',
+ userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
+
+ await waitFor(async () => {
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
+ });
+
+ expect(removeDashboardSpy).toHaveBeenCalled();
+ });
+
+ it('Shows an error if saving to localStorage fails', async () => {
+ jest.spyOn(initDashboard, 'setDashboardToFetchFromLocalStorage').mockImplementation(() => {
+ throw 'SOME ERROR';
+ });
+
+ setup( );
+
+ await openModal();
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+
+ userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
+
+ await waitFor(async () => {
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
+ });
+ });
+
+ it('Shows an error if fetching dashboard fails', async () => {
+ jest.spyOn(backendSrv, 'getDashboardByUid').mockRejectedValue('SOME ERROR');
+ jest.spyOn(backendSrv, 'search').mockResolvedValue([
+ {
+ id: 1,
+ uid: 'someUid',
+ isStarred: false,
+ items: [],
+ title: 'Dashboard Title',
+ tags: [],
+ type: DashboardSearchItemType.DashDB,
+ uri: 'someUri',
+ url: 'someUrl',
},
- })
- );
- });
+ ]);
- it('All queries are correctly passed through', async () => {
- const queries: DataQuery[] = [{ refId: 'A' }, { refId: 'B', hide: true }];
- setup( , queries);
+ setup( );
- await openModal();
+ await openModal();
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
+ userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i }));
- await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
+ userEvent.click(screen.getByRole('combobox', { name: /dashboard/i }));
- expect(addToDashboardMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({
- queries: queries,
- })
- );
- });
+ await waitFor(async () => {
+ await screen.findByLabelText('Select option');
+ });
+ userEvent.click(screen.getByLabelText('Select option'));
- it('Defaults to table if no response is available', async () => {
- const queries: DataQuery[] = [{ refId: 'A' }];
- setup( , queries, createEmptyQueryResponse());
+ userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
- await openModal();
-
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
-
- await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
-
- expect(addToDashboardMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({
- panel: 'table',
- })
- );
- });
-
- it('Defaults to table if no query is active', async () => {
- const queries: DataQuery[] = [{ refId: 'A', hide: true }];
- setup( , queries);
-
- await openModal();
-
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
-
- await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
-
- expect(addToDashboardMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({
- panel: 'table',
- })
- );
- });
-
- it('Filters out hidden queries when selecting visualization', async () => {
- const queries: DataQuery[] = [{ refId: 'A', hide: true }, { refId: 'B' }];
- setup( , queries, {
- ...createEmptyQueryResponse(),
- graphFrames: [new MutableDataFrame({ refId: 'B', fields: [] })],
- logsFrames: [new MutableDataFrame({ refId: 'A', fields: [] })],
+ await waitFor(async () => {
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
+ });
});
- await openModal();
+ it('Shows an error if an unknown error happens', async () => {
+ jest.spyOn(api, 'setDashboardInLocalStorage').mockRejectedValue('SOME ERROR');
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
+ setup( );
- await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
+ await openModal();
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
- // Query A comes before B, but it's hidden. visualization will be picked according to frames generated by B
- expect(addToDashboardMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({
- queries: queries,
- panel: 'timeseries',
- })
- );
- });
+ userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
- it('Sets visualization to logs if there are log frames', async () => {
- const queries: DataQuery[] = [{ refId: 'A' }];
- setup( , queries, {
- ...createEmptyQueryResponse(),
- logsFrames: [new MutableDataFrame({ refId: 'A', fields: [] })],
+ await waitFor(async () => {
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
+ });
});
-
- await openModal();
-
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
-
- await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
-
- // Query A comes before B, but it's hidden. visualization will be picked according to frames generated by B
- expect(addToDashboardMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({
- panel: 'logs',
- })
- );
- });
-
- it('Sets visualization to timeseries if there are graph frames', async () => {
- const queries: DataQuery[] = [{ refId: 'A' }];
- setup( , queries, {
- ...createEmptyQueryResponse(),
- graphFrames: [new MutableDataFrame({ refId: 'A', fields: [] })],
- });
-
- await openModal();
-
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
-
- await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
-
- expect(addToDashboardMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({
- panel: 'timeseries',
- })
- );
- });
-
- it('Sets visualization to nodeGraph if there are node graph frames', async () => {
- const queries: DataQuery[] = [{ refId: 'A' }];
- setup( , queries, {
- ...createEmptyQueryResponse(),
- nodeGraphFrames: [new MutableDataFrame({ refId: 'A', fields: [] })],
- });
-
- await openModal();
-
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
-
- await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
-
- expect(addToDashboardMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({
- panel: 'nodeGraph',
- })
- );
- });
-
- // trace view is not supported in dashboards, defaulting to table
- it('Sets visualization to table if there are trace frames', async () => {
- const queries: DataQuery[] = [{ refId: 'A' }];
- setup( , queries, {
- ...createEmptyQueryResponse(),
- traceFrames: [new MutableDataFrame({ refId: 'A', fields: [] })],
- });
-
- await openModal();
-
- userEvent.click(screen.getByRole('button', { name: /save and keep exploring/i }));
-
- await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Add panel to dashboard' }));
-
- expect(addToDashboardMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({
- panel: 'table',
- })
- );
});
});
diff --git a/public/app/features/explore/AddToDashboard/index.tsx b/public/app/features/explore/AddToDashboard/index.tsx
index 6cc0f31303f..50d19c3e643 100644
--- a/public/app/features/explore/AddToDashboard/index.tsx
+++ b/public/app/features/explore/AddToDashboard/index.tsx
@@ -1,41 +1,9 @@
import React, { useState } from 'react';
-import { DataFrame, DataQuery } from '@grafana/data';
-import { ExploreId, StoreState } from 'app/types';
-import { useSelector, useDispatch } from 'react-redux';
-import { getExploreItemSelector } from '../state/selectors';
-import { addToDashboard, SaveToNewDashboardDTO } from './addToDashboard';
-import { locationService } from '@grafana/runtime';
-import { notifyApp } from 'app/core/actions';
-import { createSuccessNotification } from 'app/core/copy/appNotification';
+import { ExploreId } from 'app/types';
+import { AddToDashboardModal } from './AddToDashboardModal';
import { ToolbarButton } from '@grafana/ui';
-import { AddToDashboardModal, ErrorResponse } from './AddToDashboardModal';
-
-const isVisible = (query: DataQuery) => !query.hide;
-const hasRefId = (refId: DataFrame['refId']) => (frame: DataFrame) => frame.refId === refId;
-
-const getMainPanel = (
- queries: DataQuery[],
- graphFrames?: DataFrame[],
- logsFrames?: DataFrame[],
- nodeGraphFrames?: DataFrame[]
-) => {
- for (const { refId } of queries.filter(isVisible)) {
- // traceview is not supported in dashboards, skipping it for now.
- const hasQueryRefId = hasRefId(refId);
- if (graphFrames?.some(hasQueryRefId)) {
- return 'timeseries';
- }
- if (logsFrames?.some(hasQueryRefId)) {
- return 'logs';
- }
- if (nodeGraphFrames?.some(hasQueryRefId)) {
- return 'nodeGraph';
- }
- }
-
- // falling back to table
- return 'table';
-};
+import { useSelector } from 'react-redux';
+import { getExploreItemSelector } from '../state/selectors';
interface Props {
exploreId: ExploreId;
@@ -43,41 +11,8 @@ interface Props {
export const AddToDashboard = ({ exploreId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
- const dispatch = useDispatch();
const selectExploreItem = getExploreItemSelector(exploreId);
-
- const { queries, panel, datasource } = useSelector((state: StoreState) => {
- const exploreItem = selectExploreItem(state);
- const queries = exploreItem?.queries || [];
- const datasource = exploreItem?.datasourceInstance;
- const { graphFrames, logsFrames, nodeGraphFrames } = exploreItem?.queryResponse || {};
-
- return {
- queries,
- datasource: { type: datasource?.type, uid: datasource?.uid },
- panel: getMainPanel(queries, graphFrames, logsFrames, nodeGraphFrames),
- };
- });
-
- const handleSave = async (data: SaveToNewDashboardDTO, redirect: boolean): Promise => {
- try {
- const redirectURL = await addToDashboard(data, {
- queries,
- datasource,
- panel,
- });
-
- if (redirect) {
- locationService.push(redirectURL);
- } else {
- dispatch(notifyApp(createSuccessNotification(`Panel saved to ${data.dashboardName}`)));
- setIsOpen(false);
- }
- return;
- } catch (e) {
- return { message: e.data?.message, status: e.data?.status ?? 'unknown-error' };
- }
- };
+ const explorePaneHasQueries = !!useSelector(selectExploreItem)?.queries?.length;
return (
<>
@@ -85,12 +20,12 @@ export const AddToDashboard = ({ exploreId }: Props) => {
icon="apps"
onClick={() => setIsOpen(true)}
aria-label="Add to dashboard"
- disabled={queries.length === 0}
+ disabled={!explorePaneHasQueries}
>
Add to dashboard
- {isOpen && setIsOpen(false)} onSave={handleSave} />}
+ {isOpen && setIsOpen(false)} exploreId={exploreId} />}
>
);
};
diff --git a/public/app/features/explore/ErrorContainer.test.tsx b/public/app/features/explore/ErrorContainer.test.tsx
index 589cdba349c..accc19c1877 100644
--- a/public/app/features/explore/ErrorContainer.test.tsx
+++ b/public/app/features/explore/ErrorContainer.test.tsx
@@ -11,7 +11,7 @@ describe('ErrorContainer', () => {
error: 'Error data content',
},
message: 'Error message',
- status: 'Error status',
+ status: 500,
statusText: 'Error status text',
refId: 'A',
},
@@ -30,7 +30,7 @@ describe('ErrorContainer', () => {
message: 'Error data message',
error: 'Error data content',
},
- status: 'Error status',
+ status: 500,
statusText: 'Error status text',
refId: 'A',
},
diff --git a/public/app/features/explore/ErrorContainer.tsx b/public/app/features/explore/ErrorContainer.tsx
index 675b2dcf6f7..fffa9c10313 100644
--- a/public/app/features/explore/ErrorContainer.tsx
+++ b/public/app/features/explore/ErrorContainer.tsx
@@ -1,8 +1,7 @@
import React, { FunctionComponent } from 'react';
import { DataQueryError } from '@grafana/data';
-import { Alert, useTheme2 } from '@grafana/ui';
+import { Alert } from '@grafana/ui';
import { FadeIn } from 'app/core/components/Animations/FadeIn';
-import { css } from '@emotion/css';
export interface ErrorContainerProps {
queryError?: DataQueryError;
@@ -10,18 +9,14 @@ export interface ErrorContainerProps {
export const ErrorContainer: FunctionComponent = (props) => {
const { queryError } = props;
- const theme = useTheme2();
const showError = queryError ? true : false;
const duration = showError ? 100 : 10;
const title = queryError ? 'Query error' : 'Unknown error';
const message = queryError?.message || queryError?.data?.message || null;
- const alertWithTopMargin = css`
- margin-top: ${theme.spacing(2)};
- `;
return (
-
+
{message}
diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx
new file mode 100644
index 00000000000..b9ce6c9f5f7
--- /dev/null
+++ b/public/app/features/explore/Explore.test.tsx
@@ -0,0 +1,125 @@
+import React from 'react';
+import { DataSourceApi, LoadingState, CoreApp, createTheme } from '@grafana/data';
+import { render, screen } from '@testing-library/react';
+import { ExploreId } from 'app/types/explore';
+import { Explore, Props } from './Explore';
+import { scanStopAction } from './state/query';
+import { Provider } from 'react-redux';
+import { configureStore } from 'app/store/configureStore';
+import { AutoSizerProps } from 'react-virtualized-auto-sizer';
+import { createEmptyQueryResponse } from './state/utils';
+
+const makeEmptyQueryResponse = (loadingState: LoadingState) => {
+ const baseEmptyResponse = createEmptyQueryResponse();
+
+ baseEmptyResponse.request = {
+ requestId: '1',
+ intervalMs: 0,
+ interval: '1s',
+ dashboardId: 0,
+ panelId: 1,
+ range: baseEmptyResponse.timeRange,
+ scopedVars: {
+ apps: {
+ value: 'value',
+ text: 'text',
+ },
+ },
+ targets: [
+ {
+ refId: 'A',
+ },
+ ],
+ timezone: 'UTC',
+ app: CoreApp.Explore,
+ startTime: 0,
+ };
+
+ baseEmptyResponse.state = loadingState;
+
+ return baseEmptyResponse;
+};
+
+const dummyProps: Props = {
+ logsResult: undefined,
+ changeSize: jest.fn(),
+ datasourceInstance: {
+ meta: {
+ metrics: true,
+ logs: true,
+ },
+ components: {
+ QueryEditorHelp: {},
+ },
+ } as DataSourceApi,
+ datasourceMissing: false,
+ exploreId: ExploreId.left,
+ loading: false,
+ modifyQueries: jest.fn(),
+ scanStart: jest.fn(),
+ scanStopAction: scanStopAction,
+ setQueries: jest.fn(),
+ queryKeys: [],
+ isLive: false,
+ syncedTimes: false,
+ updateTimeRange: jest.fn(),
+ makeAbsoluteTime: jest.fn(),
+ graphResult: [],
+ absoluteRange: {
+ from: 0,
+ to: 0,
+ },
+ timeZone: 'UTC',
+ queryResponse: makeEmptyQueryResponse(LoadingState.NotStarted),
+ addQueryRow: jest.fn(),
+ theme: createTheme(),
+ showMetrics: true,
+ showLogs: true,
+ showTable: true,
+ showTrace: true,
+ showNodeGraph: true,
+ splitOpen: (() => {}) as any,
+ logsVolumeData: undefined,
+ loadLogsVolumeData: () => {},
+ changeGraphStyle: () => {},
+ graphStyle: 'lines',
+};
+
+jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {
+ return {
+ getDataSourceSrv: () => ({
+ get: () => Promise.resolve({}),
+ getList: () => [],
+ getInstanceSettings: () => {},
+ }),
+ };
+});
+
+// for the AutoSizer component to have a width
+jest.mock('react-virtualized-auto-sizer', () => {
+ return ({ children }: AutoSizerProps) => children({ height: 1, width: 1 });
+});
+
+const setup = (overrideProps?: Partial) => {
+ const store = configureStore();
+ const exploreProps = { ...dummyProps, ...overrideProps };
+
+ return render(
+
+
+
+ );
+};
+
+describe('Explore', () => {
+ it('should not render no data with not started loading state', () => {
+ setup();
+ expect(screen.queryByTestId('explore-no-data')).not.toBeInTheDocument();
+ });
+
+ it('should render no data with done loading state', async () => {
+ const queryResp = makeEmptyQueryResponse(LoadingState.Done);
+ setup({ queryResponse: queryResp });
+ expect(screen.getByTestId('explore-no-data')).toBeInTheDocument();
+ });
+});
diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx
index a793f1f537f..38ec9457441 100644
--- a/public/app/features/explore/Explore.tsx
+++ b/public/app/features/explore/Explore.tsx
@@ -34,6 +34,7 @@ import appEvents from 'app/core/app_events';
import { AbsoluteTimeEvent } from 'app/types/events';
import { Unsubscribable } from 'rxjs';
import { getNodeGraphDataFrames } from 'app/plugins/panel/nodeGraph/utils';
+import { NoData } from './NoData';
const getStyles = (theme: GrafanaTheme2) => {
return {
@@ -213,6 +214,10 @@ export class Explore extends React.PureComponent {
);
}
+ renderNoData() {
+ return ;
+ }
+
renderGraphPanel(width: number) {
const { graphResult, absoluteRange, timeZone, splitOpen, queryResponse, loading, theme, graphStyle } = this.props;
const spacing = parseInt(theme.spacing(2).slice(0, -2), 10);
@@ -334,6 +339,15 @@ export class Explore extends React.PureComponent {
const showPanels = queryResponse && queryResponse.state !== LoadingState.NotStarted;
const showRichHistory = openDrawer === ExploreDrawer.RichHistory;
const showQueryInspector = openDrawer === ExploreDrawer.QueryInspector;
+ const showNoData =
+ queryResponse.state === LoadingState.Done &&
+ [
+ queryResponse.logsFrames,
+ queryResponse.graphFrames,
+ queryResponse.nodeGraphFrames,
+ queryResponse.tableFrames,
+ queryResponse.traceFrames,
+ ].every((e) => e.length === 0);
return (
{
{showLogs && {this.renderLogsPanel(width)} }
{showNodeGraph && {this.renderNodeGraphPanel()} }
{showTrace && {this.renderTraceViewPanel()} }
+ {showNoData && {this.renderNoData()} }
>
)}
{showRichHistory && (
diff --git a/public/app/features/explore/LogsMetaRow.tsx b/public/app/features/explore/LogsMetaRow.tsx
index 7a62da7a8ad..c7841420117 100644
--- a/public/app/features/explore/LogsMetaRow.tsx
+++ b/public/app/features/explore/LogsMetaRow.tsx
@@ -71,7 +71,7 @@ export const LogsMetaRow: React.FC = React.memo(
label: 'Your logs might have incorrectly escaped content',
value: (
diff --git a/public/app/features/explore/NoData.tsx b/public/app/features/explore/NoData.tsx
new file mode 100644
index 00000000000..d50330747f6
--- /dev/null
+++ b/public/app/features/explore/NoData.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { css, cx } from '@emotion/css';
+
+import { useStyles2 } from '@grafana/ui';
+
+import { GrafanaTheme2 } from '@grafana/data/src';
+
+export const NoData = () => {
+ const css = useStyles2(getStyles);
+ return (
+ <>
+
+ {'No data'}
+
+ >
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ wrapper: css`
+ label: no-data-card;
+ padding: ${theme.spacing(3)};
+ background: ${theme.colors.background.primary};
+ border-radius: ${theme.shape.borderRadius(2)};
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ flex-grow: 1;
+ `,
+ message: css`
+ font-size: ${theme.typography.h2.fontSize};
+ padding: ${theme.spacing(4)};
+ color: ${theme.colors.text.disabled};
+ `,
+});
diff --git a/public/app/features/explore/ResponseErrorContainer.test.tsx b/public/app/features/explore/ResponseErrorContainer.test.tsx
index 75982c68a2f..387dc9ee937 100644
--- a/public/app/features/explore/ResponseErrorContainer.test.tsx
+++ b/public/app/features/explore/ResponseErrorContainer.test.tsx
@@ -19,15 +19,14 @@ describe('ResponseErrorContainer', () => {
expect(errorEl).toHaveTextContent(errorMessage);
});
- it('shows error if there is refID', async () => {
+ it('do not show error if there is a refId', async () => {
const errorMessage = 'test error';
setup({
refId: 'someId',
message: errorMessage,
});
- const errorEl = screen.getByTestId(selectors.components.Alert.alertV2('error'));
- expect(errorEl).toBeInTheDocument();
- expect(errorEl).toHaveTextContent(errorMessage);
+ const errorEl = screen.queryByTestId(selectors.components.Alert.alertV2('error'));
+ expect(errorEl).not.toBeInTheDocument();
});
it('shows error.data.message if error.message does not exist', async () => {
diff --git a/public/app/features/explore/ResponseErrorContainer.tsx b/public/app/features/explore/ResponseErrorContainer.tsx
index a23ad7fa2f4..5dd5f59f154 100644
--- a/public/app/features/explore/ResponseErrorContainer.tsx
+++ b/public/app/features/explore/ResponseErrorContainer.tsx
@@ -9,8 +9,12 @@ interface Props {
}
export function ResponseErrorContainer(props: Props) {
const queryResponse = useSelector((state: StoreState) => state.explore[props.exploreId]?.queryResponse);
-
const queryError = queryResponse?.state === LoadingState.Error ? queryResponse?.error : undefined;
+ // Errors with ref ids are shown below the corresponding query
+ if (queryError?.refId) {
+ return null;
+ }
+
return ;
}
diff --git a/public/app/features/explore/RichHistory/RichHistory.test.tsx b/public/app/features/explore/RichHistory/RichHistory.test.tsx
index b49c35b06e2..b588e6f3362 100644
--- a/public/app/features/explore/RichHistory/RichHistory.test.tsx
+++ b/public/app/features/explore/RichHistory/RichHistory.test.tsx
@@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react';
import { GrafanaTheme } from '@grafana/data';
import { ExploreId } from '../../../types/explore';
import { RichHistory, RichHistoryProps, Tabs } from './RichHistory';
+import { SortOrder } from '../../../core/utils/richHistoryTypes';
jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() }));
@@ -16,6 +17,21 @@ const setup = (propOverrides?: Partial) => {
firstTab: Tabs.RichHistory,
deleteRichHistory: jest.fn(),
onClose: jest.fn(),
+ richHistorySearchFilters: {
+ search: '',
+ sortOrder: SortOrder.Descending,
+ datasourceFilters: [],
+ from: 0,
+ to: 7,
+ },
+ richHistorySettings: {
+ retentionPeriod: 0,
+ starredTabAsFirstTab: false,
+ activeDatasourceOnly: true,
+ lastUsedDatasourceFilters: [],
+ },
+ updateHistorySearchFilters: jest.fn(),
+ updateHistorySettings: jest.fn(),
};
Object.assign(props, propOverrides);
diff --git a/public/app/features/explore/RichHistory/RichHistory.tsx b/public/app/features/explore/RichHistory/RichHistory.tsx
index 410ad7f7ea6..893a17ca796 100644
--- a/public/app/features/explore/RichHistory/RichHistory.tsx
+++ b/public/app/features/explore/RichHistory/RichHistory.tsx
@@ -2,8 +2,6 @@ import React, { PureComponent } from 'react';
//Services & Utils
import { SortOrder } from 'app/core/utils/richHistory';
-import { RICH_HISTORY_SETTING_KEYS } from 'app/core/history/richHistoryLocalStorageUtils';
-import store from 'app/core/store';
import { Themeable, withTheme, TabbedContainer, TabConfig } from '@grafana/ui';
//Types
@@ -11,9 +9,10 @@ import { RichHistoryQuery, ExploreId } from 'app/types/explore';
import { SelectableValue } from '@grafana/data';
//Components
-import { RichHistorySettings } from './RichHistorySettings';
+import { RichHistorySettingsTab } from './RichHistorySettingsTab';
import { RichHistoryQueriesTab } from './RichHistoryQueriesTab';
import { RichHistoryStarredTab } from './RichHistoryStarredTab';
+import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes';
export enum Tabs {
RichHistory = 'Query history',
@@ -30,100 +29,74 @@ export const sortOrderOptions = [
export interface RichHistoryProps extends Themeable {
richHistory: RichHistoryQuery[];
+ richHistorySettings: RichHistorySettings;
+ richHistorySearchFilters: RichHistorySearchFilters;
+ updateHistorySettings: (settings: RichHistorySettings) => void;
+ updateHistorySearchFilters: (exploreId: ExploreId, filters: RichHistorySearchFilters) => void;
+ deleteRichHistory: () => void;
activeDatasourceInstance?: string;
firstTab: Tabs;
exploreId: ExploreId;
height: number;
- deleteRichHistory: () => void;
onClose: () => void;
}
-interface RichHistoryState {
- sortOrder: SortOrder;
- retentionPeriod: number;
- starredTabAsFirstTab: boolean;
- activeDatasourceOnly: boolean;
- datasourceFilters: SelectableValue[];
-}
+class UnThemedRichHistory extends PureComponent {
+ updateSettings = (settingsToUpdate: Partial) => {
+ this.props.updateHistorySettings({ ...this.props.richHistorySettings, ...settingsToUpdate });
+ };
-class UnThemedRichHistory extends PureComponent {
- constructor(props: RichHistoryProps) {
- super(props);
- this.state = {
- sortOrder: SortOrder.Descending,
- datasourceFilters: store.getObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, []),
- retentionPeriod: store.getObject(RICH_HISTORY_SETTING_KEYS.retentionPeriod, 7),
- starredTabAsFirstTab: store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false),
- activeDatasourceOnly: store.getBool(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, true),
- };
- }
+ updateFilters = (filtersToUpdate: Partial) => {
+ this.props.updateHistorySearchFilters(this.props.exploreId, {
+ ...this.props.richHistorySearchFilters,
+ ...filtersToUpdate,
+ });
+ };
onChangeRetentionPeriod = (retentionPeriod: SelectableValue) => {
if (retentionPeriod.value !== undefined) {
- this.setState({
- retentionPeriod: retentionPeriod.value,
- });
- store.set(RICH_HISTORY_SETTING_KEYS.retentionPeriod, retentionPeriod.value);
+ this.updateSettings({ retentionPeriod: retentionPeriod.value });
}
};
- toggleStarredTabAsFirstTab = () => {
- const starredTabAsFirstTab = !this.state.starredTabAsFirstTab;
- this.setState({
- starredTabAsFirstTab,
- });
- store.set(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, starredTabAsFirstTab);
- };
+ toggleStarredTabAsFirstTab = () =>
+ this.updateSettings({ starredTabAsFirstTab: !this.props.richHistorySettings.starredTabAsFirstTab });
- toggleActiveDatasourceOnly = () => {
- const activeDatasourceOnly = !this.state.activeDatasourceOnly;
- this.setState({
- activeDatasourceOnly,
- });
- store.set(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, activeDatasourceOnly);
- };
+ toggleActiveDatasourceOnly = () =>
+ this.updateSettings({ activeDatasourceOnly: !this.props.richHistorySettings.activeDatasourceOnly });
- onSelectDatasourceFilters = (value: SelectableValue[]) => {
- try {
- store.setObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, value);
- } catch (error) {
- console.error(error);
- }
- /* Set data source filters to state even though they were not successfully saved in
- * localStorage to allow interaction and filtering.
- **/
- this.setState({ datasourceFilters: value });
- };
+ onSelectDatasourceFilters = (datasourceFilters: SelectableValue[]) => this.updateFilters({ datasourceFilters });
- onChangeSortOrder = (sortOrder: SortOrder) => this.setState({ sortOrder });
+ onChangeSortOrder = (sortOrder: SortOrder) => this.updateFilters({ sortOrder });
/* If user selects activeDatasourceOnly === true, set datasource filter to currently active datasource.
* Filtering based on datasource won't be available. Otherwise set to null, as filtering will be
* available for user.
*/
- updateFilters() {
- this.state.activeDatasourceOnly && this.props.activeDatasourceInstance
- ? this.onSelectDatasourceFilters([
- { label: this.props.activeDatasourceInstance, value: this.props.activeDatasourceInstance },
- ])
- : this.onSelectDatasourceFilters(this.state.datasourceFilters);
+ initFilters() {
+ if (this.props.richHistorySettings.activeDatasourceOnly && this.props.activeDatasourceInstance) {
+ this.onSelectDatasourceFilters([
+ { label: this.props.activeDatasourceInstance, value: this.props.activeDatasourceInstance },
+ ]);
+ }
}
componentDidMount() {
- this.updateFilters();
+ this.initFilters();
}
- componentDidUpdate(prevProps: RichHistoryProps, prevState: RichHistoryState) {
- if (
- this.props.activeDatasourceInstance !== prevProps.activeDatasourceInstance ||
- this.state.activeDatasourceOnly !== prevState.activeDatasourceOnly
- ) {
- this.updateFilters();
+ /**
+ * Updating filters on didMount and didUpdate because we don't know when activeDatasourceInstance is ready
+ */
+ componentDidUpdate(prevProps: RichHistoryProps) {
+ if (this.props.activeDatasourceInstance !== prevProps.activeDatasourceInstance) {
+ this.initFilters();
}
}
render() {
- const { datasourceFilters, sortOrder, activeDatasourceOnly, retentionPeriod } = this.state;
+ const { activeDatasourceOnly, retentionPeriod } = this.props.richHistorySettings;
+ const { datasourceFilters, sortOrder } = this.props.richHistorySearchFilters;
const { richHistory, height, exploreId, deleteRichHistory, onClose, firstTab } = this.props;
const QueriesTab: TabConfig = {
@@ -166,10 +139,10 @@ class UnThemedRichHistory extends PureComponent ({ getExploreDatasources: jest.fn() }));
@@ -15,8 +16,23 @@ const setup = (propOverrides?: Partial) => {
richHistory: [],
firstTab: Tabs.RichHistory,
deleteRichHistory: jest.fn(),
- loadRichHistory: jest.fn(),
+ initRichHistory: jest.fn(),
+ updateHistorySearchFilters: jest.fn(),
+ updateHistorySettings: jest.fn(),
onClose: jest.fn(),
+ richHistorySearchFilters: {
+ search: '',
+ sortOrder: SortOrder.Descending,
+ datasourceFilters: [],
+ from: 0,
+ to: 7,
+ },
+ richHistorySettings: {
+ retentionPeriod: 0,
+ starredTabAsFirstTab: false,
+ activeDatasourceOnly: true,
+ lastUsedDatasourceFilters: [],
+ },
};
Object.assign(props, propOverrides);
@@ -25,6 +41,10 @@ const setup = (propOverrides?: Partial) => {
};
describe('RichHistoryContainer', () => {
+ it('should show loading message when settings and filters are not ready', () => {
+ const { container } = setup({ richHistorySearchFilters: undefined, richHistorySettings: undefined });
+ expect(container).toHaveTextContent('Loading...');
+ });
it('should render component with correct width', () => {
const { container } = setup();
expect(container.firstElementChild!.getAttribute('style')).toContain('width: 531.5px');
@@ -34,14 +54,14 @@ describe('RichHistoryContainer', () => {
expect(container.firstElementChild!.getAttribute('style')).toContain('height: 400px');
});
it('should re-request rich history every time the component is mounted', () => {
- const loadRichHistory = jest.fn();
- const { unmount } = setup({ loadRichHistory });
- expect(loadRichHistory).toBeCalledTimes(1);
+ const initRichHistory = jest.fn();
+ const { unmount } = setup({ initRichHistory });
+ expect(initRichHistory).toBeCalledTimes(1);
unmount();
- expect(loadRichHistory).toBeCalledTimes(1);
+ expect(initRichHistory).toBeCalledTimes(1);
- setup({ loadRichHistory });
- expect(loadRichHistory).toBeCalledTimes(2);
+ setup({ initRichHistory });
+ expect(initRichHistory).toBeCalledTimes(2);
});
});
diff --git a/public/app/features/explore/RichHistory/RichHistoryContainer.tsx b/public/app/features/explore/RichHistory/RichHistoryContainer.tsx
index aa13771e438..93f03298c10 100644
--- a/public/app/features/explore/RichHistory/RichHistoryContainer.tsx
+++ b/public/app/features/explore/RichHistory/RichHistoryContainer.tsx
@@ -2,10 +2,6 @@
import React, { useEffect, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
-// Services & Utils
-import store from 'app/core/store';
-import { RICH_HISTORY_SETTING_KEYS } from 'app/core/history/richHistoryLocalStorageUtils';
-
// Types
import { ExploreItemState, StoreState } from 'app/types';
import { ExploreId } from 'app/types/explore';
@@ -14,27 +10,36 @@ import { ExploreId } from 'app/types/explore';
import { RichHistory, Tabs } from './RichHistory';
//Actions
-import { deleteRichHistory, loadRichHistory } from '../state/history';
+import {
+ deleteRichHistory,
+ initRichHistory,
+ updateHistorySettings,
+ updateHistorySearchFilters,
+} from '../state/history';
import { ExploreDrawer } from '../ExploreDrawer';
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) {
const explore = state.explore;
// @ts-ignore
const item: ExploreItemState = explore[exploreId];
+ const richHistorySearchFilters = item.richHistorySearchFilters;
+ const richHistorySettings = explore.richHistorySettings;
const { datasourceInstance } = item;
- const firstTab = store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false)
- ? Tabs.Starred
- : Tabs.RichHistory;
+ const firstTab = richHistorySettings?.starredTabAsFirstTab ? Tabs.Starred : Tabs.RichHistory;
const { richHistory } = item;
return {
richHistory,
firstTab,
activeDatasourceInstance: datasourceInstance?.name,
+ richHistorySettings,
+ richHistorySearchFilters,
};
}
const mapDispatchToProps = {
- loadRichHistory,
+ initRichHistory,
+ updateHistorySettings,
+ updateHistorySearchFilters,
deleteRichHistory,
};
@@ -57,13 +62,21 @@ export function RichHistoryContainer(props: Props) {
activeDatasourceInstance,
exploreId,
deleteRichHistory,
- loadRichHistory,
+ initRichHistory,
+ richHistorySettings,
+ updateHistorySettings,
+ richHistorySearchFilters,
+ updateHistorySearchFilters,
onClose,
} = props;
useEffect(() => {
- loadRichHistory(exploreId);
- }, [loadRichHistory, exploreId]);
+ initRichHistory(exploreId);
+ }, [initRichHistory, exploreId]);
+
+ if (!richHistorySettings || !richHistorySearchFilters) {
+ return Loading... ;
+ }
return (
);
diff --git a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx
index 44fd55e42c3..aa410be7004 100644
--- a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx
+++ b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx
@@ -200,15 +200,15 @@ export function RichHistoryQueriesTab(props: Props) {
{!activeDatasourceOnly && (
-
-
-
+
)}
) => {
const props: RichHistorySettingsProps = {
@@ -15,7 +15,7 @@ const setup = (propOverrides?: Partial) => {
Object.assign(props, propOverrides);
- return render( );
+ return render( );
};
describe('RichHistorySettings', () => {
diff --git a/public/app/features/explore/RichHistory/RichHistorySettings.tsx b/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx
similarity index 74%
rename from public/app/features/explore/RichHistory/RichHistorySettings.tsx
rename to public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx
index 2a83c6d04be..e482dfadc74 100644
--- a/public/app/features/explore/RichHistory/RichHistorySettings.tsx
+++ b/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { css } from '@emotion/css';
-import { stylesFactory, useTheme, Select, Button, Switch, Field } from '@grafana/ui';
+import { stylesFactory, useTheme, Select, Button, Field, InlineField, InlineSwitch } from '@grafana/ui';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import appEvents from 'app/core/app_events';
import { ShowConfirmModalEvent } from '../../../types/events';
@@ -30,13 +30,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
input: css`
max-width: 200px;
`,
- switch: css`
- display: flex;
- align-items: center;
- `,
- label: css`
- margin-left: ${theme.spacing.md};
- `,
};
});
@@ -47,7 +40,7 @@ const retentionPeriodOptions = [
{ value: 14, label: '2 weeks' },
];
-export function RichHistorySettings(props: RichHistorySettingsProps) {
+export function RichHistorySettingsTab(props: RichHistorySettingsProps) {
const {
retentionPeriod,
starredTabAsFirstTab,
@@ -92,18 +85,20 @@ export function RichHistorySettings(props: RichHistorySettingsProps) {
>
-
-
-
-
Change the default active tab from “Query history” to “Starred”
-
-
-
-
-
-
Only show queries for data source currently active in Explore
-
-
+
+
+
+
+
+
{
describe('select datasource', () => {
it('should render select datasource if activeDatasourceOnly is false', () => {
const wrapper = setup();
- expect(wrapper.find({ 'aria-label': 'Filter datasources' })).toHaveLength(1);
+ expect(wrapper.find({ 'aria-label': 'Filter queries for data sources(s)' }).exists()).toBeTruthy();
});
it('should not render select datasource if activeDatasourceOnly is true', () => {
const wrapper = setup({ activeDatasourceOnly: true });
- expect(wrapper.find({ 'aria-label': 'Filter datasources' })).toHaveLength(0);
+ expect(wrapper.find({ 'aria-label': 'Filter queries for data sources(s)' }).exists()).toBeFalsy();
});
});
});
diff --git a/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx b/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx
index b1db75f57f1..cbe7915f791 100644
--- a/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx
+++ b/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx
@@ -117,15 +117,15 @@ export function RichHistoryStarredTab(props: Props) {
{!activeDatasourceOnly && (
-
-
-
+
)}
{
+ expect(withinExplore(exploreId).getByRole('tab', { name: `Tab ${tabName}`, selected: true })).toBeInTheDocument();
+};
+
+export const assertDataSourceFilterVisibility = (visible: boolean, exploreId: ExploreId = ExploreId.left) => {
+ const filterInput = withinExplore(exploreId).queryByLabelText('Filter queries for data sources(s)');
+ if (visible) {
+ expect(filterInput).toBeInTheDocument();
+ } else {
+ expect(filterInput).not.toBeInTheDocument();
+ }
+};
diff --git a/public/app/features/explore/spec/helper/interactions.ts b/public/app/features/explore/spec/helper/interactions.ts
index df8f51fb0bc..f1ed4542cc4 100644
--- a/public/app/features/explore/spec/helper/interactions.ts
+++ b/public/app/features/explore/spec/helper/interactions.ts
@@ -33,6 +33,30 @@ export const openQueryHistory = async (exploreId: ExploreId = ExploreId.left) =>
).toBeInTheDocument();
};
+export const closeQueryHistory = async (exploreId: ExploreId = ExploreId.left) => {
+ const closeButton = withinExplore(exploreId).getByRole('button', { name: 'Close query history' });
+ userEvent.click(closeButton);
+};
+
+export const switchToQueryHistoryTab = async (
+ name: 'Settings' | 'Query History',
+ exploreId: ExploreId = ExploreId.left
+) => {
+ userEvent.click(withinExplore(exploreId).getByRole('tab', { name: `Tab ${name}` }));
+};
+
+export const selectStarredTabFirst = (exploreId: ExploreId = ExploreId.left) => {
+ const checkbox = withinExplore(exploreId).getByRole('checkbox', {
+ name: 'Change the default active tab from “Query history” to “Starred”',
+ });
+ userEvent.click(checkbox);
+};
+
+export const selectOnlyActiveDataSource = (exploreId: ExploreId = ExploreId.left) => {
+ const checkbox = withinExplore(exploreId).getByLabelText(/Only show queries for data source currently active.*/);
+ userEvent.click(checkbox);
+};
+
export const starQueryHistory = (queryIndex: number, exploreId: ExploreId = ExploreId.left) => {
invokeAction(queryIndex, 'Star query', exploreId);
};
diff --git a/public/app/features/explore/spec/queryHistory.test.tsx b/public/app/features/explore/spec/queryHistory.test.tsx
index 87e9f8a2bd4..b62c8d2558d 100644
--- a/public/app/features/explore/spec/queryHistory.test.tsx
+++ b/public/app/features/explore/spec/queryHistory.test.tsx
@@ -1,8 +1,24 @@
import React from 'react';
import { serializeStateToUrlParam } from '@grafana/data';
import { setupExplore, tearDown, waitForExplore } from './helper/setup';
-import { deleteQueryHistory, inputQuery, openQueryHistory, runQuery, starQueryHistory } from './helper/interactions';
-import { assertQueryHistory, assertQueryHistoryExists, assertQueryHistoryIsStarred } from './helper/assert';
+import {
+ closeQueryHistory,
+ deleteQueryHistory,
+ inputQuery,
+ openQueryHistory,
+ runQuery,
+ selectOnlyActiveDataSource,
+ selectStarredTabFirst,
+ starQueryHistory,
+ switchToQueryHistoryTab,
+} from './helper/interactions';
+import {
+ assertDataSourceFilterVisibility,
+ assertQueryHistory,
+ assertQueryHistoryExists,
+ assertQueryHistoryIsStarred,
+ assertQueryHistoryTabIsSelected,
+} from './helper/assert';
import { makeLogsQueryResponse } from './helper/query';
import { ExploreId } from '../../../types';
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
@@ -69,7 +85,10 @@ describe('Explore: Query History', () => {
await assertQueryHistory(['{"expr":"query #2"}', '{"expr":"query #1"}']);
});
- it('updates the state in both Explore panes', async () => {
+ /**
+ * TODO: #47635 check why this test times out
+ */
+ it.skip('updates the state in both Explore panes', async () => {
const urlParams = {
left: serializeStateToUrlParam({
datasource: 'loki',
@@ -103,4 +122,26 @@ describe('Explore: Query History', () => {
await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.left);
await assertQueryHistory(['{"expr":"query #1"}'], ExploreId.right);
});
+
+ it('updates query history settings', async () => {
+ // open settings page
+ setupExplore();
+ await waitForExplore();
+ await openQueryHistory();
+
+ // assert default values
+ assertQueryHistoryTabIsSelected('Query history');
+ assertDataSourceFilterVisibility(true);
+ await switchToQueryHistoryTab('Settings');
+
+ // change settings
+ selectStarredTabFirst();
+ selectOnlyActiveDataSource();
+ await closeQueryHistory();
+ await openQueryHistory();
+
+ // assert new settings
+ assertQueryHistoryTabIsSelected('Starred');
+ assertDataSourceFilterVisibility(false);
+ });
});
diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts
index 38b07eb3f5b..a9265a441fa 100644
--- a/public/app/features/explore/state/explorePane.ts
+++ b/public/app/features/explore/state/explorePane.ts
@@ -36,7 +36,7 @@ import {
import { ThunkResult } from 'app/types';
import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors';
import { getDataSourceSrv } from '@grafana/runtime';
-import { richHistoryUpdatedAction, stateSave } from './main';
+import { richHistorySearchFiltersUpdatedAction, richHistoryUpdatedAction, stateSave } from './main';
import { keybindingSrv } from 'app/core/services/keybindingSrv';
//
@@ -262,6 +262,14 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac
};
}
+ if (richHistorySearchFiltersUpdatedAction.match(action)) {
+ const richHistorySearchFilters = action.payload.filters;
+ return {
+ ...state,
+ richHistorySearchFilters,
+ };
+ }
+
if (changeSizeAction.match(action)) {
const containerWidth = action.payload.width;
return { ...state, containerWidth };
diff --git a/public/app/features/explore/state/history.ts b/public/app/features/explore/state/history.ts
index a44329ba995..4c5982514db 100644
--- a/public/app/features/explore/state/history.ts
+++ b/public/app/features/explore/state/history.ts
@@ -3,13 +3,23 @@ import {
deleteAllFromRichHistory,
deleteQueryInRichHistory,
getRichHistory,
+ getRichHistorySettings,
+ SortOrder,
updateCommentInRichHistory,
+ updateRichHistorySettings,
updateStarredInRichHistory,
} from 'app/core/utils/richHistory';
import { ExploreId, ExploreItemState, ExploreState, RichHistoryQuery, ThunkResult } from 'app/types';
-import { richHistoryLimitExceededAction, richHistoryStorageFullAction, richHistoryUpdatedAction } from './main';
+import {
+ richHistoryLimitExceededAction,
+ richHistorySearchFiltersUpdatedAction,
+ richHistorySettingsUpdatedAction,
+ richHistoryStorageFullAction,
+ richHistoryUpdatedAction,
+} from './main';
import { DataQuery, HistoryItem } from '@grafana/data';
import { AnyAction, createAction } from '@reduxjs/toolkit';
+import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes';
//
// Actions and Payloads
@@ -106,11 +116,68 @@ export const deleteRichHistory = (): ThunkResult => {
export const loadRichHistory = (exploreId: ExploreId): ThunkResult => {
return async (dispatch) => {
+ // TODO: #45379 pass currently applied search filters
const richHistory = await getRichHistory();
dispatch(richHistoryUpdatedAction({ richHistory, exploreId }));
};
};
+/**
+ * Initialize query history pane. To load history it requires settings to be loaded first
+ * (but only once per session) and filters initialised with default values based on settings.
+ */
+export const initRichHistory = (exploreId: ExploreId): ThunkResult => {
+ return async (dispatch, getState) => {
+ let settings = getState().explore.richHistorySettings;
+ if (!settings) {
+ settings = await getRichHistorySettings();
+ dispatch(richHistorySettingsUpdatedAction(settings));
+ }
+
+ dispatch(
+ richHistorySearchFiltersUpdatedAction({
+ exploreId,
+ filters: {
+ search: '',
+ sortOrder: SortOrder.Descending,
+ datasourceFilters: settings!.lastUsedDatasourceFilters || [],
+ from: 0,
+ to: settings!.retentionPeriod,
+ },
+ })
+ );
+
+ dispatch(loadRichHistory(exploreId));
+ };
+};
+
+export const updateHistorySettings = (settings: RichHistorySettings): ThunkResult => {
+ return async (dispatch) => {
+ dispatch(richHistorySettingsUpdatedAction(settings));
+ await updateRichHistorySettings(settings);
+ };
+};
+
+/**
+ * Assumed this can be called only when settings and filters are initialised
+ */
+export const updateHistorySearchFilters = (
+ exploreId: ExploreId,
+ filters: RichHistorySearchFilters
+): ThunkResult => {
+ return async (dispatch, getState) => {
+ // TODO: #45379 get new rich history list based on filters
+ dispatch(richHistorySearchFiltersUpdatedAction({ exploreId, filters }));
+ const currentSettings = getState().explore.richHistorySettings!;
+ dispatch(
+ updateHistorySettings({
+ ...currentSettings,
+ lastUsedDatasourceFilters: filters.datasourceFilters,
+ })
+ );
+ };
+};
+
export const historyReducer = (state: ExploreItemState, action: AnyAction): ExploreItemState => {
if (historyUpdatedAction.match(action)) {
return {
diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts
index 249d7cda74c..cd439b72d85 100644
--- a/public/app/features/explore/state/main.ts
+++ b/public/app/features/explore/state/main.ts
@@ -9,6 +9,7 @@ import { getUrlStateFromPaneState, makeExplorePaneState } from './utils';
import { ThunkResult } from '../../../types';
import { TimeSrv } from '../../dashboard/services/TimeSrv';
import { PanelModel } from 'app/features/dashboard/state';
+import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes';
//
// Actions and Payloads
@@ -24,6 +25,12 @@ export const richHistoryUpdatedAction =
export const richHistoryStorageFullAction = createAction('explore/richHistoryStorageFullAction');
export const richHistoryLimitExceededAction = createAction('explore/richHistoryLimitExceededAction');
+export const richHistorySettingsUpdatedAction = createAction('explore/richHistorySettingsUpdated');
+export const richHistorySearchFiltersUpdatedAction = createAction<{
+ exploreId: ExploreId;
+ filters: RichHistorySearchFilters;
+}>('explore/richHistorySearchFiltersUpdatedAction');
+
/**
* Resets state for explore.
*/
@@ -243,6 +250,14 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction):
};
}
+ if (richHistorySettingsUpdatedAction.match(action)) {
+ const richHistorySettings = action.payload;
+ return {
+ ...state,
+ richHistorySettings,
+ };
+ }
+
if (action.payload) {
const { exploreId } = action.payload;
if (exploreId !== undefined) {
diff --git a/public/app/features/folders/state/navModel.ts b/public/app/features/folders/state/navModel.ts
index 628c5e0089e..7f4a04e9b88 100644
--- a/public/app/features/folders/state/navModel.ts
+++ b/public/app/features/folders/state/navModel.ts
@@ -1,6 +1,7 @@
import { NavModel, NavModelItem } from '@grafana/data';
+import { contextSrv } from 'app/core/services/context_srv';
-import { FolderDTO } from 'app/types';
+import { AccessControlAction, FolderDTO } from 'app/types';
export function buildNavModel(folder: FolderDTO): NavModelItem {
const model = {
@@ -29,13 +30,15 @@ export function buildNavModel(folder: FolderDTO): NavModelItem {
url: `${folder.url}/library-panels`,
});
- model.children.push({
- active: false,
- icon: 'bell',
- id: `folder-alerting-${folder.uid}`,
- text: 'Alert rules',
- url: `${folder.url}/alerting`,
- });
+ if (contextSrv.hasPermission(AccessControlAction.AlertingRuleRead)) {
+ model.children.push({
+ active: false,
+ icon: 'bell',
+ id: `folder-alerting-${folder.uid}`,
+ text: 'Alert rules',
+ url: `${folder.url}/alerting`,
+ });
+ }
if (folder.canAdmin) {
model.children.push({
diff --git a/public/app/features/inspector/InspectErrorTab.test.tsx b/public/app/features/inspector/InspectErrorTab.test.tsx
index b04e1283d35..07531dbb3e3 100644
--- a/public/app/features/inspector/InspectErrorTab.test.tsx
+++ b/public/app/features/inspector/InspectErrorTab.test.tsx
@@ -62,11 +62,11 @@ describe('InspectErrorTab', () => {
it('should return a jsonFormatter object of error if it has no .data and no .message', () => {
const error = {
- status: '400',
+ status: 400,
};
const { container } = render( );
expect(container.childElementCount).toEqual(1);
expect(screen.getByText('status:')).toBeInTheDocument();
- expect(screen.getByText('"400"')).toBeInTheDocument();
+ expect(screen.getByText('400')).toBeInTheDocument();
});
});
diff --git a/public/app/features/live/data/StreamingDataFrame.ts b/public/app/features/live/data/StreamingDataFrame.ts
index 0493728dee4..1c076c1c934 100644
--- a/public/app/features/live/data/StreamingDataFrame.ts
+++ b/public/app/features/live/data/StreamingDataFrame.ts
@@ -301,7 +301,7 @@ export class StreamingDataFrame implements DataFrame {
if (values.length !== this.fields.length) {
if (this.fields.length) {
throw new Error(
- `push message mismatch. Expected: ${this.fields.length}, recieved: ${values.length} (labels=${
+ `push message mismatch. Expected: ${this.fields.length}, received: ${values.length} (labels=${
this.pushMode === PushMode.labels
})`
);
diff --git a/public/app/features/panel/components/PanelDataErrorView.test.tsx b/public/app/features/panel/components/PanelDataErrorView.test.tsx
new file mode 100644
index 00000000000..10525bf0751
--- /dev/null
+++ b/public/app/features/panel/components/PanelDataErrorView.test.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { defaultsDeep } from 'lodash';
+import { PanelDataErrorView } from './PanelDataErrorView';
+import { PanelDataErrorViewProps } from '@grafana/runtime';
+import { getDefaultTimeRange, LoadingState } from '@grafana/data';
+import { Provider } from 'react-redux';
+import { configureStore } from 'app/store/configureStore';
+
+describe('PanelDataErrorView', () => {
+ it('show No data when there is no data', () => {
+ renderWithProps();
+
+ expect(screen.getByText('No data')).toBeInTheDocument();
+ });
+
+ it('show no value field config when there is no data', () => {
+ renderWithProps({
+ fieldConfig: {
+ overrides: [],
+ defaults: {
+ noValue: 'Query returned nothing',
+ },
+ },
+ });
+
+ expect(screen.getByText('Query returned nothing')).toBeInTheDocument();
+ });
+});
+
+function renderWithProps(overrides?: Partial) {
+ const defaults: PanelDataErrorViewProps = {
+ panelId: 1,
+ data: {
+ state: LoadingState.Done,
+ series: [],
+ timeRange: getDefaultTimeRange(),
+ },
+ };
+
+ const props = defaultsDeep(overrides ?? {}, defaults);
+ const store = configureStore();
+
+ const stuff = render(
+
+
+
+ );
+ return { ...stuff };
+}
diff --git a/public/app/features/panel/components/PanelDataErrorView.tsx b/public/app/features/panel/components/PanelDataErrorView.tsx
index 35baee77e1c..55bb7aa50ca 100644
--- a/public/app/features/panel/components/PanelDataErrorView.tsx
+++ b/public/app/features/panel/components/PanelDataErrorView.tsx
@@ -57,7 +57,7 @@ export function PanelDataErrorView(props: PanelDataErrorViewProps) {
}
function getMessageFor(
- { data, message, needsNumberField, needsTimeField, needsStringField }: PanelDataErrorViewProps,
+ { data, fieldConfig, message, needsNumberField, needsTimeField, needsStringField }: PanelDataErrorViewProps,
dataSummary: PanelDataSummary
): string {
if (message) {
@@ -66,7 +66,7 @@ function getMessageFor(
// In some cases there is a data frame but with no fields
if (!data.series || data.series.length === 0 || (data.series.length === 1 && data.series[0].fields.length === 0)) {
- return 'No data';
+ return fieldConfig?.defaults.noValue ?? 'No data';
}
if (needsStringField && !dataSummary.hasStringField) {
diff --git a/public/app/features/panel/state/actions.ts b/public/app/features/panel/state/actions.ts
index f3c9d51fa9e..0864c763632 100644
--- a/public/app/features/panel/state/actions.ts
+++ b/public/app/features/panel/state/actions.ts
@@ -2,7 +2,13 @@ import { getPanelPluginNotFound } from 'app/features/panel/components/PanelPlugi
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { loadPanelPlugin } from 'app/features/plugins/admin/state/actions';
import { ThunkResult } from 'app/types';
-import { changePanelKey, panelModelAndPluginReady } from './reducers';
+import {
+ changePanelKey,
+ cleanUpAngularComponent,
+ panelModelAndPluginReady,
+ removePanel,
+ removePanels,
+} from './reducers';
import { LibraryElementDTO } from 'app/features/library-panels/types';
import { toPanelModelLibraryPanel } from 'app/features/library-panels/utils';
import { PanelOptionsChangedEvent, PanelQueriesChangedEvent } from 'app/types/events';
@@ -31,6 +37,24 @@ export function initPanelState(panel: PanelModel): ThunkResult {
};
}
+export function cleanUpPanelState(panelKey: string): ThunkResult {
+ return (dispatch, getStore) => {
+ const store = getStore().panels;
+ cleanUpAngularComponent(store[panelKey]);
+ dispatch(removePanel({ key: panelKey }));
+ };
+}
+
+export function cleanAndRemoveMany(panelKeys: string[]): ThunkResult {
+ return (dispatch, getStore) => {
+ const store = getStore().panels;
+ for (const key of panelKeys) {
+ cleanUpAngularComponent(store[key]);
+ }
+ dispatch(removePanels({ keys: panelKeys }));
+ };
+}
+
export interface ChangePanelPluginAndOptionsArgs {
panel: PanelModel;
pluginId: string;
diff --git a/public/app/features/panel/state/reducers.ts b/public/app/features/panel/state/reducers.ts
index 4fe2f8dce70..b809a223ba4 100644
--- a/public/app/features/panel/state/reducers.ts
+++ b/public/app/features/panel/state/reducers.ts
@@ -30,10 +30,14 @@ const panelsSlice = createSlice({
state[action.payload.newKey] = state[action.payload.oldKey];
delete state[action.payload.oldKey];
},
- cleanUpPanelState: (state, action: PayloadAction<{ key: string }>) => {
- cleanUpAngularComponent(state[action.payload.key]);
+ removePanel: (state, action: PayloadAction<{ key: string }>) => {
delete state[action.payload.key];
},
+ removePanels: (state, action: PayloadAction<{ keys: string[] }>) => {
+ for (const key of action.payload.keys) {
+ delete state[key];
+ }
+ },
setPanelInstanceState: (state, action: PayloadAction) => {
state[action.payload.key].instanceState = action.payload.value;
},
@@ -45,7 +49,7 @@ const panelsSlice = createSlice({
},
});
-function cleanUpAngularComponent(panelState?: Draft) {
+export function cleanUpAngularComponent(panelState?: Draft) {
if (panelState?.angularComponent) {
panelState.angularComponent.destroy();
}
@@ -72,8 +76,9 @@ export const {
panelModelAndPluginReady,
setPanelAngularComponent,
setPanelInstanceState,
- cleanUpPanelState,
changePanelKey,
+ removePanel,
+ removePanels,
} = panelsSlice.actions;
export const panelsReducer = panelsSlice.reducer;
diff --git a/public/app/features/playlist/PlaylistEditPage.test.tsx b/public/app/features/playlist/PlaylistEditPage.test.tsx
index 0d8941313fb..2766c0fe6c4 100644
--- a/public/app/features/playlist/PlaylistEditPage.test.tsx
+++ b/public/app/features/playlist/PlaylistEditPage.test.tsx
@@ -12,6 +12,12 @@ jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => backendSrv,
}));
+jest.mock('../../core/components/TagFilter/TagFilter', () => ({
+ TagFilter: () => {
+ return <>mocked-tag-filter>;
+ },
+}));
+
async function getTestContext({ name, interval, items }: Partial = {}) {
jest.clearAllMocks();
const playlist = { name, items, interval } as unknown as Playlist;
diff --git a/public/app/features/playlist/PlaylistForm.test.tsx b/public/app/features/playlist/PlaylistForm.test.tsx
index 8a5a774579e..b401838b698 100644
--- a/public/app/features/playlist/PlaylistForm.test.tsx
+++ b/public/app/features/playlist/PlaylistForm.test.tsx
@@ -6,6 +6,12 @@ import userEvent from '@testing-library/user-event';
import { Playlist } from './types';
import { PlaylistForm } from './PlaylistForm';
+jest.mock('../../core/components/TagFilter/TagFilter', () => ({
+ TagFilter: () => {
+ return <>mocked-tag-filter>;
+ },
+}));
+
function getTestContext({ name, interval, items }: Partial = {}) {
const onSubmitMock = jest.fn();
const playlist = { name, items, interval } as unknown as Playlist;
diff --git a/public/app/features/playlist/PlaylistNewPage.test.tsx b/public/app/features/playlist/PlaylistNewPage.test.tsx
index f3469adba79..487bd152607 100644
--- a/public/app/features/playlist/PlaylistNewPage.test.tsx
+++ b/public/app/features/playlist/PlaylistNewPage.test.tsx
@@ -19,6 +19,12 @@ jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => backendSrv,
}));
+jest.mock('../../core/components/TagFilter/TagFilter', () => ({
+ TagFilter: () => {
+ return <>mocked-tag-filter>;
+ },
+}));
+
function getTestContext({ name, interval, items }: Partial = {}) {
jest.clearAllMocks();
const playlist = { name, items, interval } as unknown as Playlist;
diff --git a/public/app/features/query/components/QueryEditorRow.test.ts b/public/app/features/query/components/QueryEditorRow.test.ts
index 6117923d66c..34953929ce2 100644
--- a/public/app/features/query/components/QueryEditorRow.test.ts
+++ b/public/app/features/query/components/QueryEditorRow.test.ts
@@ -75,6 +75,21 @@ describe('filterPanelDataToQuery', () => {
expect(panelDataA?.state).toBe(LoadingState.Done);
});
+ it('should return error for query that returns no data, but another query does return data', () => {
+ const withError = {
+ ...data,
+ state: LoadingState.Error,
+ error: {
+ message: 'Sad',
+ refId: 'Q',
+ },
+ };
+
+ const panelDataB = filterPanelDataToQuery(withError, 'Q');
+ expect(panelDataB?.series.length).toBe(0);
+ expect(panelDataB?.error?.refId).toBe('Q');
+ });
+
it('should not set the state to done if the frame is loading and has no errors', () => {
const loadingData: PanelData = {
state: LoadingState.Loading,
diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx
index fb7a32444c3..d7a6f156d2e 100644
--- a/public/app/features/query/components/QueryEditorRow.tsx
+++ b/public/app/features/query/components/QueryEditorRow.tsx
@@ -32,6 +32,7 @@ import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp';
import { RowActionComponents } from './QueryActionComponent';
+import { QueryErrorAlert } from './QueryErrorAlert';
interface Props {
data: PanelData;
@@ -382,7 +383,7 @@ export class QueryEditorRow extends PureComponent extends PureComponent
+ {data?.error && data.error.refId === query.refId && }
{visualization}
@@ -463,16 +465,12 @@ export interface AngularQueryComponentScope
{
export function filterPanelDataToQuery(data: PanelData, refId: string): PanelData | undefined {
const series = data.series.filter((series) => series.refId === refId);
- // No matching series
- if (!series.length) {
- // If there was an error with no data, pass it to the QueryEditors
- if (data.error && !data.series.length) {
- return {
- ...data,
- state: LoadingState.Error,
- };
- }
- return undefined;
+ // If there was an error with no data, pass it to the QueryEditors
+ if (data.error && !data.series.length) {
+ return {
+ ...data,
+ state: LoadingState.Error,
+ };
}
// Only say this is an error if the error links to the query
diff --git a/public/app/features/query/components/QueryErrorAlert.tsx b/public/app/features/query/components/QueryErrorAlert.tsx
new file mode 100644
index 00000000000..c7cfd73909b
--- /dev/null
+++ b/public/app/features/query/components/QueryErrorAlert.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { DataQueryError, GrafanaTheme2 } from '@grafana/data';
+import { Icon, useStyles2 } from '@grafana/ui';
+import { css } from '@emotion/css';
+
+export interface Props {
+ error: DataQueryError;
+}
+
+export function QueryErrorAlert({ error }: Props) {
+ const styles = useStyles2(getStyles);
+
+ const message = error?.message ?? error?.data?.message ?? 'Query error';
+
+ return (
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ wrapper: css({
+ marginTop: theme.spacing(0.5),
+ background: theme.colors.background.secondary,
+ display: 'flex',
+ }),
+ icon: css({
+ background: theme.colors.error.main,
+ color: theme.colors.error.contrastText,
+ padding: theme.spacing(1),
+ }),
+ message: css({
+ fontSize: theme.typography.bodySmall.fontSize,
+ fontFamily: theme.typography.fontFamilyMonospace,
+ padding: theme.spacing(1),
+ }),
+});
diff --git a/public/app/features/query/components/QueryGroup.tsx b/public/app/features/query/components/QueryGroup.tsx
index 438d0b4c207..a28f71cc65d 100644
--- a/public/app/features/query/components/QueryGroup.tsx
+++ b/public/app/features/query/components/QueryGroup.tsx
@@ -111,7 +111,10 @@ export class QueryGroup extends PureComponent {
onChangeDataSource = async (newSettings: DataSourceInstanceSettings) => {
const { dsSettings } = this.state;
- const queries = updateQueries(newSettings, this.state.queries, dsSettings);
+ const currentDS = dsSettings ? await getDataSourceSrv().get(dsSettings.uid) : undefined;
+ const nextDS = await getDataSourceSrv().get(newSettings.uid);
+
+ const queries = await updateQueries(nextDS, this.state.queries, currentDS);
const dataSource = await this.dataSourceSrv.get(newSettings.name);
this.onChange({
diff --git a/public/app/features/query/state/runRequest.test.ts b/public/app/features/query/state/runRequest.test.ts
index 6742d37d0bf..8a40ef7fd08 100644
--- a/public/app/features/query/state/runRequest.test.ts
+++ b/public/app/features/query/state/runRequest.test.ts
@@ -156,7 +156,7 @@ describe('runRequest', () => {
});
});
- runRequestScenario('After tree responses, 2 with different keys', (ctx) => {
+ runRequestScenario('After three responses, 2 with different keys', (ctx) => {
ctx.setup(() => {
ctx.start();
ctx.emitPacket({
@@ -186,6 +186,40 @@ describe('runRequest', () => {
});
});
+ runRequestScenario('When the key is defined in refId', (ctx) => {
+ ctx.setup(() => {
+ ctx.start();
+ ctx.emitPacket({
+ data: [{ name: 'DataX-1', refId: 'X' } as DataFrame],
+ });
+ ctx.emitPacket({
+ data: [{ name: 'DataY-1', refId: 'Y' } as DataFrame],
+ });
+ ctx.emitPacket({
+ data: [{ name: 'DataY-2', refId: 'Y' } as DataFrame],
+ });
+ });
+
+ it('should emit 3 separate results', () => {
+ expect(ctx.results.length).toBe(3);
+ });
+
+ it('should keep data for X and Y', () => {
+ expect(ctx.results[2].series).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "name": "DataX-1",
+ "refId": "X",
+ },
+ Object {
+ "name": "DataY-2",
+ "refId": "Y",
+ },
+ ]
+ `);
+ });
+ });
+
runRequestScenario('After response with state Streaming', (ctx) => {
ctx.setup(() => {
ctx.start();
diff --git a/public/app/features/query/state/runRequest.ts b/public/app/features/query/state/runRequest.ts
index 370df008e9b..76a7442ab3f 100644
--- a/public/app/features/query/state/runRequest.ts
+++ b/public/app/features/query/state/runRequest.ts
@@ -43,7 +43,9 @@ export function processResponsePacket(packet: DataQueryResponse, state: RunningQ
...state.packets,
};
- packets[packet.key || 'A'] = packet;
+ // updates to the same key will replace previous values
+ const key = packet.key ?? packet.data?.[0]?.refId ?? 'A';
+ packets[key] = packet;
let loadingState = packet.state || LoadingState.Done;
let error: DataQueryError | undefined = undefined;
diff --git a/public/app/features/query/state/updateQueries.test.ts b/public/app/features/query/state/updateQueries.test.ts
index 13dc5b4ad56..f4f8a2de817 100644
--- a/public/app/features/query/state/updateQueries.test.ts
+++ b/public/app/features/query/state/updateQueries.test.ts
@@ -1,20 +1,54 @@
+import {
+ DataQuery,
+ DataSourceApi,
+ DataSourceWithQueryExportSupport,
+ DataSourceWithQueryImportSupport,
+} from '@grafana/data';
import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend';
import { updateQueries } from './updateQueries';
+const oldUidDS = {
+ uid: 'old-uid',
+ type: 'old-type',
+ meta: {
+ id: 'old-type',
+ },
+} as DataSourceApi;
+
+const mixedDS = {
+ uid: 'mixed',
+ meta: {
+ id: 'mixed',
+ mixed: true,
+ },
+} as DataSourceApi;
+
+const newUidDS = {
+ uid: 'new-uid',
+ type: 'new-type',
+ meta: {
+ id: 'new-type',
+ },
+} as DataSourceApi;
+
+const newUidSameTypeDS = {
+ uid: 'new-uid-same-type',
+ type: 'old-type',
+ meta: {
+ id: 'old-type',
+ },
+} as DataSourceApi;
+
describe('updateQueries', () => {
- it('Should update all queries except expression query when changing data source with same type', () => {
- const updated = updateQueries(
- {
- uid: 'new-uid',
- type: 'same-type',
- meta: {},
- } as any,
+ it('Should update all queries except expression query when changing data source with same type', async () => {
+ const updated = await updateQueries(
+ newUidSameTypeDS,
[
{
refId: 'A',
datasource: {
uid: 'old-uid',
- type: 'same-type',
+ type: 'old-type',
},
},
{
@@ -22,23 +56,16 @@ describe('updateQueries', () => {
datasource: ExpressionDatasourceRef,
},
],
- {
- uid: 'old-uid',
- type: 'same-type',
- } as any
+ oldUidDS
);
- expect(updated[0].datasource).toEqual({ type: 'same-type', uid: 'new-uid' });
+ expect(updated[0].datasource).toEqual({ type: 'old-type', uid: 'new-uid-same-type' });
expect(updated[1].datasource).toEqual(ExpressionDatasourceRef);
});
- it('Should clear queries when changing type', () => {
- const updated = updateQueries(
- {
- uid: 'new-uid',
- type: 'new-type',
- meta: {},
- } as any,
+ it('Should clear queries when changing type', async () => {
+ const updated = await updateQueries(
+ newUidDS,
[
{
refId: 'A',
@@ -55,25 +82,16 @@ describe('updateQueries', () => {
},
},
],
- {
- uid: 'old-uid',
- type: 'old-type',
- } as any
+ oldUidDS
);
expect(updated.length).toEqual(1);
expect(updated[0].datasource).toEqual({ type: 'new-type', uid: 'new-uid' });
});
- it('Should preserve query data source when changing to mixed', () => {
- const updated = updateQueries(
- {
- uid: 'mixed',
- type: 'mixed',
- meta: {
- mixed: true,
- },
- } as any,
+ it('Should preserve query data source when changing to mixed', async () => {
+ const updated = await updateQueries(
+ mixedDS,
[
{
refId: 'A',
@@ -90,25 +108,16 @@ describe('updateQueries', () => {
},
},
],
- {
- uid: 'old-uid',
- type: 'old-type',
- } as any
+ oldUidDS
);
expect(updated[0].datasource).toEqual({ type: 'old-type', uid: 'old-uid' });
expect(updated[1].datasource).toEqual({ type: 'other-type', uid: 'other-uid' });
});
- it('should change nothing mixed updated to mixed', () => {
- const updated = updateQueries(
- {
- uid: 'mixed',
- type: 'mixed',
- meta: {
- mixed: true,
- },
- } as any,
+ it('should change nothing mixed updated to mixed', async () => {
+ const updated = await updateQueries(
+ mixedDS,
[
{
refId: 'A',
@@ -125,16 +134,249 @@ describe('updateQueries', () => {
},
},
],
- {
- uid: 'mixed',
- type: 'mixed',
- meta: {
- mixed: true,
- },
- } as any
+ mixedDS
);
expect(updated[0].datasource).toEqual({ type: 'old-type', uid: 'old-uid' });
expect(updated[1].datasource).toEqual({ type: 'other-type', uid: 'other-uid' });
});
});
+
+describe('updateQueries with import', () => {
+ describe('abstract queries support', () => {
+ it('should migrate abstract queries', async () => {
+ const exportSpy = jest.fn();
+ const importSpy = jest.fn();
+
+ const newUidDSWithAbstract = {
+ uid: 'new-uid',
+ type: 'new-type',
+ meta: {
+ id: 'new-type',
+ },
+ importFromAbstractQueries: (queries) => {
+ importSpy(queries);
+ const importedQueries = queries.map((q) => ({ ...q, imported: true }));
+ return Promise.resolve(importedQueries);
+ },
+ } as DataSourceWithQueryImportSupport;
+
+ const oldUidDSWithAbstract = {
+ uid: 'old-uid',
+ type: 'old-type',
+ meta: {
+ id: 'old-type',
+ },
+ exportToAbstractQueries: (queries) => {
+ exportSpy(queries);
+ const exportedQueries = queries.map((q) => ({ ...q, exported: true }));
+ return Promise.resolve(exportedQueries);
+ },
+ } as DataSourceWithQueryExportSupport;
+
+ const queries = [
+ {
+ refId: 'A',
+ datasource: {
+ uid: 'old-uid',
+ type: 'old-type',
+ },
+ },
+ {
+ refId: 'B',
+ datasource: {
+ uid: 'other-uid',
+ type: 'other-type',
+ },
+ },
+ ];
+
+ const updated = await updateQueries(newUidDSWithAbstract as any, queries, oldUidDSWithAbstract as any);
+
+ expect(exportSpy).toBeCalledWith(queries);
+ expect(importSpy).toBeCalledWith(queries.map((q) => ({ ...q, exported: true })));
+
+ expect(updated).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "datasource": Object {
+ "type": "new-type",
+ "uid": "new-uid",
+ },
+ "exported": true,
+ "imported": true,
+ "refId": "A",
+ },
+ Object {
+ "datasource": Object {
+ "type": "new-type",
+ "uid": "new-uid",
+ },
+ "exported": true,
+ "imported": true,
+ "refId": "B",
+ },
+ ]
+ `);
+ });
+
+ it('should clear queries when no queries were imported', async () => {
+ const newUidDSWithAbstract = {
+ uid: 'new-uid',
+ type: 'new-type',
+ meta: {
+ id: 'new-type',
+ },
+ importFromAbstractQueries: () => {
+ return Promise.resolve([]);
+ },
+ } as DataSourceWithQueryImportSupport;
+
+ const oldUidDSWithAbstract = {
+ uid: 'old-uid',
+ type: 'old-type',
+ meta: {
+ id: 'old-type',
+ },
+ exportToAbstractQueries: (queries) => {
+ const exportedQueries = queries.map((q) => ({ ...q, exported: true }));
+ return Promise.resolve(exportedQueries);
+ },
+ } as DataSourceWithQueryExportSupport;
+
+ const queries = [
+ {
+ refId: 'A',
+ datasource: {
+ uid: 'old-uid',
+ type: 'old-type',
+ },
+ },
+ {
+ refId: 'B',
+ datasource: {
+ uid: 'other-uid',
+ type: 'other-type',
+ },
+ },
+ ];
+
+ const updated = await updateQueries(newUidDSWithAbstract as any, queries, oldUidDSWithAbstract as any);
+
+ expect(updated.length).toEqual(1);
+ expect(updated[0].datasource).toEqual({ type: 'new-type', uid: 'new-uid' });
+ });
+ });
+
+ describe('importQueries support', () => {
+ it('should import queries when abstract queries are not supported by datasources', async () => {
+ const importSpy = jest.fn();
+
+ const newUidDSWithImport = {
+ uid: 'new-uid',
+ type: 'new-type',
+ meta: {
+ id: 'new-type',
+ },
+ importQueries: (queries, origin) => {
+ importSpy(queries, origin);
+ const importedQueries = queries.map((q) => ({ ...q, imported: true }));
+ return Promise.resolve(importedQueries);
+ },
+ } as DataSourceApi;
+
+ const oldUidDS = {
+ uid: 'old-uid',
+ type: 'old-type',
+ meta: {
+ id: 'old-type',
+ },
+ } as DataSourceApi;
+
+ const queries = [
+ {
+ refId: 'A',
+ datasource: {
+ uid: 'old-uid',
+ type: 'old-type',
+ },
+ },
+ {
+ refId: 'B',
+ datasource: {
+ uid: 'other-uid',
+ type: 'other-type',
+ },
+ },
+ ];
+
+ const updated = await updateQueries(newUidDSWithImport, queries, oldUidDS);
+
+ expect(importSpy).toBeCalledWith(queries, { uid: 'old-uid', type: 'old-type', meta: { id: 'old-type' } });
+
+ expect(updated).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "datasource": Object {
+ "type": "new-type",
+ "uid": "new-uid",
+ },
+ "imported": true,
+ "refId": "A",
+ },
+ Object {
+ "datasource": Object {
+ "type": "new-type",
+ "uid": "new-uid",
+ },
+ "imported": true,
+ "refId": "B",
+ },
+ ]
+ `);
+ });
+
+ it('should clear queries when no queries were imported', async () => {
+ const newUidDSWithImport = {
+ uid: 'new-uid',
+ type: 'new-type',
+ meta: {
+ id: 'new-type',
+ },
+ importQueries: (queries, origin) => {
+ return Promise.resolve([] as DataQuery[]);
+ },
+ } as DataSourceApi;
+
+ const oldUidDS = {
+ uid: 'old-uid',
+ type: 'old-type',
+ meta: {
+ id: 'old-type',
+ },
+ } as DataSourceApi;
+
+ const queries = [
+ {
+ refId: 'A',
+ datasource: {
+ uid: 'old-uid',
+ type: 'old-type',
+ },
+ },
+ {
+ refId: 'B',
+ datasource: {
+ uid: 'other-uid',
+ type: 'other-type',
+ },
+ },
+ ];
+
+ const updated = await updateQueries(newUidDSWithImport, queries, oldUidDS);
+
+ expect(updated.length).toEqual(1);
+ expect(updated[0].datasource).toEqual({ type: 'new-type', uid: 'new-uid' });
+ });
+ });
+});
diff --git a/public/app/features/query/state/updateQueries.ts b/public/app/features/query/state/updateQueries.ts
index d6998173599..3eb1a1b5aa7 100644
--- a/public/app/features/query/state/updateQueries.ts
+++ b/public/app/features/query/state/updateQueries.ts
@@ -1,27 +1,42 @@
-import { DataQuery, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data';
+import { DataQuery, DataSourceApi, hasQueryExportSupport, hasQueryImportSupport } from '@grafana/data';
import { isExpressionReference } from '@grafana/runtime/src/utils/DataSourceWithBackend';
-export function updateQueries(
- newSettings: DataSourceInstanceSettings,
+export async function updateQueries(
+ nextDS: DataSourceApi,
queries: DataQuery[],
- dsSettings?: DataSourceInstanceSettings
-): DataQuery[] {
- const datasource = getDataSourceRef(newSettings);
+ currentDS?: DataSourceApi
+): Promise {
+ let nextQueries = queries;
+ const datasource = { type: nextDS.type, uid: nextDS.uid };
// we are changing data source type
- if (dsSettings?.type !== newSettings.type) {
+ if (currentDS?.meta.id !== nextDS.meta.id) {
// If changing to mixed do nothing
- if (newSettings.meta.mixed) {
+ if (nextDS.meta.mixed) {
return queries;
- } else {
- // Changing to another datasource type clear queries
+ }
+ // when both data sources support abstract queries
+ else if (hasQueryExportSupport(currentDS) && hasQueryImportSupport(nextDS)) {
+ const abstractQueries = await currentDS.exportToAbstractQueries(queries);
+ nextQueries = await nextDS.importFromAbstractQueries(abstractQueries);
+ }
+ // when datasource supports query import
+ else if (currentDS && nextDS.importQueries) {
+ nextQueries = await nextDS.importQueries(queries, currentDS);
+ }
+ // Otherwise clear queries
+ else {
return [{ refId: 'A', datasource }];
}
}
+ if (nextQueries.length === 0) {
+ return [{ refId: 'A', datasource }];
+ }
+
// Set data source on all queries except expression queries
- return queries.map((query) => {
- if (!isExpressionReference(query.datasource) && !newSettings.meta.mixed) {
+ return nextQueries.map((query) => {
+ if (!isExpressionReference(query.datasource) && !nextDS.meta.mixed) {
query.datasource = datasource;
}
return query;
diff --git a/public/app/features/search/components/DashboardSearch.test.tsx b/public/app/features/search/components/DashboardSearch.test.tsx
index 029ab069a51..8004154fc21 100644
--- a/public/app/features/search/components/DashboardSearch.test.tsx
+++ b/public/app/features/search/components/DashboardSearch.test.tsx
@@ -112,10 +112,11 @@ describe('DashboardSearch', () => {
await waitFor(() => screen.getByLabelText('Tag filter'));
const tagComponent = screen.getByLabelText('Tag filter');
- await selectOptionInTest(tagComponent, 'tag1');
-
expect(tagComponent).toBeInTheDocument();
+ tagComponent.focus();
+ await waitFor(() => selectOptionInTest(tagComponent, 'tag1'));
+
await waitFor(() =>
expect(mockSearch).toHaveBeenCalledWith({
query: '',
diff --git a/public/app/features/search/components/SearchCard.tsx b/public/app/features/search/components/SearchCard.tsx
index 06bdb6c5072..12a2d3e55a8 100644
--- a/public/app/features/search/components/SearchCard.tsx
+++ b/public/app/features/search/components/SearchCard.tsx
@@ -24,7 +24,7 @@ export function getThumbnailURL(uid: string, isLight?: boolean) {
export function SearchCard({ editable, item, onTagSelected, onToggleChecked }: Props) {
const [hasImage, setHasImage] = useState(true);
- const [lastUpdated, setLastUpdated] = useState();
+ const [lastUpdated, setLastUpdated] = useState(null);
const [showExpandedView, setShowExpandedView] = useState(false);
const timeout = useRef(null);
@@ -64,7 +64,11 @@ export function SearchCard({ editable, item, onTagSelected, onToggleChecked }: P
if (item.uid && !lastUpdated) {
const dashboard = await backendSrv.getDashboardByUid(item.uid);
const { updated } = dashboard.meta;
- setLastUpdated(new Date(updated).toLocaleString());
+ if (updated) {
+ setLastUpdated(new Date(updated).toLocaleString());
+ } else {
+ setLastUpdated(null);
+ }
}
};
diff --git a/public/app/features/search/components/SearchCardExpanded.tsx b/public/app/features/search/components/SearchCardExpanded.tsx
index f83b7d807b6..350e2b54347 100644
--- a/public/app/features/search/components/SearchCardExpanded.tsx
+++ b/public/app/features/search/components/SearchCardExpanded.tsx
@@ -11,7 +11,7 @@ export interface Props {
imageHeight: number;
imageWidth: number;
item: DashboardSectionItem;
- lastUpdated?: string;
+ lastUpdated?: string | null;
}
export function SearchCardExpanded({ className, imageHeight, imageWidth, item, lastUpdated }: Props) {
@@ -48,10 +48,12 @@ export function SearchCardExpanded({ className, imageHeight, imageWidth, item, l
{folderTitle}
-
-
Last updated
- {lastUpdated ?
{lastUpdated}
:
}
-
+ {lastUpdated !== null && (
+
+
Last updated
+ {lastUpdated ?
{lastUpdated}
:
}
+
+ )}
diff --git a/public/app/features/search/components/SearchResultsFilter.test.tsx b/public/app/features/search/components/SearchResultsFilter.test.tsx
index ff587ac1ec7..e88ce063da2 100644
--- a/public/app/features/search/components/SearchResultsFilter.test.tsx
+++ b/public/app/features/search/components/SearchResultsFilter.test.tsx
@@ -83,6 +83,7 @@ describe('SearchResultsFilter', () => {
query: { ...searchQuery, tag: [] },
});
const tagComponent = await screen.findByLabelText('Tag filter');
+ await tagComponent.focus();
await selectOptionInTest(tagComponent, 'tag1');
expect(mockFilterByTags).toHaveBeenCalledTimes(1);
diff --git a/public/app/features/search/page/SearchPage.tsx b/public/app/features/search/page/SearchPage.tsx
index a9e8ced3802..923f677bc43 100644
--- a/public/app/features/search/page/SearchPage.tsx
+++ b/public/app/features/search/page/SearchPage.tsx
@@ -63,7 +63,8 @@ export default function SearchPage() {
{results.loading &&
}
{results.value?.body && (
-
+
+
{query.datasource && (
void;
+ onCreateToken: (token: ServiceAccountToken) => void;
onClose: () => void;
}
@@ -95,7 +99,6 @@ export const CreateTokenModal = ({ isOpen, token, onCreateToken, onClose }: Crea
onClick={() =>
onCreateToken({
name: newTokenName,
- role: OrgRole.Viewer,
secondsToLive: getSecondsToLive(newTokenExpirationDate),
})
}
diff --git a/public/app/features/serviceaccounts/ServiceAccountPage.tsx b/public/app/features/serviceaccounts/ServiceAccountPage.tsx
index e8aaa35b572..061e7a0d309 100644
--- a/public/app/features/serviceaccounts/ServiceAccountPage.tsx
+++ b/public/app/features/serviceaccounts/ServiceAccountPage.tsx
@@ -3,7 +3,7 @@ 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, Role } from 'app/types';
+import { StoreState, ServiceAccountDTO, ApiKey, Role, AccessControlAction } from 'app/types';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import {
deleteServiceAccountToken,
@@ -17,7 +17,7 @@ import {
import { ServiceAccountTokensTable } from './ServiceAccountTokensTable';
import { getTimeZone, NavModel } from '@grafana/data';
import { Button } from '@grafana/ui';
-import { CreateTokenModal } from './CreateServiceAccountTokenModal';
+import { CreateTokenModal, ServiceAccountToken } from './CreateServiceAccountTokenModal';
import { contextSrv } from 'app/core/core';
interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> {
@@ -86,7 +86,7 @@ const ServiceAccountPageUnconnected = ({
deleteServiceAccountToken(parseInt(match.params.id, 10), key.id!);
};
- const onCreateToken = (token: ApiKey) => {
+ const onCreateToken = (token: ServiceAccountToken) => {
createServiceAccountToken(serviceAccount.id, token, setNewToken);
};
@@ -114,12 +114,24 @@ const ServiceAccountPageUnconnected = ({
Tokens
- setIsModalOpen(true)}>Add token
+ setIsModalOpen(true)}
+ disabled={!contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite)}
+ >
+ Add token
+
{tokens && (
)}
-
+ {contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) && (
+
+ )}
);
diff --git a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx
index 408d4346d25..368134f9077 100644
--- a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx
+++ b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx
@@ -1,9 +1,10 @@
import React, { PureComponent, useRef, useState } from 'react';
-import { Role, ServiceAccountDTO } from 'app/types';
+import { Role, ServiceAccountDTO, AccessControlAction } from 'app/types';
import { css, cx } from '@emotion/css';
import { dateTimeFormat, GrafanaTheme2, OrgRole, TimeZone } from '@grafana/data';
import { Button, ConfirmButton, ConfirmModal, Input, LegacyInputStatus, useStyles2 } from '@grafana/ui';
import { ServiceAccountRoleRow } from './ServiceAccountRoleRow';
+import { contextSrv } from 'app/core/core';
interface Props {
serviceAccount: ServiceAccountDTO;
@@ -26,6 +27,8 @@ export function ServiceAccountProfile({
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showDisableModal, setShowDisableModal] = useState(false);
+ const ableToWrite = contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite);
+
const deleteServiceAccountRef = useRef
(null);
const showDeleteServiceAccountModal = (show: boolean) => () => {
setShowDeleteModal(show);
@@ -83,9 +86,10 @@ export function ServiceAccountProfile({
Delete service account
@@ -123,7 +128,7 @@ export function ServiceAccountProfile({
/>
>
{serviceAccount.isDisabled ? (
-
+
Enable service account
) : (
@@ -133,6 +138,7 @@ export function ServiceAccountProfile({
variant="secondary"
onClick={showDisableServiceAccountModal(true)}
ref={disableServiceAccountRef}
+ disabled={!ableToWrite}
>
Disable service account
@@ -168,6 +174,7 @@ interface ServiceAccountProfileRowProps {
value?: string;
inputType?: string;
onChange?: (value: string) => void;
+ disabled?: boolean;
}
interface ServiceAccountProfileRowState {
@@ -226,6 +233,7 @@ export class ServiceAccountProfileRow extends PureComponent<
};
onSave = () => {
+ this.setState({ editing: false });
if (this.props.onChange) {
this.props.onChange(this.state.value);
}
@@ -248,7 +256,7 @@ export class ServiceAccountProfileRow extends PureComponent<
{label}
- {this.state.editing ? (
+ {!this.props.disabled && this.state.editing ? (
{this.props.onChange && (
Edit
diff --git a/public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx b/public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx
index be500c1f26e..e3c517bdef3 100644
--- a/public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx
+++ b/public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx
@@ -2,8 +2,10 @@ import React, { FC } from 'react';
import { DeleteButton, Icon, Tooltip, useStyles2, useTheme2 } from '@grafana/ui';
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
+import { AccessControlAction } from 'app/types';
import { ApiKey } from '../../types';
import { css } from '@emotion/css';
+import { contextSrv } from 'app/core/core';
interface Props {
tokens: ApiKey[];
@@ -35,9 +37,11 @@ export const ServiceAccountTokensTable: FC = ({ tokens, timeZone, onDelet
{formatDate(timeZone, key.created)}
-
- onDelete(key)} />
-
+ {contextSrv.hasPermission(AccessControlAction.ServiceAccountsDelete) && (
+
+ onDelete(key)} />
+
+ )}
);
})}
@@ -82,7 +86,7 @@ const TokenExpiration = ({ timeZone, token }: TokenExpirationProps) => {
Expired
-
+
diff --git a/public/app/features/serviceaccounts/ServiceAccountsListItem.tsx b/public/app/features/serviceaccounts/ServiceAccountsListItem.tsx
index e5d875a9be6..b2d2d3aabb8 100644
--- a/public/app/features/serviceaccounts/ServiceAccountsListItem.tsx
+++ b/public/app/features/serviceaccounts/ServiceAccountsListItem.tsx
@@ -28,7 +28,10 @@ const ServiceAccountListItem = memo(
const editUrl = `org/serviceaccounts/${serviceAccount.id}`;
const styles = useStyles2(getStyles);
const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount);
- const rolePickerDisabled = !canUpdateRole;
+ const displayRolePicker =
+ contextSrv.hasPermission(AccessControlAction.ActionRolesList) &&
+ contextSrv.hasPermission(AccessControlAction.ActionUserRolesList);
+ const enableRolePicker = contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate) && canUpdateRole;
return (
@@ -61,26 +64,30 @@ const ServiceAccountListItem = memo(
{serviceAccount.login}
-
- {contextSrv.licensedAccessControlEnabled() ? (
- onRoleChange(newRole, serviceAccount)}
- roleOptions={roleOptions}
- builtInRoles={builtInRoles}
- disabled={rolePickerDisabled}
- />
- ) : (
+ {contextSrv.licensedAccessControlEnabled() ? (
+
+ {displayRolePicker && (
+ onRoleChange(newRole, serviceAccount)}
+ roleOptions={roleOptions}
+ builtInRoles={builtInRoles}
+ disabled={!enableRolePicker}
+ />
+ )}
+
+ ) : (
+
onRoleChange(newRole, serviceAccount)}
/>
- )}
-
+
+ )}
{
return async (dispatch) => {
try {
- const options = await fetchRoleOptions();
- dispatch(acOptionsLoaded(options));
- const builtInRoles = await fetchBuiltinRoles();
- dispatch(builtInRolesLoaded(builtInRoles));
+ if (contextSrv.licensedAccessControlEnabled() && contextSrv.hasPermission(AccessControlAction.ActionRolesList)) {
+ const options = await fetchRoleOptions();
+ dispatch(acOptionsLoaded(options));
+ }
+ if (
+ contextSrv.licensedAccessControlEnabled() &&
+ contextSrv.hasPermission(AccessControlAction.ActionBuiltinRolesList)
+ ) {
+ const builtInRoles = await fetchBuiltinRoles();
+ dispatch(builtInRolesLoaded(builtInRoles));
+ }
} catch (error) {
console.error(error);
}
@@ -55,7 +64,7 @@ export function loadServiceAccount(saID: number): ThunkResult {
export function createServiceAccountToken(
saID: number,
- token: ApiKey,
+ token: ServiceAccountToken,
onTokenCreated: (key: string) => void
): ThunkResult {
return async (dispatch) => {
diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.ts b/public/app/features/transformers/calculateHeatmap/heatmap.ts
index bac9bda5086..a714be39029 100644
--- a/public/app/features/transformers/calculateHeatmap/heatmap.ts
+++ b/public/app/features/transformers/calculateHeatmap/heatmap.ts
@@ -220,7 +220,9 @@ export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCa
name: 'count',
type: FieldType.number,
values: new ArrayVector(heat2d.count),
- config: {},
+ config: {
+ unit: 'short', // always integer
+ },
},
],
};
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.test.ts
index 35a26d345c2..9f3a7fd0903 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.test.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.test.ts
@@ -28,6 +28,7 @@ describe('AzureLogAnalyticsDatasource', () => {
beforeEach(() => {
templateSrv.init([singleVariable]);
+ templateSrv.getVariables = jest.fn().mockReturnValue([singleVariable]);
ctx.instanceSettings = {
jsonData: { subscriptionId: 'xxx' },
url: 'http://azureloganalyticsapi',
@@ -120,6 +121,27 @@ describe('AzureLogAnalyticsDatasource', () => {
await ctx.ds.azureLogAnalyticsDatasource.getKustoSchema('myWorkspace/$var1');
expect(ctx.mockGetResource).lastCalledWith('loganalytics/v1myWorkspace/var1-foo/metadata');
});
+
+ it('should include macros as suggested functions', async () => {
+ const result = await ctx.ds.azureLogAnalyticsDatasource.getKustoSchema('myWorkspace');
+ expect(result.database.functions.map((f: { name: string }) => f.name)).toEqual([
+ 'Func1',
+ '_AzureBackup_GetVaults',
+ '$__timeFilter',
+ '$__timeFrom',
+ '$__timeTo',
+ '$__escapeMulti',
+ '$__contains',
+ ]);
+ });
+
+ it('should include template variables as global parameters', async () => {
+ const result = await ctx.ds.azureLogAnalyticsDatasource.getKustoSchema('myWorkspace');
+ expect(result.globalParameters.map((f: { name: string }) => f.name)).toEqual([
+ `$${singleVariable.name}`,
+ '$__timeFilter',
+ ]);
+ });
});
describe('When performing annotationQuery', () => {
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts
index c98aac12ece..6f1ece771ba 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts
@@ -112,7 +112,7 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
const templateSrv = getTemplateSrv();
const interpolatedUri = templateSrv.replace(resourceUri, {}, interpolateVariable);
const metadata = await this.getMetadata(interpolatedUri);
- return transformMetadataToKustoSchema(metadata, interpolatedUri);
+ return transformMetadataToKustoSchema(metadata, interpolatedUri, templateSrv.getVariables());
}
applyTemplateVariables(target: AzureMonitorQuery, scopedVars: ScopedVars): AzureMonitorQuery {
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/response_parser.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/response_parser.ts
index cdf82360a01..bce81329529 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/response_parser.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/response_parser.ts
@@ -1,5 +1,6 @@
+import { AnnotationEvent, dateTime, TimeSeries, VariableModel } from '@grafana/data';
import { concat, find, flattenDeep, forEach, get, map } from 'lodash';
-import { AnnotationEvent, dateTime, TimeSeries } from '@grafana/data';
+
import { AzureLogsTableData, AzureLogsVariable } from '../types';
import { AzureLogAnalyticsMetadata } from '../types/logAnalyticsMetadata';
@@ -212,7 +213,11 @@ function transformMetadataFunction(sourceSchema: AzureLogAnalyticsMetadata) {
});
}
-export function transformMetadataToKustoSchema(sourceSchema: AzureLogAnalyticsMetadata, nameOrIdOrSomething: string) {
+export function transformMetadataToKustoSchema(
+ sourceSchema: AzureLogAnalyticsMetadata,
+ nameOrIdOrSomething: string,
+ templateVariables: VariableModel[]
+) {
const database = {
name: nameOrIdOrSomething,
tables: sourceSchema.tables,
@@ -221,6 +226,79 @@ export function transformMetadataToKustoSchema(sourceSchema: AzureLogAnalyticsMe
minorVersion: 0,
};
+ // Adding macros as known functions
+ database.functions.push(
+ {
+ name: '$__timeFilter',
+ body: '{ true }',
+ inputParameters: [
+ {
+ name: 'timeColumn',
+ type: 'System.String',
+ defaultValue: 'TimeGenerated',
+ cslDefaultValue: 'TimeGenerated',
+ },
+ ],
+ },
+ {
+ name: '$__timeFrom',
+ body: '{ datetime(2018-06-05T18:09:58.907Z) }',
+ inputParameters: [],
+ },
+ {
+ name: '$__timeTo',
+ body: '{ datetime(2018-06-05T20:09:58.907Z) }',
+ inputParameters: [],
+ },
+ {
+ name: '$__escapeMulti',
+ body: `{ @'\\grafana-vm\Network(eth0)\Total', @'\\hello!'}`,
+ inputParameters: [
+ {
+ name: '$myVar',
+ type: 'System.String',
+ defaultValue: '$myVar',
+ cslDefaultValue: '$myVar',
+ },
+ ],
+ },
+ {
+ name: '$__contains',
+ body: `{ colName in ('value1','value2') }`,
+ inputParameters: [
+ {
+ name: 'colName',
+ type: 'System.String',
+ defaultValue: 'colName',
+ cslDefaultValue: 'colName',
+ },
+ {
+ name: '$myVar',
+ type: 'System.String',
+ defaultValue: '$myVar',
+ cslDefaultValue: '$myVar',
+ },
+ ],
+ }
+ );
+
+ // Adding macros as global parameters
+ const globalParameters = templateVariables.map((v) => {
+ return {
+ name: `$${v.name}`,
+ type: 'dynamic',
+ };
+ });
+
+ // It's not possible to define optional paramaters in Kusto
+ // and it's not possible to define the same function twice so
+ // we are defining $__timeFilter also as a parameter when used
+ // with no arguments as a workaround
+ globalParameters.push({
+ name: `$__timeFilter`,
+ type: 'boolean',
+ });
+
return {
clusterType: 'Engine',
cluster: {
@@ -228,5 +306,6 @@ export function transformMetadataToKustoSchema(sourceSchema: AzureLogAnalyticsMe
databases: [database],
},
database: database,
+ globalParameters,
};
}
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/QueryField.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/QueryField.tsx
index df33e362b61..b435ca4955f 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/QueryField.tsx
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/QueryField.tsx
@@ -1,6 +1,7 @@
import { CodeEditor, Monaco, MonacoEditor } from '@grafana/ui';
import { Deferred } from 'app/core/utils/deferred';
import React, { useCallback, useEffect, useRef } from 'react';
+
import { AzureQueryEditorFieldProps } from '../../types';
import { setKustoQuery } from './setQueryValue';
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/dashboards/vMInsightsRG.json b/public/app/plugins/datasource/grafana-azure-monitor-datasource/dashboards/vMInsightsRG.json
index 5dddc761e15..780b889e14f 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/dashboards/vMInsightsRG.json
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/dashboards/vMInsightsRG.json
@@ -1497,7 +1497,7 @@
"subscription": ""
}
],
- "title": "Max Available Bytes Sent and Trend Line",
+ "title": "Max Available Bytes Sent and Trend Line",
"transformations": [
{
"id": "organize",
@@ -1979,7 +1979,7 @@
"subscription": ""
}
],
- "title": "Max Available Bytes Recieved and Trend Line",
+ "title": "Max Available Bytes Received and Trend Line",
"transformations": [
{
"id": "organize",
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/dashboards/vMInsightsWorkspace.json b/public/app/plugins/datasource/grafana-azure-monitor-datasource/dashboards/vMInsightsWorkspace.json
index ee18e0db44a..551b645f398 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/dashboards/vMInsightsWorkspace.json
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/dashboards/vMInsightsWorkspace.json
@@ -1494,7 +1494,7 @@
"subscription": ""
}
],
- "title": "Max Available Bytes Sent and Trend Line",
+ "title": "Max Available Bytes Sent and Trend Line",
"transformations": [
{
"id": "organize",
@@ -1970,7 +1970,7 @@
"subscription": ""
}
],
- "title": "Max Available Bytes Recieved and Trend Line",
+ "title": "Max Available Bytes Received and Trend Line",
"transformations": [
{
"id": "organize",
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts
index d91ed228338..42997fb09f8 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts
@@ -17,6 +17,7 @@ import AzureMonitorDatasource from './azure_monitor/azure_monitor_datasource';
import AzureResourceGraphDatasource from './azure_resource_graph/azure_resource_graph_datasource';
import AppInsightsDatasource from './components/deprecated/app_insights/app_insights_datasource';
import InsightsAnalyticsDatasource from './components/deprecated/insights_analytics/insights_analytics_datasource';
+import { gtGrafana9 } from './components/deprecated/utils';
import { getAzureCloud } from './credentials';
import ResourcePickerData from './resourcePicker/resourcePickerData';
import {
@@ -174,7 +175,7 @@ export default class Datasource extends DataSourceApi {
done();
});
});
+
+ it('should return None when there is no data', (done) => {
+ const variableSupport = new VariableSupport(
+ createMockDatasource({
+ azureLogAnalyticsDatasource: {
+ defaultSubscriptionId: 'defaultSubscriptionId',
+ },
+ getMetricNames: jest.fn().mockResolvedValueOnce([]),
+ })
+ );
+ const mockRequest = {
+ targets: [
+ {
+ refId: 'A',
+ queryType: AzureQueryType.GrafanaTemplateVariableFn,
+ grafanaTemplateVariableFn: {
+ kind: 'MetricNamesQuery',
+ rawQuery: 'metricNames(resourceGroup, metricDefinition, resourceName, metricNamespace)',
+ },
+ } as AzureMonitorQuery,
+ ],
+ } as DataQueryRequest;
+ const observables = variableSupport.query(mockRequest);
+ observables.subscribe((result: DataQueryResponseData) => {
+ expect(result.data.length).toBe(0);
+ done();
+ });
+ });
});
it('passes on the query to the main datasource for all non-grafana template variable fns', (done) => {
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/variables.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/variables.ts
index 2f7b0a41cb8..6c70fa479ab 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/variables.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/variables.ts
@@ -32,7 +32,7 @@ export class VariableSupport extends CustomVariableSupport {
- return {
- requestId: 'test1',
- interval: '1s',
- intervalMs: 1000,
- range: {
- from: toUtc('2022-02-22T13:14:15'),
- to: toUtc('2022-02-22T13:15:15'),
- raw: {
- from: toUtc('2022-02-22T13:14:15'),
- to: toUtc('2022-02-22T13:15:15'),
- },
- },
- scopedVars: {},
- targets: [
- {
- refId: 'A',
- expr,
- },
- ],
- timezone: 'UTC',
- app: CoreApp.Explore,
- startTime: 0,
- };
-}
-
describe('loki backendResultTransformer', () => {
it('processes a logs-dataframe correctly', () => {
- const response: DataQueryResponse = { data: [cloneDeep(frame)] };
- const request = makeRequest('{level="info"} |= "thing1"');
+ const response: DataQueryResponse = { data: [cloneDeep(inputFrame)] };
- const expectedFrame = cloneDeep(frame);
+ const expectedFrame = cloneDeep(inputFrame);
expectedFrame.meta = {
- executedQueryString: 'something1',
+ ...expectedFrame.meta,
preferredVisualisationType: 'logs',
searchWords: ['thing1'],
custom: {
lokiQueryStatKey: 'Summary: total bytes processed',
},
};
- expectedFrame.fields[2].type = FieldType.time;
- expectedFrame.fields.push({
- name: 'id',
- type: FieldType.string,
- config: {},
- values: new ArrayVector([
- '6b099923-25a6-5336-96fa-c84a14b7c351_A',
- '0e1b7c47-a956-5cf2-a803-d487679745bd_A',
- '6f9a840c-6a00-525b-9ed4-cceea29e62af_A',
- ]),
- });
+ expectedFrame.fields[2].type = FieldType.other;
+ expectedFrame.fields[2].values = new ArrayVector([
+ { level: 'info', code: '41🌙' },
+ { level: 'error', code: '41🌙' },
+ { level: 'error', code: '43🌙' },
+ { level: 'error', code: '41🌙' },
+ { level: 'info', code: '41🌙' },
+ ]);
const expected: DataQueryResponse = { data: [expectedFrame] };
- const result = transformBackendResult(response, request);
+ const result = transformBackendResult(response, [
+ {
+ refId: 'A',
+ expr: LOKI_EXPR,
+ },
+ ]);
expect(result).toEqual(expected);
});
});
diff --git a/public/app/plugins/datasource/loki/backendResultTransformer.ts b/public/app/plugins/datasource/loki/backendResultTransformer.ts
index 2423fc81c93..ba4a6b3ebf0 100644
--- a/public/app/plugins/datasource/loki/backendResultTransformer.ts
+++ b/public/app/plugins/datasource/loki/backendResultTransformer.ts
@@ -1,8 +1,15 @@
-import { DataQueryRequest, DataQueryResponse, DataFrame, isDataFrame, FieldType, QueryResultMeta } from '@grafana/data';
+import {
+ DataQueryResponse,
+ DataFrame,
+ isDataFrame,
+ FieldType,
+ QueryResultMeta,
+ ArrayVector,
+ Labels,
+} from '@grafana/data';
import { LokiQuery, LokiQueryType } from './types';
import { makeTableFrames } from './makeTableFrames';
import { formatQuery, getHighlighterExpressionsFromQuery } from './query_utils';
-import { makeIdField } from './makeIdField';
function isMetricFrame(frame: DataFrame): boolean {
return frame.fields.every((field) => field.type === FieldType.time || field.type === FieldType.number);
@@ -19,6 +26,12 @@ function setFrameMeta(frame: DataFrame, meta: QueryResultMeta): DataFrame {
};
}
+function decodeLabelsInJson(text: string): Labels {
+ const array: Array<[string, string]> = JSON.parse(text);
+ // NOTE: maybe we should go with maps, those have guaranteed ordering
+ return Object.fromEntries(array);
+}
+
function processStreamFrame(frame: DataFrame, query: LokiQuery | undefined): DataFrame {
const meta: QueryResultMeta = {
preferredVisualisationType: 'logs',
@@ -29,21 +42,36 @@ function processStreamFrame(frame: DataFrame, query: LokiQuery | undefined): Dat
},
};
const newFrame = setFrameMeta(frame, meta);
- const newFields = frame.fields.map((field) => {
- // the nanosecond-timestamp field must have a type-time
- if (field.name === 'tsNs') {
- return {
- ...field,
- type: FieldType.time,
- };
- } else {
- return field;
+
+ const newFields = newFrame.fields.map((field) => {
+ switch (field.name) {
+ case 'labels': {
+ // the labels, when coming from the server, are json-encoded.
+ // here we decode them if needed.
+ return field.config.custom.json
+ ? {
+ name: field.name,
+ type: FieldType.other,
+ config: field.config,
+ // we are parsing the labels the same way as streaming-dataframes do
+ values: new ArrayVector(field.values.toArray().map((text) => decodeLabelsInJson(text))),
+ }
+ : field;
+ }
+ case 'tsNs': {
+ // we need to switch the field-type to be `time`
+ return {
+ ...field,
+ type: FieldType.time,
+ };
+ }
+ default: {
+ // no modification needed
+ return field;
+ }
}
});
- // we add a calculated id-field
- newFields.push(makeIdField(frame));
-
return {
...newFrame,
fields: newFields,
@@ -96,10 +124,7 @@ function groupFrames(
return { streamsFrames, metricInstantFrames, metricRangeFrames };
}
-export function transformBackendResult(
- response: DataQueryResponse,
- request: DataQueryRequest
-): DataQueryResponse {
+export function transformBackendResult(response: DataQueryResponse, queries: LokiQuery[]): DataQueryResponse {
const { data, ...rest } = response;
// in the typescript type, data is an array of basically anything.
@@ -112,7 +137,7 @@ export function transformBackendResult(
return d;
});
- const queryMap = new Map(request.targets.map((query) => [query.refId, query]));
+ const queryMap = new Map(queries.map((query) => [query.refId, query]));
const { streamsFrames, metricInstantFrames, metricRangeFrames } = groupFrames(dataFrames, queryMap);
diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts
index aca8d9795cc..e7f84a0a51f 100644
--- a/public/app/plugins/datasource/loki/datasource.test.ts
+++ b/public/app/plugins/datasource/loki/datasource.test.ts
@@ -411,7 +411,7 @@ describe('LokiDatasource', () => {
it('should not modify expression with no filters', async () => {
await lastValueFrom(ds.query(options as any));
- expect(ds.runRangeQuery).toBeCalledWith({ expr: DEFAULT_EXPR }, expect.anything(), expect.anything());
+ expect(ds.runRangeQuery).toBeCalledWith({ expr: DEFAULT_EXPR }, expect.anything());
});
it('should add filters to expression', async () => {
@@ -431,7 +431,6 @@ describe('LokiDatasource', () => {
await lastValueFrom(ds.query(options as any));
expect(ds.runRangeQuery).toBeCalledWith(
{ expr: 'rate({bar="baz",job="foo",k1="v1",k2!="v2"} |= "bar" [5m])' },
- expect.anything(),
expect.anything()
);
});
@@ -452,7 +451,6 @@ describe('LokiDatasource', () => {
await lastValueFrom(ds.query(options as any));
expect(ds.runRangeQuery).toBeCalledWith(
{ expr: 'rate({bar="baz",job="foo",k1=~"v.*",k2=~"v\\\\\'.*"} |= "bar" [5m])' },
- expect.anything(),
expect.anything()
);
});
diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts
index 944d62bbe07..9de4a813dbc 100644
--- a/public/app/plugins/datasource/loki/datasource.ts
+++ b/public/app/plugins/datasource/loki/datasource.ts
@@ -32,6 +32,7 @@ import {
QueryResultMeta,
ScopedVars,
TimeRange,
+ rangeUtil,
} from '@grafana/data';
import { BackendSrvRequest, FetchError, getBackendSrv, config, DataSourceWithBackend } from '@grafana/runtime';
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv';
@@ -79,6 +80,21 @@ const DEFAULT_QUERY_PARAMS: Partial = {
query: '',
};
+function makeRequest(query: LokiQuery, range: TimeRange, app: CoreApp, requestId: string): DataQueryRequest {
+ const intervalInfo = rangeUtil.calculateInterval(range, 1);
+ return {
+ targets: [query],
+ requestId,
+ interval: intervalInfo.interval,
+ intervalMs: intervalInfo.intervalMs,
+ range: range,
+ scopedVars: {},
+ timezone: 'UTC',
+ app,
+ startTime: Date.now(),
+ };
+}
+
export class LokiDatasource
extends DataSourceWithBackend
implements
@@ -153,15 +169,20 @@ export class LokiDatasource
...this.getRangeScopedVars(request.range),
};
- const shouldRunBackendQuery = config.featureToggles.lokiBackendMode && request.app === CoreApp.Explore;
-
- if (shouldRunBackendQuery) {
+ if (config.featureToggles.lokiBackendMode) {
// we "fix" the loki queries to have `.queryType` and not have `.instant` and `.range`
const fixedRequest = {
...request,
targets: request.targets.map(getNormalizedLokiQuery),
};
- return super.query(fixedRequest).pipe(map((response) => transformBackendResult(response, fixedRequest)));
+
+ if (fixedRequest.liveStreaming) {
+ return this.runLiveQueryThroughBackend(fixedRequest);
+ } else {
+ return super
+ .query(fixedRequest)
+ .pipe(map((response) => transformBackendResult(response, fixedRequest.targets)));
+ }
}
const filteredTargets = request.targets
@@ -184,7 +205,7 @@ export class LokiDatasource
) {
subQueries.push(doLokiChannelStream(target, this, request));
} else {
- subQueries.push(this.runRangeQuery(target, request, filteredTargets.length));
+ subQueries.push(this.runRangeQuery(target, request));
}
}
@@ -199,6 +220,27 @@ export class LokiDatasource
return merge(...subQueries);
}
+ runLiveQueryThroughBackend(request: DataQueryRequest): Observable {
+ // this only works in explore-mode, so variables don't need to be handled,
+ // and only for logs-queries, not metric queries
+ const logsQueries = request.targets.filter((query) => query.expr !== '' && !isMetricsQuery(query.expr));
+
+ if (logsQueries.length === 0) {
+ return of({
+ data: [],
+ state: LoadingState.Done,
+ });
+ }
+
+ const subQueries = logsQueries.map((query) => {
+ const maxDataPoints = query.maxLines || this.maxLines;
+ // FIXME: currently we are running it through the frontend still.
+ return this.runLiveQuery(query, maxDataPoints);
+ });
+
+ return merge(...subQueries);
+ }
+
runInstantQuery = (
target: LokiQuery,
options: DataQueryRequest,
@@ -234,7 +276,7 @@ export class LokiDatasource
}
return {
- data: [lokiResultsToTableModel(response.data.data.result, responseListLength, target.refId, meta, true)],
+ data: [lokiResultsToTableModel(response.data.data.result, responseListLength, target.refId, meta)],
key: `${target.refId}_instant`,
};
}),
@@ -275,11 +317,7 @@ export class LokiDatasource
/**
* Attempts to send a query to /loki/api/v1/query_range
*/
- runRangeQuery = (
- target: LokiQuery,
- options: RangeQueryOptions,
- responseListLength = 1
- ): Observable => {
+ runRangeQuery = (target: LokiQuery, options: RangeQueryOptions): Observable => {
// For metric query we use maxDataPoints from the request options which should be something like width of the
// visualisation in pixels. In case of logs request we either use lines limit defined in the query target or
// global limit defined for the data source which ever is lower.
@@ -306,7 +344,6 @@ export class LokiDatasource
response.data,
target,
query,
- responseListLength,
maxDataPoints,
this.instanceSettings.jsonData,
(options as DataQueryRequest).scopedVars
@@ -632,32 +669,25 @@ export class LokiDatasource
}
async annotationQuery(options: any): Promise {
- const {
- expr,
- maxLines,
- instant,
- stepInterval,
- tagKeys = '',
- titleFormat = '',
- textFormat = '',
- } = options.annotation;
+ const { expr, maxLines, instant, tagKeys = '', titleFormat = '', textFormat = '' } = options.annotation;
if (!expr) {
return [];
}
- const interpolatedExpr = this.templateSrv.replace(expr, {}, this.interpolateQueryExpr);
- const query = {
- refId: `annotation-${options.annotation.name}`,
- expr: interpolatedExpr,
+ const id = `annotation-${options.annotation.name}`;
+
+ const query: LokiQuery = {
+ refId: id,
+ expr,
maxLines,
instant,
- stepInterval,
queryType: instant ? LokiQueryType.Instant : LokiQueryType.Range,
};
- const { data } = instant
- ? await lastValueFrom(this.runInstantQuery(query, options as any))
- : await lastValueFrom(this.runRangeQuery(query, options as any));
+
+ const request = makeRequest(query, options.range, CoreApp.Dashboard, id);
+
+ const { data } = await lastValueFrom(this.query(request));
const annotations: AnnotationEvent[] = [];
const splitKeys: string[] = tagKeys.split(',').filter((v: string) => v !== '');
@@ -705,10 +735,13 @@ export class LokiDatasource
}
processError(err: FetchError, target: LokiQuery) {
- let error = cloneDeep(err);
- if (err.data.message.includes('escape') && target.expr.includes('\\')) {
+ let error: DataQueryError = cloneDeep(err);
+ error.refId = target.refId;
+
+ if (error.data && err.data.message.includes('escape') && target.expr.includes('\\')) {
error.data.message = `Error: ${err.data.message}. Make sure that all special characters are escaped with \\. For more information on escaping of special characters visit LogQL documentation at https://grafana.com/docs/loki/latest/logql/.`;
}
+
return error;
}
@@ -766,7 +799,7 @@ export class LokiDatasource
}
// Used when running queries through backend
- applyTemplateVariables(target: LokiQuery, scopedVars: ScopedVars): Record {
+ applyTemplateVariables(target: LokiQuery, scopedVars: ScopedVars): LokiQuery {
// We want to interpolate these variables on backend
const { __interval, __interval_ms, ...rest } = scopedVars;
diff --git a/public/app/plugins/datasource/loki/makeIdField.test.ts b/public/app/plugins/datasource/loki/makeIdField.test.ts
deleted file mode 100644
index 04ab5c10e8d..00000000000
--- a/public/app/plugins/datasource/loki/makeIdField.test.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { ArrayVector, DataFrame, FieldType } from '@grafana/data';
-import { makeIdField } from './makeIdField';
-
-function makeFrame(timestamps: number[], values: string[], timestampNss: string[], refId?: string): DataFrame {
- return {
- name: 'frame',
- refId,
- meta: {
- executedQueryString: 'something1',
- },
- fields: [
- {
- name: 'Time',
- type: FieldType.time,
- config: {},
- values: new ArrayVector(timestamps),
- },
- {
- name: 'Value',
- type: FieldType.string,
- config: {},
- labels: {
- foo: 'bar',
- },
- values: new ArrayVector(values),
- },
- {
- name: 'tsNs',
- type: FieldType.time,
- config: {},
- values: new ArrayVector(timestampNss),
- },
- ],
- length: timestamps.length,
- };
-}
-
-describe('loki makeIdField', () => {
- it('should always generate unique ids for logs', () => {
- const frame = makeFrame(
- [1579857562021, 1579857562021, 1579857562021, 1579857562021],
- [
- 't=2020-02-12T15:04:51+0000 lvl=info msg="Duplicated"',
- 't=2020-02-12T15:04:51+0000 lvl=info msg="Duplicated"',
- 't=2020-02-12T15:04:51+0000 lvl=info msg="Non-Duplicated"',
- 't=2020-02-12T15:04:51+0000 lvl=info msg="Duplicated"',
- ],
- ['1579857562021616000', '1579857562021616000', '1579857562021616000', '1579857562021616000']
- );
- expect(makeIdField(frame)).toEqual({
- config: {},
- name: 'id',
- type: 'string',
- values: new ArrayVector([
- '75fceace-9f98-5134-b222-643fdcde2877',
- '75fceace-9f98-5134-b222-643fdcde2877_1',
- '4a081a89-040d-5f64-9477-a4d846ce9f6b',
- '75fceace-9f98-5134-b222-643fdcde2877_2',
- ]),
- });
- });
-
- it('should append refId to the unique ids if refId is provided', () => {
- const frame = makeFrame(
- [1579857562021, 1579857562021, 1579857562021, 1579857562021],
- [
- 't=2020-02-12T15:04:51+0000 lvl=info msg="Duplicated"',
- 't=2020-02-12T15:04:51+0000 lvl=info msg="Duplicated"',
- 't=2020-02-12T15:04:51+0000 lvl=info msg="Non-Duplicated"',
- 't=2020-02-12T15:04:51+0000 lvl=info msg="Duplicated"',
- ],
- ['1579857562021616000', '1579857562021616000', '1579857562021616000', '1579857562021616000'],
- 'X'
- );
- expect(makeIdField(frame)).toEqual({
- config: {},
- name: 'id',
- type: 'string',
- values: new ArrayVector([
- '75fceace-9f98-5134-b222-643fdcde2877_X',
- '75fceace-9f98-5134-b222-643fdcde2877_1_X',
- '4a081a89-040d-5f64-9477-a4d846ce9f6b_X',
- '75fceace-9f98-5134-b222-643fdcde2877_2_X',
- ]),
- });
- });
-});
diff --git a/public/app/plugins/datasource/loki/makeIdField.ts b/public/app/plugins/datasource/loki/makeIdField.ts
deleted file mode 100644
index 02550b07a62..00000000000
--- a/public/app/plugins/datasource/loki/makeIdField.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { v5 as uuidv5 } from 'uuid';
-
-import { ArrayVector, DataFrame, Field, FieldType, Labels } from '@grafana/data';
-
-const UUID_NAMESPACE = '6ec946da-0f49-47a8-983a-1d76d17e7c92';
-
-function createUid(text: string, usedUids: Map, refId?: string): string {
- const id = uuidv5(text, UUID_NAMESPACE);
-
- // check how many times have we seen this id before,
- // set the count to zero, if never.
- const count = usedUids.get(id) ?? 0;
-
- // if we have seen this id before, we need to make
- // it unique by appending the seen-count
- // (starts with 1, and goes up)
- const uniqueId = count > 0 ? `${id}_${count}` : id;
-
- // we increment the counter for this id, to be used when we are called the next time
- usedUids.set(id, count + 1);
-
- // we add refId to the end, if it is available
- return refId !== undefined ? `${uniqueId}_${refId}` : uniqueId;
-}
-
-export function makeIdField(frame: DataFrame): Field {
- const allLabels: Labels = {};
-
- // collect labels from every field
- frame.fields.forEach((field) => {
- Object.assign(allLabels, field.labels);
- });
-
- const labelsString = Object.entries(allLabels)
- .map(([key, val]) => `${key}="${val}"`)
- .sort()
- .join('');
-
- const usedUids = new Map();
-
- const { length } = frame;
-
- const uids: string[] = new Array(length);
-
- // we need to go through the dataframe "row by row"
- for (let i = 0; i < length; i++) {
- const row = frame.fields.map((f) => String(f.values.get(i)));
- const text = `${labelsString}_${row.join('_')}`;
- const uid = createUid(text, usedUids, frame.refId);
- uids[i] = uid;
- }
-
- return { name: 'id', type: FieldType.string, config: {}, values: new ArrayVector(uids) };
-}
diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx
index 69da0eb0828..9b84ad88348 100644
--- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx
+++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx
@@ -7,7 +7,6 @@ import { QueryBuilderLabelFilter } from 'app/plugins/datasource/prometheus/query
import { lokiQueryModeller } from '../LokiQueryModeller';
import { DataSourceApi, SelectableValue } from '@grafana/data';
import { EditorRow } from '@grafana/experimental';
-import { QueryPreview } from './QueryPreview';
import { OperationsEditorRow } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationsEditorRow';
import { NestedQueryList } from './NestedQueryList';
@@ -84,11 +83,6 @@ export const LokiQueryBuilder = React.memo(({ datasource, query, nested,
{query.binaryQueries && query.binaryQueries.length > 0 && (
)}
- {!nested && (
-
-
-
- )}
>
);
});
diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderContainer.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderContainer.test.tsx
new file mode 100644
index 00000000000..80717a06fea
--- /dev/null
+++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderContainer.test.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { LokiQueryBuilderContainer } from './LokiQueryBuilderContainer';
+import { LokiDatasource } from '../../datasource';
+import { addOperation } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationList.testUtils';
+
+describe('LokiQueryBuilderContainer', () => {
+ it('translates query between string and model', async () => {
+ const props = {
+ query: {
+ expr: '{job="testjob"}',
+ refId: 'A',
+ },
+ datasource: new LokiDatasource(
+ {
+ id: 1,
+ uid: '',
+ type: 'loki',
+ name: 'loki-test',
+ access: 'proxy',
+ url: '',
+ jsonData: {},
+ meta: {} as any,
+ },
+ undefined,
+ undefined
+ ),
+ onChange: jest.fn(),
+ onRunQuery: () => {},
+ };
+ render( );
+ expect(screen.getByText('testjob')).toBeInTheDocument();
+ addOperation('Range functions', 'Rate');
+ expect(props.onChange).toBeCalledWith({
+ expr: 'rate({job="testjob"} [$__interval])',
+ refId: 'A',
+ });
+ });
+});
diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderContainer.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderContainer.tsx
new file mode 100644
index 00000000000..3a428319d78
--- /dev/null
+++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderContainer.tsx
@@ -0,0 +1,82 @@
+import React, { useEffect, useReducer } from 'react';
+import { LokiDatasource } from '../../datasource';
+import { LokiQuery } from '../../types';
+import { buildVisualQueryFromString } from '../parsing';
+import { lokiQueryModeller } from '../LokiQueryModeller';
+import { LokiQueryBuilder } from './LokiQueryBuilder';
+import { QueryPreview } from './QueryPreview';
+import { LokiVisualQuery } from '../types';
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+export interface Props {
+ query: LokiQuery;
+ datasource: LokiDatasource;
+ onChange: (update: LokiQuery) => void;
+ onRunQuery: () => void;
+}
+
+export interface State {
+ visQuery?: LokiVisualQuery;
+ expr: string;
+}
+
+/**
+ * This component is here just to contain the translation logic between string query and the visual query builder model.
+ */
+export function LokiQueryBuilderContainer(props: Props) {
+ const { query, onChange, onRunQuery, datasource } = props;
+ const [state, dispatch] = useReducer(stateSlice.reducer, {
+ expr: '',
+ visQuery: {
+ labels: [],
+ operations: [{ id: '__line_contains', params: [''] }],
+ },
+ });
+
+ // Only rebuild visual query if expr changes from outside
+ useEffect(() => {
+ dispatch(exprChanged(query.expr));
+ }, [query.expr]);
+
+ const onVisQueryChange = (visQuery: LokiVisualQuery) => {
+ const expr = lokiQueryModeller.renderQuery(visQuery);
+ dispatch(visualQueryChange({ visQuery, expr }));
+ onChange({ ...props.query, expr: expr });
+ };
+
+ if (!state.visQuery) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+const stateSlice = createSlice({
+ name: 'prom-builder-container',
+ initialState: { expr: '' } as State,
+ reducers: {
+ visualQueryChange: (state, action: PayloadAction<{ visQuery: LokiVisualQuery; expr: string }>) => {
+ state.expr = action.payload.expr;
+ state.visQuery = action.payload.visQuery;
+ },
+ exprChanged: (state, action: PayloadAction) => {
+ if (!state.visQuery || state.expr !== action.payload) {
+ state.expr = action.payload;
+ const parseResult = buildVisualQueryFromString(action.payload);
+ state.visQuery = parseResult.query;
+ }
+ },
+ },
+});
+
+const { visualQueryChange, exprChanged } = stateSlice.actions;
diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplaind.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplained.tsx
similarity index 79%
rename from public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplaind.tsx
rename to public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplained.tsx
index 69e6115a8e6..9b53334a453 100644
--- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplaind.tsx
+++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderExplained.tsx
@@ -4,19 +4,22 @@ import { Stack } from '@grafana/experimental';
import { lokiQueryModeller } from '../LokiQueryModeller';
import { OperationListExplained } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationListExplained';
import { OperationExplainedBox } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationExplainedBox';
+import { buildVisualQueryFromString } from '../parsing';
export interface Props {
- query: LokiVisualQuery;
+ query: string;
nested?: boolean;
}
export const LokiQueryBuilderExplained = React.memo(({ query, nested }) => {
+ const visQuery = buildVisualQueryFromString(query || '').query;
+
return (
-
+
Fetch all log lines matching label filters.
- stepNumber={2} queryModeller={lokiQueryModeller} query={query} />
+ stepNumber={2} queryModeller={lokiQueryModeller} query={visQuery} />
);
});
diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.test.tsx
index 46e6fa8a22e..c655ffcac96 100644
--- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.test.tsx
+++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.test.tsx
@@ -79,13 +79,6 @@ describe('LokiQueryEditorSelector', () => {
expr: defaultQuery.expr,
queryType: LokiQueryType.Range,
editorMode: QueryEditorMode.Builder,
- visualQuery: {
- labels: [
- { label: 'label1', op: '=', value: 'foo' },
- { label: 'label2', op: '=', value: 'bar' },
- ],
- operations: [],
- },
});
});
diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx
index 3223069ada1..58f0de41fe9 100644
--- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx
+++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx
@@ -6,13 +6,10 @@ import { QueryEditorModeToggle } from 'app/plugins/datasource/prometheus/querybu
import { QueryEditorMode } from 'app/plugins/datasource/prometheus/querybuilder/shared/types';
import React, { useCallback, useState } from 'react';
import { LokiQueryEditorProps } from '../../components/types';
-import { LokiQuery } from '../../types';
-
import { lokiQueryModeller } from '../LokiQueryModeller';
import { getQueryWithDefaults } from '../state';
-import { getDefaultEmptyQuery, LokiVisualQuery } from '../types';
-import { LokiQueryBuilder } from './LokiQueryBuilder';
-import { LokiQueryBuilderExplained } from './LokiQueryBuilderExplaind';
+import { LokiQueryBuilderContainer } from './LokiQueryBuilderContainer';
+import { LokiQueryBuilderExplained } from './LokiQueryBuilderExplained';
import { LokiQueryBuilderOptions } from './LokiQueryBuilderOptions';
import { LokiQueryCodeEditor } from './LokiQueryCodeEditor';
import { buildVisualQueryFromString } from '../parsing';
@@ -21,43 +18,26 @@ export const LokiQueryEditorSelector = React.memo((props)
const { onChange, onRunQuery, data } = props;
const styles = useStyles2(getStyles);
const query = getQueryWithDefaults(props.query);
- const [visualQuery, setVisualQuery] = useState(query.visualQuery ?? getDefaultEmptyQuery());
const [parseModalOpen, setParseModalOpen] = useState(false);
- const [pendingChange, setPendingChange] = useState(undefined);
const onEditorModeChange = useCallback(
(newMetricEditorMode: QueryEditorMode) => {
const change = { ...query, editorMode: newMetricEditorMode };
if (newMetricEditorMode === QueryEditorMode.Builder) {
- const result = buildVisualQueryFromString(query.expr);
- change.visualQuery = result.query;
+ 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.
if (result.errors.length) {
setParseModalOpen(true);
- setPendingChange(change);
return;
}
- setVisualQuery(change.visualQuery);
}
onChange(change);
},
[onChange, query]
);
- const onChangeViewModel = (updatedQuery: LokiVisualQuery) => {
- setVisualQuery(updatedQuery);
-
- onChange({
- ...query,
- expr: lokiQueryModeller.renderQuery(updatedQuery),
- visualQuery: updatedQuery,
- editorMode: QueryEditorMode.Builder,
- });
- };
-
// If no expr (ie new query) then default to builder
const editorMode = query.editorMode ?? (query.expr ? QueryEditorMode.Code : QueryEditorMode.Builder);
-
return (
<>
((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={() => {
- setVisualQuery(pendingChange!.visualQuery!);
- onChange(pendingChange!);
+ onChange({ ...query, editorMode: QueryEditorMode.Builder });
setParseModalOpen(false);
}}
onDismiss={() => setParseModalOpen(false)}
@@ -90,27 +69,29 @@ export const LokiQueryEditorSelector = React.memo((props)
placeholder="Query patterns"
allowCustomValue
onChange={({ value }) => {
- onChangeViewModel({
- ...visualQuery,
- operations: value?.operations!,
+ const result = buildVisualQueryFromString(query.expr || '');
+ result.query.operations = value?.operations!;
+ onChange({
+ ...query,
+ expr: lokiQueryModeller.renderQuery(result.query),
});
}}
options={lokiQueryModeller.getQueryPatterns().map((x) => ({ label: x.name, value: x }))}
/>
-
+
{editorMode === QueryEditorMode.Code && }
{editorMode === QueryEditorMode.Builder && (
-
)}
- {editorMode === QueryEditorMode.Explain && }
+ {editorMode === QueryEditorMode.Explain && }
{editorMode !== QueryEditorMode.Explain && (
)}
diff --git a/public/app/plugins/datasource/loki/querybuilder/components/QueryPreview.tsx b/public/app/plugins/datasource/loki/querybuilder/components/QueryPreview.tsx
index 55ef0adc1c6..4fd6592d8b6 100644
--- a/public/app/plugins/datasource/loki/querybuilder/components/QueryPreview.tsx
+++ b/public/app/plugins/datasource/loki/querybuilder/components/QueryPreview.tsx
@@ -1,39 +1,38 @@
import React from 'react';
-import { LokiVisualQuery } from '../types';
import { useTheme2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { css, cx } from '@emotion/css';
-import { EditorField, EditorFieldGroup } from '@grafana/experimental';
+import { EditorField, EditorFieldGroup, EditorRow } from '@grafana/experimental';
import Prism from 'prismjs';
import { lokiGrammar } from '../../syntax';
-import { lokiQueryModeller } from '../LokiQueryModeller';
export interface Props {
- query: LokiVisualQuery;
+ query: string;
}
export function QueryPreview({ query }: Props) {
const theme = useTheme2();
const styles = getStyles(theme);
- const hightlighted = Prism.highlight(lokiQueryModeller.renderQuery(query), lokiGrammar, 'lokiql');
+ const highlighted = Prism.highlight(query, lokiGrammar, 'lokiql');
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
editorField: css({
- padding: theme.spacing(0.25, 1),
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.bodySmall.fontSize,
}),
diff --git a/public/app/plugins/datasource/loki/querybuilder/operations.ts b/public/app/plugins/datasource/loki/querybuilder/operations.ts
index d44f3e9ba7a..25c1cac13f2 100644
--- a/public/app/plugins/datasource/loki/querybuilder/operations.ts
+++ b/public/app/plugins/datasource/loki/querybuilder/operations.ts
@@ -1,5 +1,8 @@
-import { createAggregationOperation } from '../../prometheus/querybuilder/aggregations';
-import { getPromAndLokiOperationDisplayName } from '../../prometheus/querybuilder/shared/operationUtils';
+import {
+ createAggregationOperation,
+ createAggregationOperationWithParam,
+ getPromAndLokiOperationDisplayName,
+} from '../../prometheus/querybuilder/shared/operationUtils';
import {
QueryBuilderOperation,
QueryBuilderOperationDef,
@@ -16,8 +19,6 @@ export function getOperationDefinitions(): QueryBuilderOperationDef[] {
LokiOperationId.Min,
LokiOperationId.Max,
LokiOperationId.Avg,
- LokiOperationId.TopK,
- LokiOperationId.BottomK,
LokiOperationId.Stddev,
LokiOperationId.Stdvar,
LokiOperationId.Count,
@@ -28,6 +29,20 @@ export function getOperationDefinitions(): QueryBuilderOperationDef[] {
})
);
+ const aggregationsWithParam = [LokiOperationId.TopK, LokiOperationId.BottomK].flatMap((opId) => {
+ return createAggregationOperationWithParam(
+ opId,
+ {
+ params: [{ name: 'K-value', type: 'number' }],
+ defaultParams: [5],
+ },
+ {
+ addOperationHandler: addLokiOperation,
+ orderRank: LokiOperationOrder.Last,
+ }
+ );
+ });
+
const list: QueryBuilderOperationDef[] = [
createRangeOperation(LokiOperationId.Rate),
createRangeOperation(LokiOperationId.CountOverTime),
@@ -44,6 +59,7 @@ export function getOperationDefinitions(): QueryBuilderOperationDef[] {
createRangeOperation(LokiOperationId.StddevOverTime),
createRangeOperation(LokiOperationId.QuantileOverTime),
...aggregations,
+ ...aggregationsWithParam,
{
id: LokiOperationId.Json,
name: 'Json',
@@ -145,7 +161,7 @@ export function getOperationDefinitions(): QueryBuilderOperationDef[] {
renderer: (model, def, innerExpr) => `${innerExpr} | line_format \`${model.params[0]}\``,
addOperationHandler: addLokiOperation,
explainHandler: () =>
- `This will replace log line using a specified template. The template can refer to stream labels and extracted labels.
+ `This will replace log line using a specified template. The template can refer to stream labels and extracted labels.
Example: \`{{.status_code}} - {{.message}}\`
@@ -166,7 +182,7 @@ export function getOperationDefinitions(): QueryBuilderOperationDef[] {
renderer: (model, def, innerExpr) => `${innerExpr} | label_format ${model.params[1]}=\`${model.params[0]}\``,
addOperationHandler: addLokiOperation,
explainHandler: () =>
- `This will change name of label to desired new label. In the example below, label "error_level" will be renamed to "level".
+ `This will change name of label to desired new label. In the example below, label "error_level" will be renamed to "level".
Example: error_level=\`level\`
diff --git a/public/app/plugins/datasource/loki/querybuilder/types.ts b/public/app/plugins/datasource/loki/querybuilder/types.ts
index 36939dc783d..63aaa7f7481 100644
--- a/public/app/plugins/datasource/loki/querybuilder/types.ts
+++ b/public/app/plugins/datasource/loki/querybuilder/types.ts
@@ -90,10 +90,3 @@ export enum LokiOperationOrder {
RangeVectorFunction = 5,
Last = 6,
}
-
-export function getDefaultEmptyQuery(): LokiVisualQuery {
- return {
- labels: [],
- operations: [{ id: '__line_contains', params: [''] }],
- };
-}
diff --git a/public/app/plugins/datasource/loki/result_transformer.ts b/public/app/plugins/datasource/loki/result_transformer.ts
index 31760ee0d08..c71fdf9bc86 100644
--- a/public/app/plugins/datasource/loki/result_transformer.ts
+++ b/public/app/plugins/datasource/loki/result_transformer.ts
@@ -226,8 +226,7 @@ export function lokiResultsToTableModel(
lokiResults: Array,
resultCount: number,
refId: string,
- meta: QueryResultMeta,
- valueWithRefId?: boolean
+ meta: QueryResultMeta
): TableModel {
if (!lokiResults || lokiResults.length === 0) {
return new TableModel();
@@ -246,7 +245,7 @@ export function lokiResultsToTableModel(
table.columns = [
{ text: 'Time', type: FieldType.time },
...sortedLabels.map((label) => ({ text: label, filterable: true, type: FieldType.string })),
- { text: resultCount > 1 || valueWithRefId ? `Value #${refId}` : 'Value', type: FieldType.number },
+ { text: `Value #${refId}`, type: FieldType.number },
];
// Populate rows, set value to empty string when label not present.
@@ -453,7 +452,6 @@ function rangeQueryResponseToTimeSeries(
response: LokiResponse,
query: LokiRangeQueryRequest,
target: LokiQuery,
- responseListLength: number,
scopedVars: ScopedVars
): TimeSeries[] {
/** Show results of Loki metric queries only in graph */
@@ -461,16 +459,10 @@ function rangeQueryResponseToTimeSeries(
preferredVisualisationType: 'graph',
};
const transformerOptions: TransformerOptions = {
- format: target.format,
legendFormat: target.legendFormat ?? '',
- start: query.start!,
- end: query.end!,
- step: query.step!,
query: query.query,
- responseListLength,
refId: target.refId,
meta,
- valueWithRefId: target.valueWithRefId,
scopedVars,
};
@@ -490,10 +482,9 @@ export function rangeQueryResponseToDataFrames(
response: LokiResponse,
query: LokiRangeQueryRequest,
target: LokiQuery,
- responseListLength: number,
scopedVars: ScopedVars
): DataFrame[] {
- const series = rangeQueryResponseToTimeSeries(response, query, target, responseListLength, scopedVars);
+ const series = rangeQueryResponseToTimeSeries(response, query, target, scopedVars);
const frames = series.map((s) => toDataFrame(s));
const { step } = query;
@@ -517,7 +508,6 @@ export function processRangeQueryResponse(
response: LokiResponse,
target: LokiQuery,
query: LokiRangeQueryRequest,
- responseListLength: number,
limit: number,
config: LokiOptions,
scopedVars: ScopedVars
@@ -532,16 +522,7 @@ export function processRangeQueryResponse(
case LokiResultType.Vector:
case LokiResultType.Matrix:
return of({
- data: rangeQueryResponseToDataFrames(
- response,
- query,
- {
- ...target,
- format: 'time_series',
- },
- responseListLength,
- scopedVars
- ),
+ data: rangeQueryResponseToDataFrames(response, query, target, scopedVars),
key: target.refId,
});
default:
diff --git a/public/app/plugins/datasource/loki/types.ts b/public/app/plugins/datasource/loki/types.ts
index da887ec18a9..f1e91c3b279 100644
--- a/public/app/plugins/datasource/loki/types.ts
+++ b/public/app/plugins/datasource/loki/types.ts
@@ -1,6 +1,5 @@
import { DataQuery, DataSourceJsonData, QueryResultMeta, ScopedVars } from '@grafana/data';
import { QueryEditorMode } from '../prometheus/querybuilder/shared/types';
-import { LokiVisualQuery } from './querybuilder/types';
export interface LokiInstantQueryRequest {
query: string;
@@ -33,10 +32,7 @@ export enum LokiQueryType {
export interface LokiQuery extends DataQuery {
queryType?: LokiQueryType;
expr: string;
- query?: string;
- format?: string;
legendFormat?: string;
- valueWithRefId?: boolean;
maxLines?: number;
resolution?: number;
/** Used in range queries */
@@ -46,8 +42,6 @@ export interface LokiQuery extends DataQuery {
/* @deprecated now use queryType */
instant?: boolean;
editorMode?: QueryEditorMode;
- /** Temporary until we have a parser */
- visualQuery?: LokiVisualQuery;
}
export interface LokiOptions extends DataSourceJsonData {
@@ -134,15 +128,9 @@ export type DerivedFieldConfig = {
};
export interface TransformerOptions {
- format?: string;
legendFormat?: string;
- step: number;
- start: number;
- end: number;
query: string;
- responseListLength: number;
refId: string;
scopedVars: ScopedVars;
meta?: QueryResultMeta;
- valueWithRefId?: boolean;
}
diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.test.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.test.tsx
index fef0655132a..ef78b652ea9 100644
--- a/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.test.tsx
+++ b/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.test.tsx
@@ -1,24 +1,41 @@
import React from 'react';
import { render, RenderResult } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { PromQueryEditorByApp } from './PromQueryEditorByApp';
import { CoreApp } from '@grafana/data';
import { noop } from 'lodash';
import { PrometheusDatasource } from '../datasource';
import { testIds as alertingTestIds } from './PromQueryEditorForAlerting';
import { testIds as regularTestIds } from './PromQueryEditor';
-import { testIds as exploreTestIds } from './PromExploreQueryEditor';
// the monaco-based editor uses lazy-loading and that does not work
// well with this test, and we do not need the monaco-related
// functionality in this test anyway, so we mock it out.
-jest.mock('./monaco-query-field/MonacoQueryFieldWrapper', () => {
- const fakeQueryField = () => prometheus query field
;
+jest.mock('./monaco-query-field/MonacoQueryFieldLazy', () => {
+ const fakeQueryField = (props: any) => {
+ return ;
+ };
return {
- MonacoQueryFieldWrapper: fakeQueryField,
+ MonacoQueryFieldLazy: fakeQueryField,
};
});
-function setup(app: CoreApp): RenderResult {
+jest.mock('@grafana/runtime', () => {
+ const runtime = jest.requireActual('@grafana/runtime');
+ return {
+ __esModule: true,
+ ...runtime,
+ config: {
+ ...runtime.config,
+ featureToggles: {
+ ...runtime.config.featureToggles,
+ promQueryBuilder: true,
+ },
+ },
+ };
+});
+
+function setup(app: CoreApp): RenderResult & { onRunQuery: jest.Mock } {
const dataSource = {
createQuery: jest.fn((q) => q),
getInitHints: () => [],
@@ -30,16 +47,22 @@ function setup(app: CoreApp): RenderResult {
metrics: [],
},
} as unknown as PrometheusDatasource;
+ const onRunQuery = jest.fn();
- return render(
+ const renderOutput = render(
);
+
+ return {
+ ...renderOutput,
+ onRunQuery,
+ };
}
describe('PromQueryEditorByApp', () => {
@@ -50,24 +73,44 @@ describe('PromQueryEditorByApp', () => {
expect(queryByTestId(regularTestIds.editor)).toBeNull();
});
- it('should render regular query editor for unkown apps', () => {
+ it('should render editor selector for unkown apps', () => {
const { getByTestId, queryByTestId } = setup(CoreApp.Unknown);
- expect(getByTestId(regularTestIds.editor)).toBeInTheDocument();
+ expect(getByTestId('QueryEditorModeToggle')).toBeInTheDocument();
expect(queryByTestId(alertingTestIds.editor)).toBeNull();
});
- it('should render regular query editor for explore', () => {
+ it('should render editor selector for explore', () => {
const { getByTestId, queryByTestId } = setup(CoreApp.Explore);
- expect(getByTestId(exploreTestIds.editor)).toBeInTheDocument();
+ expect(getByTestId('QueryEditorModeToggle')).toBeInTheDocument();
expect(queryByTestId(alertingTestIds.editor)).toBeNull();
});
- it('should render regular query editor for dashboard', () => {
+ it('should render editor selector for dashboard', () => {
const { getByTestId, queryByTestId } = setup(CoreApp.Dashboard);
- expect(getByTestId(regularTestIds.editor)).toBeInTheDocument();
+ expect(getByTestId('QueryEditorModeToggle')).toBeInTheDocument();
expect(queryByTestId(alertingTestIds.editor)).toBeNull();
});
+
+ it('should not run query onBlur in explore', () => {
+ const { getByTestId, onRunQuery } = setup(CoreApp.Explore);
+
+ const input = getByTestId('dummy-code-input');
+ expect(input).toBeInTheDocument();
+ userEvent.type(input, 'metric');
+ input.blur();
+ expect(onRunQuery).not.toHaveBeenCalled();
+ });
+
+ it('should run query onBlur in dashboard', () => {
+ const { getByTestId, onRunQuery } = setup(CoreApp.Dashboard);
+
+ const input = getByTestId('dummy-code-input');
+ expect(input).toBeInTheDocument();
+ userEvent.type(input, 'metric');
+ input.blur();
+ expect(onRunQuery).toHaveBeenCalled();
+ });
});
diff --git a/public/app/plugins/datasource/prometheus/querybuilder/aggregations.ts b/public/app/plugins/datasource/prometheus/querybuilder/aggregations.ts
index 6ecf67fadf8..ea3e2c4f837 100644
--- a/public/app/plugins/datasource/prometheus/querybuilder/aggregations.ts
+++ b/public/app/plugins/datasource/prometheus/querybuilder/aggregations.ts
@@ -1,18 +1,11 @@
-import pluralize from 'pluralize';
-import { LabelParamEditor } from './components/LabelParamEditor';
import { addOperationWithRangeVector } from './operations';
import {
- defaultAddOperationHandler,
- functionRendererLeft,
+ createAggregationOperation,
+ createAggregationOperationWithParam,
getPromAndLokiOperationDisplayName,
getRangeVectorParamDef,
} from './shared/operationUtils';
-import {
- QueryBuilderOperation,
- QueryBuilderOperationDef,
- QueryBuilderOperationParamDef,
- QueryWithOperations,
-} from './shared/types';
+import { QueryBuilderOperation, QueryBuilderOperationDef } from './shared/types';
import { PromVisualQueryOperationCategory, PromOperationId } from './types';
export function getAggregationOperations(): QueryBuilderOperationDef[] {
@@ -22,8 +15,18 @@ export function getAggregationOperations(): QueryBuilderOperationDef[] {
...createAggregationOperation(PromOperationId.Min),
...createAggregationOperation(PromOperationId.Max),
...createAggregationOperation(PromOperationId.Count),
- ...createAggregationOperation(PromOperationId.TopK),
- ...createAggregationOperation(PromOperationId.BottomK),
+ ...createAggregationOperationWithParam(PromOperationId.TopK, {
+ params: [{ name: 'K-value', type: 'number' }],
+ defaultParams: [5],
+ }),
+ ...createAggregationOperationWithParam(PromOperationId.BottomK, {
+ params: [{ name: 'K-value', type: 'number' }],
+ defaultParams: [5],
+ }),
+ ...createAggregationOperationWithParam(PromOperationId.CountValues, {
+ params: [{ name: 'Identifier', type: 'string' }],
+ defaultParams: ['count'],
+ }),
createAggregationOverTime(PromOperationId.SumOverTime),
createAggregationOverTime(PromOperationId.AvgOverTime),
createAggregationOverTime(PromOperationId.MinOverTime),
@@ -36,162 +39,6 @@ export function getAggregationOperations(): QueryBuilderOperationDef[] {
];
}
-/**
- * This function is shared between Prometheus and Loki variants
- */
-export function createAggregationOperation(
- name: string,
- overrides: Partial = {}
-): QueryBuilderOperationDef[] {
- const operations: QueryBuilderOperationDef[] = [
- {
- id: name,
- name: getPromAndLokiOperationDisplayName(name),
- params: [
- {
- name: 'By label',
- type: 'string',
- restParam: true,
- optional: true,
- },
- ],
- defaultParams: [],
- alternativesKey: 'plain aggregations',
- category: PromVisualQueryOperationCategory.Aggregations,
- renderer: functionRendererLeft,
- paramChangedHandler: getOnLabelAdddedHandler(`__${name}_by`),
- explainHandler: getAggregationExplainer(name, ''),
- addOperationHandler: defaultAddOperationHandler,
- ...overrides,
- },
- {
- id: `__${name}_by`,
- name: `${getPromAndLokiOperationDisplayName(name)} by`,
- params: [
- {
- name: 'Label',
- type: 'string',
- restParam: true,
- optional: true,
- editor: LabelParamEditor,
- },
- ],
- defaultParams: [''],
- alternativesKey: 'aggregations by',
- category: PromVisualQueryOperationCategory.Aggregations,
- renderer: getAggregationByRenderer(name),
- paramChangedHandler: getLastLabelRemovedHandler(name),
- explainHandler: getAggregationExplainer(name, 'by'),
- addOperationHandler: defaultAddOperationHandler,
- hideFromList: true,
- ...overrides,
- },
- {
- id: `__${name}_without`,
- name: `${getPromAndLokiOperationDisplayName(name)} without`,
- params: [
- {
- name: 'Label',
- type: 'string',
- restParam: true,
- optional: true,
- editor: LabelParamEditor,
- },
- ],
- defaultParams: [''],
- alternativesKey: 'aggregations by',
- category: PromVisualQueryOperationCategory.Aggregations,
- renderer: getAggregationWithoutRenderer(name),
- paramChangedHandler: getLastLabelRemovedHandler(name),
- explainHandler: getAggregationExplainer(name, 'without'),
- addOperationHandler: defaultAddOperationHandler,
- hideFromList: true,
- ...overrides,
- },
- ];
-
- // Handle some special aggregations that have parameters
- if (name === 'topk' || name === 'bottomk') {
- const param: QueryBuilderOperationParamDef = {
- name: 'K-value',
- type: 'number',
- };
- operations[0].params.unshift(param);
- operations[1].params.unshift(param);
- operations[0].defaultParams = [5];
- operations[1].defaultParams = [5, ''];
- operations[1].renderer = getAggregationByRendererWithParameter(name);
- }
-
- return operations;
-}
-
-function getAggregationByRenderer(aggregation: string) {
- return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) {
- return `${aggregation} by(${model.params.join(', ')}) (${innerExpr})`;
- };
-}
-
-function getAggregationWithoutRenderer(aggregation: string) {
- return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) {
- return `${aggregation} without(${model.params.join(', ')}) (${innerExpr})`;
- };
-}
-
-/**
- * Very simple poc implementation, needs to be modified to support all aggregation operators
- */
-function getAggregationExplainer(aggregationName: string, mode: 'by' | 'without' | '') {
- return function aggregationExplainer(model: QueryBuilderOperation) {
- const labels = model.params.map((label) => `\`${label}\``).join(' and ');
- const labelWord = pluralize('label', model.params.length);
-
- switch (mode) {
- case 'by':
- return `Calculates ${aggregationName} over dimensions while preserving ${labelWord} ${labels}.`;
- case 'without':
- return `Calculates ${aggregationName} over the dimensions ${labels}. All other labels are preserved.`;
- default:
- return `Calculates ${aggregationName} over the dimensions.`;
- }
- };
-}
-
-function getAggregationByRendererWithParameter(aggregation: string) {
- return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) {
- const firstParam = model.params[0];
- const restParams = model.params.slice(1);
- return `${aggregation} by(${restParams.join(', ')}) (${firstParam}, ${innerExpr})`;
- };
-}
-
-/**
- * This function will transform operations without labels to their plan aggregation operation
- */
-function getLastLabelRemovedHandler(changeToOperartionId: string) {
- return function onParamChanged(index: number, op: QueryBuilderOperation, def: QueryBuilderOperationDef) {
- // If definition has more params then is defined there are no optional rest params anymore
- // We then transform this operation into a different one
- if (op.params.length < def.params.length) {
- return {
- ...op,
- id: changeToOperartionId,
- };
- }
-
- return op;
- };
-}
-
-export function getOnLabelAdddedHandler(changeToOperartionId: string) {
- return function onParamChanged(index: number, op: QueryBuilderOperation) {
- return {
- ...op,
- id: changeToOperartionId,
- };
- };
-}
-
function createAggregationOverTime(name: string): QueryBuilderOperationDef {
return {
id: name,
diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx
index 6d9bbe328d5..5a46feaa0ac 100644
--- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx
+++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx
@@ -54,7 +54,7 @@ export function PromQueryBuilderContainer(props: Props) {
onRunQuery={onRunQuery}
data={data}
/>
- {query.editorPreview && }
+ {query.rawQuery && }
>
);
}
diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx
index 6d76ce7d793..7ab11a6c11e 100644
--- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx
+++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx
@@ -6,7 +6,15 @@ import { useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
-export function PromQueryCodeEditor({ query, datasource, range, onRunQuery, onChange, data }: PromQueryEditorProps) {
+export function PromQueryCodeEditor({
+ query,
+ datasource,
+ range,
+ onRunQuery,
+ onChange,
+ data,
+ app,
+}: PromQueryEditorProps) {
const styles = useStyles2(getStyles);
return (
@@ -20,6 +28,7 @@ export function PromQueryCodeEditor({ query, datasource, range, onRunQuery, onCh
history={[]}
data={data}
data-testid={testIds.editor}
+ app={app}
/>
);
diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx
index bb92ec241d0..b1283286882 100644
--- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx
+++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx
@@ -90,24 +90,24 @@ describe('PromQueryEditorSelector', () => {
});
});
- it('Can enable preview', async () => {
+ it('Can enable raw query', async () => {
const { onChange } = renderWithMode(QueryEditorMode.Builder);
expect(screen.queryByLabelText('selector')).not.toBeInTheDocument();
- screen.getByLabelText('Preview').click();
+ screen.getByLabelText('Raw query').click();
expect(onChange).toBeCalledWith({
refId: 'A',
expr: defaultQuery.expr,
range: true,
editorMode: QueryEditorMode.Builder,
- editorPreview: true,
+ rawQuery: true,
});
});
- it('Should show preview', async () => {
+ it('Should show raw query', async () => {
renderWithProps({
- editorPreview: true,
+ rawQuery: true,
editorMode: QueryEditorMode.Builder,
expr: 'my_metric',
});
diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx
index bfcd143c9f1..9bd94e3761d 100644
--- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx
+++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx
@@ -14,6 +14,7 @@ import { PromQueryBuilderContainer } from './PromQueryBuilderContainer';
import { PromQueryBuilderOptions } from './PromQueryBuilderOptions';
import { changeEditorMode, getQueryWithDefaults } from '../state';
import { PromQuery } from '../../types';
+import { FeedbackLink } from '../shared/FeedbackLink';
export const PromQueryEditorSelector = React.memo((props) => {
const { onChange, onRunQuery, data } = props;
@@ -44,7 +45,7 @@ export const PromQueryEditorSelector = React.memo((props)
const onQueryPreviewChange = (event: SyntheticEvent) => {
const isEnabled = event.currentTarget.checked;
- onChange({ ...query, editorPreview: isEnabled });
+ onChange({ ...query, rawQuery: isEnabled });
onRunQuery();
};
@@ -67,16 +68,6 @@ export const PromQueryEditorSelector = React.memo((props)
onDismiss={() => setParseModalOpen(false)}
/>
-
-
- Run query
-
{editorMode === QueryEditorMode.Builder && (
<>
((props)
}}
options={promQueryModeller.getQueryPatterns().map((x) => ({ label: x.name, value: x }))}
/>
+
>
)}
-
+ {editorMode === QueryEditorMode.Builder && (
+
+ )}
+
+
+ Run query
+
diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/QueryPreview.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/QueryPreview.tsx
index bdc0c7718de..cd7a267e542 100644
--- a/public/app/plugins/datasource/prometheus/querybuilder/components/QueryPreview.tsx
+++ b/public/app/plugins/datasource/prometheus/querybuilder/components/QueryPreview.tsx
@@ -13,16 +13,16 @@ export interface Props {
export function QueryPreview({ query }: Props) {
const theme = useTheme2();
const styles = getStyles(theme);
- const hightlighted = Prism.highlight(query, promqlGrammar, 'promql');
+ const highlighted = Prism.highlight(query, promqlGrammar, 'promql');
return (
-
+
diff --git a/public/app/plugins/datasource/prometheus/querybuilder/operations.ts b/public/app/plugins/datasource/prometheus/querybuilder/operations.ts
index 85871adae54..77d207b38d5 100644
--- a/public/app/plugins/datasource/prometheus/querybuilder/operations.ts
+++ b/public/app/plugins/datasource/prometheus/querybuilder/operations.ts
@@ -155,12 +155,6 @@ export function getOperationDefinitions(): QueryBuilderOperationDef[] {
id: PromOperationId.Cosh,
category: PromVisualQueryOperationCategory.Trigonometric,
}),
- createFunction({
- id: PromOperationId.CountValues,
- params: [{ name: 'Identifier', type: 'string' }],
- defaultParams: ['count'],
- renderer: functionRendererLeft,
- }),
createFunction({
id: PromOperationId.DayOfMonth,
category: PromVisualQueryOperationCategory.Time,
diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/FeedbackLink.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/FeedbackLink.tsx
new file mode 100644
index 00000000000..70b799046af
--- /dev/null
+++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/FeedbackLink.tsx
@@ -0,0 +1,44 @@
+import { css } from '@emotion/css';
+import { GrafanaTheme2 } from '@grafana/data';
+import { Stack } from '@grafana/experimental';
+import { config } from '@grafana/runtime';
+import { Icon, useStyles2 } from '@grafana/ui';
+import React from 'react';
+
+export interface Props {
+ feedbackUrl?: string;
+}
+
+export function FeedbackLink({ feedbackUrl }: Props) {
+ const styles = useStyles2(getStyles);
+
+ if (!config.feedbackLinksEnabled) {
+ return null;
+ }
+
+ return (
+
+
+ Give feedback
+
+
+ );
+}
+
+function getStyles(theme: GrafanaTheme2) {
+ return {
+ link: css({
+ color: theme.colors.text.secondary,
+ fontSize: theme.typography.bodySmall.fontSize,
+ ':hover': {
+ color: theme.colors.text.link,
+ },
+ }),
+ };
+}
diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle.tsx
index ae44a01e4da..61a86aab19f 100644
--- a/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle.tsx
+++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle.tsx
@@ -29,5 +29,9 @@ const editorModes = [
];
export function QueryEditorModeToggle({ mode, onChange }: Props) {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.test.ts b/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.test.ts
new file mode 100644
index 00000000000..3b6f532237e
--- /dev/null
+++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.test.ts
@@ -0,0 +1,127 @@
+import { createAggregationOperation, createAggregationOperationWithParam } from './operationUtils';
+
+describe('createAggregationOperation', () => {
+ it('returns correct aggregation definitions with overrides', () => {
+ expect(createAggregationOperation('test_aggregation', { category: 'test_category' })).toMatchObject([
+ {
+ addOperationHandler: {},
+ alternativesKey: 'plain aggregations',
+ category: 'test_category',
+ defaultParams: [],
+ explainHandler: {},
+ id: 'test_aggregation',
+ name: 'Test aggregation',
+ paramChangedHandler: {},
+ params: [
+ {
+ name: 'By label',
+ optional: true,
+ restParam: true,
+ type: 'string',
+ },
+ ],
+ renderer: {},
+ },
+ {
+ alternativesKey: 'aggregations by',
+ category: 'test_category',
+ defaultParams: [''],
+ explainHandler: {},
+ hideFromList: true,
+ id: '__test_aggregation_by',
+ name: 'Test aggregation by',
+ paramChangedHandler: {},
+ params: [
+ {
+ editor: {},
+ name: 'Label',
+ optional: true,
+ restParam: true,
+ type: 'string',
+ },
+ ],
+ renderer: {},
+ },
+ {
+ alternativesKey: 'aggregations by',
+ category: 'test_category',
+ defaultParams: [''],
+ explainHandler: {},
+ hideFromList: true,
+ id: '__test_aggregation_without',
+ name: 'Test aggregation without',
+ paramChangedHandler: {},
+ params: [
+ {
+ name: 'Label',
+ optional: true,
+ restParam: true,
+ type: 'string',
+ },
+ ],
+ renderer: {},
+ },
+ ]);
+ });
+});
+
+describe('createAggregationOperationWithParams', () => {
+ it('returns correct aggregation definitions with overrides and params', () => {
+ expect(
+ createAggregationOperationWithParam(
+ 'test_aggregation',
+ {
+ params: [{ name: 'K-value', type: 'number' }],
+ defaultParams: [5],
+ },
+ { category: 'test_category' }
+ )
+ ).toMatchObject([
+ {
+ addOperationHandler: {},
+ alternativesKey: 'plain aggregations',
+ category: 'test_category',
+ defaultParams: [5],
+ explainHandler: {},
+ id: 'test_aggregation',
+ name: 'Test aggregation',
+ paramChangedHandler: {},
+ params: [
+ { name: 'K-value', type: 'number' },
+ { name: 'By label', optional: true, restParam: true, type: 'string' },
+ ],
+ renderer: {},
+ },
+ {
+ alternativesKey: 'aggregations by',
+ category: 'test_category',
+ defaultParams: [5, ''],
+ explainHandler: {},
+ hideFromList: true,
+ id: '__test_aggregation_by',
+ name: 'Test aggregation by',
+ paramChangedHandler: {},
+ params: [
+ { name: 'K-value', type: 'number' },
+ { editor: {}, name: 'Label', optional: true, restParam: true, type: 'string' },
+ ],
+ renderer: {},
+ },
+ {
+ alternativesKey: 'aggregations by',
+ category: 'test_category',
+ defaultParams: [5, ''],
+ explainHandler: {},
+ hideFromList: true,
+ id: '__test_aggregation_without',
+ name: 'Test aggregation without',
+ paramChangedHandler: {},
+ params: [
+ { name: 'K-value', type: 'number' },
+ { name: 'Label', optional: true, restParam: true, type: 'string' },
+ ],
+ renderer: {},
+ },
+ ]);
+ });
+});
diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.ts b/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.ts
index d49c2954be7..e685255f42a 100644
--- a/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.ts
+++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.ts
@@ -3,9 +3,13 @@ import {
QueryBuilderOperation,
QueryBuilderOperationDef,
QueryBuilderOperationParamDef,
+ QueryBuilderOperationParamValue,
QueryWithOperations,
} from './types';
import { SelectableValue } from '@grafana/data/src';
+import { LabelParamEditor } from '../components/LabelParamEditor';
+import { PromVisualQueryOperationCategory } from '../types';
+import pluralize from 'pluralize';
export function functionRendererLeft(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) {
const params = renderParams(model, def, innerExpr);
@@ -144,3 +148,175 @@ export function getRangeVectorParamDef(withRateInterval = false): QueryBuilderOp
return param;
}
+
+/**
+ * This function is shared between Prometheus and Loki variants
+ */
+export function createAggregationOperation(
+ name: string,
+ overrides: Partial = {}
+): QueryBuilderOperationDef[] {
+ const operations: QueryBuilderOperationDef[] = [
+ {
+ id: name,
+ name: getPromAndLokiOperationDisplayName(name),
+ params: [
+ {
+ name: 'By label',
+ type: 'string',
+ restParam: true,
+ optional: true,
+ },
+ ],
+ defaultParams: [],
+ alternativesKey: 'plain aggregations',
+ category: PromVisualQueryOperationCategory.Aggregations,
+ renderer: functionRendererLeft,
+ paramChangedHandler: getOnLabelAddedHandler(`__${name}_by`),
+ explainHandler: getAggregationExplainer(name, ''),
+ addOperationHandler: defaultAddOperationHandler,
+ ...overrides,
+ },
+ {
+ id: `__${name}_by`,
+ name: `${getPromAndLokiOperationDisplayName(name)} by`,
+ params: [
+ {
+ name: 'Label',
+ type: 'string',
+ restParam: true,
+ optional: true,
+ editor: LabelParamEditor,
+ },
+ ],
+ defaultParams: [''],
+ alternativesKey: 'aggregations by',
+ category: PromVisualQueryOperationCategory.Aggregations,
+ renderer: getAggregationByRenderer(name),
+ paramChangedHandler: getLastLabelRemovedHandler(name),
+ explainHandler: getAggregationExplainer(name, 'by'),
+ addOperationHandler: defaultAddOperationHandler,
+ hideFromList: true,
+ ...overrides,
+ },
+ {
+ id: `__${name}_without`,
+ name: `${getPromAndLokiOperationDisplayName(name)} without`,
+ params: [
+ {
+ name: 'Label',
+ type: 'string',
+ restParam: true,
+ optional: true,
+ editor: LabelParamEditor,
+ },
+ ],
+ defaultParams: [''],
+ alternativesKey: 'aggregations by',
+ category: PromVisualQueryOperationCategory.Aggregations,
+ renderer: getAggregationWithoutRenderer(name),
+ paramChangedHandler: getLastLabelRemovedHandler(name),
+ explainHandler: getAggregationExplainer(name, 'without'),
+ addOperationHandler: defaultAddOperationHandler,
+ hideFromList: true,
+ ...overrides,
+ },
+ ];
+
+ return operations;
+}
+
+export function createAggregationOperationWithParam(
+ name: string,
+ paramsDef: { params: QueryBuilderOperationParamDef[]; defaultParams: QueryBuilderOperationParamValue[] },
+ overrides: Partial = {}
+): QueryBuilderOperationDef[] {
+ const operations = createAggregationOperation(name, overrides);
+ operations[0].params.unshift(...paramsDef.params);
+ operations[1].params.unshift(...paramsDef.params);
+ operations[2].params.unshift(...paramsDef.params);
+ operations[0].defaultParams = paramsDef.defaultParams;
+ operations[1].defaultParams = [...paramsDef.defaultParams, ''];
+ operations[2].defaultParams = [...paramsDef.defaultParams, ''];
+ operations[1].renderer = getAggregationByRendererWithParameter(name);
+ operations[2].renderer = getAggregationByRendererWithParameter(name);
+ return operations;
+}
+
+function getAggregationByRenderer(aggregation: string) {
+ return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) {
+ return `${aggregation} by(${model.params.join(', ')}) (${innerExpr})`;
+ };
+}
+
+function getAggregationWithoutRenderer(aggregation: string) {
+ return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) {
+ return `${aggregation} without(${model.params.join(', ')}) (${innerExpr})`;
+ };
+}
+
+/**
+ * Very simple poc implementation, needs to be modified to support all aggregation operators
+ */
+function getAggregationExplainer(aggregationName: string, mode: 'by' | 'without' | '') {
+ return function aggregationExplainer(model: QueryBuilderOperation) {
+ const labels = model.params.map((label) => `\`${label}\``).join(' and ');
+ const labelWord = pluralize('label', model.params.length);
+
+ switch (mode) {
+ case 'by':
+ return `Calculates ${aggregationName} over dimensions while preserving ${labelWord} ${labels}.`;
+ case 'without':
+ return `Calculates ${aggregationName} over the dimensions ${labels}. All other labels are preserved.`;
+ default:
+ return `Calculates ${aggregationName} over the dimensions.`;
+ }
+ };
+}
+
+function getAggregationByRendererWithParameter(aggregation: string) {
+ return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) {
+ function mapType(p: QueryBuilderOperationParamValue) {
+ if (typeof p === 'string') {
+ return `\"${p}\"`;
+ }
+ return p;
+ }
+ const params = model.params.slice(0, -1);
+ const restParams = model.params.slice(1);
+ return `${aggregation} by(${restParams.join(', ')}) (${params.map(mapType).join(', ')}, ${innerExpr})`;
+ };
+}
+
+/**
+ * This function will transform operations without labels to their plan aggregation operation
+ */
+function getLastLabelRemovedHandler(changeToOperationId: string) {
+ return function onParamChanged(index: number, op: QueryBuilderOperation, def: QueryBuilderOperationDef) {
+ // If definition has more params then is defined there are no optional rest params anymore.
+ // We then transform this operation into a different one
+ if (op.params.length < def.params.length) {
+ return {
+ ...op,
+ id: changeToOperationId,
+ };
+ }
+
+ return op;
+ };
+}
+
+function getOnLabelAddedHandler(changeToOperationId: string) {
+ return function onParamChanged(index: number, op: QueryBuilderOperation, def: QueryBuilderOperationDef) {
+ // Check if we actually have the label param. As it's optional the aggregation can have one less, which is the
+ // case of just simple aggregation without label. When user adds the label it now has the same number of params
+ // as it's definition, and now we can change it to it's `_by` variant.
+ if (op.params.length === def.params.length) {
+ return {
+ ...op,
+ id: changeToOperationId,
+ };
+ }
+ return op;
+ };
+}
diff --git a/public/app/plugins/datasource/prometheus/types.ts b/public/app/plugins/datasource/prometheus/types.ts
index 629028914ca..f2c4b494c5e 100644
--- a/public/app/plugins/datasource/prometheus/types.ts
+++ b/public/app/plugins/datasource/prometheus/types.ts
@@ -19,8 +19,8 @@ export interface PromQuery extends DataQuery {
showingTable?: boolean;
/** Code, Builder or Explain */
editorMode?: QueryEditorMode;
- /** Controls if the query preview is shown */
- editorPreview?: boolean;
+ /** Controls if the raw query text is shown */
+ rawQuery?: boolean;
}
export interface PromOptions extends DataSourceJsonData {
diff --git a/public/app/plugins/datasource/tempo/README.md b/public/app/plugins/datasource/tempo/README.md
new file mode 100644
index 00000000000..754d824491f
--- /dev/null
+++ b/public/app/plugins/datasource/tempo/README.md
@@ -0,0 +1,7 @@
+# Tempo Data Source - Native Plugin
+
+Grafana ships with **built in** support for Tempo, an open source, easy-to-use, and high-scale distributed tracing backend.
+
+Read more about it here:
+
+[https://docs.grafana.org/datasources/tempo/](https://docs.grafana.org/datasources/tempo/)
diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts
index e5884138fe9..e3c14e675b3 100644
--- a/public/app/plugins/datasource/tempo/datasource.test.ts
+++ b/public/app/plugins/datasource/tempo/datasource.test.ts
@@ -77,9 +77,9 @@ describe('Tempo data source', () => {
).toMatchObject([
{ name: 'id', values: ['4322526419282105830'] },
{ name: 'title', values: ['service'] },
- { name: 'subTitle', values: ['store.validateQueryTimeRange'] },
- { name: 'mainStat', values: ['14.98ms (100%)'] },
- { name: 'secondaryStat', values: ['14.98ms (100%)'] },
+ { name: 'subtitle', values: ['store.validateQueryTimeRange'] },
+ { name: 'mainstat', values: ['14.98ms (100%)'] },
+ { name: 'secondarystat', values: ['14.98ms (100%)'] },
{ name: 'color', values: [1.000007560204647] },
]);
diff --git a/public/app/plugins/datasource/tempo/graphTransform.test.ts b/public/app/plugins/datasource/tempo/graphTransform.test.ts
index a6dd5b417cf..666ce5a8945 100644
--- a/public/app/plugins/datasource/tempo/graphTransform.test.ts
+++ b/public/app/plugins/datasource/tempo/graphTransform.test.ts
@@ -13,18 +13,18 @@ describe('createGraphFrames', () => {
expect(view.get(0)).toMatchObject({
id: '4322526419282105830',
title: 'loki-all',
- subTitle: 'store.validateQueryTimeRange',
- mainStat: '0ms (0.02%)',
- secondaryStat: '0ms (100%)',
+ subtitle: 'store.validateQueryTimeRange',
+ mainstat: '0ms (0.02%)',
+ secondarystat: '0ms (100%)',
color: 0.00021968356127648162,
});
expect(view.get(29)).toMatchObject({
id: '4450900759028499335',
title: 'loki-all',
- subTitle: 'HTTP GET - loki_api_v1_query_range',
- mainStat: '18.21ms (100%)',
- secondaryStat: '3.22ms (17.71%)',
+ subtitle: 'HTTP GET - loki_api_v1_query_range',
+ mainstat: '18.21ms (100%)',
+ secondarystat: '3.22ms (17.71%)',
color: 0.17707117189595056,
});
@@ -43,9 +43,9 @@ describe('createGraphFrames', () => {
expect(view.get(0)).toMatchObject({
id: '4322526419282105830',
title: 'loki-all',
- subTitle: 'store.validateQueryTimeRange',
- mainStat: '14.98ms (100%)',
- secondaryStat: '14.98ms (100%)',
+ subtitle: 'store.validateQueryTimeRange',
+ mainstat: '14.98ms (100%)',
+ secondarystat: '14.98ms (100%)',
color: 1.000007560204647,
});
});
@@ -75,8 +75,8 @@ describe('mapPromMetricsToServiceMap', () => {
expect(nodes.fields).toMatchObject([
{ name: 'id', values: new ArrayVector(['db', 'app', 'lb']) },
{ name: 'title', values: new ArrayVector(['db', 'app', 'lb']) },
- { name: 'mainStat', values: new ArrayVector([1000, 2000, NaN]) },
- { name: 'secondaryStat', values: new ArrayVector([0.17, 0.33, NaN]) },
+ { name: 'mainstat', values: new ArrayVector([1000, 2000, NaN]) },
+ { name: 'secondarystat', values: new ArrayVector([0.17, 0.33, NaN]) },
{ name: 'arc__success', values: new ArrayVector([0.8, 0.25, 1]) },
{ name: 'arc__failed', values: new ArrayVector([0.2, 0.75, 0]) },
]);
@@ -84,8 +84,8 @@ describe('mapPromMetricsToServiceMap', () => {
{ name: 'id', values: new ArrayVector(['app_db', 'lb_app']) },
{ name: 'source', values: new ArrayVector(['app', 'lb']) },
{ name: 'target', values: new ArrayVector(['db', 'app']) },
- { name: 'mainStat', values: new ArrayVector([10, 20]) },
- { name: 'secondaryStat', values: new ArrayVector([1000, 2000]) },
+ { name: 'mainstat', values: new ArrayVector([10, 20]) },
+ { name: 'secondarystat', values: new ArrayVector([1000, 2000]) },
]);
});
@@ -107,8 +107,8 @@ describe('mapPromMetricsToServiceMap', () => {
expect(nodes.fields).toMatchObject([
{ name: 'id', values: new ArrayVector(['db', 'app', 'lb']) },
{ name: 'title', values: new ArrayVector(['db', 'app', 'lb']) },
- { name: 'mainStat', values: new ArrayVector([1000, 2000, NaN]) },
- { name: 'secondaryStat', values: new ArrayVector([0.17, 0.33, NaN]) },
+ { name: 'mainstat', values: new ArrayVector([1000, 2000, NaN]) },
+ { name: 'secondarystat', values: new ArrayVector([0.17, 0.33, NaN]) },
{ name: 'arc__success', values: new ArrayVector([0, 0, 1]) },
{ name: 'arc__failed', values: new ArrayVector([1, 1, 0]) },
]);
diff --git a/public/app/plugins/datasource/zipkin/README.md b/public/app/plugins/datasource/zipkin/README.md
new file mode 100644
index 00000000000..a4d81d38b1d
--- /dev/null
+++ b/public/app/plugins/datasource/zipkin/README.md
@@ -0,0 +1,7 @@
+# Zipkin Data Source - Native Plugin
+
+Grafana ships with **built in** support for Zipkin, an open source, distributed tracing system.
+
+Read more about it here:
+
+[https://docs.grafana.org/datasources/zipkin/](https://docs.grafana.org/datasources/zipkin/)
diff --git a/public/app/plugins/datasource/zipkin/utils/testResponse.ts b/public/app/plugins/datasource/zipkin/utils/testResponse.ts
index bdf76fdb35d..7a6ba459102 100644
--- a/public/app/plugins/datasource/zipkin/utils/testResponse.ts
+++ b/public/app/plugins/datasource/zipkin/utils/testResponse.ts
@@ -95,9 +95,9 @@ export function toNodesFrame(values: any[]) {
return toVectors([
{ name: 'id', values: values[0] },
{ name: 'title', values: values[1] },
- { name: 'subTitle', values: values[2] },
- { name: 'mainStat', values: values[3] },
- { name: 'secondaryStat', values: values[4] },
+ { name: 'subtitle', values: values[2] },
+ { name: 'mainstat', values: values[3] },
+ { name: 'secondarystat', values: values[4] },
{
name: 'color',
config: {
diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx
index 7a4e7dc5f2c..61515d561b7 100755
--- a/public/app/plugins/panel/barchart/BarChartPanel.tsx
+++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx
@@ -63,7 +63,15 @@ const propsToDiff: Array = [
interface Props extends PanelProps {}
-export const BarChartPanel: React.FunctionComponent = ({ data, options, width, height, timeZone, id }) => {
+export const BarChartPanel: React.FunctionComponent = ({
+ data,
+ options,
+ fieldConfig,
+ width,
+ height,
+ timeZone,
+ id,
+}) => {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const { eventBus } = usePanelContext();
@@ -133,7 +141,15 @@ export const BarChartPanel: React.FunctionComponent = ({ data, options, w
}, [height, options.xTickLabelRotation, options.xTickLabelMaxLength]);
if (!info.viz[0]?.fields.length) {
- return ;
+ return (
+
+ );
}
const renderTooltip = (alignedFrame: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => {
diff --git a/public/app/plugins/panel/barchart/bars.ts b/public/app/plugins/panel/barchart/bars.ts
index ef7cc7f6336..316baf93ecb 100644
--- a/public/app/plugins/panel/barchart/bars.ts
+++ b/public/app/plugins/panel/barchart/bars.ts
@@ -11,7 +11,7 @@ import {
VizTextDisplayOptions,
VizLegendOptions,
} from '@grafana/schema';
-import { preparePlotData } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
+import { preparePlotData2, StackingGroup } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
import { alpha } from '@grafana/data/src/themes/colorManipulator';
import { formatTime } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
@@ -564,16 +564,11 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
let alignedTotals: AlignedData | null = null;
- function prepData(frames: DataFrame[]) {
+ function prepData(frames: DataFrame[], stackingGroups: StackingGroup[]) {
alignedTotals = null;
-
- return preparePlotData(
- frames,
- ({ totals }) => {
- alignedTotals = totals;
- },
- opts.legend
- );
+ return preparePlotData2(frames[0], stackingGroups, ({ totals }) => {
+ alignedTotals = totals;
+ });
}
return {
diff --git a/public/app/plugins/panel/barchart/utils.ts b/public/app/plugins/panel/barchart/utils.ts
index 992138a0753..9cb28a986c1 100644
--- a/public/app/plugins/panel/barchart/utils.ts
+++ b/public/app/plugins/panel/barchart/utils.ts
@@ -25,9 +25,9 @@ import {
StackingMode,
VizLegendOptions,
} from '@grafana/schema';
-import { collectStackingGroups, orderIdsByCalcs } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
import { orderBy } from 'lodash';
import { findField } from 'app/features/dimensions';
+import { getStackingGroups } from '@grafana/ui/src/components/uPlot/utils';
function getBarCharScaleOrientation(orientation: VizOrientation) {
if (orientation === VizOrientation.Vertical) {
@@ -156,7 +156,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
let seriesIndex = 0;
const legendOrdered = isLegendOrdered(legend);
- const stackingGroups: Map = new Map();
// iterate the y values
for (let i = 1; i < frame.fields.length; i++) {
@@ -237,20 +236,11 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
grid: { show: customConfig.axisGridShow },
});
}
-
- collectStackingGroups(field, stackingGroups, seriesIndex);
}
- if (stackingGroups.size !== 0) {
- for (const [_, seriesIds] of stackingGroups.entries()) {
- const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame });
- for (let j = seriesIdxs.length - 1; j > 0; j--) {
- builder.addBand({
- series: [seriesIdxs[j], seriesIdxs[j - 1]],
- });
- }
- }
- }
+ let stackingGroups = getStackingGroups(frame);
+
+ builder.setStackingGroups(stackingGroups);
return builder;
};
diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx
index ddd24370c6b..fd691d5b84f 100644
--- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx
+++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx
@@ -209,7 +209,15 @@ export const CandlestickPanel: React.FC = ({
}, [options, data.structureRev]);
if (!info) {
- return ;
+ return (
+
+ );
}
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
diff --git a/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx b/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx
index de0f990dd25..8f83d59879f 100644
--- a/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx
+++ b/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
-import { Field, FieldType, formattedValueToString, LinkModel } from '@grafana/data';
+import { Field, FieldType, formattedValueToString, getFieldDisplayName, LinkModel } from '@grafana/data';
import { HeatmapHoverEvent } from './utils';
import { BucketLayout, HeatmapData } from './fields';
@@ -26,7 +26,7 @@ export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => {
const dashboard = getDashboardSrv().getCurrent();
return dashboard?.formatDate(v, tooltipTimeFormat);
}
- return `${v}XX`;
+ return `${v}`;
};
const xVals = xField?.values.toArray();
@@ -45,7 +45,7 @@ export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => {
if (yField?.display) {
return formattedValueToString(yField.display(v));
}
- return `${v}YYY`;
+ return `${v}`;
};
}
@@ -170,7 +170,9 @@ export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => {
Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)}
- Count: {count}
+
+ {getFieldDisplayName(countField!, data.heatmap)}: {count}
+
{links.length > 0 && (
diff --git a/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx
index ec2b798879f..a76ece81a90 100644
--- a/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx
+++ b/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx
@@ -1,6 +1,6 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { css } from '@emotion/css';
-import { formattedValueToString, GrafanaTheme2, PanelProps, reduceField, ReducerID, TimeRange } from '@grafana/data';
+import { GrafanaTheme2, PanelProps, reduceField, ReducerID, TimeRange } from '@grafana/data';
import {
Portal,
UPlotChart,
@@ -82,7 +82,7 @@ export const HeatmapPanel: React.FC = ({
return prepConfig({
dataRef,
theme,
- onhover: options.tooltip.show ? onhover : null,
+ onhover: onhover,
onclick: options.tooltip.show ? onclick : null,
onzoom: (evt) => {
onChangeTimeRange({ from: evt.xMin, to: evt.xMax });
@@ -104,7 +104,6 @@ export const HeatmapPanel: React.FC = ({
const field = info.heatmap.fields[2];
const { min, max } = reduceField({ field, reducers: [ReducerID.min, ReducerID.max] });
- const display = field.display ? (v: number) => formattedValueToString(field.display!(v)) : (v: number) => `${v}`;
let hoverValue: number | undefined = undefined;
if (hover && info.heatmap.fields) {
@@ -114,13 +113,21 @@ export const HeatmapPanel: React.FC = ({
return (
-
+
);
};
if (info.warning || !info.heatmap) {
- return ;
+ return (
+
+ );
}
return (
@@ -133,7 +140,7 @@ export const HeatmapPanel: React.FC = ({
)}
- {hover && (
+ {hover && options.tooltip.show && (
string;
+
// Errors
warning?: string;
}
@@ -84,6 +89,7 @@ const getHeatmapData = (frame: DataFrame, theme: GrafanaTheme2): HeatmapData =>
return { heatmap: frame };
}
+ // Y field values (display is used in the axis)
if (!frame.fields[1].display) {
frame.fields[1].display = getDisplayProcessor({ field: frame.fields[1], theme });
}
@@ -105,14 +111,19 @@ const getHeatmapData = (frame: DataFrame, theme: GrafanaTheme2): HeatmapData =>
let yBinIncr = ys[1] - ys[0];
let xBinIncr = xs[yBinQty] - xs[0];
+ // The "count" field
+ const disp = frame.fields[2].display ?? getValueFormat('short');
return {
heatmap: frame,
xBucketSize: xBinIncr,
yBucketSize: yBinIncr,
xBucketCount: xBinQty,
yBucketCount: yBinQty,
+
// TODO: improve heuristic
xLayout: frame.fields[0].name === 'xMax' ? BucketLayout.le : BucketLayout.ge,
yLayout: frame.fields[1].name === 'yMax' ? BucketLayout.le : BucketLayout.ge,
+
+ display: (v) => formattedValueToString(disp(v)),
};
};
diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx
index d5cc9973979..72f742ab929 100644
--- a/public/app/plugins/panel/logs/LogsPanel.tsx
+++ b/public/app/plugins/panel/logs/LogsPanel.tsx
@@ -22,6 +22,7 @@ interface LogsPanelProps extends PanelProps {}
export const LogsPanel: React.FunctionComponent = ({
data,
timeZone,
+ fieldConfig,
options: {
showLabels,
showTime,
@@ -83,7 +84,7 @@ export const LogsPanel: React.FunctionComponent = ({
);
if (!data || logRows.length === 0) {
- return ;
+ return ;
}
const renderCommonLabels = () => (
diff --git a/public/app/plugins/panel/nodeGraph/utils.test.ts b/public/app/plugins/panel/nodeGraph/utils.test.ts
index b2e25b0b98e..d95e4e72fdd 100644
--- a/public/app/plugins/panel/nodeGraph/utils.test.ts
+++ b/public/app/plugins/panel/nodeGraph/utils.test.ts
@@ -1,5 +1,12 @@
-import { ArrayVector, createTheme } from '@grafana/data';
-import { makeEdgesDataFrame, makeNodesDataFrame, processNodes } from './utils';
+import { ArrayVector, createTheme, DataFrame, FieldType, MutableDataFrame } from '@grafana/data';
+import {
+ getEdgeFields,
+ getNodeFields,
+ getNodeGraphDataFrames,
+ makeEdgesDataFrame,
+ makeNodesDataFrame,
+ processNodes,
+} from './utils';
describe('processNodes', () => {
const theme = createTheme();
@@ -62,14 +69,14 @@ describe('processNodes', () => {
mainStat: {
config: {},
index: 3,
- name: 'mainStat',
+ name: 'mainstat',
type: 'number',
values: new ArrayVector([0.1, 0.1, 0.1]),
},
secondaryStat: {
config: {},
index: 4,
- name: 'secondaryStat',
+ name: 'secondarystat',
type: 'number',
values: new ArrayVector([2, 2, 2]),
},
@@ -106,14 +113,14 @@ describe('processNodes', () => {
mainStat: {
config: {},
index: 3,
- name: 'mainStat',
+ name: 'mainstat',
type: 'number',
values: new ArrayVector([0.1, 0.1, 0.1]),
},
secondaryStat: {
config: {},
index: 4,
- name: 'secondaryStat',
+ name: 'secondarystat',
type: 'number',
values: new ArrayVector([2, 2, 2]),
},
@@ -150,14 +157,14 @@ describe('processNodes', () => {
mainStat: {
config: {},
index: 3,
- name: 'mainStat',
+ name: 'mainstat',
type: 'number',
values: new ArrayVector([0.1, 0.1, 0.1]),
},
secondaryStat: {
config: {},
index: 4,
- name: 'secondaryStat',
+ name: 'secondarystat',
type: 'number',
values: new ArrayVector([2, 2, 2]),
},
@@ -204,4 +211,85 @@ describe('processNodes', () => {
},
]);
});
+
+ it('detects dataframes correctly', () => {
+ const validFrames = [
+ new MutableDataFrame({
+ refId: 'hasPreferredVisualisationType',
+ fields: [],
+ meta: {
+ preferredVisualisationType: 'nodeGraph',
+ },
+ }),
+ new MutableDataFrame({
+ refId: 'hasName',
+ fields: [],
+ name: 'nodes',
+ }),
+ new MutableDataFrame({
+ refId: 'nodes', // hasRefId
+ fields: [],
+ }),
+ new MutableDataFrame({
+ refId: 'hasValidNodesShape',
+ fields: [{ name: 'id', type: FieldType.string }],
+ }),
+ new MutableDataFrame({
+ refId: 'hasValidEdgesShape',
+ fields: [
+ { name: 'id', type: FieldType.string },
+ { name: 'source', type: FieldType.string },
+ { name: 'target', type: FieldType.string },
+ ],
+ }),
+ ];
+ const invalidFrames = [
+ new MutableDataFrame({
+ refId: 'invalidData',
+ fields: [],
+ }),
+ ];
+ const frames = [...validFrames, ...invalidFrames];
+
+ const nodeGraphFrames = getNodeGraphDataFrames(frames as DataFrame[]);
+ expect(nodeGraphFrames.length).toBe(5);
+ expect(nodeGraphFrames).toEqual(validFrames);
+ });
+
+ it('getting fields is case insensitive', () => {
+ const nodeFrame = new MutableDataFrame({
+ refId: 'nodes',
+ fields: [
+ { name: 'id', type: FieldType.string, values: ['id'] },
+ { name: 'title', type: FieldType.string, values: ['title'] },
+ { name: 'SUBTITLE', type: FieldType.string, values: ['subTitle'] },
+ { name: 'mainstat', type: FieldType.string, values: ['mainStat'] },
+ { name: 'seconDarysTat', type: FieldType.string, values: ['secondaryStat'] },
+ ],
+ });
+
+ const nodeFields = getNodeFields(nodeFrame);
+ expect(nodeFields.id).toBeDefined();
+ expect(nodeFields.title).toBeDefined();
+ expect(nodeFields.subTitle).toBeDefined();
+ expect(nodeFields.mainStat).toBeDefined();
+ expect(nodeFields.secondaryStat).toBeDefined();
+
+ const edgeFrame = new MutableDataFrame({
+ refId: 'nodes',
+ fields: [
+ { name: 'id', type: FieldType.string, values: ['id'] },
+ { name: 'source', type: FieldType.string, values: ['title'] },
+ { name: 'TARGET', type: FieldType.string, values: ['subTitle'] },
+ { name: 'mainstat', type: FieldType.string, values: ['mainStat'] },
+ { name: 'secondarystat', type: FieldType.string, values: ['secondaryStat'] },
+ ],
+ });
+ const edgeFields = getEdgeFields(edgeFrame);
+ expect(edgeFields.id).toBeDefined();
+ expect(edgeFields.source).toBeDefined();
+ expect(edgeFields.target).toBeDefined();
+ expect(edgeFields.mainStat).toBeDefined();
+ expect(edgeFields.secondaryStat).toBeDefined();
+ });
});
diff --git a/public/app/plugins/panel/nodeGraph/utils.ts b/public/app/plugins/panel/nodeGraph/utils.ts
index c79274427f1..0aac55cedf8 100644
--- a/public/app/plugins/panel/nodeGraph/utils.ts
+++ b/public/app/plugins/panel/nodeGraph/utils.ts
@@ -35,13 +35,17 @@ export function shortenLine(line: Line, length: number): Line {
}
export function getNodeFields(nodes: DataFrame) {
- const fieldsCache = new FieldCache(nodes);
+ const normalizedFrames = {
+ ...nodes,
+ fields: nodes.fields.map((field) => ({ ...field, name: field.name.toLowerCase() })),
+ };
+ const fieldsCache = new FieldCache(normalizedFrames);
return {
- id: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id),
- title: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.title),
- subTitle: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.subTitle),
- mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat),
- secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat),
+ id: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id.toLowerCase()),
+ title: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.title.toLowerCase()),
+ subTitle: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.subTitle.toLowerCase()),
+ mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat.toLowerCase()),
+ secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat.toLowerCase()),
arc: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.arc),
details: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.detail),
color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color),
@@ -49,14 +53,18 @@ export function getNodeFields(nodes: DataFrame) {
}
export function getEdgeFields(edges: DataFrame) {
- const fieldsCache = new FieldCache(edges);
+ const normalizedFrames = {
+ ...edges,
+ fields: edges.fields.map((field) => ({ ...field, name: field.name.toLowerCase() })),
+ };
+ const fieldsCache = new FieldCache(normalizedFrames);
return {
- id: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id),
- source: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.source),
- target: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.target),
- mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat),
- secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat),
- details: findFieldsByPrefix(edges, NodeGraphDataFrameFieldNames.detail),
+ id: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id.toLowerCase()),
+ source: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.source.toLowerCase()),
+ target: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.target.toLowerCase()),
+ mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat.toLowerCase()),
+ secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat.toLowerCase()),
+ details: findFieldsByPrefix(edges, NodeGraphDataFrameFieldNames.detail.toLowerCase()),
};
}
@@ -172,11 +180,11 @@ function makeNode(index: number) {
return {
id: index.toString(),
title: `service:${index}`,
- subTitle: 'service',
+ subtitle: 'service',
arc__success: 0.5,
arc__errors: 0.5,
- mainStat: 0.1,
- secondaryStat: 2,
+ mainstat: 0.1,
+ secondarystat: 2,
color: 0.5,
};
}
@@ -327,7 +335,13 @@ export function getNodeGraphDataFrames(frames: DataFrame[]) {
if (frame.meta?.preferredVisualisationType === 'nodeGraph') {
return true;
}
- if (frame.name === 'nodes' || frame.name === 'edges') {
+
+ if (frame.name === 'nodes' || frame.name === 'edges' || frame.refId === 'nodes' || frame.refId === 'edges') {
+ return true;
+ }
+
+ const fieldsCache = new FieldCache(frame);
+ if (fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id)) {
return true;
}
diff --git a/public/app/plugins/panel/piechart/PieChartPanel.tsx b/public/app/plugins/panel/piechart/PieChartPanel.tsx
index a1ba2a08a3a..d8eeeb59efd 100644
--- a/public/app/plugins/panel/piechart/PieChartPanel.tsx
+++ b/public/app/plugins/panel/piechart/PieChartPanel.tsx
@@ -23,6 +23,7 @@ import {
VizLegendItem,
} from '@grafana/ui';
import { filterDisplayItems, sumDisplayItemsReducer } from './utils';
+import { PanelDataErrorView } from '@grafana/runtime';
const defaultLegendOptions: PieChartLegendOptions = {
displayMode: LegendDisplayMode.List,
@@ -37,7 +38,7 @@ interface Props extends PanelProps {}
* @beta
*/
export function PieChartPanel(props: Props) {
- const { data, timeZone, fieldConfig, replaceVariables, width, height, options } = props;
+ const { data, timeZone, fieldConfig, replaceVariables, width, height, options, id } = props;
const theme = useTheme2();
const highlightedTitle = useSliceHighlightState();
@@ -51,11 +52,7 @@ export function PieChartPanel(props: Props) {
});
if (!hasFrames(fieldDisplayValues)) {
- return (
-
- );
+ return ;
}
return (
diff --git a/public/app/plugins/panel/state-timeline/utils.ts b/public/app/plugins/panel/state-timeline/utils.ts
index 0bf27bd90ba..e84b6a87fbb 100644
--- a/public/app/plugins/panel/state-timeline/utils.ts
+++ b/public/app/plugins/panel/state-timeline/utils.ts
@@ -31,7 +31,7 @@ import { getConfig, TimelineCoreOptions } from './timeline';
import { VizLegendOptions, AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/schema';
import { TimelineFieldConfig, TimelineOptions } from './types';
import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types';
-import { preparePlotData } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
+import { preparePlotData2, getStackingGroups } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
import uPlot from 'uplot';
const defaultConfig: TimelineFieldConfig = {
@@ -153,7 +153,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
builder.setTooltipInterpolator(interpolateTooltip);
- builder.setPrepData(preparePlotData);
+ builder.setPrepData((frames) => preparePlotData2(frames[0], getStackingGroups(frames[0])));
builder.setCursor(coreConfig.cursor);
diff --git a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx
index d6f2b407a63..4552ac8477e 100755
--- a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx
+++ b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx
@@ -43,7 +43,7 @@ export const StatusHistoryPanel: React.FC = ({
Too many points to visualize properly.
- Update the query to return fewer points. ({frames[0].length} points recieved)
+ Update the query to return fewer points. ({frames[0].length} points received)
);
diff --git a/public/app/plugins/panel/table/TablePanel.tsx b/public/app/plugins/panel/table/TablePanel.tsx
index 852b0bbaeeb..a55e7e21e2e 100644
--- a/public/app/plugins/panel/table/TablePanel.tsx
+++ b/public/app/plugins/panel/table/TablePanel.tsx
@@ -17,6 +17,7 @@ import { applyFilterFromTable } from '../../../features/variables/adhoc/actions'
import { getDashboardSrv } from '../../../features/dashboard/services/DashboardSrv';
import { getFooterCells } from './footer';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { PanelDataErrorView } from '@grafana/runtime';
interface Props extends PanelProps {}
@@ -118,14 +119,14 @@ export class TablePanel extends Component {
}
render() {
- const { data, height, width, options } = this.props;
+ const { data, height, width, options, fieldConfig, id } = this.props;
const frames = data.series;
const count = frames?.length;
const hasFields = frames[0]?.fields.length;
if (!count || !hasFields) {
- return No data
;
+ return ;
}
if (count > 1) {
diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx
index 64c4b6ea7df..5eb7e489b41 100644
--- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx
+++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx
@@ -36,7 +36,15 @@ export const TimeSeriesPanel: React.FC = ({
const frames = useMemo(() => prepareGraphableFields(data.series, config.theme2), [data]);
if (!frames) {
- return ;
+ return (
+
+ );
}
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx
index 022b0b42aee..b749467036d 100644
--- a/public/app/routes/routes.tsx
+++ b/public/app/routes/routes.tsx
@@ -195,14 +195,14 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/org/apikeys',
- roles: () => ['Editor', 'Admin'],
+ roles: () => contextSrv.evaluatePermission(() => ['Admin'], [AccessControlAction.ActionAPIKeysRead]),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "ApiKeysPage" */ 'app/features/api-keys/ApiKeysPage')
),
},
{
path: '/org/serviceaccounts',
- roles: () => ['Editor', 'Admin'],
+ roles: () => contextSrv.evaluatePermission(() => ['Admin'], [AccessControlAction.ServiceAccountsRead]),
component: SafeDynamicImport(
() =>
import(/* webpackChunkName: "ServiceAccountsPage" */ 'app/features/serviceaccounts/ServiceAccountsListPage')
@@ -406,6 +406,8 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/dashboards/f/:uid/:slug/alerting',
+ roles: () =>
+ contextSrv.evaluatePermission(() => ['Viewer', 'Editor', 'Admin'], [AccessControlAction.AlertingRuleRead]),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "FolderAlerting"*/ 'app/features/folders/FolderAlerting')
),
diff --git a/public/app/types/accessControl.ts b/public/app/types/accessControl.ts
index b2fd9b0ca2f..969985ba21a 100644
--- a/public/app/types/accessControl.ts
+++ b/public/app/types/accessControl.ts
@@ -23,6 +23,7 @@ export enum AccessControlAction {
UsersQuotasList = 'users.quotas:list',
UsersQuotasUpdate = 'users.quotas:update',
+ ServiceAccountsRead = 'serviceaccounts:read',
ServiceAccountsCreate = 'serviceaccounts:create',
ServiceAccountsWrite = 'serviceaccounts:write',
ServiceAccountsDelete = 'serviceaccounts:delete',
@@ -107,6 +108,8 @@ export enum AccessControlAction {
// External alerting notifications actions.
AlertingNotificationsExternalWrite = 'alert.notifications.external:write',
AlertingNotificationsExternalRead = 'alert.notifications.external:read',
+
+ ActionAPIKeysRead = 'apikeys:read',
}
export interface Role {
diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts
index aec415207be..614c6061663 100644
--- a/public/app/types/dashboard.ts
+++ b/public/app/types/dashboard.ts
@@ -1,6 +1,7 @@
import { DashboardAcl } from './acl';
import { DataQuery } from '@grafana/data';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
+import { VariableModel } from 'app/features/variables/types';
export interface DashboardDTO {
redirectUri?: string;
@@ -51,6 +52,11 @@ export interface AnnotationsPermissions {
export interface DashboardDataDTO {
title: string;
+ uid: string;
+ templating: {
+ list: VariableModel[];
+ };
+ panels?: any[];
}
export enum DashboardRoutes {
diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts
index 626b894070b..ca313a28f06 100644
--- a/public/app/types/explore.ts
+++ b/public/app/types/explore.ts
@@ -15,6 +15,7 @@ import {
DataQueryResponse,
ExplorePanelsState,
} from '@grafana/data';
+import { RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistoryTypes';
export enum ExploreId {
left = 'left',
@@ -43,6 +44,11 @@ export interface ExploreState {
*/
right?: ExploreItemState;
+ /**
+ * Settings for rich history (note: filters are stored per each pane separately)
+ */
+ richHistorySettings?: RichHistorySettings;
+
/**
* True if local storage quota was exceeded when a rich history item was added. This is to prevent showing
* multiple errors when local storage is full.
@@ -153,6 +159,7 @@ export interface ExploreItemState {
* History of all queries
*/
richHistory: RichHistoryQuery[];
+ richHistorySearchFilters?: RichHistorySearchFilters;
/**
* We are using caching to store query responses of queries run from logs navigation.
diff --git a/public/sass/components/_submenu.scss b/public/sass/components/_submenu.scss
index b9c8d3afc73..b253b8b86be 100644
--- a/public/sass/components/_submenu.scss
+++ b/public/sass/components/_submenu.scss
@@ -4,6 +4,7 @@
flex-wrap: wrap;
align-content: flex-start;
align-items: flex-start;
+ gap: $space-sm $space-md;
padding: 0 0 $space-sm 0;
}
@@ -18,7 +19,6 @@
.submenu-item {
display: inline-block;
- margin-right: 15px;
.fa-caret-down {
font-size: 75%;
diff --git a/scripts/drone/pipelines/docs.star b/scripts/drone/pipelines/docs.star
index fc6ce20cfa7..ababa390740 100644
--- a/scripts/drone/pipelines/docs.star
+++ b/scripts/drone/pipelines/docs.star
@@ -1,7 +1,9 @@
load(
'scripts/drone/steps/lib.star',
'build_image',
- 'initialize_step',
+ 'yarn_install_step',
+ 'identify_runner_step',
+ 'gen_version_step',
'download_grabpl_step',
'lint_frontend_step',
'codespell_step',
@@ -25,16 +27,17 @@ load(
def docs_pipelines(edition, ver_mode, trigger):
- steps = [download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode)
-
- # Insert remaining steps
- steps.extend([
+ steps = [
+ download_grabpl_step(),
+ identify_runner_step(),
+ gen_version_step(ver_mode),
+ yarn_install_step(),
codespell_step(),
lint_docs(),
build_frontend_package_step(edition=edition, ver_mode=ver_mode),
build_frontend_docs_step(edition=edition),
build_docs_website_step(),
- ])
+ ]
return pipeline(
name='{}-docs'.format(ver_mode), edition=edition, trigger=trigger, services=[], steps=steps,
@@ -45,7 +48,7 @@ def lint_docs():
'name': 'lint-docs',
'image': build_image,
'depends_on': [
- 'initialize',
+ 'yarn-install',
],
'environment': {
'NODE_OPTIONS': '--max_old_space_size=8192',
diff --git a/scripts/drone/pipelines/main.star b/scripts/drone/pipelines/main.star
index 91562878bab..e86e337593f 100644
--- a/scripts/drone/pipelines/main.star
+++ b/scripts/drone/pipelines/main.star
@@ -2,7 +2,10 @@ load(
'scripts/drone/steps/lib.star',
'download_grabpl_step',
'build_image',
- 'initialize_step',
+ 'identify_runner_step',
+ 'gen_version_step',
+ 'wire_install_step',
+ 'yarn_install_step',
'lint_drone_step',
'lint_backend_step',
'lint_frontend_step',
@@ -67,10 +70,16 @@ load('scripts/drone/vault.star', 'from_secret')
ver_mode = 'main'
-def get_steps(edition, is_downstream=False):
+def get_steps(edition):
services = integration_test_services(edition)
- publish = edition != 'enterprise' or is_downstream
include_enterprise2 = edition == 'enterprise'
+ init_steps = [
+ identify_runner_step(),
+ download_grabpl_step(),
+ gen_version_step(ver_mode),
+ wire_install_step(),
+ yarn_install_step(),
+ ]
test_steps = [
lint_drone_step(),
codespell_step(),
@@ -84,9 +93,9 @@ def get_steps(edition, is_downstream=False):
build_steps = [
trigger_test_release(),
enterprise_downstream_step(edition=edition),
- build_backend_step(edition=edition, ver_mode=ver_mode, is_downstream=is_downstream),
- build_frontend_step(edition=edition, ver_mode=ver_mode, is_downstream=is_downstream),
- build_frontend_package_step(edition=edition, ver_mode=ver_mode, is_downstream=is_downstream),
+ build_backend_step(edition=edition, ver_mode=ver_mode),
+ build_frontend_step(edition=edition, ver_mode=ver_mode),
+ build_frontend_package_step(edition=edition, ver_mode=ver_mode),
build_plugins_step(edition=edition, sign=True),
validate_scuemata_step(),
ensure_cuetsified_step(),
@@ -106,12 +115,12 @@ def get_steps(edition, is_downstream=False):
test_backend_integration_step(edition=edition2),
])
build_steps.extend([
- build_backend_step(edition=edition2, ver_mode=ver_mode, variants=['linux-amd64'], is_downstream=is_downstream),
+ build_backend_step(edition=edition2, ver_mode=ver_mode, variants=['linux-amd64']),
])
# Insert remaining steps
build_steps.extend([
- package_step(edition=edition, ver_mode=ver_mode, include_enterprise2=include_enterprise2, is_downstream=is_downstream),
+ package_step(edition=edition, ver_mode=ver_mode, include_enterprise2=include_enterprise2),
grafana_server_step(edition=edition),
e2e_tests_step('dashboards-suite', edition=edition),
e2e_tests_step('smoke-tests-suite', edition=edition),
@@ -134,27 +143,27 @@ def get_steps(edition, is_downstream=False):
build_steps.extend([
release_canary_npm_packages_step(edition, trigger=trigger_oss),
- upload_packages_step(edition=edition, ver_mode=ver_mode, is_downstream=is_downstream, trigger=trigger_oss),
+ upload_packages_step(edition=edition, ver_mode=ver_mode, trigger=trigger_oss),
upload_cdn_step(edition=edition, ver_mode=ver_mode, trigger=trigger_oss)
])
if include_enterprise2:
edition2 = 'enterprise2'
build_steps.extend([
- package_step(edition=edition2, ver_mode=ver_mode, include_enterprise2=include_enterprise2, variants=['linux-amd64'], is_downstream=is_downstream),
- upload_packages_step(edition=edition2, ver_mode=ver_mode, is_downstream=is_downstream),
+ package_step(edition=edition2, ver_mode=ver_mode, include_enterprise2=include_enterprise2, variants=['linux-amd64']),
+ upload_packages_step(edition=edition2, ver_mode=ver_mode),
upload_cdn_step(edition=edition2, ver_mode=ver_mode)
])
- windows_steps = get_windows_steps(edition=edition, ver_mode=ver_mode, is_downstream=is_downstream)
- if edition == 'enterprise' and not is_downstream:
+ windows_steps = get_windows_steps(edition=edition, ver_mode=ver_mode)
+ if edition == 'enterprise':
store_steps = []
else:
store_steps = [
- store_packages_step(edition=edition, ver_mode=ver_mode, is_downstream=is_downstream),
+ store_packages_step(edition=edition, ver_mode=ver_mode),
]
- return test_steps, build_steps, integration_test_steps, windows_steps, store_steps
+ return init_steps, test_steps, build_steps, integration_test_steps, windows_steps, store_steps
def trigger_test_release():
return {
@@ -210,73 +219,39 @@ def main_pipelines(edition):
],
},
}
- test_steps, build_steps, integration_test_steps, windows_steps, store_steps = get_steps(edition=edition)
+ init_steps, test_steps, build_steps, integration_test_steps, windows_steps, store_steps = get_steps(edition=edition)
if edition == 'enterprise':
services.append(ldap_service())
integration_test_steps.append(benchmark_ldap_step())
- pipelines = [
- docs_pipelines(edition, ver_mode, trigger),
- pipeline(
- name='main-test', edition=edition, trigger=trigger, services=[],
- steps=[download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode) + test_steps,
- volumes=[],
- ),
- pipeline(
- name='main-build-e2e-publish', edition=edition, trigger=trigger, services=[],
- steps=[download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode) + build_steps,
- volumes=volumes,
- ),
- pipeline(
- name='main-integration-tests', edition=edition, trigger=trigger, services=services,
- steps=[download_grabpl_step()] + integration_test_steps,
- volumes=volumes,
- ),
- pipeline(
- name='windows-main', edition=edition, trigger=dict(trigger, repo = ['grafana/grafana']),
- steps=initialize_step(edition, platform='windows', ver_mode=ver_mode) + windows_steps,
- depends_on=['main-test', 'main-build-e2e-publish', 'main-integration-tests'], platform='windows',
- ), notify_pipeline(
- name='notify-drone-changes', slack_channel='slack-webhooks-test', trigger=drone_change_trigger, template=drone_change_template, secret='drone-changes-webhook',
- ),
- ]
- if edition != 'enterprise':
- pipelines.append(pipeline(
- name='publish-main', edition=edition, trigger=dict(trigger, repo = ['grafana/grafana']),
- steps=[download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode, install_deps=False) + store_steps,
- depends_on=['main-test', 'main-build-e2e-publish', 'main-integration-tests', 'windows-main',],
- ))
-
- pipelines.append(notify_pipeline(
- name='notify-main', slack_channel='grafana-ci-notifications', trigger=dict(trigger, status = ['failure']),
- depends_on=['main-test', 'main-build-e2e-publish', 'main-integration-tests', 'windows-main', 'publish-main'], template=failure_template, secret='slack_webhook'
- ))
- else:
- # Add downstream enterprise pipelines triggerable from OSS builds
- trigger = {
- 'event': ['custom',],
- }
- test_steps, build_steps, integration_test_steps, windows_steps, store_steps = get_steps(edition=edition, is_downstream=True)
- pipelines.append(pipeline(
- name='build-main-downstream', edition=edition, trigger=trigger, services=services,
- steps=[download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode, is_downstream=True) + test_steps + build_steps + integration_test_steps,
- volumes=volumes,
- ))
- pipelines.append(pipeline(
- name='windows-main-downstream', edition=edition, trigger=trigger,
- steps=[download_grabpl_step()] + initialize_step(edition, platform='windows', ver_mode=ver_mode, is_downstream=True) + windows_steps,
- platform='windows', depends_on=['build-main-downstream'],
- ))
- pipelines.append(pipeline(
- name='publish-main-downstream', edition=edition, trigger=trigger,
- steps=[download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode, is_downstream=True, install_deps=False) + store_steps,
- depends_on=['build-main-downstream', 'windows-main-downstream'],
- ))
-
- pipelines.append(notify_pipeline(
- name='notify-main-downstream', slack_channel='grafana-enterprise-ci-notifications', trigger=dict(trigger, status = ['failure']),
- depends_on=['build-main-downstream', 'windows-main-downstream', 'publish-main-downstream'], template=failure_template, secret='slack_webhook',
- ))
+ pipelines = [docs_pipelines(edition, ver_mode, trigger), pipeline(
+ name='main-test', edition=edition, trigger=trigger, services=[],
+ steps=init_steps + test_steps,
+ volumes=[],
+ ), pipeline(
+ name='main-build-e2e-publish', edition=edition, trigger=trigger, services=[],
+ steps=init_steps + build_steps,
+ volumes=volumes,
+ ), pipeline(
+ name='main-integration-tests', edition=edition, trigger=trigger, services=services,
+ steps=[download_grabpl_step(), identify_runner_step(),] + integration_test_steps,
+ volumes=volumes,
+ ), pipeline(
+ name='windows-main', edition=edition, trigger=dict(trigger, repo=['grafana/grafana']),
+ steps=[identify_runner_step('windows')] + windows_steps,
+ depends_on=['main-test', 'main-build-e2e-publish', 'main-integration-tests'], platform='windows',
+ ), notify_pipeline(
+ name='notify-drone-changes', slack_channel='slack-webhooks-test', trigger=drone_change_trigger,
+ template=drone_change_template, secret='drone-changes-webhook',
+ ), pipeline(
+ name='publish-main', edition=edition, trigger=dict(trigger, repo=['grafana/grafana']),
+ steps=[download_grabpl_step(), identify_runner_step(),] + store_steps,
+ depends_on=['main-test', 'main-build-e2e-publish', 'main-integration-tests', 'windows-main', ],
+ ), notify_pipeline(
+ name='notify-main', slack_channel='grafana-ci-notifications', trigger=dict(trigger, status=['failure']),
+ depends_on=['main-test', 'main-build-e2e-publish', 'main-integration-tests', 'windows-main', 'publish-main'],
+ template=failure_template, secret='slack_webhook'
+ )]
return pipelines
diff --git a/scripts/drone/pipelines/pr.star b/scripts/drone/pipelines/pr.star
index 1f768f1f5a2..d14425cc84e 100644
--- a/scripts/drone/pipelines/pr.star
+++ b/scripts/drone/pipelines/pr.star
@@ -1,7 +1,10 @@
load(
'scripts/drone/steps/lib.star',
'download_grabpl_step',
- 'initialize_step',
+ 'gen_version_step',
+ 'yarn_install_step',
+ 'wire_install_step',
+ 'identify_runner_step',
'lint_drone_step',
'lint_backend_step',
'lint_frontend_step',
@@ -59,6 +62,13 @@ def pr_pipelines(edition):
volumes = integration_test_services_volumes()
variants = ['linux-amd64', 'linux-amd64-musl', 'darwin-amd64', 'windows-amd64', 'armv6',]
include_enterprise2 = edition == 'enterprise'
+ init_steps = [
+ identify_runner_step(),
+ download_grabpl_step(),
+ gen_version_step(ver_mode),
+ wire_install_step(),
+ yarn_install_step(),
+ ]
test_steps = [
lint_drone_step(),
codespell_step(),
@@ -132,14 +142,12 @@ def pr_pipelines(edition):
return [
pipeline(
- name='pr-test', edition=edition, trigger=trigger, services=[], steps=[download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode)
- + test_steps,
+ name='pr-test', edition=edition, trigger=trigger, services=[], steps=init_steps + test_steps,
), pipeline(
- name='pr-build-e2e', edition=edition, trigger=trigger, services=[], steps=[download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode)
- + build_steps,
+ name='pr-build-e2e', edition=edition, trigger=trigger, services=[], steps=init_steps + build_steps,
), pipeline(
name='pr-integration-tests', edition=edition, trigger=trigger, services=services,
- steps=[download_grabpl_step()] + integration_test_steps,
+ steps=[download_grabpl_step(), identify_runner_step(),] + integration_test_steps,
volumes=volumes,
), docs_pipelines(edition, ver_mode, trigger_docs())
]
diff --git a/scripts/drone/pipelines/release.star b/scripts/drone/pipelines/release.star
index 0e10c9892fa..24f21985882 100644
--- a/scripts/drone/pipelines/release.star
+++ b/scripts/drone/pipelines/release.star
@@ -1,10 +1,15 @@
load(
'scripts/drone/steps/lib.star',
'disable_tests',
+ 'clone_enterprise_step',
'download_grabpl_step',
- 'initialize_step',
+ 'gen_version_step',
+ 'yarn_install_step',
+ 'wire_install_step',
+ 'init_enterprise_step',
'lint_drone_step',
'build_image',
+ 'identify_runner_step',
'publish_image',
'lint_backend_step',
'lint_frontend_step',
@@ -77,7 +82,7 @@ def retrieve_npm_packages_step():
'name': 'retrieve-npm-packages',
'image': publish_image,
'depends_on': [
- 'initialize',
+ 'grabpl',
],
'environment': {
'GCP_KEY': from_secret('gcp_key'),
@@ -150,16 +155,26 @@ def get_steps(edition, ver_mode):
should_upload = should_publish or ver_mode in ('release-branch',)
include_enterprise2 = edition == 'enterprise'
edition2 = 'enterprise2'
+ init_steps = [
+ identify_runner_step(),
+ download_grabpl_step(),
+ gen_version_step(ver_mode),
+ wire_install_step(),
+ yarn_install_step(),
+ ]
- test_steps = [
+ test_steps = []
+ if edition != 'enterprise':
+ test_steps.extend([shellcheck_step()])
+
+ test_steps.extend([
codespell_step(),
- shellcheck_step(),
lint_backend_step(edition=edition),
lint_frontend_step(),
test_backend_step(edition=edition),
test_backend_integration_step(edition=edition),
test_frontend_step(),
- ]
+ ])
build_steps = [
build_backend_step(edition=edition, ver_mode=ver_mode),
@@ -209,7 +224,7 @@ def get_steps(edition, ver_mode):
build_steps.append(build_storybook)
if include_enterprise2:
- integration_test_steps.extend([redis_integration_tests_step(edition=edition2, ver_mode=ver_mode), memcached_integration_tests_step(edition=edition2, ver_mode=ver_mode)])
+ integration_test_steps.extend([redis_integration_tests_step(), memcached_integration_tests_step()])
if should_upload:
publish_steps.append(upload_cdn_step(edition=edition, ver_mode=ver_mode, trigger=trigger_oss))
@@ -233,16 +248,16 @@ def get_steps(edition, ver_mode):
if step:
publish_steps.append(step)
- return test_steps, build_steps, integration_test_steps, package_steps, windows_package_steps, publish_steps
+ return init_steps, test_steps, build_steps, integration_test_steps, package_steps, windows_package_steps, publish_steps
def get_oss_pipelines(trigger, ver_mode):
edition = 'oss'
services = integration_test_services(edition=edition)
volumes = integration_test_services_volumes()
- test_steps, build_steps, integration_test_steps, package_steps, windows_package_steps, publish_steps = get_steps(edition=edition, ver_mode=ver_mode)
+ init_steps, test_steps, build_steps, integration_test_steps, package_steps, windows_package_steps, publish_steps = get_steps(edition=edition, ver_mode=ver_mode)
windows_pipeline = pipeline(
name='oss-windows-{}'.format(ver_mode), edition=edition, trigger=trigger,
- steps=initialize_step(edition, platform='windows', ver_mode=ver_mode) + windows_package_steps,
+ steps=[identify_runner_step('windows')] + windows_package_steps,
platform='windows', depends_on=[
'oss-build{}-publish-{}'.format(get_e2e_suffix(), ver_mode),
],
@@ -250,8 +265,7 @@ def get_oss_pipelines(trigger, ver_mode):
pipelines = [
pipeline(
name='oss-build{}-publish-{}'.format(get_e2e_suffix(), ver_mode), edition=edition, trigger=trigger, services=[],
- steps=[download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode) +
- build_steps + package_steps + publish_steps,
+ steps=init_steps + build_steps + package_steps + publish_steps,
volumes=volumes,
),
]
@@ -259,14 +273,12 @@ def get_oss_pipelines(trigger, ver_mode):
pipelines.extend([
pipeline(
name='oss-test-{}'.format(ver_mode), edition=edition, trigger=trigger, services=[],
- steps=[download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode) +
- test_steps,
+ steps=init_steps + test_steps,
volumes=[],
),
pipeline(
name='oss-integration-tests-{}'.format(ver_mode), edition=edition, trigger=trigger, services=services,
- steps=[download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode) +
- integration_test_steps,
+ steps=[download_grabpl_step(), identify_runner_step(),] + integration_test_steps,
volumes=volumes,
)
])
@@ -286,10 +298,28 @@ def get_enterprise_pipelines(trigger, ver_mode):
edition = 'enterprise'
services = integration_test_services(edition=edition)
volumes = integration_test_services_volumes()
- test_steps, build_steps, integration_test_steps, package_steps, windows_package_steps, publish_steps = get_steps(edition=edition, ver_mode=ver_mode)
+ deps_on_clone_enterprise_step = {
+ 'depends_on': [
+ 'init-enterprise',
+ ]
+ }
+ _, test_steps, build_steps, integration_test_steps, package_steps, windows_package_steps, publish_steps = get_steps(edition=edition, ver_mode=ver_mode)
+ init_steps = [
+ download_grabpl_step(),
+ identify_runner_step(),
+ clone_enterprise_step(ver_mode),
+ init_enterprise_step(ver_mode)
+ ]
+ for step in [wire_install_step(), yarn_install_step(), gen_version_step(ver_mode)]:
+ step.update(deps_on_clone_enterprise_step)
+ init_steps.extend([step])
+
+ for step in integration_test_steps:
+ step.update(deps_on_clone_enterprise_step)
+
windows_pipeline = pipeline(
name='enterprise-windows-{}'.format(ver_mode), edition=edition, trigger=trigger,
- steps=initialize_step(edition, platform='windows', ver_mode=ver_mode) + windows_package_steps,
+ steps=[identify_runner_step('windows')] + windows_package_steps,
platform='windows', depends_on=[
'enterprise-build{}-publish-{}'.format(get_e2e_suffix(), ver_mode),
],
@@ -297,8 +327,7 @@ def get_enterprise_pipelines(trigger, ver_mode):
pipelines = [
pipeline(
name='enterprise-build{}-publish-{}'.format(get_e2e_suffix(), ver_mode), edition=edition, trigger=trigger, services=[],
- steps=[download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode) +
- build_steps + package_steps + publish_steps,
+ steps=init_steps + build_steps + package_steps + publish_steps,
volumes=volumes,
),
]
@@ -306,14 +335,12 @@ def get_enterprise_pipelines(trigger, ver_mode):
pipelines.extend([
pipeline(
name='enterprise-test-{}'.format(ver_mode), edition=edition, trigger=trigger, services=[],
- steps=[download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode) +
- test_steps,
+ steps=init_steps + test_steps,
volumes=[],
),
pipeline(
name='enterprise-integration-tests-{}'.format(ver_mode), edition=edition, trigger=trigger, services=services,
- steps=[download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode) +
- integration_test_steps,
+ steps=[download_grabpl_step(), identify_runner_step(), clone_enterprise_step(ver_mode), init_enterprise_step(ver_mode),] + integration_test_steps,
volumes=volumes,
),
])
@@ -401,12 +428,11 @@ def publish_npm_pipelines(mode):
]
return [pipeline(
- name='publish-npm-packages-{}'.format(mode), trigger=trigger, steps = initialize_step(edition='oss', platform='linux', ver_mode='release') + steps, edition="all"
+ name='publish-npm-packages-{}'.format(mode), trigger=trigger, steps = steps, edition="all"
)]
-def release_pipelines(ver_mode='release', trigger=None, environment=None):
+def release_pipelines(ver_mode='release', trigger=None):
# 'enterprise' edition services contain both OSS and enterprise services
- services = integration_test_services(edition='enterprise')
if not trigger:
trigger = {
'event': {
@@ -420,8 +446,6 @@ def release_pipelines(ver_mode='release', trigger=None, environment=None):
},
}
- should_publish = ver_mode == 'release'
-
# The release pipelines include also enterprise ones, so both editions are built for a release.
# We could also solve this by triggering a downstream build for the enterprise repo, but by including enterprise
# in OSS release builds, we simplify the UX for the release engineer.
@@ -430,13 +454,6 @@ def release_pipelines(ver_mode='release', trigger=None, environment=None):
pipelines = oss_pipelines + enterprise_pipelines
- # if ver_mode == 'release':
- # pipelines.append(publish_artifacts_pipelines())
- #pipelines.append(notify_pipeline(
- # name='notify-{}'.format(ver_mode), slack_channel='grafana-ci-notifications', trigger=dict(trigger, status = ['failure']),
- # depends_on=[p['name'] for p in pipelines], template=failure_template, secret='slack_webhook',
- #))
-
return pipelines
def get_e2e_suffix():
diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star
index 2eea9aa70d3..55c861d9968 100644
--- a/scripts/drone/steps/lib.star
+++ b/scripts/drone/steps/lib.star
@@ -1,6 +1,6 @@
load('scripts/drone/vault.star', 'from_secret', 'github_token', 'pull_secret', 'drone_token', 'prerelease_bucket')
-grabpl_version = 'v2.9.32'
+grabpl_version = 'v2.9.37'
build_image = 'grafana/build-container:1.5.3'
publish_image = 'grafana/grafana-ci-deploy:1.3.1'
deploy_docker_image = 'us.gcr.io/kubernetes-dev/drone/plugins/deploy-image'
@@ -18,6 +18,7 @@ trigger_oss = {
}
}
+
def slack_step(channel, template, secret):
return {
'name': 'slack',
@@ -30,106 +31,49 @@ def slack_step(channel, template, secret):
}
-def initialize_step(edition, platform, ver_mode, is_downstream=False, install_deps=True):
- if platform == 'windows':
- return [
- {
- 'name': 'identify-runner',
- 'image': windows_image,
- 'commands': [
- 'echo $env:DRONE_RUNNER_NAME',
- ],
- },
- ]
-
- common_cmds = [
- # Generate Go code, will install Wire
- # TODO: Install Wire in Docker image instead
- 'make gen-go',
- ]
-
+def gen_version_step(ver_mode):
if ver_mode == 'release':
args = '${DRONE_TAG}'
- common_cmds.append('./bin/grabpl verify-version ${DRONE_TAG}')
else:
- if not is_downstream:
- build_no = '${DRONE_BUILD_NUMBER}'
- else:
- build_no = '$${SOURCE_BUILD_NUMBER}'
+ build_no = '${DRONE_BUILD_NUMBER}'
args = '--build-id {}'.format(build_no)
-
- identify_runner = identify_runner_step(platform)
-
- if install_deps:
- common_cmds.extend([
+ return {
+ 'name': 'gen-version',
+ 'image': build_image,
+ 'depends_on': [
+ 'grabpl',
+ ],
+ 'commands': [
'./bin/grabpl gen-version {}'.format(args),
+ ],
+ }
+
+
+def yarn_install_step():
+ return {
+ 'name': 'yarn-install',
+ 'image': build_image,
+ 'commands': [
'yarn install --immutable',
- ])
- if edition in ('enterprise', 'enterprise2'):
- source_commit = ''
- if ver_mode == 'release':
- committish = '${DRONE_TAG}'
- source_commit = ' ${DRONE_TAG}'
- environment = {
- 'GITHUB_TOKEN': from_secret(github_token),
- }
- token = "--github-token $${GITHUB_TOKEN}"
- elif ver_mode == 'release-branch':
- committish = '${DRONE_BRANCH}'
- environment = {}
- token = ""
- else:
- environment = {}
- if is_downstream:
- source_commit = ' $${SOURCE_COMMIT}'
- committish = '${DRONE_COMMIT}'
- token = ""
- steps = [
- identify_runner,
- clone_enterprise(committish),
- {
- 'name': 'initialize',
- 'image': build_image,
- 'depends_on': [
- 'clone-enterprise',
- ],
- 'environment': environment,
- 'commands': [
- 'mv bin/grabpl /tmp/',
- 'rmdir bin',
- 'mv grafana-enterprise /tmp/',
- '/tmp/grabpl init-enterprise {} /tmp/grafana-enterprise{}'.format(token, source_commit),
- 'mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json',
- 'mkdir bin',
- 'mv /tmp/grabpl bin/'
- ] + common_cmds,
- },
- ]
-
- return steps
-
- steps = [
- identify_runner,
- {
- 'name': 'initialize',
- 'image': build_image,
- 'commands': common_cmds,
- },
- ]
-
- return steps
+ ],
+ 'depends_on': [
+ 'grabpl',
+ ],
+ }
-def identify_runner_step(platform):
- if platform == 'linux':
- return {
- 'name': 'identify-runner',
- 'image': alpine_image,
- 'commands': [
- 'echo $DRONE_RUNNER_NAME',
- ],
- }
- else:
+def wire_install_step():
+ return {
+ 'name': 'wire-install',
+ 'image': build_image,
+ 'commands': [
+ 'make gen-go',
+ ],
+ }
+
+
+def identify_runner_step(platform='linux'):
+ if platform == 'windows':
return {
'name': 'identify-runner',
'image': windows_image,
@@ -137,9 +81,23 @@ def identify_runner_step(platform):
'echo $env:DRONE_RUNNER_NAME',
],
}
+ else:
+ return {
+ 'name': 'identify-runner',
+ 'image': alpine_image,
+ 'commands': [
+ 'echo $DRONE_RUNNER_NAME',
+ ],
+ }
-def clone_enterprise(committish):
+def clone_enterprise_step(ver_mode):
+ if ver_mode == 'release':
+ committish = '${DRONE_TAG}'
+ elif ver_mode == 'release-branch':
+ committish = '${DRONE_BRANCH}'
+ else:
+ committish = '${DRONE_COMMIT}'
return {
'name': 'clone-enterprise',
'image': build_image,
@@ -153,8 +111,51 @@ def clone_enterprise(committish):
],
}
+def init_enterprise_step(ver_mode):
+ source_commit = ''
+ if ver_mode == 'release':
+ source_commit = ' ${DRONE_TAG}'
+ environment = {
+ 'GITHUB_TOKEN': from_secret(github_token),
+ }
+ token = "--github-token $${GITHUB_TOKEN}"
+ elif ver_mode == 'release-branch':
+ environment = {}
+ token = ""
+ else:
+ environment = {}
+ token = ""
+ return {
+ 'name': 'init-enterprise',
+ 'image': build_image,
+ 'depends_on': [
+ 'clone-enterprise',
+ ],
+ 'environment': environment,
+ 'commands': [
+ 'mv bin/grabpl /tmp/',
+ 'rmdir bin',
+ 'mv grafana-enterprise /tmp/',
+ '/tmp/grabpl init-enterprise {} /tmp/grafana-enterprise{}'.format(token, source_commit),
+ 'mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json',
+ 'mkdir bin',
+ 'mv /tmp/grabpl bin/'
+ ],
+ }
+
+
+def download_grabpl_step(platform="linux"):
+ if platform == 'windows':
+ return {
+ 'name': 'grabpl',
+ 'image': wix_image,
+ 'commands': [
+ '$$ProgressPreference = "SilentlyContinue"',
+ 'Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/{}/windows/grabpl.exe -OutFile grabpl.exe'.format(
+ grabpl_version),
+ ]
+ }
-def download_grabpl_step():
return {
'name': 'grabpl',
'image': curl_image,
@@ -211,7 +212,7 @@ def lint_backend_step(edition):
'CGO_ENABLED': '1',
},
'depends_on': [
- 'initialize',
+ 'wire-install',
],
'commands': [
# Don't use Make since it will re-download the linters
@@ -224,9 +225,6 @@ def benchmark_ldap_step():
return {
'name': 'benchmark-ldap',
'image': build_image,
- 'depends_on': [
- 'initialize',
- ],
'environment': {
'LDAP_HOSTNAME': 'ldap',
},
@@ -263,25 +261,21 @@ def store_storybook_step(edition, ver_mode, trigger=None):
if edition in ('enterprise', 'enterprise2'):
return None
-
commands = []
if ver_mode == 'release':
- channels = ['latest', '${DRONE_TAG}', ]
+ commands.extend([
+ './bin/grabpl store-storybook --deployment latest --src-bucket grafana-prerelease --src-dir artifacts/storybook',
+ './bin/grabpl store-storybook --deployment ${DRONE_TAG} --src-bucket grafana-prerelease --src-dir artifacts/storybook',
+ ])
+
else:
- channels = ['canary', ]
- commands.extend([
- 'printenv GCP_KEY | base64 -d > /tmp/gcpkey.json',
- 'gcloud auth activate-service-account --key-file=/tmp/gcpkey.json',
- ] + [
- 'gsutil -m rm -r gs://$${{PRERELEASE_BUCKET}}/artifacts/storybook/{} || true && gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${{PRERELEASE_BUCKET}}/artifacts/storybook/{}'.format(
- c, c)
- for c in channels
- ])
+ # main pipelines should deploy storybook to grafana-storybook/canary public bucket
+ commands = ['./bin/grabpl store-storybook --deployment canary --src-bucket grafana-storybook', ]
step = {
'name': 'store-storybook',
'image': publish_image,
- 'depends_on': ['build-storybook',] + end_to_end_tests_deps(edition),
+ 'depends_on': ['build-storybook', ] + end_to_end_tests_deps(edition),
'environment': {
'GCP_KEY': from_secret('gcp_key'),
'PRERELEASE_BUCKET': from_secret(prerelease_bucket)
@@ -292,6 +286,7 @@ def store_storybook_step(edition, ver_mode, trigger=None):
step.update(trigger)
return step
+
def e2e_tests_artifacts(edition):
return {
'name': 'e2e-tests-artifacts-upload' + enterprise2_suffix(edition),
@@ -366,27 +361,20 @@ def upload_cdn_step(edition, ver_mode, trigger=None):
return step
-def build_backend_step(edition, ver_mode, variants=None, is_downstream=False):
+def build_backend_step(edition, ver_mode, variants=None):
variants_str = ''
if variants:
variants_str = ' --variants {}'.format(','.join(variants))
# TODO: Convert number of jobs to percentage
if ver_mode == 'release':
- env = {
- 'GITHUB_TOKEN': from_secret(github_token),
- }
cmds = [
- './bin/grabpl build-backend --jobs 8 --edition {} --github-token $${{GITHUB_TOKEN}} ${{DRONE_TAG}}'.format(
+ './bin/grabpl build-backend --jobs 8 --edition {} ${{DRONE_TAG}}'.format(
edition,
),
]
else:
- if not is_downstream:
- build_no = '${DRONE_BUILD_NUMBER}'
- else:
- build_no = '$${SOURCE_BUILD_NUMBER}'
- env = {}
+ build_no = '${DRONE_BUILD_NUMBER}'
cmds = [
'./bin/grabpl build-backend --jobs 8 --edition {} --build-id {}{}'.format(
edition, build_no, variants_str,
@@ -397,23 +385,20 @@ def build_backend_step(edition, ver_mode, variants=None, is_downstream=False):
'name': 'build-backend' + enterprise2_suffix(edition),
'image': build_image,
'depends_on': [
- 'initialize',
+ 'gen-version',
+ 'wire-install',
],
- 'environment': env,
'commands': cmds,
}
-def build_frontend_step(edition, ver_mode, is_downstream=False):
- if not is_downstream:
- build_no = '${DRONE_BUILD_NUMBER}'
- else:
- build_no = '$${SOURCE_BUILD_NUMBER}'
+def build_frontend_step(edition, ver_mode):
+ build_no = '${DRONE_BUILD_NUMBER}'
# TODO: Use percentage for num jobs
if ver_mode == 'release':
cmds = [
- './bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} ' + \
+ './bin/grabpl build-frontend --jobs 8 ' + \
'--edition {} ${{DRONE_TAG}}'.format(edition),
]
else:
@@ -425,42 +410,42 @@ def build_frontend_step(edition, ver_mode, is_downstream=False):
return {
'name': 'build-frontend',
'image': build_image,
- 'depends_on': [
- 'initialize',
- ],
'environment': {
'NODE_OPTIONS': '--max_old_space_size=8192',
},
+ 'depends_on': [
+ 'gen-version',
+ 'yarn-install',
+ ],
'commands': cmds,
}
-def build_frontend_package_step(edition, ver_mode, is_downstream=False):
- if not is_downstream:
- build_no = '${DRONE_BUILD_NUMBER}'
- else:
- build_no = '$${SOURCE_BUILD_NUMBER}'
+
+def build_frontend_package_step(edition, ver_mode):
+ build_no = '${DRONE_BUILD_NUMBER}'
# TODO: Use percentage for num jobs
if ver_mode == 'release':
cmds = [
- './bin/grabpl build-frontend-packages --jobs 8 --github-token $${GITHUB_TOKEN} ' + \
+ './bin/grabpl build-frontend-packages --jobs 8 ' + \
'--edition {} ${{DRONE_TAG}}'.format(edition),
- ]
+ ]
else:
cmds = [
'./bin/grabpl build-frontend-packages --jobs 8 --edition {} '.format(edition) + \
'--build-id {}'.format(build_no),
- ]
+ ]
return {
'name': 'build-frontend-packages',
'image': build_image,
- 'depends_on': [
- 'initialize',
- ],
'environment': {
'NODE_OPTIONS': '--max_old_space_size=8192',
},
+ 'depends_on': [
+ 'gen-version',
+ 'yarn-install',
+ ],
'commands': cmds,
}
@@ -490,10 +475,11 @@ def build_plugins_step(edition, sign=False):
return {
'name': 'build-plugins',
'image': build_image,
- 'depends_on': [
- 'initialize',
- ],
'environment': env,
+ 'depends_on': [
+ 'gen-version',
+ 'yarn-install',
+ ],
'commands': [
# TODO: Use percentage for num jobs
'./bin/grabpl build-plugins --jobs 8 --edition {}{}'.format(edition, sign_args),
@@ -506,7 +492,7 @@ def test_backend_step(edition):
'name': 'test-backend' + enterprise2_suffix(edition),
'image': build_image,
'depends_on': [
- 'initialize',
+ 'wire-install',
],
'commands': [
'./bin/grabpl test-backend --edition {}'.format(edition),
@@ -519,7 +505,7 @@ def test_backend_integration_step(edition):
'name': 'test-backend-integration' + enterprise2_suffix(edition),
'image': build_image,
'depends_on': [
- 'initialize',
+ 'wire-install',
],
'commands': [
'./bin/grabpl integration-tests --edition {}'.format(edition),
@@ -531,12 +517,12 @@ def test_frontend_step():
return {
'name': 'test-frontend',
'image': build_image,
- 'depends_on': [
- 'initialize',
- ],
'environment': {
'TEST_MAX_WORKERS': '50%',
},
+ 'depends_on': [
+ 'yarn-install',
+ ],
'commands': [
'yarn run ci:test-frontend',
],
@@ -547,16 +533,16 @@ def lint_frontend_step():
return {
'name': 'lint-frontend',
'image': build_image,
- 'depends_on': [
- 'initialize',
- ],
'environment': {
'TEST_MAX_WORKERS': '50%',
},
+ 'depends_on': [
+ 'yarn-install',
+ ],
'commands': [
'yarn run prettier:check',
'yarn run lint',
- 'yarn run i18n:compile', # TODO: right place for this?
+ 'yarn run i18n:compile', # TODO: right place for this?
'yarn run typecheck',
],
}
@@ -620,9 +606,6 @@ def codespell_step():
return {
'name': 'codespell',
'image': build_image,
- 'depends_on': [
- 'initialize',
- ],
'commands': [
# Important: all words have to be in lowercase, and separated by "\n".
'echo -e "unknwon\nreferer\nerrorstring\neror\niam\nwan" > words_to_ignore.txt',
@@ -637,7 +620,7 @@ def shellcheck_step():
'name': 'shellcheck',
'image': build_image,
'depends_on': [
- 'initialize',
+ 'grabpl',
],
'commands': [
'./bin/grabpl shellcheck',
@@ -645,7 +628,7 @@ def shellcheck_step():
}
-def package_step(edition, ver_mode, include_enterprise2=False, variants=None, is_downstream=False):
+def package_step(edition, ver_mode, include_enterprise2=False, variants=None):
deps = [
'build-plugins',
'build-backend',
@@ -666,7 +649,6 @@ def package_step(edition, ver_mode, include_enterprise2=False, variants=None, is
sign_args = ' --sign'
env = {
'GRAFANA_API_KEY': from_secret('grafana_api_key'),
- 'GITHUB_TOKEN': from_secret(github_token),
'GPG_PRIV_KEY': from_secret('gpg_priv_key'),
'GPG_PUB_KEY': from_secret('gpg_pub_key'),
'GPG_KEY_PASSWORD': from_secret('gpg_key_password'),
@@ -681,15 +663,12 @@ def package_step(edition, ver_mode, include_enterprise2=False, variants=None, is
if ver_mode == 'release':
cmds = [
'{}./bin/grabpl package --jobs 8 --edition {} '.format(test_args, edition) + \
- '--github-token $${{GITHUB_TOKEN}}{} ${{DRONE_TAG}}'.format(
+ '{} ${{DRONE_TAG}}'.format(
sign_args
),
]
else:
- if not is_downstream:
- build_no = '${DRONE_BUILD_NUMBER}'
- else:
- build_no = '$${SOURCE_BUILD_NUMBER}'
+ build_no = '${DRONE_BUILD_NUMBER}'
cmds = [
'{}./bin/grabpl package --jobs 8 --edition {} '.format(test_args, edition) + \
'--build-id {}{}{}'.format(build_no, variants_str, sign_args),
@@ -734,6 +713,7 @@ def grafana_server_step(edition, port=3001):
],
}
+
def e2e_tests_step(suite, edition, port=3001, tries=None):
cmd = './bin/grabpl e2e-tests --port {} --suite {}'.format(port, suite)
if tries:
@@ -813,13 +793,15 @@ def build_docker_images_step(edition, ver_mode, archs=None, ubuntu=False, publis
},
}
+
def publish_images_step(edition, ver_mode, mode, docker_repo, trigger=None):
if mode == 'security':
mode = '--{} '.format(mode)
else:
mode = ''
- cmd = './bin/grabpl artifacts docker publish {}--dockerhub-repo {} --base alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7'.format(mode, docker_repo)
+ cmd = './bin/grabpl artifacts docker publish {}--dockerhub-repo {} --base alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7'.format(
+ mode, docker_repo)
if ver_mode == 'release':
deps = ['fetch-images-{}'.format(edition)]
@@ -850,10 +832,7 @@ def publish_images_step(edition, ver_mode, mode, docker_repo, trigger=None):
def postgres_integration_tests_step(edition, ver_mode):
deps = []
- if edition in ('enterprise', 'enterprise2') and ver_mode in ('release-branch', 'release'):
- deps.extend(['initialize'])
- else:
- deps.extend(['grabpl'])
+ deps.extend(['grabpl'])
return {
'name': 'postgres-integration-tests',
'image': build_image,
@@ -878,10 +857,7 @@ def postgres_integration_tests_step(edition, ver_mode):
def mysql_integration_tests_step(edition, ver_mode):
deps = []
- if edition in ('enterprise', 'enterprise2') and ver_mode in ('release-branch', 'release'):
- deps.extend(['initialize'])
- else:
- deps.extend(['grabpl'])
+ deps.extend(['grabpl'])
return {
'name': 'mysql-integration-tests',
'image': build_image,
@@ -902,12 +878,9 @@ def mysql_integration_tests_step(edition, ver_mode):
}
-def redis_integration_tests_step(edition, ver_mode):
+def redis_integration_tests_step():
deps = []
- if edition in ('enterprise', 'enterprise2') and ver_mode in ('release-branch', 'release'):
- deps.extend(['initialize'])
- else:
- deps.extend(['grabpl'])
+ deps.extend(['grabpl'])
return {
'name': 'redis-integration-tests',
'image': build_image,
@@ -922,12 +895,9 @@ def redis_integration_tests_step(edition, ver_mode):
}
-def memcached_integration_tests_step(edition, ver_mode):
+def memcached_integration_tests_step():
deps = []
- if edition in ('enterprise', 'enterprise2') and ver_mode in ('release-branch', 'release'):
- deps.extend(['initialize'])
- else:
- deps.extend(['grabpl'])
+ deps.extend(['grabpl'])
return {
'name': 'memcached-integration-tests',
'image': build_image,
@@ -968,15 +938,16 @@ def enterprise2_suffix(edition):
return ''
-def upload_packages_step(edition, ver_mode, is_downstream=False, trigger=None):
- if ver_mode == 'main' and edition in ('enterprise', 'enterprise2') and not is_downstream:
+def upload_packages_step(edition, ver_mode, trigger=None):
+ if ver_mode == 'main' and edition in ('enterprise', 'enterprise2'):
return None
if ver_mode == 'release':
packages_bucket = '$${{PRERELEASE_BUCKET}}/artifacts/downloads{}'.format(enterprise2_suffix(edition))
cmd = './bin/grabpl upload-packages --edition {} --packages-bucket {}'.format(edition, packages_bucket)
elif edition == 'enterprise2':
- cmd = './bin/grabpl upload-packages --edition {} --packages-bucket grafana-downloads-enterprise2'.format(edition)
+ cmd = './bin/grabpl upload-packages --edition {} --packages-bucket grafana-downloads-enterprise2'.format(
+ edition)
else:
cmd = './bin/grabpl upload-packages --edition {} --packages-bucket grafana-downloads'.format(edition)
@@ -984,7 +955,7 @@ def upload_packages_step(edition, ver_mode, is_downstream=False, trigger=None):
if edition in 'enterprise2' or not end_to_end_tests_deps(edition):
deps.extend([
'package' + enterprise2_suffix(edition),
- ])
+ ])
else:
deps.extend(end_to_end_tests_deps(edition))
@@ -1003,16 +974,13 @@ def upload_packages_step(edition, ver_mode, is_downstream=False, trigger=None):
return step
-def store_packages_step(edition, ver_mode, is_downstream=False):
+def store_packages_step(edition, ver_mode):
if ver_mode == 'release':
cmd = './bin/grabpl store-packages --edition {} --packages-bucket grafana-downloads --gcp-key /tmp/gcpkey.json ${{DRONE_TAG}}'.format(
edition,
)
elif ver_mode == 'main':
- if not is_downstream:
- build_no = '${DRONE_BUILD_NUMBER}'
- else:
- build_no = '$${SOURCE_BUILD_NUMBER}'
+ build_no = '${DRONE_BUILD_NUMBER}'
cmd = './bin/grabpl store-packages --edition {} --gcp-key /tmp/gcpkey.json --build-id {}'.format(
edition, build_no,
)
@@ -1038,12 +1006,7 @@ def store_packages_step(edition, ver_mode, is_downstream=False):
}
-def get_windows_steps(edition, ver_mode, is_downstream=False):
- if not is_downstream:
- source_commit = ''
- else:
- source_commit = ' $$env:SOURCE_COMMIT'
-
+def get_windows_steps(edition, ver_mode):
init_cmds = []
sfx = ''
if edition in ('enterprise', 'enterprise2'):
@@ -1056,12 +1019,12 @@ def get_windows_steps(edition, ver_mode, is_downstream=False):
])
steps = [
{
- 'name': 'initialize',
+ 'name': 'windows-init',
'image': wix_image,
'commands': init_cmds,
},
]
- if (ver_mode == 'main' and (edition not in ('enterprise', 'enterprise2') or is_downstream)) or ver_mode in (
+ if (ver_mode == 'main' and (edition not in ('enterprise', 'enterprise2'))) or ver_mode in (
'release', 'release-branch',
):
bucket_part = ''
@@ -1073,10 +1036,7 @@ def get_windows_steps(edition, ver_mode, is_downstream=False):
dir = 'main'
bucket = 'grafana-downloads'
bucket_part = ' --packages-bucket {}'.format(bucket)
- if not is_downstream:
- build_no = 'DRONE_BUILD_NUMBER'
- else:
- build_no = 'SOURCE_BUILD_NUMBER'
+ build_no = 'DRONE_BUILD_NUMBER'
ver_part = '--build-id $$env:{}'.format(build_no)
installer_commands = [
'$$gcpKey = $$env:GCP_KEY',
@@ -1087,7 +1047,7 @@ def get_windows_steps(edition, ver_mode, is_downstream=False):
'rm gcpkey.json',
'cp C:\\App\\nssm-2.24.zip .',
]
- if (ver_mode == 'main' and (edition not in ('enterprise', 'enterprise2') or is_downstream)) or ver_mode in (
+ if (ver_mode == 'main' and (edition not in ('enterprise', 'enterprise2'))) or ver_mode in (
'release',
):
installer_commands.extend([
@@ -1108,15 +1068,15 @@ def get_windows_steps(edition, ver_mode, is_downstream=False):
steps.append({
'name': 'build-windows-installer',
'image': wix_image,
+ 'depends_on': [
+ 'windows-init',
+ ],
'environment': {
'GCP_KEY': from_secret('gcp_key'),
'PRERELEASE_BUCKET': from_secret(prerelease_bucket),
'GITHUB_TOKEN': from_secret('github_token')
},
'commands': installer_commands,
- 'depends_on': [
- 'initialize',
- ],
})
if edition in ('enterprise', 'enterprise2'):
@@ -1135,11 +1095,10 @@ def get_windows_steps(edition, ver_mode, is_downstream=False):
clone_cmds = [
'git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git"',
]
- if not is_downstream:
- clone_cmds.extend([
- 'cd grafana-enterprise',
- 'git checkout {}'.format(committish),
- ])
+ clone_cmds.extend([
+ 'cd grafana-enterprise',
+ 'git checkout {}'.format(committish),
+ ])
steps.insert(0, {
'name': 'clone',
'image': wix_image,
@@ -1157,7 +1116,7 @@ def get_windows_steps(edition, ver_mode, is_downstream=False):
'rm -r -force grafana-enterprise',
'cp grabpl.exe C:\\App\\grabpl.exe',
'rm -force grabpl.exe',
- 'C:\\App\\grabpl.exe init-enterprise --github-token $$env:GITHUB_TOKEN C:\\App\\grafana-enterprise{}'.format(source_commit),
+ 'C:\\App\\grabpl.exe init-enterprise --github-token $$env:GITHUB_TOKEN C:\\App\\grafana-enterprise',
'cp C:\\App\\grabpl.exe grabpl.exe',
])
if 'environment' in steps[1]:
@@ -1197,6 +1156,7 @@ def ensure_cuetsified_step():
],
}
+
def end_to_end_tests_deps(edition):
if disable_tests:
return []
diff --git a/scripts/stripnulls.sh b/scripts/stripnulls.sh
index 90a873ed50b..d64ff2fb8c9 100755
--- a/scripts/stripnulls.sh
+++ b/scripts/stripnulls.sh
@@ -7,7 +7,7 @@
SED=$(command -v gsed)
SED=${SED:-"sed"}
-FILES=$(grep -rl '"schemaVersion": 3[34]' devenv)
+FILES=$(grep -rl '"schemaVersion": 3[3456]' devenv)
set -e
set -x
for DASH in ${FILES}; do echo "${DASH}"; grep -v 'null,$' "${DASH}" > "${DASH}-nulless"; mv "${DASH}-nulless" "${DASH}"; done
diff --git a/yarn.lock b/yarn.lock
index 568fe87e119..5273232fb8d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3239,21 +3239,21 @@ __metadata:
languageName: node
linkType: hard
-"@betterer/betterer@npm:5.1.7, @betterer/betterer@npm:^5.1.7":
- version: 5.1.7
- resolution: "@betterer/betterer@npm:5.1.7"
+"@betterer/betterer@npm:5.3.0, @betterer/betterer@npm:^5.3.0":
+ version: 5.3.0
+ resolution: "@betterer/betterer@npm:5.3.0"
dependencies:
- "@betterer/constraints": ^5.0.0
- "@betterer/errors": ^5.0.0
- "@betterer/logger": ^5.1.5
- "@betterer/reporter": ^5.1.7
+ "@betterer/constraints": ^5.3.0
+ "@betterer/errors": ^5.3.0
+ "@betterer/logger": ^5.3.0
+ "@betterer/reporter": ^5.3.0
"@phenomnomnominal/debug": ^0.2.5
"@phenomnomnominal/worker-require": ^0.0.34
chokidar: ^3.3.1
djb2a: ^1.2.0
fast-memoize: ^2.5.2
- lines-and-columns: ^1.1.6
- minimatch: ^3.0.4
+ lines-and-columns: ^2.0.3
+ minimatch: ^5.0.1
prettier: ^2.3.2
simple-git: ^3.1.1
ts-node: ^10.2.1
@@ -3262,17 +3262,17 @@ __metadata:
dependenciesMeta:
typescript:
optional: true
- checksum: 3c8b980910de3078b6e44e9aaa741419d1985800ec2fb4e5c64fa1f1cd473aae03a07a93511dbde86647142c7277a3002b39c2c87fe4828f79412dac4c687faf
+ checksum: 4c948bde624d71ae1f5c970e2eec1aa4116d0b691bef848053d9ca577e19425011949068686b0630f20bd90c4784de9dcbe5c2a8bb98cea1b8282f71bbf00fbc
languageName: node
linkType: hard
-"@betterer/cli@npm:5.1.7":
- version: 5.1.7
- resolution: "@betterer/cli@npm:5.1.7"
+"@betterer/cli@npm:5.3.0":
+ version: 5.3.0
+ resolution: "@betterer/cli@npm:5.3.0"
dependencies:
- "@betterer/betterer": ^5.1.7
- "@betterer/errors": ^5.0.0
- "@betterer/tasks": ^5.1.7
+ "@betterer/betterer": ^5.3.0
+ "@betterer/errors": ^5.3.0
+ "@betterer/tasks": ^5.3.0
"@phenomnomnominal/tsquery": ^4.1.1
"@phenomnomnominal/tstemplate": ^0.1.0
"@phenomnomnominal/worker-require": ^0.0.34
@@ -3287,80 +3287,80 @@ __metadata:
tslib: ^2.3.1
bin:
betterer: bin/betterer
- checksum: cabfca31186e2d3ab76e0b25145fbc671079f68ab5737583395f6035ca019e61fd38a20bfa92ad9b488c5c86c70f8744c1676cf37215d109361ce496227840b3
+ checksum: 1bb282b039461d866f6be5981054e30416040f739009ac9cc268f091482a050aa7be971203fbddae87485c14e04bfaff09908aa0be7d862761bdfbdb380c951d
languageName: node
linkType: hard
-"@betterer/constraints@npm:^5.0.0":
- version: 5.0.0
- resolution: "@betterer/constraints@npm:5.0.0"
+"@betterer/constraints@npm:^5.3.0":
+ version: 5.3.0
+ resolution: "@betterer/constraints@npm:5.3.0"
dependencies:
tslib: ^2.3.1
- checksum: 6b6560ea7f68f5909e40e444c0a394b299968b0c2b533aea69d8c0bd9e3239952507df1f6665952dde03a1fd90d9786ea4e18b207af37b3d6e331c2f369e696d
+ checksum: a3991bc4f138bfaff2ddd842790ea43c50edc6ea4b50546e44b8489231dfb5a7f9ef995d1c7d8695149fda5cd4b1aa79731c76482708a18cbfe398c39b9dcee8
languageName: node
linkType: hard
-"@betterer/errors@npm:^5.0.0":
- version: 5.0.0
- resolution: "@betterer/errors@npm:5.0.0"
- checksum: d4245427d15305df047bd656820ea492f055237f3cf6e2e971b087f6df404fff1b8bc07ddc353e00d2ffe3aea99ae9edfa471436dccd5a187b93092d9e659217
+"@betterer/errors@npm:^5.3.0":
+ version: 5.3.0
+ resolution: "@betterer/errors@npm:5.3.0"
+ checksum: 15d6679dca00a97ee1845225af662fdbcf84a684286d3ae654023ec5d1e2e61bb6c7a97ba5e6e50dfeebbeb5247f4bf5d5e4705a0468ea067bff30e22b7ccaf3
languageName: node
linkType: hard
-"@betterer/logger@npm:^5.1.5":
- version: 5.1.5
- resolution: "@betterer/logger@npm:5.1.5"
+"@betterer/logger@npm:^5.3.0":
+ version: 5.3.0
+ resolution: "@betterer/logger@npm:5.3.0"
dependencies:
"@babel/code-frame": ^7.10.3
jest-diff: ^27.0.6
- lines-and-columns: ^1.1.6
+ lines-and-columns: ^2.0.3
react: ">=16.8.4 || >=17.0.0"
react-dom: ">=16.8.4 || >=17.0.0"
tslib: ^2.3.1
- checksum: ce0e6847049e2132b527003b4732e4ee3e80464651c7882bf94c308f2a2a9f5495013837809743b2a16a11a844d534f8b84fc29cab22ec7fbe40c628c1759cec
+ checksum: 38bc7bbc84dc174fa39e8ab533a23a4163f0aca0168942ebdebd026e087242f5254e3f7ca363bd574c5bc9d28045de0b83cdf3c11d670f74e726a24410320323
languageName: node
linkType: hard
-"@betterer/regexp@npm:5.1.7":
- version: 5.1.7
- resolution: "@betterer/regexp@npm:5.1.7"
+"@betterer/regexp@npm:5.3.0":
+ version: 5.3.0
+ resolution: "@betterer/regexp@npm:5.3.0"
dependencies:
- "@betterer/betterer": ^5.1.7
- "@betterer/errors": ^5.0.0
+ "@betterer/betterer": ^5.3.0
+ "@betterer/errors": ^5.3.0
tslib: ^2.3.1
- checksum: 17af07f3e8240ce45716b6d24cf5492aeca4ab2a78c57455ae2f26365dbbe5d1a1f93b02ee2cdaddd817b65a510de0a041e7cb8694663acedd7a090599bde547
+ checksum: 9b69160783b147ef7c9c585ad8dc82303dee39b549903ae1a74a87d0e3bfaa9a2fc408389d39e655dfae516c82565397841efd04d7b5d051e80df292d21e0530
languageName: node
linkType: hard
-"@betterer/reporter@npm:^5.1.7":
- version: 5.1.7
- resolution: "@betterer/reporter@npm:5.1.7"
+"@betterer/reporter@npm:^5.3.0":
+ version: 5.3.0
+ resolution: "@betterer/reporter@npm:5.3.0"
dependencies:
- "@betterer/betterer": ^5.1.7
- "@betterer/errors": ^5.0.0
- "@betterer/logger": ^5.1.5
- "@betterer/tasks": ^5.1.7
+ "@betterer/betterer": ^5.3.0
+ "@betterer/errors": ^5.3.0
+ "@betterer/logger": ^5.3.0
+ "@betterer/tasks": ^5.3.0
ink: ^3.0.9
ink-text-input: ^4.0.1
react: ">=16.8.4 || >=17.0.0"
react-dom: ">=16.8.4 || >=17.0.0"
tslib: ^2.3.1
- checksum: 269f65fb76e539fbd714d75c53a02b941aecdfeb46599e3944f23e7932f1251a4e0c807ad21a610183e797529ce11deeb08b2f6e13b059bc1bbbbe03878ad115
+ checksum: a303108200cd5a0b3de35093db17a0b208c1c2456f5edad8be346367f036052e88cb87f06f047a52f4671cb895158013b13545bfd4bc12aae2cfc7133c4d6a94
languageName: node
linkType: hard
-"@betterer/tasks@npm:^5.1.7":
- version: 5.1.7
- resolution: "@betterer/tasks@npm:5.1.7"
+"@betterer/tasks@npm:^5.3.0":
+ version: 5.3.0
+ resolution: "@betterer/tasks@npm:5.3.0"
dependencies:
- "@betterer/errors": ^5.0.0
- "@betterer/logger": ^5.1.5
+ "@betterer/errors": ^5.3.0
+ "@betterer/logger": ^5.3.0
chalk: ^4.1.2
ink: ^3.0.9
react: ">=16.8.4 || >=17.0.0"
react-dom: ">=16.8.4 || >=17.0.0"
tslib: ^2.3.1
- checksum: b46a019d101a2c3a143cf2e1405f40982ef493c144552a116871f0f037f92ed734618da657ca1beb517dcef370d41daa78be73c224afbb42fe5d7c6c9f45fc38
+ checksum: 65ea5a76ee8a6e447a866876521f7b14255087537b6ea20069fd326015b31d39dd24e52799c095706faf21622a8365f8933e5ba0bc805bfac55e13634ab9df55
languageName: node
linkType: hard
@@ -4018,7 +4018,7 @@ __metadata:
history: 4.10.1
lodash: 4.17.21
marked: 4.0.12
- moment: 2.29.1
+ moment: 2.29.2
moment-timezone: 0.5.34
ol: 6.14.1
papaparse: 5.3.2
@@ -4366,23 +4366,22 @@ __metadata:
"@react-aria/overlays": 3.8.1
"@react-stately/menu": 3.2.6
"@rollup/plugin-commonjs": 21.0.2
- "@rollup/plugin-image": 2.1.1
"@rollup/plugin-node-resolve": 13.1.3
"@sentry/browser": 6.19.1
- "@storybook/addon-a11y": 6.4.15
- "@storybook/addon-actions": 6.4.15
- "@storybook/addon-docs": 6.4.15
- "@storybook/addon-essentials": 6.4.15
+ "@storybook/addon-a11y": 6.4.21
+ "@storybook/addon-actions": 6.4.21
+ "@storybook/addon-docs": 6.4.21
+ "@storybook/addon-essentials": 6.4.21
"@storybook/addon-knobs": 6.4.0
- "@storybook/addon-storysource": 6.4.15
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/builder-webpack5": 6.4.15
- "@storybook/components": 6.4.15
- "@storybook/core-events": 6.4.15
- "@storybook/manager-webpack5": 6.4.15
- "@storybook/react": 6.4.15
- "@storybook/theming": 6.4.15
+ "@storybook/addon-storysource": 6.4.21
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/builder-webpack5": 6.4.21
+ "@storybook/components": 6.4.21
+ "@storybook/core-events": 6.4.21
+ "@storybook/manager-webpack5": 6.4.21
+ "@storybook/react": 6.4.21
+ "@storybook/theming": 6.4.21
"@swc/helpers": 0.3.8
"@testing-library/dom": 8.13.0
"@testing-library/jest-dom": 5.16.2
@@ -4440,7 +4439,7 @@ __metadata:
lodash: 4.17.21
memoize-one: 6.0.0
mock-raf: 1.0.1
- moment: 2.29.1
+ moment: 2.29.2
monaco-editor: ^0.31.1
ol: 6.14.1
postcss: 8.4.12
@@ -4476,6 +4475,7 @@ __metadata:
rimraf: 3.0.2
rollup: 2.70.1
rollup-plugin-sourcemaps: 0.6.3
+ rollup-plugin-svg-import: ^1.6.0
rollup-plugin-terser: 7.0.2
rxjs: 7.5.5
sass-loader: 12.6.0
@@ -4614,7 +4614,7 @@ __metadata:
lodash: 4.17.21
lru-memoize: ^1.1.0
memoize-one: 6.0.0
- moment: 2.29.1
+ moment: 2.29.2
moment-timezone: 0.5.34
prop-types: 15.8.1
react: 17.0.2
@@ -5180,15 +5180,15 @@ __metadata:
languageName: node
linkType: hard
-"@kusto/monaco-kusto@npm:4.1.6":
- version: 4.1.6
- resolution: "@kusto/monaco-kusto@npm:4.1.6"
+"@kusto/monaco-kusto@npm:5.0.0":
+ version: 5.0.0
+ resolution: "@kusto/monaco-kusto@npm:5.0.0"
dependencies:
"@kusto/language-service": 2.0.0-beta.0
"@kusto/language-service-next": 0.0.54
peerDependencies:
- monaco-editor: ^0.24.0
- checksum: c01440cc8a8511e3afdf6c63e99e23cac976237f423d083384e3ee0605935fba5b69cc20627d45bc56bac82ce42ec6f613282d24b12b3dc0a3456ab5ff631238
+ monaco-editor: 0.33.0
+ checksum: 961bcae8a84562ad0d1aa199bdacbe5adfffafb01e98a2cf927883331d45ad8fcdf7385f9bde00eed16f74a33e0dea41f9eb80127e2e5a341ea3b8a2aa9fb2d8
languageName: node
linkType: hard
@@ -7256,18 +7256,6 @@ __metadata:
languageName: node
linkType: hard
-"@rollup/plugin-image@npm:2.1.1":
- version: 2.1.1
- resolution: "@rollup/plugin-image@npm:2.1.1"
- dependencies:
- "@rollup/pluginutils": ^3.1.0
- mini-svg-data-uri: ^1.2.3
- peerDependencies:
- rollup: ^1.20.0 || ^2.0.0
- checksum: a629c8f22233ca159c23655fdbc3449dab3c939372178ed4462fc9c525cc4ecd8b11fae359eb94be4f769d26f48b85fb18eb16ce1fbc33ed16b6a7c1f84391f6
- languageName: node
- linkType: hard
-
"@rollup/plugin-json@npm:4.1.0":
version: 4.1.0
resolution: "@rollup/plugin-json@npm:4.1.0"
@@ -7308,6 +7296,16 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/pluginutils@npm:^4.1.1":
+ version: 4.2.0
+ resolution: "@rollup/pluginutils@npm:4.2.0"
+ dependencies:
+ estree-walker: ^2.0.1
+ picomatch: ^2.2.2
+ checksum: 2e86d9bfb95919727bcba0bbbdbedc98e25a1e51fe3047f18ec6d85e0743d1c73e1c0de3f9fdbd2ff6b90c32f30d4b2706c9e794f3c2e7a80156980081558e2e
+ languageName: node
+ linkType: hard
+
"@rtsao/plugin-proposal-class-properties@npm:7.0.1-patch.1":
version: 7.0.1-patch.1
resolution: "@rtsao/plugin-proposal-class-properties@npm:7.0.1-patch.1"
@@ -7555,18 +7553,18 @@ __metadata:
languageName: node
linkType: hard
-"@storybook/addon-a11y@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/addon-a11y@npm:6.4.15"
+"@storybook/addon-a11y@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/addon-a11y@npm:6.4.21"
dependencies:
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/channels": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/components": 6.4.15
- "@storybook/core-events": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/channels": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/components": 6.4.21
+ "@storybook/core-events": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
- "@storybook/theming": 6.4.15
+ "@storybook/theming": 6.4.21
axe-core: ^4.2.0
core-js: ^3.8.2
global: ^4.4.0
@@ -7583,20 +7581,20 @@ __metadata:
optional: true
react-dom:
optional: true
- checksum: 996081f2279001e2d855f950eece21ea098ac765f9b3749c5f9c109585bd2b293d1e35459d4ea9dc9fb6abccb115414fcd44fdcb4b6878d546f14d7778561eed
+ checksum: 0e71c20a9516c3ff531f7accd3326177a1c7ede13eead5baa1794e350a9f8a6fc80021638a6419c92e1f88a0a936cb2b0355cb75295a12a2bfebeb890e8bd968
languageName: node
linkType: hard
-"@storybook/addon-actions@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/addon-actions@npm:6.4.15"
+"@storybook/addon-actions@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/addon-actions@npm:6.4.21"
dependencies:
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/components": 6.4.15
- "@storybook/core-events": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/components": 6.4.21
+ "@storybook/core-events": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
- "@storybook/theming": 6.4.15
+ "@storybook/theming": 6.4.21
core-js: ^3.8.2
fast-deep-equal: ^3.1.3
global: ^4.4.0
@@ -7617,21 +7615,21 @@ __metadata:
optional: true
react-dom:
optional: true
- checksum: 311e082bebc9a93372c90feb03ccf3ab0fb591acb69e580e1845d0f76f8d007de12b5667ea35dc83e6ed891ac3f5cf40871cc4902980e4079ba2cd11015dc99f
+ checksum: 27f6cc73ecc6be33898c8623503d1c6a2db07228d390fa7bf82e03055f7d95faa64d9eab632929ac42cc2a3732de0b26fd06bc3c9397a0e756fb883302a235b6
languageName: node
linkType: hard
-"@storybook/addon-backgrounds@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/addon-backgrounds@npm:6.4.15"
+"@storybook/addon-backgrounds@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/addon-backgrounds@npm:6.4.21"
dependencies:
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/components": 6.4.15
- "@storybook/core-events": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/components": 6.4.21
+ "@storybook/core-events": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
- "@storybook/theming": 6.4.15
+ "@storybook/theming": 6.4.21
core-js: ^3.8.2
global: ^4.4.0
memoizerific: ^1.11.3
@@ -7646,23 +7644,23 @@ __metadata:
optional: true
react-dom:
optional: true
- checksum: 71ca7b45d0e3cc042394043959e45bce5f61dc0bfc003c01343b2153c84098abfab32853959c24ed393548bba65d4edc06f764f690855ed832dd30eba169e90b
+ checksum: 41f2358fce5616886d2e364d19000f284df561973e1fca44873b8f90d6a8fe45f9f84583dcb42fffc68f286e3845ef9576bd2a2970f496e6f363c20cb27be275
languageName: node
linkType: hard
-"@storybook/addon-controls@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/addon-controls@npm:6.4.15"
+"@storybook/addon-controls@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/addon-controls@npm:6.4.21"
dependencies:
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/components": 6.4.15
- "@storybook/core-common": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/components": 6.4.21
+ "@storybook/core-common": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
- "@storybook/node-logger": 6.4.15
- "@storybook/store": 6.4.15
- "@storybook/theming": 6.4.15
+ "@storybook/node-logger": 6.4.21
+ "@storybook/store": 6.4.21
+ "@storybook/theming": 6.4.21
core-js: ^3.8.2
lodash: ^4.17.21
ts-dedent: ^2.0.0
@@ -7674,13 +7672,13 @@ __metadata:
optional: true
react-dom:
optional: true
- checksum: d7daa8cdd199d0c1b0d68c027977efa4b60e75aed25e0b60e79e0d4631afc41b6f2df4a6ffd880a014789e0e8b889eea81658e8b8ddaa89abfeba671ccc0f4fc
+ checksum: c47aaffd6d13afbeac9c471033efe87837a1c46007cf4559fb55c8e4876552acf4e2814c87485cbf4afe70cb18b88e0bbfc239ed6fb8fb3eff72485b179ec12f
languageName: node
linkType: hard
-"@storybook/addon-docs@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/addon-docs@npm:6.4.15"
+"@storybook/addon-docs@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/addon-docs@npm:6.4.21"
dependencies:
"@babel/core": ^7.12.10
"@babel/generator": ^7.12.11
@@ -7691,21 +7689,21 @@ __metadata:
"@mdx-js/loader": ^1.6.22
"@mdx-js/mdx": ^1.6.22
"@mdx-js/react": ^1.6.22
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/builder-webpack4": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/components": 6.4.15
- "@storybook/core": 6.4.15
- "@storybook/core-events": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/builder-webpack4": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/components": 6.4.21
+ "@storybook/core": 6.4.21
+ "@storybook/core-events": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
- "@storybook/csf-tools": 6.4.15
- "@storybook/node-logger": 6.4.15
- "@storybook/postinstall": 6.4.15
- "@storybook/preview-web": 6.4.15
- "@storybook/source-loader": 6.4.15
- "@storybook/store": 6.4.15
- "@storybook/theming": 6.4.15
+ "@storybook/csf-tools": 6.4.21
+ "@storybook/node-logger": 6.4.21
+ "@storybook/postinstall": 6.4.21
+ "@storybook/preview-web": 6.4.21
+ "@storybook/source-loader": 6.4.21
+ "@storybook/store": 6.4.21
+ "@storybook/theming": 6.4.21
acorn: ^7.4.1
acorn-jsx: ^5.3.1
acorn-walk: ^7.2.0
@@ -7729,12 +7727,12 @@ __metadata:
ts-dedent: ^2.0.0
util-deprecate: ^1.0.2
peerDependencies:
- "@storybook/angular": 6.4.15
- "@storybook/html": 6.4.15
- "@storybook/react": 6.4.15
- "@storybook/vue": 6.4.15
- "@storybook/vue3": 6.4.15
- "@storybook/web-components": 6.4.15
+ "@storybook/angular": 6.4.21
+ "@storybook/html": 6.4.21
+ "@storybook/react": 6.4.21
+ "@storybook/vue": 6.4.21
+ "@storybook/vue3": 6.4.21
+ "@storybook/web-components": 6.4.21
lit: ^2.0.0
lit-html: ^1.4.1 || ^2.0.0
react: ^16.8.0 || ^17.0.0
@@ -7772,32 +7770,32 @@ __metadata:
optional: true
webpack:
optional: true
- checksum: a4245258c9497c50b025bf6c39d60e18c1ca5af736ac24f312f88a6376cb965d1fcc301d507eb859b0ef8b0761264fb0adcf288030ec1d889bf9a5c0f287a8a7
+ checksum: df37cdb51c8a2656bb8dbf586f0cae2437f1c654375913e1a500d83a58bd32d3e59b0ae66ec00a5ec2c3352622078e594f6f3b8ac09662de7e7b7ce93cc1b1ba
languageName: node
linkType: hard
-"@storybook/addon-essentials@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/addon-essentials@npm:6.4.15"
+"@storybook/addon-essentials@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/addon-essentials@npm:6.4.21"
dependencies:
- "@storybook/addon-actions": 6.4.15
- "@storybook/addon-backgrounds": 6.4.15
- "@storybook/addon-controls": 6.4.15
- "@storybook/addon-docs": 6.4.15
- "@storybook/addon-measure": 6.4.15
- "@storybook/addon-outline": 6.4.15
- "@storybook/addon-toolbars": 6.4.15
- "@storybook/addon-viewport": 6.4.15
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/node-logger": 6.4.15
+ "@storybook/addon-actions": 6.4.21
+ "@storybook/addon-backgrounds": 6.4.21
+ "@storybook/addon-controls": 6.4.21
+ "@storybook/addon-docs": 6.4.21
+ "@storybook/addon-measure": 6.4.21
+ "@storybook/addon-outline": 6.4.21
+ "@storybook/addon-toolbars": 6.4.21
+ "@storybook/addon-viewport": 6.4.21
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/node-logger": 6.4.21
core-js: ^3.8.2
regenerator-runtime: ^0.13.7
ts-dedent: ^2.0.0
peerDependencies:
"@babel/core": ^7.9.6
- "@storybook/vue": 6.4.15
- "@storybook/web-components": 6.4.15
+ "@storybook/vue": 6.4.21
+ "@storybook/web-components": 6.4.21
babel-loader: ^8.0.0
lit-html: ^1.4.1 || ^2.0.0-rc.3
react: ^16.8.0 || ^17.0.0
@@ -7816,7 +7814,7 @@ __metadata:
optional: true
webpack:
optional: true
- checksum: 2e05c01380393b870898bca8f5965c9b236d9e31b27f748369620b4d732e6303f930147bd47240bce2416cc7740aa7679808eeba9e2caa41fbbbb1b55563dcae
+ checksum: aee028eaebb6574a00c41d0540189ac9427d424972cb33c1d2fade68a5ec98560d8848969674acf5f54357c5c97428d14512ebe49cf1c069599d3ea3cac4cb03
languageName: node
linkType: hard
@@ -7852,15 +7850,15 @@ __metadata:
languageName: node
linkType: hard
-"@storybook/addon-measure@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/addon-measure@npm:6.4.15"
+"@storybook/addon-measure@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/addon-measure@npm:6.4.21"
dependencies:
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/components": 6.4.15
- "@storybook/core-events": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/components": 6.4.21
+ "@storybook/core-events": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
core-js: ^3.8.2
global: ^4.4.0
@@ -7872,19 +7870,19 @@ __metadata:
optional: true
react-dom:
optional: true
- checksum: fe19eeca1ed3957268c6e2d42c4f4e6ce58f513f097a7b5dc34576a4c2fdaa82584365d428119227f4180a7b3a10b3bdc4e88181675526403461c4a5371d9bf1
+ checksum: ef3004d789ff258c3eca31c6a7465678d5dbb89b2e7d7739e54749669b7bff927a6185798bd56e0e2137010a46c171f86df1166407334cb5daf7e4a5a7510887
languageName: node
linkType: hard
-"@storybook/addon-outline@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/addon-outline@npm:6.4.15"
+"@storybook/addon-outline@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/addon-outline@npm:6.4.21"
dependencies:
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/components": 6.4.15
- "@storybook/core-events": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/components": 6.4.21
+ "@storybook/core-events": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
core-js: ^3.8.2
global: ^4.4.0
@@ -7898,21 +7896,21 @@ __metadata:
optional: true
react-dom:
optional: true
- checksum: 6bbfd68754ddecd975f0ea720790973e79f91e9e01f9442fb1199c1adf049330ddd1c01806c0c6c3399a2cda6a9933b49e56b831d8842ce746e41ea70ff5a738
+ checksum: 980878641a779084ed28c5c6460ad2e15b3c2fce9b9a4ca425b2a779956b0c84bdc70cfa22109337da1b72253cb8718c1ee2b778dd41200424f2c1e0ba9dba79
languageName: node
linkType: hard
-"@storybook/addon-storysource@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/addon-storysource@npm:6.4.15"
+"@storybook/addon-storysource@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/addon-storysource@npm:6.4.21"
dependencies:
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/components": 6.4.15
- "@storybook/router": 6.4.15
- "@storybook/source-loader": 6.4.15
- "@storybook/theming": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/components": 6.4.21
+ "@storybook/router": 6.4.21
+ "@storybook/source-loader": 6.4.21
+ "@storybook/theming": 6.4.21
core-js: ^3.8.2
estraverse: ^5.2.0
loader-utils: ^2.0.0
@@ -7928,18 +7926,18 @@ __metadata:
optional: true
react-dom:
optional: true
- checksum: d87b3bbd79c815a97e794b177267569cb2005b74b3f892bca324e560c433360986371b2c9156baf00586a06838845b2b0fe0503f5393830f9801c9456dbea749
+ checksum: 41b1aca46f7cb5aa73dbccd3faf6a959e70846453847bff5009096f020dec290fc0db7d116439c4fbc339347367c6d279ac13f44ed80d67d08893be9df782a53
languageName: node
linkType: hard
-"@storybook/addon-toolbars@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/addon-toolbars@npm:6.4.15"
+"@storybook/addon-toolbars@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/addon-toolbars@npm:6.4.21"
dependencies:
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/components": 6.4.15
- "@storybook/theming": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/components": 6.4.21
+ "@storybook/theming": 6.4.21
core-js: ^3.8.2
regenerator-runtime: ^0.13.7
peerDependencies:
@@ -7950,20 +7948,20 @@ __metadata:
optional: true
react-dom:
optional: true
- checksum: bdccfc40e9e6763c3a0f0787fb274c1b6c5c1b1375717d6637539df663575fe84df3e980148c9cc5ce0123e05c3b2635e151460f9989af0a7d46eed1a452e372
+ checksum: ab1460ee855772fc1ea61e65de4f228af9fd5d83e93908bffd50445ca391584299d1f7517ea750cde7c29d56815ccdd1463b91e1f872016956e1a253fe6983fd
languageName: node
linkType: hard
-"@storybook/addon-viewport@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/addon-viewport@npm:6.4.15"
+"@storybook/addon-viewport@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/addon-viewport@npm:6.4.21"
dependencies:
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/components": 6.4.15
- "@storybook/core-events": 6.4.15
- "@storybook/theming": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/components": 6.4.21
+ "@storybook/core-events": 6.4.21
+ "@storybook/theming": 6.4.21
core-js: ^3.8.2
global: ^4.4.0
memoizerific: ^1.11.3
@@ -7977,21 +7975,21 @@ __metadata:
optional: true
react-dom:
optional: true
- checksum: 44fe3b03a17fd358bb1e47e37e483c4942adf03e55a0cb38b21a7070631bc20728443657250fc0ccf573722d77a28b9738d8a2c58ce3d8b5c1cfab308cfa6a78
+ checksum: 76966f2597f2bb7811d885ee828728cec4e210af8c87dd56589d5b01df4447de163c83a2467f07e420e77584906e04e42e8632a14a9eb358d9b3c9400c30701a
languageName: node
linkType: hard
-"@storybook/addons@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/addons@npm:6.4.15"
+"@storybook/addons@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/addons@npm:6.4.21"
dependencies:
- "@storybook/api": 6.4.15
- "@storybook/channels": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/core-events": 6.4.15
+ "@storybook/api": 6.4.21
+ "@storybook/channels": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/core-events": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
- "@storybook/router": 6.4.15
- "@storybook/theming": 6.4.15
+ "@storybook/router": 6.4.21
+ "@storybook/theming": 6.4.21
"@types/webpack-env": ^1.16.0
core-js: ^3.8.2
global: ^4.4.0
@@ -7999,21 +7997,21 @@ __metadata:
peerDependencies:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
- checksum: c853099f08ca2ac98826609709a05ba169634f357d93ba033a28820e063fd3a49fb2520d1ec63f87f5f9e7427b94487f57453bbb2fb579528d6742769dcee7e9
+ checksum: 16c173711a8261193d003a8b5521789d0e45d977e3aedaafe3d7f5afa0e0ab69864cbda5778935eb8c95267a4cf12ef14163a8a9fb20c27222606a7d1d1471ac
languageName: node
linkType: hard
-"@storybook/api@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/api@npm:6.4.15"
+"@storybook/api@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/api@npm:6.4.21"
dependencies:
- "@storybook/channels": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/core-events": 6.4.15
+ "@storybook/channels": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/core-events": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
- "@storybook/router": 6.4.15
+ "@storybook/router": 6.4.21
"@storybook/semver": ^7.3.2
- "@storybook/theming": 6.4.15
+ "@storybook/theming": 6.4.21
core-js: ^3.8.2
fast-deep-equal: ^3.1.3
global: ^4.4.0
@@ -8027,13 +8025,13 @@ __metadata:
peerDependencies:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
- checksum: 1f071611f5e0c42fa161deaacdf3606004cfdf1f444eddb31ec78afb803915d09b6964f4c8aba5416d2dd0ef0b24ed694107264ec80a1649544b7b37d033cb5e
+ checksum: c3c32c3f3589d553d8d182aae804c9337ce2d6c8e238b3973fb1cadc1dddd49fff142fc863a9b6fa1f076c802475abbb1b60d461a8cef7d18c6d45b17f8d7721
languageName: node
linkType: hard
-"@storybook/builder-webpack4@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/builder-webpack4@npm:6.4.15"
+"@storybook/builder-webpack4@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/builder-webpack4@npm:6.4.21"
dependencies:
"@babel/core": ^7.12.10
"@babel/plugin-proposal-class-properties": ^7.12.1
@@ -8056,22 +8054,22 @@ __metadata:
"@babel/preset-env": ^7.12.11
"@babel/preset-react": ^7.12.10
"@babel/preset-typescript": ^7.12.7
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/channel-postmessage": 6.4.15
- "@storybook/channels": 6.4.15
- "@storybook/client-api": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/components": 6.4.15
- "@storybook/core-common": 6.4.15
- "@storybook/core-events": 6.4.15
- "@storybook/node-logger": 6.4.15
- "@storybook/preview-web": 6.4.15
- "@storybook/router": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/channel-postmessage": 6.4.21
+ "@storybook/channels": 6.4.21
+ "@storybook/client-api": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/components": 6.4.21
+ "@storybook/core-common": 6.4.21
+ "@storybook/core-events": 6.4.21
+ "@storybook/node-logger": 6.4.21
+ "@storybook/preview-web": 6.4.21
+ "@storybook/router": 6.4.21
"@storybook/semver": ^7.3.2
- "@storybook/store": 6.4.15
- "@storybook/theming": 6.4.15
- "@storybook/ui": 6.4.15
+ "@storybook/store": 6.4.21
+ "@storybook/theming": 6.4.21
+ "@storybook/ui": 6.4.21
"@types/node": ^14.0.10
"@types/webpack": ^4.41.26
autoprefixer: ^9.8.6
@@ -8110,13 +8108,13 @@ __metadata:
peerDependenciesMeta:
typescript:
optional: true
- checksum: 43f1e999d1ac86887cf0d53862352d95aa26239807c010c46c890b02840b8fb7a7a446c77fa8c34524241665e8e432fbc74b73f4693536239a0d9ec6604e3a1b
+ checksum: 321a77c4e6f945b588feaeccc954adc314c02aa2d8a9428367a1ddf74c142504b1bb1a9e78d3e4e94ece4331478b56d8a93a86e5a625968ddbec835c2f4e33ba
languageName: node
linkType: hard
-"@storybook/builder-webpack5@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/builder-webpack5@npm:6.4.15"
+"@storybook/builder-webpack5@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/builder-webpack5@npm:6.4.21"
dependencies:
"@babel/core": ^7.12.10
"@babel/plugin-proposal-class-properties": ^7.12.1
@@ -8138,21 +8136,21 @@ __metadata:
"@babel/preset-env": ^7.12.11
"@babel/preset-react": ^7.12.10
"@babel/preset-typescript": ^7.12.7
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/channel-postmessage": 6.4.15
- "@storybook/channels": 6.4.15
- "@storybook/client-api": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/components": 6.4.15
- "@storybook/core-common": 6.4.15
- "@storybook/core-events": 6.4.15
- "@storybook/node-logger": 6.4.15
- "@storybook/preview-web": 6.4.15
- "@storybook/router": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/channel-postmessage": 6.4.21
+ "@storybook/channels": 6.4.21
+ "@storybook/client-api": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/components": 6.4.21
+ "@storybook/core-common": 6.4.21
+ "@storybook/core-events": 6.4.21
+ "@storybook/node-logger": 6.4.21
+ "@storybook/preview-web": 6.4.21
+ "@storybook/router": 6.4.21
"@storybook/semver": ^7.3.2
- "@storybook/store": 6.4.15
- "@storybook/theming": 6.4.15
+ "@storybook/store": 6.4.21
+ "@storybook/theming": 6.4.21
"@types/node": ^14.0.10
babel-loader: ^8.0.0
babel-plugin-macros: ^3.0.1
@@ -8181,60 +8179,60 @@ __metadata:
peerDependenciesMeta:
typescript:
optional: true
- checksum: b9be6ed8f093cbcb77bbbaccbe5af5e544e837f480500ed3ee4a3c595a14963cd7b4364d815dc965d5a01adeeb20f8a7fc6912be00b17199f944768cf1a2ec61
+ checksum: 1837c75680f0158ec620759c4d09c6ae7fb1d50373b2174c37ff6bf0957dc8bb9bb8edcf15d745c41aaf96efc18776ffcce47c1963307b91919e662003f480ca
languageName: node
linkType: hard
-"@storybook/channel-postmessage@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/channel-postmessage@npm:6.4.15"
+"@storybook/channel-postmessage@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/channel-postmessage@npm:6.4.21"
dependencies:
- "@storybook/channels": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/core-events": 6.4.15
+ "@storybook/channels": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/core-events": 6.4.21
core-js: ^3.8.2
global: ^4.4.0
qs: ^6.10.0
telejson: ^5.3.2
- checksum: f47439ca301cc29dbf55dd13166cb9c7fe44084b73f65cc729bd2beb3f4ca5af517f6b789de676793f1b2ee0fd902deb5a4fa619632874b277bc74b180f65f60
+ checksum: 00eafe2ab4cab45caefceb542a415296942c311eca062c66de62098e7a716a7c63854c2cf5fcdece00a57da7a5be8d010c3925d3841fccdbd4be5643d14fc842
languageName: node
linkType: hard
-"@storybook/channel-websocket@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/channel-websocket@npm:6.4.15"
+"@storybook/channel-websocket@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/channel-websocket@npm:6.4.21"
dependencies:
- "@storybook/channels": 6.4.15
- "@storybook/client-logger": 6.4.15
+ "@storybook/channels": 6.4.21
+ "@storybook/client-logger": 6.4.21
core-js: ^3.8.2
global: ^4.4.0
telejson: ^5.3.2
- checksum: d74ff19d83de1227e26d361ed24b503ee63353c00a20668bbd71b76f45fd8cf825ad698caaa69fd4bf2de383567bd2670a3f3981e8aa71081db32ae9d53029ab
+ checksum: 4f41091f6026ab10f4fd260508a0db848dadad05eb7d2faaac59fe8ce52d73d4465970e362914e0618313ca9c072e0b9c2d7e28fbfb51f5bd2d60efdb247bda5
languageName: node
linkType: hard
-"@storybook/channels@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/channels@npm:6.4.15"
+"@storybook/channels@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/channels@npm:6.4.21"
dependencies:
core-js: ^3.8.2
ts-dedent: ^2.0.0
util-deprecate: ^1.0.2
- checksum: 15467a58ddf1da417eb099e9569a85e65804d463f5928fdfa91825c412478d671af4606089d01d6e9ff06eb235bef5d2844bc7dd7a54b793ef480ad2a5cc05a0
+ checksum: 8953ac7dc34486406edca55f77e3e592283a9355f52c1608acdfa3845ed5df08b909d08467d8e4df1f6c0f7eb1e97ce3ee8e183d72a79ea6e29da4859da26681
languageName: node
linkType: hard
-"@storybook/client-api@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/client-api@npm:6.4.15"
+"@storybook/client-api@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/client-api@npm:6.4.21"
dependencies:
- "@storybook/addons": 6.4.15
- "@storybook/channel-postmessage": 6.4.15
- "@storybook/channels": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/core-events": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/channel-postmessage": 6.4.21
+ "@storybook/channels": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/core-events": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
- "@storybook/store": 6.4.15
+ "@storybook/store": 6.4.21
"@types/qs": ^6.9.5
"@types/webpack-env": ^1.16.0
core-js: ^3.8.2
@@ -8251,28 +8249,28 @@ __metadata:
peerDependencies:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
- checksum: f948d6bd8823fe84c1875171d2d77ed00e991d7f491ec6d1667f34fd58c4d92d800ddf7766450a959cbf378397361952b7d1c9f4e2925ee049e1ac79f44a728a
+ checksum: 895c4eebf092c6804a4215548b0b52d2be2eca4bad1b9d7c050f6a22eaec314f261689c1e9f637c59e91aa1a3588ae791c75580d00b44bac4fe45c4875e1a53d
languageName: node
linkType: hard
-"@storybook/client-logger@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/client-logger@npm:6.4.15"
+"@storybook/client-logger@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/client-logger@npm:6.4.21"
dependencies:
core-js: ^3.8.2
global: ^4.4.0
- checksum: c4fd16d3743cc0baa3c6e8fb0598b12e0f9f34f712055110eefef87ce3e28ff37c6b5de597233718de78ebde492ebdfe0b3735810091b5c2ab684d1426320e1f
+ checksum: e85ed5379cd214ea0d0441239bc7e9504f9a466dc353409945e4474b5baf1a2c33c437810ddc3c3bacd23df7dc5f498436ec5c58c0c2ef2ab88ebc74a800ecd5
languageName: node
linkType: hard
-"@storybook/components@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/components@npm:6.4.15"
+"@storybook/components@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/components@npm:6.4.21"
dependencies:
"@popperjs/core": ^2.6.0
- "@storybook/client-logger": 6.4.15
+ "@storybook/client-logger": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
- "@storybook/theming": 6.4.15
+ "@storybook/theming": 6.4.21
"@types/color-convert": ^2.0.0
"@types/overlayscrollbars": ^1.12.0
"@types/react-syntax-highlighter": 11.0.5
@@ -8296,24 +8294,24 @@ __metadata:
peerDependencies:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
- checksum: a49885ff2ed49a565dd74da1e6ed8f24242324b807f318ea2d5540a8d780f88053cdcea9d68a34a6f90d45a37d80723e35800fdd53fa7e1ab1df07a65870c14b
+ checksum: 24159e630a6fd643672c377c0f118ee5f61a10e4171fa4cdcdac09b6bbfa09b814e60fb65322077b4402cab7e22b782d6d260ce92238d540821ab2e9def6e3e7
languageName: node
linkType: hard
-"@storybook/core-client@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/core-client@npm:6.4.15"
+"@storybook/core-client@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/core-client@npm:6.4.21"
dependencies:
- "@storybook/addons": 6.4.15
- "@storybook/channel-postmessage": 6.4.15
- "@storybook/channel-websocket": 6.4.15
- "@storybook/client-api": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/core-events": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/channel-postmessage": 6.4.21
+ "@storybook/channel-websocket": 6.4.21
+ "@storybook/client-api": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/core-events": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
- "@storybook/preview-web": 6.4.15
- "@storybook/store": 6.4.15
- "@storybook/ui": 6.4.15
+ "@storybook/preview-web": 6.4.21
+ "@storybook/store": 6.4.21
+ "@storybook/ui": 6.4.21
airbnb-js-shims: ^2.2.1
ansi-to-html: ^0.6.11
core-js: ^3.8.2
@@ -8331,13 +8329,13 @@ __metadata:
peerDependenciesMeta:
typescript:
optional: true
- checksum: b2ddd518076475e7dddcbe8bf1f81d945a0becedfc0bf14ee49ee4e1bd3fb213c91bf379f9dad6ffb84fc1ab117d7c35fac1a222334992980ac1aa6c58381f10
+ checksum: e30c95770539a31f248e8b322fed371100bf50934e86d61331fde5e8f77a1bbb82ca162c5f9fcc177c38b62482f1ed47681ab8544fb0d977ed766be2ab52c73f
languageName: node
linkType: hard
-"@storybook/core-common@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/core-common@npm:6.4.15"
+"@storybook/core-common@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/core-common@npm:6.4.21"
dependencies:
"@babel/core": ^7.12.10
"@babel/plugin-proposal-class-properties": ^7.12.1
@@ -8360,7 +8358,7 @@ __metadata:
"@babel/preset-react": ^7.12.10
"@babel/preset-typescript": ^7.12.7
"@babel/register": ^7.12.1
- "@storybook/node-logger": 6.4.15
+ "@storybook/node-logger": 6.4.21
"@storybook/semver": ^7.3.2
"@types/node": ^14.0.10
"@types/pretty-hrtime": ^1.0.0
@@ -8394,34 +8392,34 @@ __metadata:
peerDependenciesMeta:
typescript:
optional: true
- checksum: a24aef6d2cdacadae35041ee07dfd0ac4192b1cd9cbbc3faa841d62ffde124da601728a35ca7e5821a725a030c29ab80607b556d2a9563e58b981c324124e486
+ checksum: 12d417ec8692ecd467bfbebac083ca762b0b23e7e64162ad1e31b2daf0965e16bc25ffed92aae74639fd209267cc5cafd6b98e1ce27f2ef85368bee66e37d854
languageName: node
linkType: hard
-"@storybook/core-events@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/core-events@npm:6.4.15"
+"@storybook/core-events@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/core-events@npm:6.4.21"
dependencies:
core-js: ^3.8.2
- checksum: 72dfd7b0d8424955a1347b5fabd133512e87ef0a2b8c75a7a7bfe1a0f0a23ac2ad7f70be79f9aa0ce907f78b992cc274738f938facde28b122d55d0d62abbba5
+ checksum: 70c4e9f30f894f176d76a1a0b19b84efc70e0bbf9295e08f7c2595103f903c06b44279d2e6ece73f174d894e68a2e03c9511c8c9cd4b01f5d5e9941c6feaf9a7
languageName: node
linkType: hard
-"@storybook/core-server@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/core-server@npm:6.4.15"
+"@storybook/core-server@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/core-server@npm:6.4.21"
dependencies:
"@discoveryjs/json-ext": ^0.5.3
- "@storybook/builder-webpack4": 6.4.15
- "@storybook/core-client": 6.4.15
- "@storybook/core-common": 6.4.15
- "@storybook/core-events": 6.4.15
+ "@storybook/builder-webpack4": 6.4.21
+ "@storybook/core-client": 6.4.21
+ "@storybook/core-common": 6.4.21
+ "@storybook/core-events": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
- "@storybook/csf-tools": 6.4.15
- "@storybook/manager-webpack4": 6.4.15
- "@storybook/node-logger": 6.4.15
+ "@storybook/csf-tools": 6.4.21
+ "@storybook/manager-webpack4": 6.4.21
+ "@storybook/node-logger": 6.4.21
"@storybook/semver": ^7.3.2
- "@storybook/store": 6.4.15
+ "@storybook/store": 6.4.21
"@types/node": ^14.0.10
"@types/node-fetch": ^2.5.7
"@types/pretty-hrtime": ^1.0.0
@@ -8454,8 +8452,8 @@ __metadata:
webpack: 4
ws: ^8.2.3
peerDependencies:
- "@storybook/builder-webpack5": 6.4.15
- "@storybook/manager-webpack5": 6.4.15
+ "@storybook/builder-webpack5": 6.4.21
+ "@storybook/manager-webpack5": 6.4.21
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
peerDependenciesMeta:
@@ -8465,18 +8463,18 @@ __metadata:
optional: true
typescript:
optional: true
- checksum: 52be7c418ddaa60a4e9b8f268d5dea1d4dec1dd518efe8e57e8246b1c09d0290bae7b4c98b02ecc597b78f5dbd46df7e27cbd0cb4c31e51abab2677670193a7c
+ checksum: 7efb1c4c71dfe5af4cfb392ec0efaacf1c85ce88579196c3475799a96722ddf1c8aeda814e08173621eea17d24d4c8e147e043291abe1518956e15f5e51223f6
languageName: node
linkType: hard
-"@storybook/core@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/core@npm:6.4.15"
+"@storybook/core@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/core@npm:6.4.21"
dependencies:
- "@storybook/core-client": 6.4.15
- "@storybook/core-server": 6.4.15
+ "@storybook/core-client": 6.4.21
+ "@storybook/core-server": 6.4.21
peerDependencies:
- "@storybook/builder-webpack5": 6.4.15
+ "@storybook/builder-webpack5": 6.4.21
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
webpack: "*"
@@ -8485,13 +8483,13 @@ __metadata:
optional: true
typescript:
optional: true
- checksum: d3bb7166c4c5ce51a684b8aef89a57b84f6fcf197e83f31a0cf2b60406f56a94997830dc48cf947c622b5a1144c60bc08bb1d6a38bdb68c15c609b0c729f1d95
+ checksum: d7be99431d933b8abcb4420f271fb6859a42b5dca5afb88a1c35b7a99e7d624a9c5f9d34be8c47badf589b16020ed182affac0bd4590a5cda3728dff20a52b08
languageName: node
linkType: hard
-"@storybook/csf-tools@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/csf-tools@npm:6.4.15"
+"@storybook/csf-tools@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/csf-tools@npm:6.4.21"
dependencies:
"@babel/core": ^7.12.10
"@babel/generator": ^7.12.11
@@ -8510,7 +8508,7 @@ __metadata:
prettier: ">=2.2.1 <=2.3.0"
regenerator-runtime: ^0.13.7
ts-dedent: ^2.0.0
- checksum: f9eb5b5f85ec4ac34c22b616d4536475dd61b66fe4d20f13e3dbcdab511cd7ee74c5ac8568eed3752ceac6d5411504e213f4fb764ddc2ed0c0e47ba36535a6df
+ checksum: 3c23ad1781bc681d0a8eb9eebeb084613d951c03221e597d8cee5b85514afcb4ae6ebc9748b4f7c34a8bee1b068f14ca443591bbc67de74b7aa1bdd2b7657a61
languageName: node
linkType: hard
@@ -8523,19 +8521,19 @@ __metadata:
languageName: node
linkType: hard
-"@storybook/manager-webpack4@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/manager-webpack4@npm:6.4.15"
+"@storybook/manager-webpack4@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/manager-webpack4@npm:6.4.21"
dependencies:
"@babel/core": ^7.12.10
"@babel/plugin-transform-template-literals": ^7.12.1
"@babel/preset-react": ^7.12.10
- "@storybook/addons": 6.4.15
- "@storybook/core-client": 6.4.15
- "@storybook/core-common": 6.4.15
- "@storybook/node-logger": 6.4.15
- "@storybook/theming": 6.4.15
- "@storybook/ui": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/core-client": 6.4.21
+ "@storybook/core-common": 6.4.21
+ "@storybook/node-logger": 6.4.21
+ "@storybook/theming": 6.4.21
+ "@storybook/ui": 6.4.21
"@types/node": ^14.0.10
"@types/webpack": ^4.41.26
babel-loader: ^8.0.0
@@ -8569,23 +8567,23 @@ __metadata:
peerDependenciesMeta:
typescript:
optional: true
- checksum: 88345b8850261a06c829e0424ae9ff7fc803f123b76b022d2b946b91b4d9f2b60419e5f195a5b3d14f0cd354ae1eef8290a3876fb9078c7441aa091cf3fe9656
+ checksum: c085f6d81a2ba93eed32098efbb3d80637786e4cda71b2ea3e3f2f13044aaf5245e7532cebc39d954e695ba3aea35fff44d2c057731bfbcf08666a0252082b15
languageName: node
linkType: hard
-"@storybook/manager-webpack5@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/manager-webpack5@npm:6.4.15"
+"@storybook/manager-webpack5@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/manager-webpack5@npm:6.4.21"
dependencies:
"@babel/core": ^7.12.10
"@babel/plugin-transform-template-literals": ^7.12.1
"@babel/preset-react": ^7.12.10
- "@storybook/addons": 6.4.15
- "@storybook/core-client": 6.4.15
- "@storybook/core-common": 6.4.15
- "@storybook/node-logger": 6.4.15
- "@storybook/theming": 6.4.15
- "@storybook/ui": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/core-client": 6.4.21
+ "@storybook/core-common": 6.4.21
+ "@storybook/node-logger": 6.4.21
+ "@storybook/theming": 6.4.21
+ "@storybook/ui": 6.4.21
"@types/node": ^14.0.10
babel-loader: ^8.0.0
case-sensitive-paths-webpack-plugin: ^2.3.0
@@ -8616,42 +8614,42 @@ __metadata:
peerDependenciesMeta:
typescript:
optional: true
- checksum: d81668a8bef94195095ed6981c8c6d9b9926fe5cc094fc8a9c36e5aa8875e02e4628f5bbf7423ee77a7f4eff0c9b55d56ad1b6cb04a4db39efff357e44673786
+ checksum: 97b154a3e40a9019b2eb2156ad661809d184ae4c53084a0cf15f7b730f6677328302cb6a536f64bc658a255acfe56212e14cee4bfd219fe4ed3a4a19f15109f9
languageName: node
linkType: hard
-"@storybook/node-logger@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/node-logger@npm:6.4.15"
+"@storybook/node-logger@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/node-logger@npm:6.4.21"
dependencies:
"@types/npmlog": ^4.1.2
chalk: ^4.1.0
core-js: ^3.8.2
npmlog: ^5.0.1
pretty-hrtime: ^1.0.3
- checksum: 3e37a29574b218f363c0687116d115caffe2c6e38380adb68abcd749f406fd15fc59c61b1cc21b91ff47eb2e003087935b9e6c6618deab4528b8464ff5409e52
+ checksum: d3b37def30185a3cb8df33fa34f4f85b0d152cfb871922e9591cb7c3fc78e481916cf0aaed3ee45631c3f60fea0242cfca46eab8dd2a7180eebd94719f453537
languageName: node
linkType: hard
-"@storybook/postinstall@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/postinstall@npm:6.4.15"
+"@storybook/postinstall@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/postinstall@npm:6.4.21"
dependencies:
core-js: ^3.8.2
- checksum: fc81669e1772723e61f9b72084b98cfc3b6450a28c4f6e8884002fee917aca8816b3dc57ccd5cc67b9ff79541e800e15c3f80e65e2e7bfa7e75aec7abde51e47
+ checksum: f90c57c85d144e8fd177646fbb19540f109d5f1d399b8dcc7a782048edc97391d8520ef4bbd6ad9e1d577cde61b6d91e5b7e7b9234692202e6b586b7d1fe6e9d
languageName: node
linkType: hard
-"@storybook/preview-web@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/preview-web@npm:6.4.15"
+"@storybook/preview-web@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/preview-web@npm:6.4.21"
dependencies:
- "@storybook/addons": 6.4.15
- "@storybook/channel-postmessage": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/core-events": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/channel-postmessage": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/core-events": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
- "@storybook/store": 6.4.15
+ "@storybook/store": 6.4.21
ansi-to-html: ^0.6.11
core-js: ^3.8.2
global: ^4.4.0
@@ -8665,7 +8663,7 @@ __metadata:
peerDependencies:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
- checksum: 66d20941f612d9163852309e545a44f87c98b6dafb5416b3213e8c0ad2eed86a7c55febcba5224403d7de80dce7eeb5a36ff5eb56ca7da55ac47ccabb3d8d169
+ checksum: 686175bcc867962c287e1f35a0d2a78b76bdccc55d7120f04fe3c6d10745588575f561ee7523a58e1129798d1c4e5dbc9faecf0960add89313a6d103a810f5be
languageName: node
linkType: hard
@@ -8687,21 +8685,21 @@ __metadata:
languageName: node
linkType: hard
-"@storybook/react@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/react@npm:6.4.15"
+"@storybook/react@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/react@npm:6.4.21"
dependencies:
"@babel/preset-flow": ^7.12.1
"@babel/preset-react": ^7.12.10
"@pmmmwh/react-refresh-webpack-plugin": ^0.5.1
- "@storybook/addons": 6.4.15
- "@storybook/core": 6.4.15
- "@storybook/core-common": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/core": 6.4.21
+ "@storybook/core-common": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
- "@storybook/node-logger": 6.4.15
+ "@storybook/node-logger": 6.4.21
"@storybook/react-docgen-typescript-plugin": 1.0.2-canary.253f8c1.0
"@storybook/semver": ^7.3.2
- "@storybook/store": 6.4.15
+ "@storybook/store": 6.4.21
"@types/webpack-env": ^1.16.0
babel-plugin-add-react-displayname: ^0.0.5
babel-plugin-named-asset-import: ^0.3.1
@@ -8728,15 +8726,15 @@ __metadata:
build-storybook: bin/build.js
start-storybook: bin/index.js
storybook-server: bin/index.js
- checksum: 8bd420f025af38029c89526d5374f904770be606e19d1ea10749846185a686d1674f07811f4151a3ba1958940738fc704075873528272ca10c1f65a6c0d1c744
+ checksum: 42262eb1e017f7b2ced5945af8b0d1179767028ee8e1f88818989dae9c4f8f3046fa97bd98cff72045680de0aeef1c654874aba2ae0570c62f82922d94fdc47a
languageName: node
linkType: hard
-"@storybook/router@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/router@npm:6.4.15"
+"@storybook/router@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/router@npm:6.4.21"
dependencies:
- "@storybook/client-logger": 6.4.15
+ "@storybook/client-logger": 6.4.21
core-js: ^3.8.2
fast-deep-equal: ^3.1.3
global: ^4.4.0
@@ -8750,7 +8748,7 @@ __metadata:
peerDependencies:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
- checksum: 10bb86b31bfd4ae2f75986330328f935161ee68472a8401e8bec4c1cab820a2e991bc344ad04d0e595c79bf8f251d70cd39efe1b0685612fc9a48d607759c9f5
+ checksum: bd0ce2c5d58efc6326a02002eeb10535c3a558b40995aa25d7664421ab3e2c79f36eeb824da9a6921b032cd3cdeba87b164f61d8f469a816931ffd92ae7a2087
languageName: node
linkType: hard
@@ -8766,12 +8764,12 @@ __metadata:
languageName: node
linkType: hard
-"@storybook/source-loader@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/source-loader@npm:6.4.15"
+"@storybook/source-loader@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/source-loader@npm:6.4.21"
dependencies:
- "@storybook/addons": 6.4.15
- "@storybook/client-logger": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/client-logger": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
core-js: ^3.8.2
estraverse: ^5.2.0
@@ -8783,17 +8781,17 @@ __metadata:
peerDependencies:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
- checksum: 85e91d62d541d8a41db70280dabcba700a85a24eb57dbe2973403d2733fd57e8575c88b69d40e6042ec9b3c451e87ea5bd121f3fd0853df656d55e8ec60306d9
+ checksum: 21aefe8cdfc88d598838b2e5c4814b29d38b263ca27071c450ba9f28942265c533ee5c8bcc0613d98600e63a1a3c6f7d6c7379b1b9829127a1fcd32031e7d293
languageName: node
linkType: hard
-"@storybook/store@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/store@npm:6.4.15"
+"@storybook/store@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/store@npm:6.4.21"
dependencies:
- "@storybook/addons": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/core-events": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/core-events": 6.4.21
"@storybook/csf": 0.0.2--canary.87bc651.0
core-js: ^3.8.2
fast-deep-equal: ^3.1.3
@@ -8809,18 +8807,18 @@ __metadata:
peerDependencies:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
- checksum: f0123dec020d19badaa3388137e8712f7607067fef976bfc2747f8818f58b22b2daed94c17ae5a91ba4058f8a8cf354bfd54e9dfdaff3e75e01cde14dc4a9998
+ checksum: beaa302c6e3c24e53307f8c8219c1c30c4b8bf5ecb7be46fbb1fafd99c24963052a29c8d46c7b7934ab52da67ae262025246bc8ae47ba21c008769eba1bcd359
languageName: node
linkType: hard
-"@storybook/theming@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/theming@npm:6.4.15"
+"@storybook/theming@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/theming@npm:6.4.21"
dependencies:
"@emotion/core": ^10.1.1
"@emotion/is-prop-valid": ^0.8.6
"@emotion/styled": ^10.0.27
- "@storybook/client-logger": 6.4.15
+ "@storybook/client-logger": 6.4.21
core-js: ^3.8.2
deep-object-diff: ^1.1.0
emotion-theming: ^10.0.27
@@ -8832,24 +8830,24 @@ __metadata:
peerDependencies:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
- checksum: 2a9217bbc0ecbf85733fb64fc506e210e33897980fb9907aaa722412728391f702922a1c3ab53fd6c1c683268b72934f29c8c5b5f3232cf07c2674ea48216a49
+ checksum: 11ab5d5f1708b648a0ab5c516e8d84ebb165d2fd7167af80ac38a549f40b7b1d0e3ea4e5e2a94bf71fb45f8d4e8cb1d48fee1f63c12d181e8906a7ef149601ea
languageName: node
linkType: hard
-"@storybook/ui@npm:6.4.15":
- version: 6.4.15
- resolution: "@storybook/ui@npm:6.4.15"
+"@storybook/ui@npm:6.4.21":
+ version: 6.4.21
+ resolution: "@storybook/ui@npm:6.4.21"
dependencies:
"@emotion/core": ^10.1.1
- "@storybook/addons": 6.4.15
- "@storybook/api": 6.4.15
- "@storybook/channels": 6.4.15
- "@storybook/client-logger": 6.4.15
- "@storybook/components": 6.4.15
- "@storybook/core-events": 6.4.15
- "@storybook/router": 6.4.15
+ "@storybook/addons": 6.4.21
+ "@storybook/api": 6.4.21
+ "@storybook/channels": 6.4.21
+ "@storybook/client-logger": 6.4.21
+ "@storybook/components": 6.4.21
+ "@storybook/core-events": 6.4.21
+ "@storybook/router": 6.4.21
"@storybook/semver": ^7.3.2
- "@storybook/theming": 6.4.15
+ "@storybook/theming": 6.4.21
copy-to-clipboard: ^3.3.1
core-js: ^3.8.2
core-js-pure: ^3.8.2
@@ -8871,7 +8869,7 @@ __metadata:
peerDependencies:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
- checksum: dc0f8b84964ca68cc90da007a32470897b5fc16889e234de3008842e770c586877b146ec19361b54b123e5f245c346cd79f60943eee619f76818ce93bed03e44
+ checksum: 8db7c619e6e91f8ca2cb6249bcb911be50e0cd3c463ad99b11e85b326449a78b32f78a359d6ebf4fd6b7dd0841bba04a5dd9fbba33d096432b7cc7b1a87d4b92
languageName: node
linkType: hard
@@ -10374,7 +10372,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/rc-time-picker@npm:^3":
+"@types/rc-time-picker@npm:3.4.1":
version: 3.4.1
resolution: "@types/rc-time-picker@npm:3.4.1"
dependencies:
@@ -13825,6 +13823,15 @@ __metadata:
languageName: node
linkType: hard
+"brace-expansion@npm:^2.0.1":
+ version: 2.0.1
+ resolution: "brace-expansion@npm:2.0.1"
+ dependencies:
+ balanced-match: ^1.0.0
+ checksum: a61e7cd2e8a8505e9f0036b3b6108ba5e926b4b55089eeb5550cd04a471fe216c96d4fe7e4c7f995c728c554ae20ddfc4244cad10aef255e72b62930afd233d1
+ languageName: node
+ linkType: hard
+
"brace@npm:0.11.1":
version: 0.11.1
resolution: "brace@npm:0.11.1"
@@ -20405,9 +20412,9 @@ __metadata:
"@babel/preset-env": 7.16.11
"@babel/preset-react": 7.16.7
"@babel/preset-typescript": 7.16.7
- "@betterer/betterer": 5.1.7
- "@betterer/cli": 5.1.7
- "@betterer/regexp": 5.1.7
+ "@betterer/betterer": 5.3.0
+ "@betterer/cli": 5.3.0
+ "@betterer/regexp": 5.3.0
"@emotion/css": 11.7.1
"@emotion/eslint-plugin": 11.7.0
"@emotion/react": 11.8.2
@@ -20427,7 +20434,7 @@ __metadata:
"@grafana/tsconfig": ^1.2.0-rc1
"@grafana/ui": "workspace:*"
"@jaegertracing/jaeger-ui-components": "workspace:*"
- "@kusto/monaco-kusto": 4.1.6
+ "@kusto/monaco-kusto": 5.0.0
"@lezer/common": 0.15.12
"@lezer/lr": 0.15.8
"@lingui/cli": 3.13.2
@@ -20494,7 +20501,7 @@ __metadata:
"@types/papaparse": 5.3.2
"@types/pluralize": ^0.0.29
"@types/prismjs": 1.26.0
- "@types/rc-time-picker": ^3
+ "@types/rc-time-picker": 3.4.1
"@types/react": 17.0.42
"@types/react-beautiful-dnd": 13.1.2
"@types/react-dom": 17.0.14
@@ -20605,7 +20612,7 @@ __metadata:
memoize-one: 6.0.0
mini-css-extract-plugin: 2.6.0
minisearch: 5.0.0-beta1
- moment: 2.29.1
+ moment: 2.29.2
moment-timezone: 0.5.34
monaco-editor: ^0.31.1
monaco-promql: ^1.7.2
@@ -20651,7 +20658,7 @@ __metadata:
react-reverse-portal: ^2.0.1
react-router-dom: ^5.2.0
react-select: 5.2.2
- react-select-event: ^5.1.0
+ react-select-event: 5.5.0
react-split-pane: 0.1.92
react-table: ^7.7.0
react-test-renderer: 17.0.2
@@ -24830,6 +24837,13 @@ __metadata:
languageName: node
linkType: hard
+"lines-and-columns@npm:^2.0.3":
+ version: 2.0.3
+ resolution: "lines-and-columns@npm:2.0.3"
+ checksum: 5955363dfd7d3d7c476d002eb47944dbe0310d57959e2112dce004c0dc76cecfd479cf8c098fd479ff344acdf04ee0e82b455462a26492231ac152f6c48d17a1
+ languageName: node
+ linkType: hard
+
"lint-staged@npm:12.3.7":
version: 12.3.7
resolution: "lint-staged@npm:12.3.7"
@@ -25944,15 +25958,6 @@ __metadata:
languageName: node
linkType: hard
-"mini-svg-data-uri@npm:^1.2.3":
- version: 1.4.3
- resolution: "mini-svg-data-uri@npm:1.4.3"
- bin:
- mini-svg-data-uri: cli.js
- checksum: bd1789c34907a36ae9157897b7c7f6ad700e71c8cb6d725bb2810ce6211abcaa3717333ca17408f6f8b2a44055e571cd5b78d478688e1864513cb7f8e023ae3f
- languageName: node
- linkType: hard
-
"minimalistic-assert@npm:^1.0.0, minimalistic-assert@npm:^1.0.1":
version: 1.0.1
resolution: "minimalistic-assert@npm:1.0.1"
@@ -25994,6 +25999,15 @@ __metadata:
languageName: node
linkType: hard
+"minimatch@npm:^5.0.1":
+ version: 5.0.1
+ resolution: "minimatch@npm:5.0.1"
+ dependencies:
+ brace-expansion: ^2.0.1
+ checksum: b34b98463da4754bc526b244d680c69d4d6089451ebe512edaf6dd9eeed0279399cfa3edb19233513b8f830bf4bfcad911dddcdf125e75074100d52f724774f0
+ languageName: node
+ linkType: hard
+
"minimist-options@npm:4.1.0":
version: 4.1.0
resolution: "minimist-options@npm:4.1.0"
@@ -26013,9 +26027,9 @@ __metadata:
linkType: hard
"minimist@npm:^1.1.1, minimist@npm:^1.2.0, minimist@npm:^1.2.5":
- version: 1.2.5
- resolution: "minimist@npm:1.2.5"
- checksum: 86706ce5b36c16bfc35c5fe3dbb01d5acdc9a22f2b6cc810b6680656a1d2c0e44a0159c9a3ba51fb072bb5c203e49e10b51dcd0eec39c481f4c42086719bae52
+ version: 1.2.6
+ resolution: "minimist@npm:1.2.6"
+ checksum: d15428cd1e11eb14e1233bcfb88ae07ed7a147de251441d61158619dfb32c4d7e9061d09cab4825fdee18ecd6fce323228c8c47b5ba7cd20af378ca4048fb3fb
languageName: node
linkType: hard
@@ -26255,10 +26269,10 @@ __metadata:
languageName: node
linkType: hard
-"moment@npm:2.29.1, moment@npm:2.x, moment@npm:>= 2.9.0, moment@npm:^2.19.4, moment@npm:^2.20.1":
- version: 2.29.1
- resolution: "moment@npm:2.29.1"
- checksum: 1e14d5f422a2687996be11dd2d50c8de3bd577c4a4ca79ba5d02c397242a933e5b941655de6c8cb90ac18f01cc4127e55b4a12ae3c527a6c0a274e455979345e
+"moment@npm:2.29.2, moment@npm:2.x, moment@npm:>= 2.9.0, moment@npm:^2.19.4, moment@npm:^2.20.1":
+ version: 2.29.2
+ resolution: "moment@npm:2.29.2"
+ checksum: ee850b5776485e2af0775ceb3cfebaa7d7638f0a750fe0678fcae24c310749f96c1938808384bd422a55e5703834a71fcb09c8a1d36d9cf847f6ed0205d7a3e5
languageName: node
linkType: hard
@@ -31096,6 +31110,15 @@ __metadata:
languageName: node
linkType: hard
+"react-select-event@npm:5.5.0":
+ version: 5.5.0
+ resolution: "react-select-event@npm:5.5.0"
+ dependencies:
+ "@testing-library/dom": ">=7"
+ checksum: 03fd3f5aee9f31667d6c965c532d56e8bb3de993f3c150c4d1a44f0c97e90395152b2fbc06c3707bee12a640b76213d119a12b8749f6692e7b0d90b7b797d83e
+ languageName: node
+ linkType: hard
+
"react-select-event@npm:^5.1.0":
version: 5.3.0
resolution: "react-select-event@npm:5.3.0"
@@ -32356,6 +32379,17 @@ __metadata:
languageName: node
linkType: hard
+"rollup-plugin-svg-import@npm:^1.6.0":
+ version: 1.6.0
+ resolution: "rollup-plugin-svg-import@npm:1.6.0"
+ dependencies:
+ "@rollup/pluginutils": ^4.1.1
+ peerDependencies:
+ rollup: ">=1.29.0 <3.0.0"
+ checksum: 453862c39d2301563d9d07f6647c295377ff66cf3174d2a0612389fda4bfd9fa72718d90279feb782d3525f1d6f9710e1dc78641cd9d4044360a0179f88054b0
+ languageName: node
+ linkType: hard
+
"rollup-plugin-terser@npm:7.0.2":
version: 7.0.2
resolution: "rollup-plugin-terser@npm:7.0.2"