diff --git a/.betterer.results b/.betterer.results
index 4cc661563ee..cccbb6fad1b 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -146,12 +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"]
- ],
"public/app/features/users/UsersListPage.test.tsx:2518052139": [
[0, 19, 13, "RegExp match", "2409514259"]
],
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/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/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/alerting/migrating-alerts/_index.md b/docs/sources/alerting/migrating-alerts/_index.md
index c2b1a057a0f..12a1710194d 100644
--- a/docs/sources/alerting/migrating-alerts/_index.md
+++ b/docs/sources/alerting/migrating-alerts/_index.md
@@ -1,27 +1,33 @@
---
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.
+> **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.
-| 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. |
+Existing installations that do not use legacy alerting will have Grafana Alerting enabled by default unless alerting is disabled in the configuration.
-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.
+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.
-## Roll back to legacy alerting
+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.
-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.
+> **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]({{< 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..49cf9e64fb7 100644
--- a/docs/sources/alerting/migrating-alerts/opt-out.md
+++ b/docs/sources/alerting/migrating-alerts/opt-out.md
@@ -3,41 +3,47 @@ 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
+```
+
+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.
-## 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/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/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/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.
diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md
index 61db9c50468..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`.
@@ -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/" >}}).
diff --git a/docs/sources/whatsnew/whats-new-in-v9-0.md b/docs/sources/whatsnew/whats-new-in-v9-0.md
index 169f90db421..27a8c98267c 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
@@ -215,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:
diff --git a/package.json b/package.json
index 792a190ebf7..0c26753e683 100644
--- a/package.json
+++ b/package.json
@@ -267,12 +267,12 @@
"@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",
- "@react-aria/focus": "3.5.5",
- "@react-aria/interactions": "3.8.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",
"@react-aria/menu": "3.4.4",
"@react-aria/overlays": "3.8.2",
"@react-aria/utils": "3.12.0",
@@ -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/packages/grafana-data/src/geo/layer.ts b/packages/grafana-data/src/geo/layer.ts
index 60463aa7ebc..caa0360a672 100644
--- a/packages/grafana-data/src/geo/layer.ts
+++ b/packages/grafana-data/src/geo/layer.ts
@@ -3,7 +3,7 @@ import BaseLayer from 'ol/layer/Base';
import { ReactNode } from 'react';
import { GrafanaTheme2 } from '../themes';
-import { PanelData } from '../types';
+import { MatcherConfig, PanelData } from '../types';
import { PanelOptionsEditorBuilder } from '../utils';
import { RegistryItemWithOptions } from '../utils/Registry';
@@ -58,6 +58,9 @@ export interface MapLayerOptions {
// Common method to define geometry fields
location?: FrameGeometrySource;
+ // Defines which data query refId is associated with the layer
+ filterData?: MatcherConfig;
+
// Common properties:
// https://openlayers.org/en/latest/apidoc/module-ol_layer_Base-BaseLayer.html
// Layer opacity (0-1)
@@ -72,6 +75,9 @@ export interface MapLayerOptions {
*/
export interface MapLayerHandler {
init: () => BaseLayer;
+ /**
+ * The update function should only be implemented if the layer type makes use of query data
+ */
update?: (data: PanelData) => void;
legend?: ReactNode;
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/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts
index 757bf20da8d..ef1da04661a 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;
@@ -56,4 +57,8 @@ export interface FeatureToggles {
validateDashboardsOnSave?: boolean;
prometheusWideSeries?: boolean;
canvasPanelNesting?: boolean;
+ cloudMonitoringExperimentalUI?: boolean;
+ logRequestsInstrumentedAsUnknown?: boolean;
+ dataConnectionsConsole?: boolean;
+ internationalization?: boolean;
}
diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json
index c580a71a296..e3925d07e39 100644
--- a/packages/grafana-ui/package.json
+++ b/packages/grafana-ui/package.json
@@ -39,9 +39,9 @@
"@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/dialog": "3.1.9",
- "@react-aria/focus": "3.5.5",
+ "@react-aria/button": "3.5.0",
+ "@react-aria/dialog": "3.2.0",
+ "@react-aria/focus": "3.6.0",
"@react-aria/menu": "3.4.4",
"@react-aria/overlays": "3.8.2",
"@react-stately/menu": "3.2.7",
@@ -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",
diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts
index 9a1d647c43c..b5751895103 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,
diff --git a/packages/grafana-ui/src/utils/standardEditors.tsx b/packages/grafana-ui/src/utils/standardEditors.tsx
index 07948c2ae54..ca7007896ab 100644
--- a/packages/grafana-ui/src/utils/standardEditors.tsx
+++ b/packages/grafana-ui/src/utils/standardEditors.tsx
@@ -296,7 +296,7 @@ export const getStandardOptionEditors = () => {
const fieldName: StandardEditorsRegistryItem = {
id: 'field-name',
name: 'Field name',
- description: 'Time zone selection',
+ description: 'Allows selecting a field name from a data frame',
editor: FieldNamePicker as any,
};
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)
}
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/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
}
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/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/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/dashboardversion/model.go b/pkg/services/dashboardversion/model.go
index 6de84dd4d53..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"`
@@ -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"`
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index ad2fada0e58..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
@@ -72,6 +73,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",
@@ -192,11 +199,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",
@@ -226,5 +232,25 @@ var (
State: FeatureStateAlpha,
FrontendOnly: true,
},
+ {
+ Name: "cloudMonitoringExperimentalUI",
+ Description: "Use grafana-experimental UI in Cloud Monitoring",
+ State: FeatureStateAlpha,
+ FrontendOnly: true,
+ },
+ {
+ 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,
+ },
+ {
+ 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 d27b4f2256f..f73b679a30d 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"
@@ -166,4 +170,20 @@ const (
// FlagCanvasPanelNesting
// Allow elements nesting
FlagCanvasPanelNesting = "canvasPanelNesting"
+
+ // FlagCloudMonitoringExperimentalUI
+ // Use grafana-experimental UI in Cloud Monitoring
+ FlagCloudMonitoringExperimentalUI = "cloudMonitoringExperimentalUI"
+
+ // 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"
+
+ // FlagInternationalization
+ // Enables work-in-progress internationalization
+ FlagInternationalization = "internationalization"
)
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
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,
)
}
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)
}
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
+}
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{})
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/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/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/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 {
/>
this.exportCsv(dataFrames[dataFrameIndex], { useExcelHeader: this.state.downloadForExcel })}
+ onClick={() => {
+ if (hasLogs) {
+ reportInteraction('grafana_logs_download_clicked', {
+ app,
+ format: 'csv',
+ });
+ }
+ this.exportCsv(dataFrames[dataFrameIndex], { useExcelHeader: this.state.downloadForExcel });
+ }}
className={css`
margin-bottom: 10px;
`}
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`] = `
-
-`;
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/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;
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')} {
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`] = `
-
-
-
-
-
-
-`;
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}
>
)}
- >
+
);
}
}
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 = ({
{
- 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..e01d59c674e 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,189 @@ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ } else {
+ return (
+
+
+
+
- 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 ResourceGroupsField;
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/ResourceNameField.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/ResourceNameField.tsx
deleted file mode 100644
index 1012cd32494..00000000000
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/ResourceNameField.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 { setResourceName } from './setQueryValue';
-
-interface ResourceNameFieldProps extends AzureQueryEditorFieldProps {
- resourceNames: AzureMonitorOption[];
-}
-
-const ResourceNameField: React.FC = ({
- resourceNames,
- query,
- variableOptionGroup,
- onQueryChange,
-}) => {
- const handleChange = useCallback(
- (change: SelectableValue) => {
- const newQuery = setResourceName(query, change.value);
- onQueryChange(newQuery);
- },
- [onQueryChange, query]
- );
-
- const options = useMemo(() => [...resourceNames, variableOptionGroup], [resourceNames, variableOptionGroup]);
- const value = query.azureMonitor?.resourceName ?? null;
-
- 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 */}
-
-
- );
-};
-
-export default NamespaceField;
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.test.ts
index df2713ec397..fa49e55939d 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.test.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.test.ts
@@ -5,17 +5,12 @@ import Datasource from '../../datasource';
import { AzureMetricQuery, AzureMonitorOption, AzureMonitorQuery, AzureQueryType } from '../../types';
import {
+ useMetricNames,
+ useMetricNamespaces,
+ useMetricMetadata,
DataHook,
MetricMetadata,
MetricsMetadataHook,
- updateSubscriptions,
- useMetricMetadata,
- useMetricNames,
- useMetricNamespaces,
- useResourceGroups,
- useResourceNames,
- useResourceTypes,
- useSubscriptions,
} from './dataHooks';
const WAIT_OPTIONS = {
@@ -45,124 +40,17 @@ describe('AzureMonitor: metrics dataHooks', () => {
};
const testTable: TestScenario[] = [
- {
- name: 'useSubscriptions',
- hook: useSubscriptions,
- emptyQueryPartial: {},
- topLevelCustomProperties: {
- subscription: 'subscription-$ENVIRONMENT',
- },
- customProperties: {},
- expectedOptions: [
- {
- label: 'sub-abc-123',
- value: 'sub-abc-123',
- },
- ],
- expectedCustomPropertyResults: [
- {
- label: 'sub-abc-123',
- value: 'sub-abc-123',
- },
- {
- label: 'subscription-$ENVIRONMENT',
- value: 'subscription-$ENVIRONMENT',
- },
- ],
- },
-
- {
- name: 'useResourceGroups',
- hook: useResourceGroups,
- emptyQueryPartial: {},
- customProperties: {
- resourceGroup: 'resource-group-$ENVIRONMENT',
- },
- expectedOptions: [
- {
- label: 'Web App - Production',
- value: 'web-app-production',
- },
- {
- label: 'Web App - Development',
- value: 'web-app-development',
- },
- ],
- expectedCustomPropertyResults: [
- { label: 'Web App - Production', value: 'web-app-production' },
- { label: 'Web App - Development', value: 'web-app-development' },
- { label: 'resource-group-$ENVIRONMENT', value: 'resource-group-$ENVIRONMENT' },
- ],
- },
-
- {
- name: 'useResourceTypes',
- hook: useResourceTypes,
- emptyQueryPartial: {
- resourceGroup: 'web-app-development',
- },
- customProperties: {
- resourceGroup: 'web-app-development',
- metricDefinition: 'azure/resource-type-$ENVIRONMENT',
- },
- expectedOptions: [
- {
- label: 'Virtual Machine',
- value: 'azure/vm',
- },
- {
- label: 'Database',
- value: 'azure/db',
- },
- ],
- expectedCustomPropertyResults: [
- { label: 'Virtual Machine', value: 'azure/vm' },
- { label: 'Database', value: 'azure/db' },
- { label: 'azure/resource-type-$ENVIRONMENT', value: 'azure/resource-type-$ENVIRONMENT' },
- ],
- },
- {
- name: 'useResourceNames',
- hook: useResourceNames,
- emptyQueryPartial: {
- resourceGroup: 'web-app-development',
- metricDefinition: 'azure/vm',
- },
- customProperties: {
- resourceGroup: 'web-app-development',
- metricDefinition: 'azure/vm',
- resourceName: 'resource-name-$ENVIRONMENT',
- },
- expectedOptions: [
- {
- label: 'Web server',
- value: 'web-server',
- },
- {
- label: 'Job server',
- value: 'job-server',
- },
- ],
- expectedCustomPropertyResults: [
- { label: 'Web server', value: 'web-server' },
- { label: 'Job server', value: 'job-server' },
- { label: 'resource-name-$ENVIRONMENT', value: 'resource-name-$ENVIRONMENT' },
- ],
- },
-
{
name: 'useMetricNames',
hook: useMetricNames,
emptyQueryPartial: {
- resourceGroup: 'web-app-development',
- metricDefinition: 'azure/vm',
- resourceName: 'web-server',
+ resourceUri:
+ '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
metricNamespace: 'azure/vm',
},
customProperties: {
- resourceGroup: 'web-app-development',
- metricDefinition: 'azure/vm',
- resourceName: 'web-server',
+ resourceUri:
+ '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
metricNamespace: 'azure/vm',
metricName: 'metric-$ENVIRONMENT',
},
@@ -182,20 +70,17 @@ describe('AzureMonitor: metrics dataHooks', () => {
{ label: 'metric-$ENVIRONMENT', value: 'metric-$ENVIRONMENT' },
],
},
-
{
name: 'useMetricNamespaces',
hook: useMetricNamespaces,
emptyQueryPartial: {
- resourceGroup: 'web-app-development',
- metricDefinition: 'azure/vm',
- resourceName: 'web-server',
+ resourceUri:
+ '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
metricNamespace: 'azure/vm',
},
customProperties: {
- resourceGroup: 'web-app-development',
- metricDefinition: 'azure/vm',
- resourceName: 'web-server',
+ resourceUri:
+ '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
metricNamespace: 'azure/vm-$ENVIRONMENT',
metricName: 'metric-name',
},
@@ -307,11 +192,9 @@ describe('AzureMonitor: metrics dataHooks', () => {
name: 'useMetricMetadata',
hook: useMetricMetadata,
emptyQueryPartial: {
- resourceGroup: 'web-app-development',
- metricDefinition: 'azure/vm',
- resourceName: 'web-server',
+ resourceUri:
+ '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
metricNamespace: 'azure/vm',
- subscription: 'test-sub',
metricName: 'Average CPU',
},
customProperties: {},
@@ -356,81 +239,3 @@ describe('AzureMonitor: metrics dataHooks', () => {
});
});
});
-
-describe('AzureMonitor: updateSubscriptions', () => {
- const bareQuery = {
- refId: 'A',
- queryType: AzureQueryType.AzureMonitor,
- };
-
- [
- {
- description: 'should not update with no subscriptions',
- query: bareQuery,
- subscriptionOptions: [],
- },
- {
- description: 'should not update with the subscription as an option',
- query: { ...bareQuery, subscription: 'foo' },
- subscriptionOptions: [{ label: 'foo', value: 'foo' }],
- },
- {
- description: 'should not update with a template variable',
- query: { ...bareQuery, subscription: '$foo' },
- subscriptionOptions: [],
- },
- {
- description: 'should update with the first subscription',
- query: { ...bareQuery },
- subscriptionOptions: [{ label: 'foo', value: 'foo' }],
- onChangeArgs: {
- ...bareQuery,
- subscription: 'foo',
- azureMonitor: {
- dimensionFilters: [],
- timeGrain: '',
- resourceUri: '',
- },
- },
- },
- {
- description: 'should update with the default subscription if the current subsription does not exists',
- query: { ...bareQuery, subscription: 'bar' },
- subscriptionOptions: [{ label: 'foo', value: 'foo' }],
- onChangeArgs: {
- ...bareQuery,
- subscription: 'foo',
- azureMonitor: {
- dimensionFilters: [],
- timeGrain: '',
- resourceUri: '',
- },
- },
- },
- {
- description: 'should clean up if neither the default sub nor the current sub exists',
- query: { ...bareQuery, subscription: 'bar' },
- subscriptionOptions: [{ label: 'foo', value: 'foo' }],
- defaultSubscription: 'foobar',
- onChangeArgs: {
- ...bareQuery,
- subscription: '',
- azureMonitor: {
- dimensionFilters: [],
- timeGrain: '',
- resourceUri: '',
- },
- },
- },
- ].forEach((test) => {
- it(test.description, () => {
- const onChange = jest.fn();
- updateSubscriptions(test.query, test.subscriptionOptions, onChange, test.defaultSubscription);
- if (test.onChangeArgs) {
- expect(onChange).toHaveBeenCalledWith(test.onChangeArgs);
- } else {
- expect(onChange).not.toHaveBeenCalled();
- }
- });
- });
-});
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts
index 77a6497660d..637de16d069 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts
@@ -5,24 +5,12 @@ import { rangeUtil } from '@grafana/data';
import Datasource from '../../datasource';
import TimegrainConverter from '../../time_grain_converter';
import { AzureMonitorErrorish, AzureMonitorOption, AzureMonitorQuery } from '../../types';
-import { hasOption, toOption } from '../../utils/common';
+import { toOption } from '../../utils/common';
import { useAsyncState } from '../../utils/useAsyncState';
-import { setMetricNamespace, setSubscriptionID } from './setQueryValue';
-
-export interface MetricMetadata {
- aggOptions: AzureMonitorOption[];
- timeGrains: AzureMonitorOption[];
- dimensions: AzureMonitorOption[];
- isLoading: boolean;
-
- // These two properties are only used within the hook, and not elsewhere
- supportedAggTypes: string[];
- primaryAggType: string | undefined;
-}
+import { setMetricNamespace } from './setQueryValue';
type SetErrorFn = (source: string, error: AzureMonitorErrorish | undefined) => void;
-type OnChangeFn = (newQuery: AzureMonitorQuery) => void;
export type DataHook = (
query: AzureMonitorQuery,
@@ -37,138 +25,29 @@ export type MetricsMetadataHook = (
onChange: OnChangeFn
) => MetricMetadata;
-export const updateSubscriptions = (
- query: AzureMonitorQuery,
- subscriptionOptions: AzureMonitorOption[],
- onChange: OnChangeFn,
- defaultSubscription?: string
-) => {
- const { subscription } = query;
+export interface MetricMetadata {
+ aggOptions: AzureMonitorOption[];
+ timeGrains: AzureMonitorOption[];
+ dimensions: AzureMonitorOption[];
+ isLoading: boolean;
- // Return early if subscriptions havent loaded, or if the query already has a subscription
- if (
- !subscriptionOptions.length ||
- (subscription && (hasOption(subscriptionOptions, subscription) || subscription.includes('$')))
- ) {
- return;
- }
+ // These two properties are only used within the hook, and not elsewhere
+ supportedAggTypes: string[];
+ primaryAggType: string | undefined;
+}
- const defaultSub = defaultSubscription || subscriptionOptions[0].value;
-
- if (!subscription && defaultSub && hasOption(subscriptionOptions, defaultSub)) {
- onChange(setSubscriptionID(query, defaultSub));
- }
-
- // Check if the current subscription is in the list of subscriptions
- if (subscription && !hasOption(subscriptionOptions, subscription)) {
- if (hasOption(subscriptionOptions, defaultSub)) {
- // Use the default sub if is on theh list
- onChange(setSubscriptionID(query, defaultSub));
- } else {
- // Neither the current subscription nor the defaultSub is on the list, remove it
- onChange(setSubscriptionID(query, ''));
- }
- }
-};
-
-export const useSubscriptions: DataHook = (query, datasource, onChange, setError) => {
- const defaultSubscription = datasource.azureMonitorDatasource.defaultSubscriptionId;
- const { subscription } = query;
-
- const subscriptionOptions = useAsyncState(
- async () => {
- const results = await datasource.azureMonitorDatasource.getSubscriptions();
- const options = formatOptions(results, subscription);
-
- return options;
- },
- setError,
- []
- );
-
- useEffect(() => {
- updateSubscriptions(query, subscriptionOptions, onChange, defaultSubscription);
- }, [subscriptionOptions, query, defaultSubscription, onChange]);
-
- return subscriptionOptions;
-};
-
-export const useResourceGroups: DataHook = (query, datasource, onChange, setError) => {
- const { subscription } = query;
- const { resourceGroup } = query.azureMonitor ?? {};
-
- return useAsyncState(
- async () => {
- if (!subscription) {
- return;
- }
-
- const results = await datasource.getResourceGroups(subscription);
- const options = formatOptions(results, resourceGroup);
-
- return options;
- },
- setError,
- [subscription]
- );
-};
-
-export const useResourceTypes: DataHook = (query, datasource, onChange, setError) => {
- const { subscription } = query;
- const { resourceGroup, metricDefinition } = query.azureMonitor ?? {};
-
- return useAsyncState(
- async () => {
- if (!(subscription && resourceGroup)) {
- return;
- }
-
- const results = await datasource.getMetricDefinitions(subscription, resourceGroup);
- const options = formatOptions(results, metricDefinition);
-
- return options;
- },
- setError,
- [subscription, resourceGroup]
- );
-};
-
-export const useResourceNames: DataHook = (query, datasource, onChange, setError) => {
- const { subscription } = query;
- const { resourceGroup, metricDefinition, resourceName } = query.azureMonitor ?? {};
-
- return useAsyncState(
- async () => {
- if (!(subscription && resourceGroup && metricDefinition)) {
- return;
- }
-
- const results = await datasource.getResourceNames(subscription, resourceGroup, metricDefinition);
- const options = formatOptions(results, resourceName);
-
- return options;
- },
- setError,
- [subscription, resourceGroup, metricDefinition]
- );
-};
+type OnChangeFn = (newQuery: AzureMonitorQuery) => void;
export const useMetricNamespaces: DataHook = (query, datasource, onChange, setError) => {
- const { subscription } = query;
- const { resourceGroup, metricDefinition, resourceName, metricNamespace } = query.azureMonitor ?? {};
+ const { metricNamespace, resourceUri } = query.azureMonitor ?? {};
const metricNamespaces = useAsyncState(
async () => {
- if (!(subscription && resourceGroup && metricDefinition && resourceName)) {
+ if (!resourceUri) {
return;
}
- const results = await datasource.azureMonitorDatasource.getMetricNamespaces({
- subscription,
- resourceGroup,
- metricDefinition,
- resourceName,
- });
+ const results = await datasource.azureMonitorDatasource.getMetricNamespaces({ resourceUri });
const options = formatOptions(results, metricNamespace);
// Do some cleanup of the query state if need be
@@ -179,67 +58,61 @@ export const useMetricNamespaces: DataHook = (query, datasource, onChange, setEr
return options;
},
setError,
- [subscription, resourceGroup, metricDefinition, resourceName]
+ [resourceUri]
);
return metricNamespaces;
};
export const useMetricNames: DataHook = (query, datasource, onChange, setError) => {
- const { subscription } = query;
- const { resourceGroup, metricDefinition, resourceName, metricNamespace, metricName } = query.azureMonitor ?? {};
+ const { metricNamespace, metricName, resourceUri } = query.azureMonitor ?? {};
return useAsyncState(
async () => {
- if (!(subscription && resourceGroup && metricDefinition && resourceName && metricNamespace)) {
+ if (!(metricNamespace && resourceUri)) {
return;
}
- const results = await datasource.azureMonitorDatasource.getMetricNames({
- subscription,
- resourceGroup,
- metricDefinition,
- resourceName,
- metricNamespace,
- });
-
+ const results = await datasource.azureMonitorDatasource.getMetricNames({ resourceUri, metricNamespace });
const options = formatOptions(results, metricName);
return options;
},
setError,
- [subscription, resourceGroup, metricDefinition, resourceName, metricNamespace]
+ [resourceUri, metricNamespace]
);
};
-export const useMetricMetadata = (query: AzureMonitorQuery, datasource: Datasource, onChange: OnChangeFn) => {
- const [metricMetadata, setMetricMetadata] = useState({
- aggOptions: [],
- timeGrains: [],
- dimensions: [],
- isLoading: false,
- supportedAggTypes: [],
- primaryAggType: undefined,
- });
+const defaultMetricMetadata: MetricMetadata = {
+ aggOptions: [],
+ timeGrains: [],
+ dimensions: [],
+ isLoading: false,
+ supportedAggTypes: [],
+ primaryAggType: undefined,
+};
- const { subscription } = query;
- const { resourceGroup, metricDefinition, resourceName, metricNamespace, metricName, aggregation, timeGrain } =
- query.azureMonitor ?? {};
+export const useMetricMetadata = (query: AzureMonitorQuery, datasource: Datasource, onChange: OnChangeFn) => {
+ const [metricMetadata, setMetricMetadata] = useState(defaultMetricMetadata);
+
+ const { resourceUri, metricNamespace, metricName, aggregation, timeGrain } = query.azureMonitor ?? {};
// Fetch new metric metadata when the fields change
useEffect(() => {
- if (!(subscription && resourceGroup && metricDefinition && resourceName && metricNamespace && metricName)) {
+ if (!(resourceUri && metricNamespace && metricName)) {
+ setMetricMetadata(defaultMetricMetadata);
return;
}
datasource.azureMonitorDatasource
- .getMetricMetadata({ subscription, resourceGroup, metricDefinition, resourceName, metricNamespace, metricName })
+ .getMetricMetadata({ resourceUri, metricNamespace, metricName })
.then((metadata) => {
// TODO: Move the aggregationTypes and timeGrain defaults into `getMetricMetadata`
const aggregations = (metadata.supportedAggTypes || [metadata.primaryAggType]).map((v) => ({
label: v,
value: v,
}));
+
setMetricMetadata({
aggOptions: aggregations,
timeGrains: metadata.supportedTimeGrains,
@@ -249,7 +122,7 @@ export const useMetricMetadata = (query: AzureMonitorQuery, datasource: Datasour
primaryAggType: metadata.primaryAggType,
});
});
- }, [datasource, subscription, resourceGroup, metricDefinition, resourceName, metricNamespace, metricName]);
+ }, [datasource, resourceUri, metricNamespace, metricName]);
// Update the query state in response to the meta data changing
useEffect(() => {
@@ -286,7 +159,7 @@ function formatOptions(
const options = rawResults.map(toOption);
// account for custom values that might have been set in json file like ones crafted with a template variable (ex: "cloud-datasource-resource-$Environment")
- if (selectedValue && !options.find((option) => option.value === selectedValue)) {
+ if (selectedValue && !options.find((option) => option.value === selectedValue.toLowerCase())) {
options.push({ label: selectedValue, value: selectedValue });
}
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/index.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/index.tsx
deleted file mode 100644
index d51921c7750..00000000000
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './MetricsQueryEditor';
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/setQueryValue.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/setQueryValue.ts
index 165016e33b9..bcc5d3e8a85 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/setQueryValue.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/setQueryValue.ts
@@ -16,95 +16,6 @@ export function setResource(query: AzureMonitorQuery, resourceURI: string | unde
};
}
-export function setSubscriptionID(query: AzureMonitorQuery, subscriptionID: string): AzureMonitorQuery {
- if (query.subscription === subscriptionID) {
- return query;
- }
-
- return {
- ...query,
- subscription: subscriptionID,
- azureMonitor: {
- ...query.azureMonitor,
- resourceUri: '',
- resourceGroup: undefined,
- metricDefinition: undefined,
- metricNamespace: undefined,
- resourceName: undefined,
- metricName: undefined,
- aggregation: undefined,
- timeGrain: '',
- dimensionFilters: [],
- },
- };
-}
-
-export function setResourceGroup(query: AzureMonitorQuery, resourceGroup: string | undefined): AzureMonitorQuery {
- if (query.azureMonitor?.resourceGroup === resourceGroup) {
- return query;
- }
-
- return {
- ...query,
- azureMonitor: {
- ...query.azureMonitor,
- resourceUri: '',
- resourceGroup: resourceGroup,
- metricDefinition: undefined,
- metricNamespace: undefined,
- resourceName: undefined,
- metricName: undefined,
- aggregation: undefined,
- timeGrain: '',
- dimensionFilters: [],
- },
- };
-}
-
-// In the query as "metricDefinition" for some reason
-export function setResourceType(query: AzureMonitorQuery, resourceType: string | undefined): AzureMonitorQuery {
- if (query.azureMonitor?.metricDefinition === resourceType) {
- return query;
- }
-
- const newQuery = {
- ...query,
- azureMonitor: {
- ...query.azureMonitor,
- resourceUri: '',
- metricDefinition: resourceType,
- resourceName: undefined,
- metricNamespace: undefined,
- metricName: undefined,
- aggregation: undefined,
- timeGrain: '',
- dimensionFilters: [],
- },
- };
-
- return newQuery;
-}
-
-export function setResourceName(query: AzureMonitorQuery, resourceName: string | undefined): AzureMonitorQuery {
- if (query.azureMonitor?.resourceName === resourceName) {
- return query;
- }
-
- return {
- ...query,
- azureMonitor: {
- ...query.azureMonitor,
- resourceUri: '',
- resourceName: resourceName,
- metricNamespace: undefined,
- metricName: undefined,
- aggregation: undefined,
- timeGrain: '',
- dimensionFilters: [],
- },
- };
-}
-
export function setMetricNamespace(query: AzureMonitorQuery, metricNamespace: string | undefined): AzureMonitorQuery {
if (query.azureMonitor?.metricNamespace === metricNamespace) {
return query;
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.test.tsx
deleted file mode 100644
index 0a0f60ebaa2..00000000000
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.test.tsx
+++ /dev/null
@@ -1,260 +0,0 @@
-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';
-
-const variableOptionGroup = {
- label: 'Template variables',
- options: [],
-};
-
-const tests = [
- {
- id: 'azure-monitor-metrics-query-editor-with-resource-picker',
- },
- {
- id: 'azure-monitor-metrics-query-editor-with-experimental-ui',
- },
-];
-
-export function createMockResourcePickerData() {
- const mockDatasource = new ResourcePickerData(createMockInstanceSetttings());
-
- 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({});
-
- return mockDatasource;
-}
-
-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',
- },
- });
- });
- });
-}
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.tsx
deleted file mode 100644
index fce73fa82f4..00000000000
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.tsx
+++ /dev/null
@@ -1,228 +0,0 @@
-import { css } from '@emotion/css';
-import React from 'react';
-
-import { PanelData } from '@grafana/data/src/types';
-import { EditorRows, EditorRow, EditorFieldGroup } from '@grafana/experimental';
-import { config } from '@grafana/runtime';
-import { InlineFieldRow, useStyles2 } from '@grafana/ui';
-
-import type Datasource from '../../datasource';
-import type { AzureMonitorQuery, AzureMonitorOption, AzureMonitorErrorish } from '../../types';
-import AggregationField from '../MetricsQueryEditor/AggregationField';
-import DimensionFields from '../MetricsQueryEditor/DimensionFields';
-import LegendFormatField from '../MetricsQueryEditor/LegendFormatField';
-import MetricNameField from '../MetricsQueryEditor/MetricNameField';
-import MetricNamespaceField from '../MetricsQueryEditor/MetricNamespaceField';
-import NewDimensionFields from '../MetricsQueryEditor/NewDimensionFields';
-import TimeGrainField from '../MetricsQueryEditor/TimeGrainField';
-import TopField from '../MetricsQueryEditor/TopField';
-import { setResource } from '../MetricsQueryEditor/setQueryValue';
-import ResourceField from '../ResourceField';
-import { ResourceRowType } from '../ResourcePicker/types';
-
-import { useMetricNames, useMetricNamespaces, useMetricMetadata } from './dataHooks';
-
-interface MetricsQueryEditorProps {
- data: PanelData | undefined;
- query: AzureMonitorQuery;
- datasource: Datasource;
- onChange: (newQuery: AzureMonitorQuery) => void;
- variableOptionGroup: { label: string; options: AzureMonitorOption[] };
- setError: (source: string, error: AzureMonitorErrorish | undefined) => void;
-}
-
-const MetricsQueryEditor: React.FC = ({
- data,
- query,
- datasource,
- variableOptionGroup,
- onChange,
- setError,
-}) => {
- const styles = useStyles2(getStyles);
-
- const metricsMetadata = useMetricMetadata(query, datasource, onChange);
- const metricNamespaces = useMetricNamespaces(query, datasource, onChange, setError);
- const metricNames = useMetricNames(query, datasource, onChange, setError);
- if (config.featureToggles.azureMonitorExperimentalUI) {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- } else {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-};
-
-const getStyles = () => ({
- row: css({
- rowGap: 0,
- }),
-});
-
-export default MetricsQueryEditor;
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.test.ts
deleted file mode 100644
index d7d85ed01fa..00000000000
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.test.ts
+++ /dev/null
@@ -1,235 +0,0 @@
-import { renderHook } from '@testing-library/react-hooks';
-
-import createMockDatasource from '../../__mocks__/datasource';
-import Datasource from '../../datasource';
-import { AzureMetricQuery, AzureMonitorOption, AzureMonitorQuery, AzureQueryType } from '../../types';
-import { DataHook, MetricMetadata, MetricsMetadataHook } from '../MetricsQueryEditor/dataHooks';
-
-import { useMetricNames, useMetricNamespaces, useMetricMetadata } from './dataHooks';
-
-const WAIT_OPTIONS = {
- timeout: 1000,
-};
-
-const opt = (text: string, value: string) => ({ text, value });
-
-interface TestScenario {
- name: string;
- hook: DataHook | MetricsMetadataHook;
-
- // For convenience, only need to define the azureMonitor part of the query for some tests
- emptyQueryPartial: AzureMetricQuery;
- customProperties: AzureMetricQuery;
- topLevelCustomProperties?: Partial;
-
- expectedCustomPropertyResults?: Array>;
- expectedOptions: AzureMonitorOption[] | MetricMetadata;
-}
-
-describe('AzureMonitor: metrics dataHooks', () => {
- const bareQuery = {
- refId: 'A',
- queryType: AzureQueryType.AzureMonitor,
- subscription: 'sub-abc-123',
- };
-
- const testTable: TestScenario[] = [
- {
- name: 'useMetricNames',
- hook: useMetricNames,
- emptyQueryPartial: {
- resourceUri:
- '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
- metricNamespace: 'azure/vm',
- },
- customProperties: {
- resourceUri:
- '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
- metricNamespace: 'azure/vm',
- metricName: 'metric-$ENVIRONMENT',
- },
- expectedOptions: [
- {
- label: 'Percentage CPU',
- value: 'percentage-cpu',
- },
- {
- label: 'Free memory',
- value: 'free-memory',
- },
- ],
- expectedCustomPropertyResults: [
- { label: 'Percentage CPU', value: 'percentage-cpu' },
- { label: 'Free memory', value: 'free-memory' },
- { label: 'metric-$ENVIRONMENT', value: 'metric-$ENVIRONMENT' },
- ],
- },
- {
- name: 'useMetricNamespaces',
- hook: useMetricNamespaces,
- emptyQueryPartial: {
- resourceUri:
- '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
- metricNamespace: 'azure/vm',
- },
- customProperties: {
- resourceUri:
- '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
- metricNamespace: 'azure/vm-$ENVIRONMENT',
- metricName: 'metric-name',
- },
- expectedOptions: [
- {
- label: 'Compute Virtual Machine',
- value: 'azure/vmc',
- },
- {
- label: 'Database NS',
- value: 'azure/dbns',
- },
- {
- label: 'azure/vm',
- value: 'azure/vm',
- },
- ],
- expectedCustomPropertyResults: [
- { label: 'Compute Virtual Machine', value: 'azure/vmc' },
- { label: 'Database NS', value: 'azure/dbns' },
- { label: 'azure/vm-$ENVIRONMENT', value: 'azure/vm-$ENVIRONMENT' },
- ],
- },
- ];
-
- let datasource: Datasource;
- let onChange: jest.Mock;
- let setError: jest.Mock;
-
- beforeEach(() => {
- onChange = jest.fn();
- setError = jest.fn();
-
- datasource = createMockDatasource();
- datasource.getVariables = jest.fn().mockReturnValue(['$sub', '$rg', '$rt', '$variable']);
-
- datasource.azureMonitorDatasource.getSubscriptions = jest
- .fn()
- .mockResolvedValue([opt('sub-abc-123', 'sub-abc-123')]);
-
- datasource.getResourceGroups = jest
- .fn()
- .mockResolvedValue([
- opt('Web App - Production', 'web-app-production'),
- opt('Web App - Development', 'web-app-development'),
- ]);
-
- datasource.getMetricDefinitions = jest
- .fn()
- .mockResolvedValue([opt('Virtual Machine', 'azure/vm'), opt('Database', 'azure/db')]);
-
- datasource.getResourceNames = jest
- .fn()
- .mockResolvedValue([opt('Web server', 'web-server'), opt('Job server', 'job-server')]);
-
- datasource.azureMonitorDatasource.getMetricNames = jest
- .fn()
- .mockResolvedValue([opt('Percentage CPU', 'percentage-cpu'), opt('Free memory', 'free-memory')]);
-
- datasource.azureMonitorDatasource.getMetricNamespaces = jest
- .fn()
- .mockResolvedValue([opt('Compute Virtual Machine', 'azure/vmc'), opt('Database NS', 'azure/dbns')]);
-
- datasource.azureMonitorDatasource.getMetricMetadata = jest.fn().mockResolvedValue({
- primaryAggType: 'Average',
- supportedAggTypes: ['Average'],
- supportedTimeGrains: [
- { label: 'Auto', value: 'auto' },
- { label: '1 minute', value: 'PT1M' },
- { label: '5 minutes', value: 'PT5M' },
- { label: '15 minutes', value: 'PT15M' },
- { label: '30 minutes', value: 'PT30M' },
- { label: '1 hour', value: 'PT1H' },
- { label: '6 hours', value: 'PT6H' },
- { label: '12 hours', value: 'PT12H' },
- { label: '1 day', value: 'P1D' },
- ],
- dimensions: [],
- });
- });
-
- describe.each(testTable)('scenario %#: $name', (scenario) => {
- it('returns values', async () => {
- const query = {
- ...bareQuery,
- azureMonitor: scenario.emptyQueryPartial,
- };
- const { result, waitForNextUpdate } = renderHook(() => scenario.hook(query, datasource, onChange, setError));
- await waitForNextUpdate(WAIT_OPTIONS);
-
- expect(result.current).toEqual(scenario.expectedOptions);
- });
-
- it('adds custom properties as a valid option', async () => {
- const query = {
- ...bareQuery,
- azureMonitor: scenario.customProperties,
- ...scenario.topLevelCustomProperties,
- };
- const { result, waitForNextUpdate } = renderHook(() => scenario.hook(query, datasource, onChange, setError));
- await waitForNextUpdate(WAIT_OPTIONS);
-
- expect(result.current).toEqual(scenario.expectedCustomPropertyResults);
- });
- });
-
- describe('useMetricsMetadataHook', () => {
- const metricsMetadataConfig = {
- name: 'useMetricMetadata',
- hook: useMetricMetadata,
- emptyQueryPartial: {
- resourceUri:
- '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
- metricNamespace: 'azure/vm',
- metricName: 'Average CPU',
- },
- customProperties: {},
- expectedOptions: {
- aggOptions: [{ label: 'Average', value: 'Average' }],
- timeGrains: [
- { label: 'Auto', value: 'auto' },
- { label: '1 minute', value: 'PT1M' },
- { label: '5 minutes', value: 'PT5M' },
- { label: '15 minutes', value: 'PT15M' },
- { label: '30 minutes', value: 'PT30M' },
- { label: '1 hour', value: 'PT1H' },
- { label: '6 hours', value: 'PT6H' },
- { label: '12 hours', value: 'PT12H' },
- { label: '1 day', value: 'P1D' },
- ],
- dimensions: [],
- isLoading: false,
- supportedAggTypes: ['Average'],
- primaryAggType: 'Average',
- },
- };
-
- it('returns values', async () => {
- const query = {
- ...bareQuery,
- azureMonitor: metricsMetadataConfig.emptyQueryPartial,
- };
- const { result, waitForNextUpdate } = renderHook(() => metricsMetadataConfig.hook(query, datasource, onChange));
- await waitForNextUpdate(WAIT_OPTIONS);
-
- expect(result.current).toEqual(metricsMetadataConfig.expectedOptions);
- expect(onChange).toHaveBeenCalledWith({
- ...query,
- azureMonitor: {
- ...query.azureMonitor,
- aggregation: result.current.primaryAggType,
- timeGrain: 'auto',
- allowedTimeGrainsMs: [60_000, 300_000, 900_000, 1_800_000, 3_600_000, 21_600_000, 43_200_000, 86_400_000],
- },
- });
- });
- });
-});
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.ts
deleted file mode 100644
index 9743ad93de3..00000000000
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { useEffect, useState } from 'react';
-
-import { rangeUtil } from '@grafana/data';
-
-import Datasource from '../../datasource';
-import TimegrainConverter from '../../time_grain_converter';
-import { AzureMonitorOption, AzureMonitorQuery } from '../../types';
-import { toOption } from '../../utils/common';
-import { useAsyncState } from '../../utils/useAsyncState';
-import { DataHook } from '../MetricsQueryEditor/dataHooks';
-import { setMetricNamespace } from '../MetricsQueryEditor/setQueryValue';
-
-export interface MetricMetadata {
- aggOptions: AzureMonitorOption[];
- timeGrains: AzureMonitorOption[];
- dimensions: AzureMonitorOption[];
- isLoading: boolean;
-
- // These two properties are only used within the hook, and not elsewhere
- supportedAggTypes: string[];
- primaryAggType: string | undefined;
-}
-
-type OnChangeFn = (newQuery: AzureMonitorQuery) => void;
-
-export const useMetricNamespaces: DataHook = (query, datasource, onChange, setError) => {
- const { metricNamespace, resourceUri } = query.azureMonitor ?? {};
-
- const metricNamespaces = useAsyncState(
- async () => {
- if (!resourceUri) {
- return;
- }
-
- const results = await datasource.azureMonitorDatasource.getMetricNamespaces({ resourceUri });
- const options = formatOptions(results, metricNamespace);
-
- // Do some cleanup of the query state if need be
- if (!metricNamespace && options.length) {
- onChange(setMetricNamespace(query, options[0].value));
- }
-
- return options;
- },
- setError,
- [resourceUri]
- );
-
- return metricNamespaces;
-};
-
-export const useMetricNames: DataHook = (query, datasource, onChange, setError) => {
- const { metricNamespace, metricName, resourceUri } = query.azureMonitor ?? {};
-
- return useAsyncState(
- async () => {
- if (!(metricNamespace && resourceUri)) {
- return;
- }
-
- const results = await datasource.azureMonitorDatasource.getMetricNames({ resourceUri, metricNamespace });
- const options = formatOptions(results, metricName);
-
- return options;
- },
- setError,
- [resourceUri, metricNamespace]
- );
-};
-
-const defaultMetricMetadata: MetricMetadata = {
- aggOptions: [],
- timeGrains: [],
- dimensions: [],
- isLoading: false,
- supportedAggTypes: [],
- primaryAggType: undefined,
-};
-
-export const useMetricMetadata = (query: AzureMonitorQuery, datasource: Datasource, onChange: OnChangeFn) => {
- const [metricMetadata, setMetricMetadata] = useState(defaultMetricMetadata);
-
- const { resourceUri, metricNamespace, metricName, aggregation, timeGrain } = query.azureMonitor ?? {};
-
- // Fetch new metric metadata when the fields change
- useEffect(() => {
- if (!(resourceUri && metricNamespace && metricName)) {
- setMetricMetadata(defaultMetricMetadata);
- return;
- }
-
- datasource.azureMonitorDatasource
- .getMetricMetadata({ resourceUri, metricNamespace, metricName })
- .then((metadata) => {
- // TODO: Move the aggregationTypes and timeGrain defaults into `getMetricMetadata`
- const aggregations = (metadata.supportedAggTypes || [metadata.primaryAggType]).map((v) => ({
- label: v,
- value: v,
- }));
-
- setMetricMetadata({
- aggOptions: aggregations,
- timeGrains: metadata.supportedTimeGrains,
- dimensions: metadata.dimensions,
- isLoading: false,
- supportedAggTypes: metadata.supportedAggTypes ?? [],
- primaryAggType: metadata.primaryAggType,
- });
- });
- }, [datasource, resourceUri, metricNamespace, metricName]);
-
- // Update the query state in response to the meta data changing
- useEffect(() => {
- const newAggregation = aggregation || metricMetadata.primaryAggType;
- const newTimeGrain = timeGrain || 'auto';
-
- if (newAggregation !== aggregation || newTimeGrain !== timeGrain) {
- onChange({
- ...query,
- azureMonitor: {
- ...query.azureMonitor,
- aggregation: newAggregation,
- timeGrain: newTimeGrain,
- allowedTimeGrainsMs: metricMetadata.timeGrains
- .filter((timeGrain) => timeGrain.value !== 'auto')
- .map((timeGrain) =>
- rangeUtil.intervalToMs(TimegrainConverter.createKbnUnitFromISO8601Duration(timeGrain.value))
- ),
- },
- });
- }
- }, [onChange, metricMetadata, aggregation, timeGrain, query]);
-
- return metricMetadata;
-};
-
-function formatOptions(
- rawResults: Array<{
- text: string;
- value: string;
- }>,
- selectedValue?: string
-) {
- const options = rawResults.map(toOption);
-
- // account for custom values that might have been set in json file like ones crafted with a template variable (ex: "cloud-datasource-resource-$Environment")
- if (selectedValue && !options.find((option) => option.value === selectedValue.toLowerCase())) {
- options.push({ label: selectedValue, value: selectedValue });
- }
-
- return options;
-}
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx
index 479bedf30da..a85e2c7320b 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx
@@ -16,7 +16,7 @@ import {
import useLastError from '../../utils/useLastError';
import ArgQueryEditor from '../ArgQueryEditor';
import LogsQueryEditor from '../LogsQueryEditor';
-import NewMetricsQueryEditor from '../NewMetricsQueryEditor/MetricsQueryEditor';
+import NewMetricsQueryEditor from '../MetricsQueryEditor/MetricsQueryEditor';
import { QueryHeader } from '../QueryHeader';
import { Space } from '../Space';
@@ -157,8 +157,6 @@ const EditorForQueryType: React.FC = ({
);
}
-
- return null;
};
export default QueryEditor;
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourceField/ResourceField.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourceField/ResourceField.tsx
index 20e5bbc01aa..6f3b71aa7b5 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourceField/ResourceField.tsx
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourceField/ResourceField.tsx
@@ -29,6 +29,8 @@ interface ResourceFieldProps extends AzureQueryEditorFieldProps {
setResource: (query: AzureMonitorQuery, resourceURI?: string) => 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}
/>
-
-
+
diff --git a/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx
index cead9587a9c..471b4ec5cd5 100644
--- a/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx
+++ b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx
@@ -2,6 +2,7 @@ import { shuffle } from 'lodash';
import React, { PureComponent } from 'react';
import { QueryEditorHelpProps } from '@grafana/data';
+import { reportInteraction } from '@grafana/runtime';
import LokiLanguageProvider from '../language_provider';
import { LokiQuery } from '../types';
@@ -43,6 +44,7 @@ export default class LokiCheatSheet extends PureComponent {
+ onClickExample(query);
+ reportInteraction('grafana_loki_cheatsheet_example_clicked', {});
+ };
return (
- onClickExample({ refId: 'A', expr })}>
+
onClick({ refId: 'A', expr })}>
{expr}
);
diff --git a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx
index 06f745f7482..ae2b3808da8 100644
--- a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx
+++ b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx
@@ -14,6 +14,12 @@ import {
BrowserProps,
} from './LokiLabelBrowser';
+// we have to mock out reportInteraction, otherwise it crashes the test.
+jest.mock('@grafana/runtime', () => ({
+ ...jest.requireActual('@grafana/runtime'),
+ reportInteraction: () => null,
+}));
+
describe('buildSelector()', () => {
it('returns an empty selector for no labels', () => {
expect(buildSelector([])).toEqual('{}');
diff --git a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx
index 0357e78cc87..1fc5698d4f8 100644
--- a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx
+++ b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx
@@ -3,7 +3,8 @@ import { sortBy } from 'lodash';
import React, { ChangeEvent } from 'react';
import { FixedSizeList } from 'react-window';
-import { GrafanaTheme2 } from '@grafana/data';
+import { CoreApp, GrafanaTheme2 } from '@grafana/data';
+import { reportInteraction } from '@grafana/runtime';
import {
Button,
HighlightPart,
@@ -31,6 +32,7 @@ export interface BrowserProps {
languageProvider: LokiLanguageProvider | PromQlLanguageProvider;
onChange: (selector: string) => void;
theme: GrafanaTheme2;
+ app?: CoreApp;
autoSelect?: number;
hide?: () => void;
lastUsedLabels: string[];
@@ -189,17 +191,29 @@ export class UnthemedLokiLabelBrowser extends React.Component
{
+ reportInteraction('grafana_loki_log_browser_closed', {
+ app: this.props.app,
+ closeType: 'showLogsButton',
+ });
const selector = buildSelector(this.state.labels);
this.props.onChange(selector);
};
onClickRunMetricsQuery = () => {
+ reportInteraction('grafana_loki_log_browser_closed', {
+ app: this.props.app,
+ closeType: 'showLogsRateButton',
+ });
const selector = buildSelector(this.state.labels);
const query = `rate(${selector}[$__interval])`;
this.props.onChange(query);
};
onClickClear = () => {
+ reportInteraction('grafana_loki_log_browser_closed', {
+ app: this.props.app,
+ closeType: 'clearButton',
+ });
this.setState((state) => {
const labels: SelectableLabel[] = state.labels.map((label) => ({
...label,
diff --git a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx
index c72286df18b..5dc121a52bc 100644
--- a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx
+++ b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx
@@ -3,6 +3,7 @@ import React, { ReactNode } from 'react';
import { Plugin, Node } from 'slate';
import { QueryEditorProps } from '@grafana/data';
+import { reportInteraction } from '@grafana/runtime';
import {
SlatePrism,
TypeaheadOutput,
@@ -145,6 +146,16 @@ export class LokiQueryField extends React.PureComponent {
+ if (!this.state.labelBrowserVisible) {
+ reportInteraction('grafana_loki_log_browser_opened', {
+ app: this.props.app,
+ });
+ } else {
+ reportInteraction('grafana_loki_log_browser_closed', {
+ app: this.props.app,
+ closeType: 'logBrowserButton',
+ });
+ }
this.setState((state) => ({ labelBrowserVisible: !state.labelBrowserVisible }));
};
@@ -170,6 +181,7 @@ export class LokiQueryField extends React.PureComponent
)}
diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx
index 6a6136ec75f..456c541846d 100644
--- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx
+++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx
@@ -1,7 +1,8 @@
import React from 'react';
-import { SelectableValue } from '@grafana/data';
+import { CoreApp, SelectableValue } from '@grafana/data';
import { EditorRow, EditorField } from '@grafana/experimental';
+import { reportInteraction } from '@grafana/runtime';
import { RadioButtonGroup, Select, AutoSizeInput } from '@grafana/ui';
import { QueryOptionGroup } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup';
@@ -13,15 +14,20 @@ export interface Props {
query: LokiQuery;
onChange: (update: LokiQuery) => void;
onRunQuery: () => void;
+ app?: CoreApp;
}
-export const LokiQueryBuilderOptions = React.memo(({ query, onChange, onRunQuery }) => {
+export const LokiQueryBuilderOptions = React.memo(({ app, query, onChange, onRunQuery }) => {
const onQueryTypeChange = (value: LokiQueryType) => {
onChange({ ...query, queryType: value });
onRunQuery();
};
const onResolutionChange = (option: SelectableValue) => {
+ reportInteraction('grafana_loki_resolution_clicked', {
+ app,
+ resolution: option.value,
+ });
onChange({ ...query, resolution: option.value });
onRunQuery();
};
diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx
index 2ea26ae1f60..9566144ddb8 100644
--- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx
+++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx
@@ -40,6 +40,7 @@ export function LokiQueryCodeEditor({
history={[]}
data={data}
data-testid={testIds.editor}
+ app={app}
/>
);
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 && (
-
+
)}
>
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,
diff --git a/public/app/plugins/panel/geomap/GeomapPanel.tsx b/public/app/plugins/panel/geomap/GeomapPanel.tsx
index 76f3d6d9e0d..23d58e9702f 100644
--- a/public/app/plugins/panel/geomap/GeomapPanel.tsx
+++ b/public/app/plugins/panel/geomap/GeomapPanel.tsx
@@ -21,7 +21,9 @@ import {
DataHoverClearEvent,
DataHoverEvent,
FrameGeometrySourceMode,
+ getFrameMatchers,
GrafanaTheme,
+ MapLayerHandler,
MapLayerOptions,
PanelData,
PanelProps,
@@ -251,9 +253,7 @@ export class GeomapPanel extends Component {
*/
dataChanged(data: PanelData) {
for (const state of this.layers) {
- if (state.handler.update) {
- state.handler.update(data);
- }
+ this.applyLayerFilter(state.handler, state.options);
}
}
@@ -468,9 +468,7 @@ export class GeomapPanel extends Component {
group.setAt(layerIndex, info.layer);
// initialize with new data
- if (info.handler.update) {
- info.handler.update(this.props.data);
- }
+ this.applyLayerFilter(info.handler, newOptions);
} catch (err) {
console.warn('ERROR', err);
return false;
@@ -506,10 +504,6 @@ export class GeomapPanel extends Component {
const handler = await item.create(map, options, config.theme2);
const layer = handler.init();
- if (handler.update) {
- handler.update(this.props.data);
- }
-
if (!options.name) {
options.name = this.getNextLayerName();
}
@@ -533,9 +527,26 @@ export class GeomapPanel extends Component {
this.byName.set(UID, state);
(state.layer as any).__state = state;
+
+ this.applyLayerFilter(handler, options);
+
return state;
}
+ applyLayerFilter(handler: MapLayerHandler, options: MapLayerOptions): void {
+ if (handler.update) {
+ let panelData = this.props.data;
+ if (options.filterData) {
+ const matcherFunc = getFrameMatchers(options.filterData);
+ panelData = {
+ ...panelData,
+ series: panelData.series.filter(matcherFunc),
+ };
+ }
+ handler.update(panelData);
+ }
+ }
+
initMapView(config: MapViewConfig, layers?: Collection): View {
let view = new View({
center: [0, 0],
diff --git a/public/app/plugins/panel/geomap/editor/layerEditor.tsx b/public/app/plugins/panel/geomap/editor/layerEditor.tsx
index 3ddeb4f0bd3..755e3390ba3 100644
--- a/public/app/plugins/panel/geomap/editor/layerEditor.tsx
+++ b/public/app/plugins/panel/geomap/editor/layerEditor.tsx
@@ -6,6 +6,7 @@ import { hasAlphaPanels } from 'app/core/config';
import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
import { addLocationFields } from 'app/features/geo/editor/locationEditor';
+import { FrameSelectionEditor } from '../layers/data/FrameSelectionEditor';
import { defaultMarkersConfig } from '../layers/data/markersLayer';
import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry } from '../layers/registry';
import { MapLayerState } from '../types';
@@ -76,6 +77,17 @@ export function getLayerEditor(opts: LayerEditorOptions): NestedPanelOptions {
+ if (!previousValue) {
+ return;
+ }
+ // Previously selected value is missing from the new list.
+ // Find the value that is in the new list but isn't in the old list
+ let changedTo = newRefIds.find((refId) => {
+ return !oldRefIds.some((refId2) => {
+ return refId === refId2;
+ });
+ });
+ if (changedTo) {
+ // Found the new value, we assume the old value changed to this one, so we'll use it
+ return changedTo;
+ }
+ return;
+};
+
+export const FrameSelectionEditor: FC> = ({
+ value,
+ context,
+ onChange,
+ item,
+}) => {
+ const listOfRefId = useMemo(() => {
+ return context.data.map(f => ({
+ value: f.refId,
+ label: `Query: ${f.refId} (size: ${f.length})`,
+ description: f.fields.map(f => getFieldDisplayName(f)).join(', '),
+ }));
+ }, [context.data]);
+
+ const [priorSelectionState, updatePriorSelectionState] = useState({
+ refIds: [] as SelectableValue[],
+ value: undefined as string | undefined,
+ });
+
+ const currentValue = useMemo(() => {
+ return listOfRefId.find((refId) => refId.value === value?.options) ?? recoverRefIdMissing(listOfRefId, priorSelectionState.refIds, priorSelectionState.value);
+ }, [value, listOfRefId])
+
+ const onFilterChange = useCallback((v: SelectableValue) => {
+ onChange(v?.value ? {
+ "id": FrameMatcherID.byRefId,
+ "options": v.value
+ } : undefined);
+ }, [context.options.name]);
+
+ if (listOfRefId !== priorSelectionState.refIds || currentValue?.value !== priorSelectionState.value) {
+ updatePriorSelectionState({
+ refIds: listOfRefId,
+ value: currentValue?.value
+ });
+ }
+ return (
+
+ );
+};
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;
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,
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)
diff --git a/yarn.lock b/yarn.lock
index e09ebc723a6..b3d2063d683 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4678,12 +4678,13 @@ __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/dialog": 3.1.9
- "@react-aria/focus": 3.5.5
+ "@react-aria/button": 3.5.0
+ "@react-aria/dialog": 3.2.0
+ "@react-aria/focus": 3.6.0
"@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
@@ -6585,14 +6586,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
@@ -7033,10 +7034,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
@@ -7294,38 +7295,53 @@ __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
-"@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.5.5, @react-aria/focus@npm:^3.5.5":
+"@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:
+ "@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:
@@ -7357,7 +7373,20 @@ __metadata:
languageName: node
linkType: hard
-"@react-aria/interactions@npm:3.8.4, @react-aria/interactions@npm:^3.8.4":
+"@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:
+ "@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/interactions@npm:^3.8.4":
version: 3.8.4
resolution: "@react-aria/interactions@npm:3.8.4"
dependencies:
@@ -7443,6 +7472,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"
@@ -7458,6 +7498,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"
@@ -7512,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"
@@ -7526,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
@@ -7566,6 +7634,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"
@@ -7577,26 +7656,37 @@ __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/dialog@npm:^3.3.5":
- version: 3.3.5
- resolution: "@react-types/dialog@npm:3.3.5"
+"@react-types/checkbox@npm:^3.3.0":
+ version: 3.3.0
+ resolution: "@react-types/checkbox@npm:3.3.0"
dependencies:
- "@react-types/overlays": ^3.5.5
- "@react-types/shared": ^3.12.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: 9fb34839fca2c2e46b3b68ca492eee8a9db76b532eb363717529cb28970b72880b54f80eacbbf1a5607b6a0749b1b1fa68fe010a6c0a381fe58ee50ffb7ce276
+ languageName: node
+ linkType: hard
+
+"@react-types/dialog@npm:^3.4.0":
+ version: 3.4.0
+ resolution: "@react-types/dialog@npm:3.4.0"
+ dependencies:
+ "@react-types/overlays": ^3.6.0
+ "@react-types/shared": ^3.13.0
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
+ checksum: 88d6ea76b345474e17d20e61d716151a254bcce7df5c0e86d23219db0dc4e970cc3a3c29488910bf48ec907282bbb67de99256082017aa97971dffe33f0b8ff4
languageName: node
linkType: hard
@@ -7623,6 +7713,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"
@@ -7632,6 +7733,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"
@@ -7652,6 +7762,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"
@@ -20615,13 +20736,13 @@ __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
- "@react-aria/dialog": 3.1.9
- "@react-aria/focus": 3.5.5
- "@react-aria/interactions": 3.8.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
"@react-aria/menu": 3.4.4
"@react-aria/overlays": 3.8.2
"@react-aria/utils": 3.12.0