Merge branch 'main' of https://github.com/grafana/grafana into kristina/filter-time-transformation

This commit is contained in:
Kristina Durivage
2025-03-05 11:18:39 -06:00
82 changed files with 1044 additions and 381 deletions
+103 -103
View File
@@ -25,7 +25,7 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- ./bin/build verify-drone
@@ -75,7 +75,7 @@ steps:
- go install github.com/bazelbuild/buildtools/buildifier@latest
- buildifier --lint=warn -mode=check -r .
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: lint-starlark
trigger:
event:
@@ -437,7 +437,7 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
@@ -446,21 +446,21 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-jsonnet
- commands:
- apk add --update make
- make gen-go
depends_on:
- verify-gen-cue
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: wire-install
- commands:
- apk add --update build-base shared-mime-info shared-mime-info-lang
- go list -f '{{.Dir}}/...' -m | xargs go test -short -covermode=atomic -timeout=5m
depends_on:
- wire-install
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: test-backend
- commands:
- apk add --update build-base
@@ -469,7 +469,7 @@ steps:
| grep -o '\(.*\)/' | sort -u)
depends_on:
- wire-install
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: test-backend-integration
trigger:
event:
@@ -524,7 +524,7 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- echo $(/usr/bin/github-app-external-token) > /github-app/token
@@ -569,16 +569,16 @@ steps:
- apk add --update make
- make gen-go
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: wire-install
- commands:
- go run scripts/modowners/modowners.go check go.mod
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: validate-modfile
- commands:
- apk add --update make
- make swagger-validate
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: validate-openapi-spec
trigger:
event:
@@ -655,7 +655,7 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- '# It is required that code generated from Thema/CUE be committed and in sync
@@ -665,7 +665,7 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
@@ -674,7 +674,7 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-jsonnet
- commands:
- yarn install --immutable || yarn install --immutable
@@ -712,7 +712,7 @@ steps:
- /src/grafana-build artifacts -a targz:grafana:linux/amd64 -a targz:grafana:linux/arm64
-a targz:grafana:linux/arm/v7 -a docker:grafana:linux/amd64 -a docker:grafana:linux/amd64:ubuntu
-a docker:grafana:linux/arm64 -a docker:grafana:linux/arm64:ubuntu -a docker:grafana:linux/arm/v7
-a docker:grafana:linux/arm/v7:ubuntu --go-version=1.23.5 --yarn-cache=$$YARN_CACHE_FOLDER
-a docker:grafana:linux/arm/v7:ubuntu --go-version=1.23.7 --yarn-cache=$$YARN_CACHE_FOLDER
--build-id=$$DRONE_BUILD_NUMBER --ubuntu-base=ubuntu:22.04 --alpine-base=alpine:3.21.3
--tag-format='{{ .version_base }}-{{ .buildID }}-{{ .arch }}' --ubuntu-tag-format='{{
.version_base }}-{{ .buildID }}-ubuntu-{{ .arch }}' --verify='false' --grafana-dir=$$PWD
@@ -1110,7 +1110,7 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- echo $DRONE_RUNNER_NAME
@@ -1124,7 +1124,7 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
@@ -1133,14 +1133,14 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-jsonnet
- commands:
- apk add --update make
- make gen-go
depends_on:
- verify-gen-cue
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: wire-install
- commands:
- dockerize -wait tcp://postgres:5432 -timeout 120s
@@ -1161,7 +1161,7 @@ steps:
GRAFANA_TEST_DB: postgres
PGPASSWORD: grafanatest
POSTGRES_HOST: postgres
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: postgres-integration-tests
- commands:
- dockerize -wait tcp://mysql80:3306 -timeout 120s
@@ -1182,7 +1182,7 @@ steps:
environment:
GRAFANA_TEST_DB: mysql
MYSQL_HOST: mysql80
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: mysql-8.0-integration-tests
- commands:
- dockerize -wait tcp://redis:6379 -timeout 120s
@@ -1198,7 +1198,7 @@ steps:
- wait-for-redis
environment:
REDIS_URL: redis://redis:6379/0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: redis-integration-tests
- commands:
- dockerize -wait tcp://memcached:11211 -timeout 120s
@@ -1214,7 +1214,7 @@ steps:
- wait-for-memcached
environment:
MEMCACHED_HOSTS: memcached:11211
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: memcached-integration-tests
- commands:
- dockerize -wait tcp://mimir_backend:8080 -timeout 120s
@@ -1230,7 +1230,7 @@ steps:
environment:
AM_TENANT_ID: test
AM_URL: http://mimir_backend:8080
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: remote-alertmanager-integration-tests
trigger:
event:
@@ -1312,7 +1312,7 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-cue
trigger:
event:
@@ -1433,7 +1433,7 @@ steps:
&& return 1; fi
depends_on:
- clone-enterprise
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: swagger-gen
trigger:
event:
@@ -1538,7 +1538,7 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- '# It is required that code generated from Thema/CUE be committed and in sync
@@ -1549,7 +1549,7 @@ steps:
- CODEGEN_VERIFY=1 make gen-cue
depends_on:
- clone-enterprise
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
@@ -1559,14 +1559,14 @@ steps:
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on:
- clone-enterprise
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-jsonnet
- commands:
- apk add --update make
- make gen-go
depends_on:
- verify-gen-cue
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: wire-install
- commands:
- apk add --update build-base
@@ -1574,7 +1574,7 @@ steps:
- go test -v -run=^$ -benchmem -timeout=1h -count=8 -bench=. ${GO_PACKAGES}
depends_on:
- wire-install
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: sqlite-benchmark-integration-tests
- commands:
- apk add --update build-base
@@ -1586,7 +1586,7 @@ steps:
GRAFANA_TEST_DB: postgres
PGPASSWORD: grafanatest
POSTGRES_HOST: postgres
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: postgres-benchmark-integration-tests
- commands:
- apk add --update build-base
@@ -1597,7 +1597,7 @@ steps:
environment:
GRAFANA_TEST_DB: mysql
MYSQL_HOST: mysql80
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: mysql-8.0-benchmark-integration-tests
trigger:
event:
@@ -1669,7 +1669,7 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-cue
trigger:
branch: main
@@ -1852,7 +1852,7 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
@@ -1861,21 +1861,21 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-jsonnet
- commands:
- apk add --update make
- make gen-go
depends_on:
- verify-gen-cue
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: wire-install
- commands:
- apk add --update build-base shared-mime-info shared-mime-info-lang
- go list -f '{{.Dir}}/...' -m | xargs go test -short -covermode=atomic -timeout=5m
depends_on:
- wire-install
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: test-backend
- commands:
- apk add --update build-base
@@ -1884,7 +1884,7 @@ steps:
| grep -o '\(.*\)/' | sort -u)
depends_on:
- wire-install
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: test-backend-integration
trigger:
branch: main
@@ -1929,22 +1929,22 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- apk add --update make
- make gen-go
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: wire-install
- commands:
- go run scripts/modowners/modowners.go check go.mod
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: validate-modfile
- commands:
- apk add --update make
- make swagger-validate
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: validate-openapi-spec
- commands:
- ./bin/build verify-drone
@@ -2076,7 +2076,7 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- '# It is required that code generated from Thema/CUE be committed and in sync
@@ -2086,7 +2086,7 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
@@ -2095,7 +2095,7 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-jsonnet
- commands:
- yarn install --immutable || yarn install --immutable
@@ -2132,7 +2132,7 @@ steps:
- /src/grafana-build artifacts -a targz:grafana:linux/amd64 -a targz:grafana:linux/arm64
-a targz:grafana:linux/arm/v7 -a docker:grafana:linux/amd64 -a docker:grafana:linux/amd64:ubuntu
-a docker:grafana:linux/arm64 -a docker:grafana:linux/arm64:ubuntu -a docker:grafana:linux/arm/v7
-a docker:grafana:linux/arm/v7:ubuntu --go-version=1.23.5 --yarn-cache=$$YARN_CACHE_FOLDER
-a docker:grafana:linux/arm/v7:ubuntu --go-version=1.23.7 --yarn-cache=$$YARN_CACHE_FOLDER
--build-id=$$DRONE_BUILD_NUMBER --ubuntu-base=ubuntu:22.04 --alpine-base=alpine:3.21.3
--tag-format='{{ .version_base }}-{{ .buildID }}-{{ .arch }}' --ubuntu-tag-format='{{
.version_base }}-{{ .buildID }}-ubuntu-{{ .arch }}' --verify='false' --grafana-dir=$$PWD
@@ -2607,7 +2607,7 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- echo $DRONE_RUNNER_NAME
@@ -2621,7 +2621,7 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
@@ -2630,14 +2630,14 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-jsonnet
- commands:
- apk add --update make
- make gen-go
depends_on:
- verify-gen-cue
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: wire-install
- commands:
- dockerize -wait tcp://postgres:5432 -timeout 120s
@@ -2658,7 +2658,7 @@ steps:
GRAFANA_TEST_DB: postgres
PGPASSWORD: grafanatest
POSTGRES_HOST: postgres
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: postgres-integration-tests
- commands:
- dockerize -wait tcp://mysql80:3306 -timeout 120s
@@ -2679,7 +2679,7 @@ steps:
environment:
GRAFANA_TEST_DB: mysql
MYSQL_HOST: mysql80
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: mysql-8.0-integration-tests
- commands:
- dockerize -wait tcp://redis:6379 -timeout 120s
@@ -2695,7 +2695,7 @@ steps:
- wait-for-redis
environment:
REDIS_URL: redis://redis:6379/0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: redis-integration-tests
- commands:
- dockerize -wait tcp://memcached:11211 -timeout 120s
@@ -2711,7 +2711,7 @@ steps:
- wait-for-memcached
environment:
MEMCACHED_HOSTS: memcached:11211
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: memcached-integration-tests
- commands:
- dockerize -wait tcp://mimir_backend:8080 -timeout 120s
@@ -2727,7 +2727,7 @@ steps:
environment:
AM_TENANT_ID: test
AM_URL: http://mimir_backend:8080
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: remote-alertmanager-integration-tests
trigger:
branch: main
@@ -2996,7 +2996,7 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
@@ -3005,21 +3005,21 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-jsonnet
- commands:
- apk add --update make
- make gen-go
depends_on:
- verify-gen-cue
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: wire-install
- commands:
- apk add --update build-base shared-mime-info shared-mime-info-lang
- go list -f '{{.Dir}}/...' -m | xargs go test -short -covermode=atomic -timeout=5m
depends_on:
- wire-install
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: test-backend
- commands:
- apk add --update build-base
@@ -3028,7 +3028,7 @@ steps:
| grep -o '\(.*\)/' | sort -u)
depends_on:
- wire-install
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: test-backend-integration
trigger:
branch:
@@ -3071,22 +3071,22 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- apk add --update make
- make gen-go
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: wire-install
- commands:
- go run scripts/modowners/modowners.go check go.mod
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: validate-modfile
- commands:
- apk add --update make
- make swagger-validate
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: validate-openapi-spec
trigger:
branch:
@@ -3165,7 +3165,7 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- echo $DRONE_RUNNER_NAME
@@ -3179,7 +3179,7 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
@@ -3188,14 +3188,14 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-jsonnet
- commands:
- apk add --update make
- make gen-go
depends_on:
- verify-gen-cue
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: wire-install
- commands:
- dockerize -wait tcp://postgres:5432 -timeout 120s
@@ -3216,7 +3216,7 @@ steps:
GRAFANA_TEST_DB: postgres
PGPASSWORD: grafanatest
POSTGRES_HOST: postgres
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: postgres-integration-tests
- commands:
- dockerize -wait tcp://mysql80:3306 -timeout 120s
@@ -3237,7 +3237,7 @@ steps:
environment:
GRAFANA_TEST_DB: mysql
MYSQL_HOST: mysql80
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: mysql-8.0-integration-tests
- commands:
- dockerize -wait tcp://redis:6379 -timeout 120s
@@ -3253,7 +3253,7 @@ steps:
- wait-for-redis
environment:
REDIS_URL: redis://redis:6379/0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: redis-integration-tests
- commands:
- dockerize -wait tcp://memcached:11211 -timeout 120s
@@ -3269,7 +3269,7 @@ steps:
- wait-for-memcached
environment:
MEMCACHED_HOSTS: memcached:11211
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: memcached-integration-tests
- commands:
- dockerize -wait tcp://mimir_backend:8080 -timeout 120s
@@ -3285,7 +3285,7 @@ steps:
environment:
AM_TENANT_ID: test
AM_URL: http://mimir_backend:8080
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: remote-alertmanager-integration-tests
trigger:
branch:
@@ -3385,7 +3385,7 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- ./bin/build artifacts docker fetch --edition oss
@@ -3517,7 +3517,7 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- ./bin/build artifacts docker fetch --edition oss
@@ -3658,7 +3658,7 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- ./bin/build artifacts packages --artifacts-editions=oss --tag $${DRONE_TAG} --src-bucket
@@ -3750,7 +3750,7 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- yarn install --immutable || yarn install --immutable
@@ -3850,7 +3850,7 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- depends_on:
- compile-build-cmd
@@ -3947,7 +3947,7 @@ steps:
depends_on: []
environment:
CGO_ENABLED: 0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: compile-build-cmd
- commands:
- ./bin/build publish grafana-com --edition oss ${DRONE_TAG}
@@ -4009,7 +4009,7 @@ steps:
from_secret: grafana_api_key
GCP_KEY_BASE64:
from_secret: gcp_key_base64
GO_VERSION: 1.23.5
GO_VERSION: 1.23.7
GPG_PASSPHRASE:
from_secret: packages_gpg_passphrase
GPG_PRIVATE_KEY:
@@ -4084,7 +4084,7 @@ steps:
from_secret: grafana_api_key
GCP_KEY_BASE64:
from_secret: gcp_key_base64
GO_VERSION: 1.23.5
GO_VERSION: 1.23.7
GPG_PASSPHRASE:
from_secret: packages_gpg_passphrase
GPG_PRIVATE_KEY:
@@ -4201,7 +4201,7 @@ steps:
from_secret: grafana_api_key
GCP_KEY_BASE64:
from_secret: gcp_key_base64
GO_VERSION: 1.23.5
GO_VERSION: 1.23.7
GPG_PASSPHRASE:
from_secret: packages_gpg_passphrase
GPG_PRIVATE_KEY:
@@ -4352,7 +4352,7 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
@@ -4361,21 +4361,21 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-jsonnet
- commands:
- apk add --update make
- make gen-go
depends_on:
- verify-gen-cue
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: wire-install
- commands:
- apk add --update build-base shared-mime-info shared-mime-info-lang
- go list -f '{{.Dir}}/...' -m | xargs go test -short -covermode=atomic -timeout=5m
depends_on:
- wire-install
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: test-backend
- commands:
- apk add --update build-base
@@ -4384,7 +4384,7 @@ steps:
| grep -o '\(.*\)/' | sort -u)
depends_on:
- wire-install
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: test-backend-integration
trigger:
cron:
@@ -4438,7 +4438,7 @@ steps:
from_secret: grafana_api_key
GCP_KEY_BASE64:
from_secret: gcp_key_base64
GO_VERSION: 1.23.5
GO_VERSION: 1.23.7
GPG_PASSPHRASE:
from_secret: packages_gpg_passphrase
GPG_PRIVATE_KEY:
@@ -4582,7 +4582,7 @@ steps:
from_secret: grafana_api_key
GCP_KEY_BASE64:
from_secret: gcp_key_base64
GO_VERSION: 1.23.5
GO_VERSION: 1.23.7
GPG_PASSPHRASE:
from_secret: packages_gpg_passphrase
GPG_PRIVATE_KEY:
@@ -4689,7 +4689,7 @@ steps:
- export GITHUB_TOKEN=$(cat /github-app/token)
- dagger run --silent /src/grafana-build artifacts -a $${ARTIFACTS} --grafana-ref=$${GRAFANA_REF}
--enterprise-ref=$${ENTERPRISE_REF} --grafana-repo=$${GRAFANA_REPO} --version=$${VERSION}
--go-version=1.23.5
--go-version=1.23.7
depends_on:
- github-app-generate-token
environment:
@@ -4710,7 +4710,7 @@ steps:
from_secret: grafana_api_key
GCP_KEY_BASE64:
from_secret: gcp_key_base64
GO_VERSION: 1.23.5
GO_VERSION: 1.23.7
GPG_PASSPHRASE:
from_secret: packages_gpg_passphrase
GPG_PRIVATE_KEY:
@@ -4848,7 +4848,7 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
@@ -4857,14 +4857,14 @@ steps:
- apk add --update make
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on: []
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: verify-gen-jsonnet
- commands:
- apk add --update make
- make gen-go
depends_on:
- verify-gen-cue
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: wire-install
- commands:
- dockerize -wait tcp://postgres:5432 -timeout 120s
@@ -4885,7 +4885,7 @@ steps:
GRAFANA_TEST_DB: postgres
PGPASSWORD: grafanatest
POSTGRES_HOST: postgres
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: postgres-integration-tests
- commands:
- dockerize -wait tcp://mysql80:3306 -timeout 120s
@@ -4906,7 +4906,7 @@ steps:
environment:
GRAFANA_TEST_DB: mysql
MYSQL_HOST: mysql80
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: mysql-8.0-integration-tests
- commands:
- dockerize -wait tcp://redis:6379 -timeout 120s
@@ -4922,7 +4922,7 @@ steps:
- wait-for-redis
environment:
REDIS_URL: redis://redis:6379/0
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: redis-integration-tests
- commands:
- dockerize -wait tcp://memcached:11211 -timeout 120s
@@ -4938,7 +4938,7 @@ steps:
- wait-for-memcached
environment:
MEMCACHED_HOSTS: memcached:11211
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: memcached-integration-tests
- commands:
- dockerize -wait tcp://mimir_backend:8080 -timeout 120s
@@ -4954,7 +4954,7 @@ steps:
environment:
AM_TENANT_ID: test
AM_URL: http://mimir_backend:8080
image: golang:1.23.5-alpine
image: golang:1.23.7-alpine
name: remote-alertmanager-integration-tests
trigger:
event:
@@ -5257,7 +5257,7 @@ steps:
- commands:
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM docker:27-cli
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM alpine/git:2.40.1
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM golang:1.23.5-alpine
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM golang:1.23.7-alpine
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM node:22.11.0-alpine
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM node:22-bookworm
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM google/cloud-sdk:431.0.0
@@ -5295,7 +5295,7 @@ steps:
- commands:
- trivy --exit-code 1 --severity HIGH,CRITICAL docker:27-cli
- trivy --exit-code 1 --severity HIGH,CRITICAL alpine/git:2.40.1
- trivy --exit-code 1 --severity HIGH,CRITICAL golang:1.23.5-alpine
- trivy --exit-code 1 --severity HIGH,CRITICAL golang:1.23.7-alpine
- trivy --exit-code 1 --severity HIGH,CRITICAL node:22.11.0-alpine
- trivy --exit-code 1 --severity HIGH,CRITICAL node:22-bookworm
- trivy --exit-code 1 --severity HIGH,CRITICAL google/cloud-sdk:431.0.0
@@ -5564,6 +5564,6 @@ kind: secret
name: gcr_credentials
---
kind: signature
hmac: f16a4715c7a4e6a4ffb1fe041b42fb966310fd5da455239614e9a239493aff82
hmac: 33f2e5615dfd7889899f9f8f16f7716190fa637fe98f1efd7e29607f8946be7d
...
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23.5'
go-version: '1.23.7'
cache: true
- name: Validate go.mod
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23.5'
go-version: '1.23.7'
cache: true
- name: Install dependencies
+1 -1
View File
@@ -6,7 +6,7 @@
ARG BASE_IMAGE=alpine:3.21
ARG JS_IMAGE=node:22-alpine
ARG JS_PLATFORM=linux/amd64
ARG GO_IMAGE=golang:1.23.5-alpine
ARG GO_IMAGE=golang:1.23.7-alpine
# Default to building locally
ARG GO_SRC=go-builder
+1 -1
View File
@@ -8,7 +8,7 @@ WIRE_TAGS = "oss"
include .bingo/Variables.mk
GO = go
GO_VERSION = 1.23.5
GO_VERSION = 1.23.7
GO_LINT_FILES ?= $(shell ./scripts/go-workspace/golangci-lint-includes.sh)
GO_TEST_FILES ?= $(shell ./scripts/go-workspace/test-includes.sh)
SH_FILES ?= $(shell find ./scripts -name *.sh)
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/grafana/grafana/apps/advisor
go 1.23.4
go 1.23.7
require (
github.com/grafana/grafana-app-sdk v0.31.0
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/grafana/grafana/apps/alerting/notifications
go 1.23.5
go 1.23.7
replace github.com/grafana/grafana => ../../..
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/grafana/grafana/apps/investigations
go 1.23.4
go 1.23.7
require (
github.com/grafana/grafana-app-sdk v0.31.0
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/grafana/grafana/apps/playlist
go 1.23.4
go 1.23.7
require (
github.com/grafana/grafana-app-sdk v0.31.0
@@ -1,6 +1,6 @@
module high-card
go 1.22.4
go 1.23.7
require (
github.com/prometheus/client_golang v1.20.2
+1 -1
View File
@@ -1,6 +1,6 @@
module utf8-support
go 1.22.4
go 1.23.7
require (
github.com/prometheus/client_golang v1.20.5
@@ -1,4 +1,4 @@
FROM golang:1.23.5
FROM golang:1.23.7
ADD main.go /go/src/webhook/main.go
@@ -20,8 +20,19 @@ Label-Based Access Control (LBAC) allows fine-grained access control to data sou
## Supported Data Sources
### Feature availability
LBAC for data sources is currently generally available for `Loki` and in **experimental** for `Prometheus`. Support for additional data sources may be added in future updates.
| Data source | [Grafana Cloud](/docs/grafana-cloud) | [Grafana Enterprise]({{< relref "../../introduction/grafana-enterprise/" >}}) |
| ----------- | ------------------------------------ | ----------------------------------------------------------------------------- |
| Loki | GA | GA |
| Prometheus | PrivatePreview | PrivatePreview |
{{% admonition type="note" %}}
For enterprise this feature requires Grafana Enterprise Metrics (GEM) or Grafana Enterprise Logs (GEL) to function.
{{% /admonition %}}
**LBAC for data sources offers:**
- Team-based access control using `LogQL` rules.
@@ -117,6 +117,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `improvedExternalSessionHandling` | Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves. |
| `elasticsearchCrossClusterSearch` | Enables cross cluster search in the Elasticsearch datasource |
| `improvedExternalSessionHandlingSAML` | Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly. |
| `alertRuleRestore` | Enables the alert rule restore feature |
## Experimental feature toggles
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/grafana/grafana
go 1.23.5
go 1.23.7
require (
buf.build/gen/go/parca-dev/parca/connectrpc/go v1.17.0-20240902100956-02fd72488966.1 // @grafana/observability-traces-and-profiling
+1 -1
View File
@@ -1,4 +1,4 @@
go 1.23.5
go 1.23.7
// The `skip:golangci-lint` comment tag is used to exclude the package from the `golangci-lint` GitHub Action.
// The module at the root of the repo (`.`) is excluded because ./pkg/... is included manually in the `golangci-lint` configuration.
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/grafana/grafana/hack
go 1.23.1
go 1.23.7
require k8s.io/code-generator v0.32.0
@@ -255,4 +255,5 @@ export interface FeatureToggles {
newShareReportDrawer?: boolean;
rendererDisableAppPluginsPreload?: boolean;
assetSriChecks?: boolean;
alertRuleRestore?: boolean;
}
@@ -185,6 +185,7 @@ export enum PluginExtensionPoints {
DataSourceConfig = 'grafana/datasources/config',
ExploreToolbarAction = 'grafana/explore/toolbar/action',
UserProfileTab = 'grafana/user/profile/tab',
TraceViewDetails = 'grafana/traceview/details',
}
export type PluginExtensionPanelContext = {
+1 -3
View File
@@ -1,8 +1,6 @@
module github.com/grafana/grafana/pkg/aggregator
go 1.23.5
toolchain go1.23.6
go 1.23.7
require (
github.com/emicklei/go-restful/v3 v3.11.0
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/grafana/grafana/pkg/apimachinery
go 1.23.1
go 1.23.7
require (
github.com/grafana/authlib v0.0.0-20250225105729-99e678595501 // @grafana/identity-access-team
+1 -3
View File
@@ -1,8 +1,6 @@
module github.com/grafana/grafana/pkg/apiserver
go 1.23.4
toolchain go1.23.6
go 1.23.7
require (
github.com/google/go-cmp v0.7.0
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/grafana/grafana/pkg/build
go 1.23.1
go 1.23.7
// Override docker/docker to avoid:
// go: github.com/drone-runners/drone-runner-docker@v1.8.2 requires
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/grafana/grafana/pkg/build/wire
go 1.23.1
go 1.23.7
require (
github.com/google/go-cmp v0.7.0
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/grafana/grafana/pkg/codegen
go 1.23.1
go 1.23.7
require (
cuelang.org/go v0.11.1
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/grafana/grafana/pkg/plugins/codegen
go 1.23.1
go 1.23.7
replace github.com/grafana/grafana/pkg/codegen => ../../codegen
+1 -3
View File
@@ -1,8 +1,6 @@
module github.com/grafana/grafana/pkg/promlib
go 1.23.5
toolchain go1.23.6
go 1.23.7
require (
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/grafana/grafana/pkg/semconv
go 1.23.1
go 1.23.7
require go.opentelemetry.io/otel v1.34.0
+1 -7
View File
@@ -9,7 +9,6 @@ import (
"github.com/fullstorydev/grpchan/inprocgrpc"
authzv1 "github.com/grafana/authlib/authz/proto/v1"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"google.golang.org/protobuf/types/known/wrapperspb"
dashboardalpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1"
"github.com/grafana/grafana/pkg/infra/localcache"
@@ -71,12 +70,7 @@ func NewServer(cfg setting.ZanzanaServerSettings, openfga OpenFGAServer, logger
}
func (s *Server) IsHealthy(ctx context.Context) (bool, error) {
// FIXME: get back to openfga.IsReady() when issue is fixed
// https://github.com/openfga/openfga/issues/2251
_, err := s.openfga.ListStores(ctx, &openfgav1.ListStoresRequest{
PageSize: wrapperspb.Int32(1),
})
return err == nil, nil
return s.openfga.IsReady(ctx)
}
func (s *Server) getContextuals(subject string) (*openfgav1.ContextualTupleKeys, error) {
+7
View File
@@ -1782,6 +1782,13 @@ var (
Owner: grafanaFrontendOpsWG,
FrontendOnly: true,
},
{
Name: "alertRuleRestore",
Description: "Enables the alert rule restore feature",
Stage: FeatureStagePublicPreview,
Owner: grafanaAlertingSquad,
Expression: "true", // enabled by default
},
}
)
+1
View File
@@ -236,3 +236,4 @@ alertingRuleVersionHistoryRestore,GA,@grafana/alerting-squad,false,false,true
newShareReportDrawer,experimental,@grafana/sharing-squad,false,false,false
rendererDisableAppPluginsPreload,experimental,@grafana/sharing-squad,false,false,true
assetSriChecks,experimental,@grafana/frontend-ops,false,false,true
alertRuleRestore,preview,@grafana/alerting-squad,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
236 newShareReportDrawer experimental @grafana/sharing-squad false false false
237 rendererDisableAppPluginsPreload experimental @grafana/sharing-squad false false true
238 assetSriChecks experimental @grafana/frontend-ops false false true
239 alertRuleRestore preview @grafana/alerting-squad false false false
+4
View File
@@ -954,4 +954,8 @@ const (
// FlagAssetSriChecks
// Enables SRI checks for Grafana JavaScript assets
FlagAssetSriChecks = "assetSriChecks"
// FlagAlertRuleRestore
// Enables the alert rule restore feature
FlagAlertRuleRestore = "alertRuleRestore"
)
+16
View File
@@ -108,6 +108,22 @@
"frontend": true
}
},
{
"metadata": {
"name": "alertRuleRestore",
"resourceVersion": "1741127758142",
"creationTimestamp": "2025-03-04T22:29:36Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-03-04 22:35:58.1421143 +0000 UTC"
}
},
"spec": {
"description": "Enables the alert rule restore feature",
"stage": "preview",
"codeowner": "@grafana/alerting-squad",
"expression": "true"
}
},
{
"metadata": {
"name": "alertStateHistoryLokiOnly",
@@ -1930,8 +1930,9 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment {
Cfg: setting.UnifiedAlertingSettings{
BaseInterval: time.Second * 10,
},
FolderService: folderService,
Bus: bus.ProvideBus(tracing.InitializeTracerForTest()),
FolderService: folderService,
Bus: bus.ProvideBus(tracing.InitializeTracerForTest()),
FeatureToggles: featuremgmt.WithFeatures(),
}
user := &user.SignedInUser{
OrgID: 1,
+2 -2
View File
@@ -157,7 +157,7 @@ func (srv RulerSrv) RouteDeleteAlertRules(c *contextmodel.ReqContext, namespaceU
rulesToDelete = append(rulesToDelete, uid...)
}
if len(rulesToDelete) > 0 {
err := srv.store.DeleteAlertRulesByUID(ctx, c.SignedInUser.GetOrgID(), rulesToDelete...)
err := srv.store.DeleteAlertRulesByUID(ctx, c.SignedInUser.GetOrgID(), ngmodels.NewUserUID(c.SignedInUser), rulesToDelete...)
if err != nil {
return err
}
@@ -461,7 +461,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey
UIDs = append(UIDs, rule.UID)
}
if err = srv.store.DeleteAlertRulesByUID(tranCtx, c.SignedInUser.GetOrgID(), UIDs...); err != nil {
if err = srv.store.DeleteAlertRulesByUID(tranCtx, c.SignedInUser.GetOrgID(), ngmodels.NewUserUID(c.SignedInUser), UIDs...); err != nil {
return fmt.Errorf("failed to delete rules: %w", err)
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
deleteCommands := getRecordedCommand(ruleStore)
require.Len(t, deleteCommands, 1)
cmd := deleteCommands[0]
actualUIDs := cmd.Params[1].([]string)
actualUIDs := cmd.Params[2].([]string)
require.Len(t, actualUIDs, len(expectedRules))
for _, rule := range expectedRules {
require.Containsf(t, actualUIDs, rule.UID, "Rule %s was expected to be deleted but it wasn't", rule.UID)
+1 -1
View File
@@ -28,7 +28,7 @@ type RuleStore interface {
// and return the map of uuid to id.
InsertAlertRules(ctx context.Context, user *ngmodels.UserUID, rules []ngmodels.AlertRule) ([]ngmodels.AlertRuleKeyWithId, error)
UpdateAlertRules(ctx context.Context, user *ngmodels.UserUID, rules []ngmodels.UpdateRule) error
DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error
DeleteAlertRulesByUID(ctx context.Context, orgID int64, user *ngmodels.UserUID, ruleUID ...string) error
// IncreaseVersionForAllRulesInNamespaces Increases version for all rules that have specified namespace uids
IncreaseVersionForAllRulesInNamespaces(ctx context.Context, orgID int64, namespaceUIDs []string) ([]ngmodels.AlertRuleKeyWithVersion, error)
@@ -556,7 +556,7 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity
})
}
}
if err := service.deleteRules(ctx, user.GetOrgID(), delta.Delete...); err != nil {
if err := service.deleteRules(ctx, user, delta.Delete...); err != nil {
return err
}
}
@@ -749,7 +749,7 @@ func (service *AlertRuleService) DeleteAlertRule(ctx context.Context, user ident
// This is different from deleting groups. We delete the rules directly rather than persisting a delta here to keep the semantics the same.
// TODO: Either persist a delta here as a breaking change, or deprecate this endpoint in favor of the group endpoint.
return service.xact.InTransaction(ctx, func(ctx context.Context) error {
return service.deleteRules(ctx, user.GetOrgID(), rule)
return service.deleteRules(ctx, user, rule)
})
}
@@ -775,18 +775,18 @@ func (service *AlertRuleService) checkLimitsTransactionCtx(ctx context.Context,
}
// deleteRules deletes a set of target rules and associated data, while checking for database consistency.
func (service *AlertRuleService) deleteRules(ctx context.Context, orgID int64, targets ...*models.AlertRule) error {
func (service *AlertRuleService) deleteRules(ctx context.Context, user identity.Requester, targets ...*models.AlertRule) error {
uids := make([]string, 0, len(targets))
for _, tgt := range targets {
if tgt != nil {
uids = append(uids, tgt.UID)
}
}
if err := service.ruleStore.DeleteAlertRulesByUID(ctx, orgID, uids...); err != nil {
if err := service.ruleStore.DeleteAlertRulesByUID(ctx, user.GetOrgID(), models.NewUserUID(user), uids...); err != nil {
return err
}
for _, uid := range uids {
if err := service.provenanceStore.DeleteProvenance(ctx, &models.AlertRule{UID: uid}, orgID); err != nil {
if err := service.provenanceStore.DeleteProvenance(ctx, &models.AlertRule{UID: uid}, user.GetOrgID()); err != nil {
// We failed to clean up the record, but this doesn't break things. Log it and move on.
service.log.Warn("Failed to delete provenance record for rule: %w", err)
}
@@ -1726,7 +1726,7 @@ func TestDeleteRuleGroup(t *testing.T) {
func TestDeleteRuleGroups(t *testing.T) {
orgID1 := rand.Int63()
orgID2 := rand.Int63()
u := &user.SignedInUser{OrgID: orgID1}
u := &user.SignedInUser{OrgID: orgID1, UserUID: "test-test"}
// Create groups across different orgs and namespaces
groupKey1 := models.AlertRuleGroupKey{
@@ -1805,6 +1805,7 @@ func TestDeleteRuleGroups(t *testing.T) {
// Verify only rules from group1 in org1 were deleted
deletes := getDeletedRules(t, ruleStore)
require.Len(t, deletes, 1)
require.Equal(t, "test-test", deletes[0].userID)
require.ElementsMatch(t, getUIDs(rules1), deletes[0].uids)
})
@@ -2045,8 +2046,9 @@ func getDeleteQueries(ruleStore *fakes.RuleStore) []fakes.GenericRecordedQuery {
}
type deleteRuleOperation struct {
orgID int64
uids []string
orgID int64
userID string
uids []string
}
func getDeletedRules(t *testing.T, ruleStore *fakes.RuleStore) []deleteRuleOperation {
@@ -2058,12 +2060,20 @@ func getDeletedRules(t *testing.T, ruleStore *fakes.RuleStore) []deleteRuleOpera
orgID, ok := q.Params[0].(int64)
require.True(t, ok, "orgID parameter should be int64")
uids, ok := q.Params[1].([]string)
uid := ""
userUID, ok := q.Params[1].(*models.UserUID)
require.True(t, ok, "parameter should be UserUID")
if userUID != nil {
uid = string(*userUID)
}
uids, ok := q.Params[2].([]string)
require.True(t, ok, "uids parameter should be []string")
operations = append(operations, deleteRuleOperation{
orgID: orgID,
uids: uids,
orgID: orgID,
userID: uid,
uids: uids,
})
}
return operations
@@ -2077,9 +2087,10 @@ func createAlertRuleService(t *testing.T, folderService folder.Service) AlertRul
Cfg: setting.UnifiedAlertingSettings{
BaseInterval: time.Second * 10,
},
Logger: log.NewNopLogger(),
FolderService: folderService,
Bus: bus.ProvideBus(tracing.InitializeTracerForTest()),
Logger: log.NewNopLogger(),
FolderService: folderService,
Bus: bus.ProvideBus(tracing.InitializeTracerForTest()),
FeatureToggles: featuremgmt.WithFeatures(),
}
// store := fakes.NewRuleStore(t)
quotas := MockQuotaChecker{}
+1 -1
View File
@@ -35,7 +35,7 @@ type RuleStore interface {
GetRuleGroupInterval(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string) (int64, error)
InsertAlertRules(ctx context.Context, user *models.UserUID, rule []models.AlertRule) ([]models.AlertRuleKeyWithId, error)
UpdateAlertRules(ctx context.Context, user *models.UserUID, rule []models.UpdateRule) error
DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error
DeleteAlertRulesByUID(ctx context.Context, orgID int64, user *models.UserUID, ruleUID ...string) error
GetAlertRulesGroupByRuleUID(ctx context.Context, query *models.GetAlertRulesGroupByRuleUIDQuery) ([]*models.AlertRule, error)
}
+73 -8
View File
@@ -39,7 +39,10 @@ var (
)
// DeleteAlertRulesByUID is a handler for deleting an alert rule.
func (st DBstore) DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error {
func (st DBstore) DeleteAlertRulesByUID(ctx context.Context, orgID int64, user *ngmodels.UserUID, ruleUID ...string) error {
if len(ruleUID) == 0 {
return nil
}
logger := st.Logger.New("org_id", orgID, "rule_uids", ruleUID)
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
rows, err := sess.Table(alertRule{}).Where("org_id = ?", orgID).In("uid", ruleUID).Delete(alertRule{})
@@ -57,12 +60,6 @@ func (st DBstore) DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUI
})
}
rows, err = sess.Table(alertRuleVersion{}).Where("rule_org_id = ?", orgID).In("rule_uid", ruleUID).Delete(alertRule{})
if err != nil {
return err
}
logger.Debug("Deleted alert rule versions", "count", rows)
rows, err = sess.Table("alert_instance").Where("rule_org_id = ?", orgID).In("rule_uid", ruleUID).Delete(alertRule{})
if err != nil {
return err
@@ -75,10 +72,78 @@ func (st DBstore) DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUI
}
logger.Debug("Deleted alert rule state", "count", rows)
var versions []alertRuleVersion
if st.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertRuleRestore) {
versions, err = st.getLatestVersionOfRulesByUID(ctx, orgID, ruleUID)
if err != nil {
logger.Error("Failed to get latest version of deleted alert rules. The recovery will not be possible", "error", err)
}
for idx := range versions {
version := &versions[idx]
version.ID = 0
version.RuleUID = ""
version.Created = TimeNow()
version.CreatedBy = nil
if user != nil {
version.CreatedBy = util.Pointer(string(*user))
}
}
}
rows, err = sess.Table(alertRuleVersion{}).Where("rule_org_id = ?", orgID).In("rule_uid", ruleUID).Delete(alertRule{})
if err != nil {
return err
}
logger.Debug("Deleted alert rule versions", "count", rows)
if len(versions) > 0 {
_, err = sess.Insert(versions)
if err != nil {
return fmt.Errorf("failed to persist deleted rule for recovery: %w", err)
}
logger.Debug("Inserted alert rule versions for recovery", "count", len(versions))
}
return nil
})
}
func (st DBstore) getLatestVersionOfRulesByUID(ctx context.Context, orgID int64, ruleUIDs []string) ([]alertRuleVersion, error) {
var result []alertRuleVersion
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
args, in := getINSubQueryArgs(ruleUIDs)
// take only the latest versions of each rule by GUID
rows, err := sess.SQL(fmt.Sprintf(`
SELECT v1.* FROM alert_rule_version AS v1
INNER JOIN (
SELECT rule_guid, MAX(id) AS id
FROM alert_rule_version
WHERE rule_org_id = ?
AND rule_uid IN (%s)
GROUP BY rule_guid
) AS v2 ON v1.rule_guid = v2.rule_guid AND v1.id = v2.id
`, strings.Join(in, ",")), append([]any{orgID}, args...)...).Rows(new(alertRuleVersion))
if err != nil {
return err
}
result = make([]alertRuleVersion, 0, len(ruleUIDs))
for rows.Next() {
rule := new(alertRuleVersion)
err = rows.Scan(rule)
if err != nil {
st.Logger.Error("Invalid rule version found in DB store, ignoring it", "func", "getLatestVersionOfRulesByUID", "error", err)
continue
}
result = append(result, *rule)
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
// IncreaseVersionForAllRulesInNamespaces Increases version for all rules that have specified namespace. Returns all rules that belong to the namespaces
func (st DBstore) IncreaseVersionForAllRulesInNamespaces(ctx context.Context, orgID int64, namespaceUIDs []string) ([]ngmodels.AlertRuleKeyWithVersion, error) {
var keys []ngmodels.AlertRuleKeyWithVersion
@@ -820,7 +885,7 @@ func (st DBstore) DeleteInFolders(ctx context.Context, orgID int64, folderUIDs [
}
}
if err := st.DeleteAlertRulesByUID(ctx, orgID, uids...); err != nil {
if err := st.DeleteAlertRulesByUID(ctx, orgID, ngmodels.NewUserUID(user), uids...); err != nil {
return err
}
}
+77 -8
View File
@@ -27,6 +27,7 @@ import (
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
"github.com/grafana/grafana/pkg/services/ngalert/testutil"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/infra/db"
@@ -685,7 +686,6 @@ func TestIntegration_DeleteInFolder(t *testing.T) {
b := &fakeBus{}
logger := log.New("test-dbstore")
store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b)
rule := createRule(t, store, nil)
t.Run("should not be able to delete folder without permissions to delete rules", func(t *testing.T) {
@@ -714,6 +714,9 @@ func TestIntegration_DeleteAlertRulesByUID(t *testing.T) {
sqlStore := db.InitTestDB(t)
cfg := setting.NewCfg()
cfg.UnifiedAlerting.BaseInterval = 1 * time.Second
cfg.UnifiedAlerting.RuleVersionRecordLimit = -1
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
logger := log.New("test-dbstore")
store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, &fakeBus{})
@@ -742,7 +745,7 @@ func TestIntegration_DeleteAlertRulesByUID(t *testing.T) {
called = true
return nil
}
err := store.DeleteAlertRulesByUID(context.Background(), rule.OrgID, rule.UID)
err := store.DeleteAlertRulesByUID(context.Background(), rule.OrgID, &models.AlertingUserUID, rule.UID)
require.NoError(t, err)
require.True(t, called)
})
@@ -769,7 +772,7 @@ func TestIntegration_DeleteAlertRulesByUID(t *testing.T) {
require.Len(t, savedInstances, 1)
// Delete the rule
err = store.DeleteAlertRulesByUID(context.Background(), rule.OrgID, rule.UID)
err = store.DeleteAlertRulesByUID(context.Background(), rule.OrgID, &models.AlertingUserUID, rule.UID)
require.NoError(t, err)
// Now there should be no alert rule state
@@ -780,6 +783,71 @@ func TestIntegration_DeleteAlertRulesByUID(t *testing.T) {
require.NoError(t, err)
require.Empty(t, savedInstances)
})
t.Run("should remove all version and insert one with empty rule_uid", func(t *testing.T) {
orgID := int64(rand.Intn(1000))
gen = gen.With(gen.WithOrgID(orgID))
// Create a new store to pass the custom bus to check the signal
b := &fakeBus{}
logger := log.New("test-dbstore")
store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b)
store.FeatureToggles = featuremgmt.WithFeatures(featuremgmt.FlagAlertRuleRestore)
result, err := store.InsertAlertRules(context.Background(), &models.AlertingUserUID, gen.GenerateMany(3))
uids := make([]string, 0, len(result))
for _, rule := range result {
uids = append(uids, rule.UID)
}
require.NoError(t, err)
rules, err := store.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{OrgID: orgID, RuleUIDs: uids})
require.NoError(t, err)
updates := make([]models.UpdateRule, 0, len(rules))
for _, rule := range rules {
rule2 := models.CopyRule(rule, gen.WithTitle(util.GenerateShortUID()))
updates = append(updates, models.UpdateRule{
Existing: rule,
New: *rule2,
})
}
err = store.UpdateAlertRules(context.Background(), &models.AlertingUserUID, updates)
require.NoError(t, err)
versions, err := store.GetAlertRuleVersions(context.Background(), orgID, rules[0].GUID)
require.NoError(t, err)
require.Len(t, versions, 2)
err = store.DeleteAlertRulesByUID(context.Background(), orgID, util.Pointer(models.UserUID("test")), uids...)
require.NoError(t, err)
guids := make([]string, 0, len(rules))
for _, rule := range rules {
guids = append(guids, rule.GUID)
}
_ = sqlStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
var versions []alertRuleVersion
err = sess.Table(alertRuleVersion{}).Where(`rule_uid = ''`).In("rule_guid", guids).Find(&versions)
require.NoError(t, err)
require.Len(t, versions, len(rules)) // should be one version per GUID
for _, version := range versions {
assert.Equal(t, "", version.RuleUID)
assert.Equal(t, "test", *version.CreatedBy)
// Remove the GUID from guids
for i, guid := range guids {
if guid == version.RuleGUID {
guids = append(guids[:i], guids[i+1:]...)
break
}
}
}
// Ensure that guids is empty
assert.Empty(t, guids, "Some rules are left unrecoverable")
return nil
})
})
}
func TestIntegrationInsertAlertRules(t *testing.T) {
@@ -1894,11 +1962,12 @@ func createTestStore(
bus bus.Bus,
) *DBstore {
return &DBstore{
SQLStore: sqlStore,
FolderService: folderService,
Logger: logger,
Cfg: cfg,
Bus: bus,
SQLStore: sqlStore,
FolderService: folderService,
Logger: logger,
Cfg: cfg,
Bus: bus,
FeatureToggles: featuremgmt.WithFeatures(),
}
}
+2 -2
View File
@@ -102,10 +102,10 @@ func (f *RuleStore) GetRecordedCommands(predicate func(cmd any) (any, bool)) []a
return result
}
func (f *RuleStore) DeleteAlertRulesByUID(_ context.Context, orgID int64, UIDs ...string) error {
func (f *RuleStore) DeleteAlertRulesByUID(_ context.Context, orgID int64, user *models.UserUID, UIDs ...string) error {
f.RecordedOps = append(f.RecordedOps, GenericRecordedQuery{
Name: "DeleteAlertRulesByUID",
Params: []any{orgID, UIDs},
Params: []any{orgID, user, UIDs},
})
rules := f.Rules[orgID]
+4
View File
@@ -20,6 +20,7 @@ type FakeOrgService struct {
ExpectedOrgUsers []*org.OrgUserDTO
ExpectedSearchOrgUsersResult *org.SearchOrgUsersQueryResult
ExpectedOrgListResponse OrgListResponse
SearchOrgUsersFn func(context.Context, *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error)
}
func NewOrgServiceFake() *FakeOrgService {
@@ -98,6 +99,9 @@ func (f *FakeOrgService) RemoveOrgUser(ctx context.Context, cmd *org.RemoveOrgUs
}
func (f *FakeOrgService) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) {
if f.SearchOrgUsersFn != nil {
return f.SearchOrgUsersFn(ctx, query)
}
return f.ExpectedSearchOrgUsersResult, f.ExpectedError
}
+23 -10
View File
@@ -10,6 +10,7 @@ import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -100,16 +101,28 @@ func (s *Service) Create(ctx context.Context, cmd *user.CreateUserCommand) (*use
return nil, user.ErrEmptyUsernameAndEmail.Errorf("user cannot be created with empty username and email")
}
cmdOrg := org.GetOrgIDForNewUserCommand{
Email: cmd.Email,
Login: cmd.Login,
OrgID: cmd.OrgID,
OrgName: cmd.OrgName,
SkipOrgSetup: cmd.SkipOrgSetup,
}
orgID, err := s.orgService.GetIDForNewUser(ctx, cmdOrg)
if err != nil {
return nil, err
// if the user is provisioned, use the org ID from the requester
var orgID int64
var err error
if cmd.IsProvisioned {
requester, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
orgID = requester.GetOrgID()
} else {
cmdOrg := org.GetOrgIDForNewUserCommand{
Email: cmd.Email,
Login: cmd.Login,
OrgID: cmd.OrgID,
OrgName: cmd.OrgName,
SkipOrgSetup: cmd.SkipOrgSetup,
}
orgID, err = s.orgService.GetIDForNewUser(ctx, cmdOrg)
if err != nil {
return nil, err
}
}
if cmd.Email == "" {
cmd.Email = cmd.Login
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/grafana/grafana/pkg/storage/unified/apistore
go 1.23.5
go 1.23.7
replace (
github.com/grafana/grafana => ../../../..
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/grafana/grafana/pkg/storage/unified/resource
go 1.23.5
go 1.23.7
replace (
github.com/grafana/grafana => ../../../..
+1 -3
View File
@@ -1,8 +1,6 @@
module github.com/grafana/grafana/pkg/util/xorm
go 1.23.0
toolchain go1.24.0
go 1.23.7
require (
github.com/googleapis/go-sql-spanner v1.11.1
+17 -9
View File
@@ -43,11 +43,18 @@ export const AnnoKeyFolderUrl = 'grafana.app/folderUrl';
export const AnnoKeyMessage = 'grafana.app/message';
export const AnnoKeySlug = 'grafana.app/slug';
// Identify where values came from
export const AnnoKeyRepoName = 'grafana.app/repoName';
export const AnnoKeyRepoPath = 'grafana.app/repoPath';
export const AnnoKeyRepoHash = 'grafana.app/repoHash';
export const AnnoKeyRepoTimestamp = 'grafana.app/repoTimestamp';
export enum ManagerKind {
Repo = 'repo',
Terraform = 'terraform',
Kubectl = 'kubectl',
Plugin = 'plugin',
}
export const AnnoKeyManagerKind = 'grafana.app/managedBy';
export const AnnoKeyManagerIdentity = 'grafana.app/managerId';
export const AnnoKeySourcePath = 'grafana.app/sourcePath';
export const AnnoKeySourceChecksum = 'grafana.app/sourceChecksum';
export const AnnoKeySourceTimestamp = 'grafana.app/sourceTimestamp';
export const AnnoKeySavedFromUI = 'grafana.app/saved-from-ui';
export const AnnoKeyDashboardNotFound = 'grafana.app/dashboard-not-found';
@@ -66,10 +73,11 @@ type GrafanaAnnotations = {
[AnnoKeyFolder]?: string;
[AnnoKeySlug]?: string;
[AnnoKeyRepoName]?: string;
[AnnoKeyRepoPath]?: string;
[AnnoKeyRepoHash]?: string;
[AnnoKeyRepoTimestamp]?: string;
[AnnoKeyManagerKind]?: ManagerKind;
[AnnoKeyManagerIdentity]?: string;
[AnnoKeySourcePath]?: string;
[AnnoKeySourceChecksum]?: string;
[AnnoKeySourceTimestamp]?: string;
};
// Annotations provided by the front-end client
@@ -42,6 +42,7 @@ import { VariablesChanged } from 'app/features/variables/types';
import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types';
import { ShowConfirmModalEvent } from 'app/types/events';
import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, AnnoKeySourcePath, ManagerKind } from '../../apiserver/types';
import { DashboardEditPane } from '../edit-pane/DashboardEditPane';
import { PanelEditor } from '../panel-edit/PanelEditor';
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
@@ -741,6 +742,35 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
getDashboardChanges(saveTimeRange?: boolean, saveVariables?: boolean, saveRefresh?: boolean): DashboardChangeInfo {
return this._serializer.getDashboardChangesFromScene(this, { saveTimeRange, saveVariables, saveRefresh });
}
getManagerKind(): ManagerKind | undefined {
return this.state.meta.k8s?.annotations?.[AnnoKeyManagerKind];
}
isManaged() {
return Boolean(this.getManagerKind());
}
isManagedRepository() {
return Boolean(this.getManagerKind() === ManagerKind.Repo);
}
getPath() {
return this.state.meta.k8s?.annotations?.[AnnoKeySourcePath];
}
setManager(kind: ManagerKind, id: string) {
this.setState({
meta: {
k8s: {
annotations: {
[AnnoKeyManagerKind]: kind,
[AnnoKeyManagerIdentity]: id,
},
},
},
});
}
}
export class DashboardVariableDependency implements SceneVariableDependencyConfigLike {
@@ -0,0 +1,23 @@
import { Badge } from '@grafana/ui';
import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, ManagerKind } from 'app/features/apiserver/types';
import { DashboardMeta } from 'app/types';
export default function ManagedDashboardNavBarBadge({ meta }: { meta: DashboardMeta }) {
const obj = meta.k8s;
if (!obj?.annotations) {
return;
}
let text = 'Provisioned';
const kind = obj.annotations?.[AnnoKeyManagerKind];
const id = obj.annotations?.[AnnoKeyManagerIdentity];
switch (kind) {
case ManagerKind.Terraform:
text = 'Terraform';
case ManagerKind.Kubectl:
text = 'Kubectl';
case ManagerKind.Plugin:
text = `Plugin: ${id}`;
}
return <Badge color="darkgrey" icon="exchange-alt" text={text} key="provisioned-dashboard-button-badge" />;
}
@@ -37,6 +37,7 @@ import { isLibraryPanel } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { GoToSnapshotOriginButton } from './GoToSnapshotOriginButton';
import ManagedDashboardNavBarBadge from './ManagedDashboardNavBarBadge';
interface Props {
dashboard: DashboardScene;
@@ -69,7 +70,7 @@ export function ToolbarActions({ dashboard }: Props) {
const isViewingPanel = Boolean(viewPanelScene);
const isEditedPanelDirty = usePanelEditDirty(editPanel);
const isEditingLibraryPanel = editPanel && isLibraryPanel(editPanel.state.panelRef.resolve());
const isNew = !Boolean(uid);
const isNew = !Boolean(uid || dashboard.isManaged());
const hasCopiedPanel = store.exists(LS_PANEL_COPY_KEY);
// Means we are not in settings view, fullscreen panel or edit panel
@@ -77,6 +78,7 @@ export function ToolbarActions({ dashboard }: Props) {
const isEditingAndShowingDashboard = isEditing && isShowingDashboard;
const showScopesSelector = config.featureToggles.scopeFilters && !isEditing;
const dashboardNewLayouts = config.featureToggles.dashboardNewLayouts;
const isManaged = Boolean(dashboard.isManaged());
if (!isEditingPanel) {
// This adds the presence indicators in enterprise
@@ -125,6 +127,16 @@ export function ToolbarActions({ dashboard }: Props) {
});
}
if (isManaged && meta.canEdit) {
toolbarActions.push({
group: 'icon-actions',
condition: true,
render: () => {
return <ManagedDashboardNavBarBadge meta={meta} />;
},
});
}
const isDevEnv = config.buildInfo.env === 'development';
toolbarActions.push({
@@ -548,7 +560,7 @@ export function ToolbarActions({ dashboard }: Props) {
}
// If we only can save as copy
if (canSaveAs && !meta.canSave && !meta.canMakeEditable) {
if (canSaveAs && !meta.canSave && !meta.canMakeEditable && !isManaged) {
return (
<Button
onClick={() => {
@@ -26,7 +26,6 @@ import {
getGridItemKeyForPanelId,
getDashboardSceneFor,
} from '../../utils/utils';
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
@@ -180,22 +179,6 @@ export class DefaultGridLayoutManager
return panels;
}
public hasVizPanels(): boolean {
for (const child of this.state.grid.state.children) {
if (child instanceof DashboardGridItem) {
return true;
} else if (child instanceof SceneGridRow) {
for (const rowChild of child.state.children) {
if (rowChild instanceof DashboardGridItem) {
return true;
}
}
}
}
return false;
}
public addNewRow(): SceneGridRow {
const id = dashboardSceneGraph.getNextPanelId(this);
@@ -224,17 +207,6 @@ export class DefaultGridLayoutManager
return row;
}
public addNewTab() {
const shouldAddTab = this.hasVizPanels();
const tabsLayout = TabsLayoutManager.createFromLayout(this);
if (shouldAddTab) {
tabsLayout.addNewTab();
}
getDashboardSceneFor(this).switchLayout(tabsLayout);
}
public editModeChanged(isEditing: boolean) {
const updateResizeAndDragging = () => {
this.state.grid.setState({ isDraggable: isEditing, isResizable: isEditing });
@@ -4,14 +4,7 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan
import { joinCloneKeys } from '../../utils/clone';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import {
getDashboardSceneFor,
getGridItemKeyForPanelId,
getPanelIdForVizPanel,
getVizPanelKeyForPanelId,
} from '../../utils/utils';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
import { getGridItemKeyForPanelId, getPanelIdForVizPanel, getVizPanelKeyForPanelId } from '../../utils/utils';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
@@ -111,16 +104,6 @@ export class ResponsiveGridLayoutManager
return panels;
}
public hasVizPanels(): boolean {
for (const child of this.state.layout.state.children) {
if (child instanceof ResponsiveGridItem) {
return true;
}
}
return false;
}
public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
return this.clone({
layout: this.state.layout.clone({
@@ -143,28 +126,6 @@ export class ResponsiveGridLayoutManager
});
}
public addNewRow() {
const shouldAddRow = this.hasVizPanels();
const rowsLayout = RowsLayoutManager.createFromLayout(this);
if (shouldAddRow) {
rowsLayout.addNewRow();
}
getDashboardSceneFor(this).switchLayout(rowsLayout);
}
public addNewTab() {
const shouldAddTab = this.hasVizPanels();
const tabsLayout = TabsLayoutManager.createFromLayout(this);
if (shouldAddTab) {
tabsLayout.addNewTab();
}
getDashboardSceneFor(this).switchLayout(tabsLayout);
}
public getOptions(): OptionsPaneItemDescriptor[] {
return getEditOptions(this);
}
@@ -65,16 +65,6 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
return panels;
}
public hasVizPanels(): boolean {
for (const row of this.state.rows) {
if (row.getLayout().hasVizPanels()) {
return true;
}
}
return false;
}
public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
throw new Error('Method not implemented.');
}
@@ -85,16 +85,6 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
throw new Error('Method not implemented.');
}
public hasVizPanels(): boolean {
for (const tab of this.state.tabs) {
if (tab.getLayout().hasVizPanels()) {
return true;
}
}
return false;
}
public addNewTab() {
const currentTab = new TabItem();
this.setState({ tabs: [...this.state.tabs, currentTab], currentTabIndex: this.state.tabs.length });
@@ -39,11 +39,6 @@ export interface DashboardLayoutManager<S = {}> extends SceneObject {
*/
getVizPanels(): VizPanel[];
/**
* Check if the layout has viz panels
*/
hasVizPanels(): boolean;
/**
* Notify the layout manager that the edit mode has changed
* @param isEditing
+1
View File
@@ -514,6 +514,7 @@ export class Explore extends PureComponent<Props, ExploreState> {
dataFrames={dataFrames}
splitOpenFn={this.onSplitOpen('traceView')}
scrollElement={this.scrollElement}
timeRange={queryResponse.timeRange}
/>
</ContentOutlineItem>
)
@@ -3,8 +3,8 @@ import userEvent from '@testing-library/user-event';
import { createRef } from 'react';
import { Provider } from 'react-redux';
import { DataFrame, MutableDataFrame } from '@grafana/data';
import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime';
import { DataFrame, MutableDataFrame, TimeRange } from '@grafana/data';
import { DataSourceSrv, setDataSourceSrv, setPluginLinksHook } from '@grafana/runtime';
import { configureStore } from '../../../store/configureStore';
@@ -25,6 +25,7 @@ function getTraceView(frames: DataFrame[]) {
traceProp={transformDataFrames(frames[0])!}
datasource={undefined}
topOfViewRef={topOfViewRef}
timeRange={{} as TimeRange}
/>
</Provider>
);
@@ -47,6 +48,11 @@ function renderTraceViewNew() {
describe('TraceView', () => {
beforeAll(() => {
setPluginLinksHook(() => ({
isLoading: false,
links: [],
}));
setDataSourceSrv({
getInstanceSettings() {
return undefined;
@@ -13,6 +13,7 @@ import {
LinkModel,
mapInternalLinkToExplore,
SplitOpen,
TimeRange,
} from '@grafana/data';
import { getTraceToLogsOptions, TraceToMetricsData, TraceToProfilesData } from '@grafana/o11y-ds-frontend';
import { getTemplateSrv } from '@grafana/runtime';
@@ -67,6 +68,7 @@ type Props = {
focusedSpanId?: string;
createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel<Field>;
spanFilters?: SearchProps;
timeRange: TimeRange;
};
export function TraceView(props: Props) {
@@ -238,6 +240,8 @@ export function TraceView(props: Props) {
setTraceFlameGraphs={setTraceFlameGraphs}
redrawListView={redrawListView}
setRedrawListView={setRedrawListView}
timeRange={props.timeRange}
app={exploreId ? CoreApp.Explore : CoreApp.Unknown}
/>
</>
) : (
@@ -2,6 +2,8 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { TimeRange } from '@grafana/data';
import { configureStore } from '../../../store/configureStore';
import { frameOld } from './TraceView.test';
@@ -11,6 +13,7 @@ jest.mock('@grafana/runtime', () => {
return {
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
usePluginLinks: jest.fn().mockReturnValue({ isLoading: false, links: [] }),
};
});
@@ -19,7 +22,7 @@ function renderTraceViewContainer(frames = [frameOld]) {
const { container, baseElement } = render(
<Provider store={store}>
<TraceViewContainer exploreId="left" dataFrames={frames} splitOpenFn={() => {}} />
<TraceViewContainer exploreId="left" dataFrames={frames} splitOpenFn={() => {}} timeRange={{} as TimeRange} />
</Provider>
);
return {
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { DataFrame, SplitOpen } from '@grafana/data';
import { DataFrame, SplitOpen, TimeRange } from '@grafana/data';
import { PanelChrome } from '@grafana/ui';
import { StoreState, useSelector } from 'app/types';
@@ -12,12 +12,13 @@ interface Props {
splitOpenFn: SplitOpen;
exploreId: string;
scrollElement?: Element;
timeRange: TimeRange;
}
export function TraceViewContainer(props: Props) {
// At this point we only show single trace
const frame = props.dataFrames[0];
const { dataFrames, splitOpenFn, exploreId, scrollElement } = props;
const { dataFrames, splitOpenFn, exploreId, scrollElement, timeRange } = props;
const traceProp = useMemo(() => transformDataFrames(frame), [frame]);
const datasource = useSelector(
(state: StoreState) => state.explore.panes[props.exploreId]?.datasourceInstance ?? undefined
@@ -36,6 +37,7 @@ export function TraceViewContainer(props: Props) {
scrollElement={scrollElement}
traceProp={traceProp}
datasource={datasource}
timeRange={timeRange}
/>
</PanelChrome>
);
@@ -0,0 +1,230 @@
import { CoreApp, TimeRange } from '@grafana/data';
import { usePluginLinks } from '@grafana/runtime';
import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer';
import { SpanLinkType } from '../../types/links';
import { TraceSpan } from '../../types/trace';
import { getSpanDetailLinkButtons, getProfileLinkButtonsContext } from './SpanDetailLinkButtons';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
usePluginLinks: jest.fn().mockReturnValue({ isLoading: false, links: [] }),
}));
const span = {
process: {
serviceName: 'test-service',
},
tags: [{ key: 'pyroscope.profile.id', value: 'test-profile' }],
} as TraceSpan;
const createSpanLink = jest.fn();
const timeRange = {
from: new Date(0),
to: new Date(1000),
} as unknown as TimeRange;
describe('getSpanDetailLinkButtons', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return empty buttons when createSpanLink is not provided', () => {
const result = getSpanDetailLinkButtons({
span,
createSpanLink: undefined,
datasourceType: 'test',
traceToProfilesOptions: undefined,
timeRange,
app: CoreApp.Explore,
});
expect(result.logLinkButton).toBeNull();
expect(result.profileLinkButtons).toBeNull();
expect(result.sessionLinkButton).toBeNull();
});
it('should create log link button when logs link exists', () => {
createSpanLink.mockReturnValue([{ type: SpanLinkType.Logs, href: '/logs', title: 'Logs' }]);
const result = getSpanDetailLinkButtons({
span,
createSpanLink,
datasourceType: 'test',
traceToProfilesOptions: undefined,
timeRange,
app: CoreApp.Explore,
});
expect(result.logLinkButton).toBeDefined();
expect(result.profileLinkButtons).toBeNull();
expect(result.sessionLinkButton).toBeNull();
});
it('should create profile link button when profiles link exists', () => {
createSpanLink.mockReturnValue([{ type: SpanLinkType.Profiles, href: '/profiles', title: RelatedProfilesTitle }]);
const result = getSpanDetailLinkButtons({
span,
createSpanLink,
datasourceType: 'test',
traceToProfilesOptions: {
datasourceUid: 'test-uid',
profileTypeId: 'test-type',
customQuery: false,
},
timeRange,
app: CoreApp.Explore,
});
expect(result.logLinkButton).toBeNull();
expect(result.profileLinkButtons).toBeDefined();
expect(result.sessionLinkButton).toBeNull();
});
it('should create session link button when session link exists', () => {
createSpanLink.mockReturnValue([{ type: SpanLinkType.Session, href: '/session', title: 'Session' }]);
const result = getSpanDetailLinkButtons({
span,
createSpanLink,
datasourceType: 'test',
traceToProfilesOptions: undefined,
timeRange,
app: CoreApp.Explore,
});
expect(result.logLinkButton).toBeNull();
expect(result.profileLinkButtons).toBeNull();
expect(result.sessionLinkButton).toBeDefined();
});
it('should create profile drilldown button when plugin link exists', () => {
createSpanLink.mockReturnValue([{ type: SpanLinkType.Profiles, href: '/profiles', title: RelatedProfilesTitle }]);
(usePluginLinks as jest.Mock).mockReturnValue({
isLoading: false,
links: [
{
pluginId: 'grafana-pyroscope-app',
title: 'Open in Profiles Drilldown',
onClick: jest.fn(),
},
],
});
const result = getSpanDetailLinkButtons({
span,
createSpanLink,
datasourceType: 'test',
traceToProfilesOptions: {
datasourceUid: 'test-uid',
profileTypeId: 'test-type',
customQuery: false,
},
timeRange,
app: CoreApp.Explore,
});
expect(result.profileLinkButtons).toBeDefined();
// Should render both the original profile link and the drilldown button
expect(result.profileLinkButtons?.props.children).toHaveLength(2);
});
it('should not create profile drilldown button when not in Explore', () => {
createSpanLink.mockReturnValue([{ type: SpanLinkType.Profiles, href: '/profiles', title: RelatedProfilesTitle }]);
(usePluginLinks as jest.Mock).mockReturnValue({
isLoading: false,
links: [
{
pluginId: 'grafana-pyroscope-app',
title: 'Open in Profiles Drilldown',
onClick: jest.fn(),
},
],
});
const result = getSpanDetailLinkButtons({
span,
createSpanLink,
datasourceType: 'test',
traceToProfilesOptions: {
datasourceUid: 'test-uid',
profileTypeId: 'test-type',
customQuery: false,
},
timeRange,
app: CoreApp.Dashboard,
});
expect(result.profileLinkButtons).toBeDefined();
// Should only render the original profile link
expect(result.profileLinkButtons?.props.children).toBeFalsy();
});
});
describe('getProfileLinkButtonsContext', () => {
const traceToProfilesOptions = {
datasourceUid: 'test-uid',
profileTypeId: 'test-type',
customQuery: false,
};
it('should create context with all properties', () => {
const context = getProfileLinkButtonsContext(span, traceToProfilesOptions, timeRange);
expect(context).toEqual({
serviceName: 'test-service',
profileTypeId: 'test-type',
spanSelector: 'test-profile',
explorationType: 'flame-graph',
timeRange: {
from: new Date(0).toISOString(),
to: new Date(1000).toISOString(),
},
datasource: {
uid: 'test-uid',
},
});
});
it('should handle missing traceToProfilesOptions', () => {
const context = getProfileLinkButtonsContext(span, undefined, timeRange);
expect(context).toEqual({
serviceName: 'test-service',
profileTypeId: '',
spanSelector: 'test-profile',
explorationType: 'flame-graph',
timeRange: {
from: new Date(0).toISOString(),
to: new Date(1000).toISOString(),
},
datasource: {
uid: undefined,
},
});
});
it('should handle missing service name', () => {
const spanWithoutService = {
process: {},
tags: [{ key: 'pyroscope.profile.id', value: 'test-profile' }],
} as TraceSpan;
const context = getProfileLinkButtonsContext(spanWithoutService, traceToProfilesOptions, timeRange);
expect(context.serviceName).toBe('');
});
it('should handle missing profile ID tag', () => {
const spanWithoutProfileId = {
process: { serviceName: 'test-service' },
tags: [],
} as unknown as TraceSpan;
const context = getProfileLinkButtonsContext(spanWithoutProfileId, traceToProfilesOptions, timeRange);
expect(context.spanSelector).toBe('');
});
});
@@ -0,0 +1,176 @@
import * as React from 'react';
import { CoreApp, IconName, PluginExtensionPoints, RawTimeRange, TimeRange } from '@grafana/data';
import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
import { config, locationService, reportInteraction, usePluginLinks } from '@grafana/runtime';
import { DataSourceRef } from '@grafana/schema';
import { Button, DataLinkButton } from '@grafana/ui';
import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer';
import { pyroscopeProfileIdTagKey } from '../../../createSpanLink';
import { SpanLinkFunc } from '../../types';
import { SpanLinkDef, SpanLinkType } from '../../types/links';
import { TraceSpan } from '../../types/trace';
export type ProfilesButtonContext = {
serviceName: string;
profileTypeId: string;
spanSelector: string;
explorationType: string;
timeRange: RawTimeRange;
datasource: DataSourceRef;
};
export type Props = {
span: TraceSpan;
traceToProfilesOptions?: TraceToProfilesOptions;
datasourceType: string;
timeRange: TimeRange;
createSpanLink?: SpanLinkFunc;
app: CoreApp;
};
export const getSpanDetailLinkButtons = (props: Props) => {
const { span, createSpanLink, traceToProfilesOptions, timeRange, datasourceType, app } = props;
let logLinkButton: JSX.Element | null = null;
let profileLinkButton: JSX.Element | null = null;
let sessionLinkButton: JSX.Element | null = null;
if (createSpanLink) {
const links = createSpanLink(span);
const logsLink = links?.filter((link) => link.type === SpanLinkType.Logs);
if (links && logsLink && logsLink.length > 0) {
logLinkButton = createLinkButton(logsLink[0], SpanLinkType.Logs, 'Logs for this span', 'gf-logs', datasourceType);
}
const profilesLink = links?.filter(
(link) => link.type === SpanLinkType.Profiles && link.title === RelatedProfilesTitle
);
if (links && profilesLink && profilesLink.length > 0) {
profileLinkButton = createLinkButton(
profilesLink[0],
SpanLinkType.Profiles,
'Profiles for this span',
'link',
datasourceType
);
}
const sessionLink = links?.filter((link) => link.type === SpanLinkType.Session);
if (links && sessionLink && sessionLink.length > 0) {
sessionLinkButton = createLinkButton(
sessionLink[0],
SpanLinkType.Session,
'Session for this span',
'frontend-observability',
datasourceType
);
}
}
let profileLinkButtons = profileLinkButton;
if (profileLinkButton) {
// ensure we have a profile link
const profilesDrilldownPluginId = 'grafana-pyroscope-app';
const context = getProfileLinkButtonsContext(span, traceToProfilesOptions, timeRange);
// if in explore, use the plugin extension point to get the link
// note: plugin extension point links are not currently supported in panel plugins
if (app === CoreApp.Explore) {
const extensionPointId = PluginExtensionPoints.TraceViewDetails;
const { links } = usePluginLinks({ extensionPointId, context, limitPerPlugin: 1 });
const link = links && links.length > 0 ? links.find((link) => link.pluginId === profilesDrilldownPluginId) : null;
const label = 'Open in Profiles Drilldown';
// if we have a plugin link, add a button to open in Grafana Profiles Drilldown
if (link) {
const profileDrilldownLinkButton = (
<Button
icon="link"
variant="primary"
size="sm"
onClick={() => {
if (link && link.onClick) {
reportInteraction('grafana_traces_trace_view_span_link_clicked', {
datasourceType,
grafana_version: config.buildInfo.version,
type: SpanLinkType.ProfilesDrilldown,
location: 'spanDetails',
});
link.onClick();
}
}}
>
{label}
</Button>
);
profileLinkButtons = (
<>
{profileLinkButton}
{profileDrilldownLinkButton}
</>
);
}
}
}
return { profileLinkButtons, logLinkButton, sessionLinkButton };
};
export const getProfileLinkButtonsContext = (
span: TraceSpan,
traceToProfilesOptions: TraceToProfilesOptions | undefined,
timeRange: TimeRange
) => {
const spanSelector = span.tags.filter((tag) => tag.key === pyroscopeProfileIdTagKey);
const context: ProfilesButtonContext = {
serviceName: span.process.serviceName ?? '',
profileTypeId: traceToProfilesOptions?.profileTypeId ?? '',
spanSelector: spanSelector.length === 1 && spanSelector[0].value ? spanSelector[0].value : '',
explorationType: 'flame-graph',
timeRange: {
from: timeRange.from.toISOString(),
to: timeRange.to.toISOString(),
},
datasource: { uid: traceToProfilesOptions?.datasourceUid },
};
return context;
};
const createLinkButton = (
link: SpanLinkDef,
type: SpanLinkType,
title: string,
icon: IconName,
datasourceType: string,
className?: string
) => {
return (
<DataLinkButton
link={{
...link,
title: title,
target: '_blank',
origin: link.field,
onClick: (event: React.MouseEvent) => {
// DataLinkButton assumes if you provide an onClick event you would want to prevent default behavior like navigation
// In this case, if an onClick is not defined, restore navigation to the provided href while keeping the tracking
// this interaction will not be tracked with link right clicks
reportInteraction('grafana_traces_trace_view_span_link_clicked', {
datasourceType,
grafana_version: config.buildInfo.version,
type,
location: 'spanDetails',
});
if (link.onClick) {
link.onClick?.(event);
} else {
locationService.push(link.href);
}
},
}}
buttonProps={{ icon, className }}
/>
);
};
@@ -19,7 +19,7 @@ import userEvent from '@testing-library/user-event';
import { createDataFrame, DataSourceInstanceSettings } from '@grafana/data';
import { data } from '@grafana/flamegraph';
import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime';
import { DataSourceSrv, setDataSourceSrv, setPluginLinksHook } from '@grafana/runtime';
import { pyroscopeProfileIdTagKey } from '../../../createSpanLink';
import traceGenerator from '../../demo/trace-generators';
@@ -70,6 +70,12 @@ describe('<SpanDetail>', () => {
createFocusSpanLink: jest.fn().mockReturnValue({}),
traceFlameGraphs: { [span.spanID]: createDataFrame(data) },
setRedrawListView: jest.fn(),
timeRange: {
raw: {
from: 0,
to: 1000000000000,
},
},
};
span.tags = [
@@ -156,6 +162,11 @@ describe('<SpanDetail>', () => {
props.logsToggle.mockReset();
props.logItemToggle.mockReset();
setPluginLinksHook(() => ({
isLoading: false,
links: [],
}));
setDataSourceSrv({
getList() {
return [pyroSettings];
@@ -15,29 +15,26 @@
import { css } from '@emotion/css';
import { SpanStatusCode } from '@opentelemetry/api';
import cx from 'classnames';
import * as React from 'react';
import {
CoreApp,
DataFrame,
dateTimeFormat,
GrafanaTheme2,
IconName,
LinkModel,
TimeRange,
TraceKeyValuePair,
TraceLog,
} from '@grafana/data';
import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
import { config, locationService, reportInteraction } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
import { DataLinkButton, Divider, Icon, TextArea, useStyles2 } from '@grafana/ui';
import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer';
import { Divider, Icon, TextArea, useStyles2 } from '@grafana/ui';
import { pyroscopeProfileIdTagKey } from '../../../createSpanLink';
import { autoColor } from '../../Theme';
import LabeledList from '../../common/LabeledList';
import { KIND, LIBRARY_NAME, LIBRARY_VERSION, STATUS, STATUS_MESSAGE, TRACE_STATE } from '../../constants/span';
import { SpanLinkFunc, TNil } from '../../types';
import { SpanLinkDef, SpanLinkType } from '../../types/links';
import { TraceLink, TraceSpan, TraceSpanReference } from '../../types/trace';
import { formatDuration } from '../utils';
@@ -46,6 +43,7 @@ import AccordianLogs from './AccordianLogs';
import AccordianReferences from './AccordianReferences';
import AccordianText from './AccordianText';
import DetailState from './DetailState';
import { getSpanDetailLinkButtons } from './SpanDetailLinkButtons';
import SpanFlameGraph from './SpanFlameGraph';
const getStyles = (theme: GrafanaTheme2) => {
@@ -168,6 +166,8 @@ export type SpanDetailProps = {
traceFlameGraphs: TraceFlameGraphs;
setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void;
setRedrawListView: (redraw: {}) => void;
timeRange: TimeRange;
app: CoreApp;
};
export default function SpanDetail(props: SpanDetailProps) {
@@ -193,6 +193,8 @@ export default function SpanDetail(props: SpanDetailProps) {
setTraceFlameGraphs,
traceToProfilesOptions,
setRedrawListView,
timeRange,
app,
} = props;
const {
isTagsOpen,
@@ -289,62 +291,14 @@ export default function SpanDetail(props: SpanDetailProps) {
});
}
const createLinkButton = (link: SpanLinkDef, type: SpanLinkType, title: string, icon: IconName) => {
return (
<DataLinkButton
link={{
...link,
title: title,
target: '_blank',
origin: link.field,
onClick: (event: React.MouseEvent) => {
// DataLinkButton assumes if you provide an onClick event you would want to prevent default behavior like navigation
// In this case, if an onClick is not defined, restore navigation to the provided href while keeping the tracking
// this interaction will not be tracked with link right clicks
reportInteraction('grafana_traces_trace_view_span_link_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
type,
location: 'spanDetails',
});
if (link.onClick) {
link.onClick?.(event);
} else {
locationService.push(link.href);
}
},
}}
buttonProps={{ icon }}
/>
);
};
let logLinkButton: JSX.Element | null = null;
let profileLinkButton: JSX.Element | null = null;
let sessionLinkButton: JSX.Element | null = null;
if (createSpanLink) {
const links = createSpanLink(span);
const logsLink = links?.filter((link) => link.type === SpanLinkType.Logs);
if (links && logsLink && logsLink.length > 0) {
logLinkButton = createLinkButton(logsLink[0], SpanLinkType.Logs, 'Logs for this span', 'gf-logs');
}
const profilesLink = links?.filter(
(link) => link.type === SpanLinkType.Profiles && link.title === RelatedProfilesTitle
);
if (links && profilesLink && profilesLink.length > 0) {
profileLinkButton = createLinkButton(profilesLink[0], SpanLinkType.Profiles, 'Profiles for this span', 'link');
}
const sessionLink = links?.filter((link) => link.type === SpanLinkType.Session);
if (links && sessionLink && sessionLink.length > 0) {
sessionLinkButton = createLinkButton(
sessionLink[0],
SpanLinkType.Session,
'Session for this span',
'frontend-observability'
);
}
}
const { profileLinkButtons, logLinkButton, sessionLinkButton } = getSpanDetailLinkButtons({
span,
createSpanLink,
datasourceType,
traceToProfilesOptions,
timeRange,
app,
});
const focusSpanLink = createFocusSpanLink(traceID, spanID);
return (
@@ -359,7 +313,7 @@ export default function SpanDetail(props: SpanDetailProps) {
</div>
<div className={styles.linkList}>
{logLinkButton}
{profileLinkButton}
{profileLinkButtons}
{sessionLinkButton}
</div>
<Divider spacing={1} />
@@ -16,6 +16,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createTheme } from '@grafana/data';
import { setPluginLinksHook } from '@grafana/runtime';
import DetailState from './SpanDetail/DetailState';
import { UnthemedSpanDetailRow, SpanDetailRowProps } from './SpanDetailRow';
@@ -47,12 +48,25 @@ const setup = (propOverrides?: SpanDetailRowProps) => {
traceStartTime: 1000,
theme: createTheme(),
traceFlameGraphs: {},
timeRange: {
raw: {
from: 0,
to: 1000000000000,
},
},
...propOverrides,
};
return render(<UnthemedSpanDetailRow {...(props as SpanDetailRowProps)} />);
};
describe('SpanDetailRow tests', () => {
beforeEach(() => {
setPluginLinksHook(() => ({
isLoading: false,
links: [],
}));
});
it('renders without exploding', () => {
expect(() => setup()).not.toThrow();
});
@@ -16,7 +16,7 @@ import { css } from '@emotion/css';
import classNames from 'classnames';
import { PureComponent } from 'react';
import { GrafanaTheme2, LinkModel, TraceKeyValuePair, TraceLog } from '@grafana/data';
import { CoreApp, GrafanaTheme2, LinkModel, TimeRange, TraceKeyValuePair, TraceLog } from '@grafana/data';
import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
import { TimeZone } from '@grafana/schema';
import { Button, clearButtonStyles, stylesFactory, withTheme2 } from '@grafana/ui';
@@ -103,6 +103,8 @@ export type SpanDetailRowProps = {
traceFlameGraphs: TraceFlameGraphs;
setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void;
setRedrawListView: (redraw: {}) => void;
timeRange: TimeRange;
app: CoreApp;
};
export class UnthemedSpanDetailRow extends PureComponent<SpanDetailRowProps> {
@@ -146,6 +148,8 @@ export class UnthemedSpanDetailRow extends PureComponent<SpanDetailRowProps> {
traceFlameGraphs,
setTraceFlameGraphs,
setRedrawListView,
timeRange,
app,
} = this.props;
const styles = getStyles(theme);
return (
@@ -193,6 +197,8 @@ export class UnthemedSpanDetailRow extends PureComponent<SpanDetailRowProps> {
traceFlameGraphs={traceFlameGraphs}
setTraceFlameGraphs={setTraceFlameGraphs}
setRedrawListView={setRedrawListView}
timeRange={timeRange}
app={app}
/>
</div>
</TimelineRow.Cell>
@@ -18,7 +18,7 @@ import memoizeOne from 'memoize-one';
import * as React from 'react';
import { RefObject } from 'react';
import { GrafanaTheme2, LinkModel, TraceKeyValuePair, TraceLog } from '@grafana/data';
import { CoreApp, GrafanaTheme2, LinkModel, TimeRange, TraceKeyValuePair, TraceLog } from '@grafana/data';
import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
import { config, reportInteraction } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
@@ -109,6 +109,8 @@ type TVirtualizedTraceViewOwnProps = {
setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void;
redrawListView: {};
setRedrawListView: (redraw: {}) => void;
timeRange: TimeRange;
app: CoreApp;
};
export type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TTraceTimeline;
@@ -557,6 +559,8 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
traceFlameGraphs,
setTraceFlameGraphs,
setRedrawListView,
timeRange,
app,
} = this.props;
const detailState = detailStates.get(spanID);
if (!trace || !detailState) {
@@ -598,6 +602,8 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
traceFlameGraphs={traceFlameGraphs}
setTraceFlameGraphs={setTraceFlameGraphs}
setRedrawListView={setRedrawListView}
timeRange={timeRange}
app={app}
/>
</div>
);
@@ -15,7 +15,7 @@
import { css } from '@emotion/css';
import { PureComponent, RefObject } from 'react';
import { GrafanaTheme2, LinkModel, TraceKeyValuePair, TraceLog } from '@grafana/data';
import { CoreApp, GrafanaTheme2, LinkModel, TimeRange, TraceKeyValuePair, TraceLog } from '@grafana/data';
import { SpanBarOptions, TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
import { config, reportInteraction } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
@@ -112,6 +112,8 @@ export type TProps = {
setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void;
redrawListView: {};
setRedrawListView: (redraw: {}) => void;
timeRange: TimeRange;
app: CoreApp;
};
type State = {
@@ -9,6 +9,7 @@ export enum SpanLinkType {
Traces = 'trace',
Metrics = 'metric',
Profiles = 'profile',
ProfilesDrilldown = 'profile-drilldown',
Session = 'session',
Unknown = 'unknown',
}
@@ -1,10 +1,15 @@
import { useMemo } from 'react';
import { css } from '@emotion/css';
import { useMemo, useRef, useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { SQLEditor } from '@grafana/plugin-ui';
import { useStyles2 } from '@grafana/ui';
import { ExpressionQuery } from '../types';
// Account for Monaco editor's border to prevent clipping
const EDITOR_BORDER_ADJUSTMENT = 2; // 1px border on top and bottom
interface Props {
refIds: Array<SelectableValue<string>>;
query: ExpressionQuery;
@@ -13,8 +18,10 @@ interface Props {
export const SqlExpr = ({ onChange, refIds, query }: Props) => {
const vars = useMemo(() => refIds.map((v) => v.value!), [refIds]);
const initialQuery = `select * from ${vars[0]} limit 1`;
const styles = useStyles2(getStyles);
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ height: 0 });
const onEditorChange = (expression: string) => {
onChange({
@@ -23,5 +30,37 @@ export const SqlExpr = ({ onChange, refIds, query }: Props) => {
});
};
return <SQLEditor query={query.expression || initialQuery} onChange={onEditorChange}></SQLEditor>;
// Set up resize observer to handle container resizing
useEffect(() => {
if (!containerRef.current) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
const { height } = entries[0].contentRect;
setDimensions({ height });
});
resizeObserver.observe(containerRef.current);
return () => resizeObserver.disconnect();
}, []);
return (
<div ref={containerRef} className={styles.editorContainer}>
<SQLEditor
query={query.expression || initialQuery}
onChange={onEditorChange}
height={dimensions.height - EDITOR_BORDER_ADJUSTMENT}
/>
</div>
);
};
const getStyles = () => ({
editorContainer: css({
height: '240px',
resize: 'vertical',
overflow: 'auto',
minHeight: '100px',
}),
});
@@ -53,6 +53,7 @@ function GraphiteQueryEditorContent() {
icon="pen"
variant="secondary"
aria-label="Toggle editor mode"
tooltip={state?.queryModel?.error}
onClick={() => {
dispatch(actions.toggleEditorMode());
}}
@@ -744,6 +744,9 @@ describe('graphiteDatasource', () => {
params: [{ multiple: true }],
},
updateText: () => {},
render: () => {
return '';
},
}));
});
@@ -143,7 +143,7 @@ export class GraphiteDatasource
target: query.target || '',
textEditor: false,
},
getTemplateSrv()
this.templateSrv
);
graphiteQuery.parseTarget();
@@ -77,6 +77,21 @@ export default class GraphiteQuery {
try {
this.parseTargetRecursive(astNode, null);
if (this.target.target) {
const oldQuery = this.target.target;
const newQuery = this.generateQueryString();
// Spaces, quotes, and commas are used when rendering the AST back into a string.
// We are removing these for less false positives of query changes.
const sanitizeQuery = (o: string): string => o.replace(/\s|'|"|,/g, '');
const oldSanitized = sanitizeQuery(oldQuery);
const newSanitized = sanitizeQuery(newQuery);
if (oldSanitized && newSanitized && oldSanitized !== newSanitized) {
throw new Error(
`Failed to make a visual query builder query that is equivalent to the query.\nOriginal query: ${oldQuery}\nQuery builder query: ${newQuery}`
);
}
}
} catch (err) {
if (err instanceof Error) {
console.error('error parsing target:', err.message);
@@ -181,16 +196,19 @@ export default class GraphiteQuery {
arrayMove(this.functions, index, index + offset);
}
updateModelTarget(targets: any) {
generateQueryString(): string {
const wrapFunction = (target: string, func: FuncInstance) => {
return func.render(target, (value: string) => {
return this.templateSrv ? this.templateSrv.replace(value, this.scopedVars) : value;
});
};
const metricPath = this.getSegmentPathUpTo(this.segments.length).replace(/\.?select metric$/, '');
return reduce(this.functions, wrapFunction, metricPath);
}
updateModelTarget(targets: any) {
if (!this.target.textEditor) {
const metricPath = this.getSegmentPathUpTo(this.segments.length).replace(/\.?select metric$/, '');
this.target.target = reduce(this.functions, wrapFunction, metricPath);
this.target.target = this.generateQueryString();
}
this.updateRenderedTarget(this.target, targets);
@@ -258,6 +258,20 @@ describe('Graphite query model', () => {
ctx.queryModel.updateModelTarget(targets);
expect(ctx.queryModel.target.target).toContain(nestedFunctionAsParam);
});
//This is not preferred behavior. The query builder cannot parse `maxSeries(sum(testSeries1), sum(testSeries2))` and when it can, remove this test
it('should return an error when visual query builder query does not match raw query', () => {
jest.spyOn(console, 'error').mockImplementation();
ctx.target = {
refId: 'A',
target: 'maxSeries(sum(testSeries1), sum(testSeries2))',
};
ctx.targets = [ctx.target];
ctx.queryModel = new GraphiteQuery(ctx.datasource, ctx.target, ctx.templateSrv);
expect(ctx.queryModel.error).toBe(
'Failed to make a visual query builder query that is equivalent to the query.\nOriginal query: maxSeries(sum(testSeries1), sum(testSeries2))\nQuery builder query: maxSeries(sumSeries(sumSeries(testSeries1), testSeries2))'
);
});
});
});
});
@@ -2,7 +2,6 @@ import { AnyAction } from '@reduxjs/toolkit';
import { Action, Dispatch } from 'redux';
import { DataQuery, TimeRange } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { TemplateSrv } from '../../../../features/templating/template_srv';
import { GraphiteDatasource } from '../datasource';
@@ -57,7 +56,7 @@ const reducer = async (action: Action, state: GraphiteQueryEditorState): Promise
state = {
...state,
...deps,
queryModel: new GraphiteQuery(deps.datasource, deps.target, getTemplateSrv()),
queryModel: new GraphiteQuery(deps.datasource, deps.target, state.templateSrv),
supportsTags: deps.datasource.supportsTags,
paused: false,
removeTagValue: '-- remove tag --',
@@ -52,6 +52,7 @@ export const TracesPanel = ({ data, options, replaceVariables }: PanelProps<Trac
focusedSpanId={options.focusedSpanId}
createFocusSpanLink={options.createFocusSpanLink}
spanFilters={replaceSearchVariables(replaceVariables, options.spanFilters)}
timeRange={data.timeRange}
/>
</div>
);
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.20.3-windowsservercore-1809
FROM golang:1.23.7-windowsservercore-1809
SHELL ["powershell", "-command"]
+1 -1
View File
@@ -3,7 +3,7 @@ global variables
"""
grabpl_version = "v3.1.2"
golang_version = "1.23.5"
golang_version = "1.23.7"
# nodejs_version should match what's in ".nvmrc", but without the v prefix.
nodejs_version = "22.11.0"
+1 -1
View File
@@ -1,5 +1,5 @@
module github.com/grafana/grafana/scripts/go-workspace
go 1.23.1
go 1.23.7
require golang.org/x/mod v0.20.0