From 7e59fd3ecfa3aafbf5d3833ddc2139eaa772159e Mon Sep 17 00:00:00 2001 From: Ieva Date: Thu, 9 Jun 2022 11:11:04 +0100 Subject: [PATCH 01/40] Docs: extend RBAC breaking changes (#50495) * extend RBAC breaking changes * Update docs/sources/whatsnew/whats-new-in-v9-0.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> --- docs/sources/whatsnew/whats-new-in-v9-0.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/sources/whatsnew/whats-new-in-v9-0.md b/docs/sources/whatsnew/whats-new-in-v9-0.md index 169f90db421..87bc7e9177d 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-0.md +++ b/docs/sources/whatsnew/whats-new-in-v9-0.md @@ -181,10 +181,11 @@ This is a partial list of notable breaking changes. For the complete list, see o Fine-grained access control is now called "Role-based access control". As part of the Grafana 9.0 release, the service is generally available, and there are several breaking changes: -Built-in roles are now called basic roles, and they now consist of permissions, not roles -The Terraform builtin_role_assignment resource is going away. -Grafana provisioning has a new schema, please refer to the documentation for more information. -Basic roles no longer support permission inheritance. Previously, when permissions of a Viewer basic role were modified, it was propagated to the Editor and Admin basic roles. With the Grafana 9.0 release, this is not the case anymore. +- Built-in roles are now called basic roles. They now consist of permissions, not roles. +- The Terraform builtin_role_assignment resource is going away. +- Grafana provisioning has a new schema. Please refer to the documentation for more information. +- Basic roles no longer support permission inheritance. Previously, when permissions of a Viewer basic role were modified, it was propagated to the Editor and Admin basic roles. With the Grafana 9.0 release, this is not the case anymore. +- Several role-based access control actions have been renamed. All the database entries that use legacy action names will be migrated to use the new names, but provisioning files and scripts will have to be updated by the user. This change also means that if Grafana is downgraded from 9.0 to a lower version, some role-based access control permissions will not be resolved correctly. ### Loki: logs data format changed From 9d537efb4875c5f61ce0961dd963a62ffc82501b Mon Sep 17 00:00:00 2001 From: George Robinson Date: Thu, 9 Jun 2022 11:38:20 +0100 Subject: [PATCH 02/40] Docs: Images in Notifications (#50425) --- .../alerting/images-in-notifications.md | 94 +++++++++++++++++++ .../setup-grafana/configure-grafana/_index.md | 18 ++++ 2 files changed, 112 insertions(+) create mode 100644 docs/sources/alerting/images-in-notifications.md diff --git a/docs/sources/alerting/images-in-notifications.md b/docs/sources/alerting/images-in-notifications.md new file mode 100644 index 00000000000..910d6f6596e --- /dev/null +++ b/docs/sources/alerting/images-in-notifications.md @@ -0,0 +1,94 @@ +--- +aliases: +description: +keywords: + - grafana + - alerting + - images + - notifications +title: Images in notifications +--- + +# Images in notifications + +Images in notifications helps recipients of alert notifications better understand why an alert has fired or resolved by including an image of the panel for the Grafana managed alert rule. + +> **Note**: Images in notifications are not available for Grafana Mimir and Loki managed alert rules, or when Grafana is set up to send alert notifications to an external Alertmanager. + +If Grafana is set up to send images in notifications, it takes a screenshot of the panel for the Grafana managed alert rule when either of the following happen: + +1. The alert rule transitions from pending to firing +2. The alert rule transitions from firing to OK + +Images are stored in the [data]({{< relref "../setup-grafana/configure-grafana/#paths" >}}) path and so Grafana must have write-access to this path. If Grafana cannot write to this path then taken screenshots cannot be saved to disk and an error will be logged for each failed screenshot attempt. In addition to storing images on disk, Grafana can also store the image in an external image store such as Amazon S3, Azure Blob Storage, Google Cloud Storage and even Grafana where screenshots are stored in `public/img/attachments`. Screenshots older than `temp_data_lifetime` are deleted from disk but not the external image store. If Grafana is the external image store then screenshots are deleted from `data` but not from `public/img/attachments`. + +> **Note**: It is recommended to use an external image store is used for images in notifications as not all contact points supported uploading images from disk. It is also possible that the image on disk is deleted before an alert notification is sent if `temp_data_lifetime` is less than the `group_wait` and `group_interval` options used in Alertmanager. + +## Requirements + +To use images in notifications, Grafana must be set up to use [image rendering]({{< relref "../setup-grafana/image-rendering/" >}}). It is also recommended that Grafana is set up to upload images to an [external image store]({{< relref "../setup-grafana/configure-grafana/#external_image_storage" >}}) such as Amazon S3, Azure Blob Storage, Google Cloud Storage or even Grafana. + +## Configuration + +If Grafana has been set up to use [image rendering]({{< relref "../setup-grafana/image-rendering/" >}}) images in notifications can be turned on via the `capture` option in `[unified_alerting.screenshots]`: + + # Enable screenshots in notifications. This option requires a remote HTTP image rendering service. Please + # see [rendering] for further configuration options. + capture = true + +It is recommended that `max_concurrent_screenshots` is set to a value that is less than or equal to `concurrent_render_request_limit`. The default value for both `max_concurrent_screenshots` and `concurrent_render_request_limit` is `5`: + + # The maximum number of screenshots that can be taken at the same time. This option is different from + # concurrent_render_request_limit as max_concurrent_screenshots sets the number of concurrent screenshots + # that can be taken at the same time for all firing alerts where as concurrent_render_request_limit sets + # the total number of concurrent screenshots across all Grafana services. + max_concurrent_screenshots = 5 + +If Grafana has been set up to use an external image store, `upload_external_image_storage` should be set to `true`: + + # Uploads screenshots to the local Grafana server or remote storage such as Azure, S3 and GCS. Please + # see [external_image_storage] for further configuration options. If this option is false, screenshots + # will be persisted to disk for up to temp_data_lifetime. + upload_external_image_storage = false + +Restart Grafana for the changes to take affect. + +## Supported notifiers + +Images in notifications are supported in the following notifiers and additional support will be added in the future: + +| Name | Upload images from disk | Include images from URL | +| ----------------------- | ----------------------- | ----------------------- | +| DingDing | No | No | +| Discord | Yes | Yes | +| Email | Yes | Yes | +| Google Hangouts Chat | No | Yes | +| Kafka | No | No | +| Line | No | No | +| Microsoft Teams | No | Yes | +| Opsgenie | No | Yes | +| Pagerduty | No | Yes | +| Prometheus Alertmanager | No | No | +| Pushover | No | No | +| Sensu Go | No | No | +| Slack | No | Yes | +| Telegram | No | No | +| Threema | No | No | +| VictorOps | No | No | +| Webhook | No | Yes | +| WeCom | No | No | + +Include images from URL refers to using the external image store. + +## Metrics + +Grafana provides the following metrics to observe the performance and failure rate of images in notifications. +For example, if a screenshot could not be taken within the expected time (10 seconds) then the counter `grafana_screenshot_failures_total` is updated. + +- `grafana_screenshot_cache_hits_total` +- `grafana_screenshot_cache_misses_total` +- `grafana_screenshot_duration_seconds` +- `grafana_screenshot_failures_total` +- `grafana_screenshot_successes_total` +- `grafana_screenshot_upload_failures_total` +- `grafana_screenshot_upload_successes_total` diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 61db9c50468..55f95c700ca 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -1267,6 +1267,24 @@ The interval string is a possibly signed sequence of decimal numbers, followed b
+## [unified_alerting.screenshots] + +For more information about screenshots, refer to [Images in notifications]({{< relref "../../alerting/images-in-notifications" >}}). + +### capture + +Enable screenshots in notifications. This option requires a remote HTTP image rendering service. Please see `[rendering]` for further configuration options. + +### max_concurrent_screenshots + +The maximum number of screenshots that can be taken at the same time. This option is different from `concurrent_render_request_limit` as `max_concurrent_screenshots` sets the number of concurrent screenshots that can be taken at the same time for all firing alerts where as concurrent_render_request_limit sets the total number of concurrent screenshots across all Grafana services. + +### upload_external_image_storage + +Uploads screenshots to the local Grafana server or remote storage such as Azure, S3 and GCS. Please see `[external_image_storage]` for further configuration options. If this option is false then screenshots will be persisted to disk for up to `temp_data_lifetime`. + +
+ ## [alerting] For more information about the legacy dashboard alerting feature in Grafana, refer to [Alerts overview]({{< relref "../../alerting/" >}}). From 07871f3298aab8eab80453d75ceb60badaedbe0a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 9 Jun 2022 11:54:23 +0100 Subject: [PATCH 03/40] Update dependency @microsoft/api-extractor-model to v7.18.0 (#50335) Co-authored-by: Renovate Bot --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 792a190ebf7..81ef19061b5 100644 --- a/package.json +++ b/package.json @@ -397,7 +397,7 @@ "resolutions": { "underscore": "1.13.3", "@types/slate": "0.47.2", - "@microsoft/api-extractor-model": "7.17.3", + "@microsoft/api-extractor-model": "7.18.0", "@rushstack/node-core-library": "3.45.5", "@rushstack/rig-package": "0.3.11", "@rushstack/ts-command-line": "4.11.0", diff --git a/yarn.lock b/yarn.lock index e09ebc723a6..68100180992 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6585,14 +6585,14 @@ __metadata: languageName: node linkType: hard -"@microsoft/api-extractor-model@npm:7.17.3": - version: 7.17.3 - resolution: "@microsoft/api-extractor-model@npm:7.17.3" +"@microsoft/api-extractor-model@npm:7.18.0": + version: 7.18.0 + resolution: "@microsoft/api-extractor-model@npm:7.18.0" dependencies: "@microsoft/tsdoc": 0.14.1 "@microsoft/tsdoc-config": ~0.16.1 "@rushstack/node-core-library": 3.45.5 - checksum: 5b68f59c7ea0c4cb9678e40fd9a0aab1c46e1e3cc6f754bcee58dd3fbfc290b0865b56823cdd0a6ec58b569501cbe4a84a708f87a5cc409c7fdab10c0853b112 + checksum: ccca08eafe024bbf53fbf7449aa1003cd15843eb76d57fa2ac02ffdd411e57bd19bd1e15a7b7b315e17d438463c3cc075a2d2d40c44b6e80049550597b7e8be7 languageName: node linkType: hard From a263a0f9bdc1a77b861032c160547aa4651de4e7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 9 Jun 2022 12:05:51 +0100 Subject: [PATCH 04/40] Update dependency @opentelemetry/semantic-conventions to v1.3.1 (#50388) Co-authored-by: Renovate Bot --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 81ef19061b5..1df77b45723 100644 --- a/package.json +++ b/package.json @@ -267,7 +267,7 @@ "@lingui/react": "3.13.3", "@opentelemetry/api": "1.1.0", "@opentelemetry/exporter-collector": "0.25.0", - "@opentelemetry/semantic-conventions": "1.2.0", + "@opentelemetry/semantic-conventions": "1.3.1", "@popperjs/core": "2.11.5", "@react-aria/button": "3.4.4", "@react-aria/dialog": "3.1.9", diff --git a/yarn.lock b/yarn.lock index 68100180992..3ff6306cc98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7033,10 +7033,10 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/semantic-conventions@npm:1.2.0": - version: 1.2.0 - resolution: "@opentelemetry/semantic-conventions@npm:1.2.0" - checksum: ba8ebaaecab15e7aff26b59e028e9ceb73eba2fda6c8c3b9957277d66205a56781ab2a5d1f7bf321b92e0ca44d3e247336665ac4ccaef195455f838cbcab44ff +"@opentelemetry/semantic-conventions@npm:1.3.1": + version: 1.3.1 + resolution: "@opentelemetry/semantic-conventions@npm:1.3.1" + checksum: 83fa3b8a8198f6a5265a4191a89cfcce042240b92e034cf29631b0fe749993f4f74f4d21d89b10f6b05984246424adf8d037f8be507fcf0adf60933ab7143f07 languageName: node linkType: hard @@ -20615,7 +20615,7 @@ __metadata: "@microsoft/api-extractor": 7.25.0 "@opentelemetry/api": 1.1.0 "@opentelemetry/exporter-collector": 0.25.0 - "@opentelemetry/semantic-conventions": 1.2.0 + "@opentelemetry/semantic-conventions": 1.3.1 "@pmmmwh/react-refresh-webpack-plugin": 0.5.6 "@popperjs/core": 2.11.5 "@react-aria/button": 3.4.4 From ce1ee4de04c5922dfedd33ee34aed87efffe4fb4 Mon Sep 17 00:00:00 2001 From: Joao Silva <100691367+JoaoSilvaGrafana@users.noreply.github.com> Date: Thu, 9 Jun 2022 12:50:16 +0100 Subject: [PATCH 05/40] DataSources: Fixes issue with expressions not being queried (#50446) --- public/app/features/query/state/PanelQueryRunner.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/public/app/features/query/state/PanelQueryRunner.ts b/public/app/features/query/state/PanelQueryRunner.ts index dcd7ef65c2a..c794abb5418 100644 --- a/public/app/features/query/state/PanelQueryRunner.ts +++ b/public/app/features/query/state/PanelQueryRunner.ts @@ -26,6 +26,7 @@ import { transformDataFrame, } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; +import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; import { StreamingDataFrame } from 'app/features/live/data/StreamingDataFrame'; import { isStreamingDataFrame } from 'app/features/live/data/utils'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; @@ -235,12 +236,13 @@ export class PanelQueryRunner { try { const ds = await getDataSource(datasource, request.scopedVars); - + const isMixedDS = ds.meta?.mixed; // Attach the data source to each query request.targets = request.targets.map((query) => { + const isExpressionQuery = query.datasource?.type === ExpressionDatasourceRef.type; // When using a data source variable, the panel might have the incorrect datasource // stored, so when running the query make sure it is done with the correct one - if (!query.datasource || (query.datasource.uid !== ds.uid && !ds.meta.mixed)) { + if (!query.datasource || (query.datasource.uid !== ds.uid && !isMixedDS && !isExpressionQuery)) { query.datasource = ds.getRef(); } return query; From 80a030605fd022c7d8cc2ba24447075103156861 Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Thu, 9 Jun 2022 13:51:56 +0200 Subject: [PATCH 06/40] change to debug logs to match non access control guardian (#50477) --- pkg/services/guardian/accesscontrol_guardian.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/services/guardian/accesscontrol_guardian.go b/pkg/services/guardian/accesscontrol_guardian.go index 87ddfef95c1..7287d3ae04b 100644 --- a/pkg/services/guardian/accesscontrol_guardian.go +++ b/pkg/services/guardian/accesscontrol_guardian.go @@ -143,11 +143,11 @@ func (a *AccessControlDashboardGuardian) CanCreate(folderID int64, isFolder bool func (a *AccessControlDashboardGuardian) evaluate(evaluator accesscontrol.Evaluator) (bool, error) { ok, err := a.ac.Evaluate(a.ctx, a.user, evaluator) if err != nil { - a.log.Error("Failed to evaluate access control to folder or dashboard", "error", err, "userId", a.user.UserId, "id", a.dashboardID) + a.log.Debug("Failed to evaluate access control to folder or dashboard", "error", err, "userId", a.user.UserId, "id", a.dashboardID) } if !ok && err == nil { - a.log.Info("Access denied to folder or dashboard", "userId", a.user.UserId, "id", a.dashboardID, "permissions", evaluator.GoString()) + a.log.Debug("Access denied to folder or dashboard", "userId", a.user.UserId, "id", a.dashboardID, "permissions", evaluator.GoString()) } return ok, err From 7fad8d61718e1c84b458cfd0c65c5f7673dca3ec Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Thu, 9 Jun 2022 13:56:24 +0200 Subject: [PATCH 07/40] RBAC: Move datasource access check to middleware (#50430) * Add datasource scopes to auth middleware * Remove filter call from handlers, permission check is done by auth middleware --- pkg/api/api.go | 22 ++++++++++++---------- pkg/api/datasources.go | 21 +++------------------ 2 files changed, 15 insertions(+), 28 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index ef5cb1e583b..1260547da9b 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -308,20 +308,22 @@ func (hs *HTTPServer) registerRoutes() { // Data sources apiRoute.Group("/datasources", func(datasourceRoute routing.RouteRegister) { + idScope := datasources.ScopeProvider.GetResourceScope(ac.Parameter(":id")) + uidScope := datasources.ScopeProvider.GetResourceScopeUID(ac.Parameter(":uid")) + nameScope := datasources.ScopeProvider.GetResourceScopeName(ac.Parameter(":name")) datasourceRoute.Get("/", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionRead)), routing.Wrap(hs.GetDataSources)) datasourceRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionCreate)), quota("data_source"), routing.Wrap(hs.AddDataSource)) - datasourceRoute.Put("/:id", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionWrite, datasources.ScopeProvider.GetResourceScope(ac.Parameter(":id")))), routing.Wrap(hs.UpdateDataSourceByID)) - datasourceRoute.Put("/uid/:uid", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionWrite, datasources.ScopeProvider.GetResourceScopeUID(ac.Parameter(":uid")))), routing.Wrap(hs.UpdateDataSourceByUID)) - datasourceRoute.Delete("/:id", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionDelete, datasources.ScopeProvider.GetResourceScope(ac.Parameter(":id")))), routing.Wrap(hs.DeleteDataSourceById)) - datasourceRoute.Delete("/uid/:uid", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionDelete, datasources.ScopeProvider.GetResourceScopeUID(ac.Parameter(":uid")))), routing.Wrap(hs.DeleteDataSourceByUID)) - datasourceRoute.Delete("/name/:name", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionDelete, datasources.ScopeProvider.GetResourceScopeName(ac.Parameter(":name")))), routing.Wrap(hs.DeleteDataSourceByName)) - datasourceRoute.Get("/:id", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionRead)), routing.Wrap(hs.GetDataSourceById)) - datasourceRoute.Get("/uid/:uid", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionRead)), routing.Wrap(hs.GetDataSourceByUID)) - datasourceRoute.Get("/name/:name", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionRead)), routing.Wrap(hs.GetDataSourceByName)) + datasourceRoute.Put("/:id", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionWrite, idScope)), routing.Wrap(hs.UpdateDataSourceByID)) + datasourceRoute.Put("/uid/:uid", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionWrite, uidScope)), routing.Wrap(hs.UpdateDataSourceByUID)) + datasourceRoute.Delete("/:id", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionDelete, idScope)), routing.Wrap(hs.DeleteDataSourceById)) + datasourceRoute.Delete("/uid/:uid", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionDelete, uidScope)), routing.Wrap(hs.DeleteDataSourceByUID)) + datasourceRoute.Delete("/name/:name", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionDelete, nameScope)), routing.Wrap(hs.DeleteDataSourceByName)) + datasourceRoute.Get("/:id", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionRead, idScope)), routing.Wrap(hs.GetDataSourceById)) + datasourceRoute.Get("/uid/:uid", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionRead, uidScope)), routing.Wrap(hs.GetDataSourceByUID)) + datasourceRoute.Get("/name/:name", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionRead, nameScope)), routing.Wrap(hs.GetDataSourceByName)) + datasourceRoute.Get("/id/:name", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionIDRead, nameScope)), routing.Wrap(hs.GetDataSourceIdByName)) }) - apiRoute.Get("/datasources/id/:name", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionIDRead, datasources.ScopeProvider.GetResourceScopeName(ac.Parameter(":name")))), routing.Wrap(hs.GetDataSourceIdByName)) - apiRoute.Get("/plugins", routing.Wrap(hs.GetPluginList)) apiRoute.Get("/plugins/:pluginId/settings", routing.Wrap(hs.GetPluginSettingByID)) apiRoute.Get("/plugins/:pluginId/markdown/:name", routing.Wrap(hs.GetPluginMarkdown)) diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index ee331ae92a8..ee03e50fba9 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -92,12 +92,7 @@ func (hs *HTTPServer) GetDataSourceById(c *models.ReqContext) response.Response return response.Error(500, "Failed to query datasources", err) } - filtered, err := hs.filterDatasourcesByQueryPermission(c.Req.Context(), c.SignedInUser, []*models.DataSource{query.Result}) - if err != nil || len(filtered) != 1 { - return response.Error(404, "Data source not found", err) - } - - dto := hs.convertModelToDtos(c.Req.Context(), filtered[0]) + dto := hs.convertModelToDtos(c.Req.Context(), query.Result) // Add accesscontrol metadata dto.AccessControl = hs.getAccessControlMetadata(c, c.OrgId, datasources.ScopePrefix, dto.UID) @@ -151,12 +146,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *models.ReqContext) response.Response return response.Error(http.StatusInternalServerError, "Failed to query datasource", err) } - filtered, err := hs.filterDatasourcesByQueryPermission(c.Req.Context(), c.SignedInUser, []*models.DataSource{ds}) - if err != nil || len(filtered) != 1 { - return response.Error(404, "Data source not found", err) - } - - dto := hs.convertModelToDtos(c.Req.Context(), filtered[0]) + dto := hs.convertModelToDtos(c.Req.Context(), ds) // Add accesscontrol metadata dto.AccessControl = hs.getAccessControlMetadata(c, c.OrgId, datasources.ScopePrefix, dto.UID) @@ -397,12 +387,7 @@ func (hs *HTTPServer) GetDataSourceByName(c *models.ReqContext) response.Respons return response.Error(500, "Failed to query datasources", err) } - filtered, err := hs.filterDatasourcesByQueryPermission(c.Req.Context(), c.SignedInUser, []*models.DataSource{query.Result}) - if err != nil || len(filtered) != 1 { - return response.Error(404, "Data source not found", err) - } - - dto := hs.convertModelToDtos(c.Req.Context(), filtered[0]) + dto := hs.convertModelToDtos(c.Req.Context(), query.Result) return response.JSON(http.StatusOK, &dto) } From 840a442796076b96d35603c0c10f5a7330eb4902 Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Thu, 9 Jun 2022 14:29:27 +0200 Subject: [PATCH 08/40] RBAC: Rename alerting roles to match naming convention (#50504) --- .../rbac-fixed-basic-role-definitions.md | 24 +++---- pkg/services/ngalert/accesscontrol.go | 70 +++++++++---------- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/docs/sources/enterprise/access-control/rbac-fixed-basic-role-definitions.md b/docs/sources/enterprise/access-control/rbac-fixed-basic-role-definitions.md index 0ffdaaa0397..ca0b070dd99 100644 --- a/docs/sources/enterprise/access-control/rbac-fixed-basic-role-definitions.md +++ b/docs/sources/enterprise/access-control/rbac-fixed-basic-role-definitions.md @@ -15,26 +15,26 @@ The following tables list permissions associated with basic and fixed roles. ## Basic role assignments -| Basic role | Associated fixed roles | Description | -| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Grafana Admin | `fixed:roles:reader`
`fixed:roles:writer`
`fixed:users:reader`
`fixed:users:writer`
`fixed:org.users:reader`
`fixed:org.users:writer`
`fixed:ldap:reader`
`fixed:ldap:writer`
`fixed:stats:reader`
`fixed:settings:reader`
`fixed:settings:writer`
`fixed:provisioning:writer`
`fixed:organization:reader`
`fixed:organization:maintainer`
`fixed:licensing:reader`
`fixed:licensing:writer` | Default [Grafana server administrator]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions/#grafana-server-administrators" >}}) assignments. | -| Admin | `fixed:reports:reader`
`fixed:reports:writer`
`fixed:datasources:reader`
`fixed:datasources:writer`
`fixed:organization:writer`
`fixed:datasources.permissions:reader`
`fixed:datasources.permissions:writer`
`fixed:teams:writer`
`fixed:dashboards:reader`
`fixed:dashboards:writer`
`fixed:dashboards.permissions:reader`
`fixed:dashboards.permissions:writer`
`fixed:folders:reader`
`fixes:folders:writer`
`fixed:folders.permissions:reader`
`fixed:folders.permissions:writer`
`fixed:alerting:editor`
`fixed:apikeys:reader`
`fixed:apikeys:writer`
`fixed:alerting:provisioning` | Default [Grafana organization administrator]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions/#organization-users-and-permissions" >}}) assignments. | -| Editor | `fixed:datasources:explorer`
`fixed:dashboards:creator`
`fixed:folders:creator`
`fixed:annotations:writer`
`fixed:teams:creator` if the `editors_can_admin` configuration flag is enabled
`fixed:alerting:editor` | Default [Editor]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions/#organization-users-and-permissions" >}}) assignments. | -| Viewer | `fixed:datasources:id:reader`
`fixed:organization:reader`
`fixed:annotations:reader`
`fixed:annotations.dashboard:writer`
`fixed:alerting:reader` | Default [Viewer]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions/#organization-users-and-permissions" >}}) assignments. | +| Basic role | Associated fixed roles | Description | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Grafana Admin | `fixed:roles:reader`
`fixed:roles:writer`
`fixed:users:reader`
`fixed:users:writer`
`fixed:org.users:reader`
`fixed:org.users:writer`
`fixed:ldap:reader`
`fixed:ldap:writer`
`fixed:stats:reader`
`fixed:settings:reader`
`fixed:settings:writer`
`fixed:provisioning:writer`
`fixed:organization:reader`
`fixed:organization:maintainer`
`fixed:licensing:reader`
`fixed:licensing:writer` | Default [Grafana server administrator]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions/#grafana-server-administrators" >}}) assignments. | +| Admin | `fixed:reports:reader`
`fixed:reports:writer`
`fixed:datasources:reader`
`fixed:datasources:writer`
`fixed:organization:writer`
`fixed:datasources.permissions:reader`
`fixed:datasources.permissions:writer`
`fixed:teams:writer`
`fixed:dashboards:reader`
`fixed:dashboards:writer`
`fixed:dashboards.permissions:reader`
`fixed:dashboards.permissions:writer`
`fixed:folders:reader`
`fixes:folders:writer`
`fixed:folders.permissions:reader`
`fixed:folders.permissions:writer`
`fixed:alerting:writer`
`fixed:apikeys:reader`
`fixed:apikeys:writer`
`fixed:alerting.provisioning:writer` | Default [Grafana organization administrator]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions/#organization-users-and-permissions" >}}) assignments. | +| Editor | `fixed:datasources:explorer`
`fixed:dashboards:creator`
`fixed:folders:creator`
`fixed:annotations:writer`
`fixed:teams:creator` if the `editors_can_admin` configuration flag is enabled
`fixed:alerting:writer` | Default [Editor]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions/#organization-users-and-permissions" >}}) assignments. | +| Viewer | `fixed:datasources:id:reader`
`fixed:organization:reader`
`fixed:annotations:reader`
`fixed:annotations.dashboard:writer`
`fixed:alerting:reader` | Default [Viewer]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions/#organization-users-and-permissions" >}}) assignments. | ## Fixed role definitions | Fixed role | Permissions | Description | | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `fixed:alerting.instances:editor` | All permissions from `fixed:alerting.instances:reader` and
`alert.instances:create`
`alert.instances:write` for organization scope
`alert.instances.external:write` for scope `datasources:*` | Create, update and expire all silences in the organization produced by Grafana, Mimir, and Loki.[\*](#alerting-roles) | +| `fixed:alerting.instances:writer` | All permissions from `fixed:alerting.instances:reader` and
`alert.instances:create`
`alert.instances:write` for organization scope
`alert.instances.external:write` for scope `datasources:*` | Create, update and expire all silences in the organization produced by Grafana, Mimir, and Loki.[\*](#alerting-roles) | | `fixed:alerting.instances:reader` | `alert.instances:read` for organization scope
`alert.instances.external:read` for scope `datasources:*` | Read all alerts and silences in the organization produced by Grafana Alerts and Mimir and Loki alerts and silences.[\*](#alerting-roles) | -| `fixed:alerting.notifications:editor` | All permissions from `fixed:alerting.notifications:reader` and
`alert.notifications:write`for organization scope
`alert.notifications.external:read` for scope `datasources:*` | Create, update, and delete contact points, templates, mute timings and notification policies for Grafana and external Alertmanager.[\*](#alerting-roles) | +| `fixed:alerting.notifications:writer` | All permissions from `fixed:alerting.notifications:reader` and
`alert.notifications:write`for organization scope
`alert.notifications.external:read` for scope `datasources:*` | Create, update, and delete contact points, templates, mute timings and notification policies for Grafana and external Alertmanager.[\*](#alerting-roles) | | `fixed:alerting.notifications:reader` | `alert.notifications:read` for organization scope
`alert.notifications.external:read` for scope `datasources:*` | Read all Grafana and Alertmanager contact points, templates, and notification policies.[\*](#alerting-roles) | -| `fixed:alerting.rules:editor` | All permissions from `fixed:alerting.rules:reader` and
`alert.rule:create`
`alert.rule:write`
`alert.rule:delete` for scope `folders:*`
`alert.rules.external:write` for scope `datasources:*` | Create, update, and delete all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) | +| `fixed:alerting.rules:writer` | All permissions from `fixed:alerting.rules:reader` and
`alert.rule:create`
`alert.rule:write`
`alert.rule:delete` for scope `folders:*`
`alert.rules.external:write` for scope `datasources:*` | Create, update, and delete all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) | | `fixed:alerting.rules:reader` | `alert.rule:read` for scope `folders:*`
`alert.rules.external:read` for scope `datasources:*` | Read all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) | -| `fixed:alerting:editor` | All permissions from `fixed:alerting.rules:editor`
`fixed:alerting.instances:editor`
`fixed:alerting.notifications:editor` | Create, update, and delete Grafana, Mimir, Loki and Alertmanager alert rules\*, silences, contact points, templates, mute timings, and notification policies.[\*](#alerting-roles) | +| `fixed:alerting:writer` | All permissions from `fixed:alerting.rules:writer`
`fixed:alerting.instances:writer`
`fixed:alerting.notifications:writer` | Create, update, and delete Grafana, Mimir, Loki and Alertmanager alert rules\*, silences, contact points, templates, mute timings, and notification policies.[\*](#alerting-roles) | | `fixed:alerting:reader` | All permissions from `fixed:alerting.rules:reader`
`fixed:alerting.instances:reader`
`fixed:alerting.notifications:reader` | Read-only permissions for all Grafana, Mimir, Loki and Alertmanager alert rules\*, alerts, contact points, and notification policies.[\*](#alerting-roles) | -| `fixed:alerting:provisioning` | `alert.provisioning:read` and `alert.provisioning:write` | Create, update and delete Grafana alert rules, notification policies, contact points, templates, etc via provisioning API. [\*](#alerting-roles) | +| `fixed:alerting.provisioning:writer` | `alert.provisioning:read` and `alert.provisioning:write` | Create, update and delete Grafana alert rules, notification policies, contact points, templates, etc via provisioning API. [\*](#alerting-roles) | | `fixed:annotations.dashboard:writer` | `annotations:write`
`annotations.create`
`annotations:delete` for scope `annotations:type:dashboard` | Create, update and delete dashboard annotations and annotation tags. | | `fixed:annotations:reader` | `annotations:read` for scopes `annotations:type:*` | Read all annotations and annotation tags. | | `fixed:annotations:writer` | All permissions from `fixed:annotations:reader`
`annotations:write`
`annotations.create`
`annotations:delete` for scope `annotations:type:*` | Read, create, update and delete all annotations and annotation tags. | @@ -88,6 +88,6 @@ Access to Grafana alert rules is an intersection of many permissions: - Permission to read a folder. For example, the fixed role `fixed:folders:reader` includes the action `folders:read` and a folder scope `folders:id:`. - Permission to query **all** data sources that a given alert rule uses. If a user cannot query a given data source, they cannot see any alert rules that query that data source. -There is only one exclusion at this moment. Role `fixed:alerting:provisioning` does not require user to have any additional permissions and provides access to all aspects of the alerting configuration via special provisioning API. +There is only one exclusion at this moment. Role `fixed:alerting.provisioning:writer` does not require user to have any additional permissions and provides access to all aspects of the alerting configuration via special provisioning API. For more information about the permissions required to access alert rules, refer to [Create a custom role to access alerts in a folder]({{< relref "plan-rbac-rollout-strategy/#create-a-custom-role-to-access-alerts-in-a-folder" >}}). diff --git a/pkg/services/ngalert/accesscontrol.go b/pkg/services/ngalert/accesscontrol.go index 3492414c233..87a8671831a 100644 --- a/pkg/services/ngalert/accesscontrol.go +++ b/pkg/services/ngalert/accesscontrol.go @@ -14,9 +14,9 @@ var ( Role: accesscontrol.RoleDTO{ Name: accesscontrol.FixedRolePrefix + "alerting.rules:reader", DisplayName: "Rules Reader", - Description: "Can read alert rules in all Grafana folders and external providers", + Description: "Read alert rules in all Grafana folders and external providers", Group: AlertRolesGroup, - Version: 2, + Version: 3, Permissions: []accesscontrol.Permission{ { Action: accesscontrol.ActionAlertingRuleRead, @@ -30,13 +30,13 @@ var ( }, } - rulesEditorRole = accesscontrol.RoleRegistration{ + rulesWriterRole = accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ - Name: accesscontrol.FixedRolePrefix + "alerting.rules:editor", - DisplayName: "Rules Editor", - Description: "Can add, update, and delete rules in any Grafana folder and external providers", + Name: accesscontrol.FixedRolePrefix + "alerting.rules:writer", + DisplayName: "Rules Writer", + Description: "Add, update, and delete rules in any Grafana folder and external providers", Group: AlertRolesGroup, - Version: 3, + Version: 4, Permissions: accesscontrol.ConcatPermissions(rulesReaderRole.Role.Permissions, []accesscontrol.Permission{ { Action: accesscontrol.ActionAlertingRuleCreate, @@ -62,9 +62,9 @@ var ( Role: accesscontrol.RoleDTO{ Name: accesscontrol.FixedRolePrefix + "alerting.instances:reader", DisplayName: "Instances and Silences Reader", - Description: "Can read instances and silences of Grafana and external providers", + Description: "Read instances and silences of Grafana and external providers", Group: AlertRolesGroup, - Version: 1, + Version: 2, Permissions: []accesscontrol.Permission{ { Action: accesscontrol.ActionAlertingInstanceRead, @@ -78,13 +78,13 @@ var ( }, } - instancesEditorRole = accesscontrol.RoleRegistration{ + instancesWriterRole = accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ - Name: accesscontrol.FixedRolePrefix + "alerting.instances:editor", - DisplayName: "Silences Editor", - Description: "Can add and update silences in Grafana and external providers", + Name: accesscontrol.FixedRolePrefix + "alerting.instances:writer", + DisplayName: "Silences Writer", + Description: "Add and update silences in Grafana and external providers", Group: AlertRolesGroup, - Version: 2, + Version: 3, Permissions: accesscontrol.ConcatPermissions(instancesReaderRole.Role.Permissions, []accesscontrol.Permission{ { Action: accesscontrol.ActionAlertingInstanceCreate, @@ -104,9 +104,9 @@ var ( Role: accesscontrol.RoleDTO{ Name: accesscontrol.FixedRolePrefix + "alerting.notifications:reader", DisplayName: "Notifications Reader", - Description: "Can read notification policies and contact points in Grafana and external providers", + Description: "Read notification policies and contact points in Grafana and external providers", Group: AlertRolesGroup, - Version: 1, + Version: 2, Permissions: []accesscontrol.Permission{ { Action: accesscontrol.ActionAlertingNotificationsRead, @@ -119,13 +119,13 @@ var ( }, } - notificationsEditorRole = accesscontrol.RoleRegistration{ + notificationsWriterRole = accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ - Name: accesscontrol.FixedRolePrefix + "alerting.notifications:editor", - DisplayName: "Notifications Editor", - Description: "Can add, update, and delete contact points and notification policies in Grafana and external providers", + Name: accesscontrol.FixedRolePrefix + "alerting.notifications:writer", + DisplayName: "Notifications Writer", + Description: "Add, update, and delete contact points and notification policies in Grafana and external providers", Group: AlertRolesGroup, - Version: 2, + Version: 3, Permissions: accesscontrol.ConcatPermissions(notificationsReaderRole.Role.Permissions, []accesscontrol.Permission{ { Action: accesscontrol.ActionAlertingNotificationsWrite, @@ -142,9 +142,9 @@ var ( Role: accesscontrol.RoleDTO{ Name: accesscontrol.FixedRolePrefix + "alerting:reader", DisplayName: "Full read-only access", - Description: "Can read alert rules, instances, silences, contact points, and notification policies in Grafana and all external providers", + Description: "Read alert rules, instances, silences, contact points, and notification policies in Grafana and all external providers", Group: AlertRolesGroup, - Version: 2, + Version: 3, Permissions: accesscontrol.ConcatPermissions(rulesReaderRole.Role.Permissions, instancesReaderRole.Role.Permissions, notificationsReaderRole.Role.Permissions), }, Grants: []string{string(models.ROLE_VIEWER)}, @@ -152,23 +152,23 @@ var ( alertingWriterRole = accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ - Name: accesscontrol.FixedRolePrefix + "alerting:editor", + Name: accesscontrol.FixedRolePrefix + "alerting:writer", DisplayName: "Full access", - Description: "Can add,update and delete alert rules, instances, silences, contact points, and notification policies in Grafana and all external providers", + Description: "Add,update and delete alert rules, instances, silences, contact points, and notification policies in Grafana and all external providers", Group: AlertRolesGroup, - Version: 3, - Permissions: accesscontrol.ConcatPermissions(rulesEditorRole.Role.Permissions, instancesEditorRole.Role.Permissions, notificationsEditorRole.Role.Permissions), + Version: 4, + Permissions: accesscontrol.ConcatPermissions(rulesWriterRole.Role.Permissions, instancesWriterRole.Role.Permissions, notificationsWriterRole.Role.Permissions), }, Grants: []string{string(models.ROLE_EDITOR), string(models.ROLE_ADMIN)}, } - alertingProvisioningRole = accesscontrol.RoleRegistration{ + alertingProvisionerRole = accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ - Name: accesscontrol.FixedRolePrefix + "alerting:provisioning", + Name: accesscontrol.FixedRolePrefix + "alerting.provisioning:writer", DisplayName: "Access to alert rules provisioning API", - Description: "Can manage all alert rules, contact points, notification policies, silences, etc. in the organization via provisioning API.", + Description: "Manage all alert rules, contact points, notification policies, silences, etc. in the organization via provisioning API.", Group: AlertRolesGroup, - Version: 1, + Version: 2, Permissions: []accesscontrol.Permission{ { Action: accesscontrol.ActionAlertingProvisioningRead, // organization scope @@ -184,9 +184,9 @@ var ( func DeclareFixedRoles(ac accesscontrol.AccessControl) error { return ac.DeclareFixedRoles( - rulesReaderRole, rulesEditorRole, - instancesReaderRole, instancesEditorRole, - notificationsReaderRole, notificationsEditorRole, - alertingReaderRole, alertingWriterRole, alertingProvisioningRole, + rulesReaderRole, rulesWriterRole, + instancesReaderRole, instancesWriterRole, + notificationsReaderRole, notificationsWriterRole, + alertingReaderRole, alertingWriterRole, alertingProvisionerRole, ) } From cd01eb541a83fc0a1f222dbb917ff1dc25c44c46 Mon Sep 17 00:00:00 2001 From: Timur Olzhabayev Date: Thu, 9 Jun 2022 14:37:01 +0200 Subject: [PATCH 09/40] Updating migration docs to include most relevant breaking changes (#50356) --- docs/sources/whatsnew/whats-new-in-v9-0.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/sources/whatsnew/whats-new-in-v9-0.md b/docs/sources/whatsnew/whats-new-in-v9-0.md index 87bc7e9177d..27a8c98267c 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-0.md +++ b/docs/sources/whatsnew/whats-new-in-v9-0.md @@ -216,6 +216,25 @@ In the InfluxDB data source, browser access mode was deprecated in grafana 8.0.0 The rename by regex transformation has been improved to allow global patterns of the form `//g`. Depending on the regex match used, this may cause some transformations to behave slightly differently. You can guarantee the same behavior as before by wrapping the match string in forward slashes (/), for example, (._) would become /(._)/. ([Github Issue #48179](https://github.com/grafana/grafana/pull/48179)) +### Clock Panel + +We have updated [clock panel](https://grafana.com/grafana/plugins/grafana-clock-panel/) to version `2.0.0` to make it Compatible with Grafana 9. The previous version `1.3.1` will cause the Grafana 9 to [crash](https://github.com/grafana/clock-panel/issues/106) when being used in a dashboard, we encourage you to update the panel before migrating to Grafana 9. + +### Plugins: Most relevant breaking changes + +- `getColorForTheme` was removed, use `theme.visualization.getColorByName` instead [PR #49519](https://github.com/grafana/grafana/pull/49519) +- `TextDisplayOptions` was removed, use `VizTextDisplayOptions` instead [PR #49705](https://github.com/grafana/grafana/pull/49705) +- We have changed the internals of `backendSrv.fetch()` to throw an error when the response is an incorrect JSON. Make sure to handle possible errors on the callsite where using `backendSrv.fetch()` (or any other `backendSrv` methods) [PR #47493](https://github.com/grafana/grafana/pull/47493) +- We have removed the deprecated `getFormStyles` function from [grafana-ui](https://www.npmjs.com/package/@grafana/ui). Use `GrafanaTheme2` and the `useStyles2` hook instead [PR #49945](https://github.com/grafana/grafana/pull/49945) +- We have removed the deprecated `/api/tsdb/query` metrics endpoint. Use `/api/ds/query` instead [PR #49916](https://github.com/grafana/grafana/pull/49916) + +You can find the complete list of breaking changes in the links below. Please check them out for more details and see if there is anything affecting your plugin + +- https://grafana.com/docs/grafana/next/release-notes/release-notes-9-0-0-beta1/ +- https://grafana.com/docs/grafana/next/release-notes/release-notes-9-0-0-beta2/ +- https://grafana.com/docs/grafana/next/release-notes/release-notes-9-0-0-beta3/ +- https://grafana.com/docs/grafana/next/release-notes/release-notes-9-0-0 + ## A note on Grafana Enterprise licensing When we release Grafana 9.0 on June 14th, Grafana will no longer enforce viewers and editor-admins differently. That means that regardless of whether your Grafana Enterprise license is tiered or combined, instead of seeing this on the Stats & Licensing page: From a6943cb399afeaac74be81269c1716e0952eb114 Mon Sep 17 00:00:00 2001 From: "Grot (@grafanabot)" <43478413+grafanabot@users.noreply.github.com> Date: Thu, 9 Jun 2022 07:37:27 -0600 Subject: [PATCH 10/40] ReleaseNotes: Updated changelog and release notes for 9.0.0-beta2 (#50398) * ReleaseNotes: Updated changelog and release notes for 9.0.0-beta2 * fixing duplicate beta2 link Co-authored-by: Timur Olzhabayev --- .../release-notes-9-0-0-beta2.md | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/docs/sources/release-notes/release-notes-9-0-0-beta2.md b/docs/sources/release-notes/release-notes-9-0-0-beta2.md index 44e9e6b1080..068c6295287 100644 --- a/docs/sources/release-notes/release-notes-9-0-0-beta2.md +++ b/docs/sources/release-notes/release-notes-9-0-0-beta2.md @@ -81,29 +81,7 @@ module.exports.getWebpackConfig = (config, options) => ({ }); ``` -Please refer to the webpack build error messages or the [official migration guide](https://webpack.js.org/migrate/5/) for assistance with fallbacks. - -**Which issue(s) this PR fixes**: - - - -Fixes # - -**Special notes for your reviewer**: - -It does not bump the following dependencies to the very latest due to the latest versions being ES modules: - -- ora -- globby -- execa -- chalk - Issue [#47826](https://github.com/grafana/grafana/issues/47826) +Please refer to the webpack build error messages or the [official migration guide](https://webpack.js.org/migrate/5/) for assistance with fallbacks. Issue [#47826](https://github.com/grafana/grafana/issues/47826) We have changed the internals of `backendSrv.fetch()` to throw an error when the response is an incorrect JSON. From c412a3b052ac16c23ba809e438ac5350b7d6ee0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Thu, 9 Jun 2022 15:53:23 +0200 Subject: [PATCH 11/40] logs: track the usage of certain features (#50325) * logs: track the usage of certain features * Add report interaction for logs interactions * mock reportInteraction in test * mock reportInteraction Co-authored-by: Ivana Huckova --- .../components/Inspector/InspectContent.tsx | 3 +- .../explore/ExploreQueryInspector.tsx | 3 +- .../app/features/explore/ExploreToolbar.tsx | 36 ++++++++++++------- public/app/features/explore/Logs.tsx | 6 ++++ public/app/features/explore/LogsContainer.tsx | 1 + .../features/explore/LogsNavigation.test.tsx | 6 ++++ .../app/features/explore/LogsNavigation.tsx | 7 ++++ .../features/explore/LogsNavigationPages.tsx | 9 ++++- .../explore/spec/queryHistory.test.tsx | 1 - public/app/features/explore/state/query.ts | 7 +++- .../app/features/inspector/InspectDataTab.tsx | 21 +++++++++-- .../loki/components/LokiCheatSheet.tsx | 8 ++++- .../loki/components/LokiLabelBrowser.test.tsx | 6 ++++ .../loki/components/LokiLabelBrowser.tsx | 16 ++++++++- .../loki/components/LokiQueryField.tsx | 13 +++++++ .../components/LokiQueryBuilderOptions.tsx | 10 ++++-- .../components/LokiQueryCodeEditor.tsx | 1 + .../components/LokiQueryEditorSelector.tsx | 2 +- 18 files changed, 131 insertions(+), 25 deletions(-) diff --git a/public/app/features/dashboard/components/Inspector/InspectContent.tsx b/public/app/features/dashboard/components/Inspector/InspectContent.tsx index daa3608548d..5d3e5b9fc36 100644 --- a/public/app/features/dashboard/components/Inspector/InspectContent.tsx +++ b/public/app/features/dashboard/components/Inspector/InspectContent.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { DataSourceApi, formattedValueToString, getValueFormat, PanelData, PanelPlugin } from '@grafana/data'; +import { CoreApp, DataSourceApi, formattedValueToString, getValueFormat, PanelData, PanelPlugin } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; import { Drawer, Tab, TabsBar } from '@grafana/ui'; import { InspectDataTab } from 'app/features/inspector/InspectDataTab'; @@ -90,6 +90,7 @@ export const InspectContent: React.FC = ({ options={dataOptions} onOptionsChange={onDataOptionsChange} timeZone={dashboard.timezone} + app={CoreApp.Dashboard} /> )} {data && activeTab === InspectTab.Meta && ( diff --git a/public/app/features/explore/ExploreQueryInspector.tsx b/public/app/features/explore/ExploreQueryInspector.tsx index f231bd9a33d..bc39f5ea0eb 100644 --- a/public/app/features/explore/ExploreQueryInspector.tsx +++ b/public/app/features/explore/ExploreQueryInspector.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { TimeZone } from '@grafana/data'; +import { CoreApp, TimeZone } from '@grafana/data'; import { TabbedContainer, TabConfig } from '@grafana/ui'; import { ExploreDrawer } from 'app/features/explore/ExploreDrawer'; import { InspectDataTab } from 'app/features/inspector/InspectDataTab'; @@ -51,6 +51,7 @@ export function ExploreQueryInspector(props: Props) { isLoading={loading} options={{ withTransforms: false, withFieldConfig: false }} timeZone={timeZone} + app={CoreApp.Explore} /> ), }; diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 52fb54f81b6..520c98833f7 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -2,7 +2,7 @@ import React, { lazy, PureComponent, RefObject, Suspense } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { DataSourceInstanceSettings, RawTimeRange } from '@grafana/data'; -import { config, DataSourcePicker } from '@grafana/runtime'; +import { config, DataSourcePicker, reportInteraction } from '@grafana/runtime'; import { defaultIntervals, PageToolbar, @@ -192,17 +192,28 @@ class UnConnectedExploreToolbar extends PureComponent { {hasLiveOption && ( - {(controls) => ( - - )} + {(c) => { + const controls = { + ...c, + start: () => { + reportInteraction('grafana_explore_logs_result_displayed', { + datasourceType: this.props.datasourceType, + }); + c.start(); + }, + }; + return ( + + ); + }} )} @@ -223,6 +234,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps) => { return { datasourceMissing, datasourceName: datasourceInstance?.name, + datasourceType: datasourceInstance?.type, loading, range, timeZone: getTimeZone(state.user), diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 516f53c4bd9..0e0628be007 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -23,6 +23,7 @@ import { SplitOpen, DataQueryResponse, } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import { TooltipDisplayMode } from '@grafana/schema'; import { RadioButtonGroup, @@ -68,6 +69,7 @@ interface Props extends Themeable2 { scanning?: boolean; scanRange?: RawTimeRange; exploreId: ExploreId; + datasourceType?: string; logsVolumeData: DataQueryResponse | undefined; loadLogsVolumeData: (exploreId: ExploreId) => void; showContextToggle?: (row?: LogRowModel) => boolean; @@ -144,6 +146,10 @@ class UnthemedLogs extends PureComponent { }; onChangeDedup = (dedupStrategy: LogsDedupStrategy) => { + reportInteraction('grafana_explore_logs_deduplication_clicked', { + deduplicationType: dedupStrategy, + datasourceType: this.props.datasourceType, + }); this.setState({ dedupStrategy }); }; diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index 750de6ba9e4..be59ff37e36 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -131,6 +131,7 @@ class LogsContainer extends PureComponent { ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: () => null, +})); + type LogsNavigationProps = ComponentProps; const defaultProps: LogsNavigationProps = { absoluteRange: { from: 1637319381811, to: 1637322981811 }, diff --git a/public/app/features/explore/LogsNavigation.tsx b/public/app/features/explore/LogsNavigation.tsx index 5c2cd5a0904..5d495660161 100644 --- a/public/app/features/explore/LogsNavigation.tsx +++ b/public/app/features/explore/LogsNavigation.tsx @@ -3,6 +3,7 @@ import { isEqual } from 'lodash'; import React, { memo, useState, useEffect, useRef } from 'react'; import { LogsSortOrder, AbsoluteTimeRange, TimeZone, DataQuery, GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import { Button, Icon, Spinner, useTheme2 } from '@grafana/ui'; import { LogsNavigationPages } from './LogsNavigationPages'; @@ -107,6 +108,9 @@ function LogsNavigation({ variant="secondary" onClick={() => { //If we are not on the last page, use next page's range + reportInteraction('grafana_explore_logs_pagination_clicked', { + pageType: 'olderLogsButton', + }); if (!onLastPage) { const indexChange = oldestLogsFirst ? -1 : 1; changeTime({ @@ -133,6 +137,9 @@ function LogsNavigation({ className={styles.navButton} variant="secondary" onClick={() => { + reportInteraction('grafana_explore_logs_pagination_clicked', { + pageType: 'newerLogsButton', + }); //If we are not on the first page, use previous page's range if (!onFirstPage) { const indexChange = oldestLogsFirst ? 1 : -1; diff --git a/public/app/features/explore/LogsNavigationPages.tsx b/public/app/features/explore/LogsNavigationPages.tsx index a5c978b0d8c..b4ab4153d92 100644 --- a/public/app/features/explore/LogsNavigationPages.tsx +++ b/public/app/features/explore/LogsNavigationPages.tsx @@ -2,6 +2,7 @@ import { css, cx } from '@emotion/css'; import React from 'react'; import { dateTimeFormat, systemDateFormats, TimeZone, AbsoluteTimeRange, GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import { CustomScrollbar, Spinner, useTheme2 } from '@grafana/ui'; import { LogsPage } from './LogsNavigation'; @@ -51,7 +52,13 @@ export function LogsNavigationPages({ data-testid={`page${index + 1}`} className={styles.page} key={page.queryRange.to} - onClick={() => !loading && changeTime({ from: page.queryRange.from, to: page.queryRange.to })} + onClick={() => { + reportInteraction('grafana_explore_logs_pagination_clicked', { + pageType: 'page', + pageNumber: index + 1, + }); + !loading && changeTime({ from: page.queryRange.from, to: page.queryRange.to }); + }} >
diff --git a/public/app/features/explore/spec/queryHistory.test.tsx b/public/app/features/explore/spec/queryHistory.test.tsx index f8d99773ac8..86dae67df45 100644 --- a/public/app/features/explore/spec/queryHistory.test.tsx +++ b/public/app/features/explore/spec/queryHistory.test.tsx @@ -118,7 +118,6 @@ describe('Explore: Query History', () => { await openQueryHistory(); await assertQueryHistoryExists(RAW_QUERY); - expect(reportInteractionMock).toBeCalledTimes(2); expect(reportInteractionMock).toBeCalledWith('grafana_explore_query_history_opened', { queryHistoryEnabled: false, }); diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 6a192bfcda5..137e7e6acc3 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -19,7 +19,7 @@ import { QueryFixAction, toLegacyResponseData, } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { buildQueryTransaction, ensureQueries, @@ -442,6 +442,11 @@ export const runQueries = ( ) .subscribe({ next(data) { + if (data.logsResult !== null) { + reportInteraction('grafana_explore_logs_result_displayed', { + datasourceType: datasourceInstance.type, + }); + } dispatch(queryStreamUpdatedAction({ exploreId, response: data })); // Keep scanning for results if this was the last scanning transaction diff --git a/public/app/features/inspector/InspectDataTab.tsx b/public/app/features/inspector/InspectDataTab.tsx index 8e30ce87e50..046d4677888 100644 --- a/public/app/features/inspector/InspectDataTab.tsx +++ b/public/app/features/inspector/InspectDataTab.tsx @@ -16,8 +16,10 @@ import { toCSV, transformDataFrame, TimeZone, + CoreApp, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { reportInteraction } from '@grafana/runtime'; import { Button, Spinner, Table } from '@grafana/ui'; import { config } from 'app/core/config'; import { dataFrameToLogsModel } from 'app/core/logs_model'; @@ -34,6 +36,7 @@ interface Props { isLoading: boolean; options: GetDataOptions; timeZone: TimeZone; + app?: CoreApp; data?: DataFrame[]; panel?: PanelModel; onOptionsChange?: (options: GetDataOptions) => void; @@ -107,7 +110,11 @@ export class InspectDataTab extends PureComponent { }; exportLogsAsTxt = () => { - const { data, panel } = this.props; + const { data, panel, app } = this.props; + reportInteraction('grafana_logs_download_logs_clicked', { + app, + format: 'logs', + }); const logsModel = dataFrameToLogsModel(data || [], undefined); let textToDownload = ''; @@ -223,7 +230,7 @@ export class InspectDataTab extends PureComponent { } render() { - const { isLoading, options, data, panel, onOptionsChange } = this.props; + const { isLoading, options, data, panel, onOptionsChange, app } = this.props; const { dataFrameIndex, transformId, transformationOptions, selectedDataFrame, downloadForExcel } = this.state; const styles = getPanelInspectorStyles(); @@ -266,7 +273,15 @@ export class InspectDataTab extends PureComponent { />
); diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx index 1c84d804923..b0f045f1f4c 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx @@ -126,7 +126,7 @@ export const LokiQueryEditorSelector = React.memo((props) )} {editorMode === QueryEditorMode.Explain && } {editorMode !== QueryEditorMode.Explain && ( - + )} From 615132002044e56074be63c5b597b9982e0a2a21 Mon Sep 17 00:00:00 2001 From: Kevin Yu Date: Thu, 9 Jun 2022 06:56:32 -0700 Subject: [PATCH 12/40] Cloud Monitoring: add feature toggle for experimental UI (#50460) * Cloud Monitoring: add feature toggle for experimental UI * remove RequiresDevMode --- packages/grafana-data/src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 6 ++++++ pkg/services/featuremgmt/toggles_gen.go | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 757bf20da8d..bf5c011f15c 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -56,4 +56,5 @@ export interface FeatureToggles { validateDashboardsOnSave?: boolean; prometheusWideSeries?: boolean; canvasPanelNesting?: boolean; + cloudMonitoringExperimentalUI?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index ad2fada0e58..621d4553bf6 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -226,5 +226,11 @@ var ( State: FeatureStateAlpha, FrontendOnly: true, }, + { + Name: "cloudMonitoringExperimentalUI", + Description: "Use grafana-experimental UI in Cloud Monitoring", + State: FeatureStateAlpha, + FrontendOnly: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index d27b4f2256f..624ca68c387 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -166,4 +166,8 @@ const ( // FlagCanvasPanelNesting // Allow elements nesting FlagCanvasPanelNesting = "canvasPanelNesting" + + // FlagCloudMonitoringExperimentalUI + // Use grafana-experimental UI in Cloud Monitoring + FlagCloudMonitoringExperimentalUI = "cloudMonitoringExperimentalUI" ) From 6da4feb2a92a144ee8026081817df6b20160441b Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 9 Jun 2022 16:07:40 +0100 Subject: [PATCH 13/40] Convert OrgProfile test to RTL (#50525) --- .betterer.results | 3 -- public/app/features/org/OrgProfile.test.tsx | 34 ++++++++++++++----- .../__snapshots__/OrgProfile.test.tsx.snap | 14 -------- 3 files changed, 26 insertions(+), 25 deletions(-) delete mode 100644 public/app/features/org/__snapshots__/OrgProfile.test.tsx.snap diff --git a/.betterer.results b/.betterer.results index 4cc661563ee..9d094e27732 100644 --- a/.betterer.results +++ b/.betterer.results @@ -146,9 +146,6 @@ exports[`no enzyme tests`] = { "public/app/features/org/OrgDetailsPage.test.tsx:3835042085": [ [0, 19, 13, "RegExp match", "2409514259"] ], - "public/app/features/org/OrgProfile.test.tsx:623809345": [ - [0, 19, 13, "RegExp match", "2409514259"] - ], "public/app/features/teams/TeamSettings.test.tsx:2043271249": [ [0, 19, 13, "RegExp match", "2409514259"] ], diff --git a/public/app/features/org/OrgProfile.test.tsx b/public/app/features/org/OrgProfile.test.tsx index 459ad6cbe6a..29e604cac7a 100644 --- a/public/app/features/org/OrgProfile.test.tsx +++ b/public/app/features/org/OrgProfile.test.tsx @@ -1,4 +1,5 @@ -import { shallow } from 'enzyme'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import OrgProfile, { Props } from './OrgProfile'; @@ -11,19 +12,36 @@ jest.mock('app/core/core', () => { }; }); -const setup = () => { +describe('OrgProfile', () => { const props: Props = { orgName: 'Main org', onSubmit: jest.fn(), }; - return shallow(); -}; + it('should render without crashing', () => { + expect(() => render()).not.toThrow(); + }); -describe('Render', () => { - it('should render component', () => { - const wrapper = setup(); + it('should show the current org name', () => { + render(); - expect(wrapper).toMatchSnapshot(); + const orgNameInput = screen.getByLabelText('Organization name'); + + expect(orgNameInput).toHaveValue('Main org'); + }); + + it('can update the current org name', async () => { + render(); + + const orgNameInput = screen.getByLabelText('Organization name'); + const submitButton = screen.getByRole('button', { name: 'Update organization name' }); + + expect(orgNameInput).toHaveValue('Main org'); + + await userEvent.clear(orgNameInput); + await userEvent.type(orgNameInput, 'New org name'); + await userEvent.click(submitButton); + + expect(props.onSubmit).toHaveBeenCalledWith('New org name'); }); }); diff --git a/public/app/features/org/__snapshots__/OrgProfile.test.tsx.snap b/public/app/features/org/__snapshots__/OrgProfile.test.tsx.snap deleted file mode 100644 index c6b257b3976..00000000000 --- a/public/app/features/org/__snapshots__/OrgProfile.test.tsx.snap +++ /dev/null @@ -1,14 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Render should render component 1`] = ` -
- - -`; From 9703c9211e2cb6dd2b941bd14af221105455f9bc Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Thu, 9 Jun 2022 17:27:01 +0200 Subject: [PATCH 14/40] AzureMonitor: Remove unused code (#50399) --- .../MetricsQueryEditor.test.tsx | 491 +++++++++--------- .../MetricsQueryEditor/MetricsQueryEditor.tsx | 303 ++++++----- .../ResourceGroupsField.tsx | 46 -- .../MetricsQueryEditor/ResourceNameField.tsx | 46 -- .../MetricsQueryEditor/ResourceTypeField.tsx | 50 -- .../MetricsQueryEditor/dataHooks.test.ts | 221 +------- .../MetricsQueryEditor/dataHooks.ts | 203 ++------ .../components/MetricsQueryEditor/index.tsx | 1 - .../MetricsQueryEditor/setQueryValue.ts | 89 ---- .../MetricsQueryEditor.test.tsx | 260 ---------- .../MetricsQueryEditor.tsx | 228 -------- .../NewMetricsQueryEditor/dataHooks.test.ts | 235 --------- .../NewMetricsQueryEditor/dataHooks.ts | 152 ------ .../components/QueryEditor/QueryEditor.tsx | 4 +- 14 files changed, 471 insertions(+), 1858 deletions(-) delete mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/ResourceGroupsField.tsx delete mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/ResourceNameField.tsx delete mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/ResourceTypeField.tsx delete mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/index.tsx delete mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.test.tsx delete mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.tsx delete mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.test.ts delete mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.ts diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx index f4c729c52b8..0a0f60ebaa2 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx @@ -1,10 +1,20 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; +import { config } from '@grafana/runtime'; + import createMockDatasource from '../../__mocks__/datasource'; +import { createMockInstanceSetttings } from '../../__mocks__/instanceSettings'; import createMockPanelData from '../../__mocks__/panelData'; import createMockQuery from '../../__mocks__/query'; +import { + createMockResourceGroupsBySubscription, + createMockSubscriptions, + mockResourcesByResourceGroup, +} from '../../__mocks__/resourcePickerRows'; +import ResourcePickerData from '../../resourcePicker/resourcePickerData'; import MetricsQueryEditor from './MetricsQueryEditor'; @@ -13,259 +23,238 @@ const variableOptionGroup = { options: [], }; -describe('Azure Monitor QueryEditor', () => { - const mockPanelData = createMockPanelData(); - it('should render', async () => { - const mockDatasource = createMockDatasource(); - render( - {}} - setError={() => {}} - /> - ); - await waitFor(() => expect(screen.getByTestId('azure-monitor-metrics-query-editor')).toBeInTheDocument()); - }); +const tests = [ + { + id: 'azure-monitor-metrics-query-editor-with-resource-picker', + }, + { + id: 'azure-monitor-metrics-query-editor-with-experimental-ui', + }, +]; - it('should change the subscription ID when selected', async () => { - const mockDatasource = createMockDatasource(); - const onChange = jest.fn(); - const mockQuery = createMockQuery(); - (mockQuery.azureMonitor ?? {}).metricName = undefined; - mockDatasource.azureMonitorDatasource.getSubscriptions = jest.fn().mockResolvedValueOnce([ - { - value: 'abc-123', - text: 'Primary Subscription', - }, - { - value: 'abc-456', - text: 'Another Subscription', - }, - ]); +export function createMockResourcePickerData() { + const mockDatasource = new ResourcePickerData(createMockInstanceSetttings()); - render( - {}} - /> - ); + mockDatasource.getSubscriptions = jest.fn().mockResolvedValue(createMockSubscriptions()); + mockDatasource.getResourceGroupsBySubscriptionId = jest + .fn() + .mockResolvedValue(createMockResourceGroupsBySubscription()); + mockDatasource.getResourcesForResourceGroup = jest.fn().mockResolvedValue(mockResourcesByResourceGroup()); + mockDatasource.getResourceURIFromWorkspace = jest.fn().mockReturnValue(''); + mockDatasource.getResourceURIDisplayProperties = jest.fn().mockResolvedValue({}); - const subscriptions = await screen.findByLabelText('Subscription'); - await selectOptionInTest(subscriptions, 'Another Subscription'); + return mockDatasource; +} - expect(onChange).toHaveBeenCalledWith({ - ...mockQuery, - subscription: 'abc-456', - azureMonitor: { - ...mockQuery.azureMonitor, - resourceGroup: undefined, - metricDefinition: undefined, - metricNamespace: undefined, - resourceName: undefined, - metricName: undefined, - aggregation: undefined, - timeGrain: '', - dimensionFilters: [], - }, +for (const t of tests) { + describe(`MetricsQueryEditor: ${t.id}`, () => { + const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView; + const mockPanelData = createMockPanelData(); + + beforeEach(() => { + window.HTMLElement.prototype.scrollIntoView = function () {}; + config.featureToggles.azureMonitorExperimentalUI = + t.id === 'azure-monitor-metrics-query-editor-with-experimental-ui'; + }); + afterEach(() => { + window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + config.featureToggles.azureMonitorExperimentalUI = false; + }); + + it('should render', async () => { + const mockDatasource = createMockDatasource({ resourcePickerData: createMockResourcePickerData() }); + + render( + {}} + setError={() => {}} + /> + ); + + expect(await screen.findByTestId(t.id)).toBeInTheDocument(); + }); + + it('should change resource when a resource is selected in the ResourcePicker', async () => { + const mockDatasource = createMockDatasource({ resourcePickerData: createMockResourcePickerData() }); + const query = createMockQuery(); + delete query?.azureMonitor?.resourceUri; + const onChange = jest.fn(); + + render( + {}} + /> + ); + + const resourcePickerButton = await screen.findByRole('button', { name: 'Select a resource' }); + expect(resourcePickerButton).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Expand Primary Subscription' })).not.toBeInTheDocument(); + resourcePickerButton.click(); + + const subscriptionButton = await screen.findByRole('button', { name: 'Expand Primary Subscription' }); + expect(subscriptionButton).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Expand A Great Resource Group' })).not.toBeInTheDocument(); + subscriptionButton.click(); + + const resourceGroupButton = await screen.findByRole('button', { name: 'Expand A Great Resource Group' }); + expect(resourceGroupButton).toBeInTheDocument(); + expect(screen.queryByLabelText('web-server')).not.toBeInTheDocument(); + resourceGroupButton.click(); + + const checkbox = await screen.findByLabelText('web-server'); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + await userEvent.click(await screen.findByRole('button', { name: 'Apply' })); + + expect(onChange).toBeCalledTimes(1); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + azureMonitor: expect.objectContaining({ + resourceUri: + '/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/web-server', + }), + }) + ); + }); + + it('should reset metric namespace, metric name, and aggregation fields after selecting a new resource when a valid query has already been set', async () => { + const mockDatasource = createMockDatasource({ resourcePickerData: createMockResourcePickerData() }); + const query = createMockQuery(); + const onChange = jest.fn(); + + render( + {}} + /> + ); + + const resourcePickerButton = await screen.findByRole('button', { name: /grafana/ }); + + expect(screen.getByText('Microsoft.Compute/virtualMachines')).toBeInTheDocument(); + expect(screen.getByText('Metric A')).toBeInTheDocument(); + expect(screen.getByText('Average')).toBeInTheDocument(); + + expect(resourcePickerButton).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Expand Primary Subscription' })).not.toBeInTheDocument(); + resourcePickerButton.click(); + + const subscriptionButton = await screen.findByRole('button', { name: 'Expand Dev Subscription' }); + expect(subscriptionButton).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Expand Development 3' })).not.toBeInTheDocument(); + subscriptionButton.click(); + + const resourceGroupButton = await screen.findByRole('button', { name: 'Expand Development 3' }); + expect(resourceGroupButton).toBeInTheDocument(); + expect(screen.queryByLabelText('db-server')).not.toBeInTheDocument(); + resourceGroupButton.click(); + + const checkbox = await screen.findByLabelText('db-server'); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + await userEvent.click(await screen.findByRole('button', { name: 'Apply' })); + + expect(onChange).toBeCalledTimes(1); + expect(onChange).toBeCalledWith( + expect.objectContaining({ + azureMonitor: expect.objectContaining({ + resourceUri: + '/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/db-server', + metricNamespace: undefined, + metricName: undefined, + aggregation: undefined, + timeGrain: '', + dimensionFilters: [], + }), + }) + ); + }); + + it('should change the metric name when selected', async () => { + const mockDatasource = createMockDatasource({ resourcePickerData: createMockResourcePickerData() }); + const onChange = jest.fn(); + const mockQuery = createMockQuery(); + mockDatasource.azureMonitorDatasource.getMetricNames = jest.fn().mockResolvedValue([ + { + value: 'metric-a', + text: 'Metric A', + }, + { + value: 'metric-b', + text: 'Metric B', + }, + ]); + + render( + {}} + /> + ); + + const metrics = await screen.findByLabelText('Metric'); + expect(metrics).toBeInTheDocument(); + await selectOptionInTest(metrics, 'Metric B'); + + expect(onChange).toHaveBeenLastCalledWith({ + ...mockQuery, + azureMonitor: { + ...mockQuery.azureMonitor, + metricName: 'metric-b', + aggregation: undefined, + timeGrain: '', + }, + }); + }); + + it('should change the aggregation type when selected', async () => { + const mockDatasource = createMockDatasource({ resourcePickerData: createMockResourcePickerData() }); + const onChange = jest.fn(); + const mockQuery = createMockQuery(); + + render( + {}} + /> + ); + + const aggregation = await screen.findByLabelText('Aggregation'); + expect(aggregation).toBeInTheDocument(); + await selectOptionInTest(aggregation, 'Maximum'); + + expect(onChange).toHaveBeenLastCalledWith({ + ...mockQuery, + azureMonitor: { + ...mockQuery.azureMonitor, + aggregation: 'Maximum', + }, + }); }); }); - - it('should change the resource group when selected', async () => { - const mockDatasource = createMockDatasource(); - const onChange = jest.fn(); - const mockQuery = createMockQuery(); - mockDatasource.getResourceGroups = jest.fn().mockResolvedValue([ - { text: 'grafanastaging', value: 'grafanastaging' }, - { text: 'Grafana Prod', value: 'grafanaprod' }, - ]); - render( - {}} - /> - ); - await waitFor(() => expect(screen.getByTestId('azure-monitor-metrics-query-editor')).toBeInTheDocument()); - - const resourceGroup = await screen.findByLabelText('Resource group'); - await selectOptionInTest(resourceGroup, 'Grafana Prod'); - - expect(onChange).toHaveBeenLastCalledWith({ - ...mockQuery, - azureMonitor: { - ...mockQuery.azureMonitor, - resourceUri: '', - resourceGroup: 'grafanaprod', - metricDefinition: undefined, - metricNamespace: undefined, - resourceName: undefined, - metricName: undefined, - aggregation: undefined, - timeGrain: '', - dimensionFilters: [], - }, - }); - }); - - it('should change the resource type when selected', async () => { - const mockDatasource = createMockDatasource(); - const onChange = jest.fn(); - const mockQuery = createMockQuery(); - mockDatasource.getMetricDefinitions = jest.fn().mockResolvedValue([ - { text: 'Virtual Machine', value: 'azure/vm' }, - { text: 'Database', value: 'azure/db' }, - ]); - render( - {}} - /> - ); - await waitFor(() => expect(screen.getByTestId('azure-monitor-metrics-query-editor')).toBeInTheDocument()); - - const resourceGroup = await screen.findByLabelText('Resource type'); - await selectOptionInTest(resourceGroup, 'Virtual Machine'); - - expect(onChange).toHaveBeenLastCalledWith({ - ...mockQuery, - azureMonitor: { - ...mockQuery.azureMonitor, - resourceUri: '', - metricDefinition: 'azure/vm', - resourceName: undefined, - metricNamespace: undefined, - metricName: undefined, - aggregation: undefined, - timeGrain: '', - dimensionFilters: [], - }, - }); - }); - - it('should change the resource name when selected', async () => { - const mockDatasource = createMockDatasource(); - const onChange = jest.fn(); - const mockQuery = createMockQuery(); - mockDatasource.getResourceNames = jest.fn().mockResolvedValue([ - { text: 'ResourceName1', value: 'resource-name-1' }, - { text: 'ResourceName2', value: 'resource-name-2' }, - ]); - render( - {}} - /> - ); - await waitFor(() => expect(screen.getByTestId('azure-monitor-metrics-query-editor')).toBeInTheDocument()); - - const resourceGroup = await screen.findByLabelText('Resource name'); - await selectOptionInTest(resourceGroup, 'ResourceName1'); - - expect(onChange).toHaveBeenLastCalledWith({ - ...mockQuery, - azureMonitor: { - ...mockQuery.azureMonitor, - resourceUri: '', - resourceName: 'resource-name-1', - metricNamespace: undefined, - metricName: undefined, - aggregation: undefined, - timeGrain: '', - dimensionFilters: [], - }, - }); - }); - - it('should change the metric name when selected', async () => { - const mockDatasource = createMockDatasource(); - const onChange = jest.fn(); - const mockQuery = createMockQuery(); - mockDatasource.azureMonitorDatasource.getMetricNames = jest.fn().mockResolvedValue([ - { - value: 'metric-a', - text: 'Metric A', - }, - { - value: 'metric-b', - text: 'Metric B', - }, - ]); - render( - {}} - /> - ); - await waitFor(() => expect(screen.getByTestId('azure-monitor-metrics-query-editor')).toBeInTheDocument()); - - const metrics = await screen.findByLabelText('Metric'); - await selectOptionInTest(metrics, 'Metric B'); - - expect(onChange).toHaveBeenLastCalledWith({ - ...mockQuery, - azureMonitor: { - ...mockQuery.azureMonitor, - metricName: 'metric-b', - aggregation: undefined, - timeGrain: '', - }, - }); - }); - - it('should change the aggregation type when selected', async () => { - const mockDatasource = createMockDatasource(); - const onChange = jest.fn(); - const mockQuery = createMockQuery(); - render( - {}} - /> - ); - await waitFor(() => expect(screen.getByTestId('azure-monitor-metrics-query-editor')).toBeInTheDocument()); - - const aggregation = await screen.findByLabelText('Aggregation'); - await selectOptionInTest(aggregation, 'Maximum'); - - expect(onChange).toHaveBeenLastCalledWith({ - ...mockQuery, - azureMonitor: { - ...mockQuery.azureMonitor, - aggregation: 'Maximum', - }, - }); - }); -}); +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/MetricsQueryEditor.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/MetricsQueryEditor.tsx index 6f082fe4390..c75e59029f7 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/MetricsQueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/MetricsQueryEditor.tsx @@ -1,37 +1,31 @@ +import { css } from '@emotion/css'; import React from 'react'; import { PanelData } from '@grafana/data/src/types'; -import { InlineFieldRow } from '@grafana/ui'; +import { EditorRows, EditorRow, EditorFieldGroup } from '@grafana/experimental'; +import { config } from '@grafana/runtime'; +import { InlineFieldRow, useStyles2 } from '@grafana/ui'; -import Datasource from '../../datasource'; -import { AzureMonitorQuery, AzureMonitorOption, AzureMonitorErrorish } from '../../types'; -import SubscriptionField from '../SubscriptionField'; +import type Datasource from '../../datasource'; +import type { AzureMonitorQuery, AzureMonitorOption, AzureMonitorErrorish } from '../../types'; +import ResourceField from '../ResourceField'; +import { ResourceRowType } from '../ResourcePicker/types'; import AggregationField from './AggregationField'; import DimensionFields from './DimensionFields'; import LegendFormatField from './LegendFormatField'; import MetricNameField from './MetricNameField'; import MetricNamespaceField from './MetricNamespaceField'; -import ResourceGroupsField from './ResourceGroupsField'; -import ResourceNameField from './ResourceNameField'; -import ResourceTypeField from './ResourceTypeField'; +import NewDimensionFields from './NewDimensionFields'; import TimeGrainField from './TimeGrainField'; import TopField from './TopField'; -import { - useMetricNames, - useMetricNamespaces, - useResourceGroups, - useResourceNames, - useResourceTypes, - useSubscriptions, - useMetricMetadata, -} from './dataHooks'; +import { useMetricNames, useMetricNamespaces, useMetricMetadata } from './dataHooks'; +import { setResource } from './setQueryValue'; interface MetricsQueryEditorProps { data: PanelData | undefined; query: AzureMonitorQuery; datasource: Datasource; - subscriptionId?: string; onChange: (newQuery: AzureMonitorQuery) => void; variableOptionGroup: { label: string; options: AzureMonitorOption[] }; setError: (source: string, error: AzureMonitorErrorish | undefined) => void; @@ -41,133 +35,194 @@ const MetricsQueryEditor: React.FC = ({ data, query, datasource, - subscriptionId, variableOptionGroup, onChange, setError, }) => { + const styles = useStyles2(getStyles); + const metricsMetadata = useMetricMetadata(query, datasource, onChange); - const subscriptions = useSubscriptions(query, datasource, onChange, setError); - const resourceGroups = useResourceGroups(query, datasource, onChange, setError); - const resourceTypes = useResourceTypes(query, datasource, onChange, setError); - const resourceNames = useResourceNames(query, datasource, onChange, setError); - const metricNames = useMetricNames(query, datasource, onChange, setError); const metricNamespaces = useMetricNamespaces(query, datasource, onChange, setError); + const metricNames = useMetricNames(query, datasource, onChange, setError); + if (config.featureToggles.azureMonitorExperimentalUI) { + return ( + + + + + + + - return ( -
- - + + + + + + + + + + + + + + + + + + + + + + + + + + ); + } else { + return ( +
+ + + - + + + + + + + + + - - - - - - - - - - - - - - - - - - -
- ); +
+ ); + } }; +const getStyles = () => ({ + row: css({ + rowGap: 0, + }), +}); + export default MetricsQueryEditor; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/ResourceGroupsField.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/ResourceGroupsField.tsx deleted file mode 100644 index fd9aa3fcfb2..00000000000 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/ResourceGroupsField.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; - -import { SelectableValue } from '@grafana/data'; -import { Select } from '@grafana/ui'; - -import { AzureQueryEditorFieldProps, AzureMonitorOption } from '../../types'; -import { Field } from '../Field'; - -import { setResourceGroup } from './setQueryValue'; - -interface ResourceGroupsFieldProps extends AzureQueryEditorFieldProps { - resourceGroups: AzureMonitorOption[]; -} - -const ResourceGroupsField: React.FC = ({ - query, - resourceGroups, - variableOptionGroup, - onQueryChange, - setError, -}) => { - const handleChange = useCallback( - (change: SelectableValue) => { - const newQuery = setResourceGroup(query, change.value); - onQueryChange(newQuery); - }, - [onQueryChange, query] - ); - - const options = useMemo(() => [...resourceGroups, variableOptionGroup], [resourceGroups, variableOptionGroup]); - - return ( - - - - ); -}; - -export default ResourceNameField; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/ResourceTypeField.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/ResourceTypeField.tsx deleted file mode 100644 index 3f1b2b5a69e..00000000000 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/ResourceTypeField.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; - -import { SelectableValue } from '@grafana/data'; -import { Select } from '@grafana/ui'; - -import { AzureQueryEditorFieldProps, AzureMonitorOption } from '../../types'; -import { Field } from '../Field'; - -import { setResourceType } from './setQueryValue'; - -interface NamespaceFieldProps extends AzureQueryEditorFieldProps { - resourceTypes: AzureMonitorOption[]; -} - -const NamespaceField: React.FC = ({ - resourceTypes, - query, - variableOptionGroup, - onQueryChange, -}) => { - const handleChange = useCallback( - (change: SelectableValue) => { - if (!change.value) { - return; - } - - const newQuery = setResourceType(query, change.value); - onQueryChange(newQuery); - }, - [onQueryChange, query] - ); - - const options = useMemo(() => [...resourceTypes, variableOptionGroup], [resourceTypes, variableOptionGroup]); - - return ( - - {/* It's expected that the label reads Resource type but the property is metricDefinition */} - + ); +}; From e046f14f36694e69a77289ad565ed43b2045cc82 Mon Sep 17 00:00:00 2001 From: Kevin Yu Date: Thu, 9 Jun 2022 09:22:44 -0700 Subject: [PATCH 16/40] Cloud Monitoring: Update AliasBy to use experimental UI components (#50461) --- .../components/AnnotationQueryEditor.tsx | 32 +++- .../components/Experimental/AliasBy.tsx | 32 ++++ .../Experimental/MetricQueryEditor.tsx | 159 ++++++++++++++++++ .../components/QueryEditor.tsx | 46 +++-- 4 files changed, 245 insertions(+), 24 deletions(-) create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/Experimental/AliasBy.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/Experimental/MetricQueryEditor.tsx diff --git a/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx index 806bac92516..76fa6c02b4e 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { useDebounce } from 'react-use'; import { QueryEditorProps, toOption } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { Input } from '@grafana/ui'; import { INPUT_WIDTH } from '../constants'; @@ -15,6 +16,7 @@ import { AlignmentTypes, } from '../types'; +import { MetricQueryEditor as ExperimentalMetricQueryEditor } from './Experimental/MetricQueryEditor'; import { MetricQueryEditor } from './MetricQueryEditor'; import { AnnotationsHelp, QueryEditorRow } from './'; @@ -78,15 +80,27 @@ export const AnnotationQueryEditor = (props: Props) => { return ( <> - + {config.featureToggles.cloudMonitoringExperimentalUI ? ( + + ) : ( + + )} diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Experimental/AliasBy.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Experimental/AliasBy.tsx new file mode 100644 index 00000000000..85c54b5e1ee --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/components/Experimental/AliasBy.tsx @@ -0,0 +1,32 @@ +import { debounce } from 'lodash'; +import React, { FunctionComponent, useState } from 'react'; + +import { EditorRow, EditorField } from '@grafana/experimental'; +import { Input } from '@grafana/ui'; + +import { SELECT_WIDTH } from '../../constants'; + +export interface Props { + refId: string; + onChange: (alias: any) => void; + value?: string; +} + +export const AliasBy: FunctionComponent = ({ refId, value = '', onChange }) => { + const [alias, setAlias] = useState(value ?? ''); + + const propagateOnChange = debounce(onChange, 1000); + + onChange = (e: any) => { + setAlias(e.target.value); + propagateOnChange(e.target.value); + }; + + return ( + + + + + + ); +}; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Experimental/MetricQueryEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Experimental/MetricQueryEditor.tsx new file mode 100644 index 00000000000..4f555ef6670 --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/components/Experimental/MetricQueryEditor.tsx @@ -0,0 +1,159 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { EditorRows } from '@grafana/experimental'; + +import CloudMonitoringDatasource from '../../datasource'; +import { getAlignmentPickerData } from '../../functions'; +import { + AlignmentTypes, + CustomMetaData, + EditorMode, + MetricDescriptor, + MetricKind, + MetricQuery, + PreprocessorType, + SLOQuery, + ValueTypes, +} from '../../types'; +import { Project, VisualMetricQueryEditor } from '../index'; + +import { GraphPeriod } from './../GraphPeriod'; +import { MQLQueryEditor } from './../MQLQueryEditor'; +import { AliasBy } from './AliasBy'; + +export interface Props { + refId: string; + customMetaData: CustomMetaData; + variableOptionGroup: SelectableValue; + onChange: (query: MetricQuery) => void; + onRunQuery: () => void; + query: MetricQuery; + datasource: CloudMonitoringDatasource; +} + +interface State { + labels: any; + [key: string]: any; +} + +export const defaultState: State = { + labels: {}, +}; + +export const defaultQuery: (dataSource: CloudMonitoringDatasource) => MetricQuery = (dataSource) => ({ + editorMode: EditorMode.Visual, + projectName: dataSource.getDefaultProject(), + metricType: '', + metricKind: MetricKind.GAUGE, + valueType: '', + crossSeriesReducer: 'REDUCE_MEAN', + alignmentPeriod: 'cloud-monitoring-auto', + perSeriesAligner: AlignmentTypes.ALIGN_MEAN, + groupBys: [], + filters: [], + aliasBy: '', + query: '', + preprocessor: PreprocessorType.None, +}); + +function Editor({ + refId, + query, + datasource, + onChange: onQueryChange, + onRunQuery, + customMetaData, + variableOptionGroup, +}: React.PropsWithChildren) { + const [state, setState] = useState(defaultState); + const { projectName, metricType, groupBys, editorMode, crossSeriesReducer } = query; + + useEffect(() => { + if (projectName && metricType) { + datasource + .getLabels(metricType, refId, projectName) + .then((labels) => setState((prevState) => ({ ...prevState, labels }))); + } + }, [datasource, groupBys, metricType, projectName, refId, crossSeriesReducer]); + + const onChange = useCallback( + (metricQuery: MetricQuery | SLOQuery) => { + onQueryChange({ ...query, ...metricQuery }); + onRunQuery(); + }, + [onQueryChange, onRunQuery, query] + ); + + const onMetricTypeChange = useCallback( + ({ valueType, metricKind, type }: MetricDescriptor) => { + const preprocessor = + metricKind === MetricKind.GAUGE || valueType === ValueTypes.DISTRIBUTION + ? PreprocessorType.None + : PreprocessorType.Rate; + const { perSeriesAligner } = getAlignmentPickerData(valueType, metricKind, state.perSeriesAligner, preprocessor); + onChange({ + ...query, + perSeriesAligner, + metricType: type, + valueType, + metricKind, + preprocessor, + }); + }, + [onChange, query, state] + ); + + return ( + + { + onChange({ ...query, projectName }); + }} + /> + + {editorMode === EditorMode.Visual && ( + + )} + + {editorMode === EditorMode.MQL && ( + <> + onQueryChange({ ...query, query: q })} + onRunQuery={onRunQuery} + query={query.query} + > + onQueryChange({ ...query, graphPeriod })} + graphPeriod={query.graphPeriod} + refId={refId} + variableOptionGroup={variableOptionGroup} + /> + + )} + + { + onChange({ ...query, aliasBy }); + }} + /> + + ); +} + +export const MetricQueryEditor = React.memo(Editor); diff --git a/public/app/plugins/datasource/cloud-monitoring/components/QueryEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/QueryEditor.tsx index 7dd2a810087..62fe9c3748f 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/QueryEditor.tsx @@ -2,12 +2,15 @@ import { css } from '@emotion/css'; import React, { PureComponent } from 'react'; import { QueryEditorProps, toOption } from '@grafana/data'; +import { EditorRows } from '@grafana/experimental'; +import { config } from '@grafana/runtime'; import { Button, Select } from '@grafana/ui'; import { QUERY_TYPES, SELECT_WIDTH } from '../constants'; import CloudMonitoringDatasource from '../datasource'; import { CloudMonitoringQuery, EditorMode, MetricQuery, QueryType, SLOQuery, CloudMonitoringOptions } from '../types'; +import { MetricQueryEditor as ExperimentalMetricQueryEditor } from './Experimental/MetricQueryEditor'; import { defaultQuery } from './MetricQueryEditor'; import { defaultQuery as defaultSLOQuery } from './SLO/SLOQueryEditor'; @@ -55,7 +58,7 @@ export class QueryEditor extends PureComponent { }; return ( - <> + { /> - {queryType === QueryType.METRICS && ( - { - this.props.onChange({ ...this.props.query, metricQuery }); - }} - onRunQuery={onRunQuery} - datasource={datasource} - query={metricQuery} - > - )} + {queryType === QueryType.METRICS && + (config.featureToggles.cloudMonitoringExperimentalUI ? ( + { + this.props.onChange({ ...this.props.query, metricQuery }); + }} + onRunQuery={onRunQuery} + datasource={datasource} + query={metricQuery} + /> + ) : ( + { + this.props.onChange({ ...this.props.query, metricQuery }); + }} + onRunQuery={onRunQuery} + datasource={datasource} + query={metricQuery} + /> + ))} {queryType === QueryType.SLO && ( { query={sloQuery} > )} - + ); } } From 4ed7ff2ed13f2b5ec79f0946670c945edc902d7b Mon Sep 17 00:00:00 2001 From: Joey Tawadrous <90795735+joey-grafana@users.noreply.github.com> Date: Thu, 9 Jun 2022 17:56:15 +0100 Subject: [PATCH 17/40] Traces: APM table (#48654) * APM table * Remove unnecessary changes in Explore.tsx * Enhancements * Show empty table if filter returns no results for table * Error checking * Combine extra filter options * Convert service map query to apm query for apm requests * Simplify links * Better query building * Extract method and clean up * Upgrade filter * Self review * Export method * Update test * Switch area-chart to lcd-gauge * Remove AreaChartCell * Colors, units and other UI upgrades * Remove 2 queries and reuse existing queries * Nested observables * rate/error rate/duration links: open new pane with range query and exemplars turned on * Align error rate values to rate values col according to span_name * Rearrange code * Several improvements * Filter by span_name * align the table col values to the same row name (rateName) across the table * On click tempo link filter by span_name * Filtering updates * Ensure serviceQuery null is ok * Update link expr * Update duration p90 unit * Tempo links case * Update tests * Self review * Tests * Empty state * Remove some code * Test: should build expr correctly * Test: should build link expr correctly * Test: should get rate aligned values correctly * Test: should make apm request correctly * Test: should make tempo link correctly * Move apm table tests to its own describe * Feature toggle * Added to docs for APM table * Add screenshot to APM table section in docs * Add feature toggle to test * Split queries and make method names clearer * Doc changes * Add back in config import removed by merge * Update tempo link and tests * Update filter * Set field filterable: false * Updated doc * Remove @end from queries * Add back in tempo link --- docs/sources/datasources/tempo.md | 27 +- .../src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 6 + pkg/services/featuremgmt/toggles_gen.go | 4 + .../datasource/prometheus/datasource.tsx | 5 +- .../prometheus/result_transformer.ts | 6 +- .../tempo/QueryEditor/QueryField.tsx | 3 + .../tempo/QueryEditor/ServiceGraphSection.tsx | 9 +- .../datasource/tempo/datasource.test.ts | 313 +++++++++++++-- .../plugins/datasource/tempo/datasource.ts | 357 +++++++++++++++++- .../datasource/tempo/graphTransform.ts | 13 + 11 files changed, 683 insertions(+), 61 deletions(-) diff --git a/docs/sources/datasources/tempo.md b/docs/sources/datasources/tempo.md index 63c03286255..861ca8f762d 100644 --- a/docs/sources/datasources/tempo.md +++ b/docs/sources/datasources/tempo.md @@ -174,10 +174,10 @@ To display the service graph: - [Configure the Grafana Agent](https://grafana.com/docs/tempo/next/grafana-agent/service-graphs/#quickstart) to generate service graph data - Link a Prometheus datasource in the Tempo datasource settings. -- Navigate to [Explore]({{< relref "../explore/" >}}) -- Select the Tempo datasource -- Select the **Service Graph** query type and run the query -- Optionally, filter by service name +- Navigate to [Explore]({{< relref "../explore/" >}}). +- Select the Tempo datasource. +- Select the **Service Graph** query type and run the query. +- (Optional): filter by service name. You can pan and zoom the view with buttons or you mouse. For details about the visualization, refer to [Node graph panel](https://grafana.com/docs/grafana/latest/panels/visualizations/node-graph/). @@ -192,6 +192,25 @@ The color of each circle represents the percentage of requests in each of the fo Click on the service to see a context menu with additional links for quick navigation to other relevant information. +## APM table + +The APM (Application Performance Management) table allows you to view several APM metrics out of the box. + +To display the APM table: + +1. Activate the tempoApmTable feature flag in your ini file. +1. Link a Prometheus datasource in the Tempo datasource settings. +1. Navigate to [Explore]({{< relref "../explore/_index.md" >}}). +1. Select the Tempo datasource. +1. Select the **Service Graph** query type and run the query. +1. (Optional): filter your results. + +Note: The metric traces_spanmetrics_calls_total is used to display the name, rate & error rate columns and traces_spanmetrics_duration_seconds_bucket is used to display the duration column (these metrics will need to exist in your Prometheus datasource). + +Click a row in the rate, error rate, or duration columns to open a query in Prometheus with the span name of that row automatically set in the query. Click a row in the links column to open a query in Tempo with the span name of that row automatically set in the query. + +{{< figure src="/static/img/docs/tempo/apm-table.png" class="docs-image--no-shadow" max-width="500px" caption="Screenshot of the Tempo APM table" >}} + ## Linking Trace ID from logs You can link to Tempo trace from logs in Loki or Elastic by configuring an internal link. See the [Derived fields]({{< relref "loki/#derived-fields" >}}) section in the [Loki data source]({{< relref "loki/" >}}) or [Data links]({{< relref "elasticsearch/#data-links" >}}) section in the [Elastic data source]({{< relref "elasticsearch/" >}}) for configuration instructions. diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index bf5c011f15c..e3b794c181a 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -28,6 +28,7 @@ export interface FeatureToggles { queryOverLive?: boolean; panelTitleSearch?: boolean; tempoServiceGraph?: boolean; + tempoApmTable?: boolean; prometheus_azure_auth?: boolean; prometheusAzureOverrideAudience?: boolean; influxdbBackendMigration?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 621d4553bf6..5a95129680b 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -72,6 +72,12 @@ var ( State: FeatureStateBeta, FrontendOnly: true, }, + { + Name: "tempoApmTable", + Description: "Show APM table", + State: FeatureStateAlpha, + FrontendOnly: true, + }, { Name: "prometheus_azure_auth", Description: "Experimental. Azure authentication for Prometheus datasource", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 624ca68c387..a911fcbacae 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -55,6 +55,10 @@ const ( // show service FlagTempoServiceGraph = "tempoServiceGraph" + // FlagTempoApmTable + // Show APM table + FlagTempoApmTable = "tempoApmTable" + // FlagPrometheusAzureAuth // Experimental. Azure authentication for Prometheus datasource FlagPrometheusAzureAuth = "prometheus_azure_auth" diff --git a/public/app/plugins/datasource/prometheus/datasource.tsx b/public/app/plugins/datasource/prometheus/datasource.tsx index 4d93ca7a861..daf59a796f3 100644 --- a/public/app/plugins/datasource/prometheus/datasource.tsx +++ b/public/app/plugins/datasource/prometheus/datasource.tsx @@ -837,7 +837,10 @@ export class PrometheusDatasource const seriesLabels: Array> = await Promise.all( options.series.map((series: string) => this.languageProvider.fetchSeriesLabels(series)) ); - const uniqueLabels = [...new Set(...seriesLabels.map((value) => Object.keys(value)))]; + // Combines tags from all options.series provided + let tags: string[] = []; + seriesLabels.map((value) => (tags = tags.concat(Object.keys(value)))); + const uniqueLabels = [...new Set(tags)]; return uniqueLabels.map((value: any) => ({ text: value })); } else { // Get all tags diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts index ced3875776d..4265090372d 100644 --- a/public/app/plugins/datasource/prometheus/result_transformer.ts +++ b/public/app/plugins/datasource/prometheus/result_transformer.ts @@ -19,7 +19,6 @@ import { DataQueryResponse, DataQueryRequest, PreferredVisualisationType, - CoreApp, DataFrameType, } from '@grafana/data'; import { FetchResponse, getDataSourceSrv, getTemplateSrv } from '@grafana/runtime'; @@ -48,10 +47,7 @@ interface TimeAndValue { const isTableResult = (dataFrame: DataFrame, options: DataQueryRequest): boolean => { // We want to process vector and scalar results in Explore as table - if ( - options.app === CoreApp.Explore && - (dataFrame.meta?.custom?.resultType === 'vector' || dataFrame.meta?.custom?.resultType === 'scalar') - ) { + if (dataFrame.meta?.custom?.resultType === 'vector' || dataFrame.meta?.custom?.resultType === 'scalar') { return true; } diff --git a/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx b/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx index 54dca766bc9..16575b152e5 100644 --- a/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx +++ b/public/app/plugins/datasource/tempo/QueryEditor/QueryField.tsx @@ -79,6 +79,9 @@ class TempoQueryFieldComponent extends React.PureComponent { if (config.featureToggles.tempoServiceGraph) { queryTypeOptions.push({ value: 'serviceMap', label: 'Service Graph' }); + // span names in Tempo search links (generated on the service graph page) are in camel case (for Prometheus queries) + // but the span name dropdown menu in the search tab is lower case + query.spanName = query.spanName?.toLowerCase(); } if (!datasource?.search?.hide) { diff --git a/public/app/plugins/datasource/tempo/QueryEditor/ServiceGraphSection.tsx b/public/app/plugins/datasource/tempo/QueryEditor/ServiceGraphSection.tsx index 494a4873b72..8c68c39fe13 100644 --- a/public/app/plugins/datasource/tempo/QueryEditor/ServiceGraphSection.tsx +++ b/public/app/plugins/datasource/tempo/QueryEditor/ServiceGraphSection.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { Alert, InlineField, InlineFieldRow, useStyles2 } from '@grafana/ui'; import { AdHocFilter } from '../../../../features/variables/adhoc/picker/AdHocFilter'; @@ -71,11 +72,9 @@ export function ServiceGraphSection({ datasource={{ uid: graphDatasourceUid }} filters={filters} getTagKeysOptions={{ - series: [ - 'traces_service_graph_request_server_seconds_sum', - 'traces_service_graph_request_total', - 'traces_service_graph_request_failed_total', - ], + series: config.featureToggles.tempoApmTable + ? ['traces_service_graph_request_total', 'traces_spanmetrics_calls_total'] + : ['traces_service_graph_request_total'], }} addFilter={(filter: AdHocVariableFilter) => { onChange({ diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts index 35f39b654ba..a4418ac6289 100644 --- a/public/app/plugins/datasource/tempo/datasource.test.ts +++ b/public/app/plugins/datasource/tempo/datasource.test.ts @@ -2,6 +2,7 @@ import { lastValueFrom, Observable, of } from 'rxjs'; import { createFetchResponse } from 'test/helpers/createFetchResponse'; import { + ArrayVector, DataFrame, dataFrameToJSON, DataSourceInstanceSettings, @@ -12,8 +13,19 @@ import { PluginType, } from '@grafana/data'; import { BackendDataSourceResponse, FetchResponse, setBackendSrv, setDataSourceSrv } from '@grafana/runtime'; +import config from 'app/core/config'; -import { DEFAULT_LIMIT, TempoJsonData, TempoDatasource, TempoQuery } from './datasource'; +import { + DEFAULT_LIMIT, + TempoJsonData, + TempoDatasource, + TempoQuery, + buildExpr, + buildLinkExpr, + getRateAlignedValues, + makeApmRequest, + makeTempoLink, +} from './datasource'; import mockJson from './mockJsonResponse.json'; import mockServiceGraph from './mockServiceGraph.json'; @@ -156,34 +168,6 @@ describe('Tempo data source', () => { ]); }); - it('runs service graph queries', async () => { - const ds = new TempoDatasource({ - ...defaultSettings, - jsonData: { - serviceMap: { - datasourceUid: 'prom', - }, - }, - }); - setDataSourceSrv(backendSrvWithPrometheus as any); - const response = await lastValueFrom( - ds.query({ targets: [{ queryType: 'serviceMap' }], range: getDefaultTimeRange() } as any) - ); - - expect(response.data).toHaveLength(2); - expect(response.data[0].name).toBe('Nodes'); - expect(response.data[0].fields[0].values.length).toBe(3); - - // Test Links - expect(response.data[0].fields[0].config.links.length).toBe(4); - expect(response.data[0].fields[0].config.links).toEqual(serviceGraphLinks); - - expect(response.data[1].name).toBe('Edges'); - expect(response.data[1].fields[0].values.length).toBe(2); - - expect(response.state).toBe(LoadingState.Done); - }); - it('should handle json file upload', async () => { const ds = new TempoDatasource(defaultSettings); ds.uploadedJson = JSON.stringify(mockJson); @@ -356,12 +340,235 @@ describe('Tempo data source', () => { }); }); +describe('Tempo apm table', () => { + it('runs service graph queries', async () => { + const ds = new TempoDatasource({ + ...defaultSettings, + jsonData: { + serviceMap: { + datasourceUid: 'prom', + }, + }, + }); + config.featureToggles.tempoApmTable = true; + setDataSourceSrv(backendSrvWithPrometheus as any); + const response = await lastValueFrom( + ds.query({ targets: [{ queryType: 'serviceMap' }], range: getDefaultTimeRange() } as any) + ); + + expect(response.data).toHaveLength(3); + expect(response.state).toBe(LoadingState.Done); + + // APM table + expect(response.data[0].fields[0].name).toBe('Name'); + expect(response.data[0].fields[0].values.toArray().length).toBe(2); + expect(response.data[0].fields[0].values.toArray()[0]).toBe('HTTP Client'); + expect(response.data[0].fields[0].values.toArray()[1]).toBe('HTTP GET - root'); + + expect(response.data[0].fields[1].name).toBe('Rate'); + expect(response.data[0].fields[1].values.toArray().length).toBe(2); + expect(response.data[0].fields[1].values.toArray()[0]).toBe(12.75164671814457); + expect(response.data[0].fields[1].values.toArray()[1]).toBe(12.121331111401608); + expect(response.data[0].fields[1].config.decimals).toBe(2); + expect(response.data[0].fields[1].config.links[0].title).toBe('Rate'); + expect(response.data[0].fields[1].config.links[0].internal.query.expr).toBe( + 'topk(5, sum(rate(traces_spanmetrics_calls_total{span_name="${__data.fields[0]}"}[$__rate_interval])) by (span_name))' + ); + expect(response.data[0].fields[1].config.links[0].internal.query.range).toBe(true); + expect(response.data[0].fields[1].config.links[0].internal.query.exemplar).toBe(true); + expect(response.data[0].fields[1].config.links[0].internal.query.instant).toBe(false); + + expect(response.data[0].fields[2].values.toArray().length).toBe(2); + expect(response.data[0].fields[2].values.toArray()[0]).toBe(12.75164671814457); + expect(response.data[0].fields[2].values.toArray()[1]).toBe(12.121331111401608); + expect(response.data[0].fields[2].config.color.mode).toBe('continuous-BlPu'); + expect(response.data[0].fields[2].config.custom.displayMode).toBe('lcd-gauge'); + expect(response.data[0].fields[2].config.decimals).toBe(3); + + expect(response.data[0].fields[3].name).toBe('Error Rate'); + expect(response.data[0].fields[3].values.length).toBe(2); + expect(response.data[0].fields[3].values[0]).toBe(3.75164671814457); + expect(response.data[0].fields[3].values[1]).toBe(3.121331111401608); + expect(response.data[0].fields[3].config.decimals).toBe(2); + expect(response.data[0].fields[3].config.links[0].title).toBe('Error Rate'); + expect(response.data[0].fields[3].config.links[0].internal.query.expr).toBe( + 'topk(5, sum(rate(traces_spanmetrics_calls_total{span_status="STATUS_CODE_ERROR",span_name="${__data.fields[0]}"}[$__rate_interval])) by (span_name))' + ); + expect(response.data[0].fields[3].config.links[0].internal.query.range).toBe(true); + expect(response.data[0].fields[3].config.links[0].internal.query.exemplar).toBe(true); + expect(response.data[0].fields[3].config.links[0].internal.query.instant).toBe(false); + + expect(response.data[0].fields[4].values.length).toBe(2); + expect(response.data[0].fields[4].values[0]).toBe(3.75164671814457); + expect(response.data[0].fields[4].values[1]).toBe(3.121331111401608); + expect(response.data[0].fields[4].config.color.mode).toBe('continuous-RdYlGr'); + expect(response.data[0].fields[4].config.custom.displayMode).toBe('lcd-gauge'); + expect(response.data[0].fields[4].config.decimals).toBe(3); + + expect(response.data[0].fields[5].name).toBe('Duration (p90)'); + expect(response.data[0].fields[5].values.length).toBe(2); + expect(response.data[0].fields[5].values[0]).toBe('0'); + expect(response.data[0].fields[5].values[1]).toBe(0.12003505696757232); + expect(response.data[0].fields[5].config.unit).toBe('s'); + expect(response.data[0].fields[5].config.links[0].title).toBe('Duration'); + expect(response.data[0].fields[5].config.links[0].internal.query.expr).toBe( + 'histogram_quantile(.9, sum(rate(traces_spanmetrics_duration_seconds_bucket{span_status="STATUS_CODE_ERROR",span_name="${__data.fields[0]}"}[$__rate_interval])) by (le))' + ); + expect(response.data[0].fields[5].config.links[0].internal.query.range).toBe(true); + expect(response.data[0].fields[5].config.links[0].internal.query.exemplar).toBe(true); + expect(response.data[0].fields[5].config.links[0].internal.query.instant).toBe(false); + + expect(response.data[0].fields[6].config.links[0].url).toBe(''); + expect(response.data[0].fields[6].config.links[0].title).toBe('Tempo'); + expect(response.data[0].fields[6].config.links[0].internal.query.queryType).toBe('nativeSearch'); + expect(response.data[0].fields[6].config.links[0].internal.query.spanName).toBe('${__data.fields[0]}'); + + // Service graph + expect(response.data[1].name).toBe('Nodes'); + expect(response.data[1].fields[0].values.length).toBe(3); + expect(response.data[1].fields[0].config.links.length).toBeGreaterThan(0); + expect(response.data[1].fields[0].config.links).toEqual(serviceGraphLinks); + + expect(response.data[2].name).toBe('Edges'); + expect(response.data[2].fields[0].values.length).toBe(2); + }); + + it('should build expr correctly', () => { + let targets = { targets: [{ queryType: 'serviceMap' }] } as any; + let builtQuery = buildExpr( + { expr: 'topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))', params: [] }, + '', + targets + ); + expect(builtQuery).toBe('topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))'); + + builtQuery = buildExpr( + { + expr: 'topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))', + params: ['span_status="STATUS_CODE_ERROR"'], + }, + 'span_name=~"HTTP Client|HTTP GET|HTTP GET - root|HTTP POST|HTTP POST - post"', + targets + ); + expect(builtQuery).toBe( + 'topk(5, sum(rate(traces_spanmetrics_calls_total{span_status="STATUS_CODE_ERROR",span_name=~"HTTP Client|HTTP GET|HTTP GET - root|HTTP POST|HTTP POST - post"}[$__range])) by (span_name))' + ); + + builtQuery = buildExpr( + { + expr: 'histogram_quantile(.9, sum(rate(traces_spanmetrics_duration_seconds_bucket{}[$__range])) by (le))', + params: ['span_status="STATUS_CODE_ERROR"'], + }, + 'span_name=~"HTTP Client"', + targets + ); + expect(builtQuery).toBe( + 'histogram_quantile(.9, sum(rate(traces_spanmetrics_duration_seconds_bucket{span_status="STATUS_CODE_ERROR",span_name=~"HTTP Client"}[$__range])) by (le))' + ); + + targets = { targets: [{ queryType: 'serviceMap', serviceMapQuery: '{client="app",service="app"}' }] } as any; + builtQuery = buildExpr( + { expr: 'topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))', params: [] }, + '', + targets + ); + expect(builtQuery).toBe( + 'topk(5, sum(rate(traces_spanmetrics_calls_total{service="app",service="app"}[$__range])) by (span_name))' + ); + }); + + it('should build link expr correctly', () => { + let builtQuery = buildLinkExpr('topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))'); + expect(builtQuery).toBe('topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__rate_interval])) by (span_name))'); + }); + + it('should get rate aligned values correctly', () => { + const resp = [ + { + refId: + 'topk(5, sum(rate(traces_spanmetrics_calls_total{service="app",service="app"}[$__range])) by (span_name))', + fields: [ + { + name: 'Time', + type: 'time', + config: {}, + values: [1653828275000, 1653828275000, 1653828275000, 1653828275000, 1653828275000], + }, + { + name: 'span_name', + config: { + filterable: true, + }, + type: 'string', + values: new ArrayVector(['HTTP Client', 'HTTP GET', 'HTTP GET - root', 'HTTP POST', 'HTTP POST - post']), + }, + ], + }, + ]; + const objToAlign = { + 'HTTP GET - root': { + name: 'HTTP GET - root', + value: 0.2724936652307618, + }, + 'HTTP GET': { + name: 'HTTP GET', + value: 0.2724936652307618, + }, + 'HTTP POST - post': { + name: 'HTTP POST - post', + value: 0.03697421858453128, + }, + }; + + let value = getRateAlignedValues(resp, objToAlign as any); + expect(value.toString()).toBe('0,0.2724936652307618,0.2724936652307618,0,0.03697421858453128'); + }); + + it('should make apm request correctly', () => { + const apmRequest = makeApmRequest([ + 'topk(5, sum(rate(traces_spanmetrics_calls_total{service="app"}[$__range])) by (span_name))"', + 'histogram_quantile(.9, sum(rate(traces_spanmetrics_duration_seconds_bucket{span_status="STATUS_CODE_ERROR",service="app",service="app",span_name=~"HTTP Client"}[$__range])) by (le))', + ]); + expect(apmRequest).toEqual([ + { + refId: 'topk(5, sum(rate(traces_spanmetrics_calls_total{service="app"}[$__range])) by (span_name))"', + expr: 'topk(5, sum(rate(traces_spanmetrics_calls_total{service="app"}[$__range])) by (span_name))"', + instant: true, + }, + { + refId: + 'histogram_quantile(.9, sum(rate(traces_spanmetrics_duration_seconds_bucket{span_status="STATUS_CODE_ERROR",service="app",service="app",span_name=~"HTTP Client"}[$__range])) by (le))', + expr: 'histogram_quantile(.9, sum(rate(traces_spanmetrics_duration_seconds_bucket{span_status="STATUS_CODE_ERROR",service="app",service="app",span_name=~"HTTP Client"}[$__range])) by (le))', + instant: true, + }, + ]); + }); + + it('should make tempo link correctly', () => { + const tempoLink = makeTempoLink('Tempo', '', '"${__data.fields[0]}"', 'gdev-tempo'); + expect(tempoLink).toEqual({ + url: '', + title: 'Tempo', + internal: { + query: { + queryType: 'nativeSearch', + spanName: '"${__data.fields[0]}"', + }, + datasourceUid: 'gdev-tempo', + datasourceName: 'Tempo', + }, + }); + }); +}); + const backendSrvWithPrometheus = { async get(uid: string) { if (uid === 'prom') { return { query() { - return of({ data: [totalsPromMetric, secondsPromMetric, failedPromMetric] }); + return of({ + data: [rateMetric, errorRateMetric, durationMetric, totalsPromMetric, secondsPromMetric, failedPromMetric], + }); }, }; } @@ -406,6 +613,43 @@ const defaultSettings: DataSourceInstanceSettings = { }, }; +const rateMetric = new MutableDataFrame({ + refId: 'topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))', + fields: [ + { name: 'Time', values: [1653725618609, 1653725618609] }, + { name: 'span_name', values: ['HTTP Client', 'HTTP GET - root'] }, + { + name: 'Value #topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))', + values: [12.75164671814457, 12.121331111401608], + }, + ], +}); + +const errorRateMetric = new MutableDataFrame({ + refId: + 'topk(5, sum(rate(traces_spanmetrics_calls_total{span_status="STATUS_CODE_ERROR",span_name=~"HTTP Client|HTTP GET - root"}[$__range])) by (span_name))', + fields: [ + { name: 'Time', values: [1653725618609, 1653725618609] }, + { name: 'span_name', values: ['HTTP Client', 'HTTP GET - root'] }, + { + name: 'Value #topk(5, sum(rate(traces_spanmetrics_calls_total{span_status="STATUS_CODE_ERROR"}[$__range])) by (span_name))', + values: [3.75164671814457, 3.121331111401608], + }, + ], +}); + +const durationMetric = new MutableDataFrame({ + refId: + 'histogram_quantile(.9, sum(rate(traces_spanmetrics_duration_seconds_bucket{span_status="STATUS_CODE_ERROR",span_name=~"HTTP GET - root"}[$__range])) by (le))', + fields: [ + { name: 'Time', values: [1653725618609] }, + { + name: 'Value #histogram_quantile(.9, sum(rate(traces_spanmetrics_duration_seconds_bucket{span_status="STATUS_CODE_ERROR",span_name=~"HTTP GET - root"}[$__range])) by (le))', + values: [0.12003505696757232], + }, + ], +}); + const totalsPromMetric = new MutableDataFrame({ refId: 'traces_service_graph_request_total', fields: [ @@ -485,6 +729,9 @@ const serviceGraphLinks = [ internal: { query: { expr: 'rate(traces_service_graph_request_total{server="${__data.fields.id}"}[$__rate_interval])', + instant: false, + range: true, + exemplar: true, }, datasourceUid: 'prom', datasourceName: 'Prometheus', @@ -496,6 +743,9 @@ const serviceGraphLinks = [ internal: { query: { expr: 'histogram_quantile(0.9, sum(rate(traces_service_graph_request_server_seconds_bucket{server="${__data.fields.id}"}[$__rate_interval])) by (le, client, server))', + instant: false, + range: true, + exemplar: true, }, datasourceUid: 'prom', datasourceName: 'Prometheus', @@ -507,6 +757,9 @@ const serviceGraphLinks = [ internal: { query: { expr: 'rate(traces_service_graph_request_failed_total{server="${__data.fields.id}"}[$__rate_interval])', + instant: false, + range: true, + exemplar: true, }, datasourceUid: 'prom', datasourceName: 'Prometheus', diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index b85f9314f35..e00edda69f2 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -1,19 +1,22 @@ import { identity, pick, pickBy, groupBy, startCase } from 'lodash'; import { EMPTY, from, merge, Observable, of, throwError } from 'rxjs'; -import { catchError, map, mergeMap, toArray } from 'rxjs/operators'; +import { catchError, concatMap, map, mergeMap, toArray } from 'rxjs/operators'; import { DataQuery, DataQueryRequest, DataQueryResponse, + DataQueryResponseData, DataSourceApi, DataSourceInstanceSettings, DataSourceJsonData, + FieldType, isValidGoDuration, LoadingState, ScopedVars, } from '@grafana/data'; import { + config, BackendSrvRequest, DataSourceWithBackend, getBackendSrv, @@ -36,6 +39,9 @@ import { mapPromMetricsToServiceMap, serviceMapMetrics, totalsMetric, + rateMetric, + durationMetric, + errorRateMetric, } from './graphTransform'; import { transformTrace, @@ -208,7 +214,20 @@ export class TempoDatasource extends DataSourceWithBackend 0) { - subQueries.push(serviceMapQuery(options, this.serviceMap.datasourceUid, this.name)); + const dsId = this.serviceMap.datasourceUid; + if (config.featureToggles.tempoApmTable) { + subQueries.push( + serviceMapQuery(options, dsId, this.name).pipe( + concatMap((result) => + rateQuery(options, result, dsId).pipe( + concatMap((result) => errorAndDurationQuery(options, result, dsId, this.name)) + ) + ) + ) + ); + } else { + subQueries.push(serviceMapQuery(options, dsId, this.name)); + } } if (targets.traceId?.length > 0) { @@ -394,7 +413,7 @@ export class TempoDatasource extends DataSourceWithBackend, datasourceUid: string) { +function queryPrometheus(request: DataQueryRequest, datasourceUid: string) { return from(getDatasourceSrv().get(datasourceUid)).pipe( mergeMap((ds) => { return (ds as PrometheusDatasource).query(request); @@ -403,7 +422,9 @@ function queryServiceMapPrometheus(request: DataQueryRequest, datasou } function serviceMapQuery(request: DataQueryRequest, datasourceUid: string, tempoDatasourceUid: string) { - return queryServiceMapPrometheus(makePromServiceMapRequest(request), datasourceUid).pipe( + const serviceMapRequest = makePromServiceMapRequest(request); + + return queryPrometheus(serviceMapRequest, datasourceUid).pipe( // Just collect all the responses first before processing into node graph data toArray(), map((responses: DataQueryResponse[]) => { @@ -418,19 +439,22 @@ function serviceMapQuery(request: DataQueryRequest, datasourceUid: s makePromLink( 'Request rate', `rate(${totalsMetric}{server="\${__data.fields.id}"}[$__rate_interval])`, - datasourceUid + datasourceUid, + false ), makePromLink( 'Request histogram', `histogram_quantile(0.9, sum(rate(${histogramMetric}{server="\${__data.fields.id}"}[$__rate_interval])) by (le, client, server))`, - datasourceUid + datasourceUid, + false ), makePromLink( 'Failed request rate', `rate(${failedMetric}{server="\${__data.fields.id}"}[$__rate_interval])`, - datasourceUid + datasourceUid, + false ), - makeTempoLink('View traces', `\${__data.fields[0]}`, tempoDatasourceUid), + makeTempoLink('View traces', `\${__data.fields[0]}`, '', tempoDatasourceUid), ], }; @@ -442,13 +466,99 @@ function serviceMapQuery(request: DataQueryRequest, datasourceUid: s ); } -function makePromLink(title: string, metric: string, datasourceUid: string) { +function rateQuery( + request: DataQueryRequest, + serviceMapResponse: DataQueryResponse, + datasourceUid: string +) { + const serviceMapRequest = makePromServiceMapRequest(request); + serviceMapRequest.targets = makeApmRequest([buildExpr(rateMetric, '', request)]); + + return queryPrometheus(serviceMapRequest, datasourceUid).pipe( + toArray(), + map((responses: DataQueryResponse[]) => { + const errorRes = responses.find((res) => !!res.error); + if (errorRes) { + throw new Error(errorRes.error!.message); + } + return { + data: [responses[0]?.data ?? [], serviceMapResponse.data[0], serviceMapResponse.data[1]], + state: LoadingState.Done, + }; + }) + ); +} + +// we need the response from the rate query to get the rate span_name(s), +// -> which determine the errorRate/duration span_name(s) we need to query +function errorAndDurationQuery( + request: DataQueryRequest, + rateResponse: DataQueryResponse, + datasourceUid: string, + tempoDatasourceUid: string +) { + let apmMetrics = []; + let errorRateBySpanName = ''; + let durationsBySpanName: string[] = []; + const spanNames = rateResponse.data[0][0]?.fields[1]?.values.toArray() ?? []; + + if (spanNames.length > 0) { + errorRateBySpanName = buildExpr(errorRateMetric, 'span_name=~"' + spanNames.join('|') + '"', request); + apmMetrics.push(errorRateBySpanName); + spanNames.map((name: string) => { + const metric = buildExpr(durationMetric, 'span_name=~"' + name + '"', request); + durationsBySpanName.push(metric); + apmMetrics.push(metric); + }); + } + + const serviceMapRequest = makePromServiceMapRequest(request); + serviceMapRequest.targets = makeApmRequest(apmMetrics); + + return queryPrometheus(serviceMapRequest, datasourceUid).pipe( + // Just collect all the responses first before processing into node graph data + toArray(), + map((errorAndDurationResponse: DataQueryResponse[]) => { + const errorRes = errorAndDurationResponse.find((res) => !!res.error); + if (errorRes) { + throw new Error(errorRes.error!.message); + } + + const apmTable = getApmTable( + request, + rateResponse, + errorAndDurationResponse[0], + errorRateBySpanName, + durationsBySpanName, + datasourceUid, + tempoDatasourceUid + ); + + if (apmTable.fields.length === 0) { + return { + data: [rateResponse.data[1], rateResponse.data[2]], + state: LoadingState.Done, + }; + } + + return { + data: [apmTable, rateResponse.data[1], rateResponse.data[2]], + state: LoadingState.Done, + }; + }) + ); +} + +function makePromLink(title: string, expr: string, datasourceUid: string, instant: boolean) { return { url: '', title, internal: { query: { - expr: metric, + expr: expr, + range: !instant, + exemplar: !instant, + instant: instant, } as PromQuery, datasourceUid, datasourceName: 'Prometheus', @@ -456,15 +566,20 @@ function makePromLink(title: string, metric: string, datasourceUid: string) { }; } -function makeTempoLink(title: string, serviceName: string, datasourceUid: string) { +export function makeTempoLink(title: string, serviceName: string, spanName: string, datasourceUid: string) { + let query = { queryType: 'nativeSearch' } as TempoQuery; + if (serviceName !== '') { + query.serviceName = serviceName; + } + if (spanName !== '') { + query.spanName = spanName; + } + return { url: '', title, internal: { - query: { - queryType: 'nativeSearch', - serviceName: serviceName, - } as TempoQuery, + query, datasourceUid: datasourceUid, datasourceName: 'Tempo', }, @@ -479,9 +594,219 @@ function makePromServiceMapRequest(options: DataQueryRequest): DataQ refId: metric, // options.targets[0] is not correct here, but not sure what should happen if you have multiple queries for // service map at the same time anyway - expr: `delta(${metric}${options.targets[0].serviceMapQuery || ''}[$__range])`, + expr: `rate(${metric}${options.targets[0].serviceMapQuery || ''}[$__range])`, instant: true, }; }), }; } + +function getApmTable( + request: DataQueryRequest, + rateResponse: DataQueryResponse, + secondResponse: DataQueryResponse, + errorRateBySpanName: string, + durationsBySpanName: string[], + datasourceUid: string, + tempoDatasourceUid: string +) { + let df: any = { fields: [] }; + const rate = rateResponse.data[0]?.filter((x: { refId: string }) => { + return x.refId === buildExpr(rateMetric, '', request); + }); + const errorRate = secondResponse.data.filter((x) => { + return x.refId === errorRateBySpanName; + }); + const duration = secondResponse.data.filter((x) => { + return durationsBySpanName.includes(x.refId); + }); + + if (rate.length > 0 && rate[0].fields?.length > 2) { + df.fields.push({ + ...rate[0].fields[1], + name: 'Name', + config: { + filterable: false, + }, + }); + + df.fields.push({ + ...rate[0].fields[2], + name: 'Rate', + config: { + links: [ + makePromLink( + 'Rate', + buildLinkExpr(buildExpr(rateMetric, 'span_name="${__data.fields[0]}"', request)), + datasourceUid, + false + ), + ], + decimals: 2, + }, + }); + + df.fields.push({ + ...rate[0].fields[2], + name: ' ', + labels: null, + config: { + color: { + mode: 'continuous-BlPu', + }, + custom: { + displayMode: 'lcd-gauge', + }, + decimals: 3, + }, + }); + } + + if (errorRate.length > 0 && errorRate[0].fields?.length > 2) { + const errorRateNames = errorRate[0].fields[1]?.values.toArray() ?? []; + const errorRateValues = errorRate[0].fields[2]?.values.toArray() ?? []; + let errorRateObj: any = {}; + errorRateNames.map((name: string, index: number) => { + errorRateObj[name] = { name: name, value: errorRateValues[index] }; + }); + + const values = getRateAlignedValues(rate, errorRateObj); + + df.fields.push({ + ...errorRate[0].fields[2], + name: 'Error Rate', + values: values, + config: { + links: [ + makePromLink( + 'Error Rate', + buildLinkExpr(buildExpr(errorRateMetric, 'span_name="${__data.fields[0]}"', request)), + datasourceUid, + false + ), + ], + decimals: 2, + }, + }); + + df.fields.push({ + ...errorRate[0].fields[2], + name: ' ', + values: values, + labels: null, + config: { + color: { + mode: 'continuous-RdYlGr', + }, + custom: { + displayMode: 'lcd-gauge', + }, + decimals: 3, + }, + }); + } + + if (duration.length > 0 && duration[0].fields?.length > 1) { + let durationObj: any = {}; + duration.map((d) => { + const delimiter = d.refId?.includes('span_name=~"') ? 'span_name=~"' : 'span_name="'; + const name = d.refId?.split(delimiter)[1].split('"}')[0]; + durationObj[name] = { name: name, value: d.fields[1].values.toArray()[0] }; + }); + + df.fields.push({ + ...duration[0].fields[1], + name: 'Duration (p90)', + values: getRateAlignedValues(rate, durationObj), + config: { + links: [ + makePromLink( + 'Duration', + buildLinkExpr(buildExpr(durationMetric, 'span_name="${__data.fields[0]}"', request)), + datasourceUid, + false + ), + ], + unit: 's', + }, + }); + } + + if (df.fields.length > 0 && df.fields[0].values) { + df.fields.push({ + name: 'Links', + type: FieldType.string, + values: df.fields[0].values.map(() => { + return 'Tempo'; + }), + config: { + links: [makeTempoLink('Tempo', '', `\${__data.fields[0]}`, tempoDatasourceUid)], + }, + }); + } + + return df; +} + +export function buildExpr( + metric: { expr: string; params: string[] }, + extraParams: string, + request: DataQueryRequest +) { + let serviceMapQuery = request.targets[0]?.serviceMapQuery?.replace('{', '').replace('}', '') ?? ''; + // map serviceGraph metric tags to APM metric tags + serviceMapQuery = serviceMapQuery.replace('client', 'service').replace('server', 'service'); + const metricParams = serviceMapQuery.includes('span_name') + ? metric.params.concat(serviceMapQuery) + : metric.params + .concat(serviceMapQuery) + .concat(extraParams) + .filter((item: string) => item); + return metric.expr.replace('{}', '{' + metricParams.join(',') + '}'); +} + +export function buildLinkExpr(expr: string) { + return expr.replace('__range', '__rate_interval'); +} + +// query result frames can come back in any order +// here we align the table col values to the same row name (rateName) across the table +export function getRateAlignedValues( + rateResp: DataQueryResponseData[], + objToAlign: { [x: string]: { value: string } } +) { + const rateNames = rateResp[0]?.fields[1]?.values.toArray().sort() ?? []; + let tempRateNames = rateNames; + let values: string[] = []; + + objToAlign = Object.keys(objToAlign) + .sort() + .reduce((obj: any, key) => { + obj[key] = objToAlign[key]; + return obj; + }, {}); + + for (let i = 0; i < rateNames.length; i++) { + if (tempRateNames[i]) { + if (tempRateNames[i] === Object.keys(objToAlign)[i]) { + values.push(objToAlign[Object.keys(objToAlign)[i]].value); + } else { + i--; + tempRateNames = tempRateNames.slice(1); + values.push('0'); + } + } + } + + return values; +} + +export function makeApmRequest(metrics: any[]) { + return metrics.map((metric) => { + return { + refId: metric, + expr: metric, + instant: true, + }; + }); +} diff --git a/public/app/plugins/datasource/tempo/graphTransform.ts b/public/app/plugins/datasource/tempo/graphTransform.ts index b90dda63a40..4a7b574d7e5 100644 --- a/public/app/plugins/datasource/tempo/graphTransform.ts +++ b/public/app/plugins/datasource/tempo/graphTransform.ts @@ -136,6 +136,19 @@ export const totalsMetric = 'traces_service_graph_request_total'; export const failedMetric = 'traces_service_graph_request_failed_total'; export const histogramMetric = 'traces_service_graph_request_server_seconds_bucket'; +export const rateMetric = { + expr: 'topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))', + params: [], +}; +export const errorRateMetric = { + expr: 'topk(5, sum(rate(traces_spanmetrics_calls_total{}[$__range])) by (span_name))', + params: ['span_status="STATUS_CODE_ERROR"'], +}; +export const durationMetric = { + expr: 'histogram_quantile(.9, sum(rate(traces_spanmetrics_duration_seconds_bucket{}[$__range])) by (le))', + params: ['span_status="STATUS_CODE_ERROR"'], +}; + export const serviceMapMetrics = [ secondsMetric, totalsMetric, From ef401f5d62d955a0c76e85ba11ac6663cfd4bf1b Mon Sep 17 00:00:00 2001 From: Artur Wierzbicki Date: Thu, 9 Jun 2022 21:09:06 +0400 Subject: [PATCH 18/40] Storage: improve path validation, add tests (#50441) * improve path validation * add test case --- pkg/infra/filestorage/api.go | 47 +++++++++++++++++- pkg/infra/filestorage/api_test.go | 79 +++++++++++++++++++++++++++++++ pkg/infra/filestorage/wrapper.go | 33 +------------ 3 files changed, 126 insertions(+), 33 deletions(-) diff --git a/pkg/infra/filestorage/api.go b/pkg/infra/filestorage/api.go index 730186fad2c..645ae462cf9 100644 --- a/pkg/infra/filestorage/api.go +++ b/pkg/infra/filestorage/api.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "path/filepath" "regexp" "strings" "time" @@ -13,13 +14,57 @@ var ( ErrRelativePath = errors.New("path cant be relative") ErrNonCanonicalPath = errors.New("path must be canonical") ErrPathTooLong = errors.New("path is too long") - ErrPathInvalid = errors.New("path is invalid") + ErrInvalidCharacters = errors.New("path contains unsupported characters") ErrPathEndsWithDelimiter = errors.New("path can not end with delimiter") + ErrPathPartTooLong = errors.New("path part is too long") + ErrEmptyPathPart = errors.New("path can not have empty parts") Delimiter = "/" DirectoryMimeType = "directory" multipleDelimiters = regexp.MustCompile(`/+`) + pathRegex = regexp.MustCompile(`(^/$)|(^(/[A-Za-z\d!\-_.*'() ]+)+$)`) + maxPathLength = 1024 + maxPathPartLength = 256 ) +func ValidatePath(path string) error { + if !filepath.IsAbs(path) { + return ErrRelativePath + } + + if path == Delimiter { + return nil + } + + if strings.HasSuffix(path, Delimiter) { + return ErrPathEndsWithDelimiter + } + + if filepath.Clean(path) != path { + return ErrNonCanonicalPath + } + + if len(path) > maxPathLength { + return ErrPathTooLong + } + + for _, part := range strings.Split(strings.TrimPrefix(path, Delimiter), Delimiter) { + if strings.TrimSpace(part) == "" { + return ErrEmptyPathPart + } + + if len(part) > maxPathPartLength { + return ErrPathPartTooLong + } + } + + matches := pathRegex.MatchString(path) + if !matches { + return ErrInvalidCharacters + } + + return nil +} + func Join(parts ...string) string { joinedPath := Delimiter + strings.Join(parts, Delimiter) diff --git a/pkg/infra/filestorage/api_test.go b/pkg/infra/filestorage/api_test.go index ca557e3a140..f22393bdd36 100644 --- a/pkg/infra/filestorage/api_test.go +++ b/pkg/infra/filestorage/api_test.go @@ -1,6 +1,7 @@ package filestorage import ( + "strings" "testing" "github.com/stretchr/testify/require" @@ -34,3 +35,81 @@ func TestFilestorageApi_Join(t *testing.T) { }) } } + +func pathPart(length int) string { + sb := strings.Builder{} + for i := 0; i < length; i++ { + sb.WriteString("a") + } + return sb.String() +} + +func TestFilestorageApi_ValidatePath(t *testing.T) { + var tests = []struct { + path string + expectedError error + }{ + { + path: "abc/file.jpg", + expectedError: ErrRelativePath, + }, + { + path: "../abc/file.jpg", + expectedError: ErrRelativePath, + }, + { + path: "/abc/./file.jpg", + expectedError: ErrNonCanonicalPath, + }, + { + path: "/abc/../abc/file.jpg", + expectedError: ErrNonCanonicalPath, + }, + { + path: "/abc//folder/file.jpg", + expectedError: ErrNonCanonicalPath, + }, + { + path: "/abc/file.jpg/", + expectedError: ErrPathEndsWithDelimiter, + }, + { + path: "/abc/folder/ ", + expectedError: ErrEmptyPathPart, + }, + { + path: "/abc/ /file.jpg", + expectedError: ErrEmptyPathPart, + }, + { + path: "/abc/" + pathPart(260) + "/file.jpg", + expectedError: ErrPathPartTooLong, + }, + { + path: "/abc/" + pathPart(1050) + "/file.jpg", + expectedError: ErrPathTooLong, + }, + { + path: "/abc/folder🚀/file.jpg", + expectedError: ErrInvalidCharacters, + }, + { + path: "/path/with/utf/char/at/the/end.jpg�", + expectedError: ErrInvalidCharacters, + }, + { + path: "/myFile/file.jpg", + }, + } + for _, tt := range tests { + if tt.expectedError == nil { + t.Run("path "+tt.path+" should be valid", func(t *testing.T) { + require.Nil(t, ValidatePath(tt.path)) + }) + } else { + t.Run("path "+tt.path+" should be invalid: "+tt.expectedError.Error(), func(t *testing.T) { + require.Equal(t, tt.expectedError, ValidatePath(tt.path)) + }) + } + } +} diff --git a/pkg/infra/filestorage/wrapper.go b/pkg/infra/filestorage/wrapper.go index 978777f3e7e..4165053dc74 100644 --- a/pkg/infra/filestorage/wrapper.go +++ b/pkg/infra/filestorage/wrapper.go @@ -5,7 +5,6 @@ import ( "fmt" "mime" "path/filepath" - "regexp" "strings" "github.com/grafana/grafana/pkg/infra/log" @@ -14,7 +13,6 @@ import ( var ( directoryMarker = ".___gf_dir_marker___" - pathRegex = regexp.MustCompile(`(^/$)|(^(/[A-Za-z0-9!\-_.*'() ]+)+$)`) ) type wrapper struct { @@ -100,37 +98,8 @@ func getName(path string) string { return split[len(split)-1] } -func validatePath(path string) error { - if !filepath.IsAbs(path) { - return ErrRelativePath - } - - if path == Delimiter { - return nil - } - - if filepath.Clean(path) != path { - return ErrNonCanonicalPath - } - - if strings.HasSuffix(path, Delimiter) { - return ErrPathEndsWithDelimiter - } - - if len(path) > 1000 { - return ErrPathTooLong - } - - matches := pathRegex.MatchString(path) - if !matches { - return ErrPathInvalid - } - - return nil -} - func (b wrapper) validatePath(path string) error { - if err := validatePath(path); err != nil { + if err := ValidatePath(path); err != nil { b.log.Error("Path failed validation", "path", path, "error", err) return err } From f376c339039a94d37968fc7bb8f3452e2798f202 Mon Sep 17 00:00:00 2001 From: Michael Mandrus <41969079+mmandrus@users.noreply.github.com> Date: Thu, 9 Jun 2022 13:19:27 -0400 Subject: [PATCH 19/40] WIP: Add private Secret Manager Plugins support to plugin platform (#49544) * Add protobuf config and generated code, and client wrapper * wire up loading of secretsmanager plugin, using renderer plugin as a model * update kvstore provider to check if we should use the grpc plugin. return false always in OSS * add OSS remote plugin check * refactor wire gen file * log which secrets manager is being used * Fix argument types for remote checker * Turns out if err != nil, then the result is always nil. Return empty values if there is an error. * remove duplicate import * Update pkg/services/secrets/kvstore/kvstore.go Co-authored-by: Marcus Efraimsson * Update pkg/services/secrets/kvstore/kvstore.go Co-authored-by: Marcus Efraimsson * refactor RemotePluginCheck interface to just return the Plugin client directly * rename struct to something less silly * Update pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.proto Co-authored-by: Will Browne Co-authored-by: Marcus Efraimsson Co-authored-by: Will Browne --- .../backendplugin/grpcplugin/client.go | 39 +- .../backendplugin/grpcplugin/client_v2.go | 19 + .../backendplugin/provider/provider.go | 29 +- .../secretsmanagerplugin/generate.sh | 3 + .../secretsmanagerplugin/secretsmanager.pb.go | 786 ++++++++++++++++++ .../secretsmanagerplugin/secretsmanager.proto | 56 ++ .../secretsmanager_grcp_plugin.go | 56 ++ .../secretsmanager_grpc.pb.go | 249 ++++++ pkg/plugins/ifaces.go | 5 + .../loader/initializer/initializer_test.go | 30 + pkg/plugins/manager/manager.go | 11 + pkg/plugins/plugins.go | 26 +- pkg/server/wire.go | 1 + pkg/server/wireexts_oss.go | 3 + pkg/services/secrets/kvstore/kvstore.go | 19 +- pkg/services/secrets/kvstore/remote_plugin.go | 125 +++ .../secrets/kvstore/remote_plugin_check.go | 26 + 17 files changed, 1460 insertions(+), 23 deletions(-) create mode 100755 pkg/plugins/backendplugin/secretsmanagerplugin/generate.sh create mode 100644 pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go create mode 100644 pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.proto create mode 100644 pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grcp_plugin.go create mode 100644 pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go create mode 100644 pkg/services/secrets/kvstore/remote_plugin.go create mode 100644 pkg/services/secrets/kvstore/remote_plugin_check.go diff --git a/pkg/plugins/backendplugin/grpcplugin/client.go b/pkg/plugins/backendplugin/grpcplugin/client.go index 706c7e2504c..f8372c3fb0d 100644 --- a/pkg/plugins/backendplugin/grpcplugin/client.go +++ b/pkg/plugins/backendplugin/grpcplugin/client.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2" + "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin" goplugin "github.com/hashicorp/go-plugin" ) @@ -41,23 +42,28 @@ func newClientConfig(executablePath string, env []string, logger log.Logger, // StartRendererFunc callback function called when a renderer plugin is started. type StartRendererFunc func(pluginID string, renderer pluginextensionv2.RendererPlugin, logger log.Logger) error +// StartSecretsManagerFunc callback function called when a secrets manager plugin is started. +type StartSecretsManagerFunc func(pluginID string, secretsmanager secretsmanagerplugin.SecretsManagerPlugin, logger log.Logger) error + // PluginDescriptor is a descriptor used for registering backend plugins. type PluginDescriptor struct { - pluginID string - executablePath string - managed bool - versionedPlugins map[int]goplugin.PluginSet - startRendererFn StartRendererFunc + pluginID string + executablePath string + managed bool + versionedPlugins map[int]goplugin.PluginSet + startRendererFn StartRendererFunc + startSecretsManagerFn StartSecretsManagerFunc } // getV2PluginSet returns list of plugins supported on v2. func getV2PluginSet() goplugin.PluginSet { return goplugin.PluginSet{ - "diagnostics": &grpcplugin.DiagnosticsGRPCPlugin{}, - "resource": &grpcplugin.ResourceGRPCPlugin{}, - "data": &grpcplugin.DataGRPCPlugin{}, - "stream": &grpcplugin.StreamGRPCPlugin{}, - "renderer": &pluginextensionv2.RendererGRPCPlugin{}, + "diagnostics": &grpcplugin.DiagnosticsGRPCPlugin{}, + "resource": &grpcplugin.ResourceGRPCPlugin{}, + "data": &grpcplugin.DataGRPCPlugin{}, + "stream": &grpcplugin.StreamGRPCPlugin{}, + "renderer": &pluginextensionv2.RendererGRPCPlugin{}, + "secretsmanager": &secretsmanagerplugin.SecretsManagerGRPCPlugin{}, } } @@ -85,3 +91,16 @@ func NewRendererPlugin(pluginID, executablePath string, startFn StartRendererFun startRendererFn: startFn, }) } + +// NewSecetsManagerPlugin creates a new secrets manager plugin factory used for registering a backend secrets manager plugin. +func NewSecretsManagerPlugin(pluginID, executablePath string, startFn StartSecretsManagerFunc) backendplugin.PluginFactoryFunc { + return newPlugin(PluginDescriptor{ + pluginID: pluginID, + executablePath: executablePath, + managed: false, + versionedPlugins: map[int]goplugin.PluginSet{ + grpcplugin.ProtocolVersion: getV2PluginSet(), + }, + startSecretsManagerFn: startFn, + }) +} diff --git a/pkg/plugins/backendplugin/grpcplugin/client_v2.go b/pkg/plugins/backendplugin/grpcplugin/client_v2.go index 7f3f0f9b1a3..945f72c3f8c 100644 --- a/pkg/plugins/backendplugin/grpcplugin/client_v2.go +++ b/pkg/plugins/backendplugin/grpcplugin/client_v2.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2" + "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin" "github.com/hashicorp/go-plugin" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -23,6 +24,7 @@ type ClientV2 struct { grpcplugin.DataClient grpcplugin.StreamClient pluginextensionv2.RendererPlugin + secretsmanagerplugin.SecretsManagerPlugin } func newClientV2(descriptor PluginDescriptor, logger log.Logger, rpcClient plugin.ClientProtocol) (pluginClient, error) { @@ -51,6 +53,11 @@ func newClientV2(descriptor PluginDescriptor, logger log.Logger, rpcClient plugi return nil, err } + rawSecretsManager, err := rpcClient.Dispense("secretsmanager") + if err != nil { + return nil, err + } + c := ClientV2{} if rawDiagnostics != nil { if diagnosticsClient, ok := rawDiagnostics.(grpcplugin.DiagnosticsClient); ok { @@ -82,12 +89,24 @@ func newClientV2(descriptor PluginDescriptor, logger log.Logger, rpcClient plugi } } + if rawSecretsManager != nil { + if secretsManagerPlugin, ok := rawSecretsManager.(secretsmanagerplugin.SecretsManagerPlugin); ok { + c.SecretsManagerPlugin = secretsManagerPlugin + } + } + if descriptor.startRendererFn != nil { if err := descriptor.startRendererFn(descriptor.pluginID, c.RendererPlugin, logger); err != nil { return nil, err } } + if descriptor.startSecretsManagerFn != nil { + if err := descriptor.startSecretsManagerFn(descriptor.pluginID, c.SecretsManagerPlugin, logger); err != nil { + return nil, err + } + } + return &c, nil } diff --git a/pkg/plugins/backendplugin/provider/provider.go b/pkg/plugins/backendplugin/provider/provider.go index 406f1f70639..218d89df5e1 100644 --- a/pkg/plugins/backendplugin/provider/provider.go +++ b/pkg/plugins/backendplugin/provider/provider.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/grpcplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2" + "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin" ) // PluginBackendProvider is a function type for initializing a Plugin backend. @@ -24,7 +25,7 @@ type Service struct { func New(providers ...PluginBackendProvider) *Service { if len(providers) == 0 { - return New(RendererProvider, DefaultProvider) + return New(RendererProvider, SecretsManagerProvider, DefaultProvider) } return &Service{ providerChain: providers, @@ -32,7 +33,7 @@ func New(providers ...PluginBackendProvider) *Service { } func ProvideService(coreRegistry *coreplugin.Registry) *Service { - return New(coreRegistry.BackendFactoryProvider(), RendererProvider, DefaultProvider) + return New(coreRegistry.BackendFactoryProvider(), RendererProvider, SecretsManagerProvider, DefaultProvider) } func (s *Service) BackendFactory(ctx context.Context, p *plugins.Plugin) backendplugin.PluginFactoryFunc { @@ -56,6 +57,18 @@ var RendererProvider PluginBackendProvider = func(_ context.Context, p *plugins. ) } +var SecretsManagerProvider PluginBackendProvider = func(_ context.Context, p *plugins.Plugin) backendplugin.PluginFactoryFunc { + if !p.IsSecretsManager() { + return nil + } + return grpcplugin.NewSecretsManagerPlugin(p.ID, filepath.Join(p.PluginDir, secretsManagerStartCmd()), + func(pluginID string, secretsmanager secretsmanagerplugin.SecretsManagerPlugin, logger log.Logger) error { + p.SecretsManager = secretsmanager + return nil + }, + ) +} + var DefaultProvider PluginBackendProvider = func(_ context.Context, p *plugins.Plugin) backendplugin.PluginFactoryFunc { // TODO check for executable return grpcplugin.NewBackendPlugin(p.ID, filepath.Join(p.PluginDir, pluginStartCmd(p.Executable))) @@ -84,3 +97,15 @@ func rendererStartCmd() string { return fmt.Sprintf("%s_%s_%s%s", "plugin_start", os, strings.ToLower(arch), extension) } + +func secretsManagerStartCmd() string { + os := strings.ToLower(runtime.GOOS) + arch := runtime.GOARCH + extension := "" + + if os == "windows" { + extension = ".exe" + } + + return fmt.Sprintf("%s_%s_%s%s", "secrets_plugin_start", os, strings.ToLower(arch), extension) +} diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/generate.sh b/pkg/plugins/backendplugin/secretsmanagerplugin/generate.sh new file mode 100755 index 00000000000..2da8cabab71 --- /dev/null +++ b/pkg/plugins/backendplugin/secretsmanagerplugin/generate.sh @@ -0,0 +1,3 @@ +protoc --go_out=. --go_opt=paths=source_relative \ + --go-grpc_out=. --go-grpc_opt=paths=source_relative \ + secretsmanager.proto \ No newline at end of file diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go new file mode 100644 index 00000000000..f410ac2a916 --- /dev/null +++ b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go @@ -0,0 +1,786 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.28.0 +// protoc v3.19.4 +// source: secretsmanager.proto + +package secretsmanagerplugin + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type SecretsGetRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + KeyDescriptor *Key `protobuf:"bytes,1,opt,name=keyDescriptor,proto3" json:"keyDescriptor,omitempty"` +} + +func (x *SecretsGetRequest) Reset() { + *x = SecretsGetRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_secretsmanager_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SecretsGetRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecretsGetRequest) ProtoMessage() {} + +func (x *SecretsGetRequest) ProtoReflect() protoreflect.Message { + mi := &file_secretsmanager_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecretsGetRequest.ProtoReflect.Descriptor instead. +func (*SecretsGetRequest) Descriptor() ([]byte, []int) { + return file_secretsmanager_proto_rawDescGZIP(), []int{0} +} + +func (x *SecretsGetRequest) GetKeyDescriptor() *Key { + if x != nil { + return x.KeyDescriptor + } + return nil +} + +type SecretsSetRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + KeyDescriptor *Key `protobuf:"bytes,1,opt,name=keyDescriptor,proto3" json:"keyDescriptor,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` +} + +func (x *SecretsSetRequest) Reset() { + *x = SecretsSetRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_secretsmanager_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SecretsSetRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecretsSetRequest) ProtoMessage() {} + +func (x *SecretsSetRequest) ProtoReflect() protoreflect.Message { + mi := &file_secretsmanager_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecretsSetRequest.ProtoReflect.Descriptor instead. +func (*SecretsSetRequest) Descriptor() ([]byte, []int) { + return file_secretsmanager_proto_rawDescGZIP(), []int{1} +} + +func (x *SecretsSetRequest) GetKeyDescriptor() *Key { + if x != nil { + return x.KeyDescriptor + } + return nil +} + +func (x *SecretsSetRequest) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type SecretsDelRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + KeyDescriptor *Key `protobuf:"bytes,1,opt,name=keyDescriptor,proto3" json:"keyDescriptor,omitempty"` +} + +func (x *SecretsDelRequest) Reset() { + *x = SecretsDelRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_secretsmanager_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SecretsDelRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecretsDelRequest) ProtoMessage() {} + +func (x *SecretsDelRequest) ProtoReflect() protoreflect.Message { + mi := &file_secretsmanager_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecretsDelRequest.ProtoReflect.Descriptor instead. +func (*SecretsDelRequest) Descriptor() ([]byte, []int) { + return file_secretsmanager_proto_rawDescGZIP(), []int{2} +} + +func (x *SecretsDelRequest) GetKeyDescriptor() *Key { + if x != nil { + return x.KeyDescriptor + } + return nil +} + +type SecretsKeysRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + KeyDescriptor *Key `protobuf:"bytes,1,opt,name=keyDescriptor,proto3" json:"keyDescriptor,omitempty"` + AllOrganizations bool `protobuf:"varint,2,opt,name=allOrganizations,proto3" json:"allOrganizations,omitempty"` +} + +func (x *SecretsKeysRequest) Reset() { + *x = SecretsKeysRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_secretsmanager_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SecretsKeysRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecretsKeysRequest) ProtoMessage() {} + +func (x *SecretsKeysRequest) ProtoReflect() protoreflect.Message { + mi := &file_secretsmanager_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecretsKeysRequest.ProtoReflect.Descriptor instead. +func (*SecretsKeysRequest) Descriptor() ([]byte, []int) { + return file_secretsmanager_proto_rawDescGZIP(), []int{3} +} + +func (x *SecretsKeysRequest) GetKeyDescriptor() *Key { + if x != nil { + return x.KeyDescriptor + } + return nil +} + +func (x *SecretsKeysRequest) GetAllOrganizations() bool { + if x != nil { + return x.AllOrganizations + } + return false +} + +type SecretsRenameRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + KeyDescriptor *Key `protobuf:"bytes,1,opt,name=keyDescriptor,proto3" json:"keyDescriptor,omitempty"` + NewNamespace string `protobuf:"bytes,4,opt,name=newNamespace,proto3" json:"newNamespace,omitempty"` +} + +func (x *SecretsRenameRequest) Reset() { + *x = SecretsRenameRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_secretsmanager_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SecretsRenameRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecretsRenameRequest) ProtoMessage() {} + +func (x *SecretsRenameRequest) ProtoReflect() protoreflect.Message { + mi := &file_secretsmanager_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecretsRenameRequest.ProtoReflect.Descriptor instead. +func (*SecretsRenameRequest) Descriptor() ([]byte, []int) { + return file_secretsmanager_proto_rawDescGZIP(), []int{4} +} + +func (x *SecretsRenameRequest) GetKeyDescriptor() *Key { + if x != nil { + return x.KeyDescriptor + } + return nil +} + +func (x *SecretsRenameRequest) GetNewNamespace() string { + if x != nil { + return x.NewNamespace + } + return "" +} + +type Key struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + OrgId int64 `protobuf:"varint,1,opt,name=orgId,proto3" json:"orgId,omitempty"` + Namespace string `protobuf:"bytes,2,opt,name=namespace,proto3" json:"namespace,omitempty"` + Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"` +} + +func (x *Key) Reset() { + *x = Key{} + if protoimpl.UnsafeEnabled { + mi := &file_secretsmanager_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Key) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Key) ProtoMessage() {} + +func (x *Key) ProtoReflect() protoreflect.Message { + mi := &file_secretsmanager_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Key.ProtoReflect.Descriptor instead. +func (*Key) Descriptor() ([]byte, []int) { + return file_secretsmanager_proto_rawDescGZIP(), []int{5} +} + +func (x *Key) GetOrgId() int64 { + if x != nil { + return x.OrgId + } + return 0 +} + +func (x *Key) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *Key) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +type SecretsErrorResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *SecretsErrorResponse) Reset() { + *x = SecretsErrorResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_secretsmanager_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SecretsErrorResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecretsErrorResponse) ProtoMessage() {} + +func (x *SecretsErrorResponse) ProtoReflect() protoreflect.Message { + mi := &file_secretsmanager_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecretsErrorResponse.ProtoReflect.Descriptor instead. +func (*SecretsErrorResponse) Descriptor() ([]byte, []int) { + return file_secretsmanager_proto_rawDescGZIP(), []int{6} +} + +func (x *SecretsErrorResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type SecretsGetResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + DecryptedValue string `protobuf:"bytes,2,opt,name=decryptedValue,proto3" json:"decryptedValue,omitempty"` + Exists bool `protobuf:"varint,3,opt,name=exists,proto3" json:"exists,omitempty"` +} + +func (x *SecretsGetResponse) Reset() { + *x = SecretsGetResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_secretsmanager_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SecretsGetResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecretsGetResponse) ProtoMessage() {} + +func (x *SecretsGetResponse) ProtoReflect() protoreflect.Message { + mi := &file_secretsmanager_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecretsGetResponse.ProtoReflect.Descriptor instead. +func (*SecretsGetResponse) Descriptor() ([]byte, []int) { + return file_secretsmanager_proto_rawDescGZIP(), []int{7} +} + +func (x *SecretsGetResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *SecretsGetResponse) GetDecryptedValue() string { + if x != nil { + return x.DecryptedValue + } + return "" +} + +func (x *SecretsGetResponse) GetExists() bool { + if x != nil { + return x.Exists + } + return false +} + +type SecretsKeysResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + Keys []*Key `protobuf:"bytes,2,rep,name=keys,proto3" json:"keys,omitempty"` +} + +func (x *SecretsKeysResponse) Reset() { + *x = SecretsKeysResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_secretsmanager_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SecretsKeysResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecretsKeysResponse) ProtoMessage() {} + +func (x *SecretsKeysResponse) ProtoReflect() protoreflect.Message { + mi := &file_secretsmanager_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecretsKeysResponse.ProtoReflect.Descriptor instead. +func (*SecretsKeysResponse) Descriptor() ([]byte, []int) { + return file_secretsmanager_proto_rawDescGZIP(), []int{8} +} + +func (x *SecretsKeysResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *SecretsKeysResponse) GetKeys() []*Key { + if x != nil { + return x.Keys + } + return nil +} + +var File_secretsmanager_proto protoreflect.FileDescriptor + +var file_secretsmanager_proto_rawDesc = []byte{ + 0x0a, 0x14, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x14, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x22, 0x54, 0x0a, 0x11, + 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x3f, 0x0a, 0x0d, 0x6b, 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, + 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, + 0x4b, 0x65, 0x79, 0x52, 0x0d, 0x6b, 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x6f, 0x72, 0x22, 0x6a, 0x0a, 0x11, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x53, 0x65, 0x74, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3f, 0x0a, 0x0d, 0x6b, 0x65, 0x79, 0x44, 0x65, + 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, + 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, + 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x4b, 0x65, 0x79, 0x52, 0x0d, 0x6b, 0x65, 0x79, 0x44, 0x65, + 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x54, + 0x0a, 0x11, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x44, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x3f, 0x0a, 0x0d, 0x6b, 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, + 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x73, 0x65, 0x63, + 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, + 0x6e, 0x2e, 0x4b, 0x65, 0x79, 0x52, 0x0d, 0x6b, 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, + 0x70, 0x74, 0x6f, 0x72, 0x22, 0x81, 0x01, 0x0a, 0x12, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, + 0x4b, 0x65, 0x79, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3f, 0x0a, 0x0d, 0x6b, + 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x4b, 0x65, 0x79, 0x52, 0x0d, 0x6b, + 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, 0x2a, 0x0a, 0x10, + 0x61, 0x6c, 0x6c, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x61, 0x6c, 0x6c, 0x4f, 0x72, 0x67, 0x61, 0x6e, + 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x7b, 0x0a, 0x14, 0x53, 0x65, 0x63, 0x72, + 0x65, 0x74, 0x73, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x3f, 0x0a, 0x0d, 0x6b, 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, + 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, + 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x4b, + 0x65, 0x79, 0x52, 0x0d, 0x6b, 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, + 0x72, 0x12, 0x22, 0x0a, 0x0c, 0x6e, 0x65, 0x77, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, + 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6e, 0x65, 0x77, 0x4e, 0x61, 0x6d, 0x65, + 0x73, 0x70, 0x61, 0x63, 0x65, 0x22, 0x4d, 0x0a, 0x03, 0x4b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, + 0x6f, 0x72, 0x67, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6f, 0x72, 0x67, + 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x74, 0x79, 0x70, 0x65, 0x22, 0x2c, 0x0a, 0x14, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x45, + 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, + 0x6f, 0x72, 0x22, 0x6a, 0x0a, 0x12, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x47, 0x65, 0x74, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x26, + 0x0a, 0x0e, 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, + 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, 0x22, 0x5a, + 0x0a, 0x13, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x4b, 0x65, 0x79, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x2d, 0x0a, 0x04, 0x6b, + 0x65, 0x79, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x73, 0x65, 0x63, 0x72, + 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x2e, 0x4b, 0x65, 0x79, 0x52, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x32, 0xe7, 0x03, 0x0a, 0x14, 0x52, + 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x4d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x72, 0x12, 0x58, 0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x27, 0x2e, 0x73, 0x65, 0x63, + 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, + 0x6e, 0x2e, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x53, 0x65, 0x63, 0x72, 0x65, + 0x74, 0x73, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, + 0x03, 0x53, 0x65, 0x74, 0x12, 0x27, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x53, 0x65, 0x63, 0x72, + 0x65, 0x74, 0x73, 0x53, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, + 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, + 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x45, 0x72, 0x72, 0x6f, + 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x03, 0x44, 0x65, 0x6c, + 0x12, 0x27, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x44, + 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x73, 0x65, 0x63, 0x72, + 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x2e, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, 0x0a, 0x04, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x28, 0x2e, + 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, + 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x4b, 0x65, 0x79, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, + 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x53, + 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x4b, 0x65, 0x79, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x60, 0x0a, 0x06, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2a, 0x2e, 0x73, + 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, + 0x67, 0x69, 0x6e, 0x2e, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x6e, 0x61, 0x6d, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, + 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, + 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x19, 0x5a, 0x17, 0x2e, 0x2f, 0x3b, 0x73, 0x65, 0x63, 0x72, 0x65, + 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_secretsmanager_proto_rawDescOnce sync.Once + file_secretsmanager_proto_rawDescData = file_secretsmanager_proto_rawDesc +) + +func file_secretsmanager_proto_rawDescGZIP() []byte { + file_secretsmanager_proto_rawDescOnce.Do(func() { + file_secretsmanager_proto_rawDescData = protoimpl.X.CompressGZIP(file_secretsmanager_proto_rawDescData) + }) + return file_secretsmanager_proto_rawDescData +} + +var file_secretsmanager_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_secretsmanager_proto_goTypes = []interface{}{ + (*SecretsGetRequest)(nil), // 0: secretsmanagerplugin.SecretsGetRequest + (*SecretsSetRequest)(nil), // 1: secretsmanagerplugin.SecretsSetRequest + (*SecretsDelRequest)(nil), // 2: secretsmanagerplugin.SecretsDelRequest + (*SecretsKeysRequest)(nil), // 3: secretsmanagerplugin.SecretsKeysRequest + (*SecretsRenameRequest)(nil), // 4: secretsmanagerplugin.SecretsRenameRequest + (*Key)(nil), // 5: secretsmanagerplugin.Key + (*SecretsErrorResponse)(nil), // 6: secretsmanagerplugin.SecretsErrorResponse + (*SecretsGetResponse)(nil), // 7: secretsmanagerplugin.SecretsGetResponse + (*SecretsKeysResponse)(nil), // 8: secretsmanagerplugin.SecretsKeysResponse +} +var file_secretsmanager_proto_depIdxs = []int32{ + 5, // 0: secretsmanagerplugin.SecretsGetRequest.keyDescriptor:type_name -> secretsmanagerplugin.Key + 5, // 1: secretsmanagerplugin.SecretsSetRequest.keyDescriptor:type_name -> secretsmanagerplugin.Key + 5, // 2: secretsmanagerplugin.SecretsDelRequest.keyDescriptor:type_name -> secretsmanagerplugin.Key + 5, // 3: secretsmanagerplugin.SecretsKeysRequest.keyDescriptor:type_name -> secretsmanagerplugin.Key + 5, // 4: secretsmanagerplugin.SecretsRenameRequest.keyDescriptor:type_name -> secretsmanagerplugin.Key + 5, // 5: secretsmanagerplugin.SecretsKeysResponse.keys:type_name -> secretsmanagerplugin.Key + 0, // 6: secretsmanagerplugin.RemoteSecretsManager.Get:input_type -> secretsmanagerplugin.SecretsGetRequest + 1, // 7: secretsmanagerplugin.RemoteSecretsManager.Set:input_type -> secretsmanagerplugin.SecretsSetRequest + 2, // 8: secretsmanagerplugin.RemoteSecretsManager.Del:input_type -> secretsmanagerplugin.SecretsDelRequest + 3, // 9: secretsmanagerplugin.RemoteSecretsManager.Keys:input_type -> secretsmanagerplugin.SecretsKeysRequest + 4, // 10: secretsmanagerplugin.RemoteSecretsManager.Rename:input_type -> secretsmanagerplugin.SecretsRenameRequest + 7, // 11: secretsmanagerplugin.RemoteSecretsManager.Get:output_type -> secretsmanagerplugin.SecretsGetResponse + 6, // 12: secretsmanagerplugin.RemoteSecretsManager.Set:output_type -> secretsmanagerplugin.SecretsErrorResponse + 6, // 13: secretsmanagerplugin.RemoteSecretsManager.Del:output_type -> secretsmanagerplugin.SecretsErrorResponse + 8, // 14: secretsmanagerplugin.RemoteSecretsManager.Keys:output_type -> secretsmanagerplugin.SecretsKeysResponse + 6, // 15: secretsmanagerplugin.RemoteSecretsManager.Rename:output_type -> secretsmanagerplugin.SecretsErrorResponse + 11, // [11:16] is the sub-list for method output_type + 6, // [6:11] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_secretsmanager_proto_init() } +func file_secretsmanager_proto_init() { + if File_secretsmanager_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_secretsmanager_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SecretsGetRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_secretsmanager_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SecretsSetRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_secretsmanager_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SecretsDelRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_secretsmanager_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SecretsKeysRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_secretsmanager_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SecretsRenameRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_secretsmanager_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Key); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_secretsmanager_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SecretsErrorResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_secretsmanager_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SecretsGetResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_secretsmanager_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SecretsKeysResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_secretsmanager_proto_rawDesc, + NumEnums: 0, + NumMessages: 9, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_secretsmanager_proto_goTypes, + DependencyIndexes: file_secretsmanager_proto_depIdxs, + MessageInfos: file_secretsmanager_proto_msgTypes, + }.Build() + File_secretsmanager_proto = out.File + file_secretsmanager_proto_rawDesc = nil + file_secretsmanager_proto_goTypes = nil + file_secretsmanager_proto_depIdxs = nil +} diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.proto b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.proto new file mode 100644 index 00000000000..0ab75cdf9d7 --- /dev/null +++ b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; +package secretsmanagerplugin; + +option go_package = "./;secretsmanagerplugin"; + +message SecretsGetRequest { + Key keyDescriptor = 1; +} + +message SecretsSetRequest { + Key keyDescriptor = 1; + string value = 2; +} + +message SecretsDelRequest { + Key keyDescriptor = 1; +} + +message SecretsKeysRequest { + Key keyDescriptor = 1; + bool allOrganizations = 2; +} + +message SecretsRenameRequest { + Key keyDescriptor = 1; + string newNamespace = 2; +} + +message Key { + int64 orgId = 1; + string namespace = 2; + string type = 3; +} + +message SecretsErrorResponse { + string error = 1; +} + +message SecretsGetResponse { + string error = 1; + string decryptedValue = 2; + bool exists = 3; +} + +message SecretsKeysResponse { + string error = 1; + repeated Key keys = 2; +} + +service RemoteSecretsManager { + rpc Get(SecretsGetRequest) returns (SecretsGetResponse); + rpc Set(SecretsSetRequest) returns (SecretsErrorResponse); + rpc Del(SecretsDelRequest) returns (SecretsErrorResponse); + rpc Keys(SecretsKeysRequest) returns (SecretsKeysResponse); + rpc Rename(SecretsRenameRequest) returns (SecretsErrorResponse); +} \ No newline at end of file diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grcp_plugin.go b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grcp_plugin.go new file mode 100644 index 00000000000..6be57d4bc42 --- /dev/null +++ b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grcp_plugin.go @@ -0,0 +1,56 @@ +package secretsmanagerplugin + +import ( + "context" + + "github.com/hashicorp/go-plugin" + "google.golang.org/grpc" +) + +type SecretsManagerPlugin interface { + RemoteSecretsManagerClient +} + +type SecretsManagerGRPCPlugin struct { + plugin.NetRPCUnsupportedPlugin +} + +func (p *SecretsManagerGRPCPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error { + return nil +} + +func (p *SecretsManagerGRPCPlugin) GRPCClient(ctx context.Context, broker *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) { + return &SecretsManagerGRPCClient{NewRemoteSecretsManagerClient(c)}, nil +} + +type SecretsManagerGRPCClient struct { + RemoteSecretsManagerClient +} + +// Get an item from the store +func (sm *SecretsManagerGRPCClient) Get(ctx context.Context, req *SecretsGetRequest, opts ...grpc.CallOption) (*SecretsGetResponse, error) { + return sm.RemoteSecretsManagerClient.Get(ctx, req) +} + +// Set an item in the store +func (sm *SecretsManagerGRPCClient) Set(ctx context.Context, req *SecretsSetRequest, opts ...grpc.CallOption) (*SecretsErrorResponse, error) { + return sm.RemoteSecretsManagerClient.Set(ctx, req) +} + +// Del deletes an item from the store. +func (sm *SecretsManagerGRPCClient) Del(ctx context.Context, req *SecretsDelRequest, opts ...grpc.CallOption) (*SecretsErrorResponse, error) { + return sm.RemoteSecretsManagerClient.Del(ctx, req) +} + +// Keys get all keys for a given namespace. +func (sm *SecretsManagerGRPCClient) Keys(ctx context.Context, req *SecretsKeysRequest, opts ...grpc.CallOption) (*SecretsKeysResponse, error) { + return sm.RemoteSecretsManagerClient.Keys(ctx, req) +} + +// Rename an item in the store +func (sm *SecretsManagerGRPCClient) Rename(ctx context.Context, req *SecretsRenameRequest, opts ...grpc.CallOption) (*SecretsErrorResponse, error) { + return sm.RemoteSecretsManagerClient.Rename(ctx, req) +} + +var _ RemoteSecretsManagerClient = &SecretsManagerGRPCClient{} +var _ plugin.GRPCPlugin = &SecretsManagerGRPCPlugin{} diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go new file mode 100644 index 00000000000..9d0459c8971 --- /dev/null +++ b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go @@ -0,0 +1,249 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.2.0 +// - protoc v3.19.4 +// source: secretsmanager.proto + +package secretsmanagerplugin + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +// RemoteSecretsManagerClient is the client API for RemoteSecretsManager service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type RemoteSecretsManagerClient interface { + Get(ctx context.Context, in *SecretsGetRequest, opts ...grpc.CallOption) (*SecretsGetResponse, error) + Set(ctx context.Context, in *SecretsSetRequest, opts ...grpc.CallOption) (*SecretsErrorResponse, error) + Del(ctx context.Context, in *SecretsDelRequest, opts ...grpc.CallOption) (*SecretsErrorResponse, error) + Keys(ctx context.Context, in *SecretsKeysRequest, opts ...grpc.CallOption) (*SecretsKeysResponse, error) + Rename(ctx context.Context, in *SecretsRenameRequest, opts ...grpc.CallOption) (*SecretsErrorResponse, error) +} + +type remoteSecretsManagerClient struct { + cc grpc.ClientConnInterface +} + +func NewRemoteSecretsManagerClient(cc grpc.ClientConnInterface) RemoteSecretsManagerClient { + return &remoteSecretsManagerClient{cc} +} + +func (c *remoteSecretsManagerClient) Get(ctx context.Context, in *SecretsGetRequest, opts ...grpc.CallOption) (*SecretsGetResponse, error) { + out := new(SecretsGetResponse) + err := c.cc.Invoke(ctx, "/secretsmanagerplugin.RemoteSecretsManager/Get", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *remoteSecretsManagerClient) Set(ctx context.Context, in *SecretsSetRequest, opts ...grpc.CallOption) (*SecretsErrorResponse, error) { + out := new(SecretsErrorResponse) + err := c.cc.Invoke(ctx, "/secretsmanagerplugin.RemoteSecretsManager/Set", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *remoteSecretsManagerClient) Del(ctx context.Context, in *SecretsDelRequest, opts ...grpc.CallOption) (*SecretsErrorResponse, error) { + out := new(SecretsErrorResponse) + err := c.cc.Invoke(ctx, "/secretsmanagerplugin.RemoteSecretsManager/Del", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *remoteSecretsManagerClient) Keys(ctx context.Context, in *SecretsKeysRequest, opts ...grpc.CallOption) (*SecretsKeysResponse, error) { + out := new(SecretsKeysResponse) + err := c.cc.Invoke(ctx, "/secretsmanagerplugin.RemoteSecretsManager/Keys", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *remoteSecretsManagerClient) Rename(ctx context.Context, in *SecretsRenameRequest, opts ...grpc.CallOption) (*SecretsErrorResponse, error) { + out := new(SecretsErrorResponse) + err := c.cc.Invoke(ctx, "/secretsmanagerplugin.RemoteSecretsManager/Rename", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// RemoteSecretsManagerServer is the server API for RemoteSecretsManager service. +// All implementations must embed UnimplementedRemoteSecretsManagerServer +// for forward compatibility +type RemoteSecretsManagerServer interface { + Get(context.Context, *SecretsGetRequest) (*SecretsGetResponse, error) + Set(context.Context, *SecretsSetRequest) (*SecretsErrorResponse, error) + Del(context.Context, *SecretsDelRequest) (*SecretsErrorResponse, error) + Keys(context.Context, *SecretsKeysRequest) (*SecretsKeysResponse, error) + Rename(context.Context, *SecretsRenameRequest) (*SecretsErrorResponse, error) + mustEmbedUnimplementedRemoteSecretsManagerServer() +} + +// UnimplementedRemoteSecretsManagerServer must be embedded to have forward compatible implementations. +type UnimplementedRemoteSecretsManagerServer struct { +} + +func (UnimplementedRemoteSecretsManagerServer) Get(context.Context, *SecretsGetRequest) (*SecretsGetResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Get not implemented") +} +func (UnimplementedRemoteSecretsManagerServer) Set(context.Context, *SecretsSetRequest) (*SecretsErrorResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Set not implemented") +} +func (UnimplementedRemoteSecretsManagerServer) Del(context.Context, *SecretsDelRequest) (*SecretsErrorResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Del not implemented") +} +func (UnimplementedRemoteSecretsManagerServer) Keys(context.Context, *SecretsKeysRequest) (*SecretsKeysResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Keys not implemented") +} +func (UnimplementedRemoteSecretsManagerServer) Rename(context.Context, *SecretsRenameRequest) (*SecretsErrorResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Rename not implemented") +} +func (UnimplementedRemoteSecretsManagerServer) mustEmbedUnimplementedRemoteSecretsManagerServer() {} + +// UnsafeRemoteSecretsManagerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to RemoteSecretsManagerServer will +// result in compilation errors. +type UnsafeRemoteSecretsManagerServer interface { + mustEmbedUnimplementedRemoteSecretsManagerServer() +} + +func RegisterRemoteSecretsManagerServer(s grpc.ServiceRegistrar, srv RemoteSecretsManagerServer) { + s.RegisterService(&RemoteSecretsManager_ServiceDesc, srv) +} + +func _RemoteSecretsManager_Get_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SecretsGetRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RemoteSecretsManagerServer).Get(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/secretsmanagerplugin.RemoteSecretsManager/Get", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RemoteSecretsManagerServer).Get(ctx, req.(*SecretsGetRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RemoteSecretsManager_Set_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SecretsSetRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RemoteSecretsManagerServer).Set(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/secretsmanagerplugin.RemoteSecretsManager/Set", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RemoteSecretsManagerServer).Set(ctx, req.(*SecretsSetRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RemoteSecretsManager_Del_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SecretsDelRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RemoteSecretsManagerServer).Del(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/secretsmanagerplugin.RemoteSecretsManager/Del", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RemoteSecretsManagerServer).Del(ctx, req.(*SecretsDelRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RemoteSecretsManager_Keys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SecretsKeysRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RemoteSecretsManagerServer).Keys(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/secretsmanagerplugin.RemoteSecretsManager/Keys", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RemoteSecretsManagerServer).Keys(ctx, req.(*SecretsKeysRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RemoteSecretsManager_Rename_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SecretsRenameRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RemoteSecretsManagerServer).Rename(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/secretsmanagerplugin.RemoteSecretsManager/Rename", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RemoteSecretsManagerServer).Rename(ctx, req.(*SecretsRenameRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// RemoteSecretsManager_ServiceDesc is the grpc.ServiceDesc for RemoteSecretsManager service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var RemoteSecretsManager_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "secretsmanagerplugin.RemoteSecretsManager", + HandlerType: (*RemoteSecretsManagerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Get", + Handler: _RemoteSecretsManager_Get_Handler, + }, + { + MethodName: "Set", + Handler: _RemoteSecretsManager_Set_Handler, + }, + { + MethodName: "Del", + Handler: _RemoteSecretsManager_Del_Handler, + }, + { + MethodName: "Keys", + Handler: _RemoteSecretsManager_Keys_Handler, + }, + { + MethodName: "Rename", + Handler: _RemoteSecretsManager_Rename_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "secretsmanager.proto", +} diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index 4971b0913be..062cf8e9e95 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -47,6 +47,11 @@ type RendererManager interface { Renderer() *Plugin } +type SecretsPluginManager interface { + // SecretsManager returns a secretsmanager plugin + SecretsManager() *Plugin +} + type StaticRouteResolver interface { Routes() []*StaticRoute } diff --git a/pkg/plugins/manager/loader/initializer/initializer_test.go b/pkg/plugins/manager/loader/initializer/initializer_test.go index 75052de0753..34fe5c96d9e 100644 --- a/pkg/plugins/manager/loader/initializer/initializer_test.go +++ b/pkg/plugins/manager/loader/initializer/initializer_test.go @@ -79,6 +79,36 @@ func TestInitializer_Initialize(t *testing.T) { assert.NotNil(t, c) }) + t.Run("secretsmanager", func(t *testing.T) { + p := &plugins.Plugin{ + JSONData: plugins.JSONData{ + ID: "test", + Type: plugins.SecretsManager, + Dependencies: plugins.Dependencies{ + GrafanaVersion: ">=8.x", + }, + Backend: true, + }, + PluginDir: absCurPath, + Class: plugins.External, + } + + i := &Initializer{ + cfg: plugins.NewCfg(), + log: log.NewNopLogger(), + backendProvider: &fakeBackendProvider{ + plugin: p, + }, + } + + err := i.Initialize(context.Background(), p) + assert.NoError(t, err) + + c, exists := p.Client() + assert.True(t, exists) + assert.NotNil(t, c) + }) + t.Run("non backend plugin app", func(t *testing.T) { p := &plugins.Plugin{ JSONData: plugins.JSONData{ diff --git a/pkg/plugins/manager/manager.go b/pkg/plugins/manager/manager.go index db3f665c74a..f8c631655a1 100644 --- a/pkg/plugins/manager/manager.go +++ b/pkg/plugins/manager/manager.go @@ -24,6 +24,7 @@ var _ plugins.Client = (*PluginManager)(nil) var _ plugins.Store = (*PluginManager)(nil) var _ plugins.StaticRouteResolver = (*PluginManager)(nil) var _ plugins.RendererManager = (*PluginManager)(nil) +var _ plugins.SecretsPluginManager = (*PluginManager)(nil) type PluginManager struct { cfg *plugins.Cfg @@ -117,6 +118,16 @@ func (m *PluginManager) Renderer() *plugins.Plugin { return nil } +func (m *PluginManager) SecretsManager() *plugins.Plugin { + for _, p := range m.availablePlugins(context.TODO()) { + if p.IsSecretsManager() { + return p + } + } + + return nil +} + func (m *PluginManager) Routes() []*plugins.StaticRoute { staticRoutes := make([]*plugins.StaticRoute, 0) diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 04cd731ddf4..dd17aae8501 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2" + "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin" ) type Plugin struct { @@ -36,9 +37,10 @@ type Plugin struct { Module string BaseURL string - Renderer pluginextensionv2.RendererPlugin - client backendplugin.Plugin - log log.Logger + Renderer pluginextensionv2.RendererPlugin + SecretsManager secretsmanagerplugin.SecretsManagerPlugin + client backendplugin.Plugin + log log.Logger } type PluginDTO struct { @@ -132,7 +134,7 @@ type JSONData struct { Streaming bool `json:"streaming"` SDK bool `json:"sdk,omitempty"` - // Backend (Datasource + Renderer) + // Backend (Datasource + Renderer + SecretsManager) Executable string `json:"executable,omitempty"` } @@ -347,6 +349,10 @@ func (p *Plugin) IsRenderer() bool { return p.Type == "renderer" } +func (p *Plugin) IsSecretsManager() bool { + return p.Type == "secretsmanager" +} + func (p *Plugin) IsDataSource() bool { return p.Type == "datasource" } @@ -384,20 +390,22 @@ var PluginTypes = []Type{ Panel, App, Renderer, + SecretsManager, } type Type string const ( - DataSource Type = "datasource" - Panel Type = "panel" - App Type = "app" - Renderer Type = "renderer" + DataSource Type = "datasource" + Panel Type = "panel" + App Type = "app" + Renderer Type = "renderer" + SecretsManager Type = "secretsmanager" ) func (pt Type) IsValid() bool { switch pt { - case DataSource, Panel, App, Renderer: + case DataSource, Panel, App, Renderer, SecretsManager: return true } return false diff --git a/pkg/server/wire.go b/pkg/server/wire.go index cd863c600fd..39c9ed81824 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -149,6 +149,7 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(plugins.DashboardFileStore), new(*manager.PluginManager)), wire.Bind(new(plugins.StaticRouteResolver), new(*manager.PluginManager)), wire.Bind(new(plugins.RendererManager), new(*manager.PluginManager)), + wire.Bind(new(plugins.SecretsPluginManager), new(*manager.PluginManager)), coreplugin.ProvideCoreRegistry, loader.ProvideService, wire.Bind(new(loader.Service), new(*loader.Loader)), diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 14b31878738..3551ae5b563 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -31,6 +31,7 @@ import ( "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/searchusers" "github.com/grafana/grafana/pkg/services/searchusers/filters" + secretsStore "github.com/grafana/grafana/pkg/services/secrets/kvstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrations" "github.com/grafana/grafana/pkg/services/thumbs" "github.com/grafana/grafana/pkg/services/validations" @@ -85,6 +86,8 @@ var wireExtsBasicSet = wire.NewSet( wire.Bind(new(registry.UsageStatsProvidersRegistry), new(*usagestatssvcs.UsageStatsProvidersRegistry)), ossaccesscontrol.ProvideDatasourcePermissionsService, wire.Bind(new(accesscontrol.DatasourcePermissionsService), new(*ossaccesscontrol.DatasourcePermissionsService)), + secretsStore.ProvideRemotePluginCheck, + wire.Bind(new(secretsStore.UseRemoteSecretsPluginCheck), new(*secretsStore.OSSRemoteSecretsPluginCheck)), ) var wireExtsSet = wire.NewSet( diff --git a/pkg/services/secrets/kvstore/kvstore.go b/pkg/services/secrets/kvstore/kvstore.go index b438aec3c41..d314ea4d909 100644 --- a/pkg/services/secrets/kvstore/kvstore.go +++ b/pkg/services/secrets/kvstore/kvstore.go @@ -13,11 +13,26 @@ const ( AllOrganizations = -1 ) -func ProvideService(sqlStore sqlstore.Store, secretsService secrets.Service) SecretsKVStore { +func ProvideService(sqlStore sqlstore.Store, secretsService secrets.Service, remoteCheck UseRemoteSecretsPluginCheck) SecretsKVStore { + logger := log.New("secrets.kvstore") + if remoteCheck.ShouldUseRemoteSecretsPlugin() { + logger.Debug("secrets kvstore is using a remote plugin for secrets management") + secretsPlugin, err := remoteCheck.GetPlugin() + if err != nil { + logger.Error("plugin client was nil, falling back to SQL implementation") + } else { + return &secretsKVStorePlugin{ + secretsPlugin: secretsPlugin, + secretsService: secretsService, + log: logger, + } + } + } + logger.Debug("secrets kvstore is using the default (SQL) implementation for secrets management") return &secretsKVStoreSQL{ sqlStore: sqlStore, secretsService: secretsService, - log: log.New("secrets.kvstore"), + log: logger, decryptionCache: decryptionCache{ cache: make(map[int64]cachedDecrypted), }, diff --git a/pkg/services/secrets/kvstore/remote_plugin.go b/pkg/services/secrets/kvstore/remote_plugin.go new file mode 100644 index 00000000000..97222e646e9 --- /dev/null +++ b/pkg/services/secrets/kvstore/remote_plugin.go @@ -0,0 +1,125 @@ +package kvstore + +import ( + "context" + "fmt" + + "github.com/grafana/grafana/pkg/infra/log" + smp "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin" + "github.com/grafana/grafana/pkg/services/secrets" +) + +// secretsKVStorePlugin provides a key/value store backed by the Grafana plugin gRPC interface +type secretsKVStorePlugin struct { + log log.Logger + secretsPlugin smp.SecretsManagerPlugin + secretsService secrets.Service +} + +// Get an item from the store +func (kv *secretsKVStorePlugin) Get(ctx context.Context, orgId int64, namespace string, typ string) (string, bool, error) { + req := &smp.SecretsGetRequest{ + KeyDescriptor: &smp.Key{ + OrgId: orgId, + Namespace: namespace, + Type: typ, + }, + } + res, err := kv.secretsPlugin.Get(ctx, req) + if err != nil { + return "", false, err + } else if res.Error != "" { + err = fmt.Errorf(res.Error) + } + + return res.DecryptedValue, res.Exists, err +} + +// Set an item in the store +func (kv *secretsKVStorePlugin) Set(ctx context.Context, orgId int64, namespace string, typ string, value string) error { + req := &smp.SecretsSetRequest{ + KeyDescriptor: &smp.Key{ + OrgId: orgId, + Namespace: namespace, + Type: typ, + }, + Value: value, + } + + res, err := kv.secretsPlugin.Set(ctx, req) + if err == nil && res.Error != "" { + err = fmt.Errorf(res.Error) + } + + return err +} + +// Del deletes an item from the store. +func (kv *secretsKVStorePlugin) Del(ctx context.Context, orgId int64, namespace string, typ string) error { + req := &smp.SecretsDelRequest{ + KeyDescriptor: &smp.Key{ + OrgId: orgId, + Namespace: namespace, + Type: typ, + }, + } + + res, err := kv.secretsPlugin.Del(ctx, req) + if err == nil && res.Error != "" { + err = fmt.Errorf(res.Error) + } + + return err +} + +// Keys get all keys for a given namespace. To query for all +// organizations the constant 'kvstore.AllOrganizations' can be passed as orgId. +func (kv *secretsKVStorePlugin) Keys(ctx context.Context, orgId int64, namespace string, typ string) ([]Key, error) { + req := &smp.SecretsKeysRequest{ + KeyDescriptor: &smp.Key{ + OrgId: orgId, + Namespace: namespace, + Type: typ, + }, + AllOrganizations: orgId == AllOrganizations, + } + + res, err := kv.secretsPlugin.Keys(ctx, req) + if err != nil { + return nil, err + } else if res.Error != "" { + err = fmt.Errorf(res.Error) + } + + return parseKeys(res.Keys), err +} + +// Rename an item in the store +func (kv *secretsKVStorePlugin) Rename(ctx context.Context, orgId int64, namespace string, typ string, newNamespace string) error { + req := &smp.SecretsRenameRequest{ + KeyDescriptor: &smp.Key{ + OrgId: orgId, + Namespace: namespace, + Type: typ, + }, + NewNamespace: newNamespace, + } + + res, err := kv.secretsPlugin.Rename(ctx, req) + if err == nil && res.Error != "" { + err = fmt.Errorf(res.Error) + } + + return err +} + +func parseKeys(keys []*smp.Key) []Key { + var newKeys []Key + + for _, k := range keys { + newKey := Key{OrgId: k.OrgId, Namespace: k.Namespace, Type: k.Type} + newKeys = append(newKeys, newKey) + } + + return newKeys +} diff --git a/pkg/services/secrets/kvstore/remote_plugin_check.go b/pkg/services/secrets/kvstore/remote_plugin_check.go new file mode 100644 index 00000000000..657f705d0b0 --- /dev/null +++ b/pkg/services/secrets/kvstore/remote_plugin_check.go @@ -0,0 +1,26 @@ +package kvstore + +import ( + "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin" +) + +type UseRemoteSecretsPluginCheck interface { + ShouldUseRemoteSecretsPlugin() bool + GetPlugin() (secretsmanagerplugin.SecretsManagerPlugin, error) +} + +type OSSRemoteSecretsPluginCheck struct { + UseRemoteSecretsPluginCheck +} + +func ProvideRemotePluginCheck() *OSSRemoteSecretsPluginCheck { + return &OSSRemoteSecretsPluginCheck{} +} + +func (c *OSSRemoteSecretsPluginCheck) ShouldUseRemoteSecretsPlugin() bool { + return false +} + +func (c *OSSRemoteSecretsPluginCheck) GetPlugin() (secretsmanagerplugin.SecretsManagerPlugin, error) { + return nil, nil +} From 7e2870b7bb5c0f1be9f83429ac6c97ba896fa91d Mon Sep 17 00:00:00 2001 From: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Thu, 9 Jun 2022 20:12:41 +0200 Subject: [PATCH 20/40] Docs: Updates Grafana Alerting upgrade topics (#50533) * Updates Grafana Alerting upgrade topics * Run yarn prettier:write Co-authored-by: Armand Grillet --- docs/sources/alerting/_index.md | 2 +- .../alerting/migrating-alerts/_index.md | 32 +++++---- .../migrating-alerts/disable-alerting.md | 23 +++++++ .../migrating-legacy-alerts.md | 11 +-- .../alerting/migrating-alerts/opt-in.md | 25 +++++++ .../alerting/migrating-alerts/opt-out.md | 68 ++++++++++--------- .../alerting/migrating-alerts/roll-back.md | 29 ++++++++ docs/sources/alerting/performance.md | 2 +- .../setup-grafana/configure-grafana/_index.md | 2 +- 9 files changed, 141 insertions(+), 53 deletions(-) create mode 100644 docs/sources/alerting/migrating-alerts/disable-alerting.md create mode 100644 docs/sources/alerting/migrating-alerts/opt-in.md create mode 100644 docs/sources/alerting/migrating-alerts/roll-back.md diff --git a/docs/sources/alerting/_index.md b/docs/sources/alerting/_index.md index 9803ae16ed7..b8bf41fc564 100644 --- a/docs/sources/alerting/_index.md +++ b/docs/sources/alerting/_index.md @@ -8,7 +8,7 @@ weight: 114 # Grafana Alerting -Grafana alerts allow you to learn about problems in your systems moments after they occur. Robust and actionable alerts help you identify and resolve issues quickly, minimizing disruption to your services. It centralizes alerting information in a single, searchable view that allows you to: +Grafana Alerting allows you to learn about problems in your systems moments after they occur. Robust and actionable alerts help you identify and resolve issues quickly, minimizing disruption to your services. It centralizes alerting information in a single, searchable view that allows you to: - Create and manage Grafana alerts - Create and manage Grafana Mimir and Loki managed alerts diff --git a/docs/sources/alerting/migrating-alerts/_index.md b/docs/sources/alerting/migrating-alerts/_index.md index c2b1a057a0f..094038351c0 100644 --- a/docs/sources/alerting/migrating-alerts/_index.md +++ b/docs/sources/alerting/migrating-alerts/_index.md @@ -1,27 +1,31 @@ --- aliases: - /docs/grafana/latest/alerting/migrating-alerts/ -description: Migrate Grafana alerts -title: Migrate to Grafana Alerting -weight: 113 +description: Upgrade Grafana alerts +title: Upgrade to Grafana Alerting +weight: 101 --- -# Migrate to Grafana Alerting +# Upgrade to Grafana Alerting -Grafana Alerting is the default for new Cloud, Enterprise, and OSS installations. The new installations will only show the Grafana Alerting icon in the left navigation panel. +Grafana Alerting is enabled by default for new installations or existing installations whether or not legacy alerting is configured. -Existing installations that upgrade to v9.0 will have Grafana Alerting enabled by default. +Existing installations that do not use legacy alerting will have Grafana Alerting enabled by default unless alerting is disabled in the configuration. -| Grafana instance upgraded to v 90 | | -| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Cloud | Existing Cloud installations with legacy dashboard alerting will have two alerting icons in the left navigation panel - the old alerting plugin icon and the new Grafana Alerting icon. During upgrade, existing alerts from the Cloud alerting plugin are migrated to Grafana Alerting. Once migration is complete, you can access aman manage the older alerts from the new alerting Grafana Alerting icon in the navigation panel. The (older) Cloud alerting plugin is uninstalled from your cloud instance. Contact customer support if you **do not wish** to migrate to Grafana Alerting for your Cloud stack. If you choose to use legacy alerting, use the You will see the new Grafana Alerting icon as well as the old Cloud alerting plugin in the left navigation panel. | -| Enterprise | Existing Enterprise instances using legacy alerting will have both the old (marked as legacy) and the new alerting icons in the navigation panel. During upgrade, existing legacy alerts are migrated to Grafana Alerting. If you wish, you can [opt-out]({{< relref "opt-out/" >}}) of Grafana Alerting and roll back to legacy alerting. In that case, you can manage your legacy alerts from the alerting icon marked as legacy. | -| OSS | Existing OSS installations with legacy dashboard alerting will have two alerting icons in the left navigation panel - the old alerting icon (marked as legacy) and the new Grafana Alerting icon. During upgrade, existing legacy alerts are migrated to Grafana Alerting. If you wish, you can [opt-out]({{< relref "opt-out/" >}}) of Grafana Alerting and roll back to legacy alerting. In that case, you can manage your legacy alerts from the alerting icon marked as legacy. | +Likewise, existing installations that use legacy alerting will be automatically upgraded to Grafana Alerting unless you have [opted out]({{< relref "opt-out/" >}})of Grafana Alerting before migration takes place. During the upgrade, legacy alerts are migrated to the new alerts type and no alerts or alerting data are lost. -During migration from legacy alerting to unified alerting, the legacy alerts are updated to the new alerts type, as a result, the user does not lose alerts or alerting data. However, if a user rolls back to legacy alerting after having migrated to unified alerting, they will only get the legacy alerts they had right before migration. +Once the upgrade has taken place, you still have the option to roll back [roll back]({{< relref "roll-back/" >}}) to legacy alerting. However, we do not recommend choosing this option. If you do choose to roll back, Grafana will restore your alerts to the alerts you had at the point in time when the upgrade took place. All new alerts and changes made exclusively in Grafana Alerting will be deleted. -## Roll back to legacy alerting +> **Note**: Cloud customers, who do not want to upgrade to Grafana Alerting, should contact customer support. -Although we encourage you to use Grafana Alerting, roll back to legacy alerting is supported in Grafana 9. Rolling back can result in data loss (you will loose all alerts that you created using Grafana Alerting). This is applicable to the fresh installation as well as upgraded setups. +If you have opted out or rolled back, you can always choose to opt in [opt in]({{< relref "opt-in/" >}}) to Grafana Alerting at a later point in time. + +The following table provides details on the upgrade for Cloud, Enterprise, and OSS installations and the new Grafana Alerting UI. + +| Grafana instance upgraded to 9.0 | | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Cloud | Existing Cloud installations with legacy dashboard alerting will have two alerting icons in the left navigation panel - the old alerting plugin icon and the new Grafana Alerting icon. During upgrade, existing alerts from the Cloud alerting plugin are migrated to Grafana Alerting. Once migration is complete, you can access and manage the older alerts from the new alerting Grafana Alerting icon in the navigation panel. The (older) Cloud alerting plugin is uninstalled from your cloud instance. Contact customer support if you **do not wish** to migrate to Grafana Alerting for your Cloud stack. If you choose to use legacy alerting, use the You will see the new Grafana Alerting icon as well as the old Cloud alerting plugin in the left navigation panel. | +| Enterprise | Existing Enterprise instances using legacy alerting will have both the old (marked as legacy) and the new alerting icons in the navigation panel. During upgrade, existing legacy alerts are migrated to Grafana Alerting. If you wish, you can [opt-out]({{< relref "opt-out/" >}}) of Grafana Alerting and roll back to legacy alerting. In that case, you can manage your legacy alerts from the alerting icon marked as legacy. | +| OSS | Existing OSS installations with legacy dashboard alerting will have two alerting icons in the left navigation panel - the old alerting icon (marked as legacy) and the new Grafana Alerting icon. During upgrade, existing legacy alerts are migrated to Grafana Alerting. If you wish, you can [opt-out]({{< relref "opt-out/" >}}) of Grafana Alerting and roll back to legacy alerting. In that case, you can manage your legacy alerts from the alerting icon marked as legacy. | > **Note:** Legacy alerting will be deprecated in a future release (v10). diff --git a/docs/sources/alerting/migrating-alerts/disable-alerting.md b/docs/sources/alerting/migrating-alerts/disable-alerting.md new file mode 100644 index 00000000000..0f759504417 --- /dev/null +++ b/docs/sources/alerting/migrating-alerts/disable-alerting.md @@ -0,0 +1,23 @@ +--- +aliases: + - /docs/grafana/latest/alerting/migrating-alerts/disable-alerting/ + - /docs/grafana/latest/alerting/disable-alerting/ + - /docs/grafana/latest/alerting/unified-alerting/disable-alerting/ +description: Disable alerting in Grafana +title: Disable alerting in Grafana +weight: 105 +--- + +# Disable alerting in Grafana + +To disable alerting in Grafana entirely (including both legacy and Grafana Alerting), enter the following in your configuration: + +``` +[alerting] +enabled = false + +[unified_alerting] +enabled = false +``` + +If at any time you want to turn alerting back on, you can opt in. diff --git a/docs/sources/alerting/migrating-alerts/migrating-legacy-alerts.md b/docs/sources/alerting/migrating-alerts/migrating-legacy-alerts.md index e517e3e90fa..6a94dd9a459 100644 --- a/docs/sources/alerting/migrating-alerts/migrating-legacy-alerts.md +++ b/docs/sources/alerting/migrating-alerts/migrating-legacy-alerts.md @@ -4,13 +4,14 @@ aliases: - /docs/grafana/latest/alerting/migrating-legacy-alerts/ - /docs/grafana/latest/alerting/unified-alerting/opt-in/ description: Migrate legacy dashboard alerts -title: Migrating legacy dashboard alerts -weight: 114 +title: Differences and limitations +weight: 106 --- -# Migrating legacy dashboard alerts +# Differences and limitations -When Grafana Alerting is enabled or Grafana is upgraded to the latest version, existing legacy dashboard alerts migrate in a format compatible with the Grafana Alerting. In the Alerting page of your Grafana instance, you can view the migrated alerts alongside any new alerts. This topic explains how legacy dashboard alerts are migrated and some limitations. +When Grafana Alerting is enabled or upgraded to Grafana 9.0 or later, existing legacy dashboard alerts migrate in a format compatible with the Grafana Alerting. In the Alerting page of your Grafana instance, you can view the migrated alerts alongside any new alerts. +This topic explains how legacy dashboard alerts are migrated and some limitations of the migration. > **Note:** This topic is only relevant for OSS and Enterprise customers. Contact customer support to enable or disable Grafana Alerting for your Cloud stack. @@ -24,5 +25,7 @@ Read and write access to legacy dashboard alerts and Grafana alerts are governed Notification channels are migrated to an Alertmanager configuration with the appropriate routes and receivers. Default notification channels are added as contact points to the default route. Notification channels not associated with any Dashboard alert go to the `autogen-unlinked-channel-recv` route. +## Limitations + Since `Hipchat` and `Sensu` notification channels are no longer supported, legacy alerts associated with these channels are not automatically migrated to Grafana Alerting. Assign the legacy alerts to a supported notification channel so that you continue to receive notifications for those alerts. Silences (expiring after one year) are created for all paused dashboard alerts. diff --git a/docs/sources/alerting/migrating-alerts/opt-in.md b/docs/sources/alerting/migrating-alerts/opt-in.md new file mode 100644 index 00000000000..00a62c2385b --- /dev/null +++ b/docs/sources/alerting/migrating-alerts/opt-in.md @@ -0,0 +1,25 @@ +--- +aliases: + - /docs/grafana/latest/alerting/migrating-alerts/opt-in/ + - /docs/grafana/latest/alerting/opt-in/ + - /docs/grafana/latest/alerting/unified-alerting/opt-in/ +description: Opt in to Grafana Alerting +title: Opt in to Grafana Alerting +weight: 104 +--- + +# Opt in to Grafana Alerting + +If you have previously disabled alerting in Grafana, or opted out of Grafana Alerting and have decided that you would now like to use Grafana Alerting, you can choose to opt in at any time. + +If you have been using legacy alerting up until now your existing alerts will be migrated to the new alerts type and no alerts or alerting data are lost. Even if you choose to opt in to Grafana Alerting, you can roll back to legacy alerting at any time. + +To opt in to Grafana Alerting, enter the following in your configuration: + +``` +[alerting] +enabled = false + +[unified_alerting] +enabled = true +``` diff --git a/docs/sources/alerting/migrating-alerts/opt-out.md b/docs/sources/alerting/migrating-alerts/opt-out.md index 72edb664d98..3c2ef5f0b1f 100644 --- a/docs/sources/alerting/migrating-alerts/opt-out.md +++ b/docs/sources/alerting/migrating-alerts/opt-out.md @@ -3,41 +3,45 @@ aliases: - /docs/grafana/latest/alerting/migrating-alerts/opt-out/ - /docs/grafana/latest/alerting/opt-in/ - /docs/grafana/latest/alerting/unified-alerting/opt-in/ -description: Disable Grafana alerts -title: Opt-out of Grafana Alerting -weight: 113 +description: Opt out of Grafana Alerting +title: Opt out of Grafana Alerting +weight: 102 --- -# Opt-out to Grafana Alerting in OSS +# Opt out of Grafana Alerting -This topic discusses how to disable Grafana Alerting and migrate to legacy dashboard alerting. It also provides guidance on how to enable Grafana Alerting once you are ready to migrate to Grafana Alerting. +If you have an existing installation, you can opt out of alerting in its entirety or opt out of Grafana Alerting in favor of using legacy alerting. + +Existing installations that do not use legacy alerting will have Grafana Alerting enabled by default unless alerting is disabled in the configuration. To keep alerting disabled: + +1. Go to your custom configuration file ($WORKING_DIR/conf/custom.ini). +1. Enter the following in your configuration: + +``` +[alerting] +enabled = false + +[unified_alerting] +enabled = false +``` + +3. Restart Grafana for the configuration changes to take effect. + +If at any time you want to turn alerting back on, you can do so. + +Existing installations that use legacy alerting will automatically be upgraded to Grafana Alerting unless you have opted-out of Grafana Alerting before migration takes place. During the upgrade, legacy alerts are migrated to the new alerts type and no alerts or alerting data are lost. To keep using legacy alerting and disable Grafana Alerting: + +1. Go to your custom configuration file ($WORKING_DIR/conf/custom.ini). +2. Enter the following in your configuration: + +``` +[alerting] +enabled = false + +[unified_alerting] +enabled = true +``` > **Note:** This topic is only relevant for OSS and Enterprise customers. Contact customer support to enable or disable Grafana Alerting for your Grafana Cloud stack. -## Before you begin - -We recommend that you backup Grafana's database. If you are using PostgreSQL as the backend database, then the minimum required version is 9.5. - -## Opt-out of Grafana alerts - -To opt-out of Grafana alerts and roll back to legacy dashboard alerting: - -1. In your custom configuration file ($WORKING_DIR/conf/custom.ini), go to the [Grafana Alerting]({{< relref "../../setup-grafana/configure-grafana/#unified_alerting" >}}) section. -1. Set the `enabled` property to `false`. -1. For [legacy dashboard alerting]({{< relref "../../setup-grafana/configure-grafana/#alerting" >}}), set the `enabled` flag to `true`. -1. Restart Grafana for the configuration changes to take effect. - -> **Note:** Rolling back from Grafana to legacy alerting can result in data loss. This is applicable to the fresh installation as well as upgraded setups. - -## Opt-in to Grafana Alerting - -When you are ready to make the switch, the following procedure will help you migrate to Grafana Alerting. - -To opt-in to Grafana alerts: - -1. In your custom configuration file ($WORKING_DIR/conf/custom.ini), go to the [unified alerts]({{< relref "../../setup-grafana/configure-grafana/#unified_alerting" >}}) section. -1. Set the `enabled` property to `true`. -1. Next, for [legacy dashboard alerting]({{< relref "../../setup-grafana/configure-grafana/#alerting" >}}), set the `enabled` flag to `false`. -1. Restart Grafana for the configuration changes to take effect. - -> **Note:** The `ngalert` toggle previously used to enable or disable Grafana Alerting is no longer available. +The `ngalert` toggle previously used to enable or disable Grafana Alerting is no longer available. diff --git a/docs/sources/alerting/migrating-alerts/roll-back.md b/docs/sources/alerting/migrating-alerts/roll-back.md new file mode 100644 index 00000000000..319db9b3921 --- /dev/null +++ b/docs/sources/alerting/migrating-alerts/roll-back.md @@ -0,0 +1,29 @@ +--- +aliases: + - /docs/grafana/latest/alerting/migrating-alerts/roll-back/ + - /docs/grafana/latest/alerting/opt-in/ + - /docs/grafana/latest/alerting/unified-alerting/roll-back/ +description: Roll back to legacy alerting +title: Roll back to legacy alerting +weight: 103 +--- + +# Roll back to legacy alerting + +Once the upgrade has taken place, you still have the option to roll back to legacy alerting. If you choose to roll back, Grafana will restore your alerts to the alerts you had at the point in time when the upgrade took place. + +All new alerts and changes made exclusively in Grafana Alerting will be deleted. + +To roll back to legacy alerting, enter the following in your configuration: + +``` +force_migration = true + +[alerting] +enabled = true + +[unified_alerting] +enabled = false +``` + +> **Note**: We do not recommend this option. If you choose to roll back, Grafana will restore your alerts to the alerts you had at the point in time when the upgrade took place. All new alerts and changes made exclusively in Grafana Alerting will be deleted. diff --git a/docs/sources/alerting/performance.md b/docs/sources/alerting/performance.md index c4a2802e90f..3c1f4279034 100644 --- a/docs/sources/alerting/performance.md +++ b/docs/sources/alerting/performance.md @@ -2,7 +2,7 @@ title = "Performance considerations" description = "Understanding alerting performance" keywords = ["grafana", "alerting", "performance"] -weight = 100 +weight = 555 +++ # Alerting performance considerations diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 55f95c700ca..ed821e6eddc 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -1189,7 +1189,7 @@ For more information about the Grafana alerts, refer to [About Grafana Alerting] ### enabled -Enable the Unified Alerting sub-system and interface. When enabled we'll migrate all of your alert rules and notification channels to the new system. New alert rules will be created and your notification channels will be converted into an Alertmanager configuration. Previous data is preserved to enable backwards compatibility but new data is removed. The default value is `false`. +Enable or disable Grafana Alerting. If enabled, we’ll migrate all your alert rules and notification channels to the new system as alert rules and notification channels you had previously defined will be converted into an Alertmanager configuration. Legacy alerting data is preserved to enable backwards compatibility. If disabled, all your legacy alerting data will be available again, but the data you created using Grafana Alerting will be deleted. Set force_migration=true to avoid deletion of data. The default value is `true`. Alerting Rules migrated from dashboards and panels will include a link back via the `annotations`. From 5df694b924d24905756ed2aa5844a3e88672c6db Mon Sep 17 00:00:00 2001 From: Adam Simpson Date: Thu, 9 Jun 2022 18:24:48 +0000 Subject: [PATCH 21/40] AzureMonitor: changes to azureMonitorExperimentalUI after feedback (#50455) * AzureMonitor: tweak experimental-ui after UX discussions. - move metrics up to resource row - allow resources field to take an inlineField prop and a labelWidth prop. Eventually once the feature toggle is removed the inlineField prop will be the only code branch in Field.tsx. * AzureMonitor: remove devmode req for azureMonitorExperimentalUI flag * refactor: consolidate logic branch for inline Field --- pkg/services/featuremgmt/registry.go | 9 ++++----- .../components/Field.tsx | 11 ++++++++--- .../components/LogsQueryEditor/LogsQueryEditor.tsx | 2 ++ .../MetricsQueryEditor/MetricsQueryEditor.tsx | 5 ----- .../components/ResourceField/ResourceField.tsx | 7 +++++-- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 5a95129680b..623ea09178f 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -198,11 +198,10 @@ var ( State: FeatureStateAlpha, }, { - Name: "azureMonitorExperimentalUI", - Description: "Use grafana-experimental UI in Azure Monitor", - State: FeatureStateAlpha, - RequiresDevMode: true, - FrontendOnly: true, + Name: "azureMonitorExperimentalUI", + Description: "Use grafana-experimental UI in Azure Monitor", + State: FeatureStateAlpha, + FrontendOnly: true, }, { Name: "traceToMetrics", diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/Field.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/Field.tsx index 52e2fa818bc..61318cf8527 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/Field.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/Field.tsx @@ -7,13 +7,18 @@ import { Props as InlineFieldProps } from '@grafana/ui/src/components/Forms/Inli interface Props extends InlineFieldProps { label: string; + inlineField?: boolean; + labelWidth?: number; } const DEFAULT_LABEL_WIDTH = 18; export const Field = (props: Props) => { - if (config.featureToggles.azureMonitorExperimentalUI) { - return ; + const { labelWidth, inlineField, ...remainingProps } = props; + + if (config.featureToggles.azureMonitorExperimentalUI && !inlineField) { + return ; + } else { + return ; } - return ; }; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/LogsQueryEditor.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/LogsQueryEditor.tsx index 83d858ac5b6..6879724dc17 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/LogsQueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/LogsQueryEditor.tsx @@ -44,6 +44,8 @@ const LogsQueryEditor: React.FC = ({ = ({ setResource={setResource} resourceUri={query.azureMonitor?.resourceUri} /> - - - - - AzureMonitorQuery; selectableEntryTypes: ResourceRowType[]; resourceUri?: string; + inlineField?: boolean; + labelWidth?: number; } const ResourceField: React.FC = ({ @@ -38,6 +40,8 @@ const ResourceField: React.FC = ({ setResource, selectableEntryTypes, resourceUri, + inlineField, + labelWidth, }) => { const styles = useStyles2(getStyles); const [pickerIsOpen, setPickerIsOpen] = useState(false); @@ -77,8 +81,7 @@ const ResourceField: React.FC = ({ selectableEntryTypes={selectableEntryTypes} /> - - + From be7518c97beff67905d2cd14a7940f24841dba60 Mon Sep 17 00:00:00 2001 From: idafurjes <36131195+idafurjes@users.noreply.github.com> Date: Thu, 9 Jun 2022 21:17:03 +0200 Subject: [PATCH 22/40] Bug: Fix List dashboard version (#50544) * Fix List dashboard version * Fix * Roll back tag for dahboard version id --- pkg/services/dashboardversion/model.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/services/dashboardversion/model.go b/pkg/services/dashboardversion/model.go index 6de84dd4d53..fd087921269 100644 --- a/pkg/services/dashboardversion/model.go +++ b/pkg/services/dashboardversion/model.go @@ -45,9 +45,9 @@ type ListDashboardVersionsQuery struct { } type DashboardVersionDTO struct { - ID int64 `json:"id"` - DashboardID int64 `json:"dashboardId"` - DashboardUID string `json:"dashboardUid"` + ID int64 `json:"id" xorm:"id"` + DashboardID int64 `json:"dashboardId" xorm:"dashboard_id"` + DashboardUID string `json:"dashboardUid" xorm:"dashboard_uid"` ParentVersion int `json:"parentVersion"` RestoredFrom int `json:"restoredFrom"` Version int `json:"version"` From fd239f94e8041e246445b7b8cadbf7e754887933 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Fri, 10 Jun 2022 09:50:53 +0200 Subject: [PATCH 23/40] TeamSettings: Convert tests to RTL (#50513) --- .betterer.results | 3 -- .../app/features/teams/TeamSettings.test.tsx | 46 ++++++++++++++----- public/app/features/teams/TeamSettings.tsx | 10 +++- .../__snapshots__/TeamSettings.test.tsx.snap | 30 ------------ 4 files changed, 42 insertions(+), 47 deletions(-) delete mode 100644 public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap diff --git a/.betterer.results b/.betterer.results index 9d094e27732..cccbb6fad1b 100644 --- a/.betterer.results +++ b/.betterer.results @@ -146,9 +146,6 @@ exports[`no enzyme tests`] = { "public/app/features/org/OrgDetailsPage.test.tsx:3835042085": [ [0, 19, 13, "RegExp match", "2409514259"] ], - "public/app/features/teams/TeamSettings.test.tsx:2043271249": [ - [0, 19, 13, "RegExp match", "2409514259"] - ], "public/app/features/users/UsersListPage.test.tsx:2518052139": [ [0, 19, 13, "RegExp match", "2409514259"] ], diff --git a/public/app/features/teams/TeamSettings.test.tsx b/public/app/features/teams/TeamSettings.test.tsx index 2b6bb754ca3..1092aefede7 100644 --- a/public/app/features/teams/TeamSettings.test.tsx +++ b/public/app/features/teams/TeamSettings.test.tsx @@ -1,15 +1,20 @@ -import { shallow } from 'enzyme'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { Props, TeamSettings } from './TeamSettings'; import { getMockTeam } from './__mocks__/teamMocks'; -jest.mock('app/core/core', () => ({ +jest.mock('app/core/services/context_srv', () => ({ contextSrv: { hasPermissionInMetadata: () => true, }, })); +jest.mock('app/core/components/SharedPreferences/SharedPreferences', () => { + return { SharedPreferences: () =>
}; +}); + const setup = (propOverrides?: object) => { const props: Props = { team: getMockTeam(), @@ -18,19 +23,36 @@ const setup = (propOverrides?: object) => { Object.assign(props, propOverrides); - const wrapper = shallow(); - const instance = wrapper.instance() as any; - - return { - wrapper, - instance, - }; + render(); }; -describe('Render', () => { +describe('Team settings', () => { it('should render component', () => { - const { wrapper } = setup(); + setup(); - expect(wrapper).toMatchSnapshot(); + expect(screen.getByText('Team settings')).toBeInTheDocument(); + }); + + it('should validate required fields', async () => { + const mockUpdate = jest.fn(); + setup({ updateTeam: mockUpdate }); + await userEvent.clear(screen.getByRole('textbox', { name: /Name/ })); + await userEvent.type(screen.getByLabelText(/Email/i), 'team@test.com'); + // Submitting with userEvent doesn't work here + fireEvent.submit(screen.getByRole('button', { name: 'Update' })); + expect(await screen.findByText('Name is required')).toBeInTheDocument(); + await waitFor(() => expect(mockUpdate).not.toHaveBeenCalled()); + }); + + it('should submit form with correct values', async () => { + const mockUpdate = jest.fn(); + setup({ updateTeam: mockUpdate }); + await userEvent.clear(screen.getByRole('textbox', { name: /Name/ })); + await userEvent.clear(screen.getByLabelText(/Email/i)); + await userEvent.type(screen.getByRole('textbox', { name: /Name/ }), 'New team'); + await userEvent.type(screen.getByLabelText(/Email/i), 'team@test.com'); + // Submitting with userEvent doesn't work here + fireEvent.submit(screen.getByRole('button', { name: 'Update' })); + await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith('New team', 'team@test.com')); }); }); diff --git a/public/app/features/teams/TeamSettings.tsx b/public/app/features/teams/TeamSettings.tsx index d4fb9016887..7a828f13064 100644 --- a/public/app/features/teams/TeamSettings.tsx +++ b/public/app/features/teams/TeamSettings.tsx @@ -32,9 +32,15 @@ export const TeamSettings: FC = ({ team, updateTeam }) => { }} disabled={!canWriteTeamSettings} > - {({ register }) => ( + {({ register, errors }) => ( <> - + diff --git a/public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap deleted file mode 100644 index 1fe40abc971..00000000000 --- a/public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap +++ /dev/null @@ -1,30 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Render should render component 1`] = ` - -
-
- - -
- -
-`; From ce6a73a623d05e7d4506f1aabcf345cbfa0d0756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Bedi?= Date: Fri, 10 Jun 2022 09:55:46 +0200 Subject: [PATCH 24/40] TimeSeries: Add missing legend width migration (#50551) --- .../plugins/panel/timeseries/migrations.test.ts | 17 +++++++++++++++++ .../app/plugins/panel/timeseries/migrations.ts | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/public/app/plugins/panel/timeseries/migrations.test.ts b/public/app/plugins/panel/timeseries/migrations.test.ts index 14310ce9fb6..6daee162a78 100644 --- a/public/app/plugins/panel/timeseries/migrations.test.ts +++ b/public/app/plugins/panel/timeseries/migrations.test.ts @@ -127,6 +127,23 @@ describe('Graph Migrations', () => { panel.options = graphPanelChangedHandler(panel, 'graph', old, prevFieldConfig); expect(panel).toMatchSnapshot(); }); + test('with sideWidth', () => { + const old: any = { + angular: { + legend: { + alignAsTable: true, + rightSide: true, + show: true, + sideWidth: 200, + total: true, + values: true, + }, + }, + }; + const panel = {} as PanelModel; + panel.options = graphPanelChangedHandler(panel, 'graph', old, prevFieldConfig); + expect(panel.options.legend.width).toBe(200); + }); }); describe('stacking', () => { diff --git a/public/app/plugins/panel/timeseries/migrations.ts b/public/app/plugins/panel/timeseries/migrations.ts index e27b75fc8b1..d49cca16649 100644 --- a/public/app/plugins/panel/timeseries/migrations.ts +++ b/public/app/plugins/panel/timeseries/migrations.ts @@ -345,6 +345,10 @@ export function flotToGraphOptions(angular: any): { fieldConfig: FieldConfigSour const enabledLegendValues = pickBy(angular.legend); options.legend.calcs = getReducersFromLegend(enabledLegendValues); } + + if (angular.legend.sideWidth) { + options.legend.width = angular.legend.sideWidth; + } } const tooltipConfig = angular.tooltip; From bda47df4adc00aa22808e4ab325b87cb696af21d Mon Sep 17 00:00:00 2001 From: George Robinson Date: Fri, 10 Jun 2022 08:59:58 +0100 Subject: [PATCH 25/40] Alerting: Invalid setting of enabled for unified alerting should return error (#49876) --- pkg/setting/setting_test.go | 118 ++++++++++++++++++++++-- pkg/setting/setting_unified_alerting.go | 20 ++-- 2 files changed, 124 insertions(+), 14 deletions(-) diff --git a/pkg/setting/setting_test.go b/pkg/setting/setting_test.go index 826e6ad4e3b..92f2a42a54d 100644 --- a/pkg/setting/setting_test.go +++ b/pkg/setting/setting_test.go @@ -489,7 +489,7 @@ func TestAlertingEnabled(t *testing.T) { }, }, { - desc: "when legacy alerting is enabled and unified is invalid (or not defined) [OSS]", + desc: "when legacy alerting is enabled and unified is not defined [OSS]", legacyAlertingEnabled: "true", unifiedAlertingEnabled: "", isEnterprise: false, @@ -507,7 +507,24 @@ func TestAlertingEnabled(t *testing.T) { }, }, { - desc: "when legacy alerting is enabled and unified is invalid (or not defined) [Enterprise]", + desc: "when legacy alerting is enabled and unified is invalid [OSS]", + legacyAlertingEnabled: "true", + unifiedAlertingEnabled: "invalid", + isEnterprise: false, + verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { + err := readAlertingSettings(f) + require.NoError(t, err) + err = cfg.readFeatureToggles(f) + require.NoError(t, err) + err = cfg.ReadUnifiedAlertingSettings(f) + assert.EqualError(t, err, "failed to read unified alerting enabled setting: invalid value invalid, should be either true or false") + assert.Nil(t, cfg.UnifiedAlerting.Enabled) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, false, *AlertingEnabled) + }, + }, + { + desc: "when legacy alerting is enabled and unified is not defined [Enterprise]", legacyAlertingEnabled: "true", unifiedAlertingEnabled: "", isEnterprise: true, @@ -525,10 +542,27 @@ func TestAlertingEnabled(t *testing.T) { }, }, { - desc: "when legacy alerting is disabled and unified is invalid (or not defined) [OSS]", - legacyAlertingEnabled: "false", + desc: "when legacy alerting is enabled and unified is invalid [Enterprise]", + legacyAlertingEnabled: "true", unifiedAlertingEnabled: "invalid", isEnterprise: false, + verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { + err := readAlertingSettings(f) + require.NoError(t, err) + err = cfg.readFeatureToggles(f) + require.NoError(t, err) + err = cfg.ReadUnifiedAlertingSettings(f) + assert.EqualError(t, err, "failed to read unified alerting enabled setting: invalid value invalid, should be either true or false") + assert.Nil(t, cfg.UnifiedAlerting.Enabled) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, false, *AlertingEnabled) + }, + }, + { + desc: "when legacy alerting is disabled and unified is not defined [OSS]", + legacyAlertingEnabled: "false", + unifiedAlertingEnabled: "", + isEnterprise: false, verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) require.NoError(t, err) @@ -543,9 +577,26 @@ func TestAlertingEnabled(t *testing.T) { }, }, { - desc: "when legacy alerting is disabled and unified is invalid (or not defined) [Enterprise]", + desc: "when legacy alerting is disabled and unified is invalid [OSS]", legacyAlertingEnabled: "false", unifiedAlertingEnabled: "invalid", + isEnterprise: false, + verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { + err := readAlertingSettings(f) + require.NoError(t, err) + err = cfg.readFeatureToggles(f) + require.NoError(t, err) + err = cfg.ReadUnifiedAlertingSettings(f) + assert.EqualError(t, err, "failed to read unified alerting enabled setting: invalid value invalid, should be either true or false") + assert.Nil(t, cfg.UnifiedAlerting.Enabled) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, false, *AlertingEnabled) + }, + }, + { + desc: "when legacy alerting is disabled and unified is not defined [Enterprise]", + legacyAlertingEnabled: "false", + unifiedAlertingEnabled: "", isEnterprise: true, verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) @@ -561,10 +612,27 @@ func TestAlertingEnabled(t *testing.T) { }, }, { - desc: "when both are invalid (or not defined) [OSS]", - legacyAlertingEnabled: "invalid", + desc: "when legacy alerting is disabled and unified is invalid [Enterprise]", + legacyAlertingEnabled: "false", unifiedAlertingEnabled: "invalid", isEnterprise: false, + verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { + err := readAlertingSettings(f) + require.NoError(t, err) + err = cfg.readFeatureToggles(f) + require.NoError(t, err) + err = cfg.ReadUnifiedAlertingSettings(f) + assert.EqualError(t, err, "failed to read unified alerting enabled setting: invalid value invalid, should be either true or false") + assert.Nil(t, cfg.UnifiedAlerting.Enabled) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, false, *AlertingEnabled) + }, + }, + { + desc: "when both are not defined [OSS]", + legacyAlertingEnabled: "", + unifiedAlertingEnabled: "", + isEnterprise: false, verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) require.NoError(t, err) @@ -578,9 +646,26 @@ func TestAlertingEnabled(t *testing.T) { }, }, { - desc: "when both are invalid (or not defined) [Enterprise]", + desc: "when both are not invalid [OSS]", legacyAlertingEnabled: "invalid", unifiedAlertingEnabled: "invalid", + isEnterprise: false, + verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { + err := readAlertingSettings(f) + require.NoError(t, err) + err = cfg.readFeatureToggles(f) + require.NoError(t, err) + err = cfg.ReadUnifiedAlertingSettings(f) + assert.EqualError(t, err, "failed to read unified alerting enabled setting: invalid value invalid, should be either true or false") + assert.Nil(t, cfg.UnifiedAlerting.Enabled) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, false, *AlertingEnabled) + }, + }, + { + desc: "when both are not defined [Enterprise]", + legacyAlertingEnabled: "", + unifiedAlertingEnabled: "", isEnterprise: true, verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) @@ -594,6 +679,23 @@ func TestAlertingEnabled(t *testing.T) { assert.Nil(t, AlertingEnabled) }, }, + { + desc: "when both are not invalid [Enterprise]", + legacyAlertingEnabled: "invalid", + unifiedAlertingEnabled: "invalid", + isEnterprise: false, + verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { + err := readAlertingSettings(f) + require.NoError(t, err) + err = cfg.readFeatureToggles(f) + require.NoError(t, err) + err = cfg.ReadUnifiedAlertingSettings(f) + assert.EqualError(t, err, "failed to read unified alerting enabled setting: invalid value invalid, should be either true or false") + assert.Nil(t, cfg.UnifiedAlerting.Enabled) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, false, *AlertingEnabled) + }, + }, { desc: "when both are false", legacyAlertingEnabled: "false", diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index e0bcc3da0e3..5762760c38d 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -103,31 +103,39 @@ func (cfg *Cfg) readUnifiedAlertingEnabledSetting(section *ini.Section) (*bool, // At present an invalid value is considered the same as no value. This means that a // spelling mistake in the string "false" could enable unified alerting rather // than disable it. This issue can be found here - unifiedAlerting, err := section.Key("enabled").Bool() - if err != nil { + hasEnabled := section.Key("enabled").Value() != "" + if !hasEnabled { // TODO: Remove in Grafana v9 if cfg.IsFeatureToggleEnabled("ngalert") { cfg.Logger.Warn("ngalert feature flag is deprecated: use unified alerting enabled setting instead") // feature flag overrides the legacy alerting setting legacyAlerting := false AlertingEnabled = &legacyAlerting - unifiedAlerting = true + unifiedAlerting := true return &unifiedAlerting, nil } // if legacy alerting has not been configured then enable unified alerting if AlertingEnabled == nil { - unifiedAlerting = true + unifiedAlerting := true return &unifiedAlerting, nil } // enable unified alerting and disable legacy alerting legacyAlerting := false AlertingEnabled = &legacyAlerting - unifiedAlerting = true + unifiedAlerting := true return &unifiedAlerting, nil } + unifiedAlerting, err := section.Key("enabled").Bool() + if err != nil { + // the value for unified alerting is invalid so disable all alerting + legacyAlerting := false + AlertingEnabled = &legacyAlerting + return nil, fmt.Errorf("invalid value %s, should be either true or false", section.Key("enabled")) + } + // If both legacy and unified alerting are enabled then return an error if AlertingEnabled != nil && *AlertingEnabled && unifiedAlerting { return nil, errors.New("legacy and unified alerting cannot both be enabled at the same time, please disable one of them and restart Grafana") @@ -149,7 +157,7 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { ua := iniFile.Section("unified_alerting") uaCfg.Enabled, err = cfg.readUnifiedAlertingEnabledSetting(ua) if err != nil { - return err + return fmt.Errorf("failed to read unified alerting enabled setting: %w", err) } uaCfg.DisabledOrgs = make(map[int64]struct{}) From 6a4906ef7eedee975a1a928e2284a981dff77ef0 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Fri, 10 Jun 2022 10:37:40 +0200 Subject: [PATCH 26/40] Grafana UI: Fix nonexistent indexof module for plugin tests (#50529) * fix(grafana-ui): prevent module resolution failure where component-indexof appears as indexof * chore(grafana-ui): update comment explaining need for alias plugin --- packages/grafana-ui/package.json | 2 ++ packages/grafana-ui/rollup.config.ts | 6 +++++- yarn.lock | 31 +++++++++++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index c580a71a296..124445a3bc5 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -97,6 +97,7 @@ "@babel/core": "7.18.2", "@grafana/tsconfig": "^1.2.0-rc1", "@mdx-js/react": "1.6.22", + "@rollup/plugin-alias": "^3.1.9", "@rollup/plugin-commonjs": "22.0.0", "@rollup/plugin-node-resolve": "13.3.0", "@storybook/addon-a11y": "6.4.21", @@ -169,6 +170,7 @@ "rollup-plugin-sourcemaps": "0.6.3", "rollup-plugin-svg-import": "^1.6.0", "rollup-plugin-terser": "7.0.2", + "rollup-plugin-visualizer": "^5.6.0", "sass-loader": "13.0.0", "storybook-dark-mode": "1.1.0", "style-loader": "3.3.1", diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts index 9a1d647c43c..1979de79239 100644 --- a/packages/grafana-ui/rollup.config.ts +++ b/packages/grafana-ui/rollup.config.ts @@ -1,3 +1,4 @@ +import alias from '@rollup/plugin-alias'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import svg from 'rollup-plugin-svg-import'; @@ -39,6 +40,9 @@ const buildCjsPackage = ({ env }) => { '@emotion/css', ], plugins: [ + // rc-time-picker has a transitive dependency on component-indexof which + // when bundled via `component-classes` imports a nonexistent `indexof` module. + alias({ entries: [{ find: 'indexof', replacement: 'component-indexof' }] }), commonjs({ include: /node_modules/, ignoreTryCatch: false, @@ -49,4 +53,4 @@ const buildCjsPackage = ({ env }) => { ], }; }; -export default [buildCjsPackage({ env: 'development' }), buildCjsPackage({ env: 'production' })]; +export default [buildCjsPackage({ env: 'development' })]; diff --git a/yarn.lock b/yarn.lock index 3ff6306cc98..6ee0af96e06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4684,6 +4684,7 @@ __metadata: "@react-aria/menu": 3.4.4 "@react-aria/overlays": 3.8.2 "@react-stately/menu": 3.2.7 + "@rollup/plugin-alias": ^3.1.9 "@rollup/plugin-commonjs": 22.0.0 "@rollup/plugin-node-resolve": 13.3.0 "@sentry/browser": 6.19.7 @@ -4796,6 +4797,7 @@ __metadata: rollup-plugin-sourcemaps: 0.6.3 rollup-plugin-svg-import: ^1.6.0 rollup-plugin-terser: 7.0.2 + rollup-plugin-visualizer: ^5.6.0 rxjs: 7.5.5 sass-loader: 13.0.0 slate: 0.47.8 @@ -7652,6 +7654,17 @@ __metadata: languageName: node linkType: hard +"@rollup/plugin-alias@npm:^3.1.9": + version: 3.1.9 + resolution: "@rollup/plugin-alias@npm:3.1.9" + dependencies: + slash: ^3.0.0 + peerDependencies: + rollup: ^1.20.0||^2.0.0 + checksum: cefae9dfb7c30f0dc78d24f4ad9ccb8a0878397b313c0fa9d0f519667394941c58a930d968d841e25aee43b0fb892d1e3f7edbb55e8197f191cce7da6a50b882 + languageName: node + linkType: hard + "@rollup/plugin-commonjs@npm:22.0.0": version: 22.0.0 resolution: "@rollup/plugin-commonjs@npm:22.0.0" @@ -26412,7 +26425,7 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.3.3, nanoid@npm:^3.3.4": +"nanoid@npm:^3.1.32, nanoid@npm:^3.3.3, nanoid@npm:^3.3.4": version: 3.3.4 resolution: "nanoid@npm:3.3.4" bin: @@ -32205,6 +32218,22 @@ __metadata: languageName: node linkType: hard +"rollup-plugin-visualizer@npm:^5.6.0": + version: 5.6.0 + resolution: "rollup-plugin-visualizer@npm:5.6.0" + dependencies: + nanoid: ^3.1.32 + open: ^8.4.0 + source-map: ^0.7.3 + yargs: ^17.3.1 + peerDependencies: + rollup: ^2.0.0 + bin: + rollup-plugin-visualizer: dist/bin/cli.js + checksum: 1036a3873f3b6a0c46d692538aaac5dc9f7a7b2a5aec9e7ab816aaf8a31576ae750f415e1019e401707d18490b634a982fbe8c22d9761cafbd9b12c756e7fd99 + languageName: node + linkType: hard + "rollup@npm:2.74.1": version: 2.74.1 resolution: "rollup@npm:2.74.1" From 2d6e69226be20a7f001c7792bda8df68d90287c6 Mon Sep 17 00:00:00 2001 From: Carl Bergquist Date: Fri, 10 Jun 2022 10:56:55 +0200 Subject: [PATCH 27/40] Instrumentation: Add feature toggle for logging requests instrumented as unknown (#50566) Signed-off-by: bergquist --- .../src/types/featureToggles.gen.ts | 1 + pkg/middleware/request_metrics.go | 18 ++++++++++++------ pkg/services/featuremgmt/registry.go | 5 +++++ pkg/services/featuremgmt/toggles_gen.go | 4 ++++ 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index e3b794c181a..3cc6fa27b0b 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -58,4 +58,5 @@ export interface FeatureToggles { prometheusWideSeries?: boolean; canvasPanelNesting?: boolean; cloudMonitoringExperimentalUI?: boolean; + logRequestsInstrumentedAsUnknown?: boolean; } diff --git a/pkg/middleware/request_metrics.go b/pkg/middleware/request_metrics.go index 654271e92cd..fb6c7a57117 100644 --- a/pkg/middleware/request_metrics.go +++ b/pkg/middleware/request_metrics.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -46,6 +47,8 @@ func init() { // RequestMetrics is a middleware handler that instruments the request. func RequestMetrics(features featuremgmt.FeatureToggles) web.Handler { + log := log.New("middleware.request-metrics") + return func(res http.ResponseWriter, req *http.Request, c *web.Context) { if strings.HasPrefix(c.Req.URL.Path, "/public/") || c.Req.URL.Path == "robots.txt" || c.Req.URL.Path == "/metrics" { c.Next() @@ -58,15 +61,18 @@ func RequestMetrics(features featuremgmt.FeatureToggles) web.Handler { defer httpRequestsInFlight.Dec() c.Next() - handler := "unknown" - - if routeOperation, exists := RouteOperationNameFromContext(c.Req.Context()); exists { - handler = routeOperation - } - status := rw.Status() code := sanitizeCode(status) + handler := "unknown" + if routeOperation, exists := RouteOperationNameFromContext(c.Req.Context()); exists { + handler = routeOperation + } else { + if features.IsEnabled(featuremgmt.FlagLogRequestsInstrumentedAsUnknown) { + log.Warn("request instrumented as unknown", "path", c.Req.URL.Path, "status_code", status) + } + } + // avoiding the sanitize functions for in the new instrumentation // since they dont make much sense. We should remove them later. histogram := httpRequestDurationHistogram. diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 623ea09178f..1490b6be669 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -237,5 +237,10 @@ var ( State: FeatureStateAlpha, FrontendOnly: true, }, + { + Name: "logRequestsInstrumentedAsUnknown", + Description: "Logs the path for requests that are instrumented as unknown", + State: FeatureStateAlpha, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index a911fcbacae..bbc6b55dfd7 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -174,4 +174,8 @@ const ( // FlagCloudMonitoringExperimentalUI // Use grafana-experimental UI in Cloud Monitoring FlagCloudMonitoringExperimentalUI = "cloudMonitoringExperimentalUI" + + // FlagLogRequestsInstrumentedAsUnknown + // Logs the path for requests that are instrumented as unknown + FlagLogRequestsInstrumentedAsUnknown = "logRequestsInstrumentedAsUnknown" ) From c8914ecd8aa68987d8875e74bb37bb0e2b342dee Mon Sep 17 00:00:00 2001 From: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Fri, 10 Jun 2022 11:27:08 +0200 Subject: [PATCH 28/40] Docs: Fixes typos and adds enterprise note (#50564) * Fixes typos and space errors * Docs: adds note for enterprise users * Run yarn prettier:write Co-authored-by: Armand Grillet --- docs/sources/alerting/migrating-alerts/_index.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/sources/alerting/migrating-alerts/_index.md b/docs/sources/alerting/migrating-alerts/_index.md index 094038351c0..12a1710194d 100644 --- a/docs/sources/alerting/migrating-alerts/_index.md +++ b/docs/sources/alerting/migrating-alerts/_index.md @@ -10,15 +10,17 @@ weight: 101 Grafana Alerting is enabled by default for new installations or existing installations whether or not legacy alerting is configured. +> **Note**: We recommend that Grafana Enterprise customers with more than a dozen Grafana dashboard alert rules do not upgrade and remain on legacy alerting for now by [opting out]({{< relref "opt-out/" >}}). If you do want to upgrade to Grafana Alerting, contact customer support. + Existing installations that do not use legacy alerting will have Grafana Alerting enabled by default unless alerting is disabled in the configuration. -Likewise, existing installations that use legacy alerting will be automatically upgraded to Grafana Alerting unless you have [opted out]({{< relref "opt-out/" >}})of Grafana Alerting before migration takes place. During the upgrade, legacy alerts are migrated to the new alerts type and no alerts or alerting data are lost. +Likewise, existing installations that use legacy alerting will be automatically upgraded to Grafana Alerting unless you have [opted out]({{< relref "opt-out/" >}}) of Grafana Alerting before migration takes place. During the upgrade, legacy alerts are migrated to the new alerts type and no alerts or alerting data are lost. -Once the upgrade has taken place, you still have the option to roll back [roll back]({{< relref "roll-back/" >}}) to legacy alerting. However, we do not recommend choosing this option. If you do choose to roll back, Grafana will restore your alerts to the alerts you had at the point in time when the upgrade took place. All new alerts and changes made exclusively in Grafana Alerting will be deleted. +Once the upgrade has taken place, you still have the option to [roll back]({{< relref "roll-back/" >}}) to legacy alerting. However, we do not recommend choosing this option. If you do choose to roll back, Grafana will restore your alerts to the alerts you had at the point in time when the upgrade took place. All new alerts and changes made exclusively in Grafana Alerting will be deleted. > **Note**: Cloud customers, who do not want to upgrade to Grafana Alerting, should contact customer support. -If you have opted out or rolled back, you can always choose to opt in [opt in]({{< relref "opt-in/" >}}) to Grafana Alerting at a later point in time. +If you have opted out or rolled back, you can always choose to [opt in]({{< relref "opt-in/" >}}) to Grafana Alerting at a later point in time. The following table provides details on the upgrade for Cloud, Enterprise, and OSS installations and the new Grafana Alerting UI. From c1d405a200b6fc99ee4d7dbcabb83670fcef7930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 10 Jun 2022 11:30:52 +0200 Subject: [PATCH 29/40] Search: Fixes margins and paddings and left position (#50570) --- packages/grafana-data/src/themes/createComponents.ts | 2 +- public/app/core/components/NavBar/Next/NavBarNext.tsx | 2 +- public/app/features/search/components/DashboardSearch.tsx | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/grafana-data/src/themes/createComponents.ts b/packages/grafana-data/src/themes/createComponents.ts index 314b9ba218e..7646160521d 100644 --- a/packages/grafana-data/src/themes/createComponents.ts +++ b/packages/grafana-data/src/themes/createComponents.ts @@ -90,7 +90,7 @@ export function createComponents(colors: ThemeColors, shadows: ThemeShadows): Th background: colors.mode === 'dark' ? 'rgba(63, 62, 62, 0.45)' : 'rgba(208, 209, 211, 0.24)', }, sidemenu: { - width: 48, + width: 57, }, menuTabs: { height: 41, diff --git a/public/app/core/components/NavBar/Next/NavBarNext.tsx b/public/app/core/components/NavBar/Next/NavBarNext.tsx index 6fbaee70c0f..fa1031aca37 100644 --- a/public/app/core/components/NavBar/Next/NavBarNext.tsx +++ b/public/app/core/components/NavBar/Next/NavBarNext.tsx @@ -205,7 +205,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ zIndex: theme.zIndex.sidemenu, padding: `${theme.spacing(1)} 0`, position: 'relative', - width: `calc(${theme.spacing(7)} + 1px)`, + width: theme.components.sidemenu.width, borderRight: `1px solid ${theme.colors.border.weak}`, [theme.breakpoints.down('md')]: { diff --git a/public/app/features/search/components/DashboardSearch.tsx b/public/app/features/search/components/DashboardSearch.tsx index d283e0e8c9a..03bc8058289 100644 --- a/public/app/features/search/components/DashboardSearch.tsx +++ b/public/app/features/search/components/DashboardSearch.tsx @@ -144,10 +144,12 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => { z-index: ${theme.zIndex.sidemenu}; position: fixed; background: ${theme.colors.background.canvas}; + padding: ${theme.spacing(1)}; ${theme.breakpoints.up('md')} { left: ${theme.components.sidemenu.width}px; z-index: ${theme.zIndex.navbarFixed + 1}; + padding: ${theme.spacing(2)}; } `, container: css` @@ -155,11 +157,9 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => { flex-direction: column; max-width: 1400px; margin: 0 auto; - padding: ${theme.spacing(2)}; + padding: ${theme.spacing(1)}; background: ${theme.colors.background.primary}; border: 1px solid ${theme.components.panel.borderColor}; - margin-top: ${theme.spacing(4)}; - height: 100%; ${theme.breakpoints.up('md')} { From f132cf33884b441ccaae1c358ca04a340021cc74 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 10 Jun 2022 09:37:54 +0000 Subject: [PATCH 30/40] Update dependency @react-aria/focus to v3.6.0 (#50506) Co-authored-by: Renovate Bot --- package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 80 ++++++++++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 1df77b45723..cbfcaab8b6f 100644 --- a/package.json +++ b/package.json @@ -271,7 +271,7 @@ "@popperjs/core": "2.11.5", "@react-aria/button": "3.4.4", "@react-aria/dialog": "3.1.9", - "@react-aria/focus": "3.5.5", + "@react-aria/focus": "3.6.0", "@react-aria/interactions": "3.8.4", "@react-aria/menu": "3.4.4", "@react-aria/overlays": "3.8.2", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 124445a3bc5..667d75b9922 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -41,7 +41,7 @@ "@popperjs/core": "2.11.5", "@react-aria/button": "3.4.4", "@react-aria/dialog": "3.1.9", - "@react-aria/focus": "3.5.5", + "@react-aria/focus": "3.6.0", "@react-aria/menu": "3.4.4", "@react-aria/overlays": "3.8.2", "@react-stately/menu": "3.2.7", diff --git a/yarn.lock b/yarn.lock index 6ee0af96e06..9b295f86b84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4680,7 +4680,7 @@ __metadata: "@popperjs/core": 2.11.5 "@react-aria/button": 3.4.4 "@react-aria/dialog": 3.1.9 - "@react-aria/focus": 3.5.5 + "@react-aria/focus": 3.6.0 "@react-aria/menu": 3.4.4 "@react-aria/overlays": 3.8.2 "@react-stately/menu": 3.2.7 @@ -7327,7 +7327,22 @@ __metadata: languageName: node linkType: hard -"@react-aria/focus@npm:3.5.5, @react-aria/focus@npm:^3.5.5": +"@react-aria/focus@npm:3.6.0": + version: 3.6.0 + resolution: "@react-aria/focus@npm:3.6.0" + dependencies: + "@babel/runtime": ^7.6.2 + "@react-aria/interactions": ^3.9.0 + "@react-aria/utils": ^3.13.0 + "@react-types/shared": ^3.13.0 + clsx: ^1.1.1 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: 5b459ec9e7150c58bc515cc70db87d3fc10df42b171b69f021dee339e71fecafe4055f08c423f1e006643aa678b08e975312c5f02e474d7342c1ad24d6e26429 + languageName: node + linkType: hard + +"@react-aria/focus@npm:^3.5.5": version: 3.5.5 resolution: "@react-aria/focus@npm:3.5.5" dependencies: @@ -7372,6 +7387,19 @@ __metadata: languageName: node linkType: hard +"@react-aria/interactions@npm:^3.9.0": + version: 3.9.0 + resolution: "@react-aria/interactions@npm:3.9.0" + dependencies: + "@babel/runtime": ^7.6.2 + "@react-aria/utils": ^3.13.0 + "@react-types/shared": ^3.13.0 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: 783d4fa59bda9329f4977d8e83c88555c3376c489e40d97c974c7725ebf286849ed149f8e20226fbab070bd45356e5ba01ace328d2ed785f0f40e28790e7b0d5 + languageName: node + linkType: hard + "@react-aria/menu@npm:3.4.4": version: 3.4.4 resolution: "@react-aria/menu@npm:3.4.4" @@ -7445,6 +7473,17 @@ __metadata: languageName: node linkType: hard +"@react-aria/ssr@npm:^3.2.0": + version: 3.2.0 + resolution: "@react-aria/ssr@npm:3.2.0" + dependencies: + "@babel/runtime": ^7.6.2 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: 30468b436f9d56636eb3fb89cbbbf861c55ad841e255e529d225a0efd9c23628badaee72c42ab3870b5f17a9d5b5163564a64e9b928565230a090cb4f7de23bf + languageName: node + linkType: hard + "@react-aria/utils@npm:3.12.0, @react-aria/utils@npm:^3.12.0": version: 3.12.0 resolution: "@react-aria/utils@npm:3.12.0" @@ -7460,6 +7499,21 @@ __metadata: languageName: node linkType: hard +"@react-aria/utils@npm:^3.13.0": + version: 3.13.0 + resolution: "@react-aria/utils@npm:3.13.0" + dependencies: + "@babel/runtime": ^7.6.2 + "@react-aria/ssr": ^3.2.0 + "@react-stately/utils": ^3.5.0 + "@react-types/shared": ^3.13.0 + clsx: ^1.1.1 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: 7a154d6258adf6205ac367def0fcaf1b3fd0db3644c43481b2cb6eb9bc0f35ebddab9ecbddbec8dee92bbf5e38b63222137e1df5c26e5c8c1775ffa0e0b172fa + languageName: node + linkType: hard + "@react-aria/visually-hidden@npm:^3.2.8": version: 3.2.8 resolution: "@react-aria/visually-hidden@npm:3.2.8" @@ -7568,6 +7622,17 @@ __metadata: languageName: node linkType: hard +"@react-stately/utils@npm:^3.5.0": + version: 3.5.0 + resolution: "@react-stately/utils@npm:3.5.0" + dependencies: + "@babel/runtime": ^7.6.2 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: e948b783a01325a5396c46915508981152fcc02a8738c848342c8ecfbc64bdefc14530616d8ccfb6680fc85f24d0546f3df3686b189a9fbaa86ccbbe29e2fe1b + languageName: node + linkType: hard + "@react-types/button@npm:3.4.5, @react-types/button@npm:^3.4.5": version: 3.4.5 resolution: "@react-types/button@npm:3.4.5" @@ -7634,6 +7699,15 @@ __metadata: languageName: node linkType: hard +"@react-types/shared@npm:^3.13.0": + version: 3.13.0 + resolution: "@react-types/shared@npm:3.13.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: 59b94fd8ef8fbb8edeb44e233113450a15fee107f231eaced043a1acb820c7fb8bf690623a0f85e2a638a870ee074919d6f89263ad2395c26e363ae25b102bdf + languageName: node + linkType: hard + "@reduxjs/toolkit@npm:1.8.1": version: 1.8.1 resolution: "@reduxjs/toolkit@npm:1.8.1" @@ -20633,7 +20707,7 @@ __metadata: "@popperjs/core": 2.11.5 "@react-aria/button": 3.4.4 "@react-aria/dialog": 3.1.9 - "@react-aria/focus": 3.5.5 + "@react-aria/focus": 3.6.0 "@react-aria/interactions": 3.8.4 "@react-aria/menu": 3.4.4 "@react-aria/overlays": 3.8.2 From 2d1ab276360133233eb47b7e0590ba26d9c31933 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Fri, 10 Jun 2022 11:47:29 +0200 Subject: [PATCH 31/40] Grafana UI: Put back production build in rollup config (#50575) --- packages/grafana-ui/package.json | 1 - packages/grafana-ui/rollup.config.ts | 2 +- yarn.lock | 19 +------------------ 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 667d75b9922..4fa504c4180 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -170,7 +170,6 @@ "rollup-plugin-sourcemaps": "0.6.3", "rollup-plugin-svg-import": "^1.6.0", "rollup-plugin-terser": "7.0.2", - "rollup-plugin-visualizer": "^5.6.0", "sass-loader": "13.0.0", "storybook-dark-mode": "1.1.0", "style-loader": "3.3.1", diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts index 1979de79239..b5751895103 100644 --- a/packages/grafana-ui/rollup.config.ts +++ b/packages/grafana-ui/rollup.config.ts @@ -53,4 +53,4 @@ const buildCjsPackage = ({ env }) => { ], }; }; -export default [buildCjsPackage({ env: 'development' })]; +export default [buildCjsPackage({ env: 'development' }), buildCjsPackage({ env: 'production' })]; diff --git a/yarn.lock b/yarn.lock index 9b295f86b84..e0b445bc62a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4797,7 +4797,6 @@ __metadata: rollup-plugin-sourcemaps: 0.6.3 rollup-plugin-svg-import: ^1.6.0 rollup-plugin-terser: 7.0.2 - rollup-plugin-visualizer: ^5.6.0 rxjs: 7.5.5 sass-loader: 13.0.0 slate: 0.47.8 @@ -26499,7 +26498,7 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.1.32, nanoid@npm:^3.3.3, nanoid@npm:^3.3.4": +"nanoid@npm:^3.3.3, nanoid@npm:^3.3.4": version: 3.3.4 resolution: "nanoid@npm:3.3.4" bin: @@ -32292,22 +32291,6 @@ __metadata: languageName: node linkType: hard -"rollup-plugin-visualizer@npm:^5.6.0": - version: 5.6.0 - resolution: "rollup-plugin-visualizer@npm:5.6.0" - dependencies: - nanoid: ^3.1.32 - open: ^8.4.0 - source-map: ^0.7.3 - yargs: ^17.3.1 - peerDependencies: - rollup: ^2.0.0 - bin: - rollup-plugin-visualizer: dist/bin/cli.js - checksum: 1036a3873f3b6a0c46d692538aaac5dc9f7a7b2a5aec9e7ab816aaf8a31576ae750f415e1019e401707d18490b634a982fbe8c22d9761cafbd9b12c756e7fd99 - languageName: node - linkType: hard - "rollup@npm:2.74.1": version: 2.74.1 resolution: "rollup@npm:2.74.1" From 363f11156bca9cdeabdcfc29f51aa020e8f94f3f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 10 Jun 2022 11:54:20 +0200 Subject: [PATCH 32/40] Update dependency @react-aria/dialog to v3.2.0 (#50505) Co-authored-by: Renovate Bot --- package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 62 ++++++++++++++++++++++---------- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index cbfcaab8b6f..9e96bd945fa 100644 --- a/package.json +++ b/package.json @@ -270,7 +270,7 @@ "@opentelemetry/semantic-conventions": "1.3.1", "@popperjs/core": "2.11.5", "@react-aria/button": "3.4.4", - "@react-aria/dialog": "3.1.9", + "@react-aria/dialog": "3.2.0", "@react-aria/focus": "3.6.0", "@react-aria/interactions": "3.8.4", "@react-aria/menu": "3.4.4", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 4fa504c4180..819039f6293 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -40,7 +40,7 @@ "@monaco-editor/react": "4.3.1", "@popperjs/core": "2.11.5", "@react-aria/button": "3.4.4", - "@react-aria/dialog": "3.1.9", + "@react-aria/dialog": "3.2.0", "@react-aria/focus": "3.6.0", "@react-aria/menu": "3.4.4", "@react-aria/overlays": "3.8.2", diff --git a/yarn.lock b/yarn.lock index e0b445bc62a..1c8577a1ebe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4679,7 +4679,7 @@ __metadata: "@monaco-editor/react": 4.3.1 "@popperjs/core": 2.11.5 "@react-aria/button": 3.4.4 - "@react-aria/dialog": 3.1.9 + "@react-aria/dialog": 3.2.0 "@react-aria/focus": 3.6.0 "@react-aria/menu": 3.4.4 "@react-aria/overlays": 3.8.2 @@ -7311,22 +7311,22 @@ __metadata: languageName: node linkType: hard -"@react-aria/dialog@npm:3.1.9": - version: 3.1.9 - resolution: "@react-aria/dialog@npm:3.1.9" +"@react-aria/dialog@npm:3.2.0": + version: 3.2.0 + resolution: "@react-aria/dialog@npm:3.2.0" dependencies: "@babel/runtime": ^7.6.2 - "@react-aria/focus": ^3.5.5 - "@react-aria/utils": ^3.12.0 - "@react-stately/overlays": ^3.2.0 - "@react-types/dialog": ^3.3.5 + "@react-aria/focus": ^3.6.0 + "@react-aria/utils": ^3.13.0 + "@react-stately/overlays": ^3.3.0 + "@react-types/dialog": ^3.4.0 peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 - checksum: 6ce0f22d0804bb4cb86521c0da81024a52605490f0deb12162cc56f416544f4e4015d38c401dfed906d3101310a5b6c122d96ad2d506d444c671a29011c6df98 + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: a62a7c71bf65fc28f7e863bd1f250977f16043097b0863583cebd235d8d5abfed40789a0d00af06fb398afe5d16219e1f694d017d0bd0ff0d8479fa041409f19 languageName: node linkType: hard -"@react-aria/focus@npm:3.6.0": +"@react-aria/focus@npm:3.6.0, @react-aria/focus@npm:^3.6.0": version: 3.6.0 resolution: "@react-aria/focus@npm:3.6.0" dependencies: @@ -7567,6 +7567,19 @@ __metadata: languageName: node linkType: hard +"@react-stately/overlays@npm:^3.3.0": + version: 3.3.0 + resolution: "@react-stately/overlays@npm:3.3.0" + dependencies: + "@babel/runtime": ^7.6.2 + "@react-stately/utils": ^3.5.0 + "@react-types/overlays": ^3.6.0 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: 19d4e9f2c9fb745704f9400d54a7b78766bd3f7592df9253ddf11a9231b2a21f6412166d714d7cea8ace28db33791989c092ac1ab7f566a8b53f3c9d17e9b18c + languageName: node + linkType: hard + "@react-stately/selection@npm:^3.9.4": version: 3.9.4 resolution: "@react-stately/selection@npm:3.9.4" @@ -7654,15 +7667,15 @@ __metadata: languageName: node linkType: hard -"@react-types/dialog@npm:^3.3.5": - version: 3.3.5 - resolution: "@react-types/dialog@npm:3.3.5" +"@react-types/dialog@npm:^3.4.0": + version: 3.4.0 + resolution: "@react-types/dialog@npm:3.4.0" dependencies: - "@react-types/overlays": ^3.5.5 - "@react-types/shared": ^3.12.0 + "@react-types/overlays": ^3.6.0 + "@react-types/shared": ^3.13.0 peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 - checksum: 50847ec5ccb6b4cffb62fb19fb7d026d3965cd02db293b247f415ace1e394e61291637dcd83159ec7de99749994a3a67ab45ec927a810e4677c2f4bdb7ed54bd + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: 88d6ea76b345474e17d20e61d716151a254bcce7df5c0e86d23219db0dc4e970cc3a3c29488910bf48ec907282bbb67de99256082017aa97971dffe33f0b8ff4 languageName: node linkType: hard @@ -7689,6 +7702,17 @@ __metadata: languageName: node linkType: hard +"@react-types/overlays@npm:^3.6.0": + version: 3.6.0 + resolution: "@react-types/overlays@npm:3.6.0" + dependencies: + "@react-types/shared": ^3.13.0 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: 9f6c773bcb4877c9847d79946e90286d335f5d0306e4f656be068bfeaa395bb685231614155282395356bf9acffc7a9086fc4aca8b117b4231e5985332cbe8fc + languageName: node + linkType: hard + "@react-types/shared@npm:3.12.0, @react-types/shared@npm:^3.12.0": version: 3.12.0 resolution: "@react-types/shared@npm:3.12.0" @@ -20705,7 +20729,7 @@ __metadata: "@pmmmwh/react-refresh-webpack-plugin": 0.5.6 "@popperjs/core": 2.11.5 "@react-aria/button": 3.4.4 - "@react-aria/dialog": 3.1.9 + "@react-aria/dialog": 3.2.0 "@react-aria/focus": 3.6.0 "@react-aria/interactions": 3.8.4 "@react-aria/menu": 3.4.4 From 502c6e4e6b0e160a9d5bb49d5f432d4bc940f60c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 10 Jun 2022 09:59:37 +0000 Subject: [PATCH 33/40] Update dependency @react-aria/interactions to v3.9.0 (#50579) Co-authored-by: Renovate Bot --- package.json | 2 +- yarn.lock | 30 +++++++++++++++--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 9e96bd945fa..add10c6848f 100644 --- a/package.json +++ b/package.json @@ -272,7 +272,7 @@ "@react-aria/button": "3.4.4", "@react-aria/dialog": "3.2.0", "@react-aria/focus": "3.6.0", - "@react-aria/interactions": "3.8.4", + "@react-aria/interactions": "3.9.0", "@react-aria/menu": "3.4.4", "@react-aria/overlays": "3.8.2", "@react-aria/utils": "3.12.0", diff --git a/yarn.lock b/yarn.lock index 1c8577a1ebe..a539999587b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7373,20 +7373,7 @@ __metadata: languageName: node linkType: hard -"@react-aria/interactions@npm:3.8.4, @react-aria/interactions@npm:^3.8.4": - version: 3.8.4 - resolution: "@react-aria/interactions@npm:3.8.4" - dependencies: - "@babel/runtime": ^7.6.2 - "@react-aria/utils": ^3.12.0 - "@react-types/shared": ^3.12.0 - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 - checksum: 0fae18cfd47e73fb92355312ff3fe54bc469e9b67b97e6d7d566b894433c95232128352c2ff439472a449f3097c866732ba6a7c658efa6f9391f02d3e0e977ef - languageName: node - linkType: hard - -"@react-aria/interactions@npm:^3.9.0": +"@react-aria/interactions@npm:3.9.0, @react-aria/interactions@npm:^3.9.0": version: 3.9.0 resolution: "@react-aria/interactions@npm:3.9.0" dependencies: @@ -7399,6 +7386,19 @@ __metadata: languageName: node linkType: hard +"@react-aria/interactions@npm:^3.8.4": + version: 3.8.4 + resolution: "@react-aria/interactions@npm:3.8.4" + dependencies: + "@babel/runtime": ^7.6.2 + "@react-aria/utils": ^3.12.0 + "@react-types/shared": ^3.12.0 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + checksum: 0fae18cfd47e73fb92355312ff3fe54bc469e9b67b97e6d7d566b894433c95232128352c2ff439472a449f3097c866732ba6a7c658efa6f9391f02d3e0e977ef + languageName: node + linkType: hard + "@react-aria/menu@npm:3.4.4": version: 3.4.4 resolution: "@react-aria/menu@npm:3.4.4" @@ -20731,7 +20731,7 @@ __metadata: "@react-aria/button": 3.4.4 "@react-aria/dialog": 3.2.0 "@react-aria/focus": 3.6.0 - "@react-aria/interactions": 3.8.4 + "@react-aria/interactions": 3.9.0 "@react-aria/menu": 3.4.4 "@react-aria/overlays": 3.8.2 "@react-aria/utils": 3.12.0 From 9a85a2e4410266270a1cd59db1937c7ba248966e Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Fri, 10 Jun 2022 12:13:31 +0200 Subject: [PATCH 34/40] Data Connections: Create a new top-level page (#50018) * Feature Flags: introduce a flag for enabling the Data Connections page * Feature Flags: generate schemas * Navigation: add navigation weight for the Data Connections page * NavLink: add a comment pointing out where icon names can be looked up * NavTree: add a new page called Data Connections * fix(Api): prefix the navigation IDs with the parent ("data-connections") * feat(Frontend): add a basic page with four tabs * feat(Plugins): add a hook for importing an app plugin * feat(Plugins): add a component for loading app plugins anywhere * feat(Data Connections): load the cloud-onboarding app under the "Cloud onboarding" tab * feat(Data Connections): generate a proper nav model to highlight active tabs * test(Data Connections): add tests * refactor(Data Connections): update temporary text content This is only used as a placeholder until the tabs are under development. * refactor(Data Cnnnections): move /pages to /tabs * refactor(Data Connections): remove the `types.ts` file as it is not referenced by any module * feat(Data Connections): only register routes if feature is enabled --- .../src/types/featureToggles.gen.ts | 1 + pkg/api/dtos/index.go | 3 +- pkg/api/index.go | 56 + pkg/services/featuremgmt/registry.go | 4 + pkg/services/featuremgmt/toggles_gen.go | 4 + .../DataConnectionsPage.test.tsx | 42 + .../data-connections/DataConnectionsPage.tsx | 30 + .../__mocks__/store.navIndex.mock.ts | 2519 +++++++++++++++++ .../features/data-connections/constants.ts | 12 + .../data-connections/hooks/useNavModel.ts | 26 + .../app/features/data-connections/routes.tsx | 21 + .../CloudIntegrations/CloudIntegrations.tsx | 25 + .../tabs/CloudIntegrations/index.tsx | 1 + .../tabs/DataSources/DataSources.tsx | 5 + .../tabs/DataSources/index.tsx | 1 + .../data-connections/tabs/Plugins/Plugins.tsx | 5 + .../data-connections/tabs/Plugins/index.tsx | 1 + .../tabs/RecordedQueries/RecordedQueries.tsx | 5 + .../tabs/RecordedQueries/index.tsx | 1 + .../components/AppPluginLoader.test.tsx | 132 + .../plugins/components/AppPluginLoader.tsx | 44 + .../hooks/tests/useImportAppPlugin.test.tsx | 150 + .../plugins/hooks/useImportAppPlugin.ts | 26 + public/app/routes/routes.tsx | 2 + 24 files changed, 3115 insertions(+), 1 deletion(-) create mode 100644 public/app/features/data-connections/DataConnectionsPage.test.tsx create mode 100644 public/app/features/data-connections/DataConnectionsPage.tsx create mode 100644 public/app/features/data-connections/__mocks__/store.navIndex.mock.ts create mode 100644 public/app/features/data-connections/constants.ts create mode 100644 public/app/features/data-connections/hooks/useNavModel.ts create mode 100644 public/app/features/data-connections/routes.tsx create mode 100644 public/app/features/data-connections/tabs/CloudIntegrations/CloudIntegrations.tsx create mode 100644 public/app/features/data-connections/tabs/CloudIntegrations/index.tsx create mode 100644 public/app/features/data-connections/tabs/DataSources/DataSources.tsx create mode 100644 public/app/features/data-connections/tabs/DataSources/index.tsx create mode 100644 public/app/features/data-connections/tabs/Plugins/Plugins.tsx create mode 100644 public/app/features/data-connections/tabs/Plugins/index.tsx create mode 100644 public/app/features/data-connections/tabs/RecordedQueries/RecordedQueries.tsx create mode 100644 public/app/features/data-connections/tabs/RecordedQueries/index.tsx create mode 100644 public/app/features/plugins/components/AppPluginLoader.test.tsx create mode 100644 public/app/features/plugins/components/AppPluginLoader.tsx create mode 100644 public/app/features/plugins/hooks/tests/useImportAppPlugin.test.tsx create mode 100644 public/app/features/plugins/hooks/useImportAppPlugin.ts diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 3cc6fa27b0b..e140056e6ee 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -59,4 +59,5 @@ export interface FeatureToggles { canvasPanelNesting?: boolean; cloudMonitoringExperimentalUI?: boolean; logRequestsInstrumentedAsUnknown?: boolean; + dataConnectionsConsole?: boolean; } diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index caba326a2a8..d951bcfc87a 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -42,6 +42,7 @@ const ( WeightDashboard WeightExplore WeightAlerting + WeightDataConnections WeightPlugin WeightConfig WeightAdmin @@ -61,7 +62,7 @@ type NavLink struct { Description string `json:"description,omitempty"` Section string `json:"section,omitempty"` SubTitle string `json:"subTitle,omitempty"` - Icon string `json:"icon,omitempty"` + Icon string `json:"icon,omitempty"` // Available icons can be browsed in Storybook: https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview Img string `json:"img,omitempty"` Url string `json:"url,omitempty"` Target string `json:"target,omitempty"` diff --git a/pkg/api/index.go b/pkg/api/index.go index 8aa9a327461..c288c653817 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -245,6 +245,10 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs * navTree = append(navTree, hs.buildAlertNavLinks(c)...) } + if hs.Features.IsEnabled(featuremgmt.FlagDataConnectionsConsole) { + navTree = append(navTree, hs.buildDataConnectionsNavLink(c)) + } + appLinks, err := hs.getAppLinks(c) if err != nil { return nil, err @@ -630,6 +634,58 @@ func (hs *HTTPServer) buildCreateNavLinks(c *models.ReqContext) []*dtos.NavLink return children } +func (hs *HTTPServer) buildDataConnectionsNavLink(c *models.ReqContext) *dtos.NavLink { + var children []*dtos.NavLink + var navLink *dtos.NavLink + + baseId := "data-connections" + baseUrl := hs.Cfg.AppSubURL + "/" + baseId + + children = append(children, &dtos.NavLink{ + Id: baseId + "-datasources", + Text: "Data sources", + Icon: "database", + Description: "Add and configure data sources", + Url: baseUrl + "/datasources", + }) + + children = append(children, &dtos.NavLink{ + Id: baseId + "-plugins", + Text: "Plugins", + Icon: "plug", + Description: "Manage plugins", + Url: baseUrl + "/plugins", + }) + + children = append(children, &dtos.NavLink{ + Id: baseId + "-cloud-integrations", + Text: "Cloud integrations", + Icon: "bolt", + Description: "Manage your cloud integrations", + Url: baseUrl + "/cloud-integrations", + }) + + children = append(children, &dtos.NavLink{ + Id: baseId + "-recorded-queries", + Text: "Recorded queries", + Icon: "record-audio", + Description: "Manage your recorded queries", + Url: baseUrl + "/recorded-queries", + }) + + navLink = &dtos.NavLink{ + Text: "Data Connections", + Icon: "link", + Id: baseId, + Url: baseUrl, + Children: children, + Section: dtos.NavSectionCore, + SortWeight: dtos.WeightDataConnections, + } + + return navLink +} + func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink { hasAccess := ac.HasAccess(hs.AccessControl, c) hasGlobalAccess := ac.HasGlobalAccess(hs.AccessControl, c) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 1490b6be669..416f5da8ef8 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -240,6 +240,10 @@ var ( { Name: "logRequestsInstrumentedAsUnknown", Description: "Logs the path for requests that are instrumented as unknown", + }, + { + Name: "dataConnectionsConsole", + Description: "Enables a new top-level page called Data Connections. This page is an experiment for better grouping of installing / configuring data sources and other plugins.", State: FeatureStateAlpha, }, } diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index bbc6b55dfd7..ec83161bcf8 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -178,4 +178,8 @@ const ( // FlagLogRequestsInstrumentedAsUnknown // Logs the path for requests that are instrumented as unknown FlagLogRequestsInstrumentedAsUnknown = "logRequestsInstrumentedAsUnknown" + + // FlagDataConnectionsConsole + // Enables a new top-level page called Data Connections. This page is an experiment for better grouping of installing / configuring data sources and other plugins. + FlagDataConnectionsConsole = "dataConnectionsConsole" ) diff --git a/public/app/features/data-connections/DataConnectionsPage.test.tsx b/public/app/features/data-connections/DataConnectionsPage.test.tsx new file mode 100644 index 00000000000..3498905301f --- /dev/null +++ b/public/app/features/data-connections/DataConnectionsPage.test.tsx @@ -0,0 +1,42 @@ +import { render, RenderResult, screen } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; + +import { locationService } from '@grafana/runtime'; +import { configureStore } from 'app/store/configureStore'; + +import DataConnectionsPage from './DataConnectionsPage'; +import navIndex from './__mocks__/store.navIndex.mock'; +import { ROUTE_BASE_ID } from './constants'; + +const renderPage = (path = `/${ROUTE_BASE_ID}`): RenderResult => { + // @ts-ignore + const store = configureStore({ navIndex }); + locationService.push(path); + + return render( + + + + + + ); +}; + +describe('Data Connections Page', () => { + test('shows all the four tabs', async () => { + renderPage(); + + expect(await screen.findByLabelText('Tab Data sources')).toBeVisible(); + expect(await screen.findByLabelText('Tab Plugins')).toBeVisible(); + expect(await screen.findByLabelText('Tab Cloud integrations')).toBeVisible(); + expect(await screen.findByLabelText('Tab Recorded queries')).toBeVisible(); + }); + + test('shows the "Data sources" tab by default', async () => { + renderPage(); + + expect(await screen.findByText('The list of data sources is under development.')).toBeVisible(); + }); +}); diff --git a/public/app/features/data-connections/DataConnectionsPage.tsx b/public/app/features/data-connections/DataConnectionsPage.tsx new file mode 100644 index 00000000000..0cf34e69871 --- /dev/null +++ b/public/app/features/data-connections/DataConnectionsPage.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { Page } from 'app/core/components/Page/Page'; + +import { ROUTES } from './constants'; +import { useNavModel } from './hooks/useNavModel'; +import { CloudIntegrations } from './tabs/CloudIntegrations'; +import { DataSources } from './tabs/DataSources'; +import { Plugins } from './tabs/Plugins'; +import { RecordedQueries } from './tabs/RecordedQueries'; + +export default function DataConnectionsPage(): React.ReactElement | null { + const navModel = useNavModel(); + + return ( + + + + + + + + {/* Default page */} + + + + + ); +} diff --git a/public/app/features/data-connections/__mocks__/store.navIndex.mock.ts b/public/app/features/data-connections/__mocks__/store.navIndex.mock.ts new file mode 100644 index 00000000000..4546a29ec7c --- /dev/null +++ b/public/app/features/data-connections/__mocks__/store.navIndex.mock.ts @@ -0,0 +1,2519 @@ +export default { + dashboards: { + id: 'dashboards', + text: 'Dashboards', + section: 'core', + subTitle: 'Manage dashboards and folders', + icon: 'apps', + url: '/dashboards', + sortWeight: -1800, + children: [ + { + id: 'manage-dashboards', + text: 'Browse', + icon: 'sitemap', + url: '/dashboards', + }, + { + id: 'playlists', + text: 'Playlists', + icon: 'presentation-play', + url: '/playlists', + }, + { + id: 'snapshots', + text: 'Snapshots', + icon: 'camera', + url: '/dashboard/snapshots', + }, + { + id: 'library-panels', + text: 'Library panels', + icon: 'library-panel', + url: '/library-panels', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'new-dashboard', + text: 'New dashboard', + icon: 'plus', + url: '/dashboard/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'new-folder', + text: 'New folder', + subTitle: 'Create a new folder to organize your dashboards', + icon: 'plus', + url: '/dashboards/folder/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'import', + text: 'Import', + subTitle: 'Import dashboard from file or Grafana.com', + icon: 'plus', + url: '/dashboard/import', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + 'manage-dashboards': { + id: 'manage-dashboards', + text: 'Browse', + icon: 'sitemap', + url: '/dashboards', + parentItem: { + id: 'dashboards', + text: 'Dashboards', + section: 'core', + subTitle: 'Manage dashboards and folders', + icon: 'apps', + url: '/dashboards', + sortWeight: -1800, + children: [ + { + id: 'manage-dashboards', + text: 'Browse', + icon: 'sitemap', + url: '/dashboards', + }, + { + id: 'playlists', + text: 'Playlists', + icon: 'presentation-play', + url: '/playlists', + }, + { + id: 'snapshots', + text: 'Snapshots', + icon: 'camera', + url: '/dashboard/snapshots', + }, + { + id: 'library-panels', + text: 'Library panels', + icon: 'library-panel', + url: '/library-panels', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'new-dashboard', + text: 'New dashboard', + icon: 'plus', + url: '/dashboard/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'new-folder', + text: 'New folder', + subTitle: 'Create a new folder to organize your dashboards', + icon: 'plus', + url: '/dashboards/folder/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'import', + text: 'Import', + subTitle: 'Import dashboard from file or Grafana.com', + icon: 'plus', + url: '/dashboard/import', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + playlists: { + id: 'playlists', + text: 'Playlists', + icon: 'presentation-play', + url: '/playlists', + parentItem: { + id: 'dashboards', + text: 'Dashboards', + section: 'core', + subTitle: 'Manage dashboards and folders', + icon: 'apps', + url: '/dashboards', + sortWeight: -1800, + children: [ + { + id: 'manage-dashboards', + text: 'Browse', + icon: 'sitemap', + url: '/dashboards', + }, + { + id: 'playlists', + text: 'Playlists', + icon: 'presentation-play', + url: '/playlists', + }, + { + id: 'snapshots', + text: 'Snapshots', + icon: 'camera', + url: '/dashboard/snapshots', + }, + { + id: 'library-panels', + text: 'Library panels', + icon: 'library-panel', + url: '/library-panels', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'new-dashboard', + text: 'New dashboard', + icon: 'plus', + url: '/dashboard/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'new-folder', + text: 'New folder', + subTitle: 'Create a new folder to organize your dashboards', + icon: 'plus', + url: '/dashboards/folder/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'import', + text: 'Import', + subTitle: 'Import dashboard from file or Grafana.com', + icon: 'plus', + url: '/dashboard/import', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + snapshots: { + id: 'snapshots', + text: 'Snapshots', + icon: 'camera', + url: '/dashboard/snapshots', + parentItem: { + id: 'dashboards', + text: 'Dashboards', + section: 'core', + subTitle: 'Manage dashboards and folders', + icon: 'apps', + url: '/dashboards', + sortWeight: -1800, + children: [ + { + id: 'manage-dashboards', + text: 'Browse', + icon: 'sitemap', + url: '/dashboards', + }, + { + id: 'playlists', + text: 'Playlists', + icon: 'presentation-play', + url: '/playlists', + }, + { + id: 'snapshots', + text: 'Snapshots', + icon: 'camera', + url: '/dashboard/snapshots', + }, + { + id: 'library-panels', + text: 'Library panels', + icon: 'library-panel', + url: '/library-panels', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'new-dashboard', + text: 'New dashboard', + icon: 'plus', + url: '/dashboard/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'new-folder', + text: 'New folder', + subTitle: 'Create a new folder to organize your dashboards', + icon: 'plus', + url: '/dashboards/folder/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'import', + text: 'Import', + subTitle: 'Import dashboard from file or Grafana.com', + icon: 'plus', + url: '/dashboard/import', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + 'library-panels': { + id: 'library-panels', + text: 'Library panels', + icon: 'library-panel', + url: '/library-panels', + parentItem: { + id: 'dashboards', + text: 'Dashboards', + section: 'core', + subTitle: 'Manage dashboards and folders', + icon: 'apps', + url: '/dashboards', + sortWeight: -1800, + children: [ + { + id: 'manage-dashboards', + text: 'Browse', + icon: 'sitemap', + url: '/dashboards', + }, + { + id: 'playlists', + text: 'Playlists', + icon: 'presentation-play', + url: '/playlists', + }, + { + id: 'snapshots', + text: 'Snapshots', + icon: 'camera', + url: '/dashboard/snapshots', + }, + { + id: 'library-panels', + text: 'Library panels', + icon: 'library-panel', + url: '/library-panels', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'new-dashboard', + text: 'New dashboard', + icon: 'plus', + url: '/dashboard/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'new-folder', + text: 'New folder', + subTitle: 'Create a new folder to organize your dashboards', + icon: 'plus', + url: '/dashboards/folder/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'import', + text: 'Import', + subTitle: 'Import dashboard from file or Grafana.com', + icon: 'plus', + url: '/dashboard/import', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + divider: { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + parentItem: { + id: 'alerting', + text: 'Alerting', + section: 'core', + subTitle: 'Alert rules and notifications', + icon: 'bell', + url: '/alerting/list', + sortWeight: -1600, + children: [ + { + id: 'alert-list', + text: 'Alert rules', + icon: 'list-ul', + url: '/alerting/list', + }, + { + id: 'receivers', + text: 'Contact points', + icon: 'comment-alt-share', + url: '/alerting/notifications', + }, + { + id: 'am-routes', + text: 'Notification policies', + icon: 'sitemap', + url: '/alerting/routes', + }, + { + id: 'silences', + text: 'Silences', + icon: 'bell-slash', + url: '/alerting/silences', + }, + { + id: 'groups', + text: 'Alert groups', + icon: 'layer-group', + url: '/alerting/groups', + }, + { + id: 'alerting-admin', + text: 'Admin', + icon: 'cog', + url: '/alerting/admin', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'alert', + text: 'New alert rule', + subTitle: 'Create an alert rule', + icon: 'plus', + url: '/alerting/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + 'new-dashboard': { + id: 'new-dashboard', + text: 'New dashboard', + icon: 'plus', + url: '/dashboard/new', + hideFromTabs: true, + showIconInNavbar: true, + parentItem: { + id: 'dashboards', + text: 'Dashboards', + section: 'core', + subTitle: 'Manage dashboards and folders', + icon: 'apps', + url: '/dashboards', + sortWeight: -1800, + children: [ + { + id: 'manage-dashboards', + text: 'Browse', + icon: 'sitemap', + url: '/dashboards', + }, + { + id: 'playlists', + text: 'Playlists', + icon: 'presentation-play', + url: '/playlists', + }, + { + id: 'snapshots', + text: 'Snapshots', + icon: 'camera', + url: '/dashboard/snapshots', + }, + { + id: 'library-panels', + text: 'Library panels', + icon: 'library-panel', + url: '/library-panels', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'new-dashboard', + text: 'New dashboard', + icon: 'plus', + url: '/dashboard/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'new-folder', + text: 'New folder', + subTitle: 'Create a new folder to organize your dashboards', + icon: 'plus', + url: '/dashboards/folder/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'import', + text: 'Import', + subTitle: 'Import dashboard from file or Grafana.com', + icon: 'plus', + url: '/dashboard/import', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + 'new-folder': { + id: 'new-folder', + text: 'New folder', + subTitle: 'Create a new folder to organize your dashboards', + icon: 'plus', + url: '/dashboards/folder/new', + hideFromTabs: true, + showIconInNavbar: true, + parentItem: { + id: 'dashboards', + text: 'Dashboards', + section: 'core', + subTitle: 'Manage dashboards and folders', + icon: 'apps', + url: '/dashboards', + sortWeight: -1800, + children: [ + { + id: 'manage-dashboards', + text: 'Browse', + icon: 'sitemap', + url: '/dashboards', + }, + { + id: 'playlists', + text: 'Playlists', + icon: 'presentation-play', + url: '/playlists', + }, + { + id: 'snapshots', + text: 'Snapshots', + icon: 'camera', + url: '/dashboard/snapshots', + }, + { + id: 'library-panels', + text: 'Library panels', + icon: 'library-panel', + url: '/library-panels', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'new-dashboard', + text: 'New dashboard', + icon: 'plus', + url: '/dashboard/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'new-folder', + text: 'New folder', + subTitle: 'Create a new folder to organize your dashboards', + icon: 'plus', + url: '/dashboards/folder/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'import', + text: 'Import', + subTitle: 'Import dashboard from file or Grafana.com', + icon: 'plus', + url: '/dashboard/import', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + import: { + id: 'import', + text: 'Import', + subTitle: 'Import dashboard from file or Grafana.com', + icon: 'plus', + url: '/dashboard/import', + hideFromTabs: true, + showIconInNavbar: true, + parentItem: { + id: 'dashboards', + text: 'Dashboards', + section: 'core', + subTitle: 'Manage dashboards and folders', + icon: 'apps', + url: '/dashboards', + sortWeight: -1800, + children: [ + { + id: 'manage-dashboards', + text: 'Browse', + icon: 'sitemap', + url: '/dashboards', + }, + { + id: 'playlists', + text: 'Playlists', + icon: 'presentation-play', + url: '/playlists', + }, + { + id: 'snapshots', + text: 'Snapshots', + icon: 'camera', + url: '/dashboard/snapshots', + }, + { + id: 'library-panels', + text: 'Library panels', + icon: 'library-panel', + url: '/library-panels', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'new-dashboard', + text: 'New dashboard', + icon: 'plus', + url: '/dashboard/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'new-folder', + text: 'New folder', + subTitle: 'Create a new folder to organize your dashboards', + icon: 'plus', + url: '/dashboards/folder/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + { + id: 'import', + text: 'Import', + subTitle: 'Import dashboard from file or Grafana.com', + icon: 'plus', + url: '/dashboard/import', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + 'not-found': { + text: 'Page not found', + subTitle: '404 Error', + icon: 'exclamation-triangle', + }, + explore: { + id: 'explore', + text: 'Explore', + section: 'core', + subTitle: 'Explore your data', + icon: 'compass', + url: '/explore', + sortWeight: -1700, + }, + alerting: { + id: 'alerting', + text: 'Alerting', + section: 'core', + subTitle: 'Alert rules and notifications', + icon: 'bell', + url: '/alerting/list', + sortWeight: -1600, + children: [ + { + id: 'alert-list', + text: 'Alert rules', + icon: 'list-ul', + url: '/alerting/list', + }, + { + id: 'receivers', + text: 'Contact points', + icon: 'comment-alt-share', + url: '/alerting/notifications', + }, + { + id: 'am-routes', + text: 'Notification policies', + icon: 'sitemap', + url: '/alerting/routes', + }, + { + id: 'silences', + text: 'Silences', + icon: 'bell-slash', + url: '/alerting/silences', + }, + { + id: 'groups', + text: 'Alert groups', + icon: 'layer-group', + url: '/alerting/groups', + }, + { + id: 'alerting-admin', + text: 'Admin', + icon: 'cog', + url: '/alerting/admin', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'alert', + text: 'New alert rule', + subTitle: 'Create an alert rule', + icon: 'plus', + url: '/alerting/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + 'alert-list': { + id: 'alert-list', + text: 'Alert rules', + icon: 'list-ul', + url: '/alerting/list', + parentItem: { + id: 'alerting', + text: 'Alerting', + section: 'core', + subTitle: 'Alert rules and notifications', + icon: 'bell', + url: '/alerting/list', + sortWeight: -1600, + children: [ + { + id: 'alert-list', + text: 'Alert rules', + icon: 'list-ul', + url: '/alerting/list', + }, + { + id: 'receivers', + text: 'Contact points', + icon: 'comment-alt-share', + url: '/alerting/notifications', + }, + { + id: 'am-routes', + text: 'Notification policies', + icon: 'sitemap', + url: '/alerting/routes', + }, + { + id: 'silences', + text: 'Silences', + icon: 'bell-slash', + url: '/alerting/silences', + }, + { + id: 'groups', + text: 'Alert groups', + icon: 'layer-group', + url: '/alerting/groups', + }, + { + id: 'alerting-admin', + text: 'Admin', + icon: 'cog', + url: '/alerting/admin', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'alert', + text: 'New alert rule', + subTitle: 'Create an alert rule', + icon: 'plus', + url: '/alerting/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + receivers: { + id: 'receivers', + text: 'Contact points', + icon: 'comment-alt-share', + url: '/alerting/notifications', + parentItem: { + id: 'alerting', + text: 'Alerting', + section: 'core', + subTitle: 'Alert rules and notifications', + icon: 'bell', + url: '/alerting/list', + sortWeight: -1600, + children: [ + { + id: 'alert-list', + text: 'Alert rules', + icon: 'list-ul', + url: '/alerting/list', + }, + { + id: 'receivers', + text: 'Contact points', + icon: 'comment-alt-share', + url: '/alerting/notifications', + }, + { + id: 'am-routes', + text: 'Notification policies', + icon: 'sitemap', + url: '/alerting/routes', + }, + { + id: 'silences', + text: 'Silences', + icon: 'bell-slash', + url: '/alerting/silences', + }, + { + id: 'groups', + text: 'Alert groups', + icon: 'layer-group', + url: '/alerting/groups', + }, + { + id: 'alerting-admin', + text: 'Admin', + icon: 'cog', + url: '/alerting/admin', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'alert', + text: 'New alert rule', + subTitle: 'Create an alert rule', + icon: 'plus', + url: '/alerting/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + 'am-routes': { + id: 'am-routes', + text: 'Notification policies', + icon: 'sitemap', + url: '/alerting/routes', + parentItem: { + id: 'alerting', + text: 'Alerting', + section: 'core', + subTitle: 'Alert rules and notifications', + icon: 'bell', + url: '/alerting/list', + sortWeight: -1600, + children: [ + { + id: 'alert-list', + text: 'Alert rules', + icon: 'list-ul', + url: '/alerting/list', + }, + { + id: 'receivers', + text: 'Contact points', + icon: 'comment-alt-share', + url: '/alerting/notifications', + }, + { + id: 'am-routes', + text: 'Notification policies', + icon: 'sitemap', + url: '/alerting/routes', + }, + { + id: 'silences', + text: 'Silences', + icon: 'bell-slash', + url: '/alerting/silences', + }, + { + id: 'groups', + text: 'Alert groups', + icon: 'layer-group', + url: '/alerting/groups', + }, + { + id: 'alerting-admin', + text: 'Admin', + icon: 'cog', + url: '/alerting/admin', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'alert', + text: 'New alert rule', + subTitle: 'Create an alert rule', + icon: 'plus', + url: '/alerting/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + silences: { + id: 'silences', + text: 'Silences', + icon: 'bell-slash', + url: '/alerting/silences', + parentItem: { + id: 'alerting', + text: 'Alerting', + section: 'core', + subTitle: 'Alert rules and notifications', + icon: 'bell', + url: '/alerting/list', + sortWeight: -1600, + children: [ + { + id: 'alert-list', + text: 'Alert rules', + icon: 'list-ul', + url: '/alerting/list', + }, + { + id: 'receivers', + text: 'Contact points', + icon: 'comment-alt-share', + url: '/alerting/notifications', + }, + { + id: 'am-routes', + text: 'Notification policies', + icon: 'sitemap', + url: '/alerting/routes', + }, + { + id: 'silences', + text: 'Silences', + icon: 'bell-slash', + url: '/alerting/silences', + }, + { + id: 'groups', + text: 'Alert groups', + icon: 'layer-group', + url: '/alerting/groups', + }, + { + id: 'alerting-admin', + text: 'Admin', + icon: 'cog', + url: '/alerting/admin', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'alert', + text: 'New alert rule', + subTitle: 'Create an alert rule', + icon: 'plus', + url: '/alerting/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + groups: { + id: 'groups', + text: 'Alert groups', + icon: 'layer-group', + url: '/alerting/groups', + parentItem: { + id: 'alerting', + text: 'Alerting', + section: 'core', + subTitle: 'Alert rules and notifications', + icon: 'bell', + url: '/alerting/list', + sortWeight: -1600, + children: [ + { + id: 'alert-list', + text: 'Alert rules', + icon: 'list-ul', + url: '/alerting/list', + }, + { + id: 'receivers', + text: 'Contact points', + icon: 'comment-alt-share', + url: '/alerting/notifications', + }, + { + id: 'am-routes', + text: 'Notification policies', + icon: 'sitemap', + url: '/alerting/routes', + }, + { + id: 'silences', + text: 'Silences', + icon: 'bell-slash', + url: '/alerting/silences', + }, + { + id: 'groups', + text: 'Alert groups', + icon: 'layer-group', + url: '/alerting/groups', + }, + { + id: 'alerting-admin', + text: 'Admin', + icon: 'cog', + url: '/alerting/admin', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'alert', + text: 'New alert rule', + subTitle: 'Create an alert rule', + icon: 'plus', + url: '/alerting/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + 'alerting-admin': { + id: 'alerting-admin', + text: 'Admin', + icon: 'cog', + url: '/alerting/admin', + parentItem: { + id: 'alerting', + text: 'Alerting', + section: 'core', + subTitle: 'Alert rules and notifications', + icon: 'bell', + url: '/alerting/list', + sortWeight: -1600, + children: [ + { + id: 'alert-list', + text: 'Alert rules', + icon: 'list-ul', + url: '/alerting/list', + }, + { + id: 'receivers', + text: 'Contact points', + icon: 'comment-alt-share', + url: '/alerting/notifications', + }, + { + id: 'am-routes', + text: 'Notification policies', + icon: 'sitemap', + url: '/alerting/routes', + }, + { + id: 'silences', + text: 'Silences', + icon: 'bell-slash', + url: '/alerting/silences', + }, + { + id: 'groups', + text: 'Alert groups', + icon: 'layer-group', + url: '/alerting/groups', + }, + { + id: 'alerting-admin', + text: 'Admin', + icon: 'cog', + url: '/alerting/admin', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'alert', + text: 'New alert rule', + subTitle: 'Create an alert rule', + icon: 'plus', + url: '/alerting/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + alert: { + id: 'alert', + text: 'New alert rule', + subTitle: 'Create an alert rule', + icon: 'plus', + url: '/alerting/new', + hideFromTabs: true, + showIconInNavbar: true, + parentItem: { + id: 'alerting', + text: 'Alerting', + section: 'core', + subTitle: 'Alert rules and notifications', + icon: 'bell', + url: '/alerting/list', + sortWeight: -1600, + children: [ + { + id: 'alert-list', + text: 'Alert rules', + icon: 'list-ul', + url: '/alerting/list', + }, + { + id: 'receivers', + text: 'Contact points', + icon: 'comment-alt-share', + url: '/alerting/notifications', + }, + { + id: 'am-routes', + text: 'Notification policies', + icon: 'sitemap', + url: '/alerting/routes', + }, + { + id: 'silences', + text: 'Silences', + icon: 'bell-slash', + url: '/alerting/silences', + }, + { + id: 'groups', + text: 'Alert groups', + icon: 'layer-group', + url: '/alerting/groups', + }, + { + id: 'alerting-admin', + text: 'Admin', + icon: 'cog', + url: '/alerting/admin', + }, + { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, + }, + { + id: 'alert', + text: 'New alert rule', + subTitle: 'Create an alert rule', + icon: 'plus', + url: '/alerting/new', + hideFromTabs: true, + showIconInNavbar: true, + }, + ], + }, + }, + 'data-connections': { + id: 'data-connections', + text: 'Data Connections', + section: 'core', + icon: 'link', + url: '/data-connections', + sortWeight: -1500, + children: [ + { + id: 'data-connections-datasources', + text: 'Data sources', + description: 'Add and configure data sources', + icon: 'database', + url: '/data-connections/datasources', + active: false, + }, + { + id: 'data-connections-plugins', + text: 'Plugins', + description: 'Manage plugins', + icon: 'plug', + url: '/data-connections/plugins', + active: false, + }, + { + id: 'data-connections-cloud-integrations', + text: 'Cloud integrations', + description: 'Manage your cloud integrations', + icon: 'bolt', + url: '/data-connections/cloud-integrations', + active: true, + }, + { + id: 'data-connections-recorded-queries', + text: 'Recorded queries', + description: 'Manage your recorded queries', + icon: 'record-audio', + url: '/data-connections/recorded-queries', + active: false, + }, + ], + }, + 'data-connections-datasources': { + id: 'data-connections-datasources', + text: 'Data sources', + description: 'Add and configure data sources', + icon: 'database', + url: '/data-connections/datasources', + parentItem: { + id: 'data-connections', + text: 'Data Connections', + section: 'core', + icon: 'link', + url: '/data-connections', + sortWeight: -1500, + children: [ + { + id: 'data-connections-datasources', + text: 'Data sources', + description: 'Add and configure data sources', + icon: 'database', + url: '/data-connections/datasources', + }, + { + id: 'data-connections-plugins', + text: 'Plugins', + description: 'Manage plugins', + icon: 'plug', + url: '/data-connections/plugins', + }, + { + id: 'data-connections-cloud-integrations', + text: 'Cloud integrations', + description: 'Manage your cloud integrations', + icon: 'bolt', + url: '/data-connections/cloud-integrations', + }, + { + id: 'data-connections-recorded-queries', + text: 'Recorded queries', + description: 'Manage your recorded queries', + icon: 'record-audio', + url: '/data-connections/recorded-queries', + }, + ], + }, + }, + 'data-connections-plugins': { + id: 'data-connections-plugins', + text: 'Plugins', + description: 'Manage plugins', + icon: 'plug', + url: '/data-connections/plugins', + parentItem: { + id: 'data-connections', + text: 'Data Connections', + section: 'core', + icon: 'link', + url: '/data-connections', + sortWeight: -1500, + children: [ + { + id: 'data-connections-datasources', + text: 'Data sources', + description: 'Add and configure data sources', + icon: 'database', + url: '/data-connections/datasources', + }, + { + id: 'data-connections-plugins', + text: 'Plugins', + description: 'Manage plugins', + icon: 'plug', + url: '/data-connections/plugins', + }, + { + id: 'data-connections-cloud-integrations', + text: 'Cloud integrations', + description: 'Manage your cloud integrations', + icon: 'bolt', + url: '/data-connections/cloud-integrations', + }, + { + id: 'data-connections-recorded-queries', + text: 'Recorded queries', + description: 'Manage your recorded queries', + icon: 'record-audio', + url: '/data-connections/recorded-queries', + }, + ], + }, + }, + 'data-connections-cloud-integrations': { + id: 'data-connections-cloud-integrations', + text: 'Cloud integrations', + description: 'Manage your cloud integrations', + icon: 'bolt', + url: '/data-connections/cloud-integrations', + parentItem: { + id: 'data-connections', + text: 'Data Connections', + section: 'core', + icon: 'link', + url: '/data-connections', + sortWeight: -1500, + children: [ + { + id: 'data-connections-datasources', + text: 'Data sources', + description: 'Add and configure data sources', + icon: 'database', + url: '/data-connections/datasources', + }, + { + id: 'data-connections-plugins', + text: 'Plugins', + description: 'Manage plugins', + icon: 'plug', + url: '/data-connections/plugins', + }, + { + id: 'data-connections-cloud-integrations', + text: 'Cloud integrations', + description: 'Manage your cloud integrations', + icon: 'bolt', + url: '/data-connections/cloud-integrations', + }, + { + id: 'data-connections-recorded-queries', + text: 'Recorded queries', + description: 'Manage your recorded queries', + icon: 'record-audio', + url: '/data-connections/recorded-queries', + }, + ], + }, + }, + 'data-connections-recorded-queries': { + id: 'data-connections-recorded-queries', + text: 'Recorded queries', + description: 'Manage your recorded queries', + icon: 'record-audio', + url: '/data-connections/recorded-queries', + parentItem: { + id: 'data-connections', + text: 'Data Connections', + section: 'core', + icon: 'link', + url: '/data-connections', + sortWeight: -1500, + children: [ + { + id: 'data-connections-datasources', + text: 'Data sources', + description: 'Add and configure data sources', + icon: 'database', + url: '/data-connections/datasources', + }, + { + id: 'data-connections-plugins', + text: 'Plugins', + description: 'Manage plugins', + icon: 'plug', + url: '/data-connections/plugins', + }, + { + id: 'data-connections-cloud-integrations', + text: 'Cloud integrations', + description: 'Manage your cloud integrations', + icon: 'bolt', + url: '/data-connections/cloud-integrations', + }, + { + id: 'data-connections-recorded-queries', + text: 'Recorded queries', + description: 'Manage your recorded queries', + icon: 'record-audio', + url: '/data-connections/recorded-queries', + }, + ], + }, + }, + 'plugin-page-basic-app': { + id: 'plugin-page-basic-app', + text: 'Basic App', + section: 'plugin', + img: 'public/plugins/basic-app/img/logo.svg', + url: '/a/basic-app/one', + sortWeight: -1400, + children: [ + { + text: 'Page One', + url: '/a/basic-app/one', + }, + { + text: 'Page Two', + url: '/a/basic-app/two', + }, + { + text: 'Page Three', + url: '/a/basic-app/three', + }, + { + text: 'Page Four', + url: '/a/basic-app/four', + }, + { + text: 'Configuration', + icon: 'fa fa-cog', + url: '/plugins/basic-app', + }, + ], + }, + undefined: { + text: 'Config', + url: '/plugins/grafana-synthetic-monitoring-app', + parentItem: { + id: 'plugin-page-grafana-synthetic-monitoring-app', + text: 'Synthetic Monitoring', + section: 'plugin', + img: 'public/plugins/grafana-synthetic-monitoring-app/img/logo.svg', + url: '/a/grafana-synthetic-monitoring-app/home', + sortWeight: -1400, + children: [ + { + text: 'Home', + url: '/a/grafana-synthetic-monitoring-app/home', + }, + { + text: 'Summary', + url: '/a/grafana-synthetic-monitoring-app/redirect?dashboard=summary', + }, + { + text: 'Checks', + url: '/a/grafana-synthetic-monitoring-app/checks', + }, + { + text: 'Probes', + url: '/a/grafana-synthetic-monitoring-app/probes', + }, + { + text: 'Alerts', + url: '/a/grafana-synthetic-monitoring-app/alerts', + }, + { + text: 'Config', + url: '/plugins/grafana-synthetic-monitoring-app', + }, + ], + }, + }, + 'plugin-page-cloudflare-app': { + id: 'plugin-page-cloudflare-app', + text: 'Cloudflare Grafana App', + section: 'plugin', + img: 'public/plugins/cloudflare-app/img/cf_icon.png', + sortWeight: -1400, + children: [ + { + text: 'Zones', + url: '/d/KAVdMAw9k', + }, + { + text: 'DNS Firewall', + url: '/d/QrKttDVqu', + }, + ], + }, + 'plugin-page-grafana-easystart-app': { + id: 'plugin-page-grafana-easystart-app', + text: 'Integrations and Connections', + section: 'plugin', + img: 'public/plugins/grafana-easystart-app/img/logo.svg', + url: '/a/grafana-easystart-app', + sortWeight: -1400, + }, + 'plugin-page-redis-explorer-app': { + id: 'plugin-page-redis-explorer-app', + text: 'Redis Explorer', + section: 'plugin', + img: 'public/plugins/redis-explorer-app/img/logo.svg', + url: '/a/redis-explorer-app/', + sortWeight: -1400, + children: [ + { + text: 'Home', + icon: 'home-alt', + url: '/a/redis-explorer-app/', + }, + { + text: 'Enterprise Clusters', + icon: 'apps', + url: '/d/1dKhTjtGk', + }, + { + text: 'Cluster Overview', + icon: 'monitor', + url: '/d/viroIzSGz', + }, + { + text: 'Cluster Nodes', + icon: 'sitemap', + url: '/d/hqze6rtGz', + }, + { + text: 'Cluster Databases', + icon: 'database', + url: '/d/k_A8MjtMk', + }, + { + text: 'Cluster Alerts', + icon: 'info-circle', + url: '/d/xESAiFcnk', + }, + ], + }, + 'plugin-page-grafana-synthetic-monitoring-app': { + id: 'plugin-page-grafana-synthetic-monitoring-app', + text: 'Synthetic Monitoring', + section: 'plugin', + img: 'public/plugins/grafana-synthetic-monitoring-app/img/logo.svg', + url: '/a/grafana-synthetic-monitoring-app/home', + sortWeight: -1400, + children: [ + { + text: 'Home', + url: '/a/grafana-synthetic-monitoring-app/home', + }, + { + text: 'Summary', + url: '/a/grafana-synthetic-monitoring-app/redirect?dashboard=summary', + }, + { + text: 'Checks', + url: '/a/grafana-synthetic-monitoring-app/checks', + }, + { + text: 'Probes', + url: '/a/grafana-synthetic-monitoring-app/probes', + }, + { + text: 'Alerts', + url: '/a/grafana-synthetic-monitoring-app/alerts', + }, + { + text: 'Config', + url: '/plugins/grafana-synthetic-monitoring-app', + }, + ], + }, + 'plugin-page-grafana-k6-app': { + id: 'plugin-page-grafana-k6-app', + text: 'k6 Cloud App', + section: 'plugin', + img: 'public/plugins/grafana-k6-app/img/logo.svg', + url: '/a/grafana-k6-app', + sortWeight: -1400, + }, + cfg: { + id: 'cfg', + text: 'Configuration', + section: 'config', + subTitle: 'Organization: Main Org.', + icon: 'cog', + url: '/datasources', + sortWeight: -1300, + children: [ + { + id: 'datasources', + text: 'Data sources', + description: 'Add and configure data sources', + icon: 'database', + url: '/datasources', + }, + { + id: 'users', + text: 'Users', + description: 'Manage org members', + icon: 'user', + url: '/org/users', + }, + { + id: 'teams', + text: 'Teams', + description: 'Manage org groups', + icon: 'users-alt', + url: '/org/teams', + }, + { + id: 'plugins', + text: 'Plugins', + description: 'View and configure plugins', + icon: 'plug', + url: '/plugins', + }, + { + id: 'org-settings', + text: 'Preferences', + description: 'Organization preferences', + icon: 'sliders-v-alt', + url: '/org', + }, + { + id: 'apikeys', + text: 'API keys', + description: 'Create & manage API keys', + icon: 'key-skeleton-alt', + url: '/org/apikeys', + }, + ], + }, + datasources: { + id: 'datasources', + text: 'Data sources', + description: 'Add and configure data sources', + icon: 'database', + url: '/datasources', + parentItem: { + id: 'cfg', + text: 'Configuration', + section: 'config', + subTitle: 'Organization: Main Org.', + icon: 'cog', + url: '/datasources', + sortWeight: -1300, + children: [ + { + id: 'datasources', + text: 'Data sources', + description: 'Add and configure data sources', + icon: 'database', + url: '/datasources', + }, + { + id: 'users', + text: 'Users', + description: 'Manage org members', + icon: 'user', + url: '/org/users', + }, + { + id: 'teams', + text: 'Teams', + description: 'Manage org groups', + icon: 'users-alt', + url: '/org/teams', + }, + { + id: 'plugins', + text: 'Plugins', + description: 'View and configure plugins', + icon: 'plug', + url: '/plugins', + }, + { + id: 'org-settings', + text: 'Preferences', + description: 'Organization preferences', + icon: 'sliders-v-alt', + url: '/org', + }, + { + id: 'apikeys', + text: 'API keys', + description: 'Create & manage API keys', + icon: 'key-skeleton-alt', + url: '/org/apikeys', + }, + ], + }, + }, + users: { + id: 'users', + text: 'Users', + description: 'Manage org members', + icon: 'user', + url: '/org/users', + parentItem: { + id: 'cfg', + text: 'Configuration', + section: 'config', + subTitle: 'Organization: Main Org.', + icon: 'cog', + url: '/datasources', + sortWeight: -1300, + children: [ + { + id: 'datasources', + text: 'Data sources', + description: 'Add and configure data sources', + icon: 'database', + url: '/datasources', + }, + { + id: 'users', + text: 'Users', + description: 'Manage org members', + icon: 'user', + url: '/org/users', + }, + { + id: 'teams', + text: 'Teams', + description: 'Manage org groups', + icon: 'users-alt', + url: '/org/teams', + }, + { + id: 'plugins', + text: 'Plugins', + description: 'View and configure plugins', + icon: 'plug', + url: '/plugins', + }, + { + id: 'org-settings', + text: 'Preferences', + description: 'Organization preferences', + icon: 'sliders-v-alt', + url: '/org', + }, + { + id: 'apikeys', + text: 'API keys', + description: 'Create & manage API keys', + icon: 'key-skeleton-alt', + url: '/org/apikeys', + }, + ], + }, + }, + teams: { + id: 'teams', + text: 'Teams', + description: 'Manage org groups', + icon: 'users-alt', + url: '/org/teams', + parentItem: { + id: 'cfg', + text: 'Configuration', + section: 'config', + subTitle: 'Organization: Main Org.', + icon: 'cog', + url: '/datasources', + sortWeight: -1300, + children: [ + { + id: 'datasources', + text: 'Data sources', + description: 'Add and configure data sources', + icon: 'database', + url: '/datasources', + }, + { + id: 'users', + text: 'Users', + description: 'Manage org members', + icon: 'user', + url: '/org/users', + }, + { + id: 'teams', + text: 'Teams', + description: 'Manage org groups', + icon: 'users-alt', + url: '/org/teams', + }, + { + id: 'plugins', + text: 'Plugins', + description: 'View and configure plugins', + icon: 'plug', + url: '/plugins', + }, + { + id: 'org-settings', + text: 'Preferences', + description: 'Organization preferences', + icon: 'sliders-v-alt', + url: '/org', + }, + { + id: 'apikeys', + text: 'API keys', + description: 'Create & manage API keys', + icon: 'key-skeleton-alt', + url: '/org/apikeys', + }, + ], + }, + }, + plugins: { + id: 'plugins', + text: 'Plugins', + description: 'View and configure plugins', + icon: 'plug', + url: '/plugins', + parentItem: { + id: 'cfg', + text: 'Configuration', + section: 'config', + subTitle: 'Organization: Main Org.', + icon: 'cog', + url: '/datasources', + sortWeight: -1300, + children: [ + { + id: 'datasources', + text: 'Data sources', + description: 'Add and configure data sources', + icon: 'database', + url: '/datasources', + }, + { + id: 'users', + text: 'Users', + description: 'Manage org members', + icon: 'user', + url: '/org/users', + }, + { + id: 'teams', + text: 'Teams', + description: 'Manage org groups', + icon: 'users-alt', + url: '/org/teams', + }, + { + id: 'plugins', + text: 'Plugins', + description: 'View and configure plugins', + icon: 'plug', + url: '/plugins', + }, + { + id: 'org-settings', + text: 'Preferences', + description: 'Organization preferences', + icon: 'sliders-v-alt', + url: '/org', + }, + { + id: 'apikeys', + text: 'API keys', + description: 'Create & manage API keys', + icon: 'key-skeleton-alt', + url: '/org/apikeys', + }, + ], + }, + }, + 'org-settings': { + id: 'org-settings', + text: 'Preferences', + description: 'Organization preferences', + icon: 'sliders-v-alt', + url: '/org', + parentItem: { + id: 'cfg', + text: 'Configuration', + section: 'config', + subTitle: 'Organization: Main Org.', + icon: 'cog', + url: '/datasources', + sortWeight: -1300, + children: [ + { + id: 'datasources', + text: 'Data sources', + description: 'Add and configure data sources', + icon: 'database', + url: '/datasources', + }, + { + id: 'users', + text: 'Users', + description: 'Manage org members', + icon: 'user', + url: '/org/users', + }, + { + id: 'teams', + text: 'Teams', + description: 'Manage org groups', + icon: 'users-alt', + url: '/org/teams', + }, + { + id: 'plugins', + text: 'Plugins', + description: 'View and configure plugins', + icon: 'plug', + url: '/plugins', + }, + { + id: 'org-settings', + text: 'Preferences', + description: 'Organization preferences', + icon: 'sliders-v-alt', + url: '/org', + }, + { + id: 'apikeys', + text: 'API keys', + description: 'Create & manage API keys', + icon: 'key-skeleton-alt', + url: '/org/apikeys', + }, + ], + }, + }, + apikeys: { + id: 'apikeys', + text: 'API keys', + description: 'Create & manage API keys', + icon: 'key-skeleton-alt', + url: '/org/apikeys', + parentItem: { + id: 'cfg', + text: 'Configuration', + section: 'config', + subTitle: 'Organization: Main Org.', + icon: 'cog', + url: '/datasources', + sortWeight: -1300, + children: [ + { + id: 'datasources', + text: 'Data sources', + description: 'Add and configure data sources', + icon: 'database', + url: '/datasources', + }, + { + id: 'users', + text: 'Users', + description: 'Manage org members', + icon: 'user', + url: '/org/users', + }, + { + id: 'teams', + text: 'Teams', + description: 'Manage org groups', + icon: 'users-alt', + url: '/org/teams', + }, + { + id: 'plugins', + text: 'Plugins', + description: 'View and configure plugins', + icon: 'plug', + url: '/plugins', + }, + { + id: 'org-settings', + text: 'Preferences', + description: 'Organization preferences', + icon: 'sliders-v-alt', + url: '/org', + }, + { + id: 'apikeys', + text: 'API keys', + description: 'Create & manage API keys', + icon: 'key-skeleton-alt', + url: '/org/apikeys', + }, + ], + }, + }, + admin: { + id: 'admin', + text: 'Server Admin', + section: 'config', + subTitle: 'Manage all users and orgs', + icon: 'shield', + url: '/admin/users', + sortWeight: -1200, + hideFromTabs: true, + children: [ + { + id: 'global-users', + text: 'Users', + icon: 'user', + url: '/admin/users', + }, + { + id: 'global-orgs', + text: 'Orgs', + icon: 'building', + url: '/admin/orgs', + }, + { + id: 'server-settings', + text: 'Settings', + icon: 'sliders-v-alt', + url: '/admin/settings', + }, + { + id: 'admin-plugins', + text: 'Plugins', + icon: 'plug', + url: '/admin/plugins', + }, + { + id: 'upgrading', + text: 'Stats and license', + icon: 'unlock', + url: '/admin/upgrading', + }, + ], + }, + 'global-users': { + id: 'global-users', + text: 'Users', + icon: 'user', + url: '/admin/users', + parentItem: { + id: 'admin', + text: 'Server Admin', + section: 'config', + subTitle: 'Manage all users and orgs', + icon: 'shield', + url: '/admin/users', + sortWeight: -1200, + hideFromTabs: true, + children: [ + { + id: 'global-users', + text: 'Users', + icon: 'user', + url: '/admin/users', + }, + { + id: 'global-orgs', + text: 'Orgs', + icon: 'building', + url: '/admin/orgs', + }, + { + id: 'server-settings', + text: 'Settings', + icon: 'sliders-v-alt', + url: '/admin/settings', + }, + { + id: 'admin-plugins', + text: 'Plugins', + icon: 'plug', + url: '/admin/plugins', + }, + { + id: 'upgrading', + text: 'Stats and license', + icon: 'unlock', + url: '/admin/upgrading', + }, + ], + }, + }, + 'global-orgs': { + id: 'global-orgs', + text: 'Orgs', + icon: 'building', + url: '/admin/orgs', + parentItem: { + id: 'admin', + text: 'Server Admin', + section: 'config', + subTitle: 'Manage all users and orgs', + icon: 'shield', + url: '/admin/users', + sortWeight: -1200, + hideFromTabs: true, + children: [ + { + id: 'global-users', + text: 'Users', + icon: 'user', + url: '/admin/users', + }, + { + id: 'global-orgs', + text: 'Orgs', + icon: 'building', + url: '/admin/orgs', + }, + { + id: 'server-settings', + text: 'Settings', + icon: 'sliders-v-alt', + url: '/admin/settings', + }, + { + id: 'admin-plugins', + text: 'Plugins', + icon: 'plug', + url: '/admin/plugins', + }, + { + id: 'upgrading', + text: 'Stats and license', + icon: 'unlock', + url: '/admin/upgrading', + }, + ], + }, + }, + 'server-settings': { + id: 'server-settings', + text: 'Settings', + icon: 'sliders-v-alt', + url: '/admin/settings', + parentItem: { + id: 'admin', + text: 'Server Admin', + section: 'config', + subTitle: 'Manage all users and orgs', + icon: 'shield', + url: '/admin/users', + sortWeight: -1200, + hideFromTabs: true, + children: [ + { + id: 'global-users', + text: 'Users', + icon: 'user', + url: '/admin/users', + }, + { + id: 'global-orgs', + text: 'Orgs', + icon: 'building', + url: '/admin/orgs', + }, + { + id: 'server-settings', + text: 'Settings', + icon: 'sliders-v-alt', + url: '/admin/settings', + }, + { + id: 'admin-plugins', + text: 'Plugins', + icon: 'plug', + url: '/admin/plugins', + }, + { + id: 'upgrading', + text: 'Stats and license', + icon: 'unlock', + url: '/admin/upgrading', + }, + ], + }, + }, + 'admin-plugins': { + id: 'admin-plugins', + text: 'Plugins', + icon: 'plug', + url: '/admin/plugins', + parentItem: { + id: 'admin', + text: 'Server Admin', + section: 'config', + subTitle: 'Manage all users and orgs', + icon: 'shield', + url: '/admin/users', + sortWeight: -1200, + hideFromTabs: true, + children: [ + { + id: 'global-users', + text: 'Users', + icon: 'user', + url: '/admin/users', + }, + { + id: 'global-orgs', + text: 'Orgs', + icon: 'building', + url: '/admin/orgs', + }, + { + id: 'server-settings', + text: 'Settings', + icon: 'sliders-v-alt', + url: '/admin/settings', + }, + { + id: 'admin-plugins', + text: 'Plugins', + icon: 'plug', + url: '/admin/plugins', + }, + { + id: 'upgrading', + text: 'Stats and license', + icon: 'unlock', + url: '/admin/upgrading', + }, + ], + }, + }, + upgrading: { + id: 'upgrading', + text: 'Stats and license', + icon: 'unlock', + url: '/admin/upgrading', + parentItem: { + id: 'admin', + text: 'Server Admin', + section: 'config', + subTitle: 'Manage all users and orgs', + icon: 'shield', + url: '/admin/users', + sortWeight: -1200, + hideFromTabs: true, + children: [ + { + id: 'global-users', + text: 'Users', + icon: 'user', + url: '/admin/users', + }, + { + id: 'global-orgs', + text: 'Orgs', + icon: 'building', + url: '/admin/orgs', + }, + { + id: 'server-settings', + text: 'Settings', + icon: 'sliders-v-alt', + url: '/admin/settings', + }, + { + id: 'admin-plugins', + text: 'Plugins', + icon: 'plug', + url: '/admin/plugins', + }, + { + id: 'upgrading', + text: 'Stats and license', + icon: 'unlock', + url: '/admin/upgrading', + }, + ], + }, + }, + profile: { + id: 'profile', + text: 'admin', + section: 'config', + img: '/avatar/46d229b033af06a191ff2267bca9ae56', + url: '/profile', + sortWeight: -1100, + children: [ + { + id: 'profile-settings', + text: 'Preferences', + icon: 'sliders-v-alt', + url: '/profile', + }, + { + id: 'notifications', + text: 'Notification history', + icon: 'bell', + url: '/notifications', + }, + { + id: 'change-password', + text: 'Change password', + icon: 'lock', + url: '/profile/password', + }, + { + id: 'sign-out', + text: 'Sign out', + icon: 'arrow-from-right', + url: '/logout', + target: '_self', + hideFromTabs: true, + }, + ], + }, + 'profile-settings': { + id: 'profile-settings', + text: 'Preferences', + icon: 'sliders-v-alt', + url: '/profile', + parentItem: { + id: 'profile', + text: 'admin', + section: 'config', + img: '/avatar/46d229b033af06a191ff2267bca9ae56', + url: '/profile', + sortWeight: -1100, + children: [ + { + id: 'profile-settings', + text: 'Preferences', + icon: 'sliders-v-alt', + url: '/profile', + }, + { + id: 'notifications', + text: 'Notification history', + icon: 'bell', + url: '/notifications', + }, + { + id: 'change-password', + text: 'Change password', + icon: 'lock', + url: '/profile/password', + }, + { + id: 'sign-out', + text: 'Sign out', + icon: 'arrow-from-right', + url: '/logout', + target: '_self', + hideFromTabs: true, + }, + ], + }, + }, + notifications: { + id: 'notifications', + text: 'Notification history', + icon: 'bell', + url: '/notifications', + parentItem: { + id: 'profile', + text: 'admin', + section: 'config', + img: '/avatar/46d229b033af06a191ff2267bca9ae56', + url: '/profile', + sortWeight: -1100, + children: [ + { + id: 'profile-settings', + text: 'Preferences', + icon: 'sliders-v-alt', + url: '/profile', + }, + { + id: 'notifications', + text: 'Notification history', + icon: 'bell', + url: '/notifications', + }, + { + id: 'change-password', + text: 'Change password', + icon: 'lock', + url: '/profile/password', + }, + { + id: 'sign-out', + text: 'Sign out', + icon: 'arrow-from-right', + url: '/logout', + target: '_self', + hideFromTabs: true, + }, + ], + }, + }, + 'change-password': { + id: 'change-password', + text: 'Change password', + icon: 'lock', + url: '/profile/password', + parentItem: { + id: 'profile', + text: 'admin', + section: 'config', + img: '/avatar/46d229b033af06a191ff2267bca9ae56', + url: '/profile', + sortWeight: -1100, + children: [ + { + id: 'profile-settings', + text: 'Preferences', + icon: 'sliders-v-alt', + url: '/profile', + }, + { + id: 'notifications', + text: 'Notification history', + icon: 'bell', + url: '/notifications', + }, + { + id: 'change-password', + text: 'Change password', + icon: 'lock', + url: '/profile/password', + }, + { + id: 'sign-out', + text: 'Sign out', + icon: 'arrow-from-right', + url: '/logout', + target: '_self', + hideFromTabs: true, + }, + ], + }, + }, + 'sign-out': { + id: 'sign-out', + text: 'Sign out', + icon: 'arrow-from-right', + url: '/logout', + target: '_self', + hideFromTabs: true, + parentItem: { + id: 'profile', + text: 'admin', + section: 'config', + img: '/avatar/46d229b033af06a191ff2267bca9ae56', + url: '/profile', + sortWeight: -1100, + children: [ + { + id: 'profile-settings', + text: 'Preferences', + icon: 'sliders-v-alt', + url: '/profile', + }, + { + id: 'notifications', + text: 'Notification history', + icon: 'bell', + url: '/notifications', + }, + { + id: 'change-password', + text: 'Change password', + icon: 'lock', + url: '/profile/password', + }, + { + id: 'sign-out', + text: 'Sign out', + icon: 'arrow-from-right', + url: '/logout', + target: '_self', + hideFromTabs: true, + }, + ], + }, + }, + help: { + id: 'help', + text: 'Help', + section: 'config', + subTitle: 'Grafana v9.0.0-pre (abb5c6109a)', + icon: 'question-circle', + url: '#', + sortWeight: -1000, + }, +}; diff --git a/public/app/features/data-connections/constants.ts b/public/app/features/data-connections/constants.ts new file mode 100644 index 00000000000..c35a30aba1b --- /dev/null +++ b/public/app/features/data-connections/constants.ts @@ -0,0 +1,12 @@ +// The ID of the app plugin that we render under that "Cloud Integrations" tab +export const CLOUD_ONBOARDING_APP_ID = 'grafana-easystart-app'; + +// The ID of the main nav-tree item (the main item in the NavIndex) +export const ROUTE_BASE_ID = 'data-connections'; + +export enum ROUTES { + DataSources = '/data-connections/data-sources', + Plugins = '/data-connections/plugins', + CloudIntegrations = '/data-connections/cloud-integrations', + RecordedQueries = '/data-connections/recorded-queries', +} diff --git a/public/app/features/data-connections/hooks/useNavModel.ts b/public/app/features/data-connections/hooks/useNavModel.ts new file mode 100644 index 00000000000..b030c3e0b66 --- /dev/null +++ b/public/app/features/data-connections/hooks/useNavModel.ts @@ -0,0 +1,26 @@ +import { useSelector } from 'react-redux'; +import { useLocation } from 'react-router-dom'; + +import { StoreState } from 'app/types/store'; + +import { ROUTE_BASE_ID } from '../constants'; + +// We need this utility logic to make sure that the tab with the current URL is marked as active. +// (In case we were using `getNavModel()` from app/core/selectors/navModel, then we would need to set +// the child nav-model-item's ID on the call-site.) +export const useNavModel = () => { + const { pathname } = useLocation(); + const navIndex = useSelector((state: StoreState) => state.navIndex); + const node = navIndex[ROUTE_BASE_ID]; + const main = node; + + main.children = main.children?.map((item) => ({ + ...item, + active: item.url === pathname, + })); + + return { + node, + main, + }; +}; diff --git a/public/app/features/data-connections/routes.tsx b/public/app/features/data-connections/routes.tsx new file mode 100644 index 00000000000..5366ec53caa --- /dev/null +++ b/public/app/features/data-connections/routes.tsx @@ -0,0 +1,21 @@ +import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; +import { config } from 'app/core/config'; +import { RouteDescriptor } from 'app/core/navigation/types'; + +import { ROUTE_BASE_ID } from './constants'; + +export function getRoutes(): RouteDescriptor[] { + if (config.featureToggles.dataConnectionsConsole) { + return [ + { + path: `/${ROUTE_BASE_ID}`, + exact: false, + component: SafeDynamicImport( + () => import(/* webpackChunkName: "DataConnectionsPage"*/ 'app/features/data-connections/DataConnectionsPage') + ), + }, + ]; + } + + return []; +} diff --git a/public/app/features/data-connections/tabs/CloudIntegrations/CloudIntegrations.tsx b/public/app/features/data-connections/tabs/CloudIntegrations/CloudIntegrations.tsx new file mode 100644 index 00000000000..c912fb8d9b7 --- /dev/null +++ b/public/app/features/data-connections/tabs/CloudIntegrations/CloudIntegrations.tsx @@ -0,0 +1,25 @@ +import { css } from '@emotion/css'; +import React, { ReactElement } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { AppPluginLoader } from 'app/features/plugins/components/AppPluginLoader'; + +import { CLOUD_ONBOARDING_APP_ID } from '../../constants'; + +export function CloudIntegrations(): ReactElement | null { + const s = useStyles2(getStyles); + + return ( +
+ +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + // We would like to force the app to stay inside the provided tab + container: css` + position: relative; + `, +}); diff --git a/public/app/features/data-connections/tabs/CloudIntegrations/index.tsx b/public/app/features/data-connections/tabs/CloudIntegrations/index.tsx new file mode 100644 index 00000000000..41c904956aa --- /dev/null +++ b/public/app/features/data-connections/tabs/CloudIntegrations/index.tsx @@ -0,0 +1 @@ +export * from './CloudIntegrations'; diff --git a/public/app/features/data-connections/tabs/DataSources/DataSources.tsx b/public/app/features/data-connections/tabs/DataSources/DataSources.tsx new file mode 100644 index 00000000000..a809c21511e --- /dev/null +++ b/public/app/features/data-connections/tabs/DataSources/DataSources.tsx @@ -0,0 +1,5 @@ +import React, { ReactElement } from 'react'; + +export function DataSources(): ReactElement | null { + return
The list of data sources is under development.
; +} diff --git a/public/app/features/data-connections/tabs/DataSources/index.tsx b/public/app/features/data-connections/tabs/DataSources/index.tsx new file mode 100644 index 00000000000..5d681837186 --- /dev/null +++ b/public/app/features/data-connections/tabs/DataSources/index.tsx @@ -0,0 +1 @@ +export * from './DataSources'; diff --git a/public/app/features/data-connections/tabs/Plugins/Plugins.tsx b/public/app/features/data-connections/tabs/Plugins/Plugins.tsx new file mode 100644 index 00000000000..e1befaab13f --- /dev/null +++ b/public/app/features/data-connections/tabs/Plugins/Plugins.tsx @@ -0,0 +1,5 @@ +import React, { ReactElement } from 'react'; + +export function Plugins(): ReactElement | null { + return
The list of plugins is under development
; +} diff --git a/public/app/features/data-connections/tabs/Plugins/index.tsx b/public/app/features/data-connections/tabs/Plugins/index.tsx new file mode 100644 index 00000000000..20c2507f6d8 --- /dev/null +++ b/public/app/features/data-connections/tabs/Plugins/index.tsx @@ -0,0 +1 @@ +export * from './Plugins'; diff --git a/public/app/features/data-connections/tabs/RecordedQueries/RecordedQueries.tsx b/public/app/features/data-connections/tabs/RecordedQueries/RecordedQueries.tsx new file mode 100644 index 00000000000..90ec2c24f76 --- /dev/null +++ b/public/app/features/data-connections/tabs/RecordedQueries/RecordedQueries.tsx @@ -0,0 +1,5 @@ +import React, { ReactElement } from 'react'; + +export function RecordedQueries(): ReactElement | null { + return
The recorded queries tab is under development.
; +} diff --git a/public/app/features/data-connections/tabs/RecordedQueries/index.tsx b/public/app/features/data-connections/tabs/RecordedQueries/index.tsx new file mode 100644 index 00000000000..fc4ff37714f --- /dev/null +++ b/public/app/features/data-connections/tabs/RecordedQueries/index.tsx @@ -0,0 +1 @@ +export * from './RecordedQueries'; diff --git a/public/app/features/plugins/components/AppPluginLoader.test.tsx b/public/app/features/plugins/components/AppPluginLoader.test.tsx new file mode 100644 index 00000000000..4c1430c7372 --- /dev/null +++ b/public/app/features/plugins/components/AppPluginLoader.test.tsx @@ -0,0 +1,132 @@ +import { render, screen } from '@testing-library/react'; +import React, { Component } from 'react'; +import { Router } from 'react-router-dom'; + +import { AppPlugin, PluginType, AppRootProps, NavModelItem } from '@grafana/data'; +import { locationService, setEchoSrv } from '@grafana/runtime'; +import { Echo } from 'app/core/services/echo/Echo'; + +import { getMockPlugin } from '../__mocks__/pluginMocks'; +import { useImportAppPlugin } from '../hooks/useImportAppPlugin'; + +import { AppPluginLoader } from './AppPluginLoader'; + +jest.mock('../hooks/useImportAppPlugin', () => ({ + useImportAppPlugin: jest.fn(), +})); + +const useImportAppPluginMock = useImportAppPlugin as jest.Mock< + ReturnType, + Parameters +>; + +const TEXTS = { + PLUGIN_TITLE: 'Amazing App', + PLUGIN_CONTENT: 'This is my amazing app plugin!', + PLUGIN_TAB_TITLE_A: 'Tab (A)', + PLUGIN_TAB_TITLE_B: 'Tab (B)', +}; + +describe('AppPluginLoader', () => { + beforeEach(() => { + jest.resetAllMocks(); + AppPluginComponent.timesMounted = 0; + setEchoSrv(new Echo()); + }); + + test('renders the app plugin correctly', async () => { + useImportAppPluginMock.mockReturnValue({ value: getAppPluginMock(), loading: false, error: undefined }); + + renderAppPlugin(); + + expect(await screen.findByText(TEXTS.PLUGIN_TITLE)).toBeVisible(); + expect(await screen.findByText(TEXTS.PLUGIN_CONTENT)).toBeVisible(); + expect(await screen.findByLabelText(`Tab ${TEXTS.PLUGIN_TAB_TITLE_A}`)).toBeVisible(); + expect(await screen.findByLabelText(`Tab ${TEXTS.PLUGIN_TAB_TITLE_B}`)).toBeVisible(); + expect(screen.queryByText('Loading ...')).not.toBeInTheDocument(); + }); + + test('renders the app plugin only once', async () => { + useImportAppPluginMock.mockReturnValue({ value: getAppPluginMock(), loading: false, error: undefined }); + + renderAppPlugin(); + + expect(await screen.findByText(TEXTS.PLUGIN_TITLE)).toBeVisible(); + expect(AppPluginComponent.timesMounted).toEqual(1); + }); + + test('renders a loader while the plugin is loading', async () => { + useImportAppPluginMock.mockReturnValue({ value: undefined, loading: true, error: undefined }); + + renderAppPlugin(); + + expect(await screen.findByText('Loading ...')).toBeVisible(); + expect(screen.queryByText(TEXTS.PLUGIN_TITLE)).not.toBeInTheDocument(); + }); + + test('renders an error message if there are any errors while importing the plugin', async () => { + const errorMsg = 'Unable to find plugin'; + useImportAppPluginMock.mockReturnValue({ value: undefined, loading: false, error: new Error(errorMsg) }); + + renderAppPlugin(); + + expect(await screen.findByText(errorMsg)).toBeVisible(); + expect(screen.queryByText(TEXTS.PLUGIN_TITLE)).not.toBeInTheDocument(); + }); +}); + +function renderAppPlugin() { + render( + + ; + + ); +} +class AppPluginComponent extends Component { + static timesMounted = 0; + + componentDidMount() { + AppPluginComponent.timesMounted += 1; + + const node: NavModelItem = { + text: TEXTS.PLUGIN_TITLE, + children: [ + { + text: TEXTS.PLUGIN_TAB_TITLE_A, + url: '/tab-a', + id: 'a', + }, + { + text: TEXTS.PLUGIN_TAB_TITLE_B, + url: '/tab-b', + id: 'b', + }, + ], + }; + + this.props.onNavChanged({ + main: node, + node, + }); + } + + render() { + return

{TEXTS.PLUGIN_CONTENT}

; + } +} + +function getAppPluginMeta() { + return getMockPlugin({ + type: PluginType.app, + enabled: true, + }); +} + +function getAppPluginMock() { + const plugin = new AppPlugin(); + + plugin.root = AppPluginComponent; + plugin.init(getAppPluginMeta()); + + return plugin; +} diff --git a/public/app/features/plugins/components/AppPluginLoader.tsx b/public/app/features/plugins/components/AppPluginLoader.tsx new file mode 100644 index 00000000000..fe263276930 --- /dev/null +++ b/public/app/features/plugins/components/AppPluginLoader.tsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; + +import { NavModel } from '@grafana/data'; +import { getWarningNav } from 'app/angular/services/nav_model_srv'; +import Page from 'app/core/components/Page/Page'; +import PageLoader from 'app/core/components/PageLoader/PageLoader'; + +import { useImportAppPlugin } from '../hooks/useImportAppPlugin'; + +type AppPluginLoaderProps = { + // The id of the app plugin to be loaded + id: string; + // The base URL path - defaults to the current path + basePath?: string; +}; + +// This component can be used to render an app-plugin based on its plugin ID. +export const AppPluginLoader = ({ id, basePath }: AppPluginLoaderProps) => { + const [nav, setNav] = useState(null); + const { value: plugin, error, loading } = useImportAppPlugin(id); + const queryParams = useParams(); + const { pathname } = useLocation(); + + if (error) { + return ; + } + + return ( + <> + {loading && } + {nav && } + {!loading && plugin && plugin.root && ( + + )} + + ); +}; diff --git a/public/app/features/plugins/hooks/tests/useImportAppPlugin.test.tsx b/public/app/features/plugins/hooks/tests/useImportAppPlugin.test.tsx new file mode 100644 index 00000000000..5d90c9700ce --- /dev/null +++ b/public/app/features/plugins/hooks/tests/useImportAppPlugin.test.tsx @@ -0,0 +1,150 @@ +import { render, act, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { AppPlugin, PluginType } from '@grafana/data'; + +import { getMockPlugin } from '../../__mocks__/pluginMocks'; +import { getPluginSettings } from '../../pluginSettings'; +import { importAppPlugin } from '../../plugin_loader'; +import { useImportAppPlugin } from '../useImportAppPlugin'; + +jest.mock('../../pluginSettings', () => ({ + getPluginSettings: jest.fn(), +})); +jest.mock('../../plugin_loader', () => ({ + importAppPlugin: jest.fn(), +})); + +const importAppPluginMock = importAppPlugin as jest.Mock< + ReturnType, + Parameters +>; + +const getPluginSettingsMock = getPluginSettings as jest.Mock< + ReturnType, + Parameters +>; + +const PLUGIN_ID = 'sample-plugin'; + +describe('useImportAppPlugin()', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('returns the imported plugin in case it exists', async () => { + let response: any; + getPluginSettingsMock.mockResolvedValue(getAppPluginMeta()); + importAppPluginMock.mockResolvedValue(getAppPluginMock()); + + act(() => { + response = runHook(PLUGIN_ID); + }); + + await waitFor(() => expect(response.value).not.toBeUndefined()); + await waitFor(() => expect(response.error).toBeUndefined()); + await waitFor(() => expect(response.loading).toBe(false)); + }); + + test('returns an error if the plugin does not exist', async () => { + let response: any; + + act(() => { + response = runHook(PLUGIN_ID); + }); + + await waitFor(() => expect(response.value).toBeUndefined()); + await waitFor(() => expect(response.error).not.toBeUndefined()); + await waitFor(() => expect(response.error.message).toMatch(/unknown plugin/i)); + await waitFor(() => expect(response.loading).toBe(false)); + }); + + test('returns an error if the plugin is not an app', async () => { + let response: any; + getPluginSettingsMock.mockResolvedValue(getAppPluginMeta({ type: PluginType.panel })); + importAppPluginMock.mockResolvedValue(getAppPluginMock()); + + act(() => { + response = runHook(PLUGIN_ID); + }); + + await waitFor(() => expect(response.value).toBeUndefined()); + await waitFor(() => expect(response.error).not.toBeUndefined()); + await waitFor(() => expect(response.error.message).toMatch(/plugin must be an app/i)); + await waitFor(() => expect(response.loading).toBe(false)); + }); + + test('returns an error if the plugin is not enabled', async () => { + let response: any; + getPluginSettingsMock.mockResolvedValue(getAppPluginMeta({ enabled: false })); + importAppPluginMock.mockResolvedValue(getAppPluginMock()); + + act(() => { + response = runHook(PLUGIN_ID); + }); + + await waitFor(() => expect(response.value).toBeUndefined()); + await waitFor(() => expect(response.error).not.toBeUndefined()); + await waitFor(() => expect(response.error.message).toMatch(/is not enabled/i)); + await waitFor(() => expect(response.loading).toBe(false)); + }); + + test('returns errors that happen during fetching plugin settings', async () => { + let response: any; + const errorMsg = 'Error while fetching plugin data'; + getPluginSettingsMock.mockRejectedValue(new Error(errorMsg)); + importAppPluginMock.mockResolvedValue(getAppPluginMock()); + + act(() => { + response = runHook(PLUGIN_ID); + }); + + await waitFor(() => expect(response.value).toBeUndefined()); + await waitFor(() => expect(response.error).not.toBeUndefined()); + await waitFor(() => expect(response.error.message).toBe(errorMsg)); + await waitFor(() => expect(response.loading).toBe(false)); + }); + + test('returns errors that happen during importing a plugin', async () => { + let response: any; + const errorMsg = 'Error while importing the plugin'; + getPluginSettingsMock.mockResolvedValue(getAppPluginMeta()); + importAppPluginMock.mockRejectedValue(new Error(errorMsg)); + + act(() => { + response = runHook(PLUGIN_ID); + }); + + await waitFor(() => expect(response.value).toBeUndefined()); + await waitFor(() => expect(response.error).not.toBeUndefined()); + await waitFor(() => expect(response.error.message).toBe(errorMsg)); + await waitFor(() => expect(response.loading).toBe(false)); + }); +}); + +function runHook(id: string): any { + const returnVal = {}; + function TestComponent() { + Object.assign(returnVal, useImportAppPlugin(id)); + return null; + } + render(); + return returnVal; +} + +function getAppPluginMeta(overrides?: Record) { + return getMockPlugin({ + id: PLUGIN_ID, + type: PluginType.app, + enabled: true, + ...overrides, + }); +} + +function getAppPluginMock() { + const plugin = new AppPlugin(); + + plugin.init(getAppPluginMeta()); + + return plugin; +} diff --git a/public/app/features/plugins/hooks/useImportAppPlugin.ts b/public/app/features/plugins/hooks/useImportAppPlugin.ts new file mode 100644 index 00000000000..cd48d9ce22f --- /dev/null +++ b/public/app/features/plugins/hooks/useImportAppPlugin.ts @@ -0,0 +1,26 @@ +import useAsync from 'react-use/lib/useAsync'; + +import { PluginType } from '@grafana/data'; + +import { getPluginSettings } from '../pluginSettings'; +import { importAppPlugin } from '../plugin_loader'; + +export const useImportAppPlugin = (id: string) => { + return useAsync(async () => { + const pluginMeta = await getPluginSettings(id); + + if (!pluginMeta) { + throw new Error(`Unknown plugin: "${id}"`); + } + + if (pluginMeta.type !== PluginType.app) { + throw new Error(`Plugin must be an app (currently "${pluginMeta.type}")`); + } + + if (!pluginMeta.enabled) { + throw new Error(`Application "${id}" is not enabled`); + } + + return await importAppPlugin(pluginMeta); + }); +}; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 420d887f8af..e69f684807a 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -8,6 +8,7 @@ import { contextSrv } from 'app/core/services/context_srv'; import UserAdminPage from 'app/features/admin/UserAdminPage'; import LdapPage from 'app/features/admin/ldap/LdapPage'; import { getAlertingRoutes } from 'app/features/alerting/routes'; +import { getRoutes as getDataConnectionsRoutes } from 'app/features/data-connections/routes'; import { getLiveRoutes } from 'app/features/live/pages/routes'; import { getRoutes as getPluginCatalogRoutes } from 'app/features/plugins/admin/routes'; import { getProfileRoutes } from 'app/features/profile/routes'; @@ -431,6 +432,7 @@ export function getAppRoutes(): RouteDescriptor[] { ...getProfileRoutes(), ...extraRoutes, ...getPublicDashboardRoutes(), + ...getDataConnectionsRoutes(), { path: '/*', component: ErrorPage, From 06da129210fae46fb439ba3b2ec8c421a8de049e Mon Sep 17 00:00:00 2001 From: George Robinson Date: Fri, 10 Jun 2022 11:19:20 +0100 Subject: [PATCH 35/40] Docs: Add paragraph about rolling back to opt-out (#50583) --- docs/sources/alerting/migrating-alerts/opt-out.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/sources/alerting/migrating-alerts/opt-out.md b/docs/sources/alerting/migrating-alerts/opt-out.md index 3c2ef5f0b1f..49cf9e64fb7 100644 --- a/docs/sources/alerting/migrating-alerts/opt-out.md +++ b/docs/sources/alerting/migrating-alerts/opt-out.md @@ -42,6 +42,8 @@ enabled = false enabled = true ``` +Installations that have been migrated to Grafana Alerting can [roll back]({{< relref "roll-back/" >}}) to legacy alerting at any time. + > **Note:** This topic is only relevant for OSS and Enterprise customers. Contact customer support to enable or disable Grafana Alerting for your Grafana Cloud stack. The `ngalert` toggle previously used to enable or disable Grafana Alerting is no longer available. From c4f7cb2a556d3b50e00a05bd648b33e710775257 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 10 Jun 2022 12:21:49 +0200 Subject: [PATCH 36/40] Update dependency @react-aria/button to v3.5.0 (#50391) Co-authored-by: Renovate Bot --- package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 63 +++++++++++++++++++------------- 3 files changed, 39 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index add10c6848f..0c26753e683 100644 --- a/package.json +++ b/package.json @@ -269,7 +269,7 @@ "@opentelemetry/exporter-collector": "0.25.0", "@opentelemetry/semantic-conventions": "1.3.1", "@popperjs/core": "2.11.5", - "@react-aria/button": "3.4.4", + "@react-aria/button": "3.5.0", "@react-aria/dialog": "3.2.0", "@react-aria/focus": "3.6.0", "@react-aria/interactions": "3.9.0", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 819039f6293..e3925d07e39 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -39,7 +39,7 @@ "@grafana/slate-react": "0.22.10-grafana", "@monaco-editor/react": "4.3.1", "@popperjs/core": "2.11.5", - "@react-aria/button": "3.4.4", + "@react-aria/button": "3.5.0", "@react-aria/dialog": "3.2.0", "@react-aria/focus": "3.6.0", "@react-aria/menu": "3.4.4", diff --git a/yarn.lock b/yarn.lock index a539999587b..b3d2063d683 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4678,7 +4678,7 @@ __metadata: "@mdx-js/react": 1.6.22 "@monaco-editor/react": 4.3.1 "@popperjs/core": 2.11.5 - "@react-aria/button": 3.4.4 + "@react-aria/button": 3.5.0 "@react-aria/dialog": 3.2.0 "@react-aria/focus": 3.6.0 "@react-aria/menu": 3.4.4 @@ -7295,19 +7295,19 @@ __metadata: languageName: node linkType: hard -"@react-aria/button@npm:3.4.4": - version: 3.4.4 - resolution: "@react-aria/button@npm:3.4.4" +"@react-aria/button@npm:3.5.0": + version: 3.5.0 + resolution: "@react-aria/button@npm:3.5.0" dependencies: "@babel/runtime": ^7.6.2 - "@react-aria/focus": ^3.5.5 - "@react-aria/interactions": ^3.8.4 - "@react-aria/utils": ^3.12.0 - "@react-stately/toggle": ^3.2.7 - "@react-types/button": ^3.4.5 + "@react-aria/focus": ^3.6.0 + "@react-aria/interactions": ^3.9.0 + "@react-aria/utils": ^3.13.0 + "@react-stately/toggle": ^3.3.0 + "@react-types/button": ^3.5.0 peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 - checksum: 8320dfd1ead1eb2097025edc347615ac020b59f05581b5bdf14815bb7518d399ab280525242852122bb864af3318ef5d2bc3bcd3431063c53f6a2fd2cca45fa1 + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: f6960c91b073ea68f82dd7751f8c77ccdc4d62e227f190ae0030811a15bf943998e87708ceadaded2d7fbd45015b3c68ea74618d7157121c01c6126cc06de3ee languageName: node linkType: hard @@ -7594,17 +7594,17 @@ __metadata: languageName: node linkType: hard -"@react-stately/toggle@npm:^3.2.7": - version: 3.2.7 - resolution: "@react-stately/toggle@npm:3.2.7" +"@react-stately/toggle@npm:^3.3.0": + version: 3.3.0 + resolution: "@react-stately/toggle@npm:3.3.0" dependencies: "@babel/runtime": ^7.6.2 - "@react-stately/utils": ^3.4.1 - "@react-types/checkbox": ^3.2.7 - "@react-types/shared": ^3.12.0 + "@react-stately/utils": ^3.5.0 + "@react-types/checkbox": ^3.3.0 + "@react-types/shared": ^3.13.0 peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 - checksum: 47119dac74d5da5fca3cdc1579eb28c5367e8610c55c27a69a78102ad3aa6518623fd422820f3d49362c3da6a3d738f4900d8f6660b0b7a47ca6adaff1e81f84 + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: 3c6e721acb8c7527d44f82dbf1edb219bccbf84006c908b8ba66c7e044d4d81115227f84b781f3dcdf60d835986d964526ff653564f15889001499f6c22c6e85 languageName: node linkType: hard @@ -7656,14 +7656,25 @@ __metadata: languageName: node linkType: hard -"@react-types/checkbox@npm:^3.2.7": - version: 3.2.7 - resolution: "@react-types/checkbox@npm:3.2.7" +"@react-types/button@npm:^3.5.0": + version: 3.5.0 + resolution: "@react-types/button@npm:3.5.0" dependencies: - "@react-types/shared": ^3.12.0 + "@react-types/shared": ^3.13.0 peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 - checksum: 02214780b7efe2863cdd9cf97048fabbd0ad3abcaa5c4f1f073e39ede8541fd4a7345cd88f3ad4ad25f5acedfe52120e6a1a4470508f29c641a91d5493915060 + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: 85c6e278c5e0da8a9f4595b8d2943dcb28ecd2ffe56eab0c9387daf6a25823045cb432964c88a8729cf036cf5058a077e2b33c63636412af2e9f14ec8f70d534 + languageName: node + linkType: hard + +"@react-types/checkbox@npm:^3.3.0": + version: 3.3.0 + resolution: "@react-types/checkbox@npm:3.3.0" + dependencies: + "@react-types/shared": ^3.13.0 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: 9fb34839fca2c2e46b3b68ca492eee8a9db76b532eb363717529cb28970b72880b54f80eacbbf1a5607b6a0749b1b1fa68fe010a6c0a381fe58ee50ffb7ce276 languageName: node linkType: hard @@ -20728,7 +20739,7 @@ __metadata: "@opentelemetry/semantic-conventions": 1.3.1 "@pmmmwh/react-refresh-webpack-plugin": 0.5.6 "@popperjs/core": 2.11.5 - "@react-aria/button": 3.4.4 + "@react-aria/button": 3.5.0 "@react-aria/dialog": 3.2.0 "@react-aria/focus": 3.6.0 "@react-aria/interactions": 3.9.0 From 5633648383a3e41ee75f80766b907436562d808f Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Fri, 10 Jun 2022 13:28:50 +0300 Subject: [PATCH 37/40] Rename release pipelines (#50585) --- .drone.yml | 58 ++++++++++++++-------------- scripts/drone/pipelines/release.star | 28 +++++++------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/.drone.yml b/.drone.yml index afd93a2702e..59af3003bb4 100644 --- a/.drone.yml +++ b/.drone.yml @@ -1505,7 +1505,7 @@ type: docker --- depends_on: [] kind: pipeline -name: oss-build-e2e-publish-release +name: release-oss-build-e2e-publish node: type: no-parallel platform: @@ -1802,7 +1802,7 @@ volumes: --- depends_on: [] kind: pipeline -name: oss-test-release +name: release-oss-test node: type: no-parallel platform: @@ -1920,7 +1920,7 @@ volumes: --- depends_on: [] kind: pipeline -name: oss-integration-tests-release +name: release-oss-integration-tests node: type: no-parallel platform: @@ -2010,11 +2010,11 @@ volumes: medium: memory --- depends_on: -- oss-build-e2e-publish-release -- oss-test-release -- oss-integration-tests-release +- release-oss-build-e2e-publish +- release-oss-test +- release-oss-integration-tests kind: pipeline -name: oss-windows-release +name: release-oss-windows platform: arch: amd64 os: windows @@ -2076,7 +2076,7 @@ depends_on: [] image_pull_secrets: - dockerconfigjson kind: pipeline -name: enterprise-build-e2e-publish-release +name: release-enterprise-build-e2e-publish node: type: no-parallel platform: @@ -2424,7 +2424,7 @@ depends_on: [] image_pull_secrets: - dockerconfigjson kind: pipeline -name: enterprise-test-release +name: release-enterprise-test node: type: no-parallel platform: @@ -2587,7 +2587,7 @@ depends_on: [] image_pull_secrets: - dockerconfigjson kind: pipeline -name: enterprise-integration-tests-release +name: release-enterprise-integration-tests node: type: no-parallel platform: @@ -2728,13 +2728,13 @@ volumes: clone: disable: true depends_on: -- enterprise-build-e2e-publish-release -- enterprise-test-release -- enterprise-integration-tests-release +- release-enterprise-build-e2e-publish +- release-enterprise-test +- release-enterprise-integration-tests image_pull_secrets: - dockerconfigjson kind: pipeline -name: enterprise-windows-release +name: release-enterprise-windows platform: arch: amd64 os: windows @@ -3317,7 +3317,7 @@ volumes: --- depends_on: [] kind: pipeline -name: oss-build-e2e-publish-release-branch +name: release-branch-oss-build-e2e-publish node: type: no-parallel platform: @@ -3584,7 +3584,7 @@ volumes: --- depends_on: [] kind: pipeline -name: oss-test-release-branch +name: release-branch-oss-test node: type: no-parallel platform: @@ -3696,7 +3696,7 @@ volumes: --- depends_on: [] kind: pipeline -name: oss-integration-tests-release-branch +name: release-branch-oss-integration-tests node: type: no-parallel platform: @@ -3780,11 +3780,11 @@ volumes: medium: memory --- depends_on: -- oss-build-e2e-publish-release-branch -- oss-test-release-branch -- oss-integration-tests-release-branch +- release-branch-oss-build-e2e-publish +- release-branch-oss-test +- release-branch-oss-integration-tests kind: pipeline -name: oss-windows-release-branch +name: release-branch-oss-windows platform: arch: amd64 os: windows @@ -3835,7 +3835,7 @@ depends_on: [] image_pull_secrets: - dockerconfigjson kind: pipeline -name: enterprise-build-e2e-publish-release-branch +name: release-branch-enterprise-build-e2e-publish node: type: no-parallel platform: @@ -4181,7 +4181,7 @@ depends_on: [] image_pull_secrets: - dockerconfigjson kind: pipeline -name: enterprise-test-release-branch +name: release-branch-enterprise-test node: type: no-parallel platform: @@ -4335,7 +4335,7 @@ depends_on: [] image_pull_secrets: - dockerconfigjson kind: pipeline -name: enterprise-integration-tests-release-branch +name: release-branch-enterprise-integration-tests node: type: no-parallel platform: @@ -4467,13 +4467,13 @@ volumes: clone: disable: true depends_on: -- enterprise-build-e2e-publish-release-branch -- enterprise-test-release-branch -- enterprise-integration-tests-release-branch +- release-branch-enterprise-build-e2e-publish +- release-branch-enterprise-test +- release-branch-enterprise-integration-tests image_pull_secrets: - dockerconfigjson kind: pipeline -name: enterprise-windows-release-branch +name: release-branch-enterprise-windows platform: arch: amd64 os: windows @@ -4683,6 +4683,6 @@ kind: secret name: gcp_upload_artifacts_key --- kind: signature -hmac: 378147a306d077b0566f5353c9e5c236c40dbb8d81392091a1346ed275b86496 +hmac: 0abf901971b403a4cf5887d1051ec4a656335a23d9a92809a71312d115b4862f ... diff --git a/scripts/drone/pipelines/release.star b/scripts/drone/pipelines/release.star index 8b3f3af3e80..38c81d3c129 100644 --- a/scripts/drone/pipelines/release.star +++ b/scripts/drone/pipelines/release.star @@ -253,7 +253,7 @@ def get_oss_pipelines(trigger, ver_mode): volumes = integration_test_services_volumes() init_steps, test_steps, build_steps, integration_test_steps, package_steps, windows_package_steps, publish_steps = get_steps(edition=edition, ver_mode=ver_mode) windows_pipeline = pipeline( - name='oss-windows-{}'.format(ver_mode), edition=edition, trigger=trigger, + name='{}-oss-windows'.format(ver_mode), edition=edition, trigger=trigger, steps=[identify_runner_step('windows')] + windows_package_steps, platform='windows', depends_on=[ 'oss-build{}-publish-{}'.format(get_e2e_suffix(), ver_mode), @@ -261,7 +261,7 @@ def get_oss_pipelines(trigger, ver_mode): ) pipelines = [ pipeline( - name='oss-build{}-publish-{}'.format(get_e2e_suffix(), ver_mode), edition=edition, trigger=trigger, services=[], + name='{}-oss-build{}-publish'.format(ver_mode, get_e2e_suffix()), edition=edition, trigger=trigger, services=[], steps=init_steps + build_steps + package_steps + publish_steps, volumes=volumes, ), @@ -269,21 +269,21 @@ def get_oss_pipelines(trigger, ver_mode): if not disable_tests: pipelines.extend([ pipeline( - name='oss-test-{}'.format(ver_mode), edition=edition, trigger=trigger, services=[], + name='{}-oss-test'.format(ver_mode), edition=edition, trigger=trigger, services=[], steps=init_steps + test_steps, volumes=[], ), pipeline( - name='oss-integration-tests-{}'.format(ver_mode), edition=edition, trigger=trigger, services=services, + name='{}-oss-integration-tests'.format(ver_mode), edition=edition, trigger=trigger, services=services, steps=[download_grabpl_step(), identify_runner_step(),] + integration_test_steps, volumes=volumes, ) ]) deps = { 'depends_on': [ - 'oss-build{}-publish-{}'.format(get_e2e_suffix(), ver_mode), - 'oss-test-{}'.format(ver_mode), - 'oss-integration-tests-{}'.format(ver_mode) + '{}-oss-build{}-publish'.format(ver_mode, get_e2e_suffix()), + '{}-oss-test'.format(ver_mode), + '{}-oss-integration-tests'.format(ver_mode) ] } windows_pipeline.update(deps) @@ -315,7 +315,7 @@ def get_enterprise_pipelines(trigger, ver_mode): step.update(deps_on_clone_enterprise_step) windows_pipeline = pipeline( - name='enterprise-windows-{}'.format(ver_mode), edition=edition, trigger=trigger, + name='{}-enterprise-windows'.format(ver_mode), edition=edition, trigger=trigger, steps=[identify_runner_step('windows')] + windows_package_steps, platform='windows', depends_on=[ 'enterprise-build{}-publish-{}'.format(get_e2e_suffix(), ver_mode), @@ -323,7 +323,7 @@ def get_enterprise_pipelines(trigger, ver_mode): ) pipelines = [ pipeline( - name='enterprise-build{}-publish-{}'.format(get_e2e_suffix(), ver_mode), edition=edition, trigger=trigger, services=[], + name='{}-enterprise-build{}-publish'.format(ver_mode, get_e2e_suffix()), edition=edition, trigger=trigger, services=[], steps=init_steps + build_steps + package_steps + publish_steps, volumes=volumes, ), @@ -331,21 +331,21 @@ def get_enterprise_pipelines(trigger, ver_mode): if not disable_tests: pipelines.extend([ pipeline( - name='enterprise-test-{}'.format(ver_mode), edition=edition, trigger=trigger, services=[], + name='{}-enterprise-test'.format(ver_mode), edition=edition, trigger=trigger, services=[], steps=init_steps + test_steps, volumes=[], ), pipeline( - name='enterprise-integration-tests-{}'.format(ver_mode), edition=edition, trigger=trigger, services=services, + name='{}-enterprise-integration-tests'.format(ver_mode), edition=edition, trigger=trigger, services=services, steps=[download_grabpl_step(), identify_runner_step(), clone_enterprise_step(ver_mode), init_enterprise_step(ver_mode),] + integration_test_steps, volumes=volumes, ), ]) deps = { 'depends_on': [ - 'enterprise-build{}-publish-{}'.format(get_e2e_suffix(), ver_mode), - 'enterprise-test-{}'.format(ver_mode), - 'enterprise-integration-tests-{}'.format(ver_mode) + '{}-enterprise-build{}-publish'.format(ver_mode, get_e2e_suffix()), + '{}-enterprise-test'.format(ver_mode), + '{}-enterprise-integration-tests'.format(ver_mode) ] } windows_pipeline.update(deps) From 9a62849dc39e960640b5762cc5eace1baf29c0ee Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Fri, 10 Jun 2022 12:02:52 +0100 Subject: [PATCH 38/40] Add i18n feature flag (#50590) --- packages/grafana-data/src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 6 ++++++ pkg/services/featuremgmt/toggles_gen.go | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index e140056e6ee..ef1da04661a 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -60,4 +60,5 @@ export interface FeatureToggles { cloudMonitoringExperimentalUI?: boolean; logRequestsInstrumentedAsUnknown?: boolean; dataConnectionsConsole?: boolean; + internationalization?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 416f5da8ef8..b913a3384e8 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2,6 +2,7 @@ // pkg/services/featuremgmt/registry.go // Then run tests in: // pkg/services/featuremgmt/toggles_gen_test.go +// twice to generate and validate the feature flag files package featuremgmt @@ -246,5 +247,10 @@ var ( Description: "Enables a new top-level page called Data Connections. This page is an experiment for better grouping of installing / configuring data sources and other plugins.", State: FeatureStateAlpha, }, + { + Name: "internationalization", + Description: "Enables work-in-progress internationalization", + State: FeatureStateAlpha, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index ec83161bcf8..f73b679a30d 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -182,4 +182,8 @@ const ( // FlagDataConnectionsConsole // Enables a new top-level page called Data Connections. This page is an experiment for better grouping of installing / configuring data sources and other plugins. FlagDataConnectionsConsole = "dataConnectionsConsole" + + // FlagInternationalization + // Enables work-in-progress internationalization + FlagInternationalization = "internationalization" ) From 5a9b62270893ee93029d3297ef9f271a655b6055 Mon Sep 17 00:00:00 2001 From: idafurjes <36131195+idafurjes@users.noreply.github.com> Date: Fri, 10 Jun 2022 14:33:12 +0200 Subject: [PATCH 39/40] Add xorm tag to dashboard version id (#50599) --- pkg/services/dashboardversion/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/services/dashboardversion/model.go b/pkg/services/dashboardversion/model.go index fd087921269..4141430f529 100644 --- a/pkg/services/dashboardversion/model.go +++ b/pkg/services/dashboardversion/model.go @@ -13,7 +13,7 @@ var ( ) type DashboardVersion struct { - ID int64 `json:"id"` + ID int64 `json:"id" xorm:"pk autoincr 'id'"` DashboardID int64 `json:"dashboardId" xorm:"dashboard_id"` ParentVersion int `json:"parentVersion"` RestoredFrom int `json:"restoredFrom"` From 99db588919000165d726fd9466fb10ba0445dde6 Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Fri, 10 Jun 2022 08:41:29 -0400 Subject: [PATCH 40/40] Data Sources: Add QueryData OAuth & cookie forwarding middleware (#50466) --- .../forwarded_cookie_middleware_test.go | 65 +++++++++++++++ .../forwarded_cookies_middleware.go | 24 ++++++ .../forwarded_oauth_identity_middleware.go | 31 +++++++ ...orwarded_oauth_identity_middleware_test.go | 82 +++++++++++++++++++ .../http_client_provider.go | 3 +- pkg/services/query/query.go | 12 +++ 6 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 pkg/infra/httpclient/httpclientprovider/forwarded_cookie_middleware_test.go create mode 100644 pkg/infra/httpclient/httpclientprovider/forwarded_cookies_middleware.go create mode 100644 pkg/infra/httpclient/httpclientprovider/forwarded_oauth_identity_middleware.go create mode 100644 pkg/infra/httpclient/httpclientprovider/forwarded_oauth_identity_middleware_test.go diff --git a/pkg/infra/httpclient/httpclientprovider/forwarded_cookie_middleware_test.go b/pkg/infra/httpclient/httpclientprovider/forwarded_cookie_middleware_test.go new file mode 100644 index 00000000000..07753e0c0f5 --- /dev/null +++ b/pkg/infra/httpclient/httpclientprovider/forwarded_cookie_middleware_test.go @@ -0,0 +1,65 @@ +package httpclientprovider_test + +import ( + "net/http" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/infra/httpclient/httpclientprovider" + "github.com/stretchr/testify/require" +) + +func TestForwardedCookiesMiddleware(t *testing.T) { + tcs := []struct { + desc string + allowedCookies []string + expectedCookieHeader string + }{ + { + desc: "With nil allowedCookies should not populate Cookie header", + allowedCookies: nil, + expectedCookieHeader: "", + }, + { + desc: "With empty allowed cookies should not populate Cookie header", + allowedCookies: []string{}, + expectedCookieHeader: "", + }, + { + desc: "When provided with allowed cookies should populate Cookie header", + allowedCookies: []string{"c1", "c3"}, + expectedCookieHeader: "c1=1; c3=3", + }, + } + + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + ctx := &testContext{} + finalRoundTripper := ctx.createRoundTripper() + forwarded := []*http.Cookie{ + {Name: "c1", Value: "1"}, + {Name: "c2", Value: "2"}, + {Name: "c3", Value: "3"}, + } + mw := httpclientprovider.ForwardedCookiesMiddleware(forwarded, tc.allowedCookies) + opts := httpclient.Options{} + rt := mw.CreateMiddleware(opts, finalRoundTripper) + require.NotNil(t, rt) + middlewareName, ok := mw.(httpclient.MiddlewareName) + require.True(t, ok) + require.Equal(t, "forwarded-cookies", middlewareName.MiddlewareName()) + + req, err := http.NewRequest(http.MethodGet, "http://", nil) + require.NoError(t, err) + res, err := rt.RoundTrip(req) + require.NoError(t, err) + require.NotNil(t, res) + if res.Body != nil { + require.NoError(t, res.Body.Close()) + } + require.Len(t, ctx.callChain, 1) + require.ElementsMatch(t, []string{"final"}, ctx.callChain) + require.Equal(t, tc.expectedCookieHeader, ctx.req.Header.Get("Cookie")) + }) + } +} diff --git a/pkg/infra/httpclient/httpclientprovider/forwarded_cookies_middleware.go b/pkg/infra/httpclient/httpclientprovider/forwarded_cookies_middleware.go new file mode 100644 index 00000000000..7d0ef6f2ef5 --- /dev/null +++ b/pkg/infra/httpclient/httpclientprovider/forwarded_cookies_middleware.go @@ -0,0 +1,24 @@ +package httpclientprovider + +import ( + "net/http" + + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/util/proxyutil" +) + +const ForwardedCookiesMiddlewareName = "forwarded-cookies" + +// ForwardedCookiesMiddleware middleware that sets Cookie header on the +// outgoing request, if forwarded cookies configured/provided. +func ForwardedCookiesMiddleware(forwardedCookies []*http.Cookie, allowedCookies []string) httpclient.Middleware { + return httpclient.NamedMiddlewareFunc(ForwardedCookiesMiddlewareName, func(opts httpclient.Options, next http.RoundTripper) http.RoundTripper { + return httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + for _, cookie := range forwardedCookies { + req.AddCookie(cookie) + } + proxyutil.ClearCookieHeader(req, allowedCookies) + return next.RoundTrip(req) + }) + }) +} diff --git a/pkg/infra/httpclient/httpclientprovider/forwarded_oauth_identity_middleware.go b/pkg/infra/httpclient/httpclientprovider/forwarded_oauth_identity_middleware.go new file mode 100644 index 00000000000..d5c78e794d7 --- /dev/null +++ b/pkg/infra/httpclient/httpclientprovider/forwarded_oauth_identity_middleware.go @@ -0,0 +1,31 @@ +package httpclientprovider + +import ( + "fmt" + "net/http" + + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "golang.org/x/oauth2" +) + +const ForwardedOAuthIdentityMiddlewareName = "forwarded-oauth-identity" + +// ForwardedOAuthIdentityMiddleware middleware that sets Authorization/X-ID-Token +// headers on the outgoing request if an OAuth Token is provided +func ForwardedOAuthIdentityMiddleware(token *oauth2.Token) httpclient.Middleware { + return httpclient.NamedMiddlewareFunc(ForwardedOAuthIdentityMiddlewareName, func(opts httpclient.Options, next http.RoundTripper) http.RoundTripper { + if token == nil { + return next + } + return httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", fmt.Sprintf("%s %s", token.Type(), token.AccessToken)) + + idToken, ok := token.Extra("id_token").(string) + if ok && idToken != "" { + req.Header.Set("X-ID-Token", idToken) + } + + return next.RoundTrip(req) + }) + }) +} diff --git a/pkg/infra/httpclient/httpclientprovider/forwarded_oauth_identity_middleware_test.go b/pkg/infra/httpclient/httpclientprovider/forwarded_oauth_identity_middleware_test.go new file mode 100644 index 00000000000..1cd65d1bf09 --- /dev/null +++ b/pkg/infra/httpclient/httpclientprovider/forwarded_oauth_identity_middleware_test.go @@ -0,0 +1,82 @@ +package httpclientprovider_test + +import ( + "net/http" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/infra/httpclient/httpclientprovider" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func TestForwardedOAuthIdentityMiddleware(t *testing.T) { + at := &oauth2.Token{ + AccessToken: "access-token", + } + tcs := []struct { + desc string + token *oauth2.Token + expectedAuthorizationHeader string + expectedIDTokenHeader string + }{ + { + desc: "With nil token should not populate Cookie headers", + token: nil, + expectedAuthorizationHeader: "", + expectedIDTokenHeader: "", + }, + { + desc: "With access token set should populate Authorization header", + token: at, + expectedAuthorizationHeader: "Bearer access-token", + expectedIDTokenHeader: "", + }, + { + desc: "With Authorization and X-ID-Token header set should populate Authorization and X-Id-Token header", + token: at.WithExtra(map[string]interface{}{"id_token": "id-token"}), + expectedAuthorizationHeader: "Bearer access-token", + expectedIDTokenHeader: "id-token", + }, + } + + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + ctx := &testContext{} + finalRoundTripper := ctx.createRoundTripper() + mw := httpclientprovider.ForwardedOAuthIdentityMiddleware(tc.token) + opts := httpclient.Options{} + rt := mw.CreateMiddleware(opts, finalRoundTripper) + require.NotNil(t, rt) + middlewareName, ok := mw.(httpclient.MiddlewareName) + require.True(t, ok) + require.Equal(t, "forwarded-oauth-identity", middlewareName.MiddlewareName()) + + req, err := http.NewRequest(http.MethodGet, "http://", nil) + require.NoError(t, err) + res, err := rt.RoundTrip(req) + require.NoError(t, err) + require.NotNil(t, res) + if res.Body != nil { + require.NoError(t, res.Body.Close()) + } + require.Len(t, ctx.callChain, 1) + require.ElementsMatch(t, []string{"final"}, ctx.callChain) + require.Equal(t, tc.expectedAuthorizationHeader, ctx.req.Header.Get("Authorization")) + require.Equal(t, tc.expectedIDTokenHeader, ctx.req.Header.Get("X-ID-Token")) + }) + } +} + +type testContext struct { + callChain []string + req *http.Request +} + +func (c *testContext) createRoundTripper() http.RoundTripper { + return httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + c.callChain = append(c.callChain, "final") + c.req = req + return &http.Response{StatusCode: http.StatusOK}, nil + }) +} diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go index 0c6944cffba..b09b50c7b8f 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go +++ b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go @@ -5,12 +5,11 @@ import ( "net/http" "time" - "github.com/grafana/grafana/pkg/models" - sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/mwitkow/go-conntrack" ) diff --git a/pkg/services/query/query.go b/pkg/services/query/query.go index 082696f6691..206d06ed39e 100644 --- a/pkg/services/query/query.go +++ b/pkg/services/query/query.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/infra/httpclient/httpclientprovider" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" @@ -22,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/util/proxyutil" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" ) const ( @@ -136,6 +138,13 @@ func (s *Service) handleQueryData(ctx context.Context, user *models.SignedInUser Queries: []backend.DataQuery{}, } + middlewares := []httpclient.Middleware{} + if parsedReq.httpRequest != nil { + middlewares = append(middlewares, + httpclientprovider.ForwardedCookiesMiddleware(parsedReq.httpRequest.Cookies(), ds.AllowedCookies()), + ) + } + if s.oAuthTokenService.IsOAuthPassThruEnabled(ds) { if token := s.oAuthTokenService.GetCurrentOAuthToken(ctx, user); token != nil { req.Headers["Authorization"] = fmt.Sprintf("%s %s", token.Type(), token.AccessToken) @@ -144,6 +153,7 @@ func (s *Service) handleQueryData(ctx context.Context, user *models.SignedInUser if ok && idToken != "" { req.Headers["X-ID-Token"] = idToken } + middlewares = append(middlewares, httpclientprovider.ForwardedOAuthIdentityMiddleware(token)) } } @@ -162,6 +172,8 @@ func (s *Service) handleQueryData(ctx context.Context, user *models.SignedInUser req.Queries = append(req.Queries, q.query) } + ctx = httpclient.WithContextualMiddleware(ctx, middlewares...) + return s.pluginClient.QueryData(ctx, req) }