Merge branch 'main' into adela/one_click_data_links

pull/97397/head
Adela Almasan 7 months ago
commit 05ae5be918
  1. 15
      .betterer.results
  2. 13
      .drone.yml
  3. 5
      .github/renovate.json5
  4. 1
      .gitignore
  5. 47
      apps/alerting/notifications/go.mod
  6. 63
      apps/alerting/notifications/go.sum
  7. 2
      docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md
  8. 120
      docs/sources/panels-visualizations/visualizations/pie-chart/index.md
  9. 4
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  10. 2
      docs/sources/setup-grafana/installation/_index.md
  11. 2
      e2e/dashboards-suite/dashboard-public-templating.spec.ts
  12. 2
      e2e/dashboards-suite/snapshot-create.spec.ts
  13. 2
      go.mod
  14. 2
      go.sum
  15. 8
      go.work.sum
  16. 6
      package.json
  17. 3
      packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx
  18. 2
      pkg/api/README.md
  19. 12
      pkg/build/cmd/publishaws.go
  20. 6
      pkg/build/cmd/publishaws_test.go
  21. 3
      pkg/build/go.mod
  22. 6
      pkg/build/go.sum
  23. 4
      pkg/server/module_server_test.go
  24. 6
      pkg/services/accesscontrol/dualwrite/reconciler.go
  25. 20
      pkg/services/authz/zanzana/common/info.go
  26. 107
      pkg/services/authz/zanzana/common/tuple.go
  27. 6
      pkg/services/authz/zanzana/schema/README.md
  28. 20
      pkg/services/authz/zanzana/schema/schema_core.fga
  29. 6
      pkg/services/authz/zanzana/schema/schema_folder.fga
  30. 23
      pkg/services/authz/zanzana/schema/schema_resource.fga
  31. 2
      pkg/services/authz/zanzana/server/server_batch_check.go
  32. 4
      pkg/services/authz/zanzana/server/server_batch_check_test.go
  33. 10
      pkg/services/authz/zanzana/server/server_capabilities.go
  34. 20
      pkg/services/authz/zanzana/server/server_capabilities_test.go
  35. 65
      pkg/services/authz/zanzana/server/server_check.go
  36. 4
      pkg/services/authz/zanzana/server/server_check_test.go
  37. 87
      pkg/services/authz/zanzana/server/server_list.go
  38. 22
      pkg/services/authz/zanzana/server/server_test.go
  39. 30
      pkg/services/authz/zanzana/translations.go
  40. 29
      pkg/services/authz/zanzana/zanzana.go
  41. 5
      pkg/services/featuremgmt/registry.go
  42. 2
      pkg/services/featuremgmt/toggles_gen.csv
  43. 18
      pkg/services/featuremgmt/toggles_gen.json
  44. 3
      pkg/services/sqlstore/sqlutil/sqlutil.go
  45. 8
      pkg/storage/unified/resource/search.go
  46. 57
      pkg/storage/unified/resource/server.go
  47. 11
      pkg/storage/unified/search/bleve.go
  48. 2
      pkg/storage/unified/sql/backend.go
  49. 9
      pkg/tests/testinfra/testinfra.go
  50. 4
      pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go
  51. 9
      pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go
  52. 2
      pkg/tsdb/mssql/kerberos/kerberos.go
  53. 93
      pkg/tsdb/mssql/sqleng/handler_checkhealth.go
  54. 60
      pkg/tsdb/mssql/sqleng/handler_checkhealth_test.go
  55. 9
      pkg/tsdb/mssql/sqleng/sql_engine.go
  56. 9
      pkg/tsdb/mysql/sqleng/handler_checkhealth.go
  57. 107
      public/api-enterprise-spec.json
  58. 12
      public/api-merged.json
  59. 234
      public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx
  60. 26
      public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts
  61. 57
      public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx
  62. 33
      public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/utils.test.ts.snap
  63. 15
      public/app/features/alerting/unified/components/rules/central-state-history/utils.test.ts
  64. 15
      public/app/features/alerting/unified/components/rules/central-state-history/utils.ts
  65. 31
      public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx
  66. 16
      public/app/features/dashboard-scene/edit-pane/DashboardEditPaneBehavior.tsx
  67. 30
      public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx
  68. 130
      public/app/features/dashboard-scene/edit-pane/VizPanelEditPaneBehavior.tsx
  69. 36
      public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx
  70. 213
      public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
  71. 52
      public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx
  72. 22
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx
  73. 112
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx
  74. 242
      public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx
  75. 113
      public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx
  76. 66
      public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx
  77. 105
      public/app/features/dashboard-scene/scene/layouts-shared/LayoutEditChrome.tsx
  78. 7
      public/app/features/dashboard-scene/scene/layouts-shared/layoutRegistry.ts
  79. 17
      public/app/features/dashboard-scene/scene/types.ts
  80. 51
      public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
  81. 2
      public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts
  82. 7
      public/app/features/dashboard-scene/sharing/ShareButton/ShareButton.tsx
  83. 3
      public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts
  84. 15
      public/app/features/dashboard-scene/utils/utils.ts
  85. 6
      public/app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor.tsx
  86. 22
      public/app/features/logs/components/LogRows.test.tsx
  87. 12
      public/app/features/logs/components/LogRows.tsx
  88. 153
      public/app/plugins/datasource/mssql/azureauth/AzureAuth.test.ts
  89. 37
      public/app/plugins/datasource/mssql/azureauth/AzureAuth.testMocks.ts
  90. 15
      public/app/plugins/datasource/mssql/azureauth/AzureAuthSettings.tsx
  91. 21
      public/app/plugins/datasource/mssql/azureauth/AzureCredentials.ts
  92. 173
      public/app/plugins/datasource/mssql/azureauth/AzureCredentialsConfig.ts
  93. 164
      public/app/plugins/datasource/mssql/azureauth/AzureCredentialsForm.tsx
  94. 37
      public/app/plugins/datasource/mssql/types.ts
  95. 18
      public/app/plugins/panel/logs/panelcfg.cue
  96. 12
      public/openapi3.json
  97. 2
      scripts/drone/steps/github.star
  98. 32
      yarn.lock

@ -1969,6 +1969,11 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/dashboard-scene/edit-pane/VizPanelEditPaneBehavior.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
@ -2092,7 +2097,10 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "12"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "12"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "13"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "14"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "15"]
],
"public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
@ -2100,6 +2108,11 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],

@ -193,6 +193,7 @@ steps:
from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY:
from_secret: github-app-private-key
failure: ignore
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
name: github-app-generate-token
volumes:
@ -276,6 +277,7 @@ steps:
from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY:
from_secret: github-app-private-key
failure: ignore
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
name: github-app-generate-token
volumes:
@ -383,6 +385,7 @@ steps:
from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY:
from_secret: github-app-private-key
failure: ignore
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
name: github-app-generate-token
volumes:
@ -521,6 +524,7 @@ steps:
from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY:
from_secret: github-app-private-key
failure: ignore
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
name: github-app-generate-token
volumes:
@ -618,6 +622,7 @@ steps:
from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY:
from_secret: github-app-private-key
failure: ignore
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
name: github-app-generate-token
volumes:
@ -1062,6 +1067,7 @@ steps:
from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY:
from_secret: github-app-private-key
failure: ignore
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
name: github-app-generate-token
volumes:
@ -1414,6 +1420,7 @@ steps:
from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY:
from_secret: github-app-private-key
failure: ignore
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
name: github-app-generate-token
volumes:
@ -1538,6 +1545,7 @@ steps:
from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY:
from_secret: github-app-private-key
failure: ignore
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
name: github-app-generate-token
volumes:
@ -2095,6 +2103,7 @@ steps:
from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY:
from_secret: github-app-private-key
failure: ignore
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
name: github-app-generate-token
volumes:
@ -3791,6 +3800,7 @@ steps:
from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY:
from_secret: github-app-private-key
failure: ignore
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
name: github-app-generate-token
volumes:
@ -4822,6 +4832,7 @@ steps:
from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY:
from_secret: github-app-private-key
failure: ignore
image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59
name: github-app-generate-token
volumes:
@ -5740,6 +5751,6 @@ kind: secret
name: gcr_credentials
---
kind: signature
hmac: e97f7a0c3923b506dad6bf861bb1ea440a8f072ee3744742eec35e7278c3581c
hmac: 04ba0c9b8e69705a28a24ba03de14ece0b15c4b44f6262fcbc6a9ee874b5a9db
...

@ -67,6 +67,11 @@
reviewers: ["team:grafana/plugins-platform-frontend"],
matchPackageNames: ["@locker/{/,}**"],
},
{
groupName: "augurs",
matchPackageNames: ["@bsull/augurs"],
reviewers: ["sd2k"],
},
],
pin: {
enabled: false,

1
.gitignore vendored

@ -39,6 +39,7 @@ __debug_bin*
/devenv/docker/blocks/saml-enterprise
# This is the new place of the block, but I leave the previous here for a while
/devenv/docker/blocks/auth/saml-enterprise
/devenv/docker/blocks/auth/signer
/tmp
tools/phantomjs/phantomjs

@ -4,8 +4,8 @@ go 1.23.1
require (
github.com/grafana/grafana-app-sdk v0.23.1
k8s.io/apimachinery v0.31.1
k8s.io/apiserver v0.31.1
k8s.io/apimachinery v0.31.3
k8s.io/apiserver v0.31.3
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340
)
@ -26,6 +26,7 @@ require (
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
@ -36,7 +37,7 @@ require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@ -49,43 +50,45 @@ require (
github.com/onsi/gomega v1.34.1 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.60.0 // indirect
github.com/prometheus/common v0.60.1 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.etcd.io/bbolt v1.3.10 // indirect
go.etcd.io/etcd/api/v3 v3.5.14 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.5.14 // indirect
go.etcd.io/etcd/client/v3 v3.5.14 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.56.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect
go.opentelemetry.io/otel v1.31.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 // indirect
go.opentelemetry.io/otel/metric v1.31.0 // indirect
go.opentelemetry.io/otel/sdk v1.31.0 // indirect
go.opentelemetry.io/otel/trace v1.31.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect
go.opentelemetry.io/otel v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 // indirect
go.opentelemetry.io/otel/metric v1.32.0 // indirect
go.opentelemetry.io/otel/sdk v1.32.0 // indirect
go.opentelemetry.io/otel/trace v1.32.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/term v0.25.0 // indirect
golang.org/x/text v0.19.0 // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/term v0.26.0 // indirect
golang.org/x/text v0.20.0 // indirect
golang.org/x/time v0.6.0 // indirect
golang.org/x/tools v0.27.0 // indirect
google.golang.org/genproto v0.0.0-20240820151423-278611b39280 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect
google.golang.org/grpc v1.67.1 // indirect
google.golang.org/protobuf v1.35.1 // indirect
google.golang.org/protobuf v1.35.2 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.31.1 // indirect
k8s.io/client-go v0.31.1 // indirect
k8s.io/component-base v0.31.1 // indirect
k8s.io/api v0.31.3 // indirect
k8s.io/client-go v0.31.3 // indirect
k8s.io/component-base v0.31.3 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // indirect

@ -28,7 +28,7 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
@ -48,7 +48,7 @@ github.com/grafana/grafana-app-sdk v0.23.1 h1:BRpUG0bA0oVxjthkmO2thuJBo3nbjaRSSm
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY=
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU=
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
@ -83,7 +83,7 @@ github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.60.0 h1:+V9PAREWNvJMAuJ1x1BaWl9dewMW4YrHZQbx0sJNllA=
github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
@ -94,8 +94,7 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
@ -111,15 +110,13 @@ go.etcd.io/etcd/pkg/v3 v3.5.13 h1:st9bDWNsKkBNpP4PR1MvM/9NqUPfvYZx/YXegsYEH8M=
go.etcd.io/etcd/raft/v3 v3.5.13 h1:7r/NKAOups1YnKcfro2RvGGo2PTuizF/xh26Z2CTAzA=
go.etcd.io/etcd/server/v3 v3.5.13 h1:V6KG+yMfMSqWt+lGnhFpP5z5dRUj1BDRJ5k1fQ9DFok=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.56.0 h1:yMkBS9yViCc7U7yeLzJPM2XizlfdVvBRSmsQDWu6qc0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 h1:FFeLy03iVTXP6ffeN2iXrxfGsZGCjVx0/4KlizjyBwU=
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk=
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw=
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 h1:9kV11HXBHZAvuPUZxmMWrH8hZn/6UnHX4K0mu36vNsU=
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@ -127,7 +124,7 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e h1:I88y4caeGeuDQxgdoFPUq097j7kNfw6uvuiNxUBfcBk=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@ -135,39 +132,34 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20240820151423-278611b39280 h1:oKt8r1ZvaPqBe3oeGTdyx1iNjuBS+VJcc9QdU1CD3d8=
google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 h1:T6rh4haD3GVYsgEfWExoCZA2o2FmbNyKpTuAxbEFPTg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc=
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 h1:XVhgTWWV3kGQlwJHR3upFWZeTsei6Oks1apkZSeonIE=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@ -180,14 +172,11 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU=
k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI=
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c=
k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0=
k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg=
k8s.io/component-base v0.31.1 h1:UpOepcrX3rQ3ab5NB6g5iP0tvsgJWzxTyAo20sgYSy8=
k8s.io/api v0.31.3 h1:umzm5o8lFbdN/hIXbrK9oRpOproJO62CV1zqxXrLgk8=
k8s.io/apimachinery v0.31.3 h1:6l0WhcYgasZ/wk9ktLq5vLaoXJJr5ts6lkaQzgeYPq4=
k8s.io/apiserver v0.31.3 h1:+1oHTtCB+OheqFEz375D0IlzHZ5VeQKX1KGXnx+TTuY=
k8s.io/client-go v0.31.3 h1:CAlZuM+PH2cm+86LOBemaJI/lQ5linJ6UFxKX/SoG+4=
k8s.io/component-base v0.31.3 h1:DMCXXVx546Rfvhj+3cOm2EUxhS+EyztH423j+8sOwhQ=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=

@ -239,6 +239,7 @@ guaranteed because plugin developers can override this functionality. The follow
### Unsupported
- Graphite
- Dynatrace
### Unconfirmed
@ -258,7 +259,6 @@ guaranteed because plugin developers can override this functionality. The follow
- Datadog
- Dataset
- Druid
- Dynatrace
- GitHub
- Google BigQuery
- Grafana for YNAB

@ -19,13 +19,18 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/calculation-types/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/calculation-types/
configure-legends:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/configure-legend/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-legend/
---
# Pie chart
A pie chart is a graph that displays data as segments of a circle proportional to the whole, making it look like a sliced pie. Each slice corresponds to a value or measurement.
{{< figure src="/static/img/docs/pie-chart-panel/pie-chart-example.png" max-width="1200px" lightbox="true" alt="Pie charts" >}}
![Pie chart visualizations](/media/docs/grafana/panels-visualizations/screenshot-pie-chart-v11.4.png)
The pie chart visualization is ideal when you have data that adds up to a total and you want to show the proportion of each value compared to other slices, as well as to the whole of the pie.
@ -98,116 +103,85 @@ If you want to display only the values from a given field (or column), once the
![Pie chart visualization with multiple rows and columns showing values from one column](/media/docs/grafana/panels-visualizations/screenshot-grafana-12.1-pie-example6.png)
## Panel options
{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
## Value options
Use the following options to refine the value in your visualization.
### Show
Choose how much information to show.
## Configuration options
- **Calculate -** Reduces each value to a single value per series.
- **All values -** Displays every value from a single series.
{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
### Calculation
### Panel options
Select a calculation to reduce each series when Calculate has been selected. For information about available calculations, refer to [Calculation types](ref:calculation-types).
{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
### Limit
### Value options
When displaying every value from a single series, this limits the number of values displayed.
Use the following options to refine the value in your visualization.
### Fields
<!-- prettier-ignore-start -->
Select which field or fields to display in the visualization. Each field name is available on the list, or you can select one of the following options:
| Option | Description |
| ------ | ----------- |
| Show | Set how much information to show. Choose from:<ul><li>**Calculate** - Reduces each value to a single value per series.</li><li>**All values** - Displays every value from a single series.</li></ul> |
| Calculation | If you chose **Calculate** as your **Show** option, select a calculation to reduce each series. For information about available calculations, refer to [Calculation types](ref:calculation-types). |
| Limit | If you chose **All values** as your **Show** option, enter a value to limit the number of values displayed. |
| Fields | Select which field or fields to display in the visualization. Each field name is available on the list, or you can select one of the following options:<ul><li>**Numeric fields** - All fields with numerical values.</li><li>**All fields** - All fields that are not removed by transformations.</li><li>**Time** - All fields with time values.</li></ul> |
- **Numeric fields -** All fields with numerical values.
- **All fields -** All fields that are not removed by transformations.
- **Time -** All fields with time values.
<!-- prettier-ignore-end -->
## Pie chart options
### Pie chart options
Use these options to refine how your visualization looks.
### Pie chart type
Select the pie chart display style.
#### Pie chart type
### Pie
Select the pie chart display style. Choose from **Pie** or **Donut**.
![Pie type chart](/static/img/docs/pie-chart-panel/pie-type-chart-7-5.png)
![Pie chart types](/media/docs/grafana/panels-visualizations/screenshot-pie-chart-types.png)
### Donut
![Donut type chart](/static/img/docs/pie-chart-panel/donut-type-chart-7-5.png)
### Labels
#### Labels
Select labels to display on the pie chart. You can select more than one.
- **Name -** The series or field name.
- **Percent -** The percentage of the whole.
- **Value -** The raw numerical value.
- **Name** - The series or field name.
- **Percent** - The percentage of the whole.
- **Value** - The raw numerical value.
Labels are displayed in white over the body of the chart. You might need to select darker chart colors to make them more visible. Long names or numbers might be clipped.
The following example shows a pie chart with **Name** and **Percent** labels displayed.
![Pie chart labels](/static/img/docs/pie-chart-panel/pie-chart-labels-7-5.png)
## Tooltip options
{{< docs/shared lookup="visualizations/tooltip-options-1.md" source="grafana" version="<GRAFANA_VERSION>" >}}
## Legend options
Use these settings to define how the legend appears in your visualization. For more information about the legend, refer to [Configure a legend]({{< relref "../../configure-legend" >}}).
### Visibility
Toggle the switch to turn the legend on or off.
### Mode
Use these settings to define how the legend appears in your visualization.
- **List -** Displays the legend as a list. This is a default display mode of the legend.
- **Table -** Displays the legend as a table.
The following example shows a pie chart with **Name** and **Percent** labels displayed:
### Placement
{{< figure src="/static/img/docs/pie-chart-panel/pie-chart-labels-7-5.png" alt="Pie chart labels" max-width="350px" >}}
Choose where to display the legend.
### Tooltip options
- **Bottom -** Below the graph.
- **Right -** To the right of the graph.
{{< docs/shared lookup="visualizations/tooltip-options-1.md" source="grafana" version="<GRAFANA_VERSION>" leveloffset="+1" >}}
#### Width
### Legend options
Control how wide the legend is when placed on the right side of the visualization. This option is only displayed if you set the legend placement to **Right**.
Use these settings to define how the legend appears in your visualization. For more information about the legend, refer to [Configure a legend](ref:configure-legends).
### Values
<!-- prettier-ignore-start -->
Select values to display in the legend. You can select more than one.
| Option | Description |
| ------ | ----------- |
| Visibility | Toggle the switch to turn the legend on or off. |
| Mode | Use these settings to define how the legend appears in your visualization. Choose from:<ul><li>**List** - Displays the legend as a list. This is a default display mode of the legend.</li><li>**Table** - Displays the legend as a table.</li></ul> |
| Placement | Select where to display the legend. Choose **Bottom** or **Right**. |
| Width | Control how wide the legend is when placed on the right side of the visualization. This option is only displayed if you set the legend placement to **Right**. |
| Legend values | Select values to display in the legend. You can select more than one:<ul><li>**Percent** - The percentage of the whole.</li><li>**Value** - The raw numerical value.</li></ul> |
- **Percent:** The percentage of the whole.
- **Value:** The raw numerical value.
<!-- prettier-ignore-end -->
## Standard options
### Standard options
{{< docs/shared lookup="visualizations/standard-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
## Data links
### Data links
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
## Value mappings
### Value mappings
{{< docs/shared lookup="visualizations/value-mappings-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
## Field overrides
### Field overrides
{{< docs/shared lookup="visualizations/overrides-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}

@ -70,13 +70,14 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `tlsMemcached` | Use TLS-enabled memcached in the enterprise caching feature | Yes |
| `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes |
| `accessActionSets` | Introduces action sets for resource permissions. Also ensures that all folder editors and admins can create subfolders without needing any additional permissions. | Yes |
| `newDashboardSharingComponent` | Enables the new sharing drawer design | |
| `newDashboardSharingComponent` | Enables the new sharing drawer design | Yes |
| `notificationBanner` | Enables the notification banner UI and API | Yes |
| `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | |
| `pinNavItems` | Enables pinning of nav items | Yes |
| `openSearchBackendFlowEnabled` | Enables the backend query flow for Open Search datasource plugin | Yes |
| `cloudWatchRoundUpEndTime` | Round up end time for metric queries to the next minute to avoid missing data | Yes |
| `cloudwatchMetricInsightsCrossAccount` | Enables cross account observability for Cloudwatch Metric Insights query builder | Yes |
| `newFiltersUI` | Enables new combobox style UI for the Ad hoc filters variable in scenes architecture | Yes |
| `singleTopNav` | Unifies the top search bar and breadcrumb bar into one | Yes |
| `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | |
| `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes |
@ -205,7 +206,6 @@ Experimental features might be changed or removed without prior notice.
| `failWrongDSUID` | Throws an error if a datasource has an invalid UIDs |
| `alertingApiServer` | Register Alerting APIs with the K8s API server |
| `dataplaneAggregator` | Enable grafana dataplane aggregator |
| `newFiltersUI` | Enables new combobox style UI for the Ad hoc filters variable in scenes architecture |
| `lokiSendDashboardPanelNames` | Send dashboard and panel names to Loki when querying |
| `alertingPrometheusRulesPrimary` | Uses Prometheus rules as the primary source of truth for ruler-enabled data sources |
| `exploreLogsShardSplitting` | Used in Explore Logs to split queries into multiple queries based on the number of shards |

@ -46,7 +46,7 @@ Installation of Grafana on other operating systems is possible, but is not recom
Grafana requires the minimum system resources:
- Minimum recommended memory: 512 MB
- Minimum recommended CPU: 1
- Minimum recommended CPU: 1 core
Some features might require more memory or CPUs, including:

@ -7,7 +7,7 @@ describe('Create a public dashboard with template variables shows a template var
it('Create a public dashboard with template variables shows a template variable warning', () => {
// Opening a dashboard with template variables
e2e.flows.openDashboard({ uid: 'HYaGDGIMk' });
e2e.flows.openDashboard({ uid: 'HYaGDGIMk', queryParams: { '__feature.newDashboardSharingComponent': false } });
// Open sharing modal
e2e.components.NavToolbar.shareDashboard().click();

@ -10,7 +10,7 @@ describe('Snapshots', () => {
cy.intercept({
pathname: '/api/ds/query',
}).as('query');
e2e.flows.openDashboard({ uid: 'ZqZnVvFZz' });
e2e.flows.openDashboard({ uid: 'ZqZnVvFZz', queryParams: { '__feature.newDashboardSharingComponent': false } });
cy.wait('@query');
const panelsToCheck = [

@ -478,6 +478,8 @@ require github.com/grafana/loki/v3 v3.2.1 // @grafana/observability-logs
require github.com/openzipkin/zipkin-go v0.4.3 // @grafana/oss-big-tent
require github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20241209165425-c324376999f7 // @grafana/alerting-backend
require (
cloud.google.com/go/longrunning v0.6.0 // indirect
github.com/at-wat/mqtt-go v0.19.4 // indirect

@ -2327,6 +2327,8 @@ github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/
github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk=
github.com/grafana/grafana-plugin-sdk-go v0.260.1 h1:KzbooQP9mv/9CPsn+SoUwGuomA8oUxO0iuIq6Rg/ekE=
github.com/grafana/grafana-plugin-sdk-go v0.260.1/go.mod h1:JriieK5Oc5v120QKhMs/LO55N0P3YI2ttEiVT1wfYsw=
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20241209165425-c324376999f7 h1:JFB5dvs0XwBh/RiDNA5OrqcF3eWCQmTYBm6Hy79PDMQ=
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20241209165425-c324376999f7/go.mod h1:AVvGgNqHsruJINRjKkhhY5NZMh5ke6Ei2bywuQ4Uuus=
github.com/grafana/grafana/apps/playlist v0.0.0-20241105090059-facca37f4d1f h1:zZN/Jy7PjoqtrMiBRV5O3x4xAArcSbUznuyAPACrKXI=
github.com/grafana/grafana/apps/playlist v0.0.0-20241105090059-facca37f4d1f/go.mod h1:e97Zxn1WX4Wn9TXEvwTjMNwU6yrjX/K7uVNSCZyEwxY=
github.com/grafana/grafana/pkg/aggregator v0.0.0-20240813192817-1b0e6b5c09b2 h1:2H9x4q53pkfUGtSNYD1qSBpNnxrFgylof/TYADb5xMI=

@ -1231,6 +1231,8 @@ github.com/mitchellh/iochan v1.0.0 h1:C+X3KsSTLFVBr/tK1eYN/vs4rJcvsiLU338UhYPJWe
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mithrandie/readline-csvq v1.3.0 h1:VTJEOGouJ8j27jJCD4kBBbNTxM0OdBvE1aY1tMhlqE8=
github.com/mithrandie/readline-csvq v1.3.0/go.mod h1:FKyYqDgf/G4SNov7SMFXRWO6LQLXIOeTog/NB97FZl0=
github.com/moby/moby v26.0.0+incompatible h1:2n9/cIWkxiEI1VsWgTGgXhxIWUbv42PyxEP9L+RReC0=
github.com/moby/moby v26.0.0+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5 h1:8Q0qkMVC/MmWkpIdlvZgcv2o2jrlF6zqVOh7W5YHdMA=
@ -1701,6 +1703,7 @@ go.opentelemetry.io/contrib/propagators/b3 v1.23.0/go.mod h1:Gyz7V7XghvwTq+mIhLF
go.opentelemetry.io/contrib/propagators/b3 v1.27.0 h1:IjgxbomVrV9za6bRi8fWCNXENs0co37SZedQilP2hm0=
go.opentelemetry.io/contrib/propagators/b3 v1.27.0/go.mod h1:Dv9obQz25lCisDvvs4dy28UPh974CxkahRDUPsY7y9E=
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/otel/bridge/opencensus v1.26.0 h1:DZzxj9QjznMVoehskOJnFP2gsTCWtDTFBDvFhPAY7nc=
go.opentelemetry.io/otel/bridge/opencensus v1.26.0/go.mod h1:rJiX0KrF5m8Tm1XE8jLczpAv5zUaDcvhKecFG0ZoFG4=
go.opentelemetry.io/otel/bridge/opencensus v1.27.0 h1:ao9aGGHd+G4YfjBpGs6vbkvt5hoC67STlJA9fCnOAcs=
@ -1745,6 +1748,7 @@ go.opentelemetry.io/otel/sdk/metric v1.26.0/go.mod h1:ClMFFknnThJCksebJwz7KIyEDH
go.opentelemetry.io/otel/sdk/metric v1.28.0 h1:OkuaKgKrgAbYrrY0t92c+cC+2F6hsFNnCQArXCKlg08=
go.opentelemetry.io/otel/sdk/metric v1.28.0/go.mod h1:cWPjykihLAPvXKi4iZc1dpER3Jdq2Z0YLse3moQUCpg=
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
@ -1790,6 +1794,7 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
@ -1803,12 +1808,14 @@ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -1875,6 +1882,7 @@ google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjr
google.golang.org/grpc v1.66.1/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
gopkg.in/cheggaaa/pb.v1 v1.0.25 h1:Ev7yu1/f6+d+b3pi5vPdRPc6nNtP1umSfcWiEfRqv6I=
gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=

@ -251,7 +251,7 @@
"yargs": "^17.5.1"
},
"dependencies": {
"@bsull/augurs": "^0.6.0",
"@bsull/augurs": "^0.7.0",
"@emotion/css": "11.13.5",
"@emotion/react": "11.13.5",
"@fingerprintjs/fingerprintjs": "^3.4.2",
@ -274,8 +274,8 @@
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/saga-icons": "workspace:*",
"@grafana/scenes": "5.28.1",
"@grafana/scenes-react": "5.28.1",
"@grafana/scenes": "^5.30.0",
"@grafana/scenes-react": "^5.30.0",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",

@ -34,6 +34,7 @@ interface BaseProps {
menu?: ReactElement | (() => ReactElement);
dragClass?: string;
dragClassCancel?: string;
onDragStart?: (e: React.PointerEvent) => void;
selectionId?: string;
/**
* Use only to indicate loading or streaming data in the panel.
@ -142,6 +143,7 @@ export function PanelChrome({
onFocus,
onMouseMove,
onMouseEnter,
onDragStart,
showMenuAlways = false,
}: PanelChromeProps) {
const theme = useTheme2();
@ -312,6 +314,7 @@ export function PanelChrome({
className={cx(styles.headerContainer, dragClass)}
style={headerStyles}
data-testid="header-container"
onPointerDown={onDragStart}
onPointerUp={onSelect}
>
{statusMessage && (

@ -12,6 +12,8 @@ Developers modifying the HTTP API endpoints need to make sure to add the necessa
The following route defines a `PATCH` endpoint under the `/serviceaccounts/{serviceAccountId}` path with tag `service_accounts` (used for grouping together several routes) and operation ID `updateServiceAccount` (used for uniquely identifying routes and associate parameters and response with them).
> For enterprise endpoints make sure you add the `enterprise` tag as well.
```go
// swagger:route PATCH /serviceaccounts/{serviceAccountId} service_accounts updateServiceAccount

@ -15,7 +15,7 @@ import (
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ecr"
"github.com/aws/aws-sdk-go/service/marketplacecatalog"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/client"
"github.com/urfave/cli/v2"
@ -60,9 +60,9 @@ type AwsMarketplacePublishingService struct {
}
type AwsMarketplaceDocker interface {
ImagePull(ctx context.Context, refStr string, options types.ImagePullOptions) (io.ReadCloser, error)
ImagePull(ctx context.Context, refStr string, options image.PullOptions) (io.ReadCloser, error)
ImageTag(ctx context.Context, source string, target string) error
ImagePush(ctx context.Context, image string, options types.ImagePushOptions) (io.ReadCloser, error)
ImagePush(ctx context.Context, image string, options image.PushOptions) (io.ReadCloser, error)
}
type AwsMarketplaceRegistry interface {
@ -172,8 +172,8 @@ func (s *AwsMarketplacePublishingService) Login(ctx context.Context) error {
return err
}
func (s *AwsMarketplacePublishingService) PullImage(ctx context.Context, image string, version string) error {
reader, err := s.docker.ImagePull(ctx, fmt.Sprintf("%s:%s", image, version), types.ImagePullOptions{
func (s *AwsMarketplacePublishingService) PullImage(ctx context.Context, img string, version string) error {
reader, err := s.docker.ImagePull(ctx, fmt.Sprintf("%s:%s", img, version), image.PullOptions{
Platform: imagePlatform,
})
if err != nil {
@ -201,7 +201,7 @@ func (s *AwsMarketplacePublishingService) TagImage(ctx context.Context, image st
}
func (s *AwsMarketplacePublishingService) PushToMarketplace(ctx context.Context, repo string, version string) error {
reader, err := s.docker.ImagePush(ctx, fmt.Sprintf("%s/%s:%s", marketplaceRegistryUrl, repo, version), types.ImagePushOptions{
reader, err := s.docker.ImagePush(ctx, fmt.Sprintf("%s/%s:%s", marketplaceRegistryUrl, repo, version), image.PushOptions{
RegistryAuth: s.auth,
})
if err != nil {

@ -14,7 +14,7 @@ import (
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/service/ecr"
"github.com/aws/aws-sdk-go/service/marketplacecatalog"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/image"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v2"
)
@ -168,13 +168,13 @@ type mockAwsMarketplaceDocker struct {
ImagePushError error
}
func (m *mockAwsMarketplaceDocker) ImagePull(ctx context.Context, refStr string, options types.ImagePullOptions) (io.ReadCloser, error) {
func (m *mockAwsMarketplaceDocker) ImagePull(ctx context.Context, refStr string, options image.PullOptions) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader([]byte(""))), m.ImagePullError
}
func (m *mockAwsMarketplaceDocker) ImageTag(ctx context.Context, source string, target string) error {
return m.ImageTagError
}
func (m *mockAwsMarketplaceDocker) ImagePush(ctx context.Context, image string, options types.ImagePushOptions) (io.ReadCloser, error) {
func (m *mockAwsMarketplaceDocker) ImagePush(ctx context.Context, image string, options image.PushOptions) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader([]byte(""))), m.ImagePushError
}

@ -5,7 +5,7 @@ go 1.23.1
// Override docker/docker to avoid:
// go: github.com/drone-runners/drone-runner-docker@v1.8.2 requires
// github.com/docker/docker@v0.0.0-00010101000000-000000000000: invalid version: unknown revision 000000000000
replace github.com/docker/docker => github.com/moby/moby v25.0.2+incompatible
replace github.com/docker/docker => github.com/moby/moby v26.0.0+incompatible
// contains openapi encoder fixes. remove ASAP
replace cuelang.org/go => github.com/grafana/cue v0.0.0-20230926092038-971951014e3f // @grafana/grafana-as-code
@ -103,6 +103,7 @@ require (
github.com/distribution/reference v0.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/sosodev/duration v1.2.0 // indirect
github.com/vektah/gqlparser/v2 v2.5.11 // indirect

@ -169,8 +169,10 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/moby/moby v25.0.2+incompatible h1:g2oKRI7vgWkiPHZbBghaPbcV/SuKP1g/YLx0I2nxFT4=
github.com/moby/moby v25.0.2+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby v26.0.0+incompatible h1:2n9/cIWkxiEI1VsWgTGgXhxIWUbv42PyxEP9L+RReC0=
github.com/moby/moby v26.0.0+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=

@ -32,6 +32,10 @@ func TestIntegrationWillRunInstrumentationServerWhenTargetHasNoHttpServer(t *tes
if dbType == "sqlite3" {
t.Skip("skipping - sqlite not supported for storage server target")
}
// TODO - fix this test for postgres
if dbType == "postgres" {
t.Skip("skipping - test not working with postgres in Drone. Works locally.")
}
_, cfg := db.InitTestDBWithCfg(t)
cfg.HTTPPort = "3001"

@ -55,19 +55,19 @@ func NewZanzanaReconciler(cfg *setting.Cfg, client zanzana.Client, store db.DB,
newResourceReconciler(
"managed folder permissions",
managedPermissionsCollector(store, zanzana.KindFolders),
zanzanaCollector(zanzana.FolderRelations),
zanzanaCollector(zanzana.RelationsFolder),
client,
),
newResourceReconciler(
"managed dashboard permissions",
managedPermissionsCollector(store, zanzana.KindDashboards),
zanzanaCollector(zanzana.ResourceRelations),
zanzanaCollector(zanzana.RelationsResouce),
client,
),
newResourceReconciler(
"role permissions",
rolePermissionsCollector(store),
zanzanaCollector(zanzana.FolderRelations),
zanzanaCollector(zanzana.RelationsFolder),
client,
),
newResourceReconciler(

@ -11,11 +11,15 @@ type TypeInfo struct {
Relations []string
}
func (t TypeInfo) IsValidRelation(relation string) bool {
return isValidRelation(relation, t.Relations)
}
var typedResources = map[string]TypeInfo{
FormatGroupResource(
folderalpha1.FolderResourceInfo.GroupResource().Group,
folderalpha1.FolderResourceInfo.GroupResource().Resource,
): {Type: "folder", Relations: append(ResourceRelations, RelationCreate)},
): {Type: "folder", Relations: RelationsFolder},
}
func GetTypeInfo(group, resource string) (TypeInfo, bool) {
@ -24,19 +28,19 @@ func GetTypeInfo(group, resource string) (TypeInfo, bool) {
}
var VerbMapping = map[string]string{
utils.VerbGet: RelationRead,
utils.VerbList: RelationRead,
utils.VerbWatch: RelationRead,
utils.VerbGet: RelationGet,
utils.VerbList: RelationGet,
utils.VerbWatch: RelationGet,
utils.VerbCreate: RelationCreate,
utils.VerbUpdate: RelationWrite,
utils.VerbPatch: RelationWrite,
utils.VerbUpdate: RelationUpdate,
utils.VerbPatch: RelationUpdate,
utils.VerbDelete: RelationDelete,
utils.VerbDeleteCollection: RelationDelete,
}
var RelationToVerbMapping = map[string]string{
RelationRead: utils.VerbGet,
RelationGet: utils.VerbGet,
RelationCreate: utils.VerbCreate,
RelationWrite: utils.VerbUpdate,
RelationUpdate: utils.VerbUpdate,
RelationDelete: utils.VerbDelete,
}

@ -16,9 +16,12 @@ const (
TypeRenderService string = "render"
TypeTeam string = "team"
TypeRole string = "role"
TypeFolder string = "folder"
TypeResource string = "resource"
TypeNamespace string = "namespace"
)
const (
TypeFolder string = "folder"
TypeResource string = "resource"
TypeGroupResouce string = "group_resource"
)
const (
@ -31,44 +34,74 @@ const (
RelationSetEdit string = "edit"
RelationSetAdmin string = "admin"
RelationRead string = "read"
RelationWrite string = "write"
RelationCreate string = "create"
RelationDelete string = "delete"
RelationPermissionsRead string = "permissions_read"
RelationPermissionsWrite string = "permissions_write"
RelationGet string = "get"
RelationUpdate string = "update"
RelationCreate string = "create"
RelationDelete string = "delete"
RelationFolderResourceSetView string = "resource_" + RelationSetView
RelationFolderResourceSetEdit string = "resource_" + RelationSetEdit
RelationFolderResourceSetAdmin string = "resource_" + RelationSetAdmin
RelationFolderResourceRead string = "resource_" + RelationRead
RelationFolderResourceWrite string = "resource_" + RelationWrite
RelationFolderResourceCreate string = "resource_" + RelationCreate
RelationFolderResourceDelete string = "resource_" + RelationDelete
RelationFolderResourcePermissionsRead string = "resource_" + RelationPermissionsRead
RelationFolderResourcePermissionsWrite string = "resource_" + RelationPermissionsWrite
RelationFolderResourceGet string = "resource_" + RelationGet
RelationFolderResourceUpdate string = "resource_" + RelationUpdate
RelationFolderResourceCreate string = "resource_" + RelationCreate
RelationFolderResourceDelete string = "resource_" + RelationDelete
)
var ResourceRelations = []string{
RelationRead,
RelationWrite,
// RelationsGroupResource are relations that can be added on type "group_resource".
var RelationsGroupResource = []string{
RelationGet,
RelationUpdate,
RelationCreate,
RelationDelete,
RelationPermissionsRead,
RelationPermissionsWrite,
}
var FolderRelations = append(
ResourceRelations,
RelationCreate,
RelationFolderResourceRead,
RelationFolderResourceWrite,
// RelationsResource are relations that can be added on type "resource".
var RelationsResource = []string{
RelationGet,
RelationUpdate,
RelationDelete,
}
// RelationsFolderResource are relations that can be added on type "folder" for child resources.
var RelationsFolderResource = []string{
RelationFolderResourceGet,
RelationFolderResourceUpdate,
RelationFolderResourceCreate,
RelationFolderResourceDelete,
RelationFolderResourcePermissionsRead,
RelationFolderResourcePermissionsWrite,
}
// RelationsFolder are relations that can be added on type "folder".
var RelationsFolder = append(
RelationsFolderResource,
RelationGet,
RelationUpdate,
RelationCreate,
RelationDelete,
)
func IsGroupResourceRelation(relation string) bool {
return isValidRelation(relation, RelationsGroupResource)
}
func IsFolderResourceRelation(relation string) bool {
return isValidRelation(relation, RelationsFolderResource)
}
func IsResourceRelation(relation string) bool {
return isValidRelation(relation, RelationsResource)
}
func isValidRelation(relation string, valid []string) bool {
for _, r := range valid {
if r == relation {
return true
}
}
return false
}
func FolderResourceRelation(relation string) string {
return fmt.Sprintf("%s_%s", TypeResource, relation)
}
@ -85,8 +118,8 @@ func NewFolderIdent(name string) string {
return fmt.Sprintf("%s:%s", TypeFolder, name)
}
func NewNamespaceResourceIdent(group, resource string) string {
return fmt.Sprintf("%s:%s", TypeNamespace, FormatGroupResource(group, resource))
func NewGroupResourceIdent(group, resource string) string {
return fmt.Sprintf("%s:%s", TypeGroupResouce, FormatGroupResource(group, resource))
}
func FormatGroupResource(group, resource string) string {
@ -139,11 +172,11 @@ func NewFolderResourceTuple(subject, relation, group, resource, folder string) *
}
}
func NewNamespaceResourceTuple(subject, relation, group, resource string) *openfgav1.TupleKey {
func NewGroupResourceTuple(subject, relation, group, resource string) *openfgav1.TupleKey {
return &openfgav1.TupleKey{
User: subject,
Relation: relation,
Object: NewNamespaceResourceIdent(group, resource),
Object: NewGroupResourceIdent(group, resource),
}
}
@ -258,10 +291,18 @@ func AddRenderContext(req *openfgav1.CheckRequest) {
req.ContextualTuples.TupleKeys = append(req.ContextualTuples.TupleKeys, &openfgav1.TupleKey{
User: req.TupleKey.User,
Relation: "view",
Object: NewNamespaceResourceIdent(
Relation: RelationSetView,
Object: NewGroupResourceIdent(
dashboardalpha1.DashboardResourceInfo.GroupResource().Group,
dashboardalpha1.DashboardResourceInfo.GroupResource().Resource,
),
})
}
func NewResourceContext(group, resource string) *structpb.Struct {
return &structpb.Struct{
Fields: map[string]*structpb.Value{
"requested_group": structpb.NewStringValue(FormatGroupResource(group, resource)),
},
}
}

@ -2,10 +2,10 @@
Here's some notes about [OpenFGA authorization model](https://openfga.dev/docs/modeling/getting-started) (schema) using to model access control in Grafana.
## Namespace level permissions
## GroupResource level permissions
A relation to a namespace object grant access to all objects of the GroupResource in the entire namespace.
They take the form of `{ “user”: “user:1”, relation: “read”, object:”namespace:dashboard.grafana.app/dashboard” }`. This
A relation to a group_resource object grants access to all objects of the GroupResource.
They take the form of `{ “user”: “user:1”, relation: “read”, object:”group_resource:dashboard.grafana.app/dashboard” }`. This
example would grant `user:1` access to all `dashboard.grafana.app/dashboard` in the namespace.
## Folder level permissions

@ -6,31 +6,15 @@ type service-account
type render
type namespace
relations
define view: [user, service-account, render, team#member, role#assignee] or edit
define edit: [user, service-account, team#member, role#assignee] or admin
define admin: [user, service-account, team#member, role#assignee]
define read: [user, service-account, render, team#member, role#assignee] or view
define create: [user, service-account, team#member, role#assignee] or edit
define write: [user, service-account, team#member, role#assignee] or edit
define delete: [user, service-account, team#member, role#assignee] or edit
define permissions_read: [user, service-account, team#member, role#assignee] or admin
define permissions_write: [user, service-account, team#member, role#assignee] or admin
type role
relations
define assignee: [user, service-account, team#member, role#assignee]
type team
relations
# Action sets
define admin: [user, service-account]
define member: [user, service-account] or admin
define read: [role#assignee] or member
define write: [role#assignee] or admin
define get: [role#assignee] or member
define update: [role#assignee] or admin
define delete: [role#assignee] or admin
define permissions_read: [role#assignee] or admin
define permissions_write: [role#assignee] or admin

@ -9,9 +9,7 @@ type folder
define edit: [user, service-account, team#member, role#assignee] or admin or edit from parent
define admin: [user, service-account, team#member, role#assignee] or admin from parent
define read: [user, service-account, team#member, role#assignee] or view or read from parent
define get: [user, service-account, team#member, role#assignee] or view or get from parent
define create: [user, service-account, team#member, role#assignee] or edit or create from parent
define write: [user, service-account, team#member, role#assignee] or edit or write from parent
define update: [user, service-account, team#member, role#assignee] or edit or update from parent
define delete: [user, service-account, team#member, role#assignee] or edit or delete from parent
define permissions_read: [user, service-account, team#member, role#assignee] or admin or permissions_read from parent
define permissions_write: [user, service-account, team#member, role#assignee] or admin or permissions_write from parent

@ -6,12 +6,21 @@ extend type folder
define resource_edit: [user, service-account, team#member, role#assignee] or resource_admin or resource_edit from parent
define resource_admin: [user, service-account, team#member, role#assignee] or resource_admin from parent
define resource_read: [user with folder_group_filter, service-account with folder_group_filter, team#member with folder_group_filter, role#assignee with folder_group_filter] or resource_view or resource_read from parent
define resource_get: [user with folder_group_filter, service-account with folder_group_filter, team#member with folder_group_filter, role#assignee with folder_group_filter] or resource_view or resource_get from parent
define resource_create: [user with folder_group_filter, service-account with folder_group_filter, team#member with folder_group_filter, role#assignee with folder_group_filter] or resource_edit or resource_create from parent
define resource_write: [user with folder_group_filter, service-account with folder_group_filter, team#member with folder_group_filter, role#assignee with folder_group_filter] or resource_edit or resource_write from parent
define resource_update: [user with folder_group_filter, service-account with folder_group_filter, team#member with folder_group_filter, role#assignee with folder_group_filter] or resource_edit or resource_update from parent
define resource_delete: [user with folder_group_filter, service-account with folder_group_filter, team#member with folder_group_filter, role#assignee with folder_group_filter] or resource_edit or resource_delete from parent
define resource_permissions_read: [user with folder_group_filter, service-account with folder_group_filter, team#member with folder_group_filter, role#assignee with folder_group_filter] or resource_admin or resource_permissions_read from parent
define resource_permissions_write: [user with folder_group_filter, service-account with folder_group_filter, team#member with folder_group_filter, role#assignee with folder_group_filter] or resource_admin or resource_permissions_write from parent
type group_resource
relations
define view: [user, service-account, render, team#member, role#assignee] or edit
define edit: [user, service-account, team#member, role#assignee] or admin
define admin: [user, service-account, team#member, role#assignee]
define get: [user, service-account, render, team#member, role#assignee] or view
define create: [user, service-account, team#member, role#assignee] or edit
define update: [user, service-account, team#member, role#assignee] or edit
define delete: [user, service-account, team#member, role#assignee] or edit
type resource
relations
@ -19,11 +28,9 @@ type resource
define edit: [user with group_filter, service-account with group_filter, team#member with group_filter, role#assignee with group_filter] or admin
define admin: [user with group_filter, service-account with group_filter, team#member with group_filter, role#assignee with group_filter]
define read: [user with group_filter, service-account with group_filter, team#member with group_filter, role#assignee with group_filter] or view
define write: [user with group_filter, service-account with group_filter, team#member with group_filter, role#assignee with group_filter] or edit
define get: [user with group_filter, service-account with group_filter, team#member with group_filter, role#assignee with group_filter] or view
define update: [user with group_filter, service-account with group_filter, team#member with group_filter, role#assignee with group_filter] or edit
define delete: [user with group_filter, service-account with group_filter, team#member with group_filter, role#assignee with group_filter] or edit
define permissions_read: [user with group_filter, service-account with group_filter, team#member with group_filter, role#assignee with group_filter] or admin
define permissions_write: [user with group_filter, service-account with group_filter, team#member with group_filter, role#assignee with group_filter] or admin
condition group_filter(requested_group: string, group_resource: string) {
requested_group == group_resource

@ -56,7 +56,7 @@ func (s *Server) batchCheckItem(
allowed, ok := groupResourceAccess[groupResource]
if !ok {
res, err := s.checkNamespace(ctx, r.GetSubject(), relation, item.GetGroup(), item.GetResource(), store)
res, err := s.checkGroupResource(ctx, r.GetSubject(), relation, item.GetGroup(), item.GetResource(), store)
if err != nil {
return nil, err
}

@ -44,7 +44,7 @@ func testBatchCheck(t *testing.T, server *Server) {
assert.False(t, res.Groups[groupResource].Items["2"])
})
t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/{1,2} through namespace", func(t *testing.T) {
t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/{1,2} through group_resource", func(t *testing.T) {
groupResource := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
res, err := server.BatchCheck(context.Background(), newReq("user:2", utils.VerbGet, dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
@ -108,7 +108,7 @@ func testBatchCheck(t *testing.T, server *Server) {
assert.False(t, res.Groups[groupResource].Items["2"])
})
t.Run("user:7 should be able to read folder {1,2} through namespace access", func(t *testing.T) {
t.Run("user:7 should be able to read folder {1,2} through group_resource access", func(t *testing.T) {
groupResource := zanzana.FormatGroupResource(folderGroup, folderResource)
res, err := server.BatchCheck(context.Background(), newReq("user:7", utils.VerbGet, folderGroup, folderResource, []*authzextv1.BatchCheckItem{
{Name: "1"},

@ -20,9 +20,9 @@ func (s *Server) Capabilities(ctx context.Context, r *authzextv1.CapabilitiesReq
}
func (s *Server) capabilitiesTyped(ctx context.Context, r *authzextv1.CapabilitiesRequest, info common.TypeInfo, store *storeInfo) (*authzextv1.CapabilitiesResponse, error) {
out := make([]string, 0, len(common.ResourceRelations))
out := make([]string, 0, len(common.RelationsResource))
for _, relation := range info.Relations {
res, err := s.checkNamespace(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
res, err := s.checkGroupResource(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
if err != nil {
return nil, err
}
@ -46,9 +46,9 @@ func (s *Server) capabilitiesTyped(ctx context.Context, r *authzextv1.Capabiliti
}
func (s *Server) capabilitiesGeneric(ctx context.Context, r *authzextv1.CapabilitiesRequest, store *storeInfo) (*authzextv1.CapabilitiesResponse, error) {
out := make([]string, 0, len(common.ResourceRelations))
for _, relation := range common.ResourceRelations {
res, err := s.checkNamespace(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
out := make([]string, 0, len(common.RelationsResource))
for _, relation := range common.RelationsResource {
res, err := s.checkGroupResource(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
if err != nil {
return nil, err
}

@ -26,42 +26,42 @@ func testCapabilities(t *testing.T, server *Server) {
t.Run("user:1 should only be able to read and write resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) {
res, err := server.Capabilities(context.Background(), newReq("user:1", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err)
assert.Equal(t, []string{common.RelationRead, common.RelationWrite}, res.GetCapabilities())
assert.Equal(t, []string{common.RelationGet, common.RelationUpdate}, res.GetCapabilities())
})
t.Run("user:2 should be able to read and write resource:dashboards.grafana.app/dashboards/1 through namespace", func(t *testing.T) {
t.Run("user:2 should be able to read and write resource:dashboards.grafana.app/dashboards/1 through group_resource", func(t *testing.T) {
res, err := server.Capabilities(context.Background(), newReq("user:2", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err)
assert.Equal(t, []string{common.RelationRead, common.RelationWrite}, res.GetCapabilities())
assert.Equal(t, []string{common.RelationGet, common.RelationUpdate}, res.GetCapabilities())
})
t.Run("user:3 should be able to read resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
res, err := server.Capabilities(context.Background(), newReq("user:3", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err)
assert.Equal(t, []string{common.RelationRead}, res.GetCapabilities())
assert.Equal(t, []string{common.RelationGet}, res.GetCapabilities())
})
t.Run("user:4 should be able to read dashboards.grafana.app/dashboards in folder 1", func(t *testing.T) {
res, err := server.Capabilities(context.Background(), newReq("user:4", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err)
assert.Equal(t, []string{common.RelationRead}, res.GetCapabilities())
assert.Equal(t, []string{common.RelationGet}, res.GetCapabilities())
})
t.Run("user:5 should be able to read, write, create and delete resource:dashboards.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) {
res, err := server.Capabilities(context.Background(), newReq("user:5", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err)
assert.Equal(t, []string{common.RelationRead, common.RelationWrite, common.RelationDelete}, res.GetCapabilities())
assert.Equal(t, []string{common.RelationGet, common.RelationUpdate, common.RelationDelete}, res.GetCapabilities())
})
t.Run("user:6 should be able to read folder 1 ", func(t *testing.T) {
t.Run("user:6 should be able to read folder 1", func(t *testing.T) {
res, err := server.Capabilities(context.Background(), newReq("user:6", folderGroup, folderResource, "", "1"))
require.NoError(t, err)
assert.Equal(t, []string{common.RelationRead}, res.GetCapabilities())
assert.Equal(t, []string{common.RelationGet}, res.GetCapabilities())
})
t.Run("user:7 should be able to read folder one through namespace access", func(t *testing.T) {
t.Run("user:7 should be able to read folder one through group_resource access", func(t *testing.T) {
res, err := server.Capabilities(context.Background(), newReq("user:7", folderGroup, folderResource, "", "1"))
require.NoError(t, err)
assert.Equal(t, []string{common.RelationRead}, res.GetCapabilities())
assert.Equal(t, []string{common.RelationGet}, res.GetCapabilities())
})
}

@ -7,7 +7,6 @@ import (
authzv1 "github.com/grafana/authlib/authz/proto/v1"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"google.golang.org/protobuf/types/known/structpb"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
@ -22,9 +21,7 @@ func (s *Server) Check(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.C
}
relation := common.VerbMapping[r.GetVerb()]
// Check if subject has access through namespace
res, err := s.checkNamespace(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
res, err := s.checkGroupResource(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
if err != nil {
return nil, err
}
@ -39,18 +36,23 @@ func (s *Server) Check(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.C
return s.checkGeneric(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), r.GetName(), r.GetFolder(), store)
}
// checkTyped performes check on the root "namespace". If subject has access through the namespace they have access to
// every resource for that "GroupResource".
func (s *Server) checkNamespace(ctx context.Context, subject, relation, group, resource string, store *storeInfo) (*authzv1.CheckResponse, error) {
// checkGroupResource check if subject has access to the full "GroupResource", if they do they can access every object
// within it.
func (s *Server) checkGroupResource(ctx context.Context, subject, relation, group, resource string, store *storeInfo) (*authzv1.CheckResponse, error) {
if !common.IsGroupResourceRelation(relation) {
return &authzv1.CheckResponse{Allowed: false}, nil
}
req := &openfgav1.CheckRequest{
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
TupleKey: &openfgav1.CheckRequestTupleKey{
User: subject,
Relation: relation,
Object: common.NewNamespaceResourceIdent(group, resource),
Object: common.NewGroupResourceIdent(group, resource),
},
}
if strings.HasPrefix(subject, fmt.Sprintf("%s:", common.TypeRenderService)) {
common.AddRenderContext(req)
}
@ -63,8 +65,12 @@ func (s *Server) checkNamespace(ctx context.Context, subject, relation, group, r
return &authzv1.CheckResponse{Allowed: res.GetAllowed()}, nil
}
// checkTyped performes checks on our typed resources e.g. folder.
// checkTyped checks on our typed resources e.g. folder.
func (s *Server) checkTyped(ctx context.Context, subject, relation, name string, info common.TypeInfo, store *storeInfo) (*authzv1.CheckResponse, error) {
if !info.IsValidRelation(relation) {
return &authzv1.CheckResponse{Allowed: false}, nil
}
// Check if subject has direct access to resource
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
StoreId: store.ID,
@ -86,27 +92,26 @@ func (s *Server) checkTyped(ctx context.Context, subject, relation, name string,
return &authzv1.CheckResponse{Allowed: false}, nil
}
// checkGeneric check our generic "resource" type.
// checkGeneric check our generic "resource" type. It checks:
// 1. If subject has access as a sub resource for a folder.
// 2. If subject has direct access to resource.
func (s *Server) checkGeneric(ctx context.Context, subject, relation, group, resource, name, folder string, store *storeInfo) (*authzv1.CheckResponse, error) {
groupResource := structpb.NewStringValue(common.FormatGroupResource(group, resource))
var (
resourceCtx = common.NewResourceContext(group, resource)
folderRelation = common.FolderResourceRelation(relation)
)
// Create relation can only exist on namespace or folder level.
// So we skip direct resource access check.
if relation != common.RelationCreate {
// Check if subject has direct access to resource
if folder != "" && common.IsFolderResourceRelation(folderRelation) {
// Check if subject has access as a sub resource for the folder
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
TupleKey: &openfgav1.CheckRequestTupleKey{
User: subject,
Relation: relation,
Object: common.NewResourceIdent(group, resource, name),
},
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"requested_group": groupResource,
},
Relation: common.FolderResourceRelation(relation),
Object: common.NewFolderIdent(folder),
},
Context: resourceCtx,
})
if err != nil {
@ -114,28 +119,24 @@ func (s *Server) checkGeneric(ctx context.Context, subject, relation, group, res
}
if res.GetAllowed() {
return &authzv1.CheckResponse{Allowed: true}, nil
return &authzv1.CheckResponse{Allowed: res.GetAllowed()}, nil
}
}
if folder == "" {
if !common.IsResourceRelation(relation) {
return &authzv1.CheckResponse{Allowed: false}, nil
}
// Check if subject has access as a sub resource for the folder
// Check if subject has direct access to resource
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
TupleKey: &openfgav1.CheckRequestTupleKey{
User: subject,
Relation: common.FolderResourceRelation(relation),
Object: common.NewFolderIdent(folder),
},
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"requested_group": groupResource,
},
Relation: relation,
Object: common.NewResourceIdent(group, resource, name),
},
Context: resourceCtx,
})
if err != nil {

@ -35,7 +35,7 @@ func testCheck(t *testing.T, server *Server) {
assert.False(t, res.GetAllowed())
})
t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/1 through namespace", func(t *testing.T) {
t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/1 through group_resource", func(t *testing.T) {
res, err := server.Check(context.Background(), newReq("user:2", utils.VerbGet, dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
@ -83,7 +83,7 @@ func testCheck(t *testing.T, server *Server) {
assert.True(t, res.GetAllowed())
})
t.Run("user:7 should be able to read folder one through namespace access", func(t *testing.T) {
t.Run("user:7 should be able to read folder one through group_resource access", func(t *testing.T) {
res, err := server.Check(context.Background(), newReq("user:7", utils.VerbGet, folderGroup, folderResource, "", "1"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())

@ -6,7 +6,6 @@ import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"google.golang.org/protobuf/types/known/structpb"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
@ -23,15 +22,7 @@ func (s *Server) List(ctx context.Context, r *authzextv1.ListRequest) (*authzext
relation := common.VerbMapping[r.GetVerb()]
res, err := s.checkNamespace(
ctx,
r.GetSubject(),
relation,
r.GetGroup(),
r.GetResource(),
store,
)
res, err := s.checkGroupResource(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
if err != nil {
return nil, err
}
@ -55,8 +46,12 @@ func (s *Server) listObjects(ctx context.Context, req *openfgav1.ListObjectsRequ
}
func (s *Server) listTyped(ctx context.Context, subject, relation string, info common.TypeInfo, store *storeInfo) (*authzextv1.ListResponse, error) {
if !info.IsValidRelation(relation) {
return &authzextv1.ListResponse{}, nil
}
// List all resources user has access too
listRes, err := s.listObjects(ctx, &openfgav1.ListObjectsRequest{
res, err := s.listObjects(ctx, &openfgav1.ListObjectsRequest{
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
Type: info.Type,
@ -68,50 +63,56 @@ func (s *Server) listTyped(ctx context.Context, subject, relation string, info c
}
return &authzextv1.ListResponse{
Items: typedObjects(info.Type, listRes.GetObjects()),
Items: typedObjects(info.Type, res.GetObjects()),
}, nil
}
func (s *Server) listGeneric(ctx context.Context, subject, relation, group, resource string, store *storeInfo) (*authzextv1.ListResponse, error) {
groupResource := structpb.NewStringValue(common.FormatGroupResource(group, resource))
var (
resourceCtx = common.NewResourceContext(group, resource)
folderRelation = common.FolderResourceRelation(relation)
)
// 1. List all folders subject has access to resource type in
folders, err := s.listObjects(ctx, &openfgav1.ListObjectsRequest{
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
Type: common.TypeFolder,
Relation: common.FolderResourceRelation(relation),
User: subject,
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"requested_group": groupResource,
},
},
})
if err != nil {
return nil, err
var folders []string
if common.IsFolderResourceRelation(folderRelation) {
res, err := s.listObjects(ctx, &openfgav1.ListObjectsRequest{
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
Type: common.TypeFolder,
Relation: folderRelation,
User: subject,
Context: resourceCtx,
})
if err != nil {
return nil, err
}
folders = res.GetObjects()
}
// 2. List all resource directly assigned to subject
direct, err := s.listObjects(ctx, &openfgav1.ListObjectsRequest{
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
Type: common.TypeResource,
Relation: relation,
User: subject,
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"requested_group": groupResource,
},
},
})
if err != nil {
return nil, err
var resources []string
if common.IsResourceRelation(relation) {
res, err := s.listObjects(ctx, &openfgav1.ListObjectsRequest{
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
Type: common.TypeResource,
Relation: relation,
User: subject,
Context: resourceCtx,
})
if err != nil {
return nil, err
}
resources = res.GetObjects()
}
return &authzextv1.ListResponse{
Folders: folderObject(folders.GetObjects()),
Items: directObjects(group, resource, direct.GetObjects()),
Folders: folderObject(folders),
Items: directObjects(group, resource, resources),
}, nil
}

@ -80,19 +80,19 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
AuthorizationModelId: storeInf.ModelID,
Writes: &openfgav1.WriteRequestWrites{
TupleKeys: []*openfgav1.TupleKey{
common.NewResourceTuple("user:1", "read", dashboardGroup, dashboardResource, "1"),
common.NewResourceTuple("user:1", "write", dashboardGroup, dashboardResource, "1"),
common.NewNamespaceResourceTuple("user:2", "read", dashboardGroup, dashboardResource),
common.NewNamespaceResourceTuple("user:2", "write", dashboardGroup, dashboardResource),
common.NewResourceTuple("user:3", "view", dashboardGroup, dashboardResource, "1"),
common.NewFolderResourceTuple("user:4", "read", dashboardGroup, dashboardResource, "1"),
common.NewFolderResourceTuple("user:4", "read", dashboardGroup, dashboardResource, "3"),
common.NewFolderResourceTuple("user:5", "edit", dashboardGroup, dashboardResource, "1"),
common.NewFolderTuple("user:6", "read", "1"),
common.NewNamespaceResourceTuple("user:7", "read", folderGroup, folderResource),
common.NewResourceTuple("user:1", common.RelationGet, dashboardGroup, dashboardResource, "1"),
common.NewResourceTuple("user:1", common.RelationUpdate, dashboardGroup, dashboardResource, "1"),
common.NewGroupResourceTuple("user:2", common.RelationGet, dashboardGroup, dashboardResource),
common.NewGroupResourceTuple("user:2", common.RelationUpdate, dashboardGroup, dashboardResource),
common.NewResourceTuple("user:3", common.RelationSetView, dashboardGroup, dashboardResource, "1"),
common.NewFolderResourceTuple("user:4", common.RelationGet, dashboardGroup, dashboardResource, "1"),
common.NewFolderResourceTuple("user:4", common.RelationGet, dashboardGroup, dashboardResource, "3"),
common.NewFolderResourceTuple("user:5", common.RelationSetEdit, dashboardGroup, dashboardResource, "1"),
common.NewFolderTuple("user:6", common.RelationGet, "1"),
common.NewGroupResourceTuple("user:7", common.RelationGet, folderGroup, folderResource),
common.NewFolderParentTuple("5", "4"),
common.NewFolderParentTuple("6", "5"),
common.NewFolderResourceTuple("user:8", "edit", dashboardGroup, dashboardResource, "5"),
common.NewFolderResourceTuple("user:8", common.RelationSetEdit, dashboardGroup, dashboardResource, "5"),
common.NewFolderResourceTuple("user:9", "create", dashboardGroup, dashboardResource, "5"),
},
},

@ -56,18 +56,14 @@ var resourceTranslations = map[string]resourceTranslation{
group: folderGroup,
resource: folderResource,
mapping: map[string]actionMappig{
"folders:read": newMapping(RelationRead),
"folders:write": newMapping(RelationWrite),
"folders:create": newMapping(RelationCreate),
"folders:delete": newMapping(RelationDelete),
"folders.permissions:read": newMapping(RelationPermissionsRead),
"folders.permissions:write": newMapping(RelationPermissionsWrite),
"dashboards:read": newScopedMapping(RelationRead, dashboardGroup, dashboardResource),
"dashboards:write": newScopedMapping(RelationWrite, dashboardGroup, dashboardResource),
"dashboards:create": newScopedMapping(RelationCreate, dashboardGroup, dashboardResource),
"dashboards:delete": newScopedMapping(RelationDelete, dashboardGroup, dashboardResource),
"dashboards.permissions:read": newScopedMapping(RelationPermissionsRead, dashboardGroup, dashboardResource),
"dashboards.permissions:write": newScopedMapping(RelationPermissionsWrite, dashboardGroup, dashboardResource),
"folders:read": newMapping(RelationGet),
"folders:write": newMapping(RelationUpdate),
"folders:create": newMapping(RelationCreate),
"folders:delete": newMapping(RelationDelete),
"dashboards:read": newScopedMapping(RelationGet, dashboardGroup, dashboardResource),
"dashboards:write": newScopedMapping(RelationUpdate, dashboardGroup, dashboardResource),
"dashboards:create": newScopedMapping(RelationCreate, dashboardGroup, dashboardResource),
"dashboards:delete": newScopedMapping(RelationDelete, dashboardGroup, dashboardResource),
},
},
KindDashboards: {
@ -75,12 +71,10 @@ var resourceTranslations = map[string]resourceTranslation{
group: dashboardGroup,
resource: dashboardResource,
mapping: map[string]actionMappig{
"dashboards:read": newMapping(RelationRead),
"dashboards:write": newMapping(RelationWrite),
"dashboards:create": newMapping(RelationCreate),
"dashboards:delete": newMapping(RelationDelete),
"dashboards.permissions:read": newMapping(RelationPermissionsRead),
"dashboards.permissions:write": newMapping(RelationPermissionsWrite),
"dashboards:read": newMapping(RelationGet),
"dashboards:write": newMapping(RelationUpdate),
"dashboards:create": newMapping(RelationCreate),
"dashboards:delete": newMapping(RelationDelete),
},
},
}

@ -18,7 +18,7 @@ const (
TypeRole = common.TypeRole
TypeFolder = common.TypeFolder
TypeResource = common.TypeResource
TypeNamespace = common.TypeNamespace
TypeNamespace = common.TypeGroupResouce
)
const (
@ -31,28 +31,25 @@ const (
RelationSetEdit = common.RelationSetEdit
RelationSetAdmin = common.RelationSetAdmin
RelationRead = common.RelationRead
RelationWrite = common.RelationWrite
RelationCreate = common.RelationCreate
RelationDelete = common.RelationDelete
RelationPermissionsRead = common.RelationPermissionsRead
RelationPermissionsWrite = common.RelationPermissionsWrite
RelationGet = common.RelationGet
RelationUpdate = common.RelationUpdate
RelationCreate = common.RelationCreate
RelationDelete = common.RelationDelete
RelationFolderResourceSetView = common.RelationFolderResourceSetView
RelationFolderResourceSetEdit = common.RelationFolderResourceSetEdit
RelationFolderResourceSetAdmin = common.RelationFolderResourceSetAdmin
RelationFolderResourceRead = common.RelationFolderResourceRead
RelationFolderResourceWrite = common.RelationFolderResourceWrite
RelationFolderResourceCreate = common.RelationFolderResourceCreate
RelationFolderResourceDelete = common.RelationFolderResourceDelete
RelationFolderResourcePermissionsRead = common.RelationFolderResourcePermissionsRead
RelationFolderResourcePermissionsWrite = common.RelationFolderResourcePermissionsWrite
RelationFolderResourceRead = common.RelationFolderResourceGet
RelationFolderResourceWrite = common.RelationFolderResourceUpdate
RelationFolderResourceCreate = common.RelationFolderResourceCreate
RelationFolderResourceDelete = common.RelationFolderResourceDelete
)
var (
FolderRelations = common.FolderRelations
ResourceRelations = common.ResourceRelations
RelationsFolder = common.RelationsFolder
RelationsFolderResource = common.RelationsFolder
RelationsResouce = common.RelationsResource
)
const (
@ -98,7 +95,7 @@ func TranslateToResourceTuple(subject string, action, kind, name string) (*openf
}
if name == "*" {
return common.NewNamespaceResourceTuple(subject, m.relation, translation.group, translation.resource), true
return common.NewGroupResourceTuple(subject, m.relation, translation.group, translation.resource), true
}
if translation.typ == TypeResource {

@ -1248,7 +1248,7 @@ var (
Stage: FeatureStageGeneralAvailability,
Owner: grafanaSharingSquad,
FrontendOnly: true,
Expression: "false", // disabled by default
Expression: "true", // enabled by default
},
{
Name: "alertingListViewV2",
@ -1436,8 +1436,9 @@ var (
{
Name: "newFiltersUI",
Description: "Enables new combobox style UI for the Ad hoc filters variable in scenes architecture",
Stage: FeatureStageExperimental,
Stage: FeatureStageGeneralAvailability,
Owner: grafanaDashboardsSquad,
Expression: "true", // enabled by default
},
{
Name: "lokiSendDashboardPanelNames",

@ -188,7 +188,7 @@ cloudwatchMetricInsightsCrossAccount,GA,@grafana/aws-datasources,false,false,tru
prometheusAzureOverrideAudience,deprecated,@grafana/partner-datasources,false,false,false
alertingFilterV2,experimental,@grafana/alerting-squad,false,false,false
dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false
newFiltersUI,experimental,@grafana/dashboards-squad,false,false,false
newFiltersUI,GA,@grafana/dashboards-squad,false,false,false
lokiSendDashboardPanelNames,experimental,@grafana/observability-logs,false,false,false
alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true
singleTopNav,GA,@grafana/grafana-frontend-platform,false,false,true

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
188 prometheusAzureOverrideAudience deprecated @grafana/partner-datasources false false false
189 alertingFilterV2 experimental @grafana/alerting-squad false false false
190 dataplaneAggregator experimental @grafana/grafana-app-platform-squad false true false
191 newFiltersUI experimental GA @grafana/dashboards-squad false false false
192 lokiSendDashboardPanelNames experimental @grafana/observability-logs false false false
193 alertingPrometheusRulesPrimary experimental @grafana/alerting-squad false false true
194 singleTopNav GA @grafana/grafana-frontend-platform false false true

@ -2327,10 +2327,10 @@
{
"metadata": {
"name": "newDashboardSharingComponent",
"resourceVersion": "1726241874335",
"resourceVersion": "1733231733564",
"creationTimestamp": "2024-05-03T15:02:18Z",
"annotations": {
"grafana.app/updatedTimestamp": "2024-09-13 15:37:54.335099 +0000 UTC"
"grafana.app/updatedTimestamp": "2024-12-03 13:15:33.564083 +0000 UTC"
}
},
"spec": {
@ -2338,7 +2338,7 @@
"stage": "GA",
"codeowner": "@grafana/sharing-squad",
"frontend": true,
"expression": "false"
"expression": "true"
}
},
{
@ -2358,13 +2358,17 @@
{
"metadata": {
"name": "newFiltersUI",
"resourceVersion": "1724228641625",
"creationTimestamp": "2024-08-30T12:48:13Z"
"resourceVersion": "1733391902652",
"creationTimestamp": "2024-08-30T12:48:13Z",
"annotations": {
"grafana.app/updatedTimestamp": "2024-12-05 09:45:02.652078 +0000 UTC"
}
},
"spec": {
"description": "Enables new combobox style UI for the Ad hoc filters variable in scenes architecture",
"stage": "experimental",
"codeowner": "@grafana/dashboards-squad"
"stage": "GA",
"codeowner": "@grafana/dashboards-squad",
"expression": "true"
}
},
{

@ -110,7 +110,8 @@ func sqLite3TestDB() (*TestDB, error) {
ret.ConnStr = "file:" + sqliteDb + "?cache=private&mode=rwc"
if os.Getenv("SQLITE_JOURNAL_MODE") != "false" {
ret.ConnStr += "&_journal_mode=WAL"
// For tests, set sync=OFF for faster commits. Reference: https://www.sqlite.org/pragma.html#pragma_synchronous.
ret.ConnStr += "&_journal_mode=WAL&_synchronous=OFF"
}
ret.Path = sqliteDb

@ -8,7 +8,6 @@ import (
"sync"
"time"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
"github.com/hashicorp/golang-lru/v2/expirable"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
@ -171,7 +170,7 @@ func (s *searchSupport) Search(ctx context.Context, req *ResourceSearchRequest)
// init is called during startup. any failure will block startup and continued execution
func (s *searchSupport) init(ctx context.Context) error {
_, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Init")
ctx, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Init")
defer span.End()
start := time.Now().Unix()
@ -214,6 +213,7 @@ func (s *searchSupport) init(ctx context.Context) error {
}()
end := time.Now().Unix()
s.log.Info("search index initialized", "duration_secs", end-start, "total_docs", s.search.TotalDocs())
if IndexMetrics != nil {
IndexMetrics.IndexCreationTime.WithLabelValues().Observe(float64(end - start))
}
@ -277,7 +277,7 @@ func (s *searchSupport) handleEvent(ctx context.Context, evt *WrittenEvent) {
// record latency from when event was created to when it was indexed
latencySeconds := float64(time.Now().UnixMicro()-evt.ResourceVersion) / 1e6
if latencySeconds > 5 {
logger.Warn("high index latency", "latency", latencySeconds)
s.log.Warn("high index latency", "latency", latencySeconds)
}
if IndexMetrics != nil {
IndexMetrics.IndexLatency.WithLabelValues(evt.Key.Resource).Observe(latencySeconds)
@ -307,7 +307,7 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedReso
}
func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size int64, rv int64) (ResourceIndex, int64, error) {
_, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Build")
ctx, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Build")
defer span.End()
builder, err := s.builders.get(ctx, nsr)

@ -255,6 +255,12 @@ func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) {
}
}
err := s.Init(ctx)
if err != nil {
s.log.Error("error initializing resource server", "error", err)
return nil, err
}
return s, nil
}
@ -294,16 +300,16 @@ func (s *server) Init(ctx context.Context) error {
}
}
// Start watching for changes
if s.initErr == nil {
s.initErr = s.initWatcher()
}
// initialize the search index
if s.initErr == nil && s.search != nil {
s.initErr = s.search.init(ctx)
}
// Start watching for changes
if s.initErr == nil {
s.initErr = s.initWatcher()
}
if s.initErr != nil {
s.log.Error("error initializing resource server", "error", s.initErr)
}
@ -446,10 +452,6 @@ func (s *server) Create(ctx context.Context, req *CreateRequest) (*CreateRespons
ctx, span := s.tracer.Start(ctx, "storage_server.Create")
defer span.End()
if err := s.Init(ctx); err != nil {
return nil, err
}
rsp := &CreateResponse{}
user, ok := claims.From(ctx)
if !ok || user == nil {
@ -488,10 +490,6 @@ func (s *server) Update(ctx context.Context, req *UpdateRequest) (*UpdateRespons
ctx, span := s.tracer.Start(ctx, "storage_server.Update")
defer span.End()
if err := s.Init(ctx); err != nil {
return nil, err
}
rsp := &UpdateResponse{}
user, ok := claims.From(ctx)
if !ok || user == nil {
@ -542,10 +540,6 @@ func (s *server) Delete(ctx context.Context, req *DeleteRequest) (*DeleteRespons
ctx, span := s.tracer.Start(ctx, "storage_server.Delete")
defer span.End()
if err := s.Init(ctx); err != nil {
return nil, err
}
rsp := &DeleteResponse{}
if req.ResourceVersion < 0 {
return nil, apierrors.NewBadRequest("update must include the previous version")
@ -634,9 +628,6 @@ func (s *server) Delete(ctx context.Context, req *DeleteRequest) (*DeleteRespons
}
func (s *server) Read(ctx context.Context, req *ReadRequest) (*ReadResponse, error) {
if err := s.Init(ctx); err != nil {
return nil, err
}
user, ok := claims.From(ctx)
if !ok || user == nil {
return &ReadResponse{
@ -693,9 +684,6 @@ func (s *server) List(ctx context.Context, req *ListRequest) (*ListResponse, err
}}, nil
}
if err := s.Init(ctx); err != nil {
return nil, err
}
if req.Limit < 1 {
req.Limit = 50 // default max 50 items in a page
}
@ -786,10 +774,6 @@ func (s *server) initWatcher() error {
func (s *server) Watch(req *WatchRequest, srv ResourceStore_WatchServer) error {
ctx := srv.Context()
if err := s.Init(ctx); err != nil {
return err
}
user, ok := claims.From(ctx)
if !ok || user == nil {
return apierrors.NewUnauthorized("no user found in context")
@ -930,9 +914,6 @@ func (s *server) Watch(req *WatchRequest, srv ResourceStore_WatchServer) error {
}
func (s *server) Search(ctx context.Context, req *ResourceSearchRequest) (*ResourceSearchResponse, error) {
if err := s.Init(ctx); err != nil {
return nil, err
}
if s.search == nil {
return nil, fmt.Errorf("search index not configured")
}
@ -941,25 +922,16 @@ func (s *server) Search(ctx context.Context, req *ResourceSearchRequest) (*Resou
// History implements ResourceServer.
func (s *server) History(ctx context.Context, req *HistoryRequest) (*HistoryResponse, error) {
if err := s.Init(ctx); err != nil {
return nil, err
}
return s.search.History(ctx, req)
}
// Origin implements ResourceServer.
func (s *server) Origin(ctx context.Context, req *OriginRequest) (*OriginResponse, error) {
if err := s.Init(ctx); err != nil {
return nil, err
}
return s.search.Origin(ctx, req)
}
// IsHealthy implements ResourceServer.
func (s *server) IsHealthy(ctx context.Context, req *HealthCheckRequest) (*HealthCheckResponse, error) {
if err := s.Init(ctx); err != nil {
return nil, err
}
return s.diagnostics.IsHealthy(ctx, req)
}
@ -971,9 +943,6 @@ func (s *server) PutBlob(ctx context.Context, req *PutBlobRequest) (*PutBlobResp
Code: http.StatusNotImplemented,
}}, nil
}
if err := s.Init(ctx); err != nil {
return nil, err
}
rsp, err := s.blob.PutResourceBlob(ctx, req)
if err != nil {
@ -1016,10 +985,6 @@ func (s *server) GetBlob(ctx context.Context, req *GetBlobRequest) (*GetBlobResp
}}, nil
}
if err := s.Init(ctx); err != nil {
return nil, err
}
// The linked blob is stored in the resource metadata attributes
obj, status := s.getPartialObject(ctx, req.Resource, req.ResourceVersion)
if status != nil {

@ -85,9 +85,6 @@ func (b *bleveBackend) BuildIndex(ctx context.Context,
// The builder will write all documents before returning
builder func(index resource.ResourceIndex) (int64, error),
) (resource.ResourceIndex, error) {
b.cacheMu.Lock()
defer b.cacheMu.Unlock()
_, span := b.tracer.Start(ctx, tracingPrexfixBleve+"BuildIndex")
defer span.End()
@ -99,9 +96,9 @@ func (b *bleveBackend) BuildIndex(ctx context.Context,
if size > b.opts.FileThreshold {
dir := filepath.Join(b.opts.Root, key.Namespace, fmt.Sprintf("%s.%s", key.Resource, key.Group))
index, err = bleve.New(dir, mapper)
if err == nil {
b.log.Info("TODO, check last RV so we can see if the numbers have changed", "dir", dir)
}
// TODO, check last RV so we can see if the numbers have changed
resource.IndexMetrics.IndexTenants.WithLabelValues(key.Namespace, "file").Inc()
} else {
index, err = bleve.NewMemOnly(mapper)
@ -137,7 +134,9 @@ func (b *bleveBackend) BuildIndex(ctx context.Context,
return nil, err
}
b.cacheMu.Lock()
b.cache[key] = idx
b.cacheMu.Unlock()
return idx, nil
}

@ -123,7 +123,7 @@ func (b *backend) Stop(_ context.Context) error {
// GetResourceStats implements Backend.
func (b *backend) GetResourceStats(ctx context.Context, namespace string, minCount int) ([]resource.ResourceStats, error) {
_, span := b.tracer.Start(ctx, tracePrefix+".GetResourceStats")
ctx, span := b.tracer.Start(ctx, tracePrefix+".GetResourceStats")
defer span.End()
req := &sqlStatsRequest{

@ -461,9 +461,14 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) {
}
}
}
logSection, err := getOrCreateSection("database")
dbSection, err := getOrCreateSection("database")
require.NoError(t, err)
_, err = dbSection.NewKey("query_retries", fmt.Sprintf("%d", queryRetries))
require.NoError(t, err)
_, err = dbSection.NewKey("max_open_conn", "2")
require.NoError(t, err)
_, err = logSection.NewKey("query_retries", fmt.Sprintf("%d", queryRetries))
_, err = dbSection.NewKey("max_idle_conn", "2")
require.NoError(t, err)
cfgPath := filepath.Join(cfgDir, "test.ini")

@ -121,8 +121,8 @@ func writeErrorResponse(rw http.ResponseWriter, code int, msg string) {
errorBody := map[string]string{
"error": msg,
}
json, _ := json.Marshal(errorBody)
_, err := rw.Write(json)
jsonRes, _ := json.Marshal(errorBody)
_, err := rw.Write(jsonRes)
if err != nil {
backend.Logger.Error("Unable to write HTTP response", "error", err)
}

@ -16,7 +16,7 @@ import (
func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
err := e.Ping()
if err != nil {
logCheckHealthError(ctx, e.dsInfo, err, e.log)
logCheckHealthError(ctx, e.dsInfo, err)
if strings.EqualFold(req.PluginContext.User.Role, "Admin") {
return ErrToHealthCheckResult(err)
}
@ -73,7 +73,8 @@ func ErrToHealthCheckResult(err error) (*backend.CheckHealthResult, error) {
return res, nil
}
func logCheckHealthError(_ context.Context, dsInfo DataSourceInfo, err error, logger log.Logger) {
func logCheckHealthError(ctx context.Context, dsInfo DataSourceInfo, err error) {
logger := log.DefaultLogger.FromContext(ctx)
configSummary := map[string]any{
"config_url_length": len(dsInfo.URL),
"config_user_length": len(dsInfo.User),
@ -104,8 +105,8 @@ func logCheckHealthError(_ context.Context, dsInfo DataSourceInfo, err error, lo
}
configSummaryJson, marshalError := json.Marshal(configSummary)
if marshalError != nil {
logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error", "plugin_id", "grafana-postgresql-datasource")
logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error")
return
}
logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error_detailed", "plugin_id", "grafana-postgresql-datasource", "details", string(configSummaryJson))
logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error_detailed", "details", string(configSummaryJson))
}

@ -73,8 +73,6 @@ func Krb5ParseAuthCredentials(host string, port string, db string, user string,
krb5DriverParams += "krb5-dnslookupkdc=" + kerberosAuth.EnableDNSLookupKDC + ";"
}
logger.Info(fmt.Sprintf("final krb connstr: %s", krb5DriverParams))
return krb5DriverParams
}

@ -0,0 +1,93 @@
package sqleng
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"strings"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
)
func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
if err := e.db.Ping(); err != nil {
logCheckHealthError(ctx, e.dsInfo, err)
if strings.EqualFold(req.PluginContext.User.Role, "Admin") {
return ErrToHealthCheckResult(err)
}
return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: e.TransformQueryError(e.log, err).Error()}, nil
}
return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "Database Connection OK"}, nil
}
// ErrToHealthCheckResult converts error into user friendly health check message
// This should be called with non nil error. If the err parameter is empty, we will send Internal Server Error
func ErrToHealthCheckResult(err error) (*backend.CheckHealthResult, error) {
if err == nil {
return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "Internal Server Error"}, nil
}
res := &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: err.Error()}
details := map[string]string{
"verboseMessage": err.Error(),
"errorDetailsLink": "https://grafana.com/docs/grafana/latest/datasources/mssql",
}
var opErr *net.OpError
if errors.As(err, &opErr) {
res.Message = "Network error: Failed to connect to the server"
if opErr != nil && opErr.Err != nil {
res.Message += fmt.Sprintf(". Error message: %s", opErr.Err.Error())
}
}
if strings.HasPrefix(err.Error(), "mssql: ") {
res.Message = "Database error: Failed to connect to the mssql server"
if unwrappedErr := errors.Unwrap(err); unwrappedErr != nil {
details["verboseMessage"] = unwrappedErr.Error()
}
}
detailBytes, marshalErr := json.Marshal(details)
if marshalErr != nil {
return res, nil
}
res.JSONDetails = detailBytes
return res, nil
}
func logCheckHealthError(ctx context.Context, dsInfo DataSourceInfo, err error) {
logger := log.DefaultLogger.FromContext(ctx)
configSummary := map[string]any{
"config_url_length": len(dsInfo.URL),
"config_user_length": len(dsInfo.User),
"config_database_length": len(dsInfo.Database),
"config_json_data_database_length": len(dsInfo.JsonData.Database),
"config_max_open_conns": dsInfo.JsonData.MaxOpenConns,
"config_max_idle_conns": dsInfo.JsonData.MaxIdleConns,
"config_conn_max_life_time": dsInfo.JsonData.ConnMaxLifetime,
"config_conn_timeout": dsInfo.JsonData.ConnectionTimeout,
"config_ssl_mode": dsInfo.JsonData.Mode,
"config_tls_configuration_method": dsInfo.JsonData.ConfigurationMethod,
"config_tls_skip_verify": dsInfo.JsonData.TlsSkipVerify,
"config_timezone": dsInfo.JsonData.Timezone,
"config_time_interval": dsInfo.JsonData.TimeInterval,
"config_enable_secure_proxy": dsInfo.JsonData.SecureDSProxy,
"config_allow_clear_text_passwords": dsInfo.JsonData.AllowCleartextPasswords,
"config_authentication_type": dsInfo.JsonData.AuthenticationType,
"config_ssl_root_cert_file_length": len(dsInfo.JsonData.RootCertFile),
"config_ssl_cert_file_length": len(dsInfo.JsonData.CertFile),
"config_ssl_key_file_length": len(dsInfo.JsonData.CertKeyFile),
"config_encrypt_length": len(dsInfo.JsonData.Encrypt),
"config_server_name_length": len(dsInfo.JsonData.Servername),
"config_password_length": len(dsInfo.DecryptedSecureJSONData["password"]),
"config_tls_ca_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsCACert"]),
"config_tls_client_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsClientCert"]),
"config_tls_client_key_length": len(dsInfo.DecryptedSecureJSONData["tlsClientKey"]),
}
configSummaryJson, marshalError := json.Marshal(configSummary)
if marshalError != nil {
logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error")
return
}
logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error_detailed", "details", string(configSummaryJson))
}

@ -0,0 +1,60 @@
package sqleng
import (
"errors"
"net"
"testing"
"github.com/grafana/grafana-plugin-sdk-go/backend"
mssql "github.com/microsoft/go-mssqldb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestErrToHealthCheckResult(t *testing.T) {
tests := []struct {
name string
err error
want *backend.CheckHealthResult
}{
{
name: "without error",
want: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "Internal Server Error"},
},
{
name: "network error",
err: errors.Join(errors.New("foo"), &net.OpError{Op: "read", Net: "tcp", Err: errors.New("some op")}),
want: &backend.CheckHealthResult{
Status: backend.HealthStatusError,
Message: "Network error: Failed to connect to the server. Error message: some op",
JSONDetails: []byte(`{"errorDetailsLink":"https://grafana.com/docs/grafana/latest/datasources/mssql","verboseMessage":"foo\nread tcp: some op"}`),
},
},
{
name: "db error",
err: errors.Join(errors.New("foo"), &mssql.Error{Message: "error foo occurred in mssql server"}),
want: &backend.CheckHealthResult{
Status: backend.HealthStatusError,
Message: "foo\nmssql: error foo occurred in mssql server",
JSONDetails: []byte(`{"errorDetailsLink":"https://grafana.com/docs/grafana/latest/datasources/mssql","verboseMessage":"foo\nmssql: error foo occurred in mssql server"}`),
},
},
{
name: "regular error",
err: errors.New("internal server error"),
want: &backend.CheckHealthResult{
Status: backend.HealthStatusError,
Message: "internal server error",
JSONDetails: []byte(`{"errorDetailsLink":"https://grafana.com/docs/grafana/latest/datasources/mssql","verboseMessage":"internal server error"}`),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ErrToHealthCheckResult(tt.err)
require.Nil(t, err)
assert.Equal(t, string(tt.want.JSONDetails), string(got.JSONDetails))
require.Equal(t, tt.want, got)
})
}
}

@ -152,15 +152,6 @@ func (e *DataSourceHandler) Dispose() {
e.log.Debug("DB disposed")
}
func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
err := e.db.Ping()
if err != nil {
return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: e.TransformQueryError(e.log, err).Error()}, nil
}
return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "Database Connection OK"}, nil
}
func (e *DataSourceHandler) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
result := backend.NewQueryDataResponse()
ch := make(chan DBDataResponse, len(req.Queries))

@ -16,7 +16,7 @@ import (
func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
err := e.db.Ping()
if err != nil {
logCheckHealthError(ctx, e.dsInfo, err, e.log)
logCheckHealthError(ctx, e.dsInfo, err)
if strings.EqualFold(req.PluginContext.User.Role, "Admin") {
return ErrToHealthCheckResult(err)
}
@ -63,7 +63,8 @@ func ErrToHealthCheckResult(err error) (*backend.CheckHealthResult, error) {
return res, nil
}
func logCheckHealthError(_ context.Context, dsInfo DataSourceInfo, err error, logger log.Logger) {
func logCheckHealthError(ctx context.Context, dsInfo DataSourceInfo, err error) {
logger := log.DefaultLogger.FromContext(ctx)
configSummary := map[string]any{
"config_url_length": len(dsInfo.URL),
"config_user_length": len(dsInfo.User),
@ -94,8 +95,8 @@ func logCheckHealthError(_ context.Context, dsInfo DataSourceInfo, err error, lo
}
configSummaryJson, marshalError := json.Marshal(configSummary)
if marshalError != nil {
logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error", "plugin_id", "mysql")
logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error")
return
}
logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error_detailed", "plugin_id", "mysql", "details", string(configSummaryJson))
logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error_detailed", "details", string(configSummaryJson))
}

@ -2211,6 +2211,113 @@
}
}
},
"/scim/users/": {
"get": {
"tags": [
"users",
"enterprise"
],
"summary": "Fetches all users in UserSchema format.",
"operationId": "getUsers",
"responses": {
"200": {
"$ref": "#/responses/getUsers"
},
"400": {
"$ref": "#/responses/badRequestError"
},
"401": {
"$ref": "#/responses/unauthorisedError"
},
"404": {
"$ref": "#/responses/notFoundError"
},
"500": {
"$ref": "#/responses/internalServerError"
}
}
},
"post": {
"tags": [
"users",
"enterprise"
],
"summary": "Creates user.",
"operationId": "createUser",
"responses": {
"200": {
"$ref": "#/responses/okResponse"
},
"400": {
"$ref": "#/responses/badRequestError"
},
"401": {
"$ref": "#/responses/unauthorisedError"
},
"403": {
"$ref": "#/responses/forbiddenError"
},
"409": {
"$ref": "#/responses/conflictError"
},
"500": {
"$ref": "#/responses/internalServerError"
}
}
},
"delete": {
"tags": [
"user",
"enterprise"
],
"summary": "Deletes user.",
"operationId": "deleteUser",
"responses": {
"200": {
"$ref": "#/responses/okResponse"
},
"400": {
"$ref": "#/responses/badRequestError"
},
"401": {
"$ref": "#/responses/unauthorisedError"
},
"404": {
"$ref": "#/responses/notFoundError"
},
"500": {
"$ref": "#/responses/internalServerError"
}
}
}
},
"/scim/users/:id": {
"get": {
"tags": [
"user",
"enterprise"
],
"summary": "Gets user by id.",
"operationId": "getUser",
"responses": {
"200": {
"$ref": "#/responses/okResponse"
},
"400": {
"$ref": "#/responses/badRequestError"
},
"401": {
"$ref": "#/responses/unauthorisedError"
},
"403": {
"$ref": "#/responses/forbiddenError"
},
"500": {
"$ref": "#/responses/internalServerError"
}
}
}
},
"/teams/{teamId}/groups": {
"get": {
"tags": [

@ -8609,7 +8609,8 @@
"/scim/users/": {
"get": {
"tags": [
"users"
"users",
"enterprise"
],
"summary": "Fetches all users in UserSchema format.",
"operationId": "getUsers",
@ -8633,7 +8634,8 @@
},
"post": {
"tags": [
"users"
"users",
"enterprise"
],
"summary": "Creates user.",
"operationId": "createUser",
@ -8660,7 +8662,8 @@
},
"delete": {
"tags": [
"user"
"user",
"enterprise"
],
"summary": "Deletes user.",
"operationId": "deleteUser",
@ -8686,7 +8689,8 @@
"/scim/users/:id": {
"get": {
"tags": [
"user"
"user",
"enterprise"
],
"summary": "Gets user by id.",
"operationId": "getUser",

@ -1,14 +1,16 @@
import { css } from '@emotion/css';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { GrafanaTheme2, VariableHide } from '@grafana/data';
import {
CustomVariable,
EmbeddedScene,
PanelBuilders,
SceneComponentProps,
SceneControlsSpacer,
SceneFlexItem,
SceneFlexLayout,
SceneObjectBase,
SceneQueryRunner,
SceneReactObject,
SceneRefreshPicker,
@ -16,7 +18,9 @@ import {
SceneTimeRange,
SceneVariableSet,
TextBoxVariable,
VariableDependencyConfig,
VariableValueSelectors,
sceneGraph,
useUrlSync,
} from '@grafana/scenes';
import { GraphDrawStyle, VisibilityMode } from '@grafana/schema/dist/esm/index';
@ -36,14 +40,13 @@ import {
import { Trans } from 'app/core/internationalization';
import { LogMessages, logInfo } from '../../../Analytics';
import { DataSourceInformation } from '../../../home/Insights';
import { alertStateHistoryDatasource, useRegisterHistoryRuntimeDataSource } from './CentralHistoryRuntimeDataSource';
import { HistoryEventsListObject } from './EventListSceneObject';
export const LABELS_FILTER = 'labelsFilter';
export const STATE_FILTER_TO = 'stateFilterTo';
export const STATE_FILTER_FROM = 'stateFilterFrom';
export const LABELS_FILTER = 'LABELS_FILTER';
export const STATE_FILTER_TO = 'STATE_FILTER_TO';
export const STATE_FILTER_FROM = 'STATE_FILTER_FROM';
/**
*
* This scene shows the history of the alert state changes.
@ -67,74 +70,72 @@ export const CentralAlertHistoryScene = () => {
logInfo(LogMessages.loadedCentralAlertStateHistory);
}, []);
// create the variables for the filters
// textbox variable for filtering by labels
const labelsFilterVariable = new TextBoxVariable({
name: LABELS_FILTER,
label: 'Labels: ',
});
//custom variable for filtering by the current state
const transitionsToFilterVariable = new CustomVariable({
name: STATE_FILTER_TO,
value: StateFilterValues.all,
label: 'End state:',
hide: VariableHide.dontHide,
query: `All : ${StateFilterValues.all}, To Firing : ${StateFilterValues.firing},To Normal : ${StateFilterValues.normal},To Pending : ${StateFilterValues.pending}`,
});
//custom variable for filtering by the previous state
const transitionsFromFilterVariable = new CustomVariable({
name: STATE_FILTER_FROM,
value: StateFilterValues.all,
label: 'Start state:',
hide: VariableHide.dontHide,
query: `All : ${StateFilterValues.all}, From Firing : ${StateFilterValues.firing},From Normal : ${StateFilterValues.normal},From Pending : ${StateFilterValues.pending}`,
});
useRegisterHistoryRuntimeDataSource(); // register the runtime datasource for the history api.
const scene = new EmbeddedScene({
controls: [
new SceneReactObject({
component: LabelFilter,
}),
new SceneReactObject({
component: FilterInfo,
}),
new VariableValueSelectors({}),
new SceneReactObject({
component: ClearFilterButton,
props: {
labelsFilterVariable,
transitionsToFilterVariable,
transitionsFromFilterVariable,
},
}),
new SceneControlsSpacer(),
new SceneTimePicker({}),
new SceneRefreshPicker({}),
],
// use default time range as from 1 hour ago to now, as the limit of the history api is 5000 events,
// and using a wider time range might lead to showing gaps in the events list and the chart.
$timeRange: new SceneTimeRange({
from: 'now-1h',
to: 'now',
}),
$variables: new SceneVariableSet({
variables: [labelsFilterVariable, transitionsFromFilterVariable, transitionsToFilterVariable],
}),
body: new SceneFlexLayout({
direction: 'column',
children: [
new SceneFlexItem({
ySizing: 'content',
body: getEventsSceneObject(alertStateHistoryDatasource),
const scene = useMemo(() => {
// create the variables for the filters
// textbox variable for filtering by labels
const labelsFilterVariable = new TextBoxVariable({
name: LABELS_FILTER,
label: 'Labels: ',
});
//custom variable for filtering by the current state
const transitionsToFilterVariable = new CustomVariable({
name: STATE_FILTER_TO,
value: StateFilterValues.all,
label: 'End state:',
hide: VariableHide.dontHide,
query: `All : ${StateFilterValues.all}, To Firing : ${StateFilterValues.firing},To Normal : ${StateFilterValues.normal},To Pending : ${StateFilterValues.pending}`,
});
//custom variable for filtering by the previous state
const transitionsFromFilterVariable = new CustomVariable({
name: STATE_FILTER_FROM,
value: StateFilterValues.all,
label: 'Start state:',
hide: VariableHide.dontHide,
query: `All : ${StateFilterValues.all}, From Firing : ${StateFilterValues.firing},From Normal : ${StateFilterValues.normal},From Pending : ${StateFilterValues.pending}`,
});
return new EmbeddedScene({
controls: [
new SceneReactObject({
component: LabelFilter,
}),
new SceneFlexItem({
body: new HistoryEventsListObject(),
new SceneReactObject({
component: FilterInfo,
}),
new VariableValueSelectors({}),
new ClearFilterButtonScenesObject({}),
new SceneControlsSpacer(),
new SceneTimePicker({}),
new SceneRefreshPicker({}),
],
}),
});
// use default time range as from 1 hour ago to now, as the limit of the history api is 5000 events,
// and using a wider time range might lead to showing gaps in the events list and the chart.
$timeRange: new SceneTimeRange({
from: 'now-1h',
to: 'now',
}),
$variables: new SceneVariableSet({
variables: [labelsFilterVariable, transitionsFromFilterVariable, transitionsToFilterVariable],
}),
body: new SceneFlexLayout({
direction: 'column',
children: [
new SceneFlexItem({
ySizing: 'content',
body: getEventsSceneObject(),
}),
new SceneFlexItem({
body: new HistoryEventsListObject({}),
}),
],
}),
});
}, []);
// we need to call this to sync the url with the scene state
const isUrlSyncInitialized = useUrlSync(scene);
@ -147,22 +148,11 @@ export const CentralAlertHistoryScene = () => {
/**
* Creates a SceneFlexItem with a timeseries panel that shows the events.
* The query uses a runtime datasource that fetches the events from the history api.
* @param alertStateHistoryDataSource the datasource information for the runtime datasource
*/
function getEventsSceneObject(alertStateHistoryDataSource: DataSourceInformation) {
return new EmbeddedScene({
controls: [],
body: new SceneFlexLayout({
direction: 'column',
children: [
new SceneFlexItem({
ySizing: 'content',
body: new SceneFlexLayout({
children: [getEventsScenesFlexItem(alertStateHistoryDataSource)],
}),
}),
],
}),
function getEventsSceneObject() {
return new SceneFlexLayout({
direction: 'column',
children: [getEventsScenesFlexItem()],
});
}
@ -171,15 +161,15 @@ function getEventsSceneObject(alertStateHistoryDataSource: DataSourceInformation
* @param datasource the datasource information for the runtime datasource
* @returns the SceneQueryRunner
*/
function getSceneQuery(datasource: DataSourceInformation) {
function getQueryRunnerForAlertHistoryDataSource() {
const query = new SceneQueryRunner({
datasource: datasource,
datasource: alertStateHistoryDatasource,
queries: [
{
refId: 'A',
expr: '',
queryType: 'range',
step: '10s',
labels: '${LABELS_FILTER}',
stateFrom: '${STATE_FILTER_FROM}',
stateTo: '${STATE_FILTER_TO}',
},
],
});
@ -189,7 +179,7 @@ function getSceneQuery(datasource: DataSourceInformation) {
* This function creates a SceneFlexItem with a timeseries panel that shows the events.
* The query uses a runtime datasource that fetches the events from the history api.
*/
export function getEventsScenesFlexItem(datasource: DataSourceInformation) {
export function getEventsScenesFlexItem() {
return new SceneFlexItem({
minHeight: 300,
body: PanelBuilders.timeseries()
@ -197,7 +187,7 @@ export function getEventsScenesFlexItem(datasource: DataSourceInformation) {
.setDescription(
'Each alert event represents an alert instance that changed its state at a particular point in time. The history of the data is displayed over a period of time.'
)
.setData(getSceneQuery(datasource))
.setData(getQueryRunnerForAlertHistoryDataSource())
.setColor({ mode: 'continuous-BlPu' })
.setCustomFieldConfig('fillOpacity', 100)
.setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars)
@ -213,47 +203,49 @@ export function getEventsScenesFlexItem(datasource: DataSourceInformation) {
.setCustomFieldConfig('scaleDistribution', { type: ScaleDistribution.Linear })
.setOption('legend', { showLegend: false, displayMode: LegendDisplayMode.Hidden })
.setOption('tooltip', { mode: TooltipDisplayMode.Single })
.setNoValue('No events found')
.build(),
});
}
/*
* This component shows a button to clear the filters.
* It is shown when the filters are active.
* props:
* labelsFilterVariable: the textbox variable for filtering by labels
* transitionsToFilterVariable: the custom variable for filtering by the current state
* transitionsFromFilterVariable: the custom variable for filtering by the previous state
*/
function ClearFilterButton({
labelsFilterVariable,
transitionsToFilterVariable,
transitionsFromFilterVariable,
}: {
labelsFilterVariable: TextBoxVariable;
transitionsToFilterVariable: CustomVariable;
transitionsFromFilterVariable: CustomVariable;
}) {
// get the current values of the filters
const valueInLabelsFilter = labelsFilterVariable.getValue();
//todo: use parsePromQLStyleMatcherLooseSafe to validate the label filter and check the lenghtof the result
const valueInTransitionsFilter = transitionsToFilterVariable.getValue();
const valueInTransitionsFromFilter = transitionsFromFilterVariable.getValue();
export class ClearFilterButtonScenesObject extends SceneObjectBase {
public static Component = ClearFilterButtonObjectRenderer;
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO],
});
}
export function ClearFilterButtonObjectRenderer({ model }: SceneComponentProps<ClearFilterButtonScenesObject>) {
// This make sure the component is re-rendered when the variables change
model.useState();
const labelsFilter = sceneGraph.interpolate(model, '${LABELS_FILTER}');
const stateTo = sceneGraph.interpolate(model, '${STATE_FILTER_TO}');
const stateFrom = sceneGraph.interpolate(model, '${STATE_FILTER_FROM}');
// if no filter is active, return null
if (
!valueInLabelsFilter &&
valueInTransitionsFilter === StateFilterValues.all &&
valueInTransitionsFromFilter === StateFilterValues.all
) {
if (!labelsFilter && stateTo === StateFilterValues.all && stateFrom === StateFilterValues.all) {
return null;
}
const onClearFilter = () => {
labelsFilterVariable.setValue('');
transitionsToFilterVariable.changeValueTo(StateFilterValues.all);
transitionsFromFilterVariable.changeValueTo(StateFilterValues.all);
const labelsFiltersVariable = sceneGraph.lookupVariable(LABELS_FILTER, model);
if (labelsFiltersVariable instanceof TextBoxVariable) {
labelsFiltersVariable.setValue('');
}
const stateToFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_TO, model);
if (stateToFilterVariable instanceof CustomVariable) {
stateToFilterVariable.changeValueTo(StateFilterValues.all);
}
const stateFromFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_FROM, model);
if (stateFromFilterVariable instanceof CustomVariable) {
stateFromFilterVariable.changeValueTo(StateFilterValues.all);
}
};
return (
<Tooltip content="Clear filter">
<Button variant={'secondary'} icon="times" onClick={onClearFilter}>

@ -1,6 +1,7 @@
import { useEffect, useMemo } from 'react';
import { DataQuery, DataQueryRequest, DataQueryResponse, TestDataSourceResponse } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { RuntimeDataSource, sceneUtils } from '@grafana/scenes';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { dispatch } from 'app/store/store';
@ -9,7 +10,7 @@ import { stateHistoryApi } from '../../../api/stateHistoryApi';
import { DataSourceInformation } from '../../../home/Insights';
import { LIMIT_EVENTS } from './EventListSceneObject';
import { getStateFilterFromInQueryParams, getStateFilterToInQueryParams, historyResultToDataFrame } from './utils';
import { historyResultToDataFrame } from './utils';
const historyDataSourceUid = '__history_api_ds_uid__';
const historyDataSourcePluginId = '__history_api_ds_pluginId__';
@ -31,6 +32,12 @@ export function useRegisterHistoryRuntimeDataSource() {
}, [ds]);
}
interface HistoryAPIQuery extends DataQuery {
labels?: string;
stateFrom?: string;
stateTo?: string;
}
/**
* This class is a runtime datasource that fetches the events from the history api.
* The events are grouped by alert instance and then converted to a DataFrame list.
@ -38,23 +45,28 @@ export function useRegisterHistoryRuntimeDataSource() {
* This allows us to filter the events by labels.
* The result is a timeseries panel that shows the events for the selected time range and filtered by labels.
*/
class HistoryAPIDatasource extends RuntimeDataSource {
class HistoryAPIDatasource extends RuntimeDataSource<HistoryAPIQuery> {
constructor(pluginId: string, uid: string) {
super(uid, pluginId);
}
async query(request: DataQueryRequest<DataQuery>): Promise<DataQueryResponse> {
async query(request: DataQueryRequest<HistoryAPIQuery>): Promise<DataQueryResponse> {
const from = request.range.from.unix();
const to = request.range.to.unix();
// get the query from the request
const query = request.targets[0]!;
const templateSrv = getTemplateSrv();
// Get the labels and states filters from the URL
const stateTo = getStateFilterToInQueryParams();
const stateFrom = getStateFilterFromInQueryParams();
// we get the labels, stateTo and stateFrom from the query variables
const labels = templateSrv.replace(query.labels ?? '', request.scopedVars);
const stateTo = templateSrv.replace(query.stateTo ?? '', request.scopedVars);
const stateFrom = templateSrv.replace(query.stateFrom ?? '', request.scopedVars);
const historyResult = await getHistory(from, to);
return {
data: historyResultToDataFrame(historyResult, { stateTo, stateFrom }),
data: historyResultToDataFrame(historyResult, { stateTo, stateFrom, labels }),
};
}

@ -9,6 +9,7 @@ import {
SceneComponentProps,
SceneObjectBase,
TextBoxVariable,
VariableDependencyConfig,
VariableValue,
sceneGraph,
} from '@grafana/scenes';
@ -495,48 +496,54 @@ export const getStyles = (theme: GrafanaTheme2) => {
export class HistoryEventsListObject extends SceneObjectBase {
public static Component = HistoryEventsListObjectRenderer;
public constructor() {
super({});
}
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO],
});
}
export type FilterType = 'label' | 'stateFrom' | 'stateTo';
export function HistoryEventsListObjectRenderer({ model }: SceneComponentProps<HistoryEventsListObject>) {
// This make sure the component is re-rendered when the variables change
model.useState();
const { value: timeRange } = sceneGraph.getTimeRange(model).useState(); // get time range from scene graph
// eslint-disable-next-line
const labelsFiltersVariable = sceneGraph.lookupVariable(LABELS_FILTER, model)! as TextBoxVariable;
// eslint-disable-next-line
const stateToFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_TO, model)! as CustomVariable;
// eslint-disable-next-line
const stateFromFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_FROM, model)! as CustomVariable;
const valueInfilterTextBox: VariableValue = labelsFiltersVariable.getValue();
const valueInStateToFilter = stateToFilterVariable.getValue();
const valueInStateFromFilter = stateFromFilterVariable.getValue();
const labelsFiltersVariable = sceneGraph.lookupVariable(LABELS_FILTER, model);
const stateToFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_TO, model);
const stateFromFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_FROM, model);
const addFilter = (key: string, value: string, type: FilterType) => {
const newFilterToAdd = `${key}=${value}`;
trackUseCentralHistoryFilterByClicking({ type, key, value });
if (type === 'stateTo') {
if (type === 'stateTo' && stateToFilterVariable instanceof CustomVariable) {
stateToFilterVariable.changeValueTo(value);
}
if (type === 'stateFrom') {
if (type === 'stateFrom' && stateFromFilterVariable instanceof CustomVariable) {
stateFromFilterVariable.changeValueTo(value);
}
const finalFilter = combineMatcherStrings(valueInfilterTextBox.toString(), newFilterToAdd);
if (type === 'label') {
if (type === 'label' && labelsFiltersVariable instanceof TextBoxVariable) {
const finalFilter = combineMatcherStrings(labelsFiltersVariable.state.value.toString(), newFilterToAdd);
labelsFiltersVariable.setValue(finalFilter);
}
};
return (
<HistoryEventsList
timeRange={timeRange}
valueInLabelFilter={valueInfilterTextBox}
addFilter={addFilter}
valueInStateToFilter={valueInStateToFilter}
valueInStateFromFilter={valueInStateFromFilter}
/>
);
if (
stateToFilterVariable instanceof CustomVariable &&
stateFromFilterVariable instanceof CustomVariable &&
labelsFiltersVariable instanceof TextBoxVariable
) {
return (
<HistoryEventsList
timeRange={timeRange}
valueInLabelFilter={labelsFiltersVariable.state.value}
addFilter={addFilter}
valueInStateToFilter={stateToFilterVariable.state.value}
valueInStateFromFilter={stateFromFilterVariable.state.value}
/>
);
} else {
return null;
}
}

@ -39,7 +39,7 @@ exports[`historyResultToDataFrame should decode 1`] = `
]
`;
exports[`historyResultToDataFrame should decode and filter 1`] = `
exports[`historyResultToDataFrame should decode and filter example1 1`] = `
[
{
"fields": [
@ -69,3 +69,34 @@ exports[`historyResultToDataFrame should decode and filter 1`] = `
},
]
`;
exports[`historyResultToDataFrame should decode and filter example2 1`] = `
[
{
"fields": [
{
"config": {
"custom": {
"fillOpacity": 100,
},
"displayName": "Time",
},
"name": "time",
"type": "time",
"values": [
1727189670000,
],
},
{
"config": {},
"name": "value",
"type": "number",
"values": [
8,
],
},
],
"length": 1,
},
]
`;

@ -6,7 +6,18 @@ describe('historyResultToDataFrame', () => {
expect(historyResultToDataFrame(fixtureData)).toMatchSnapshot();
});
it('should decode and filter', () => {
expect(historyResultToDataFrame(fixtureData, { stateFrom: 'Pending', stateTo: 'Alerting' })).toMatchSnapshot();
it('should decode and filter example1', () => {
expect(
historyResultToDataFrame(fixtureData, {
stateFrom: 'Pending',
stateTo: 'Alerting',
labels: "alertname: 'XSS attack vector'",
})
).toMatchSnapshot();
});
it('should decode and filter example2', () => {
expect(
historyResultToDataFrame(fixtureData, { stateFrom: 'Normal', stateTo: 'NoData', labels: 'region: EMEA' })
).toMatchSnapshot();
});
});

@ -24,9 +24,16 @@ import { LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO, StateFilterValues }
const GROUPING_INTERVAL = 10 * 1000; // 10 seconds
const QUERY_PARAM_PREFIX = 'var-'; // Prefix used by Grafana to sync variables in the URL
const emptyFilters = {
interface HistoryFilters {
stateTo: string;
stateFrom: string;
labels: string;
}
const emptyFilters: HistoryFilters = {
stateTo: 'all',
stateFrom: 'all',
labels: '',
};
/*
@ -75,7 +82,7 @@ export function historyResultToDataFrame({ data }: DataFrameJSON, filters = empt
});
// Group DataFrames by time and filter by labels
return groupDataFramesByTimeAndFilterByLabels(dataFrames);
return groupDataFramesByTimeAndFilterByLabels(dataFrames, filters);
}
// Scenes sync variables in the URL adding a prefix to the variable name.
@ -98,9 +105,9 @@ export function getStateFilterFromInQueryParams() {
* This function groups the data frames by time and filters them by labels.
* The interval is set to 10 seconds.
* */
function groupDataFramesByTimeAndFilterByLabels(dataFrames: DataFrame[]): DataFrame[] {
export function groupDataFramesByTimeAndFilterByLabels(dataFrames: DataFrame[], filters: HistoryFilters): DataFrame[] {
// Filter data frames by labels. This is used to filter out the data frames that do not match the query.
const labelsFilterValue = getLabelsFilterInQueryParams();
const labelsFilterValue = filters.labels;
const dataframesFiltered = dataFrames.filter((frame) => {
const labels = JSON.parse(frame.name ?? ''); // in name we store the labels stringified

@ -5,6 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { SceneObjectState, SceneObjectBase, SceneObject, SceneObjectRef } from '@grafana/scenes';
import { ToolbarButton, useStyles2 } from '@grafana/ui';
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types';
import { getDashboardSceneFor } from '../utils/utils';
import { ElementEditPane } from './ElementEditPane';
@ -13,7 +14,17 @@ export interface DashboardEditPaneState extends SceneObjectState {
selectedObject?: SceneObjectRef<SceneObject>;
}
export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {}
export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
public selectObject(obj: SceneObject) {
const currentSelection = this.state.selectedObject?.resolve();
if (currentSelection === obj) {
const dashboard = getDashboardSceneFor(this);
this.setState({ selectedObject: dashboard.getRef() });
} else {
this.setState({ selectedObject: obj.getRef() });
}
}
}
export interface Props {
editPane: DashboardEditPane;
@ -57,13 +68,29 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla
);
}
const element = getEditableElementFor(selectedObject.resolve());
return (
<div className={styles.wrapper} ref={paneRef}>
<ElementEditPane obj={selectedObject.resolve()} />
<ElementEditPane element={element} key={element.getTypeName()} />
</div>
);
}
function getEditableElementFor(obj: SceneObject): EditableDashboardElement {
if (isEditableDashboardElement(obj)) {
return obj;
}
for (const behavior of obj.state.$behaviors ?? []) {
if (isEditableDashboardElement(behavior)) {
return behavior;
}
}
throw new Error("Can't find editable element for selected object");
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({

@ -1,19 +1,23 @@
import { useMemo } from 'react';
import { SceneObjectBase } from '@grafana/scenes';
import { Input, TextArea } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { DashboardScene } from '../scene/DashboardScene';
import { useLayoutCategory } from '../scene/layouts-shared/DashboardLayoutSelector';
import { EditableDashboardElement } from '../scene/types';
import { getDashboardSceneFor } from '../utils/utils';
export class DummySelectedObject implements EditableDashboardElement {
export class DashboardEditPaneBehavior extends SceneObjectBase implements EditableDashboardElement {
public isEditableDashboardElement: true = true;
constructor(private dashboard: DashboardScene) {}
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
const dashboard = this.dashboard;
const dashboard = getDashboardSceneFor(this);
// When layout changes we need to update options list
const { body } = dashboard.useState();
const dashboardOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
@ -39,7 +43,9 @@ export class DummySelectedObject implements EditableDashboardElement {
);
}, [dashboard]);
return [dashboardOptions];
const layoutCategory = useLayoutCategory(body);
return [dashboardOptions, layoutCategory];
}
public getTypeName(): string {

@ -1,21 +1,16 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneObject } from '@grafana/scenes';
import { Stack, useStyles2 } from '@grafana/ui';
import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory';
import { DashboardScene } from '../scene/DashboardScene';
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types';
import { DummySelectedObject } from './DummySelectedObject';
import { EditableDashboardElement } from '../scene/types';
export interface Props {
obj: SceneObject;
element: EditableDashboardElement;
}
export function ElementEditPane({ obj }: Props) {
const element = getEditableElementFor(obj);
export function ElementEditPane({ element }: Props) {
const categories = element.useEditPaneOptions();
const styles = useStyles2(getStyles);
@ -36,25 +31,6 @@ export function ElementEditPane({ obj }: Props) {
);
}
function getEditableElementFor(obj: SceneObject): EditableDashboardElement {
if (isEditableDashboardElement(obj)) {
return obj;
}
for (const behavior of obj.state.$behaviors ?? []) {
if (isEditableDashboardElement(behavior)) {
return behavior;
}
}
// Temp thing to show somethin in edit pane
if (obj instanceof DashboardScene) {
return new DummySelectedObject(obj);
}
throw new Error("Can't find editable element for selected object");
}
function getStyles(theme: GrafanaTheme2) {
return {
noBorderTop: css({

@ -0,0 +1,130 @@
import { useMemo } from 'react';
import { sceneGraph, SceneObjectBase, VizPanel } from '@grafana/scenes';
import { Button } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { getVisualizationOptions2 } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions';
import {
PanelBackgroundSwitch,
PanelDescriptionTextArea,
PanelFrameTitleInput,
} from '../panel-edit/getPanelFrameOptions';
import { EditableDashboardElement, isDashboardLayoutItem } from '../scene/types';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
export class VizPanelEditPaneBehavior extends SceneObjectBase implements EditableDashboardElement {
public isEditableDashboardElement: true = true;
private getPanel(): VizPanel {
const panel = this.parent;
if (!(panel instanceof VizPanel)) {
throw new Error('VizPanelEditPaneBehavior must have a VizPanel parent');
}
return panel;
}
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
const panel = this.getPanel();
const layoutElement = panel.parent!;
const panelOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
title: 'Panel options',
id: 'panel-options',
isOpenDefault: true,
})
.addItem(
new OptionsPaneItemDescriptor({
title: 'Title',
value: panel.state.title,
popularRank: 1,
render: function renderTitle() {
return <PanelFrameTitleInput panel={panel} />;
},
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Description',
value: panel.state.description,
render: function renderDescription() {
return <PanelDescriptionTextArea panel={panel} />;
},
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Transparent background',
render: function renderTransparent() {
return <PanelBackgroundSwitch panel={panel} />;
},
})
);
}, [panel]);
const layoutCategory = useMemo(() => {
if (isDashboardLayoutItem(layoutElement) && layoutElement.getOptions) {
return layoutElement.getOptions();
}
return undefined;
}, [layoutElement]);
const { options, fieldConfig, _pluginInstanceState } = panel.useState();
const dataProvider = sceneGraph.getData(panel);
const { data } = dataProvider.useState();
const visualizationOptions = useMemo(() => {
const plugin = panel.getPlugin();
if (!plugin) {
return [];
}
return getVisualizationOptions2({
panel,
data,
plugin: plugin,
eventBus: panel.getPanelContext().eventBus,
instanceState: _pluginInstanceState,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, panel, options, fieldConfig, _pluginInstanceState]);
const categories = [panelOptions];
if (layoutCategory) {
categories.push(layoutCategory);
}
categories.push(...visualizationOptions);
return categories;
}
public getTypeName(): string {
return 'Panel';
}
public onDelete = () => {
const layout = dashboardSceneGraph.getLayoutManagerFor(this);
layout.removePanel(this.getPanel());
};
public renderActions(): React.ReactNode {
return (
<>
<Button size="sm" variant="secondary">
Edit
</Button>
<Button size="sm" variant="secondary">
Copy
</Button>
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete}>
Delete
</Button>
</>
);
}
}

@ -34,7 +34,7 @@ export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescri
value: panel.state.title,
popularRank: 1,
render: function renderTitle() {
return <PanelFrameTitle panel={panel} />;
return <PanelFrameTitleInput panel={panel} />;
},
addon: config.featureToggles.dashgpt && (
<GenAIPanelTitleButton
@ -50,7 +50,7 @@ export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescri
title: 'Description',
value: panel.state.description,
render: function renderDescription() {
return <DescriptionTextArea panel={panel} />;
return <PanelDescriptionTextArea panel={panel} />;
},
addon: config.featureToggles.dashgpt && (
<GenAIPanelDescriptionButton
@ -64,17 +64,7 @@ export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescri
new OptionsPaneItemDescriptor({
title: 'Transparent background',
render: function renderTransparent() {
return (
<Switch
value={panel.state.displayMode === 'transparent'}
id="transparent-background"
onChange={() => {
panel.setState({
displayMode: panel.state.displayMode === 'transparent' ? 'default' : 'transparent',
});
}}
/>
);
return <PanelBackgroundSwitch panel={panel} />;
},
})
)
@ -116,7 +106,7 @@ function ScenePanelLinksEditor({ panelLinks }: ScenePanelLinksEditorProps) {
);
}
function PanelFrameTitle({ panel }: { panel: VizPanel }) {
export function PanelFrameTitleInput({ panel }: { panel: VizPanel }) {
const { title } = panel.useState();
return (
@ -128,7 +118,7 @@ function PanelFrameTitle({ panel }: { panel: VizPanel }) {
);
}
function DescriptionTextArea({ panel }: { panel: VizPanel }) {
export function PanelDescriptionTextArea({ panel }: { panel: VizPanel }) {
const { description } = panel.useState();
return (
@ -140,6 +130,22 @@ function DescriptionTextArea({ panel }: { panel: VizPanel }) {
);
}
export function PanelBackgroundSwitch({ panel }: { panel: VizPanel }) {
const { displayMode } = panel.useState();
return (
<Switch
value={displayMode === 'transparent'}
id="transparent-background"
onChange={() => {
panel.setState({
displayMode: panel.state.displayMode === 'transparent' ? 'default' : 'transparent',
});
}}
/>
);
}
function setPanelTitle(panel: VizPanel, title: string) {
panel.setState({ title: title, hoverHeader: getUpdatedHoverHeader(title, panel.state.$timeRange) });
}

@ -60,6 +60,7 @@ export function ToolbarActions({ dashboard }: Props) {
const canSaveAs = contextSrv.hasEditPermissionInFolders;
const toolbarActions: ToolbarAction[] = [];
const leftActions: ToolbarAction[] = [];
const styles = useStyles2(getStyles);
const isEditingPanel = Boolean(editPanel);
const isViewingPanel = Boolean(viewPanelScene);
@ -69,7 +70,8 @@ export function ToolbarActions({ dashboard }: Props) {
// Means we are not in settings view, fullscreen panel or edit panel
const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel;
const isEditingAndShowingDashboard = isEditing && isShowingDashboard;
const showScopesSelector = config.featureToggles.singleTopNav && config.featureToggles.scopeFilters;
const showScopesSelector = config.featureToggles.singleTopNav && config.featureToggles.scopeFilters && !isEditing;
const dashboardNewLayouts = config.featureToggles.dashboardNewLayouts;
if (!isEditingPanel) {
// This adds the precence indicators in enterprise
@ -151,74 +153,135 @@ export function ToolbarActions({ dashboard }: Props) {
addDynamicActions(toolbarActions, dynamicDashNavActions.right, 'icon-actions');
}
toolbarActions.push({
group: 'add-panel',
condition: isEditingAndShowingDashboard,
render: () => (
<Dropdown
key="add-panel-dropdown"
onVisibleChange={(isOpen) => {
setIsAddPanelMenuOpen(isOpen);
DashboardInteractions.toolbarAddClick();
}}
overlay={() => (
<Menu>
<Menu.Item
key="add-visualization"
testId={selectors.pages.AddDashboard.itemButton('Add new visualization menu item')}
label={t('dashboard.add-menu.visualization', 'Visualization')}
onClick={() => {
const vizPanel = dashboard.onCreateNewPanel();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' });
dashboard.setState({ editPanel: buildPanelEditScene(vizPanel, true) });
}}
/>
<Menu.Item
key="add-panel-lib"
testId={selectors.pages.AddDashboard.itemButton('Add new panel from panel library menu item')}
label={t('dashboard.add-menu.import', 'Import from library')}
onClick={() => {
dashboard.onShowAddLibraryPanelDrawer();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' });
}}
/>
<Menu.Item
key="add-row"
testId={selectors.pages.AddDashboard.itemButton('Add new row menu item')}
label={t('dashboard.add-menu.row', 'Row')}
onClick={() => {
dashboard.onCreateNewRow();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_row' });
}}
/>
<Menu.Item
key="paste-panel"
disabled={!hasCopiedPanel}
testId={selectors.pages.AddDashboard.itemButton('Add new panel from clipboard menu item')}
label={t('dashboard.add-menu.paste-panel', 'Paste panel')}
onClick={() => {
dashboard.pastePanel();
DashboardInteractions.toolbarAddButtonClicked({ item: 'paste_panel' });
}}
/>
</Menu>
)}
placement="bottom"
offset={[0, 6]}
>
if (dashboardNewLayouts) {
leftActions.push({
group: 'add-panel',
condition: isEditingAndShowingDashboard,
render: () => (
<Button
key="add-panel-button"
variant="primary"
variant="secondary"
size="sm"
fill="outline"
data-testid={selectors.components.PageToolbar.itemButton('Add button')}
icon="plus"
fill="text"
onClick={() => {
dashboard.onCreateNewPanel();
}}
data-testid={selectors.components.PageToolbar.itemButton('add_visualization')}
>
<Trans i18nKey="dashboard.toolbar.add">Add</Trans>
<Icon name={isAddPanelMenuOpen ? 'angle-up' : 'angle-down'} size="lg" />
Panel
</Button>
</Dropdown>
),
});
),
});
leftActions.push({
group: 'add-panel',
condition: isEditingAndShowingDashboard,
render: () => (
<Button
key="add-panel-button"
variant="secondary"
size="sm"
icon="plus"
fill="text"
onClick={() => {
dashboard.onCreateNewRow();
}}
data-testid={selectors.components.PageToolbar.itemButton('add_row')}
>
Row
</Button>
),
});
leftActions.push({
group: 'add-panel',
condition: isEditingAndShowingDashboard,
render: () => (
<Button
key="add-panel-lib"
variant="secondary"
size="sm"
icon="plus"
fill="text"
data-testid={selectors.pages.AddDashboard.itemButton('Add new panel from panel library menu item')}
onClick={() => {
dashboard.onShowAddLibraryPanelDrawer();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' });
}}
>
Import
</Button>
),
});
} else {
toolbarActions.push({
group: 'add-panel',
condition: isEditingAndShowingDashboard,
render: () => (
<Dropdown
key="add-panel-dropdown"
onVisibleChange={(isOpen) => {
setIsAddPanelMenuOpen(isOpen);
DashboardInteractions.toolbarAddClick();
}}
overlay={() => (
<Menu>
<Menu.Item
key="add-visualization"
testId={selectors.pages.AddDashboard.itemButton('Add new visualization menu item')}
label={t('dashboard.add-menu.visualization', 'Visualization')}
onClick={() => {
const vizPanel = dashboard.onCreateNewPanel();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' });
dashboard.setState({ editPanel: buildPanelEditScene(vizPanel, true) });
}}
/>
<Menu.Item
key="add-panel-lib"
testId={selectors.pages.AddDashboard.itemButton('Add new panel from panel library menu item')}
label={t('dashboard.add-menu.import', 'Import from library')}
onClick={() => {
dashboard.onShowAddLibraryPanelDrawer();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' });
}}
/>
<Menu.Item
key="add-row"
testId={selectors.pages.AddDashboard.itemButton('Add new row menu item')}
label={t('dashboard.add-menu.row', 'Row')}
onClick={() => {
dashboard.onCreateNewRow();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_row' });
}}
/>
<Menu.Item
key="paste-panel"
disabled={!hasCopiedPanel}
testId={selectors.pages.AddDashboard.itemButton('Add new panel from clipboard menu item')}
label={t('dashboard.add-menu.paste-panel', 'Paste panel')}
onClick={() => {
dashboard.pastePanel();
DashboardInteractions.toolbarAddButtonClicked({ item: 'paste_panel' });
}}
/>
</Menu>
)}
placement="bottom"
offset={[0, 6]}
>
<Button
key="add-panel-button"
variant="primary"
size="sm"
fill="outline"
data-testid={selectors.components.PageToolbar.itemButton('Add button')}
>
<Trans i18nKey="dashboard.toolbar.add">Add</Trans>
<Icon name={isAddPanelMenuOpen ? 'angle-up' : 'angle-down'} size="lg" />
</Button>
</Dropdown>
),
});
}
toolbarActions.push({
group: 'playlist-actions',
@ -595,6 +658,20 @@ export function ToolbarActions({ dashboard }: Props) {
},
});
const rigthActionsElements: React.ReactNode[] = renderActionElements(toolbarActions);
const leftActionsElements: React.ReactNode[] = renderActionElements(leftActions);
const hasActionsToLeftAndRight = showScopesSelector || leftActionsElements.length > 0;
return (
<Stack flex={1} minWidth={0} justifyContent={hasActionsToLeftAndRight ? 'space-between' : 'flex-end'}>
{showScopesSelector && <ScopesSelector />}
{leftActionsElements.length > 0 && <ToolbarButtonRow alignment="left">{leftActionsElements}</ToolbarButtonRow>}
<ToolbarButtonRow alignment="right">{rigthActionsElements}</ToolbarButtonRow>
</Stack>
);
}
function renderActionElements(toolbarActions: ToolbarAction[]) {
const actionElements: React.ReactNode[] = [];
let lastGroup = '';
@ -610,13 +687,7 @@ export function ToolbarActions({ dashboard }: Props) {
actionElements.push(action.render());
lastGroup = action.group;
}
return (
<Stack flex={1} minWidth={0} justifyContent={showScopesSelector ? 'space-between' : 'flex-end'}>
{showScopesSelector && <ScopesSelector />}
<ToolbarButtonRow alignment="right">{actionElements}</ToolbarButtonRow>
</Stack>
);
return actionElements;
}
function addDynamicActions(

@ -9,23 +9,18 @@ import {
sceneUtils,
SceneComponentProps,
} from '@grafana/scenes';
import { Button } from '@grafana/ui';
import { GRID_COLUMN_COUNT } from 'app/core/constants';
import { Trans } from 'app/core/internationalization';
import { DashboardInteractions } from '../../utils/interactions';
import {
forceRenderChildren,
getPanelIdForVizPanel,
NEW_PANEL_HEIGHT,
NEW_PANEL_WIDTH,
getVizPanelKeyForPanelId,
getDefaultVizPanel,
} from '../../utils/utils';
import { RowRepeaterBehavior } from '../RowRepeaterBehavior';
import { LayoutEditChrome } from '../layouts-shared/LayoutEditChrome';
import { RowActions } from '../row-actions/RowActions';
import { DashboardLayoutManager, LayoutEditorProps, LayoutRegistryItem } from '../types';
import { DashboardLayoutManager, LayoutRegistryItem } from '../types';
import { DashboardGridItem } from './DashboardGridItem';
@ -40,6 +35,8 @@ export class DefaultGridLayoutManager
extends SceneObjectBase<DefaultGridLayoutManagerState>
implements DashboardLayoutManager
{
public isDashboardLayoutManager: true = true;
public editModeChanged(isEditing: boolean): void {
const updateResizeAndDragging = () => {
this.state.grid.setState({ isDraggable: isEditing, isResizable: isEditing });
@ -387,48 +384,7 @@ export class DefaultGridLayoutManager
});
}
public renderEditor() {
return <DefaultGridLayoutEditor layoutManager={this} />;
}
public static Component = ({ model }: SceneComponentProps<DefaultGridLayoutManager>) => {
if (!config.featureToggles.dashboardNewLayouts) {
return <model.state.grid.Component model={model.state.grid} />;
}
return (
<LayoutEditChrome layoutManager={model}>
<model.state.grid.Component model={model.state.grid} />
</LayoutEditChrome>
);
return <model.state.grid.Component model={model.state.grid} />;
};
}
function DefaultGridLayoutEditor({ layoutManager }: LayoutEditorProps<DefaultGridLayoutManager>) {
return (
<>
<Button
fill="outline"
icon="plus"
onClick={() => {
const vizPanel = getDefaultVizPanel();
layoutManager.addPanel(vizPanel);
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' });
}}
>
<Trans i18nKey="dashboard.add-menu.visualization">Visualization</Trans>
</Button>
<Button
fill="outline"
icon="plus"
onClick={() => {
layoutManager.addNewRow!();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_row' });
}}
>
<Trans i18nKey="dashboard.add-menu.row">Row</Trans>
</Button>
</>
);
}

@ -1,5 +1,8 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneObjectState, VizPanel, SceneObjectBase, SceneObject, SceneComponentProps } from '@grafana/scenes';
import { Switch } from '@grafana/ui';
import { Switch, useStyles2 } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@ -65,7 +68,22 @@ export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState>
public static Component = ({ model }: SceneComponentProps<ResponsiveGridItem>) => {
const { body } = model.useState();
const style = useStyles2(getStyles);
return (
<div className={cx(style.wrapper)}>
<body.Component model={body} />
</div>
);
};
}
return <body.Component model={body} />;
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
width: '100%',
height: '100%',
position: 'relative',
}),
};
}

@ -1,12 +1,10 @@
import { SelectableValue } from '@grafana/data';
import { SceneComponentProps, SceneCSSGridLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { Button, Field, Select } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { Select } from '@grafana/ui';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { DashboardInteractions } from '../../utils/interactions';
import { getDefaultVizPanel, getPanelIdForVizPanel, getVizPanelKeyForPanelId } from '../../utils/utils';
import { LayoutEditChrome } from '../layouts-shared/LayoutEditChrome';
import { DashboardLayoutManager, LayoutRegistryItem, LayoutEditorProps } from '../types';
import { getPanelIdForVizPanel, getVizPanelKeyForPanelId } from '../../utils/utils';
import { DashboardLayoutManager, LayoutRegistryItem } from '../types';
import { ResponsiveGridItem } from './ResponsiveGridItem';
@ -18,6 +16,8 @@ export class ResponsiveGridLayoutManager
extends SceneObjectBase<ResponsiveGridLayoutManagerState>
implements DashboardLayoutManager
{
public isDashboardLayoutManager: true = true;
public editModeChanged(isEditing: boolean): void {}
public addPanel(vizPanel: VizPanel): void {
@ -72,8 +72,8 @@ export class ResponsiveGridLayoutManager
return panels;
}
public renderEditor() {
return <AutomaticGridEditor layoutManager={this} />;
public getOptions(): OptionsPaneItemDescriptor[] {
return getOptions(this);
}
public getDescriptor(): LayoutRegistryItem {
@ -90,7 +90,13 @@ export class ResponsiveGridLayoutManager
}
public static createEmpty() {
return new ResponsiveGridLayoutManager({ layout: new SceneCSSGridLayout({ children: [] }) });
return new ResponsiveGridLayoutManager({
layout: new SceneCSSGridLayout({
children: [],
templateColumns: 'repeat(auto-fit, minmax(400px, auto))',
autoRows: 'minmax(300px, auto)',
}),
});
}
public static createFromLayout(layout: DashboardLayoutManager): ResponsiveGridLayoutManager {
@ -110,18 +116,22 @@ export class ResponsiveGridLayoutManager
});
}
toSaveModel?() {
throw new Error('Method not implemented.');
}
activateRepeaters?(): void {
throw new Error('Method not implemented.');
}
public static Component = ({ model }: SceneComponentProps<ResponsiveGridLayoutManager>) => {
return (
<LayoutEditChrome layoutManager={model}>
<model.state.layout.Component model={model.state.layout} />
</LayoutEditChrome>
);
return <model.state.layout.Component model={model.state.layout} />;
};
}
function AutomaticGridEditor({ layoutManager }: LayoutEditorProps<ResponsiveGridLayoutManager>) {
function getOptions(layoutManager: ResponsiveGridLayoutManager): OptionsPaneItemDescriptor[] {
const options: OptionsPaneItemDescriptor[] = [];
const cssLayout = layoutManager.state.layout;
const { templateColumns, autoRows } = cssLayout.useState();
const rowOptions: Array<SelectableValue<string>> = [];
const sizes = [100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 650];
@ -143,39 +153,43 @@ function AutomaticGridEditor({ layoutManager }: LayoutEditorProps<ResponsiveGrid
rowOptions.push({ label: `Fixed: ${size}px`, value: `${size}px` });
}
const onColumnsChange = (value: SelectableValue<string>) => {
cssLayout.setState({ templateColumns: value.value });
};
const onRowsChange = (value: SelectableValue<string>) => {
cssLayout.setState({ autoRows: value.value });
};
options.push(
new OptionsPaneItemDescriptor({
title: 'Columns',
render: () => {
const { templateColumns } = cssLayout.useState();
return (
<Select
options={colOptions}
value={String(templateColumns)}
onChange={(value) => {
cssLayout.setState({ templateColumns: value.value });
}}
allowCustomValue={true}
/>
);
},
})
);
return (
<>
<Field label="Columns">
<Select
options={colOptions}
value={String(templateColumns)}
onChange={onColumnsChange}
allowCustomValue={true}
/>
</Field>
<Field label="Row height">
<Select options={rowOptions} value={String(autoRows)} onChange={onRowsChange} />
</Field>
<Button
fill="outline"
icon="plus"
onClick={() => {
const vizPanel = getDefaultVizPanel();
layoutManager.addPanel(vizPanel);
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' });
}}
>
<Trans i18nKey="dashboard.add-menu.visualization">Visualization</Trans>
</Button>
</>
options.push(
new OptionsPaneItemDescriptor({
title: 'Rows',
render: () => {
const { autoRows } = cssLayout.useState();
return (
<Select
options={rowOptions}
value={String(autoRows)}
onChange={(value) => {
cssLayout.setState({ autoRows: value.value });
}}
allowCustomValue={true}
/>
);
},
})
);
return options;
}

@ -0,0 +1,242 @@
import { css, cx } from '@emotion/css';
import { useMemo, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { SceneObjectState, SceneObjectBase, SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { Button, Icon, Input, RadioButtonGroup, Switch, useStyles2 } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { getDashboardSceneFor, getDefaultVizPanel } from '../../utils/utils';
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
import { DashboardLayoutManager, EditableDashboardElement, LayoutParent } from '../types';
import { RowsLayoutManager } from './RowsLayoutManager';
export interface RowItemState extends SceneObjectState {
layout: DashboardLayoutManager;
title?: string;
isCollapsed?: boolean;
isHeaderHidden?: boolean;
height?: 'expand' | 'min';
}
export class RowItem extends SceneObjectBase<RowItemState> implements LayoutParent, EditableDashboardElement {
public isEditableDashboardElement: true = true;
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
const row = this;
const rowOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
title: 'Row options',
id: 'row-options',
isOpenDefault: true,
})
.addItem(
new OptionsPaneItemDescriptor({
title: 'Title',
render: () => <RowTitleInput row={row} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Height',
render: () => <RowHeightSelect row={row} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Hide row header',
render: () => <RowHeaderSwitch row={row} />,
})
);
}, [row]);
const { layout } = this.useState();
const layoutOptions = useLayoutCategory(layout);
return [rowOptions, layoutOptions];
}
public getTypeName(): string {
return 'Row';
}
public onDelete = () => {
const layout = sceneGraph.getAncestor(this, RowsLayoutManager);
layout.removeRow(this);
};
public renderActions(): React.ReactNode {
return (
<>
<Button size="sm" variant="secondary">
Copy
</Button>
<Button size="sm" variant="primary" onClick={this.onAddPanel} fill="outline">
Add panel
</Button>
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete}>
Delete
</Button>
</>
);
}
public getLayout(): DashboardLayoutManager {
return this.state.layout;
}
public switchLayout(layout: DashboardLayoutManager): void {
this.setState({ layout });
}
public onCollapseToggle = () => {
this.setState({ isCollapsed: !this.state.isCollapsed });
};
public onAddPanel = () => {
const vizPanel = getDefaultVizPanel();
this.state.layout.addPanel(vizPanel);
};
public onEdit = () => {
const dashboard = getDashboardSceneFor(this);
dashboard.state.editPane.selectObject(this);
};
public static Component = ({ model }: SceneComponentProps<RowItem>) => {
const { layout, title, isCollapsed, height = 'expand' } = model.useState();
const { isEditing } = getDashboardSceneFor(model).useState();
const styles = useStyles2(getStyles);
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
const ref = useRef<HTMLDivElement>(null);
const shouldGrow = !isCollapsed && height === 'expand';
return (
<div
className={cx(styles.wrapper, isCollapsed && styles.wrapperCollapsed, shouldGrow && styles.wrapperGrow)}
ref={ref}
>
<div className={styles.rowHeader}>
<button
onClick={model.onCollapseToggle}
className={styles.rowTitleButton}
aria-label={isCollapsed ? 'Expand row' : 'Collapse row'}
data-testid={selectors.components.DashboardRow.title(titleInterpolated)}
>
<Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />
<span className={styles.rowTitle} role="heading">
{titleInterpolated}
</span>
</button>
{isEditing && <Button icon="pen" variant="secondary" size="sm" fill="text" onClick={() => model.onEdit()} />}
</div>
{!isCollapsed && <layout.Component model={layout} />}
</div>
);
};
}
function getStyles(theme: GrafanaTheme2) {
return {
rowHeader: css({
width: '100%',
display: 'flex',
gap: theme.spacing(1),
padding: theme.spacing(0, 0, 0.5, 0),
margin: theme.spacing(0, 0, 1, 0),
alignItems: 'center',
'&:hover, &:focus-within': {
'& > div': {
opacity: 1,
},
},
'& > div': {
marginBottom: 0,
marginRight: theme.spacing(1),
},
}),
rowTitleButton: css({
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
background: 'transparent',
border: 'none',
minWidth: 0,
gap: theme.spacing(1),
}),
rowTitle: css({
fontSize: theme.typography.h5.fontSize,
fontWeight: theme.typography.fontWeightMedium,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
flexGrow: 1,
minWidth: 0,
}),
wrapper: css({
display: 'flex',
flexDirection: 'column',
width: '100%',
}),
wrapperGrow: css({
flexGrow: 1,
}),
wrapperCollapsed: css({
flexGrow: 0,
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
rowActions: css({
display: 'flex',
opacity: 0,
}),
};
}
export function RowTitleInput({ row }: { row: RowItem }) {
const { title } = row.useState();
return <Input value={title} onChange={(e) => row.setState({ title: e.currentTarget.value })} />;
}
export function RowHeaderSwitch({ row }: { row: RowItem }) {
const { isHeaderHidden } = row.useState();
return (
<Switch
value={isHeaderHidden}
onChange={() => {
row.setState({
isHeaderHidden: !row.state.isHeaderHidden,
});
}}
/>
);
}
export function RowHeightSelect({ row }: { row: RowItem }) {
const { height = 'expand' } = row.useState();
const options = [
{ label: 'Expand', value: 'expand' as const },
{ label: 'Min', value: 'min' as const },
];
return (
<RadioButtonGroup
options={options}
value={height}
onChange={(option) =>
row.setState({
height: option,
})
}
/>
);
}

@ -0,0 +1,113 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { DashboardLayoutManager, LayoutRegistryItem } from '../types';
import { RowItem } from './RowItem';
interface RowsLayoutManagerState extends SceneObjectState {
rows: RowItem[];
}
export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> implements DashboardLayoutManager {
public isDashboardLayoutManager: true = true;
public editModeChanged(isEditing: boolean): void {}
public addPanel(vizPanel: VizPanel): void {}
public addNewRow(): void {
this.setState({
rows: [
...this.state.rows,
new RowItem({
title: 'New row',
layout: ResponsiveGridLayoutManager.createEmpty(),
}),
],
});
}
public getNextPanelId(): number {
return 0;
}
public removePanel(panel: VizPanel) {}
public removeRow(row: RowItem) {
this.setState({
rows: this.state.rows.filter((r) => r !== row),
});
}
public duplicatePanel(panel: VizPanel): void {
throw new Error('Method not implemented.');
}
public getVizPanels(): VizPanel[] {
const panels: VizPanel[] = [];
for (const row of this.state.rows) {
const innerPanels = row.state.layout.getVizPanels();
panels.push(...innerPanels);
}
return panels;
}
public getOptions() {
return [];
}
public getDescriptor(): LayoutRegistryItem {
return RowsLayoutManager.getDescriptor();
}
public static getDescriptor(): LayoutRegistryItem {
return {
name: 'Rows',
description: 'Rows layout',
id: 'rows-layout',
createFromLayout: RowsLayoutManager.createFromLayout,
};
}
public static createEmpty() {
return new RowsLayoutManager({ rows: [] });
}
public static createFromLayout(layout: DashboardLayoutManager): RowsLayoutManager {
const row = new RowItem({ layout: layout.clone(), title: 'Row title' });
return new RowsLayoutManager({ rows: [row] });
}
public static Component = ({ model }: SceneComponentProps<RowsLayoutManager>) => {
const { rows } = model.useState();
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
{rows.map((row) => (
<RowItem.Component model={row} key={row.state.key!} />
))}
</div>
);
};
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
height: '100%',
width: '100%',
}),
};
}

@ -0,0 +1,66 @@
import { useMemo } from 'react';
import { Select } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { DashboardLayoutManager, isLayoutParent, LayoutRegistryItem } from '../types';
import { layoutRegistry } from './layoutRegistry';
export interface Props {
layoutManager: DashboardLayoutManager;
}
export function DashboardLayoutSelector({ layoutManager }: { layoutManager: DashboardLayoutManager }) {
const layouts = layoutRegistry.list();
const options = layouts.map((layout) => ({
label: layout.name,
value: layout,
}));
const currentLayoutId = layoutManager.getDescriptor().id;
const currentLayoutOption = options.find((option) => option.value.id === currentLayoutId);
return (
<Select
options={options}
value={currentLayoutOption}
onChange={(option) => changeLayoutTo(layoutManager, option.value!)}
/>
);
}
export function useLayoutCategory(layoutManager: DashboardLayoutManager) {
return useMemo(() => {
const layoutCategory = new OptionsPaneCategoryDescriptor({
title: 'Layout',
id: 'layout-options',
isOpenDefault: true,
});
layoutCategory.addItem(
new OptionsPaneItemDescriptor({
title: 'Type',
render: function renderTitle() {
return <DashboardLayoutSelector layoutManager={layoutManager} />;
},
})
);
if (layoutManager.getOptions) {
for (const option of layoutManager.getOptions()) {
layoutCategory.addItem(option);
}
}
return layoutCategory;
}, [layoutManager]);
}
function changeLayoutTo(currentLayout: DashboardLayoutManager, newLayoutDescriptor: LayoutRegistryItem) {
const layoutParent = currentLayout.parent;
if (layoutParent && isLayoutParent(layoutParent)) {
layoutParent.switchLayout(newLayoutDescriptor.createFromLayout(currentLayout));
}
}

@ -1,105 +0,0 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Field, Select } from '@grafana/ui';
import { getDashboardSceneFor } from '../../utils/utils';
import { DashboardLayoutManager, isLayoutParent, LayoutRegistryItem } from '../types';
import { layoutRegistry } from './layoutRegistry';
interface Props {
layoutManager: DashboardLayoutManager;
children: React.ReactNode;
}
export function LayoutEditChrome({ layoutManager, children }: Props) {
const styles = useStyles2(getStyles);
const { isEditing } = getDashboardSceneFor(layoutManager).useState();
const layouts = layoutRegistry.list();
const options = layouts.map((layout) => ({
label: layout.name,
value: layout,
}));
const currentLayoutId = layoutManager.getDescriptor().id;
const currentLayoutOption = options.find((option) => option.value.id === currentLayoutId);
return (
<div className={styles.wrapper}>
{isEditing && (
<div className={styles.editHeader}>
<Field label="Layout type">
<Select
options={options}
value={currentLayoutOption}
onChange={(option) => changeLayoutTo(layoutManager, option.value!)}
/>
</Field>
{layoutManager.renderEditor?.()}
</div>
)}
{children}
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
editHeader: css({
width: '100%',
display: 'flex',
gap: theme.spacing(1),
padding: theme.spacing(0, 1, 0.5, 1),
margin: theme.spacing(0, 0, 1, 0),
alignItems: 'flex-end',
borderBottom: `1px solid ${theme.colors.border.weak}`,
paddingBottom: theme.spacing(1),
'&:hover, &:focus-within': {
'& > div': {
opacity: 1,
},
},
'& > div': {
marginBottom: 0,
marginRight: theme.spacing(1),
},
}),
wrapper: css({
display: 'flex',
flexDirection: 'column',
flex: '1 1 0',
width: '100%',
}),
icon: css({
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
background: 'transparent',
border: 'none',
gap: theme.spacing(1),
}),
rowTitle: css({}),
rowActions: css({
display: 'flex',
opacity: 0,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'opacity 200ms ease-in',
},
'&:hover, &:focus-within': {
opacity: 1,
},
}),
};
}
function changeLayoutTo(currentLayout: DashboardLayoutManager, newLayoutDescriptor: LayoutRegistryItem) {
const layoutParent = currentLayout.parent;
if (layoutParent && isLayoutParent(layoutParent)) {
layoutParent.switchLayout(newLayoutDescriptor.createFromLayout(currentLayout));
}
}

@ -2,8 +2,13 @@ import { Registry } from '@grafana/data';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { LayoutRegistryItem } from '../types';
export const layoutRegistry: Registry<LayoutRegistryItem> = new Registry<LayoutRegistryItem>(() => {
return [DefaultGridLayoutManager.getDescriptor(), ResponsiveGridLayoutManager.getDescriptor()];
return [
DefaultGridLayoutManager.getDescriptor(),
ResponsiveGridLayoutManager.getDescriptor(),
RowsLayoutManager.getDescriptor(),
];
});

@ -1,21 +1,20 @@
import { BusEventWithPayload, RegistryItem } from '@grafana/data';
import { SceneObject, VizPanel } from '@grafana/scenes';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
/**
* A scene object that usually wraps an underlying layout
* Dealing with all the state management and editing of the layout
*/
export interface DashboardLayoutManager extends SceneObject {
/** Marks it as a DashboardLayoutManager */
isDashboardLayoutManager: true;
/**
* Notify the layout manager that the edit mode has changed
* @param isEditing
*/
editModeChanged(isEditing: boolean): void;
/**
* Not sure we will need this in the long run, we should be able to handle this inside internally
*/
getNextPanelId(): number;
/**
* Remove an element / panel
* @param element
@ -54,7 +53,11 @@ export interface DashboardLayoutManager extends SceneObject {
/**
* Renders options and layout actions
*/
renderEditor?(): React.ReactNode;
getOptions?(): OptionsPaneItemDescriptor[];
}
export function isDashboardLayoutManager(obj: SceneObject): obj is DashboardLayoutManager {
return 'isDashboardLayoutManager' in obj;
}
/**
@ -73,10 +76,6 @@ export interface LayoutRegistryItem extends RegistryItem {
createFromSaveModel?(saveModel: any): void;
}
export interface LayoutEditorProps<T> {
layoutManager: T;
}
/**
* This interface is needed to support layouts existing on different levels of the scene (DashboardScene and inside the TabsLayoutManager)
*/

@ -19,12 +19,14 @@ import {
SceneDataLayerProvider,
SceneDataLayerControls,
UserActionEvent,
SceneObjectState,
} from '@grafana/scenes';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { DashboardDTO, DashboardDataDTO } from 'app/types';
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';
import { DashboardEditPaneBehavior } from '../edit-pane/DashboardEditPaneBehavior';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls';
@ -214,6 +216,32 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
});
}
const behaviorList: SceneObjectState['$behaviors'] = [
new behaviors.CursorSync({
sync: oldModel.graphTooltip,
}),
new behaviors.SceneQueryController(),
registerDashboardMacro,
registerPanelInteractionsReporter,
new DashboardEditPaneBehavior({}),
new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }),
preserveDashboardSceneStateInLocalStorage,
addPanelsOnLoadBehavior,
new DashboardScopesFacade({
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange && oldModel.meta.reloadOnParamsChange,
uid: oldModel.uid,
}),
new DashboardReloadBehavior({
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange && oldModel.meta.reloadOnParamsChange,
uid: oldModel.uid,
version: oldModel.version,
}),
];
if (config.featureToggles.dashboardNewLayouts) {
behaviorList.push(new DashboardEditPaneBehavior({}));
}
const dashboardScene = new DashboardScene({
description: oldModel.description,
editable: oldModel.editable,
@ -242,28 +270,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
UNSAFE_nowDelay: oldModel.timepicker?.nowDelay,
}),
$variables: variables,
$behaviors: [
new behaviors.CursorSync({
sync: oldModel.graphTooltip,
}),
new behaviors.SceneQueryController(),
registerDashboardMacro,
registerPanelInteractionsReporter,
new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }),
preserveDashboardSceneStateInLocalStorage,
addPanelsOnLoadBehavior,
new DashboardScopesFacade({
reloadOnParamsChange:
config.featureToggles.reloadDashboardsOnParamsChange && oldModel.meta.reloadOnParamsChange,
uid: oldModel.uid,
}),
new DashboardReloadBehavior({
reloadOnParamsChange:
config.featureToggles.reloadDashboardsOnParamsChange && oldModel.meta.reloadOnParamsChange,
uid: oldModel.uid,
version: oldModel.version,
}),
],
$behaviors: behaviorList,
$data: new DashboardDataLayerSet({ annotationLayers, alertStatesLayer }),
controls: new DashboardControls({
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],

@ -34,7 +34,7 @@ import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLay
import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2';
function setupDashboardScene(state: DashboardSceneState): DashboardScene {
function setupDashboardScene(state: Partial<DashboardSceneState>): DashboardScene {
return new DashboardScene(state);
}

@ -46,7 +46,12 @@ export default function ShareButton({ dashboard, panel }: { dashboard: Dashboard
<Trans i18nKey="share-dashboard.share-button">Share</Trans>
</Button>
<Dropdown overlay={MenuActions} placement="bottom-end" onVisibleChange={onMenuClick}>
<Button data-testid={newShareButtonSelector.arrowMenu} size="sm" icon={isOpen ? 'angle-up' : 'angle-down'} />
<Button
aria-label="share-dropdown-menu"
data-testid={newShareButtonSelector.arrowMenu}
size="sm"
icon={isOpen ? 'angle-up' : 'angle-down'}
/>
</Dropdown>
</ButtonGroup>
);

@ -4,6 +4,8 @@ import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardScene } from '../scene/DashboardScene';
import { VizPanelLinks } from '../scene/PanelLinks';
import { getLayoutManagerFor } from './utils';
function getTimePicker(scene: DashboardScene) {
return scene.state.controls?.state.timePicker;
}
@ -53,4 +55,5 @@ export const dashboardSceneGraph = {
getVizPanels,
getDataLayers,
getCursorSync,
getLayoutManagerFor,
};

@ -18,6 +18,7 @@ import { DashboardScene } from '../scene/DashboardScene';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types';
export const NEW_PANEL_HEIGHT = 8;
export const NEW_PANEL_WIDTH = 12;
@ -220,6 +221,7 @@ export function getDefaultVizPanel(): VizPanel {
pluginId: 'timeseries',
titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })],
hoverHeaderOffset: 0,
$behaviors: [],
menu: new VizPanelMenu({
$behaviors: [panelMenuBehavior],
}),
@ -279,3 +281,16 @@ export function activateSceneObjectAndParentTree(so: SceneObject): CancelActivat
* Useful when rendering a scene object out of context of it's parent
*/
export const activateInActiveParents = activateSceneObjectAndParentTree;
export function getLayoutManagerFor(sceneObject: SceneObject): DashboardLayoutManager {
let parent = sceneObject.parent;
while (parent) {
if (isDashboardLayoutManager(parent)) {
return parent;
}
parent = parent.parent;
}
throw new Error('Could not find layout manager for scene object');
}

@ -1,5 +1,7 @@
import * as React from 'react';
import { Box } from '@grafana/ui';
import { OptionsPaneCategory } from './OptionsPaneCategory';
import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor';
@ -57,6 +59,10 @@ export class OptionsPaneCategoryDescriptor {
return this.props.customRender();
}
if (this.props.id === '') {
return <Box padding={2}>{this.items.map((item) => item.render(searchQuery))}</Box>;
}
return (
<OptionsPaneCategory key={this.props.title} {...this.props}>
{this.items.map((item) => item.render(searchQuery))}

@ -275,4 +275,26 @@ describe('Popover menu', () => {
expect(onClickFilterOutString).toHaveBeenCalledTimes(1);
expect(onClickFilterString).toHaveBeenCalledTimes(1);
});
describe('Interacting with log details', () => {
it('Allows text selection even if the popover menu is not available', async () => {
setup({
onClickFilterOutString: undefined,
onClickFilterString: undefined,
});
await userEvent.click(screen.getByText('log message 1'));
expect(screen.queryByText('Copy selection')).not.toBeInTheDocument();
expect(screen.queryByText(/details/)).not.toBeInTheDocument();
});
it('Displays Log Details if there is no text selection', async () => {
jest.spyOn(document, 'getSelection').mockReturnValue(null);
setup({
onClickFilterOutString: undefined,
onClickFilterString: undefined,
});
await userEvent.click(screen.getByText('log message 1'));
expect(screen.queryByText('Copy selection')).not.toBeInTheDocument();
expect(screen.getByText(/details/)).toBeInTheDocument();
});
});
});

@ -115,13 +115,15 @@ class UnThemedLogRows extends PureComponent<Props, State> {
}
handleSelection = (e: MouseEvent<HTMLTableRowElement>, row: LogRowModel): boolean => {
if (this.popoverMenuSupported() === false) {
return false;
}
const selection = document.getSelection()?.toString();
if (!selection) {
return false;
}
if (this.popoverMenuSupported() === false) {
// This signals onRowClick inside LogRow to skip the event because the user is selecting text
return selection ? true : false;
}
if (!this.logRowsRef.current) {
return false;
}
@ -247,7 +249,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle}
pinned={this.props.pinnedRowId === row.uid || pinnedLogs?.some((logId) => logId === row.rowId)}
isFilterLabelActive={this.props.isFilterLabelActive}
handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined}
handleTextSelection={this.handleSelection}
{...rest}
/>
))}
@ -270,7 +272,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle}
pinned={this.props.pinnedRowId === row.uid || pinnedLogs?.some((logId) => logId === row.rowId)}
isFilterLabelActive={this.props.isFilterLabelActive}
handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined}
handleTextSelection={this.handleSelection}
{...rest}
/>
))}

@ -1,92 +1,115 @@
import { AzureAuthType, AzureCloud, AzureCredentialsType, ConcealedSecretType } from '../types';
import {
AzureCredentials,
AzureCloud,
ConcealedSecret,
AzureClientSecretCredentials,
instanceOfAzureCredential,
updateDatasourceCredentials,
} from '@grafana/azure-sdk';
import { config } from '@grafana/runtime';
import {
configWithManagedIdentityEnabled,
configWithManagedIdentityDisabled,
dataSourceSettingsWithMsiCredentials,
dataSourceSettingsWithClientSecretOnServer,
dataSourceSettingsWithClientSecretInSecureJSONData,
} from './AzureAuth.testMocks';
import { getDefaultCredentials, getSecret, getCredentials, updateCredentials } from './AzureCredentialsConfig';
import { getDefaultCredentials, getCredentials } from './AzureCredentialsConfig';
// NOTE: @ts-ignores are used to ignore the type errors that are thrown when passing in the mocks.
// This is because the mocks are partials of the actual types, so the types are not complete.
export const CLIENT_SECRET_SYMBOL: ConcealedSecretType = Symbol('Concealed client secret');
export const CLIENT_SECRET_SYMBOL: ConcealedSecret = Symbol('Concealed client secret');
export const CLIENT_SECRET_STRING = 'XXXX-super-secret-secret-XXXX';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), // Keep the rest of the actual module
}));
describe('AzureAuth', () => {
beforeEach(() => {
jest.resetModules();
});
describe('AzureCredentialsConfig', () => {
it('`getDefaultCredentials()` should return the correct credentials based on whether the managed identity is enabled', () => {
const resultForManagedIdentityEnabled = getDefaultCredentials(true, AzureCloud.Public);
const resultForManagedIdentityDisabled = getDefaultCredentials(false, AzureCloud.Public);
jest.mocked(config).azure.managedIdentityEnabled = true;
const resultForManagedIdentityEnabled = getDefaultCredentials();
jest.mocked(config).azure.managedIdentityEnabled = false;
const resultForManagedIdentityDisabled = getDefaultCredentials();
expect(resultForManagedIdentityEnabled).toEqual({ authType: 'msi' });
expect(resultForManagedIdentityDisabled).toEqual({ authType: 'clientsecret', azureCloud: 'AzureCloud' });
});
it("`getSecret()` should correctly return the client secret if it's not concealed", () => {
const resultFromServerSideSecret = getSecret(false, CLIENT_SECRET_STRING);
expect(resultFromServerSideSecret).toBe(CLIENT_SECRET_STRING);
const resultFromSecureJSONDataSecret = typeof getSecret(true, '');
expect(resultFromSecureJSONDataSecret).toBe('symbol');
});
describe('getCredentials()', () => {
it('should return the correct managed identity credentials', () => {
// If `dataSourceSettings.authType === AzureAuthType.MSI` && `config.azure.managedIdentityEnabled === true`.
// If `dataSourceSettings.authType === 'msi'` && `config.azure.managedIdentityEnabled === true`.
jest.mocked(config).azure.managedIdentityEnabled = true;
const resultForManagedIdentityEnabled = getCredentials(
// @ts-ignore
dataSourceSettingsWithMsiCredentials,
configWithManagedIdentityEnabled
dataSourceSettingsWithMsiCredentials
);
expect(resultForManagedIdentityEnabled).toEqual({ authType: AzureAuthType.MSI });
expect(resultForManagedIdentityEnabled).toEqual({ authType: 'msi' });
// If `dataSourceSettings.authType === AzureAuthType.MSI` but `config.azure.managedIdentityEnabled !== true`.
// If `dataSourceSettings.authType === 'msi'` but `config.azure.managedIdentityEnabled !== true`.
// Default to basic client secret credentials.
jest.mocked(config).azure.managedIdentityEnabled = false;
const resultForManagedIdentityEnabledInJSONButDisabledInConfig = getCredentials(
// @ts-ignore
dataSourceSettingsWithMsiCredentials,
configWithManagedIdentityDisabled
dataSourceSettingsWithMsiCredentials
);
expect(resultForManagedIdentityEnabledInJSONButDisabledInConfig).toEqual({
authType: AzureAuthType.CLIENT_SECRET,
authType: 'clientsecret',
azureCloud: 'AzureCloud',
});
});
it('should return the correct client secret credentials', () => {
const basicExpectedResult = {
authType: AzureAuthType.CLIENT_SECRET,
authType: 'clientsecret',
azureCloud: 'AzureCloud',
tenantId: 'XXXX-tenant-id-XXXX',
clientId: 'XXXX-client-id-XXXX',
};
// If `dataSourceSettings.authType === AzureAuthType.CLIENT_SECRET` && `secureJsonFields.azureClientSecret == true`,
// If `dataSourceSettings.authType === 'clientsecret'` && `secureJsonFields.azureClientSecret == true`,
// i.e. the client secret is stored on the server.
jest.mocked(config).azure.managedIdentityEnabled = false;
const resultForClientSecretCredentialsOnServer = getCredentials(
// @ts-ignore
dataSourceSettingsWithClientSecretOnServer,
configWithManagedIdentityDisabled
dataSourceSettingsWithClientSecretOnServer
);
// Here we test the properties separately because the client secret is a symbol,
// and since JS symobls are unique, we test via the `typeof` operator.
expect(resultForClientSecretCredentialsOnServer.authType).toEqual(AzureAuthType.CLIENT_SECRET);
expect(resultForClientSecretCredentialsOnServer.azureCloud).toEqual('AzureCloud');
expect(resultForClientSecretCredentialsOnServer.tenantId).toEqual('XXXX-tenant-id-XXXX');
expect(resultForClientSecretCredentialsOnServer.clientId).toEqual('XXXX-client-id-XXXX');
expect(typeof resultForClientSecretCredentialsOnServer.clientSecret).toEqual('symbol');
expect(resultForClientSecretCredentialsOnServer.authType).toEqual('clientsecret');
expect(
instanceOfAzureCredential<AzureClientSecretCredentials>(
'clientsecret',
resultForClientSecretCredentialsOnServer
)
).toEqual(true);
expect((resultForClientSecretCredentialsOnServer as AzureClientSecretCredentials).azureCloud).toEqual(
'AzureCloud'
);
expect((resultForClientSecretCredentialsOnServer as AzureClientSecretCredentials).tenantId).toEqual(
'XXXX-tenant-id-XXXX'
);
expect((resultForClientSecretCredentialsOnServer as AzureClientSecretCredentials).clientId).toEqual(
'XXXX-client-id-XXXX'
);
expect(typeof (resultForClientSecretCredentialsOnServer as AzureClientSecretCredentials).clientSecret).toEqual(
'symbol'
);
// If `dataSourceSettings.authType === AzureAuthType.CLIENT_SECRET` && `secureJsonFields.azureClientSecret == false`,
// If `dataSourceSettings.authType === 'clientsecret'` && `secureJsonFields.azureClientSecret == false`,
// i.e. the client secret is stored in the secureJson.
jest.mocked(config).azure.managedIdentityEnabled = false;
const resultForClientSecretCredentialsInSecureJSON = getCredentials(
// @ts-ignore
dataSourceSettingsWithClientSecretInSecureJSONData,
configWithManagedIdentityDisabled
dataSourceSettingsWithClientSecretInSecureJSONData
);
expect(resultForClientSecretCredentialsInSecureJSON).toEqual({
...basicExpectedResult,
@ -97,66 +120,68 @@ describe('AzureAuth', () => {
describe('updateCredentials()', () => {
it('should update the credentials for managed service identity correctly', () => {
// If `dataSourceSettings.authType === AzureAuthType.MSI` && `config.azure.managedIdentityEnabled === true`.
const resultForMsiCredentials = updateCredentials(
// If `dataSourceSettings.authType === 'msi'` && `config.azure.managedIdentityEnabled === true`.
jest.mocked(config).azure.managedIdentityEnabled = true;
const resultForMsiCredentials = updateDatasourceCredentials(
// @ts-ignore
dataSourceSettingsWithMsiCredentials,
configWithManagedIdentityEnabled,
{
authType: AzureAuthType.MSI,
authType: 'msi',
}
);
expect(resultForMsiCredentials).toEqual({ jsonData: { azureCredentials: { authType: 'msi' } } });
// If `dataSourceSettings.authType === AzureAuthType.MSI` but `config.azure.managedIdentityEnabled !== true`.
// If `dataSourceSettings.authType === 'msi'` but `config.azure.managedIdentityEnabled !== true`.
jest.mocked(config).azure.managedIdentityEnabled = false;
expect(() =>
updateCredentials(
updateDatasourceCredentials(
// @ts-ignore
dataSourceSettingsWithMsiCredentials,
configWithManagedIdentityDisabled,
{
authType: AzureAuthType.MSI,
authType: 'msi',
}
)
).toThrow('Managed Identity authentication is not enabled in Grafana config.');
});
it('should update the credentials for client secret correctly', () => {
const basicClientSecretCredentials: AzureCredentialsType = {
authType: AzureAuthType.CLIENT_SECRET,
azureCloud: 'AzureCloud',
const basicClientSecretCredentials: AzureCredentials = {
authType: 'clientsecret',
azureCloud: AzureCloud.Public,
tenantId: 'XXXX-tenant-id-XXXX',
clientId: 'XXXX-client-id-XXXX',
};
// If `dataSourceSettings.authType === AzureAuthType.CLIENT_SECRET` && `secureJsonFields.azureClientSecret == true`.
const resultForClientSecretCredentials1 = updateCredentials(
// If `dataSourceSettings.authType === 'clientsecret'` && `secureJsonFields.azureClientSecret == true`.
jest.mocked(config).azure.managedIdentityEnabled = false;
const resultForClientSecretCredentials1 = updateDatasourceCredentials(
// @ts-ignore
dataSourceSettingsWithClientSecretOnServer,
configWithManagedIdentityDisabled,
basicClientSecretCredentials
);
expect(resultForClientSecretCredentials1).toEqual({
jsonData: {
azureCredentials: { ...basicClientSecretCredentials },
},
secureJsonData: { azureClientSecret: undefined },
secureJsonFields: { azureClientSecret: false },
expect(resultForClientSecretCredentials1.jsonData.azureCredentials).toEqual(basicClientSecretCredentials);
expect(resultForClientSecretCredentials1.secureJsonData).toEqual({ azureClientSecret: undefined });
expect(resultForClientSecretCredentials1.secureJsonFields).toEqual({
azureClientSecret: false,
clientSecret: false,
});
// If `dataSourceSettings.authType === AzureAuthType.CLIENT_SECRET` && `secureJsonFields.azureClientSecret == false`.
const resultForClientSecretCredentials2 = updateCredentials(
// If `dataSourceSettings.authType === 'clientsecret'` && `secureJsonFields.azureClientSecret == false`.
jest.mocked(config).azure.managedIdentityEnabled = false;
const resultForClientSecretCredentials2 = updateDatasourceCredentials(
// @ts-ignore
dataSourceSettingsWithClientSecretInSecureJSONData,
configWithManagedIdentityDisabled,
{ ...basicClientSecretCredentials, clientSecret: 'XXXX-super-secret-secret-XXXX' }
);
expect(resultForClientSecretCredentials2).toEqual({
jsonData: {
azureCredentials: { ...basicClientSecretCredentials },
},
secureJsonData: { azureClientSecret: 'XXXX-super-secret-secret-XXXX' },
secureJsonFields: { azureClientSecret: false },
expect(resultForClientSecretCredentials2.jsonData.azureCredentials).toEqual(basicClientSecretCredentials);
expect(resultForClientSecretCredentials2.secureJsonData).toEqual({
azureClientSecret: 'XXXX-super-secret-secret-XXXX',
});
expect(resultForClientSecretCredentials2.secureJsonFields).toEqual({
azureClientSecret: false,
clientSecret: false,
});
});
});

@ -1,8 +1,6 @@
import { DataSourceSettings } from '@grafana/data';
import { AzureDataSourceSettings } from '@grafana/azure-sdk';
import { GrafanaBootConfig } from '@grafana/runtime';
import { AzureAuthSecureJSONDataType, AzureAuthJSONDataType, AzureAuthType } from '../types';
export const configWithManagedIdentityEnabled: Partial<GrafanaBootConfig> = {
azure: {
managedIdentityEnabled: true,
@ -24,31 +22,22 @@ export const configWithManagedIdentityDisabled: Partial<GrafanaBootConfig> = {
},
};
export const dataSourceSettingsWithMsiCredentials: Partial<
DataSourceSettings<AzureAuthJSONDataType, AzureAuthSecureJSONDataType>
> = {
jsonData: { azureCredentials: { authType: AzureAuthType.MSI } },
export const dataSourceSettingsWithMsiCredentials: Partial<AzureDataSourceSettings> = {
jsonData: { azureCredentials: { authType: 'msi' } },
};
const basicJSONData = {
// Will return symbol as the secret is concealed
export const dataSourceSettingsWithClientSecretOnServer: Partial<AzureDataSourceSettings> = {
jsonData: {
azureCredentials: {
authType: AzureAuthType.CLIENT_SECRET,
tenantId: 'XXXX-tenant-id-XXXX',
clientId: 'XXXX-client-id-XXXX',
},
azureCredentials: { authType: 'clientsecret', clientId: 'XXXX-client-id-XXXX', tenantId: 'XXXX-tenant-id-XXXX' },
},
secureJsonFields: { azureClientSecret: true },
};
// Will return symbol as the secret is concealed
export const dataSourceSettingsWithClientSecretOnServer: Partial<
DataSourceSettings<AzureAuthJSONDataType, AzureAuthSecureJSONDataType>
> = { ...basicJSONData, secureJsonFields: { azureClientSecret: true } };
// Will return the secret as a string from the secureJsonData
export const dataSourceSettingsWithClientSecretInSecureJSONData: Partial<
DataSourceSettings<AzureAuthJSONDataType, AzureAuthSecureJSONDataType>
> = {
...basicJSONData,
secureJsonData: { azureClientSecret: 'XXXX-super-secret-secret-XXXX', password: undefined },
export const dataSourceSettingsWithClientSecretInSecureJSONData: Partial<AzureDataSourceSettings> = {
jsonData: {
azureCredentials: { authType: 'clientsecret', clientId: 'XXXX-client-id-XXXX', tenantId: 'XXXX-tenant-id-XXXX' },
},
secureJsonFields: { azureClientSecret: false },
secureJsonData: { azureClientSecret: 'XXXX-super-secret-secret-XXXX' },
};

@ -1,24 +1,25 @@
import { useMemo } from 'react';
import { useEffectOnce } from 'react-use';
import { AzureCredentials, AzureCloud, updateDatasourceCredentials } from '@grafana/azure-sdk';
import { SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { HttpSettingsBaseProps } from '@grafana/ui/src/components/DataSourceSettings/types';
import { AzureCredentialsType } from '../types';
import { KnownAzureClouds } from './AzureCredentials';
import { getCredentials, updateCredentials } from './AzureCredentialsConfig';
import { getCredentials } from './AzureCredentialsConfig';
import { AzureCredentialsForm } from './AzureCredentialsForm';
export const KnownAzureClouds: Array<SelectableValue<AzureCloud>> = [{ value: AzureCloud.Public, label: 'Azure' }];
export const AzureAuthSettings = (props: HttpSettingsBaseProps) => {
const { dataSourceConfig: dsSettings, onChange } = props;
const managedIdentityEnabled = config.azure.managedIdentityEnabled;
const azureEntraPasswordCredentialsEnabled = config.azure.azureEntraPasswordCredentialsEnabled;
const credentials = useMemo(() => getCredentials(dsSettings, config), [dsSettings]);
const credentials = useMemo(() => getCredentials(dsSettings), [dsSettings]);
const onCredentialsChange = (credentials: AzureCredentialsType): void => {
onChange(updateCredentials(dsSettings, config, credentials));
const onCredentialsChange = (credentials: AzureCredentials): void => {
onChange(updateDatasourceCredentials(dsSettings, credentials));
};
// The auth type needs to be set on the first load of the data source

@ -1,21 +0,0 @@
import { SelectableValue } from '@grafana/data';
import { AzureCredentialsType, AzureAuthType } from '../types';
export enum AzureCloud {
Public = 'AzureCloud',
None = '',
}
export const KnownAzureClouds: Array<SelectableValue<AzureCloud>> = [{ value: AzureCloud.Public, label: 'Azure' }];
export function isCredentialsComplete(credentials: AzureCredentialsType): boolean {
switch (credentials.authType) {
case AzureAuthType.MSI:
return true;
case AzureAuthType.CLIENT_SECRET:
return !!(credentials.azureCloud && credentials.tenantId && credentials.clientId && credentials.clientSecret);
case AzureAuthType.AD_PASSWORD:
return !!(credentials.clientId && credentials.password && credentials.userId);
}
}

@ -1,167 +1,26 @@
import { DataSourceSettings } from '@grafana/data';
import { GrafanaBootConfig } from '@grafana/runtime';
import {
AzureCloud,
AzureCredentialsType,
ConcealedSecretType,
AzureAuthSecureJSONDataType,
AzureAuthJSONDataType,
AzureAuthType,
} from '../types';
export const getDefaultCredentials = (managedIdentityEnabled: boolean, cloud: string): AzureCredentialsType => {
if (managedIdentityEnabled) {
return { authType: AzureAuthType.MSI };
AzureCredentials,
AzureDataSourceSettings,
getDatasourceCredentials,
getDefaultAzureCloud,
} from '@grafana/azure-sdk';
import { config } from '@grafana/runtime';
export const getDefaultCredentials = (): AzureCredentials => {
if (config.azure.managedIdentityEnabled) {
return { authType: 'msi' };
} else {
return { authType: AzureAuthType.CLIENT_SECRET, azureCloud: cloud };
return { authType: 'clientsecret', azureCloud: getDefaultAzureCloud() };
}
};
export const getSecret = (
storedServerSide: boolean,
secret: string | symbol | undefined
): undefined | string | ConcealedSecretType => {
const concealedSecret: ConcealedSecretType = Symbol('Concealed client secret');
if (storedServerSide) {
// The secret is concealed server side, so return the symbol
return concealedSecret;
} else {
return typeof secret === 'string' && secret.length > 0 ? secret : undefined;
export const getCredentials = (dsSettings: AzureDataSourceSettings): AzureCredentials => {
const credentials = getDatasourceCredentials(dsSettings);
if (credentials) {
return credentials;
}
};
export const getCredentials = (
dsSettings: DataSourceSettings<AzureAuthJSONDataType, AzureAuthSecureJSONDataType>,
bootConfig: GrafanaBootConfig
): AzureCredentialsType => {
// JSON data
const credentials = dsSettings.jsonData?.azureCredentials;
// Secure JSON data/fields
const clientSecretStoredServerSide = dsSettings.secureJsonFields?.azureClientSecret;
const clientSecret = dsSettings.secureJsonData?.azureClientSecret;
const passwordStoredServerSide = dsSettings.secureJsonFields?.password;
const password = dsSettings.secureJsonData?.password;
// BootConfig data
const managedIdentityEnabled = !!bootConfig.azure?.managedIdentityEnabled;
const cloud = bootConfig.azure?.cloud || AzureCloud.Public;
// If no credentials saved, then return empty credentials
// of type based on whether the managed identity enabled
if (!credentials) {
return getDefaultCredentials(managedIdentityEnabled, cloud);
}
switch (credentials.authType) {
case AzureAuthType.MSI:
if (managedIdentityEnabled) {
return {
authType: AzureAuthType.MSI,
};
} else {
// If authentication type is managed identity but managed identities were disabled in Grafana config,
// then we should fallback to an empty app registration (client secret) configuration
return {
authType: AzureAuthType.CLIENT_SECRET,
azureCloud: cloud,
};
}
case AzureAuthType.CLIENT_SECRET:
return {
authType: AzureAuthType.CLIENT_SECRET,
azureCloud: credentials.azureCloud || cloud,
tenantId: credentials.tenantId,
clientId: credentials.clientId,
clientSecret: getSecret(clientSecretStoredServerSide, clientSecret),
};
case AzureAuthType.AD_PASSWORD:
return {
authType: AzureAuthType.AD_PASSWORD,
userId: credentials.userId,
clientId: credentials.clientId,
password: getSecret(passwordStoredServerSide, password),
};
}
};
export const updateCredentials = (
dsSettings: DataSourceSettings<AzureAuthJSONDataType>,
bootConfig: GrafanaBootConfig,
credentials: AzureCredentialsType
): DataSourceSettings<AzureAuthJSONDataType> => {
// BootConfig data
const managedIdentityEnabled = !!bootConfig.azure?.managedIdentityEnabled;
const cloud = bootConfig.azure?.cloud || AzureCloud.Public;
switch (credentials.authType) {
case AzureAuthType.MSI:
if (!managedIdentityEnabled) {
throw new Error('Managed Identity authentication is not enabled in Grafana config.');
}
dsSettings = {
...dsSettings,
jsonData: {
...dsSettings.jsonData,
azureCredentials: {
authType: AzureAuthType.MSI,
},
},
};
return dsSettings;
case AzureAuthType.CLIENT_SECRET:
dsSettings = {
...dsSettings,
jsonData: {
...dsSettings.jsonData,
azureCredentials: {
authType: AzureAuthType.CLIENT_SECRET,
azureCloud: credentials.azureCloud || cloud,
tenantId: credentials.tenantId,
clientId: credentials.clientId,
},
},
secureJsonData: {
...dsSettings.secureJsonData,
azureClientSecret:
typeof credentials.clientSecret === 'string' && credentials.clientSecret.length > 0
? credentials.clientSecret
: undefined,
},
secureJsonFields: {
...dsSettings.secureJsonFields,
azureClientSecret: typeof credentials.clientSecret === 'symbol',
},
};
return dsSettings;
case AzureAuthType.AD_PASSWORD:
return {
...dsSettings,
jsonData: {
...dsSettings.jsonData,
azureCredentials: {
authType: AzureAuthType.AD_PASSWORD,
userId: credentials.userId,
clientId: credentials.clientId,
},
},
secureJsonData: {
...dsSettings.secureJsonData,
password:
typeof credentials.password === 'string' && credentials.password.length > 0
? credentials.password
: undefined,
},
secureJsonFields: {
...dsSettings.secureJsonFields,
password: typeof credentials.password === 'symbol',
},
};
}
return getDefaultCredentials();
};

@ -1,16 +1,15 @@
import { ChangeEvent } from 'react';
import { AzureCredentials, AzureAuthType } from '@grafana/azure-sdk';
import { SelectableValue } from '@grafana/data';
import { Button, Field, Select, Input } from '@grafana/ui/src/components';
import { AzureCredentialsType, AzureAuthType } from '../types';
export interface Props {
managedIdentityEnabled: boolean;
azureEntraPasswordCredentialsEnabled: boolean;
credentials: AzureCredentialsType;
credentials: AzureCredentials;
azureCloudOptions?: SelectableValue[];
onCredentialsChange: (updatedCredentials: AzureCredentialsType) => void;
onCredentialsChange: (updatedCredentials: AzureCredentials) => void;
disabled?: boolean;
}
@ -26,9 +25,89 @@ export const AzureCredentialsForm = (props: Props) => {
const onAuthTypeChange = (selected: SelectableValue<AzureAuthType>) => {
if (onCredentialsChange) {
const updated: AzureCredentialsType = {
const updated: AzureCredentials = {
...credentials,
authType: selected.value || 'msi',
};
onCredentialsChange(updated);
}
};
const onAzureCloudChange = (selected: SelectableValue<string>) => {
if (credentials.authType === 'clientsecret') {
const updated: AzureCredentials = {
...credentials,
azureCloud: selected.value,
};
onCredentialsChange(updated);
}
};
const onTenantIdChange = (event: ChangeEvent<HTMLInputElement>) => {
if (credentials.authType === 'clientsecret') {
const updated: AzureCredentials = {
...credentials,
tenantId: event.target.value,
};
onCredentialsChange(updated);
}
};
const onClientIdChange = (event: ChangeEvent<HTMLInputElement>) => {
if (credentials.authType === 'clientsecret' || credentials.authType === 'ad-password') {
const updated: AzureCredentials = {
...credentials,
clientId: event.target.value,
};
onCredentialsChange(updated);
}
};
const onClientSecretChange = (event: ChangeEvent<HTMLInputElement>) => {
if (credentials.authType === 'clientsecret') {
const updated: AzureCredentials = {
...credentials,
clientSecret: event.target.value,
};
onCredentialsChange(updated);
}
};
const onClientSecretReset = () => {
if (credentials.authType === 'clientsecret') {
const updated: AzureCredentials = {
...credentials,
clientSecret: '',
};
onCredentialsChange(updated);
}
};
const onUserIdChange = (event: ChangeEvent<HTMLInputElement>) => {
if (credentials.authType === 'ad-password') {
const updated: AzureCredentials = {
...credentials,
userId: event.target.value,
};
onCredentialsChange(updated);
}
};
const onPasswordChange = (event: ChangeEvent<HTMLInputElement>) => {
if (credentials.authType === 'ad-password') {
const updated: AzureCredentials = {
...credentials,
password: event.target.value,
};
onCredentialsChange(updated);
}
};
const onPasswordReset = () => {
if (credentials.authType === 'ad-password') {
const updated: AzureCredentials = {
...credentials,
authType: selected.value || AzureAuthType.MSI,
password: '',
};
onCredentialsChange(updated);
}
@ -36,33 +115,23 @@ export const AzureCredentialsForm = (props: Props) => {
const authTypeOptions: Array<SelectableValue<AzureAuthType>> = [
{
value: AzureAuthType.CLIENT_SECRET,
value: 'clientsecret',
label: 'App Registration',
},
];
if (managedIdentityEnabled) {
authTypeOptions.push({
value: AzureAuthType.MSI,
value: 'msi',
label: 'Managed Identity',
});
}
if (azureEntraPasswordCredentialsEnabled) {
authTypeOptions.push({
value: AzureAuthType.AD_PASSWORD,
value: 'ad-password',
label: 'Azure Entra Password',
});
}
const onInputChange = ({ property, value }: { property: keyof AzureCredentialsType; value: string }) => {
if (onCredentialsChange) {
const updated: AzureCredentialsType = {
...credentials,
[property]: value,
};
onCredentialsChange(updated);
}
};
return (
<div>
<Field
@ -78,17 +147,14 @@ export const AzureCredentialsForm = (props: Props) => {
disabled={disabled}
/>
</Field>
{credentials.authType === AzureAuthType.CLIENT_SECRET && (
{credentials.authType === 'clientsecret' && (
<>
{azureCloudOptions && (
<Field label="Azure Cloud" htmlFor="azure-cloud-type" disabled={disabled}>
<Select
value={azureCloudOptions.find((opt) => opt.value === credentials.azureCloud)}
options={azureCloudOptions}
onChange={(selected: SelectableValue<AzureAuthType>) => {
const value = selected.value || '';
onInputChange({ property: 'azureCloud', value });
}}
onChange={onAzureCloudChange}
isDisabled={disabled}
inputId="azure-cloud-type"
aria-label="Azure Cloud"
@ -107,10 +173,7 @@ export const AzureCredentialsForm = (props: Props) => {
width={45}
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value={credentials.tenantId || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
onInputChange({ property: 'tenantId', value });
}}
onChange={onTenantIdChange}
disabled={disabled}
aria-label="Tenant ID"
/>
@ -126,10 +189,7 @@ export const AzureCredentialsForm = (props: Props) => {
width={45}
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value={credentials.clientId || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
onInputChange({ property: 'clientId', value });
}}
onChange={onClientIdChange}
disabled={disabled}
aria-label="Client ID"
/>
@ -145,14 +205,7 @@ export const AzureCredentialsForm = (props: Props) => {
data-testid={'client-secret'}
width={45}
/>
<Button
variant="secondary"
type="button"
onClick={() => {
onInputChange({ property: 'clientSecret', value: '' });
}}
disabled={disabled}
>
<Button variant="secondary" type="button" onClick={onClientSecretReset} disabled={disabled}>
Reset
</Button>
</div>
@ -170,10 +223,7 @@ export const AzureCredentialsForm = (props: Props) => {
aria-label="Client Secret"
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
value={credentials.clientSecret || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
onInputChange({ property: 'clientSecret', value });
}}
onChange={onClientSecretChange}
id="client-secret"
disabled={disabled}
/>
@ -181,16 +231,13 @@ export const AzureCredentialsForm = (props: Props) => {
))}
</>
)}
{credentials.authType === AzureAuthType.AD_PASSWORD && azureEntraPasswordCredentialsEnabled && (
{credentials.authType === 'ad-password' && azureEntraPasswordCredentialsEnabled && (
<>
<Field label="User Id" required htmlFor="user-id" invalid={!credentials.userId} error={'User ID is required'}>
<Input
width={45}
value={credentials.userId || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
onInputChange({ property: 'userId', value });
}}
onChange={onUserIdChange}
disabled={disabled}
aria-label="User ID"
/>
@ -205,10 +252,7 @@ export const AzureCredentialsForm = (props: Props) => {
<Input
width={45}
value={credentials.clientId || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
onInputChange({ property: 'clientId', value });
}}
onChange={onClientIdChange}
disabled={disabled}
aria-label="Application Client ID"
/>
@ -224,14 +268,7 @@ export const AzureCredentialsForm = (props: Props) => {
data-testid={'password'}
width={45}
/>
<Button
variant="secondary"
type="button"
onClick={() => {
onInputChange({ property: 'password', value: '' });
}}
disabled={disabled}
>
<Button variant="secondary" type="button" onClick={onPasswordReset} disabled={disabled}>
Reset
</Button>
</div>
@ -248,10 +285,7 @@ export const AzureCredentialsForm = (props: Props) => {
width={45}
aria-label="Password"
value={credentials.password || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
onInputChange({ property: 'password', value });
}}
onChange={onPasswordChange}
id="password"
disabled={disabled}
/>

@ -1,4 +1,4 @@
import { DataSourceJsonData } from '@grafana/data';
import { AzureCredentials } from '@grafana/azure-sdk';
import { SQLOptions } from '@grafana/sql';
import { HttpSettingsBaseProps } from '@grafana/ui/src/components/DataSourceSettings/types';
@ -17,37 +17,13 @@ export enum MSSQLEncryptOptions {
false = 'false',
true = 'true',
}
export enum AzureCloud {
Public = 'AzureCloud',
None = '',
}
export type ConcealedSecretType = symbol;
export enum AzureAuthType {
MSI = 'msi',
CLIENT_SECRET = 'clientsecret',
AD_PASSWORD = 'ad-password',
}
export interface AzureCredentialsType {
authType: AzureAuthType;
azureCloud?: string;
tenantId?: string;
clientId?: string;
clientSecret?: string | ConcealedSecretType;
userId?: string;
password?: string | ConcealedSecretType;
}
export interface MssqlOptions extends SQLOptions {
authenticationType?: MSSQLAuthenticationType;
encrypt?: MSSQLEncryptOptions;
sslRootCertFile?: string;
serverName?: string;
connectionTimeout?: number;
azureCredentials?: AzureCredentialsType;
azureCredentials?: AzureCredentials;
keytabFilePath?: string;
credentialCache?: string;
credentialCacheLookupFile?: string;
@ -60,15 +36,6 @@ export interface MssqlSecureOptions {
password?: string;
}
export type AzureAuthJSONDataType = DataSourceJsonData & {
azureCredentials: AzureCredentialsType;
};
export type AzureAuthSecureJSONDataType = {
azureClientSecret: undefined | string | ConcealedSecretType;
password: undefined | string | ConcealedSecretType;
};
export type AzureAuthConfigType = {
azureAuthIsSupported: boolean;
azureAuthSettingsUI: (props: HttpSettingsBaseProps) => JSX.Element;

@ -26,15 +26,15 @@ composableKinds: PanelCfg: {
version: [0, 0]
schema: {
Options: {
showLabels: bool
showCommonLabels: bool
showTime: bool
showLogContextToggle: bool
wrapLogMessage: bool
prettifyLogMessage: bool
enableLogDetails: bool
sortOrder: common.LogsSortOrder
dedupStrategy: common.LogsDedupStrategy
showLabels: bool
showCommonLabels: bool
showTime: bool
showLogContextToggle: bool
wrapLogMessage: bool
prettifyLogMessage: bool
enableLogDetails: bool
sortOrder: common.LogsSortOrder
dedupStrategy: common.LogsDedupStrategy
enableInfiniteScrolling?: bool
// TODO: figure out how to define callbacks
onClickFilterLabel?: _

@ -22639,7 +22639,8 @@
},
"summary": "Deletes user.",
"tags": [
"user"
"user",
"enterprise"
]
},
"get": {
@ -22663,7 +22664,8 @@
},
"summary": "Fetches all users in UserSchema format.",
"tags": [
"users"
"users",
"enterprise"
]
},
"post": {
@ -22690,7 +22692,8 @@
},
"summary": "Creates user.",
"tags": [
"users"
"users",
"enterprise"
]
}
},
@ -22716,7 +22719,8 @@
},
"summary": "Gets user by id.",
"tags": [
"user"
"user",
"enterprise"
]
}
},

@ -37,4 +37,6 @@ def github_app_generate_token_step():
"echo $(/usr/bin/github-app-external-token) > /github-app/token",
],
"volumes": github_app_step_volumes(),
# forks or those without access would cause it to fail, but we can safely ignore it since there'll be no token.
"failure": "ignore",
}

@ -1629,10 +1629,10 @@ __metadata:
languageName: node
linkType: hard
"@bsull/augurs@npm:^0.6.0":
version: 0.6.0
resolution: "@bsull/augurs@npm:0.6.0"
checksum: 10/0ba2ea0432f7d4c44ccec4d112e672f8d5d977407be42ff5995e1d6641b4b0238f97b9cdf13bc1fc066559bbd54e6fb00ad1f418014823a6c22af619e7a29c6a
"@bsull/augurs@npm:^0.7.0":
version: 0.7.0
resolution: "@bsull/augurs@npm:0.7.0"
checksum: 10/7291b401f37fd2d120e97449eb2820a069ae744a04ba3f952fde20f31c80eeb221e0b8861d5853e62667ed6ed7b33f5028dbd442d491fe06183dd5a5ad6f725f
languageName: node
linkType: hard
@ -3790,11 +3790,11 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes-react@npm:5.28.1":
version: 5.28.1
resolution: "@grafana/scenes-react@npm:5.28.1"
"@grafana/scenes-react@npm:^5.30.0":
version: 5.30.0
resolution: "@grafana/scenes-react@npm:5.30.0"
dependencies:
"@grafana/scenes": "npm:5.28.1"
"@grafana/scenes": "npm:5.30.0"
lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0"
peerDependencies:
@ -3805,13 +3805,13 @@ __metadata:
"@grafana/ui": ^11.0.0
react: ^18.0.0
react-dom: ^18.0.0
checksum: 10/1979eebf0eea30550e9ffc1c9a766cd239914f5b79e0487ddf77d49006288c78f2e3f72bbe97782b6144902ef05e649f6f46197891811e3bbf809e6d016f0920
checksum: 10/8d33b0024865e4a869cbba4dc7530c7494f4c2cb3615bbe7e2c9a912736fc7618848ab92c03941f0847f5edc9dc68a2d68bf99aac9ff95e0d40c85f1fa410088
languageName: node
linkType: hard
"@grafana/scenes@npm:5.28.1":
version: 5.28.1
resolution: "@grafana/scenes@npm:5.28.1"
"@grafana/scenes@npm:5.30.0, @grafana/scenes@npm:^5.30.0":
version: 5.30.0
resolution: "@grafana/scenes@npm:5.30.0"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16"
@ -3828,7 +3828,7 @@ __metadata:
"@grafana/ui": ">=10.4"
react: ^18.0.0
react-dom: ^18.0.0
checksum: 10/a12ab38c048e886a880bff64c5b17ae959f34efea44bee06afa4acdc4dcde14ccafc7aa263df568f54d7d5dd165d40952f11ce0ee0c1daa3ad46ea435dbe00ad
checksum: 10/d226f523ef2b22eac0de26e7929dbe5d5775f297b0f0c07a4a1b5c792c224c72eab67312d43b7a88d85416c9e4c91f9064777d54e696fdf9da2366c70914b6b3
languageName: node
linkType: hard
@ -17531,7 +17531,7 @@ __metadata:
"@betterer/betterer": "npm:5.4.0"
"@betterer/cli": "npm:5.4.0"
"@betterer/eslint": "npm:5.4.0"
"@bsull/augurs": "npm:^0.6.0"
"@bsull/augurs": "npm:^0.7.0"
"@cypress/webpack-preprocessor": "npm:6.0.2"
"@emotion/css": "npm:11.13.5"
"@emotion/eslint-plugin": "npm:11.12.0"
@ -17559,8 +17559,8 @@ __metadata:
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/saga-icons": "workspace:*"
"@grafana/scenes": "npm:5.28.1"
"@grafana/scenes-react": "npm:5.28.1"
"@grafana/scenes": "npm:^5.30.0"
"@grafana/scenes-react": "npm:^5.30.0"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/tsconfig": "npm:^2.0.0"

Loading…
Cancel
Save