Merge branch 'main' into 20230331_add_kerberos_support
This commit is contained in:
+1
-2
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
...
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||

|
||||
|
||||
Grafana displays a preview of your query results with the visualization applied.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 '&'. |
|
||||
| **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 '&'. |
|
||||
| **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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
+14
@@ -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**.
|
||||
|
||||
@@ -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" >}}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
DownstreamMode VersionMode = "downstream"
|
||||
Enterprise2Mode VersionMode = "enterprise2"
|
||||
CronjobMode VersionMode = "cron"
|
||||
CloudMode VersionMode = "cloud"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -267,6 +267,10 @@ const (
|
||||
// Enable time series table transformer & 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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+43
-11
@@ -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
Reference in New Issue
Block a user