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 />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"] [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": [ "public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"] [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 />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"], [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 />", "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": [ "public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [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": [ "public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [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": [ "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 />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],

@ -193,6 +193,7 @@ steps:
from_secret: github-app-installation-id from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY: GITHUB_APP_PRIVATE_KEY:
from_secret: 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 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 name: github-app-generate-token
volumes: volumes:
@ -276,6 +277,7 @@ steps:
from_secret: github-app-installation-id from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY: GITHUB_APP_PRIVATE_KEY:
from_secret: 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 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 name: github-app-generate-token
volumes: volumes:
@ -383,6 +385,7 @@ steps:
from_secret: github-app-installation-id from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY: GITHUB_APP_PRIVATE_KEY:
from_secret: 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 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 name: github-app-generate-token
volumes: volumes:
@ -521,6 +524,7 @@ steps:
from_secret: github-app-installation-id from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY: GITHUB_APP_PRIVATE_KEY:
from_secret: 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 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 name: github-app-generate-token
volumes: volumes:
@ -618,6 +622,7 @@ steps:
from_secret: github-app-installation-id from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY: GITHUB_APP_PRIVATE_KEY:
from_secret: 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 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 name: github-app-generate-token
volumes: volumes:
@ -1062,6 +1067,7 @@ steps:
from_secret: github-app-installation-id from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY: GITHUB_APP_PRIVATE_KEY:
from_secret: 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 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 name: github-app-generate-token
volumes: volumes:
@ -1414,6 +1420,7 @@ steps:
from_secret: github-app-installation-id from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY: GITHUB_APP_PRIVATE_KEY:
from_secret: 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 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 name: github-app-generate-token
volumes: volumes:
@ -1538,6 +1545,7 @@ steps:
from_secret: github-app-installation-id from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY: GITHUB_APP_PRIVATE_KEY:
from_secret: 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 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 name: github-app-generate-token
volumes: volumes:
@ -2095,6 +2103,7 @@ steps:
from_secret: github-app-installation-id from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY: GITHUB_APP_PRIVATE_KEY:
from_secret: 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 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 name: github-app-generate-token
volumes: volumes:
@ -3791,6 +3800,7 @@ steps:
from_secret: github-app-installation-id from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY: GITHUB_APP_PRIVATE_KEY:
from_secret: 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 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 name: github-app-generate-token
volumes: volumes:
@ -4822,6 +4832,7 @@ steps:
from_secret: github-app-installation-id from_secret: github-app-installation-id
GITHUB_APP_PRIVATE_KEY: GITHUB_APP_PRIVATE_KEY:
from_secret: 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 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 name: github-app-generate-token
volumes: volumes:
@ -5740,6 +5751,6 @@ kind: secret
name: gcr_credentials name: gcr_credentials
--- ---
kind: signature kind: signature
hmac: e97f7a0c3923b506dad6bf861bb1ea440a8f072ee3744742eec35e7278c3581c hmac: 04ba0c9b8e69705a28a24ba03de14ece0b15c4b44f6262fcbc6a9ee874b5a9db
... ...

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

1
.gitignore vendored

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

@ -4,8 +4,8 @@ go 1.23.1
require ( require (
github.com/grafana/grafana-app-sdk v0.23.1 github.com/grafana/grafana-app-sdk v0.23.1
k8s.io/apimachinery v0.31.1 k8s.io/apimachinery v0.31.3
k8s.io/apiserver v0.31.1 k8s.io/apiserver v0.31.3
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 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/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect
github.com/gogo/protobuf v1.3.2 // 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/golang/protobuf v1.5.4 // indirect
github.com/google/btree v1.1.2 // indirect github.com/google/btree v1.1.2 // indirect
github.com/google/gnostic-models v0.6.8 // 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/gorilla/websocket v1.5.3 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // 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/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/jonboulle/clockwork v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // 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/onsi/gomega v1.34.1 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // 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/prometheus/procfs v0.15.1 // indirect
github.com/spf13/pflag v1.0.5 // 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 github.com/x448/float16 v0.8.4 // indirect
go.etcd.io/bbolt v1.3.10 // indirect go.etcd.io/bbolt v1.3.10 // indirect
go.etcd.io/etcd/api/v3 v3.5.14 // 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/pkg/v3 v3.5.14 // indirect
go.etcd.io/etcd/client/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/google.golang.org/grpc/otelgrpc v0.56.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect
go.opentelemetry.io/otel v1.31.0 // indirect go.opentelemetry.io/otel v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 // indirect
go.opentelemetry.io/otel/metric v1.31.0 // indirect go.opentelemetry.io/otel/metric v1.32.0 // indirect
go.opentelemetry.io/otel/sdk v1.31.0 // indirect go.opentelemetry.io/otel/sdk v1.32.0 // indirect
go.opentelemetry.io/otel/trace v1.31.0 // indirect go.opentelemetry.io/otel/trace v1.32.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect
golang.org/x/net v0.30.0 // indirect golang.org/x/net v0.31.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sys v0.26.0 // indirect golang.org/x/sys v0.27.0 // indirect
golang.org/x/term v0.25.0 // indirect golang.org/x/term v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect golang.org/x/text v0.20.0 // indirect
golang.org/x/time v0.6.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 v0.0.0-20240820151423-278611b39280 // indirect
google.golang.org/genproto/googleapis/api 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-20241007155032-5fefd90f89a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect
google.golang.org/grpc v1.67.1 // 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/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.31.1 // indirect k8s.io/api v0.31.3 // indirect
k8s.io/client-go v0.31.1 // indirect k8s.io/client-go v0.31.3 // indirect
k8s.io/component-base v0.31.1 // indirect k8s.io/component-base v0.31.3 // indirect
k8s.io/klog/v2 v2.130.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // 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/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 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= 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-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/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 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/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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 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_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 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 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 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 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= 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/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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= 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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 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/raft/v3 v3.5.13 h1:7r/NKAOups1YnKcfro2RvGGo2PTuizF/xh26Z2CTAzA=
go.etcd.io/etcd/server/v3 v3.5.13 h1:V6KG+yMfMSqWt+lGnhFpP5z5dRUj1BDRJ5k1fQ9DFok= 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/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/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw=
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 h1:9kV11HXBHZAvuPUZxmMWrH8hZn/6UnHX4K0mu36vNsU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 h1:FFeLy03iVTXP6ffeN2iXrxfGsZGCjVx0/4KlizjyBwU= go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
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/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 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-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 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/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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.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-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-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.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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-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/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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 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-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-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-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.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-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-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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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 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/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc= 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/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.3 h1:umzm5o8lFbdN/hIXbrK9oRpOproJO62CV1zqxXrLgk8=
k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI= k8s.io/apimachinery v0.31.3 h1:6l0WhcYgasZ/wk9ktLq5vLaoXJJr5ts6lkaQzgeYPq4=
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= k8s.io/apiserver v0.31.3 h1:+1oHTtCB+OheqFEz375D0IlzHZ5VeQKX1KGXnx+TTuY=
k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= k8s.io/client-go v0.31.3 h1:CAlZuM+PH2cm+86LOBemaJI/lQ5linJ6UFxKX/SoG+4=
k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c= k8s.io/component-base v0.31.3 h1:DMCXXVx546Rfvhj+3cOm2EUxhS+EyztH423j+8sOwhQ=
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/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= 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 ### Unsupported
- Graphite - Graphite
- Dynatrace
### Unconfirmed ### Unconfirmed
@ -258,7 +259,6 @@ guaranteed because plugin developers can override this functionality. The follow
- Datadog - Datadog
- Dataset - Dataset
- Druid - Druid
- Dynatrace
- GitHub - GitHub
- Google BigQuery - Google BigQuery
- Grafana for YNAB - Grafana for YNAB

@ -19,13 +19,18 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/calculation-types/ destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/calculation-types/
- pattern: /docs/grafana-cloud/ - pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/calculation-types/ 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 # 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. 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. 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) ![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 ## Configuration 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.
- **Calculate -** Reduces each value to a single value per series. {{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
- **All values -** Displays every value from a single series.
### 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. <!-- prettier-ignore-end -->
- **All fields -** All fields that are not removed by transformations.
- **Time -** All fields with time values.
## Pie chart options ### Pie chart options
Use these options to refine how your visualization looks. Use these options to refine how your visualization looks.
### Pie chart type #### Pie chart type
Select the pie chart display style.
### 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 #### Labels
![Donut type chart](/static/img/docs/pie-chart-panel/donut-type-chart-7-5.png)
### Labels
Select labels to display on the pie chart. You can select more than one. Select labels to display on the pie chart. You can select more than one.
- **Name -** The series or field name. - **Name** - The series or field name.
- **Percent -** The percentage of the whole. - **Percent** - The percentage of the whole.
- **Value -** The raw numerical value. - **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. 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. 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.
### 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. {{< docs/shared lookup="visualizations/tooltip-options-1.md" source="grafana" version="<GRAFANA_VERSION>" leveloffset="+1" >}}
- **Right -** To the right of the graph.
#### 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. <!-- prettier-ignore-end -->
- **Value:** The raw numerical value.
## Standard options ### Standard options
{{< docs/shared lookup="visualizations/standard-options.md" source="grafana" version="<GRAFANA_VERSION>" >}} {{< 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>" >}} {{< 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>" >}} {{< 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>" >}} {{< 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 | | `tlsMemcached` | Use TLS-enabled memcached in the enterprise caching feature | Yes |
| `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | 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 | | `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 | | `notificationBanner` | Enables the notification banner UI and API | Yes |
| `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | | | `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | |
| `pinNavItems` | Enables pinning of nav items | Yes | | `pinNavItems` | Enables pinning of nav items | Yes |
| `openSearchBackendFlowEnabled` | Enables the backend query flow for Open Search datasource plugin | 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 | | `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 | | `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 | | `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. | | | `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 | | `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 | | `failWrongDSUID` | Throws an error if a datasource has an invalid UIDs |
| `alertingApiServer` | Register Alerting APIs with the K8s API server | | `alertingApiServer` | Register Alerting APIs with the K8s API server |
| `dataplaneAggregator` | Enable grafana dataplane aggregator | | `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 | | `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 | | `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 | | `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: Grafana requires the minimum system resources:
- Minimum recommended memory: 512 MB - Minimum recommended memory: 512 MB
- Minimum recommended CPU: 1 - Minimum recommended CPU: 1 core
Some features might require more memory or CPUs, including: 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', () => { it('Create a public dashboard with template variables shows a template variable warning', () => {
// Opening a dashboard with template variables // Opening a dashboard with template variables
e2e.flows.openDashboard({ uid: 'HYaGDGIMk' }); e2e.flows.openDashboard({ uid: 'HYaGDGIMk', queryParams: { '__feature.newDashboardSharingComponent': false } });
// Open sharing modal // Open sharing modal
e2e.components.NavToolbar.shareDashboard().click(); e2e.components.NavToolbar.shareDashboard().click();

@ -10,7 +10,7 @@ describe('Snapshots', () => {
cy.intercept({ cy.intercept({
pathname: '/api/ds/query', pathname: '/api/ds/query',
}).as('query'); }).as('query');
e2e.flows.openDashboard({ uid: 'ZqZnVvFZz' }); e2e.flows.openDashboard({ uid: 'ZqZnVvFZz', queryParams: { '__feature.newDashboardSharingComponent': false } });
cy.wait('@query'); cy.wait('@query');
const panelsToCheck = [ 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/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 ( require (
cloud.google.com/go/longrunning v0.6.0 // indirect cloud.google.com/go/longrunning v0.6.0 // indirect
github.com/at-wat/mqtt-go v0.19.4 // 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.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 h1:KzbooQP9mv/9CPsn+SoUwGuomA8oUxO0iuIq6Rg/ekE=
github.com/grafana/grafana-plugin-sdk-go v0.260.1/go.mod h1:JriieK5Oc5v120QKhMs/LO55N0P3YI2ttEiVT1wfYsw= 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 h1:zZN/Jy7PjoqtrMiBRV5O3x4xAArcSbUznuyAPACrKXI=
github.com/grafana/grafana/apps/playlist v0.0.0-20241105090059-facca37f4d1f/go.mod h1:e97Zxn1WX4Wn9TXEvwTjMNwU6yrjX/K7uVNSCZyEwxY= 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= 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/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 h1:VTJEOGouJ8j27jJCD4kBBbNTxM0OdBvE1aY1tMhlqE8=
github.com/mithrandie/readline-csvq v1.3.0/go.mod h1:FKyYqDgf/G4SNov7SMFXRWO6LQLXIOeTog/NB97FZl0= 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 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= 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= 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 h1:IjgxbomVrV9za6bRi8fWCNXENs0co37SZedQilP2hm0=
go.opentelemetry.io/contrib/propagators/b3 v1.27.0/go.mod h1:Dv9obQz25lCisDvvs4dy28UPh974CxkahRDUPsY7y9E= 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.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 h1:DZzxj9QjznMVoehskOJnFP2gsTCWtDTFBDvFhPAY7nc=
go.opentelemetry.io/otel/bridge/opencensus v1.26.0/go.mod h1:rJiX0KrF5m8Tm1XE8jLczpAv5zUaDcvhKecFG0ZoFG4= 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= 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 h1:OkuaKgKrgAbYrrY0t92c+cC+2F6hsFNnCQArXCKlg08=
go.opentelemetry.io/otel/sdk/metric v1.28.0/go.mod h1:cWPjykihLAPvXKi4iZc1dpER3Jdq2Z0YLse3moQUCpg= 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.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/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8= go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 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.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.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.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.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.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.22.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.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.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.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 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= 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/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.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 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.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-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/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= 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 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/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.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/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
gopkg.in/cheggaaa/pb.v1 v1.0.25 h1:Ev7yu1/f6+d+b3pi5vPdRPc6nNtP1umSfcWiEfRqv6I= gopkg.in/cheggaaa/pb.v1 v1.0.25 h1:Ev7yu1/f6+d+b3pi5vPdRPc6nNtP1umSfcWiEfRqv6I=
gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=

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

@ -34,6 +34,7 @@ interface BaseProps {
menu?: ReactElement | (() => ReactElement); menu?: ReactElement | (() => ReactElement);
dragClass?: string; dragClass?: string;
dragClassCancel?: string; dragClassCancel?: string;
onDragStart?: (e: React.PointerEvent) => void;
selectionId?: string; selectionId?: string;
/** /**
* Use only to indicate loading or streaming data in the panel. * Use only to indicate loading or streaming data in the panel.
@ -142,6 +143,7 @@ export function PanelChrome({
onFocus, onFocus,
onMouseMove, onMouseMove,
onMouseEnter, onMouseEnter,
onDragStart,
showMenuAlways = false, showMenuAlways = false,
}: PanelChromeProps) { }: PanelChromeProps) {
const theme = useTheme2(); const theme = useTheme2();
@ -312,6 +314,7 @@ export function PanelChrome({
className={cx(styles.headerContainer, dragClass)} className={cx(styles.headerContainer, dragClass)}
style={headerStyles} style={headerStyles}
data-testid="header-container" data-testid="header-container"
onPointerDown={onDragStart}
onPointerUp={onSelect} onPointerUp={onSelect}
> >
{statusMessage && ( {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). 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 ```go
// swagger:route PATCH /serviceaccounts/{serviceAccountId} service_accounts updateServiceAccount // 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/aws/session"
"github.com/aws/aws-sdk-go/service/ecr" "github.com/aws/aws-sdk-go/service/ecr"
"github.com/aws/aws-sdk-go/service/marketplacecatalog" "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/api/types/registry"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
@ -60,9 +60,9 @@ type AwsMarketplacePublishingService struct {
} }
type AwsMarketplaceDocker interface { 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 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 { type AwsMarketplaceRegistry interface {
@ -172,8 +172,8 @@ func (s *AwsMarketplacePublishingService) Login(ctx context.Context) error {
return err return err
} }
func (s *AwsMarketplacePublishingService) PullImage(ctx context.Context, image string, version string) error { func (s *AwsMarketplacePublishingService) PullImage(ctx context.Context, img string, version string) error {
reader, err := s.docker.ImagePull(ctx, fmt.Sprintf("%s:%s", image, version), types.ImagePullOptions{ reader, err := s.docker.ImagePull(ctx, fmt.Sprintf("%s:%s", img, version), image.PullOptions{
Platform: imagePlatform, Platform: imagePlatform,
}) })
if err != nil { 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 { 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, RegistryAuth: s.auth,
}) })
if err != nil { if err != nil {

@ -14,7 +14,7 @@ import (
"github.com/aws/aws-sdk-go/aws/request" "github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/service/ecr" "github.com/aws/aws-sdk-go/service/ecr"
"github.com/aws/aws-sdk-go/service/marketplacecatalog" "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/stretchr/testify/assert"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
@ -168,13 +168,13 @@ type mockAwsMarketplaceDocker struct {
ImagePushError error 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 return io.NopCloser(bytes.NewReader([]byte(""))), m.ImagePullError
} }
func (m *mockAwsMarketplaceDocker) ImageTag(ctx context.Context, source string, target string) error { func (m *mockAwsMarketplaceDocker) ImageTag(ctx context.Context, source string, target string) error {
return m.ImageTagError 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 return io.NopCloser(bytes.NewReader([]byte(""))), m.ImagePushError
} }

@ -5,7 +5,7 @@ go 1.23.1
// Override docker/docker to avoid: // Override docker/docker to avoid:
// go: github.com/drone-runners/drone-runner-docker@v1.8.2 requires // 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 // 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 // contains openapi encoder fixes. remove ASAP
replace cuelang.org/go => github.com/grafana/cue v0.0.0-20230926092038-971951014e3f // @grafana/grafana-as-code 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/distribution/reference v0.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect
github.com/mitchellh/go-homedir v1.1.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/moby/term v0.5.0 // indirect
github.com/sosodev/duration v1.2.0 // indirect github.com/sosodev/duration v1.2.0 // indirect
github.com/vektah/gqlparser/v2 v2.5.11 // 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/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 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 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/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/moby v25.0.2+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc= 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 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=

@ -32,6 +32,10 @@ func TestIntegrationWillRunInstrumentationServerWhenTargetHasNoHttpServer(t *tes
if dbType == "sqlite3" { if dbType == "sqlite3" {
t.Skip("skipping - sqlite not supported for storage server target") 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 := db.InitTestDBWithCfg(t)
cfg.HTTPPort = "3001" cfg.HTTPPort = "3001"

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

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

@ -16,9 +16,12 @@ const (
TypeRenderService string = "render" TypeRenderService string = "render"
TypeTeam string = "team" TypeTeam string = "team"
TypeRole string = "role" TypeRole string = "role"
TypeFolder string = "folder" )
TypeResource string = "resource"
TypeNamespace string = "namespace" const (
TypeFolder string = "folder"
TypeResource string = "resource"
TypeGroupResouce string = "group_resource"
) )
const ( const (
@ -31,44 +34,74 @@ const (
RelationSetEdit string = "edit" RelationSetEdit string = "edit"
RelationSetAdmin string = "admin" RelationSetAdmin string = "admin"
RelationRead string = "read" RelationGet string = "get"
RelationWrite string = "write" RelationUpdate string = "update"
RelationCreate string = "create" RelationCreate string = "create"
RelationDelete string = "delete" RelationDelete string = "delete"
RelationPermissionsRead string = "permissions_read"
RelationPermissionsWrite string = "permissions_write"
RelationFolderResourceSetView string = "resource_" + RelationSetView RelationFolderResourceSetView string = "resource_" + RelationSetView
RelationFolderResourceSetEdit string = "resource_" + RelationSetEdit RelationFolderResourceSetEdit string = "resource_" + RelationSetEdit
RelationFolderResourceSetAdmin string = "resource_" + RelationSetAdmin RelationFolderResourceSetAdmin string = "resource_" + RelationSetAdmin
RelationFolderResourceRead string = "resource_" + RelationRead RelationFolderResourceGet string = "resource_" + RelationGet
RelationFolderResourceWrite string = "resource_" + RelationWrite RelationFolderResourceUpdate string = "resource_" + RelationUpdate
RelationFolderResourceCreate string = "resource_" + RelationCreate RelationFolderResourceCreate string = "resource_" + RelationCreate
RelationFolderResourceDelete string = "resource_" + RelationDelete RelationFolderResourceDelete string = "resource_" + RelationDelete
RelationFolderResourcePermissionsRead string = "resource_" + RelationPermissionsRead
RelationFolderResourcePermissionsWrite string = "resource_" + RelationPermissionsWrite
) )
var ResourceRelations = []string{ // RelationsGroupResource are relations that can be added on type "group_resource".
RelationRead, var RelationsGroupResource = []string{
RelationWrite, RelationGet,
RelationUpdate,
RelationCreate,
RelationDelete, RelationDelete,
RelationPermissionsRead,
RelationPermissionsWrite,
} }
var FolderRelations = append( // RelationsResource are relations that can be added on type "resource".
ResourceRelations, var RelationsResource = []string{
RelationCreate, RelationGet,
RelationFolderResourceRead, RelationUpdate,
RelationFolderResourceWrite, RelationDelete,
}
// RelationsFolderResource are relations that can be added on type "folder" for child resources.
var RelationsFolderResource = []string{
RelationFolderResourceGet,
RelationFolderResourceUpdate,
RelationFolderResourceCreate, RelationFolderResourceCreate,
RelationFolderResourceDelete, 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 { func FolderResourceRelation(relation string) string {
return fmt.Sprintf("%s_%s", TypeResource, relation) return fmt.Sprintf("%s_%s", TypeResource, relation)
} }
@ -85,8 +118,8 @@ func NewFolderIdent(name string) string {
return fmt.Sprintf("%s:%s", TypeFolder, name) return fmt.Sprintf("%s:%s", TypeFolder, name)
} }
func NewNamespaceResourceIdent(group, resource string) string { func NewGroupResourceIdent(group, resource string) string {
return fmt.Sprintf("%s:%s", TypeNamespace, FormatGroupResource(group, resource)) return fmt.Sprintf("%s:%s", TypeGroupResouce, FormatGroupResource(group, resource))
} }
func FormatGroupResource(group, resource string) string { 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{ return &openfgav1.TupleKey{
User: subject, User: subject,
Relation: relation, 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{ req.ContextualTuples.TupleKeys = append(req.ContextualTuples.TupleKeys, &openfgav1.TupleKey{
User: req.TupleKey.User, User: req.TupleKey.User,
Relation: "view", Relation: RelationSetView,
Object: NewNamespaceResourceIdent( Object: NewGroupResourceIdent(
dashboardalpha1.DashboardResourceInfo.GroupResource().Group, dashboardalpha1.DashboardResourceInfo.GroupResource().Group,
dashboardalpha1.DashboardResourceInfo.GroupResource().Resource, 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. 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. 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:”namespace:dashboard.grafana.app/dashboard” }`. This 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. example would grant `user:1` access to all `dashboard.grafana.app/dashboard` in the namespace.
## Folder level permissions ## Folder level permissions

@ -6,31 +6,15 @@ type service-account
type render 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 type role
relations relations
define assignee: [user, service-account, team#member, role#assignee] define assignee: [user, service-account, team#member, role#assignee]
type team type team
relations relations
# Action sets
define admin: [user, service-account] define admin: [user, service-account]
define member: [user, service-account] or admin define member: [user, service-account] or admin
define read: [role#assignee] or member define get: [role#assignee] or member
define write: [role#assignee] or admin define update: [role#assignee] or admin
define delete: [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 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 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 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 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_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_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_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_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 type resource
relations 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 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 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 get: [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 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 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) { condition group_filter(requested_group: string, group_resource: string) {
requested_group == group_resource requested_group == group_resource

@ -56,7 +56,7 @@ func (s *Server) batchCheckItem(
allowed, ok := groupResourceAccess[groupResource] allowed, ok := groupResourceAccess[groupResource]
if !ok { 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 { if err != nil {
return nil, err return nil, err
} }

@ -44,7 +44,7 @@ func testBatchCheck(t *testing.T, server *Server) {
assert.False(t, res.Groups[groupResource].Items["2"]) 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) groupResource := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
res, err := server.BatchCheck(context.Background(), newReq("user:2", utils.VerbGet, dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ res, err := server.BatchCheck(context.Background(), newReq("user:2", utils.VerbGet, dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"}, {Name: "1", Folder: "1"},
@ -108,7 +108,7 @@ func testBatchCheck(t *testing.T, server *Server) {
assert.False(t, res.Groups[groupResource].Items["2"]) 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) groupResource := zanzana.FormatGroupResource(folderGroup, folderResource)
res, err := server.BatchCheck(context.Background(), newReq("user:7", utils.VerbGet, folderGroup, folderResource, []*authzextv1.BatchCheckItem{ res, err := server.BatchCheck(context.Background(), newReq("user:7", utils.VerbGet, folderGroup, folderResource, []*authzextv1.BatchCheckItem{
{Name: "1"}, {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) { 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 { 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 { if err != nil {
return nil, err 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) { func (s *Server) capabilitiesGeneric(ctx context.Context, r *authzextv1.CapabilitiesRequest, store *storeInfo) (*authzextv1.CapabilitiesResponse, error) {
out := make([]string, 0, len(common.ResourceRelations)) out := make([]string, 0, len(common.RelationsResource))
for _, relation := range common.ResourceRelations { for _, relation := range common.RelationsResource {
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 { if err != nil {
return nil, err 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) { 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")) res, err := server.Capabilities(context.Background(), newReq("user:1", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err) 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")) res, err := server.Capabilities(context.Background(), newReq("user:2", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err) 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) { 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")) res, err := server.Capabilities(context.Background(), newReq("user:3", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err) 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) { 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")) res, err := server.Capabilities(context.Background(), newReq("user:4", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err) 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) { 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")) res, err := server.Capabilities(context.Background(), newReq("user:5", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err) 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")) res, err := server.Capabilities(context.Background(), newReq("user:6", folderGroup, folderResource, "", "1"))
require.NoError(t, err) 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")) res, err := server.Capabilities(context.Background(), newReq("user:7", folderGroup, folderResource, "", "1"))
require.NoError(t, err) 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" authzv1 "github.com/grafana/authlib/authz/proto/v1"
openfgav1 "github.com/openfga/api/proto/openfga/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" "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()] relation := common.VerbMapping[r.GetVerb()]
res, err := s.checkGroupResource(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
// Check if subject has access through namespace
res, err := s.checkNamespace(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
if err != nil { if err != nil {
return nil, err 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) 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 // checkGroupResource check if subject has access to the full "GroupResource", if they do they can access every object
// every resource for that "GroupResource". // within it.
func (s *Server) checkNamespace(ctx context.Context, subject, relation, group, resource string, store *storeInfo) (*authzv1.CheckResponse, error) { 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{ req := &openfgav1.CheckRequest{
StoreId: store.ID, StoreId: store.ID,
AuthorizationModelId: store.ModelID, AuthorizationModelId: store.ModelID,
TupleKey: &openfgav1.CheckRequestTupleKey{ TupleKey: &openfgav1.CheckRequestTupleKey{
User: subject, User: subject,
Relation: relation, Relation: relation,
Object: common.NewNamespaceResourceIdent(group, resource), Object: common.NewGroupResourceIdent(group, resource),
}, },
} }
if strings.HasPrefix(subject, fmt.Sprintf("%s:", common.TypeRenderService)) { if strings.HasPrefix(subject, fmt.Sprintf("%s:", common.TypeRenderService)) {
common.AddRenderContext(req) 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 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) { 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 // Check if subject has direct access to resource
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{ res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
StoreId: store.ID, StoreId: store.ID,
@ -86,27 +92,26 @@ func (s *Server) checkTyped(ctx context.Context, subject, relation, name string,
return &authzv1.CheckResponse{Allowed: false}, nil 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) { 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. if folder != "" && common.IsFolderResourceRelation(folderRelation) {
// So we skip direct resource access check. // Check if subject has access as a sub resource for the folder
if relation != common.RelationCreate {
// Check if subject has direct access to resource
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{ res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
StoreId: store.ID, StoreId: store.ID,
AuthorizationModelId: store.ModelID, AuthorizationModelId: store.ModelID,
TupleKey: &openfgav1.CheckRequestTupleKey{ TupleKey: &openfgav1.CheckRequestTupleKey{
User: subject, User: subject,
Relation: relation, Relation: common.FolderResourceRelation(relation),
Object: common.NewResourceIdent(group, resource, name), Object: common.NewFolderIdent(folder),
},
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"requested_group": groupResource,
},
}, },
Context: resourceCtx,
}) })
if err != nil { if err != nil {
@ -114,28 +119,24 @@ func (s *Server) checkGeneric(ctx context.Context, subject, relation, group, res
} }
if res.GetAllowed() { 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 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{ res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
StoreId: store.ID, StoreId: store.ID,
AuthorizationModelId: store.ModelID, AuthorizationModelId: store.ModelID,
TupleKey: &openfgav1.CheckRequestTupleKey{ TupleKey: &openfgav1.CheckRequestTupleKey{
User: subject, User: subject,
Relation: common.FolderResourceRelation(relation), Relation: relation,
Object: common.NewFolderIdent(folder), Object: common.NewResourceIdent(group, resource, name),
},
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"requested_group": groupResource,
},
}, },
Context: resourceCtx,
}) })
if err != nil { if err != nil {

@ -35,7 +35,7 @@ func testCheck(t *testing.T, server *Server) {
assert.False(t, res.GetAllowed()) 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")) res, err := server.Check(context.Background(), newReq("user:2", utils.VerbGet, dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err) require.NoError(t, err)
assert.True(t, res.GetAllowed()) assert.True(t, res.GetAllowed())
@ -83,7 +83,7 @@ func testCheck(t *testing.T, server *Server) {
assert.True(t, res.GetAllowed()) 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")) res, err := server.Check(context.Background(), newReq("user:7", utils.VerbGet, folderGroup, folderResource, "", "1"))
require.NoError(t, err) require.NoError(t, err)
assert.True(t, res.GetAllowed()) assert.True(t, res.GetAllowed())

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

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

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

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

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

@ -188,7 +188,7 @@ cloudwatchMetricInsightsCrossAccount,GA,@grafana/aws-datasources,false,false,tru
prometheusAzureOverrideAudience,deprecated,@grafana/partner-datasources,false,false,false prometheusAzureOverrideAudience,deprecated,@grafana/partner-datasources,false,false,false
alertingFilterV2,experimental,@grafana/alerting-squad,false,false,false alertingFilterV2,experimental,@grafana/alerting-squad,false,false,false
dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,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 lokiSendDashboardPanelNames,experimental,@grafana/observability-logs,false,false,false
alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true
singleTopNav,GA,@grafana/grafana-frontend-platform,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": { "metadata": {
"name": "newDashboardSharingComponent", "name": "newDashboardSharingComponent",
"resourceVersion": "1726241874335", "resourceVersion": "1733231733564",
"creationTimestamp": "2024-05-03T15:02:18Z", "creationTimestamp": "2024-05-03T15:02:18Z",
"annotations": { "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": { "spec": {
@ -2338,7 +2338,7 @@
"stage": "GA", "stage": "GA",
"codeowner": "@grafana/sharing-squad", "codeowner": "@grafana/sharing-squad",
"frontend": true, "frontend": true,
"expression": "false" "expression": "true"
} }
}, },
{ {
@ -2358,13 +2358,17 @@
{ {
"metadata": { "metadata": {
"name": "newFiltersUI", "name": "newFiltersUI",
"resourceVersion": "1724228641625", "resourceVersion": "1733391902652",
"creationTimestamp": "2024-08-30T12:48:13Z" "creationTimestamp": "2024-08-30T12:48:13Z",
"annotations": {
"grafana.app/updatedTimestamp": "2024-12-05 09:45:02.652078 +0000 UTC"
}
}, },
"spec": { "spec": {
"description": "Enables new combobox style UI for the Ad hoc filters variable in scenes architecture", "description": "Enables new combobox style UI for the Ad hoc filters variable in scenes architecture",
"stage": "experimental", "stage": "GA",
"codeowner": "@grafana/dashboards-squad" "codeowner": "@grafana/dashboards-squad",
"expression": "true"
} }
}, },
{ {

@ -110,7 +110,8 @@ func sqLite3TestDB() (*TestDB, error) {
ret.ConnStr = "file:" + sqliteDb + "?cache=private&mode=rwc" ret.ConnStr = "file:" + sqliteDb + "?cache=private&mode=rwc"
if os.Getenv("SQLITE_JOURNAL_MODE") != "false" { 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 ret.Path = sqliteDb

@ -8,7 +8,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
"github.com/hashicorp/golang-lru/v2/expirable" "github.com/hashicorp/golang-lru/v2/expirable"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace" "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 // init is called during startup. any failure will block startup and continued execution
func (s *searchSupport) init(ctx context.Context) error { 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() defer span.End()
start := time.Now().Unix() start := time.Now().Unix()
@ -214,6 +213,7 @@ func (s *searchSupport) init(ctx context.Context) error {
}() }()
end := time.Now().Unix() end := time.Now().Unix()
s.log.Info("search index initialized", "duration_secs", end-start, "total_docs", s.search.TotalDocs())
if IndexMetrics != nil { if IndexMetrics != nil {
IndexMetrics.IndexCreationTime.WithLabelValues().Observe(float64(end - start)) 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 // record latency from when event was created to when it was indexed
latencySeconds := float64(time.Now().UnixMicro()-evt.ResourceVersion) / 1e6 latencySeconds := float64(time.Now().UnixMicro()-evt.ResourceVersion) / 1e6
if latencySeconds > 5 { if latencySeconds > 5 {
logger.Warn("high index latency", "latency", latencySeconds) s.log.Warn("high index latency", "latency", latencySeconds)
} }
if IndexMetrics != nil { if IndexMetrics != nil {
IndexMetrics.IndexLatency.WithLabelValues(evt.Key.Resource).Observe(latencySeconds) 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) { 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() defer span.End()
builder, err := s.builders.get(ctx, nsr) 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 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 // initialize the search index
if s.initErr == nil && s.search != nil { if s.initErr == nil && s.search != nil {
s.initErr = s.search.init(ctx) s.initErr = s.search.init(ctx)
} }
// Start watching for changes
if s.initErr == nil {
s.initErr = s.initWatcher()
}
if s.initErr != nil { if s.initErr != nil {
s.log.Error("error initializing resource server", "error", s.initErr) 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") ctx, span := s.tracer.Start(ctx, "storage_server.Create")
defer span.End() defer span.End()
if err := s.Init(ctx); err != nil {
return nil, err
}
rsp := &CreateResponse{} rsp := &CreateResponse{}
user, ok := claims.From(ctx) user, ok := claims.From(ctx)
if !ok || user == nil { 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") ctx, span := s.tracer.Start(ctx, "storage_server.Update")
defer span.End() defer span.End()
if err := s.Init(ctx); err != nil {
return nil, err
}
rsp := &UpdateResponse{} rsp := &UpdateResponse{}
user, ok := claims.From(ctx) user, ok := claims.From(ctx)
if !ok || user == nil { 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") ctx, span := s.tracer.Start(ctx, "storage_server.Delete")
defer span.End() defer span.End()
if err := s.Init(ctx); err != nil {
return nil, err
}
rsp := &DeleteResponse{} rsp := &DeleteResponse{}
if req.ResourceVersion < 0 { if req.ResourceVersion < 0 {
return nil, apierrors.NewBadRequest("update must include the previous version") 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) { 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) user, ok := claims.From(ctx)
if !ok || user == nil { if !ok || user == nil {
return &ReadResponse{ return &ReadResponse{
@ -693,9 +684,6 @@ func (s *server) List(ctx context.Context, req *ListRequest) (*ListResponse, err
}}, nil }}, nil
} }
if err := s.Init(ctx); err != nil {
return nil, err
}
if req.Limit < 1 { if req.Limit < 1 {
req.Limit = 50 // default max 50 items in a page 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 { func (s *server) Watch(req *WatchRequest, srv ResourceStore_WatchServer) error {
ctx := srv.Context() ctx := srv.Context()
if err := s.Init(ctx); err != nil {
return err
}
user, ok := claims.From(ctx) user, ok := claims.From(ctx)
if !ok || user == nil { if !ok || user == nil {
return apierrors.NewUnauthorized("no user found in context") 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) { 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 { if s.search == nil {
return nil, fmt.Errorf("search index not configured") 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. // History implements ResourceServer.
func (s *server) History(ctx context.Context, req *HistoryRequest) (*HistoryResponse, error) { 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) return s.search.History(ctx, req)
} }
// Origin implements ResourceServer. // Origin implements ResourceServer.
func (s *server) Origin(ctx context.Context, req *OriginRequest) (*OriginResponse, error) { 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) return s.search.Origin(ctx, req)
} }
// IsHealthy implements ResourceServer. // IsHealthy implements ResourceServer.
func (s *server) IsHealthy(ctx context.Context, req *HealthCheckRequest) (*HealthCheckResponse, error) { 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) return s.diagnostics.IsHealthy(ctx, req)
} }
@ -971,9 +943,6 @@ func (s *server) PutBlob(ctx context.Context, req *PutBlobRequest) (*PutBlobResp
Code: http.StatusNotImplemented, Code: http.StatusNotImplemented,
}}, nil }}, nil
} }
if err := s.Init(ctx); err != nil {
return nil, err
}
rsp, err := s.blob.PutResourceBlob(ctx, req) rsp, err := s.blob.PutResourceBlob(ctx, req)
if err != nil { if err != nil {
@ -1016,10 +985,6 @@ func (s *server) GetBlob(ctx context.Context, req *GetBlobRequest) (*GetBlobResp
}}, nil }}, nil
} }
if err := s.Init(ctx); err != nil {
return nil, err
}
// The linked blob is stored in the resource metadata attributes // The linked blob is stored in the resource metadata attributes
obj, status := s.getPartialObject(ctx, req.Resource, req.ResourceVersion) obj, status := s.getPartialObject(ctx, req.Resource, req.ResourceVersion)
if status != nil { if status != nil {

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

@ -123,7 +123,7 @@ func (b *backend) Stop(_ context.Context) error {
// GetResourceStats implements Backend. // GetResourceStats implements Backend.
func (b *backend) GetResourceStats(ctx context.Context, namespace string, minCount int) ([]resource.ResourceStats, error) { 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() defer span.End()
req := &sqlStatsRequest{ 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) require.NoError(t, err)
_, err = logSection.NewKey("query_retries", fmt.Sprintf("%d", queryRetries)) _, err = dbSection.NewKey("max_idle_conn", "2")
require.NoError(t, err) require.NoError(t, err)
cfgPath := filepath.Join(cfgDir, "test.ini") cfgPath := filepath.Join(cfgDir, "test.ini")

@ -121,8 +121,8 @@ func writeErrorResponse(rw http.ResponseWriter, code int, msg string) {
errorBody := map[string]string{ errorBody := map[string]string{
"error": msg, "error": msg,
} }
json, _ := json.Marshal(errorBody) jsonRes, _ := json.Marshal(errorBody)
_, err := rw.Write(json) _, err := rw.Write(jsonRes)
if err != nil { if err != nil {
backend.Logger.Error("Unable to write HTTP response", "error", err) 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) { func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
err := e.Ping() err := e.Ping()
if err != nil { if err != nil {
logCheckHealthError(ctx, e.dsInfo, err, e.log) logCheckHealthError(ctx, e.dsInfo, err)
if strings.EqualFold(req.PluginContext.User.Role, "Admin") { if strings.EqualFold(req.PluginContext.User.Role, "Admin") {
return ErrToHealthCheckResult(err) return ErrToHealthCheckResult(err)
} }
@ -73,7 +73,8 @@ func ErrToHealthCheckResult(err error) (*backend.CheckHealthResult, error) {
return res, nil 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{ configSummary := map[string]any{
"config_url_length": len(dsInfo.URL), "config_url_length": len(dsInfo.URL),
"config_user_length": len(dsInfo.User), "config_user_length": len(dsInfo.User),
@ -104,8 +105,8 @@ func logCheckHealthError(_ context.Context, dsInfo DataSourceInfo, err error, lo
} }
configSummaryJson, marshalError := json.Marshal(configSummary) configSummaryJson, marshalError := json.Marshal(configSummary)
if marshalError != nil { 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 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 + ";" krb5DriverParams += "krb5-dnslookupkdc=" + kerberosAuth.EnableDNSLookupKDC + ";"
} }
logger.Info(fmt.Sprintf("final krb connstr: %s", krb5DriverParams))
return 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") 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) { func (e *DataSourceHandler) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
result := backend.NewQueryDataResponse() result := backend.NewQueryDataResponse()
ch := make(chan DBDataResponse, len(req.Queries)) 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) { func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
err := e.db.Ping() err := e.db.Ping()
if err != nil { if err != nil {
logCheckHealthError(ctx, e.dsInfo, err, e.log) logCheckHealthError(ctx, e.dsInfo, err)
if strings.EqualFold(req.PluginContext.User.Role, "Admin") { if strings.EqualFold(req.PluginContext.User.Role, "Admin") {
return ErrToHealthCheckResult(err) return ErrToHealthCheckResult(err)
} }
@ -63,7 +63,8 @@ func ErrToHealthCheckResult(err error) (*backend.CheckHealthResult, error) {
return res, nil 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{ configSummary := map[string]any{
"config_url_length": len(dsInfo.URL), "config_url_length": len(dsInfo.URL),
"config_user_length": len(dsInfo.User), "config_user_length": len(dsInfo.User),
@ -94,8 +95,8 @@ func logCheckHealthError(_ context.Context, dsInfo DataSourceInfo, err error, lo
} }
configSummaryJson, marshalError := json.Marshal(configSummary) configSummaryJson, marshalError := json.Marshal(configSummary)
if marshalError != nil { 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 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": { "/teams/{teamId}/groups": {
"get": { "get": {
"tags": [ "tags": [

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

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

@ -1,6 +1,7 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { DataQuery, DataQueryRequest, DataQueryResponse, TestDataSourceResponse } from '@grafana/data'; import { DataQuery, DataQueryRequest, DataQueryResponse, TestDataSourceResponse } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { RuntimeDataSource, sceneUtils } from '@grafana/scenes'; import { RuntimeDataSource, sceneUtils } from '@grafana/scenes';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { dispatch } from 'app/store/store'; import { dispatch } from 'app/store/store';
@ -9,7 +10,7 @@ import { stateHistoryApi } from '../../../api/stateHistoryApi';
import { DataSourceInformation } from '../../../home/Insights'; import { DataSourceInformation } from '../../../home/Insights';
import { LIMIT_EVENTS } from './EventListSceneObject'; import { LIMIT_EVENTS } from './EventListSceneObject';
import { getStateFilterFromInQueryParams, getStateFilterToInQueryParams, historyResultToDataFrame } from './utils'; import { historyResultToDataFrame } from './utils';
const historyDataSourceUid = '__history_api_ds_uid__'; const historyDataSourceUid = '__history_api_ds_uid__';
const historyDataSourcePluginId = '__history_api_ds_pluginId__'; const historyDataSourcePluginId = '__history_api_ds_pluginId__';
@ -31,6 +32,12 @@ export function useRegisterHistoryRuntimeDataSource() {
}, [ds]); }, [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. * 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. * 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. * 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. * 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) { constructor(pluginId: string, uid: string) {
super(uid, pluginId); super(uid, pluginId);
} }
async query(request: DataQueryRequest<DataQuery>): Promise<DataQueryResponse> { async query(request: DataQueryRequest<HistoryAPIQuery>): Promise<DataQueryResponse> {
const from = request.range.from.unix(); const from = request.range.from.unix();
const to = request.range.to.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 // we get the labels, stateTo and stateFrom from the query variables
const stateTo = getStateFilterToInQueryParams(); const labels = templateSrv.replace(query.labels ?? '', request.scopedVars);
const stateFrom = getStateFilterFromInQueryParams(); const stateTo = templateSrv.replace(query.stateTo ?? '', request.scopedVars);
const stateFrom = templateSrv.replace(query.stateFrom ?? '', request.scopedVars);
const historyResult = await getHistory(from, to); const historyResult = await getHistory(from, to);
return { return {
data: historyResultToDataFrame(historyResult, { stateTo, stateFrom }), data: historyResultToDataFrame(historyResult, { stateTo, stateFrom, labels }),
}; };
} }

@ -9,6 +9,7 @@ import {
SceneComponentProps, SceneComponentProps,
SceneObjectBase, SceneObjectBase,
TextBoxVariable, TextBoxVariable,
VariableDependencyConfig,
VariableValue, VariableValue,
sceneGraph, sceneGraph,
} from '@grafana/scenes'; } from '@grafana/scenes';
@ -495,48 +496,54 @@ export const getStyles = (theme: GrafanaTheme2) => {
export class HistoryEventsListObject extends SceneObjectBase { export class HistoryEventsListObject extends SceneObjectBase {
public static Component = HistoryEventsListObjectRenderer; 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 type FilterType = 'label' | 'stateFrom' | 'stateTo';
export function HistoryEventsListObjectRenderer({ model }: SceneComponentProps<HistoryEventsListObject>) { 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 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 labelsFiltersVariable = sceneGraph.lookupVariable(LABELS_FILTER, model);
const valueInStateToFilter = stateToFilterVariable.getValue(); const stateToFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_TO, model);
const valueInStateFromFilter = stateFromFilterVariable.getValue(); const stateFromFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_FROM, model);
const addFilter = (key: string, value: string, type: FilterType) => { const addFilter = (key: string, value: string, type: FilterType) => {
const newFilterToAdd = `${key}=${value}`; const newFilterToAdd = `${key}=${value}`;
trackUseCentralHistoryFilterByClicking({ type, key, value }); trackUseCentralHistoryFilterByClicking({ type, key, value });
if (type === 'stateTo') { if (type === 'stateTo' && stateToFilterVariable instanceof CustomVariable) {
stateToFilterVariable.changeValueTo(value); stateToFilterVariable.changeValueTo(value);
} }
if (type === 'stateFrom') { if (type === 'stateFrom' && stateFromFilterVariable instanceof CustomVariable) {
stateFromFilterVariable.changeValueTo(value); stateFromFilterVariable.changeValueTo(value);
} }
const finalFilter = combineMatcherStrings(valueInfilterTextBox.toString(), newFilterToAdd); if (type === 'label' && labelsFiltersVariable instanceof TextBoxVariable) {
if (type === 'label') { const finalFilter = combineMatcherStrings(labelsFiltersVariable.state.value.toString(), newFilterToAdd);
labelsFiltersVariable.setValue(finalFilter); labelsFiltersVariable.setValue(finalFilter);
} }
}; };
return ( if (
<HistoryEventsList stateToFilterVariable instanceof CustomVariable &&
timeRange={timeRange} stateFromFilterVariable instanceof CustomVariable &&
valueInLabelFilter={valueInfilterTextBox} labelsFiltersVariable instanceof TextBoxVariable
addFilter={addFilter} ) {
valueInStateToFilter={valueInStateToFilter} return (
valueInStateFromFilter={valueInStateFromFilter} <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": [ "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(); expect(historyResultToDataFrame(fixtureData)).toMatchSnapshot();
}); });
it('should decode and filter', () => { it('should decode and filter example1', () => {
expect(historyResultToDataFrame(fixtureData, { stateFrom: 'Pending', stateTo: 'Alerting' })).toMatchSnapshot(); 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 GROUPING_INTERVAL = 10 * 1000; // 10 seconds
const QUERY_PARAM_PREFIX = 'var-'; // Prefix used by Grafana to sync variables in the URL 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', stateTo: 'all',
stateFrom: 'all', stateFrom: 'all',
labels: '',
}; };
/* /*
@ -75,7 +82,7 @@ export function historyResultToDataFrame({ data }: DataFrameJSON, filters = empt
}); });
// Group DataFrames by time and filter by labels // 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. // 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. * This function groups the data frames by time and filters them by labels.
* The interval is set to 10 seconds. * 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. // 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 dataframesFiltered = dataFrames.filter((frame) => {
const labels = JSON.parse(frame.name ?? ''); // in name we store the labels stringified 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 { SceneObjectState, SceneObjectBase, SceneObject, SceneObjectRef } from '@grafana/scenes';
import { ToolbarButton, useStyles2 } from '@grafana/ui'; import { ToolbarButton, useStyles2 } from '@grafana/ui';
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types';
import { getDashboardSceneFor } from '../utils/utils'; import { getDashboardSceneFor } from '../utils/utils';
import { ElementEditPane } from './ElementEditPane'; import { ElementEditPane } from './ElementEditPane';
@ -13,7 +14,17 @@ export interface DashboardEditPaneState extends SceneObjectState {
selectedObject?: SceneObjectRef<SceneObject>; 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 { export interface Props {
editPane: DashboardEditPane; editPane: DashboardEditPane;
@ -57,13 +68,29 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla
); );
} }
const element = getEditableElementFor(selectedObject.resolve());
return ( return (
<div className={styles.wrapper} ref={paneRef}> <div className={styles.wrapper} ref={paneRef}>
<ElementEditPane obj={selectedObject.resolve()} /> <ElementEditPane element={element} key={element.getTypeName()} />
</div> </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) { function getStyles(theme: GrafanaTheme2) {
return { return {
wrapper: css({ wrapper: css({

@ -1,19 +1,23 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { SceneObjectBase } from '@grafana/scenes';
import { Input, TextArea } from '@grafana/ui'; import { Input, TextArea } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { DashboardScene } from '../scene/DashboardScene'; import { DashboardScene } from '../scene/DashboardScene';
import { useLayoutCategory } from '../scene/layouts-shared/DashboardLayoutSelector';
import { EditableDashboardElement } from '../scene/types'; 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; public isEditableDashboardElement: true = true;
constructor(private dashboard: DashboardScene) {}
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { 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(() => { const dashboardOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({ return new OptionsPaneCategoryDescriptor({
@ -39,7 +43,9 @@ export class DummySelectedObject implements EditableDashboardElement {
); );
}, [dashboard]); }, [dashboard]);
return [dashboardOptions]; const layoutCategory = useLayoutCategory(body);
return [dashboardOptions, layoutCategory];
} }
public getTypeName(): string { public getTypeName(): string {

@ -1,21 +1,16 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { SceneObject } from '@grafana/scenes';
import { Stack, useStyles2 } from '@grafana/ui'; import { Stack, useStyles2 } from '@grafana/ui';
import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory'; import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory';
import { DashboardScene } from '../scene/DashboardScene'; import { EditableDashboardElement } from '../scene/types';
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types';
import { DummySelectedObject } from './DummySelectedObject';
export interface Props { export interface Props {
obj: SceneObject; element: EditableDashboardElement;
} }
export function ElementEditPane({ obj }: Props) { export function ElementEditPane({ element }: Props) {
const element = getEditableElementFor(obj);
const categories = element.useEditPaneOptions(); const categories = element.useEditPaneOptions();
const styles = useStyles2(getStyles); 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) { function getStyles(theme: GrafanaTheme2) {
return { return {
noBorderTop: css({ 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, value: panel.state.title,
popularRank: 1, popularRank: 1,
render: function renderTitle() { render: function renderTitle() {
return <PanelFrameTitle panel={panel} />; return <PanelFrameTitleInput panel={panel} />;
}, },
addon: config.featureToggles.dashgpt && ( addon: config.featureToggles.dashgpt && (
<GenAIPanelTitleButton <GenAIPanelTitleButton
@ -50,7 +50,7 @@ export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescri
title: 'Description', title: 'Description',
value: panel.state.description, value: panel.state.description,
render: function renderDescription() { render: function renderDescription() {
return <DescriptionTextArea panel={panel} />; return <PanelDescriptionTextArea panel={panel} />;
}, },
addon: config.featureToggles.dashgpt && ( addon: config.featureToggles.dashgpt && (
<GenAIPanelDescriptionButton <GenAIPanelDescriptionButton
@ -64,17 +64,7 @@ export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescri
new OptionsPaneItemDescriptor({ new OptionsPaneItemDescriptor({
title: 'Transparent background', title: 'Transparent background',
render: function renderTransparent() { render: function renderTransparent() {
return ( return <PanelBackgroundSwitch panel={panel} />;
<Switch
value={panel.state.displayMode === 'transparent'}
id="transparent-background"
onChange={() => {
panel.setState({
displayMode: panel.state.displayMode === 'transparent' ? 'default' : 'transparent',
});
}}
/>
);
}, },
}) })
) )
@ -116,7 +106,7 @@ function ScenePanelLinksEditor({ panelLinks }: ScenePanelLinksEditorProps) {
); );
} }
function PanelFrameTitle({ panel }: { panel: VizPanel }) { export function PanelFrameTitleInput({ panel }: { panel: VizPanel }) {
const { title } = panel.useState(); const { title } = panel.useState();
return ( 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(); const { description } = panel.useState();
return ( 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) { function setPanelTitle(panel: VizPanel, title: string) {
panel.setState({ title: title, hoverHeader: getUpdatedHoverHeader(title, panel.state.$timeRange) }); panel.setState({ title: title, hoverHeader: getUpdatedHoverHeader(title, panel.state.$timeRange) });
} }

@ -60,6 +60,7 @@ export function ToolbarActions({ dashboard }: Props) {
const canSaveAs = contextSrv.hasEditPermissionInFolders; const canSaveAs = contextSrv.hasEditPermissionInFolders;
const toolbarActions: ToolbarAction[] = []; const toolbarActions: ToolbarAction[] = [];
const leftActions: ToolbarAction[] = [];
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const isEditingPanel = Boolean(editPanel); const isEditingPanel = Boolean(editPanel);
const isViewingPanel = Boolean(viewPanelScene); 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 // Means we are not in settings view, fullscreen panel or edit panel
const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel; const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel;
const isEditingAndShowingDashboard = isEditing && isShowingDashboard; 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) { if (!isEditingPanel) {
// This adds the precence indicators in enterprise // This adds the precence indicators in enterprise
@ -151,74 +153,135 @@ export function ToolbarActions({ dashboard }: Props) {
addDynamicActions(toolbarActions, dynamicDashNavActions.right, 'icon-actions'); addDynamicActions(toolbarActions, dynamicDashNavActions.right, 'icon-actions');
} }
toolbarActions.push({ if (dashboardNewLayouts) {
group: 'add-panel', leftActions.push({
condition: isEditingAndShowingDashboard, group: 'add-panel',
render: () => ( condition: isEditingAndShowingDashboard,
<Dropdown render: () => (
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 <Button
key="add-panel-button" key="add-panel-button"
variant="primary" variant="secondary"
size="sm" size="sm"
fill="outline" icon="plus"
data-testid={selectors.components.PageToolbar.itemButton('Add button')} fill="text"
onClick={() => {
dashboard.onCreateNewPanel();
}}
data-testid={selectors.components.PageToolbar.itemButton('add_visualization')}
> >
<Trans i18nKey="dashboard.toolbar.add">Add</Trans> Panel
<Icon name={isAddPanelMenuOpen ? 'angle-up' : 'angle-down'} size="lg" />
</Button> </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({ toolbarActions.push({
group: 'playlist-actions', 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[] = []; const actionElements: React.ReactNode[] = [];
let lastGroup = ''; let lastGroup = '';
@ -610,13 +687,7 @@ export function ToolbarActions({ dashboard }: Props) {
actionElements.push(action.render()); actionElements.push(action.render());
lastGroup = action.group; lastGroup = action.group;
} }
return actionElements;
return (
<Stack flex={1} minWidth={0} justifyContent={showScopesSelector ? 'space-between' : 'flex-end'}>
{showScopesSelector && <ScopesSelector />}
<ToolbarButtonRow alignment="right">{actionElements}</ToolbarButtonRow>
</Stack>
);
} }
function addDynamicActions( function addDynamicActions(

@ -9,23 +9,18 @@ import {
sceneUtils, sceneUtils,
SceneComponentProps, SceneComponentProps,
} from '@grafana/scenes'; } from '@grafana/scenes';
import { Button } from '@grafana/ui';
import { GRID_COLUMN_COUNT } from 'app/core/constants'; import { GRID_COLUMN_COUNT } from 'app/core/constants';
import { Trans } from 'app/core/internationalization';
import { DashboardInteractions } from '../../utils/interactions';
import { import {
forceRenderChildren, forceRenderChildren,
getPanelIdForVizPanel, getPanelIdForVizPanel,
NEW_PANEL_HEIGHT, NEW_PANEL_HEIGHT,
NEW_PANEL_WIDTH, NEW_PANEL_WIDTH,
getVizPanelKeyForPanelId, getVizPanelKeyForPanelId,
getDefaultVizPanel,
} from '../../utils/utils'; } from '../../utils/utils';
import { RowRepeaterBehavior } from '../RowRepeaterBehavior'; import { RowRepeaterBehavior } from '../RowRepeaterBehavior';
import { LayoutEditChrome } from '../layouts-shared/LayoutEditChrome';
import { RowActions } from '../row-actions/RowActions'; import { RowActions } from '../row-actions/RowActions';
import { DashboardLayoutManager, LayoutEditorProps, LayoutRegistryItem } from '../types'; import { DashboardLayoutManager, LayoutRegistryItem } from '../types';
import { DashboardGridItem } from './DashboardGridItem'; import { DashboardGridItem } from './DashboardGridItem';
@ -40,6 +35,8 @@ export class DefaultGridLayoutManager
extends SceneObjectBase<DefaultGridLayoutManagerState> extends SceneObjectBase<DefaultGridLayoutManagerState>
implements DashboardLayoutManager implements DashboardLayoutManager
{ {
public isDashboardLayoutManager: true = true;
public editModeChanged(isEditing: boolean): void { public editModeChanged(isEditing: boolean): void {
const updateResizeAndDragging = () => { const updateResizeAndDragging = () => {
this.state.grid.setState({ isDraggable: isEditing, isResizable: isEditing }); 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>) => { public static Component = ({ model }: SceneComponentProps<DefaultGridLayoutManager>) => {
if (!config.featureToggles.dashboardNewLayouts) { return <model.state.grid.Component model={model.state.grid} />;
return <model.state.grid.Component model={model.state.grid} />;
}
return (
<LayoutEditChrome layoutManager={model}>
<model.state.grid.Component model={model.state.grid} />
</LayoutEditChrome>
);
}; };
} }
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 { 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 { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; 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>) => { public static Component = ({ model }: SceneComponentProps<ResponsiveGridItem>) => {
const { body } = model.useState(); 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 { SelectableValue } from '@grafana/data';
import { SceneComponentProps, SceneCSSGridLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; import { SceneComponentProps, SceneCSSGridLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { Button, Field, Select } from '@grafana/ui'; import { Select } from '@grafana/ui';
import { Trans } from 'app/core/internationalization'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { DashboardInteractions } from '../../utils/interactions'; import { getPanelIdForVizPanel, getVizPanelKeyForPanelId } from '../../utils/utils';
import { getDefaultVizPanel, getPanelIdForVizPanel, getVizPanelKeyForPanelId } from '../../utils/utils'; import { DashboardLayoutManager, LayoutRegistryItem } from '../types';
import { LayoutEditChrome } from '../layouts-shared/LayoutEditChrome';
import { DashboardLayoutManager, LayoutRegistryItem, LayoutEditorProps } from '../types';
import { ResponsiveGridItem } from './ResponsiveGridItem'; import { ResponsiveGridItem } from './ResponsiveGridItem';
@ -18,6 +16,8 @@ export class ResponsiveGridLayoutManager
extends SceneObjectBase<ResponsiveGridLayoutManagerState> extends SceneObjectBase<ResponsiveGridLayoutManagerState>
implements DashboardLayoutManager implements DashboardLayoutManager
{ {
public isDashboardLayoutManager: true = true;
public editModeChanged(isEditing: boolean): void {} public editModeChanged(isEditing: boolean): void {}
public addPanel(vizPanel: VizPanel): void { public addPanel(vizPanel: VizPanel): void {
@ -72,8 +72,8 @@ export class ResponsiveGridLayoutManager
return panels; return panels;
} }
public renderEditor() { public getOptions(): OptionsPaneItemDescriptor[] {
return <AutomaticGridEditor layoutManager={this} />; return getOptions(this);
} }
public getDescriptor(): LayoutRegistryItem { public getDescriptor(): LayoutRegistryItem {
@ -90,7 +90,13 @@ export class ResponsiveGridLayoutManager
} }
public static createEmpty() { 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 { 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>) => { public static Component = ({ model }: SceneComponentProps<ResponsiveGridLayoutManager>) => {
return ( return <model.state.layout.Component model={model.state.layout} />;
<LayoutEditChrome layoutManager={model}>
<model.state.layout.Component model={model.state.layout} />
</LayoutEditChrome>
);
}; };
} }
function AutomaticGridEditor({ layoutManager }: LayoutEditorProps<ResponsiveGridLayoutManager>) { function getOptions(layoutManager: ResponsiveGridLayoutManager): OptionsPaneItemDescriptor[] {
const options: OptionsPaneItemDescriptor[] = [];
const cssLayout = layoutManager.state.layout; const cssLayout = layoutManager.state.layout;
const { templateColumns, autoRows } = cssLayout.useState();
const rowOptions: Array<SelectableValue<string>> = []; const rowOptions: Array<SelectableValue<string>> = [];
const sizes = [100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 650]; 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` }); rowOptions.push({ label: `Fixed: ${size}px`, value: `${size}px` });
} }
const onColumnsChange = (value: SelectableValue<string>) => { options.push(
cssLayout.setState({ templateColumns: value.value }); new OptionsPaneItemDescriptor({
}; title: 'Columns',
render: () => {
const onRowsChange = (value: SelectableValue<string>) => { const { templateColumns } = cssLayout.useState();
cssLayout.setState({ autoRows: value.value }); return (
}; <Select
options={colOptions}
value={String(templateColumns)}
onChange={(value) => {
cssLayout.setState({ templateColumns: value.value });
}}
allowCustomValue={true}
/>
);
},
})
);
return ( options.push(
<> new OptionsPaneItemDescriptor({
<Field label="Columns"> title: 'Rows',
<Select render: () => {
options={colOptions} const { autoRows } = cssLayout.useState();
value={String(templateColumns)} return (
onChange={onColumnsChange} <Select
allowCustomValue={true} options={rowOptions}
/> value={String(autoRows)}
</Field> onChange={(value) => {
cssLayout.setState({ autoRows: value.value });
<Field label="Row height"> }}
<Select options={rowOptions} value={String(autoRows)} onChange={onRowsChange} /> allowCustomValue={true}
</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>
</>
); );
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 { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager'; import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { LayoutRegistryItem } from '../types'; import { LayoutRegistryItem } from '../types';
export const layoutRegistry: Registry<LayoutRegistryItem> = new Registry<LayoutRegistryItem>(() => { 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 { BusEventWithPayload, RegistryItem } from '@grafana/data';
import { SceneObject, VizPanel } from '@grafana/scenes'; import { SceneObject, VizPanel } from '@grafana/scenes';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; 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 * A scene object that usually wraps an underlying layout
* Dealing with all the state management and editing of the layout * Dealing with all the state management and editing of the layout
*/ */
export interface DashboardLayoutManager extends SceneObject { export interface DashboardLayoutManager extends SceneObject {
/** Marks it as a DashboardLayoutManager */
isDashboardLayoutManager: true;
/** /**
* Notify the layout manager that the edit mode has changed * Notify the layout manager that the edit mode has changed
* @param isEditing * @param isEditing
*/ */
editModeChanged(isEditing: boolean): void; 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 * Remove an element / panel
* @param element * @param element
@ -54,7 +53,11 @@ export interface DashboardLayoutManager extends SceneObject {
/** /**
* Renders options and layout actions * 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; 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) * 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, SceneDataLayerProvider,
SceneDataLayerControls, SceneDataLayerControls,
UserActionEvent, UserActionEvent,
SceneObjectState,
} from '@grafana/scenes'; } from '@grafana/scenes';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { DashboardDTO, DashboardDataDTO } from 'app/types'; import { DashboardDTO, DashboardDataDTO } from 'app/types';
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior'; import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';
import { DashboardEditPaneBehavior } from '../edit-pane/DashboardEditPaneBehavior';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls'; 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({ const dashboardScene = new DashboardScene({
description: oldModel.description, description: oldModel.description,
editable: oldModel.editable, editable: oldModel.editable,
@ -242,28 +270,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
UNSAFE_nowDelay: oldModel.timepicker?.nowDelay, UNSAFE_nowDelay: oldModel.timepicker?.nowDelay,
}), }),
$variables: variables, $variables: variables,
$behaviors: [ $behaviors: behaviorList,
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,
}),
],
$data: new DashboardDataLayerSet({ annotationLayers, alertStatesLayer }), $data: new DashboardDataLayerSet({ annotationLayers, alertStatesLayer }),
controls: new DashboardControls({ controls: new DashboardControls({
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()], variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],

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

@ -46,7 +46,12 @@ export default function ShareButton({ dashboard, panel }: { dashboard: Dashboard
<Trans i18nKey="share-dashboard.share-button">Share</Trans> <Trans i18nKey="share-dashboard.share-button">Share</Trans>
</Button> </Button>
<Dropdown overlay={MenuActions} placement="bottom-end" onVisibleChange={onMenuClick}> <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> </Dropdown>
</ButtonGroup> </ButtonGroup>
); );

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

@ -18,6 +18,7 @@ import { DashboardScene } from '../scene/DashboardScene';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelMenuBehavior } from '../scene/PanelMenuBehavior'; import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types';
export const NEW_PANEL_HEIGHT = 8; export const NEW_PANEL_HEIGHT = 8;
export const NEW_PANEL_WIDTH = 12; export const NEW_PANEL_WIDTH = 12;
@ -220,6 +221,7 @@ export function getDefaultVizPanel(): VizPanel {
pluginId: 'timeseries', pluginId: 'timeseries',
titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })],
hoverHeaderOffset: 0, hoverHeaderOffset: 0,
$behaviors: [],
menu: new VizPanelMenu({ menu: new VizPanelMenu({
$behaviors: [panelMenuBehavior], $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 * Useful when rendering a scene object out of context of it's parent
*/ */
export const activateInActiveParents = activateSceneObjectAndParentTree; 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 * as React from 'react';
import { Box } from '@grafana/ui';
import { OptionsPaneCategory } from './OptionsPaneCategory'; import { OptionsPaneCategory } from './OptionsPaneCategory';
import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor'; import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor';
@ -57,6 +59,10 @@ export class OptionsPaneCategoryDescriptor {
return this.props.customRender(); return this.props.customRender();
} }
if (this.props.id === '') {
return <Box padding={2}>{this.items.map((item) => item.render(searchQuery))}</Box>;
}
return ( return (
<OptionsPaneCategory key={this.props.title} {...this.props}> <OptionsPaneCategory key={this.props.title} {...this.props}>
{this.items.map((item) => item.render(searchQuery))} {this.items.map((item) => item.render(searchQuery))}

@ -275,4 +275,26 @@ describe('Popover menu', () => {
expect(onClickFilterOutString).toHaveBeenCalledTimes(1); expect(onClickFilterOutString).toHaveBeenCalledTimes(1);
expect(onClickFilterString).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 => { handleSelection = (e: MouseEvent<HTMLTableRowElement>, row: LogRowModel): boolean => {
if (this.popoverMenuSupported() === false) {
return false;
}
const selection = document.getSelection()?.toString(); const selection = document.getSelection()?.toString();
if (!selection) { if (!selection) {
return false; 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) { if (!this.logRowsRef.current) {
return false; return false;
} }
@ -247,7 +249,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle} pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle}
pinned={this.props.pinnedRowId === row.uid || pinnedLogs?.some((logId) => logId === row.rowId)} pinned={this.props.pinnedRowId === row.uid || pinnedLogs?.some((logId) => logId === row.rowId)}
isFilterLabelActive={this.props.isFilterLabelActive} isFilterLabelActive={this.props.isFilterLabelActive}
handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined} handleTextSelection={this.handleSelection}
{...rest} {...rest}
/> />
))} ))}
@ -270,7 +272,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle} pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle}
pinned={this.props.pinnedRowId === row.uid || pinnedLogs?.some((logId) => logId === row.rowId)} pinned={this.props.pinnedRowId === row.uid || pinnedLogs?.some((logId) => logId === row.rowId)}
isFilterLabelActive={this.props.isFilterLabelActive} isFilterLabelActive={this.props.isFilterLabelActive}
handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined} handleTextSelection={this.handleSelection}
{...rest} {...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 { import {
configWithManagedIdentityEnabled,
configWithManagedIdentityDisabled,
dataSourceSettingsWithMsiCredentials, dataSourceSettingsWithMsiCredentials,
dataSourceSettingsWithClientSecretOnServer, dataSourceSettingsWithClientSecretOnServer,
dataSourceSettingsWithClientSecretInSecureJSONData, dataSourceSettingsWithClientSecretInSecureJSONData,
} from './AzureAuth.testMocks'; } 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. // 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. // 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'; 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', () => { describe('AzureAuth', () => {
beforeEach(() => {
jest.resetModules();
});
describe('AzureCredentialsConfig', () => { describe('AzureCredentialsConfig', () => {
it('`getDefaultCredentials()` should return the correct credentials based on whether the managed identity is enabled', () => { it('`getDefaultCredentials()` should return the correct credentials based on whether the managed identity is enabled', () => {
const resultForManagedIdentityEnabled = getDefaultCredentials(true, AzureCloud.Public); jest.mocked(config).azure.managedIdentityEnabled = true;
const resultForManagedIdentityDisabled = getDefaultCredentials(false, AzureCloud.Public); const resultForManagedIdentityEnabled = getDefaultCredentials();
jest.mocked(config).azure.managedIdentityEnabled = false;
const resultForManagedIdentityDisabled = getDefaultCredentials();
expect(resultForManagedIdentityEnabled).toEqual({ authType: 'msi' }); expect(resultForManagedIdentityEnabled).toEqual({ authType: 'msi' });
expect(resultForManagedIdentityDisabled).toEqual({ authType: 'clientsecret', azureCloud: 'AzureCloud' }); 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()', () => { describe('getCredentials()', () => {
it('should return the correct managed identity credentials', () => { 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( const resultForManagedIdentityEnabled = getCredentials(
// @ts-ignore // @ts-ignore
dataSourceSettingsWithMsiCredentials, dataSourceSettingsWithMsiCredentials
configWithManagedIdentityEnabled
); );
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. // Default to basic client secret credentials.
jest.mocked(config).azure.managedIdentityEnabled = false;
const resultForManagedIdentityEnabledInJSONButDisabledInConfig = getCredentials( const resultForManagedIdentityEnabledInJSONButDisabledInConfig = getCredentials(
// @ts-ignore // @ts-ignore
dataSourceSettingsWithMsiCredentials, dataSourceSettingsWithMsiCredentials
configWithManagedIdentityDisabled
); );
expect(resultForManagedIdentityEnabledInJSONButDisabledInConfig).toEqual({ expect(resultForManagedIdentityEnabledInJSONButDisabledInConfig).toEqual({
authType: AzureAuthType.CLIENT_SECRET, authType: 'clientsecret',
azureCloud: 'AzureCloud', azureCloud: 'AzureCloud',
}); });
}); });
it('should return the correct client secret credentials', () => { it('should return the correct client secret credentials', () => {
const basicExpectedResult = { const basicExpectedResult = {
authType: AzureAuthType.CLIENT_SECRET, authType: 'clientsecret',
azureCloud: 'AzureCloud', azureCloud: 'AzureCloud',
tenantId: 'XXXX-tenant-id-XXXX', tenantId: 'XXXX-tenant-id-XXXX',
clientId: 'XXXX-client-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. // i.e. the client secret is stored on the server.
jest.mocked(config).azure.managedIdentityEnabled = false;
const resultForClientSecretCredentialsOnServer = getCredentials( const resultForClientSecretCredentialsOnServer = getCredentials(
// @ts-ignore // @ts-ignore
dataSourceSettingsWithClientSecretOnServer, dataSourceSettingsWithClientSecretOnServer
configWithManagedIdentityDisabled
); );
// Here we test the properties separately because the client secret is a symbol, // 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. // and since JS symobls are unique, we test via the `typeof` operator.
expect(resultForClientSecretCredentialsOnServer.authType).toEqual(AzureAuthType.CLIENT_SECRET); expect(resultForClientSecretCredentialsOnServer.authType).toEqual('clientsecret');
expect(resultForClientSecretCredentialsOnServer.azureCloud).toEqual('AzureCloud'); expect(
expect(resultForClientSecretCredentialsOnServer.tenantId).toEqual('XXXX-tenant-id-XXXX'); instanceOfAzureCredential<AzureClientSecretCredentials>(
expect(resultForClientSecretCredentialsOnServer.clientId).toEqual('XXXX-client-id-XXXX'); 'clientsecret',
expect(typeof resultForClientSecretCredentialsOnServer.clientSecret).toEqual('symbol'); 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. // i.e. the client secret is stored in the secureJson.
jest.mocked(config).azure.managedIdentityEnabled = false;
const resultForClientSecretCredentialsInSecureJSON = getCredentials( const resultForClientSecretCredentialsInSecureJSON = getCredentials(
// @ts-ignore // @ts-ignore
dataSourceSettingsWithClientSecretInSecureJSONData, dataSourceSettingsWithClientSecretInSecureJSONData
configWithManagedIdentityDisabled
); );
expect(resultForClientSecretCredentialsInSecureJSON).toEqual({ expect(resultForClientSecretCredentialsInSecureJSON).toEqual({
...basicExpectedResult, ...basicExpectedResult,
@ -97,66 +120,68 @@ describe('AzureAuth', () => {
describe('updateCredentials()', () => { describe('updateCredentials()', () => {
it('should update the credentials for managed service identity correctly', () => { it('should update the credentials for managed service identity correctly', () => {
// If `dataSourceSettings.authType === AzureAuthType.MSI` && `config.azure.managedIdentityEnabled === true`. // If `dataSourceSettings.authType === 'msi'` && `config.azure.managedIdentityEnabled === true`.
const resultForMsiCredentials = updateCredentials( jest.mocked(config).azure.managedIdentityEnabled = true;
const resultForMsiCredentials = updateDatasourceCredentials(
// @ts-ignore // @ts-ignore
dataSourceSettingsWithMsiCredentials, dataSourceSettingsWithMsiCredentials,
configWithManagedIdentityEnabled,
{ {
authType: AzureAuthType.MSI, authType: 'msi',
} }
); );
expect(resultForMsiCredentials).toEqual({ jsonData: { azureCredentials: { 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(() => expect(() =>
updateCredentials( updateDatasourceCredentials(
// @ts-ignore // @ts-ignore
dataSourceSettingsWithMsiCredentials, dataSourceSettingsWithMsiCredentials,
configWithManagedIdentityDisabled,
{ {
authType: AzureAuthType.MSI, authType: 'msi',
} }
) )
).toThrow('Managed Identity authentication is not enabled in Grafana config.'); ).toThrow('Managed Identity authentication is not enabled in Grafana config.');
}); });
it('should update the credentials for client secret correctly', () => { it('should update the credentials for client secret correctly', () => {
const basicClientSecretCredentials: AzureCredentialsType = { const basicClientSecretCredentials: AzureCredentials = {
authType: AzureAuthType.CLIENT_SECRET, authType: 'clientsecret',
azureCloud: 'AzureCloud', azureCloud: AzureCloud.Public,
tenantId: 'XXXX-tenant-id-XXXX', tenantId: 'XXXX-tenant-id-XXXX',
clientId: 'XXXX-client-id-XXXX', clientId: 'XXXX-client-id-XXXX',
}; };
// If `dataSourceSettings.authType === AzureAuthType.CLIENT_SECRET` && `secureJsonFields.azureClientSecret == true`. // If `dataSourceSettings.authType === 'clientsecret'` && `secureJsonFields.azureClientSecret == true`.
const resultForClientSecretCredentials1 = updateCredentials( jest.mocked(config).azure.managedIdentityEnabled = false;
const resultForClientSecretCredentials1 = updateDatasourceCredentials(
// @ts-ignore // @ts-ignore
dataSourceSettingsWithClientSecretOnServer, dataSourceSettingsWithClientSecretOnServer,
configWithManagedIdentityDisabled,
basicClientSecretCredentials basicClientSecretCredentials
); );
expect(resultForClientSecretCredentials1).toEqual({
jsonData: { expect(resultForClientSecretCredentials1.jsonData.azureCredentials).toEqual(basicClientSecretCredentials);
azureCredentials: { ...basicClientSecretCredentials }, expect(resultForClientSecretCredentials1.secureJsonData).toEqual({ azureClientSecret: undefined });
}, expect(resultForClientSecretCredentials1.secureJsonFields).toEqual({
secureJsonData: { azureClientSecret: undefined }, azureClientSecret: false,
secureJsonFields: { azureClientSecret: false }, clientSecret: false,
}); });
// If `dataSourceSettings.authType === AzureAuthType.CLIENT_SECRET` && `secureJsonFields.azureClientSecret == false`. // If `dataSourceSettings.authType === 'clientsecret'` && `secureJsonFields.azureClientSecret == false`.
const resultForClientSecretCredentials2 = updateCredentials( jest.mocked(config).azure.managedIdentityEnabled = false;
const resultForClientSecretCredentials2 = updateDatasourceCredentials(
// @ts-ignore // @ts-ignore
dataSourceSettingsWithClientSecretInSecureJSONData, dataSourceSettingsWithClientSecretInSecureJSONData,
configWithManagedIdentityDisabled,
{ ...basicClientSecretCredentials, clientSecret: 'XXXX-super-secret-secret-XXXX' } { ...basicClientSecretCredentials, clientSecret: 'XXXX-super-secret-secret-XXXX' }
); );
expect(resultForClientSecretCredentials2).toEqual({
jsonData: { expect(resultForClientSecretCredentials2.jsonData.azureCredentials).toEqual(basicClientSecretCredentials);
azureCredentials: { ...basicClientSecretCredentials }, expect(resultForClientSecretCredentials2.secureJsonData).toEqual({
}, azureClientSecret: 'XXXX-super-secret-secret-XXXX',
secureJsonData: { azureClientSecret: 'XXXX-super-secret-secret-XXXX' }, });
secureJsonFields: { azureClientSecret: false }, 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 { GrafanaBootConfig } from '@grafana/runtime';
import { AzureAuthSecureJSONDataType, AzureAuthJSONDataType, AzureAuthType } from '../types';
export const configWithManagedIdentityEnabled: Partial<GrafanaBootConfig> = { export const configWithManagedIdentityEnabled: Partial<GrafanaBootConfig> = {
azure: { azure: {
managedIdentityEnabled: true, managedIdentityEnabled: true,
@ -24,31 +22,22 @@ export const configWithManagedIdentityDisabled: Partial<GrafanaBootConfig> = {
}, },
}; };
export const dataSourceSettingsWithMsiCredentials: Partial< export const dataSourceSettingsWithMsiCredentials: Partial<AzureDataSourceSettings> = {
DataSourceSettings<AzureAuthJSONDataType, AzureAuthSecureJSONDataType> jsonData: { azureCredentials: { authType: 'msi' } },
> = {
jsonData: { azureCredentials: { authType: AzureAuthType.MSI } },
}; };
const basicJSONData = { // Will return symbol as the secret is concealed
export const dataSourceSettingsWithClientSecretOnServer: Partial<AzureDataSourceSettings> = {
jsonData: { jsonData: {
azureCredentials: { azureCredentials: { authType: 'clientsecret', clientId: 'XXXX-client-id-XXXX', tenantId: 'XXXX-tenant-id-XXXX' },
authType: AzureAuthType.CLIENT_SECRET,
tenantId: 'XXXX-tenant-id-XXXX',
clientId: 'XXXX-client-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 // Will return the secret as a string from the secureJsonData
export const dataSourceSettingsWithClientSecretInSecureJSONData: Partial< export const dataSourceSettingsWithClientSecretInSecureJSONData: Partial<AzureDataSourceSettings> = {
DataSourceSettings<AzureAuthJSONDataType, AzureAuthSecureJSONDataType> jsonData: {
> = { azureCredentials: { authType: 'clientsecret', clientId: 'XXXX-client-id-XXXX', tenantId: 'XXXX-tenant-id-XXXX' },
...basicJSONData, },
secureJsonData: { azureClientSecret: 'XXXX-super-secret-secret-XXXX', password: undefined }, secureJsonFields: { azureClientSecret: false },
secureJsonData: { azureClientSecret: 'XXXX-super-secret-secret-XXXX' },
}; };

@ -1,24 +1,25 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useEffectOnce } from 'react-use'; import { useEffectOnce } from 'react-use';
import { AzureCredentials, AzureCloud, updateDatasourceCredentials } from '@grafana/azure-sdk';
import { SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { HttpSettingsBaseProps } from '@grafana/ui/src/components/DataSourceSettings/types'; import { HttpSettingsBaseProps } from '@grafana/ui/src/components/DataSourceSettings/types';
import { AzureCredentialsType } from '../types'; import { getCredentials } from './AzureCredentialsConfig';
import { KnownAzureClouds } from './AzureCredentials';
import { getCredentials, updateCredentials } from './AzureCredentialsConfig';
import { AzureCredentialsForm } from './AzureCredentialsForm'; import { AzureCredentialsForm } from './AzureCredentialsForm';
export const KnownAzureClouds: Array<SelectableValue<AzureCloud>> = [{ value: AzureCloud.Public, label: 'Azure' }];
export const AzureAuthSettings = (props: HttpSettingsBaseProps) => { export const AzureAuthSettings = (props: HttpSettingsBaseProps) => {
const { dataSourceConfig: dsSettings, onChange } = props; const { dataSourceConfig: dsSettings, onChange } = props;
const managedIdentityEnabled = config.azure.managedIdentityEnabled; const managedIdentityEnabled = config.azure.managedIdentityEnabled;
const azureEntraPasswordCredentialsEnabled = config.azure.azureEntraPasswordCredentialsEnabled; const azureEntraPasswordCredentialsEnabled = config.azure.azureEntraPasswordCredentialsEnabled;
const credentials = useMemo(() => getCredentials(dsSettings, config), [dsSettings]); const credentials = useMemo(() => getCredentials(dsSettings), [dsSettings]);
const onCredentialsChange = (credentials: AzureCredentialsType): void => { const onCredentialsChange = (credentials: AzureCredentials): void => {
onChange(updateCredentials(dsSettings, config, credentials)); onChange(updateDatasourceCredentials(dsSettings, credentials));
}; };
// The auth type needs to be set on the first load of the data source // 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 { import {
AzureCloud, AzureCredentials,
AzureCredentialsType, AzureDataSourceSettings,
ConcealedSecretType, getDatasourceCredentials,
AzureAuthSecureJSONDataType, getDefaultAzureCloud,
AzureAuthJSONDataType, } from '@grafana/azure-sdk';
AzureAuthType, import { config } from '@grafana/runtime';
} from '../types';
export const getDefaultCredentials = (): AzureCredentials => {
export const getDefaultCredentials = (managedIdentityEnabled: boolean, cloud: string): AzureCredentialsType => { if (config.azure.managedIdentityEnabled) {
if (managedIdentityEnabled) { return { authType: 'msi' };
return { authType: AzureAuthType.MSI };
} else { } else {
return { authType: AzureAuthType.CLIENT_SECRET, azureCloud: cloud }; return { authType: 'clientsecret', azureCloud: getDefaultAzureCloud() };
} }
}; };
export const getSecret = ( export const getCredentials = (dsSettings: AzureDataSourceSettings): AzureCredentials => {
storedServerSide: boolean, const credentials = getDatasourceCredentials(dsSettings);
secret: string | symbol | undefined if (credentials) {
): undefined | string | ConcealedSecretType => { return credentials;
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: 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 // If no credentials saved, then return empty credentials
// of type based on whether the managed identity enabled // of type based on whether the managed identity enabled
if (!credentials) { return getDefaultCredentials();
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',
},
};
}
}; };

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

@ -1,4 +1,4 @@
import { DataSourceJsonData } from '@grafana/data'; import { AzureCredentials } from '@grafana/azure-sdk';
import { SQLOptions } from '@grafana/sql'; import { SQLOptions } from '@grafana/sql';
import { HttpSettingsBaseProps } from '@grafana/ui/src/components/DataSourceSettings/types'; import { HttpSettingsBaseProps } from '@grafana/ui/src/components/DataSourceSettings/types';
@ -17,37 +17,13 @@ export enum MSSQLEncryptOptions {
false = 'false', false = 'false',
true = 'true', 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 { export interface MssqlOptions extends SQLOptions {
authenticationType?: MSSQLAuthenticationType; authenticationType?: MSSQLAuthenticationType;
encrypt?: MSSQLEncryptOptions; encrypt?: MSSQLEncryptOptions;
sslRootCertFile?: string; sslRootCertFile?: string;
serverName?: string; serverName?: string;
connectionTimeout?: number; connectionTimeout?: number;
azureCredentials?: AzureCredentialsType; azureCredentials?: AzureCredentials;
keytabFilePath?: string; keytabFilePath?: string;
credentialCache?: string; credentialCache?: string;
credentialCacheLookupFile?: string; credentialCacheLookupFile?: string;
@ -60,15 +36,6 @@ export interface MssqlSecureOptions {
password?: string; password?: string;
} }
export type AzureAuthJSONDataType = DataSourceJsonData & {
azureCredentials: AzureCredentialsType;
};
export type AzureAuthSecureJSONDataType = {
azureClientSecret: undefined | string | ConcealedSecretType;
password: undefined | string | ConcealedSecretType;
};
export type AzureAuthConfigType = { export type AzureAuthConfigType = {
azureAuthIsSupported: boolean; azureAuthIsSupported: boolean;
azureAuthSettingsUI: (props: HttpSettingsBaseProps) => JSX.Element; azureAuthSettingsUI: (props: HttpSettingsBaseProps) => JSX.Element;

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

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

@ -37,4 +37,6 @@ def github_app_generate_token_step():
"echo $(/usr/bin/github-app-external-token) > /github-app/token", "echo $(/usr/bin/github-app-external-token) > /github-app/token",
], ],
"volumes": github_app_step_volumes(), "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 languageName: node
linkType: hard linkType: hard
"@bsull/augurs@npm:^0.6.0": "@bsull/augurs@npm:^0.7.0":
version: 0.6.0 version: 0.7.0
resolution: "@bsull/augurs@npm:0.6.0" resolution: "@bsull/augurs@npm:0.7.0"
checksum: 10/0ba2ea0432f7d4c44ccec4d112e672f8d5d977407be42ff5995e1d6641b4b0238f97b9cdf13bc1fc066559bbd54e6fb00ad1f418014823a6c22af619e7a29c6a checksum: 10/7291b401f37fd2d120e97449eb2820a069ae744a04ba3f952fde20f31c80eeb221e0b8861d5853e62667ed6ed7b33f5028dbd442d491fe06183dd5a5ad6f725f
languageName: node languageName: node
linkType: hard linkType: hard
@ -3790,11 +3790,11 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"@grafana/scenes-react@npm:5.28.1": "@grafana/scenes-react@npm:^5.30.0":
version: 5.28.1 version: 5.30.0
resolution: "@grafana/scenes-react@npm:5.28.1" resolution: "@grafana/scenes-react@npm:5.30.0"
dependencies: dependencies:
"@grafana/scenes": "npm:5.28.1" "@grafana/scenes": "npm:5.30.0"
lru-cache: "npm:^10.2.2" lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0" react-use: "npm:^17.4.0"
peerDependencies: peerDependencies:
@ -3805,13 +3805,13 @@ __metadata:
"@grafana/ui": ^11.0.0 "@grafana/ui": ^11.0.0
react: ^18.0.0 react: ^18.0.0
react-dom: ^18.0.0 react-dom: ^18.0.0
checksum: 10/1979eebf0eea30550e9ffc1c9a766cd239914f5b79e0487ddf77d49006288c78f2e3f72bbe97782b6144902ef05e649f6f46197891811e3bbf809e6d016f0920 checksum: 10/8d33b0024865e4a869cbba4dc7530c7494f4c2cb3615bbe7e2c9a912736fc7618848ab92c03941f0847f5edc9dc68a2d68bf99aac9ff95e0d40c85f1fa410088
languageName: node languageName: node
linkType: hard linkType: hard
"@grafana/scenes@npm:5.28.1": "@grafana/scenes@npm:5.30.0, @grafana/scenes@npm:^5.30.0":
version: 5.28.1 version: 5.30.0
resolution: "@grafana/scenes@npm:5.28.1" resolution: "@grafana/scenes@npm:5.30.0"
dependencies: dependencies:
"@floating-ui/react": "npm:^0.26.16" "@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16" "@leeoniya/ufuzzy": "npm:^1.0.16"
@ -3828,7 +3828,7 @@ __metadata:
"@grafana/ui": ">=10.4" "@grafana/ui": ">=10.4"
react: ^18.0.0 react: ^18.0.0
react-dom: ^18.0.0 react-dom: ^18.0.0
checksum: 10/a12ab38c048e886a880bff64c5b17ae959f34efea44bee06afa4acdc4dcde14ccafc7aa263df568f54d7d5dd165d40952f11ce0ee0c1daa3ad46ea435dbe00ad checksum: 10/d226f523ef2b22eac0de26e7929dbe5d5775f297b0f0c07a4a1b5c792c224c72eab67312d43b7a88d85416c9e4c91f9064777d54e696fdf9da2366c70914b6b3
languageName: node languageName: node
linkType: hard linkType: hard
@ -17531,7 +17531,7 @@ __metadata:
"@betterer/betterer": "npm:5.4.0" "@betterer/betterer": "npm:5.4.0"
"@betterer/cli": "npm:5.4.0" "@betterer/cli": "npm:5.4.0"
"@betterer/eslint": "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" "@cypress/webpack-preprocessor": "npm:6.0.2"
"@emotion/css": "npm:11.13.5" "@emotion/css": "npm:11.13.5"
"@emotion/eslint-plugin": "npm:11.12.0" "@emotion/eslint-plugin": "npm:11.12.0"
@ -17559,8 +17559,8 @@ __metadata:
"@grafana/prometheus": "workspace:*" "@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*" "@grafana/runtime": "workspace:*"
"@grafana/saga-icons": "workspace:*" "@grafana/saga-icons": "workspace:*"
"@grafana/scenes": "npm:5.28.1" "@grafana/scenes": "npm:^5.30.0"
"@grafana/scenes-react": "npm:5.28.1" "@grafana/scenes-react": "npm:^5.30.0"
"@grafana/schema": "workspace:*" "@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*" "@grafana/sql": "workspace:*"
"@grafana/tsconfig": "npm:^2.0.0" "@grafana/tsconfig": "npm:^2.0.0"

Loading…
Cancel
Save