Merge branch 'main' into 20230331_add_kerberos_support

This commit is contained in:
Brian Gann
2023-04-03 21:46:01 -04:00
committed by GitHub
165 changed files with 4739 additions and 2921 deletions
+1 -2
View File
@@ -324,8 +324,7 @@ exports[`better eslint`] = {
],
"packages/grafana-data/src/types/app.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"packages/grafana-data/src/types/config.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
+2
View File
@@ -14,6 +14,7 @@ load(
"artifacts_page_pipeline",
"enterprise2_pipelines",
"enterprise_pipelines",
"integration_test_pipelines",
"oss_pipelines",
"publish_artifacts_pipelines",
"publish_npm_pipelines",
@@ -52,6 +53,7 @@ def main(_ctx):
publish_packages_pipeline() +
artifacts_page_pipeline() +
version_branch_pipelines() +
integration_test_pipelines() +
cronjobs() +
secrets()
)
+348 -329
View File
@@ -2366,18 +2366,15 @@ trigger:
exclude:
- promote
ref:
- refs/tags/v*
exclude:
- refs/tags/*-cloud*
include:
- refs/tags/v*
type: docker
volumes:
- host:
path: /var/run/docker.sock
name: docker
- name: postgres
temp:
medium: memory
- name: mysql
temp:
medium: memory
---
clone:
retries: 3
@@ -2429,7 +2426,10 @@ trigger:
exclude:
- promote
ref:
- refs/tags/v*
exclude:
- refs/tags/*-cloud*
include:
- refs/tags/v*
type: docker
volumes:
- host:
@@ -2503,131 +2503,15 @@ trigger:
exclude:
- promote
ref:
- refs/tags/v*
type: docker
volumes:
- host:
path: /var/run/docker.sock
name: docker
---
clone:
retries: 3
depends_on: []
environment:
EDITION: oss
image_pull_secrets:
- dockerconfigjson
kind: pipeline
name: release-oss-integration-tests
node:
type: no-parallel
platform:
arch: amd64
os: linux
services:
- environment:
PGDATA: /var/lib/postgresql/data/pgdata
POSTGRES_DB: grafanatest
POSTGRES_PASSWORD: grafanatest
POSTGRES_USER: grafanatest
image: postgres:12.3-alpine
name: postgres
volumes:
- name: postgres
path: /var/lib/postgresql/data/pgdata
- environment:
MYSQL_DATABASE: grafana_tests
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_USER: grafana
image: mysql:5.7.39
name: mysql
volumes:
- name: mysql
path: /var/lib/mysql
steps:
- commands:
- mkdir -p bin
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.30/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
- commands:
- echo $DRONE_RUNNER_NAME
image: alpine:3.17.1
name: identify-runner
- commands:
- '# It is required that code generated from Thema/CUE be committed and in sync
with its inputs.'
- '# The following command will fail if running code generators produces any diff
in output.'
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: grafana/build-container:1.7.2
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
- '# The following command will fail if running code generators produces any diff
in output.'
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on: []
image: grafana/build-container:1.7.2
name: verify-gen-jsonnet
- commands:
- make gen-go
depends_on:
- verify-gen-cue
image: grafana/build-container:1.7.2
name: wire-install
- commands:
- apt-get update
- apt-get install -yq postgresql-client
- dockerize -wait tcp://postgres:5432 -timeout 120s
- psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql
- go clean -testcache
- go list './pkg/...' | xargs -I {} sh -c 'go test -run Integration -covermode=atomic
-timeout=5m {}'
depends_on:
- wire-install
environment:
GRAFANA_TEST_DB: postgres
PGPASSWORD: grafanatest
POSTGRES_HOST: postgres
image: grafana/build-container:1.7.2
name: postgres-integration-tests
- commands:
- apt-get update
- apt-get install -yq default-mysql-client
- dockerize -wait tcp://mysql:3306 -timeout 120s
- cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root
-prootpass
- go clean -testcache
- go list './pkg/...' | xargs -I {} sh -c 'go test -run Integration -covermode=atomic
-timeout=5m {}'
depends_on:
- wire-install
environment:
GRAFANA_TEST_DB: mysql
MYSQL_HOST: mysql
image: grafana/build-container:1.7.2
name: mysql-integration-tests
trigger:
event:
exclude:
- promote
ref:
- refs/tags/v*
- refs/tags/*-cloud*
include:
- refs/tags/v*
type: docker
volumes:
- host:
path: /var/run/docker.sock
name: docker
- name: postgres
temp:
medium: memory
- name: mysql
temp:
medium: memory
---
clone:
retries: 3
@@ -2635,7 +2519,6 @@ depends_on:
- release-oss-build-e2e-publish
- release-oss-test-frontend
- release-oss-test-backend
- release-oss-integration-tests
environment:
EDITION: oss
image_pull_secrets:
@@ -2686,7 +2569,10 @@ trigger:
exclude:
- promote
ref:
- refs/tags/v*
exclude:
- refs/tags/*-cloud*
include:
- refs/tags/v*
type: docker
volumes:
- host:
@@ -2739,6 +2625,7 @@ steps:
- mv /tmp/grabpl bin/
depends_on:
- clone-enterprise
- grabpl
environment:
GITHUB_TOKEN:
from_secret: github_token
@@ -2977,18 +2864,15 @@ trigger:
exclude:
- promote
ref:
- refs/tags/v*
exclude:
- refs/tags/*-cloud*
include:
- refs/tags/v*
type: docker
volumes:
- host:
path: /var/run/docker.sock
name: docker
- name: postgres
temp:
medium: memory
- name: mysql
temp:
medium: memory
---
clone:
disable: true
@@ -3006,6 +2890,12 @@ platform:
os: linux
services: []
steps:
- commands:
- mkdir -p bin
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.30/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
- commands:
- git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
- cd grafana-enterprise
@@ -3026,6 +2916,7 @@ steps:
- mv /tmp/grabpl bin/
depends_on:
- clone-enterprise
- grabpl
environment:
GITHUB_TOKEN:
from_secret: github_token
@@ -3035,12 +2926,6 @@ steps:
- echo $DRONE_RUNNER_NAME
image: alpine:3.17.1
name: identify-runner
- commands:
- mkdir -p bin
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.30/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
- commands:
- yarn install --immutable
depends_on:
@@ -3068,7 +2953,10 @@ trigger:
exclude:
- promote
ref:
- refs/tags/v*
exclude:
- refs/tags/*-cloud*
include:
- refs/tags/v*
type: docker
volumes:
- host:
@@ -3117,6 +3005,7 @@ steps:
- mv /tmp/grabpl bin/
depends_on:
- clone-enterprise
- grabpl
environment:
GITHUB_TOKEN:
from_secret: github_token
@@ -3176,186 +3065,15 @@ trigger:
exclude:
- promote
ref:
- refs/tags/v*
type: docker
volumes:
- host:
path: /var/run/docker.sock
name: docker
---
clone:
disable: true
depends_on: []
environment:
EDITION: enterprise
image_pull_secrets:
- dockerconfigjson
kind: pipeline
name: release-enterprise-integration-tests
node:
type: no-parallel
platform:
arch: amd64
os: linux
services:
- environment:
PGDATA: /var/lib/postgresql/data/pgdata
POSTGRES_DB: grafanatest
POSTGRES_PASSWORD: grafanatest
POSTGRES_USER: grafanatest
image: postgres:12.3-alpine
name: postgres
volumes:
- name: postgres
path: /var/lib/postgresql/data/pgdata
- environment:
MYSQL_DATABASE: grafana_tests
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_USER: grafana
image: mysql:5.7.39
name: mysql
volumes:
- name: mysql
path: /var/lib/mysql
- environment: {}
image: redis:6.2.1-alpine
name: redis
- environment: {}
image: memcached:1.6.9-alpine
name: memcached
steps:
- commands:
- mkdir -p bin
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.30/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
- commands:
- echo $DRONE_RUNNER_NAME
image: alpine:3.17.1
name: identify-runner
- commands:
- git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
- cd grafana-enterprise
- git checkout ${DRONE_TAG}
environment:
GITHUB_TOKEN:
from_secret: github_token
image: grafana/build-container:1.7.2
name: clone-enterprise
- commands:
- mv bin/grabpl /tmp/
- rmdir bin
- mv grafana-enterprise /tmp/
- /tmp/grabpl init-enterprise --github-token $${GITHUB_TOKEN} /tmp/grafana-enterprise
${DRONE_TAG}
- mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json
- mkdir bin
- mv /tmp/grabpl bin/
depends_on:
- clone-enterprise
environment:
GITHUB_TOKEN:
from_secret: github_token
image: grafana/build-container:1.7.2
name: init-enterprise
- commands:
- '# It is required that code generated from Thema/CUE be committed and in sync
with its inputs.'
- '# The following command will fail if running code generators produces any diff
in output.'
- CODEGEN_VERIFY=1 make gen-cue
depends_on:
- init-enterprise
image: grafana/build-container:1.7.2
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
- '# The following command will fail if running code generators produces any diff
in output.'
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on:
- init-enterprise
image: grafana/build-container:1.7.2
name: verify-gen-jsonnet
- commands:
- make gen-go
depends_on:
- verify-gen-cue
image: grafana/build-container:1.7.2
name: wire-install
- commands:
- apt-get update
- apt-get install -yq postgresql-client
- dockerize -wait tcp://postgres:5432 -timeout 120s
- psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql
- go clean -testcache
- go list './pkg/...' | xargs -I {} sh -c 'go test -run Integration -covermode=atomic
-timeout=5m {}'
depends_on:
- wire-install
environment:
GRAFANA_TEST_DB: postgres
PGPASSWORD: grafanatest
POSTGRES_HOST: postgres
image: grafana/build-container:1.7.2
name: postgres-integration-tests
- commands:
- apt-get update
- apt-get install -yq default-mysql-client
- dockerize -wait tcp://mysql:3306 -timeout 120s
- cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root
-prootpass
- go clean -testcache
- go list './pkg/...' | xargs -I {} sh -c 'go test -run Integration -covermode=atomic
-timeout=5m {}'
depends_on:
- wire-install
environment:
GRAFANA_TEST_DB: mysql
MYSQL_HOST: mysql
image: grafana/build-container:1.7.2
name: mysql-integration-tests
- commands:
- dockerize -wait tcp://redis:6379/0 -timeout 120s
- go clean -testcache
- go list './pkg/...' | xargs -I {} sh -c 'go test -run Integration -covermode=atomic
-timeout=5m {}'
depends_on:
- wire-install
environment:
REDIS_URL: redis://redis:6379/0
image: grafana/build-container:1.7.2
name: redis-integration-tests
- commands:
- dockerize -wait tcp://memcached:11211 -timeout 120s
- go clean -testcache
- go list './pkg/...' | xargs -I {} sh -c 'go test -run Integration -covermode=atomic
-timeout=5m {}'
depends_on:
- wire-install
environment:
MEMCACHED_HOSTS: memcached:11211
image: grafana/build-container:1.7.2
name: memcached-integration-tests
trigger:
event:
exclude:
- promote
ref:
- refs/tags/v*
- refs/tags/*-cloud*
include:
- refs/tags/v*
type: docker
volumes:
- host:
path: /var/run/docker.sock
name: docker
- name: postgres
temp:
medium: memory
- name: mysql
temp:
medium: memory
---
clone:
disable: true
@@ -3363,7 +3081,6 @@ depends_on:
- release-enterprise-build-e2e-publish
- release-enterprise-test-frontend
- release-enterprise-test-backend
- release-enterprise-integration-tests
environment:
EDITION: enterprise
image_pull_secrets:
@@ -3434,7 +3151,10 @@ trigger:
exclude:
- promote
ref:
- refs/tags/v*
exclude:
- refs/tags/*-cloud*
include:
- refs/tags/v*
type: docker
volumes:
- host:
@@ -3487,6 +3207,7 @@ steps:
- mv /tmp/grabpl bin/
depends_on:
- clone-enterprise
- grabpl
environment:
GITHUB_TOKEN:
from_secret: github_token
@@ -3678,7 +3399,10 @@ trigger:
exclude:
- promote
ref:
- refs/tags/v*
exclude:
- refs/tags/*-cloud*
include:
- refs/tags/v*
type: docker
volumes:
- host:
@@ -3737,6 +3461,7 @@ steps:
- mv /tmp/grabpl bin/
depends_on:
- clone-enterprise
- grabpl
environment:
GITHUB_TOKEN:
from_secret: github_token
@@ -4759,6 +4484,7 @@ steps:
- mv /tmp/grabpl bin/
depends_on:
- clone-enterprise
- grabpl
environment:
GITHUB_TOKEN:
from_secret: github_token
@@ -5317,7 +5043,6 @@ depends_on:
- release-branch-oss-build-e2e-publish
- release-branch-oss-test-frontend
- release-branch-oss-test-backend
- release-branch-oss-integration-tests
environment:
EDITION: oss
image_pull_secrets:
@@ -5413,6 +5138,7 @@ steps:
- mv /tmp/grabpl bin/
depends_on:
- clone-enterprise
- grabpl
environment:
GITHUB_TOKEN:
from_secret: github_token
@@ -5684,6 +5410,12 @@ platform:
os: linux
services: []
steps:
- commands:
- mkdir -p bin
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.30/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
- commands:
- git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
- cd grafana-enterprise
@@ -5703,6 +5435,7 @@ steps:
- mv /tmp/grabpl bin/
depends_on:
- clone-enterprise
- grabpl
environment:
GITHUB_TOKEN:
from_secret: github_token
@@ -5712,12 +5445,6 @@ steps:
- echo $DRONE_RUNNER_NAME
image: alpine:3.17.1
name: identify-runner
- commands:
- mkdir -p bin
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.30/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
- commands:
- yarn install --immutable
depends_on:
@@ -5790,6 +5517,7 @@ steps:
- mv /tmp/grabpl bin/
depends_on:
- clone-enterprise
- grabpl
environment:
GITHUB_TOKEN:
from_secret: github_token
@@ -5924,6 +5652,7 @@ steps:
- mv /tmp/grabpl bin/
depends_on:
- clone-enterprise
- grabpl
environment:
GITHUB_TOKEN:
from_secret: github_token
@@ -6029,7 +5758,6 @@ depends_on:
- release-branch-enterprise-build-e2e-publish
- release-branch-enterprise-test-frontend
- release-branch-enterprise-test-backend
- release-branch-enterprise-integration-tests
environment:
EDITION: enterprise
image_pull_secrets:
@@ -6145,6 +5873,7 @@ steps:
- mv /tmp/grabpl bin/
depends_on:
- clone-enterprise
- grabpl
environment:
GITHUB_TOKEN:
from_secret: github_token
@@ -6348,6 +6077,296 @@ volumes:
temp:
medium: memory
---
clone:
retries: 3
depends_on: []
environment:
EDITION: oss
image_pull_secrets:
- dockerconfigjson
kind: pipeline
name: integration-tests-oss
node:
type: no-parallel
platform:
arch: amd64
os: linux
services:
- environment:
PGDATA: /var/lib/postgresql/data/pgdata
POSTGRES_DB: grafanatest
POSTGRES_PASSWORD: grafanatest
POSTGRES_USER: grafanatest
image: postgres:12.3-alpine
name: postgres
volumes:
- name: postgres
path: /var/lib/postgresql/data/pgdata
- environment:
MYSQL_DATABASE: grafana_tests
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_USER: grafana
image: mysql:5.7.39
name: mysql
volumes:
- name: mysql
path: /var/lib/mysql
steps:
- commands:
- mkdir -p bin
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.30/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
- commands:
- echo $DRONE_RUNNER_NAME
image: alpine:3.17.1
name: identify-runner
- commands:
- '# It is required that code generated from Thema/CUE be committed and in sync
with its inputs.'
- '# The following command will fail if running code generators produces any diff
in output.'
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: grafana/build-container:1.7.2
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
- '# The following command will fail if running code generators produces any diff
in output.'
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on: []
image: grafana/build-container:1.7.2
name: verify-gen-jsonnet
- commands:
- make gen-go
depends_on:
- verify-gen-cue
image: grafana/build-container:1.7.2
name: wire-install
- commands:
- apt-get update
- apt-get install -yq postgresql-client
- dockerize -wait tcp://postgres:5432 -timeout 120s
- psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql
- go clean -testcache
- go list './pkg/...' | xargs -I {} sh -c 'go test -run Integration -covermode=atomic
-timeout=5m {}'
depends_on:
- wire-install
environment:
GRAFANA_TEST_DB: postgres
PGPASSWORD: grafanatest
POSTGRES_HOST: postgres
image: grafana/build-container:1.7.2
name: postgres-integration-tests
- commands:
- apt-get update
- apt-get install -yq default-mysql-client
- dockerize -wait tcp://mysql:3306 -timeout 120s
- cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root
-prootpass
- go clean -testcache
- go list './pkg/...' | xargs -I {} sh -c 'go test -run Integration -covermode=atomic
-timeout=5m {}'
depends_on:
- wire-install
environment:
GRAFANA_TEST_DB: mysql
MYSQL_HOST: mysql
image: grafana/build-container:1.7.2
name: mysql-integration-tests
trigger:
event:
- promote
target: integration-tests
type: docker
volumes:
- host:
path: /var/run/docker.sock
name: docker
- name: postgres
temp:
medium: memory
- name: mysql
temp:
medium: memory
---
clone:
disable: true
depends_on: []
environment:
EDITION: enterprise
image_pull_secrets:
- dockerconfigjson
kind: pipeline
name: integration-tests-enterprise
node:
type: no-parallel
platform:
arch: amd64
os: linux
services:
- environment:
PGDATA: /var/lib/postgresql/data/pgdata
POSTGRES_DB: grafanatest
POSTGRES_PASSWORD: grafanatest
POSTGRES_USER: grafanatest
image: postgres:12.3-alpine
name: postgres
volumes:
- name: postgres
path: /var/lib/postgresql/data/pgdata
- environment:
MYSQL_DATABASE: grafana_tests
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_USER: grafana
image: mysql:5.7.39
name: mysql
volumes:
- name: mysql
path: /var/lib/mysql
- environment: {}
image: redis:6.2.1-alpine
name: redis
- environment: {}
image: memcached:1.6.9-alpine
name: memcached
steps:
- commands:
- mkdir -p bin
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.30/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
- commands:
- echo $DRONE_RUNNER_NAME
image: alpine:3.17.1
name: identify-runner
- commands:
- git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
- cd grafana-enterprise
- git checkout ${DRONE_TAG}
environment:
GITHUB_TOKEN:
from_secret: github_token
image: grafana/build-container:1.7.2
name: clone-enterprise
- commands:
- mv bin/grabpl /tmp/
- rmdir bin
- mv grafana-enterprise /tmp/
- /tmp/grabpl init-enterprise --github-token $${GITHUB_TOKEN} /tmp/grafana-enterprise
${DRONE_TAG}
- mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json
- mkdir bin
- mv /tmp/grabpl bin/
depends_on:
- clone-enterprise
- grabpl
environment:
GITHUB_TOKEN:
from_secret: github_token
image: grafana/build-container:1.7.2
name: init-enterprise
- commands:
- '# It is required that code generated from Thema/CUE be committed and in sync
with its inputs.'
- '# The following command will fail if running code generators produces any diff
in output.'
- CODEGEN_VERIFY=1 make gen-cue
depends_on:
- init-enterprise
image: grafana/build-container:1.7.2
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
- '# The following command will fail if running code generators produces any diff
in output.'
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on:
- init-enterprise
image: grafana/build-container:1.7.2
name: verify-gen-jsonnet
- commands:
- make gen-go
depends_on:
- verify-gen-cue
image: grafana/build-container:1.7.2
name: wire-install
- commands:
- apt-get update
- apt-get install -yq postgresql-client
- dockerize -wait tcp://postgres:5432 -timeout 120s
- psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql
- go clean -testcache
- go list './pkg/...' | xargs -I {} sh -c 'go test -run Integration -covermode=atomic
-timeout=5m {}'
depends_on:
- wire-install
environment:
GRAFANA_TEST_DB: postgres
PGPASSWORD: grafanatest
POSTGRES_HOST: postgres
image: grafana/build-container:1.7.2
name: postgres-integration-tests
- commands:
- apt-get update
- apt-get install -yq default-mysql-client
- dockerize -wait tcp://mysql:3306 -timeout 120s
- cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root
-prootpass
- go clean -testcache
- go list './pkg/...' | xargs -I {} sh -c 'go test -run Integration -covermode=atomic
-timeout=5m {}'
depends_on:
- wire-install
environment:
GRAFANA_TEST_DB: mysql
MYSQL_HOST: mysql
image: grafana/build-container:1.7.2
name: mysql-integration-tests
- commands:
- dockerize -wait tcp://redis:6379/0 -timeout 120s
- go clean -testcache
- go list './pkg/...' | xargs -I {} sh -c 'go test -run Integration -covermode=atomic
-timeout=5m {}'
depends_on:
- wire-install
environment:
REDIS_URL: redis://redis:6379/0
image: grafana/build-container:1.7.2
name: redis-integration-tests
- commands:
- dockerize -wait tcp://memcached:11211 -timeout 120s
- go clean -testcache
- go list './pkg/...' | xargs -I {} sh -c 'go test -run Integration -covermode=atomic
-timeout=5m {}'
depends_on:
- wire-install
environment:
MEMCACHED_HOSTS: memcached:11211
image: grafana/build-container:1.7.2
name: memcached-integration-tests
trigger:
event:
- promote
target: integration-tests
type: docker
volumes:
- host:
path: /var/run/docker.sock
name: docker
- name: postgres
temp:
medium: memory
- name: mysql
temp:
medium: memory
---
clone:
retries: 3
kind: pipeline
@@ -6634,6 +6653,6 @@ kind: secret
name: enterprise2_security_prefix
---
kind: signature
hmac: 7ed023fb45c28415f26835221bd1e1e440c6f6796a5262da866f8ce9c4314542
hmac: 675c90070f9b68e15f349d079fbafa5288c3e5c30c345a4c9678d69afd1cc5a7
...
-2
View File
@@ -48,5 +48,3 @@ Please check that:
- [ ] It works as expected from a user's perspective.
- [ ] If this is a pre-GA feature, it is behind a feature toggle.
- [ ] The docs are updated, and if this is a [notable improvement](https://grafana.com/docs/writers-toolkit/writing-guide/contribute-release-notes/#how-to-determine-if-content-belongs-in-a-whats-new-document), it's added to our [What's New](https://grafana.com/docs/writers-toolkit/writing-guide/contribute-release-notes/) doc.
- [ ] There are no known compatibility issues with older supported versions of Grafana, or plugins.
- [ ] It passes the [Hosted Grafana feature readiness review](https://docs.google.com/document/d/1QL9Ly8KnXzpb6ISbg49pTODRO5mhA5tkkfIZVX6pqQU/edit) for observability, scalability, performance, and security.
+2 -1
View File
@@ -37,7 +37,7 @@ ARG BINGO="true"
# Install build dependencies
RUN if grep -i -q alpine /etc/issue; then \
apk add --no-cache gcc g++ krb5-libs krb5-dev make; \
apk add --no-cache gcc g++ make git krb5-libs krb5-dev; \
fi
WORKDIR /tmp/grafana
@@ -62,6 +62,7 @@ COPY pkg pkg
COPY scripts scripts
COPY conf conf
COPY .github .github
COPY .git .git
RUN make build-go GO_BUILD_TAGS=${GO_BUILD_TAGS} WIRE_TAGS=${WIRE_TAGS}
+1 -1
View File
@@ -99,7 +99,7 @@ The current team members are:
- Ryan McKinley ([Grafana Labs](https://grafana.com/))
- Sofia Papagiannaki ([Grafana Labs](https://grafana.com/))
- Stephanie Closson ([Grafana Labs](https://grafana.com/))
- Tobias Skarhed ([CERN](https://home.web.cern.ch/))
- Tobias Skarhed ([Grafana Labs](https://grafana.com/))
- Torkel Ödegaard ([Grafana Labs](https://grafana.com/))
- Utkarsh Bhatnagar ([Tinder](https://www.tinder.com/))
- Will Browne ([Grafana Labs](https://grafana.com/))
@@ -169,56 +169,57 @@ Common settings in the [built-in core data sources]({{< relref "../../datasource
> **Note:** Data sources tagged with _HTTP\*_ communicate using the HTTP protocol, which includes all core data source plugins except MySQL, PostgreSQL, and MSSQL.
| Name | Type | Data source | Description |
| -------------------------- | ------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| tlsAuth | boolean | _HTTP\*_, MySQL | Enable TLS authentication using client cert configured in secure json data |
| tlsAuthWithCACert | boolean | _HTTP\*_, MySQL, PostgreSQL | Enable TLS authentication using CA cert |
| tlsSkipVerify | boolean | _HTTP\*_, MySQL, PostgreSQL, MSSQL | Controls whether a client verifies the server's certificate chain and host name. |
| serverName | string | _HTTP\*_, MSSQL | Optional. Controls the server name used for certificate common name/subject alternative name verification. Defaults to using the data source URL. |
| timeout | string | _HTTP\*_ | Request timeout in seconds. Overrides dataproxy.timeout option |
| graphiteVersion | string | Graphite | Graphite version |
| timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL and MSSQL | Lowest interval/step value that should be used for this data source. |
| httpMode | string | Influxdb | HTTP Method. 'GET', 'POST', defaults to GET |
| maxSeries | number | Influxdb | Max number of series/tables that Grafana processes |
| httpMethod | string | Prometheus | HTTP Method. 'GET', 'POST', defaults to POST |
| customQueryParameters | string | Prometheus | Query parameters to add, as a URL-encoded string. |
| manageAlerts | boolean | Prometheus and Loki | Manage alerts via Alerting UI |
| alertmanagerUid | string | Prometheus and Loki | UID of Alert Manager that manages Alert for this data source. |
| timeField | string | Elasticsearch | Which field that should be used as timestamp |
| interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' |
| logMessageField | string | Elasticsearch | Which field should be used as the log message |
| logLevelField | string | Elasticsearch | Which field should be used to indicate the priority of the log message |
| maxConcurrentShardRequests | number | Elasticsearch | Maximum number of concurrent shard requests that each sub-search request executes per node |
| sigV4Auth | boolean | Elasticsearch and Prometheus | Enable usage of SigV4 |
| sigV4AuthType | string | Elasticsearch and Prometheus | SigV4 auth provider. default/credentials/keys |
| sigV4ExternalId | string | Elasticsearch and Prometheus | Optional SigV4 External ID |
| sigV4AssumeRoleArn | string | Elasticsearch and Prometheus | Optional SigV4 ARN role to assume |
| sigV4Region | string | Elasticsearch and Prometheus | SigV4 AWS region |
| sigV4Profile | string | Elasticsearch and Prometheus | Optional SigV4 credentials profile |
| authType | string | Cloudwatch | Auth provider. default/credentials/keys |
| externalId | string | Cloudwatch | Optional External ID |
| assumeRoleArn | string | Cloudwatch | Optional ARN role to assume |
| defaultRegion | string | Cloudwatch | Optional default AWS region |
| customMetricsNamespaces | string | Cloudwatch | Namespaces of Custom Metrics |
| profile | string | Cloudwatch | Optional credentials profile |
| tsdbVersion | string | OpenTSDB | Version |
| tsdbResolution | string | OpenTSDB | Resolution |
| sslmode | string | PostgreSQL | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' |
| tlsConfigurationMethod | string | PostgreSQL | SSL Certificate configuration, either by 'file-path' or 'file-content' |
| sslRootCertFile | string | PostgreSQL, MSSQL | SSL server root certificate file, must be readable by the Grafana user |
| sslCertFile | string | PostgreSQL | SSL client certificate file, must be readable by the Grafana user |
| sslKeyFile | string | PostgreSQL | SSL client key file, must be readable by _only_ the Grafana user |
| encrypt | string | MSSQL | Connection SSL encryption handling. 'disable', 'false' or 'true' |
| postgresVersion | number | PostgreSQL | Postgres version as a number (903/904/905/906/1000) meaning v9.3, v9.4, ..., v10 |
| timescaledb | boolean | PostgreSQL | Enable usage of TimescaleDB extension |
| maxOpenConns | number | MySQL, PostgreSQL and MSSQL | Maximum number of open connections to the database (Grafana v5.4+) |
| maxIdleConns | number | MySQL, PostgreSQL and MSSQL | Maximum number of connections in the idle connection pool (Grafana v5.4+) |
| connMaxLifetime | number | MySQL, PostgreSQL and MSSQL | Maximum amount of time in seconds a connection may be reused (Grafana v5.4+) |
| keepCookies | array | _HTTP\*_ | Cookies that needs to be passed along while communicating with data sources |
| prometheusVersion | string | Prometheus | The version of the Prometheus data source, such as `2.37.0`, `2.24.0` |
| prometheusType | string | Prometheus | The type of the Prometheus data sources. such as `Prometheus`, `Cortex`, `Thanos`, `Mimir` |
| implementation | string | AlertManager | The implementation of the AlertManager data source, such as `prometheus`, `cortex` or `mimir` |
| handleGrafanaManagedAlerts | boolean | AlertManager | When enabled, Grafana-managed alerts are sent to this Alertmanager |
| Name | Type | Data source | Description |
| -------------------------- | ------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| tlsAuth | boolean | _HTTP\*_, MySQL | Enable TLS authentication using client cert configured in secure json data |
| tlsAuthWithCACert | boolean | _HTTP\*_, MySQL, PostgreSQL | Enable TLS authentication using CA cert |
| tlsSkipVerify | boolean | _HTTP\*_, MySQL, PostgreSQL, MSSQL | Controls whether a client verifies the server's certificate chain and host name. |
| serverName | string | _HTTP\*_, MSSQL | Optional. Controls the server name used for certificate common name/subject alternative name verification. Defaults to using the data source URL. |
| timeout | string | _HTTP\*_ | Request timeout in seconds. Overrides dataproxy.timeout option |
| graphiteVersion | string | Graphite | Graphite version |
| timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL and MSSQL | Lowest interval/step value that should be used for this data source. |
| httpMode | string | Influxdb | HTTP Method. 'GET', 'POST', defaults to GET |
| maxSeries | number | Influxdb | Max number of series/tables that Grafana processes |
| httpMethod | string | Prometheus | HTTP Method. 'GET', 'POST', defaults to POST |
| customQueryParameters | string | Prometheus | Query parameters to add, as a URL-encoded string. |
| manageAlerts | boolean | Prometheus and Loki | Manage alerts via Alerting UI |
| alertmanagerUid | string | Prometheus and Loki | UID of Alert Manager that manages Alert for this data source. |
| timeField | string | Elasticsearch | Which field that should be used as timestamp |
| interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' |
| logMessageField | string | Elasticsearch | Which field should be used as the log message |
| logLevelField | string | Elasticsearch | Which field should be used to indicate the priority of the log message |
| maxConcurrentShardRequests | number | Elasticsearch | Maximum number of concurrent shard requests that each sub-search request executes per node |
| sigV4Auth | boolean | Elasticsearch and Prometheus | Enable usage of SigV4 |
| sigV4AuthType | string | Elasticsearch and Prometheus | SigV4 auth provider. default/credentials/keys |
| sigV4ExternalId | string | Elasticsearch and Prometheus | Optional SigV4 External ID |
| sigV4AssumeRoleArn | string | Elasticsearch and Prometheus | Optional SigV4 ARN role to assume |
| sigV4Region | string | Elasticsearch and Prometheus | SigV4 AWS region |
| sigV4Profile | string | Elasticsearch and Prometheus | Optional SigV4 credentials profile |
| authType | string | Cloudwatch | Auth provider. default/credentials/keys |
| externalId | string | Cloudwatch | Optional External ID |
| assumeRoleArn | string | Cloudwatch | Optional ARN role to assume |
| defaultRegion | string | Cloudwatch | Optional default AWS region |
| customMetricsNamespaces | string | Cloudwatch | Namespaces of Custom Metrics |
| profile | string | Cloudwatch | Optional credentials profile |
| tsdbVersion | string | OpenTSDB | Version |
| tsdbResolution | string | OpenTSDB | Resolution |
| sslmode | string | PostgreSQL | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' |
| tlsConfigurationMethod | string | PostgreSQL | SSL Certificate configuration, either by 'file-path' or 'file-content' |
| sslRootCertFile | string | PostgreSQL, MSSQL | SSL server root certificate file, must be readable by the Grafana user |
| sslCertFile | string | PostgreSQL | SSL client certificate file, must be readable by the Grafana user |
| sslKeyFile | string | PostgreSQL | SSL client key file, must be readable by _only_ the Grafana user |
| encrypt | string | MSSQL | Connection SSL encryption handling. 'disable', 'false' or 'true' |
| postgresVersion | number | PostgreSQL | Postgres version as a number (903/904/905/906/1000) meaning v9.3, v9.4, ..., v10 |
| timescaledb | boolean | PostgreSQL | Enable usage of TimescaleDB extension |
| maxOpenConns | number | MySQL, PostgreSQL and MSSQL | Maximum number of open connections to the database (Grafana v5.4+) |
| maxIdleConns | number | MySQL, PostgreSQL and MSSQL | Maximum number of connections in the idle connection pool (Grafana v5.4+) |
| connMaxLifetime | number | MySQL, PostgreSQL and MSSQL | Maximum amount of time in seconds a connection may be reused (Grafana v5.4+) |
| keepCookies | array | _HTTP\*_ | Cookies that needs to be passed along while communicating with data sources |
| prometheusVersion | string | Prometheus | The version of the Prometheus data source, such as `2.37.0`, `2.24.0` |
| prometheusType | string | Prometheus | The type of the Prometheus data sources. such as `Prometheus`, `Cortex`, `Thanos`, `Mimir` |
| cacheLevel | string | Prometheus | This determines the duration of the browser cache. Valid values include: `Low`, `Medium`, `High`, and `None`. This field is configurable when you enable the `prometheusResourceBrowserCache` feature flag. |
| implementation | string | AlertManager | The implementation of the AlertManager data source, such as `prometheus`, `cortex` or `mimir` |
| handleGrafanaManagedAlerts | boolean | AlertManager | When enabled, Grafana-managed alerts are sent to this Alertmanager |
For examples of specific data sources' JSON data, refer to that [data source's documentation]({{< relref "../../datasources" >}}).
@@ -23,19 +23,20 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee
**To create a dashboard**:
1. Sign in to Grafana, hover your cursor over **Dashboard**, and click **+ New Dashboard**.
1. Click **Add a new panel**.
1. In the first line of the **Query** tab, click the drop-down list and select a data source.
1. Click **Dashboards** in the left-side menu.
1. Click **New** and select **New Dashboard**.
1. On the empty dashboard, click **+ Add visualization**.
1. In the first line of the **Query** tab, click the dropdown list and select a data source.
1. Write or construct a query in the query language of your data source.
For more information about data sources, refer to [Data sources]({{< relref "../../../datasources/" >}}) for specific guidelines.
1. In the Visualization list, select a visualization type.
1. In the visualization list, select a visualization type.
![Visualization selector](/media/docs/grafana/dashboards/screenshot-select-visualization-9-5.png)
Grafana displays a preview of your query results with the visualization applied.
![](/static/img/docs/panel-editor/select-visualization-8-0.png)
For more information about individual visualizations, refer to [Visualizations options]({{< relref "../../../panels-visualizations/visualizations/" >}}).
1. Refer to the following documentation for ways you can adjust panel settings.
@@ -48,9 +49,9 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee
- [Configure thresholds]({{< relref "../../../panels-visualizations/configure-thresholds/" >}})
- [Configure standard options]({{< relref "../../../panels-visualizations/configure-standard-options/" >}})
1. Add a note to describe the visualization (or describe your changes) and then click **Save** in the upper-right corner of the page.
Notes can be helpful if you need to revert the dashboard to a previous version.
1. When you've finished editing your panel, click **Save** in the top right corner.
1. Enter a name for your dashboard and select a folder, if applicable.
1. Click **Save**.
## Configure repeating rows
@@ -64,13 +65,15 @@ To see an example of repeating rows, refer to [Dashboard with repeating rows](ht
**To configure repeating rows:**
1. On the dashboard home page, click **Add panel**.
1. Click **Dashboards** in the left-side menu.
1. Navigate to the dashboard you want to work on.
1. At the top of the dashboard, click **Add** and select **Row** in the dropdown.
1. On the **Add a panel** dialog box, click **Add a new row**.
If the dashboard is empty, you can click the **+ Add row** button in the middle of the dashboard.
1. Hover over the row title and click the cog icon.
1. On the **Row Options** dialog box, add a title and select the variable for which you want to add repeating rows.
1. In the **Row Options** dialog box, add a title and select the variable for which you want to add repeating rows.
1. Click **Update**.
> **Note:** To provide context to dashboard users, add the variable to the row title.
@@ -78,14 +81,14 @@ To see an example of repeating rows, refer to [Dashboard with repeating rows](ht
You can place a panel on a dashboard in any location.
1. Open the dashboard.
1. Click **Dashboards** in the left-side menu.
1. Navigate to the dashboard you want to work on.
1. Click the panel title and drag the panel to the new location.
## Resize a panel
You can size a dashboard panel to suits your needs.
1. Open the dashboard.
1. Click **Dashboards** in the left-side menu.
1. Navigate to the dashboard you want to work on.
1. To adjust the size of the panel, click and drag the lower-right corner of the panel.
@@ -40,12 +40,9 @@ Once created, you can modify the library panel using any dashboard on which it a
Add a Grafana library panel to a dashboard when you want to provide visualizations to other dashboard users.
1. Hover over the **Dashboards** option on the left menu, then select **New dashboard** from the drop-down options.
The **Add** panel dialog opens.
{{< figure src="/static/img/docs/library-panels/add-library-panel-8-0.png" class="docs-image--no-shadow" max-width= "900px" caption="Screenshot of the edit panel" >}}
1. Click the **Add a panel from the panel library** option.
1. Click **Dashboards** in the left-side menu.
1. Click **New** and select **New Dashboard**.
1. On the empty dashboard, click **+ Import library panel**.
You will see a list of your library panels.
@@ -56,17 +53,20 @@ Add a Grafana library panel to a dashboard when you want to provide visualizatio
Unlink a library panel when you want to make a change to the panel and not affect other instances of the library panel.
1. Hover over **Dashboard** on the left menu, and then click **Library panels**.
1. Click **Dashboards** in the left-side menu.
1. Click **Library panels**.
1. Select a library panel that is being used in different dashboards.
1. Select the panel you want to unlink.
1. Click the title of the panel and then click **Edit**. The panel opens in edit mode.
1. Click the **Unlink** option on the top right corner of the page.
1. Hover over any part of the panel to display the actions menu on the top right corner.
1. Click the menu and select **Edit**.
1. Click **Unlink** on the top right corner of the page.
## View a list of library panels
You can view a list of available library panels and search for a library panel.
1. Hover over the **Dashboard** option on the left menu, then click **Library panels**.
1. Click **Dashboards** in the left-side menu.
1. Click **Library panels**.
You can see a list of previously defined library panels.
{{< figure src="/static/img/docs/library-panels/library-panel-list-8-0.png" class="docs-image--no-shadow" max-width= "900px" caption="Screenshot of the edit panel" >}}
@@ -79,8 +79,6 @@ You can view a list of available library panels and search for a library panel.
Delete a library panel when you no longer need it.
1. Hover over **Dashboard** on the left menu, and select **Library panels**.
1. Select the panel you want to delete.
1. Click **Dashboards** in the left-side menu.
1. Click **Library panels**.
1. Click the delete icon next to the library panel name.
+13 -14
View File
@@ -54,9 +54,21 @@ For example, this video demonstrates the visual Prometheus query builder:
For general information about querying in Grafana, and common options and user interface elements across all query editors, refer to [Query and transform data]({{< relref "../panels-visualizations/query-transform-data/" >}}).
## Special data sources
Grafana includes three special data sources:
- **Grafana:** A built-in data source that generates random walk data and can poll the [Testdata]({{< relref "./testdata/" >}}) data source. Additionally, it can list files and get other data from a Grafana installation. This can be helpful for testing visualizations and running experiments.
- **Mixed:** An abstraction that lets you query multiple data sources in the same panel.
When you select Mixed, you can then select a different data source for each new query that you add.
- The first query uses the data source that was selected before you selected **Mixed**.
- You can't change an existing query to use the **Mixed** data source.
- Grafana Play example: [Mixed data sources](https://play.grafana.org/d/000000100/mixed-datasources?orgId=1)
- **Dashboard:** A data source that uses the result set from another panel in the same dashboard. The dashboard data source can use data either directly from the selected panel or from annotations attached to the selected panel.
## Built-in core data sources
These built-in core data sources are included in the Grafana documentation:
These built-in core data sources are also included in the Grafana documentation:
- [Alertmanager]({{< relref "./alertmanager/" >}})
- [AWS CloudWatch]({{< relref "./aws-cloudwatch/" >}})
@@ -75,16 +87,3 @@ These built-in core data sources are included in the Grafana documentation:
- [Tempo]({{< relref "./tempo/" >}})
- [Testdata]({{< relref "./testdata/" >}})
- [Zipkin]({{< relref "./zipkin/" >}})
## Special data sources
Grafana also includes three special data sources:
- **Grafana:** A built-in data source that generates random walk data and can poll the [Testdata]({{< relref "./testdata/" >}}) data source.
This helps you test visualizations and run experiments.
- **Mixed:** An abstraction that lets you query multiple data sources in the same panel.
When you select Mixed, you can then select a different data source for each new query that you add.
- The first query uses the data source that was selected before you selected **Mixed**.
- You can't change an existing query to use the Mixed data source.
- Grafana Play example: [Mixed data sources](https://play.grafana.org/d/000000100/mixed-datasources?orgId=1)
- **Dashboard:** A data source that uses the result set from another panel in the same dashboard.
@@ -40,9 +40,12 @@ The Azure Monitor data source supports visualizing data from three Azure service
**To access the data source configuration page:**
1. Hover the cursor over the **Configuration** (gear) icon.
1. Select **Data Sources**.
1. Select the Azure Monitor data source.
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `Azure Monitor` in the search bar.
1. Click **Azure Monitor**.
The **Settings** tab of the data source is displayed.
### Configure Azure Active Directory (AD) authentication
@@ -133,7 +136,7 @@ For details on Azure managed identities, refer to the [Azure documentation](http
This hides the directory ID, application ID, and client secret fields, and the data source uses managed identity to authenticate to Azure Monitor Metrics and Logs, and Azure Resource Graph.
{{< figure src="/static/img/docs/azure-monitor/managed-identity.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Monitor Metrics screenshot showing Dimensions" >}}
{{< figure src="/media/docs/grafana/data-sources/screenshot-managed-identity.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Monitor Metrics screenshot showing Dimensions" >}}
## Query the data source
@@ -35,22 +35,25 @@ This data source supports these versions of Elasticsearch:
## Configure the data source
**To access the data source configuration page:**
To configure basic settings for the data source, complete the following steps:
1. Hover the cursor over the **Configuration** (gear) icon.
1. Select **Data Sources**.
1. Select the Elasticsearch data source.
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `Elasticsearch` in the search bar.
1. Click **Elasticsearch**.
Set the data source's basic configuration options carefully:
The **Settings** tab of the data source is displayed.
| Name | Description |
| ----------- | -------------------------------------------------------------------------- |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Sets the data source that's pre-selected for new panels. |
| **Url** | Sets the HTTP protocol, IP, and port of your Elasticsearch server. |
| **Access** | Don't modify Access. Use "Server (default)" or the data source won't work. |
1. Set the data source's basic configuration options:
You must also configure settings specific to the Elasticsearch data source.
| Name | Description |
| ----------- | -------------------------------------------------------------------------- |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Sets the data source that's pre-selected for new panels. |
| **Url** | Sets the HTTP protocol, IP, and port of your Elasticsearch server. |
| **Access** | Don't modify Access. Use `Server (default)` or the data source won't work. |
You must also configure settings specific to the Elasticsearch data source. These options are described in the sections below.
### Index settings
@@ -33,18 +33,21 @@ Once you've added the Google Cloud Monitoring data source, you can [configure it
## Configure the data source
**To access the data source configuration page:**
To configure basic settings for the data source, complete the following steps:
1. Hover the cursor over the **Configuration** (gear) icon.
1. Select **Data Sources**.
1. Select the **Google Cloud Monitoring** data source.
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `Google Cloud Monitoring` in the search bar.
1. Click **Google Cloud Monitoring**.
Set the data source's basic configuration options carefully:
The **Settings** tab of the data source is displayed.
| Name | Description |
| ----------- | ------------------------------------------------------------------------ |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Sets whether the data source is pre-selected for new panels. |
1. Set the data source's basic configuration options:
| Name | Description |
| ----------- | ------------------------------------------------------------------------ |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Sets whether the data source is pre-selected for new panels. |
### Configure Google authentication
+17 -13
View File
@@ -30,23 +30,27 @@ Once you've added the InfluxDB data source, you can [configure it]({{< relref "#
## Configure the data source
**To access the data source configuration page:**
To configure basic settings for the data source, complete the following steps:
1. Hover the cursor over the **Configuration** (gear) icon.
1. Select **Data Sources**.
1. Select the InfluxDB data source.
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `InfluxDB` in the search bar.
1. Select **InfluxDB**.
Set the data source's basic configuration options carefully:
The **Settings** tab of the data source is displayed.
| Name | Description |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Name** | Sets the name you use to refer to the data source in panels and queries. We recommend something like `InfluxDB-InfluxQL`. |
| **Default** | Sets whether the data source is pre-selected for new panels. |
| **URL** | The HTTP protocol, IP address, and port of your InfluxDB API. InfluxDB's default API port is 8086. |
| **Min time interval** | _(Optional)_ Refer to [Min time interval]({{< relref "#configure-min-time-interval" >}}). |
| **Max series** | _(Optional)_ Limits the number of series and tables that Grafana processes. Lower this number to prevent abuse, and increase it if you have many small time series and not all are shown. Defaults to 1,000. |
1. Set the data source's basic configuration options carefully:
You can also configure settings specific to the InfluxDB data source:
| Name | Description |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Name** | Sets the name you use to refer to the data source in panels and queries. We recommend something like `InfluxDB-InfluxQL`. |
| **Default** | Sets whether the data source is pre-selected for new panels. |
| **URL** | The HTTP protocol, IP address, and port of your InfluxDB API. InfluxDB's default API port is 8086. |
| **Min time interval** | _(Optional)_ Refer to [Min time interval]({{< relref "#configure-min-time-interval" >}}). |
| **Max series** | _(Optional)_ Limits the number of series and tables that Grafana processes. Lower this number to prevent abuse, and increase it if you have many small time series and not all are shown. Defaults to 1,000. |
You can also configure settings specific to the InfluxDB data source. These options are described in the sections below.
### Min time interval
+16 -13
View File
@@ -27,22 +27,25 @@ You can also [upload a JSON trace file]({{< relref "#upload-a-json-trace-file" >
## Configure the data source
**To access the data source configuration page:**
To configure basic settings for the data source, complete the following steps:
1. Hover the cursor over the **Configuration** (gear) icon.
1. Select **Data Sources**.
1. Select the Jaeger data source.
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `Jaeger` in the search bar.
1. Select **Jaeger**.
Set the data source's basic configuration options carefully:
The **Settings** tab of the data source is displayed.
| Name | Description |
| -------------- | ------------------------------------------------------------------------ |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Defines whether this data source is pre-selected for new panels. |
| **URL** | Sets the URL of the Jaeger instance, such as `http://localhost:16686`. |
| **Basic Auth** | Enables basic authentication for the Jaeger data source. |
| **User** | Defines the user name for basic authentication. |
| **Password** | Defines the password for basic authentication. |
1. Set the data source's basic configuration options:
| Name | Description |
| -------------- | ------------------------------------------------------------------------ |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Defines whether this data source is pre-selected for new panels. |
| **URL** | Sets the URL of the Jaeger instance, such as `http://localhost:16686`. |
| **Basic Auth** | Enables basic authentication for the Jaeger data source. |
| **User** | Defines the user name for basic authentication. |
| **Password** | Defines the password for basic authentication. |
You can also configure settings specific to the Jaeger data source. These options are described in the sections below.
+15 -12
View File
@@ -26,21 +26,24 @@ Once you've added the Loki data source, you can [configure it]({{< relref "#conf
## Configure the data source
**To access the data source configuration page:**
To configure basic settings for the data source, complete the following steps:
1. Hover the cursor over the **Configuration** (gear) icon.
1. Select **Data Sources**.
1. Select the Loki data source.
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `Loki` in the search bar.
1. Select **Loki**.
Set the data source's basic configuration options carefully:
The **Settings** tab of the data source is displayed.
| Name | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Sets the data source that's pre-selected for new panels. |
| **URL** | Sets the HTTP protocol, IP, and port of your Loki instance, such as `http://localhost:3100`. |
| **Allowed cookies** | Defines which cookies are forwarded to the data source. Grafana Proxy deletes all other cookies. |
| **Maximum lines** | Sets the upper limit for the number of log lines returned by Loki. Defaults to 1,000. Lower this limit if your browser is sluggish when displaying logs in Explore. |
1. Set the data source's basic configuration options:
| Name | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Sets the data source that's pre-selected for new panels. |
| **URL** | Sets the HTTP protocol, IP, and port of your Loki instance, such as `http://localhost:3100`. |
| **Allowed cookies** | Defines which cookies are forwarded to the data source. Grafana Proxy deletes all other cookies. |
| **Maximum lines** | Sets the upper limit for the number of log lines returned by Loki. Defaults to 1,000. Lower this limit if your browser is sluggish when displaying logs in Explore. |
> **Note:** To troubleshoot configuration and other issues, check the log file located at `/var/log/grafana/grafana.log` on Unix systems, or in `<grafana_install_dir>/data/log` on other platforms and manual installations.
@@ -129,15 +129,6 @@ In same cases the query editor can detect which operations would be most appropr
This section is only shown if the `Explain query` switch from the query editor top toolbar is set to `on`. It shows a step by step explanation of all query parts and the operations.
### Raw query
It shows the raw query that will be created and executed by the query editor.
There are two types of LogQL queries:
- Log queries
- Metric queries
## Create a log query
Loki log queries return the contents of the log lines.
+22 -19
View File
@@ -30,29 +30,32 @@ Once you've added the Microsoft SQL Server data source, you can [configure it]({
## Configure the data source
**To access the data source configuration page:**
To configure basic settings for the data source, complete the following steps:
1. Hover the cursor over the **Configuration** (gear) icon.
1. Select **Data Sources**.
1. Select the Microsoft SQL Server data source.
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `Microsoft SQL Server` in the search bar.
1. Select **Microsoft SQL Server**.
Set the data source's basic configuration options carefully:
The **Settings** tab of the data source is displayed.
| Name | Description |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Sets the data source that's pre-selected for new panels. |
| **Host** | Sets the IP address/hostname and optional port of your MS SQL instance. Default port is 0, the driver default. You can specify multiple connection properties, such as `ApplicationIntent`, by separating each property with a semicolon (`;`). |
| **Database** | Sets the name of your MS SQL database. |
| **Authentication** | Sets the authentication mode, either using SQL Server Authentication or Windows Authentication (single sign-on for Windows users). |
| **User** | Defines the database user's username. |
| **Password** | Defines the database user's password. |
| **Encrypt** | Determines whether to negotiate a secure SSL TCP/IP connection with the server, or to which extent. Default is `false`. |
| **Max open** | Sets the maximum number of open connections to the database. Default is `unlimited`. |
| **Max idle** | Sets the maximum number of connections in the idle connection pool. Default is `2`. |
| **Max lifetime** | Sets the maximum number of seconds that the data source can reuse a connection. Default is `14400` (4 hours). |
1. Set the data source's basic configuration options:
You can also configure settings specific to the Microsoft SQL Server data source:
| Name | Description |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Sets the data source that's pre-selected for new panels. |
| **Host** | Sets the IP address/hostname and optional port of your MS SQL instance. Default port is 0, the driver default. You can specify multiple connection properties, such as `ApplicationIntent`, by separating each property with a semicolon (`;`). |
| **Database** | Sets the name of your MS SQL database. |
| **Authentication** | Sets the authentication mode, either using SQL Server Authentication or Windows Authentication (single sign-on for Windows users). |
| **User** | Defines the database user's username. |
| **Password** | Defines the database user's password. |
| **Encrypt** | Determines whether to negotiate a secure SSL TCP/IP connection with the server, or to which extent. Default is `false`. |
| **Max open** | Sets the maximum number of open connections to the database. Default is `unlimited`. |
| **Max idle** | Sets the maximum number of connections in the idle connection pool. Default is `2`. |
| **Max lifetime** | Sets the maximum number of seconds that the data source can reuse a connection. Default is `14400` (4 hours). |
You can also configure settings specific to the Microsoft SQL Server data source. These options are described in the sections below.
### Min time interval
+11
View File
@@ -24,6 +24,17 @@ Administrators can also [configure the data source via YAML]({{< relref "#provis
## Configure the data source
**To access the data source configuration page:**
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `MySQL` in the search bar.
1. Select **MySQL**.
The **Settings** tab of the data source is displayed.
1. Set the data source's basic configuration options.
### Data source options
| Name | Description |
+20 -10
View File
@@ -24,17 +24,27 @@ Administrators can also [configure the data source via YAML]({{< relref "#provis
## OpenTSDB settings
To access OpenTSDB settings, hover your mouse over the **Configuration** (gear) icon, then click **Data Sources**, and then click the OpenTSDB data source.
To configure basic settings for the data source, complete the following steps:
| Name | Description |
| ------------------- | --------------------------------------------------------------------------------------- |
| **Name** | The data source name. This is how you refer to the data source in panels and queries. |
| **Default** | Default data source means that it will be pre-selected for new panels. |
| **URL** | The HTTP protocol, IP, and port of your OpenTSDB server (default port is usually 4242) |
| **Allowed cookies** | List the names of cookies to forward to the data source. |
| **Version** | Version = opentsdb version, either <=2.1 or 2.2 |
| **Resolution** | Metrics from opentsdb may have datapoints with either second or millisecond resolution. |
| **Lookup limit** | Default is 1000. |
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `OpenTSDB` in the search bar.
1. Select **OpenTSDB**.
The **Settings** tab of the data source is displayed.
1. Set the data source's basic configuration options:
| Name | Description |
| ------------------- | --------------------------------------------------------------------------------------- |
| **Name** | The data source name. This is how you refer to the data source in panels and queries. |
| **Default** | Default data source means that it will be pre-selected for new panels. |
| **URL** | The HTTP protocol, IP, and port of your OpenTSDB server (default port is usually 4242) |
| **Allowed cookies** | List the names of cookies to forward to the data source. |
| **Version** | Version = opentsdb version, either <=2.1 or 2.2 |
| **Resolution** | Metrics from opentsdb may have datapoints with either second or millisecond resolution. |
| **Lookup limit** | Default is 1000. |
### Provision the data source
+27 -17
View File
@@ -22,24 +22,34 @@ Administrators can also [configure the data source via YAML]({{< relref "#provis
## PostgreSQL settings
To access PostgreSQL settings, hover your mouse over the **Configuration** (gear) icon, then click **Data Sources**, and then click the PostgreSQL data source.
To configure basic settings for the data source, complete the following steps:
| Name | Description |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | The data source name. This is how you refer to the data source in panels and queries. |
| **Default** | Default data source means that it will be pre-selected for new panels. |
| **Host** | The IP address/hostname and optional port of your PostgreSQL instance. _Do not_ include the database name. The connection string for connecting to Postgres will not be correct and it may cause errors. |
| **Database** | Name of your PostgreSQL database. |
| **User** | Database user's login/username |
| **Password** | Database user's password |
| **SSL Mode** | Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. When SSL Mode is disabled, SSL Method and Auth Details would not be visible. |
| **SSL Auth Details Method** | Determines whether the SSL Auth details will be configured as a file path or file content. Grafana v7.5+ |
| **SSL Auth Details Value** | File path or file content of SSL root certificate, client certificate and client key |
| **Max open** | The maximum number of open connections to the database, default `unlimited` (Grafana v5.4+). |
| **Max idle** | The maximum number of connections in the idle connection pool, default `2` (Grafana v5.4+). |
| **Max lifetime** | The maximum amount of time in seconds a connection may be reused, default `14400`/4 hours (Grafana v5.4+). |
| **Version** | Determines which functions are available in the query builder (only available in Grafana 5.3+). |
| **TimescaleDB** | A time-series database built as a PostgreSQL extension. When enabled, Grafana uses `time_bucket` in the `$__timeGroup` macro to display TimescaleDB specific aggregate functions in the query builder (only available in Grafana 5.3+). For more information, see [TimescaleDB documentation](https://docs.timescale.com/timescaledb/latest/tutorials/grafana/grafana-timescalecloud/#connect-timescaledb-and-grafana). |
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `PostgreSQL` in the search bar.
1. Select **PostgreSQL**.
The **Settings** tab of the data source is displayed.
1. Set the data source's basic configuration options:
| Name | Description |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | The data source name. This is how you refer to the data source in panels and queries. |
| **Default** | Default data source means that it will be pre-selected for new panels. |
| **Host** | The IP address/hostname and optional port of your PostgreSQL instance. _Do not_ include the database name. The connection string for connecting to Postgres will not be correct and it may cause errors. |
| **Database** | Name of your PostgreSQL database. |
| **User** | Database user's login/username |
| **Password** | Database user's password |
| **SSL Mode** | Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. When SSL Mode is disabled, SSL Method and Auth Details would not be visible. |
| **SSL Auth Details Method** | Determines whether the SSL Auth details will be configured as a file path or file content. Grafana v7.5+ |
| **SSL Auth Details Value** | File path or file content of SSL root certificate, client certificate and client key |
| **Max open** | The maximum number of open connections to the database, default `unlimited` (Grafana v5.4+). |
| **Max idle** | The maximum number of connections in the idle connection pool, default `2` (Grafana v5.4+). |
| **Max lifetime** | The maximum amount of time in seconds a connection may be reused, default `14400`/4 hours (Grafana v5.4+). |
| **Version** | Determines which functions are available in the query builder (only available in Grafana 5.3+). |
| **TimescaleDB** | A time-series database built as a PostgreSQL extension. When enabled, Grafana uses `time_bucket` in the `$__timeGroup` macro to display TimescaleDB specific aggregate functions in the query builder (only available in Grafana 5.3+). For more information, see [TimescaleDB documentation](https://docs.timescale.com/timescaledb/latest/tutorials/grafana/grafana-timescalecloud/#connect-timescaledb-and-grafana). |
### Min time interval
+41 -35
View File
@@ -34,46 +34,51 @@ For more information on how to query other Prometheus-compatible projects from G
## Configure the data source
**To access the data source configuration page:**
To configure basic settings for the data source, complete the following steps:
1. Hover the cursor over the **Configuration** (gear) icon.
1. Select **Data Sources**.
1. Select the Prometheus data source.
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `Prometheus` in the search bar.
1. Select **Prometheus**.
Set the data source's basic configuration options carefully:
The **Settings** tab of the data source is displayed.
| Name | Description |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Name` | The data source name. This is how you refer to the data source in panels and queries. |
| `Default` | Default data source that is pre-selected for new panels. |
| `URL` | The URL of your Prometheus server, for example, `http://prometheus.example.org:9090`. |
| `Access` | Only Server access mode is functional. If Server mode is already selected this option is hidden. Otherwise change to Server mode to prevent errors. |
| `Basic Auth` | Enable basic authentication to the Prometheus data source. |
| `User` | User name for basic authentication. |
| `Password` | Password for basic authentication. |
| `Manage alerts via Alerting UI` | Toggle whether to enable Alertmanager integration for this data source. |
| `Scrape interval` | Set this to the typical scrape and evaluation interval configured in Prometheus. Defaults to 15s. |
| `HTTP method` | Use either POST or GET HTTP method to query your data source. POST is the recommended and pre-selected method as it allows bigger queries. Change this to GET if you have a Prometheus version older than 2.1 or if POST requests are restricted in your network. |
| `Type` | The type of your Prometheus server; `Prometheus`, `Cortex`, `Thanos`, `Mimir`. When selected, the **Version** field attempts to populate automatically using the Prometheus [buildinfo](https://semver.org/) API. Some Prometheus types, such as Cortex, don't support this API and must be manually populated. |
| `Version` | The version of your Prometheus server, note that this field is not visible until the Prometheus type is selected. |
| `Disable metrics lookup` | Checking this option will disable the metrics chooser and metric/label support in the query field's autocomplete. This helps if you have performance issues with bigger Prometheus instances. |
| `Custom query parameters` | Add custom parameters to the Prometheus query URL. For example `timeout`, `partial_response`, `dedup`, or `max_source_resolution`. Multiple parameters should be concatenated together with an '&amp;'. |
| **Exemplars configuration** | |
| `Internal link` | Enable this option is you have an internal link. When you enable this option, you will see a data source selector. Select the backend tracing data store for your exemplar data. |
| `Data source` | You will see this option only if you enable `Internal link` option. Select the backend tracing data store for your exemplar data. |
| `URL` | You will see this option only if the `Internal link` option is disabled. Enter the full URL of the external link. You can interpolate the value from the field with `${__value.raw }` macro. |
| `URL Label` | (Optional) add a custom display label to override the value of the `Label name` field. |
| `Label name` | Add a name for the exemplar traceID property. |
1. Set the data source's basic configuration options:
**Exemplars configuration:**
| Name | Description |
| Name | Description |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Internal link** | Enable this option if you have an internal link. When enabled, this reveals the data source selector. Select the backend tracing data store for your exemplar data. |
| **Data source** | _(Visible only if you enable `Internal link`)_ Selects the backend tracing data store for your exemplar data. |
| **URL** | _(Visible only if you disable `Internal link`)_ Defines the external link's full URL. You can interpolate the value from the field by using the [`${__value.raw}` macro]({{< relref "../..//panels-visualizations/configure-data-links/#value-variables" >}}). |
| **URL label** | _(Optional)_ Adds a custom display label to override the value of the `Label name` field. |
| **Label name** | Adds a name for the exemplar traceID property. |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Name` | The data source name. This is how you refer to the data source in panels and queries. |
| `Default` | Default data source that is pre-selected for new panels. |
| `URL` | The URL of your Prometheus server, for example, `http://prometheus.example.org:9090`. |
| `Access` | Only Server access mode is functional. If Server mode is already selected this option is hidden. Otherwise change to Server mode to prevent errors. |
| `Basic Auth` | Enable basic authentication to the Prometheus data source. |
| `User` | User name for basic authentication. |
| `Password` | Password for basic authentication. |
| `Manage alerts via Alerting UI` | Toggle whether to enable Alertmanager integration for this data source. |
| `Scrape interval` | Set this to the typical scrape and evaluation interval configured in Prometheus. Defaults to 15s. |
| `HTTP method` | Use either POST or GET HTTP method to query your data source. POST is the recommended and pre-selected method as it allows bigger queries. Change this to GET if you have a Prometheus version older than 2.1 or if POST requests are restricted in your network. |
| `Type` | The type of your Prometheus server; `Prometheus`, `Cortex`, `Thanos`, `Mimir`. When selected, the **Version** field attempts to populate automatically using the Prometheus [buildinfo](https://semver.org/) API. Some Prometheus types, such as Cortex, don't support this API and must be manually populated. |
| `Version` | The version of your Prometheus server, note that this field is not visible until the Prometheus type is selected. |
| `Disable metrics lookup` | Checking this option will disable the metrics chooser and metric/label support in the query field's autocomplete. This helps if you have performance issues with bigger Prometheus instances. |
| `Custom query parameters` | Add custom parameters to the Prometheus query URL. For example `timeout`, `partial_response`, `dedup`, or `max_source_resolution`. Multiple parameters should be concatenated together with an '&amp;'. |
| **Exemplars configuration** | |
| `Internal link` | Enable this option is you have an internal link. When you enable this option, you will see a data source selector. Select the backend tracing data store for your exemplar data. |
| `Data source` | You will see this option only if you enable `Internal link` option. Select the backend tracing data store for your exemplar data. |
| `URL` | You will see this option only if the `Internal link` option is disabled. Enter the full URL of the external link. You can interpolate the value from the field with `${__value.raw }` macro. |
| `URL Label` | (Optional) add a custom display label to override the value of the `Label name` field. |
| `Label name` | Add a name for the exemplar traceID property. |
**Exemplars configuration:**
| Name | Description |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Internal link** | Enable this option if you have an internal link. When enabled, this reveals the data source selector. Select the backend tracing data store for your exemplar data. |
| **Data source** | _(Visible only if you enable `Internal link`)_ Selects the backend tracing data store for your exemplar data. |
| **URL** | _(Visible only if you disable `Internal link`)_ Defines the external link's full URL. You can interpolate the value from the field by using the [`${__value.raw}` macro]({{< relref "../..//panels-visualizations/configure-data-links/#value-variables" >}}). |
| **URL label** | _(Optional)_ Adds a custom display label to override the value of the `Label name` field. |
| **Label name** | Adds a name for the exemplar traceID property. |
### Provision the data source
@@ -96,6 +101,7 @@ datasources:
manageAlerts: true
prometheusType: Prometheus
prometheusVersion: 2.37.0
cacheLevel: 'High'
exemplarTraceIdDestinations:
# Field with internal link pointing to data source in Grafana.
# datasourceUid value can be anything, but it should be unique across all defined data source uids.
+17 -13
View File
@@ -27,22 +27,26 @@ You can also [use the Service Graph]({{< relref "#use-the-service-graph" >}}) to
## Configure the data source
**To access the data source configuration page:**
To configure basic settings for the data source, complete the following steps:
1. Hover the cursor over the **Configuration** (gear) icon.
1. Select **Data Sources**.
1. Select the Tempo data source.
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `Tempo` in the search bar.
1. Select **Tempo**.
Set the data source's basic configuration options carefully:
The **Settings** tab of the data source is displayed.
| Name | Description |
| -------------- | ------------------------------------------------------------------------ |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Sets the data source that's pre-selected for new panels. |
| **URL** | Sets the URL of the Tempo instance, such as `http://tempo`. |
| **Basic Auth** | Enables basic authentication to the Tempo data source. |
| **User** | Sets the user name for basic authentication. |
| **Password** | Sets the password for basic authentication. |
1. Set the data source's basic configuration options:
| Name | Description |
| -------------- | ------------------------------------------------------------------------ |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Sets the data source that's pre-selected for new panels. |
| **URL** | Sets the URL of the Tempo instance, such as `http://tempo`. |
| **Basic Auth** | Enables basic authentication to the Tempo data source. |
| **User** | Sets the user name for basic authentication. |
| **Password** | Sets the password for basic authentication. |
You can also configure settings specific to the Tempo data source. These options are described in the sections below.
+11 -9
View File
@@ -24,18 +24,20 @@ Only users with the organization administrator role can add data sources.
## Configure the data source
**To access the data source configuration page:**
To configure basic settings for the data source, complete the following steps:
1. Hover the cursor over the **Configuration** (gear) icon.
1. Select **Data Sources**.
1. Select the TestData data source.
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `TestData` in the search bar.
1. Select **TestData**.
The data source doesn't provide any settings beyond the most basic options common to all data sources:
The **Settings** tab of the data source is displayed. The data source doesn't provide any settings beyond the most basic options common to all data sources:
| Name | Description |
| ----------- | ------------------------------------------------------------------------ |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Defines whether this data source is pre-selected for new panels. |
| Name | Description |
| ----------- | ------------------------------------------------------------------------ |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Defines whether this data source is pre-selected for new panels. |
## Create mock data
+17 -13
View File
@@ -27,22 +27,26 @@ You can also [upload a JSON trace file]({{< relref "#upload-a-json-trace-file" >
## Configure the data source
**To access the data source configuration page:**
To configure basic settings for the data source, complete the following steps:
1. Hover the cursor over the **Configuration** (gear) icon.
1. Select **Data Sources**.
1. Select the Zipkin data source.
1. Click **Connections** in the left-side menu.
1. Under Your connections, click **Data sources**.
1. Enter `Zipkin` in the search bar.
1. Select **Zipkin**.
Set the data source's basic configuration options carefully:
The **Settings** tab of the data source is displayed.
| Name | Description |
| -------------- | ------------------------------------------------------------------------ |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Defines whether this data source is pre-selected for new panels. |
| **URL** | Sets the URL of the Zipkin instance, such as `http://localhost:9411`. |
| **Basic Auth** | Enables basic authentication for the Zipkin data source. |
| **User** | Defines the user name for basic authentication. |
| **Password** | Defines the password for basic authentication. |
1. Set the data source's basic configuration options:
| Name | Description |
| -------------- | ------------------------------------------------------------------------ |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Defines whether this data source is pre-selected for new panels. |
| **URL** | Sets the URL of the Zipkin instance, such as `http://localhost:9411`. |
| **Basic Auth** | Enables basic authentication for the Zipkin data source. |
| **User** | Defines the user name for basic authentication. |
| **Password** | Defines the password for basic authentication. |
### Trace to logs
@@ -0,0 +1,70 @@
---
title: Cross plugin links
description: Learn how to add plugin links to a Grafana app plugin
---
# Cross plugin links
Using the Plugin extensions API, App plugins can register extension points of their own to display other plugins links. This cross-plugin linking creates a more immersive user experience for installed plugins.
## Available extension points within plugins
An extension point is a location in another plugins UI where your plugin can insert links. All extension point ids within plugins should follow the naming convention `plugins/<plugin-id>/<extension-point-id>`.
## How to create an extension point within a plugin
The `getPluginExtensions` method in `@grafana/runtime` can be used to create an extension point within your plugin. An extension point is a way of specifying where in the plugin UI other plugins links are rendered. `getPluginExtensions` takes an object consisting of the `extensionPointId`, which must begin `plugin/<pluginId>`, and any contextual information you would like to provide. It returns a list of `extensionLinks` that your program can loop over.
_Note: Creating a extension point in a plugin creates a public interface for other plugins to interact with. Changes to the extension point id or the context it passes could break any plugin that attempts to register a link inside your plugin._
```typescript
import { getPluginExtensions } from '@grafana/runtime';
import { isPluginExtensionLink } from '@grafana/data';
import { LinkButton } from '@grafana/ui';
function AppExtensionPointExample() {
const { extensions } = getPluginExtensions({
extensionPointId: 'plugin/another-app-plugin/menu',
context: {
pluginId: 'another-app-plugin',
},
});
if (extensions.length === 0) {
return null;
}
return (
<div>
{extensions.map((extension) => {
if (isPluginExtensionLink(extension)) {
return (
<LinkButton href={extension.path} title={extension.description} key={extension.key}>
{extension.title}
</LinkButton>
);
}
return null;
})}
</div>
);
}
```
In the above example, we created a component that renders `<LinkButton />` components for all link extensions other plugins registered for the `plugin/another-app-plugin/menu` extension point id. We pass the context as the second parameter to `getPluginExtensions`, which will use `Object.freeze` to make the context immutable before passing it to other plugins.
## How to insert links into another plugin
Create links for other plugins in the same way you would [extend the Grafana application UI]({{< relref "./extend-the-grafana-ui-with-links" >}}) with a link. Rather than specify a `grafana/...` extension point, specify the plugin extension point `plugin/<pluginId>/<extensionPointId>` instead. Given the example above, use a plugin link such as the following.
### Link example
```typescript
new AppPlugin().configureExtensionLink({
title: 'Go to basic app',
description: 'Will navigate the user to the basic app',
extensionPointId: 'plugin/another-app-plugin/menu',
path: '/a/myorg-basic-app/one',
});
```
@@ -0,0 +1,119 @@
---
title: Extend the Grafana UI with links
description: Learn how to add links to the Grafana user interface from an App plugin
---
# Extend the Grafana UI with links
Use the Plugin extensions API with your Grafana App plugins to add links to the Grafana UI. Doing so allows you to direct users to your plugins pages from various "extension points" within the Grafana application.
For a plugin to successfully register links it must:
- Be an App plugin.
- Be [preloaded]({{< relref "./metadata" >}}).
- Be installed and enabled.
## Available extension points within Grafana
An extension point is a location within the Grafana application UI where a plugin can insert links. All extension points within Grafana start with `grafana/`. The following extension point ids are available:
- `grafana/dashboard/panel/menu`: all panel menu dropdowns found in dashboards
## Add a link extension within Grafana
Here's how you can add a link to the dashboard panel menus in Grafana via your plugin:
Define the link extension in your plugin's `module.ts` file. First, define a new instance of the `AppPlugin` class by using the `configureExtensionLink` method. This method takes an object that describes your link extension, including a `title` property for the link text, a `extensionPointId` that tells Grafana where the link should appear, and a `path` for the user to navigate to your plugin.
```typescript
new AppPlugin().configureExtensionLink({
title: 'Go to basic app',
description: 'Will navigate the user to the basic app',
extensionPointId: 'grafana/dashboard/panel/menu',
path: '/a/myorg-basic-app/one', // Must start with "/a/<PLUGIN_ID>/"
});
```
That's it! Your link will be displayed in dashboard panel menus. When the user clicks the link, they will be navigated to the path you defined earlier.
_Note: Each plugin is limited to a maximum of two links per extension point._
## Add a link extension using context within Grafana
The above example works for simple cases however you may want to act on information from the panel the user is navigating from. This can be achieved by making use of the `configure` property which takes a function and returns an object that consists of a `title` property for the link text, a `path` to navigate the user to your plugin. Alternatively this function may return `undefined` if there's a need to hide the link for certain scenarios.
```typescript
new AppPlugin().configureExtensionLink({
title: 'Go to basic app',
description: 'Will send the user to the basic app',
extensionPointId: 'grafana/dashboard/panel/menu',
path: '/a/myorg-basic-app/one',
configure: (context: PanelContext) => {
switch (context?.pluginId) {
case 'timeseries':
return {
title: 'Go to page one',
description: 'hello',
path: '/a/myorg-basic-app/one',
};
case 'piechart':
return {
title: 'Go to page two',
path: '/a/myorg-basic-app/two',
};
// Returning undefined tells Grafana to hide the link
default:
return undefined;
}
},
});
```
The above example demonstrates how to return a different link `path` based on which plugin the dashboard panel is using. If the clicked-upon panel is neither a timeseries nor a piechart panel, then the configure function returns `undefined` and the link isn't rendered.
_Note: The context passed to the `configure` function is bound by the `extensionPointId` the link is inserted into. Different extension points contain different contexts._
## Add an onClick event handler to a link
Link extensions provide the means to direct users to a plugin page via href links within the Grafana UI. They can also trigger onClick events to perform dynamic actions when clicked.
Here's how you can add an onClick handler to a link in the dashboard panel menus of Grafana via your plugin:
1. Define the link extension in the plugin's `module.ts` file.
2. Create a new instance of the `AppPlugin` class, again using the `configureExtensionLink` method. This time we add an `onClick` property which takes a function. This function receives the click `event` plus, an object consisting of the `context` and an `openModal` function.
In the following example, we open a modal.
```typescript
new AppPlugin().configureExtensionLink({
title: 'Go to basic app',
description: 'Will send the user to the basic app',
extensionPointId: 'grafana/dashboard/panel/menu',
path: '/a/myorg-basic-app/one',
onClick: (event, { context, openModal }) => {
event.preventDefault();
openModal({
title: 'My plugin modal',
body: ({ onDismiss }) => <SampleModal onDismiss={onDismiss} pluginId={context?.pluginId} />,
});
},
});
type Props = {
onDismiss: () => void;
pluginId?: string;
};
const SampleModal = ({ onDismiss, pluginId }: Props) => {
return (
<VerticalGroup spacing="sm">
<p>This modal was opened via the plugin extensions API.</p>
<p>The panel is using a {pluginId} plugin to display data.</p>
</VerticalGroup>
);
};
```
The plugin extensions API is a powerful feature for you to insert links into the UI of Grafana applications that send users to plugin features or trigger actions based on where the user clicked. This feature can also be used for [cross plugin linking]({{< relref "./cross-plugin-linking" >}}).
@@ -40,15 +40,15 @@ To sign in to Grafana for the first time:
To create your first dashboard:
1. Click **Dashboards** in the left-side menu.
1. On the Dashboards, click **New** and select **New Dashboard** from dropdown menu.
1. On the dashboard, click **Add a new panel**.
1. On the Dashboards page, click **New** and select **New Dashboard** from the dropdown menu.
1. On the dashboard, click **+ Add visualization**.
1. In the New dashboard/Edit panel view, go to the **Query** tab.
1. Configure your [query]({{< relref "../panels-visualizations/query-transform-data/#add-a-query" >}}) by selecting `-- Grafana --` from the data source selector.
This generates the Random Walk dashboard.
1. Click **Save** in the top right corner of your screen to save the dashboard.
1. Add a descriptive name, and then click **Save**.
1. Add a descriptive name for the dashboard, and then click **Save**.
Congratulations, you have created your first dashboard and it is displaying results.
@@ -33,7 +33,7 @@ Annotations are not currently supported in the new table panel. This might be ad
## Sort column
Click a column title to change the sort order from default to descending to ascending. Each time you click, the sort order changes to the next option in the cycle. You can only sort by one column at a time.
Click a column title to change the sort order from default to descending to ascending. Each time you click, the sort order changes to the next option in the cycle. You can sort multiple columns by holding the `shift` key and clicking the column name.
![Sort descending](/static/img/docs/tables/sort-descending.png 'Sort descending')
@@ -17,7 +17,7 @@ weight: 9
Dashboard previews provide an overview of all available dashboards. They help you quickly find the right dashboard when the dashboard names aren't enough on their own.
> **Note:** Dashboard previews are available in Grafana 9.0+ as an opt-in beta feature. Data source permissions are not yet taken into the account when displaying the dashboard previews - refer to the [permissions]({{< relref "#preview-visibility">}}) to learn more before enabling the feature.
> **Warning:** The dashboard previews feature is deprecated and will be removed in Grafana v10.
The dashboard previews feature is an opt-in feature that is disabled by default. You can view dashboard previews after an administrator enables the feature, and after you select the new grid layout. The feature-enablement procedure is outlined in the following section.
@@ -29,7 +29,9 @@ Some stable features are enabled by default. You can disable a stable feature by
| `internationalization` | Enables internationalization | Yes |
| `topnav` | Enables new top navigation and page layouts | Yes |
| `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes |
| `newPanelChromeUI` | Show updated look and feel of grafana-ui PanelChrome: panel header, icons, and menu | Yes |
| `accessTokenExpirationCheck` | Enable OAuth access_token expiration check and token refresh using the refresh_token | |
| `emptyDashboardPage` | Enable the redesigned user interface of a dashboard page that includes no panels | Yes |
| `disablePrometheusExemplarSampling` | Disable Prometheus exemplar sampling | |
| `logsSampleInExplore` | Enables access to the logs sample feature in Explore | Yes |
@@ -48,6 +50,7 @@ Some stable features are enabled by default. You can disable a stable feature by
| `accessControlOnCall` | Access control primitives for OnCall |
| `alertingNoNormalState` | Stop maintaining state of alerts that are not firing |
| `disableElasticsearchBackendExploreQuery` | Disable executing of Elasticsearch Explore queries trough backend |
| `renderAuthJWT` | Uses JWT-based auth for rendering instead of relying on remote cache |
## Alpha feature toggles
@@ -77,12 +80,10 @@ Alpha features might be changed or removed without prior notice.
| `logRequestsInstrumentedAsUnknown` | Logs the path for requests that are instrumented as unknown |
| `redshiftAsyncQueryDataSupport` | Enable async query data support for Redshift |
| `athenaAsyncQueryDataSupport` | Enable async query data support for Athena |
| `newPanelChromeUI` | Show updated look and feel of grafana-ui PanelChrome: panel header, icons, and menu |
| `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema |
| `mysqlAnsiQuotes` | Use double quotes to escape keyword in a MySQL query |
| `elasticsearchBackendMigration` | Use Elasticsearch as backend data source |
| `datasourceOnboarding` | Enable data source onboarding page |
| `emptyDashboardPage` | Enable the redesigned user interface of a dashboard page that includes no panels |
| `secureSocksDatasourceProxy` | Enable secure socks tunneling for supported core datasources |
| `authnService` | Use new auth service to perform authentication |
| `alertingBacktesting` | Rule backtesting API for alerting |
@@ -96,6 +97,7 @@ Alpha features might be changed or removed without prior notice.
| `traceqlSearch` | Enables the 'TraceQL Search' tab for the Tempo datasource which provides a UI to generate TraceQL queries |
| `prometheusMetricEncyclopedia` | Replaces the Prometheus query builder metric select option with a paginated and filterable component |
| `timeSeriesTable` | Enable time series table transformer & sparkline cell type |
| `prometheusResourceBrowserCache` | Displays browser caching options in Prometheus data source configuration |
| `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy |
| `clientTokenRotation` | Replaces the current in-request token rotation so that the client initiates the rotation |
| `prometheusDataplane` | Changes responses to from Prometheus to be compliant with the dataplane specification. In particular it sets the numeric Field.Name from 'Value' to the value of the `__name__` label when present. |
@@ -67,3 +67,17 @@ active_sync_enabled = true # enabled by default
```
Single bind configuration (as in the [Single bind example]({{< relref "ldap/#single-bind-example" >}})) is not supported with active LDAP synchronization because Grafana needs user information to perform LDAP searches.
For the synchronization to work, the `servers.search_filter` and `servers.attributes.username` in the ldap.toml config file must match. By default, the `servers.attributes.username` is `cn`, so if you use another attribute as the search filter, you must also update the username attribute.
For example:
```
[[servers]]
search_filter = "(sAMAccountName=%s)"
[servers.attributes]
username = "sAMAccountName"
```
If the attributes aren't the same, the users' sessions will be terminated after each synchronization. That's because the search will be done using the username's value, and that value doesn't exist for the attribute used in the search filter.
@@ -215,16 +215,17 @@ Dashboards consist of _panels_, each representing a part of the story you want y
Every panel consists of a _query_ and a _visualization_. The query defines _what_ data you want to display, whereas the visualization defines _how_ the data is displayed.
1. In the sidebar, hover your cursor over the **Create** (plus sign) icon and then click **Dashboard**.
1. Click **Add a new panel**.
1. In the **Query editor** below the graph, enter the query from earlier and then press Shift + Enter:
1. Click **Dashboard** in the left-side menu.
1. On the Dashboards page, click **New** and select **New Dashboard** in the dropdown.
1. Click **+ Add visualization**.
1. In the **Query** tab below the graph, enter the query from earlier and then press Shift + Enter:
```
sum(rate(tns_request_duration_seconds_count[5m])) by(route)
```
1. In the **Legend** field, enter **{{route}}** to rename the time series in the legend. The graph legend updates when you click outside the field.
1. In the Panel editor on the right, under **Settings**, change the panel title to "Traffic".
1. In the panel editor on the right, under **Panel options**, change the panel title to "Traffic".
1. Click **Apply** in the top-right corner to save the panel and go back to the dashboard view.
1. Click the **Save dashboard** (disk) icon at the top of the dashboard to save your dashboard.
1. Enter a name in the **Dashboard name** field and then click **Save**.
+2 -2
View File
@@ -140,8 +140,8 @@ For details on using this functionality, see [GitHub pull request #55313](https:
## Transformations: INNER JOINs
Transformations allow you to shape raw data from data sources, like metrics series or GitHub issues, into a format that's appropriate for the chosen visualization.
We have extended the Join transformation to support INNER JOINs in addition to OUTER JOINs. These work similarly to SQL JOINs.
[Transformations]({{< relref "../panels-visualizations/query-transform-data/transform-data/" >}}) allow you to shape raw data from data sources, like metrics series or GitHub issues, into a format that's appropriate for the chosen visualization.
We have extended the [Join transformation]({{< relref "../panels-visualizations/query-transform-data/transform-data/#join-by-field" >}}) to support INNER JOINs in addition to OUTER JOINs. These work similarly to SQL JOINs.
{{< figure src="/static/img/docs/transformations/transform-outer-join-9-2.png" max-width="750px" caption="Query builder groupings for Google Cloud monitoring" >}}
+1 -1
View File
@@ -13,7 +13,7 @@ e2e.scenario({
e2e.flows.openDashboard({ uid: 'TkZXxlNG3' });
e2e().wait('@query');
e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST);
e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST, true);
// New panel editor opens when navigating from Panel menu
e2e.components.PanelEditor.General.content().should('be.visible');
+2 -2
View File
@@ -10,8 +10,8 @@ export const smokeTestScenario = {
scenario: () => {
// wait for time to be set to account for any layout shift
e2e().contains('2020-01-01 00:00:00 to 2020-01-01 06:00:00').should('be.visible');
e2e.components.PageToolbar.item('Add panel').click();
e2e.pages.AddDashboard.addNewPanel().click();
e2e.components.PageToolbar.itemButton('Add panel button').click();
e2e.components.PageToolbar.itemButton('Add new visualization menu item').click();
e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer()
.should('be.visible')
+9 -6
View File
@@ -1,6 +1,6 @@
import { e2e } from '@grafana/e2e';
const PANEL_UNDER_TEST = '2 yaxis and axis labels';
const PANEL_UNDER_TEST = 'Value reducers 1';
e2e.scenario({
describeName: 'Inspect drawer tests',
@@ -34,10 +34,10 @@ e2e.scenario({
});
const viewPortWidth = e2e.config().viewportWidth;
e2e.flows.openDashboard({ uid: '5SdHCadmz' });
e2e.flows.openDashboard({ uid: 'wfTJJL5Wz' });
// testing opening inspect drawer directly by clicking on Inspect in header menu
e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Inspect, PANEL_UNDER_TEST);
e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Inspect, PANEL_UNDER_TEST, true);
expectDrawerTabsAndContent();
@@ -49,7 +49,7 @@ e2e.scenario({
expectSubMenuScenario('Query');
expectSubMenuScenario('Panel JSON', 'JSON');
e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST);
e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST, true);
e2e.components.QueryTab.queryInspectorButton().should('be.visible').click();
@@ -135,10 +135,13 @@ const expectSubMenuScenario = (subMenu: string, tabTitle?: string) => {
tabTitle = tabTitle ?? subMenu;
// testing opening inspect drawer from sub menus under Inspect in header menu
e2e.components.Panels.Panel.title(PANEL_UNDER_TEST).scrollIntoView().should('be.visible').click();
e2e.components.Panels.Panel.menu(PANEL_UNDER_TEST).click({ force: true }); // force click because menu is hidden and show on hover
// sub menus are in the DOM but not visible and because there is no hover support in Cypress force click
// https://github.com/cypress-io/cypress-example-recipes/blob/master/examples/testing-dom__hover-hidden-elements/cypress/integration/hover-hidden-elements-spec.js
e2e.components.Panels.Panel.headerItems(subMenu).click({ force: true });
// simulate hover on Inspector menu item to display sub menus
e2e.components.Panels.Panel.menuItems('Inspect').trigger('mouseover', { force: true });
e2e.components.Panels.Panel.menuItems(subMenu).click({ force: true });
// data should be the default tab
e2e.components.Tab.title(tabTitle).should('be.visible');
+9 -57
View File
@@ -3,7 +3,7 @@ import { ComponentType } from 'react';
import { KeyValue } from './data';
import { NavModel } from './navModel';
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
import { extensionLinkConfigIsValid, type PluginExtensionCommand, type PluginExtensionLink } from './pluginExtensions';
import { type PluginExtensionLinkConfig, PluginExtensionTypes } from './pluginExtensions';
/**
* @public
@@ -50,44 +50,8 @@ export interface AppPluginMeta<T extends KeyValue = KeyValue> extends PluginMeta
// TODO anything specific to apps?
}
/**
* The `configure()` function can only update certain properties of the extension, and due to this
* it only receives a subset of the original extension object.
*/
export type AppPluginExtensionLink = Pick<PluginExtensionLink, 'description' | 'path' | 'title'>;
// A list of helpers that can be used in the command handler
export type AppPluginExtensionCommandHelpers = {
// Opens a modal dialog and renders the provided React component inside it
openModal: (options: {
// The title of the modal
title: string;
// A React element that will be rendered inside the modal
body: React.ElementType<{ onDismiss?: () => void }>;
}) => void;
};
export type AppPluginExtensionCommand = Pick<PluginExtensionCommand, 'description' | 'title'>;
export type AppPluginExtensionLinkConfig<C extends object = object> = {
title: string;
description: string;
placement: string;
path: string;
configure?: (context?: C) => Partial<AppPluginExtensionLink> | undefined;
};
export type AppPluginExtensionCommandConfig<C extends object = object> = {
title: string;
description: string;
placement: string;
handler: (context?: C, helpers?: AppPluginExtensionCommandHelpers) => void;
configure?: (context?: C) => Partial<AppPluginExtensionCommand> | undefined;
};
export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppPluginMeta<T>> {
private linkExtensions: AppPluginExtensionLinkConfig[] = [];
private commandExtensions: AppPluginExtensionCommandConfig[] = [];
private _extensionConfigs: PluginExtensionLinkConfig[] = [];
// Content under: /a/${plugin-id}/*
root?: ComponentType<AppRootProps<T>>;
@@ -129,28 +93,16 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
}
}
get extensionLinks(): AppPluginExtensionLinkConfig[] {
return this.linkExtensions;
get extensionConfigs() {
return this._extensionConfigs;
}
get extensionCommands(): AppPluginExtensionCommandConfig[] {
return this.commandExtensions;
}
configureExtensionLink<Context extends object>(extension: Exclude<PluginExtensionLinkConfig<Context>, 'type'>) {
this._extensionConfigs.push({
...extension,
type: PluginExtensionTypes.link,
} as PluginExtensionLinkConfig);
configureExtensionLink<C extends object>(config: AppPluginExtensionLinkConfig<C>) {
const { path, description, title, placement } = config;
if (!extensionLinkConfigIsValid({ path, description, title, placement })) {
console.warn('[Plugins] Disabled extension because configureExtensionLink was called with an invalid object.');
return this;
}
this.linkExtensions.push(config as AppPluginExtensionLinkConfig);
return this;
}
configureExtensionCommand<C extends object>(config: AppPluginExtensionCommandConfig<C>) {
this.commandExtensions.push(config as AppPluginExtensionCommandConfig);
return this;
}
}
@@ -83,6 +83,7 @@ export interface FeatureToggles {
traceqlSearch?: boolean;
prometheusMetricEncyclopedia?: boolean;
timeSeriesTable?: boolean;
prometheusResourceBrowserCache?: boolean;
influxdbBackendMigration?: boolean;
clientTokenRotation?: boolean;
disableElasticsearchBackendExploreQuery?: boolean;
@@ -91,4 +92,5 @@ export interface FeatureToggles {
alertStateHistoryLokiPrimary?: boolean;
alertStateHistoryLokiOnly?: boolean;
unifiedRequestLog?: boolean;
renderAuthJWT?: boolean;
}
+6 -7
View File
@@ -53,13 +53,12 @@ export * from './slider';
export * from './accesscontrol';
export * from './icon';
export {
PluginExtensionTypes,
PluginExtensionPoints,
type PluginExtension,
type PluginExtensionLink,
isPluginExtensionLink,
assertPluginExtensionLink,
type PluginExtensionCommand,
isPluginExtensionCommand,
assertPluginExtensionCommand,
PluginExtensionTypes,
PluginExtensionPlacements,
type PluginExtensionConfig,
type PluginExtensionLinkConfig,
type PluginExtensionEventHelpers,
type PluginExtensionPanelContext,
} from './pluginExtensions';
@@ -1,70 +1,88 @@
/**
* These types are exposed when rendering extension points
*/
import { RawTimeRange, TimeZone } from './time';
export enum PluginExtensionPlacements {
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
}
// Plugin Extensions types
// ---------------------------------------
export enum PluginExtensionTypes {
link = 'link',
command = 'command',
}
export type PluginExtension = {
id: string;
type: PluginExtensionTypes;
title: string;
description: string;
key: number;
pluginId: string;
};
export type PluginExtensionLink = PluginExtension & {
type: PluginExtensionTypes.link;
path: string;
};
export type PluginExtensionCommand = PluginExtension & {
type: PluginExtensionTypes.command;
callHandlerWithContext: () => void;
};
export function isPluginExtensionLink(extension: PluginExtension | undefined): extension is PluginExtensionLink {
if (!extension) {
return false;
}
return extension.type === PluginExtensionTypes.link && 'path' in extension;
}
export function assertPluginExtensionLink(
extension: PluginExtension | undefined
): asserts extension is PluginExtensionLink {
if (!isPluginExtensionLink(extension)) {
throw new Error(`extension is not a link extension`);
}
}
export function isPluginExtensionCommand(extension: PluginExtension | undefined): extension is PluginExtensionCommand {
if (!extension) {
return false;
}
return extension.type === PluginExtensionTypes.command;
}
export function assertPluginExtensionCommand(
extension: PluginExtension | undefined
): asserts extension is PluginExtensionCommand {
if (!isPluginExtensionCommand(extension)) {
throw new Error(`extension is not a command extension`);
}
}
export function extensionLinkConfigIsValid(props: {
path?: string;
description?: string;
title?: string;
placement?: string;
}) {
const valuesAreStrings = Object.values(props).every((val) => typeof val === 'string' && val.length);
const placementIsValid = props.placement?.startsWith('grafana/') || props.placement?.startsWith('plugins/');
return valuesAreStrings && placementIsValid;
onClick?: (event: React.MouseEvent) => void;
};
// Objects used for registering extensions (in app plugins)
// --------------------------------------------------------
export type PluginExtensionConfig<Context extends object = object, ExtraProps extends object = object> = Pick<
PluginExtension,
'title' | 'description'
> &
ExtraProps & {
// The unique identifier of the Extension Point
// (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
extensionPointId: string;
// (Optional) A function that can be used to configure the extension dynamically based on the extension point's context
configure?: (
context?: Readonly<Context>
) => Partial<{ title: string; description: string } & ExtraProps> | undefined;
};
export type PluginExtensionLinkConfig<Context extends object = object> = PluginExtensionConfig<
Context,
Pick<PluginExtensionLink, 'path'> & {
type: PluginExtensionTypes.link;
onClick?: (event: React.MouseEvent, helpers: PluginExtensionEventHelpers<Context>) => void;
}
>;
export type PluginExtensionEventHelpers<Context extends object = object> = {
context?: Readonly<Context>;
// Opens a modal dialog and renders the provided React component inside it
openModal: (options: {
// The title of the modal
title: string;
// A React element that will be rendered inside the modal
body: React.ElementType<{ onDismiss?: () => void }>;
}) => void;
};
// Extension Points & Contexts
// --------------------------------------------------------
// Extension Points available in core Grafana
export enum PluginExtensionPoints {
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
}
export type PluginExtensionPanelContext = {
pluginId: string;
id: number;
title: string;
timeRange: RawTimeRange;
timeZone: TimeZone;
dashboard: Dashboard;
targets: Target[];
};
type Dashboard = {
uid: string;
title: string;
tags: string[];
};
type Target = {
pluginId: string;
refId: string;
};
@@ -73,6 +73,7 @@ export const Components = {
title: (title: string) => `data-testid Panel header ${title}`,
headerItems: (item: string) => `Panel header item ${item}`,
menuItems: (item: string) => `data-testid Panel menu item ${item}`,
menu: (title: string) => `data-testid Panel menu ${title}`,
containerByTitle: (title: string) => `${title} panel`,
headerCornerInfo: (mode: string) => `Panel header ${mode}`,
},
@@ -256,6 +257,7 @@ export const Components = {
PageToolbar: {
container: () => '.page-toolbar',
item: (tooltip: string) => `${tooltip}`,
itemButton: (title: string) => `data-testid ${title}`,
},
QueryEditorToolbarItem: {
button: (title: string) => `QueryEditor toolbar item button ${title}`,
@@ -42,6 +42,7 @@ export const Pages = {
},
AddDashboard: {
url: '/dashboard/new',
itemButton: (title: string) => `data-testid ${title}`,
addNewPanel: 'Add new panel',
addNewRow: 'Add new row',
addNewPanelLibrary: 'Add new panel from panel library',
@@ -50,8 +50,8 @@ export const importDashboard = (dashboardToImport: Dashboard, queryTimeout?: num
if (!skipPanelValidation) {
dashboardToImport.panels.forEach((panel) => {
// Look at the json data
e2e.components.Panels.Panel.title(panel.title).should('be.visible').click();
e2e.components.Panels.Panel.headerItems('Inspect').should('be.visible').click();
e2e.components.Panels.Panel.menu(panel.title).click({ force: true }); // force click because menu is hidden and show on hover
e2e.components.Panels.Panel.menuItems('Inspect').should('be.visible').click();
e2e.components.Tab.title('JSON').should('be.visible').click();
e2e.components.PanelInspector.Json.content().should('be.visible').contains('Panel JSON').click({ force: true });
e2e.components.Select.option().should('be.visible').contains('Panel data').click();
@@ -7,10 +7,16 @@ export enum PanelMenuItems {
Extensions = 'Extensions',
}
export const openPanelMenuItem = (menu: PanelMenuItems, panelTitle = 'Panel Title') => {
e2e.components.Panels.Panel.title(panelTitle).should('be.visible').click();
e2e.components.Panels.Panel.headerItems(menu).should('be.visible').click();
export const openPanelMenuItem = (menu: PanelMenuItems, panelTitle = 'Panel Title', isReactPanel = false) => {
// we changed the way we open the panel menu in react panels with the new panel header
if (isReactPanel) {
e2e.components.Panels.Panel.menu(panelTitle).click({ force: true }); // force click because menu is hidden and show on hover
e2e.components.Panels.Panel.menuItems(menu).should('be.visible').click();
} else {
// this is the old way of opening the panel menu from the title
e2e.components.Panels.Panel.title(panelTitle).should('be.visible').click();
e2e.components.Panels.Panel.headerItems(menu).should('be.visible').click();
}
};
export const openPanelMenuExtension = (extensionTitle: string, panelTitle = 'Panel Title') => {
@@ -8,14 +8,10 @@ export * from './legacyAngularInjector';
export * from './live';
export * from './LocationService';
export * from './appEvents';
export {
type PluginExtensionRegistry,
type PluginExtensionRegistryItem,
setPluginsExtensionRegistry,
} from './pluginExtensions/registry';
export {
type PluginExtensionsOptions,
type PluginExtensionsResult,
setPluginExtensionGetter,
getPluginExtensions,
} from './pluginExtensions/extensions';
export { type PluginExtensionPanelContext } from './pluginExtensions/contexts';
type GetPluginExtensions,
} from './pluginExtensions/getPluginExtensions';
export { isPluginExtensionLink } from './pluginExtensions/utils';
@@ -1,22 +0,0 @@
import { RawTimeRange, TimeZone } from '@grafana/data';
type Dashboard = {
uid: string;
title: string;
tags: Readonly<Array<Readonly<string>>>;
};
type Target = {
pluginId: string;
refId: string;
};
export type PluginExtensionPanelContext = Readonly<{
pluginId: string;
id: number;
title: string;
timeRange: Readonly<RawTimeRange>;
timeZone: TimeZone;
dashboard: Readonly<Dashboard>;
targets: Readonly<Array<Readonly<Target>>>;
}>;
@@ -1,79 +0,0 @@
import { assertPluginExtensionLink, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
import { getPluginExtensions } from './extensions';
import { PluginExtensionRegistryItem, setPluginsExtensionRegistry } from './registry';
describe('getPluginExtensions', () => {
describe('when getting extensions for placement', () => {
const placement = 'grafana/dashboard/panel/menu';
const pluginId = 'grafana-basic-app';
beforeAll(() => {
setPluginsExtensionRegistry({
[placement]: [
createRegistryLinkItem({
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: `/a/${pluginId}/declare-incident`,
key: 1,
}),
],
'plugins/myorg-basic-app/start': [
createRegistryLinkItem({
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: `/a/${pluginId}/declare-incident`,
key: 2,
}),
],
});
});
it('should return extensions with correct path', () => {
const { extensions } = getPluginExtensions({ placement });
const [extension] = extensions;
assertPluginExtensionLink(extension);
expect(extension.path).toBe(`/a/${pluginId}/declare-incident`);
expect(extensions.length).toBe(1);
});
it('should return extensions with correct description', () => {
const { extensions } = getPluginExtensions({ placement });
const [extension] = extensions;
assertPluginExtensionLink(extension);
expect(extension.description).toBe('Declaring an incident in the app');
expect(extensions.length).toBe(1);
});
it('should return extensions with correct title', () => {
const { extensions } = getPluginExtensions({ placement });
const [extension] = extensions;
assertPluginExtensionLink(extension);
expect(extension.title).toBe('Declare incident');
expect(extensions.length).toBe(1);
});
it('should return an empty array when extensions can be found', () => {
const { extensions } = getPluginExtensions({
placement: 'plugins/not-installed-app/news',
});
expect(extensions.length).toBe(0);
});
});
});
function createRegistryLinkItem(
link: Omit<PluginExtensionLink, 'type'>
): PluginExtensionRegistryItem<PluginExtensionLink> {
return (context?: object) => ({
...link,
type: PluginExtensionTypes.link,
});
}
@@ -1,35 +0,0 @@
import { type PluginExtension } from '@grafana/data';
import { getPluginsExtensionRegistry } from './registry';
export type PluginExtensionsOptions<T extends object> = {
placement: string;
context?: T;
};
export type PluginExtensionsResult = {
extensions: PluginExtension[];
};
export function getPluginExtensions<T extends object = {}>(
options: PluginExtensionsOptions<T>
): PluginExtensionsResult {
const { placement, context } = options;
const registry = getPluginsExtensionRegistry();
const configureFuncs = registry[placement] ?? [];
const extensions = configureFuncs.reduce<PluginExtension[]>((result, configure) => {
const extension = configure(context);
// If the configure() function returns `undefined`, the extension is not displayed
if (extension) {
result.push(extension);
}
return result;
}, []);
return {
extensions: extensions,
};
}
@@ -0,0 +1,39 @@
import { setPluginExtensionGetter, type GetPluginExtensions, getPluginExtensions } from './getPluginExtensions';
describe('Plugin Extensions / Get Plugin Extensions', () => {
afterEach(() => {
process.env.NODE_ENV = 'test';
});
test('should always return the same extension-getter function that was previously set', () => {
const getter: GetPluginExtensions = jest.fn().mockReturnValue({ extensions: [] });
setPluginExtensionGetter(getter);
getPluginExtensions({ extensionPointId: 'panel-menu' });
expect(getter).toHaveBeenCalledTimes(1);
expect(getter).toHaveBeenCalledWith({ extensionPointId: 'panel-menu' });
});
test('should throw an error when trying to redefine the app-wide extension-getter function', () => {
// By default, NODE_ENV is set to 'test' in jest.config.js, which allows to override the registry in tests.
process.env.NODE_ENV = 'production';
const getter: GetPluginExtensions = () => ({ extensions: [] });
expect(() => {
setPluginExtensionGetter(getter);
setPluginExtensionGetter(getter);
}).toThrowError();
});
test('should throw an error when trying to access the extension-getter function before it was set', () => {
// "Unsetting" the registry
// @ts-ignore
setPluginExtensionGetter(undefined);
expect(() => {
getPluginExtensions({ extensionPointId: 'panel-menu' });
}).toThrowError();
});
});
@@ -0,0 +1,30 @@
import { PluginExtension } from '@grafana/data';
export type GetPluginExtensions = ({
extensionPointId,
context,
}: {
extensionPointId: string;
context?: object | Record<string | symbol, unknown>;
}) => {
extensions: PluginExtension[];
};
let singleton: GetPluginExtensions | undefined;
export function setPluginExtensionGetter(instance: GetPluginExtensions): void {
// We allow overriding the registry in tests
if (singleton && process.env.NODE_ENV !== 'test') {
throw new Error('setPluginExtensionGetter() function should only be called once, when Grafana is starting.');
}
singleton = instance;
}
function getPluginExtensionGetter(): GetPluginExtensions {
if (!singleton) {
throw new Error('getPluginExtensionGetter() can only be used after the Grafana instance has started.');
}
return singleton;
}
export const getPluginExtensions: GetPluginExtensions = (options) => getPluginExtensionGetter()(options);
@@ -1,23 +0,0 @@
import { PluginExtension } from '@grafana/data';
export type PluginExtensionRegistryItem<T extends PluginExtension = PluginExtension, C extends object = object> = (
context?: C
) => T | undefined;
export type PluginExtensionRegistry = Record<string, PluginExtensionRegistryItem[]>;
let registry: PluginExtensionRegistry | undefined;
export function setPluginsExtensionRegistry(instance: PluginExtensionRegistry): void {
if (registry && process.env.NODE_ENV !== 'test') {
throw new Error('setPluginsExtensionRegistry function should only be called once, when Grafana is starting.');
}
registry = instance;
}
export function getPluginsExtensionRegistry(): PluginExtensionRegistry {
if (!registry) {
throw new Error('getPluginsExtensionRegistry can only be used after the Grafana instance has started.');
}
return registry;
}
@@ -0,0 +1,50 @@
import { PluginExtension, PluginExtensionTypes } from '@grafana/data';
import { isPluginExtensionLink } from './utils';
describe('Plugin Extensions / Utils', () => {
describe('isPluginExtensionLink()', () => {
test('should return TRUE if the object is a link extension', () => {
expect(
isPluginExtensionLink({
id: 'id',
pluginId: 'plugin-id',
type: PluginExtensionTypes.link,
title: 'Title',
description: 'Description',
path: '...',
} as PluginExtension)
).toBe(true);
expect(
isPluginExtensionLink({
id: 'id',
pluginId: 'plugin-id',
type: PluginExtensionTypes.link,
title: 'Title',
description: 'Description',
onClick: () => {},
} as PluginExtension)
).toBe(true);
});
test('should return FALSE if the object is NOT a link extension', () => {
expect(
isPluginExtensionLink({
type: PluginExtensionTypes.link,
title: 'Title',
description: 'Description',
} as PluginExtension)
).toBe(false);
expect(
// @ts-ignore (Right now we only have a single type of extension)
isPluginExtensionLink({
type: 'unknown',
title: 'Title',
description: 'Description',
path: '...',
} as PluginExtension)
).toBe(false);
});
});
});
@@ -0,0 +1,8 @@
import { PluginExtension, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
export function isPluginExtensionLink(extension: PluginExtension | undefined): extension is PluginExtensionLink {
if (!extension) {
return false;
}
return extension.type === PluginExtensionTypes.link && ('path' in extension || 'onClick' in extension);
}
@@ -77,7 +77,7 @@ enum PluginRequestHeaders {
DatasourceUID = 'X-Datasource-Uid', // can be used for routing/ load balancing
DashboardUID = 'X-Dashboard-Uid', // mainly useful for debuging slow queries
PanelID = 'X-Panel-Id', // mainly useful for debuging slow queries
QueryGroupID = 'X-Query-Group-Id', // mainly useful to find related queries with query chunking
QueryGroupID = 'X-Query-Group-Id', // mainly useful to find related queries with query splitting
FromExpression = 'X-Grafana-From-Expr', // used by datasources to identify expression queries
}
@@ -10,7 +10,7 @@ import { PanelMenu } from './PanelMenu';
interface Props {
children?: React.ReactNode;
menu: ReactElement | (() => ReactElement);
menu?: ReactElement | (() => ReactElement);
title?: string;
offset?: number;
dragClass?: string;
@@ -41,17 +41,19 @@ export function HoverWidget({ menu, title, dragClass, children, offset = -32 }:
style={{ top: `${offset}px` }}
data-testid="hover-header-container"
>
<div
className={cx(styles.square, styles.draggable, dragClass)}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
ref={draggableRef}
>
<Icon name="expand-arrows" className={styles.draggableIcon} />
</div>
{dragClass && (
<div
className={cx(styles.square, styles.draggable, dragClass)}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
ref={draggableRef}
>
<Icon name="expand-arrows" className={styles.draggableIcon} />
</div>
)}
{!title && <h6 className={cx(styles.untitled, styles.draggable, dragClass)}>Untitled</h6>}
{children}
<div className={styles.square}>
{menu && (
<PanelMenu
menu={menu}
title={title}
@@ -59,7 +61,7 @@ export function HoverWidget({ menu, title, dragClass, children, offset = -32 }:
menuButtonClass={styles.menuButton}
onVisibleChange={setMenuOpen}
/>
</div>
)}
</div>
);
}
@@ -92,7 +94,6 @@ function getStyles(theme: GrafanaTheme2) {
alignItems: 'center',
width: theme.spacing(4),
height: '100%',
paddingRight: theme.spacing(0.5),
}),
draggable: css({
cursor: 'move',
@@ -109,12 +110,10 @@ function getStyles(theme: GrafanaTheme2) {
background: theme.colors.secondary.main,
},
}),
title: css({
padding: theme.spacing(0.75),
}),
untitled: css({
color: theme.colors.text.disabled,
fontStyle: 'italic',
padding: theme.spacing(0, 1),
marginBottom: 0,
}),
draggableIcon: css({
@@ -56,7 +56,7 @@ Component used for rendering content wrapped in the same style as grafana panels
style={{
width: innerwidth,
height: innerheight,
background: 'gray',
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -133,7 +133,7 @@ Component used for rendering content wrapped in the same style as grafana panels
style={{
width: innerwidth,
height: innerheight,
background: 'gray',
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -170,7 +170,7 @@ Component used for rendering content wrapped in the same style as grafana panels
style={{
width: innerwidth,
height: innerheight,
background: 'gray',
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -202,7 +202,7 @@ Component used for rendering content wrapped in the same style as grafana panels
style={{
width: innerwidth,
height: innerheight,
background: 'white',
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -232,7 +232,7 @@ Component used for rendering content wrapped in the same style as grafana panels
style={{
width: innerwidth,
height: innerheight,
background: 'gray',
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -258,7 +258,7 @@ Component used for rendering content wrapped in the same style as grafana panels
style={{
width: innerwidth,
height: innerheight,
background: 'gray',
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -285,7 +285,7 @@ Component used for rendering content wrapped in the same style as grafana panels
style={{
width: innerwidth,
height: innerheight,
background: 'gray',
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -300,7 +300,7 @@ Component used for rendering content wrapped in the same style as grafana panels
</HorizontalGroup>
</Canvas>
### Extra options? Title Items
### Extra options? Title items and actions
```tsx
<PanelChrome
@@ -311,8 +311,12 @@ Component used for rendering content wrapped in the same style as grafana panels
<Button fill="text" icon="sliders-v-alt" variant="secondary" tooltip="extra content2 to render" />
</div>
}
description="Here I will put a description that explains a bit more this panel"
width={400}
actions={
<Button size="sm" variant="secondary" key="A">
Breakdown
</Button>
}
width={500}
height={200}
>
{(innerwidth, innerheight) => {
@@ -321,7 +325,7 @@ Component used for rendering content wrapped in the same style as grafana panels
style={{
width: innerwidth,
height: innerheight,
background: 'white',
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -337,14 +341,18 @@ Component used for rendering content wrapped in the same style as grafana panels
<Canvas>
<PanelChrome
title="My awesome panel title"
description="Here I will put a description that explains a bit more this panel"
titleItems={
<div>
<Button fill="text" icon="github" variant="secondary" tooltip="extra content to render" />
<Button fill="text" icon="sliders-v-alt" variant="secondary" tooltip="extra content2 to render" />
</div>
}
width={400}
actions={
<Button size="sm" variant="secondary" key="A">
Breakdown
</Button>
}
width={500}
height={200}
>
{(innerwidth, innerheight) => {
@@ -353,7 +361,7 @@ Component used for rendering content wrapped in the same style as grafana panels
style={{
width: innerwidth,
height: innerheight,
background: 'gray',
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -415,7 +423,7 @@ Component used for rendering content wrapped in the same style as grafana panels
style={{
width: innerwidth,
height: innerheight,
background: 'gray',
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -470,7 +478,7 @@ Component used for rendering content wrapped in the same style as grafana panels
style={{
width: innerwidth,
height: innerheight,
background: 'gray',
background: 'rgba(230,0,0,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -5,7 +5,7 @@ import React, { CSSProperties, useState, ReactNode } from 'react';
import { useInterval } from 'react-use';
import { LoadingState } from '@grafana/data';
import { PanelChrome, PanelChromeProps } from '@grafana/ui';
import { Button, Icon, PanelChrome, PanelChromeProps, RadioButtonGroup } from '@grafana/ui';
import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
@@ -40,7 +40,7 @@ function getContentStyle(): CSSProperties {
function renderPanel(name: string, overrides?: Partial<PanelChromeProps>) {
const props: PanelChromeProps = {
width: 400,
height: 130,
height: 150,
children: () => undefined,
};
@@ -131,10 +131,6 @@ export const Examples = () => {
{renderPanel('No title, streaming loadingState', {
loadingState: LoadingState.Streaming,
})}
{renderPanel('No title, loading loadingState', {
loadingState: LoadingState.Loading,
})}
{renderPanel('Error status, menu', {
title: 'Default title',
menu,
@@ -183,22 +179,120 @@ export const Examples = () => {
/>,
],
})}
{renderPanel('Deprecated error indicator, menu', {
title: 'Default title',
menu,
leftItems: [
<PanelChrome.ErrorIndicator
key="errorIndicator"
error="Error text"
onClick={action('ErrorIndicator: onClick fired')}
/>,
],
})}
{renderPanel('Display mode = transparent', {
title: 'Default title',
displayMode: 'transparent',
menu,
leftItems: [],
})}
{renderPanel('Actions with button no menu', {
title: 'Actions with button no menu',
actions: (
<Button size="sm" variant="secondary" key="A">
Breakdown
</Button>
),
})}
{renderPanel('Panel with two actions', {
title: 'I have two buttons',
actions: [
<Button size="sm" variant="secondary" key="A">
Breakdown
</Button>,
<Button size="sm" variant="secondary" icon="times" key="B" />,
],
})}
{renderPanel('With radio button', {
title: 'I have a radio button',
actions: [
<RadioButtonGroup
key="radio-button-group"
size="sm"
value="A"
options={[
{ label: 'Graph', value: 'A' },
{ label: 'Table', value: 'B' },
]}
/>,
],
})}
{renderPanel('Panel with action link', {
title: 'Panel with action link',
actions: (
<a className="external-link" href="/some/page">
Error details
<Icon name="arrow-right" />
</a>
),
})}
{renderPanel('Action and menu (should be rare)', {
title: 'Action and menu',
menu,
actions: (
<Button size="sm" variant="secondary">
Breakdown
</Button>
),
})}
</HorizontalGroup>
</div>
</DashboardStoryCanvas>
);
};
export const ExamplesHoverHeader = () => {
return (
<DashboardStoryCanvas>
<div>
<HorizontalGroup spacing="md" align="flex-start" wrap>
{renderPanel('Title items, menu, hover header', {
title: 'Default title',
description: 'This is a description',
menu,
hoverHeader: true,
dragClass: 'draggable',
titleItems: (
<PanelChrome.TitleItem title="Online">
<Icon name="heart" />
</PanelChrome.TitleItem>
),
})}
{renderPanel('Multiple title items', {
title: 'Default title',
menu,
hoverHeader: true,
dragClass: 'draggable',
titleItems: [
<PanelChrome.TitleItem title="Online" key="A">
<Icon name="heart" />
</PanelChrome.TitleItem>,
<PanelChrome.TitleItem title="Link" key="B" onClick={() => {}}>
<Icon name="external-link-alt" />
</PanelChrome.TitleItem>,
],
})}
{renderPanel('Hover header, loading loadingState', {
loadingState: LoadingState.Loading,
hoverHeader: true,
title: 'I am a hover header',
dragClass: 'draggable',
})}
{renderPanel('No title, Hover header', {
hoverHeader: true,
dragClass: 'draggable',
})}
{renderPanel('Should not have drag icon', {
title: 'No drag icon',
hoverHeader: true,
})}
{renderPanel('With action link', {
title: 'With link in hover header',
hoverHeader: true,
actions: (
<a className="external-link" href="/some/page">
Error details
<Icon name="arrow-right" />
</a>
),
})}
</HorizontalGroup>
</div>
@@ -47,13 +47,10 @@ export interface PanelChromeProps {
*/
statusMessageOnClick?: (e: React.SyntheticEvent) => void;
/**
* @deprecated in favor of props
* statusMessage for error messages
* and loadingState for loading and streaming data
* which will serve the same purpose
* of showing/interacting with the panel's state
*/
* @deprecated use `actions' instead
**/
leftItems?: ReactNode[];
actions?: ReactNode;
displayMode?: 'default' | 'transparent';
onCancelQuery?: () => void;
}
@@ -84,6 +81,7 @@ export function PanelChrome({
statusMessage,
statusMessageOnClick,
leftItems,
actions,
onCancelQuery,
}: PanelChromeProps) {
const theme = useTheme2();
@@ -111,7 +109,12 @@ export function PanelChrome({
containerStyles.border = 'none';
}
const ariaLabel = title ? selectors.components.Panels.Panel.containerByTitle(title) : 'Panel';
/** Old property name now maps to actions */
if (leftItems) {
actions = leftItems;
}
const testid = title ? selectors.components.Panels.Panel.title(title) : 'Panel';
const headerContent = (
<>
@@ -142,22 +145,24 @@ export function PanelChrome({
</Tooltip>
</DelayRender>
)}
<div className={styles.rightAligned}>
{actions && <div className={styles.rightActions}>{itemsRenderer(actions, (item) => item)}</div>}
</div>
</>
);
return (
<div className={styles.container} style={containerStyles} aria-label={ariaLabel}>
<div className={styles.container} style={containerStyles} data-testid={testid}>
<div className={styles.loadingBarContainer}>
{loadingState === LoadingState.Loading ? <LoadingBar width={width} ariaLabel="Panel loading bar" /> : null}
</div>
{hoverHeader && !isTouchDevice && (
<>
{menu && (
<HoverWidget menu={menu} title={title} offset={hoverHeaderOffset} dragClass={dragClass}>
{headerContent}
</HoverWidget>
)}
<HoverWidget menu={menu} title={title} offset={hoverHeaderOffset} dragClass={dragClass}>
{headerContent}
</HoverWidget>
{statusMessage && (
<div className={styles.errorContainerFloating}>
<PanelStatus message={statusMessage} onClick={statusMessageOnClick} ariaLabel="Panel status" />
@@ -176,23 +181,19 @@ export function PanelChrome({
{headerContent}
<div className={styles.rightAligned}>
{menu && (
<PanelMenu
menu={menu}
title={title}
placement="bottom-end"
menuButtonClass={cx(
{ [styles.hiddenMenu]: !isTouchDevice },
styles.menuItem,
dragClassCancel,
showOnHoverClass
)}
/>
)}
{leftItems && <div className={styles.leftItems}>{itemsRenderer(leftItems, (item) => item)}</div>}
</div>
{menu && (
<PanelMenu
menu={menu}
title={title}
placement="bottom-end"
menuButtonClass={cx(
{ [styles.hiddenMenu]: !isTouchDevice },
styles.menuItem,
dragClassCancel,
showOnHoverClass
)}
/>
)}
</div>
)}
@@ -203,7 +204,7 @@ export function PanelChrome({
);
}
const itemsRenderer = (items: ReactNode[], renderer: (items: ReactNode[]) => ReactNode): ReactNode => {
const itemsRenderer = (items: ReactNode[] | ReactNode, renderer: (items: ReactNode[]) => ReactNode): ReactNode => {
const toRender = React.Children.toArray(items).filter(Boolean);
return toRender.length > 0 ? renderer(toRender) : null;
};
@@ -339,9 +340,10 @@ const getStyles = (theme: GrafanaTheme2) => {
top: 0,
zIndex: theme.zIndex.tooltip,
}),
leftItems: css({
rightActions: css({
display: 'flex',
paddingRight: theme.spacing(padding),
padding: theme.spacing(0, padding),
gap: theme.spacing(1),
}),
rightAligned: css({
label: 'right-aligned-container',
@@ -31,7 +31,7 @@ export function PanelDescription({ description, className }: Props) {
return description !== '' ? (
<Tooltip interactive content={getDescriptionContent}>
<TitleItem className={cx(className, styles.description)}>
<Icon name="info-circle" size="md" title="description" />
<Icon name="info-circle" size="md" />
</TitleItem>
</Tooltip>
) : null;
@@ -1,6 +1,8 @@
import { cx } from '@emotion/css';
import React, { ReactElement } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { Dropdown } from '../Dropdown/Dropdown';
import { ToolbarButton } from '../ToolbarButton';
import { TooltipPlacement } from '../Tooltip';
@@ -24,6 +26,7 @@ export function PanelMenu({
menuButtonClass,
onVisibleChange,
}: PanelMenuProps) {
const testId = title ? selectors.components.Panels.Panel.menu(title) : `panel-menu-button`;
return (
<Dropdown overlay={menu} placement={placement} offset={offset} onVisibleChange={onVisibleChange}>
<ToolbarButton
@@ -32,7 +35,7 @@ export function PanelMenu({
icon="ellipsis-v"
iconSize="md"
narrow
data-testid="panel-menu-button"
data-testid={testId}
className={cx(menuButtonClass, dragClassCancel)}
/>
</Dropdown>
@@ -7,7 +7,7 @@ import { TableCellBackgroundDisplayMode, TableCellOptions } from '@grafana/schem
import { useStyles2 } from '../../themes';
import { getCellLinks, getTextColorForAlphaBackground } from '../../utils';
import { Button, clearLinkButtonStyles } from '../Button';
import { clearLinkButtonStyles } from '../Button';
import { DataLinksContextMenu } from '../DataLinks/DataLinksContextMenu';
import { CellActions } from './CellActions';
@@ -42,15 +42,17 @@ export const DefaultCell = (props: TableCellProps) => {
{hasLinks && (
<DataLinksContextMenu links={() => getCellLinks(field, row) || []}>
{(api) => {
const content = <div className={getLinkStyle(tableStyles, cellOptions, api.targetClassName)}>{value}</div>;
if (api.openMenu) {
return (
<Button className={cx(clearButtonStyle)} onClick={api.openMenu}>
{content}
</Button>
<button
className={cx(clearButtonStyle, getLinkStyle(tableStyles, cellOptions, api.targetClassName))}
onClick={api.openMenu}
>
{value}
</button>
);
} else {
return content;
return <div className={getLinkStyle(tableStyles, cellOptions, api.targetClassName)}>{value}</div>;
}
}}
</DataLinksContextMenu>
@@ -175,6 +175,7 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell
font-weight: ${theme.typography.fontWeightMedium};
&:hover {
text-decoration: underline;
color: ${theme.colors.text.link};
}
`,
cellLinkForColoredCell: css`
@@ -14,6 +14,7 @@ export const DashboardStoryCanvas = ({ children }: Props) => {
height: 100%;
padding: 32px;
background: ${theme.colors.background.canvas};
overflow: auto;
`;
return <div className={style}>{children}</div>;
+1 -1
View File
@@ -145,7 +145,7 @@ func getVersionFolder(cfg uploadConfig, event string) (string, error) {
return releaseFolder, nil
case config.MainMode, config.DownstreamMode:
return mainFolder, nil
case config.ReleaseBranchMode:
case config.ReleaseBranchMode, config.CloudMode:
return releaseBranchFolder, nil
default:
// Corner case for custom enterprise2 mode
+3
View File
@@ -123,6 +123,7 @@ func CheckDroneTargetBranch() (VersionMode, error) {
func CheckSemverSuffix() (ReleaseMode, error) {
reBetaRls := regexp.MustCompile(`beta.*`)
reTestRls := regexp.MustCompile(`test.*`)
reCloudRls := regexp.MustCompile(`cloud.*`)
tagSuffix, ok := os.LookupEnv("DRONE_SEMVER_PRERELEASE")
if !ok || tagSuffix == "" {
fmt.Println("DRONE_SEMVER_PRERELEASE doesn't exist for a tag, this is a release event...")
@@ -133,6 +134,8 @@ func CheckSemverSuffix() (ReleaseMode, error) {
return ReleaseMode{Mode: TagMode, IsBeta: true}, nil
case reTestRls.MatchString(tagSuffix):
return ReleaseMode{Mode: TagMode, IsTest: true}, nil
case reCloudRls.MatchString(tagSuffix):
return ReleaseMode{Mode: CloudMode}, nil
default:
fmt.Printf("DRONE_SEMVER_PRERELEASE is custom string, release event with %s suffix\n", tagSuffix)
return ReleaseMode{Mode: TagMode}, nil
+1
View File
@@ -11,6 +11,7 @@ const (
DownstreamMode VersionMode = "downstream"
Enterprise2Mode VersionMode = "enterprise2"
CronjobMode VersionMode = "cron"
CloudMode VersionMode = "cloud"
)
const (
+27
View File
@@ -204,4 +204,31 @@ var Versions = VersionMap{
StorybookSrcDir: "artifacts/storybook",
},
},
CloudMode: {
Variants: []Variant{
VariantLinuxAmd64Musl,
},
PluginSignature: PluginSignature{
Sign: true,
AdminSign: true,
},
Docker: Docker{
ShouldSave: true,
Architectures: []Architecture{
ArchAMD64,
},
Distribution: []Distribution{
Alpine,
},
PrereleaseBucket: "grafana-prerelease/artifacts/docker",
},
Buckets: Buckets{
Artifacts: "grafana-prerelease/artifacts/downloads",
ArtifactsEnterprise2: "grafana-prerelease/artifacts/downloads-enterprise2",
CDNAssets: "grafana-prerelease",
CDNAssetsDir: "artifacts/static-assets",
Storybook: "grafana-prerelease",
StorybookSrcDir: "artifacts/storybook",
},
},
}
+23 -21
View File
@@ -184,30 +184,32 @@ func signRPMPackages(edition config.Edition, cfg config.Config, grafanaDir strin
return err
}
rpmArgs := append([]string{"--addsign"}, rpms...)
log.Printf("Invoking rpm with args: %+v", rpmArgs)
//nolint:gosec
cmd := exec.Command("rpm", rpmArgs...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to sign RPM packages: %s", output)
}
if err := os.Remove(cfg.GPGPassPath); err != nil {
return fmt.Errorf("failed to remove %q: %w", cfg.GPGPassPath, err)
}
log.Printf("Verifying %s RPM packages...", edition)
// The output changed between rpm versions
reOutput := regexp.MustCompile("(?:digests signatures OK)|(?:pgp.+OK)")
for _, p := range rpms {
if len(rpms) > 0 {
rpmArgs := append([]string{"--addsign"}, rpms...)
log.Printf("Invoking rpm with args: %+v", rpmArgs)
//nolint:gosec
cmd := exec.Command("rpm", "-K", p)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to verify RPM signature: %w", err)
cmd := exec.Command("rpm", rpmArgs...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to sign RPM packages: %s", output)
}
if err := os.Remove(cfg.GPGPassPath); err != nil {
return fmt.Errorf("failed to remove %q: %w", cfg.GPGPassPath, err)
}
if !reOutput.Match(output) {
return fmt.Errorf("RPM package %q not verified: %s", p, output)
log.Printf("Verifying %s RPM packages...", edition)
// The output changed between rpm versions
reOutput := regexp.MustCompile("(?:digests signatures OK)|(?:pgp.+OK)")
for _, p := range rpms {
//nolint:gosec
cmd := exec.Command("rpm", "-K", p)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to verify RPM signature: %w", err)
}
if !reOutput.Match(output) {
return fmt.Errorf("RPM package %q not verified: %s", p, output)
}
}
}
+14
View File
@@ -122,6 +122,9 @@ var (
// MRenderingSummary is a metric summary for image rendering request duration
MRenderingSummary *prometheus.SummaryVec
// MRenderingUserLookupSummary is a metric summary for image rendering user lookup duration
MRenderingUserLookupSummary *prometheus.SummaryVec
// MAccessPermissionsSummary is a metric summary for loading permissions request duration when evaluating access
MAccessPermissionsSummary prometheus.Histogram
@@ -392,6 +395,16 @@ func init() {
[]string{"status", "type"},
)
MRenderingUserLookupSummary = prometheus.NewSummaryVec(
prometheus.SummaryOpts{
Name: "rendering_user_lookup_duration_milliseconds",
Help: "summary of rendering user lookup duration",
Objectives: objectiveMap,
Namespace: ExporterName,
},
[]string{"success", "from"},
)
MRenderingQueue = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "rendering_queue_size",
Help: "size of rendering queue",
@@ -663,6 +676,7 @@ func initMetricVars() {
LDAPUsersSyncExecutionTime,
MRenderingRequestTotal,
MRenderingSummary,
MRenderingUserLookupSummary,
MRenderingQueue,
MAccessPermissionsSummary,
MAccessEvaluationsSummary,
@@ -2,7 +2,6 @@ package statscollector
import (
"context"
"fmt"
"strings"
"time"
@@ -74,7 +73,6 @@ func ProvideService(
s.collectConcurrentUsers,
s.collectDatasourceStats,
s.collectDatasourceAccess,
s.collectElasticStats,
s.collectAlertNotifierStats,
s.collectPrometheusFlavors,
s.collectAdditionalMetrics,
@@ -240,28 +238,6 @@ func (s *Service) collectDatasourceStats(ctx context.Context) (map[string]interf
return m, nil
}
func (s *Service) collectElasticStats(ctx context.Context) (map[string]interface{}, error) {
m := map[string]interface{}{}
esDataSourcesQuery := datasources.GetDataSourcesByTypeQuery{Type: datasources.DS_ES}
dataSources, err := s.datasources.GetDataSourcesByType(ctx, &esDataSourcesQuery)
if err != nil {
s.log.Error("Failed to get elasticsearch json data", "error", err)
return nil, err
}
for _, data := range dataSources {
esVersion, err := data.JsonData.Get("esVersion").String()
if err != nil {
continue
}
statName := fmt.Sprintf("stats.ds.elasticsearch.v%s.count", strings.ReplaceAll(esVersion, ".", "_"))
count, _ := m[statName].(int64)
m[statName] = count + 1
}
return m, nil
}
func (s *Service) collectDatasourceAccess(ctx context.Context) (map[string]interface{}, error) {
m := map[string]interface{}{}
@@ -128,19 +128,13 @@ func TestCollectingUsageStats(t *testing.T) {
statsService := statstest.NewFakeService()
expectedDataSources := []*datasources.DataSource{
{
JsonData: simplejson.NewFromAny(map[string]interface{}{
"esVersion": "2.0.0",
}),
JsonData: simplejson.NewFromAny(map[string]interface{}{}),
},
{
JsonData: simplejson.NewFromAny(map[string]interface{}{
"esVersion": "2.0.0",
}),
JsonData: simplejson.NewFromAny(map[string]interface{}{}),
},
{
JsonData: simplejson.NewFromAny(map[string]interface{}{
"esVersion": "70.1.1",
}),
JsonData: simplejson.NewFromAny(map[string]interface{}{}),
},
}
@@ -187,46 +181,6 @@ func TestCollectingUsageStats(t *testing.T) {
assert.InDelta(t, int64(65), metrics["stats.uptime"], 6)
}
func TestElasticStats(t *testing.T) {
sqlStore := dbtest.NewFakeDB()
statsService := statstest.NewFakeService()
expectedDataSources := []*datasources.DataSource{
{
JsonData: simplejson.NewFromAny(map[string]interface{}{
"esVersion": "2.0.0",
}),
},
{
JsonData: simplejson.NewFromAny(map[string]interface{}{
"esVersion": "2.0.0",
}),
},
{
JsonData: simplejson.NewFromAny(map[string]interface{}{
"esVersion": "70.1.1",
}),
},
}
s := createService(t, &setting.Cfg{
ReportingEnabled: true,
BuildVersion: "5.0.0",
AnonymousEnabled: true,
BasicAuthEnabled: true,
LDAPAuthEnabled: true,
AuthProxyEnabled: true,
Packaging: "deb",
ReportingDistributor: "hosted-grafana",
}, sqlStore, statsService,
withDatasources(mockDatasourceService{datasources: expectedDataSources}))
metrics, err := s.collectElasticStats(context.Background())
require.NoError(t, err)
assert.EqualValues(t, 2, metrics["stats.ds."+datasources.DS_ES+".v2_0_0.count"])
assert.EqualValues(t, 1, metrics["stats.ds."+datasources.DS_ES+".v70_1_1.count"])
}
func TestDatasourceStats(t *testing.T) {
sqlStore := dbtest.NewFakeDB()
statsService := statstest.NewFakeService()
@@ -253,24 +207,6 @@ func TestDatasourceStats(t *testing.T) {
},
}
_ = []*datasources.DataSource{
{
JsonData: simplejson.NewFromAny(map[string]interface{}{
"esVersion": 2,
}),
},
{
JsonData: simplejson.NewFromAny(map[string]interface{}{
"esVersion": 2,
}),
},
{
JsonData: simplejson.NewFromAny(map[string]interface{}{
"esVersion": 70,
}),
},
}
statsService.ExpectedDataSourcesAccessStats = []*stats.DataSourceAccessStats{
{
Type: datasources.DS_ES,
@@ -51,9 +51,6 @@ func (i *Initializer) envVars(plugin *plugins.Plugin) []string {
hostEnv := []string{
fmt.Sprintf("GF_VERSION=%s", i.cfg.BuildVersion),
}
if plugin.Info.Version != "" {
hostEnv = append(hostEnv, fmt.Sprintf("GF_PLUGIN_VERSION=%s", plugin.Info.Version))
}
if i.license != nil {
hostEnv = append(
@@ -69,11 +66,14 @@ func (i *Initializer) envVars(plugin *plugins.Plugin) []string {
hostEnv = append(hostEnv, azsettings.WriteToEnvStr(i.cfg.Azure)...)
// Tracing
pluginTracingEnabled := true
var pluginTracingEnabled bool
if v, exists := i.cfg.PluginSettings[plugin.ID]["tracing"]; exists {
pluginTracingEnabled = v != "false"
pluginTracingEnabled = v == "true"
}
if i.cfg.Opentelemetry.IsEnabled() && pluginTracingEnabled {
if plugin.Info.Version != "" {
hostEnv = append(hostEnv, fmt.Sprintf("GF_PLUGIN_VERSION=%s", plugin.Info.Version))
}
hostEnv = append(
hostEnv,
fmt.Sprintf("GF_INSTANCE_OTLP_ADDRESS=%s", i.cfg.Opentelemetry.Address),
@@ -132,53 +132,6 @@ func TestInitializer_Initialize(t *testing.T) {
}
func TestInitializer_envVars(t *testing.T) {
t.Run("version", func(t *testing.T) {
for _, tc := range []struct {
name string
setup func(p *plugins.Plugin)
exp func(t *testing.T, i *Initializer, p *plugins.Plugin)
}{
{
name: "not provided",
setup: func(p *plugins.Plugin) {
p.Info = plugins.Info{}
},
exp: func(t *testing.T, i *Initializer, p *plugins.Plugin) {
for _, k := range i.envVars(p) {
if strings.HasPrefix("GF_PLUGIN_VERSION=", k) {
require.Fail(t, "found unexpected env var GF_PLUGIN_VERSION")
}
}
},
},
{
name: "provided",
setup: func(p *plugins.Plugin) {
p.Info = plugins.Info{Version: "0.1"}
},
exp: func(t *testing.T, i *Initializer, p *plugins.Plugin) {
require.Contains(t, i.envVars(p), "GF_PLUGIN_VERSION=0.1")
},
},
} {
t.Run(tc.name, func(t *testing.T) {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: "test",
Info: plugins.Info{Version: "0.1"},
},
}
tc.setup(p)
i := &Initializer{
cfg: &config.Cfg{},
log: log.NewTestLogger(),
backendProvider: &fakeBackendProvider{plugin: p},
}
tc.exp(t, i, p)
})
}
})
t.Run("backend datasource with license", func(t *testing.T) {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
@@ -222,7 +175,13 @@ func TestInitializer_envVars(t *testing.T) {
func TestInitializer_tracingEnvironmentVariables(t *testing.T) {
const pluginID = "plugin_id"
p := &plugins.Plugin{
defaultPlugin := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: pluginID,
Info: plugins.Info{Version: "1.0.0"},
},
}
pluginWithoutVersion := &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
}
@@ -233,9 +192,9 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) {
expDefaultOtlp := func(t *testing.T, envVars []string) {
found := map[string]bool{
"address": false,
"version": false,
"propagation": false,
"address": false,
"plugin_version": false,
"propagation": false,
}
setFound := func(v string) {
require.False(t, found[v], "duplicate env var found")
@@ -243,8 +202,8 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) {
}
for _, v := range envVars {
switch v {
case "GF_VERSION=":
setFound("version")
case "GF_PLUGIN_VERSION=1.0.0":
setFound("plugin_version")
case "GF_INSTANCE_OTLP_ADDRESS=127.0.0.1:4317":
setFound("address")
case "GF_INSTANCE_OTLP_PROPAGATION=":
@@ -252,48 +211,109 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) {
}
}
for k, f := range found {
require.Truef(t, f, "%q env var not found", k)
require.Truef(t, f, "%q env var not found: %+v", k, envVars)
}
}
expNoTracing := func(t *testing.T, envVars []string) {
for _, v := range envVars {
assert.False(t, strings.HasPrefix(v, "GF_TRACING"), "should not have tracing env var")
assert.False(
t,
strings.HasPrefix(v, "GF_PLUGIN_VERSION"),
"GF_PLUGIN_VERSION is tracing-only and should not be present when tracing is disabled",
)
}
}
expGfPluginVersionNotPresent := func(t *testing.T, envVars []string) {
for _, e := range envVars {
assert.False(t, strings.HasPrefix("GF_PLUGIN_VERSION=", e), "GF_PLUGIN_VERSION shouldn't be present")
}
}
expGfPluginVersionPresent := func(t *testing.T, envVars []string) {
var found bool
for _, e := range envVars {
if e != "GF_PLUGIN_VERSION=1.0.0" {
continue
}
assert.False(t, found, "GF_PLUGIN_VERSION is present multiple times")
found = true
}
assert.Truef(t, found, "GF_PLUGIN_VERSION is not present: %+v", envVars)
}
for _, tc := range []struct {
name string
cfg *config.Cfg
exp func(t *testing.T, envVars []string)
name string
cfg *config.Cfg
plugin *plugins.Plugin
exp func(t *testing.T, envVars []string)
}{
{
name: "disabled",
name: "otel not configured",
cfg: &config.Cfg{
Opentelemetry: config.OpentelemetryCfg{},
},
exp: expNoTracing,
plugin: defaultPlugin,
exp: expNoTracing,
},
{
name: "otlp no propagation",
name: "otel not configured but plugin-tracing enabled",
cfg: &config.Cfg{
Opentelemetry: config.OpentelemetryCfg{},
PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}},
},
plugin: defaultPlugin,
exp: expNoTracing,
},
{
name: "otlp no propagation plugin enabled",
cfg: &config.Cfg{
Opentelemetry: defaultOtelCfg,
PluginSettings: map[string]map[string]string{
pluginID: {"tracing": "true"},
},
},
plugin: defaultPlugin,
exp: expDefaultOtlp,
},
{
name: "otlp no propagation disabled by default",
cfg: &config.Cfg{
Opentelemetry: defaultOtelCfg,
},
exp: expDefaultOtlp,
plugin: defaultPlugin,
exp: expNoTracing,
},
{
name: "otlp propagation",
name: "otlp propagation plugin enabled",
cfg: &config.Cfg{
Opentelemetry: config.OpentelemetryCfg{
Address: "127.0.0.1:4317",
Propagation: "w3c",
},
PluginSettings: map[string]map[string]string{
pluginID: {"tracing": "true"},
},
},
plugin: defaultPlugin,
exp: func(t *testing.T, envVars []string) {
assert.Len(t, envVars, 5)
assert.Equal(t, "GF_PLUGIN_TRACING=true", envVars[0])
assert.Equal(t, "GF_VERSION=", envVars[1])
assert.Equal(t, "GF_PLUGIN_VERSION=1.0.0", envVars[2])
assert.Equal(t, "GF_INSTANCE_OTLP_ADDRESS=127.0.0.1:4317", envVars[3])
assert.Equal(t, "GF_INSTANCE_OTLP_PROPAGATION=w3c", envVars[4])
},
},
{
name: "otlp no propagation disabled by default",
cfg: &config.Cfg{
Opentelemetry: config.OpentelemetryCfg{
Address: "127.0.0.1:4317",
Propagation: "w3c",
},
},
exp: func(t *testing.T, envVars []string) {
assert.Len(t, envVars, 3)
assert.Equal(t, "GF_VERSION=", envVars[0])
assert.Equal(t, "GF_INSTANCE_OTLP_ADDRESS=127.0.0.1:4317", envVars[1])
assert.Equal(t, "GF_INSTANCE_OTLP_PROPAGATION=w3c", envVars[2])
},
plugin: defaultPlugin,
exp: expNoTracing,
},
{
name: "disabled on plugin",
@@ -303,17 +323,57 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) {
pluginID: map[string]string{"tracing": "false"},
},
},
exp: expNoTracing,
plugin: defaultPlugin,
exp: expNoTracing,
},
{
name: "not disabled on plugin with other plugin settings",
name: "disabled on plugin with other plugin settings",
cfg: &config.Cfg{
Opentelemetry: defaultOtelCfg,
PluginSettings: map[string]map[string]string{
pluginID: {"some_other_option": "true"},
},
},
exp: expDefaultOtlp,
plugin: defaultPlugin,
exp: expNoTracing,
},
{
name: "enabled on plugin with other plugin settings",
cfg: &config.Cfg{
Opentelemetry: defaultOtelCfg,
PluginSettings: map[string]map[string]string{
pluginID: {"some_other_option": "true", "tracing": "true"},
},
},
plugin: defaultPlugin,
exp: expDefaultOtlp,
},
{
name: "GF_PLUGIN_VERSION is not present if tracing is disabled",
cfg: &config.Cfg{
Opentelemetry: config.OpentelemetryCfg{}, // no OTEL config
PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}},
},
plugin: defaultPlugin,
exp: expGfPluginVersionNotPresent,
},
{
name: "GF_PLUGIN_VERSION is present if tracing is enabled and plugin has version",
cfg: &config.Cfg{
Opentelemetry: defaultOtelCfg,
PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}},
},
plugin: defaultPlugin,
exp: expGfPluginVersionPresent,
},
{
name: "GF_PLUGIN_VERSION is not present if tracing is enabled but plugin doesn't have a version",
cfg: &config.Cfg{
Opentelemetry: config.OpentelemetryCfg{},
PluginSettings: map[string]map[string]string{pluginID: {"tracing": "true"}},
},
plugin: pluginWithoutVersion,
exp: expGfPluginVersionNotPresent,
},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -321,7 +381,7 @@ func TestInitializer_tracingEnvironmentVariables(t *testing.T) {
cfg: tc.cfg,
log: log.NewTestLogger(),
}
envVars := i.envVars(p)
envVars := i.envVars(tc.plugin)
tc.exp(t, envVars)
})
}
@@ -46,8 +46,6 @@ const (
InvalidAPIKey = "invalid API key"
)
const ServiceName = "ContextHandler"
func ProvideService(cfg *setting.Cfg, tokenService auth.UserTokenService, jwtService jwt.JWTService,
remoteCache *remotecache.RemoteCache, renderService rendering.Service, sqlStore db.DB,
tracer tracing.Tracer, authProxy *authproxy.AuthProxy, loginService login.Service,
+17 -2
View File
@@ -274,8 +274,9 @@ var (
{
Name: "newPanelChromeUI",
Description: "Show updated look and feel of grafana-ui PanelChrome: panel header, icons, and menu",
State: FeatureStateAlpha,
State: FeatureStateStable,
FrontendOnly: true,
Expression: "true", // enabled by default
Owner: grafanaDashboardsSquad,
},
{
@@ -324,8 +325,9 @@ var (
{
Name: "emptyDashboardPage",
Description: "Enable the redesigned user interface of a dashboard page that includes no panels",
State: FeatureStateAlpha,
State: FeatureStateStable,
FrontendOnly: true,
Expression: "true", // enabled by default
Owner: grafanaDashboardsSquad,
},
{
@@ -436,6 +438,13 @@ var (
FrontendOnly: true,
Owner: appO11ySquad,
},
{
Name: "prometheusResourceBrowserCache",
Description: "Displays browser caching options in Prometheus data source configuration",
State: FeatureStateAlpha,
FrontendOnly: true,
Owner: grafanaObservabilityMetricsSquad,
},
{
Name: "influxdbBackendMigration",
Description: "Query InfluxDB InfluxQL without the proxy",
@@ -485,5 +494,11 @@ var (
State: FeatureStateAlpha,
Owner: grafanaBackendPlatformSquad,
},
{
Name: "renderAuthJWT",
Description: "Uses JWT-based auth for rendering instead of relying on remote cache",
State: FeatureStateBeta,
Owner: grafanaAsCodeSquad,
},
}
)
+4 -2
View File
@@ -39,7 +39,7 @@ entityStore,alpha,@grafana/grafana-app-platform-squad,true,false,false,false
cloudWatchCrossAccountQuerying,stable,@grafana/aws-plugins,false,false,false,false
redshiftAsyncQueryDataSupport,alpha,@grafana/aws-plugins,false,false,false,true
athenaAsyncQueryDataSupport,alpha,@grafana/aws-plugins,false,false,false,true
newPanelChromeUI,alpha,@grafana/dashboards-squad,false,false,false,true
newPanelChromeUI,stable,@grafana/dashboards-squad,false,false,false,true
showDashboardValidationWarnings,alpha,@grafana/dashboards-squad,false,false,false,false
mysqlAnsiQuotes,alpha,@grafana/backend-platform,false,false,false,false
accessControlOnCall,beta,@grafana/grafana-authnz-team,false,false,false,false
@@ -47,7 +47,7 @@ nestedFolders,alpha,@grafana/backend-platform,true,false,false,false
accessTokenExpirationCheck,stable,@grafana/grafana-authnz-team,false,false,false,false
elasticsearchBackendMigration,alpha,@grafana/observability-logs,false,false,false,false
datasourceOnboarding,alpha,@grafana/dashboards-squad,false,false,false,false
emptyDashboardPage,alpha,@grafana/dashboards-squad,false,false,false,true
emptyDashboardPage,stable,@grafana/dashboards-squad,false,false,false,true
secureSocksDatasourceProxy,alpha,@grafana/hosted-grafana-team,false,false,false,false
authnService,alpha,@grafana/grafana-authnz-team,false,false,false,false
disablePrometheusExemplarSampling,stable,@grafana/observability-metrics,false,false,false,false
@@ -64,6 +64,7 @@ drawerDataSourcePicker,alpha,@grafana/grafana-bi-squad,false,false,false,true
traceqlSearch,alpha,@grafana/observability-traces-and-profiling,false,false,false,true
prometheusMetricEncyclopedia,alpha,@grafana/observability-metrics,false,false,false,true
timeSeriesTable,alpha,@grafana/app-o11y,false,false,false,true
prometheusResourceBrowserCache,alpha,@grafana/observability-metrics,false,false,false,true
influxdbBackendMigration,alpha,@grafana/observability-metrics,false,false,false,true
clientTokenRotation,alpha,@grafana/grafana-authnz-team,false,false,false,false
disableElasticsearchBackendExploreQuery,beta,@grafana/observability-logs,false,false,false,false
@@ -72,3 +73,4 @@ alertStateHistoryLokiSecondary,alpha,@grafana/alerting-squad,false,false,false,f
alertStateHistoryLokiPrimary,alpha,@grafana/alerting-squad,false,false,false,false
alertStateHistoryLokiOnly,alpha,@grafana/alerting-squad,false,false,false,false
unifiedRequestLog,alpha,@grafana/backend-platform,false,false,false,false
renderAuthJWT,beta,@grafana/grafana-as-code,false,false,false,false
1 Name State Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
39 cloudWatchCrossAccountQuerying stable @grafana/aws-plugins false false false false
40 redshiftAsyncQueryDataSupport alpha @grafana/aws-plugins false false false true
41 athenaAsyncQueryDataSupport alpha @grafana/aws-plugins false false false true
42 newPanelChromeUI alpha stable @grafana/dashboards-squad false false false true
43 showDashboardValidationWarnings alpha @grafana/dashboards-squad false false false false
44 mysqlAnsiQuotes alpha @grafana/backend-platform false false false false
45 accessControlOnCall beta @grafana/grafana-authnz-team false false false false
47 accessTokenExpirationCheck stable @grafana/grafana-authnz-team false false false false
48 elasticsearchBackendMigration alpha @grafana/observability-logs false false false false
49 datasourceOnboarding alpha @grafana/dashboards-squad false false false false
50 emptyDashboardPage alpha stable @grafana/dashboards-squad false false false true
51 secureSocksDatasourceProxy alpha @grafana/hosted-grafana-team false false false false
52 authnService alpha @grafana/grafana-authnz-team false false false false
53 disablePrometheusExemplarSampling stable @grafana/observability-metrics false false false false
64 traceqlSearch alpha @grafana/observability-traces-and-profiling false false false true
65 prometheusMetricEncyclopedia alpha @grafana/observability-metrics false false false true
66 timeSeriesTable alpha @grafana/app-o11y false false false true
67 prometheusResourceBrowserCache alpha @grafana/observability-metrics false false false true
68 influxdbBackendMigration alpha @grafana/observability-metrics false false false true
69 clientTokenRotation alpha @grafana/grafana-authnz-team false false false false
70 disableElasticsearchBackendExploreQuery beta @grafana/observability-logs false false false false
73 alertStateHistoryLokiPrimary alpha @grafana/alerting-squad false false false false
74 alertStateHistoryLokiOnly alpha @grafana/alerting-squad false false false false
75 unifiedRequestLog alpha @grafana/backend-platform false false false false
76 renderAuthJWT beta @grafana/grafana-as-code false false false false
+8
View File
@@ -267,6 +267,10 @@ const (
// Enable time series table transformer &amp; sparkline cell type
FlagTimeSeriesTable = "timeSeriesTable"
// FlagPrometheusResourceBrowserCache
// Displays browser caching options in Prometheus data source configuration
FlagPrometheusResourceBrowserCache = "prometheusResourceBrowserCache"
// FlagInfluxdbBackendMigration
// Query InfluxDB InfluxQL without the proxy
FlagInfluxdbBackendMigration = "influxdbBackendMigration"
@@ -298,4 +302,8 @@ const (
// FlagUnifiedRequestLog
// Writes error logs to the request logger
FlagUnifiedRequestLog = "unifiedRequestLog"
// FlagRenderAuthJWT
// Uses JWT-based auth for rendering instead of relying on remote cache
FlagRenderAuthJWT = "renderAuthJWT"
)
+5 -1
View File
@@ -543,10 +543,14 @@ func (s *Service) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*fol
}
}
newParentUID := ""
if cmd.NewParentUID != "" {
newParentUID = cmd.NewParentUID
}
return s.store.Update(ctx, folder.UpdateFolderCommand{
UID: cmd.UID,
OrgID: cmd.OrgID,
NewParentUID: &cmd.NewParentUID,
NewParentUID: &newParentUID,
SignedInUser: cmd.SignedInUser,
})
}
+10 -2
View File
@@ -92,6 +92,10 @@ func (ss *sqlStore) Update(ctx context.Context, cmd folder.UpdateFolderCommand)
uid := cmd.UID
var foldr *folder.Folder
if cmd.NewDescription == nil && cmd.NewTitle == nil && cmd.NewUID == nil && cmd.NewParentUID == nil {
return nil, folder.ErrBadRequest.Errorf("nothing to update")
}
err := ss.db.WithDbSession(ctx, func(sess *db.Session) error {
sql := strings.Builder{}
sql.Write([]byte("UPDATE folder SET "))
@@ -114,8 +118,12 @@ func (ss *sqlStore) Update(ctx context.Context, cmd folder.UpdateFolderCommand)
}
if cmd.NewParentUID != nil {
columnsToUpdate = append(columnsToUpdate, "parent_uid = ?")
args = append(args, *cmd.NewParentUID)
if *cmd.NewParentUID == "" {
columnsToUpdate = append(columnsToUpdate, "parent_uid = NULL")
} else {
columnsToUpdate = append(columnsToUpdate, "parent_uid = ?")
args = append(args, *cmd.NewParentUID)
}
}
if len(columnsToUpdate) == 0 {
+101 -10
View File
@@ -27,7 +27,7 @@ func TestIntegrationCreate(t *testing.T) {
}
db := sqlstore.InitTestDB(t)
folderStore := ProvideStore(db, db.Cfg, &featuremgmt.FeatureManager{})
folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders))
orgID := CreateOrg(t, db)
@@ -142,7 +142,7 @@ func TestIntegrationDelete(t *testing.T) {
}
db := sqlstore.InitTestDB(t)
folderStore := ProvideStore(db, db.Cfg, &featuremgmt.FeatureManager{})
folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders))
orgID := CreateOrg(t, db)
@@ -189,18 +189,30 @@ func TestIntegrationUpdate(t *testing.T) {
}
db := sqlstore.InitTestDB(t)
folderStore := ProvideStore(db, db.Cfg, &featuremgmt.FeatureManager{})
folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders))
orgID := CreateOrg(t, db)
// create folder
f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{
// create parent folder
parent, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{
Title: folderTitle,
Description: folderDsc,
OrgID: orgID,
UID: util.GenerateShortUID(),
})
require.NoError(t, err)
// create subfolder
f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{
Title: folderTitle,
Description: folderDsc,
OrgID: orgID,
UID: util.GenerateShortUID(),
ParentUID: parent.UID,
})
require.NoError(t, err)
require.Equal(t, f.ParentUID, parent.UID)
t.Cleanup(func() {
err := folderStore.Delete(context.Background(), f.UID, orgID)
require.NoError(t, err)
@@ -250,6 +262,7 @@ func TestIntegrationUpdate(t *testing.T) {
assert.Equal(t, f.UID, updated.UID)
assert.Equal(t, newTitle, updated.Title)
assert.Equal(t, newDesc, updated.Description)
assert.Equal(t, parent.UID, updated.ParentUID)
// assert.GreaterOrEqual(t, updated.Updated.UnixNano(), existingUpdated.UnixNano())
updated, err = folderStore.Get(context.Background(), folder.GetFolderQuery{
@@ -259,6 +272,8 @@ func TestIntegrationUpdate(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, newTitle, updated.Title)
assert.Equal(t, newDesc, updated.Description)
// parent should not change
assert.Equal(t, parent.UID, updated.ParentUID)
f = updated
})
@@ -285,6 +300,82 @@ func TestIntegrationUpdate(t *testing.T) {
assert.Equal(t, existingTitle, updated.Title)
assert.Equal(t, existingDesc, updated.Description)
})
t.Run("updating folder parent UID", func(t *testing.T) {
testCases := []struct {
desc string
reqNewParentUID *string
expectedError error
expectedParentUIDFunc func(existing string) string
}{
{
desc: "should succeed when moving to other folder",
reqNewParentUID: util.Pointer("new"),
expectedParentUIDFunc: func(_ string) string { return "new" },
},
{
desc: "should succeed when moving to root folder (NewParentUID is empty)",
reqNewParentUID: util.Pointer(""),
expectedParentUIDFunc: func(_ string) string { return "" },
},
{
desc: "should do nothing when NewParentUID is nil",
reqNewParentUID: nil,
expectedError: folder.ErrBadRequest,
expectedParentUIDFunc: func(existingParentUID string) string { return existingParentUID },
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
// create parent folder
parentUID := util.GenerateShortUID()
_, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{
Title: "parent",
Description: "parent",
OrgID: orgID,
UID: parentUID,
})
require.NoError(t, err)
// create subfolder
UID := util.GenerateShortUID()
f, err = folderStore.Create(context.Background(), folder.CreateFolderCommand{
Title: "subfolder",
Description: "subfolder",
OrgID: orgID,
UID: UID,
ParentUID: parentUID,
})
require.NoError(t, err)
existingTitle := f.Title
existingDesc := f.Description
existingUID := f.UID
updated, err := folderStore.Update(context.Background(), folder.UpdateFolderCommand{
UID: f.UID,
OrgID: f.OrgID,
NewParentUID: tc.reqNewParentUID,
})
if tc.expectedError == nil {
require.NoError(t, err)
assert.Equal(t, tc.expectedParentUIDFunc(parentUID), updated.ParentUID)
} else {
assert.ErrorIs(t, err, tc.expectedError)
}
updated, err = folderStore.Get(context.Background(), folder.GetFolderQuery{
UID: &f.UID,
OrgID: orgID,
})
require.NoError(t, err)
assert.Equal(t, tc.expectedParentUIDFunc(parentUID), updated.ParentUID)
assert.Equal(t, existingTitle, updated.Title)
assert.Equal(t, existingDesc, updated.Description)
assert.Equal(t, existingUID, updated.UID)
})
}
})
}
func TestIntegrationGet(t *testing.T) {
@@ -293,7 +384,7 @@ func TestIntegrationGet(t *testing.T) {
}
db := sqlstore.InitTestDB(t)
folderStore := ProvideStore(db, db.Cfg, &featuremgmt.FeatureManager{})
folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders))
orgID := CreateOrg(t, db)
@@ -371,7 +462,7 @@ func TestIntegrationGetParents(t *testing.T) {
}
db := sqlstore.InitTestDB(t)
folderStore := ProvideStore(db, db.Cfg, &featuremgmt.FeatureManager{})
folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders))
orgID := CreateOrg(t, db)
@@ -390,7 +481,7 @@ func TestIntegrationGetParents(t *testing.T) {
require.NoError(t, err)
})
t.Run("get parents of root folder should should be empty", func(t *testing.T) {
t.Run("get parents of root folder should be empty", func(t *testing.T) {
parents, err := folderStore.GetParents(context.Background(), folder.GetParentsQuery{})
require.NoError(t, err)
require.Empty(t, parents)
@@ -438,7 +529,7 @@ func TestIntegrationGetChildren(t *testing.T) {
}
db := sqlstore.InitTestDB(t)
folderStore := ProvideStore(db, db.Cfg, &featuremgmt.FeatureManager{})
folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders))
orgID := CreateOrg(t, db)
@@ -591,7 +682,7 @@ func TestIntegrationGetHeight(t *testing.T) {
}
db := sqlstore.InitTestDB(t)
folderStore := ProvideStore(db, db.Cfg, &featuremgmt.FeatureManager{})
folderStore := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders))
orgID := CreateOrg(t, db)
+4 -2
View File
@@ -14,8 +14,10 @@ type store interface {
// Delete deletes a folder from the folder store.
Delete(ctx context.Context, uid string, orgID int64) error
// Update updates the given folder's UID, Title, and Description.
// Use Move to change a dashboard's parent ID.
// Update updates the given folder's UID, Title, and Description (update mode).
// If the NewParentUID field is not nil, it updates also the parent UID (move mode).
// If it's a non empty string, it moves the folder under the folder with the specific UID
// otherwise, it moves the folder under the root folder (parent_uid column is set to NULL).
Update(ctx context.Context, cmd folder.UpdateFolderCommand) (*folder.Folder, error)
// Get returns a folder.
+94 -12
View File
@@ -5,10 +5,16 @@ import (
"context"
"encoding/gob"
"fmt"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/util"
)
@@ -20,19 +26,63 @@ type RenderUser struct {
OrgRole string `json:"org_role"`
}
type renderJWT struct {
RenderUser *RenderUser
jwt.RegisteredClaims
}
func (rs *RenderingService) GetRenderUser(ctx context.Context, key string) (*RenderUser, bool) {
val, err := rs.RemoteCacheService.Get(ctx, fmt.Sprintf(renderKeyPrefix, key))
if err != nil {
rs.log.Error("Failed to get render key from cache", "error", err)
}
ru := &RenderUser{}
buf := bytes.NewBuffer(val)
err = gob.NewDecoder(buf).Decode(&ru)
if err != nil {
return nil, false
var from string
start := time.Now()
var renderUser *RenderUser
if looksLikeJWT(key) && rs.features.IsEnabled(featuremgmt.FlagRenderAuthJWT) {
from = "jwt"
renderUser = rs.getRenderUserFromJWT(key)
} else {
from = "cache"
renderUser = rs.getRenderUserFromCache(ctx, key)
}
return ru, true
found := renderUser != nil
success := strconv.FormatBool(found)
metrics.MRenderingUserLookupSummary.WithLabelValues(success, from).Observe(float64(time.Since(start)))
return renderUser, found
}
func (rs *RenderingService) getRenderUserFromJWT(key string) *RenderUser {
claims := new(renderJWT)
tkn, err := jwt.ParseWithClaims(key, claims, func(_ *jwt.Token) (interface{}, error) {
return []byte(rs.Cfg.RendererAuthToken), nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS512.Alg()}))
if err != nil || !tkn.Valid {
rs.log.Error("Could not get render user from JWT", "err", err)
return nil
}
return claims.RenderUser
}
func (rs *RenderingService) getRenderUserFromCache(ctx context.Context, key string) *RenderUser {
val, err := rs.RemoteCacheService.Get(ctx, fmt.Sprintf(renderKeyPrefix, key))
if err != nil {
rs.log.Error("Could not get render user from remote cache", "err", err)
return nil
}
ru := new(RenderUser)
buf := bytes.NewBuffer(val)
err = gob.NewDecoder(buf).Decode(&ru)
if err != nil {
rs.log.Error("Could not decode render user from remote cache", "err", err)
return nil
}
return ru
}
func setRenderKey(cache *remotecache.RemoteCache, ctx context.Context, opts AuthOpts, renderKey string, expiry time.Duration) error {
@@ -103,7 +153,7 @@ func (r *perRequestRenderKeyProvider) get(ctx context.Context, opts AuthOpts) (s
return generateAndSetRenderKey(r.cache, ctx, opts, r.keyExpiry)
}
func (r *perRequestRenderKeyProvider) afterRequest(ctx context.Context, opts AuthOpts, renderKey string) {
func (r *perRequestRenderKeyProvider) afterRequest(ctx context.Context, _ AuthOpts, renderKey string) {
deleteRenderKey(r.cache, r.log, ctx, renderKey)
}
@@ -117,7 +167,7 @@ func (r *longLivedRenderKeyProvider) get(ctx context.Context, opts AuthOpts) (st
return r.renderKey, nil
}
func (r *longLivedRenderKeyProvider) afterRequest(ctx context.Context, opts AuthOpts, renderKey string) {
func (r *longLivedRenderKeyProvider) afterRequest(_ context.Context, _ AuthOpts, _ string) {
// do nothing - renderKey from longLivedRenderKeyProvider is deleted only after session expires
// or someone calls session.Dispose()
}
@@ -125,3 +175,35 @@ func (r *longLivedRenderKeyProvider) afterRequest(ctx context.Context, opts Auth
func (r *longLivedRenderKeyProvider) Dispose(ctx context.Context) {
deleteRenderKey(r.cache, r.log, ctx, r.renderKey)
}
type jwtRenderKeyProvider struct {
log log.Logger
authToken []byte
keyExpiry time.Duration
}
func (j *jwtRenderKeyProvider) get(_ context.Context, opts AuthOpts) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS512, j.buildJWTClaims(opts))
return token.SignedString(j.authToken)
}
func (j *jwtRenderKeyProvider) buildJWTClaims(opts AuthOpts) renderJWT {
return renderJWT{
RenderUser: &RenderUser{
OrgID: opts.OrgID,
UserID: opts.UserID,
OrgRole: string(opts.OrgRole),
},
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().UTC().Add(j.keyExpiry)),
},
}
}
func (j *jwtRenderKeyProvider) afterRequest(_ context.Context, _ AuthOpts, _ string) {
// do nothing - the JWT will just expire
}
func looksLikeJWT(key string) bool {
return strings.HasPrefix(key, "eyJ")
}
+18 -6
View File
@@ -19,14 +19,13 @@ import (
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
var _ Service = (*RenderingService)(nil)
const ServiceName = "RenderingService"
type RenderingService struct {
log log.Logger
pluginInfo *plugins.Plugin
@@ -42,11 +41,12 @@ type RenderingService struct {
perRequestRenderKeyProvider renderKeyProvider
Cfg *setting.Cfg
features *featuremgmt.FeatureManager
RemoteCacheService *remotecache.RemoteCache
RendererPluginManager plugins.RendererManager
}
func ProvideService(cfg *setting.Cfg, remoteCache *remotecache.RemoteCache, rm plugins.RendererManager) (*RenderingService, error) {
func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureManager, remoteCache *remotecache.RemoteCache, rm plugins.RendererManager) (*RenderingService, error) {
// ensure ImagesDir exists
err := os.MkdirAll(cfg.ImagesDir, 0700)
if err != nil {
@@ -83,12 +83,23 @@ func ProvideService(cfg *setting.Cfg, remoteCache *remotecache.RemoteCache, rm p
domain = "localhost"
}
s := &RenderingService{
perRequestRenderKeyProvider: &perRequestRenderKeyProvider{
var renderKeyProvider renderKeyProvider
if features.IsEnabled(featuremgmt.FlagRenderAuthJWT) {
renderKeyProvider = &jwtRenderKeyProvider{
log: logger,
authToken: []byte(cfg.RendererAuthToken),
keyExpiry: cfg.RendererRenderKeyLifeTime,
}
} else {
renderKeyProvider = &perRequestRenderKeyProvider{
cache: remoteCache,
log: logger,
keyExpiry: cfg.RendererRenderKeyLifeTime,
},
}
}
s := &RenderingService{
perRequestRenderKeyProvider: renderKeyProvider,
capabilities: []Capability{
{
name: FullHeightImages,
@@ -104,6 +115,7 @@ func ProvideService(cfg *setting.Cfg, remoteCache *remotecache.RemoteCache, rm p
},
},
Cfg: cfg,
features: features,
RemoteCacheService: remoteCache,
RendererPluginManager: rm,
log: logger,
@@ -47,7 +47,6 @@ func TestIntegrationElasticsearch(t *testing.T) {
jsonData := simplejson.NewFromAny(map[string]interface{}{
"httpMethod": "post",
"httpHeaderName1": "X-CUSTOM-HEADER",
"esVersion": "8.0.0",
"timeField": "@timestamp",
})
secureJSONData := map[string]string{
+6 -1
View File
@@ -7,7 +7,6 @@ import (
"net/http"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/tsdb/prometheus/client"
"github.com/grafana/grafana/pkg/tsdb/prometheus/utils"
@@ -47,6 +46,12 @@ func (r *Resource) Execute(ctx context.Context, req *backend.CallResourceRequest
return nil, fmt.Errorf("error querying resource: %v", err)
}
// frontend sets the X-Grafana-Cache with the desired response cache control value
if len(req.GetHTTPHeaders().Get("X-Grafana-Cache")) > 0 {
resp.Header.Set("X-Grafana-Cache", "y")
resp.Header.Set("Cache-Control", req.GetHTTPHeaders().Get("X-Grafana-Cache"))
}
defer func() {
tmpErr := resp.Body.Close()
if tmpErr != nil && err == nil {
+2 -22
View File
@@ -1,6 +1,7 @@
import { NavModel, NavModelItem } from '@grafana/data';
import { NavModelItem } from '@grafana/data';
import coreModule from 'app/angular/core_module';
import config from 'app/core/config';
import { getNotFoundNav } from 'app/core/navigation/errorModels';
interface Nav {
breadcrumbs: NavModelItem[];
@@ -55,25 +56,4 @@ export class NavModelSrv {
}
}
export function getExceptionNav(error: any): NavModel {
console.error(error);
return getWarningNav('Exception thrown', 'See console for details');
}
export function getNotFoundNav(): NavModel {
return getWarningNav('Page not found', '404 Error');
}
export function getWarningNav(text: string, subTitle?: string): NavModel {
const node: NavModelItem = {
text,
subTitle,
icon: 'exclamation-triangle',
};
return {
node: node,
main: node,
};
}
coreModule.service('navModelSrv', NavModelSrv);
+7 -4
View File
@@ -33,8 +33,9 @@ import {
setQueryRunnerFactory,
setRunRequest,
setPluginImportUtils,
setPluginsExtensionRegistry,
setPluginExtensionGetter,
setAppEvents,
type GetPluginExtensions,
} from '@grafana/runtime';
import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView';
import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer';
@@ -72,7 +73,8 @@ import { getTimeSrv } from './features/dashboard/services/TimeSrv';
import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView';
import { PanelRenderer } from './features/panel/components/PanelRenderer';
import { DatasourceSrv } from './features/plugins/datasource_srv';
import { createPluginExtensionRegistry } from './features/plugins/extensions/registryFactory';
import { createPluginExtensionRegistry } from './features/plugins/extensions/createPluginExtensionRegistry';
import { getPluginExtensions } from './features/plugins/extensions/getPluginExtensions';
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
import { preloadPlugins } from './features/plugins/pluginPreloader';
import { QueryRunner } from './features/query/state/QueryRunner';
@@ -187,8 +189,9 @@ export class GrafanaApp {
const preloadResults = await preloadPlugins(config.apps);
// Create extension registry out of the preloaded plugins
const extensionsRegistry = createPluginExtensionRegistry(preloadResults);
setPluginsExtensionRegistry(extensionsRegistry);
const pluginExtensionGetter: GetPluginExtensions = (options) =>
getPluginExtensions({ ...options, registry: createPluginExtensionRegistry(preloadResults) });
setPluginExtensionGetter(pluginExtensionGetter);
// initialize chrome service
const queryParams = locationService.getSearchObject();
+22
View File
@@ -0,0 +1,22 @@
import { NavModel, NavModelItem } from '@grafana/data';
export function getExceptionNav(error: unknown): NavModel {
console.error(error);
return getWarningNav('Exception thrown', 'See console for details');
}
export function getNotFoundNav(): NavModel {
return getWarningNav('Page not found', '404 Error');
}
export function getWarningNav(text: string, subTitle?: string): NavModel {
const node: NavModelItem = {
text,
subTitle,
icon: 'exclamation-triangle',
};
return {
node: node,
main: node,
};
}
@@ -1,4 +1,4 @@
import { findByText, findByTitle, render } from '@testing-library/react';
import { findByRole, findByText, findByTitle, getByTestId, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
@@ -107,8 +107,8 @@ describe('AnnotationsField', function () {
title: 'My dashboard',
uid: 'dash-test-uid',
panels: [
{ id: 1, title: 'First panel' },
{ id: 2, title: 'Second panel' },
{ id: 1, title: 'First panel', type: 'timeseries' },
{ id: 2, title: 'Second panel', type: 'timeseries' },
],
})
);
@@ -137,8 +137,8 @@ describe('AnnotationsField', function () {
title: 'My dashboard',
uid: 'dash-test-uid',
panels: [
{ id: 1, title: 'First panel' },
{ id: 2, title: 'Second panel' },
{ id: 1, title: 'First panel', type: 'graph' },
{ id: 2, title: 'Second panel', type: 'graph' },
],
})
);
@@ -186,8 +186,8 @@ describe('AnnotationsField', function () {
title: 'My dashboard',
uid: 'dash-test-uid',
panels: [
{ id: 1, title: 'First panel' },
{ id: 2, title: 'Second panel' },
{ id: 1, title: 'First panel', type: 'timeseries' },
{ id: 2, title: 'Second panel', type: 'timeseries' },
],
})
);
@@ -195,7 +195,7 @@ describe('AnnotationsField', function () {
mockDashboardDto({
title: 'My other dashboard',
uid: 'dash-other-uid',
panels: [{ id: 3, title: 'Third panel' }],
panels: [{ id: 3, title: 'Third panel', type: 'timeseries' }],
})
);
@@ -216,10 +216,12 @@ describe('AnnotationsField', function () {
expect(annotationValueElements[0]).toHaveTextContent('dash-test-uid');
expect(annotationValueElements[1]).toHaveTextContent('1');
const { confirmButton, dialog } = ui.dashboardPicker;
await user.click(ui.setDashboardButton.get());
await user.click(await findByTitle(ui.dashboardPicker.dialog.get(), 'My other dashboard'));
await user.click(await findByText(ui.dashboardPicker.dialog.get(), 'Third panel'));
await user.click(ui.dashboardPicker.confirmButton.get());
await user.click(await findByRole(dialog.get(), 'button', { name: /My other dashboard/ }));
await user.click(await findByRole(dialog.get(), 'button', { name: /Third panel/ }));
await user.click(confirmButton.get());
expect(ui.dashboardPicker.dialog.query()).not.toBeInTheDocument();
@@ -235,6 +237,36 @@ describe('AnnotationsField', function () {
expect(annotationKeyElements[1]).toHaveTextContent('Panel ID');
expect(annotationValueElements[1]).toHaveTextContent('3');
});
it('should render warning icon for panels of type other than graph and timeseries', async function () {
mockSearchApiResponse(server, [
mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }),
]);
mockGetDashboardResponse(
mockDashboardDto({
title: 'My dashboard',
uid: 'dash-test-uid',
panels: [
{ id: 1, title: 'First panel', type: 'bar' },
{ id: 2, title: 'Second panel', type: 'graph' },
],
})
);
const user = userEvent.setup();
render(<FormWrapper formValues={{ annotations: [] }} />);
const { dialog } = ui.dashboardPicker;
await user.click(ui.setDashboardButton.get());
await user.click(await findByTitle(dialog.get(), 'My dashboard'));
const warnedPanel = await findByRole(dialog.get(), 'button', { name: /First panel/ });
expect(getByTestId(warnedPanel, 'warning-icon')).toBeInTheDocument();
});
});
});
@@ -14,6 +14,7 @@ import {
Button,
Alert,
clearButtonStyles,
Tooltip,
} from '@grafana/ui';
import { dashboardApi } from '../../api/dashboardApi';
@@ -21,6 +22,7 @@ import { dashboardApi } from '../../api/dashboardApi';
export interface PanelDTO {
id: number;
title?: string;
type: string;
}
function panelSort(a: PanelDTO, b: PanelDTO) {
@@ -71,7 +73,7 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
const filteredPanels =
dashboardResult?.dashboard?.panels
?.filter((panel): panel is PanelDTO => typeof panel.id === 'number')
?.filter((panel): panel is PanelDTO => typeof panel.id === 'number' && typeof panel.type === 'string')
?.filter((panel) => panel.title?.toLowerCase().includes(panelFilter.toLowerCase()))
.sort(panelSort) ?? [];
@@ -115,7 +117,7 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
className={cx(styles.rowButton, { [styles.rowOdd]: index % 2 === 1, [styles.rowSelected]: isSelected })}
onClick={() => handleDashboardChange(dashboard.uid)}
>
<div className={styles.dashboardTitle}>{dashboard.title}</div>
<div className={cx(styles.dashboardTitle, styles.rowButtonTitle)}>{dashboard.title}</div>
<div className={styles.dashboardFolder}>
<Icon name="folder" /> {dashboard.folderTitle ?? 'General'}
</div>
@@ -125,16 +127,28 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
const PanelRow = ({ index, style }: { index: number; style: CSSProperties }) => {
const panel = filteredPanels[index];
const panelTitle = panel.title || '<No title>';
const isSelected = selectedPanelId === panel.id.toString();
const isAlertingCompatible = panel.type === 'graph' || panel.type === 'timeseries';
return (
<button
type="button"
style={style}
className={cx(styles.rowButton, { [styles.rowOdd]: index % 2 === 1, [styles.rowSelected]: isSelected })}
className={cx(styles.rowButton, styles.panelButton, {
[styles.rowOdd]: index % 2 === 1,
[styles.rowSelected]: isSelected,
})}
onClick={() => setSelectedPanelId(panel.id.toString())}
>
{panel.title || '<No title>'}
<div className={styles.rowButtonTitle} title={panelTitle}>
{panelTitle}
</div>
{!isAlertingCompatible && (
<Tooltip content="Alert tab will be disabled for this panel. It is only supported on graph and timeseries panels">
<Icon name="exclamation-triangle" className={styles.warnIcon} data-testid="warning-icon" />
</Tooltip>
)}
</button>
);
};
@@ -195,12 +209,16 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
</div>
<div className={styles.column}>
{!dashboardUid && !isDashboardFetching && <div>Select a dashboard to get a list of available panels</div>}
{!selectedDashboardUid && !isDashboardFetching && (
<div className={styles.selectDashboardPlaceholder}>
<div>Select a dashboard to get a list of available panels</div>
</div>
)}
{isDashboardFetching && (
<LoadingPlaceholder text="Loading dashboard..." className={styles.loadingPlaceholder} />
)}
{!isDashboardFetching && (
{selectedDashboardUid && !isDashboardFetching && (
<AutoSizer>
{({ width, height }) => (
<FixedSizeList itemSize={32} height={height} width={width} itemCount={filteredPanels.length}>
@@ -269,6 +287,15 @@ const getPickerStyles = (theme: GrafanaTheme2) => {
white-space: nowrap;
cursor: pointer;
border: 2px solid transparent;
&:disabled {
cursor: not-allowed;
color: ${theme.colors.text.disabled};
}
`,
rowButtonTitle: css`
text-overflow: ellipsis;
overflow: hidden;
`,
rowSelected: css`
border-color: ${theme.colors.primary.border};
@@ -276,12 +303,27 @@ const getPickerStyles = (theme: GrafanaTheme2) => {
rowOdd: css`
background-color: ${theme.colors.background.secondary};
`,
panelButton: css`
display: flex;
gap: ${theme.spacing(1)};
justify-content: space-between;
align-items: center;
`,
loadingPlaceholder: css`
height: 100%;
display: flex;
justify-content: center;
align-items: center;
`,
selectDashboardPlaceholder: css`
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
font-weight: ${theme.typography.fontWeightBold};
`,
modal: css`
height: 100%;
`,
@@ -293,5 +335,8 @@ const getPickerStyles = (theme: GrafanaTheme2) => {
modalAlert: css`
flex-grow: 0;
`,
warnIcon: css`
fill: ${theme.colors.warning.main};
`,
};
};
@@ -35,6 +35,7 @@ import {
} from 'app/types/unified-alerting';
import {
AlertQuery,
GrafanaAlertState,
GrafanaAlertStateDecision,
GrafanaRuleDefinition,
PromAlertingRuleState,
@@ -630,3 +631,7 @@ export function getCloudRule(override?: Partial<CombinedRule>) {
...override,
});
}
export function mockAlertWithState(state: GrafanaAlertState, labels?: {}): Alert {
return { activeAt: '', annotations: {}, labels: labels || {}, state: state, value: '' };
}
@@ -2,6 +2,7 @@ import { css, cx } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Dropdown, Button, useTheme2, Icon } from '@grafana/ui';
import { DashboardModel } from 'app/features/dashboard/state';
@@ -27,6 +28,7 @@ export const AddPanelButton = ({ dashboard }: Props) => {
size="lg"
fill="outline"
className={cx(styles.button, styles.buttonIcon, styles.buttonText)}
data-testid={selectors.components.PageToolbar.itemButton('Add panel button')}
>
Add
<Icon name={isMenuOpen ? 'angle-up' : 'angle-down'} size="lg" />
@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { locationService, reportInteraction } from '@grafana/runtime';
import { Menu } from '@grafana/ui';
import { DashboardModel } from 'app/features/dashboard/state';
@@ -23,7 +24,7 @@ export const AddPanelMenu = ({ dashboard }: Props) => {
<Menu.Item
key="add-visualisation"
label="Visualization"
ariaLabel="Add new panel"
testId={selectors.components.PageToolbar.itemButton('Add new visualization menu item')}
onClick={() => {
reportInteraction('Create new panel');
const id = onCreateNewPanel(dashboard);
@@ -33,7 +34,7 @@ export const AddPanelMenu = ({ dashboard }: Props) => {
<Menu.Item
key="add-row"
label="Row"
ariaLabel="Add new row"
testId={selectors.components.PageToolbar.itemButton('Add new row menu item')}
onClick={() => {
reportInteraction('Create new row');
onCreateNewRow(dashboard);
@@ -42,7 +43,7 @@ export const AddPanelMenu = ({ dashboard }: Props) => {
<Menu.Item
key="add-panel-lib"
label="Import from library"
ariaLabel="Add new panel from panel library"
testId={selectors.components.PageToolbar.itemButton('Add new panel from panel library menu item')}
onClick={() => {
reportInteraction('Add a panel from the panel library');
onAddLibraryPanel(dashboard);
@@ -51,7 +52,7 @@ export const AddPanelMenu = ({ dashboard }: Props) => {
<Menu.Item
key="add-panel-clipboard"
label="Paste panel"
ariaLabel="Add new panel from clipboard"
testId={selectors.components.PageToolbar.itemButton('Add new panel from clipboard menu item')}
onClick={() => {
reportInteraction('Paste panel from clipboard');
onPasteCopiedPanel(dashboard, copiedPanelPlugin);
@@ -1,15 +1,5 @@
import {
PanelMenuItem,
PluginExtension,
PluginExtensionLink,
PluginExtensionTypes,
PluginExtensionPlacements,
} from '@grafana/data';
import {
PluginExtensionPanelContext,
PluginExtensionRegistryItem,
setPluginsExtensionRegistry,
} from '@grafana/runtime';
import { PanelMenuItem, PluginExtensionPanelContext, PluginExtensionTypes } from '@grafana/data';
import { getPluginExtensions } from '@grafana/runtime';
import config from 'app/core/config';
import * as actions from 'app/features/explore/state/main';
import { setStore } from 'app/store/store';
@@ -25,9 +15,16 @@ jest.mock('app/core/services/context_srv', () => ({
},
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(),
getPluginExtensions: jest.fn(),
}));
describe('getPanelMenu()', () => {
beforeEach(() => {
setPluginsExtensionRegistry({});
(getPluginExtensions as jest.Mock).mockRestore();
(getPluginExtensions as jest.Mock).mockReturnValue({ extensions: [] });
});
it('should return the correct panel menu items', () => {
@@ -111,24 +108,24 @@ describe('getPanelMenu()', () => {
describe('when extending panel menu from plugins', () => {
it('should contain menu item from link extension', () => {
setPluginsExtensionRegistry({
[PluginExtensionPlacements.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>({
(getPluginExtensions as jest.Mock).mockReturnValue({
extensions: [
{
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
}),
},
],
});
const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel);
const moreSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
expect(moreSubMenu).toEqual(
expect(extensionsSubMenu).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: 'Declare incident',
@@ -139,24 +136,24 @@ describe('getPanelMenu()', () => {
});
it('should truncate menu item title to 25 chars', () => {
setPluginsExtensionRegistry({
[PluginExtensionPlacements.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>({
(getPluginExtensions as jest.Mock).mockReturnValue({
extensions: [
{
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
}),
},
],
});
const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel);
const moreSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
expect(moreSubMenu).toEqual(
expect(extensionsSubMenu).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: 'Declare incident when...',
@@ -166,94 +163,32 @@ describe('getPanelMenu()', () => {
);
});
it('should use extension for panel menu returned by configure function', () => {
const configure: PluginExtensionRegistryItem<PluginExtensionLink> = () => ({
title: 'Wohoo',
type: PluginExtensionTypes.link,
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
});
it('should pass onClick from plugin extension link to menu item', () => {
const expectedOnClick = jest.fn();
setPluginsExtensionRegistry({
[PluginExtensionPlacements.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>(
{
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
},
configure
),
(getPluginExtensions as jest.Mock).mockReturnValue({
extensions: [
{
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
onClick: expectedOnClick,
},
],
});
const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel);
const moreSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
const menuItem = extensionsSubMenu?.find((i) => (i.text = 'Declare incident when...'));
expect(moreSubMenu).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: 'Wohoo',
href: '/a/grafana-basic-app/declare-incident',
}),
])
);
});
it('should hide menu item if configure function returns undefined', () => {
setPluginsExtensionRegistry({
[PluginExtensionPlacements.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>(
{
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
},
() => undefined
),
],
});
const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel);
const moreSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
expect(moreSubMenu).toEqual(
expect.not.arrayContaining([
expect.objectContaining({
text: 'Declare incident when...',
href: '/a/grafana-basic-app/declare-incident',
}),
])
);
menuItem?.onClick?.({} as React.MouseEvent);
expect(expectedOnClick).toBeCalledTimes(1);
});
it('should pass context with correct values when configuring extension', () => {
const configure = jest.fn();
setPluginsExtensionRegistry({
[PluginExtensionPlacements.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>(
{
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
},
configure
),
],
});
const panel = new PanelModel({
type: 'timeseries',
id: 1,
@@ -303,65 +238,7 @@ describe('getPanelMenu()', () => {
},
};
expect(configure).toBeCalledWith(context);
});
it('should pass context that can not be edited in configure function', () => {
const configure: PluginExtensionRegistryItem<PluginExtensionLink> = (context) => {
// trying to change values in the context
// @ts-ignore
context.pluginId = 'changed';
return {
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
};
};
setPluginsExtensionRegistry({
[PluginExtensionPlacements.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>(
{
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
},
configure
),
],
});
const panel = new PanelModel({
type: 'timeseries',
id: 1,
title: 'My panel',
targets: [
{
refId: 'A',
datasource: {
type: 'testdata',
},
},
],
});
const dashboard = createDashboardModelFixture({
timezone: 'utc',
time: {
from: 'now-5m',
to: 'now',
},
tags: ['database', 'panel'],
uid: '123',
title: 'My dashboard',
});
expect(() => getPanelMenu(dashboard, panel)).toThrowError(TypeError);
expect(getPluginExtensions).toBeCalledWith(expect.objectContaining({ context }));
});
});
@@ -479,11 +356,3 @@ describe('getPanelMenu()', () => {
});
});
});
function createRegistryItem<T extends PluginExtension, C extends object = object>(
extension: T,
configure?: PluginExtensionRegistryItem<T, C>
): PluginExtensionRegistryItem<T, C> {
const defaultConfigure = () => extension;
return configure || defaultConfigure;
}
@@ -1,16 +1,11 @@
import { PanelMenuItem, PluginExtensionPoints, type PluginExtensionPanelContext } from '@grafana/data';
import {
isPluginExtensionCommand,
isPluginExtensionLink,
PanelMenuItem,
PluginExtensionPlacements,
} from '@grafana/data';
import {
AngularComponent,
getDataSourceSrv,
getPluginExtensions,
locationService,
reportInteraction,
PluginExtensionPanelContext,
} from '@grafana/runtime';
import { PanelCtrl } from 'app/angular/panel/panel_ctrl';
import config from 'app/core/config';
@@ -284,11 +279,11 @@ export function getPanelMenu(
}
const { extensions } = getPluginExtensions({
placement: PluginExtensionPlacements.DashboardPanelMenu,
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
context: createExtensionContext(panel, dashboard),
});
if (extensions.length > 0) {
if (extensions.length > 0 && !panel.isEditing) {
const extensionsMenu: PanelMenuItem[] = [];
for (const extension of extensions) {
@@ -296,14 +291,7 @@ export function getPanelMenu(
extensionsMenu.push({
text: truncateTitle(extension.title, 25),
href: extension.path,
});
continue;
}
if (isPluginExtensionCommand(extension)) {
extensionsMenu.push({
text: truncateTitle(extension.title, 25),
onClick: extension.callHandlerWithContext,
onClick: extension.onClick,
});
continue;
}
@@ -340,26 +328,20 @@ function truncateTitle(title: string, length: number): string {
}
function createExtensionContext(panel: PanelModel, dashboard: DashboardModel): PluginExtensionPanelContext {
const timeRange = Object.assign({}, dashboard.time);
return Object.freeze({
return {
id: panel.id,
pluginId: panel.type,
title: panel.title,
timeRange: Object.freeze(timeRange),
timeRange: Object.assign({}, dashboard.time),
timeZone: dashboard.timezone,
dashboard: Object.freeze({
dashboard: {
uid: dashboard.uid,
title: dashboard.title,
tags: Object.freeze(Array.from<string>(dashboard.tags)),
}),
targets: Object.freeze(
panel.targets.map((t) =>
Object.freeze({
refId: t.refId,
pluginId: t.datasource?.type ?? 'unknown',
})
)
),
});
tags: Array.from<string>(dashboard.tags),
},
targets: panel.targets.map((t) => ({
refId: t.refId,
pluginId: t.datasource?.type ?? 'unknown',
})),
};
}
@@ -6,10 +6,10 @@ import { useLocation, useRouteMatch } from 'react-router-dom';
import { AppEvents, AppPlugin, AppPluginMeta, NavModel, NavModelItem, PluginType } from '@grafana/data';
import { config, locationSearchToObject } from '@grafana/runtime';
import { getNotFoundNav, getWarningNav, getExceptionNav } from 'app/angular/services/nav_model_srv';
import { Page } from 'app/core/components/Page/Page';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { appEvents } from 'app/core/core';
import { getNotFoundNav, getWarningNav, getExceptionNav } from 'app/core/navigation/errorModels';
import { getPluginSettings } from '../pluginSettings';
import { importAppPlugin } from '../plugin_loader';
@@ -0,0 +1 @@
export const MAX_EXTENSIONS_PER_POINT = 2;
@@ -0,0 +1,167 @@
import { PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data';
import { createPluginExtensionRegistry } from './createPluginExtensionRegistry';
describe('createRegistry()', () => {
const placement1 = 'grafana/dashboard/panel/menu';
const placement2 = 'plugins/myorg-basic-app/start';
const pluginId = 'grafana-basic-app';
let link1: PluginExtensionLinkConfig, link2: PluginExtensionLinkConfig;
beforeEach(() => {
link1 = {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: placement1,
configure: jest.fn().mockReturnValue({}),
};
link2 = {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: placement2,
configure: jest.fn().mockImplementation((context) => ({ title: context?.title })),
};
global.console.warn = jest.fn();
});
it('should be possible to register extensions', () => {
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
expect(Object.getOwnPropertyNames(registry)).toEqual([placement1, placement2]);
// Placement 1
expect(registry[placement1]).toHaveLength(1);
expect(registry[placement1]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link1,
configure: expect.any(Function),
},
}),
])
);
// Placement 2
expect(registry[placement2]).toHaveLength(1);
expect(registry[placement2]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link2,
configure: expect.any(Function),
},
}),
])
);
});
it('should register maximum 2 extensions / plugin / placement', () => {
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link1, link1] }]);
expect(Object.getOwnPropertyNames(registry)).toEqual([placement1]);
// Placement 1
expect(registry[placement1]).toHaveLength(2);
expect(registry[placement1]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link1,
configure: expect.any(Function),
},
}),
expect.objectContaining({
pluginId,
config: {
...link1,
configure: expect.any(Function),
},
}),
])
);
});
it('should not register link extensions with invalid path configured', () => {
const registry = createPluginExtensionRegistry([
{ pluginId, extensionConfigs: [{ ...link1, path: 'invalid-path' }, link2] },
]);
expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]);
// Placement 2
expect(registry[placement2]).toHaveLength(1);
expect(registry[placement2]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link2,
configure: expect.any(Function),
},
}),
])
);
});
it('should not register extensions for a plugin that had errors', () => {
const registry = createPluginExtensionRegistry([
{ pluginId, extensionConfigs: [link1, link2], error: new Error('Plugin failed to load') },
]);
expect(Object.getOwnPropertyNames(registry)).toEqual([]);
});
it('should not register an extension if it has an invalid configure() function', () => {
const registry = createPluginExtensionRegistry([
// @ts-ignore (We would like to provide an invalid configure function on purpose)
{ pluginId, extensionConfigs: [{ ...link1, configure: '...' }, link2] },
]);
expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]);
// Placement 2 (checking if it still registers the extension with a valid configuration)
expect(registry[placement2]).toHaveLength(1);
expect(registry[placement2]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link2,
configure: expect.any(Function),
},
}),
])
);
});
it('should not register an extension if it has invalid properties (empty title / description)', () => {
const registry = createPluginExtensionRegistry([
{ pluginId, extensionConfigs: [{ ...link1, title: '', description: '' }, link2] },
]);
expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]);
// Placement 2 (checking if it still registers the extension with a valid configuration)
expect(registry[placement2]).toHaveLength(1);
expect(registry[placement2]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link2,
configure: expect.any(Function),
},
}),
])
);
});
});
@@ -0,0 +1,49 @@
import type { PluginPreloadResult } from '../pluginPreloader';
import { MAX_EXTENSIONS_PER_POINT } from './constants';
import { ExtensionsPerPlugin } from './extensionsPerPlugin';
import type { PluginExtensionRegistryItem, PluginExtensionRegistry } from './types';
import { deepFreeze, logWarning } from './utils';
import { isPluginExtensionConfigValid } from './validators';
export function createPluginExtensionRegistry(pluginPreloadResults: PluginPreloadResult[]): PluginExtensionRegistry {
const registry: PluginExtensionRegistry = {};
const extensionsPerPlugin = new ExtensionsPerPlugin();
for (const { pluginId, extensionConfigs, error } of pluginPreloadResults) {
if (error) {
logWarning(`"${pluginId}" plugin failed to load, skip registering its extensions.`);
continue;
}
for (const extensionConfig of extensionConfigs) {
const { extensionPointId } = extensionConfig;
if (!extensionsPerPlugin.allowedToAdd(extensionConfig)) {
logWarning(
`"${pluginId}" plugin has reached the limit of ${MAX_EXTENSIONS_PER_POINT} for "${extensionPointId}", skip registering extension "${extensionConfig.title}".`
);
continue;
}
if (!extensionConfig || !isPluginExtensionConfigValid(pluginId, extensionConfig)) {
continue;
}
let registryItem: PluginExtensionRegistryItem = {
config: extensionConfig,
// Additional meta information about the extension
pluginId,
};
if (!Array.isArray(registry[extensionPointId])) {
registry[extensionPointId] = [registryItem];
} else {
registry[extensionPointId].push(registryItem);
}
}
}
return deepFreeze(registry);
}

Some files were not shown because too many files have changed in this diff Show More