diff --git a/.betterer.results b/.betterer.results index 1d488edfe10..416142425ce 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1296,16 +1296,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/core/components/help/HelpModal.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"] - ], "public/app/core/navigation/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/.eslintrc b/.eslintrc index 0ba27fd484e..e8cfd522609 100644 --- a/.eslintrc +++ b/.eslintrc @@ -113,7 +113,9 @@ "public/app/plugins/datasource/tempo/*.{ts,tsx}", "public/app/plugins/datasource/tempo/**/*.{ts,tsx}", "public/app/plugins/datasource/loki/*.{ts,tsx}", - "public/app/plugins/datasource/loki/**/*.{ts,tsx}" + "public/app/plugins/datasource/loki/**/*.{ts,tsx}", + "public/app/plugins/datasource/elasticsearch/*.{ts,tsx}", + "public/app/plugins/datasource/elasticsearch/**/*.{ts,tsx}" ], "settings": { "import/resolver": { diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index dd8e6988ebd..39fcdff146c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -142,7 +142,6 @@ /pkg/tests/apis/ @grafana/grafana-app-platform-squad /pkg/tests/api/correlations/ @grafana/explore-squad /pkg/tsdb/grafanads/ @grafana/backend-platform -/pkg/tsdb/intervalv2/ @grafana/backend-platform /pkg/tsdb/legacydata/ @grafana/backend-platform /pkg/tsdb/opentsdb/ @grafana/backend-platform /pkg/tsdb/sqleng/ @grafana/partner-datasources @grafana/oss-big-tent diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 065b17028d8..7ebf4542543 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -6,6 +6,7 @@ "ignoreDeps": [ "history", // we should bump this together with react-router-dom (see https://github.com/grafana/grafana/issues/76744) "react-router-dom", // we should bump this together with history (see https://github.com/grafana/grafana/issues/76744) + "loader-utils", // v3 requires upstream changes in ngtemplate-loader. ignore, and remove when we remove angular. "monaco-editor", // due to us exposing this via @grafana/ui/CodeEditor's props bumping can break plugins "@fingerprintjs/fingerprintjs", // we don't want to bump to v4 due to licensing changes ], diff --git a/.github/workflows/core-plugins-build-and-release.yml b/.github/workflows/core-plugins-build-and-release.yml index a385637e9b8..b8659a726f0 100644 --- a/.github/workflows/core-plugins-build-and-release.yml +++ b/.github/workflows/core-plugins-build-and-release.yml @@ -9,9 +9,9 @@ on: type: choice options: - grafana-azure-monitor-datasource - - grafana-cloud-monitoring-datasource - grafana-testdata-datasource - parca + - stackdriver - tempo concurrency: diff --git a/.prettierignore b/.prettierignore index 9f95507f7b3..524e01209cd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,6 +12,7 @@ node_modules pkg public/lib/monaco public/sass/*.generated.scss +scripts/cli/bettererIssueTemplate.md scripts/grafana-server/tmp vendor @@ -37,4 +38,5 @@ kinds/report.json # Generated schema docs docs/sources/developers/kinds/ -scripts/cli/bettererIssueTemplate.md +# Crowdin files +public/locales/**/*.json diff --git a/conf/defaults.ini b/conf/defaults.ini index 1ff2d4612ee..8bcb30a11e5 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -840,6 +840,7 @@ key_file = key_id = role_attribute_path = role_attribute_strict = false +groups_attribute_path = auto_sign_up = false url_login = false allow_assign_grafana_admin = false @@ -1202,6 +1203,10 @@ max_state_save_concurrency = 1 # The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m. state_periodic_save_interval = 5m +# Disables the smoothing of alert evaluations across their evaluation window. +# Rules will evaluate in sync. +disable_jitter = false + [unified_alerting.screenshots] # Enable screenshots in notifications. You must have either installed the Grafana image rendering # plugin, or set up Grafana to use a remote rendering service. diff --git a/conf/sample.ini b/conf/sample.ini index 66631fd8c6a..e938b40ff58 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -774,6 +774,7 @@ # Use in conjunction with key_file in case the JWT token's header specifies a key ID in "kid" field ;key_id = some-key-id ;role_attribute_path = +;groups_attribute_path = ;role_attribute_strict = false ;auto_sign_up = false ;url_login = false @@ -1129,6 +1130,10 @@ # The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m. ;state_periodic_save_interval = 5m +# Disables the smoothing of alert evaluations across their evaluation window. +# Rules will evaluate in sync. +;disable_jitter = false + [unified_alerting.reserved_labels] # Comma-separated list of reserved labels added by the Grafana Alerting engine that should be disabled. # For example: `disabled_labels=grafana_folder` @@ -1639,4 +1644,3 @@ [public_dashboards] # Set to false to disable public dashboards ;enabled = true - diff --git a/contribute/backend/style-guide.md b/contribute/backend/style-guide.md index 39fb84c3cdb..4d50bf95b2e 100644 --- a/contribute/backend/style-guide.md +++ b/contribute/backend/style-guide.md @@ -29,13 +29,40 @@ We value clean and readable code, that is loosely coupled and covered by unit te Tests must use the standard library, `testing`. For assertions, prefer using [testify](https://github.com/stretchr/testify). +### Test Suite and Database Tests + +We have a [testsuite](https://github.com/grafana/grafana/tree/main/pkg/tests/testsuite) package which provides utilities for package-level setup and teardown. + +Currently this is just used to ensure that test databases are correctly set up and torn down, but it also provides a place we can attach future tasks. + +Each package SHOULD include a [TestMain](https://pkg.go.dev/testing#hdr-Main) function that calls `testsuite.Run(m)`: + +```go +package mypkg + +import ( + "testing" + + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} +``` + +You only need to define `TestMain` in one `_test.go` file within each package. + +> Warning +> For tests that use the database, you MUST define `TestMain` so that the test databases can be cleaned up properly. + ### Integration Tests We run unit and integration tests separately, to help keep our CI pipeline running smoothly and provide a better developer experience. To properly mark a test as being an integration test, you must format your test function definition as follows, with the function name starting with `TestIntegration` and the check for `testing.Short()`: -``` +```go func TestIntegrationFoo(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/contribute/developer-guide.md b/contribute/developer-guide.md index c07a242caff..52923577d1a 100644 --- a/contribute/developer-guide.md +++ b/contribute/developer-guide.md @@ -9,6 +9,7 @@ Make sure you have the following dependencies installed before setting up your d - [Git](https://git-scm.com/) - [Go](https://golang.org/dl/) (see [go.mod](../go.mod#L3) for minimum required version) - [Node.js (Long Term Support)](https://nodejs.org), with [corepack enabled](https://nodejs.org/api/corepack.html#enabling-the-feature) +- GCC (required for Cgo dependencies) ### macOS diff --git a/devenv/docker/blocks/auth/jwt_proxy/readme.md b/devenv/docker/blocks/auth/jwt_proxy/readme.md index f3e32d147c4..823287f54e3 100644 --- a/devenv/docker/blocks/auth/jwt_proxy/readme.md +++ b/devenv/docker/blocks/auth/jwt_proxy/readme.md @@ -24,6 +24,7 @@ expect_claims = {"iss": "http://env.grafana.local:8087/realms/grafana", "azp": " auto_sign_up = true role_attribute_path = contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin' || contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer' role_attribute_strict = false +groups_attribute_path = groups[] allow_assign_grafana_admin = true ``` diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md index 8161b297f99..0957733aeb1 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md @@ -28,7 +28,7 @@ The following tables list permissions associated with basic and fixed roles. | 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`
`fixed:datasources.caching:reader`
`fixed:datasources.caching:writer`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader`
`fixed:plugins:maintainer`
`fixed:authentication.config:writer` | Default [Grafana server administrator]({{< relref "../../#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:dashboards.public:writer`
`fixed:folders:reader`
`fixed:folders:writer`
`fixed:folders.permissions:reader`
`fixed:folders.permissions:writer`
`fixed:alerting:writer`
`fixed:apikeys:reader`
`fixed:apikeys:writer`
`fixed:alerting.provisioning.secrets:reader`
`fixed:alerting.provisioning:writer`
`fixed:datasources.caching:reader`
`fixed:datasources.caching:writer`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader`
`fixed:plugins:writer` | Default [Grafana organization administrator]({{< relref "../#basic-roles" >}}) 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`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader` | Default [Editor]({{< relref "../#basic-roles" >}}) assignments. | -| Viewer | `fixed:datasources:id:reader`
`fixed:organization:reader`
`fixed:annotations:reader`
`fixed:annotations.dashboard:writer`
`fixed:alerting:reader`
`fixed:plugins.app:reader`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader` | Default [Viewer]({{< relref "../#basic-roles" >}}) assignments. | +| Viewer | `fixed:datasources.id:reader`
`fixed:organization:reader`
`fixed:annotations:reader`
`fixed:annotations.dashboard:writer`
`fixed:alerting:reader`
`fixed:plugins.app:reader`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader` | Default [Viewer]({{< relref "../#basic-roles" >}}) assignments. | | No Basic Role | | Default [No Basic Role]({{< relref "../#basic-roles" >}}) | ## Fixed role definitions @@ -61,7 +61,7 @@ The following tables list permissions associated with basic and fixed roles. | `fixed:datasources.caching:reader` | `datasources.caching:read` | Read data source query caching settings. | | `fixed:datasources.caching:writer` | `datasources.caching:read`
`datasources.caching:write` | Enable, disable, or update query caching settings. | | `fixed:datasources:explorer` | `datasources:explore` | Enable the Explore feature. Data source permissions still apply, you can only query data sources for which you have query permissions. | -| `fixed:datasources:id:reader` | `datasources.id:read` | Read the ID of a data source based on its name. | +| `fixed:datasources.id:reader` | `datasources.id:read` | Read the ID of a data source based on its name. | | `fixed:datasources.insights:reader` | `datasources.insights:read` | Read data source insights data. | | `fixed:datasources.permissions:reader` | `datasources.permissions:read` | Read data source permissions. | | `fixed:datasources.permissions:writer` | All permissions from `fixed:datasources.permissions:reader` and
`datasources.permissions:write` | Create, read, or delete permissions of a data source. | diff --git a/docs/sources/alerting/set-up/_index.md b/docs/sources/alerting/set-up/_index.md index 761a2be3222..4123fda2935 100644 --- a/docs/sources/alerting/set-up/_index.md +++ b/docs/sources/alerting/set-up/_index.md @@ -54,7 +54,7 @@ Grafana Alerting supports many additional configuration options, from configurin The following topics provide you with advanced configuration options for Grafana Alerting. -- [Provision alert rules using file provisioning][file-provisioning] +- [Provision alert rules using file provisioning](/docs/grafana//alerting/set-up/provision-alerting-resources/file-provisioning) - [Provision alert rules using Terraform][terraform-provisioning] - [Add an external Alertmanager][configure-alertmanager] - [Configure high availability][configure-high-availability] @@ -72,9 +72,6 @@ The following topics provide you with advanced configuration options for Grafana [data-source-management]: "/docs/grafana/ -> /docs/grafana//administration/data-source-management" [data-source-management]: "/docs/grafana-cloud/ -> /docs/grafana//administration/data-source-management" -[file-provisioning]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/file-provisioning" -[file-provisioning]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/file-provisioning" - [terraform-provisioning]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/terraform-provisioning" [terraform-provisioning]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/terraform-provisioning" {{% /docs/reference %}} diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/_index.md b/docs/sources/alerting/set-up/provision-alerting-resources/_index.md index 8e6c588da66..823ae1be82a 100644 --- a/docs/sources/alerting/set-up/provision-alerting-resources/_index.md +++ b/docs/sources/alerting/set-up/provision-alerting-resources/_index.md @@ -2,7 +2,7 @@ aliases: - ../provision-alerting-resources/ canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/ -description: Import and export alerting resources +description: Provision alerting resources keywords: - grafana - alerting @@ -14,11 +14,11 @@ labels: - cloud - enterprise - oss -title: Import and export Grafana Alerting resources +title: Provision Alerting resources weight: 300 --- -# Import and export Grafana Alerting resources +# Provision Alerting resources Alerting infrastructure is often complex, with many pieces of the pipeline that often live in different places. Scaling this across multiple teams and organizations is an especially challenging task. Importing and exporting (or provisioning) your alerting resources in Grafana Alerting makes this process easier by enabling you to create, manage, and maintain your alerting data in a way that best suits your organization. @@ -26,116 +26,55 @@ You can import alert rules, contact points, notification policies, mute timings, You cannot edit imported alerting resources in the Grafana UI in the same way as alerting resources that were not imported. You can only edit imported contact points, notification policies, templates, and mute timings in the source where they were created. For example, if you manage your alerting resources using files from disk, you cannot edit the data in Terraform or from within Grafana. +## Import alerting resources + +Choose from the options below to import (or provision) your Grafana Alerting resources. + +1. [Use configuration files to provision your alerting resources](/docs/grafana//alerting/set-up/provision-alerting-resources/file-provisioning), such as alert rules and contact points, through files on disk. + + {{< admonition type="note" >}} + File provisioning is not available in Grafana Cloud instances. + {{< /admonition >}} + +1. Use [Terraform to provision alerting resources][alerting_tf_provisioning]. + +1. Use the [Alerting provisioning HTTP API][alerting_http_provisioning] to manage alerting resources. + + {{< admonition type="note" >}} + The JSON output from the majority of Alerting HTTP endpoints isn't compatible for provisioning via configuration files. + Instead, use the [Export Alerting endpoints](/docs/grafana//alerting/set-up/provision-alerting-resources/export-alerting-resources#export-api-endpoints) to return or download the alerting resources in provisioning format. + {{< /admonition >}} + +## Export alerting resources + +You can export both manually created and provisioned alerting resources. For more information, refer to [Export alerting resources][alerting_export]. + To modify imported alert rules, you can use the **Modify export** feature to edit and then export. -Choose from the options below to import your Grafana Alerting resources. +## View provisioned alerting resources -1. Use file provisioning to manage your Grafana Alerting resources, such as alert rules and contact points, through files on disk. +To view your provisioned resources in Grafana, complete the following steps. - {{% admonition type="note" %}} - File provisioning is not available in Grafana Cloud instances. - {{% /admonition %}} +1. Open your Grafana instance. +1. Navigate to Alerting. +1. Click an alerting resource folder, for example, Alert rules. -2. Use the Alerting Provisioning HTTP API. - - For more information on the Alerting Provisioning HTTP API, refer to [Alerting provisioning HTTP API][alerting_provisioning]. - - Here is a ready-to-use template for alert rules: - - #### Alert rules template - -``` -{ - "title": "TEST-API_1", - "ruleGroup": "API", - "folderUID": "FOLDER", - "noDataState": "OK", - "execErrState": "OK", - "for": "5m", - "orgId": 1, - "uid": "", - "condition": "B", - "annotations": { - "summary": "test_api_1" - }, - "labels": { - "API": "test1" - }, - "data": [ - { - "refId": "A", - "queryType": "", - "relativeTimeRange": { - "from": 600, - "to": 0 - }, - "datasourceUid": " XXXXXXXXX-XXXXXXXXX-XXXXXXXXXX", - "model": { - "expr": "up", - "hide": false, - "intervalMs": 1000, - "maxDataPoints": 43200, - "refId": "A" - } - }, - { - "refId": "B", - "queryType": "", - "relativeTimeRange": { - "from": 0, - "to": 0 - }, - "datasourceUid": "-100", - "model": { - "conditions": [ - { - "evaluator": { - "params": [ - 6 - ], - "type": "gt" - }, - "operator": { - "type": "and" - }, - "query": { - "params": [ - "A" - ] - }, - "reducer": { - "params": [], - "type": "last" - }, - "type": "query" - } - ], - "datasource": { - "type": "__expr__", - "uid": "-100" - }, - "hide": false, - "intervalMs": 1000, - "maxDataPoints": 43200, - "refId": "B", - "type": "classic_conditions" - } - } - ] -} -``` - -3. Use [Terraform](https://www.terraform.io/). +Provisioned resources are labeled **Provisioned**, so that it is clear that they were not created manually. **Useful Links:** [Grafana provisioning][provisioning] -[Grafana Alerting provisioning API][alerting_provisioning] - {{% docs/reference %}} -[alerting_provisioning]: "/docs/grafana/ -> /docs/grafana//developers/http_api/alerting_provisioning" -[alerting_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana//developers/http_api/alerting_provisioning" +[alerting_tf_provisioning]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/terraform-provisioning" +[alerting_tf_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/terraform-provisioning" +[alerting_http_provisioning]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning" +[alerting_http_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning" +[alerting_export]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/export-alerting-resources" +[alerting_export]: "/docs/grafana-cloud/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/export-alerting-resources" + +[alerting_export_http]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/export-alerting-resources#export-api-endpoints" +[alerting_export_http]: "/docs/grafana-cloud/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/export-alerting-resources#export-api-endpoints" [provisioning]: "/docs/grafana/ -> /docs/grafana//administration/provisioning" [provisioning]: "/docs/grafana-cloud/ -> /docs/grafana//administration/provisioning" diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/export-alerting-resources/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/export-alerting-resources/index.md new file mode 100644 index 00000000000..2bfc1500c1b --- /dev/null +++ b/docs/sources/alerting/set-up/provision-alerting-resources/export-alerting-resources/index.md @@ -0,0 +1,106 @@ +--- +aliases: + - ../../provision-alerting-resources/view-provisioned-resources/ + - ./view-provisioned-resources/ +canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/export-alerting-resources/ +description: Export alerting resources in Grafana +keywords: + - grafana + - alerting + - alerting resources + - provisioning +labels: + products: + - cloud + - enterprise + - oss +title: Export alerting resources +weight: 300 +--- + +# Export alerting resources + +Export your alerting resources, such as alert rules, contact points, and notification policies for provisioning, automatically importing single folders and single groups. + +The export options listed below enable you to download resources in YAML, JSON, or Terraform format, facilitating their provisioning through [configuration files](/docs/grafana//alerting/set-up/provision-alerting-resources/file-provisioning) or [Terraform][alerting_tf_provisioning]. + +## Export alert rules + +To export alert rules from the Grafana UI, complete the following steps. + +1. Click **Alerts & IRM** -> **Alert rules**. +1. To export all Grafana-managed rules, click **Export rules**. +1. To export a folder, change the **View as** to **List**. +1. Select the folder you want to export and click the **Export rules folder** icon. +1. To export a group, change the **View as** to **Grouped**. +1. Find the group you want to export and click the **Export rule group** icon. +1. Choose the format to export in. + + The exported rule data appears in different formats - YAML, JSON, Terraform. + +1. Click **Copy Code** or **Download**. + + a. Choose **Copy Code** to go to an existing file and paste in the code. + + b. Choose **Download** to download a file with the exported data. + +## Modify and export alert rules without saving changes + +Use the **Modify export** mode to edit and export an alert rule without updating it. + +{{% admonition type="note" %}} This feature is for Grafana-managed alert rules only. It is available to Admin, Viewer, and Editor roles. {{% /admonition %}} + +To export a modified alert rule without saving the modifications, complete the following steps from the Grafana UI. + +1. Click **Alerts & IRM** -> **Alert rules**. +1. Locate the alert rule you want to edit and click **More** -> **Modify Export** to open the Alert Rule form. +1. From the Alert Rule form, edit the fields you want to change. Changes made are not applied to the alert rule. +1. Click **Export**. +1. Choose the format to export in. + + The exported rule data appears in different formats - YAML, JSON, Terraform. + +1. Click **Copy Code** or **Download**. + + a. Choose **Copy Code** to go to an existing file and paste in the code. + + b. Choose **Download** to download a file with the exported data. + +## Export API endpoints + +You can also use the **Alerting provisioning HTTP API** to export alerting resources in YAML or JSON formats for provisioning. + +Note that most Alerting endpoints return a JSON format that is not compatible for provisioning via configuration files, except the ones listed below. + +| Method | URI | Summary | +| ------ | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| GET | /api/v1/provisioning/alert-rules/:uid/export | [Export an alert rule in provisioning file format.][export_rule] | +| GET | /api/v1/provisioning/folder/:folderUid/rule-groups/:group/export | [Export an alert rule group in provisioning file format.][export_rule_group] | +| GET | /api/v1/provisioning/alert-rules/export | [Export all alert rules in provisioning file format.][export_rules] | +| GET | /api/v1/provisioning/contact-points/export | [Export all contact points in provisioning file format.][export_contacts] | +| GET | /api/v1/provisioning/policies/export | [Export the notification policy tree in provisioning file format.][export_notifications] | + +These endpoints accept a `download` parameter to download a file containing the exported resources. + +{{% docs/reference %}} +[alerting_tf_provisioning]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/terraform-provisioning" +[alerting_tf_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/terraform-provisioning" + +[alerting_http_provisioning]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning" +[alerting_http_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning" + +[export_rule]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-alert-rule-exportspan-export-an-alert-rule-in-provisioning-file-format-_routegetalertruleexport_" +[export_rule]: "/docs/grafana-cloud/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-alert-rule-exportspan-export-an-alert-rule-in-provisioning-file-format-_routegetalertruleexport_" + +[export_rule_group]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-alert-rule-group-exportspan-export-an-alert-rule-group-in-provisioning-file-format-_routegetalertrulegroupexport_" +[export_rule_group]: "/docs/grafana-cloud/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-alert-rule-group-exportspan-export-an-alert-rule-group-in-provisioning-file-format-_routegetalertrulegroupexport_" + +[export_rules]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-alert-rules-exportspan-export-all-alert-rules-in-provisioning-file-format-_routegetalertrulesexport_" +[export_rules]: "/docs/grafana-cloud/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-alert-rules-exportspan-export-all-alert-rules-in-provisioning-file-format-_routegetalertrulesexport_" + +[export_contacts]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-contactpoints-exportspan-export-all-contact-points-in-provisioning-file-format-_routegetcontactpointsexport_" +[export_contacts]: "/docs/grafana-cloud/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-contactpoints-exportspan-export-all-contact-points-in-provisioning-file-format-_routegetcontactpointsexport_" + +[export_notifications]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-policy-tree-exportspan-export-the-notification-policy-tree-in-provisioning-file-format-_routegetpolicytreeexport_" +[export_notifications]: "/docs/grafana-cloud/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-policy-tree-exportspan-export-the-notification-policy-tree-in-provisioning-file-format-_routegetpolicytreeexport_" +{{% /docs/reference %}} diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md index b99e6a70f22..63018a906ad 100644 --- a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md +++ b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md @@ -11,14 +11,14 @@ keywords: - provisioning labels: products: - - cloud - enterprise - oss -title: Use file provisioning to manage alerting resources +menuTitle: Use configuration files to provision +title: Use configuration files to provision alerting resources weight: 100 --- -## Use file provisioning to manage alerting resources +# Use configuration files to provision alerting resources Manage your alerting resources using files from disk. When you start Grafana, the data from these files is created in your Grafana system. Grafana adds any new resources you created, updates any that you changed, and deletes old ones. @@ -26,26 +26,28 @@ Arrange your files in a directory in a way that best suits your use case. For ex Details on how to set up the files and which fields are required for each object are listed below depending on which resource you are provisioning. -**Note:** +For a complete guide about how Grafana provisions resources, refer to the [Provision Grafana][provisioning] documentation. -Importing takes place during the initial set up of your Grafana system, but you can re-run it at any time using the [Grafana Admin API][reload-provisioning-configurations]. +{{< admonition type="note" >}} -### Import alert rules +- You cannot edit provisioned resources from files in Grafana. You can only change the resource properties by changing the provisioning file and restarting Grafana or carrying out a hot reload. This prevents changes being made to the resource that would be overwritten if a file is provisioned again or a hot reload is carried out. + +- Importing takes place during the initial set up of your Grafana system, but you can re-run it at any time using the [Grafana Admin API](/docs/grafana//developers/http_api/admin#reload-provisioning-configurations). + +- Importing an existing alerting resource results in a conflict. First, when present, remove the resources you plan to import. + {{< /admonition >}} + +## Import alert rules Create or delete alert rules in your Grafana instance(s). 1. Create alert rules in Grafana. -1. Use the [Alerting provisioning API][alerting_provisioning] export endpoints to download a provisioning file for your alert rules. -1. Copy the contents into a YAML or JSON configuration file in the default provisioning directory or in your configured directory. +1. [Export][alerting_export] and download a provisioning file for your alert rules. +1. Copy the contents into a YAML or JSON configuration file in the `provisioning/alerting` directory. Example configuration files can be found below. -1. Ensure that your files are in the right directory on the node running the Grafana server, so that they deploy alongside your Grafana instance(s). -1. Delete the alert rules in Grafana that are going to be imported. - - **Note:** - - If you do not delete the alert rule, it will clash with the imported alert rule once uploaded. +1. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s). Here is an example of a configuration file for creating alert rules. @@ -134,17 +136,17 @@ deleteRules: uid: my_id_1 ``` -### Import contact points +## Import contact points Create or delete contact points in your Grafana instance(s). 1. Create a contact point in Grafana. -1. Use the [Alerting provisioning API][alerting_provisioning] export endpoints to download a provisioning file for your contact point. -1. Copy the contents into a YAML or JSON configuration file in the default provisioning directory or in your configured directory. +1. [Export][alerting_export] and download a provisioning file for your contact point. +1. Copy the contents into a YAML or JSON configuration file in the `provisioning/alerting` directory. Example configuration files can be found below. -1. Ensure that your files are in the right directory on the node running the Grafana server, so that they deploy alongside your Grafana instance(s). +1. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s). Here is an example of a configuration file for creating contact points. @@ -184,12 +186,14 @@ deleteContactPoints: uid: first_uid ``` -#### Settings +### Settings Here are some examples of settings you can use for the different contact point integrations. -##### Alertmanager +{{< collapse title="Alertmanager" >}} + +#### Alertmanager ```yaml type: prometheus-alertmanager @@ -202,7 +206,11 @@ settings: basicAuthPassword: abc123 ``` -##### DingDing +{{< /collapse >}} + +{{< collapse title="DingDing" >}} + +#### DingDing ```yaml type: dingding @@ -216,7 +224,11 @@ settings: {{ template "default.message" . }} ``` -##### Discord +{{< /collapse >}} + +{{< collapse title="Discord" >}} + +#### Discord ```yaml type: discord @@ -232,7 +244,11 @@ settings: {{ template "default.message" . }} ``` -##### E-Mail +{{< /collapse >}} + +{{< collapse title="E-Mail" >}} + +#### E-Mail ```yaml type: email @@ -248,7 +264,11 @@ settings: {{ template "default.title" . }} ``` -##### Google Chat +{{< /collapse >}} + +{{< collapse title="Google Chat" >}} + +#### Google Chat ```yaml type: googlechat @@ -260,7 +280,11 @@ settings: {{ template "default.message" . }} ``` -##### Kafka +{{< /collapse >}} + +{{< collapse title="Kafka" >}} + +#### Kafka ```yaml type: kafka @@ -271,7 +295,11 @@ settings: kafkaTopic: topic1 ``` -##### LINE +{{< /collapse >}} + +{{< collapse title="LINE" >}} + +#### LINE ```yaml type: line @@ -280,7 +308,11 @@ settings: token: xxx ``` -##### Microsoft Teams +{{< /collapse >}} + +{{< collapse title="Microsoft Teams" >}} + +#### Microsoft Teams ```yaml type: teams @@ -297,7 +329,11 @@ settings: {{ template "default.message" . }} ``` -##### OpsGenie +{{< /collapse >}} + +{{< collapse title="OpsGenie" >}} + +#### OpsGenie ```yaml type: opsgenie @@ -319,7 +355,11 @@ settings: sendTagsAs: both ``` -##### PagerDuty +{{< /collapse >}} + +{{< collapse title="PagerDuty" >}} + +#### PagerDuty ```yaml type: pagerduty @@ -339,7 +379,11 @@ settings: {{ template "default.message" . }} ``` -##### Pushover +{{< /collapse >}} + +{{< collapse title="Pushover" >}} + +#### Pushover ```yaml type: pushover @@ -367,7 +411,11 @@ settings: {{ template "default.message" . }} ``` -##### Slack +{{< /collapse >}} + +{{< collapse title="Slack" >}} + +#### Slack ```yaml type: slack @@ -399,7 +447,11 @@ settings: {{ template "slack.default.text" . }} ``` -##### Sensu Go +{{< /collapse >}} + +{{< collapse title="Sensu Go" >}} + +#### Sensu Go ```yaml type: sensugo @@ -421,7 +473,11 @@ settings: {{ template "default.message" . }} ``` -##### Telegram +{{< /collapse >}} + +{{< collapse title="Telegram" >}} + +#### Telegram ```yaml type: telegram @@ -435,7 +491,11 @@ settings: {{ template "default.message" . }} ``` -##### Threema Gateway +{{< /collapse >}} + +{{< collapse title="Threema Gateway" >}} + +#### Threema Gateway ```yaml type: threema @@ -448,7 +508,11 @@ settings: recipient_id: A9R4KL4S ``` -##### VictorOps +{{< /collapse >}} + +{{< collapse title="VictorOps" >}} + +#### VictorOps ```yaml type: victorops @@ -459,7 +523,11 @@ settings: messageType: CRITICAL ``` -##### Webhook +{{< /collapse >}} + +{{< collapse title="Webhook" >}} + +#### Webhook ```yaml type: webhook @@ -480,7 +548,11 @@ settings: maxAlerts: '10' ``` -##### WeCom +{{< /collapse >}} + +{{< collapse title="WeCom" >}} + +#### WeCom ```yaml type: wecom @@ -495,17 +567,27 @@ settings: {{ template "default.title" . }} ``` -### Import notification policies +{{< /collapse >}} + +## Import notification policies Create or reset the notification policy tree in your Grafana instance(s). +In Grafana, the entire notification policy tree is considered a single, large resource. Add new specific policies as sub-policies under the root policy. Since specific policies may depend on each other, you cannot provision subsets of the policy tree; the entire tree must be defined in a single place. + +{{% admonition type="warning" %}} + +Since the policy tree is a single resource, provisioning it will overwrite a policy tree created through any other means. + +{{< /admonition >}} + 1. Create a notification policy in Grafana. -1. Use the [Alerting provisioning API][alerting_provisioning] export endpoints to download a provisioning file for your notification policy. -1. Copy the contents into a YAML or JSON configuration file in the default provisioning directory or in your configured directory. +1. [Export][alerting_export] and download a provisioning file for your notification policy. +1. Copy the contents into a YAML or JSON configuration file in the `provisioning/alerting` directory. Example configuration files can be found below. -1. Ensure that your files are in the right directory on the node running the Grafana server, so that they deploy alongside your Grafana instance(s). +1. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s). Here is an example of a configuration file for creating notification policies. @@ -581,13 +663,7 @@ resetPolicies: - 1 ``` -**Note:** - -In Grafana, the entire notification policy tree is considered a single, large resource. Add new specific policies as sub-policies under the root policy. Since specific policies may depend on each other, you cannot provision subsets of the policy tree; the entire tree must be defined in a single place. - -Since the policy tree is a single resource, applying it will overwrite a policy tree created through any other means. - -### Import templates +## Import templates Create or delete templates in your Grafana instance(s). @@ -595,7 +671,7 @@ Create or delete templates in your Grafana instance(s). Example configuration files can be found below. -2. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s). +1. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s). Here is an example of a configuration file for creating templates. @@ -627,7 +703,7 @@ deleteTemplates: name: my_first_template ``` -### Import mute timings +## Import mute timings Create or delete mute timings in your Grafana instance(s). @@ -676,65 +752,68 @@ deleteMuteTimes: name: mti_1 ``` -### File provisioning using Kubernetes +## File provisioning using Kubernetes If you are a Kubernetes user, you can leverage file provisioning using Kubernetes configuration maps. 1. Create one or more configuration maps as follows. -```yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-alerting -data: - provisioning.yaml: | - templates: - - name: my_first_template - template: the content for my template -``` + ```yaml + apiVersion: v1 + kind: ConfigMap + metadata: + name: grafana-alerting + data: + provisioning.yaml: | + templates: + - name: my_first_template + template: the content for my template + ``` -2. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s). +1. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s). -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: grafana -spec: - replicas: 1 - selector: - matchLabels: - app: grafana - template: - metadata: - name: grafana - labels: - app: grafana - spec: - containers: - - name: grafana - image: grafana/grafana:latest - ports: - - name: grafana - containerPort: 3000 - volumeMounts: - - mountPath: /etc/grafana/provisioning/alerting - name: grafana-alerting - readOnly: false - volumes: - - name: grafana-alerting - configMap: - defaultMode: 420 - name: grafana-alerting -``` + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: grafana + spec: + replicas: 1 + selector: + matchLabels: + app: grafana + template: + metadata: + name: grafana + labels: + app: grafana + spec: + containers: + - name: grafana + image: grafana/grafana:latest + ports: + - name: grafana + containerPort: 3000 + volumeMounts: + - mountPath: /etc/grafana/provisioning/alerting + name: grafana-alerting + readOnly: false + volumes: + - name: grafana-alerting + configMap: + defaultMode: 420 + name: grafana-alerting + ``` This eliminates the need for a persistent database to use Grafana Alerting in Kubernetes; all your provisioned resources appear after each restart or re-deployment. Grafana still requires a database for normal operation, you do not need to persist the contents of the database between restarts if all objects are provisioned using files. -{{% docs/reference %}} -[alerting_provisioning]: "/docs/grafana/ -> /docs/grafana//developers/http_api/alerting_provisioning" -[alerting_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana//developers/http_api/alerting_provisioning" +**Useful Links:** -[reload-provisioning-configurations]: "/docs/grafana/ -> /docs/grafana//developers/http_api/admin#reload-provisioning-configurations" -[reload-provisioning-configurations]: "/docs/grafana-cloud/ -> /docs/grafana//developers/http_api/admin#reload-provisioning-configurations" +[Grafana provisioning][provisioning] + +{{% docs/reference %}} +[alerting_export]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/export-alerting-resources" +[alerting_export]: "/docs/grafana-cloud/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/export-alerting-resources" +[provisioning]: "/docs/grafana/ -> /docs/grafana//administration/provisioning" +[provisioning]: "/docs/grafana-cloud/ -> /docs/grafana//administration/provisioning" {{% /docs/reference %}} diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/http-api-provisioning/_index.md b/docs/sources/alerting/set-up/provision-alerting-resources/http-api-provisioning/_index.md new file mode 100644 index 00000000000..4c1f4606571 --- /dev/null +++ b/docs/sources/alerting/set-up/provision-alerting-resources/http-api-provisioning/_index.md @@ -0,0 +1,20 @@ +--- +canonical: https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/ +description: Create and manage alerting resources using the HTTP API +keywords: + - grafana + - alerting + - alerting resources + - provisioning +labels: + products: + - cloud + - enterprise + - oss +title: Use the HTTP API to manage alerting resources +weight: 400 +--- + +# Use the HTTP API to manage alerting resources + +{{< docs/shared lookup="alerts/alerting_provisioning.md" source="grafana" version="latest" >}} diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md index 0ad04166a99..5050d2868f9 100644 --- a/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md +++ b/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md @@ -11,41 +11,46 @@ keywords: - Terraform labels: products: + - cloud - enterprise - oss -title: Use Terraform to manage alerting resources +menuTitle: Use Terraform to provision +title: Use Terraform to provision alerting resources weight: 200 --- -# Use Terraform to manage alerting resources +# Use Terraform to provision alerting resources Use Terraform’s Grafana Provider to manage your alerting resources and provision them into your Grafana system. Terraform provider support for Grafana Alerting makes it easy to create, manage, and maintain your entire Grafana Alerting stack as code. -For more information on managing your alerting resources using Terraform, refer to the [Grafana Provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) documentation. +Refer to [Grafana Provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) documentation for more examples and information on Terraform Alerting schemas. Complete the following tasks to create and manage your alerting resources using Terraform. 1. Create an API key for provisioning. 1. Configure the Terraform provider. -1. Define your alerting resources in Terraform. +1. Define your alerting resources in Terraform. [Export alerting resources][alerting_export] in Terraform format, or implement the [Terraform Alerting schemas](https://registry.terraform.io/providers/grafana/grafana/latest/docs). + 1. Run `terraform apply` to provision your alerting resources. -## Before you begin +{{< admonition type="note" >}} -- Ensure you have the grafana/grafana [Terraform provider](https://registry.terraform.io/providers/grafana/grafana/1.28.0) 1.27.0 or higher. +- By default, you cannot edit resources provisioned from Terraform from the UI. This ensures that your alerting stack always stays in sync with your code. To change the default behaviour, refer to [Edit provisioned resources in the Grafana UI](#edit-provisioned-resources-in-the-grafana-ui). -- Ensure you are using Grafana 9.1 or higher. +- Before you begin, ensure you have the [Grafana Terraform Provider](https://registry.terraform.io/providers/grafana/grafana/) 1.27.0 or higher, and are using Grafana 9.1 or higher. + +{{< /admonition >}} ## Create an API key for provisioning -You can [create a normal Grafana API key][api-keys] to authenticate Terraform with Grafana. Most existing tooling using API keys should automatically work with the new Grafana Alerting support. +You can create a [service account token][service-accounts] to authenticate Terraform with Grafana. Most existing tooling using API keys should automatically work with the new Grafana Alerting support. -There are also dedicated RBAC roles for alerting provisioning. This lets you easily authenticate as a [service account][service-accounts] with the minimum permissions needed to provision your Alerting infrastructure. +There are also dedicated RBAC roles for alerting provisioning. This lets you easily authenticate as a service account with the minimum permissions needed to provision your Alerting infrastructure. To create an API key for provisioning, complete the following steps. -1. Create a new service account for your CI pipeline. -1. Assign the role “Access the alert rules Provisioning API.” +1. Create a new service account. +1. Assign the role or permission to access the [Alerting provisioning API][alerting_http_provisioning]. 1. Create a new service account token. 1. Name and save the token for use in Terraform. @@ -73,70 +78,68 @@ provider "grafana" { } ``` -## Provision contact points and templates +## Import contact points and templates -Contact points connect an alerting stack to the outside world. They tell Grafana how to connect to your external systems and where to deliver notifications. There are over fifteen different [integrations](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/contact_point#optional) to choose from. +Contact points connect an alerting stack to the outside world. They tell Grafana how to connect to your external systems and where to deliver notifications. -To provision contact points and templates, complete the following steps. +To provision contact points and templates, refer to the [grafana_contact_point schema](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/contact_point) and [grafana_message_template schema](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/message_template), and complete the following steps. -1. Copy this code block into a .tf file on your local machine. +1. Copy this code block into a `.tf` file on your local machine. -This example creates a contact point that sends alert notifications to Slack. + This example creates a contact point that sends alert notifications to Slack. -```HCL -resource "grafana_contact_point" "my_slack_contact_point" { - name = "Send to My Slack Channel" + ```HCL + resource "grafana_contact_point" "my_slack_contact_point" { + name = "Send to My Slack Channel" - slack { - url = - text = < + text = <}} -```HCL -resource "grafana_notification_policy" "my_policy" { - group_by = ["alertname"] - contact_point = grafana_contact_point.my_slack_contact_point.name +1. Copy this code block into a `.tf` file on your local machine. - group_wait = "45s" - group_interval = "6m" - repeat_interval = "3h" + In this example, the alerts are grouped by `alertname`, which means that any notifications coming from alerts which share the same name, are grouped into the same Slack message. You can provide any set of label keys here, or you can use the special label `"..."` to route by all label keys, sending each alert in a separate notification. - policy { - matcher { - label = "a" - match = "=" - value = "b" - } - group_by = ["..."] - contact_point = grafana_contact_point.a_different_contact_point.name - mute_timings = [grafana_mute_timing.my_mute_timing.name] + If you want to route specific notifications differently, you can add sub-policies. Sub-policies allow you to apply routing to different alerts based on label matching. In this example, we apply a mute timing to all alerts with the label a=b. - policy { - matcher { - label = "sublabel" - match = "=" - value = "subvalue" - } - contact_point = grafana_contact_point.a_third_contact_point.name - group_by = ["..."] - } - } -} -``` + ```HCL + resource "grafana_notification_policy" "my_policy" { + group_by = ["alertname"] + contact_point = grafana_contact_point.my_slack_contact_point.name -2. In the mute_timings field, link a mute timing to your notification policy. + group_wait = "45s" + group_interval = "6m" + repeat_interval = "3h" -3. Run the command ‘terraform apply’. + policy { + matcher { + label = "a" + match = "=" + value = "b" + } + group_by = ["..."] + contact_point = grafana_contact_point.a_different_contact_point.name + mute_timings = [grafana_mute_timing.my_mute_timing.name] -4. Go to the Grafana UI and check the details of your notification policy. + policy { + matcher { + label = "sublabel" + match = "=" + value = "subvalue" + } + contact_point = grafana_contact_point.a_third_contact_point.name + group_by = ["..."] + } + } + } + ``` -**Note:** +1. In the mute_timings field, link a mute timing to your notification policy. -Since the policy tree is a single resource, applying it will overwrite a policy tree created through any other means. +1. Run the command `terraform apply`. -By default, you cannot edit resources provisioned from Terraform from the UI. This ensures that your alerting stack always stays in sync with your code. +1. Go to the Grafana UI and check the details of your notification policy. -5. Click **Test** to verify that the notification point is working correctly. +1. Click **Test** to verify that the notification point is working correctly. -## Provision mute timings +## Import mute timings Mute timings provide the ability to mute alert notifications for defined time periods. -To provision mute timings, complete the following steps. +To provision mute timings, refer to the [grafana_mute_timing schema](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/mute_timing), and complete the following steps. -1. Copy this code block into a .tf file on your local machine. +1. Copy this code block into a `.tf` file on your local machine. -In this example, alert notifications are muted on weekends. + In this example, alert notifications are muted on weekends. -```HCL -resource "grafana_mute_timing" "my_mute_timing" { - name = "My Mute Timing" + ```HCL + resource "grafana_mute_timing" "my_mute_timing" { + name = "My Mute Timing" - intervals { - times { - start = "04:56" - end = "14:17" - } - weekdays = ["saturday", "sunday", "tuesday:thursday"] - months = ["january:march", "12"] - years = ["2025:2027"] - } -} -``` + intervals { + times { + start = "04:56" + end = "14:17" + } + weekdays = ["saturday", "sunday", "tuesday:thursday"] + months = ["january:march", "12"] + years = ["2025:2027"] + } + } + ``` -2. Run the command ‘terraform apply’. -3. Go to the Grafana UI and check the details of your mute timing. -4. Reference your newly created mute timing in a notification policy using the `mute_timings` field. +1. Run the command `terraform apply`. +1. Go to the Grafana UI and check the details of your mute timing. +1. Reference your newly created mute timing in a notification policy using the `mute_timings` field. This will apply your mute timing to some or all of your notifications. -**Note:** +1. Click **Test** to verify that the mute timing is working correctly. -By default, you cannot edit resources provisioned from Terraform from the UI. This ensures that your alerting stack always stays in sync with your code. - -5. Click **Test** to verify that the mute timing is working correctly. - -## Provision alert rules +## Import alert rules [Alert rules][alerting-rules] enable you to alert against any Grafana data source. This can be a data source that you already have configured, or you can [define your data sources in Terraform](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/data_source) alongside your alert rules. -To provision alert rules, complete the following steps. +To provision alert rules, refer to the [grafana_rule_group schema](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/rule_group), and complete the following steps. 1. Create a data source to query and a folder to store your rules in. -In this example, the [TestData][testdata] data source is used. + In this example, the [TestData][testdata] data source is used. -Alerts can be defined against any backend datasource in Grafana. + Alerts can be defined against any backend datasource in Grafana. -```HCL -resource "grafana_data_source" "testdata_datasource" { - name = "TestData" - type = "testdata" -} + ```HCL + resource "grafana_data_source" "testdata_datasource" { + name = "TestData" + type = "testdata" + } -resource "grafana_folder" "rule_folder" { - title = "My Rule Folder" -} -``` + resource "grafana_folder" "rule_folder" { + title = "My Rule Folder" + } + ``` -2. Define an alert rule. +1. Define an alert rule. -For more information on alert rules, refer to [how to create Grafana-managed alerts](/blog/2022/08/01/grafana-alerting-video-how-to-create-alerts-in-grafana-9/). + For more information on alert rules, refer to [how to create Grafana-managed alerts](/blog/2022/08/01/grafana-alerting-video-how-to-create-alerts-in-grafana-9/). -3. Create a rule group containing one or more rules. +1. Create a rule group containing one or more rules. -In this example, the `grafana_rule_group` resource group is used. + In this example, the `grafana_rule_group` resource group is used. -```HCL -resource "grafana_rule_group" "my_rule_group" { - name = "My Alert Rules" - folder_uid = grafana_folder.rule_folder.uid - interval_seconds = 60 - org_id = 1 + ```HCL + resource "grafana_rule_group" "my_rule_group" { + name = "My Alert Rules" + folder_uid = grafana_folder.rule_folder.uid + interval_seconds = 60 + org_id = 1 - rule { - name = "My Random Walk Alert" - condition = "C" - for = "0s" + rule { + name = "My Random Walk Alert" + condition = "C" + for = "0s" - // Query the datasource. - data { - ref_id = "A" - relative_time_range { - from = 600 - to = 0 - } - datasource_uid = grafana_data_source.testdata_datasource.uid - // `model` is a JSON blob that sends datasource-specific data. - // It's different for every datasource. The alert's query is defined here. - model = jsonencode({ - intervalMs = 1000 - maxDataPoints = 43200 - refId = "A" - }) - } + // Query the datasource. + data { + ref_id = "A" + relative_time_range { + from = 600 + to = 0 + } + datasource_uid = grafana_data_source.testdata_datasource.uid + // `model` is a JSON blob that sends datasource-specific data. + // It's different for every datasource. The alert's query is defined here. + model = jsonencode({ + intervalMs = 1000 + maxDataPoints = 43200 + refId = "A" + }) + } - // The query was configured to obtain data from the last 60 seconds. Let's alert on the average value of that series using a Reduce stage. - data { - datasource_uid = "__expr__" - // You can also create a rule in the UI, then GET that rule to obtain the JSON. - // This can be helpful when using more complex reduce expressions. - model = < /docs/grafana//alerting/alerting-rules" [alerting-rules]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules" -[api-keys]: "/docs/grafana/ -> /docs/grafana//administration/api-keys" -[api-keys]: "/docs/grafana-cloud/ -> /docs/grafana//administration/api-keys" +[alerting_export]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/export-alerting-resources" +[alerting_export]: "/docs/grafana-cloud/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/export-alerting-resources" + +[alerting_http_provisioning]: "/docs/grafana/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning" +[alerting_http_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana//alerting/set-up/provision-alerting-resources/http-api-provisioning" [service-accounts]: "/docs/grafana/ -> /docs/grafana//administration/service-accounts" [service-accounts]: "/docs/grafana-cloud/ -> /docs/grafana//administration/service-accounts" diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/view-provisioned-resources/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/view-provisioned-resources/index.md deleted file mode 100644 index 5b473a7776d..00000000000 --- a/docs/sources/alerting/set-up/provision-alerting-resources/view-provisioned-resources/index.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -aliases: - - ../../provision-alerting-resources/view-provisioned-resources/ -canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/view-provisioned-resources/ -description: Manage provisioned alerting resources in Grafana -keywords: - - grafana - - alerting - - alerting resources - - provisioning -labels: - products: - - cloud - - enterprise - - oss -menuTitle: Manage provisioned alerting resources -title: Manage provisioned alerting resources -weight: 300 ---- - -# Manage provisioned alerting resources - -Verify that your alerting resources were created in Grafana, as well as edit or export your provisioned alerting resources. - -## View provisioned alerting resoureces - -To view your provisioned resources in Grafana, complete the following steps. - -1. Open your Grafana instance. -1. Navigate to Alerting. -1. Click an alerting resource folder, for example, Alert rules. - -Provisioned resources are labeled **Provisioned**, so that it is clear that they were not created manually. - -## Export provisioned alerting resources - -Export your alerting resources, such as alert rules, contact points, and notification policies in JSON, YAML, or Terraform format. You can export all Grafana-managed alert rules, single folders, and single groups. - -To export provisioned alerting resources from the Grafana UI, complete the following steps. - -1. Click **Alerts & IRM** -> **Alert rules**. -1. To export all Grafana-managed rules, click **Export rules**. -1. To export a folder, change the **View as** to **List**. -1. Select the folder you want to export and click the **Export rules folder** icon. -1. To export a group, change the **View as** to **Grouped**. -1. Find the group you want to export and click the **Export rule group** icon. -1. Choose the format to export in. - - Note that formats JSON and YAML are suitable only for file provisioning. To get rule definition in provisioning API format, use the provisioning GET API. - -1. Click **Copy Code** or **Download**. -1. Choose **Copy Code** to go to an existing file and paste in the code. -1. Choose **Download** to download a file with the exported data. - -## Edit provisioned alert rules - -Use the **Modify export** mode for alert rules to edit provisioned alert rules and export a modified version. - -{{% admonition type="note" %}} This feature is for Grafana-managed alert rules only. It is available to Admin, Viewer, and Editor roles. {{% /admonition %}} - -To edit provisioned alerting alert rules from the Grafana UI, complete the following steps. - -1. Click **Alerts & IRM** -> **Alert rules**. -1. Locate the alert rule you want to edit and click **More** -> **Modify Export** to open the Alert Rule form. -1. From the Alert Rule form, edit the fields you want to change. -1. Click **Export** to export all alert rules within the group. - - You can only export groups of rules; not single rules. - The exported rule data appears in different formats - HTML, JSON, Terraform. - -1. Choose the format to export in. -1. Click **Copy Code** or **Download**. - - a. Choose **Copy Code** to go to an existing file and paste in the code. - - b. Choose **Download** to download a file with the exported data. - -## Edit API-provisioned alerting resources - -To enable editing of API-provisioned resources in the Grafana UI, add the `X-Disable-Provenance` header to the following requests in the API: - -- `POST /api/v1/provisioning/alert-rules` -- `PUT /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}` (calling this endpoint will change provenance for all alert rules within the alert group) -- `POST /api/v1/provisioning/contact-points` -- `POST /api/v1/provisioning/mute-timings` -- `PUT /api/v1/provisioning/policies` -- `PUT /api/v1/provisioning/templates/{name}` - -To reset the notification policy tree to the default and unlock it for editing in the Grafana UI, use the `DELETE /api/v1/provisioning/policies` endpoint. - -In Terraform, you can use the `disable_provenance` attribute on alerting resources: - -``` -provider "grafana" { - url = "http://grafana.example.com/" - auth = var.grafana_auth -} - -resource "grafana_mute_timing" "mute_all" { - name = "mute all" - disable_provenance = true - intervals {} -} -``` - -**Note:** - -You cannot edit provisioned resources from files in Grafana. You can only change the resource properties by changing the provisioning file and restarting Grafana or carrying out a hot reload. This prevents changes being made to the resource that would be overwritten if a file is provisioned again or a hot reload is carried out. diff --git a/docs/sources/datasources/pyroscope/_index.md b/docs/sources/datasources/pyroscope/_index.md new file mode 100644 index 00000000000..88d5a1c88be --- /dev/null +++ b/docs/sources/datasources/pyroscope/_index.md @@ -0,0 +1,80 @@ +--- +aliases: + - ../features/datasources/phlare/ # /docs/grafana//features/datasources/phlare/ + - ../features/datasources/grafana-pyroscope/ # /docs/grafana//features/datasources/grafana-pyroscope/ + - ../datasources/grafana-pyroscope/ # /docs/grafana//datasources/grafana-pyroscope/ +description: Horizontally-scalable, highly-available, multi-tenant continuous profiling + aggregation system. OSS profiling solution from Grafana Labs. +keywords: + - grafana + - phlare + - guide + - profiling + - pyroscope +labels: + products: + - cloud + - enterprise + - oss +title: Grafana Pyroscope +weight: 1150 +--- + +# Grafana Pyroscope data source + +Grafana Pyroscope is a horizontally scalable, highly available, multi-tenant, OSS, continuous profiling aggregation system. Add it as a data source, and you are ready to query your profiles in [Explore][explore]. + +To learn more about profiling and Pyroscope, refer to the [Introduction to Pyroscope](/docs/pyroscope/introduction/). + +For information on configuring the Pyroscope data source, refer to [Configure the Grafana Pyroscope data source](./configure-pyroscope-data-source). + +## Integrate profiles into dashboards + +Using the Pyroscope data source, you can integrate profiles into your dashboards. +In this case, the screenshot shows memory profiles alongside panels for logs and metrics to be able to debug out of memory (OOM) errors alongside the associated logs and metrics. + +![dashboard](https://grafana.com/static/img/pyroscope/grafana-pyroscope-dashboard-2023-11-30.png) + +## Visualize traces and profiles data using Traces to profiles + +You can link profile and tracing data using your Pyroscope data source with the Tempo data source. + +Combined traces and profiles let you see granular line-level detail when available for a trace span. This allows you pinpoint the exact function that's causing a bottleneck in your application as well as a specific request. + +![trace-profiler-view](https://grafana.com/static/img/pyroscope/pyroscope-trace-profiler-view-2023-11-30.png) + +For more information, refer to the [Traces to profile section][configure-tempo-data-source] of the Tempo data source documentation. + +{{< youtube id="AG8VzfFMLxo" >}} + +## Provision the Grafana Pyroscope data source + +You can modify the Grafana configuration files to provision the Grafana Pyroscope data source. +To learn more, and to view the available provisioning settings, refer to [provisioning documentation][provisioning-data-sources]. + +Here is an example configuration: + +```yaml +apiVersion: 1 + +datasources: + - name: Grafana Pyroscope + type: grafana-pyroscope-datasource + url: http://localhost:4040 + jsonData: + minStep: '15s' +``` + +{{% docs/reference %}} +[explore]: "/docs/grafana/ -> /docs/grafana//explore" +[explore]: "/docs/grafana-cloud/ -> /docs/grafana//explore" + +[flame-graph]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/flame-graph" +[flame-graph]: "/docs/grafana-cloud/ -> /docs/grafana//panels-visualizations/visualizations/flame-graph" + +[provisioning-data-sources]: "/docs/grafana/ -> /docs/grafana//administration/provisioning#datasources" +[provisioning-data-sources]: "/docs/grafana-cloud/ -> /docs/grafana//administration/provisioning#datasources" + +[configure-tempo-data-source]: "/docs/grafana/ -> /docs/grafana//datasources/tempo/configure-tempo-data-source" +[configure-tempo-data-source]: "/docs/grafana-cloud/ -> docs/grafana-cloud/connect-externally-hosted/data-sources/tempo/configure-tempo-data-source" +{{% /docs/reference %}} diff --git a/docs/sources/datasources/grafana-pyroscope.md b/docs/sources/datasources/pyroscope/configure-pyroscope-data-source.md similarity index 50% rename from docs/sources/datasources/grafana-pyroscope.md rename to docs/sources/datasources/pyroscope/configure-pyroscope-data-source.md index 014b36f170e..8503f2dfa45 100644 --- a/docs/sources/datasources/grafana-pyroscope.md +++ b/docs/sources/datasources/pyroscope/configure-pyroscope-data-source.md @@ -1,13 +1,7 @@ --- -aliases: - - ../features/datasources/phlare/ - - ../features/datasources/grafana-pyroscope/ -description: Horizontally-scalable, highly-available, multi-tenant continuous profiling - aggregation system. OSS profiling solution from Grafana Labs. +description: Configure your Pyroscope data source for Grafana. keywords: - - grafana - - phlare - - guide + - configure - profiling - pyroscope labels: @@ -15,15 +9,12 @@ labels: - cloud - enterprise - oss -title: Grafana Pyroscope -weight: 1150 +title: Configure the Grafana Pyroscope data source +menuTitle: Configure Pyroscope +weight: 200 --- -# Grafana Pyroscope data source - -Grafana Pyroscope is a horizontally scalable, highly available, multi-tenant, OSS, continuous profiling aggregation system. Add it as a data source, and you are ready to query your profiles in [Explore][explore]. - -## Configure the Grafana Pyroscope data source +# Configure the Grafana Pyroscope data source To configure basic settings for the data source, complete the following steps: @@ -44,84 +35,6 @@ To configure basic settings for the data source, complete the following steps: | `Password` | Password for basic authentication. | | `Minimal step` | Used for queries returning timeseries data. The Pyroscope backend, similar to Prometheus, scrapes profiles at certain intervals. To prevent querying at smaller interval, use Minimal step same or higher than your Pyroscope scrape interval. This prevents returning too many data points to the frontend. | -### Traces to profiles - -You can link profile and tracing data using your Pyroscope data source with the Tempo data source. -For more information, refer to the [Traces to profile section][configure-tempo-data-source] of the Tempo data source documentation. - -{{< youtube id="AG8VzfFMLxo" >}} - -## Querying - -You can query your profiling data using the query editor. - -### Query editor - -The query editor gives you access to a profile type selector, a label selector, and collapsible options. - -![Query editor](/media/docs/pyroscope/query-editor/query-editor.png 'Query editor') - -To access the query editor: - -1. Sign into Grafana or Grafana Cloud. -1. Select your Pyroscope data source. -1. From the menu, choose **Explore**. - -1. Select a profile type from the drop-down menu. - - {{< figure src="/media/docs/pyroscope/query-editor/select-profile.png" class="docs-image--no-shadow" max-width="450px" caption="Profile selector" >}} - -1. Use the labels selector input to filter by labels. Pyroscope uses similar syntax to Prometheus to filter labels. - Refer to [Pyroscope documentation](https://grafana.com/docs/pyroscope/latest/) for available operators and syntax. - - While the label selector can be left empty to query all profiles without filtering by labels, the profile type or app must be selected for the query to be valid. - - Grafana doesn't show any data if the profile type or app isn’t selected when a query runs. - - ![Labels selector](/media/docs/pyroscope/query-editor/labels-selector.png 'Labels selector') - -1. Expand the **Options** section to view **Query Type** and **Group by**. - ![Options section](/media/docs/pyroscope/query-editor/options-section.png 'Options section') - -1. Select a query type to return the profile data which can be shown in the [Flame Graph][flame-graph], metric data visualized in a graph, or both. You can only select both options in a dashboard, because panels allow only one visualization. - -**Group by** allows you to group metric data by a specified label. Without any **Group by** label, metric data is aggregated over all the labels into single time series. You can use multiple labels to group by. Group by has only an effect on the metric data and doesn't change the profile data results. - -### Profiles query results - -Profiles can be visualized in a flame graph. See the [Flame Graph documentation][flame-graph] to learn about the visualization and its features. - -![Flame graph](/media/docs/pyroscope/query-editor/flame-graph.png 'Flame graph') - -Pyroscope returns profiles aggregated over a selected time range. -The absolute values in the flame graph grow as the time range gets bigger while keeping the relative values meaningful. -You can zoom in on the time range to get a higher granularity profile up to the point of a single scrape interval. - -### Metrics query results - -Metrics results represent the aggregated sum value over time of the selected profile type. - -![Metrics graph](/media/docs/pyroscope/query-editor/metric-graph.png 'Metrics graph') - -This allows you to quickly see any spikes in the value of the scraped profiles and zoom in to a particular time range. - -## Provision the Grafana Pyroscope data source - -You can modify the Grafana configuration files to provision the Grafana Pyroscope data source. To learn more, and to view the available provisioning settings, see [provisioning documentation][provisioning-data-sources]. - -Here is an example configuration: - -```yaml -apiVersion: 1 - -datasources: - - name: Grafana Pyroscope - type: grafana-pyroscope-datasource - url: http://localhost:4040 - jsonData: - minStep: '15s' -``` - {{% docs/reference %}} [explore]: "/docs/grafana/ -> /docs/grafana//explore" [explore]: "/docs/grafana-cloud/ -> /docs/grafana//explore" diff --git a/docs/sources/datasources/pyroscope/query-profile-data.md b/docs/sources/datasources/pyroscope/query-profile-data.md new file mode 100644 index 00000000000..8c68fb4275b --- /dev/null +++ b/docs/sources/datasources/pyroscope/query-profile-data.md @@ -0,0 +1,82 @@ +--- +description: Use the query editor to explore your Pyroscope data. +keywords: + - query + - profiling + - pyroscope +labels: + products: + - cloud + - enterprise + - oss +title: Query profile data +menuTitle: Query profile data +weight: 300 +--- + +# Query profile data + +The Pyroscope data source query editor gives you access to a profile type selector, a label selector, and collapsible options. + +![Query editor](/media/docs/pyroscope/query-editor/query-editor.png 'Query editor') + +To access the query editor: + +1. Sign into Grafana or Grafana Cloud. +1. Select your Pyroscope data source. +1. From the menu, choose **Explore**. + +1. Select a profile type from the drop-down menu. + + {{< figure src="/media/docs/pyroscope/query-editor/select-profile.png" class="docs-image--no-shadow" max-width="450px" caption="Profile selector" >}} + +1. Use the labels selector input to filter by labels. Pyroscope uses similar syntax to Prometheus to filter labels. + Refer to [Pyroscope documentation](https://grafana.com/docs/pyroscope/latest/) for available operators and syntax. + + While the label selector can be left empty to query all profiles without filtering by labels, the profile type or app must be selected for the query to be valid. + + Grafana doesn't show any data if the profile type or app isn’t selected when a query runs. + + ![Labels selector](/media/docs/pyroscope/query-editor/labels-selector.png 'Labels selector') + +1. Expand the **Options** section to view **Query Type** and **Group by**. + ![Options section](/media/docs/pyroscope/query-editor/options-section.png 'Options section') + +1. Select a query type to return the profile data. Data is shown in the [Flame Graph][flame-graph], metric data visualized in a graph, or both. You can only select both options in Explore. The panels used on dashboards allow only one visualization. + +Using **Group by**, you can group metric data by a specified label. +Without any **Group by** label, metric data aggregates over all the labels into single time series. +You can use multiple labels to group by. Group by only effects the metric data and doesn't change the profile data results. + +## Profiles query results + +Profiles can be visualized in a flame graph. +Refer to the [Flame Graph documentation][flame-graph] to learn about the visualization and its features. + +![Flame graph](/media/docs/pyroscope/query-editor/flame-graph.png 'Flame graph') + +Pyroscope returns profiles aggregated over a selected time range. +The absolute values in the flame graph grow as the time range gets bigger while keeping the relative values meaningful. +You can zoom in on the time range to get a higher granularity profile up to the point of a single scrape interval. + +## Metrics query results + +Metrics results represent the aggregated sum value over time of the selected profile type. + +![Metrics graph](/media/docs/pyroscope/query-editor/metric-graph.png 'Metrics graph') + +This allows you to quickly see any spikes in the value of the scraped profiles and zoom in to a particular time range. + +{{% docs/reference %}} +[explore]: "/docs/grafana/ -> /docs/grafana//explore" +[explore]: "/docs/grafana-cloud/ -> /docs/grafana//explore" + +[flame-graph]: "/docs/grafana/ -> /docs/grafana//panels-visualizations/visualizations/flame-graph" +[flame-graph]: "/docs/grafana-cloud/ -> /docs/grafana//panels-visualizations/visualizations/flame-graph" + +[provisioning-data-sources]: "/docs/grafana/ -> /docs/grafana//administration/provisioning#datasources" +[provisioning-data-sources]: "/docs/grafana-cloud/ -> /docs/grafana//administration/provisioning#datasources" + +[configure-tempo-data-source]: "/docs/grafana/ -> /docs/grafana//datasources/tempo/configure-tempo-data-source" +[configure-tempo-data-source]: "/docs/grafana-cloud/ -> docs/grafana-cloud/connect-externally-hosted/data-sources/tempo/configure-tempo-data-source" +{{% /docs/reference %}} diff --git a/docs/sources/developers/http_api/alerting_provisioning.md b/docs/sources/developers/http_api/alerting_provisioning.md index 7643fabd65a..0f9cfe3225a 100644 --- a/docs/sources/developers/http_api/alerting_provisioning.md +++ b/docs/sources/developers/http_api/alerting_provisioning.md @@ -12,6 +12,7 @@ keywords: - alerts labels: products: + - cloud - enterprise - oss title: 'Alerting Provisioning HTTP API ' @@ -19,1525 +20,4 @@ title: 'Alerting Provisioning HTTP API ' # Alerting provisioning HTTP API -The Alerting provisioning API can be used to create, modify, and delete resources relevant to [Grafana Managed alerts]({{< relref "/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule" >}}). And is the one used by our [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs). - -For managing resources related to [data source-managed alerts]({{< relref "/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule" >}}) including Recording Rules, you can use [Mimir tool](https://grafana.com/docs/mimir/latest/manage/tools/mimirtool/) and [Cortex tool](https://github.com/grafana/cortex-tools#cortextool) respectively. - -## Information - -### Version - -1.1.0 - -## Content negotiation - -### Consumes - -- application/json - -### Produces - -- application/json -- text/yaml -- application/yaml - -## All endpoints - -### Alert rules - -| Method | URI | Name | Summary | -| ------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------- | -| DELETE | /api/v1/provisioning/alert-rules/:uid | [route delete alert rule](#route-delete-alert-rule) | Delete a specific alert rule by UID. | -| GET | /api/v1/provisioning/alert-rules/:uid | [route get alert rule](#route-get-alert-rule) | Get a specific alert rule by UID. | -| GET | /api/v1/provisioning/alert-rules/:uid/export | [route get alert rule export](#route-get-alert-rule-export) | Export an alert rule in provisioning file format. | -| GET | /api/v1/provisioning/folder/:folderUid/rule-groups/:group | [route get alert rule group](#route-get-alert-rule-group) | Get a rule group. | -| GET | /api/v1/provisioning/folder/:folderUid/rule-groups/:group/export | [route get alert rule group export](#route-get-alert-rule-group-export) | Export an alert rule group in provisioning file format. | -| GET | /api/v1/provisioning/alert-rules | [route get alert rules](#route-get-alert-rules) | Get all the alert rules. | -| GET | /api/v1/provisioning/alert-rules/export | [route get alert rules export](#route-get-alert-rules-export) | Export all alert rules in provisioning file format. | -| POST | /api/v1/provisioning/alert-rules | [route post alert rule](#route-post-alert-rule) | Create a new alert rule. | -| PUT | /api/v1/provisioning/alert-rules/:uid | [route put alert rule](#route-put-alert-rule) | Update an existing alert rule. | -| PUT | /api/v1/provisioning/folder/:folderUid/rule-groups/:group | [route put alert rule group](#route-put-alert-rule-group) | Update the interval of a rule group or modify the rules of the group. | - -### Contact points - -| Method | URI | Name | Summary | -| ------ | ------------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------ | -| DELETE | /api/v1/provisioning/contact-points/:uid | [route delete contactpoints](#route-delete-contactpoints) | Delete a contact point. | -| GET | /api/v1/provisioning/contact-points | [route get contactpoints](#route-get-contactpoints) | Get all the contact points. | -| GET | /api/v1/provisioning/contact-points/export | [route get contactpoints export](#route-get-contactpoints-export) | Export all contact points in provisioning file format. | -| POST | /api/v1/provisioning/contact-points | [route post contactpoints](#route-post-contactpoints) | Create a contact point. | -| PUT | /api/v1/provisioning/contact-points/:uid | [route put contactpoint](#route-put-contactpoint) | Update an existing contact point. | - -### Notification policies - -| Method | URI | Name | Summary | -| ------ | ------------------------------------ | ------------------------------------------------------------- | ---------------------------------------------------------------- | -| DELETE | /api/v1/provisioning/policies | [route reset policy tree](#route-reset-policy-tree) | Clears the notification policy tree. | -| GET | /api/v1/provisioning/policies | [route get policy tree](#route-get-policy-tree) | Get the notification policy tree. | -| GET | /api/v1/provisioning/policies/export | [route get policy tree export](#route-get-policy-tree-export) | Export the notification policy tree in provisioning file format. | -| PUT | /api/v1/provisioning/policies | [route put policy tree](#route-put-policy-tree) | Sets the notification policy tree. | - -### Mute timings - -| Method | URI | Name | Summary | -| ------ | --------------------------------------- | ----------------------------------------------------- | -------------------------------- | -| DELETE | /api/v1/provisioning/mute-timings/:name | [route delete mute timing](#route-delete-mute-timing) | Delete a mute timing. | -| GET | /api/v1/provisioning/mute-timings/:name | [route get mute timing](#route-get-mute-timing) | Get a mute timing. | -| GET | /api/v1/provisioning/mute-timings | [route get mute timings](#route-get-mute-timings) | Get all the mute timings. | -| POST | /api/v1/provisioning/mute-timings | [route post mute timing](#route-post-mute-timing) | Create a new mute timing. | -| PUT | /api/v1/provisioning/mute-timings/:name | [route put mute timing](#route-put-mute-timing) | Replace an existing mute timing. | - -### Templates - -| Method | URI | Name | Summary | -| ------ | ------------------------------------ | ----------------------------------------------- | ------------------------------------------ | -| DELETE | /api/v1/provisioning/templates/:name | [route delete template](#route-delete-template) | Delete a template. | -| GET | /api/v1/provisioning/templates/:name | [route get template](#route-get-template) | Get a notification template. | -| GET | /api/v1/provisioning/templates | [route get templates](#route-get-templates) | Get all notification templates. | -| PUT | /api/v1/provisioning/templates/:name | [route put template](#route-put-template) | Updates an existing notification template. | - -## Paths - -### Delete a specific alert rule by UID. (_RouteDeleteAlertRule_) - -``` -DELETE /api/v1/provisioning/alert-rules/:uid -``` - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------------- | -------- | ------ | -------- | --------- | :------: | ------- | --------------------------------------------------------- | -| UID | `path` | string | `string` | | ✓ | | Alert rule UID | -| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ----------------------------------- | ---------- | ---------------------------------------- | :---------: | --------------------------------------------- | -| [204](#route-delete-alert-rule-204) | No Content | The alert rule was deleted successfully. | | [schema](#route-delete-alert-rule-204-schema) | - -#### Responses - -##### 204 - The alert rule was deleted successfully. - -Status: No Content - -###### Schema - -### Delete a contact point. (_RouteDeleteContactpoints_) - -``` -DELETE /api/v1/provisioning/contact-points/:uid -``` - -#### Consumes - -- application/json - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------------------------------------ | -| UID | `path` | string | `string` | | ✓ | | UID is the contact point unique identifier | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| -------------------------------------- | ---------- | ------------------------------------------- | :---------: | ------------------------------------------------ | -| [204](#route-delete-contactpoints-204) | No Content | The contact point was deleted successfully. | | [schema](#route-delete-contactpoints-204-schema) | - -#### Responses - -##### 204 - The contact point was deleted successfully. - -Status: No Content - -###### Schema - -### Delete a mute timing. (_RouteDeleteMuteTiming_) - -``` -DELETE /api/v1/provisioning/mute-timings/:name -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ---------------- | -| name | `path` | string | `string` | | ✓ | | Mute timing name | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ------------------------------------ | ---------- | ----------------------------------------- | :---------: | ---------------------------------------------- | -| [204](#route-delete-mute-timing-204) | No Content | The mute timing was deleted successfully. | | [schema](#route-delete-mute-timing-204-schema) | - -#### Responses - -##### 204 - The mute timing was deleted successfully. - -Status: No Content - -###### Schema - -### Delete a template. (_RouteDeleteTemplate_) - -``` -DELETE /api/v1/provisioning/templates/:name -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------- | -| name | `path` | string | `string` | | ✓ | | Template Name | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ---------- | -------------------------------------- | :---------: | ------------------------------------------- | -| [204](#route-delete-template-204) | No Content | The template was deleted successfully. | | [schema](#route-delete-template-204-schema) | - -#### Responses - -##### 204 - The template was deleted successfully. - -Status: No Content - -###### Schema - -### Get a specific alert rule by UID. (_RouteGetAlertRule_) - -``` -GET /api/v1/provisioning/alert-rules/:uid -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | -------------- | -| UID | `path` | string | `string` | | ✓ | | Alert rule UID | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| -------------------------------- | --------- | -------------------- | :---------: | ------------------------------------------ | -| [200](#route-get-alert-rule-200) | OK | ProvisionedAlertRule | | [schema](#route-get-alert-rule-200-schema) | -| [404](#route-get-alert-rule-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-404-schema) | - -#### Responses - -##### 200 - ProvisionedAlertRule - -Status: OK - -###### Schema - -[ProvisionedAlertRule](#provisioned-alert-rule) - -##### 404 - Not found. - -Status: Not Found - -###### Schema - -### Export an alert rule in provisioning file format. (_RouteGetAlertRuleExport_) - -``` -GET /api/v1/provisioning/alert-rules/:uid/export -``` - -#### Produces - -- application/json -- application/yaml -- text/yaml - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -| UID | `path` | string | `string` | | ✓ | | Alert rule UID | -| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | -| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------------- | --------- | ------------------ | :---------: | ------------------------------------------------- | -| [200](#route-get-alert-rule-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rule-export-200-schema) | -| [404](#route-get-alert-rule-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-export-404-schema) | - -#### Responses - -##### 200 - AlertingFileExport - -Status: OK - -###### Schema - -[AlertingFileExport](#alerting-file-export) - -##### 404 - Not found. - -Status: Not Found - -###### Schema - -### Get a rule group. (_RouteGetAlertRuleGroup_) - -``` -GET /api/v1/provisioning/folder/:folderUid/rule-groups/:group -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| --------- | ------ | ------ | -------- | --------- | :------: | ------- | ----------- | -| FolderUID | `path` | string | `string` | | ✓ | | | -| Group | `path` | string | `string` | | ✓ | | | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| -------------------------------------- | --------- | -------------- | :---------: | ------------------------------------------------ | -| [200](#route-get-alert-rule-group-200) | OK | AlertRuleGroup | | [schema](#route-get-alert-rule-group-200-schema) | -| [404](#route-get-alert-rule-group-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-group-404-schema) | - -#### Responses - -##### 200 - AlertRuleGroup - -Status: OK - -###### Schema - -[AlertRuleGroup](#alert-rule-group) - -##### 404 - Not found. - -Status: Not Found - -###### Schema - -### Export an alert rule group in provisioning file format. (_RouteGetAlertRuleGroupExport_) - -``` -GET /api/v1/provisioning/folder/:folderUid/rule-groups/:group/export -``` - -#### Produces - -- application/json -- application/yaml -- text/yaml - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| --------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -| FolderUID | `path` | string | `string` | | ✓ | | | -| Group | `path` | string | `string` | | ✓ | | | -| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | -| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------------------- | --------- | ------------------ | :---------: | ------------------------------------------------------- | -| [200](#route-get-alert-rule-group-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rule-group-export-200-schema) | -| [404](#route-get-alert-rule-group-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-group-export-404-schema) | - -#### Responses - -##### 200 - AlertingFileExport - -Status: OK - -###### Schema - -[AlertingFileExport](#alerting-file-export) - -##### 404 - Not found. - -Status: Not Found - -###### Schema - -### Get all the alert rules. (_RouteGetAlertRules_) - -``` -GET /api/v1/provisioning/alert-rules -``` - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ------ | --------------------- | :---------: | ------------------------------------------- | -| [200](#route-get-alert-rules-200) | OK | ProvisionedAlertRules | | [schema](#route-get-alert-rules-200-schema) | - -#### Responses - -##### 200 - ProvisionedAlertRules - -Status: OK - -###### Schema - -[ProvisionedAlertRules](#provisioned-alert-rules) - -### Export all alert rules in provisioning file format. (_RouteGetAlertRulesExport_) - -``` -GET /api/v1/provisioning/alert-rules/export -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | -| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ---------------------------------------- | --------- | ------------------ | :---------: | -------------------------------------------------- | -| [200](#route-get-alert-rules-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rules-export-200-schema) | -| [404](#route-get-alert-rules-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rules-export-404-schema) | - -#### Responses - -##### 200 - AlertingFileExport - -Status: OK - -###### Schema - -[AlertingFileExport](#alerting-file-export) - -##### 404 - Not found. - -Status: Not Found - -###### Schema - -### Get all the contact points. (_RouteGetContactpoints_) - -``` -GET /api/v1/provisioning/contact-points -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------- | ------ | -------- | --------- | :------: | ------- | -------------- | -| name | `query` | string | `string` | | | | Filter by name | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ----------------------------------- | ------ | ------------- | :---------: | --------------------------------------------- | -| [200](#route-get-contactpoints-200) | OK | ContactPoints | | [schema](#route-get-contactpoints-200-schema) | - -#### Responses - -##### 200 - ContactPoints - -Status: OK - -###### Schema - -[ContactPoints](#contact-points) - -### Export all contact points in provisioning file format. (_RouteGetContactpointsExport_) - -``` -GET /api/v1/provisioning/contact-points/export -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------- | ------- | ------- | -------- | --------- | :------: | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| decrypt | `query` | boolean | `bool` | | | | Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings. | -| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | -| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | -| name | `query` | string | `string` | | | | Filter by name | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ------------------------------------------ | --------- | ------------------ | :---------: | ---------------------------------------------------- | -| [200](#route-get-contactpoints-export-200) | OK | AlertingFileExport | | [schema](#route-get-contactpoints-export-200-schema) | -| [403](#route-get-contactpoints-export-403) | Forbidden | PermissionDenied | | [schema](#route-get-contactpoints-export-403-schema) | - -#### Responses - -##### 200 - AlertingFileExport - -Status: OK - -###### Schema - -[AlertingFileExport](#alerting-file-export) - -##### 403 - PermissionDenied - -Status: Forbidden - -###### Schema - -[PermissionDenied](#permission-denied) - -### Get a mute timing. (_RouteGetMuteTiming_) - -``` -GET /api/v1/provisioning/mute-timings/:name -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ---------------- | -| name | `path` | string | `string` | | ✓ | | Mute timing name | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | --------- | ---------------- | :---------: | ------------------------------------------- | -| [200](#route-get-mute-timing-200) | OK | MuteTimeInterval | | [schema](#route-get-mute-timing-200-schema) | -| [404](#route-get-mute-timing-404) | Not Found | Not found. | | [schema](#route-get-mute-timing-404-schema) | - -#### Responses - -##### 200 - MuteTimeInterval - -Status: OK - -###### Schema - -[MuteTimeInterval](#mute-time-interval) - -##### 404 - Not found. - -Status: Not Found - -###### Schema - -### Get all the mute timings. (_RouteGetMuteTimings_) - -``` -GET /api/v1/provisioning/mute-timings -``` - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ---------------------------------- | ------ | ----------- | :---------: | -------------------------------------------- | -| [200](#route-get-mute-timings-200) | OK | MuteTimings | | [schema](#route-get-mute-timings-200-schema) | - -#### Responses - -##### 200 - MuteTimings - -Status: OK - -###### Schema - -[MuteTimings](#mute-timings) - -### Get the notification policy tree. (_RouteGetPolicyTree_) - -``` -GET /api/v1/provisioning/policies -``` - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ------ | ----------- | :---------: | ------------------------------------------- | -| [200](#route-get-policy-tree-200) | OK | Route | | [schema](#route-get-policy-tree-200-schema) | - -#### Responses - -##### 200 - Route - -Status: OK - -###### Schema - -[Route](#route) - -### Export the notification policy tree in provisioning file format. (_RouteGetPolicyTreeExport_) - -``` -GET /api/v1/provisioning/policies/export -``` - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ---------------------------------------- | --------- | ------------------ | :---------: | -------------------------------------------------- | -| [200](#route-get-policy-tree-export-200) | OK | AlertingFileExport | | [schema](#route-get-policy-tree-export-200-schema) | -| [404](#route-get-policy-tree-export-404) | Not Found | NotFound | | [schema](#route-get-policy-tree-export-404-schema) | - -#### Responses - -##### 200 - AlertingFileExport - -Status: OK - -###### Schema - -[AlertingFileExport](#alerting-file-export) - -##### 404 - NotFound - -Status: Not Found - -###### Schema - -[NotFound](#not-found) - -### Get a notification template. (_RouteGetTemplate_) - -``` -GET /api/v1/provisioning/templates/:name -``` - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------- | -| name | `path` | string | `string` | | ✓ | | Template Name | - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ------------------------------ | --------- | -------------------- | :---------: | ---------------------------------------- | -| [200](#route-get-template-200) | OK | NotificationTemplate | | [schema](#route-get-template-200-schema) | -| [404](#route-get-template-404) | Not Found | Not found. | | [schema](#route-get-template-404-schema) | - -#### Responses - -##### 200 - NotificationTemplate - -Status: OK - -###### Schema - -[NotificationTemplate](#notification-template) - -##### 404 - Not found. - -Status: Not Found - -###### Schema - -### Get all notification templates. (_RouteGetTemplates_) - -``` -GET /api/v1/provisioning/templates -``` - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ------------------------------- | --------- | --------------------- | :---------: | ----------------------------------------- | -| [200](#route-get-templates-200) | OK | NotificationTemplates | | [schema](#route-get-templates-200-schema) | -| [404](#route-get-templates-404) | Not Found | Not found. | | [schema](#route-get-templates-404-schema) | - -#### Responses - -##### 200 - NotificationTemplates - -Status: OK - -###### Schema - -[NotificationTemplates](#notification-templates) - -##### 404 - Not found. - -Status: Not Found - -###### Schema - -### Create a new alert rule. (_RoutePostAlertRule_) - -``` -POST /api/v1/provisioning/alert-rules -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [ProvisionedAlertRule](#provisioned-alert-rule) | `models.ProvisionedAlertRule` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ----------- | -------------------- | :---------: | ------------------------------------------- | -| [201](#route-post-alert-rule-201) | Created | ProvisionedAlertRule | | [schema](#route-post-alert-rule-201-schema) | -| [400](#route-post-alert-rule-400) | Bad Request | ValidationError | | [schema](#route-post-alert-rule-400-schema) | - -#### Responses - -##### 201 - ProvisionedAlertRule - -Status: Created - -###### Schema - -[ProvisionedAlertRule](#provisioned-alert-rule) - -##### 400 - ValidationError - -Status: Bad Request - -###### Schema - -[ValidationError](#validation-error) - -### Create a contact point. (_RoutePostContactpoints_) - -``` -POST /api/v1/provisioning/contact-points -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [EmbeddedContactPoint](#embedded-contact-point) | `models.EmbeddedContactPoint` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ------------------------------------ | ----------- | -------------------- | :---------: | ---------------------------------------------- | -| [202](#route-post-contactpoints-202) | Accepted | EmbeddedContactPoint | | [schema](#route-post-contactpoints-202-schema) | -| [400](#route-post-contactpoints-400) | Bad Request | ValidationError | | [schema](#route-post-contactpoints-400-schema) | - -#### Responses - -##### 202 - EmbeddedContactPoint - -Status: Accepted - -###### Schema - -[EmbeddedContactPoint](#embedded-contact-point) - -##### 400 - ValidationError - -Status: Bad Request - -###### Schema - -[ValidationError](#validation-error) - -### Create a new mute timing. (_RoutePostMuteTiming_) - -``` -POST /api/v1/provisioning/mute-timings -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------------- | -------- | --------------------------------------- | ------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [MuteTimeInterval](#mute-time-interval) | `models.MuteTimeInterval` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ---------------------------------- | ----------- | ---------------- | :---------: | -------------------------------------------- | -| [201](#route-post-mute-timing-201) | Created | MuteTimeInterval | | [schema](#route-post-mute-timing-201-schema) | -| [400](#route-post-mute-timing-400) | Bad Request | ValidationError | | [schema](#route-post-mute-timing-400-schema) | - -#### Responses - -##### 201 - MuteTimeInterval - -Status: Created - -###### Schema - -[MuteTimeInterval](#mute-time-interval) - -##### 400 - ValidationError - -Status: Bad Request - -###### Schema - -[ValidationError](#validation-error) - -### Update an existing alert rule. (_RoutePutAlertRule_) - -``` -PUT /api/v1/provisioning/alert-rules/:uid -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| UID | `path` | string | `string` | | ✓ | | Alert rule UID | -| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [ProvisionedAlertRule](#provisioned-alert-rule) | `models.ProvisionedAlertRule` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| -------------------------------- | ----------- | -------------------- | :---------: | ------------------------------------------ | -| [200](#route-put-alert-rule-200) | OK | ProvisionedAlertRule | | [schema](#route-put-alert-rule-200-schema) | -| [400](#route-put-alert-rule-400) | Bad Request | ValidationError | | [schema](#route-put-alert-rule-400-schema) | - -#### Responses - -##### 200 - ProvisionedAlertRule - -Status: OK - -###### Schema - -[ProvisionedAlertRule](#provisioned-alert-rule) - -##### 400 - ValidationError - -Status: Bad Request - -###### Schema - -[ValidationError](#validation-error) - -### Update the interval or alert rules of a rule group. (_RoutePutAlertRuleGroup_) - -``` -PUT /api/v1/provisioning/folder/:folderUid/rule-groups/:group -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------------- | -------- | ----------------------------------- | ----------------------- | --------- | :------: | ------- | ------------------------------------------------------------------------------------------------------- | -| FolderUID | `path` | string | `string` | | ✓ | | | -| Group | `path` | string | `string` | | ✓ | | | -| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [AlertRuleGroup](#alert-rule-group) | `models.AlertRuleGroup` | | | | This action is idempotent and rules included in this body will overwrite configured rules for the group | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| -------------------------------------- | ----------- | --------------- | :---------: | ------------------------------------------------ | -| [200](#route-put-alert-rule-group-200) | OK | AlertRuleGroup | | [schema](#route-put-alert-rule-group-200-schema) | -| [400](#route-put-alert-rule-group-400) | Bad Request | ValidationError | | [schema](#route-put-alert-rule-group-400-schema) | - -#### Responses - -##### 200 - AlertRuleGroup - -Status: OK - -###### Schema - -[AlertRuleGroup](#alert-rule-group) - -##### 400 - ValidationError - -Status: Bad Request - -###### Schema - -[ValidationError](#validation-error) - -### Update an existing contact point. (_RoutePutContactpoint_) - -``` -PUT /api/v1/provisioning/contact-points/:uid -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| UID | `path` | string | `string` | | ✓ | | UID is the contact point unique identifier | -| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [EmbeddedContactPoint](#embedded-contact-point) | `models.EmbeddedContactPoint` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ---------------------------------- | ----------- | --------------- | :---------: | -------------------------------------------- | -| [202](#route-put-contactpoint-202) | Accepted | Ack | | [schema](#route-put-contactpoint-202-schema) | -| [400](#route-put-contactpoint-400) | Bad Request | ValidationError | | [schema](#route-put-contactpoint-400-schema) | - -#### Responses - -##### 202 - Ack - -Status: Accepted - -###### Schema - -[Ack](#ack) - -##### 400 - ValidationError - -Status: Bad Request - -###### Schema - -[ValidationError](#validation-error) - -### Replace an existing mute timing. (_RoutePutMuteTiming_) - -``` -PUT /api/v1/provisioning/mute-timings/:name -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------------- | -------- | --------------------------------------- | ------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| name | `path` | string | `string` | | ✓ | | Mute timing name | -| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [MuteTimeInterval](#mute-time-interval) | `models.MuteTimeInterval` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ----------- | ---------------- | :---------: | ------------------------------------------- | -| [200](#route-put-mute-timing-200) | OK | MuteTimeInterval | | [schema](#route-put-mute-timing-200-schema) | -| [400](#route-put-mute-timing-400) | Bad Request | ValidationError | | [schema](#route-put-mute-timing-400-schema) | - -#### Responses - -##### 200 - MuteTimeInterval - -Status: OK - -###### Schema - -[MuteTimeInterval](#mute-time-interval) - -##### 400 - ValidationError - -Status: Bad Request - -###### Schema - -[ValidationError](#validation-error) - -### Sets the notification policy tree. (_RoutePutPolicyTree_) - -``` -PUT /api/v1/provisioning/policies -``` - -#### Consumes - -- application/json - -#### Parameters - -{{% responsive-table %}} - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------------- | -------- | --------------- | -------------- | --------- | :------: | ------- | --------------------------------------------------------- | -| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [Route](#route) | `models.Route` | | | | The new notification routing tree to use | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| --------------------------------- | ----------- | --------------- | :---------: | ------------------------------------------- | -| [202](#route-put-policy-tree-202) | Accepted | Ack | | [schema](#route-put-policy-tree-202-schema) | -| [400](#route-put-policy-tree-400) | Bad Request | ValidationError | | [schema](#route-put-policy-tree-400-schema) | - -#### Responses - -##### 202 - Ack - -Status: Accepted - -###### Schema - -[Ack](#ack) - -##### 400 - ValidationError - -Status: Bad Request - -###### Schema - -[ValidationError](#validation-error) - -### Updates an existing notification template. (_RoutePutTemplate_) - -``` -PUT /api/v1/provisioning/templates/:name -``` - -#### Consumes - -- application/json - -{{% responsive-table %}} - -#### Parameters - -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------------------------- | -------- | ------------------------------------------------------------- | ------------------------------------ | --------- | :------: | ------- | --------------------------------------------------------- | -| name | `path` | string | `string` | | ✓ | | Template Name | -| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | -| Body | `body` | [NotificationTemplateContent](#notification-template-content) | `models.NotificationTemplateContent` | | | | | - -{{% /responsive-table %}} - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ------------------------------ | ----------- | -------------------- | :---------: | ---------------------------------------- | -| [202](#route-put-template-202) | Accepted | NotificationTemplate | | [schema](#route-put-template-202-schema) | -| [400](#route-put-template-400) | Bad Request | ValidationError | | [schema](#route-put-template-400-schema) | - -#### Responses - -##### 202 - NotificationTemplate - -Status: Accepted - -###### Schema - -[NotificationTemplate](#notification-template) - -##### 400 - ValidationError - -Status: Bad Request - -###### Schema - -[ValidationError](#validation-error) - -### Clears the notification policy tree. (_RouteResetPolicyTree_) - -``` -DELETE /api/v1/provisioning/policies -``` - -#### Consumes - -- application/json - -#### All responses - -| Code | Status | Description | Has headers | Schema | -| ----------------------------------- | -------- | ----------- | :---------: | --------------------------------------------- | -| [202](#route-reset-policy-tree-202) | Accepted | Ack | | [schema](#route-reset-policy-tree-202-schema) | - -#### Responses - -##### 202 - Ack - -Status: Accepted - -###### Schema - -[Ack](#ack) - -## Models - -### Ack - -[interface{}](#interface) - -### AlertQuery - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| --------------------------------------------------------- | ----------------------------------------- | ------------------- | :------: | ------- | ------------------------------------------------------------------------------------------------------ | ------- | -| datasourceUid | string | `string` | | | Grafana data source unique identifier; it should be '**expr**' for a Server Side Expression operation. | | -| model | [interface{}](#interface) | `interface{}` | | | JSON is the raw JSON query and includes the above properties as well as custom properties. | | -| queryType | string | `string` | | | QueryType is an optional identifier for the type of query. | -| It can be used to distinguish different types of queries. | | -| refId | string | `string` | | | RefID is the unique identifier of the query, set by the frontend call. | | -| relativeTimeRange | [RelativeTimeRange](#relative-time-range) | `RelativeTimeRange` | | | | | - -{{% /responsive-table %}} - -### AlertQueryExport - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ----------------- | ----------------------------------------- | ------------------- | :------: | ------- | ----------- | ------- | -| datasourceUid | string | `string` | | | | | -| model | [interface{}](#interface) | `interface{}` | | | | | -| queryType | string | `string` | | | | | -| refId | string | `string` | | | | | -| relativeTimeRange | [RelativeTimeRange](#relative-time-range) | `RelativeTimeRange` | | | | | - -{{% /responsive-table %}} - -### AlertRuleExport - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ------------ | ----------------------------------------- | --------------------- | :------: | ------- | ----------- | ------- | -| annotations | map of string | `map[string]string` | | | | | -| condition | string | `string` | | | | | -| dashboardUid | string | `string` | | | | | -| data | [][AlertQueryExport](#alert-query-export) | `[]*AlertQueryExport` | | | | | -| execErrState | string | `string` | | | | | -| for | [Duration](#duration) | `Duration` | | | | | -| isPaused | boolean | `bool` | | | | | -| labels | map of string | `map[string]string` | | | | | -| noDataState | string | `string` | | | | | -| panelId | int64 (formatted integer) | `int64` | | | | | -| title | string | `string` | | | | | -| uid | string | `string` | | | | | - -{{% /responsive-table %}} - -### AlertRuleGroup - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| --------- | ------------------------------------------------- | ------------------------- | :------: | ------- | ----------- | ------- | -| folderUid | string | `string` | | | | | -| interval | int64 (formatted integer) | `int64` | | | | | -| rules | [][ProvisionedAlertRule](#provisioned-alert-rule) | `[]*ProvisionedAlertRule` | | | | | -| title | string | `string` | | | | | - -{{% /responsive-table %}} - -### AlertRuleGroupExport - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| -------- | --------------------------------------- | -------------------- | :------: | ------- | ----------- | ------- | -| folder | string | `string` | | | | | -| interval | [Duration](#duration) | `Duration` | | | | | -| name | string | `string` | | | | | -| orgId | int64 (formatted integer) | `int64` | | | | | -| rules | [][AlertRuleExport](#alert-rule-export) | `[]*AlertRuleExport` | | | | | - -{{% /responsive-table %}} - -### AlertingFileExport - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ------------- | --------------------------------------------------------- | ----------------------------- | :------: | ------- | ----------- | ------- | -| apiVersion | int64 (formatted integer) | `int64` | | | | | -| contactPoints | [][ContactPointExport](#contact-point-export) | `[]*ContactPointExport` | | | | | -| groups | [][AlertRuleGroupExport](#alert-rule-group-export) | `[]*AlertRuleGroupExport` | | | | | -| policies | [][NotificationPolicyExport](#notification-policy-export) | `[]*NotificationPolicyExport` | | | | | - -{{% /responsive-table %}} - -### ContactPointExport - -**Properties** - -| Name | Type | Go type | Required | Default | Description | Example | -| --------- | ------------------------------------ | ------------------- | :------: | ------- | ----------- | ------- | -| name | string | `string` | | | | | -| orgId | int64 (formatted integer) | `int64` | | | | | -| receivers | [][ReceiverExport](#receiver-export) | `[]*ReceiverExport` | | | | | - -### ContactPoints - -[][EmbeddedContactPoint](#embedded-contact-point) - -### Duration - -| Name | Type | Go type | Default | Description | Example | -| -------- | ------------------------- | ------- | ------- | ----------- | ------- | -| Duration | int64 (formatted integer) | int64 | | | | - -### EmbeddedContactPoint - -> EmbeddedContactPoint is the contact point type that is used -> by grafanas embedded alertmanager implementation. - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ------------------------------------ | ----------------------- | -------- | :------: | ------- | ----------------------------------------------------------------- | --------- | -| disableResolveMessage | boolean | `bool` | | | | `false` | -| name | string | `string` | | | Name is used as grouping key in the UI. Contact points with the | -| same name will be grouped in the UI. | `webhook_1` | -| provenance | string | `string` | | | | | -| settings | [JSON](#json) | `JSON` | ✓ | | | | -| type | string | `string` | ✓ | | | `webhook` | -| uid | string | `string` | | | UID is the unique identifier of the contact point. The UID can be | -| set by the user. | `my_external_reference` | - -{{% /responsive-table %}} - -### Json - -[interface{}](#interface) - -### MatchRegexps - -[MatchRegexps](#match-regexps) - -### MatchType - -| Name | Type | Go type | Default | Description | Example | -| --------- | ------------------------- | ------- | ------- | ----------- | ------- | -| MatchType | int64 (formatted integer) | int64 | | | | - -### Matcher - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ----- | ------------------------ | ----------- | :------: | ------- | ----------- | ------- | -| Name | string | `string` | | | | | -| Type | [MatchType](#match-type) | `MatchType` | | | | | -| Value | string | `string` | | | | | - -{{% /responsive-table %}} - -### Matchers - -> Matchers is a slice of Matchers that is sortable, implements Stringer, and -> provides a Matches method to match a LabelSet against all Matchers in the -> slice. Note that some users of Matchers might require it to be sorted. - -[][Matcher](#matcher) - -### MuteTimeInterval - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| -------------- | -------------------------------- | ----------------- | :------: | ------- | ----------- | ------- | -| name | string | `string` | | | | | -| time_intervals | [][TimeInterval](#time-interval) | `[]*TimeInterval` | | | | | - -{{% /responsive-table %}} - -### MuteTimings - -[][MuteTimeInterval](#mute-time-interval) - -### NotFound - -[interface{}](#interface) - -### NotificationPolicyExport - -**Properties** - -| Name | Type | Go type | Required | Default | Description | Example | -| ------ | ---------------------------- | ------------- | :------: | ------- | ----------- | ------- | -| Policy | [RouteExport](#route-export) | `RouteExport` | | | inline | | -| orgId | int64 (formatted integer) | `int64` | | | | | - -### NotificationTemplate - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ---------- | ------------------------- | ------------ | :------: | ------- | ----------- | ------- | -| name | string | `string` | | | | | -| provenance | [Provenance](#provenance) | `Provenance` | | | | | -| template | string | `string` | | | | | - -{{% /responsive-table %}} - -### NotificationTemplateContent - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| -------- | ------ | -------- | :------: | ------- | ----------- | ------- | -| template | string | `string` | | | | | - -{{% /responsive-table %}} - -### NotificationTemplates - -[][NotificationTemplate](#notification-template) - -### ObjectMatchers - -[Matchers](#matchers) - -#### Inlined models - -### PermissionDenied - -[interface{}](#interface) - -### Provenance - -| Name | Type | Go type | Default | Description | Example | -| ---------- | ------ | ------- | ------- | ----------- | ------- | -| Provenance | string | string | | | | - -### ProvisionedAlertRule - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ------------ | ---------------------------- | ------------------- | :------: | ------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| annotations | map of string | `map[string]string` | | | | `{"runbook_url":"https://supercoolrunbook.com/page/13"}` | -| condition | string | `string` | ✓ | | | `A` | -| data | [][AlertQuery](#alert-query) | `[]*AlertQuery` | ✓ | | | `[{"datasourceUid":"__expr__","model":{"conditions":[{"evaluator":{"params":[0,0],"type":"gt"},"operator":{"type":"and"},"query":{"params":[]},"reducer":{"params":[],"type":"avg"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1 == 1","hide":false,"intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"},"queryType":"","refId":"A","relativeTimeRange":{"from":0,"to":0}}]` | -| execErrState | string | `string` | ✓ | | | | -| folderUID | string | `string` | ✓ | | | `project_x` | -| for | [Duration](#duration) | `Duration` | ✓ | | | | -| id | int64 (formatted integer) | `int64` | | | | | -| isPaused | boolean | `bool` | | | | `false` | -| labels | map of string | `map[string]string` | | | | `{"team":"sre-team-1"}` | -| noDataState | string | `string` | ✓ | | | | -| orgID | int64 (formatted integer) | `int64` | ✓ | | | | -| provenance | [Provenance](#provenance) | `Provenance` | | | | | -| ruleGroup | string | `string` | ✓ | | | `eval_group_1` | -| title | string | `string` | ✓ | | | `Always firing` | -| uid | string | `string` | | | | | -| updated | date-time (formatted string) | `strfmt.DateTime` | | | | | - -{{% /responsive-table %}} - -### ProvisionedAlertRules - -[][ProvisionedAlertRule](#provisioned-alert-rule) - -### RawMessage - -[interface{}](#interface) - -### ReceiverExport - -**Properties** - -| Name | Type | Go type | Required | Default | Description | Example | -| --------------------- | -------------------------- | ------------ | :------: | ------- | ----------- | ------- | -| disableResolveMessage | boolean | `bool` | | | | | -| settings | [RawMessage](#raw-message) | `RawMessage` | | | | | -| type | string | `string` | | | | | -| uid | string | `string` | | | | | - -### Regexp - -> A Regexp is safe for concurrent use by multiple goroutines, -> except for configuration methods, such as Longest. - -[interface{}](#interface) - -### RelativeTimeRange - -> RelativeTimeRange is the per query start and end time -> for requests. - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ---- | --------------------- | ---------- | :------: | ------- | ----------- | ------- | -| from | [Duration](#duration) | `Duration` | | | | | -| to | [Duration](#duration) | `Duration` | | | | | - -{{% /responsive-table %}} - -### Route - -> A Route is a node that contains definitions of how to handle alerts. This is modified -> from the upstream alertmanager in that it adds the ObjectMatchers property. - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ------------------- | ---------------------------------- | ------------------- | :------: | ------- | --------------------------------------- | ------- | -| continue | boolean | `bool` | | | | | -| group_by | []string | `[]string` | | | | | -| group_interval | string | `string` | | | | | -| group_wait | string | `string` | | | | | -| match | map of string | `map[string]string` | | | Deprecated. Remove before v1.0 release. | | -| match_re | [MatchRegexps](#match-regexps) | `MatchRegexps` | | | | | -| matchers | [Matchers](#matchers) | `Matchers` | | | | | -| mute_time_intervals | []string | `[]string` | | | | | -| object_matchers | [ObjectMatchers](#object-matchers) | `ObjectMatchers` | | | | | -| provenance | [Provenance](#provenance) | `Provenance` | | | | | -| receiver | string | `string` | | | | | -| repeat_interval | string | `string` | | | | | -| routes | [][Route](#route) | `[]*Route` | | | | | - -{{% /responsive-table %}} - -### RouteExport - -> RouteExport is the provisioned file export of definitions.Route. This is needed to hide fields that aren't usable in -> provisioning file format. An alternative would be to define a custom MarshalJSON and MarshalYAML that excludes them. - -**Properties** - -| Name | Type | Go type | Required | Default | Description | Example | -| ------------------- | ---------------------------------- | ------------------- | :------: | ------- | --------------------------------------- | ------- | -| continue | boolean | `bool` | | | | | -| group_by | []string | `[]string` | | | | | -| group_interval | string | `string` | | | | | -| group_wait | string | `string` | | | | | -| match | map of string | `map[string]string` | | | Deprecated. Remove before v1.0 release. | | -| match_re | [MatchRegexps](#match-regexps) | `MatchRegexps` | | | | | -| matchers | [Matchers](#matchers) | `Matchers` | | | | | -| mute_time_intervals | []string | `[]string` | | | | | -| object_matchers | [ObjectMatchers](#object-matchers) | `ObjectMatchers` | | | | | -| receiver | string | `string` | | | | | -| repeat_interval | string | `string` | | | | | -| routes | [][RouteExport](#route-export) | `[]*RouteExport` | | | | | - -### TimeInterval - -> TimeInterval describes intervals of time. ContainsTime will tell you if a golang time is contained -> within the interval. - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ------------- | -------------------------- | -------------- | :------: | ------- | ----------- | ------- | -| days_of_month | []string | `[]string` | | | | | -| location | string | `string` | | | | | -| months | []string | `[]string` | | | | | -| times | [][TimeRange](#time-range) | `[]*TimeRange` | | | | | -| weekdays | []string | `[]string` | | | | | -| years | []string | `[]string` | | | | | - -{{% /responsive-table %}} - -### TimeRange - -> For example, 4:00PM to End of the day would Begin at 1020 and End at 1440. - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ----------- | ------------------------- | ------- | :------: | ------- | ----------- | ------- | -| EndMinute | int64 (formatted integer) | `int64` | | | | | -| StartMinute | int64 (formatted integer) | `int64` | | | | | - -{{% /responsive-table %}} - -### ValidationError - -**Properties** - -{{% responsive-table %}} - -| Name | Type | Go type | Required | Default | Description | Example | -| ---- | ------ | -------- | :------: | ------- | ----------- | --------------- | -| msg | string | `string` | | | | `error message` | - -{{% /responsive-table %}} +{{< docs/shared lookup="alerts/alerting_provisioning.md" source="grafana" version="latest" >}} diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index d55ddae6997..afc76be7de0 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -55,7 +55,6 @@ Some features are enabled by default. You can disable these feature by setting t | `lokiQueryHints` | Enables query hints for Loki | Yes | | `alertingPreviewUpgrade` | Show Unified Alerting preview and upgrade page in legacy alerting | Yes | | `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | | -| `jitterAlertRules` | Distributes alert rule evaluations more evenly over time, by rule group | | ## Preview feature toggles @@ -160,6 +159,7 @@ Experimental features might be changed or removed without prior notice. | `annotationPermissionUpdate` | Separate annotation permissions from dashboard permissions to allow for more granular control. | | `extractFieldsNameDeduplication` | Make sure extracted field names are unique in the dataframe | | `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | +| `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels | | `dashboardScene` | Enables dashboard rendering using scenes for all roles | | `ssoSettingsApi` | Enables the SSO settings API | | `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | diff --git a/docs/sources/shared/alerts/alerting_provisioning.md b/docs/sources/shared/alerts/alerting_provisioning.md new file mode 100644 index 00000000000..7be0b2d010c --- /dev/null +++ b/docs/sources/shared/alerts/alerting_provisioning.md @@ -0,0 +1,1622 @@ +--- +labels: + products: + - enterprise + - oss +title: 'Alerting Provisioning HTTP API ' +--- + +The Alerting provisioning API can be used to create, modify, and delete resources relevant to [Grafana Managed alerts]({{< relref "/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule" >}}). And is the one used by our [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs). + +For managing resources related to [data source-managed alerts]({{< relref "/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule" >}}) including Recording Rules, you can use [Mimir tool](https://grafana.com/docs/mimir/latest/manage/tools/mimirtool/) and [Cortex tool](https://github.com/grafana/cortex-tools#cortextool) respectively. + +## Information + +### Version + +1.1.0 + +## Content negotiation + +### Consumes + +- application/json + +### Produces + +- application/json +- text/yaml +- application/yaml + +## All endpoints + +### Alert rules + +| Method | URI | Name | Summary | +| ------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------- | +| DELETE | /api/v1/provisioning/alert-rules/:uid | [route delete alert rule](#route-delete-alert-rule) | Delete a specific alert rule by UID. | +| GET | /api/v1/provisioning/alert-rules/:uid | [route get alert rule](#route-get-alert-rule) | Get a specific alert rule by UID. | +| GET | /api/v1/provisioning/alert-rules/:uid/export | [route get alert rule export](#route-get-alert-rule-export) | Export an alert rule in provisioning file format. | +| GET | /api/v1/provisioning/folder/:folderUid/rule-groups/:group | [route get alert rule group](#route-get-alert-rule-group) | Get a rule group. | +| GET | /api/v1/provisioning/folder/:folderUid/rule-groups/:group/export | [route get alert rule group export](#route-get-alert-rule-group-export) | Export an alert rule group in provisioning file format. | +| GET | /api/v1/provisioning/alert-rules | [route get alert rules](#route-get-alert-rules) | Get all the alert rules. | +| GET | /api/v1/provisioning/alert-rules/export | [route get alert rules export](#route-get-alert-rules-export) | Export all alert rules in provisioning file format. | +| POST | /api/v1/provisioning/alert-rules | [route post alert rule](#route-post-alert-rule) | Create a new alert rule. | +| PUT | /api/v1/provisioning/alert-rules/:uid | [route put alert rule](#route-put-alert-rule) | Update an existing alert rule. | +| PUT | /api/v1/provisioning/folder/:folderUid/rule-groups/:group | [route put alert rule group](#route-put-alert-rule-group) | Update the interval of a rule group or modify the rules of the group. | + +#### Example alert rules template + +```json +{ + "title": "TEST-API_1", + "ruleGroup": "API", + "folderUID": "FOLDER", + "noDataState": "OK", + "execErrState": "OK", + "for": "5m", + "orgId": 1, + "uid": "", + "condition": "B", + "annotations": { + "summary": "test_api_1" + }, + "labels": { + "API": "test1" + }, + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": " XXXXXXXXX-XXXXXXXXX-XXXXXXXXXX", + "model": { + "expr": "up", + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A" + } + }, + { + "refId": "B", + "queryType": "", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "-100", + "model": { + "conditions": [ + { + "evaluator": { + "params": [6], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": ["A"] + }, + "reducer": { + "params": [], + "type": "last" + }, + "type": "query" + } + ], + "datasource": { + "type": "__expr__", + "uid": "-100" + }, + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "B", + "type": "classic_conditions" + } + } + ] +} +``` + +### Contact points + +| Method | URI | Name | Summary | +| ------ | ------------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------ | +| DELETE | /api/v1/provisioning/contact-points/:uid | [route delete contactpoints](#route-delete-contactpoints) | Delete a contact point. | +| GET | /api/v1/provisioning/contact-points | [route get contactpoints](#route-get-contactpoints) | Get all the contact points. | +| GET | /api/v1/provisioning/contact-points/export | [route get contactpoints export](#route-get-contactpoints-export) | Export all contact points in provisioning file format. | +| POST | /api/v1/provisioning/contact-points | [route post contactpoints](#route-post-contactpoints) | Create a contact point. | +| PUT | /api/v1/provisioning/contact-points/:uid | [route put contactpoint](#route-put-contactpoint) | Update an existing contact point. | + +### Notification policies + +| Method | URI | Name | Summary | +| ------ | ------------------------------------ | ------------------------------------------------------------- | ---------------------------------------------------------------- | +| DELETE | /api/v1/provisioning/policies | [route reset policy tree](#route-reset-policy-tree) | Clears the notification policy tree. | +| GET | /api/v1/provisioning/policies | [route get policy tree](#route-get-policy-tree) | Get the notification policy tree. | +| GET | /api/v1/provisioning/policies/export | [route get policy tree export](#route-get-policy-tree-export) | Export the notification policy tree in provisioning file format. | +| PUT | /api/v1/provisioning/policies | [route put policy tree](#route-put-policy-tree) | Sets the notification policy tree. | + +### Mute timings + +| Method | URI | Name | Summary | +| ------ | --------------------------------------- | ----------------------------------------------------- | -------------------------------- | +| DELETE | /api/v1/provisioning/mute-timings/:name | [route delete mute timing](#route-delete-mute-timing) | Delete a mute timing. | +| GET | /api/v1/provisioning/mute-timings/:name | [route get mute timing](#route-get-mute-timing) | Get a mute timing. | +| GET | /api/v1/provisioning/mute-timings | [route get mute timings](#route-get-mute-timings) | Get all the mute timings. | +| POST | /api/v1/provisioning/mute-timings | [route post mute timing](#route-post-mute-timing) | Create a new mute timing. | +| PUT | /api/v1/provisioning/mute-timings/:name | [route put mute timing](#route-put-mute-timing) | Replace an existing mute timing. | + +### Templates + +| Method | URI | Name | Summary | +| ------ | ------------------------------------ | ----------------------------------------------- | ------------------------------------------ | +| DELETE | /api/v1/provisioning/templates/:name | [route delete template](#route-delete-template) | Delete a template. | +| GET | /api/v1/provisioning/templates/:name | [route get template](#route-get-template) | Get a notification template. | +| GET | /api/v1/provisioning/templates | [route get templates](#route-get-templates) | Get all notification templates. | +| PUT | /api/v1/provisioning/templates/:name | [route put template](#route-put-template) | Updates an existing notification template. | + +## Edit resources in the Grafana UI + +By default, you cannot edit API-provisioned alerting resources in Grafana. To enable editing these resources in the Grafana UI, add the `X-Disable-Provenance` header to the following requests in the API: + +- `POST /api/v1/provisioning/alert-rules` +- `PUT /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}` (calling this endpoint will change provenance for all alert rules within the alert group) +- `POST /api/v1/provisioning/contact-points` +- `POST /api/v1/provisioning/mute-timings` +- `PUT /api/v1/provisioning/policies` +- `PUT /api/v1/provisioning/templates/{name}` + +To reset the notification policy tree to the default and unlock it for editing in the Grafana UI, use the `DELETE /api/v1/provisioning/policies` endpoint. + +## Paths + +### Delete a specific alert rule by UID. (_RouteDeleteAlertRule_) + +``` +DELETE /api/v1/provisioning/alert-rules/:uid +``` + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | ------ | -------- | --------- | :------: | ------- | --------------------------------------------------------- | +| UID | `path` | string | `string` | | ✓ | | Alert rule UID | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ----------------------------------- | ---------- | ---------------------------------------- | :---------: | --------------------------------------------- | +| [204](#route-delete-alert-rule-204) | No Content | The alert rule was deleted successfully. | | [schema](#route-delete-alert-rule-204-schema) | + +#### Responses + +##### 204 - The alert rule was deleted successfully. + +Status: No Content + +###### Schema + +### Delete a contact point. (_RouteDeleteContactpoints_) + +``` +DELETE /api/v1/provisioning/contact-points/:uid +``` + +#### Consumes + +- application/json + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------------------------------------ | +| UID | `path` | string | `string` | | ✓ | | UID is the contact point unique identifier | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| -------------------------------------- | ---------- | ------------------------------------------- | :---------: | ------------------------------------------------ | +| [204](#route-delete-contactpoints-204) | No Content | The contact point was deleted successfully. | | [schema](#route-delete-contactpoints-204-schema) | + +#### Responses + +##### 204 - The contact point was deleted successfully. + +Status: No Content + +###### Schema + +### Delete a mute timing. (_RouteDeleteMuteTiming_) + +``` +DELETE /api/v1/provisioning/mute-timings/:name +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ---------------- | +| name | `path` | string | `string` | | ✓ | | Mute timing name | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ------------------------------------ | ---------- | ----------------------------------------- | :---------: | ---------------------------------------------- | +| [204](#route-delete-mute-timing-204) | No Content | The mute timing was deleted successfully. | | [schema](#route-delete-mute-timing-204-schema) | + +#### Responses + +##### 204 - The mute timing was deleted successfully. + +Status: No Content + +###### Schema + +### Delete a template. (_RouteDeleteTemplate_) + +``` +DELETE /api/v1/provisioning/templates/:name +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------- | +| name | `path` | string | `string` | | ✓ | | Template Name | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ---------- | -------------------------------------- | :---------: | ------------------------------------------- | +| [204](#route-delete-template-204) | No Content | The template was deleted successfully. | | [schema](#route-delete-template-204-schema) | + +#### Responses + +##### 204 - The template was deleted successfully. + +Status: No Content + +###### Schema + +### Get a specific alert rule by UID. (_RouteGetAlertRule_) + +``` +GET /api/v1/provisioning/alert-rules/:uid +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | -------------- | +| UID | `path` | string | `string` | | ✓ | | Alert rule UID | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| -------------------------------- | --------- | -------------------- | :---------: | ------------------------------------------ | +| [200](#route-get-alert-rule-200) | OK | ProvisionedAlertRule | | [schema](#route-get-alert-rule-200-schema) | +| [404](#route-get-alert-rule-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-404-schema) | + +#### Responses + +##### 200 - ProvisionedAlertRule + +Status: OK + +###### Schema + +[ProvisionedAlertRule](#provisioned-alert-rule) + +##### 404 - Not found. + +Status: Not Found + +###### Schema + +### Export an alert rule in provisioning file format. (_RouteGetAlertRuleExport_) + +``` +GET /api/v1/provisioning/alert-rules/:uid/export +``` + +#### Produces + +- application/json +- application/yaml +- text/yaml + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| UID | `path` | string | `string` | | ✓ | | Alert rule UID | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | +| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------------- | --------- | ------------------ | :---------: | ------------------------------------------------- | +| [200](#route-get-alert-rule-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rule-export-200-schema) | +| [404](#route-get-alert-rule-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-export-404-schema) | + +#### Responses + +##### 200 - AlertingFileExport + +Status: OK + +###### Schema + +[AlertingFileExport](#alerting-file-export) + +##### 404 - Not found. + +Status: Not Found + +###### Schema + +### Get a rule group. (_RouteGetAlertRuleGroup_) + +``` +GET /api/v1/provisioning/folder/:folderUid/rule-groups/:group +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| --------- | ------ | ------ | -------- | --------- | :------: | ------- | ----------- | +| FolderUID | `path` | string | `string` | | ✓ | | | +| Group | `path` | string | `string` | | ✓ | | | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| -------------------------------------- | --------- | -------------- | :---------: | ------------------------------------------------ | +| [200](#route-get-alert-rule-group-200) | OK | AlertRuleGroup | | [schema](#route-get-alert-rule-group-200-schema) | +| [404](#route-get-alert-rule-group-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-group-404-schema) | + +#### Responses + +##### 200 - AlertRuleGroup + +Status: OK + +###### Schema + +[AlertRuleGroup](#alert-rule-group) + +##### 404 - Not found. + +Status: Not Found + +###### Schema + +### Export an alert rule group in provisioning file format. (_RouteGetAlertRuleGroupExport_) + +``` +GET /api/v1/provisioning/folder/:folderUid/rule-groups/:group/export +``` + +#### Produces + +- application/json +- application/yaml +- text/yaml + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| --------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| FolderUID | `path` | string | `string` | | ✓ | | | +| Group | `path` | string | `string` | | ✓ | | | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | +| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------------------- | --------- | ------------------ | :---------: | ------------------------------------------------------- | +| [200](#route-get-alert-rule-group-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rule-group-export-200-schema) | +| [404](#route-get-alert-rule-group-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rule-group-export-404-schema) | + +#### Responses + +##### 200 - AlertingFileExport + +Status: OK + +###### Schema + +[AlertingFileExport](#alerting-file-export) + +##### 404 - Not found. + +Status: Not Found + +###### Schema + +### Get all the alert rules. (_RouteGetAlertRules_) + +``` +GET /api/v1/provisioning/alert-rules +``` + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ------ | --------------------- | :---------: | ------------------------------------------- | +| [200](#route-get-alert-rules-200) | OK | ProvisionedAlertRules | | [schema](#route-get-alert-rules-200-schema) | + +#### Responses + +##### 200 - ProvisionedAlertRules + +Status: OK + +###### Schema + +[ProvisionedAlertRules](#provisioned-alert-rules) + +### Export all alert rules in provisioning file format. (_RouteGetAlertRulesExport_) + +``` +GET /api/v1/provisioning/alert-rules/export +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | +| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ---------------------------------------- | --------- | ------------------ | :---------: | -------------------------------------------------- | +| [200](#route-get-alert-rules-export-200) | OK | AlertingFileExport | | [schema](#route-get-alert-rules-export-200-schema) | +| [404](#route-get-alert-rules-export-404) | Not Found | Not found. | | [schema](#route-get-alert-rules-export-404-schema) | + +#### Responses + +##### 200 - AlertingFileExport + +Status: OK + +###### Schema + +[AlertingFileExport](#alerting-file-export) + +##### 404 - Not found. + +Status: Not Found + +###### Schema + +### Get all the contact points. (_RouteGetContactpoints_) + +``` +GET /api/v1/provisioning/contact-points +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------- | ------ | -------- | --------- | :------: | ------- | -------------- | +| name | `query` | string | `string` | | | | Filter by name | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ----------------------------------- | ------ | ------------- | :---------: | --------------------------------------------- | +| [200](#route-get-contactpoints-200) | OK | ContactPoints | | [schema](#route-get-contactpoints-200-schema) | + +#### Responses + +##### 200 - ContactPoints + +Status: OK + +###### Schema + +[ContactPoints](#contact-points) + +### Export all contact points in provisioning file format. (_RouteGetContactpointsExport_) + +``` +GET /api/v1/provisioning/contact-points/export +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------- | ------- | ------- | -------- | --------- | :------: | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| decrypt | `query` | boolean | `bool` | | | | Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings. | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | +| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | +| name | `query` | string | `string` | | | | Filter by name | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ------------------------------------------ | --------- | ------------------ | :---------: | ---------------------------------------------------- | +| [200](#route-get-contactpoints-export-200) | OK | AlertingFileExport | | [schema](#route-get-contactpoints-export-200-schema) | +| [403](#route-get-contactpoints-export-403) | Forbidden | PermissionDenied | | [schema](#route-get-contactpoints-export-403-schema) | + +#### Responses + +##### 200 - AlertingFileExport + +Status: OK + +###### Schema + +[AlertingFileExport](#alerting-file-export) + +##### 403 - PermissionDenied + +Status: Forbidden + +###### Schema + +[PermissionDenied](#permission-denied) + +### Get a mute timing. (_RouteGetMuteTiming_) + +``` +GET /api/v1/provisioning/mute-timings/:name +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ---------------- | +| name | `path` | string | `string` | | ✓ | | Mute timing name | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | --------- | ---------------- | :---------: | ------------------------------------------- | +| [200](#route-get-mute-timing-200) | OK | MuteTimeInterval | | [schema](#route-get-mute-timing-200-schema) | +| [404](#route-get-mute-timing-404) | Not Found | Not found. | | [schema](#route-get-mute-timing-404-schema) | + +#### Responses + +##### 200 - MuteTimeInterval + +Status: OK + +###### Schema + +[MuteTimeInterval](#mute-time-interval) + +##### 404 - Not found. + +Status: Not Found + +###### Schema + +### Get all the mute timings. (_RouteGetMuteTimings_) + +``` +GET /api/v1/provisioning/mute-timings +``` + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ---------------------------------- | ------ | ----------- | :---------: | -------------------------------------------- | +| [200](#route-get-mute-timings-200) | OK | MuteTimings | | [schema](#route-get-mute-timings-200-schema) | + +#### Responses + +##### 200 - MuteTimings + +Status: OK + +###### Schema + +[MuteTimings](#mute-timings) + +### Get the notification policy tree. (_RouteGetPolicyTree_) + +``` +GET /api/v1/provisioning/policies +``` + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ------ | ----------- | :---------: | ------------------------------------------- | +| [200](#route-get-policy-tree-200) | OK | Route | | [schema](#route-get-policy-tree-200-schema) | + +#### Responses + +##### 200 - Route + +Status: OK + +###### Schema + +[Route](#route) + +### Export the notification policy tree in provisioning file format. (_RouteGetPolicyTreeExport_) + +``` +GET /api/v1/provisioning/policies/export +``` + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ---------------------------------------- | --------- | ------------------ | :---------: | -------------------------------------------------- | +| [200](#route-get-policy-tree-export-200) | OK | AlertingFileExport | | [schema](#route-get-policy-tree-export-200-schema) | +| [404](#route-get-policy-tree-export-404) | Not Found | NotFound | | [schema](#route-get-policy-tree-export-404-schema) | + +#### Responses + +##### 200 - AlertingFileExport + +Status: OK + +###### Schema + +[AlertingFileExport](#alerting-file-export) + +##### 404 - NotFound + +Status: Not Found + +###### Schema + +[NotFound](#not-found) + +### Get a notification template. (_RouteGetTemplate_) + +``` +GET /api/v1/provisioning/templates/:name +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| ---- | ------ | ------ | -------- | --------- | :------: | ------- | ------------- | +| name | `path` | string | `string` | | ✓ | | Template Name | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ------------------------------ | --------- | -------------------- | :---------: | ---------------------------------------- | +| [200](#route-get-template-200) | OK | NotificationTemplate | | [schema](#route-get-template-200-schema) | +| [404](#route-get-template-404) | Not Found | Not found. | | [schema](#route-get-template-404-schema) | + +#### Responses + +##### 200 - NotificationTemplate + +Status: OK + +###### Schema + +[NotificationTemplate](#notification-template) + +##### 404 - Not found. + +Status: Not Found + +###### Schema + +### Get all notification templates. (_RouteGetTemplates_) + +``` +GET /api/v1/provisioning/templates +``` + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ------------------------------- | --------- | --------------------- | :---------: | ----------------------------------------- | +| [200](#route-get-templates-200) | OK | NotificationTemplates | | [schema](#route-get-templates-200-schema) | +| [404](#route-get-templates-404) | Not Found | Not found. | | [schema](#route-get-templates-404-schema) | + +#### Responses + +##### 200 - NotificationTemplates + +Status: OK + +###### Schema + +[NotificationTemplates](#notification-templates) + +##### 404 - Not found. + +Status: Not Found + +###### Schema + +### Create a new alert rule. (_RoutePostAlertRule_) + +``` +POST /api/v1/provisioning/alert-rules +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [ProvisionedAlertRule](#provisioned-alert-rule) | `models.ProvisionedAlertRule` | | | | | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ----------- | -------------------- | :---------: | ------------------------------------------- | +| [201](#route-post-alert-rule-201) | Created | ProvisionedAlertRule | | [schema](#route-post-alert-rule-201-schema) | +| [400](#route-post-alert-rule-400) | Bad Request | ValidationError | | [schema](#route-post-alert-rule-400-schema) | + +#### Responses + +##### 201 - ProvisionedAlertRule + +Status: Created + +###### Schema + +[ProvisionedAlertRule](#provisioned-alert-rule) + +##### 400 - ValidationError + +Status: Bad Request + +###### Schema + +[ValidationError](#validation-error) + +### Create a contact point. (_RoutePostContactpoints_) + +``` +POST /api/v1/provisioning/contact-points +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [EmbeddedContactPoint](#embedded-contact-point) | `models.EmbeddedContactPoint` | | | | | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ------------------------------------ | ----------- | -------------------- | :---------: | ---------------------------------------------- | +| [202](#route-post-contactpoints-202) | Accepted | EmbeddedContactPoint | | [schema](#route-post-contactpoints-202-schema) | +| [400](#route-post-contactpoints-400) | Bad Request | ValidationError | | [schema](#route-post-contactpoints-400-schema) | + +#### Responses + +##### 202 - EmbeddedContactPoint + +Status: Accepted + +###### Schema + +[EmbeddedContactPoint](#embedded-contact-point) + +##### 400 - ValidationError + +Status: Bad Request + +###### Schema + +[ValidationError](#validation-error) + +### Create a new mute timing. (_RoutePostMuteTiming_) + +``` +POST /api/v1/provisioning/mute-timings +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | --------------------------------------- | ------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [MuteTimeInterval](#mute-time-interval) | `models.MuteTimeInterval` | | | | | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ---------------------------------- | ----------- | ---------------- | :---------: | -------------------------------------------- | +| [201](#route-post-mute-timing-201) | Created | MuteTimeInterval | | [schema](#route-post-mute-timing-201-schema) | +| [400](#route-post-mute-timing-400) | Bad Request | ValidationError | | [schema](#route-post-mute-timing-400-schema) | + +#### Responses + +##### 201 - MuteTimeInterval + +Status: Created + +###### Schema + +[MuteTimeInterval](#mute-time-interval) + +##### 400 - ValidationError + +Status: Bad Request + +###### Schema + +[ValidationError](#validation-error) + +### Update an existing alert rule. (_RoutePutAlertRule_) + +``` +PUT /api/v1/provisioning/alert-rules/:uid +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | +| UID | `path` | string | `string` | | ✓ | | Alert rule UID | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [ProvisionedAlertRule](#provisioned-alert-rule) | `models.ProvisionedAlertRule` | | | | | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| -------------------------------- | ----------- | -------------------- | :---------: | ------------------------------------------ | +| [200](#route-put-alert-rule-200) | OK | ProvisionedAlertRule | | [schema](#route-put-alert-rule-200-schema) | +| [400](#route-put-alert-rule-400) | Bad Request | ValidationError | | [schema](#route-put-alert-rule-400-schema) | + +#### Responses + +##### 200 - ProvisionedAlertRule + +Status: OK + +###### Schema + +[ProvisionedAlertRule](#provisioned-alert-rule) + +##### 400 - ValidationError + +Status: Bad Request + +###### Schema + +[ValidationError](#validation-error) + +### Update the interval or alert rules of a rule group. (_RoutePutAlertRuleGroup_) + +``` +PUT /api/v1/provisioning/folder/:folderUid/rule-groups/:group +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | ----------------------------------- | ----------------------- | --------- | :------: | ------- | ------------------------------------------------------------------------------------------------------- | +| FolderUID | `path` | string | `string` | | ✓ | | | +| Group | `path` | string | `string` | | ✓ | | | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [AlertRuleGroup](#alert-rule-group) | `models.AlertRuleGroup` | | | | This action is idempotent and rules included in this body will overwrite configured rules for the group | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| -------------------------------------- | ----------- | --------------- | :---------: | ------------------------------------------------ | +| [200](#route-put-alert-rule-group-200) | OK | AlertRuleGroup | | [schema](#route-put-alert-rule-group-200-schema) | +| [400](#route-put-alert-rule-group-400) | Bad Request | ValidationError | | [schema](#route-put-alert-rule-group-400-schema) | + +#### Responses + +##### 200 - AlertRuleGroup + +Status: OK + +###### Schema + +[AlertRuleGroup](#alert-rule-group) + +##### 400 - ValidationError + +Status: Bad Request + +###### Schema + +[ValidationError](#validation-error) + +### Update an existing contact point. (_RoutePutContactpoint_) + +``` +PUT /api/v1/provisioning/contact-points/:uid +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | ----------------------------------------------- | ----------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | +| UID | `path` | string | `string` | | ✓ | | UID is the contact point unique identifier | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [EmbeddedContactPoint](#embedded-contact-point) | `models.EmbeddedContactPoint` | | | | | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ---------------------------------- | ----------- | --------------- | :---------: | -------------------------------------------- | +| [202](#route-put-contactpoint-202) | Accepted | Ack | | [schema](#route-put-contactpoint-202-schema) | +| [400](#route-put-contactpoint-400) | Bad Request | ValidationError | | [schema](#route-put-contactpoint-400-schema) | + +#### Responses + +##### 202 - Ack + +Status: Accepted + +###### Schema + +[Ack](#ack) + +##### 400 - ValidationError + +Status: Bad Request + +###### Schema + +[ValidationError](#validation-error) + +### Replace an existing mute timing. (_RoutePutMuteTiming_) + +``` +PUT /api/v1/provisioning/mute-timings/:name +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | --------------------------------------- | ------------------------- | --------- | :------: | ------- | --------------------------------------------------------- | +| name | `path` | string | `string` | | ✓ | | Mute timing name | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [MuteTimeInterval](#mute-time-interval) | `models.MuteTimeInterval` | | | | | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ----------- | ---------------- | :---------: | ------------------------------------------- | +| [200](#route-put-mute-timing-200) | OK | MuteTimeInterval | | [schema](#route-put-mute-timing-200-schema) | +| [400](#route-put-mute-timing-400) | Bad Request | ValidationError | | [schema](#route-put-mute-timing-400-schema) | + +#### Responses + +##### 200 - MuteTimeInterval + +Status: OK + +###### Schema + +[MuteTimeInterval](#mute-time-interval) + +##### 400 - ValidationError + +Status: Bad Request + +###### Schema + +[ValidationError](#validation-error) + +### Sets the notification policy tree. (_RoutePutPolicyTree_) + +``` +PUT /api/v1/provisioning/policies +``` + +#### Consumes + +- application/json + +#### Parameters + +{{% responsive-table %}} + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | --------------- | -------------- | --------- | :------: | ------- | --------------------------------------------------------- | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [Route](#route) | `models.Route` | | | | The new notification routing tree to use | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| --------------------------------- | ----------- | --------------- | :---------: | ------------------------------------------- | +| [202](#route-put-policy-tree-202) | Accepted | Ack | | [schema](#route-put-policy-tree-202-schema) | +| [400](#route-put-policy-tree-400) | Bad Request | ValidationError | | [schema](#route-put-policy-tree-400-schema) | + +#### Responses + +##### 202 - Ack + +Status: Accepted + +###### Schema + +[Ack](#ack) + +##### 400 - ValidationError + +Status: Bad Request + +###### Schema + +[ValidationError](#validation-error) + +### Updates an existing notification template. (_RoutePutTemplate_) + +``` +PUT /api/v1/provisioning/templates/:name +``` + +#### Consumes + +- application/json + +{{% responsive-table %}} + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------------------------- | -------- | ------------------------------------------------------------- | ------------------------------------ | --------- | :------: | ------- | --------------------------------------------------------- | +| name | `path` | string | `string` | | ✓ | | Template Name | +| X-Disable-Provenance: true | `header` | string | `string` | | | | Allows editing of provisioned resources in the Grafana UI | +| Body | `body` | [NotificationTemplateContent](#notification-template-content) | `models.NotificationTemplateContent` | | | | | + +{{% /responsive-table %}} + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ------------------------------ | ----------- | -------------------- | :---------: | ---------------------------------------- | +| [202](#route-put-template-202) | Accepted | NotificationTemplate | | [schema](#route-put-template-202-schema) | +| [400](#route-put-template-400) | Bad Request | ValidationError | | [schema](#route-put-template-400-schema) | + +#### Responses + +##### 202 - NotificationTemplate + +Status: Accepted + +###### Schema + +[NotificationTemplate](#notification-template) + +##### 400 - ValidationError + +Status: Bad Request + +###### Schema + +[ValidationError](#validation-error) + +### Clears the notification policy tree. (_RouteResetPolicyTree_) + +``` +DELETE /api/v1/provisioning/policies +``` + +#### Consumes + +- application/json + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ----------------------------------- | -------- | ----------- | :---------: | --------------------------------------------- | +| [202](#route-reset-policy-tree-202) | Accepted | Ack | | [schema](#route-reset-policy-tree-202-schema) | + +#### Responses + +##### 202 - Ack + +Status: Accepted + +###### Schema + +[Ack](#ack) + +## Models + +### Ack + +[interface{}](#interface) + +### AlertQuery + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| --------------------------------------------------------- | ----------------------------------------- | ------------------- | :------: | ------- | ------------------------------------------------------------------------------------------------------ | ------- | +| datasourceUid | string | `string` | | | Grafana data source unique identifier; it should be '**expr**' for a Server Side Expression operation. | | +| model | [interface{}](#interface) | `interface{}` | | | JSON is the raw JSON query and includes the above properties as well as custom properties. | | +| queryType | string | `string` | | | QueryType is an optional identifier for the type of query. | +| It can be used to distinguish different types of queries. | | +| refId | string | `string` | | | RefID is the unique identifier of the query, set by the frontend call. | | +| relativeTimeRange | [RelativeTimeRange](#relative-time-range) | `RelativeTimeRange` | | | | | + +{{% /responsive-table %}} + +### AlertQueryExport + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ----------------- | ----------------------------------------- | ------------------- | :------: | ------- | ----------- | ------- | +| datasourceUid | string | `string` | | | | | +| model | [interface{}](#interface) | `interface{}` | | | | | +| queryType | string | `string` | | | | | +| refId | string | `string` | | | | | +| relativeTimeRange | [RelativeTimeRange](#relative-time-range) | `RelativeTimeRange` | | | | | + +{{% /responsive-table %}} + +### AlertRuleExport + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------ | ----------------------------------------- | --------------------- | :------: | ------- | ----------- | ------- | +| annotations | map of string | `map[string]string` | | | | | +| condition | string | `string` | | | | | +| dashboardUid | string | `string` | | | | | +| data | [][AlertQueryExport](#alert-query-export) | `[]*AlertQueryExport` | | | | | +| execErrState | string | `string` | | | | | +| for | [Duration](#duration) | `Duration` | | | | | +| isPaused | boolean | `bool` | | | | | +| labels | map of string | `map[string]string` | | | | | +| noDataState | string | `string` | | | | | +| panelId | int64 (formatted integer) | `int64` | | | | | +| title | string | `string` | | | | | +| uid | string | `string` | | | | | + +{{% /responsive-table %}} + +### AlertRuleGroup + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| --------- | ------------------------------------------------- | ------------------------- | :------: | ------- | ----------- | ------- | +| folderUid | string | `string` | | | | | +| interval | int64 (formatted integer) | `int64` | | | | | +| rules | [][ProvisionedAlertRule](#provisioned-alert-rule) | `[]*ProvisionedAlertRule` | | | | | +| title | string | `string` | | | | | + +{{% /responsive-table %}} + +### AlertRuleGroupExport + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| -------- | --------------------------------------- | -------------------- | :------: | ------- | ----------- | ------- | +| folder | string | `string` | | | | | +| interval | [Duration](#duration) | `Duration` | | | | | +| name | string | `string` | | | | | +| orgId | int64 (formatted integer) | `int64` | | | | | +| rules | [][AlertRuleExport](#alert-rule-export) | `[]*AlertRuleExport` | | | | | + +{{% /responsive-table %}} + +### AlertingFileExport + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------- | --------------------------------------------------------- | ----------------------------- | :------: | ------- | ----------- | ------- | +| apiVersion | int64 (formatted integer) | `int64` | | | | | +| contactPoints | [][ContactPointExport](#contact-point-export) | `[]*ContactPointExport` | | | | | +| groups | [][AlertRuleGroupExport](#alert-rule-group-export) | `[]*AlertRuleGroupExport` | | | | | +| policies | [][NotificationPolicyExport](#notification-policy-export) | `[]*NotificationPolicyExport` | | | | | + +{{% /responsive-table %}} + +### ContactPointExport + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| --------- | ------------------------------------ | ------------------- | :------: | ------- | ----------- | ------- | +| name | string | `string` | | | | | +| orgId | int64 (formatted integer) | `int64` | | | | | +| receivers | [][ReceiverExport](#receiver-export) | `[]*ReceiverExport` | | | | | + +### ContactPoints + +[][EmbeddedContactPoint](#embedded-contact-point) + +### Duration + +| Name | Type | Go type | Default | Description | Example | +| -------- | ------------------------- | ------- | ------- | ----------- | ------- | +| Duration | int64 (formatted integer) | int64 | | | | + +### EmbeddedContactPoint + +> EmbeddedContactPoint is the contact point type that is used +> by grafanas embedded alertmanager implementation. + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------------------------------ | ----------------------- | -------- | :------: | ------- | ----------------------------------------------------------------- | --------- | +| disableResolveMessage | boolean | `bool` | | | | `false` | +| name | string | `string` | | | Name is used as grouping key in the UI. Contact points with the | +| same name will be grouped in the UI. | `webhook_1` | +| provenance | string | `string` | | | | | +| settings | [JSON](#json) | `JSON` | ✓ | | | | +| type | string | `string` | ✓ | | | `webhook` | +| uid | string | `string` | | | UID is the unique identifier of the contact point. The UID can be | +| set by the user. | `my_external_reference` | + +{{% /responsive-table %}} + +### Json + +[interface{}](#interface) + +### MatchRegexps + +[MatchRegexps](#match-regexps) + +### MatchType + +| Name | Type | Go type | Default | Description | Example | +| --------- | ------------------------- | ------- | ------- | ----------- | ------- | +| MatchType | int64 (formatted integer) | int64 | | | | + +### Matcher + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ----- | ------------------------ | ----------- | :------: | ------- | ----------- | ------- | +| Name | string | `string` | | | | | +| Type | [MatchType](#match-type) | `MatchType` | | | | | +| Value | string | `string` | | | | | + +{{% /responsive-table %}} + +### Matchers + +> Matchers is a slice of Matchers that is sortable, implements Stringer, and +> provides a Matches method to match a LabelSet against all Matchers in the +> slice. Note that some users of Matchers might require it to be sorted. + +[][Matcher](#matcher) + +### MuteTimeInterval + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| -------------- | -------------------------------- | ----------------- | :------: | ------- | ----------- | ------- | +| name | string | `string` | | | | | +| time_intervals | [][TimeInterval](#time-interval) | `[]*TimeInterval` | | | | | + +{{% /responsive-table %}} + +### MuteTimings + +[][MuteTimeInterval](#mute-time-interval) + +### NotFound + +[interface{}](#interface) + +### NotificationPolicyExport + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| ------ | ---------------------------- | ------------- | :------: | ------- | ----------- | ------- | +| Policy | [RouteExport](#route-export) | `RouteExport` | | | inline | | +| orgId | int64 (formatted integer) | `int64` | | | | | + +### NotificationTemplate + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ---------- | ------------------------- | ------------ | :------: | ------- | ----------- | ------- | +| name | string | `string` | | | | | +| provenance | [Provenance](#provenance) | `Provenance` | | | | | +| template | string | `string` | | | | | + +{{% /responsive-table %}} + +### NotificationTemplateContent + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| -------- | ------ | -------- | :------: | ------- | ----------- | ------- | +| template | string | `string` | | | | | + +{{% /responsive-table %}} + +### NotificationTemplates + +[][NotificationTemplate](#notification-template) + +### ObjectMatchers + +[Matchers](#matchers) + +#### Inlined models + +### PermissionDenied + +[interface{}](#interface) + +### Provenance + +| Name | Type | Go type | Default | Description | Example | +| ---------- | ------ | ------- | ------- | ----------- | ------- | +| Provenance | string | string | | | | + +### ProvisionedAlertRule + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------ | ---------------------------- | ------------------- | :------: | ------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| annotations | map of string | `map[string]string` | | | | `{"runbook_url":"https://supercoolrunbook.com/page/13"}` | +| condition | string | `string` | ✓ | | | `A` | +| data | [][AlertQuery](#alert-query) | `[]*AlertQuery` | ✓ | | | `[{"datasourceUid":"__expr__","model":{"conditions":[{"evaluator":{"params":[0,0],"type":"gt"},"operator":{"type":"and"},"query":{"params":[]},"reducer":{"params":[],"type":"avg"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1 == 1","hide":false,"intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"},"queryType":"","refId":"A","relativeTimeRange":{"from":0,"to":0}}]` | +| execErrState | string | `string` | ✓ | | | | +| folderUID | string | `string` | ✓ | | | `project_x` | +| for | [Duration](#duration) | `Duration` | ✓ | | | | +| id | int64 (formatted integer) | `int64` | | | | | +| isPaused | boolean | `bool` | | | | `false` | +| labels | map of string | `map[string]string` | | | | `{"team":"sre-team-1"}` | +| noDataState | string | `string` | ✓ | | | | +| orgID | int64 (formatted integer) | `int64` | ✓ | | | | +| provenance | [Provenance](#provenance) | `Provenance` | | | | | +| ruleGroup | string | `string` | ✓ | | | `eval_group_1` | +| title | string | `string` | ✓ | | | `Always firing` | +| uid | string | `string` | | | | | +| updated | date-time (formatted string) | `strfmt.DateTime` | | | | | + +{{% /responsive-table %}} + +### ProvisionedAlertRules + +[][ProvisionedAlertRule](#provisioned-alert-rule) + +### RawMessage + +[interface{}](#interface) + +### ReceiverExport + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| --------------------- | -------------------------- | ------------ | :------: | ------- | ----------- | ------- | +| disableResolveMessage | boolean | `bool` | | | | | +| settings | [RawMessage](#raw-message) | `RawMessage` | | | | | +| type | string | `string` | | | | | +| uid | string | `string` | | | | | + +### Regexp + +> A Regexp is safe for concurrent use by multiple goroutines, +> except for configuration methods, such as Longest. + +[interface{}](#interface) + +### RelativeTimeRange + +> RelativeTimeRange is the per query start and end time +> for requests. + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ---- | --------------------- | ---------- | :------: | ------- | ----------- | ------- | +| from | [Duration](#duration) | `Duration` | | | | | +| to | [Duration](#duration) | `Duration` | | | | | + +{{% /responsive-table %}} + +### Route + +> A Route is a node that contains definitions of how to handle alerts. This is modified +> from the upstream alertmanager in that it adds the ObjectMatchers property. + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------------- | ---------------------------------- | ------------------- | :------: | ------- | --------------------------------------- | ------- | +| continue | boolean | `bool` | | | | | +| group_by | []string | `[]string` | | | | | +| group_interval | string | `string` | | | | | +| group_wait | string | `string` | | | | | +| match | map of string | `map[string]string` | | | Deprecated. Remove before v1.0 release. | | +| match_re | [MatchRegexps](#match-regexps) | `MatchRegexps` | | | | | +| matchers | [Matchers](#matchers) | `Matchers` | | | | | +| mute_time_intervals | []string | `[]string` | | | | | +| object_matchers | [ObjectMatchers](#object-matchers) | `ObjectMatchers` | | | | | +| provenance | [Provenance](#provenance) | `Provenance` | | | | | +| receiver | string | `string` | | | | | +| repeat_interval | string | `string` | | | | | +| routes | [][Route](#route) | `[]*Route` | | | | | + +{{% /responsive-table %}} + +### RouteExport + +> RouteExport is the provisioned file export of definitions.Route. This is needed to hide fields that aren't usable in +> provisioning file format. An alternative would be to define a custom MarshalJSON and MarshalYAML that excludes them. + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------------- | ---------------------------------- | ------------------- | :------: | ------- | --------------------------------------- | ------- | +| continue | boolean | `bool` | | | | | +| group_by | []string | `[]string` | | | | | +| group_interval | string | `string` | | | | | +| group_wait | string | `string` | | | | | +| match | map of string | `map[string]string` | | | Deprecated. Remove before v1.0 release. | | +| match_re | [MatchRegexps](#match-regexps) | `MatchRegexps` | | | | | +| matchers | [Matchers](#matchers) | `Matchers` | | | | | +| mute_time_intervals | []string | `[]string` | | | | | +| object_matchers | [ObjectMatchers](#object-matchers) | `ObjectMatchers` | | | | | +| receiver | string | `string` | | | | | +| repeat_interval | string | `string` | | | | | +| routes | [][RouteExport](#route-export) | `[]*RouteExport` | | | | | + +### TimeInterval + +> TimeInterval describes intervals of time. ContainsTime will tell you if a golang time is contained +> within the interval. + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ------------- | -------------------------- | -------------- | :------: | ------- | ----------- | ------- | +| days_of_month | []string | `[]string` | | | | | +| location | string | `string` | | | | | +| months | []string | `[]string` | | | | | +| times | [][TimeRange](#time-range) | `[]*TimeRange` | | | | | +| weekdays | []string | `[]string` | | | | | +| years | []string | `[]string` | | | | | + +{{% /responsive-table %}} + +### TimeRange + +> For example, 4:00PM to End of the day would Begin at 1020 and End at 1440. + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ----------- | ------------------------- | ------- | :------: | ------- | ----------- | ------- | +| EndMinute | int64 (formatted integer) | `int64` | | | | | +| StartMinute | int64 (formatted integer) | `int64` | | | | | + +{{% /responsive-table %}} + +### ValidationError + +**Properties** + +{{% responsive-table %}} + +| Name | Type | Go type | Required | Default | Description | Example | +| ---- | ------ | -------- | :------: | ------- | ----------- | --------------- | +| msg | string | `string` | | | | `error message` | + +{{% /responsive-table %}} diff --git a/docs/sources/shared/datasources/tempo-traces-to-profiles.md b/docs/sources/shared/datasources/tempo-traces-to-profiles.md index 5724051fceb..fde1964f20d 100644 --- a/docs/sources/shared/datasources/tempo-traces-to-profiles.md +++ b/docs/sources/shared/datasources/tempo-traces-to-profiles.md @@ -25,9 +25,13 @@ When configured, this connection lets you run queries from a trace span into the There are two ways to configure the trace to profiles feature: -- Use a simplified configuration with default query, or +- Use a basic configuration with default query, or - Configure a custom query where you can use a template language to interpolate variables from the trace or span. +{{< admonition type="note">}} +Traces to profile requires a Tempo data source with Traces to profiles configured and a Pyroscope data source. This integration supports profile data generated using Go, Ruby, and Java instrumentation SDKs. +{{< /admonition >}} + To use trace to profiles, navigate to **Explore** and query a trace. Each span now links to your queries. Clicking a link runs the query in a split panel. If tags are configured, Grafana dynamically inserts the span attribute values into the query. The query runs over the time range of the (span start time - 60) to (span end time + 60 seconds). ![Selecting a link in the span queries the profile data source](/media/docs/tempo/profiles/tempo-trace-to-profile.png) @@ -40,7 +44,7 @@ Hover over a particular block in the flame graph to see more details about the r ## Use a basic configuration -To use a simple configuration, follow these steps: +To use a basic configuration, follow these steps: 1. Select a Pyroscope data source from the **Data source** drop-down. 1. Optional: Choose any tags to use in the query. If left blank, the default values of `service.name` and `service.namespace` are used. diff --git a/e2e/various-suite/loki-query-builder.spec.ts b/e2e/various-suite/loki-query-builder.spec.ts index a635fff2567..1707a32dd85 100644 --- a/e2e/various-suite/loki-query-builder.spec.ts +++ b/e2e/various-suite/loki-query-builder.spec.ts @@ -43,6 +43,10 @@ describe('Loki query builder', () => { req.reply({ status: 'success', data: ['instance1', 'instance2'] }); }).as('valuesRequest'); + cy.intercept(/index\/stats/, (req) => { + req.reply({ streams: 2, chunks: 2660, bytes: 2721792, entries: 14408 }); + }); + // Go to Explore and choose Loki data source e2e.pages.Explore.visit(); e2e.components.DataSourcePicker.container().should('be.visible').click(); @@ -68,21 +72,20 @@ describe('Loki query builder', () => { // Add labels to remove error e2e.components.QueryBuilder.labelSelect().should('be.visible').click(); // wait until labels are loaded and set on the component before starting to type + e2e.components.QueryBuilder.labelSelect().children('div').children('input').type('i'); cy.wait('@labelsRequest'); - e2e.components.QueryBuilder.labelSelect().children('div').children('input').type('instance{enter}'); + e2e.components.QueryBuilder.labelSelect().children('div').children('input').type('nstance{enter}'); e2e.components.QueryBuilder.matchOperatorSelect() .should('be.visible') - .click() + .click({ force: true }) .children('div') .children('input') .type('=~{enter}', { force: true }); e2e.components.QueryBuilder.valueSelect().should('be.visible').click(); + e2e.components.QueryBuilder.valueSelect().children('div').children('input').type('instance1{enter}'); cy.wait('@valuesRequest'); - e2e.components.QueryBuilder.valueSelect() - .children('div') - .children('input') - .type('instance1{enter}') - .type('instance2{enter}'); + e2e.components.QueryBuilder.valueSelect().children('div').children('input').type('instance2{enter}'); + cy.contains(MISSING_LABEL_FILTER_ERROR_MESSAGE).should('not.exist'); cy.contains(finalQuery).should('be.visible'); diff --git a/e2e/various-suite/solo-route.spec.ts b/e2e/various-suite/solo-route.spec.ts index 1cfba1b70b7..c508ace3aef 100644 --- a/e2e/various-suite/solo-route.spec.ts +++ b/e2e/various-suite/solo-route.spec.ts @@ -11,4 +11,34 @@ describe('Solo Route', () => { cy.get('canvas').should('have.length', 6); }); + + it('Can view solo panel in scenes', () => { + // open Panel Tests - Graph NG + e2e.pages.SoloPanel.visit( + 'TkZXxlNG3/panel-tests-graph-ng?orgId=1&from=1699954597665&to=1699956397665&panelId=54&__feature.dashboardSceneSolo=true' + ); + + e2e.components.Panels.Panel.title('Interpolation: Step before').should('exist'); + cy.contains('uplot-main-div').should('not.exist'); + }); + + it('Can view solo repeated panel in scenes', () => { + // open Panel Tests - Graph NG + e2e.pages.SoloPanel.visit( + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true' + ); + + e2e.components.Panels.Panel.title('server=B').should('exist'); + cy.contains('uplot-main-div').should('not.exist'); + }); + + it('Can view solo in repeaterd row and panel in scenes', () => { + // open Panel Tests - Graph NG + e2e.pages.SoloPanel.visit( + 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-2-row-2-clone-2&__feature.dashboardSceneSolo=true' + ); + + e2e.components.Panels.Panel.title('server = D, pod = Sod').should('exist'); + cy.contains('uplot-main-div').should('not.exist'); + }); }); diff --git a/go.mod b/go.mod index c5a3d492415..03d2dee4c87 100644 --- a/go.mod +++ b/go.mod @@ -63,7 +63,7 @@ require ( github.com/grafana/cuetsy v0.1.11 // @grafana/grafana-as-code github.com/grafana/grafana-aws-sdk v0.23.1 // @grafana/aws-datasources github.com/grafana/grafana-azure-sdk-go v1.12.0 // @grafana/partner-datasources - github.com/grafana/grafana-plugin-sdk-go v0.208.0 // @grafana/plugins-platform-backend + github.com/grafana/grafana-plugin-sdk-go v0.209.0 // @grafana/plugins-platform-backend github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/backend-platform github.com/hashicorp/go-hclog v1.6.2 // @grafana/plugins-platform-backend github.com/hashicorp/go-plugin v1.6.0 // @grafana/plugins-platform-backend diff --git a/go.sum b/go.sum index 6c38639b981..81402e4e444 100644 --- a/go.sum +++ b/go.sum @@ -1946,8 +1946,8 @@ github.com/grafana/grafana-google-sdk-go v0.1.0/go.mod h1:Vo2TKWfDVmNTELBUM+3lkr github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 h1:r+mU5bGMzcXCRVAuOrTn54S80qbfVkvTdUJZfSfTNbs= github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ= github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk= -github.com/grafana/grafana-plugin-sdk-go v0.208.0 h1:+cHmkoayG+nkqwbyQ9uVoRZJFcyLuZafwkTaeRO/r8U= -github.com/grafana/grafana-plugin-sdk-go v0.208.0/go.mod h1:RpVdugdeeNmo/DUQdFRbsBrIwDg+igNHSNsaUqiXdEc= +github.com/grafana/grafana-plugin-sdk-go v0.209.0 h1:izPAnJePvzqrpJ3/X3eCbESnxR2bPqx32Q92glYHmkU= +github.com/grafana/grafana-plugin-sdk-go v0.209.0/go.mod h1:RpVdugdeeNmo/DUQdFRbsBrIwDg+igNHSNsaUqiXdEc= github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482 h1:1YNoeIhii4UIIQpCPU+EXidnqf449d0C3ZntAEt4KSo= github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482/go.mod h1:GNcfpy5+SY6RVbNGQW264gC0r336Dm+0zgQ5vt6+M8Y= github.com/grafana/prometheus-alertmanager v0.25.1-0.20240208102907-e82436ce63e6 h1:CBm0rwLCPDyarg9/bHJ50rBLYmyMDoyCWpgRMITZhdA= diff --git a/package.json b/package.json index 21374db5bfc..5a46e33cfac 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,9 @@ "packages:prepare": "lerna version --no-push --no-git-tag-version --force-publish --exact", "packages:pack": "mkdir -p ./npm-artifacts && lerna exec --no-private -- yarn pack --out \"../../npm-artifacts/%s-%v.tgz\"", "packages:typecheck": "lerna run typecheck", - "prettier:check": "prettier --check --list-different=false --log-level=warn \"**/*.{ts,tsx,scss,md,mdx}\"", - "prettier:checkDocs": "prettier --check --list-different=false --log-level=warn \"docs/**/*.md\" \"*.md\" \"packages/**/*.{ts,tsx,scss,md,mdx}\"", - "prettier:write": "prettier --list-different \"**/*.{js,ts,tsx,scss,md,mdx}\" --write", + "prettier:check": "prettier --check --list-different=false --log-level=warn \"**/*.{ts,tsx,scss,md,mdx,json}\"", + "prettier:checkDocs": "prettier --check --list-different=false --log-level=warn \"docs/**/*.md\" \"*.md\" \"packages/**/*.{ts,tsx,scss,md,mdx,json}\"", + "prettier:write": "prettier --list-different \"**/*.{js,ts,tsx,scss,md,mdx,json}\" --write", "start": "yarn themes:generate && yarn dev --watch", "start:noTsCheck": "yarn start --env noTsCheck=1", "start:noLint": "yarn start --env noTsCheck=1 --env noLint=1", @@ -110,7 +110,7 @@ "@types/lucene": "^2", "@types/marked": "5.0.2", "@types/mousetrap": "1.6.15", - "@types/node": "20.11.16", + "@types/node": "20.11.17", "@types/node-forge": "^1", "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.4", "@types/papaparse": "5.3.14", @@ -181,7 +181,7 @@ "jest-fail-on-console": "3.1.2", "jest-junit": "16.0.0", "jest-matcher-utils": "29.7.0", - "lerna": "8.1.2", + "lerna": "7.4.1", "mini-css-extract-plugin": "2.8.0", "msw": "1.3.2", "mutationobserver-shim": "0.3.7", @@ -228,10 +228,10 @@ "@floating-ui/react": "0.26.9", "@glideapps/glide-data-grid": "^6.0.0", "@grafana-plugins/grafana-azure-monitor-datasource": "workspace:*", - "@grafana-plugins/grafana-cloud-monitoring-datasource": "workspace:*", "@grafana-plugins/grafana-pyroscope-datasource": "workspace:*", "@grafana-plugins/grafana-testdata-datasource": "workspace:*", "@grafana-plugins/parca": "workspace:*", + "@grafana-plugins/stackdriver": "workspace:*", "@grafana-plugins/tempo": "workspace:*", "@grafana/aws-sdk": "0.3.1", "@grafana/data": "workspace:*", @@ -246,7 +246,7 @@ "@grafana/o11y-ds-frontend": "workspace:*", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "2.6.6", + "@grafana/scenes": "^2.6.5", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index f0b713ebdf5..cd1105d0fe3 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -76,7 +76,7 @@ "@types/jquery": "3.5.29", "@types/lodash": "4.14.202", "@types/marked": "5.0.2", - "@types/node": "20.11.16", + "@types/node": "20.11.17", "@types/papaparse": "5.3.14", "@types/react": "18.2.55", "@types/react-dom": "18.2.19", diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 90dfc96b506..59acae823fa 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -146,6 +146,7 @@ export interface FeatureToggles { annotationPermissionUpdate?: boolean; extractFieldsNameDeduplication?: boolean; dashboardSceneForViewers?: boolean; + dashboardSceneSolo?: boolean; dashboardScene?: boolean; panelFilterVariable?: boolean; pdfTables?: boolean; @@ -168,7 +169,6 @@ export interface FeatureToggles { cloudRBACRoles?: boolean; alertingQueryOptimization?: boolean; newFolderPicker?: boolean; - jitterAlertRules?: boolean; jitterAlertRulesWithinGroups?: boolean; onPremToCloudMigrations?: boolean; alertingSaveStatePeriodic?: boolean; diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index b515b6d6e09..25883821bec 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -41,7 +41,7 @@ "devDependencies": { "@rollup/plugin-commonjs": "25.0.7", "@rollup/plugin-node-resolve": "15.2.3", - "@types/node": "20.11.16", + "@types/node": "20.11.17", "esbuild": "0.18.12", "rimraf": "5.0.5", "rollup": "2.79.1", diff --git a/packages/grafana-e2e/cypress/fixtures/exemplars-query-response.json b/packages/grafana-e2e/cypress/fixtures/exemplars-query-response.json index c90d2c8457e..6c26a9fbf7d 100644 --- a/packages/grafana-e2e/cypress/fixtures/exemplars-query-response.json +++ b/packages/grafana-e2e/cypress/fixtures/exemplars-query-response.json @@ -23,50 +23,16 @@ "data": { "values": [ [ - 1633619595000, - 1633619610000, - 1633619625000, - 1633619640000, - 1633619655000, - 1633619670000, - 1633619685000, - 1633619700000, - 1633619715000, - 1633619730000, - 1633619745000, - 1633619760000, - 1633619775000, - 1633619790000, - 1633619805000, - 1633619820000, - 1633619835000, - 1633619850000, - 1633619865000, - 1633619880000, - 1633619895000 + 1633619595000, 1633619610000, 1633619625000, 1633619640000, 1633619655000, 1633619670000, 1633619685000, + 1633619700000, 1633619715000, 1633619730000, 1633619745000, 1633619760000, 1633619775000, 1633619790000, + 1633619805000, 1633619820000, 1633619835000, 1633619850000, 1633619865000, 1633619880000, 1633619895000 ], [ - 0.07245212135073513, - 0.07253198890830721, - 0.07247862573797707, - 0.07238248338231042, - 0.07221687487740913, - 0.07223291298743946, - 0.07225427016727755, - 0.024531677091864545, - 0.02317081920915543, - 0.07548902139580993, - 0.0777721702857508, - 0.07768649905047344, - 0.07782257603228229, - 0.07788810213200052, - 0.07791835055437593, - 0.07798387201529966, - 0.07790826751849372, - 0.07794858648610933, - 0.07778729925797964, - 0.07769657495236215, - 0.077550401329267 + 0.07245212135073513, 0.07253198890830721, 0.07247862573797707, 0.07238248338231042, 0.07221687487740913, + 0.07223291298743946, 0.07225427016727755, 0.024531677091864545, 0.02317081920915543, + 0.07548902139580993, 0.0777721702857508, 0.07768649905047344, 0.07782257603228229, 0.07788810213200052, + 0.07791835055437593, 0.07798387201529966, 0.07790826751849372, 0.07794858648610933, 0.07778729925797964, + 0.07769657495236215, 0.077550401329267 ] ] } @@ -113,54 +79,15 @@ "data": { "values": [ [ - 1633619598000, - 1633619622000, - 1633619625000, - 1633619646000, - 1633619658000, - 1633619682000, - 1633619695000, - 1633619712000, - 1633619712000, - 1633619724000, - 1633619717000, - 1633619742000, - 1633619757000, - 1633619771000, - 1633619784000, - 1633619801000, - 1633619806000, - 1633619833000, - 1633619833000, - 1633619845000, - 1633619862000, - 1633619877000, - 1633619889000 + 1633619598000, 1633619622000, 1633619625000, 1633619646000, 1633619658000, 1633619682000, 1633619695000, + 1633619712000, 1633619712000, 1633619724000, 1633619717000, 1633619742000, 1633619757000, 1633619771000, + 1633619784000, 1633619801000, 1633619806000, 1633619833000, 1633619833000, 1633619845000, 1633619862000, + 1633619877000, 1633619889000 ], [ - 0.0146153, - 0.0118506, - 0.0473847, - 0.026997, - 0.0164318, - 0.0113532, - 0.0105197, - 0.162789, - 0.0556026, - 0.148856, - 0.0433809, - 0.0117758, - 0.0114496, - 0.0114099, - 0.0421927, - 0.0134148, - 0.0152827, - 0.6975967, - 0.0394788, - 0.0137441, - 0.0110939, - 0.0104496, - 0.0101284 + 0.0146153, 0.0118506, 0.0473847, 0.026997, 0.0164318, 0.0113532, 0.0105197, 0.162789, 0.0556026, + 0.148856, 0.0433809, 0.0117758, 0.0114496, 0.0114099, 0.0421927, 0.0134148, 0.0152827, 0.6975967, + 0.0394788, 0.0137441, 0.0110939, 0.0104496, 0.0101284 ], [ "app:80", diff --git a/packages/grafana-o11y-ds-frontend/tsconfig.json b/packages/grafana-o11y-ds-frontend/tsconfig.json index 8cafcb9c417..03aa83f469f 100644 --- a/packages/grafana-o11y-ds-frontend/tsconfig.json +++ b/packages/grafana-o11y-ds-frontend/tsconfig.json @@ -4,9 +4,9 @@ "declarationDir": "./compiled", "emitDeclarationOnly": true, "isolatedModules": true, - "rootDirs": ["."], + "rootDirs": ["."] }, "exclude": ["dist/**/*"], "extends": "@grafana/tsconfig", - "include": ["src/**/*.ts*", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts"], + "include": ["src/**/*.ts*", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts"] } diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index e1bc89e5334..7c701a97efe 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -82,7 +82,7 @@ "@types/jquery": "3.5.29", "@types/lodash": "4.14.202", "@types/marked": "5.0.2", - "@types/node": "20.11.16", + "@types/node": "20.11.17", "@types/pluralize": "^0.0.33", "@types/prismjs": "1.26.3", "@types/react": "18.2.55", diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index 5b3e9ec7f27..5a0264a4571 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -1,4 +1,4 @@ -import { cloneDeep, defaults } from 'lodash'; +import { defaults } from 'lodash'; import { lastValueFrom, Observable, throwError } from 'rxjs'; import { map, tap } from 'rxjs/operators'; import semver from 'semver/preload'; @@ -877,7 +877,7 @@ export class PrometheusDatasource // Used when running queries through backend applyTemplateVariables(target: PromQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[]) { - const variables = cloneDeep(scopedVars); + const variables = { ...scopedVars }; // We want to interpolate these variables on backend. // The pre-calculated values are replaced withe the variable strings. diff --git a/packages/grafana-sql/src/datasource/SqlDatasource.ts b/packages/grafana-sql/src/datasource/SqlDatasource.ts index 0788326341f..ce5d3bc7ea2 100644 --- a/packages/grafana-sql/src/datasource/SqlDatasource.ts +++ b/packages/grafana-sql/src/datasource/SqlDatasource.ts @@ -208,7 +208,15 @@ export abstract class SqlDatasource extends DataSourceWithBackend +### With overline + +```jsx + + Filter option + Filter by name + Filter data by query. + +``` + + + + Filter option + Filter by name + Filter data by query. + + + ### Props diff --git a/packages/grafana-ui/src/components/Card/Card.story.tsx b/packages/grafana-ui/src/components/Card/Card.story.tsx index f6904421394..4d4f8367b17 100644 --- a/packages/grafana-ui/src/components/Card/Card.story.tsx +++ b/packages/grafana-ui/src/components/Card/Card.story.tsx @@ -24,9 +24,9 @@ const meta: Meta = { }, }; -export const Basic: StoryFn = ({ disabled }) => { +export const Basic: StoryFn = (args) => { return ( - + Filter by name Filter data by query. This is useful if you are sharing the results from a different panel that has many queries @@ -36,24 +36,24 @@ export const Basic: StoryFn = ({ disabled }) => { ); }; -export const AsLink: StoryFn = ({ disabled }) => { +export const AsLink: StoryFn = (args) => { return ( - + Filter by name Filter data by query. This is useful if you are sharing the results from a different panel that has many queries and you want to only visualize a subset of that in this panel. - + Filter by name2 Filter data by query. This is useful if you are sharing the results from a different panel that has many queries and you want to only visualize a subset of that in this panel. - + Production system overview Meta tags @@ -61,9 +61,9 @@ export const AsLink: StoryFn = ({ disabled }) => { ); }; -export const WithTags: StoryFn = ({ disabled }) => { +export const WithTags: StoryFn = (args) => { return ( - + Elasticsearch – Custom Templated Query Elastic Search @@ -73,9 +73,9 @@ export const WithTags: StoryFn = ({ disabled }) => { ); }; -export const WithMedia: StoryFn = ({ disabled }) => { +export const WithMedia: StoryFn = (args) => { return ( - + 1-ops-tools1-fallback Prometheus @@ -89,9 +89,9 @@ export const WithMedia: StoryFn = ({ disabled }) => { ); }; -export const WithActions: StoryFn = ({ disabled }) => { +export const WithActions: StoryFn = (args) => { return ( - + 1-ops-tools1-fallback Prometheus @@ -118,9 +118,9 @@ export const WithActions: StoryFn = ({ disabled }) => { ); }; -export const Full: StoryFn = ({ disabled }) => { +export const Full: StoryFn = (args) => { return ( - + Card title Description, body text. Greetings! Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod @@ -179,4 +179,18 @@ export const NotSelected: StoryFn = () => { ); }; +export const WithOverline: StoryFn = (args) => { + return ( + + Overline text above the title + Card title + + Description, body text. Greetings! Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco + laboris nisi ut aliquip ex ea commodo consequat. + + + ); +}; + export default meta; diff --git a/packages/grafana-ui/src/components/Card/Card.tsx b/packages/grafana-ui/src/components/Card/Card.tsx index 4ebb077f1fa..be178a25c29 100644 --- a/packages/grafana-ui/src/components/Card/Card.tsx +++ b/packages/grafana-ui/src/components/Card/Card.tsx @@ -5,6 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../themes'; import { getFocusStyles } from '../../themes/mixins'; +import { Text } from '../Text/Text'; import { CardContainer, CardContainerProps, getCardContainerStyles } from './CardContainer'; @@ -23,9 +24,12 @@ export interface Props extends Omit { + Overline: typeof Overline; Heading: typeof Heading; Tags: typeof Tags; Figure: typeof Figure; @@ -47,7 +51,16 @@ const CardContext = React.createContext<{ * * @public */ -export const Card: CardInterface = ({ disabled, href, onClick, children, isSelected, className, ...htmlProps }) => { +export const Card: CardInterface = ({ + disabled, + href, + onClick, + children, + isSelected, + isCompact, + className, + ...htmlProps +}) => { const hasHeadingComponent = useMemo( () => React.Children.toArray(children).some((c) => React.isValidElement(c) && c.type === Heading), [children] @@ -55,7 +68,7 @@ export const Card: CardInterface = ({ disabled, href, onClick, children, isSelec const disableHover = disabled || (!onClick && !href); const onCardClick = onClick && !disabled ? onClick : undefined; - const styles = useStyles2(getCardContainerStyles, disabled, disableHover, isSelected); + const styles = useStyles2(getCardContainerStyles, disabled, disableHover, isSelected, isCompact); return ( ({ }), }); +/** Card text to be displayed above title */ +const Overline = ({ children, className }: ChildProps) => { + const styles = useStyles2(getOverlineStyles); + return ( +
+ {children && ( + + {children} + + )} +
+ ); +}; +Overline.displayName = 'Overline'; + +const getOverlineStyles = (theme: GrafanaTheme2) => ({ + overline: css({ + gridArea: 'Overline', + marginBottom: theme.spacing(0.5), + }), +}); + const Tags = ({ children, className }: ChildProps) => { const styles = useStyles2(getTagStyles); return
{children}
; @@ -349,6 +384,7 @@ export const getCardStyles = (theme: GrafanaTheme2) => { }; }; +Card.Overline = Overline; Card.Heading = Heading; Card.Tags = Tags; Card.Figure = Figure; diff --git a/packages/grafana-ui/src/components/Card/CardContainer.tsx b/packages/grafana-ui/src/components/Card/CardContainer.tsx index e4a52c9f1f2..430634f9ba7 100644 --- a/packages/grafana-ui/src/components/Card/CardContainer.tsx +++ b/packages/grafana-ui/src/components/Card/CardContainer.tsx @@ -70,7 +70,8 @@ export const getCardContainerStyles = ( theme: GrafanaTheme2, disabled = false, disableHover = false, - isSelected?: boolean + isSelected?: boolean, + isCompact?: boolean ) => { const isSelectable = isSelected !== undefined; @@ -79,16 +80,17 @@ export const getCardContainerStyles = ( display: 'grid', position: 'relative', gridTemplateColumns: 'auto 1fr auto', - gridTemplateRows: '1fr auto auto auto', + gridTemplateRows: 'auto 1fr auto auto auto', gridAutoColumns: '1fr', gridAutoFlow: 'row', gridTemplateAreas: ` + "Figure Overline Tags" "Figure Heading Tags" "Figure Meta Tags" "Figure Description Tags" "Figure Actions Secondary"`, width: '100%', - padding: theme.spacing(2), + padding: theme.spacing(isCompact ? 1 : 2), background: theme.colors.background.secondary, borderRadius: theme.shape.radius.default, marginBottom: '8px', diff --git a/packages/grafana-ui/src/components/Table/RowsList.tsx b/packages/grafana-ui/src/components/Table/RowsList.tsx index 878aa1cf69e..6b16f4430a2 100644 --- a/packages/grafana-ui/src/components/Table/RowsList.tsx +++ b/packages/grafana-ui/src/components/Table/RowsList.tsx @@ -44,6 +44,7 @@ interface RowsListProps { onCellFilterAdded?: TableFilterActionCallback; timeRange?: TimeRange; footerPaginationEnabled: boolean; + initialRowIndex?: number; } export const RowsList = (props: RowsListProps) => { @@ -66,9 +67,10 @@ export const RowsList = (props: RowsListProps) => { listHeight, listRef, enableSharedCrosshair = false, + initialRowIndex = undefined, } = props; - const [rowHighlightIndex, setRowHighlightIndex] = useState(undefined); + const [rowHighlightIndex, setRowHighlightIndex] = useState(initialRowIndex); const theme = useTheme2(); const panelContext = usePanelContext(); @@ -203,6 +205,7 @@ export const RowsList = (props: RowsListProps) => { ({ index, style, rowHighlightIndex }: { index: number; style: CSSProperties; rowHighlightIndex?: number }) => { const indexForPagination = rowIndexForPagination(index); const row = rows[indexForPagination]; + let additionalProps: React.HTMLAttributes = {}; prepareRow(row); @@ -210,11 +213,13 @@ export const RowsList = (props: RowsListProps) => { if (rowHighlightIndex !== undefined && row.index === rowHighlightIndex) { style = { ...style, backgroundColor: theme.components.table.rowHoverBackground }; + additionalProps = { + 'aria-selected': 'true', + }; } - return (
onRowHover(index, data)} onMouseLeave={onRowLeave} diff --git a/packages/grafana-ui/src/components/Table/Table.test.tsx b/packages/grafana-ui/src/components/Table/Table.test.tsx index 4b2322923d4..5d855d25a4c 100644 --- a/packages/grafana-ui/src/components/Table/Table.test.tsx +++ b/packages/grafana-ui/src/components/Table/Table.test.tsx @@ -89,6 +89,7 @@ function getTestContext(propOverrides: Partial = {}) { onSortByChange, onCellFilterAdded, onColumnResize, + initialRowIndex: undefined, }; Object.assign(props, propOverrides); @@ -657,4 +658,19 @@ describe('Table', () => { expect(subTable.style.height).toBe('108px'); }); }); + + describe('when mounted with scrolled to specific row', () => { + it('the row should be visible', async () => { + getTestContext({ + initialRowIndex: 2, + }); + expect(getTable()).toBeInTheDocument(); + + const rows = within(getTable()).getAllByRole('row'); + expect(rows).toHaveLength(5); + + let selected = within(getTable()).getByRole('row', { selected: true }); + expect(selected).toBeVisible(); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index 7f65db3f71d..7bb9e9cf976 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -48,6 +48,7 @@ export const Table = memo((props: Props) => { cellHeight = TableCellHeight.Sm, timeRange, enableSharedCrosshair = false, + initialRowIndex = undefined, } = props; const listRef = useRef(null); @@ -307,6 +308,7 @@ export const Table = memo((props: Props) => { tableStyles={tableStyles} footerPaginationEnabled={Boolean(enablePagination)} enableSharedCrosshair={enableSharedCrosshair} + initialRowIndex={initialRowIndex} />
) : ( diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index b99a3cad619..62a6d1471ee 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -97,6 +97,8 @@ export interface Props { /** @alpha Used by SparklineCell when provided */ timeRange?: TimeRange; enableSharedCrosshair?: boolean; + // The index of the field value that the table will initialize scrolled to + initialRowIndex?: number; } /** diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index c449854c322..d40417f72f3 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -320,9 +320,9 @@ func Test_AdminUpdateUserPermissions(t *testing.T) { case login.GenericOAuthModule: socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{AllowAssignGrafanaAdmin: tc.allowAssignGrafanaAdmin, Enabled: tc.authEnabled, SkipOrgRoleSync: tc.skipOrgRoleSync} case login.JWTModule: - cfg.JWTAuthEnabled = tc.authEnabled - cfg.JWTAuthSkipOrgRoleSync = tc.skipOrgRoleSync - cfg.JWTAuthAllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin + cfg.JWTAuth.Enabled = tc.authEnabled + cfg.JWTAuth.SkipOrgRoleSync = tc.skipOrgRoleSync + cfg.JWTAuth.AllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin } hs := &HTTPServer{ diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go new file mode 100644 index 00000000000..0cbeea1f752 --- /dev/null +++ b/pkg/api/api_test.go @@ -0,0 +1,11 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} diff --git a/pkg/api/fakes.go b/pkg/api/fakes.go index 209a27f0304..28b1a747443 100644 --- a/pkg/api/fakes.go +++ b/pkg/api/fakes.go @@ -30,7 +30,7 @@ func (pm *fakePluginInstaller) Add(_ context.Context, pluginID, version string, return nil } -func (pm *fakePluginInstaller) Remove(_ context.Context, pluginID string) error { +func (pm *fakePluginInstaller) Remove(_ context.Context, pluginID, _ string) error { delete(pm.plugins, pluginID) return nil } diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index ce055ded32b..3016bd44c1e 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -173,8 +173,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro AllowOrgCreate: (hs.Cfg.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin, AuthProxyEnabled: hs.Cfg.AuthProxyEnabled, LdapEnabled: hs.Cfg.LDAPAuthEnabled, - JwtHeaderName: hs.Cfg.JWTAuthHeaderName, - JwtUrlLogin: hs.Cfg.JWTAuthURLLogin, + JwtHeaderName: hs.Cfg.JWTAuth.HeaderName, + JwtUrlLogin: hs.Cfg.JWTAuth.URLLogin, AlertingErrorOrTimeout: hs.Cfg.AlertingErrorOrTimeout, AlertingNoDataOrNullValues: hs.Cfg.AlertingNoDataOrNullValues, AlertingMinInterval: hs.Cfg.AlertingMinInterval, @@ -321,7 +321,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro OAuthSkipOrgRoleUpdateSync: hs.Cfg.OAuthSkipOrgRoleUpdateSync, SAMLSkipOrgRoleSync: hs.Cfg.SAMLSkipOrgRoleSync, LDAPSkipOrgRoleSync: hs.Cfg.LDAPSkipOrgRoleSync, - JWTAuthSkipOrgRoleSync: hs.Cfg.JWTAuthSkipOrgRoleSync, + JWTAuthSkipOrgRoleSync: hs.Cfg.JWTAuth.SkipOrgRoleSync, GoogleSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GoogleProviderName]), GrafanaComSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GrafanaComProviderName]), GenericOAuthSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GenericOAuthProviderName]), diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 99ec8bcaaea..13e6ed00735 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -45,9 +45,14 @@ import ( secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/web" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestDataSourceProxy_routeRule(t *testing.T) { cfg := &setting.Cfg{} diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index f8cbf7ac66e..c8e9d1b48a5 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -274,7 +274,12 @@ func (hs *HTTPServer) GetPluginMarkdown(c *contextmodel.ReqContext) response.Res pluginID := web.Params(c.Req)[":pluginId"] name := web.Params(c.Req)[":name"] - content, err := hs.pluginMarkdown(c.Req.Context(), pluginID, name) + p, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID) + if !exists { + return response.Error(http.StatusNotFound, "Plugin not installed", nil) + } + + content, err := hs.pluginMarkdown(c.Req.Context(), pluginID, p.Info.Version, name) if err != nil { var notFound plugins.NotFoundError if errors.As(err, ¬Found) { @@ -286,7 +291,7 @@ func (hs *HTTPServer) GetPluginMarkdown(c *contextmodel.ReqContext) response.Res // fallback try readme if len(content) == 0 { - content, err = hs.pluginMarkdown(c.Req.Context(), pluginID, "readme") + content, err = hs.pluginMarkdown(c.Req.Context(), pluginID, p.Info.Version, "readme") if err != nil { if errors.Is(err, plugins.ErrFileNotExist) { return response.Error(http.StatusNotFound, plugins.ErrFileNotExist.Error(), nil) @@ -354,7 +359,7 @@ func (hs *HTTPServer) getPluginAssets(c *contextmodel.ReqContext) { // serveLocalPluginAsset returns the content of a plugin asset file from the local filesystem to the http client. func (hs *HTTPServer) serveLocalPluginAsset(c *contextmodel.ReqContext, plugin pluginstore.Plugin, assetPath string) { - f, err := hs.pluginFileStore.File(c.Req.Context(), plugin.ID, assetPath) + f, err := hs.pluginFileStore.File(c.Req.Context(), plugin.ID, plugin.Info.Version, assetPath) if err != nil { if errors.Is(err, plugins.ErrFileNotExist) { c.JsonApiErr(404, "Plugin file not found", nil) @@ -476,8 +481,12 @@ func (hs *HTTPServer) InstallPlugin(c *contextmodel.ReqContext) response.Respons func (hs *HTTPServer) UninstallPlugin(c *contextmodel.ReqContext) response.Response { pluginID := web.Params(c.Req)[":pluginId"] + plugin, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID) + if !exists { + return response.Error(http.StatusNotFound, "Plugin not installed", nil) + } - err := hs.pluginInstaller.Remove(c.Req.Context(), pluginID) + err := hs.pluginInstaller.Remove(c.Req.Context(), pluginID, plugin.Info.Version) if err != nil { if errors.Is(err, plugins.ErrPluginNotInstalled) { return response.Error(http.StatusNotFound, "Plugin not installed", err) @@ -494,19 +503,19 @@ func translatePluginRequestErrorToAPIError(err error) response.Response { return response.ErrOrFallback(http.StatusInternalServerError, "Plugin request failed", err) } -func (hs *HTTPServer) pluginMarkdown(ctx context.Context, pluginID string, name string) ([]byte, error) { +func (hs *HTTPServer) pluginMarkdown(ctx context.Context, pluginID, pluginVersion, name string) ([]byte, error) { file, err := mdFilepath(strings.ToUpper(name)) if err != nil { return make([]byte, 0), err } - md, err := hs.pluginFileStore.File(ctx, pluginID, file) + md, err := hs.pluginFileStore.File(ctx, pluginID, pluginVersion, file) if err != nil { if errors.Is(err, plugins.ErrPluginNotInstalled) { return make([]byte, 0), plugins.NotFoundError{PluginID: pluginID} } - md, err = hs.pluginFileStore.File(ctx, pluginID, strings.ToLower(file)) + md, err = hs.pluginFileStore.File(ctx, pluginID, pluginVersion, strings.ToLower(file)) if err != nil { return make([]byte, 0), nil } diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index 29eefb94762..96fb1078a14 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -49,6 +49,7 @@ func Test_PluginsInstallAndUninstall(t *testing.T) { canInstall := []ac.Permission{{Action: pluginaccesscontrol.ActionInstall}} cannotInstall := []ac.Permission{{Action: "plugins:cannotinstall"}} + pluginID := "grafana-test-datasource" localOrg := int64(1) globalOrg := int64(ac.GlobalOrgID) @@ -88,11 +89,17 @@ func Test_PluginsInstallAndUninstall(t *testing.T) { hs.pluginInstaller = NewFakePluginInstaller() hs.pluginFileStore = &fakes.FakePluginFileStore{} + hs.pluginStore = pluginstore.NewFakePluginStore(pluginstore.Plugin{ + JSONData: plugins.JSONData{ + ID: pluginID, + }, + }) }) t.Run(testName("Install", tc), func(t *testing.T) { input := strings.NewReader(`{"version": "1.0.2"}`) - req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/install", input), userWithPermissions(tc.permissionOrg, tc.permissions)) + endpoint := fmt.Sprintf("/api/plugins/%s/install", pluginID) + req := webtest.RequestWithSignedInUser(server.NewPostRequest(endpoint, input), userWithPermissions(tc.permissionOrg, tc.permissions)) res, err := server.SendJSON(req) require.NoError(t, err) require.Equal(t, tc.expectedCode, res.StatusCode) @@ -101,7 +108,8 @@ func Test_PluginsInstallAndUninstall(t *testing.T) { t.Run(testName("Uninstall", tc), func(t *testing.T) { input := strings.NewReader("{ }") - req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/uninstall", input), userWithPermissions(tc.permissionOrg, tc.permissions)) + endpoint := fmt.Sprintf("/api/plugins/%s/uninstall", pluginID) + req := webtest.RequestWithSignedInUser(server.NewPostRequest(endpoint, input), userWithPermissions(tc.permissionOrg, tc.permissions)) res, err := server.SendJSON(req) require.NoError(t, err) require.Equal(t, tc.expectedCode, res.StatusCode) @@ -401,14 +409,14 @@ func TestMakePluginResourceRequestContentTypeEmpty(t *testing.T) { func TestPluginMarkdown(t *testing.T) { t.Run("Plugin not installed returns error", func(t *testing.T) { pluginFileStore := &fakes.FakePluginFileStore{ - FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) { + FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) { return nil, plugins.ErrPluginNotInstalled }, } hs := HTTPServer{pluginFileStore: pluginFileStore} pluginID := "test-datasource" - md, err := hs.pluginMarkdown(context.Background(), pluginID, "test") + md, err := hs.pluginMarkdown(context.Background(), pluginID, "", "test") require.ErrorAs(t, err, &plugins.NotFoundError{PluginID: pluginID}) require.Equal(t, []byte{}, md) }) @@ -416,7 +424,7 @@ func TestPluginMarkdown(t *testing.T) { t.Run("File fetch will be retried using different casing if error occurs", func(t *testing.T) { var requestedFiles []string pluginFileStore := &fakes.FakePluginFileStore{ - FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) { + FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) { requestedFiles = append(requestedFiles, filename) return nil, errors.New("some error") }, @@ -424,7 +432,7 @@ func TestPluginMarkdown(t *testing.T) { hs := HTTPServer{pluginFileStore: pluginFileStore} - md, err := hs.pluginMarkdown(context.Background(), "", "reAdMe") + md, err := hs.pluginMarkdown(context.Background(), "", "", "reAdMe") require.NoError(t, err) require.Equal(t, []byte{}, md) require.Equal(t, []string{"README.md", "readme.md"}, requestedFiles) @@ -453,7 +461,7 @@ func TestPluginMarkdown(t *testing.T) { data := []byte{123} var requestedFiles []string pluginFileStore := &fakes.FakePluginFileStore{ - FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) { + FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) { requestedFiles = append(requestedFiles, filename) return &plugins.File{Content: data}, nil }, @@ -461,7 +469,7 @@ func TestPluginMarkdown(t *testing.T) { hs := HTTPServer{pluginFileStore: pluginFileStore} - md, err := hs.pluginMarkdown(context.Background(), "test-datasource", tc.filePath) + md, err := hs.pluginMarkdown(context.Background(), "test-datasource", "", tc.filePath) require.NoError(t, err) require.Equal(t, data, md) require.Equal(t, tc.expected, requestedFiles) @@ -471,7 +479,7 @@ func TestPluginMarkdown(t *testing.T) { t.Run("Non markdown file request returns an error", func(t *testing.T) { hs := HTTPServer{pluginFileStore: &fakes.FakePluginFileStore{}} - md, err := hs.pluginMarkdown(context.Background(), "", "test.json") + md, err := hs.pluginMarkdown(context.Background(), "", "", "test.json") require.ErrorIs(t, err, ErrUnexpectedFileExtension) require.Equal(t, []byte{}, md) }) @@ -480,14 +488,14 @@ func TestPluginMarkdown(t *testing.T) { data := []byte{1, 2, 3} pluginFileStore := &fakes.FakePluginFileStore{ - FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) { + FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) { return &plugins.File{Content: data}, nil }, } hs := HTTPServer{pluginFileStore: pluginFileStore} - md, err := hs.pluginMarkdown(context.Background(), "", "someFile") + md, err := hs.pluginMarkdown(context.Background(), "", "", "someFile") require.NoError(t, err) require.Equal(t, data, md) }) diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index 964eb23da87..e8b96c5b2fa 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -302,9 +302,9 @@ func Test_GetUserByID(t *testing.T) { case login.GenericOAuthModule: socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{AllowAssignGrafanaAdmin: tc.allowAssignGrafanaAdmin, Enabled: tc.authEnabled, SkipOrgRoleSync: tc.skipOrgRoleSync} case login.JWTModule: - cfg.JWTAuthEnabled = tc.authEnabled - cfg.JWTAuthSkipOrgRoleSync = tc.skipOrgRoleSync - cfg.JWTAuthAllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin + cfg.JWTAuth.Enabled = tc.authEnabled + cfg.JWTAuth.SkipOrgRoleSync = tc.skipOrgRoleSync + cfg.JWTAuth.AllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin } hs := &HTTPServer{ diff --git a/pkg/apis/featuretoggle/v0alpha1/types.go b/pkg/apis/featuretoggle/v0alpha1/types.go index 4fa25c5135d..e10c7f2b080 100644 --- a/pkg/apis/featuretoggle/v0alpha1/types.go +++ b/pkg/apis/featuretoggle/v0alpha1/types.go @@ -98,6 +98,9 @@ type ToggleStatus struct { // The flag description Description string `json:"description,omitempty"` + // The feature toggle stage + Stage string `json:"stage"` + // Is the flag enabled Enabled bool `json:"enabled"` diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go index d15eb50f495..c96bf2e2cc6 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go @@ -23,11 +23,16 @@ import ( "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) // "Skipping conflicting users test for mysql as it does make unique constraint case insensitive by default const ignoredDatabase = migrator.MySQL +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestBuildConflictBlock(t *testing.T) { type testBuildConflictBlock struct { desc string diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go b/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go index 99b89d59eb6..d24e0782cab 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go @@ -12,9 +12,14 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestPasswordMigrationCommand(t *testing.T) { // setup datasources with password, basic_auth and none store := db.InitTestDB(t) diff --git a/pkg/infra/db/db.go b/pkg/infra/db/db.go index 6cf32ae6606..08cea4a761f 100644 --- a/pkg/infra/db/db.go +++ b/pkg/infra/db/db.go @@ -10,6 +10,8 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/session" + "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" + "github.com/grafana/grafana/pkg/setting" ) type DB interface { @@ -51,10 +53,16 @@ type DB interface { type Session = sqlstore.DBSession type InitTestDBOpt = sqlstore.InitTestDBOpt +var SetupTestDB = sqlstore.SetupTestDB var InitTestDB = sqlstore.InitTestDB -var InitTestDBwithCfg = sqlstore.InitTestDBWithCfg +var CleanupTestDB = sqlstore.CleanupTestDB var ProvideService = sqlstore.ProvideService +func InitTestDBwithCfg(t sqlutil.ITestDB, opts ...InitTestDBOpt) (*sqlstore.SQLStore, *setting.Cfg) { + store := InitTestDB(t, opts...) + return store, store.Cfg +} + func IsTestDbSQLite() bool { if db, present := os.LookupEnv("GRAFANA_TEST_DB"); !present || db == "sqlite" { return true diff --git a/pkg/infra/filestorage/fs_integration_test.go b/pkg/infra/filestorage/fs_integration_test.go index 9336636ab23..13d74b5de35 100644 --- a/pkg/infra/filestorage/fs_integration_test.go +++ b/pkg/infra/filestorage/fs_integration_test.go @@ -12,12 +12,17 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/tests/testsuite" ) const ( pngImageBase64 = "iVBORw0KGgoNAANSUhEUgAAAC4AAAAmCAYAAAC76qlaAAAABHNCSVQICAgIfAhkiAAAABl0RVh0U29mdHdhcmUAZ25vbWUtc2NyZWVuc2hvdO8Dvz4AAABFSURBVFiF7c5BDQAhEACx4/x7XjzwGELSKuiamfke9N8OnBKvidfEa+I18Zp4TbwmXhOvidfEa+I18Zp4TbwmXhOvidc2lcsESD1LGnUAAAAASUVORK5CYII=" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type fsTestCase struct { name string skip *bool diff --git a/pkg/infra/kvstore/kvstore_test.go b/pkg/infra/kvstore/kvstore_test.go index e3471285e26..f94fd7dc0d2 100644 --- a/pkg/infra/kvstore/kvstore_test.go +++ b/pkg/infra/kvstore/kvstore_test.go @@ -10,8 +10,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func createTestableKVStore(t *testing.T) KVStore { t.Helper() diff --git a/pkg/infra/remotecache/remotecache_test.go b/pkg/infra/remotecache/remotecache_test.go index 0daf3202abc..a51a836fa6c 100644 --- a/pkg/infra/remotecache/remotecache_test.go +++ b/pkg/infra/remotecache/remotecache_test.go @@ -13,8 +13,13 @@ import ( "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func createTestClient(t *testing.T, opts *setting.RemoteCacheOptions, sqlstore db.DB) CacheStorage { t.Helper() diff --git a/pkg/infra/serverlock/serverlock_test.go b/pkg/infra/serverlock/serverlock_test.go index 79212a02c57..3c060c0f22e 100644 --- a/pkg/infra/serverlock/serverlock_test.go +++ b/pkg/infra/serverlock/serverlock_test.go @@ -11,8 +11,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func createTestableServerLock(t *testing.T) *ServerLockService { t.Helper() diff --git a/pkg/infra/usagestats/service/usage_stats_test.go b/pkg/infra/usagestats/service/usage_stats_test.go index fceb924ad35..352345aca8c 100644 --- a/pkg/infra/usagestats/service/usage_stats_test.go +++ b/pkg/infra/usagestats/service/usage_stats_test.go @@ -25,8 +25,13 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + // This is to ensure that the interface contract is held by the implementation func Test_InterfaceContractValidity(t *testing.T) { newUsageStats := func() usagestats.Service { diff --git a/pkg/infra/usagestats/statscollector/concurrent_users_test.go b/pkg/infra/usagestats/statscollector/concurrent_users_test.go index 15c006441bd..33ffcb3244a 100644 --- a/pkg/infra/usagestats/statscollector/concurrent_users_test.go +++ b/pkg/infra/usagestats/statscollector/concurrent_users_test.go @@ -14,9 +14,15 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/stats/statsimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) +// run tests with cleanup +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestConcurrentUsersMetrics(t *testing.T) { sqlStore, cfg := db.InitTestDBwithCfg(t) statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore) diff --git a/pkg/login/social/connectors/azuread_oauth.go b/pkg/login/social/connectors/azuread_oauth.go index 712d0f145ba..435f1c209ff 100644 --- a/pkg/login/social/connectors/azuread_oauth.go +++ b/pkg/login/social/connectors/azuread_oauth.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/ssosettings" @@ -185,13 +186,13 @@ func (s *SocialAzureAD) Reload(ctx context.Context, settings ssoModels.SSOSettin return nil } -func (s *SocialAzureAD) Validate(ctx context.Context, settings ssoModels.SSOSettings) error { +func (s *SocialAzureAD) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error { info, err := CreateOAuthInfoFromKeyValues(settings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } - err = validateInfo(info) + err = validateInfo(info, requester) if err != nil { return err } diff --git a/pkg/login/social/connectors/azuread_oauth_test.go b/pkg/login/social/connectors/azuread_oauth_test.go index e59803135b2..39da9b7f76e 100644 --- a/pkg/login/social/connectors/azuread_oauth_test.go +++ b/pkg/login/social/connectors/azuread_oauth_test.go @@ -18,10 +18,12 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -992,9 +994,10 @@ func TestSocialAzureAD_InitializeExtraFields(t *testing.T) { func TestSocialAzureAD_Validate(t *testing.T) { testCases := []struct { - name string - settings ssoModels.SSOSettings - wantErr error + name string + settings ssoModels.SSOSettings + requester identity.Requester + wantErr error }{ { name: "SSOSettings is valid", @@ -1052,13 +1055,29 @@ func TestSocialAzureAD_Validate(t *testing.T) { }, wantErr: ssosettings.ErrBaseInvalidOAuthConfig, }, + { + name: "fails if the user is not allowed to update allow assign grafana admin", + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { s := NewAzureADProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), nil) - err := s.Validate(context.Background(), tc.settings) + if tc.requester == nil { + tc.requester = &user.SignedInUser{IsGrafanaAdmin: false} + } + err := s.Validate(context.Background(), tc.settings, tc.requester) if tc.wantErr != nil { require.ErrorIs(t, err, tc.wantErr) return diff --git a/pkg/login/social/connectors/common.go b/pkg/login/social/connectors/common.go index 93517fed981..96bf84020ba 100644 --- a/pkg/login/social/connectors/common.go +++ b/pkg/login/social/connectors/common.go @@ -2,8 +2,6 @@ package connectors import ( "context" - "encoding/json" - "errors" "fmt" "io" "net/http" @@ -12,7 +10,6 @@ import ( "strconv" "strings" - "github.com/jmespath/go-jmespath" "github.com/mitchellh/mapstructure" "golang.org/x/oauth2" @@ -96,63 +93,6 @@ func (s *SocialBase) httpGet(ctx context.Context, client *http.Client, url strin return response, nil } -func (s *SocialBase) searchJSONForAttr(attributePath string, data []byte) (any, error) { - if attributePath == "" { - return "", errors.New("no attribute path specified") - } - - if len(data) == 0 { - return "", errors.New("empty user info JSON response provided") - } - - var buf any - if err := json.Unmarshal(data, &buf); err != nil { - return "", fmt.Errorf("%v: %w", "failed to unmarshal user info JSON response", err) - } - - val, err := jmespath.Search(attributePath, buf) - if err != nil { - return "", fmt.Errorf("failed to search user info JSON response with provided path: %q: %w", attributePath, err) - } - - return val, nil -} - -func (s *SocialBase) searchJSONForStringAttr(attributePath string, data []byte) (string, error) { - val, err := s.searchJSONForAttr(attributePath, data) - if err != nil { - return "", err - } - - strVal, ok := val.(string) - if ok { - return strVal, nil - } - - return "", nil -} - -func (s *SocialBase) searchJSONForStringArrayAttr(attributePath string, data []byte) ([]string, error) { - val, err := s.searchJSONForAttr(attributePath, data) - if err != nil { - return []string{}, err - } - - ifArr, ok := val.([]any) - if !ok { - return []string{}, nil - } - - result := []string{} - for _, v := range ifArr { - if strVal, ok := v.(string); ok { - result = append(result, strVal) - } - } - - return result, nil -} - func createOAuthConfig(info *social.OAuthInfo, cfg *setting.Cfg, defaultName string) *oauth2.Config { var authStyle oauth2.AuthStyle switch strings.ToLower(info.AuthStyle) { diff --git a/pkg/login/social/connectors/generic_oauth.go b/pkg/login/social/connectors/generic_oauth.go index 3e1c3cecb38..a8ddd13fd2a 100644 --- a/pkg/login/social/connectors/generic_oauth.go +++ b/pkg/login/social/connectors/generic_oauth.go @@ -13,6 +13,7 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" @@ -67,13 +68,13 @@ func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettin return provider } -func (s *SocialGenericOAuth) Validate(ctx context.Context, settings ssoModels.SSOSettings) error { +func (s *SocialGenericOAuth) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error { info, err := CreateOAuthInfoFromKeyValues(settings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } - err = validateInfo(info) + err = validateInfo(info, requester) if err != nil { return err } @@ -362,7 +363,7 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string { } if s.emailAttributePath != "" { - email, err := s.searchJSONForStringAttr(s.emailAttributePath, data.rawJSON) + email, err := util.SearchJSONForStringAttr(s.emailAttributePath, data.rawJSON) if err != nil { s.log.Error("Failed to search JSON for attribute", "error", err) } else if email != "" { @@ -394,7 +395,7 @@ func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson) string { if s.loginAttributePath != "" { s.log.Debug("Searching for login among JSON", "loginAttributePath", s.loginAttributePath) - login, err := s.searchJSONForStringAttr(s.loginAttributePath, data.rawJSON) + login, err := util.SearchJSONForStringAttr(s.loginAttributePath, data.rawJSON) if err != nil { s.log.Error("Failed to search JSON for login attribute", "error", err) } @@ -414,7 +415,7 @@ func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson) string { func (s *SocialGenericOAuth) extractUserName(data *UserInfoJson) string { if s.nameAttributePath != "" { - name, err := s.searchJSONForStringAttr(s.nameAttributePath, data.rawJSON) + name, err := util.SearchJSONForStringAttr(s.nameAttributePath, data.rawJSON) if err != nil { s.log.Error("Failed to search JSON for attribute", "error", err) } else if name != "" { @@ -442,7 +443,7 @@ func (s *SocialGenericOAuth) extractGroups(data *UserInfoJson) ([]string, error) return []string{}, nil } - return s.searchJSONForStringArrayAttr(s.groupsAttributePath, data.rawJSON) + return util.SearchJSONForStringSliceAttr(s.groupsAttributePath, data.rawJSON) } func (s *SocialGenericOAuth) FetchPrivateEmail(ctx context.Context, client *http.Client) (string, error) { @@ -553,7 +554,7 @@ func (s *SocialGenericOAuth) fetchTeamMembershipsFromTeamsUrl(ctx context.Contex return nil, err } - return s.searchJSONForStringArrayAttr(s.teamIdsAttributePath, response.Body) + return util.SearchJSONForStringSliceAttr(s.teamIdsAttributePath, response.Body) } func (s *SocialGenericOAuth) FetchOrganizations(ctx context.Context, client *http.Client) ([]string, bool) { diff --git a/pkg/login/social/connectors/generic_oauth_test.go b/pkg/login/social/connectors/generic_oauth_test.go index edcc5eb76e3..3812a2d43bd 100644 --- a/pkg/login/social/connectors/generic_oauth_test.go +++ b/pkg/login/social/connectors/generic_oauth_test.go @@ -13,216 +13,16 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) -func TestSearchJSONForEmail(t *testing.T) { - t.Run("Given a generic OAuth provider", func(t *testing.T) { - provider := NewGenericOAuthProvider(social.NewOAuthInfo(), &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) - - tests := []struct { - Name string - UserInfoJSONResponse []byte - EmailAttributePath string - ExpectedResult string - ExpectedError string - }{ - { - Name: "Given an invalid user info JSON response", - UserInfoJSONResponse: []byte("{"), - EmailAttributePath: "attributes.email", - ExpectedResult: "", - ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input", - }, - { - Name: "Given an empty user info JSON response and empty JMES path", - UserInfoJSONResponse: []byte{}, - EmailAttributePath: "", - ExpectedResult: "", - ExpectedError: "no attribute path specified", - }, - { - Name: "Given an empty user info JSON response and valid JMES path", - UserInfoJSONResponse: []byte{}, - EmailAttributePath: "attributes.email", - ExpectedResult: "", - ExpectedError: "empty user info JSON response provided", - }, - { - Name: "Given a simple user info JSON response and valid JMES path", - UserInfoJSONResponse: []byte(`{ - "attributes": { - "email": "grafana@localhost" - } -}`), - EmailAttributePath: "attributes.email", - ExpectedResult: "grafana@localhost", - }, - { - Name: "Given a user info JSON response with e-mails array and valid JMES path", - UserInfoJSONResponse: []byte(`{ - "attributes": { - "emails": ["grafana@localhost", "admin@localhost"] - } -}`), - EmailAttributePath: "attributes.emails[0]", - ExpectedResult: "grafana@localhost", - }, - { - Name: "Given a nested user info JSON response and valid JMES path", - UserInfoJSONResponse: []byte(`{ - "identities": [ - { - "userId": "grafana@localhost" - }, - { - "userId": "admin@localhost" - } - ] -}`), - EmailAttributePath: "identities[0].userId", - ExpectedResult: "grafana@localhost", - }, - } - - for _, test := range tests { - provider.emailAttributePath = test.EmailAttributePath - t.Run(test.Name, func(t *testing.T) { - actualResult, err := provider.searchJSONForStringAttr(test.EmailAttributePath, test.UserInfoJSONResponse) - if test.ExpectedError == "" { - require.NoError(t, err, "Testing case %q", test.Name) - } else { - require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name) - } - require.Equal(t, test.ExpectedResult, actualResult) - }) - } - }) -} - -func TestSearchJSONForGroups(t *testing.T) { - t.Run("Given a generic OAuth provider", func(t *testing.T) { - provider := NewGenericOAuthProvider(social.NewOAuthInfo(), &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) - - tests := []struct { - Name string - UserInfoJSONResponse []byte - GroupsAttributePath string - ExpectedResult []string - ExpectedError string - }{ - { - Name: "Given an invalid user info JSON response", - UserInfoJSONResponse: []byte("{"), - GroupsAttributePath: "attributes.groups", - ExpectedResult: []string{}, - ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input", - }, - { - Name: "Given an empty user info JSON response and empty JMES path", - UserInfoJSONResponse: []byte{}, - GroupsAttributePath: "", - ExpectedResult: []string{}, - ExpectedError: "no attribute path specified", - }, - { - Name: "Given an empty user info JSON response and valid JMES path", - UserInfoJSONResponse: []byte{}, - GroupsAttributePath: "attributes.groups", - ExpectedResult: []string{}, - ExpectedError: "empty user info JSON response provided", - }, - { - Name: "Given a simple user info JSON response and valid JMES path", - UserInfoJSONResponse: []byte(`{ - "attributes": { - "groups": ["foo", "bar"] - } -}`), - GroupsAttributePath: "attributes.groups[]", - ExpectedResult: []string{"foo", "bar"}, - }, - } - - for _, test := range tests { - provider.groupsAttributePath = test.GroupsAttributePath - t.Run(test.Name, func(t *testing.T) { - actualResult, err := provider.searchJSONForStringArrayAttr(test.GroupsAttributePath, test.UserInfoJSONResponse) - if test.ExpectedError == "" { - require.NoError(t, err, "Testing case %q", test.Name) - } else { - require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name) - } - require.Equal(t, test.ExpectedResult, actualResult) - }) - } - }) -} - -func TestSearchJSONForRole(t *testing.T) { - t.Run("Given a generic OAuth provider", func(t *testing.T) { - provider := NewGenericOAuthProvider(social.NewOAuthInfo(), &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) - - tests := []struct { - Name string - UserInfoJSONResponse []byte - RoleAttributePath string - ExpectedResult string - ExpectedError string - }{ - { - Name: "Given an invalid user info JSON response", - UserInfoJSONResponse: []byte("{"), - RoleAttributePath: "attributes.role", - ExpectedResult: "", - ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input", - }, - { - Name: "Given an empty user info JSON response and empty JMES path", - UserInfoJSONResponse: []byte{}, - RoleAttributePath: "", - ExpectedResult: "", - ExpectedError: "no attribute path specified", - }, - { - Name: "Given an empty user info JSON response and valid JMES path", - UserInfoJSONResponse: []byte{}, - RoleAttributePath: "attributes.role", - ExpectedResult: "", - ExpectedError: "empty user info JSON response provided", - }, - { - Name: "Given a simple user info JSON response and valid JMES path", - UserInfoJSONResponse: []byte(`{ - "attributes": { - "role": "admin" - } -}`), - RoleAttributePath: "attributes.role", - ExpectedResult: "admin", - }, - } - - for _, test := range tests { - provider.info.RoleAttributePath = test.RoleAttributePath - t.Run(test.Name, func(t *testing.T) { - actualResult, err := provider.searchJSONForStringAttr(test.RoleAttributePath, test.UserInfoJSONResponse) - if test.ExpectedError == "" { - require.NoError(t, err, "Testing case %q", test.Name) - } else { - require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name) - } - require.Equal(t, test.ExpectedResult, actualResult) - }) - } - }) -} - func TestUserInfoSearchesForEmailAndRole(t *testing.T) { provider := NewGenericOAuthProvider(&social.OAuthInfo{ EmailAttributePath: "email", @@ -920,17 +720,20 @@ func TestSocialGenericOAuth_InitializeExtraFields(t *testing.T) { func TestSocialGenericOAuth_Validate(t *testing.T) { testCases := []struct { - name string - settings ssoModels.SSOSettings - wantErr error + name string + settings ssoModels.SSOSettings + requester identity.Requester + wantErr error }{ { name: "SSOSettings is valid", settings: ssoModels.SSOSettings{ Settings: map[string]any{ - "client_id": "client-id", + "client_id": "client-id", + "allow_assign_grafana_admin": "true", }, }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, }, { name: "fails if settings map contains an invalid field", @@ -969,13 +772,30 @@ func TestSocialGenericOAuth_Validate(t *testing.T) { }, wantErr: ssosettings.ErrBaseInvalidOAuthConfig, }, + { + name: "fails if the user is not allowed to update allow assign grafana admin", + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "true", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { s := NewGenericOAuthProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) - err := s.Validate(context.Background(), tc.settings) + if tc.requester == nil { + tc.requester = &user.SignedInUser{IsGrafanaAdmin: false} + } + err := s.Validate(context.Background(), tc.settings, tc.requester) if tc.wantErr != nil { require.ErrorIs(t, err, tc.wantErr) return diff --git a/pkg/login/social/connectors/github_oauth.go b/pkg/login/social/connectors/github_oauth.go index de7fc6def39..8e9bddfc316 100644 --- a/pkg/login/social/connectors/github_oauth.go +++ b/pkg/login/social/connectors/github_oauth.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" @@ -74,13 +75,13 @@ func NewGitHubProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings sso return provider } -func (s *SocialGithub) Validate(ctx context.Context, settings ssoModels.SSOSettings) error { +func (s *SocialGithub) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error { info, err := CreateOAuthInfoFromKeyValues(settings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } - err = validateInfo(info) + err = validateInfo(info, requester) if err != nil { return err } diff --git a/pkg/login/social/connectors/github_oauth_test.go b/pkg/login/social/connectors/github_oauth_test.go index f0e09a85e07..9b27703da7d 100644 --- a/pkg/login/social/connectors/github_oauth_test.go +++ b/pkg/login/social/connectors/github_oauth_test.go @@ -12,10 +12,12 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -346,17 +348,20 @@ func TestSocialGitHub_InitializeExtraFields(t *testing.T) { func TestSocialGitHub_Validate(t *testing.T) { testCases := []struct { - name string - settings ssoModels.SSOSettings - wantErr error + name string + settings ssoModels.SSOSettings + requester identity.Requester + wantErr error }{ { name: "SSOSettings is valid", settings: ssoModels.SSOSettings{ Settings: map[string]any{ - "client_id": "client-id", + "client_id": "client-id", + "allow_assign_grafana_admin": "true", }, }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, }, { name: "fails if settings map contains an invalid field", @@ -405,13 +410,31 @@ func TestSocialGitHub_Validate(t *testing.T) { }, wantErr: ssosettings.ErrBaseInvalidOAuthConfig, }, + { + name: "fails if the user is not allowed to update allow assign grafana admin", + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "true", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { s := NewGitHubProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) - err := s.Validate(context.Background(), tc.settings) + if tc.requester == nil { + tc.requester = &user.SignedInUser{IsGrafanaAdmin: false} + } + + err := s.Validate(context.Background(), tc.settings, tc.requester) if tc.wantErr != nil { require.ErrorIs(t, err, tc.wantErr) return diff --git a/pkg/login/social/connectors/gitlab_oauth.go b/pkg/login/social/connectors/gitlab_oauth.go index 4025c26d1bf..329b4d58c11 100644 --- a/pkg/login/social/connectors/gitlab_oauth.go +++ b/pkg/login/social/connectors/gitlab_oauth.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" @@ -64,13 +65,13 @@ func NewGitLabProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings sso return provider } -func (s *SocialGitlab) Validate(ctx context.Context, settings ssoModels.SSOSettings) error { +func (s *SocialGitlab) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error { info, err := CreateOAuthInfoFromKeyValues(settings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } - err = validateInfo(info) + err = validateInfo(info, requester) if err != nil { return err } diff --git a/pkg/login/social/connectors/gitlab_oauth_test.go b/pkg/login/social/connectors/gitlab_oauth_test.go index f2bf015da03..926bccaae9f 100644 --- a/pkg/login/social/connectors/gitlab_oauth_test.go +++ b/pkg/login/social/connectors/gitlab_oauth_test.go @@ -16,11 +16,13 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -464,17 +466,20 @@ func TestSocialGitlab_GetGroupsNextPage(t *testing.T) { func TestSocialGitlab_Validate(t *testing.T) { testCases := []struct { - name string - settings ssoModels.SSOSettings - wantErr error + name string + settings ssoModels.SSOSettings + requester identity.Requester + wantErr error }{ { name: "SSOSettings is valid", settings: ssoModels.SSOSettings{ Settings: map[string]any{ - "client_id": "client-id", + "client_id": "client-id", + "allow_assign_grafana_admin": "true", }, }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, }, { name: "fails if settings map contains an invalid field", @@ -513,13 +518,31 @@ func TestSocialGitlab_Validate(t *testing.T) { }, wantErr: ssosettings.ErrBaseInvalidOAuthConfig, }, + { + name: "fails if the user is not allowed to update allow assign grafana admin", + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "true", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { s := NewGitLabProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) - err := s.Validate(context.Background(), tc.settings) + if tc.requester == nil { + tc.requester = &user.SignedInUser{IsGrafanaAdmin: false} + } + + err := s.Validate(context.Background(), tc.settings, tc.requester) if tc.wantErr != nil { require.ErrorIs(t, err, tc.wantErr) return diff --git a/pkg/login/social/connectors/google_oauth.go b/pkg/login/social/connectors/google_oauth.go index 1398f045397..6964a21dd6c 100644 --- a/pkg/login/social/connectors/google_oauth.go +++ b/pkg/login/social/connectors/google_oauth.go @@ -11,6 +11,7 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" @@ -54,13 +55,13 @@ func NewGoogleProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings sso return provider } -func (s *SocialGoogle) Validate(ctx context.Context, settings ssoModels.SSOSettings) error { +func (s *SocialGoogle) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error { info, err := CreateOAuthInfoFromKeyValues(settings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } - err = validateInfo(info) + err = validateInfo(info, requester) if err != nil { return err } diff --git a/pkg/login/social/connectors/google_oauth_test.go b/pkg/login/social/connectors/google_oauth_test.go index df66caa0d7c..055f416952f 100644 --- a/pkg/login/social/connectors/google_oauth_test.go +++ b/pkg/login/social/connectors/google_oauth_test.go @@ -16,10 +16,12 @@ import ( "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -669,17 +671,20 @@ func TestSocialGoogle_UserInfo(t *testing.T) { func TestSocialGoogle_Validate(t *testing.T) { testCases := []struct { - name string - settings ssoModels.SSOSettings - wantErr error + name string + settings ssoModels.SSOSettings + requester identity.Requester + wantErr error }{ { name: "SSOSettings is valid", settings: ssoModels.SSOSettings{ Settings: map[string]any{ - "client_id": "client-id", + "client_id": "client-id", + "allow_assign_grafana_admin": "true", }, }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, }, { name: "fails if settings map contains an invalid field", @@ -718,13 +723,31 @@ func TestSocialGoogle_Validate(t *testing.T) { }, wantErr: ssosettings.ErrBaseInvalidOAuthConfig, }, + { + name: "fails if the user is not allowed to update allow assign grafana admin", + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "true", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { s := NewGoogleProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) - err := s.Validate(context.Background(), tc.settings) + if tc.requester == nil { + tc.requester = &user.SignedInUser{IsGrafanaAdmin: false} + } + + err := s.Validate(context.Background(), tc.settings, tc.requester) if tc.wantErr != nil { require.ErrorIs(t, err, tc.wantErr) return diff --git a/pkg/login/social/connectors/grafana_com_oauth.go b/pkg/login/social/connectors/grafana_com_oauth.go index 60c3d23e51e..1db16e06c29 100644 --- a/pkg/login/social/connectors/grafana_com_oauth.go +++ b/pkg/login/social/connectors/grafana_com_oauth.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/ssosettings" @@ -52,13 +53,13 @@ func NewGrafanaComProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings return provider } -func (s *SocialGrafanaCom) Validate(ctx context.Context, settings ssoModels.SSOSettings) error { +func (s *SocialGrafanaCom) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error { info, err := CreateOAuthInfoFromKeyValues(settings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } - err = validateInfo(info) + err = validateInfo(info, requester) if err != nil { return err } diff --git a/pkg/login/social/connectors/grafana_com_oauth_test.go b/pkg/login/social/connectors/grafana_com_oauth_test.go index bdcf15e081b..dc800775f89 100644 --- a/pkg/login/social/connectors/grafana_com_oauth_test.go +++ b/pkg/login/social/connectors/grafana_com_oauth_test.go @@ -10,9 +10,11 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -139,15 +141,18 @@ func TestSocialGrafanaCom_Validate(t *testing.T) { testCases := []struct { name string settings ssoModels.SSOSettings + requester identity.Requester expectError bool }{ { name: "SSOSettings is valid", settings: ssoModels.SSOSettings{ Settings: map[string]any{ - "client_id": "client-id", + "client_id": "client-id", + "allow_assign_grafana_admin": "true", }, }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, expectError: false, }, { @@ -176,13 +181,31 @@ func TestSocialGrafanaCom_Validate(t *testing.T) { }, expectError: true, }, + { + name: "fails if the user is not allowed to update allow assign grafana admin", + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "true", + }, + }, + expectError: true, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { s := NewGrafanaComProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) - err := s.Validate(context.Background(), tc.settings) + if tc.requester == nil { + tc.requester = &user.SignedInUser{IsGrafanaAdmin: false} + } + + err := s.Validate(context.Background(), tc.settings, tc.requester) if tc.expectError { require.Error(t, err) } else { diff --git a/pkg/login/social/connectors/okta_oauth.go b/pkg/login/social/connectors/okta_oauth.go index 39eb41a1bc6..8354ab1a20f 100644 --- a/pkg/login/social/connectors/okta_oauth.go +++ b/pkg/login/social/connectors/okta_oauth.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" @@ -60,13 +61,13 @@ func NewOktaProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssose return provider } -func (s *SocialOkta) Validate(ctx context.Context, settings ssoModels.SSOSettings) error { +func (s *SocialOkta) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error { info, err := CreateOAuthInfoFromKeyValues(settings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } - err = validateInfo(info) + err = validateInfo(info, requester) if err != nil { return err } diff --git a/pkg/login/social/connectors/okta_oauth_test.go b/pkg/login/social/connectors/okta_oauth_test.go index 2bddad48984..e390680a522 100644 --- a/pkg/login/social/connectors/okta_oauth_test.go +++ b/pkg/login/social/connectors/okta_oauth_test.go @@ -14,10 +14,12 @@ import ( "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/roletype" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -137,17 +139,20 @@ func TestSocialOkta_UserInfo(t *testing.T) { func TestSocialOkta_Validate(t *testing.T) { testCases := []struct { - name string - settings ssoModels.SSOSettings - wantErr error + name string + settings ssoModels.SSOSettings + requester identity.Requester + wantErr error }{ { name: "SSOSettings is valid", settings: ssoModels.SSOSettings{ Settings: map[string]any{ - "client_id": "client-id", + "client_id": "client-id", + "allow_assign_grafana_admin": "true", }, }, + requester: &user.SignedInUser{IsGrafanaAdmin: true}, }, { name: "fails if settings map contains an invalid field", @@ -186,13 +191,30 @@ func TestSocialOkta_Validate(t *testing.T) { }, wantErr: ssosettings.ErrBaseInvalidOAuthConfig, }, + { + name: "fails if the user is not allowed to update allow assign grafana admin", + requester: &user.SignedInUser{ + IsGrafanaAdmin: false, + }, + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "allow_assign_grafana_admin": "true", + "skip_org_role_sync": "true", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { s := NewOktaProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures()) - err := s.Validate(context.Background(), tc.settings) + if tc.requester == nil { + tc.requester = &user.SignedInUser{IsGrafanaAdmin: false} + } + err := s.Validate(context.Background(), tc.settings, tc.requester) if tc.wantErr != nil { require.ErrorIs(t, err, tc.wantErr) return diff --git a/pkg/login/social/connectors/social_base.go b/pkg/login/social/connectors/social_base.go index 31f8163cbba..afe64097c4e 100644 --- a/pkg/login/social/connectors/social_base.go +++ b/pkg/login/social/connectors/social_base.go @@ -17,10 +17,12 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/ssosettings" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) type SocialBase struct { @@ -111,13 +113,13 @@ func (s *SocialBase) extractRoleAndAdmin(rawJSON []byte, groups []string) (org.R } func (s *SocialBase) searchRole(rawJSON []byte, groups []string) (org.RoleType, bool) { - role, err := s.searchJSONForStringAttr(s.info.RoleAttributePath, rawJSON) + role, err := util.SearchJSONForStringAttr(s.info.RoleAttributePath, rawJSON) if err == nil && role != "" { return getRoleFromSearch(role) } if groupBytes, err := json.Marshal(groupStruct{groups}); err == nil { - role, err := s.searchJSONForStringAttr(s.info.RoleAttributePath, groupBytes) + role, err := util.SearchJSONForStringAttr(s.info.RoleAttributePath, groupBytes) if err == nil && role != "" { return getRoleFromSearch(role) } @@ -220,9 +222,13 @@ func getRoleFromSearch(role string) (org.RoleType, bool) { return org.RoleType(cases.Title(language.Und).String(role)), false } -func validateInfo(info *social.OAuthInfo) error { +func validateInfo(info *social.OAuthInfo, requester identity.Requester) error { if info.ClientId == "" { - return ssosettings.ErrInvalidOAuthConfig("ClientId is empty") + return ssosettings.ErrInvalidOAuthConfig("Client Id is empty.") + } + + if info.AllowAssignGrafanaAdmin && !requester.GetIsGrafanaAdmin() { + return ssosettings.ErrInvalidOAuthConfig("Allow assign Grafana Admin can only be updated by Grafana Server Admins.") } if info.AllowAssignGrafanaAdmin && info.SkipOrgRoleSync { diff --git a/pkg/login/social/socialimpl/service_test.go b/pkg/login/social/socialimpl/service_test.go index a1c703ea1ba..a0f80172de4 100644 --- a/pkg/login/social/socialimpl/service_test.go +++ b/pkg/login/social/socialimpl/service_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingsimpl" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestSocialService_ProvideService(t *testing.T) { type testEnv struct { features featuremgmt.FeatureToggles diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index 00fe2bc7f74..aa3dcd0180b 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -14,7 +14,7 @@ type Installer interface { // Add adds a new plugin. Add(ctx context.Context, pluginID, version string, opts CompatOpts) error // Remove removes an existing plugin. - Remove(ctx context.Context, pluginID string) error + Remove(ctx context.Context, pluginID, version string) error } type PluginSource interface { @@ -25,7 +25,7 @@ type PluginSource interface { type FileStore interface { // File retrieves a plugin file. - File(ctx context.Context, pluginID, filename string) (*File, error) + File(ctx context.Context, pluginID, pluginVersion, filename string) (*File, error) } type File struct { diff --git a/pkg/plugins/manager/client/client.go b/pkg/plugins/manager/client/client.go index 9914b615f2e..3565d97ef9f 100644 --- a/pkg/plugins/manager/client/client.go +++ b/pkg/plugins/manager/client/client.go @@ -44,7 +44,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return nil, errNilRequest } - p, exists := s.plugin(ctx, req.PluginContext.PluginID) + p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion) if !exists { return nil, plugins.ErrPluginNotRegistered } @@ -87,7 +87,7 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq return errNilSender } - p, exists := s.plugin(ctx, req.PluginContext.PluginID) + p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion) if !exists { return plugins.ErrPluginNotRegistered } @@ -130,7 +130,7 @@ func (s *Service) CollectMetrics(ctx context.Context, req *backend.CollectMetric return nil, errNilRequest } - p, exists := s.plugin(ctx, req.PluginContext.PluginID) + p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion) if !exists { return nil, plugins.ErrPluginNotRegistered } @@ -152,7 +152,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque return nil, errNilRequest } - p, exists := s.plugin(ctx, req.PluginContext.PluginID) + p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion) if !exists { return nil, plugins.ErrPluginNotRegistered } @@ -182,7 +182,7 @@ func (s *Service) SubscribeStream(ctx context.Context, req *backend.SubscribeStr return nil, errNilRequest } - plugin, exists := s.plugin(ctx, req.PluginContext.PluginID) + plugin, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion) if !exists { return nil, plugins.ErrPluginNotRegistered } @@ -195,7 +195,7 @@ func (s *Service) PublishStream(ctx context.Context, req *backend.PublishStreamR return nil, errNilRequest } - plugin, exists := s.plugin(ctx, req.PluginContext.PluginID) + plugin, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion) if !exists { return nil, plugins.ErrPluginNotRegistered } @@ -212,7 +212,7 @@ func (s *Service) RunStream(ctx context.Context, req *backend.RunStreamRequest, return errNilSender } - plugin, exists := s.plugin(ctx, req.PluginContext.PluginID) + plugin, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion) if !exists { return plugins.ErrPluginNotRegistered } @@ -221,8 +221,8 @@ func (s *Service) RunStream(ctx context.Context, req *backend.RunStreamRequest, } // plugin finds a plugin with `pluginID` from the registry that is not decommissioned -func (s *Service) plugin(ctx context.Context, pluginID string) (*plugins.Plugin, bool) { - p, exists := s.pluginRegistry.Plugin(ctx, pluginID) +func (s *Service) plugin(ctx context.Context, pluginID, pluginVersion string) (*plugins.Plugin, bool) { + p, exists := s.pluginRegistry.Plugin(ctx, pluginID, pluginVersion) if !exists { return nil, false } diff --git a/pkg/plugins/manager/fakes/fakes.go b/pkg/plugins/manager/fakes/fakes.go index e460cef4ef9..51f40db5838 100644 --- a/pkg/plugins/manager/fakes/fakes.go +++ b/pkg/plugins/manager/fakes/fakes.go @@ -176,7 +176,7 @@ func NewFakePluginRegistry() *FakePluginRegistry { } } -func (f *FakePluginRegistry) Plugin(_ context.Context, id string) (*plugins.Plugin, bool) { +func (f *FakePluginRegistry) Plugin(_ context.Context, id, _ string) (*plugins.Plugin, bool) { p, exists := f.Store[id] return p, exists } @@ -195,7 +195,7 @@ func (f *FakePluginRegistry) Add(_ context.Context, p *plugins.Plugin) error { return nil } -func (f *FakePluginRegistry) Remove(_ context.Context, id string) error { +func (f *FakePluginRegistry) Remove(_ context.Context, id, _ string) error { delete(f.Store, id) return nil } @@ -423,12 +423,12 @@ func (s *FakePluginSource) DefaultSignature(ctx context.Context) (plugins.Signat } type FakePluginFileStore struct { - FileFunc func(ctx context.Context, pluginID, filename string) (*plugins.File, error) + FileFunc func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) } -func (f *FakePluginFileStore) File(ctx context.Context, pluginID, filename string) (*plugins.File, error) { +func (f *FakePluginFileStore) File(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) { if f.FileFunc != nil { - return f.FileFunc(ctx, pluginID, filename) + return f.FileFunc(ctx, pluginID, pluginVersion, filename) } return nil, nil } diff --git a/pkg/plugins/manager/filestore/fs.go b/pkg/plugins/manager/filestore/fs.go index 6d133d7bc6d..155880eb570 100644 --- a/pkg/plugins/manager/filestore/fs.go +++ b/pkg/plugins/manager/filestore/fs.go @@ -21,8 +21,8 @@ func ProvideService(pluginRegistry registry.Service) *Service { } } -func (s *Service) File(ctx context.Context, pluginID, filename string) (*plugins.File, error) { - if p, exists := s.pluginRegistry.Plugin(ctx, pluginID); exists { +func (s *Service) File(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) { + if p, exists := s.pluginRegistry.Plugin(ctx, pluginID, pluginVersion); exists { f, err := p.File(filename) if err != nil { return nil, err diff --git a/pkg/plugins/manager/installer.go b/pkg/plugins/manager/installer.go index e4f26dc54c8..4463220a885 100644 --- a/pkg/plugins/manager/installer.go +++ b/pkg/plugins/manager/installer.go @@ -55,7 +55,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt } var pluginArchive *repo.PluginArchive - if plugin, exists := m.plugin(ctx, pluginID); exists { + if plugin, exists := m.plugin(ctx, pluginID, version); exists { if plugin.IsCorePlugin() || plugin.IsBundledPlugin() { return plugins.ErrInstallCorePlugin } @@ -84,7 +84,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt } // remove existing installation of plugin - err = m.Remove(ctx, plugin.ID) + err = m.Remove(ctx, plugin.ID, plugin.Info.Version) if err != nil { return err } @@ -139,8 +139,8 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt return nil } -func (m *PluginInstaller) Remove(ctx context.Context, pluginID string) error { - plugin, exists := m.plugin(ctx, pluginID) +func (m *PluginInstaller) Remove(ctx context.Context, pluginID, version string) error { + plugin, exists := m.plugin(ctx, pluginID, version) if !exists { return plugins.ErrPluginNotInstalled } @@ -168,8 +168,8 @@ func (m *PluginInstaller) Remove(ctx context.Context, pluginID string) error { } // plugin finds a plugin with `pluginID` from the store -func (m *PluginInstaller) plugin(ctx context.Context, pluginID string) (*plugins.Plugin, bool) { - p, exists := m.pluginRegistry.Plugin(ctx, pluginID) +func (m *PluginInstaller) plugin(ctx context.Context, pluginID, pluginVersion string) (*plugins.Plugin, bool) { + p, exists := m.pluginRegistry.Plugin(ctx, pluginID, pluginVersion) if !exists { return nil, false } diff --git a/pkg/plugins/manager/installer_test.go b/pkg/plugins/manager/installer_test.go index 43465cd23ff..7bfadece3af 100644 --- a/pkg/plugins/manager/installer_test.go +++ b/pkg/plugins/manager/installer_test.go @@ -23,6 +23,8 @@ func TestPluginManager_Add_Remove(t *testing.T) { const ( pluginID, v1 = "test-panel", "1.0.0" zipNameV1 = "test-panel-1.0.0.zip" + v2 = "2.0.0" + zipNameV2 = "test-panel-2.0.0.zip" ) // mock a plugin to be returned automatically by the plugin loader @@ -83,10 +85,6 @@ func TestPluginManager_Add_Remove(t *testing.T) { }) t.Run("Update plugin to different version", func(t *testing.T) { - const ( - v2 = "2.0.0" - zipNameV2 = "test-panel-2.0.0.zip" - ) // mock a plugin to be returned automatically by the plugin loader pluginV2 := createPlugin(t, pluginID, plugins.ClassExternal, true, true, func(plugin *plugins.Plugin) { plugin.Info.Version = v2 @@ -138,7 +136,7 @@ func TestPluginManager_Add_Remove(t *testing.T) { }, } - err = inst.Remove(context.Background(), pluginID) + err = inst.Remove(context.Background(), pluginID, v2) require.NoError(t, err) require.Equal(t, []string{pluginID}, unloadedPlugins) @@ -146,7 +144,7 @@ func TestPluginManager_Add_Remove(t *testing.T) { t.Run("Won't remove if not exists", func(t *testing.T) { inst.pluginRegistry = fakes.NewFakePluginRegistry() - err = inst.Remove(context.Background(), pluginID) + err = inst.Remove(context.Background(), pluginID, v2) require.Equal(t, plugins.ErrPluginNotInstalled, err) }) }) @@ -179,7 +177,7 @@ func TestPluginManager_Add_Remove(t *testing.T) { require.Equal(t, plugins.ErrInstallCorePlugin, err) t.Run(fmt.Sprintf("Can't uninstall %s plugin", tc.class), func(t *testing.T) { - err = pm.Remove(context.Background(), p.ID) + err = pm.Remove(context.Background(), p.ID, p.Info.Version) require.Equal(t, plugins.ErrUninstallCorePlugin, err) }) } diff --git a/pkg/plugins/manager/pipeline/discovery/steps.go b/pkg/plugins/manager/pipeline/discovery/steps.go index 179eca4c12a..bff2c41856d 100644 --- a/pkg/plugins/manager/pipeline/discovery/steps.go +++ b/pkg/plugins/manager/pipeline/discovery/steps.go @@ -6,9 +6,7 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" - "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/loader/finder" - "github.com/grafana/grafana/pkg/plugins/manager/registry" ) // DefaultFindFunc is the default function used for the Find step of the Discovery stage. It will scan the local @@ -17,44 +15,6 @@ func DefaultFindFunc(cfg *config.Cfg) FindFunc { return finder.NewLocalFinder(cfg.DevMode, cfg.Features).Find } -// DuplicatePluginValidation is a filter step that will filter out any plugins that are already registered with the -// registry. This includes both the primary plugin and any child plugins, which are matched using the plugin ID field. -type DuplicatePluginValidation struct { - registry registry.Service - log log.Logger -} - -// NewDuplicatePluginFilterStep returns a new DuplicatePluginValidation. -func NewDuplicatePluginFilterStep(registry registry.Service) *DuplicatePluginValidation { - return &DuplicatePluginValidation{ - registry: registry, - log: log.New("plugins.dedupe"), - } -} - -// Filter will filter out any plugins that are already registered with the registry. -func (d *DuplicatePluginValidation) Filter(ctx context.Context, bundles []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) { - res := make([]*plugins.FoundBundle, 0, len(bundles)) - for _, b := range bundles { - _, exists := d.registry.Plugin(ctx, b.Primary.JSONData.ID) - if exists { - d.log.Warn("Skipping loading of plugin as it's a duplicate", "pluginId", b.Primary.JSONData.ID) - continue - } - - for _, child := range b.Children { - _, exists = d.registry.Plugin(ctx, child.JSONData.ID) - if exists { - d.log.Warn("Skipping loading of child plugin as it's a duplicate", "pluginId", child.JSONData.ID) - continue - } - } - res = append(res, b) - } - - return res, nil -} - // PermittedPluginTypesFilter is a filter step that will filter out any plugins that are not of a permitted type. type PermittedPluginTypesFilter struct { permittedTypes []plugins.Type diff --git a/pkg/plugins/manager/pipeline/termination/steps.go b/pkg/plugins/manager/pipeline/termination/steps.go index e2fdcc3bed8..0d5e2144d40 100644 --- a/pkg/plugins/manager/pipeline/termination/steps.go +++ b/pkg/plugins/manager/pipeline/termination/steps.go @@ -54,7 +54,7 @@ func newDeregister(pluginRegistry registry.Service) *Deregister { // Deregister removes a plugin from the plugin registry. func (d *Deregister) Deregister(ctx context.Context, p *plugins.Plugin) error { - if err := d.pluginRegistry.Remove(ctx, p.ID); err != nil { + if err := d.pluginRegistry.Remove(ctx, p.ID, p.Info.Version); err != nil { return err } d.log.Debug("Plugin unregistered", "pluginId", p.ID) diff --git a/pkg/plugins/manager/registry/ifaces.go b/pkg/plugins/manager/registry/ifaces.go index 94c7696eac8..680dcdab5bf 100644 --- a/pkg/plugins/manager/registry/ifaces.go +++ b/pkg/plugins/manager/registry/ifaces.go @@ -8,12 +8,12 @@ import ( // Service is responsible for the internal storing and retrieval of plugins. type Service interface { - // Plugin finds a plugin by its ID. - Plugin(ctx context.Context, id string) (*plugins.Plugin, bool) + // Plugin finds a plugin by its ID and version. + Plugin(ctx context.Context, id, version string) (*plugins.Plugin, bool) // Plugins returns all plugins. Plugins(ctx context.Context) []*plugins.Plugin // Add adds the provided plugin to the registry. Add(ctx context.Context, plugin *plugins.Plugin) error // Remove deletes the requested plugin from the registry. - Remove(ctx context.Context, id string) error + Remove(ctx context.Context, id, version string) error } diff --git a/pkg/plugins/manager/registry/in_memory.go b/pkg/plugins/manager/registry/in_memory.go index 8b533b985d8..af3652e5d6c 100644 --- a/pkg/plugins/manager/registry/in_memory.go +++ b/pkg/plugins/manager/registry/in_memory.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/plugins" ) +// InMemory is a registry that only allows a single version of a plugin to be registered at a time. type InMemory struct { store map[string]*plugins.Plugin alias map[string]*plugins.Plugin @@ -25,7 +26,7 @@ func NewInMemory() *InMemory { } } -func (i *InMemory) Plugin(_ context.Context, pluginID string) (*plugins.Plugin, bool) { +func (i *InMemory) Plugin(_ context.Context, pluginID, _ string) (*plugins.Plugin, bool) { return i.plugin(pluginID) } @@ -56,7 +57,7 @@ func (i *InMemory) Add(_ context.Context, p *plugins.Plugin) error { return nil } -func (i *InMemory) Remove(_ context.Context, pluginID string) error { +func (i *InMemory) Remove(_ context.Context, pluginID, _ string) error { p, ok := i.plugin(pluginID) if !ok { return fmt.Errorf("plugin %s is not registered", pluginID) diff --git a/pkg/plugins/manager/registry/in_memory_test.go b/pkg/plugins/manager/registry/in_memory_test.go index 25d3f42342a..31d6a21a2df 100644 --- a/pkg/plugins/manager/registry/in_memory_test.go +++ b/pkg/plugins/manager/registry/in_memory_test.go @@ -11,29 +11,41 @@ import ( "github.com/grafana/grafana/pkg/plugins" ) -const pluginID = "test-ds" +const ( + pluginID = "test-ds" + v1 = "1.0.0" + v2 = "2.0.0" +) func TestInMemory(t *testing.T) { t.Run("Test mix of registry operations", func(t *testing.T) { i := NewInMemory() ctx := context.Background() - p, exists := i.Plugin(ctx, pluginID) + p, exists := i.Plugin(ctx, pluginID, v1) require.False(t, exists) require.Nil(t, p) - err := i.Remove(ctx, pluginID) + err := i.Remove(ctx, pluginID, v1) require.EqualError(t, err, fmt.Errorf("plugin %s is not registered", pluginID).Error()) + pv1 := &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID, Info: plugins.Info{Version: v1}}} + err = i.Add(ctx, pv1) + require.NoError(t, err) + + pv2 := &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID, Info: plugins.Info{Version: v2}}} + err = i.Add(ctx, pv2) + require.Errorf(t, err, fmt.Sprintf("plugin %s is already registered", pluginID)) + + existingP, exists := i.Plugin(ctx, pluginID, v1) + require.True(t, exists) + require.Equal(t, pv1, existingP) + p = &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID}} err = i.Add(ctx, p) - require.NoError(t, err) + require.Errorf(t, err, fmt.Sprintf("plugin %s is already registered", pluginID)) - existingP, exists := i.Plugin(ctx, pluginID) - require.True(t, exists) - require.Equal(t, p, existingP) - - err = i.Remove(ctx, pluginID) + err = i.Remove(ctx, pluginID, v1) require.NoError(t, err) existingPlugins := i.Plugins(ctx) @@ -87,6 +99,28 @@ func TestInMemory_Add(t *testing.T) { }, err: fmt.Errorf("plugin %s is already registered", pluginID), }, + { + name: "Cannot add a plugin to the registry even if it has a different version", + mocks: mocks{ + store: map[string]*plugins.Plugin{ + pluginID: { + JSONData: plugins.JSONData{ + ID: pluginID, + Info: plugins.Info{Version: v1}, + }, + }, + }, + }, + args: args{ + p: &plugins.Plugin{ + JSONData: plugins.JSONData{ + ID: pluginID, + Info: plugins.Info{Version: v2}, + }, + }, + }, + err: fmt.Errorf("plugin %s is already registered", pluginID), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -151,7 +185,7 @@ func TestInMemory_Plugin(t *testing.T) { i := &InMemory{ store: tt.mocks.store, } - p, exists := i.Plugin(context.Background(), tt.args.pluginID) + p, exists := i.Plugin(context.Background(), tt.args.pluginID, "") if exists != tt.exists { t.Errorf("Plugin() got1 = %v, expected %v", exists, tt.exists) } @@ -265,7 +299,7 @@ func TestInMemory_Remove(t *testing.T) { i := &InMemory{ store: tt.mocks.store, } - err := i.Remove(context.Background(), tt.args.pluginID) + err := i.Remove(context.Background(), tt.args.pluginID, "") require.Equal(t, tt.err, err) }) } @@ -280,7 +314,7 @@ func TestAliasSupport(t *testing.T) { pluginIdOld := "plugin-old" pluginIdOld2 := "plugin-old2" - p, exists := i.Plugin(ctx, pluginIdNew) + p, exists := i.Plugin(ctx, pluginIdNew, "") require.False(t, exists) require.Nil(t, p) @@ -294,17 +328,17 @@ func TestAliasSupport(t *testing.T) { require.NoError(t, err) // Can lookup by the new ID - found, exists := i.Plugin(ctx, pluginIdNew) + found, exists := i.Plugin(ctx, pluginIdNew, "") require.True(t, exists) require.Equal(t, pluginNew, found) // Can lookup by the old ID - found, exists = i.Plugin(ctx, pluginIdOld) + found, exists = i.Plugin(ctx, pluginIdOld, "") require.True(t, exists) require.Equal(t, pluginNew, found) // Can lookup by the other old ID - found, exists = i.Plugin(ctx, pluginIdOld2) + found, exists = i.Plugin(ctx, pluginIdOld2, "") require.True(t, exists) require.Equal(t, pluginNew, found) @@ -313,7 +347,7 @@ func TestAliasSupport(t *testing.T) { ID: pluginIdOld, }} require.NoError(t, i.Add(ctx, pluginOld)) - found, exists = i.Plugin(ctx, pluginIdOld) + found, exists = i.Plugin(ctx, pluginIdOld, "") require.True(t, exists) require.Equal(t, pluginOld, found) }) diff --git a/pkg/plugins/plugindef/plugindef.cue b/pkg/plugins/plugindef/plugindef.cue index 1ddcd19d251..40ab7c25752 100644 --- a/pkg/plugins/plugindef/plugindef.cue +++ b/pkg/plugins/plugindef/plugindef.cue @@ -16,7 +16,7 @@ schemas: [{ // grafana.com, then the plugin `id` has to follow the naming // conventions. id: string & strings.MinRunes(1) - id: =~"^([0-9a-z]+\\-([0-9a-z]+\\-)?(\(strings.Join([ for t in _types {t}], "|"))))|(alertGroups|alertlist|annolist|barchart|bargauge|candlestick|canvas|dashlist|debug|datagrid|gauge|geomap|gettingstarted|graph|heatmap|histogram|icon|live|logs|news|nodeGraph|piechart|pluginlist|stat|state-timeline|status-history|table|table-old|text|timeseries|trend|traces|welcome|xychart|alertmanager|cloudwatch|dashboard|elasticsearch|grafana|grafana-azure-monitor-datasource|grafana-cloud-monitoring-datasource|graphite|influxdb|jaeger|loki|mixed|mssql|mysql|opentsdb|postgres|prometheus|stackdriver|tempo|grafana-testdata-datasource|zipkin|phlare|parca)$" + id: =~"^([0-9a-z]+\\-([0-9a-z]+\\-)?(\(strings.Join([ for t in _types {t}], "|"))))|(alertGroups|alertlist|annolist|barchart|bargauge|candlestick|canvas|dashlist|debug|datagrid|gauge|geomap|gettingstarted|graph|heatmap|histogram|icon|live|logs|news|nodeGraph|piechart|pluginlist|stat|state-timeline|status-history|table|table-old|text|timeseries|trend|traces|welcome|xychart|alertmanager|cloudwatch|dashboard|elasticsearch|grafana|grafana-azure-monitor-datasource|stackdriver|graphite|influxdb|jaeger|loki|mixed|mssql|mysql|opentsdb|postgres|prometheus|stackdriver|tempo|grafana-testdata-datasource|zipkin|phlare|parca)$" // An alias is useful when migrating from one plugin id to another (rebranding etc) // This should be used sparingly, and is currently only supported though a hardcoded checklist diff --git a/pkg/registry/apis/featuretoggle/README.md b/pkg/registry/apis/featuretoggle/README.md new file mode 100644 index 00000000000..7267e2e2819 --- /dev/null +++ b/pkg/registry/apis/featuretoggle/README.md @@ -0,0 +1,5 @@ +This package supports the [Feature toggle admin page](https://grafana.com/docs/grafana/latest/administration/feature-toggles/) feature. + +In order to update feature toggles through the app, the PATCH handler calls a webhook that should update Grafana's configuration and restarts the instance. + +For local development, set the app mode to `development` by adding `app_mode = development` to the top level of your Grafana .ini file. \ No newline at end of file diff --git a/pkg/registry/apis/featuretoggle/current.go b/pkg/registry/apis/featuretoggle/current.go index 7bc5661c75b..aa8204fc4aa 100644 --- a/pkg/registry/apis/featuretoggle/current.go +++ b/pkg/registry/apis/featuretoggle/current.go @@ -51,6 +51,7 @@ func (b *FeatureFlagAPIBuilder) getResolvedToggleState(ctx context.Context) v0al toggle := v0alpha1.ToggleStatus{ Name: name, Description: f.Description, // simplify the UI changes + Stage: f.Stage.String(), Enabled: state.Enabled[name], Writeable: b.features.IsEditableFromAdminPage(name), Source: startupRef, @@ -76,6 +77,17 @@ func (b *FeatureFlagAPIBuilder) getResolvedToggleState(ctx context.Context) v0al return state } +func (b *FeatureFlagAPIBuilder) userCanRead(ctx context.Context, u *user.SignedInUser) bool { + if u == nil { + u, _ = appcontext.User(ctx) + if u == nil { + return false + } + } + ok, err := b.accessControl.Evaluate(ctx, u, ac.EvalPermission(ac.ActionFeatureManagementRead)) + return ok && err == nil +} + func (b *FeatureFlagAPIBuilder) userCanWrite(ctx context.Context, u *user.SignedInUser) bool { if u == nil { u, _ = appcontext.User(ctx) @@ -93,7 +105,24 @@ func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *ht return } + // Check if the user can access toggle info + ctx := r.Context() + user, err := appcontext.User(ctx) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + if !b.userCanRead(ctx, user) { + err = errutil.Unauthorized("featuretoggle.canNotRead", + errutil.WithPublicMessage("missing read permission")).Errorf("user %s does not have read permissions", user.Login) + errhttp.Write(ctx, err, w) + return + } + + // Write the state to the response body state := b.getResolvedToggleState(r.Context()) + w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(state) } @@ -101,7 +130,9 @@ func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *ht func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !b.features.IsFeatureEditingAllowed() { - errhttp.Write(ctx, fmt.Errorf("feature editing is not enabled"), w) + err := errutil.Forbidden("featuretoggle.disabled", + errutil.WithPublicMessage("feature toggles are read-only")).Errorf("feature toggles are not writeable due to missing configuration") + errhttp.Write(ctx, err, w) return } @@ -113,7 +144,7 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt if !b.userCanWrite(ctx, user) { err = errutil.Unauthorized("featuretoggle.canNotWrite", - errutil.WithPublicMessage("missing write permission")) + errutil.WithPublicMessage("missing write permission")).Errorf("user %s does not have write permissions", user.Login) errhttp.Write(ctx, err, w) return } @@ -127,7 +158,7 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt if len(request.Toggles) > 0 { err = errutil.BadRequest("featuretoggle.badRequest", - errutil.WithPublicMessage("can only path the enabled section")) + errutil.WithPublicMessage("can only patch the enabled section")).Errorf("request payload included properties in the read-only Toggles section") errhttp.Write(ctx, err, w) return } @@ -138,7 +169,7 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt if current != v { if !b.features.IsEditableFromAdminPage(k) { err = errutil.BadRequest("featuretoggle.badRequest", - errutil.WithPublicMessage("can not edit toggle: "+k)) + errutil.WithPublicMessage("invalid toggle passed in")).Errorf("can not edit toggle %s", k) errhttp.Write(ctx, err, w) w.WriteHeader(http.StatusBadRequest) return @@ -158,7 +189,8 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt } err = sendWebhookUpdate(b.features.Settings, payload) - if err != nil { + if err != nil && b.cfg.Env != setting.Dev { + err = errutil.Internal("featuretoggle.webhookFailure", errutil.WithPublicMessage("an error occurred while updating feeature toggles")).Errorf("webhook error: %w", err) errhttp.Write(ctx, err, w) return } diff --git a/pkg/registry/apis/featuretoggle/current_test.go b/pkg/registry/apis/featuretoggle/current_test.go new file mode 100644 index 00000000000..7b1b24c52bb --- /dev/null +++ b/pkg/registry/apis/featuretoggle/current_test.go @@ -0,0 +1,460 @@ +package featuretoggle + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" +) + +func TestGetFeatureToggles(t *testing.T) { + t.Run("fails without adequate permissions", func(t *testing.T) { + features := featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, []*featuremgmt.FeatureFlag{{ + // Add this here to ensure the feature works as expected during tests + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }}) + + b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{}) + + callGetWith(t, b, http.StatusUnauthorized) + }) + + t.Run("should be able to get feature toggles", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + disabled := []string{"toggle2"} + + b := newTestAPIBuilder(t, features, disabled, setting.FeatureMgmtSettings{}) + result := callGetWith(t, b, http.StatusOK) + assert.Len(t, result.Toggles, 2) + t1, _ := findResult(t, result, "toggle1") + assert.True(t, t1.Enabled) + t2, _ := findResult(t, result, "toggle2") + assert.False(t, t2.Enabled) + }) + + t.Run("toggles hidden by config are not present in the response", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + settings := setting.FeatureMgmtSettings{ + HiddenToggles: map[string]struct{}{"toggle1": {}}, + } + + b := newTestAPIBuilder(t, features, []string{}, settings) + result := callGetWith(t, b, http.StatusOK) + + assert.Len(t, result.Toggles, 1) + assert.Equal(t, "toggle2", result.Toggles[0].Name) + }) + + t.Run("toggles that are read-only by config have the readOnly field set", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + disabled := []string{"toggle2"} + settings := setting.FeatureMgmtSettings{ + HiddenToggles: map[string]struct{}{"toggle1": {}}, + ReadOnlyToggles: map[string]struct{}{"toggle2": {}}, + AllowEditing: true, + UpdateWebhook: "bogus", + } + + b := newTestAPIBuilder(t, features, disabled, settings) + result := callGetWith(t, b, http.StatusOK) + + assert.Len(t, result.Toggles, 1) + assert.Equal(t, "toggle2", result.Toggles[0].Name) + assert.False(t, result.Toggles[0].Writeable) + }) + + t.Run("feature toggle defailts", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageUnknown, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageExperimental, + }, { + Name: "toggle3", + Stage: featuremgmt.FeatureStagePrivatePreview, + }, { + Name: "toggle4", + Stage: featuremgmt.FeatureStagePublicPreview, + AllowSelfServe: true, + }, { + Name: "toggle5", + Stage: featuremgmt.FeatureStageGeneralAvailability, + AllowSelfServe: true, + }, { + Name: "toggle6", + Stage: featuremgmt.FeatureStageDeprecated, + AllowSelfServe: true, + }, { + Name: "toggle7", + Stage: featuremgmt.FeatureStageGeneralAvailability, + AllowSelfServe: false, + }, + } + + t.Run("unknown, experimental, and private preview toggles are hidden by default", func(t *testing.T) { + b := newTestAPIBuilder(t, features, []string{}, setting.FeatureMgmtSettings{}) + result := callGetWith(t, b, http.StatusOK) + + assert.Len(t, result.Toggles, 4) + + _, ok := findResult(t, result, "toggle1") + assert.False(t, ok) + _, ok = findResult(t, result, "toggle2") + assert.False(t, ok) + _, ok = findResult(t, result, "toggle3") + assert.False(t, ok) + }) + + t.Run("only public preview and GA with AllowSelfServe are writeable", func(t *testing.T) { + settings := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "bogus", + } + + b := newTestAPIBuilder(t, features, []string{}, settings) + result := callGetWith(t, b, http.StatusOK) + + t4, ok := findResult(t, result, "toggle4") + assert.True(t, ok) + assert.True(t, t4.Writeable) + t5, ok := findResult(t, result, "toggle5") + assert.True(t, ok) + assert.True(t, t5.Writeable) + t6, ok := findResult(t, result, "toggle6") + assert.True(t, ok) + assert.True(t, t6.Writeable) + }) + + t.Run("all toggles are read-only when server is misconfigured", func(t *testing.T) { + settings := setting.FeatureMgmtSettings{ + AllowEditing: false, + UpdateWebhook: "", + } + b := newTestAPIBuilder(t, features, []string{}, settings) + result := callGetWith(t, b, http.StatusOK) + + assert.Len(t, result.Toggles, 4) + + t4, ok := findResult(t, result, "toggle4") + assert.True(t, ok) + assert.False(t, t4.Writeable) + t5, ok := findResult(t, result, "toggle5") + assert.True(t, ok) + assert.False(t, t5.Writeable) + t6, ok := findResult(t, result, "toggle6") + assert.True(t, ok) + assert.False(t, t6.Writeable) + }) + }) +} + +func TestSetFeatureToggles(t *testing.T) { + t.Run("fails when the user doesn't have write permissions", func(t *testing.T) { + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "random", + } + features := featuremgmt.WithFeatureManager(s, []*featuremgmt.FeatureFlag{{ + // Add this here to ensure the feature works as expected during tests + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }}) + + b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{}) + msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusUnauthorized) + assert.Equal(t, "missing write permission", msg) + }) + + t.Run("fails when update toggle url is not set", func(t *testing.T) { + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + } + b := newTestAPIBuilder(t, nil, []string{}, s) + msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusForbidden) + assert.Equal(t, "feature toggles are read-only", msg) + }) + + t.Run("fails with non-existent toggle", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: "toggle1", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + disabled := []string{"toggle2"} + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + "toggle3": true, + }, + } + + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "random", + } + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusBadRequest) + assert.Equal(t, "invalid toggle passed in", msg) + }) + + t.Run("fails with read-only toggles", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStagePublicPreview, + }, { + Name: "toggle3", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, + } + disabled := []string{"toggle2", "toggle3"} + + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "random", + ReadOnlyToggles: map[string]struct{}{ + "toggle3": {}, + }, + } + + t.Run("because it is the feature toggle admin page toggle", func(t *testing.T) { + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + featuremgmt.FlagFeatureToggleAdminPage: true, + }, + } + b := newTestAPIBuilder(t, features, disabled, s) + callPatchWith(t, b, update, http.StatusNotModified) + }) + + t.Run("because it is not GA or Deprecated", func(t *testing.T) { + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + "toggle2": true, + }, + } + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusBadRequest) + assert.Equal(t, "invalid toggle passed in", msg) + }) + + t.Run("because it is configured to be read-only", func(t *testing.T) { + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + "toggle2": true, + }, + } + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusBadRequest) + assert.Equal(t, "invalid toggle passed in", msg) + }) + }) + + t.Run("when all conditions met", func(t *testing.T) { + features := []*featuremgmt.FeatureFlag{ + { + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle2", + Stage: featuremgmt.FeatureStagePublicPreview, + }, { + Name: "toggle3", + Stage: featuremgmt.FeatureStageGeneralAvailability, + }, { + Name: "toggle4", + Stage: featuremgmt.FeatureStageGeneralAvailability, + AllowSelfServe: true, + }, { + Name: "toggle5", + Stage: featuremgmt.FeatureStageDeprecated, + AllowSelfServe: true, + }, + } + disabled := []string{"toggle2", "toggle3", "toggle4"} + + s := setting.FeatureMgmtSettings{ + AllowEditing: true, + UpdateWebhook: "random", + UpdateWebhookToken: "token", + ReadOnlyToggles: map[string]struct{}{ + "toggle3": {}, + }, + } + + update := v0alpha1.ResolvedToggleState{ + Enabled: map[string]bool{ + "toggle4": true, + "toggle5": false, + }, + } + t.Run("fail when webhook request is not successful", func(t *testing.T) { + webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer webhookServer.Close() + s.UpdateWebhook = webhookServer.URL + + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusInternalServerError) + assert.Equal(t, "an error occurred while updating feeature toggles", msg) + }) + + t.Run("succeed when webhook request is not successful but app is in dev mode", func(t *testing.T) { + webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer webhookServer.Close() + s.UpdateWebhook = webhookServer.URL + + b := newTestAPIBuilder(t, features, disabled, s) + b.cfg.Env = setting.Dev + callPatchWith(t, b, update, http.StatusOK) + }) + + t.Run("succeed when webhook request is successful", func(t *testing.T) { + webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer "+s.UpdateWebhookToken, r.Header.Get("Authorization")) + + var req featuremgmt.FeatureToggleWebhookPayload + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + + assert.Equal(t, "true", req.FeatureToggles["toggle4"]) + assert.Equal(t, "false", req.FeatureToggles["toggle5"]) + w.WriteHeader(http.StatusOK) + })) + defer webhookServer.Close() + s.UpdateWebhook = webhookServer.URL + + b := newTestAPIBuilder(t, features, disabled, s) + msg := callPatchWith(t, b, update, http.StatusOK) + assert.Equal(t, "feature toggles updated successfully", msg) + }) + }) +} + +func findResult(t *testing.T, result v0alpha1.ResolvedToggleState, name string) (v0alpha1.ToggleStatus, bool) { + t.Helper() + + for _, t := range result.Toggles { + if t.Name == name { + return t, true + } + } + return v0alpha1.ToggleStatus{}, false +} + +func callGetWith(t *testing.T, b *FeatureFlagAPIBuilder, expectedCode int) v0alpha1.ResolvedToggleState { + w := response.CreateNormalResponse(http.Header{}, []byte{}, 0) + req := &http.Request{ + Method: "GET", + Header: http.Header{}, + } + req.Header.Add("content-type", "application/json") + req = req.WithContext(appcontext.WithUser(req.Context(), &user.SignedInUser{})) + b.handleCurrentStatus(w, req) + + rts := v0alpha1.ResolvedToggleState{} + require.NoError(t, json.Unmarshal(w.Body(), &rts)) + require.Equal(t, expectedCode, w.Status()) + + // Tests don't expect the feature toggle admin page feature to be present, so remove them from the resolved toggle state + for i, t := range rts.Toggles { + if t.Name == "featureToggleAdminPage" { + rts.Toggles = append(rts.Toggles[0:i], rts.Toggles[i+1:]...) + } + } + + return rts +} + +func callPatchWith(t *testing.T, b *FeatureFlagAPIBuilder, update v0alpha1.ResolvedToggleState, expectedCode int) string { + w := response.CreateNormalResponse(http.Header{}, []byte{}, 0) + + body, err := json.Marshal(update) + require.NoError(t, err) + + req := &http.Request{ + Method: "PATCH", + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{}, + } + req.Header.Add("content-type", "application/json") + req = req.WithContext(appcontext.WithUser(req.Context(), &user.SignedInUser{})) + b.handleCurrentStatus(w, req) + + require.NotNil(t, w.Body()) + require.Equal(t, expectedCode, w.Status()) + + // Extract the public facing message if this is an error + if w.Status() > 399 { + res := map[string]any{} + require.NoError(t, json.Unmarshal(w.Body(), &res)) + + return res["message"].(string) + } + + return string(w.Body()) +} + +func newTestAPIBuilder( + t *testing.T, + serverFeatures []*featuremgmt.FeatureFlag, + disabled []string, // the flags that are disabled + settings setting.FeatureMgmtSettings, +) *FeatureFlagAPIBuilder { + t.Helper() + features := featuremgmt.WithFeatureManager(settings, append([]*featuremgmt.FeatureFlag{{ + // Add this here to ensure the feature works as expected during tests + Name: featuremgmt.FlagFeatureToggleAdminPage, + Stage: featuremgmt.FeatureStageGeneralAvailability, + }}, serverFeatures...), disabled...) + + return NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: true}, &setting.Cfg{}) +} diff --git a/pkg/registry/apis/featuretoggle/features.go b/pkg/registry/apis/featuretoggle/features.go index 23808272b73..37d8352a0c3 100644 --- a/pkg/registry/apis/featuretoggle/features.go +++ b/pkg/registry/apis/featuretoggle/features.go @@ -3,7 +3,7 @@ package featuretoggle import ( "context" "fmt" - "time" + "sync" "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -27,18 +27,16 @@ var ( type featuresStorage struct { resource *common.ResourceInfo tableConverter rest.TableConvertor - features []featuremgmt.FeatureFlag - startup int64 + features *v0alpha1.FeatureList + featuresOnce sync.Once } // NOTE! this does not depend on config or any system state! // In the future, the existence of features (and their properties) can be defined dynamically -func NewFeaturesStorage(features []featuremgmt.FeatureFlag) *featuresStorage { +func NewFeaturesStorage() *featuresStorage { resourceInfo := v0alpha1.FeatureResourceInfo return &featuresStorage{ - startup: time.Now().UnixMilli(), resource: &resourceInfo, - features: features, tableConverter: utils.NewTableConverter( resourceInfo.GroupResource(), []metav1.TableColumnDefinition{ @@ -82,44 +80,35 @@ func (s *featuresStorage) ConvertToTable(ctx context.Context, object runtime.Obj return s.tableConverter.ConvertToTable(ctx, object, tableOptions) } +func (s *featuresStorage) init() { + s.featuresOnce.Do(func() { + rv := "1" + features, _ := featuremgmt.GetEmbeddedFeatureList() + for _, feature := range features.Items { + if feature.ResourceVersion > rv { + rv = feature.ResourceVersion + } + } + features.ResourceVersion = rv + s.features = &features + }) +} + func (s *featuresStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { - flags := &v0alpha1.FeatureList{ - ListMeta: metav1.ListMeta{ - ResourceVersion: fmt.Sprintf("%d", s.startup), - }, + s.init() + if s.features == nil { + return nil, fmt.Errorf("error loading embedded features") } - for _, flag := range s.features { - flags.Items = append(flags.Items, toK8sForm(flag)) - } - return flags, nil + return s.features, nil } func (s *featuresStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { - for _, flag := range s.features { - if name == flag.Name { - obj := toK8sForm(flag) - return &obj, nil + s.init() + + for idx, flag := range s.features.Items { + if flag.Name == name { + return &s.features.Items[idx], nil } } return nil, fmt.Errorf("not found") } - -func toK8sForm(flag featuremgmt.FeatureFlag) v0alpha1.Feature { - return v0alpha1.Feature{ - ObjectMeta: metav1.ObjectMeta{ - Name: flag.Name, - CreationTimestamp: metav1.NewTime(flag.Created), - }, - Spec: v0alpha1.FeatureSpec{ - Description: flag.Description, - Stage: flag.Stage.String(), - Owner: string(flag.Owner), - AllowSelfServe: flag.AllowSelfServe, - HideFromAdminPage: flag.HideFromAdminPage, - HideFromDocs: flag.HideFromDocs, - FrontendOnly: flag.FrontendOnly, - RequiresDevMode: flag.RequiresDevMode, - RequiresRestart: flag.RequiresRestart, - }, - } -} diff --git a/pkg/registry/apis/featuretoggle/register.go b/pkg/registry/apis/featuretoggle/register.go index f5ae2466c42..205f3a25e0a 100644 --- a/pkg/registry/apis/featuretoggle/register.go +++ b/pkg/registry/apis/featuretoggle/register.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" ) var _ builder.APIGroupBuilder = (*FeatureFlagAPIBuilder)(nil) @@ -27,17 +28,19 @@ var gv = v0alpha1.SchemeGroupVersion type FeatureFlagAPIBuilder struct { features *featuremgmt.FeatureManager accessControl accesscontrol.AccessControl + cfg *setting.Cfg } -func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager, accessControl accesscontrol.AccessControl) *FeatureFlagAPIBuilder { - return &FeatureFlagAPIBuilder{features, accessControl} +func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager, accessControl accesscontrol.AccessControl, cfg *setting.Cfg) *FeatureFlagAPIBuilder { + return &FeatureFlagAPIBuilder{features, accessControl, cfg} } func RegisterAPIService(features *featuremgmt.FeatureManager, accessControl accesscontrol.AccessControl, apiregistration builder.APIRegistrar, + cfg *setting.Cfg, ) *FeatureFlagAPIBuilder { - builder := NewFeatureFlagAPIBuilder(features, accessControl) + builder := NewFeatureFlagAPIBuilder(features, accessControl, cfg) apiregistration.RegisterAPI(builder) return builder } @@ -83,7 +86,7 @@ func (b *FeatureFlagAPIBuilder) GetAPIGroupInfo( ) (*genericapiserver.APIGroupInfo, error) { apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs) - featureStore := NewFeaturesStorage(b.features.GetFlags()) + featureStore := NewFeaturesStorage() toggleStore := NewTogglesStorage(b.features) storage := map[string]rest.Storage{} diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 44d0b7bc5ba..adf79d115ce 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -131,6 +131,7 @@ import ( "github.com/grafana/grafana/pkg/services/signingkeys" "github.com/grafana/grafana/pkg/services/signingkeys/signingkeysimpl" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" "github.com/grafana/grafana/pkg/services/ssosettings" ssoSettingsImpl "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingsimpl" starApi "github.com/grafana/grafana/pkg/services/star/api" @@ -441,7 +442,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser return &Server{}, nil } -func InitializeForTest(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*TestEnv, error) { +func InitializeForTest(t sqlutil.ITestDB, cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*TestEnv, error) { wire.Build(wireExtsTestSet) return &TestEnv{Server: &Server{}, SQLStore: &sqlstore.SQLStore{}}, nil } diff --git a/pkg/services/accesscontrol/acimpl/service_test.go b/pkg/services/accesscontrol/acimpl/service_test.go index ff0649cc126..d9e6467a072 100644 --- a/pkg/services/accesscontrol/acimpl/service_test.go +++ b/pkg/services/accesscontrol/acimpl/service_test.go @@ -21,8 +21,13 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func setupTestEnv(t testing.TB) *Service { t.Helper() cfg := setting.NewCfg() diff --git a/pkg/services/accesscontrol/database/database_test.go b/pkg/services/accesscontrol/database/database_test.go index 34da7704fda..106c9eff14b 100644 --- a/pkg/services/accesscontrol/database/database_test.go +++ b/pkg/services/accesscontrol/database/database_test.go @@ -23,8 +23,14 @@ import ( "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +// run tests with cleanup +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type getUserPermissionsTestCase struct { desc string anonymousUser bool diff --git a/pkg/services/accesscontrol/filter_test.go b/pkg/services/accesscontrol/filter_test.go index a68d2a6b4f7..480bee31345 100644 --- a/pkg/services/accesscontrol/filter_test.go +++ b/pkg/services/accesscontrol/filter_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" dsService "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" ) type filterDatasourcesTestCase struct { @@ -27,6 +28,10 @@ type filterDatasourcesTestCase struct { expectErr bool } +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestFilter_Datasources(t *testing.T) { tests := []filterDatasourcesTestCase{ { diff --git a/pkg/services/accesscontrol/migrator/migrator_test.go b/pkg/services/accesscontrol/migrator/migrator_test.go index 779bc399fa3..bfa35f17efc 100644 --- a/pkg/services/accesscontrol/migrator/migrator_test.go +++ b/pkg/services/accesscontrol/migrator/migrator_test.go @@ -14,8 +14,13 @@ import ( "github.com/grafana/grafana/pkg/infra/log" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func batchInsertPermissions(cnt int, sqlStore db.DB) error { now := time.Now() diff --git a/pkg/services/accesscontrol/resourcepermissions/store_test.go b/pkg/services/accesscontrol/resourcepermissions/store_test.go index d83d41a4fad..1af01485939 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/store_test.go @@ -21,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/tests/testsuite" ) type setUserResourcePermissionTest struct { @@ -34,6 +35,10 @@ type setUserResourcePermissionTest struct { seeds []SetResourcePermissionCommand } +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationStore_SetUserResourcePermission(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/alerting/conditions/query_interval_test.go b/pkg/services/alerting/conditions/query_interval_test.go index 443b0f74d83..bb841e0f9ad 100644 --- a/pkg/services/alerting/conditions/query_interval_test.go +++ b/pkg/services/alerting/conditions/query_interval_test.go @@ -15,10 +15,11 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/validations" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" "github.com/grafana/grafana/pkg/tsdb/legacydata" ) +const DefaultRes int64 = 1500 + func TestQueryInterval(t *testing.T) { t.Run("When evaluating query condition, regarding the interval value", func(t *testing.T) { t.Run("Can handle interval-calculation with no panel-min-interval and no datasource-min-interval", func(t *testing.T) { @@ -34,7 +35,7 @@ func TestQueryInterval(t *testing.T) { // 5minutes timerange = 300000milliseconds; default-resolution is 1500pixels, // so we should have 300000/1500 = 200milliseconds here require.Equal(t, int64(200), query.IntervalMS) - require.Equal(t, intervalv2.DefaultRes, query.MaxDataPoints) + require.Equal(t, DefaultRes, query.MaxDataPoints) } applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) @@ -50,7 +51,7 @@ func TestQueryInterval(t *testing.T) { verifier := func(query legacydata.DataSubQuery) { require.Equal(t, int64(123000), query.IntervalMS) - require.Equal(t, intervalv2.DefaultRes, query.MaxDataPoints) + require.Equal(t, DefaultRes, query.MaxDataPoints) } applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) @@ -69,7 +70,7 @@ func TestQueryInterval(t *testing.T) { verifier := func(query legacydata.DataSubQuery) { require.Equal(t, int64(71000), query.IntervalMS) - require.Equal(t, intervalv2.DefaultRes, query.MaxDataPoints) + require.Equal(t, DefaultRes, query.MaxDataPoints) } applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) @@ -90,7 +91,7 @@ func TestQueryInterval(t *testing.T) { // when both panel-min-interval and datasource-min-interval exists, // panel-min-interval is used require.Equal(t, int64(19000), query.IntervalMS) - require.Equal(t, intervalv2.DefaultRes, query.MaxDataPoints) + require.Equal(t, DefaultRes, query.MaxDataPoints) } applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) @@ -109,7 +110,7 @@ func TestQueryInterval(t *testing.T) { // no min-interval exists, the default-min-interval will be used, // and for such a short time-range this will cause the value to be 1millisecond. require.Equal(t, int64(1), query.IntervalMS) - require.Equal(t, intervalv2.DefaultRes, query.MaxDataPoints) + require.Equal(t, DefaultRes, query.MaxDataPoints) } applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) diff --git a/pkg/services/alerting/service_test.go b/pkg/services/alerting/service_test.go index d3470094baf..e929498602d 100644 --- a/pkg/services/alerting/service_test.go +++ b/pkg/services/alerting/service_test.go @@ -18,8 +18,13 @@ import ( encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestService(t *testing.T) { sqlStore := &sqlStore{ db: db.InitTestDB(t), diff --git a/pkg/services/annotations/accesscontrol/accesscontrol_test.go b/pkg/services/annotations/accesscontrol/accesscontrol_test.go index 7bb4d74f2a6..12ece80dd2c 100644 --- a/pkg/services/annotations/accesscontrol/accesscontrol_test.go +++ b/pkg/services/annotations/accesscontrol/accesscontrol_test.go @@ -12,9 +12,14 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAuthorize(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/annotations/annotationsimpl/annotations_test.go b/pkg/services/annotations/annotationsimpl/annotations_test.go index 9b6a824e423..ef407f09a83 100644 --- a/pkg/services/annotations/annotationsimpl/annotations_test.go +++ b/pkg/services/annotations/annotationsimpl/annotations_test.go @@ -29,8 +29,13 @@ import ( "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go b/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go index b29272563c5..1123a7bbddc 100644 --- a/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go +++ b/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go @@ -27,11 +27,16 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/state/historian" historymodel "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAlertStateHistoryStore(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/anonymous/anonimpl/anonstore/database_test.go b/pkg/services/anonymous/anonimpl/anonstore/database_test.go index 0da29d5f6b7..54e625662ed 100644 --- a/pkg/services/anonymous/anonimpl/anonstore/database_test.go +++ b/pkg/services/anonymous/anonimpl/anonstore/database_test.go @@ -9,8 +9,13 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAnonStore_DeleteDevicesOlderThan(t *testing.T) { store := db.InitTestDB(t) anonDBStore := ProvideAnonDBStore(store, 0) diff --git a/pkg/services/anonymous/anonimpl/impl_test.go b/pkg/services/anonymous/anonimpl/impl_test.go index e3c93db2ff2..a84e913f3b1 100644 --- a/pkg/services/anonymous/anonimpl/impl_test.go +++ b/pkg/services/anonymous/anonimpl/impl_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationDeviceService_tag(t *testing.T) { type tagReq struct { httpReq *http.Request diff --git a/pkg/services/apikey/apikeyimpl/store_test.go b/pkg/services/apikey/apikeyimpl/store_test.go index 925e418d404..3b5089526cd 100644 --- a/pkg/services/apikey/apikeyimpl/store_test.go +++ b/pkg/services/apikey/apikeyimpl/store_test.go @@ -14,8 +14,13 @@ import ( "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type getStore func(db.DB) store type getApiKeysTestCase struct { diff --git a/pkg/services/apiserver/endpoints/request/namespace.go b/pkg/services/apiserver/endpoints/request/namespace.go index ccec8680f81..899e342dc9f 100644 --- a/pkg/services/apiserver/endpoints/request/namespace.go +++ b/pkg/services/apiserver/endpoints/request/namespace.go @@ -67,10 +67,12 @@ func ParseNamespace(ns string) (NamespaceInfo, error) { } if strings.HasPrefix(ns, "stack-") { - info.StackID = ns[6:] - if len(info.StackID) < 2 { + stackIDStr := ns[6:] + stackID, err := strconv.Atoi(stackIDStr) + if err != nil || stackID < 1 { return info, fmt.Errorf("invalid stack id") } + info.StackID = stackIDStr info.OrgID = 1 return info, nil } diff --git a/pkg/services/apiserver/endpoints/request/namespace_test.go b/pkg/services/apiserver/endpoints/request/namespace_test.go index cd5a8e58394..f5665ff2f5b 100644 --- a/pkg/services/apiserver/endpoints/request/namespace_test.go +++ b/pkg/services/apiserver/endpoints/request/namespace_test.go @@ -77,15 +77,15 @@ func TestParseNamespace(t *testing.T) { }, }, { - name: "valid stack", + name: "invalid stack id (must be an int)", + expectErr: true, namespace: "stack-abcdef", expected: request.NamespaceInfo{ - OrgID: 1, - StackID: "abcdef", + OrgID: -1, }, }, { - name: "invalid stack id", + name: "invalid stack id (must be provided)", namespace: "stack-", expectErr: true, expected: request.NamespaceInfo{ @@ -93,11 +93,18 @@ func TestParseNamespace(t *testing.T) { }, }, { - name: "invalid stack id (too short)", - namespace: "stack-1", + name: "invalid stack id (cannot be 0)", + namespace: "stack-0", expectErr: true, expected: request.NamespaceInfo{ - OrgID: -1, + OrgID: -1, + }, + }, + { + name: "valid stack", + namespace: "stack-1", + expected: request.NamespaceInfo{ + OrgID: 1, StackID: "1", }, }, diff --git a/pkg/services/apiserver/standalone/factory.go b/pkg/services/apiserver/standalone/factory.go index 0768109a0d1..eec8ddacb1a 100644 --- a/pkg/services/apiserver/standalone/factory.go +++ b/pkg/services/apiserver/standalone/factory.go @@ -79,6 +79,7 @@ func (p *DummyAPIFactory) MakeAPIServer(gv schema.GroupVersion) (builder.APIGrou return featuretoggle.NewFeatureFlagAPIBuilder( featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, nil), // none... for now &actest.FakeAccessControl{ExpectedEvaluate: false}, + &setting.Cfg{}, ), nil case "testdata.datasource.grafana.app": diff --git a/pkg/services/auth/authimpl/auth_token_test.go b/pkg/services/auth/authimpl/auth_token_test.go index 5b947fb915b..b0a8beb94f1 100644 --- a/pkg/services/auth/authimpl/auth_token_test.go +++ b/pkg/services/auth/authimpl/auth_token_test.go @@ -19,8 +19,13 @@ import ( "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationUserAuthToken(t *testing.T) { ctx := createTestContext(t) usr := &user.User{ID: int64(10)} diff --git a/pkg/services/auth/jwt/auth.go b/pkg/services/auth/jwt/auth.go index c2d317a056e..37e0a7fa373 100644 --- a/pkg/services/auth/jwt/auth.go +++ b/pkg/services/auth/jwt/auth.go @@ -33,7 +33,7 @@ func newService(cfg *setting.Cfg, remoteCache *remotecache.RemoteCache) *AuthSer } func (s *AuthService) init() error { - if !s.Cfg.JWTAuthEnabled { + if !s.Cfg.JWTAuth.Enabled { return nil } diff --git a/pkg/services/auth/jwt/auth_test.go b/pkg/services/auth/jwt/auth_test.go index f8ffc542ccd..73d2106b72d 100644 --- a/pkg/services/auth/jwt/auth_test.go +++ b/pkg/services/auth/jwt/auth_test.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) type scenarioContext struct { @@ -39,6 +40,10 @@ type cachingScenarioFunc func(*testing.T, cachingScenarioContext) const subject = "foo-subj" +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestVerifyUsingPKIXPublicKeyFile(t *testing.T) { key := rsaKeys[0] unknownKey := rsaKeys[1] @@ -70,7 +75,7 @@ func TestVerifyUsingPKIXPublicKeyFile(t *testing.T) { assert.Equal(t, verifiedClaims["sub"], subject) }, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) { t.Helper() - cfg.JWTAuthKeyID = publicKeyID + cfg.JWTAuth.KeyID = publicKeyID }) } @@ -89,7 +94,7 @@ func TestVerifyUsingJWKSetFile(t *testing.T) { require.NoError(t, json.NewEncoder(file).Encode(jwksPublic)) require.NoError(t, file.Close()) - cfg.JWTAuthJWKSetFile = file.Name() + cfg.JWTAuth.JWKSetFile = file.Name() } scenario(t, "verifies a token signed with a key from the set", func(t *testing.T, sc scenarioContext) { @@ -118,18 +123,18 @@ func TestVerifyUsingJWKSetURL(t *testing.T) { var err error _, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthJWKSetURL = "https://example.com/.well-known/jwks.json" + cfg.JWTAuth.JWKSetURL = "https://example.com/.well-known/jwks.json" }) require.NoError(t, err) _, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthJWKSetURL = "http://example.com/.well-known/jwks.json" + cfg.JWTAuth.JWKSetURL = "http://example.com/.well-known/jwks.json" }) require.NoError(t, err) _, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) { cfg.Env = setting.Prod - cfg.JWTAuthJWKSetURL = "http://example.com/.well-known/jwks.json" + cfg.JWTAuth.JWKSetURL = "http://example.com/.well-known/jwks.json" }) require.Error(t, err) }) @@ -180,7 +185,7 @@ func TestCachingJWKHTTPResponse(t *testing.T) { assert.Equal(t, 1, *sc.reqCount) }, func(t *testing.T, cfg *setting.Cfg) { // Arbitrary high value, several times what the test should take. - cfg.JWTAuthCacheTTL = time.Minute + cfg.JWTAuth.CacheTTL = time.Minute }) jwkCachingScenario(t, "does not cache the response when TTL is zero", func(t *testing.T, sc cachingScenarioContext) { @@ -191,7 +196,7 @@ func TestCachingJWKHTTPResponse(t *testing.T) { assert.Equal(t, 2, *sc.reqCount) }, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthCacheTTL = 0 + cfg.JWTAuth.CacheTTL = 0 }) } @@ -216,7 +221,7 @@ func TestClaimValidation(t *testing.T) { _, err = sc.authJWTSvc.Verify(sc.ctx, tokenInvalid) require.Error(t, err) }, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthExpectClaims = `{"iss": "http://foo"}` + cfg.JWTAuth.ExpectClaims = `{"iss": "http://foo"}` }) scenario(t, "validates sub field for equality", func(t *testing.T, sc scenarioContext) { @@ -231,7 +236,7 @@ func TestClaimValidation(t *testing.T) { _, err = sc.authJWTSvc.Verify(sc.ctx, tokenInvalid) require.Error(t, err) }, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthExpectClaims = `{"sub": "foo"}` + cfg.JWTAuth.ExpectClaims = `{"sub": "foo"}` }) scenario(t, "validates aud field for inclusion", func(t *testing.T, sc scenarioContext) { @@ -252,7 +257,7 @@ func TestClaimValidation(t *testing.T) { _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"baz"}}, nil)) require.Error(t, err) }, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthExpectClaims = `{"aud": ["foo", "bar"]}` + cfg.JWTAuth.ExpectClaims = `{"aud": ["foo", "bar"]}` }) scenario(t, "validates non-registered (custom) claims for equality", func(t *testing.T, sc scenarioContext) { @@ -273,7 +278,7 @@ func TestClaimValidation(t *testing.T) { _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, map[string]any{"my-number": 123}, nil)) require.Error(t, err) }, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthExpectClaims = `{"my-str": "foo", "my-number": 123}` + cfg.JWTAuth.ExpectClaims = `{"my-str": "foo", "my-number": 123}` }) scenario(t, "validates exp claim of the token", func(t *testing.T, sc scenarioContext) { @@ -318,7 +323,7 @@ func jwkHTTPScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...configur t.Cleanup(ts.Close) configure := func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthJWKSetURL = ts.URL + cfg.JWTAuth.JWKSetURL = ts.URL } runner := scenarioRunner(func(t *testing.T, sc scenarioContext) { keySet := sc.authJWTSvc.keySet.(*keySetHTTP) @@ -350,8 +355,8 @@ func jwkCachingScenario(t *testing.T, desc string, fn cachingScenarioFunc, cbs . t.Cleanup(ts.Close) configure := func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthJWKSetURL = ts.URL - cfg.JWTAuthCacheTTL = time.Hour + cfg.JWTAuth.JWKSetURL = ts.URL + cfg.JWTAuth.CacheTTL = time.Hour } runner := scenarioRunner(func(t *testing.T, sc scenarioContext) { keySet := sc.authJWTSvc.keySet.(*keySetHTTP) @@ -392,8 +397,8 @@ func initAuthService(t *testing.T, cbs ...configureFunc) (*AuthService, error) { t.Helper() cfg := setting.NewCfg() - cfg.JWTAuthEnabled = true - cfg.JWTAuthExpectClaims = "{}" + cfg.JWTAuth.Enabled = true + cfg.JWTAuth.ExpectClaims = "{}" for _, cb := range cbs { cb(t, cfg) @@ -437,5 +442,5 @@ func configurePKIXPublicKeyFile(t *testing.T, cfg *setting.Cfg) { })) require.NoError(t, file.Close()) - cfg.JWTAuthKeyFile = file.Name() + cfg.JWTAuth.KeyFile = file.Name() } diff --git a/pkg/services/auth/jwt/jwt.go b/pkg/services/auth/jwt/jwt.go index c0c7ddd4292..c1175b5c72d 100644 --- a/pkg/services/auth/jwt/jwt.go +++ b/pkg/services/auth/jwt/jwt.go @@ -2,9 +2,11 @@ package jwt import ( "context" + + "github.com/grafana/grafana/pkg/util" ) -type JWTClaims map[string]any +type JWTClaims util.DynMap type JWTService interface { Verify(ctx context.Context, strToken string) (JWTClaims, error) diff --git a/pkg/services/auth/jwt/key_sets.go b/pkg/services/auth/jwt/key_sets.go index f981b21a591..5361f3b3fbb 100644 --- a/pkg/services/auth/jwt/key_sets.go +++ b/pkg/services/auth/jwt/key_sets.go @@ -49,13 +49,13 @@ type keySetHTTP struct { func (s *AuthService) checkKeySetConfiguration() error { var count int - if s.Cfg.JWTAuthKeyFile != "" { + if s.Cfg.JWTAuth.KeyFile != "" { count++ } - if s.Cfg.JWTAuthJWKSetFile != "" { + if s.Cfg.JWTAuth.JWKSetFile != "" { count++ } - if s.Cfg.JWTAuthJWKSetURL != "" { + if s.Cfg.JWTAuth.JWKSetURL != "" { count++ } @@ -75,7 +75,7 @@ func (s *AuthService) initKeySet() error { return err } - if keyFilePath := s.Cfg.JWTAuthKeyFile; keyFilePath != "" { + if keyFilePath := s.Cfg.JWTAuth.KeyFile; keyFilePath != "" { // nolint:gosec // We can ignore the gosec G304 warning on this one because `fileName` comes from grafana configuration file file, err := os.Open(keyFilePath) @@ -125,10 +125,10 @@ func (s *AuthService) initKeySet() error { s.keySet = &keySetJWKS{ jose.JSONWebKeySet{ - Keys: []jose.JSONWebKey{{Key: key, KeyID: s.Cfg.JWTAuthKeyID}}, + Keys: []jose.JSONWebKey{{Key: key, KeyID: s.Cfg.JWTAuth.KeyID}}, }, } - } else if keyFilePath := s.Cfg.JWTAuthJWKSetFile; keyFilePath != "" { + } else if keyFilePath := s.Cfg.JWTAuth.JWKSetFile; keyFilePath != "" { // nolint:gosec // We can ignore the gosec G304 warning on this one because `fileName` comes from grafana configuration file file, err := os.Open(keyFilePath) @@ -147,7 +147,7 @@ func (s *AuthService) initKeySet() error { } s.keySet = &keySetJWKS{jwks} - } else if urlStr := s.Cfg.JWTAuthJWKSetURL; urlStr != "" { + } else if urlStr := s.Cfg.JWTAuth.JWKSetURL; urlStr != "" { urlParsed, err := url.Parse(urlStr) if err != nil { return err @@ -176,7 +176,7 @@ func (s *AuthService) initKeySet() error { Timeout: time.Second * 30, }, cacheKey: fmt.Sprintf("auth-jwt:jwk-%s", urlStr), - cacheExpiration: s.Cfg.JWTAuthCacheTTL, + cacheExpiration: s.Cfg.JWTAuth.CacheTTL, cache: s.RemoteCache, } } diff --git a/pkg/services/auth/jwt/validation.go b/pkg/services/auth/jwt/validation.go index 260aa964f29..4fe5b88119f 100644 --- a/pkg/services/auth/jwt/validation.go +++ b/pkg/services/auth/jwt/validation.go @@ -10,7 +10,7 @@ import ( ) func (s *AuthService) initClaimExpectations() error { - if err := json.Unmarshal([]byte(s.Cfg.JWTAuthExpectClaims), &s.expect); err != nil { + if err := json.Unmarshal([]byte(s.Cfg.JWTAuth.ExpectClaims), &s.expect); err != nil { return err } diff --git a/pkg/services/authn/authnimpl/service.go b/pkg/services/authn/authnimpl/service.go index a6bc4d451cd..fe65c436739 100644 --- a/pkg/services/authn/authnimpl/service.go +++ b/pkg/services/authn/authnimpl/service.go @@ -131,7 +131,7 @@ func ProvideService( } } - if s.cfg.JWTAuthEnabled { + if s.cfg.JWTAuth.Enabled { s.RegisterClient(clients.ProvideJWT(jwtService, cfg)) } diff --git a/pkg/services/authn/authnimpl/usage_stats.go b/pkg/services/authn/authnimpl/usage_stats.go index e2b15cf1ab4..b6ea1a95c6d 100644 --- a/pkg/services/authn/authnimpl/usage_stats.go +++ b/pkg/services/authn/authnimpl/usage_stats.go @@ -15,7 +15,7 @@ func (s *Service) getUsageStats(ctx context.Context) (map[string]any, error) { authTypes["ldap"] = s.cfg.LDAPAuthEnabled authTypes["auth_proxy"] = s.cfg.AuthProxyEnabled authTypes["anonymous"] = s.cfg.AnonymousEnabled - authTypes["jwt"] = s.cfg.JWTAuthEnabled + authTypes["jwt"] = s.cfg.JWTAuth.Enabled authTypes["grafana_password"] = !s.cfg.DisableLogin authTypes["login_form"] = !s.cfg.DisableLoginForm diff --git a/pkg/services/authn/authnimpl/usage_stats_test.go b/pkg/services/authn/authnimpl/usage_stats_test.go index ba59c1f1818..cded65c117e 100644 --- a/pkg/services/authn/authnimpl/usage_stats_test.go +++ b/pkg/services/authn/authnimpl/usage_stats_test.go @@ -21,7 +21,7 @@ func TestService_getUsageStats(t *testing.T) { svc.cfg.DisableLogin = false svc.cfg.BasicAuthEnabled = true svc.cfg.AuthProxyEnabled = true - svc.cfg.JWTAuthEnabled = true + svc.cfg.JWTAuth.Enabled = true svc.cfg.LDAPAuthEnabled = true svc.cfg.EditorsCanAdmin = true svc.cfg.ViewersCanEdit = true diff --git a/pkg/services/authn/clients/jwt.go b/pkg/services/authn/clients/jwt.go index 9c628906baf..e789f9153a0 100644 --- a/pkg/services/authn/clients/jwt.go +++ b/pkg/services/authn/clients/jwt.go @@ -2,13 +2,9 @@ package clients import ( "context" - "errors" - "fmt" "net/http" "strings" - "github.com/jmespath/go-jmespath" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/auth" authJWT "github.com/grafana/grafana/pkg/services/auth/jwt" @@ -16,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -73,15 +70,16 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi SyncUser: true, FetchSyncedUser: true, SyncPermissions: true, - SyncOrgRoles: !s.cfg.JWTAuthSkipOrgRoleSync, - AllowSignUp: s.cfg.JWTAuthAutoSignUp, + SyncOrgRoles: !s.cfg.JWTAuth.SkipOrgRoleSync, + AllowSignUp: s.cfg.JWTAuth.AutoSignUp, + SyncTeams: s.cfg.JWTAuth.GroupsAttributePath != "", }} - if key := s.cfg.JWTAuthUsernameClaim; key != "" { + if key := s.cfg.JWTAuth.UsernameClaim; key != "" { id.Login, _ = claims[key].(string) id.ClientParams.LookUpParams.Login = &id.Login } - if key := s.cfg.JWTAuthEmailClaim; key != "" { + if key := s.cfg.JWTAuth.EmailClaim; key != "" { id.Email, _ = claims[key].(string) id.ClientParams.LookUpParams.Email = &id.Email } @@ -91,16 +89,16 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi } orgRoles, isGrafanaAdmin, err := getRoles(s.cfg, func() (org.RoleType, *bool, error) { - if s.cfg.JWTAuthSkipOrgRoleSync { + if s.cfg.JWTAuth.SkipOrgRoleSync { return "", nil, nil } role, grafanaAdmin := s.extractRoleAndAdmin(claims) - if s.cfg.JWTAuthRoleAttributeStrict && !role.IsValid() { + if s.cfg.JWTAuth.RoleAttributeStrict && !role.IsValid() { return "", nil, errJWTInvalidRole.Errorf("invalid role claim in JWT: %s", role) } - if !s.cfg.JWTAuthAllowAssignGrafanaAdmin { + if !s.cfg.JWTAuth.AllowAssignGrafanaAdmin { return role, nil, nil } @@ -114,6 +112,11 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi id.OrgRoles = orgRoles id.IsGrafanaAdmin = isGrafanaAdmin + id.Groups, err = s.extractGroups(claims) + if err != nil { + return nil, err + } + if id.Login == "" && id.Email == "" { s.log.FromContext(ctx).Debug("Failed to get an authentication claim from JWT", "login", id.Login, "email", id.Email) @@ -126,7 +129,7 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi // remove sensitive query param // avoid JWT URL login passing auth_token in URL func (s *JWT) stripSensitiveParam(httpRequest *http.Request) { - if s.cfg.JWTAuthURLLogin { + if s.cfg.JWTAuth.URLLogin { params := httpRequest.URL.Query() if params.Has(authQueryParamName) { params.Del(authQueryParamName) @@ -137,8 +140,8 @@ func (s *JWT) stripSensitiveParam(httpRequest *http.Request) { // retrieveToken retrieves the JWT token from the request. func (s *JWT) retrieveToken(httpRequest *http.Request) string { - jwtToken := httpRequest.Header.Get(s.cfg.JWTAuthHeaderName) - if jwtToken == "" && s.cfg.JWTAuthURLLogin { + jwtToken := httpRequest.Header.Get(s.cfg.JWTAuth.HeaderName) + if jwtToken == "" && s.cfg.JWTAuth.URLLogin { jwtToken = httpRequest.URL.Query().Get("auth_token") } // Strip the 'Bearer' prefix if it exists. @@ -146,7 +149,7 @@ func (s *JWT) retrieveToken(httpRequest *http.Request) string { } func (s *JWT) Test(ctx context.Context, r *authn.Request) bool { - if !s.cfg.JWTAuthEnabled || s.cfg.JWTAuthHeaderName == "" { + if !s.cfg.JWTAuth.Enabled || s.cfg.JWTAuth.HeaderName == "" { return false } @@ -171,11 +174,11 @@ func (s *JWT) Priority() uint { const roleGrafanaAdmin = "GrafanaAdmin" func (s *JWT) extractRoleAndAdmin(claims map[string]any) (org.RoleType, bool) { - if s.cfg.JWTAuthRoleAttributePath == "" { + if s.cfg.JWTAuth.RoleAttributePath == "" { return "", false } - role, err := searchClaimsForStringAttr(s.cfg.JWTAuthRoleAttributePath, claims) + role, err := util.SearchJSONForStringAttr(s.cfg.JWTAuth.RoleAttributePath, claims) if err != nil || role == "" { return "", false } @@ -186,33 +189,10 @@ func (s *JWT) extractRoleAndAdmin(claims map[string]any) (org.RoleType, bool) { return org.RoleType(role), false } -func searchClaimsForStringAttr(attributePath string, claims map[string]any) (string, error) { - val, err := searchClaimsForAttr(attributePath, claims) - if err != nil { - return "", err +func (s *JWT) extractGroups(claims map[string]any) ([]string, error) { + if s.cfg.JWTAuth.GroupsAttributePath == "" { + return []string{}, nil } - strVal, ok := val.(string) - if ok { - return strVal, nil - } - - return "", nil -} - -func searchClaimsForAttr(attributePath string, claims map[string]any) (any, error) { - if attributePath == "" { - return "", errors.New("no attribute path specified") - } - - if len(claims) == 0 { - return "", errors.New("empty claims provided") - } - - val, err := jmespath.Search(attributePath, claims) - if err != nil { - return "", fmt.Errorf("failed to search claims with provided path: %q: %w", attributePath, err) - } - - return val, nil + return util.SearchJSONForStringSliceAttr(s.cfg.JWTAuth.GroupsAttributePath, claims) } diff --git a/pkg/services/authn/clients/jwt_test.go b/pkg/services/authn/clients/jwt_test.go index 95ce08467a1..f704a691189 100644 --- a/pkg/services/authn/clients/jwt_test.go +++ b/pkg/services/authn/clients/jwt_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) func stringPtr(s string) *string { @@ -22,72 +23,153 @@ func stringPtr(s string) *string { } func TestAuthenticateJWT(t *testing.T) { - jwtService := &jwt.FakeJWTService{ - VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { - return jwt.JWTClaims{ - "sub": "1234567890", - "email": "eai.doe@cor.po", - "preferred_username": "eai-doe", - "name": "Eai Doe", - "roles": "Admin", - }, nil - }, - } + t.Parallel() + jwtHeaderName := "X-Forwarded-User" - wantID := &authn.Identity{ - OrgID: 0, - OrgName: "", - OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleAdmin}, - ID: "", - Login: "eai-doe", - Name: "Eai Doe", - Email: "eai.doe@cor.po", - IsGrafanaAdmin: boolPtr(false), - AuthenticatedBy: login.JWTModule, - AuthID: "1234567890", - IsDisabled: false, - HelpFlags1: 0, - ClientParams: authn.ClientParams{ - SyncUser: true, - AllowSignUp: true, - FetchSyncedUser: true, - SyncOrgRoles: true, - SyncPermissions: true, - LookUpParams: login.UserLookupParams{ - UserID: nil, - Email: stringPtr("eai.doe@cor.po"), - Login: stringPtr("eai-doe"), + + testCases := []struct { + name string + wantID *authn.Identity + verifyProvider func(context.Context, string) (jwt.JWTClaims, error) + cfg *setting.Cfg + }{ + { + name: "Valid Use case with group path", + wantID: &authn.Identity{ + OrgID: 0, + OrgName: "", + OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleAdmin}, + Groups: []string{"foo", "bar"}, + ID: "", + Login: "eai-doe", + Name: "Eai Doe", + Email: "eai.doe@cor.po", + IsGrafanaAdmin: boolPtr(false), + AuthenticatedBy: login.JWTModule, + AuthID: "1234567890", + IsDisabled: false, + HelpFlags1: 0, + ClientParams: authn.ClientParams{ + SyncUser: true, + AllowSignUp: true, + FetchSyncedUser: true, + SyncOrgRoles: true, + SyncPermissions: true, + SyncTeams: true, + LookUpParams: login.UserLookupParams{ + UserID: nil, + Email: stringPtr("eai.doe@cor.po"), + Login: stringPtr("eai-doe"), + }, + }, + }, + verifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { + return jwt.JWTClaims{ + "sub": "1234567890", + "email": "eai.doe@cor.po", + "preferred_username": "eai-doe", + "name": "Eai Doe", + "roles": "Admin", + "groups": []string{"foo", "bar"}, + }, nil + }, + cfg: &setting.Cfg{ + JWTAuth: setting.AuthJWTSettings{ + Enabled: true, + HeaderName: jwtHeaderName, + EmailClaim: "email", + UsernameClaim: "preferred_username", + AutoSignUp: true, + AllowAssignGrafanaAdmin: true, + RoleAttributeStrict: true, + RoleAttributePath: "roles", + GroupsAttributePath: "groups[]", + }, + }, + }, + { + name: "Valid Use case without group path", + wantID: &authn.Identity{ + OrgID: 0, + OrgName: "", + OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleAdmin}, + ID: "", + Login: "eai-doe", + Groups: []string{}, + Name: "Eai Doe", + Email: "eai.doe@cor.po", + IsGrafanaAdmin: boolPtr(false), + AuthenticatedBy: login.JWTModule, + AuthID: "1234567890", + IsDisabled: false, + HelpFlags1: 0, + ClientParams: authn.ClientParams{ + SyncUser: true, + AllowSignUp: true, + FetchSyncedUser: true, + SyncOrgRoles: true, + SyncPermissions: true, + SyncTeams: false, + LookUpParams: login.UserLookupParams{ + UserID: nil, + Email: stringPtr("eai.doe@cor.po"), + Login: stringPtr("eai-doe"), + }, + }, + }, + verifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { + return jwt.JWTClaims{ + "sub": "1234567890", + "email": "eai.doe@cor.po", + "preferred_username": "eai-doe", + "name": "Eai Doe", + "roles": "Admin", + "groups": []string{"foo", "bar"}, + }, nil + }, + cfg: &setting.Cfg{ + JWTAuth: setting.AuthJWTSettings{ + Enabled: true, + HeaderName: jwtHeaderName, + EmailClaim: "email", + UsernameClaim: "preferred_username", + AutoSignUp: true, + AllowAssignGrafanaAdmin: true, + RoleAttributeStrict: true, + RoleAttributePath: "roles", + }, }, }, } - cfg := &setting.Cfg{ - JWTAuthEnabled: true, - JWTAuthHeaderName: jwtHeaderName, - JWTAuthEmailClaim: "email", - JWTAuthUsernameClaim: "preferred_username", - JWTAuthAutoSignUp: true, - JWTAuthAllowAssignGrafanaAdmin: true, - JWTAuthRoleAttributeStrict: true, - JWTAuthRoleAttributePath: "roles", - } - jwtClient := ProvideJWT(jwtService, cfg) - validHTTPReq := &http.Request{ - Header: map[string][]string{ - jwtHeaderName: {"sample-token"}}, - } + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + jwtService := &jwt.FakeJWTService{ + VerifyProvider: tc.verifyProvider, + } - id, err := jwtClient.Authenticate(context.Background(), &authn.Request{ - OrgID: 1, - HTTPRequest: validHTTPReq, - Resp: nil, - }) - require.NoError(t, err) + jwtClient := ProvideJWT(jwtService, tc.cfg) + validHTTPReq := &http.Request{ + Header: map[string][]string{ + jwtHeaderName: {"sample-token"}}, + } - assert.EqualValues(t, wantID, id, fmt.Sprintf("%+v", id)) + id, err := jwtClient.Authenticate(context.Background(), &authn.Request{ + OrgID: 1, + HTTPRequest: validHTTPReq, + Resp: nil, + }) + require.NoError(t, err) + + assert.EqualValues(t, tc.wantID, id, fmt.Sprintf("%+v", id)) + }) + } } func TestJWTClaimConfig(t *testing.T) { + t.Parallel() jwtService := &jwt.FakeJWTService{ VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { return jwt.JWTClaims{ @@ -102,30 +184,19 @@ func TestJWTClaimConfig(t *testing.T) { jwtHeaderName := "X-Forwarded-User" - cfg := &setting.Cfg{ - JWTAuthEnabled: true, - JWTAuthHeaderName: jwtHeaderName, - JWTAuthAutoSignUp: true, - JWTAuthAllowAssignGrafanaAdmin: true, - JWTAuthRoleAttributeStrict: true, - JWTAuthRoleAttributePath: "roles", - } - // #nosec G101 -- This is a dummy/test token token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o" - type Dictionary map[string]any - type testCase struct { desc string - claimsConfigurations []Dictionary + claimsConfigurations []util.DynMap valid bool } testCases := []testCase{ { desc: "JWT configuration with email and username claims", - claimsConfigurations: []Dictionary{ + claimsConfigurations: []util.DynMap{ { "JWTAuthEmailClaim": true, "JWTAuthUsernameClaim": true, @@ -135,7 +206,7 @@ func TestJWTClaimConfig(t *testing.T) { }, { desc: "JWT configuration with email claim", - claimsConfigurations: []Dictionary{ + claimsConfigurations: []util.DynMap{ { "JWTAuthEmailClaim": true, "JWTAuthUsernameClaim": false, @@ -145,7 +216,7 @@ func TestJWTClaimConfig(t *testing.T) { }, { desc: "JWT configuration with username claim", - claimsConfigurations: []Dictionary{ + claimsConfigurations: []util.DynMap{ { "JWTAuthEmailClaim": false, "JWTAuthUsernameClaim": true, @@ -155,7 +226,7 @@ func TestJWTClaimConfig(t *testing.T) { }, { desc: "JWT configuration without email and username claims", - claimsConfigurations: []Dictionary{ + claimsConfigurations: []util.DynMap{ { "JWTAuthEmailClaim": false, "JWTAuthUsernameClaim": false, @@ -166,39 +237,53 @@ func TestJWTClaimConfig(t *testing.T) { } for _, tc := range testCases { + tc := tc t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + cfg := &setting.Cfg{ + JWTAuth: setting.AuthJWTSettings{ + Enabled: true, + HeaderName: jwtHeaderName, + AutoSignUp: true, + AllowAssignGrafanaAdmin: true, + RoleAttributeStrict: true, + RoleAttributePath: "roles", + }, + } for _, claims := range tc.claimsConfigurations { - cfg.JWTAuthEmailClaim = "" - cfg.JWTAuthUsernameClaim = "" + cfg.JWTAuth.EmailClaim = "" + cfg.JWTAuth.UsernameClaim = "" if claims["JWTAuthEmailClaim"] == true { - cfg.JWTAuthEmailClaim = "email" + cfg.JWTAuth.EmailClaim = "email" } if claims["JWTAuthUsernameClaim"] == true { - cfg.JWTAuthUsernameClaim = "preferred_username" + cfg.JWTAuth.UsernameClaim = "preferred_username" } } + + httpReq := &http.Request{ + URL: &url.URL{RawQuery: "auth_token=" + token}, + Header: map[string][]string{ + jwtHeaderName: {token}}, + } + jwtClient := ProvideJWT(jwtService, cfg) + _, err := jwtClient.Authenticate(context.Background(), &authn.Request{ + OrgID: 1, + HTTPRequest: httpReq, + Resp: nil, + }) + if tc.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } }) - httpReq := &http.Request{ - URL: &url.URL{RawQuery: "auth_token=" + token}, - Header: map[string][]string{ - jwtHeaderName: {token}}, - } - jwtClient := ProvideJWT(jwtService, cfg) - _, err := jwtClient.Authenticate(context.Background(), &authn.Request{ - OrgID: 1, - HTTPRequest: httpReq, - Resp: nil, - }) - if tc.valid { - require.NoError(t, err) - } else { - require.Error(t, err) - } } } func TestJWTTest(t *testing.T) { + t.Parallel() jwtService := &jwt.FakeJWTService{} jwtHeaderName := "X-Forwarded-User" // #nosec G101 -- This is dummy/test token @@ -280,14 +365,18 @@ func TestJWTTest(t *testing.T) { } for _, tc := range testCases { + tc := tc t.Run(tc.desc, func(t *testing.T) { + t.Parallel() cfg := &setting.Cfg{ - JWTAuthEnabled: true, - JWTAuthURLLogin: tc.urlLogin, - JWTAuthHeaderName: tc.cfgHeaderName, - JWTAuthAutoSignUp: true, - JWTAuthAllowAssignGrafanaAdmin: true, - JWTAuthRoleAttributeStrict: true, + JWTAuth: setting.AuthJWTSettings{ + Enabled: true, + URLLogin: tc.urlLogin, + HeaderName: tc.cfgHeaderName, + AutoSignUp: true, + AllowAssignGrafanaAdmin: true, + RoleAttributeStrict: true, + }, } jwtClient := ProvideJWT(jwtService, cfg) httpReq := &http.Request{ @@ -308,6 +397,7 @@ func TestJWTTest(t *testing.T) { } func TestJWTStripParam(t *testing.T) { + t.Parallel() jwtService := &jwt.FakeJWTService{ VerifyProvider: func(context.Context, string) (jwt.JWTClaims, error) { return jwt.JWTClaims{ @@ -323,15 +413,17 @@ func TestJWTStripParam(t *testing.T) { jwtHeaderName := "X-Forwarded-User" cfg := &setting.Cfg{ - JWTAuthEnabled: true, - JWTAuthHeaderName: jwtHeaderName, - JWTAuthAutoSignUp: true, - JWTAuthAllowAssignGrafanaAdmin: true, - JWTAuthURLLogin: true, - JWTAuthRoleAttributeStrict: false, - JWTAuthRoleAttributePath: "roles", - JWTAuthEmailClaim: "email", - JWTAuthUsernameClaim: "preferred_username", + JWTAuth: setting.AuthJWTSettings{ + Enabled: true, + HeaderName: jwtHeaderName, + AutoSignUp: true, + AllowAssignGrafanaAdmin: true, + URLLogin: true, + RoleAttributeStrict: false, + RoleAttributePath: "roles", + EmailClaim: "email", + UsernameClaim: "preferred_username", + }, } // #nosec G101 -- This is a dummy/test token diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index 6ea987300f6..7bb582c970e 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -213,8 +213,8 @@ func WithAuthHTTPHeaders(ctx context.Context, cfg *setting.Cfg) context.Context list.Items = append(list.Items, "X-Grafana-Device-Id") // if jwt is enabled we add it to the list. We can ignore in case it is set to Authorization - if cfg.JWTAuthEnabled && cfg.JWTAuthHeaderName != "" && cfg.JWTAuthHeaderName != "Authorization" { - list.Items = append(list.Items, cfg.JWTAuthHeaderName) + if cfg.JWTAuth.Enabled && cfg.JWTAuth.HeaderName != "" && cfg.JWTAuth.HeaderName != "Authorization" { + list.Items = append(list.Items, cfg.JWTAuth.HeaderName) } // if auth proxy is enabled add the main proxy header and all configured headers diff --git a/pkg/services/contexthandler/contexthandler_test.go b/pkg/services/contexthandler/contexthandler_test.go index 47f5adc4816..cd6b1e1663b 100644 --- a/pkg/services/contexthandler/contexthandler_test.go +++ b/pkg/services/contexthandler/contexthandler_test.go @@ -153,8 +153,8 @@ func TestContextHandler(t *testing.T) { t.Run("should store auth header in context", func(t *testing.T) { cfg := setting.NewCfg() - cfg.JWTAuthEnabled = true - cfg.JWTAuthHeaderName = "jwt-header" + cfg.JWTAuth.Enabled = true + cfg.JWTAuth.HeaderName = "jwt-header" cfg.AuthProxyEnabled = true cfg.AuthProxyHeaderName = "proxy-header" cfg.AuthProxyHeaders = map[string]string{ diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index 0aacdf667ba..c1f6c44f6c9 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -29,9 +29,15 @@ import ( "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) +// run tests with cleanup +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationDashboardDataAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/dashboards/service/dashboard_service_integration_test.go b/pkg/services/dashboards/service/dashboard_service_integration_test.go index 4c095921220..79a9ea4d384 100644 --- a/pkg/services/dashboards/service/dashboard_service_integration_test.go +++ b/pkg/services/dashboards/service/dashboard_service_integration_test.go @@ -27,10 +27,15 @@ import ( "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) const testOrgID int64 = 1 +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationIntegratedDashboardService(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/dashboardsnapshots/database/database_test.go b/pkg/services/dashboardsnapshots/database/database_test.go index f19461d4fdb..3a2b732ae48 100644 --- a/pkg/services/dashboardsnapshots/database/database_test.go +++ b/pkg/services/dashboardsnapshots/database/database_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationDashboardSnapshotDBAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/dashboardsnapshots/service/service_test.go b/pkg/services/dashboardsnapshots/service/service_test.go index f4b80d9d9c9..70c924e1648 100644 --- a/pkg/services/dashboardsnapshots/service/service_test.go +++ b/pkg/services/dashboardsnapshots/service/service_test.go @@ -15,8 +15,13 @@ import ( "github.com/grafana/grafana/pkg/services/secrets/database" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestDashboardSnapshotsService(t *testing.T) { sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() diff --git a/pkg/services/dashboardversion/dashverimpl/store_test.go b/pkg/services/dashboardversion/dashverimpl/store_test.go index f3583bcd82b..cccd1d220ce 100644 --- a/pkg/services/dashboardversion/dashverimpl/store_test.go +++ b/pkg/services/dashboardversion/dashverimpl/store_test.go @@ -13,9 +13,14 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type getStore func(db.DB) store func testIntegrationGetDashboardVersion(t *testing.T, fn getStore) { diff --git a/pkg/services/datasources/service/datasource_test.go b/pkg/services/datasources/service/datasource_test.go index 3672bb9e817..c9818f4bcb4 100644 --- a/pkg/services/datasources/service/datasource_test.go +++ b/pkg/services/datasources/service/datasource_test.go @@ -31,8 +31,13 @@ import ( secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type dataSourceMockRetriever struct { res []*datasources.DataSource } diff --git a/pkg/services/extsvcauth/oauthserver/store/database_test.go b/pkg/services/extsvcauth/oauthserver/store/database_test.go index 69b50ade317..ae03a1c6f8f 100644 --- a/pkg/services/extsvcauth/oauthserver/store/database_test.go +++ b/pkg/services/extsvcauth/oauthserver/store/database_test.go @@ -12,8 +12,13 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestStore_RegisterAndGetClient(t *testing.T) { s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})} tests := []struct { diff --git a/pkg/services/featuremgmt/codeowners.go b/pkg/services/featuremgmt/codeowners.go index 42e0c139623..a303f74f9b2 100644 --- a/pkg/services/featuremgmt/codeowners.go +++ b/pkg/services/featuremgmt/codeowners.go @@ -8,7 +8,6 @@ const ( grafanaAppPlatformSquad codeowner = "@grafana/grafana-app-platform-squad" grafanaDashboardsSquad codeowner = "@grafana/dashboards-squad" grafanaExploreSquad codeowner = "@grafana/explore-squad" - grafanaBiSquad codeowner = "@grafana/grafana-bi-squad" grafanaDatavizSquad codeowner = "@grafana/dataviz-squad" grafanaFrontendPlatformSquad codeowner = "@grafana/grafana-frontend-platform" grafanaBackendPlatformSquad codeowner = "@grafana/backend-platform" diff --git a/pkg/services/featuremgmt/manager.go b/pkg/services/featuremgmt/manager.go index 74ea3350412..457436790a5 100644 --- a/pkg/services/featuremgmt/manager.go +++ b/pkg/services/featuremgmt/manager.go @@ -151,6 +151,7 @@ func (fm *FeatureManager) IsEditableFromAdminPage(key string) bool { return false } return flag.Stage == FeatureStageGeneralAvailability || + flag.Stage == FeatureStagePublicPreview || flag.Stage == FeatureStageDeprecated } diff --git a/pkg/services/featuremgmt/models.go b/pkg/services/featuremgmt/models.go index b9c8b9ff7ea..6260547585b 100644 --- a/pkg/services/featuremgmt/models.go +++ b/pkg/services/featuremgmt/models.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "time" ) type FeatureToggles interface { @@ -115,11 +114,10 @@ type FeatureFlag struct { Name string `json:"name" yaml:"name"` // Unique name Description string `json:"description"` Stage FeatureFlagStage `json:"stage,omitempty"` - Created time.Time `json:"created,omitempty"` // when the flag was introduced - Owner codeowner `json:"-"` // Owner person or team that owns this feature flag + Owner codeowner `json:"-"` // Owner person or team that owns this feature flag // Recommended properties - control behavior of the feature toggle management page in the UI - AllowSelfServe bool `json:"allowSelfServe,omitempty"` // allow users with the right privileges to toggle this from the UI (GeneralAvailability and Deprecated toggles only) + AllowSelfServe bool `json:"allowSelfServe,omitempty"` // allow users with the right privileges to toggle this from the UI (GeneralAvailability, PublicPreview, and Deprecated toggles only) HideFromAdminPage bool `json:"hideFromAdminPage,omitempty"` // GA, Deprecated, and PublicPreview toggles only: don't display this feature in the UI; if this is a GA toggle, add a comment with the reasoning // CEL-GO expression. Using the value "true" will mean this is on by default diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 97b62d848ee..cb84847fa25 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -7,7 +7,10 @@ package featuremgmt import ( - "time" + "embed" + "encoding/json" + + featuretoggle "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" ) var ( @@ -20,7 +23,6 @@ var ( Owner: grafanaAsCodeSquad, HideFromAdminPage: true, AllowSelfServe: false, - Created: time.Date(2022, time.May, 24, 12, 0, 0, 0, time.UTC), }, { Name: "live-service-web-worker", @@ -28,7 +30,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaAppPlatformSquad, - Created: time.Date(2021, time.November, 9, 12, 0, 0, 0, time.UTC), }, { Name: "queryOverLive", @@ -36,7 +37,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaAppPlatformSquad, - Created: time.Date(2022, time.January, 5, 12, 0, 0, 0, time.UTC), }, { Name: "panelTitleSearch", @@ -44,7 +44,6 @@ var ( Stage: FeatureStagePublicPreview, Owner: grafanaAppPlatformSquad, HideFromAdminPage: true, - Created: time.Date(2022, time.February, 15, 12, 0, 0, 0, time.UTC), }, { Name: "publicDashboards", @@ -53,7 +52,6 @@ var ( Owner: grafanaSharingSquad, Expression: "true", // enabled by default AllowSelfServe: true, - Created: time.Date(2022, time.April, 7, 12, 0, 0, 0, time.UTC), }, { Name: "publicDashboardsEmailSharing", @@ -62,14 +60,12 @@ var ( Owner: grafanaSharingSquad, HideFromDocs: true, HideFromAdminPage: true, - Created: time.Date(2022, time.December, 21, 12, 0, 0, 0, time.UTC), }, { Name: "lokiExperimentalStreaming", Description: "Support new streaming approach for loki (prototype, needs special loki build)", Stage: FeatureStageExperimental, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.June, 19, 12, 0, 0, 0, time.UTC), }, { Name: "featureHighlights", @@ -77,21 +73,18 @@ var ( Stage: FeatureStageGeneralAvailability, Owner: grafanaAsCodeSquad, AllowSelfServe: true, - Created: time.Date(2022, time.February, 3, 12, 0, 0, 0, time.UTC), }, { Name: "migrationLocking", Description: "Lock database during migrations", Stage: FeatureStagePublicPreview, Owner: grafanaBackendPlatformSquad, - Created: time.Date(2022, time.February, 15, 12, 0, 0, 0, time.UTC), }, { Name: "storage", Description: "Configurable storage for dashboards, datasources, and resources", Stage: FeatureStageExperimental, Owner: grafanaAppPlatformSquad, - Created: time.Date(2022, time.March, 17, 12, 0, 0, 0, time.UTC), }, { Name: "correlations", @@ -99,7 +92,6 @@ var ( Stage: FeatureStageGeneralAvailability, Owner: grafanaExploreSquad, AllowSelfServe: true, - Created: time.Date(2022, time.September, 16, 12, 0, 0, 0, time.UTC), }, { Name: "exploreContentOutline", @@ -109,14 +101,12 @@ var ( Expression: "true", // enabled by default FrontendOnly: true, AllowSelfServe: true, - Created: time.Date(2023, time.November, 3, 12, 0, 0, 0, time.UTC), }, { Name: "datasourceQueryMultiStatus", Description: "Introduce HTTP 207 Multi Status for api/ds/query", Stage: FeatureStageExperimental, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2022, time.May, 3, 12, 0, 0, 0, time.UTC), }, { Name: "traceToMetrics", @@ -124,7 +114,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityTracesAndProfilingSquad, - Created: time.Date(2022, time.March, 7, 12, 0, 0, 0, time.UTC), }, { Name: "autoMigrateOldPanels", @@ -132,7 +121,6 @@ var ( Stage: FeatureStagePublicPreview, FrontendOnly: true, Owner: grafanaDatavizSquad, - Created: time.Date(2022, time.June, 11, 12, 0, 0, 0, time.UTC), }, { Name: "autoMigrateGraphPanel", @@ -140,7 +128,6 @@ var ( Stage: FeatureStagePublicPreview, FrontendOnly: true, Owner: grafanaDatavizSquad, - Created: time.Date(2023, time.December, 11, 7, 0, 0, 0, time.UTC), }, { Name: "disableAngular", @@ -149,7 +136,6 @@ var ( FrontendOnly: true, Owner: grafanaDatavizSquad, HideFromAdminPage: true, - Created: time.Date(2023, time.March, 23, 12, 0, 0, 0, time.UTC), }, { Name: "canvasPanelNesting", @@ -158,7 +144,6 @@ var ( FrontendOnly: true, Owner: grafanaDatavizSquad, HideFromAdminPage: true, - Created: time.Date(2022, time.May, 31, 12, 0, 0, 0, time.UTC), }, { Name: "newVizTooltips", @@ -167,7 +152,6 @@ var ( FrontendOnly: true, Owner: grafanaDatavizSquad, AllowSelfServe: false, - Created: time.Date(2023, time.November, 3, 12, 0, 0, 0, time.UTC), }, { Name: "scenes", @@ -175,7 +159,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaDashboardsSquad, - Created: time.Date(2022, time.July, 7, 12, 0, 0, 0, time.UTC), }, { Name: "disableSecretsCompatibility", @@ -183,14 +166,12 @@ var ( Stage: FeatureStageExperimental, RequiresRestart: true, Owner: hostedGrafanaTeam, - Created: time.Date(2022, time.July, 13, 12, 0, 0, 0, time.UTC), }, { Name: "logRequestsInstrumentedAsUnknown", Description: "Logs the path for requests that are instrumented as unknown", Stage: FeatureStageExperimental, Owner: hostedGrafanaTeam, - Created: time.Date(2022, time.June, 10, 12, 0, 0, 0, time.UTC), }, { Name: "dataConnectionsConsole", @@ -199,7 +180,6 @@ var ( Expression: "true", // turned on by default Owner: grafanaPluginsPlatformSquad, AllowSelfServe: true, - Created: time.Date(2022, time.June, 1, 12, 0, 0, 0, time.UTC), }, { // Some plugins rely on topnav feature flag being enabled, so we cannot remove this until we @@ -209,7 +189,6 @@ var ( Stage: FeatureStageDeprecated, Expression: "true", // enabled by default Owner: grafanaFrontendPlatformSquad, - Created: time.Date(2022, time.June, 20, 12, 0, 0, 0, time.UTC), }, { Name: "returnToPrevious", @@ -217,7 +196,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaFrontendPlatformSquad, - Created: time.Date(2024, time.January, 9, 11, 0, 0, 0, time.UTC), }, { Name: "grpcServer", @@ -225,7 +203,6 @@ var ( Stage: FeatureStagePublicPreview, Owner: grafanaAppPlatformSquad, HideFromAdminPage: true, - Created: time.Date(2022, time.September, 27, 12, 0, 0, 0, time.UTC), }, { Name: "unifiedStorage", @@ -234,7 +211,6 @@ var ( RequiresDevMode: true, RequiresRestart: true, // new SQL tables created Owner: grafanaAppPlatformSquad, - Created: time.Date(2022, time.December, 1, 12, 0, 0, 0, time.UTC), }, { Name: "cloudWatchCrossAccountQuerying", @@ -243,7 +219,6 @@ var ( Expression: "true", // enabled by default Owner: awsDatasourcesSquad, AllowSelfServe: true, - Created: time.Date(2022, time.November, 28, 12, 0, 0, 0, time.UTC), }, { Name: "redshiftAsyncQueryDataSupport", @@ -252,7 +227,6 @@ var ( Expression: "true", // enabled by default Owner: awsDatasourcesSquad, AllowSelfServe: false, - Created: time.Date(2022, time.August, 27, 12, 0, 0, 0, time.UTC), }, { Name: "athenaAsyncQueryDataSupport", @@ -262,21 +236,18 @@ var ( FrontendOnly: true, Owner: awsDatasourcesSquad, AllowSelfServe: false, - Created: time.Date(2022, time.August, 27, 12, 0, 0, 0, time.UTC), }, { Name: "showDashboardValidationWarnings", Description: "Show warnings when dashboards do not validate against the schema", Stage: FeatureStageExperimental, Owner: grafanaDashboardsSquad, - Created: time.Date(2022, time.October, 14, 12, 0, 0, 0, time.UTC), }, { Name: "mysqlAnsiQuotes", Description: "Use double quotes to escape keyword in a MySQL query", Stage: FeatureStageExperimental, Owner: grafanaBackendPlatformSquad, - Created: time.Date(2022, time.October, 12, 12, 0, 0, 0, time.UTC), }, { Name: "accessControlOnCall", @@ -284,14 +255,12 @@ var ( Stage: FeatureStagePublicPreview, Owner: identityAccessTeam, HideFromAdminPage: true, - Created: time.Date(2022, time.October, 19, 12, 0, 0, 0, time.UTC), }, { Name: "nestedFolders", Description: "Enable folder nesting", Stage: FeatureStagePublicPreview, Owner: grafanaBackendPlatformSquad, - Created: time.Date(2022, time.October, 22, 12, 0, 0, 0, time.UTC), }, { Name: "nestedFolderPicker", @@ -301,22 +270,19 @@ var ( FrontendOnly: true, Expression: "true", // enabled by default AllowSelfServe: true, - Created: time.Date(2023, time.July, 24, 12, 0, 0, 0, time.UTC), }, { Name: "alertingBacktesting", Description: "Rule backtesting API for alerting", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - Created: time.Date(2022, time.October, 20, 12, 0, 0, 0, time.UTC), }, { Name: "editPanelCSVDragAndDrop", Description: "Enables drag and drop for CSV and Excel files", FrontendOnly: true, Stage: FeatureStageExperimental, - Owner: grafanaBiSquad, - Created: time.Date(2022, time.December, 20, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "alertingNoNormalState", @@ -325,7 +291,6 @@ var ( RequiresRestart: false, Owner: grafanaAlertingSquad, HideFromAdminPage: true, - Created: time.Date(2023, time.January, 14, 12, 0, 0, 0, time.UTC), }, { Name: "logsContextDatasourceUi", @@ -335,7 +300,6 @@ var ( Owner: grafanaObservabilityLogsSquad, Expression: "true", // turned on by default AllowSelfServe: true, - Created: time.Date(2023, time.January, 27, 12, 0, 0, 0, time.UTC), }, { Name: "lokiQuerySplitting", @@ -345,7 +309,6 @@ var ( Owner: grafanaObservabilityLogsSquad, Expression: "true", // turned on by default AllowSelfServe: true, - Created: time.Date(2023, time.February, 9, 12, 0, 0, 0, time.UTC), }, { Name: "lokiQuerySplittingConfig", @@ -353,14 +316,12 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.March, 20, 12, 0, 0, 0, time.UTC), }, { Name: "individualCookiePreferences", Description: "Support overriding cookie preferences per user", Stage: FeatureStageExperimental, Owner: grafanaBackendPlatformSquad, - Created: time.Date(2023, time.February, 23, 12, 0, 0, 0, time.UTC), }, { Name: "prometheusMetricEncyclopedia", @@ -370,7 +331,6 @@ var ( FrontendOnly: true, Owner: grafanaObservabilityMetricsSquad, AllowSelfServe: true, - Created: time.Date(2023, time.March, 7, 12, 0, 0, 0, time.UTC), }, { Name: "influxdbBackendMigration", @@ -380,14 +340,12 @@ var ( Owner: grafanaObservabilityMetricsSquad, Expression: "true", // enabled by default AllowSelfServe: false, - Created: time.Date(2023, time.March, 15, 12, 0, 0, 0, time.UTC), }, { Name: "influxqlStreamingParser", Description: "Enable streaming JSON parser for InfluxDB datasource InfluxQL query language", Stage: FeatureStageExperimental, Owner: grafanaObservabilityMetricsSquad, - Created: time.Date(2023, time.November, 29, 12, 0, 0, 0, time.UTC), }, { Name: "influxdbRunQueriesInParallel", @@ -395,7 +353,6 @@ var ( Stage: FeatureStagePrivatePreview, FrontendOnly: false, Owner: grafanaObservabilityMetricsSquad, - Created: time.Date(2024, time.January, 29, 12, 0, 0, 0, time.UTC), }, { Name: "clientTokenRotation", @@ -404,7 +361,6 @@ var ( Expression: "true", Owner: identityAccessTeam, AllowSelfServe: false, - Created: time.Date(2023, time.March, 23, 12, 0, 0, 0, time.UTC), }, { Name: "prometheusDataplane", @@ -413,7 +369,6 @@ var ( Stage: FeatureStageGeneralAvailability, Owner: grafanaObservabilityMetricsSquad, AllowSelfServe: true, - Created: time.Date(2023, time.March, 29, 12, 0, 0, 0, time.UTC), }, { Name: "lokiMetricDataplane", @@ -422,14 +377,12 @@ var ( Expression: "true", Owner: grafanaObservabilityLogsSquad, AllowSelfServe: true, - Created: time.Date(2023, time.April, 13, 12, 0, 0, 0, time.UTC), }, { Name: "lokiLogsDataplane", Description: "Changes logs responses from Loki to be compliant with the dataplane specification.", Stage: FeatureStageExperimental, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.July, 13, 12, 0, 0, 0, time.UTC), }, { Name: "dataplaneFrontendFallback", @@ -439,42 +392,36 @@ var ( Expression: "true", Owner: grafanaObservabilityMetricsSquad, AllowSelfServe: true, - Created: time.Date(2023, time.April, 24, 12, 0, 0, 0, time.UTC), }, { Name: "disableSSEDataplane", Description: "Disables dataplane specific processing in server side expressions.", Stage: FeatureStageExperimental, Owner: grafanaObservabilityMetricsSquad, - Created: time.Date(2023, time.April, 24, 12, 0, 0, 0, time.UTC), }, { Name: "alertStateHistoryLokiSecondary", Description: "Enable Grafana to write alert state history to an external Loki instance in addition to Grafana annotations.", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - Created: time.Date(2023, time.March, 30, 12, 0, 0, 0, time.UTC), }, { Name: "alertStateHistoryLokiPrimary", Description: "Enable a remote Loki instance as the primary source for state history reads.", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - Created: time.Date(2023, time.March, 30, 12, 0, 0, 0, time.UTC), }, { Name: "alertStateHistoryLokiOnly", Description: "Disable Grafana alerts from emitting annotations when a remote Loki instance is available.", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - Created: time.Date(2023, time.March, 30, 12, 0, 0, 0, time.UTC), }, { Name: "unifiedRequestLog", Description: "Writes error logs to the request logger", Stage: FeatureStageExperimental, Owner: grafanaBackendPlatformSquad, - Created: time.Date(2023, time.March, 31, 12, 0, 0, 0, time.UTC), }, { Name: "renderAuthJWT", @@ -482,7 +429,6 @@ var ( Stage: FeatureStagePublicPreview, Owner: grafanaAsCodeSquad, HideFromAdminPage: true, - Created: time.Date(2023, time.April, 3, 12, 0, 0, 0, time.UTC), }, { Name: "externalServiceAuth", @@ -490,7 +436,6 @@ var ( Stage: FeatureStageExperimental, RequiresDevMode: true, Owner: identityAccessTeam, - Created: time.Date(2023, time.April, 11, 12, 0, 0, 0, time.UTC), }, { Name: "refactorVariablesTimeRange", @@ -498,7 +443,6 @@ var ( Stage: FeatureStagePublicPreview, Owner: grafanaDashboardsSquad, HideFromAdminPage: true, // Non-feature, used to test out a bug fix that impacts the performance of template variables. - Created: time.Date(2023, time.June, 6, 12, 0, 0, 0, time.UTC), }, { Name: "enableElasticsearchBackendQuerying", @@ -507,7 +451,6 @@ var ( Owner: grafanaObservabilityLogsSquad, Expression: "true", // enabled by default AllowSelfServe: true, - Created: time.Date(2023, time.April, 14, 12, 0, 0, 0, time.UTC), }, { Name: "faroDatasourceSelector", @@ -515,15 +458,13 @@ var ( Stage: FeatureStagePublicPreview, FrontendOnly: true, Owner: appO11ySquad, - Created: time.Date(2023, time.May, 4, 12, 0, 0, 0, time.UTC), }, { Name: "enableDatagridEditing", Description: "Enables the edit functionality in the datagrid panel", FrontendOnly: true, Stage: FeatureStagePublicPreview, - Owner: grafanaBiSquad, - Created: time.Date(2023, time.April, 24, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "extraThemes", @@ -531,7 +472,6 @@ var ( FrontendOnly: true, Stage: FeatureStageExperimental, Owner: grafanaFrontendPlatformSquad, - Created: time.Date(2023, time.May, 10, 12, 0, 0, 0, time.UTC), }, { Name: "lokiPredefinedOperations", @@ -539,7 +479,6 @@ var ( FrontendOnly: true, Stage: FeatureStageExperimental, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.June, 2, 12, 0, 0, 0, time.UTC), }, { Name: "pluginsFrontendSandbox", @@ -547,7 +486,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.June, 5, 12, 0, 0, 0, time.UTC), }, { Name: "dashboardEmbed", @@ -555,7 +493,6 @@ var ( FrontendOnly: true, Stage: FeatureStageExperimental, Owner: grafanaAsCodeSquad, - Created: time.Date(2023, time.July, 6, 12, 0, 0, 0, time.UTC), }, { Name: "frontendSandboxMonitorOnly", @@ -563,16 +500,14 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.July, 5, 12, 0, 0, 0, time.UTC), }, { Name: "sqlDatasourceDatabaseSelection", Description: "Enables previous SQL data source dataset dropdown behavior", FrontendOnly: true, Stage: FeatureStagePublicPreview, - Owner: grafanaBiSquad, + Owner: grafanaDatavizSquad, HideFromAdminPage: true, - Created: time.Date(2023, time.June, 6, 12, 0, 0, 0, time.UTC), }, { Name: "lokiFormatQuery", @@ -580,7 +515,6 @@ var ( FrontendOnly: true, Stage: FeatureStageExperimental, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.June, 21, 12, 0, 0, 0, time.UTC), }, { Name: "cloudWatchLogsMonacoEditor", @@ -590,7 +524,6 @@ var ( Expression: "true", // enabled by default Owner: awsDatasourcesSquad, AllowSelfServe: true, - Created: time.Date(2023, time.June, 12, 12, 0, 0, 0, time.UTC), }, { Name: "exploreScrollableLogsContainer", @@ -598,7 +531,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.June, 15, 12, 0, 0, 0, time.UTC), }, { Name: "recordedQueriesMulti", @@ -607,7 +539,6 @@ var ( Expression: "true", Owner: grafanaObservabilityMetricsSquad, AllowSelfServe: false, - Created: time.Date(2023, time.June, 14, 12, 0, 0, 0, time.UTC), }, { Name: "pluginsDynamicAngularDetectionPatterns", @@ -615,7 +546,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: false, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.June, 26, 12, 0, 0, 0, time.UTC), }, { Name: "vizAndWidgetSplit", @@ -623,7 +553,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaDashboardsSquad, - Created: time.Date(2023, time.June, 27, 12, 0, 0, 0, time.UTC), }, { Name: "prometheusIncrementalQueryInstrumentation", @@ -631,7 +560,6 @@ var ( FrontendOnly: true, Stage: FeatureStageExperimental, Owner: grafanaObservabilityMetricsSquad, - Created: time.Date(2023, time.July, 5, 12, 0, 0, 0, time.UTC), }, { Name: "logsExploreTableVisualisation", @@ -639,14 +567,12 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.July, 12, 12, 0, 0, 0, time.UTC), }, { Name: "awsDatasourcesTempCredentials", Description: "Support temporary security credentials in AWS plugins for Grafana Cloud customers", Stage: FeatureStageExperimental, Owner: awsDatasourcesSquad, - Created: time.Date(2023, time.July, 6, 12, 0, 0, 0, time.UTC), }, { Name: "transformationsRedesign", @@ -656,7 +582,6 @@ var ( Expression: "true", // enabled by default Owner: grafanaObservabilityMetricsSquad, AllowSelfServe: true, - Created: time.Date(2023, time.July, 12, 12, 0, 0, 0, time.UTC), }, { Name: "mlExpressions", @@ -664,7 +589,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: false, Owner: grafanaAlertingSquad, - Created: time.Date(2023, time.July, 13, 12, 0, 0, 0, time.UTC), }, { Name: "traceQLStreaming", @@ -672,7 +596,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityTracesAndProfilingSquad, - Created: time.Date(2023, time.July, 26, 12, 0, 0, 0, time.UTC), }, { Name: "metricsSummary", @@ -680,7 +603,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityTracesAndProfilingSquad, - Created: time.Date(2023, time.August, 28, 12, 0, 0, 0, time.UTC), }, { Name: "grafanaAPIServerWithExperimentalAPIs", @@ -689,7 +611,6 @@ var ( RequiresRestart: true, RequiresDevMode: true, Owner: grafanaAppPlatformSquad, - Created: time.Date(2023, time.October, 6, 12, 0, 0, 0, time.UTC), }, { Name: "grafanaAPIServerEnsureKubectlAccess", @@ -698,7 +619,6 @@ var ( RequiresDevMode: true, RequiresRestart: true, Owner: grafanaAppPlatformSquad, - Created: time.Date(2023, time.December, 6, 12, 0, 0, 0, time.UTC), }, { Name: "featureToggleAdminPage", @@ -707,7 +627,6 @@ var ( FrontendOnly: false, Owner: grafanaOperatorExperienceSquad, RequiresRestart: true, - Created: time.Date(2023, time.July, 18, 12, 0, 0, 0, time.UTC), }, { Name: "awsAsyncQueryCaching", @@ -715,7 +634,6 @@ var ( Stage: FeatureStageGeneralAvailability, Expression: "true", // enabled by default Owner: awsDatasourcesSquad, - Created: time.Date(2023, time.July, 21, 12, 0, 0, 0, time.UTC), }, { Name: "splitScopes", @@ -726,14 +644,12 @@ var ( Owner: identityAccessTeam, RequiresRestart: true, HideFromAdminPage: true, // This is internal work to speed up dashboard search, and is not ready for wider use - Created: time.Date(2023, time.July, 21, 12, 0, 0, 0, time.UTC), }, { Name: "permissionsFilterRemoveSubquery", Description: "Alternative permission filter implementation that does not use subqueries for fetching the dashboard folder", Stage: FeatureStageExperimental, Owner: grafanaBackendPlatformSquad, - Created: time.Date(2023, time.August, 2, 12, 0, 0, 0, time.UTC), }, { Name: "prometheusConfigOverhaulAuth", @@ -742,7 +658,6 @@ var ( Stage: FeatureStageGeneralAvailability, Expression: "true", // on by default AllowSelfServe: false, - Created: time.Date(2023, time.July, 21, 12, 0, 0, 0, time.UTC), }, { Name: "configurableSchedulerTick", @@ -752,7 +667,6 @@ var ( Owner: grafanaAlertingSquad, RequiresRestart: true, HideFromDocs: true, - Created: time.Date(2023, time.July, 26, 12, 0, 0, 0, time.UTC), }, { Name: "influxdbSqlSupport", @@ -763,7 +677,6 @@ var ( RequiresRestart: true, AllowSelfServe: true, Expression: "true", // enabled by default - Created: time.Date(2023, time.August, 2, 12, 0, 0, 0, time.UTC), }, { Name: "alertingNoDataErrorExecution", @@ -773,7 +686,6 @@ var ( Owner: grafanaAlertingSquad, RequiresRestart: true, Expression: "true", // enabled by default - Created: time.Date(2023, time.August, 15, 12, 0, 0, 0, time.UTC), }, { Name: "angularDeprecationUI", @@ -781,7 +693,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.August, 29, 12, 0, 0, 0, time.UTC), }, { Name: "dashgpt", @@ -789,7 +700,6 @@ var ( Stage: FeatureStagePublicPreview, FrontendOnly: true, Owner: grafanaDashboardsSquad, - Created: time.Date(2023, time.November, 17, 12, 0, 0, 0, time.UTC), }, { Name: "reportingRetries", @@ -798,14 +708,12 @@ var ( FrontendOnly: false, Owner: grafanaSharingSquad, RequiresRestart: true, - Created: time.Date(2023, time.August, 31, 12, 0, 0, 0, time.UTC), }, { Name: "sseGroupByDatasource", Description: "Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch.", Stage: FeatureStageExperimental, Owner: grafanaObservabilityMetricsSquad, - Created: time.Date(2023, time.September, 7, 12, 0, 0, 0, time.UTC), }, { Name: "libraryPanelRBAC", @@ -814,7 +722,6 @@ var ( FrontendOnly: false, Owner: grafanaDashboardsSquad, RequiresRestart: true, - Created: time.Date(2023, time.October, 11, 12, 0, 0, 0, time.UTC), }, { Name: "lokiRunQueriesInParallel", @@ -822,7 +729,6 @@ var ( Stage: FeatureStagePrivatePreview, FrontendOnly: false, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.September, 19, 12, 0, 0, 0, time.UTC), }, { Name: "wargamesTesting", @@ -830,7 +736,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: false, Owner: hostedGrafanaTeam, - Created: time.Date(2023, time.September, 13, 12, 0, 0, 0, time.UTC), }, { Name: "alertingInsights", @@ -841,14 +746,12 @@ var ( Expression: "true", // enabled by default AllowSelfServe: false, HideFromAdminPage: true, // This is moving away from being a feature toggle. - Created: time.Date(2023, time.September, 14, 12, 0, 0, 0, time.UTC), }, { Name: "externalCorePlugins", Description: "Allow core plugins to be loaded as external", Stage: FeatureStageExperimental, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.September, 22, 12, 0, 0, 0, time.UTC), }, { Name: "pluginsAPIMetrics", @@ -856,14 +759,12 @@ var ( FrontendOnly: true, Stage: FeatureStageExperimental, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.September, 21, 12, 0, 0, 0, time.UTC), }, { Name: "idForwarding", Description: "Generate signed id token for identity that can be forwarded to plugins and external services", Stage: FeatureStageExperimental, Owner: identityAccessTeam, - Created: time.Date(2023, time.September, 25, 12, 0, 0, 0, time.UTC), }, { Name: "cloudWatchWildCardDimensionValues", @@ -872,7 +773,6 @@ var ( Expression: "true", // enabled by default Owner: awsDatasourcesSquad, AllowSelfServe: true, - Created: time.Date(2023, time.September, 27, 12, 0, 0, 0, time.UTC), }, { Name: "externalServiceAccounts", @@ -880,7 +780,6 @@ var ( HideFromAdminPage: true, Stage: FeatureStagePublicPreview, Owner: identityAccessTeam, - Created: time.Date(2023, time.September, 28, 12, 0, 0, 0, time.UTC), }, { Name: "panelMonitoring", @@ -888,7 +787,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaDatavizSquad, FrontendOnly: true, - Created: time.Date(2023, time.October, 8, 12, 0, 0, 0, time.UTC), }, { Name: "enableNativeHTTPHistogram", @@ -896,23 +794,20 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: false, Owner: hostedGrafanaTeam, - Created: time.Date(2023, time.October, 3, 12, 0, 0, 0, time.UTC), }, { Name: "formatString", Description: "Enable format string transformer", Stage: FeatureStagePublicPreview, FrontendOnly: true, - Owner: grafanaBiSquad, - Created: time.Date(2023, time.October, 13, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "transformationsVariableSupport", Description: "Allows using variables in transformations", FrontendOnly: true, Stage: FeatureStagePublicPreview, - Owner: grafanaBiSquad, - Created: time.Date(2023, time.October, 4, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "kubernetesPlaylists", @@ -920,7 +815,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaAppPlatformSquad, RequiresRestart: true, // changes the API routing - Created: time.Date(2023, time.November, 8, 12, 0, 0, 0, time.UTC), }, { Name: "kubernetesSnapshots", @@ -928,7 +822,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaAppPlatformSquad, RequiresRestart: true, // changes the API routing - Created: time.Date(2023, time.December, 4, 12, 0, 0, 0, time.UTC), }, { Name: "kubernetesQueryServiceRewrite", @@ -937,14 +830,12 @@ var ( Owner: grafanaAppPlatformSquad, RequiresRestart: true, // changes the API routing RequiresDevMode: true, - Created: time.Date(2024, time.January, 28, 12, 0, 0, 0, time.UTC), }, { Name: "cloudWatchBatchQueries", Description: "Runs CloudWatch metrics queries as separate batches", Stage: FeatureStagePublicPreview, Owner: awsDatasourcesSquad, - Created: time.Date(2023, time.October, 20, 12, 0, 0, 0, time.UTC), }, { Name: "recoveryThreshold", @@ -953,7 +844,6 @@ var ( FrontendOnly: false, Owner: grafanaAlertingSquad, RequiresRestart: true, - Created: time.Date(2023, time.October, 10, 12, 0, 0, 0, time.UTC), Expression: "true", }, { @@ -962,7 +852,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: false, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.November, 16, 12, 0, 0, 0, time.UTC), }, { Name: "teamHttpHeaders", @@ -970,7 +859,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: false, Owner: identityAccessTeam, - Created: time.Date(2023, time.October, 17, 12, 0, 0, 0, time.UTC), }, { Name: "awsDatasourcesNewFormStyling", @@ -978,7 +866,6 @@ var ( Stage: FeatureStagePublicPreview, FrontendOnly: true, Owner: awsDatasourcesSquad, - Created: time.Date(2023, time.October, 12, 12, 0, 0, 0, time.UTC), }, { Name: "cachingOptimizeSerializationMemoryUsage", @@ -986,7 +873,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaOperatorExperienceSquad, FrontendOnly: false, - Created: time.Date(2023, time.October, 12, 12, 0, 0, 0, time.UTC), }, { Name: "panelTitleSearchInV1", @@ -994,7 +880,6 @@ var ( RequiresDevMode: true, Stage: FeatureStageExperimental, Owner: grafanaBackendPlatformSquad, - Created: time.Date(2023, time.October, 13, 12, 0, 0, 0, time.UTC), }, { Name: "pluginsInstrumentationStatusSource", @@ -1002,7 +887,6 @@ var ( FrontendOnly: false, Stage: FeatureStageExperimental, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.October, 17, 12, 0, 0, 0, time.UTC), }, { Name: "managedPluginsInstall", @@ -1010,7 +894,6 @@ var ( Stage: FeatureStagePublicPreview, RequiresDevMode: false, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.October, 18, 12, 0, 0, 0, time.UTC), }, { Name: "prometheusPromQAIL", @@ -1018,36 +901,31 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityMetricsSquad, - Created: time.Date(2023, time.October, 19, 12, 0, 0, 0, time.UTC), }, { Name: "addFieldFromCalculationStatFunctions", Description: "Add cumulative and window functions to the add field from calculation transformation", Stage: FeatureStagePublicPreview, FrontendOnly: true, - Owner: grafanaBiSquad, - Created: time.Date(2023, time.November, 3, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "alertmanagerRemoteSecondary", Description: "Enable Grafana to sync configuration and state with a remote Alertmanager.", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - Created: time.Date(2023, time.October, 30, 12, 0, 0, 0, time.UTC), }, { Name: "alertmanagerRemotePrimary", Description: "Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - Created: time.Date(2023, time.October, 30, 12, 0, 0, 0, time.UTC), }, { Name: "alertmanagerRemoteOnly", Description: "Disable the internal Alertmanager and only use the external one defined.", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - Created: time.Date(2023, time.October, 30, 12, 0, 0, 0, time.UTC), }, { Name: "annotationPermissionUpdate", @@ -1055,15 +933,13 @@ var ( Stage: FeatureStageExperimental, RequiresDevMode: false, Owner: identityAccessTeam, - Created: time.Date(2023, time.October, 31, 12, 0, 0, 0, time.UTC), }, { Name: "extractFieldsNameDeduplication", Description: "Make sure extracted field names are unique in the dataframe", Stage: FeatureStageExperimental, FrontendOnly: true, - Owner: grafanaBiSquad, - Created: time.Date(2023, time.November, 2, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "dashboardSceneForViewers", @@ -1071,7 +947,13 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaDashboardsSquad, - Created: time.Date(2023, time.November, 2, 12, 0, 0, 0, time.UTC), + }, + { + Name: "dashboardSceneSolo", + Description: "Enables rendering dashboards using scenes for solo panels", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDashboardsSquad, }, { Name: "dashboardScene", @@ -1079,7 +961,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaDashboardsSquad, - Created: time.Date(2023, time.November, 13, 12, 0, 0, 0, time.UTC), }, { Name: "panelFilterVariable", @@ -1088,7 +969,6 @@ var ( FrontendOnly: true, Owner: grafanaDashboardsSquad, HideFromDocs: true, - Created: time.Date(2023, time.November, 3, 12, 0, 0, 0, time.UTC), }, { Name: "pdfTables", @@ -1096,7 +976,6 @@ var ( Stage: FeatureStagePublicPreview, FrontendOnly: false, Owner: grafanaSharingSquad, - Created: time.Date(2023, time.November, 6, 12, 0, 0, 0, time.UTC), }, { Name: "ssoSettingsApi", @@ -1104,7 +983,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: false, Owner: identityAccessTeam, - Created: time.Date(2023, time.November, 8, 12, 0, 0, 0, time.UTC), }, { Name: "canvasPanelPanZoom", @@ -1112,7 +990,6 @@ var ( Stage: FeatureStagePublicPreview, FrontendOnly: true, Owner: grafanaDatavizSquad, - Created: time.Date(2023, time.December, 27, 12, 0, 0, 0, time.UTC), }, { Name: "logsInfiniteScrolling", @@ -1120,7 +997,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.November, 9, 12, 0, 0, 0, time.UTC), }, { Name: "flameGraphItemCollapsing", @@ -1128,7 +1004,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityTracesAndProfilingSquad, - Created: time.Date(2023, time.November, 9, 12, 0, 0, 0, time.UTC), }, { Name: "alertingDetailsViewV2", @@ -1137,7 +1012,6 @@ var ( FrontendOnly: true, Owner: grafanaAlertingSquad, HideFromDocs: true, - Created: time.Date(2023, time.November, 9, 12, 0, 0, 0, time.UTC), }, { Name: "datatrails", @@ -1146,7 +1020,6 @@ var ( FrontendOnly: true, Owner: grafanaDashboardsSquad, HideFromDocs: true, - Created: time.Date(2023, time.November, 15, 12, 0, 0, 0, time.UTC), }, { Name: "alertingSimplifiedRouting", @@ -1155,7 +1028,6 @@ var ( FrontendOnly: false, Owner: grafanaAlertingSquad, HideFromDocs: true, - Created: time.Date(2023, time.November, 10, 12, 0, 0, 0, time.UTC), }, { Name: "logRowsPopoverMenu", @@ -1164,7 +1036,6 @@ var ( FrontendOnly: true, Expression: "true", Owner: grafanaObservabilityLogsSquad, - Created: time.Date(2023, time.November, 16, 12, 0, 0, 0, time.UTC), }, { Name: "pluginsSkipHostEnvVars", @@ -1172,23 +1043,20 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: false, Owner: grafanaPluginsPlatformSquad, - Created: time.Date(2023, time.November, 15, 12, 0, 0, 0, time.UTC), }, { Name: "tableSharedCrosshair", Description: "Enables shared crosshair in table panel", FrontendOnly: true, Stage: FeatureStageExperimental, - Owner: grafanaBiSquad, - Created: time.Date(2023, time.December, 12, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "regressionTransformation", Description: "Enables regression analysis transformation", Stage: FeatureStagePublicPreview, FrontendOnly: true, - Owner: grafanaBiSquad, - Created: time.Date(2023, time.November, 24, 12, 0, 0, 0, time.UTC), + Owner: grafanaDatavizSquad, }, { Name: "displayAnonymousStats", @@ -1196,7 +1064,6 @@ var ( Stage: FeatureStageGeneralAvailability, FrontendOnly: true, Owner: identityAccessTeam, - Created: time.Date(2023, time.November, 29, 12, 0, 0, 0, time.UTC), AllowSelfServe: false, Expression: "true", // enabled by default }, @@ -1209,7 +1076,6 @@ var ( Expression: "true", Owner: grafanaObservabilityLogsSquad, AllowSelfServe: false, - Created: time.Date(2023, time.December, 18, 12, 0, 0, 0, time.UTC), }, { Name: "kubernetesFeatureToggles", @@ -1218,7 +1084,6 @@ var ( FrontendOnly: true, Owner: grafanaOperatorExperienceSquad, AllowSelfServe: false, - Created: time.Date(2023, time.December, 22, 3, 43, 0, 0, time.UTC), HideFromAdminPage: true, }, { @@ -1228,7 +1093,6 @@ var ( Stage: FeatureStageGeneralAvailability, Owner: grafanaAlertingSquad, RequiresRestart: true, - Created: time.Date(2024, time.January, 3, 12, 0, 0, 0, time.UTC), Expression: "true", // enabled by default }, { @@ -1238,7 +1102,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaPluginsPlatformSquad, RequiresRestart: true, - Created: time.Date(2024, time.January, 9, 12, 0, 0, 0, time.UTC), }, { Name: "cloudRBACRoles", @@ -1247,7 +1110,6 @@ var ( Owner: identityAccessTeam, HideFromDocs: true, RequiresRestart: true, - Created: time.Date(2024, time.January, 10, 12, 0, 0, 0, time.UTC), }, { Name: "alertingQueryOptimization", @@ -1255,7 +1117,6 @@ var ( Stage: FeatureStageGeneralAvailability, Owner: grafanaAlertingSquad, AllowSelfServe: false, - Created: time.Date(2024, time.January, 10, 12, 0, 0, 0, time.UTC), }, { Name: "newFolderPicker", @@ -1263,19 +1124,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaFrontendPlatformSquad, FrontendOnly: true, - Created: time.Date(2024, time.January, 12, 12, 0, 0, 0, time.UTC), - }, - { - Name: "jitterAlertRules", - Description: "Distributes alert rule evaluations more evenly over time, by rule group", - FrontendOnly: false, - Stage: FeatureStageGeneralAvailability, - Owner: grafanaAlertingSquad, - AllowSelfServe: false, - HideFromDocs: false, - HideFromAdminPage: false, - RequiresRestart: true, - Created: time.Date(2024, time.January, 17, 12, 0, 0, 0, time.UTC), }, { Name: "jitterAlertRulesWithinGroups", @@ -1287,14 +1135,12 @@ var ( HideFromDocs: true, HideFromAdminPage: false, RequiresRestart: true, - Created: time.Date(2024, time.January, 17, 12, 0, 0, 0, time.UTC), }, { Name: "onPremToCloudMigrations", Description: "In-development feature that will allow users to easily migrate their on-prem Grafana instances to Grafana Cloud.", Stage: FeatureStageExperimental, Owner: grafanaOperatorExperienceSquad, - Created: time.Date(2024, time.January, 22, 3, 30, 00, 00, time.UTC), }, { Name: "alertingSaveStatePeriodic", @@ -1302,14 +1148,12 @@ var ( Stage: FeatureStagePrivatePreview, FrontendOnly: false, Owner: grafanaAlertingSquad, - Created: time.Date(2024, time.January, 22, 12, 0, 0, 0, time.UTC), }, { Name: "promQLScope", Description: "In-development feature that will allow injection of labels into prometheus queries.", Stage: FeatureStageExperimental, Owner: grafanaObservabilityMetricsSquad, - Created: time.Date(2024, time.January, 29, 0, 0, 0, 0, time.UTC), }, { Name: "nodeGraphDotLayout", @@ -1317,7 +1161,6 @@ var ( Stage: FeatureStageExperimental, FrontendOnly: true, Owner: grafanaObservabilityTracesAndProfilingSquad, - Created: time.Date(2024, time.January, 2, 12, 0, 0, 0, time.UTC), }, { Name: "groupToNestedTableTransformation", @@ -1325,14 +1168,25 @@ var ( Stage: FeatureStagePublicPreview, FrontendOnly: true, Owner: grafanaDatavizSquad, - Created: time.Date(2024, time.February, 5, 12, 0, 0, 0, time.UTC), }, { Name: "newPDFRendering", Description: "New implementation for the dashboard to PDF rendering", Stage: FeatureStageExperimental, Owner: grafanaSharingSquad, - Created: time.Date(2024, time.February, 8, 9, 51, 00, 00, time.UTC), }, } ) + +//go:embed toggles_gen.json +var f embed.FS + +// Get the cached feature list (exposed as a k8s resource) +func GetEmbeddedFeatureList() (featuretoggle.FeatureList, error) { + features := featuretoggle.FeatureList{} + body, err := f.ReadFile("toggles_gen.json") + if err == nil { + err = json.Unmarshal(body, &features) + } + return features, err +} diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 40fdc057987..5d693934c4e 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -1,159 +1,159 @@ -Name,Stage,Owner,Created,requiresDevMode,RequiresRestart,FrontendOnly -disableEnvelopeEncryption,GA,@grafana/grafana-as-code,2022-05-24,false,false,false -live-service-web-worker,experimental,@grafana/grafana-app-platform-squad,2021-11-09,false,false,true -queryOverLive,experimental,@grafana/grafana-app-platform-squad,2022-01-05,false,false,true -panelTitleSearch,preview,@grafana/grafana-app-platform-squad,2022-02-15,false,false,false -publicDashboards,GA,@grafana/sharing-squad,2022-04-07,false,false,false -publicDashboardsEmailSharing,preview,@grafana/sharing-squad,2022-12-21,false,false,false -lokiExperimentalStreaming,experimental,@grafana/observability-logs,2023-06-19,false,false,false -featureHighlights,GA,@grafana/grafana-as-code,2022-02-03,false,false,false -migrationLocking,preview,@grafana/backend-platform,2022-02-15,false,false,false -storage,experimental,@grafana/grafana-app-platform-squad,2022-03-17,false,false,false -correlations,GA,@grafana/explore-squad,2022-09-16,false,false,false -exploreContentOutline,GA,@grafana/explore-squad,2023-11-03,false,false,true -datasourceQueryMultiStatus,experimental,@grafana/plugins-platform-backend,2022-05-03,false,false,false -traceToMetrics,experimental,@grafana/observability-traces-and-profiling,2022-03-07,false,false,true -autoMigrateOldPanels,preview,@grafana/dataviz-squad,2022-06-11,false,false,true -autoMigrateGraphPanel,preview,@grafana/dataviz-squad,2023-12-11,false,false,true -disableAngular,preview,@grafana/dataviz-squad,2023-03-23,false,false,true -canvasPanelNesting,experimental,@grafana/dataviz-squad,2022-05-31,false,false,true -newVizTooltips,preview,@grafana/dataviz-squad,2023-11-03,false,false,true -scenes,experimental,@grafana/dashboards-squad,2022-07-07,false,false,true -disableSecretsCompatibility,experimental,@grafana/hosted-grafana-team,2022-07-13,false,true,false -logRequestsInstrumentedAsUnknown,experimental,@grafana/hosted-grafana-team,2022-06-10,false,false,false -dataConnectionsConsole,GA,@grafana/plugins-platform-backend,2022-06-01,false,false,false -topnav,deprecated,@grafana/grafana-frontend-platform,2022-06-20,false,false,false -returnToPrevious,experimental,@grafana/grafana-frontend-platform,2024-01-09,false,false,true -grpcServer,preview,@grafana/grafana-app-platform-squad,2022-09-27,false,false,false -unifiedStorage,experimental,@grafana/grafana-app-platform-squad,2022-12-01,true,true,false -cloudWatchCrossAccountQuerying,GA,@grafana/aws-datasources,2022-11-28,false,false,false -redshiftAsyncQueryDataSupport,GA,@grafana/aws-datasources,2022-08-27,false,false,false -athenaAsyncQueryDataSupport,GA,@grafana/aws-datasources,2022-08-27,false,false,true -showDashboardValidationWarnings,experimental,@grafana/dashboards-squad,2022-10-14,false,false,false -mysqlAnsiQuotes,experimental,@grafana/backend-platform,2022-10-12,false,false,false -accessControlOnCall,preview,@grafana/identity-access-team,2022-10-19,false,false,false -nestedFolders,preview,@grafana/backend-platform,2022-10-22,false,false,false -nestedFolderPicker,GA,@grafana/grafana-frontend-platform,2023-07-24,false,false,true -alertingBacktesting,experimental,@grafana/alerting-squad,2022-10-20,false,false,false -editPanelCSVDragAndDrop,experimental,@grafana/grafana-bi-squad,2022-12-20,false,false,true -alertingNoNormalState,preview,@grafana/alerting-squad,2023-01-14,false,false,false -logsContextDatasourceUi,GA,@grafana/observability-logs,2023-01-27,false,false,true -lokiQuerySplitting,GA,@grafana/observability-logs,2023-02-09,false,false,true -lokiQuerySplittingConfig,experimental,@grafana/observability-logs,2023-03-20,false,false,true -individualCookiePreferences,experimental,@grafana/backend-platform,2023-02-23,false,false,false -prometheusMetricEncyclopedia,GA,@grafana/observability-metrics,2023-03-07,false,false,true -influxdbBackendMigration,GA,@grafana/observability-metrics,2023-03-15,false,false,true -influxqlStreamingParser,experimental,@grafana/observability-metrics,2023-11-29,false,false,false -influxdbRunQueriesInParallel,privatePreview,@grafana/observability-metrics,2024-01-29,false,false,false -clientTokenRotation,GA,@grafana/identity-access-team,2023-03-23,false,false,false -prometheusDataplane,GA,@grafana/observability-metrics,2023-03-29,false,false,false -lokiMetricDataplane,GA,@grafana/observability-logs,2023-04-13,false,false,false -lokiLogsDataplane,experimental,@grafana/observability-logs,2023-07-13,false,false,false -dataplaneFrontendFallback,GA,@grafana/observability-metrics,2023-04-24,false,false,true -disableSSEDataplane,experimental,@grafana/observability-metrics,2023-04-24,false,false,false -alertStateHistoryLokiSecondary,experimental,@grafana/alerting-squad,2023-03-30,false,false,false -alertStateHistoryLokiPrimary,experimental,@grafana/alerting-squad,2023-03-30,false,false,false -alertStateHistoryLokiOnly,experimental,@grafana/alerting-squad,2023-03-30,false,false,false -unifiedRequestLog,experimental,@grafana/backend-platform,2023-03-31,false,false,false -renderAuthJWT,preview,@grafana/grafana-as-code,2023-04-03,false,false,false -externalServiceAuth,experimental,@grafana/identity-access-team,2023-04-11,true,false,false -refactorVariablesTimeRange,preview,@grafana/dashboards-squad,2023-06-06,false,false,false -enableElasticsearchBackendQuerying,GA,@grafana/observability-logs,2023-04-14,false,false,false -faroDatasourceSelector,preview,@grafana/app-o11y,2023-05-04,false,false,true -enableDatagridEditing,preview,@grafana/grafana-bi-squad,2023-04-24,false,false,true -extraThemes,experimental,@grafana/grafana-frontend-platform,2023-05-10,false,false,true -lokiPredefinedOperations,experimental,@grafana/observability-logs,2023-06-02,false,false,true -pluginsFrontendSandbox,experimental,@grafana/plugins-platform-backend,2023-06-05,false,false,true -dashboardEmbed,experimental,@grafana/grafana-as-code,2023-07-06,false,false,true -frontendSandboxMonitorOnly,experimental,@grafana/plugins-platform-backend,2023-07-05,false,false,true -sqlDatasourceDatabaseSelection,preview,@grafana/grafana-bi-squad,2023-06-06,false,false,true -lokiFormatQuery,experimental,@grafana/observability-logs,2023-06-21,false,false,true -cloudWatchLogsMonacoEditor,GA,@grafana/aws-datasources,2023-06-12,false,false,true -exploreScrollableLogsContainer,experimental,@grafana/observability-logs,2023-06-15,false,false,true -recordedQueriesMulti,GA,@grafana/observability-metrics,2023-06-14,false,false,false -pluginsDynamicAngularDetectionPatterns,experimental,@grafana/plugins-platform-backend,2023-06-26,false,false,false -vizAndWidgetSplit,experimental,@grafana/dashboards-squad,2023-06-27,false,false,true -prometheusIncrementalQueryInstrumentation,experimental,@grafana/observability-metrics,2023-07-05,false,false,true -logsExploreTableVisualisation,experimental,@grafana/observability-logs,2023-07-12,false,false,true -awsDatasourcesTempCredentials,experimental,@grafana/aws-datasources,2023-07-06,false,false,false -transformationsRedesign,GA,@grafana/observability-metrics,2023-07-12,false,false,true -mlExpressions,experimental,@grafana/alerting-squad,2023-07-13,false,false,false -traceQLStreaming,experimental,@grafana/observability-traces-and-profiling,2023-07-26,false,false,true -metricsSummary,experimental,@grafana/observability-traces-and-profiling,2023-08-28,false,false,true -grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,2023-10-06,true,true,false -grafanaAPIServerEnsureKubectlAccess,experimental,@grafana/grafana-app-platform-squad,2023-12-06,true,true,false -featureToggleAdminPage,experimental,@grafana/grafana-operator-experience-squad,2023-07-18,false,true,false -awsAsyncQueryCaching,GA,@grafana/aws-datasources,2023-07-21,false,false,false -splitScopes,deprecated,@grafana/identity-access-team,2023-07-21,false,true,false -permissionsFilterRemoveSubquery,experimental,@grafana/backend-platform,2023-08-02,false,false,false -prometheusConfigOverhaulAuth,GA,@grafana/observability-metrics,2023-07-21,false,false,false -configurableSchedulerTick,experimental,@grafana/alerting-squad,2023-07-26,false,true,false -influxdbSqlSupport,GA,@grafana/observability-metrics,2023-08-02,false,true,false -alertingNoDataErrorExecution,GA,@grafana/alerting-squad,2023-08-15,false,true,false -angularDeprecationUI,experimental,@grafana/plugins-platform-backend,2023-08-29,false,false,true -dashgpt,preview,@grafana/dashboards-squad,2023-11-17,false,false,true -reportingRetries,preview,@grafana/sharing-squad,2023-08-31,false,true,false -sseGroupByDatasource,experimental,@grafana/observability-metrics,2023-09-07,false,false,false -libraryPanelRBAC,experimental,@grafana/dashboards-squad,2023-10-11,false,true,false -lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,2023-09-19,false,false,false -wargamesTesting,experimental,@grafana/hosted-grafana-team,2023-09-13,false,false,false -alertingInsights,GA,@grafana/alerting-squad,2023-09-14,false,false,true -externalCorePlugins,experimental,@grafana/plugins-platform-backend,2023-09-22,false,false,false -pluginsAPIMetrics,experimental,@grafana/plugins-platform-backend,2023-09-21,false,false,true -idForwarding,experimental,@grafana/identity-access-team,2023-09-25,false,false,false -cloudWatchWildCardDimensionValues,GA,@grafana/aws-datasources,2023-09-27,false,false,false -externalServiceAccounts,preview,@grafana/identity-access-team,2023-09-28,false,false,false -panelMonitoring,experimental,@grafana/dataviz-squad,2023-10-08,false,false,true -enableNativeHTTPHistogram,experimental,@grafana/hosted-grafana-team,2023-10-03,false,false,false -formatString,preview,@grafana/grafana-bi-squad,2023-10-13,false,false,true -transformationsVariableSupport,preview,@grafana/grafana-bi-squad,2023-10-04,false,false,true -kubernetesPlaylists,experimental,@grafana/grafana-app-platform-squad,2023-11-08,false,true,false -kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,2023-12-04,false,true,false -kubernetesQueryServiceRewrite,experimental,@grafana/grafana-app-platform-squad,2024-01-28,true,true,false -cloudWatchBatchQueries,preview,@grafana/aws-datasources,2023-10-20,false,false,false -recoveryThreshold,GA,@grafana/alerting-squad,2023-10-10,false,true,false -lokiStructuredMetadata,experimental,@grafana/observability-logs,2023-11-16,false,false,false -teamHttpHeaders,experimental,@grafana/identity-access-team,2023-10-17,false,false,false -awsDatasourcesNewFormStyling,preview,@grafana/aws-datasources,2023-10-12,false,false,true -cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-experience-squad,2023-10-12,false,false,false -panelTitleSearchInV1,experimental,@grafana/backend-platform,2023-10-13,true,false,false -pluginsInstrumentationStatusSource,experimental,@grafana/plugins-platform-backend,2023-10-17,false,false,false -managedPluginsInstall,preview,@grafana/plugins-platform-backend,2023-10-18,false,false,false -prometheusPromQAIL,experimental,@grafana/observability-metrics,2023-10-19,false,false,true -addFieldFromCalculationStatFunctions,preview,@grafana/grafana-bi-squad,2023-11-03,false,false,true -alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,2023-10-30,false,false,false -alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,2023-10-30,false,false,false -alertmanagerRemoteOnly,experimental,@grafana/alerting-squad,2023-10-30,false,false,false -annotationPermissionUpdate,experimental,@grafana/identity-access-team,2023-10-31,false,false,false -extractFieldsNameDeduplication,experimental,@grafana/grafana-bi-squad,2023-11-02,false,false,true -dashboardSceneForViewers,experimental,@grafana/dashboards-squad,2023-11-02,false,false,true -dashboardScene,experimental,@grafana/dashboards-squad,2023-11-13,false,false,true -panelFilterVariable,experimental,@grafana/dashboards-squad,2023-11-03,false,false,true -pdfTables,preview,@grafana/sharing-squad,2023-11-06,false,false,false -ssoSettingsApi,experimental,@grafana/identity-access-team,2023-11-08,false,false,false -canvasPanelPanZoom,preview,@grafana/dataviz-squad,2023-12-27,false,false,true -logsInfiniteScrolling,experimental,@grafana/observability-logs,2023-11-09,false,false,true -flameGraphItemCollapsing,experimental,@grafana/observability-traces-and-profiling,2023-11-09,false,false,true -alertingDetailsViewV2,experimental,@grafana/alerting-squad,2023-11-09,false,false,true -datatrails,experimental,@grafana/dashboards-squad,2023-11-15,false,false,true -alertingSimplifiedRouting,experimental,@grafana/alerting-squad,2023-11-10,false,false,false -logRowsPopoverMenu,GA,@grafana/observability-logs,2023-11-16,false,false,true -pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,2023-11-15,false,false,false -tableSharedCrosshair,experimental,@grafana/grafana-bi-squad,2023-12-12,false,false,true -regressionTransformation,preview,@grafana/grafana-bi-squad,2023-11-24,false,false,true -displayAnonymousStats,GA,@grafana/identity-access-team,2023-11-29,false,false,true -lokiQueryHints,GA,@grafana/observability-logs,2023-12-18,false,false,true -kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,2023-12-22,false,false,true -alertingPreviewUpgrade,GA,@grafana/alerting-squad,2024-01-03,false,true,false -enablePluginsTracingByDefault,experimental,@grafana/plugins-platform-backend,2024-01-09,false,true,false -cloudRBACRoles,experimental,@grafana/identity-access-team,2024-01-10,false,true,false -alertingQueryOptimization,GA,@grafana/alerting-squad,2024-01-10,false,false,false -newFolderPicker,experimental,@grafana/grafana-frontend-platform,2024-01-12,false,false,true -jitterAlertRules,GA,@grafana/alerting-squad,2024-01-17,false,true,false -jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,2024-01-17,false,true,false -onPremToCloudMigrations,experimental,@grafana/grafana-operator-experience-squad,2024-01-22,false,false,false -alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,2024-01-22,false,false,false -promQLScope,experimental,@grafana/observability-metrics,2024-01-29,false,false,false -nodeGraphDotLayout,experimental,@grafana/observability-traces-and-profiling,2024-01-02,false,false,true -groupToNestedTableTransformation,preview,@grafana/dataviz-squad,2024-02-05,false,false,true -newPDFRendering,experimental,@grafana/sharing-squad,2024-02-08,false,false,false +Name,Stage,Owner,requiresDevMode,RequiresRestart,FrontendOnly +disableEnvelopeEncryption,GA,@grafana/grafana-as-code,false,false,false +live-service-web-worker,experimental,@grafana/grafana-app-platform-squad,false,false,true +queryOverLive,experimental,@grafana/grafana-app-platform-squad,false,false,true +panelTitleSearch,preview,@grafana/grafana-app-platform-squad,false,false,false +publicDashboards,GA,@grafana/sharing-squad,false,false,false +publicDashboardsEmailSharing,preview,@grafana/sharing-squad,false,false,false +lokiExperimentalStreaming,experimental,@grafana/observability-logs,false,false,false +featureHighlights,GA,@grafana/grafana-as-code,false,false,false +migrationLocking,preview,@grafana/backend-platform,false,false,false +storage,experimental,@grafana/grafana-app-platform-squad,false,false,false +correlations,GA,@grafana/explore-squad,false,false,false +exploreContentOutline,GA,@grafana/explore-squad,false,false,true +datasourceQueryMultiStatus,experimental,@grafana/plugins-platform-backend,false,false,false +traceToMetrics,experimental,@grafana/observability-traces-and-profiling,false,false,true +autoMigrateOldPanels,preview,@grafana/dataviz-squad,false,false,true +autoMigrateGraphPanel,preview,@grafana/dataviz-squad,false,false,true +disableAngular,preview,@grafana/dataviz-squad,false,false,true +canvasPanelNesting,experimental,@grafana/dataviz-squad,false,false,true +newVizTooltips,preview,@grafana/dataviz-squad,false,false,true +scenes,experimental,@grafana/dashboards-squad,false,false,true +disableSecretsCompatibility,experimental,@grafana/hosted-grafana-team,false,true,false +logRequestsInstrumentedAsUnknown,experimental,@grafana/hosted-grafana-team,false,false,false +dataConnectionsConsole,GA,@grafana/plugins-platform-backend,false,false,false +topnav,deprecated,@grafana/grafana-frontend-platform,false,false,false +returnToPrevious,experimental,@grafana/grafana-frontend-platform,false,false,true +grpcServer,preview,@grafana/grafana-app-platform-squad,false,false,false +unifiedStorage,experimental,@grafana/grafana-app-platform-squad,true,true,false +cloudWatchCrossAccountQuerying,GA,@grafana/aws-datasources,false,false,false +redshiftAsyncQueryDataSupport,GA,@grafana/aws-datasources,false,false,false +athenaAsyncQueryDataSupport,GA,@grafana/aws-datasources,false,false,true +showDashboardValidationWarnings,experimental,@grafana/dashboards-squad,false,false,false +mysqlAnsiQuotes,experimental,@grafana/backend-platform,false,false,false +accessControlOnCall,preview,@grafana/identity-access-team,false,false,false +nestedFolders,preview,@grafana/backend-platform,false,false,false +nestedFolderPicker,GA,@grafana/grafana-frontend-platform,false,false,true +alertingBacktesting,experimental,@grafana/alerting-squad,false,false,false +editPanelCSVDragAndDrop,experimental,@grafana/dataviz-squad,false,false,true +alertingNoNormalState,preview,@grafana/alerting-squad,false,false,false +logsContextDatasourceUi,GA,@grafana/observability-logs,false,false,true +lokiQuerySplitting,GA,@grafana/observability-logs,false,false,true +lokiQuerySplittingConfig,experimental,@grafana/observability-logs,false,false,true +individualCookiePreferences,experimental,@grafana/backend-platform,false,false,false +prometheusMetricEncyclopedia,GA,@grafana/observability-metrics,false,false,true +influxdbBackendMigration,GA,@grafana/observability-metrics,false,false,true +influxqlStreamingParser,experimental,@grafana/observability-metrics,false,false,false +influxdbRunQueriesInParallel,privatePreview,@grafana/observability-metrics,false,false,false +clientTokenRotation,GA,@grafana/identity-access-team,false,false,false +prometheusDataplane,GA,@grafana/observability-metrics,false,false,false +lokiMetricDataplane,GA,@grafana/observability-logs,false,false,false +lokiLogsDataplane,experimental,@grafana/observability-logs,false,false,false +dataplaneFrontendFallback,GA,@grafana/observability-metrics,false,false,true +disableSSEDataplane,experimental,@grafana/observability-metrics,false,false,false +alertStateHistoryLokiSecondary,experimental,@grafana/alerting-squad,false,false,false +alertStateHistoryLokiPrimary,experimental,@grafana/alerting-squad,false,false,false +alertStateHistoryLokiOnly,experimental,@grafana/alerting-squad,false,false,false +unifiedRequestLog,experimental,@grafana/backend-platform,false,false,false +renderAuthJWT,preview,@grafana/grafana-as-code,false,false,false +externalServiceAuth,experimental,@grafana/identity-access-team,true,false,false +refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false +enableElasticsearchBackendQuerying,GA,@grafana/observability-logs,false,false,false +faroDatasourceSelector,preview,@grafana/app-o11y,false,false,true +enableDatagridEditing,preview,@grafana/dataviz-squad,false,false,true +extraThemes,experimental,@grafana/grafana-frontend-platform,false,false,true +lokiPredefinedOperations,experimental,@grafana/observability-logs,false,false,true +pluginsFrontendSandbox,experimental,@grafana/plugins-platform-backend,false,false,true +dashboardEmbed,experimental,@grafana/grafana-as-code,false,false,true +frontendSandboxMonitorOnly,experimental,@grafana/plugins-platform-backend,false,false,true +sqlDatasourceDatabaseSelection,preview,@grafana/dataviz-squad,false,false,true +lokiFormatQuery,experimental,@grafana/observability-logs,false,false,true +cloudWatchLogsMonacoEditor,GA,@grafana/aws-datasources,false,false,true +exploreScrollableLogsContainer,experimental,@grafana/observability-logs,false,false,true +recordedQueriesMulti,GA,@grafana/observability-metrics,false,false,false +pluginsDynamicAngularDetectionPatterns,experimental,@grafana/plugins-platform-backend,false,false,false +vizAndWidgetSplit,experimental,@grafana/dashboards-squad,false,false,true +prometheusIncrementalQueryInstrumentation,experimental,@grafana/observability-metrics,false,false,true +logsExploreTableVisualisation,experimental,@grafana/observability-logs,false,false,true +awsDatasourcesTempCredentials,experimental,@grafana/aws-datasources,false,false,false +transformationsRedesign,GA,@grafana/observability-metrics,false,false,true +mlExpressions,experimental,@grafana/alerting-squad,false,false,false +traceQLStreaming,experimental,@grafana/observability-traces-and-profiling,false,false,true +metricsSummary,experimental,@grafana/observability-traces-and-profiling,false,false,true +grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,true,true,false +grafanaAPIServerEnsureKubectlAccess,experimental,@grafana/grafana-app-platform-squad,true,true,false +featureToggleAdminPage,experimental,@grafana/grafana-operator-experience-squad,false,true,false +awsAsyncQueryCaching,GA,@grafana/aws-datasources,false,false,false +splitScopes,deprecated,@grafana/identity-access-team,false,true,false +permissionsFilterRemoveSubquery,experimental,@grafana/backend-platform,false,false,false +prometheusConfigOverhaulAuth,GA,@grafana/observability-metrics,false,false,false +configurableSchedulerTick,experimental,@grafana/alerting-squad,false,true,false +influxdbSqlSupport,GA,@grafana/observability-metrics,false,true,false +alertingNoDataErrorExecution,GA,@grafana/alerting-squad,false,true,false +angularDeprecationUI,experimental,@grafana/plugins-platform-backend,false,false,true +dashgpt,preview,@grafana/dashboards-squad,false,false,true +reportingRetries,preview,@grafana/sharing-squad,false,true,false +sseGroupByDatasource,experimental,@grafana/observability-metrics,false,false,false +libraryPanelRBAC,experimental,@grafana/dashboards-squad,false,true,false +lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,false,false,false +wargamesTesting,experimental,@grafana/hosted-grafana-team,false,false,false +alertingInsights,GA,@grafana/alerting-squad,false,false,true +externalCorePlugins,experimental,@grafana/plugins-platform-backend,false,false,false +pluginsAPIMetrics,experimental,@grafana/plugins-platform-backend,false,false,true +idForwarding,experimental,@grafana/identity-access-team,false,false,false +cloudWatchWildCardDimensionValues,GA,@grafana/aws-datasources,false,false,false +externalServiceAccounts,preview,@grafana/identity-access-team,false,false,false +panelMonitoring,experimental,@grafana/dataviz-squad,false,false,true +enableNativeHTTPHistogram,experimental,@grafana/hosted-grafana-team,false,false,false +formatString,preview,@grafana/dataviz-squad,false,false,true +transformationsVariableSupport,preview,@grafana/dataviz-squad,false,false,true +kubernetesPlaylists,experimental,@grafana/grafana-app-platform-squad,false,true,false +kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,false +kubernetesQueryServiceRewrite,experimental,@grafana/grafana-app-platform-squad,true,true,false +cloudWatchBatchQueries,preview,@grafana/aws-datasources,false,false,false +recoveryThreshold,GA,@grafana/alerting-squad,false,true,false +lokiStructuredMetadata,experimental,@grafana/observability-logs,false,false,false +teamHttpHeaders,experimental,@grafana/identity-access-team,false,false,false +awsDatasourcesNewFormStyling,preview,@grafana/aws-datasources,false,false,true +cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-experience-squad,false,false,false +panelTitleSearchInV1,experimental,@grafana/backend-platform,true,false,false +pluginsInstrumentationStatusSource,experimental,@grafana/plugins-platform-backend,false,false,false +managedPluginsInstall,preview,@grafana/plugins-platform-backend,false,false,false +prometheusPromQAIL,experimental,@grafana/observability-metrics,false,false,true +addFieldFromCalculationStatFunctions,preview,@grafana/dataviz-squad,false,false,true +alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false +alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false +alertmanagerRemoteOnly,experimental,@grafana/alerting-squad,false,false,false +annotationPermissionUpdate,experimental,@grafana/identity-access-team,false,false,false +extractFieldsNameDeduplication,experimental,@grafana/dataviz-squad,false,false,true +dashboardSceneForViewers,experimental,@grafana/dashboards-squad,false,false,true +dashboardSceneSolo,experimental,@grafana/dashboards-squad,false,false,true +dashboardScene,experimental,@grafana/dashboards-squad,false,false,true +panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true +pdfTables,preview,@grafana/sharing-squad,false,false,false +ssoSettingsApi,experimental,@grafana/identity-access-team,false,false,false +canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true +logsInfiniteScrolling,experimental,@grafana/observability-logs,false,false,true +flameGraphItemCollapsing,experimental,@grafana/observability-traces-and-profiling,false,false,true +alertingDetailsViewV2,experimental,@grafana/alerting-squad,false,false,true +datatrails,experimental,@grafana/dashboards-squad,false,false,true +alertingSimplifiedRouting,experimental,@grafana/alerting-squad,false,false,false +logRowsPopoverMenu,GA,@grafana/observability-logs,false,false,true +pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,false,false,false +tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true +regressionTransformation,preview,@grafana/dataviz-squad,false,false,true +displayAnonymousStats,GA,@grafana/identity-access-team,false,false,true +lokiQueryHints,GA,@grafana/observability-logs,false,false,true +kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true +alertingPreviewUpgrade,GA,@grafana/alerting-squad,false,true,false +enablePluginsTracingByDefault,experimental,@grafana/plugins-platform-backend,false,true,false +cloudRBACRoles,experimental,@grafana/identity-access-team,false,true,false +alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false +newFolderPicker,experimental,@grafana/grafana-frontend-platform,false,false,true +jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false +onPremToCloudMigrations,experimental,@grafana/grafana-operator-experience-squad,false,false,false +alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false +promQLScope,experimental,@grafana/observability-metrics,false,false,false +nodeGraphDotLayout,experimental,@grafana/observability-traces-and-profiling,false,false,true +groupToNestedTableTransformation,preview,@grafana/dataviz-squad,false,false,true +newPDFRendering,experimental,@grafana/sharing-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 5be1a1a1cc7..7456a13c927 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -519,6 +519,10 @@ const ( // Enables dashboard rendering using Scenes for viewer roles FlagDashboardSceneForViewers = "dashboardSceneForViewers" + // FlagDashboardSceneSolo + // Enables rendering dashboards using scenes for solo panels + FlagDashboardSceneSolo = "dashboardSceneSolo" + // FlagDashboardScene // Enables dashboard rendering using scenes for all roles FlagDashboardScene = "dashboardScene" @@ -607,10 +611,6 @@ const ( // Enables the nested folder picker without having nested folders enabled FlagNewFolderPicker = "newFolderPicker" - // FlagJitterAlertRules - // Distributes alert rule evaluations more evenly over time, by rule group - FlagJitterAlertRules = "jitterAlertRules" - // FlagJitterAlertRulesWithinGroups // Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group FlagJitterAlertRulesWithinGroups = "jitterAlertRulesWithinGroups" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json new file mode 100644 index 00000000000..e80566d3192 --- /dev/null +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -0,0 +1,2068 @@ +{ + "kind": "FeatureList", + "apiVersion": "featuretoggle.grafana.app/v0alpha1", + "metadata": {}, + "items": [ + { + "metadata": { + "name": "disableEnvelopeEncryption", + "resourceVersion": "1653393600000", + "creationTimestamp": "2022-05-24T12:00:00Z" + }, + "spec": { + "description": "Disable envelope encryption (emergency only)", + "stage": "GA", + "codeowner": "@grafana/grafana-as-code", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "live-service-web-worker", + "resourceVersion": "1636459200000", + "creationTimestamp": "2021-11-09T12:00:00Z" + }, + "spec": { + "description": "This will use a webworker thread to processes events rather than the main thread", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "queryOverLive", + "resourceVersion": "1641384000000", + "creationTimestamp": "2022-01-05T12:00:00Z" + }, + "spec": { + "description": "Use Grafana Live WebSocket to execute backend queries", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "panelTitleSearch", + "resourceVersion": "1644926400000", + "creationTimestamp": "2022-02-15T12:00:00Z" + }, + "spec": { + "description": "Search for dashboards using panel title", + "stage": "preview", + "codeowner": "@grafana/grafana-app-platform-squad", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "publicDashboards", + "resourceVersion": "1649332800000", + "creationTimestamp": "2022-04-07T12:00:00Z" + }, + "spec": { + "description": "[Deprecated] Public dashboards are now enabled by default; to disable them, use the configuration setting. This feature toggle will be removed in the next major version.", + "stage": "GA", + "codeowner": "@grafana/sharing-squad", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "publicDashboardsEmailSharing", + "resourceVersion": "1671624000000", + "creationTimestamp": "2022-12-21T12:00:00Z" + }, + "spec": { + "description": "Enables public dashboard sharing to be restricted to only allowed emails", + "stage": "preview", + "codeowner": "@grafana/sharing-squad", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "lokiExperimentalStreaming", + "resourceVersion": "1687176000000", + "creationTimestamp": "2023-06-19T12:00:00Z" + }, + "spec": { + "description": "Support new streaming approach for loki (prototype, needs special loki build)", + "stage": "experimental", + "codeowner": "@grafana/observability-logs" + } + }, + { + "metadata": { + "name": "featureHighlights", + "resourceVersion": "1643889600000", + "creationTimestamp": "2022-02-03T12:00:00Z" + }, + "spec": { + "description": "Highlight Grafana Enterprise features", + "stage": "GA", + "codeowner": "@grafana/grafana-as-code", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "migrationLocking", + "resourceVersion": "1644926400000", + "creationTimestamp": "2022-02-15T12:00:00Z" + }, + "spec": { + "description": "Lock database during migrations", + "stage": "preview", + "codeowner": "@grafana/backend-platform" + } + }, + { + "metadata": { + "name": "storage", + "resourceVersion": "1647518400000", + "creationTimestamp": "2022-03-17T12:00:00Z" + }, + "spec": { + "description": "Configurable storage for dashboards, datasources, and resources", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad" + } + }, + { + "metadata": { + "name": "correlations", + "resourceVersion": "1663329600000", + "creationTimestamp": "2022-09-16T12:00:00Z" + }, + "spec": { + "description": "Correlations page", + "stage": "GA", + "codeowner": "@grafana/explore-squad", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "exploreContentOutline", + "resourceVersion": "1699012800000", + "creationTimestamp": "2023-11-03T12:00:00Z" + }, + "spec": { + "description": "Content outline sidebar", + "stage": "GA", + "codeowner": "@grafana/explore-squad", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "datasourceQueryMultiStatus", + "resourceVersion": "1651579200000", + "creationTimestamp": "2022-05-03T12:00:00Z" + }, + "spec": { + "description": "Introduce HTTP 207 Multi Status for api/ds/query", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, + { + "metadata": { + "name": "traceToMetrics", + "resourceVersion": "1646654400000", + "creationTimestamp": "2022-03-07T12:00:00Z" + }, + "spec": { + "description": "Enable trace to metrics links", + "stage": "experimental", + "codeowner": "@grafana/observability-traces-and-profiling", + "frontend": true + } + }, + { + "metadata": { + "name": "autoMigrateOldPanels", + "resourceVersion": "1654948800000", + "creationTimestamp": "2022-06-11T12:00:00Z" + }, + "spec": { + "description": "Migrate old angular panels to supported versions (graph, table-old, worldmap, etc)", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "disableAngular", + "resourceVersion": "1679572800000", + "creationTimestamp": "2023-03-23T12:00:00Z" + }, + "spec": { + "description": "Dynamic flag to disable angular at runtime. The preferred method is to set `angular_support_enabled` to `false` in the [security] settings, which allows you to change the state at runtime.", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true, + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "canvasPanelNesting", + "resourceVersion": "1653998400000", + "creationTimestamp": "2022-05-31T12:00:00Z" + }, + "spec": { + "description": "Allow elements nesting", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true, + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "newVizTooltips", + "resourceVersion": "1699012800000", + "creationTimestamp": "2023-11-03T12:00:00Z" + }, + "spec": { + "description": "New visualizations tooltips UX", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "scenes", + "resourceVersion": "1657195200000", + "creationTimestamp": "2022-07-07T12:00:00Z" + }, + "spec": { + "description": "Experimental framework to build interactive dashboards", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "disableSecretsCompatibility", + "resourceVersion": "1657713600000", + "creationTimestamp": "2022-07-13T12:00:00Z" + }, + "spec": { + "description": "Disable duplicated secret storage in legacy tables", + "stage": "experimental", + "codeowner": "@grafana/hosted-grafana-team", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "logRequestsInstrumentedAsUnknown", + "resourceVersion": "1654862400000", + "creationTimestamp": "2022-06-10T12:00:00Z" + }, + "spec": { + "description": "Logs the path for requests that are instrumented as unknown", + "stage": "experimental", + "codeowner": "@grafana/hosted-grafana-team" + } + }, + { + "metadata": { + "name": "dataConnectionsConsole", + "resourceVersion": "1654084800000", + "creationTimestamp": "2022-06-01T12:00:00Z" + }, + "spec": { + "description": "Enables a new top-level page called Connections. This page is an experiment that provides a better experience when you install and configure data sources and other plugins.", + "stage": "GA", + "codeowner": "@grafana/plugins-platform-backend", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "topnav", + "resourceVersion": "1655726400000", + "creationTimestamp": "2022-06-20T12:00:00Z" + }, + "spec": { + "description": "Enables topnav support in external plugins. The new Grafana navigation cannot be disabled.", + "stage": "deprecated", + "codeowner": "@grafana/grafana-frontend-platform" + } + }, + { + "metadata": { + "name": "returnToPrevious", + "resourceVersion": "1704798000000", + "creationTimestamp": "2024-01-09T11:00:00Z" + }, + "spec": { + "description": "Enables the return to previous context functionality", + "stage": "experimental", + "codeowner": "@grafana/grafana-frontend-platform", + "frontend": true + } + }, + { + "metadata": { + "name": "grpcServer", + "resourceVersion": "1664280000000", + "creationTimestamp": "2022-09-27T12:00:00Z" + }, + "spec": { + "description": "Run the GRPC server", + "stage": "preview", + "codeowner": "@grafana/grafana-app-platform-squad", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "unifiedStorage", + "resourceVersion": "1669896000000", + "creationTimestamp": "2022-12-01T12:00:00Z" + }, + "spec": { + "description": "SQL-based k8s storage", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresDevMode": true, + "requiresRestart": true + } + }, + { + "metadata": { + "name": "cloudWatchCrossAccountQuerying", + "resourceVersion": "1669636800000", + "creationTimestamp": "2022-11-28T12:00:00Z" + }, + "spec": { + "description": "Enables cross-account querying in CloudWatch datasources", + "stage": "GA", + "codeowner": "@grafana/aws-datasources", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "redshiftAsyncQueryDataSupport", + "resourceVersion": "1661601600000", + "creationTimestamp": "2022-08-27T12:00:00Z" + }, + "spec": { + "description": "Enable async query data support for Redshift", + "stage": "GA", + "codeowner": "@grafana/aws-datasources" + } + }, + { + "metadata": { + "name": "athenaAsyncQueryDataSupport", + "resourceVersion": "1661601600000", + "creationTimestamp": "2022-08-27T12:00:00Z" + }, + "spec": { + "description": "Enable async query data support for Athena", + "stage": "GA", + "codeowner": "@grafana/aws-datasources", + "frontend": true + } + }, + { + "metadata": { + "name": "showDashboardValidationWarnings", + "resourceVersion": "1665748800000", + "creationTimestamp": "2022-10-14T12:00:00Z" + }, + "spec": { + "description": "Show warnings when dashboards do not validate against the schema", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad" + } + }, + { + "metadata": { + "name": "mysqlAnsiQuotes", + "resourceVersion": "1665576000000", + "creationTimestamp": "2022-10-12T12:00:00Z" + }, + "spec": { + "description": "Use double quotes to escape keyword in a MySQL query", + "stage": "experimental", + "codeowner": "@grafana/backend-platform" + } + }, + { + "metadata": { + "name": "accessControlOnCall", + "resourceVersion": "1666180800000", + "creationTimestamp": "2022-10-19T12:00:00Z" + }, + "spec": { + "description": "Access control primitives for OnCall", + "stage": "preview", + "codeowner": "@grafana/identity-access-team", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "nestedFolders", + "resourceVersion": "1666440000000", + "creationTimestamp": "2022-10-22T12:00:00Z" + }, + "spec": { + "description": "Enable folder nesting", + "stage": "preview", + "codeowner": "@grafana/backend-platform" + } + }, + { + "metadata": { + "name": "nestedFolderPicker", + "resourceVersion": "1690200000000", + "creationTimestamp": "2023-07-24T12:00:00Z" + }, + "spec": { + "description": "Enables the new folder picker to work with nested folders. Requires the nestedFolders feature toggle", + "stage": "GA", + "codeowner": "@grafana/grafana-frontend-platform", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "alertingBacktesting", + "resourceVersion": "1666267200000", + "creationTimestamp": "2022-10-20T12:00:00Z" + }, + "spec": { + "description": "Rule backtesting API for alerting", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "editPanelCSVDragAndDrop", + "resourceVersion": "1707523537136", + "creationTimestamp": "2022-12-20T12:00:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-02-10 00:05:37.1362 +0000 UTC" + } + }, + "spec": { + "description": "Enables drag and drop for CSV and Excel files", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "alertingNoNormalState", + "resourceVersion": "1673697600000", + "creationTimestamp": "2023-01-14T12:00:00Z" + }, + "spec": { + "description": "Stop maintaining state of alerts that are not firing", + "stage": "preview", + "codeowner": "@grafana/alerting-squad", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "logsContextDatasourceUi", + "resourceVersion": "1674820800000", + "creationTimestamp": "2023-01-27T12:00:00Z" + }, + "spec": { + "description": "Allow datasource to provide custom UI for context view", + "stage": "GA", + "codeowner": "@grafana/observability-logs", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "lokiQuerySplitting", + "resourceVersion": "1675944000000", + "creationTimestamp": "2023-02-09T12:00:00Z" + }, + "spec": { + "description": "Split large interval queries into subqueries with smaller time intervals", + "stage": "GA", + "codeowner": "@grafana/observability-logs", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "lokiQuerySplittingConfig", + "resourceVersion": "1679313600000", + "creationTimestamp": "2023-03-20T12:00:00Z" + }, + "spec": { + "description": "Give users the option to configure split durations for Loki queries", + "stage": "experimental", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "individualCookiePreferences", + "resourceVersion": "1677153600000", + "creationTimestamp": "2023-02-23T12:00:00Z" + }, + "spec": { + "description": "Support overriding cookie preferences per user", + "stage": "experimental", + "codeowner": "@grafana/backend-platform" + } + }, + { + "metadata": { + "name": "prometheusMetricEncyclopedia", + "resourceVersion": "1678190400000", + "creationTimestamp": "2023-03-07T12:00:00Z" + }, + "spec": { + "description": "Adds the metrics explorer component to the Prometheus query builder as an option in metric select", + "stage": "GA", + "codeowner": "@grafana/observability-metrics", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "influxdbBackendMigration", + "resourceVersion": "1678881600000", + "creationTimestamp": "2023-03-15T12:00:00Z" + }, + "spec": { + "description": "Query InfluxDB InfluxQL without the proxy", + "stage": "GA", + "codeowner": "@grafana/observability-metrics", + "frontend": true + } + }, + { + "metadata": { + "name": "influxqlStreamingParser", + "resourceVersion": "1701259200000", + "creationTimestamp": "2023-11-29T12:00:00Z" + }, + "spec": { + "description": "Enable streaming JSON parser for InfluxDB datasource InfluxQL query language", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "influxdbRunQueriesInParallel", + "resourceVersion": "1706529600000", + "creationTimestamp": "2024-01-29T12:00:00Z" + }, + "spec": { + "description": "Enables running InfluxDB Influxql queries in parallel", + "stage": "privatePreview", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "clientTokenRotation", + "resourceVersion": "1679572800000", + "creationTimestamp": "2023-03-23T12:00:00Z" + }, + "spec": { + "description": "Replaces the current in-request token rotation so that the client initiates the rotation", + "stage": "GA", + "codeowner": "@grafana/identity-access-team" + } + }, + { + "metadata": { + "name": "prometheusDataplane", + "resourceVersion": "1680091200000", + "creationTimestamp": "2023-03-29T12:00:00Z" + }, + "spec": { + "description": "Changes responses to from Prometheus to be compliant with the dataplane specification. In particular, when this feature toggle is active, the numeric `Field.Name` is set from 'Value' to the value of the `__name__` label.", + "stage": "GA", + "codeowner": "@grafana/observability-metrics", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "lokiMetricDataplane", + "resourceVersion": "1681387200000", + "creationTimestamp": "2023-04-13T12:00:00Z" + }, + "spec": { + "description": "Changes metric responses from Loki to be compliant with the dataplane specification.", + "stage": "GA", + "codeowner": "@grafana/observability-logs", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "lokiLogsDataplane", + "resourceVersion": "1689249600000", + "creationTimestamp": "2023-07-13T12:00:00Z" + }, + "spec": { + "description": "Changes logs responses from Loki to be compliant with the dataplane specification.", + "stage": "experimental", + "codeowner": "@grafana/observability-logs" + } + }, + { + "metadata": { + "name": "dataplaneFrontendFallback", + "resourceVersion": "1682337600000", + "creationTimestamp": "2023-04-24T12:00:00Z" + }, + "spec": { + "description": "Support dataplane contract field name change for transformations and field name matchers where the name is different", + "stage": "GA", + "codeowner": "@grafana/observability-metrics", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "disableSSEDataplane", + "resourceVersion": "1682337600000", + "creationTimestamp": "2023-04-24T12:00:00Z" + }, + "spec": { + "description": "Disables dataplane specific processing in server side expressions.", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "alertStateHistoryLokiSecondary", + "resourceVersion": "1680177600000", + "creationTimestamp": "2023-03-30T12:00:00Z" + }, + "spec": { + "description": "Enable Grafana to write alert state history to an external Loki instance in addition to Grafana annotations.", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "alertStateHistoryLokiPrimary", + "resourceVersion": "1680177600000", + "creationTimestamp": "2023-03-30T12:00:00Z" + }, + "spec": { + "description": "Enable a remote Loki instance as the primary source for state history reads.", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "alertStateHistoryLokiOnly", + "resourceVersion": "1680177600000", + "creationTimestamp": "2023-03-30T12:00:00Z" + }, + "spec": { + "description": "Disable Grafana alerts from emitting annotations when a remote Loki instance is available.", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "unifiedRequestLog", + "resourceVersion": "1680264000000", + "creationTimestamp": "2023-03-31T12:00:00Z" + }, + "spec": { + "description": "Writes error logs to the request logger", + "stage": "experimental", + "codeowner": "@grafana/backend-platform" + } + }, + { + "metadata": { + "name": "renderAuthJWT", + "resourceVersion": "1680523200000", + "creationTimestamp": "2023-04-03T12:00:00Z" + }, + "spec": { + "description": "Uses JWT-based auth for rendering instead of relying on remote cache", + "stage": "preview", + "codeowner": "@grafana/grafana-as-code", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "externalServiceAuth", + "resourceVersion": "1681214400000", + "creationTimestamp": "2023-04-11T12:00:00Z" + }, + "spec": { + "description": "Starts an OAuth2 authentication provider for external services", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "requiresDevMode": true + } + }, + { + "metadata": { + "name": "refactorVariablesTimeRange", + "resourceVersion": "1686052800000", + "creationTimestamp": "2023-06-06T12:00:00Z" + }, + "spec": { + "description": "Refactor time range variables flow to reduce number of API calls made when query variables are chained", + "stage": "preview", + "codeowner": "@grafana/dashboards-squad", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "enableElasticsearchBackendQuerying", + "resourceVersion": "1681473600000", + "creationTimestamp": "2023-04-14T12:00:00Z" + }, + "spec": { + "description": "Enable the processing of queries and responses in the Elasticsearch data source through backend", + "stage": "GA", + "codeowner": "@grafana/observability-logs", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "faroDatasourceSelector", + "resourceVersion": "1683201600000", + "creationTimestamp": "2023-05-04T12:00:00Z" + }, + "spec": { + "description": "Enable the data source selector within the Frontend Apps section of the Frontend Observability", + "stage": "preview", + "codeowner": "@grafana/app-o11y", + "frontend": true + } + }, + { + "metadata": { + "name": "enableDatagridEditing", + "resourceVersion": "1707523537136", + "creationTimestamp": "2023-04-24T12:00:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-02-10 00:05:37.1362 +0000 UTC" + } + }, + "spec": { + "description": "Enables the edit functionality in the datagrid panel", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "extraThemes", + "resourceVersion": "1683720000000", + "creationTimestamp": "2023-05-10T12:00:00Z" + }, + "spec": { + "description": "Enables extra themes", + "stage": "experimental", + "codeowner": "@grafana/grafana-frontend-platform", + "frontend": true + } + }, + { + "metadata": { + "name": "lokiPredefinedOperations", + "resourceVersion": "1685707200000", + "creationTimestamp": "2023-06-02T12:00:00Z" + }, + "spec": { + "description": "Adds predefined query operations to Loki query editor", + "stage": "experimental", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "pluginsFrontendSandbox", + "resourceVersion": "1685966400000", + "creationTimestamp": "2023-06-05T12:00:00Z" + }, + "spec": { + "description": "Enables the plugins frontend sandbox", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true + } + }, + { + "metadata": { + "name": "dashboardEmbed", + "resourceVersion": "1688644800000", + "creationTimestamp": "2023-07-06T12:00:00Z" + }, + "spec": { + "description": "Allow embedding dashboard for external use in Code editors", + "stage": "experimental", + "codeowner": "@grafana/grafana-as-code", + "frontend": true + } + }, + { + "metadata": { + "name": "frontendSandboxMonitorOnly", + "resourceVersion": "1688558400000", + "creationTimestamp": "2023-07-05T12:00:00Z" + }, + "spec": { + "description": "Enables monitor only in the plugin frontend sandbox (if enabled)", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true + } + }, + { + "metadata": { + "name": "sqlDatasourceDatabaseSelection", + "resourceVersion": "1707523537136", + "creationTimestamp": "2023-06-06T12:00:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-02-10 00:05:37.1362 +0000 UTC" + } + }, + "spec": { + "description": "Enables previous SQL data source dataset dropdown behavior", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true, + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "lokiFormatQuery", + "resourceVersion": "1687348800000", + "creationTimestamp": "2023-06-21T12:00:00Z" + }, + "spec": { + "description": "Enables the ability to format Loki queries", + "stage": "experimental", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "cloudWatchLogsMonacoEditor", + "resourceVersion": "1686571200000", + "creationTimestamp": "2023-06-12T12:00:00Z" + }, + "spec": { + "description": "Enables the Monaco editor for CloudWatch Logs queries", + "stage": "GA", + "codeowner": "@grafana/aws-datasources", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "exploreScrollableLogsContainer", + "resourceVersion": "1686830400000", + "creationTimestamp": "2023-06-15T12:00:00Z" + }, + "spec": { + "description": "Improves the scrolling behavior of logs in Explore", + "stage": "experimental", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "recordedQueriesMulti", + "resourceVersion": "1686744000000", + "creationTimestamp": "2023-06-14T12:00:00Z" + }, + "spec": { + "description": "Enables writing multiple items from a single query within Recorded Queries", + "stage": "GA", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "pluginsDynamicAngularDetectionPatterns", + "resourceVersion": "1687780800000", + "creationTimestamp": "2023-06-26T12:00:00Z" + }, + "spec": { + "description": "Enables fetching Angular detection patterns for plugins from GCOM and fallback to hardcoded ones", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, + { + "metadata": { + "name": "vizAndWidgetSplit", + "resourceVersion": "1687867200000", + "creationTimestamp": "2023-06-27T12:00:00Z" + }, + "spec": { + "description": "Split panels between visualizations and widgets", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "prometheusIncrementalQueryInstrumentation", + "resourceVersion": "1688558400000", + "creationTimestamp": "2023-07-05T12:00:00Z" + }, + "spec": { + "description": "Adds RudderStack events to incremental queries", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics", + "frontend": true + } + }, + { + "metadata": { + "name": "logsExploreTableVisualisation", + "resourceVersion": "1689163200000", + "creationTimestamp": "2023-07-12T12:00:00Z" + }, + "spec": { + "description": "A table visualisation for logs in Explore", + "stage": "experimental", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "awsDatasourcesTempCredentials", + "resourceVersion": "1688644800000", + "creationTimestamp": "2023-07-06T12:00:00Z" + }, + "spec": { + "description": "Support temporary security credentials in AWS plugins for Grafana Cloud customers", + "stage": "experimental", + "codeowner": "@grafana/aws-datasources" + } + }, + { + "metadata": { + "name": "transformationsRedesign", + "resourceVersion": "1689163200000", + "creationTimestamp": "2023-07-12T12:00:00Z" + }, + "spec": { + "description": "Enables the transformations redesign", + "stage": "GA", + "codeowner": "@grafana/observability-metrics", + "frontend": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "mlExpressions", + "resourceVersion": "1689249600000", + "creationTimestamp": "2023-07-13T12:00:00Z" + }, + "spec": { + "description": "Enable support for Machine Learning in server-side expressions", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "traceQLStreaming", + "resourceVersion": "1690372800000", + "creationTimestamp": "2023-07-26T12:00:00Z" + }, + "spec": { + "description": "Enables response streaming of TraceQL queries of the Tempo data source", + "stage": "experimental", + "codeowner": "@grafana/observability-traces-and-profiling", + "frontend": true + } + }, + { + "metadata": { + "name": "metricsSummary", + "resourceVersion": "1693224000000", + "creationTimestamp": "2023-08-28T12:00:00Z" + }, + "spec": { + "description": "Enables metrics summary queries in the Tempo data source", + "stage": "experimental", + "codeowner": "@grafana/observability-traces-and-profiling", + "frontend": true + } + }, + { + "metadata": { + "name": "grafanaAPIServerWithExperimentalAPIs", + "resourceVersion": "1696593600000", + "creationTimestamp": "2023-10-06T12:00:00Z" + }, + "spec": { + "description": "Register experimental APIs with the k8s API server", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresDevMode": true, + "requiresRestart": true + } + }, + { + "metadata": { + "name": "grafanaAPIServerEnsureKubectlAccess", + "resourceVersion": "1701864000000", + "creationTimestamp": "2023-12-06T12:00:00Z" + }, + "spec": { + "description": "Start an additional https handler and write kubectl options", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresDevMode": true, + "requiresRestart": true + } + }, + { + "metadata": { + "name": "featureToggleAdminPage", + "resourceVersion": "1689681600000", + "creationTimestamp": "2023-07-18T12:00:00Z" + }, + "spec": { + "description": "Enable admin page for managing feature toggles from the Grafana front-end", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "awsAsyncQueryCaching", + "resourceVersion": "1689940800000", + "creationTimestamp": "2023-07-21T12:00:00Z" + }, + "spec": { + "description": "Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled", + "stage": "GA", + "codeowner": "@grafana/aws-datasources" + } + }, + { + "metadata": { + "name": "splitScopes", + "resourceVersion": "1689940800000", + "creationTimestamp": "2023-07-21T12:00:00Z" + }, + "spec": { + "description": "Support faster dashboard and folder search by splitting permission scopes into parts", + "stage": "deprecated", + "codeowner": "@grafana/identity-access-team", + "requiresRestart": true, + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "permissionsFilterRemoveSubquery", + "resourceVersion": "1690977600000", + "creationTimestamp": "2023-08-02T12:00:00Z" + }, + "spec": { + "description": "Alternative permission filter implementation that does not use subqueries for fetching the dashboard folder", + "stage": "experimental", + "codeowner": "@grafana/backend-platform" + } + }, + { + "metadata": { + "name": "prometheusConfigOverhaulAuth", + "resourceVersion": "1689940800000", + "creationTimestamp": "2023-07-21T12:00:00Z" + }, + "spec": { + "description": "Update the Prometheus configuration page with the new auth component", + "stage": "GA", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "configurableSchedulerTick", + "resourceVersion": "1690372800000", + "creationTimestamp": "2023-07-26T12:00:00Z" + }, + "spec": { + "description": "Enable changing the scheduler base interval via configuration option unified_alerting.scheduler_tick_interval", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "influxdbSqlSupport", + "resourceVersion": "1690977600000", + "creationTimestamp": "2023-08-02T12:00:00Z" + }, + "spec": { + "description": "Enable InfluxDB SQL query language support with new querying UI", + "stage": "GA", + "codeowner": "@grafana/observability-metrics", + "requiresRestart": true, + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "alertingNoDataErrorExecution", + "resourceVersion": "1692100800000", + "creationTimestamp": "2023-08-15T12:00:00Z" + }, + "spec": { + "description": "Changes how Alerting state manager handles execution of NoData/Error", + "stage": "GA", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "angularDeprecationUI", + "resourceVersion": "1693310400000", + "creationTimestamp": "2023-08-29T12:00:00Z" + }, + "spec": { + "description": "Display new Angular deprecation-related UI features", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true + } + }, + { + "metadata": { + "name": "dashgpt", + "resourceVersion": "1700222400000", + "creationTimestamp": "2023-11-17T12:00:00Z" + }, + "spec": { + "description": "Enable AI powered features in dashboards", + "stage": "preview", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "reportingRetries", + "resourceVersion": "1693483200000", + "creationTimestamp": "2023-08-31T12:00:00Z" + }, + "spec": { + "description": "Enables rendering retries for the reporting feature", + "stage": "preview", + "codeowner": "@grafana/sharing-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "sseGroupByDatasource", + "resourceVersion": "1694088000000", + "creationTimestamp": "2023-09-07T12:00:00Z" + }, + "spec": { + "description": "Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch.", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "libraryPanelRBAC", + "resourceVersion": "1697025600000", + "creationTimestamp": "2023-10-11T12:00:00Z" + }, + "spec": { + "description": "Enables RBAC support for library panels", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "lokiRunQueriesInParallel", + "resourceVersion": "1695124800000", + "creationTimestamp": "2023-09-19T12:00:00Z" + }, + "spec": { + "description": "Enables running Loki queries in parallel", + "stage": "privatePreview", + "codeowner": "@grafana/observability-logs" + } + }, + { + "metadata": { + "name": "wargamesTesting", + "resourceVersion": "1694606400000", + "creationTimestamp": "2023-09-13T12:00:00Z" + }, + "spec": { + "description": "Placeholder feature flag for internal testing", + "stage": "experimental", + "codeowner": "@grafana/hosted-grafana-team" + } + }, + { + "metadata": { + "name": "alertingInsights", + "resourceVersion": "1694692800000", + "creationTimestamp": "2023-09-14T12:00:00Z" + }, + "spec": { + "description": "Show the new alerting insights landing page", + "stage": "GA", + "codeowner": "@grafana/alerting-squad", + "frontend": true, + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "externalCorePlugins", + "resourceVersion": "1695384000000", + "creationTimestamp": "2023-09-22T12:00:00Z" + }, + "spec": { + "description": "Allow core plugins to be loaded as external", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, + { + "metadata": { + "name": "pluginsAPIMetrics", + "resourceVersion": "1695297600000", + "creationTimestamp": "2023-09-21T12:00:00Z" + }, + "spec": { + "description": "Sends metrics of public grafana packages usage by plugins", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true + } + }, + { + "metadata": { + "name": "idForwarding", + "resourceVersion": "1695643200000", + "creationTimestamp": "2023-09-25T12:00:00Z" + }, + "spec": { + "description": "Generate signed id token for identity that can be forwarded to plugins and external services", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team" + } + }, + { + "metadata": { + "name": "cloudWatchWildCardDimensionValues", + "resourceVersion": "1695816000000", + "creationTimestamp": "2023-09-27T12:00:00Z" + }, + "spec": { + "description": "Fetches dimension values from CloudWatch to correctly label wildcard dimensions", + "stage": "GA", + "codeowner": "@grafana/aws-datasources", + "allowSelfServe": true + } + }, + { + "metadata": { + "name": "externalServiceAccounts", + "resourceVersion": "1695902400000", + "creationTimestamp": "2023-09-28T12:00:00Z" + }, + "spec": { + "description": "Automatic service account and token setup for plugins", + "stage": "preview", + "codeowner": "@grafana/identity-access-team", + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "panelMonitoring", + "resourceVersion": "1696766400000", + "creationTimestamp": "2023-10-08T12:00:00Z" + }, + "spec": { + "description": "Enables panel monitoring through logs and measurements", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "enableNativeHTTPHistogram", + "resourceVersion": "1696334400000", + "creationTimestamp": "2023-10-03T12:00:00Z" + }, + "spec": { + "description": "Enables native HTTP Histograms", + "stage": "experimental", + "codeowner": "@grafana/hosted-grafana-team" + } + }, + { + "metadata": { + "name": "formatString", + "resourceVersion": "1707523537136", + "creationTimestamp": "2023-10-13T12:00:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-02-10 00:05:37.1362 +0000 UTC" + } + }, + "spec": { + "description": "Enable format string transformer", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "transformationsVariableSupport", + "resourceVersion": "1707523537136", + "creationTimestamp": "2023-10-04T12:00:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-02-10 00:05:37.1362 +0000 UTC" + } + }, + "spec": { + "description": "Allows using variables in transformations", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "kubernetesPlaylists", + "resourceVersion": "1699444800000", + "creationTimestamp": "2023-11-08T12:00:00Z" + }, + "spec": { + "description": "Use the kubernetes API in the frontend for playlists, and route /api/playlist requests to k8s", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "kubernetesSnapshots", + "resourceVersion": "1707374669879", + "creationTimestamp": "2023-12-04T12:00:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-02-08 06:44:29.879787 +0000 UTC" + } + }, + "spec": { + "description": "Routes snapshot requests from /api to the /apis endpoint", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "kubernetesQueryServiceRewrite", + "resourceVersion": "1706443200000", + "creationTimestamp": "2024-01-28T12:00:00Z" + }, + "spec": { + "description": "Rewrite requests targeting /ds/query to the query service", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresDevMode": true, + "requiresRestart": true + } + }, + { + "metadata": { + "name": "cloudWatchBatchQueries", + "resourceVersion": "1697803200000", + "creationTimestamp": "2023-10-20T12:00:00Z" + }, + "spec": { + "description": "Runs CloudWatch metrics queries as separate batches", + "stage": "preview", + "codeowner": "@grafana/aws-datasources" + } + }, + { + "metadata": { + "name": "recoveryThreshold", + "resourceVersion": "1696939200000", + "creationTimestamp": "2023-10-10T12:00:00Z" + }, + "spec": { + "description": "Enables feature recovery threshold (aka hysteresis) for threshold server-side expression", + "stage": "GA", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "lokiStructuredMetadata", + "resourceVersion": "1700136000000", + "creationTimestamp": "2023-11-16T12:00:00Z" + }, + "spec": { + "description": "Enables the loki data source to request structured metadata from the Loki server", + "stage": "experimental", + "codeowner": "@grafana/observability-logs" + } + }, + { + "metadata": { + "name": "teamHttpHeaders", + "resourceVersion": "1697544000000", + "creationTimestamp": "2023-10-17T12:00:00Z" + }, + "spec": { + "description": "Enables datasources to apply team headers to the client requests", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team" + } + }, + { + "metadata": { + "name": "awsDatasourcesNewFormStyling", + "resourceVersion": "1697112000000", + "creationTimestamp": "2023-10-12T12:00:00Z" + }, + "spec": { + "description": "Applies new form styling for configuration and query editors in AWS plugins", + "stage": "preview", + "codeowner": "@grafana/aws-datasources", + "frontend": true + } + }, + { + "metadata": { + "name": "cachingOptimizeSerializationMemoryUsage", + "resourceVersion": "1697112000000", + "creationTimestamp": "2023-10-12T12:00:00Z" + }, + "spec": { + "description": "If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses.", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad" + } + }, + { + "metadata": { + "name": "panelTitleSearchInV1", + "resourceVersion": "1697198400000", + "creationTimestamp": "2023-10-13T12:00:00Z" + }, + "spec": { + "description": "Enable searching for dashboards using panel title in search v1", + "stage": "experimental", + "codeowner": "@grafana/backend-platform", + "requiresDevMode": true + } + }, + { + "metadata": { + "name": "pluginsInstrumentationStatusSource", + "resourceVersion": "1697544000000", + "creationTimestamp": "2023-10-17T12:00:00Z" + }, + "spec": { + "description": "Include a status source label for plugin request metrics and logs", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, + { + "metadata": { + "name": "managedPluginsInstall", + "resourceVersion": "1697630400000", + "creationTimestamp": "2023-10-18T12:00:00Z" + }, + "spec": { + "description": "Install managed plugins directly from plugins catalog", + "stage": "preview", + "codeowner": "@grafana/plugins-platform-backend" + } + }, + { + "metadata": { + "name": "prometheusPromQAIL", + "resourceVersion": "1697716800000", + "creationTimestamp": "2023-10-19T12:00:00Z" + }, + "spec": { + "description": "Prometheus and AI/ML to assist users in creating a query", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics", + "frontend": true + } + }, + { + "metadata": { + "name": "addFieldFromCalculationStatFunctions", + "resourceVersion": "1707523537136", + "creationTimestamp": "2023-11-03T12:00:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-02-10 00:05:37.1362 +0000 UTC" + } + }, + "spec": { + "description": "Add cumulative and window functions to the add field from calculation transformation", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "alertmanagerRemoteSecondary", + "resourceVersion": "1698667200000", + "creationTimestamp": "2023-10-30T12:00:00Z" + }, + "spec": { + "description": "Enable Grafana to sync configuration and state with a remote Alertmanager.", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "alertmanagerRemotePrimary", + "resourceVersion": "1698667200000", + "creationTimestamp": "2023-10-30T12:00:00Z" + }, + "spec": { + "description": "Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "alertmanagerRemoteOnly", + "resourceVersion": "1698667200000", + "creationTimestamp": "2023-10-30T12:00:00Z" + }, + "spec": { + "description": "Disable the internal Alertmanager and only use the external one defined.", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "annotationPermissionUpdate", + "resourceVersion": "1698753600000", + "creationTimestamp": "2023-10-31T12:00:00Z" + }, + "spec": { + "description": "Separate annotation permissions from dashboard permissions to allow for more granular control.", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team" + } + }, + { + "metadata": { + "name": "extractFieldsNameDeduplication", + "resourceVersion": "1707523537136", + "creationTimestamp": "2023-11-02T12:00:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-02-10 00:05:37.1362 +0000 UTC" + } + }, + "spec": { + "description": "Make sure extracted field names are unique in the dataframe", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "dashboardSceneForViewers", + "resourceVersion": "1698926400000", + "creationTimestamp": "2023-11-02T12:00:00Z" + }, + "spec": { + "description": "Enables dashboard rendering using Scenes for viewer roles", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "dashboardScene", + "resourceVersion": "1699876800000", + "creationTimestamp": "2023-11-13T12:00:00Z" + }, + "spec": { + "description": "Enables dashboard rendering using scenes for all roles", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "panelFilterVariable", + "resourceVersion": "1699012800000", + "creationTimestamp": "2023-11-03T12:00:00Z" + }, + "spec": { + "description": "Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "pdfTables", + "resourceVersion": "1699272000000", + "creationTimestamp": "2023-11-06T12:00:00Z" + }, + "spec": { + "description": "Enables generating table data as PDF in reporting", + "stage": "preview", + "codeowner": "@grafana/sharing-squad" + } + }, + { + "metadata": { + "name": "ssoSettingsApi", + "resourceVersion": "1699444800000", + "creationTimestamp": "2023-11-08T12:00:00Z" + }, + "spec": { + "description": "Enables the SSO settings API", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team" + } + }, + { + "metadata": { + "name": "canvasPanelPanZoom", + "resourceVersion": "1703678400000", + "creationTimestamp": "2023-12-27T12:00:00Z" + }, + "spec": { + "description": "Allow pan and zoom in canvas panel", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "logsInfiniteScrolling", + "resourceVersion": "1699531200000", + "creationTimestamp": "2023-11-09T12:00:00Z" + }, + "spec": { + "description": "Enables infinite scrolling for the Logs panel in Explore and Dashboards", + "stage": "experimental", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "flameGraphItemCollapsing", + "resourceVersion": "1699531200000", + "creationTimestamp": "2023-11-09T12:00:00Z" + }, + "spec": { + "description": "Allow collapsing of flame graph items", + "stage": "experimental", + "codeowner": "@grafana/observability-traces-and-profiling", + "frontend": true + } + }, + { + "metadata": { + "name": "alertingDetailsViewV2", + "resourceVersion": "1699531200000", + "creationTimestamp": "2023-11-09T12:00:00Z" + }, + "spec": { + "description": "Enables the preview of the new alert details view", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "frontend": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "datatrails", + "resourceVersion": "1700049600000", + "creationTimestamp": "2023-11-15T12:00:00Z" + }, + "spec": { + "description": "Enables the new core app datatrails", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "alertingSimplifiedRouting", + "resourceVersion": "1699617600000", + "creationTimestamp": "2023-11-10T12:00:00Z" + }, + "spec": { + "description": "Enables the simplified routing for alerting", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "logRowsPopoverMenu", + "resourceVersion": "1700136000000", + "creationTimestamp": "2023-11-16T12:00:00Z" + }, + "spec": { + "description": "Enable filtering menu displayed when text of a log line is selected", + "stage": "GA", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "pluginsSkipHostEnvVars", + "resourceVersion": "1700049600000", + "creationTimestamp": "2023-11-15T12:00:00Z" + }, + "spec": { + "description": "Disables passing host environment variable to plugin processes", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, + { + "metadata": { + "name": "tableSharedCrosshair", + "resourceVersion": "1707523537136", + "creationTimestamp": "2023-12-12T12:00:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-02-10 00:05:37.1362 +0000 UTC" + } + }, + "spec": { + "description": "Enables shared crosshair in table panel", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "regressionTransformation", + "resourceVersion": "1707523537136", + "creationTimestamp": "2023-11-24T12:00:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-02-10 00:05:37.1362 +0000 UTC" + } + }, + "spec": { + "description": "Enables regression analysis transformation", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "displayAnonymousStats", + "resourceVersion": "1701259200000", + "creationTimestamp": "2023-11-29T12:00:00Z" + }, + "spec": { + "description": "Enables anonymous stats to be shown in the UI for Grafana", + "stage": "GA", + "codeowner": "@grafana/identity-access-team", + "frontend": true + } + }, + { + "metadata": { + "name": "lokiQueryHints", + "resourceVersion": "1702900800000", + "creationTimestamp": "2023-12-18T12:00:00Z" + }, + "spec": { + "description": "Enables query hints for Loki", + "stage": "GA", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, + { + "metadata": { + "name": "kubernetesFeatureToggles", + "resourceVersion": "1703216580000", + "creationTimestamp": "2023-12-22T03:43:00Z" + }, + "spec": { + "description": "Use the kubernetes API for feature toggle management in the frontend", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad", + "frontend": true, + "hideFromAdminPage": true + } + }, + { + "metadata": { + "name": "alertingPreviewUpgrade", + "resourceVersion": "1707425412785", + "creationTimestamp": "2024-01-03T12:00:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-02-08 20:50:12.785364 +0000 UTC" + } + }, + "spec": { + "description": "Show Unified Alerting preview and upgrade page in legacy alerting", + "stage": "GA", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "enablePluginsTracingByDefault", + "resourceVersion": "1704801600000", + "creationTimestamp": "2024-01-09T12:00:00Z" + }, + "spec": { + "description": "Enable plugin tracing for all external plugins", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "requiresRestart": true + } + }, + { + "metadata": { + "name": "cloudRBACRoles", + "resourceVersion": "1704888000000", + "creationTimestamp": "2024-01-10T12:00:00Z" + }, + "spec": { + "description": "Enabled grafana cloud specific RBAC roles", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "requiresRestart": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "alertingQueryOptimization", + "resourceVersion": "1704888000000", + "creationTimestamp": "2024-01-10T12:00:00Z" + }, + "spec": { + "description": "Optimizes eligible queries in order to reduce load on datasources", + "stage": "GA", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "newFolderPicker", + "resourceVersion": "1705060800000", + "creationTimestamp": "2024-01-12T12:00:00Z" + }, + "spec": { + "description": "Enables the nested folder picker without having nested folders enabled", + "stage": "experimental", + "codeowner": "@grafana/grafana-frontend-platform", + "frontend": true + } + }, + { + "metadata": { + "name": "jitterAlertRulesWithinGroups", + "resourceVersion": "1705492800000", + "creationTimestamp": "2024-01-17T12:00:00Z" + }, + "spec": { + "description": "Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group", + "stage": "preview", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true, + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "onPremToCloudMigrations", + "resourceVersion": "1705894200000", + "creationTimestamp": "2024-01-22T03:30:00Z" + }, + "spec": { + "description": "In-development feature that will allow users to easily migrate their on-prem Grafana instances to Grafana Cloud.", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad" + } + }, + { + "metadata": { + "name": "alertingSaveStatePeriodic", + "resourceVersion": "1705924800000", + "creationTimestamp": "2024-01-22T12:00:00Z" + }, + "spec": { + "description": "Writes the state periodically to the database, asynchronous to rule evaluation", + "stage": "privatePreview", + "codeowner": "@grafana/alerting-squad" + } + }, + { + "metadata": { + "name": "promQLScope", + "resourceVersion": "1706486400000", + "creationTimestamp": "2024-01-29T00:00:00Z" + }, + "spec": { + "description": "In-development feature that will allow injection of labels into prometheus queries.", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "nodeGraphDotLayout", + "resourceVersion": "1704196800000", + "creationTimestamp": "2024-01-02T12:00:00Z" + }, + "spec": { + "description": "Changed the layout algorithm for the node graph", + "stage": "experimental", + "codeowner": "@grafana/observability-traces-and-profiling", + "frontend": true + } + }, + { + "metadata": { + "name": "groupToNestedTableTransformation", + "resourceVersion": "1707134400000", + "creationTimestamp": "2024-02-05T12:00:00Z" + }, + "spec": { + "description": "Enables the group to nested table transformation", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "newPDFRendering", + "resourceVersion": "1707425412785", + "creationTimestamp": "2024-02-08T20:50:12Z" + }, + "spec": { + "description": "New implementation for the dashboard to PDF rendering", + "stage": "experimental", + "codeowner": "@grafana/sharing-squad" + } + }, + { + "metadata": { + "name": "autoMigrateGraphPanel", + "resourceVersion": "1707433170195", + "creationTimestamp": "2024-02-08T22:59:30Z" + }, + "spec": { + "description": "Migrate old graph panel to supported time series panel - broken out from autoMigrateOldPanels to enable granular tracking", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, + { + "metadata": { + "name": "dashboardSceneSolo", + "resourceVersion": "1707577534071", + "creationTimestamp": "2024-02-10T15:05:34Z" + }, + "spec": { + "description": "Enables rendering dashboards using scenes for solo panels", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + } + ] +} \ No newline at end of file diff --git a/pkg/services/featuremgmt/toggles_gen_test.go b/pkg/services/featuremgmt/toggles_gen_test.go index 9dd117f976f..946a52e4079 100644 --- a/pkg/services/featuremgmt/toggles_gen_test.go +++ b/pkg/services/featuremgmt/toggles_gen_test.go @@ -3,6 +3,7 @@ package featuremgmt import ( "bytes" "encoding/csv" + "encoding/json" "fmt" "html/template" "log" @@ -16,7 +17,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/olekukonko/tablewriter" "github.com/stretchr/testify/require" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + featuretoggleapi "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/utils" "github.com/grafana/grafana/pkg/services/featuremgmt/strcase" ) @@ -26,6 +30,9 @@ func TestFeatureToggleFiles(t *testing.T) { } t.Run("check registry constraints", func(t *testing.T) { + invalidNames := make([]string, 0) + + // Check that all flags set in code are valid for _, flag := range standardFeatureFlags { if flag.Expression == "true" && !(flag.Stage == FeatureStageGeneralAvailability || flag.Stage == FeatureStageDeprecated) { t.Errorf("only FeatureStageGeneralAvailability or FeatureStageDeprecated features can be enabled by default. See: %s", flag.Name) @@ -45,19 +52,105 @@ func TestFeatureToggleFiles(t *testing.T) { if flag.AllowSelfServe && flag.Stage != FeatureStageGeneralAvailability { t.Errorf("only allow self-serving GA toggles") } - if flag.Created.Year() < 2021 { - t.Errorf("flag requires a reasonable created date. See: %s (%s)", - flag.Name, flag.Created.Format(time.DateOnly)) - } - } - }) - - t.Run("all new features should have an owner", func(t *testing.T) { - for _, flag := range standardFeatureFlags { if flag.Owner == "" { t.Errorf("feature %s does not have an owner. please fill the FeatureFlag.Owner property", flag.Name) } + // Check camel case names + if flag.Name != strcase.ToLowerCamel(flag.Name) && !legacyNames[flag.Name] { + invalidNames = append(invalidNames, flag.Name) + } } + + // Make sure the names are valid + require.Empty(t, invalidNames, "%s feature names should be camel cased", invalidNames) + // acronyms can be configured as needed via `ConfigureAcronym` function from `./strcase/camel.go` + + // Now that we know they are valid, update the json database + t.Run("update k8s resource list", func(t *testing.T) { + created := v1.NewTime(time.Now().UTC()) + resourceVersion := fmt.Sprintf("%d", created.UnixMilli()) + + featuresFile := "toggles_gen.json" + current := featuretoggleapi.FeatureList{ + TypeMeta: v1.TypeMeta{ + Kind: "FeatureList", + APIVersion: featuretoggleapi.APIVERSION, + }, + } + existing := featuretoggleapi.FeatureList{} + body, err := os.ReadFile(featuresFile) + if err == nil { + _ = json.Unmarshal(body, &existing) + current.ListMeta = existing.ListMeta + } + + lookup := map[string]featuretoggleapi.FeatureSpec{} + for _, flag := range standardFeatureFlags { + lookup[flag.Name] = featuretoggleapi.FeatureSpec{ + Description: flag.Description, + Stage: flag.Stage.String(), + Owner: string(flag.Owner), + RequiresDevMode: flag.RequiresDevMode, + FrontendOnly: flag.FrontendOnly, + RequiresRestart: flag.RequiresRestart, + AllowSelfServe: flag.AllowSelfServe, + HideFromAdminPage: flag.HideFromAdminPage, + HideFromDocs: flag.HideFromDocs, + // EnabledVersion: ???, + } + + // Replace them all + // current.Items = append(current.Items, featuretoggleapi.Feature{ + // ObjectMeta: v1.ObjectMeta{ + // Name: flag.Name, + // CreationTimestamp: v1.NewTime(flag.Created), + // ResourceVersion: fmt.Sprintf("%d", flag.Created.UnixMilli()), + // }, + // Spec: lookup[flag.Name], + // }) + // current.ListMeta.ResourceVersion = resourceVersion + } + + // Check for changes in any existing values + for _, item := range existing.Items { + v, ok := lookup[item.Name] + if ok { + delete(lookup, item.Name) + a, e1 := json.Marshal(v) + b, e2 := json.Marshal(item.Spec) + if e1 != nil || e2 != nil || !bytes.Equal(a, b) { + item.ResourceVersion = resourceVersion + if item.Annotations == nil { + item.Annotations = make(map[string]string) + } + item.Annotations[utils.AnnoKeyUpdatedTimestamp] = created.String() + item.Spec = v // the current value + } + } else { + item.DeletionTimestamp = &created + fmt.Printf("mark feature as deleted") + } + current.Items = append(current.Items, item) + } + + // New flags not in the existing list + for k, v := range lookup { + current.Items = append(current.Items, featuretoggleapi.Feature{ + ObjectMeta: v1.ObjectMeta{ + Name: k, + CreationTimestamp: created, + ResourceVersion: fmt.Sprintf("%d", created.UnixMilli()), + }, + Spec: v, + }) + } + + out, err := json.MarshalIndent(current, "", " ") + require.NoError(t, err) + + err = os.WriteFile(featuresFile, out, 0644) + require.NoError(t, err, "error writing file") + }) }) t.Run("verify files", func(t *testing.T) { @@ -85,22 +178,6 @@ func TestFeatureToggleFiles(t *testing.T) { generateCSV(), ) }) - - t.Run("check feature naming convention", func(t *testing.T) { - invalidNames := make([]string, 0) - for _, f := range standardFeatureFlags { - if legacyNames[f.Name] { - continue - } - - if f.Name != strcase.ToLowerCamel(f.Name) { - invalidNames = append(invalidNames, f.Name) - } - } - - require.Empty(t, invalidNames, "%s feature names should be camel cased", invalidNames) - // acronyms can be configured as needed via `ConfigureAcronym` function from `./strcase/camel.go` - }) } func verifyAndGenerateFile(t *testing.T, fpath string, gen string) { @@ -214,9 +291,8 @@ func generateCSV() string { w := csv.NewWriter(&buf) if err := w.Write([]string{ "Name", - "Stage", //flag.Stage.String(), - "Owner", //string(flag.Owner), - "Created", + "Stage", //flag.Stage.String(), + "Owner", //string(flag.Owner), "requiresDevMode", //strconv.FormatBool(flag.RequiresDevMode), "RequiresRestart", //strconv.FormatBool(flag.RequiresRestart), "FrontendOnly", //strconv.FormatBool(flag.FrontendOnly), @@ -224,19 +300,11 @@ func generateCSV() string { log.Fatalln("error writing record to csv:", err) } - dateFormatter := func(t time.Time) string { - if t.Year() < 2020 { // fake year - return "" - } - return t.Format(time.DateOnly) - } - for _, flag := range standardFeatureFlags { if err := w.Write([]string{ flag.Name, flag.Stage.String(), string(flag.Owner), - dateFormatter(flag.Created), strconv.FormatBool(flag.RequiresDevMode), strconv.FormatBool(flag.RequiresRestart), strconv.FormatBool(flag.FrontendOnly), diff --git a/pkg/services/folder/folderimpl/dashboard_folder_store_test.go b/pkg/services/folder/folderimpl/dashboard_folder_store_test.go index 4f69dfbfa1d..12d8c65c1b4 100644 --- a/pkg/services/folder/folderimpl/dashboard_folder_store_test.go +++ b/pkg/services/folder/folderimpl/dashboard_folder_store_test.go @@ -15,8 +15,14 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +// run tests with cleanup +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationDashboardFolderStore(t *testing.T) { var sqlStore *sqlstore.SQLStore var cfg *setting.Cfg diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index a2078ce200d..861c68a0119 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -44,12 +44,17 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/web" ) const userInDbName = "user_in_db" const userInDbAvatar = "/avatar/402d08de060496d6b6874495fe20f5ad" +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestDeleteLibraryPanelsInFolder(t *testing.T) { scenarioWithPanel(t, "When an admin tries to delete a folder that contains connected library elements, it should fail", func(t *testing.T, sc scenarioContext) { diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 53b6ce6ecda..9c57b3f1d40 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -40,11 +40,17 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) const userInDbName = "user_in_db" const userInDbAvatar = "/avatar/402d08de060496d6b6874495fe20f5ad" +// run tests with cleanup +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestConnectLibraryPanelsForDashboard(t *testing.T) { scenarioWithLibraryPanel(t, "When an admin tries to store a dashboard with a library panel, it should connect the two", func(t *testing.T, sc scenarioContext) { diff --git a/pkg/services/live/database/tests/storage_test.go b/pkg/services/live/database/tests/storage_test.go index cd3a483e390..b9ce2d2bd2c 100644 --- a/pkg/services/live/database/tests/storage_test.go +++ b/pkg/services/live/database/tests/storage_test.go @@ -7,8 +7,13 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/services/live/model" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationLiveMessage(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/live/live_test.go b/pkg/services/live/live_test.go index f19f1a8e30b..f37266acb16 100644 --- a/pkg/services/live/live_test.go +++ b/pkg/services/live/live_test.go @@ -17,8 +17,13 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func Test_provideLiveService_RedisUnavailable(t *testing.T) { cfg := setting.NewCfg() diff --git a/pkg/services/login/authinfo.go b/pkg/services/login/authinfo.go index f70dae8c869..4f3d95bd931 100644 --- a/pkg/services/login/authinfo.go +++ b/pkg/services/login/authinfo.go @@ -76,7 +76,7 @@ func IsExternallySynced(cfg *setting.Cfg, authModule string, oauthInfo *social.O case LDAPAuthModule: return !cfg.LDAPSkipOrgRoleSync case JWTModule: - return !cfg.JWTAuthSkipOrgRoleSync + return !cfg.JWTAuth.SkipOrgRoleSync } // then check the rest of the oauth providers // FIXME: remove this once we remove the setting @@ -104,7 +104,7 @@ func IsGrafanaAdminExternallySynced(cfg *setting.Cfg, oauthInfo *social.OAuthInf switch authModule { case JWTModule: - return cfg.JWTAuthAllowAssignGrafanaAdmin + return cfg.JWTAuth.AllowAssignGrafanaAdmin case SAMLAuthModule: return cfg.SAMLRoleValuesGrafanaAdmin != "" case LDAPAuthModule: @@ -121,7 +121,7 @@ func IsProviderEnabled(cfg *setting.Cfg, authModule string, oauthInfo *social.OA case LDAPAuthModule: return cfg.LDAPAuthEnabled case JWTModule: - return cfg.JWTAuthEnabled + return cfg.JWTAuth.Enabled case GoogleAuthModule, OktaAuthModule, AzureADAuthModule, GitLabAuthModule, GithubAuthModule, GrafanaComAuthModule, GenericOAuthModule: if oauthInfo == nil { return false diff --git a/pkg/services/login/authinfo_test.go b/pkg/services/login/authinfo_test.go index bd84e471e83..39746ef8745 100644 --- a/pkg/services/login/authinfo_test.go +++ b/pkg/services/login/authinfo_test.go @@ -3,9 +3,10 @@ package login import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/assert" ) func TestIsExternallySynced(t *testing.T) { @@ -82,20 +83,20 @@ func TestIsExternallySynced(t *testing.T) { // jwt { name: "JWT synced user should return that it is externally synced", - cfg: &setting.Cfg{JWTAuthEnabled: true, JWTAuthSkipOrgRoleSync: false}, + cfg: &setting.Cfg{JWTAuth: setting.AuthJWTSettings{Enabled: true, SkipOrgRoleSync: false}}, provider: JWTModule, expected: true, }, { name: "JWT synced user should return that it is not externally synced when org role sync is set", - cfg: &setting.Cfg{JWTAuthEnabled: true, JWTAuthSkipOrgRoleSync: true}, + cfg: &setting.Cfg{JWTAuth: setting.AuthJWTSettings{Enabled: true, SkipOrgRoleSync: true}}, provider: JWTModule, expected: false, }, // IsProvider test { name: "If no provider enabled should return false", - cfg: &setting.Cfg{JWTAuthSkipOrgRoleSync: true}, + cfg: &setting.Cfg{JWTAuth: setting.AuthJWTSettings{Enabled: false, SkipOrgRoleSync: true}}, provider: JWTModule, expected: false, }, diff --git a/pkg/services/login/authinfoimpl/store_test.go b/pkg/services/login/authinfoimpl/store_test.go index 147673b3b5c..9fad32337e8 100644 --- a/pkg/services/login/authinfoimpl/store_test.go +++ b/pkg/services/login/authinfoimpl/store_test.go @@ -13,8 +13,13 @@ import ( "github.com/grafana/grafana/pkg/services/login" secretstest "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAuthInfoStore(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/loginattempt/loginattemptimpl/store_test.go b/pkg/services/loginattempt/loginattemptimpl/store_test.go index 45c27c0c90f..194e64ff572 100644 --- a/pkg/services/loginattempt/loginattemptimpl/store_test.go +++ b/pkg/services/loginattempt/loginattemptimpl/store_test.go @@ -8,8 +8,13 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationLoginAttemptsQuery(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index f2d196cda2a..1fc0fb12bb2 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -35,10 +35,15 @@ import ( secrets_fakes "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestProvisioningApi(t *testing.T) { t.Run("policies", func(t *testing.T) { t.Run("successful GET returns 200", func(t *testing.T) { diff --git a/pkg/services/ngalert/migration/migration_test.go b/pkg/services/ngalert/migration/migration_test.go index 63d90b62dc7..ce887535d43 100644 --- a/pkg/services/ngalert/migration/migration_test.go +++ b/pkg/services/ngalert/migration/migration_test.go @@ -30,9 +30,14 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/tsdb/legacydata" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + // TestServiceStart tests the wrapper method that decides when to run the migration based on migration status and settings. func TestServiceStart(t *testing.T) { tc := []struct { diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 1b9b1596235..d75f5d91960 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -274,7 +274,7 @@ func (ng *AlertNG) init() error { BaseInterval: ng.Cfg.UnifiedAlerting.BaseInterval, MinRuleInterval: ng.Cfg.UnifiedAlerting.MinInterval, DisableGrafanaFolder: ng.Cfg.UnifiedAlerting.ReservedLabels.IsReservedLabelDisabled(models.FolderTitleLabel), - JitterEvaluations: schedule.JitterStrategyFrom(ng.FeatureToggles), + JitterEvaluations: schedule.JitterStrategyFrom(ng.Cfg.UnifiedAlerting, ng.FeatureToggles), AppURL: appUrl, EvaluatorFactory: evalFactory, RuleStore: ng.store, diff --git a/pkg/services/ngalert/notifier/alertmanager_test.go b/pkg/services/ngalert/notifier/alertmanager_test.go index 907e97ad362..17e3af804e7 100644 --- a/pkg/services/ngalert/notifier/alertmanager_test.go +++ b/pkg/services/ngalert/notifier/alertmanager_test.go @@ -17,8 +17,13 @@ import ( "github.com/grafana/grafana/pkg/services/secrets/database" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func setupAMTest(t *testing.T) *alertmanager { dir := t.TempDir() cfg := &setting.Cfg{ diff --git a/pkg/services/ngalert/provisioning/provisioning_test.go b/pkg/services/ngalert/provisioning/provisioning_test.go new file mode 100644 index 00000000000..f274f08b869 --- /dev/null +++ b/pkg/services/ngalert/provisioning/provisioning_test.go @@ -0,0 +1,11 @@ +package provisioning + +import ( + "testing" + + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} diff --git a/pkg/services/ngalert/schedule/jitter.go b/pkg/services/ngalert/schedule/jitter.go index 0db59e567ee..61adadb1286 100644 --- a/pkg/services/ngalert/schedule/jitter.go +++ b/pkg/services/ngalert/schedule/jitter.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/services/featuremgmt" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/setting" ) // JitterStrategy represents a modifier to alert rule timing that affects how evaluations are distributed. @@ -19,14 +20,14 @@ const ( ) // JitterStrategyFrom returns the JitterStrategy indicated by the current Grafana feature toggles. -func JitterStrategyFrom(toggles featuremgmt.FeatureToggles) JitterStrategy { - strategy := JitterNever +func JitterStrategyFrom(cfg setting.UnifiedAlertingSettings, toggles featuremgmt.FeatureToggles) JitterStrategy { + strategy := JitterByGroup + if cfg.DisableJitter { + return JitterNever + } if toggles == nil { return strategy } - if toggles.IsEnabledGlobally(featuremgmt.FlagJitterAlertRules) { - strategy = JitterByGroup - } if toggles.IsEnabledGlobally(featuremgmt.FlagJitterAlertRulesWithinGroups) { strategy = JitterByRule } diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index ee582b7856e..365ffbd0c1f 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -34,9 +34,14 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/state/historian" "github.com/grafana/grafana/pkg/services/ngalert/tests" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestWarmStateCache(t *testing.T) { evaluationTime, err := time.Parse("2006-01-02", "2021-03-25") require.NoError(t, err) diff --git a/pkg/services/ngalert/store/alertmanager_test.go b/pkg/services/ngalert/store/alertmanager_test.go index 35a6920c615..4433760c8e1 100644 --- a/pkg/services/ngalert/store/alertmanager_test.go +++ b/pkg/services/ngalert/store/alertmanager_test.go @@ -13,8 +13,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAlertmanagerStore(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/org/orgimpl/store_test.go b/pkg/services/org/orgimpl/store_test.go index bc9d0452183..292c08d0335 100644 --- a/pkg/services/org/orgimpl/store_test.go +++ b/pkg/services/org/orgimpl/store_test.go @@ -21,8 +21,13 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationOrgDataAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/playlist/playlistimpl/store_test.go b/pkg/services/playlist/playlistimpl/store_test.go index f0d733deb1c..9eb8c57f767 100644 --- a/pkg/services/playlist/playlistimpl/store_test.go +++ b/pkg/services/playlist/playlistimpl/store_test.go @@ -11,8 +11,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/playlist" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type getStore func(db.DB) store func testIntegrationPlaylistDataAccess(t *testing.T, fn getStore) { diff --git a/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware.go index 329117f5c87..3ea7f566118 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware.go +++ b/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware.go @@ -89,8 +89,8 @@ func NewMetricsMiddleware(promRegisterer prometheus.Registerer, pluginRegistry r } // pluginTarget returns the value for the "target" Prometheus label for the given plugin ID. -func (m *MetricsMiddleware) pluginTarget(ctx context.Context, pluginID string) (string, error) { - p, exists := m.pluginRegistry.Plugin(ctx, pluginID) +func (m *MetricsMiddleware) pluginTarget(ctx context.Context, pluginID, pluginVersion string) (string, error) { + p, exists := m.pluginRegistry.Plugin(ctx, pluginID, pluginVersion) if !exists { return "", plugins.ErrPluginNotRegistered } @@ -99,7 +99,7 @@ func (m *MetricsMiddleware) pluginTarget(ctx context.Context, pluginID string) ( // instrumentPluginRequestSize tracks the size of the given request in the m.pluginRequestSize metric. func (m *MetricsMiddleware) instrumentPluginRequestSize(ctx context.Context, pluginCtx backend.PluginContext, endpoint string, requestSize float64) error { - target, err := m.pluginTarget(ctx, pluginCtx.PluginID) + target, err := m.pluginTarget(ctx, pluginCtx.PluginID, pluginCtx.PluginVersion) if err != nil { return err } @@ -109,7 +109,7 @@ func (m *MetricsMiddleware) instrumentPluginRequestSize(ctx context.Context, plu // instrumentPluginRequest increments the m.pluginRequestCounter metric and tracks the duration of the given request. func (m *MetricsMiddleware) instrumentPluginRequest(ctx context.Context, pluginCtx backend.PluginContext, endpoint string, fn func(context.Context) (requestStatus, error)) error { - target, err := m.pluginTarget(ctx, pluginCtx.PluginID) + target, err := m.pluginTarget(ctx, pluginCtx.PluginID, pluginCtx.PluginVersion) if err != nil { return err } diff --git a/pkg/services/pluginsintegration/dashboards/filestore.go b/pkg/services/pluginsintegration/dashboards/filestore.go index 988650e2a42..e854e6e1340 100644 --- a/pkg/services/pluginsintegration/dashboards/filestore.go +++ b/pkg/services/pluginsintegration/dashboards/filestore.go @@ -24,8 +24,8 @@ func ProvideFileStoreManager(pluginStore pluginstore.Store, pluginFileStore plug } } -var openDashboardFile = func(ctx context.Context, pluginFileStore plugins.FileStore, pluginID, name string) (*plugins.File, error) { - f, err := pluginFileStore.File(ctx, pluginID, name) +var openDashboardFile = func(ctx context.Context, pluginFileStore plugins.FileStore, pluginID, pluginVersion, name string) (*plugins.File, error) { + f, err := pluginFileStore.File(ctx, pluginID, pluginVersion, name) if err != nil { return &plugins.File{}, err } @@ -93,7 +93,7 @@ func (m *FileStoreManager) GetPluginDashboardFileContents(ctx context.Context, a return nil, err } - file, err := openDashboardFile(ctx, m.pluginFileStore, plugin.ID, cleanPath) + file, err := openDashboardFile(ctx, m.pluginFileStore, plugin.ID, plugin.Info.Version, cleanPath) if err != nil { return nil, err } diff --git a/pkg/services/pluginsintegration/dashboards/filestore_test.go b/pkg/services/pluginsintegration/dashboards/filestore_test.go index 9981e768d02..8f8a76252d4 100644 --- a/pkg/services/pluginsintegration/dashboards/filestore_test.go +++ b/pkg/services/pluginsintegration/dashboards/filestore_test.go @@ -132,7 +132,7 @@ func TestDashboardFileStore(t *testing.T) { Data: []byte("dash2"), }, } - openDashboardFile = func(ctx context.Context, pluginFiles plugins.FileStore, pluginID, name string) (*plugins.File, error) { + openDashboardFile = func(ctx context.Context, pluginFiles plugins.FileStore, pluginID, _, name string) (*plugins.File, error) { f, err := mapFs.Open(name) require.NoError(t, err) diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index 42799286cc1..c2b605716d8 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -1666,7 +1666,7 @@ func verifyState(t *testing.T, ps []*plugins.Plugin, reg registry.Service, t.Helper() for _, p := range ps { - regP, exists := reg.Plugin(context.Background(), p.ID) + regP, exists := reg.Plugin(context.Background(), p.ID, p.Info.Version) require.True(t, exists) if !cmp.Equal(p, regP, compareOpts...) { t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(p, regP, compareOpts...)) diff --git a/pkg/services/pluginsintegration/pipeline/pipeline.go b/pkg/services/pluginsintegration/pipeline/pipeline.go index 1c2c9598e61..6ed3cbe622c 100644 --- a/pkg/services/pluginsintegration/pipeline/pipeline.go +++ b/pkg/services/pluginsintegration/pipeline/pipeline.go @@ -29,7 +29,7 @@ func ProvideDiscoveryStage(cfg *config.Cfg, pf finder.Finder, pr registry.Servic plugins.TypeDataSource, plugins.TypeApp, plugins.TypePanel, plugins.TypeSecretsManager, }), func(ctx context.Context, _ plugins.Class, b []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) { - return discovery.NewDuplicatePluginFilterStep(pr).Filter(ctx, b) + return NewDuplicatePluginIDFilterStep(pr).Filter(ctx, b) }, func(_ context.Context, _ plugins.Class, b []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) { return NewDisablePluginsStep(cfg).Filter(b) diff --git a/pkg/services/pluginsintegration/pipeline/steps.go b/pkg/services/pluginsintegration/pipeline/steps.go index c1a013223c1..2685a634c6a 100644 --- a/pkg/services/pluginsintegration/pipeline/steps.go +++ b/pkg/services/pluginsintegration/pipeline/steps.go @@ -3,6 +3,7 @@ package pipeline import ( "context" "errors" + "slices" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/plugins" @@ -11,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/initialization" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation" + "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/plugindef" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -195,3 +197,54 @@ func (c *AsExternal) Filter(cl plugins.Class, bundles []*plugins.FoundBundle) ([ } return bundles, nil } + +// DuplicatePluginIDValidation is a filter step that will filter out any plugins that are already registered with the same +// plugin ID. This includes both the primary plugin and child plugins, which are matched using the plugin.json plugin +// ID field. +type DuplicatePluginIDValidation struct { + registry registry.Service + log log.Logger +} + +// NewDuplicatePluginIDFilterStep returns a new DuplicatePluginIDValidation. +func NewDuplicatePluginIDFilterStep(registry registry.Service) *DuplicatePluginIDValidation { + return &DuplicatePluginIDValidation{ + registry: registry, + log: log.New("plugins.dedupe"), + } +} + +// Filter will filter out any plugins that have already been registered under the same plugin ID. +func (d *DuplicatePluginIDValidation) Filter(ctx context.Context, bundles []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) { + res := make([]*plugins.FoundBundle, 0, len(bundles)) + + var matchesPluginIDFunc = func(fp plugins.FoundPlugin) func(p *plugins.Plugin) bool { + return func(p *plugins.Plugin) bool { + return p.ID == fp.JSONData.ID + } + } + + for _, b := range bundles { + ps := d.registry.Plugins(ctx) + + if slices.ContainsFunc(ps, matchesPluginIDFunc(b.Primary)) { + d.log.Warn("Skipping loading of plugin as it's a duplicate", "pluginId", b.Primary.JSONData.ID) + continue + } + + var nonDupeChildren []*plugins.FoundPlugin + for _, child := range b.Children { + if slices.ContainsFunc(ps, matchesPluginIDFunc(*child)) { + d.log.Warn("Skipping loading of child plugin as it's a duplicate", "pluginId", child.JSONData.ID) + continue + } + nonDupeChildren = append(nonDupeChildren, child) + } + res = append(res, &plugins.FoundBundle{ + Primary: b.Primary, + Children: nonDupeChildren, + }) + } + + return res, nil +} diff --git a/pkg/services/pluginsintegration/pipeline/steps_test.go b/pkg/services/pluginsintegration/pipeline/steps_test.go index edad51c8916..e3431d5ced8 100644 --- a/pkg/services/pluginsintegration/pipeline/steps_test.go +++ b/pkg/services/pluginsintegration/pipeline/steps_test.go @@ -1,12 +1,14 @@ package pipeline import ( + "context" "testing" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" ) @@ -82,3 +84,98 @@ func TestAsExternal(t *testing.T) { require.Equal(t, filtered[0].Primary.JSONData.ID, "plugin2") }) } + +func TestDuplicatePluginIDValidation(t *testing.T) { + tcs := []struct { + name string + registeredPlugins []string + in []*plugins.FoundBundle + out []*plugins.FoundBundle + }{ + { + name: "should filter out a plugin if it already exists in the plugin registry", + registeredPlugins: []string{"foobar-datasource"}, + in: []*plugins.FoundBundle{ + { + Primary: plugins.FoundPlugin{ + JSONData: plugins.JSONData{ + ID: "foobar-datasource", + }, + }, + }, + }, + out: []*plugins.FoundBundle{}, + }, + { + name: "should not filter out a plugin if it doesn't exist in the plugin registry", + registeredPlugins: []string{"foobar-datasource"}, + in: []*plugins.FoundBundle{ + { + Primary: plugins.FoundPlugin{ + JSONData: plugins.JSONData{ + ID: "test-datasource", + }, + }, + }, + }, + out: []*plugins.FoundBundle{ + { + Primary: plugins.FoundPlugin{ + JSONData: plugins.JSONData{ + ID: "test-datasource", + }, + }, + }, + }, + }, + { + name: "should filter out child plugins if they are already registered", + registeredPlugins: []string{"foobar-datasource"}, + in: []*plugins.FoundBundle{ + { + Primary: plugins.FoundPlugin{ + JSONData: plugins.JSONData{ + ID: "test-datasource", + }, + }, + Children: []*plugins.FoundPlugin{ + { + JSONData: plugins.JSONData{ + ID: "foobar-datasource", + }, + }, + }, + }, + }, + out: []*plugins.FoundBundle{ + { + Primary: plugins.FoundPlugin{ + JSONData: plugins.JSONData{ + ID: "test-datasource", + }, + }, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + r := registry.NewInMemory() + s := NewDuplicatePluginIDFilterStep(r) + + ctx := context.Background() + for _, pluginID := range tc.registeredPlugins { + err := r.Add(ctx, &plugins.Plugin{ + JSONData: plugins.JSONData{ + ID: pluginID, + }, + }) + require.NoError(t, err) + } + + res, err := s.Filter(ctx, tc.in) + require.NoError(t, err) + require.Equal(t, tc.out, res) + }) + } +} diff --git a/pkg/services/pluginsintegration/plugins_integration_test.go b/pkg/services/pluginsintegration/plugins_integration_test.go index f42099c05da..778d0c13014 100644 --- a/pkg/services/pluginsintegration/plugins_integration_test.go +++ b/pkg/services/pluginsintegration/plugins_integration_test.go @@ -17,11 +17,11 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" - "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/searchV2" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" cloudmonitoring "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring" "github.com/grafana/grafana/pkg/tsdb/cloudwatch" @@ -41,6 +41,10 @@ import ( "github.com/grafana/grafana/pkg/tsdb/tempo" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationPluginManager(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -95,7 +99,7 @@ func TestIntegrationPluginManager(t *testing.T) { ctx := context.Background() verifyCorePluginCatalogue(t, ctx, testCtx.PluginStore) verifyBundledPlugins(t, ctx, testCtx.PluginStore) - verifyPluginStaticRoutes(t, ctx, testCtx.PluginStore, testCtx.PluginRegistry) + verifyPluginStaticRoutes(t, ctx, testCtx.PluginStore, testCtx.PluginStore) verifyBackendProcesses(t, testCtx.PluginRegistry.Plugins(ctx)) verifyPluginQuery(t, ctx, testCtx.PluginClient) } @@ -248,7 +252,7 @@ func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *pluginstore.Ser } } -func verifyPluginStaticRoutes(t *testing.T, ctx context.Context, rr plugins.StaticRouteResolver, reg registry.Service) { +func verifyPluginStaticRoutes(t *testing.T, ctx context.Context, rr plugins.StaticRouteResolver, ps *pluginstore.Service) { routes := make(map[string]*plugins.StaticRoute) for _, route := range rr.Routes(ctx) { routes[route.PluginID] = route @@ -256,13 +260,13 @@ func verifyPluginStaticRoutes(t *testing.T, ctx context.Context, rr plugins.Stat require.Len(t, routes, 2) - inputPlugin, _ := reg.Plugin(ctx, "input") + inputPlugin, _ := ps.Plugin(ctx, "input") require.NotNil(t, routes["input"]) - require.Equal(t, routes["input"].Directory, inputPlugin.FS.Base()) + require.Equal(t, routes["input"].Directory, inputPlugin.Base()) - testAppPlugin, _ := reg.Plugin(ctx, "test-app") + testAppPlugin, _ := ps.Plugin(ctx, "test-app") require.Contains(t, routes, "test-app") - require.Equal(t, routes["test-app"].Directory, testAppPlugin.FS.Base()) + require.Equal(t, routes["test-app"].Directory, testAppPlugin.Base()) } func verifyBackendProcesses(t *testing.T, ps []*plugins.Plugin) { diff --git a/pkg/services/pluginsintegration/pluginsettings/service/service_test.go b/pkg/services/pluginsintegration/pluginsettings/service/service_test.go index e76d1aedf28..44b7ccea597 100644 --- a/pkg/services/pluginsintegration/pluginsettings/service/service_test.go +++ b/pkg/services/pluginsintegration/pluginsettings/service/service_test.go @@ -12,8 +12,13 @@ import ( "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestService_DecryptedValuesCache(t *testing.T) { t.Run("When plugin settings hasn't been updated, encrypted JSON should be fetched from cache", func(t *testing.T) { ctx := context.Background() diff --git a/pkg/services/pluginsintegration/pluginstore/store.go b/pkg/services/pluginsintegration/pluginstore/store.go index 164876d187b..e4d188572d2 100644 --- a/pkg/services/pluginsintegration/pluginstore/store.go +++ b/pkg/services/pluginsintegration/pluginstore/store.go @@ -18,6 +18,7 @@ var _ Store = (*Service)(nil) // Store is the publicly accessible storage for plugins. type Store interface { // Plugin finds a plugin by its ID. + // Note: version is not required since Grafana only supports single versions of a plugin. Plugin(ctx context.Context, pluginID string) (Plugin, bool) // Plugins returns plugins by their requested type. Plugins(ctx context.Context, pluginTypes ...plugins.Type) []Plugin @@ -104,7 +105,7 @@ func (s *Service) SecretsManager(ctx context.Context) *plugins.Plugin { // plugin finds a plugin with `pluginID` from the registry that is not decommissioned func (s *Service) plugin(ctx context.Context, pluginID string) (*plugins.Plugin, bool) { - p, exists := s.pluginRegistry.Plugin(ctx, pluginID) + p, exists := s.pluginRegistry.Plugin(ctx, pluginID, "") // version is not required since Grafana only supports single versions of a plugin if !exists { return nil, false } diff --git a/pkg/services/pluginsintegration/renderer/renderer.go b/pkg/services/pluginsintegration/renderer/renderer.go index 940a6652534..fda142b5391 100644 --- a/pkg/services/pluginsintegration/renderer/renderer.go +++ b/pkg/services/pluginsintegration/renderer/renderer.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/sources" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" "github.com/grafana/grafana/pkg/services/rendering" ) @@ -106,7 +107,7 @@ func createLoader(cfg *config.Cfg, pr registry.Service, l plugins.Licensing) (lo FindFilterFuncs: []discovery.FindFilterFunc{ discovery.NewPermittedPluginTypesFilterStep([]plugins.Type{plugins.TypeRenderer}), func(ctx context.Context, class plugins.Class, bundles []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) { - return discovery.NewDuplicatePluginFilterStep(pr).Filter(ctx, bundles) + return pipeline.NewDuplicatePluginIDFilterStep(pr).Filter(ctx, bundles) }, }, }) diff --git a/pkg/services/preference/prefimpl/store_test.go b/pkg/services/preference/prefimpl/store_test.go index 43d56d4c97f..afbfea86240 100644 --- a/pkg/services/preference/prefimpl/store_test.go +++ b/pkg/services/preference/prefimpl/store_test.go @@ -11,8 +11,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" pref "github.com/grafana/grafana/pkg/services/preference" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type getStore func(db.DB) store func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) { diff --git a/pkg/services/provisioning/notifiers/config_reader_test.go b/pkg/services/provisioning/notifiers/config_reader_test.go index 979a1c9dd21..ad0f87e6feb 100644 --- a/pkg/services/provisioning/notifiers/config_reader_test.go +++ b/pkg/services/provisioning/notifiers/config_reader_test.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) var ( @@ -34,6 +35,10 @@ var ( unknownNotifier = "./testdata/test-configs/unknown-notifier" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestNotificationAsConfig(t *testing.T) { var sqlStore *sqlstore.SQLStore var orgService org.Service diff --git a/pkg/services/publicdashboards/api/common_test.go b/pkg/services/publicdashboards/api/common_test.go index 3f016a34e46..7a857bc6f14 100644 --- a/pkg/services/publicdashboards/api/common_test.go +++ b/pkg/services/publicdashboards/api/common_test.go @@ -34,9 +34,14 @@ import ( fakeSecrets "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/web" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func setupTestServer( t *testing.T, cfg *setting.Cfg, diff --git a/pkg/services/publicdashboards/database/database_test.go b/pkg/services/publicdashboards/database/database_test.go index 88a216de755..a76ca6d3154 100644 --- a/pkg/services/publicdashboards/database/database_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) @@ -33,6 +34,11 @@ var DefaultTimeSettings = &TimeSettings{} // Default time to pass in with seconds rounded var DefaultTime = time.Now().UTC().Round(time.Second) +// run tests with cleanup +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestLogPrefix(t *testing.T) { assert.Equal(t, LogPrefix, "publicdashboards.store") } diff --git a/pkg/services/publicdashboards/service/intervalv2/intervalv2.go b/pkg/services/publicdashboards/service/intervalv2/intervalv2.go new file mode 100644 index 00000000000..b20defc27f6 --- /dev/null +++ b/pkg/services/publicdashboards/service/intervalv2/intervalv2.go @@ -0,0 +1,77 @@ +package intervalv2 + +import ( + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" +) + +var ( + DefaultRes int64 = 1500 + defaultMinInterval = time.Millisecond * 1 +) + +type Interval struct { + Text string + Value time.Duration +} + +type intervalCalculator struct { + minInterval time.Duration +} + +type Calculator interface { + Calculate(timerange backend.TimeRange, minInterval time.Duration, maxDataPoints int64) Interval + CalculateSafeInterval(timerange backend.TimeRange, resolution int64) Interval +} + +type CalculatorOptions struct { + MinInterval time.Duration +} + +func NewCalculator(opts ...CalculatorOptions) *intervalCalculator { + calc := &intervalCalculator{} + + for _, o := range opts { + if o.MinInterval == 0 { + calc.minInterval = defaultMinInterval + } else { + calc.minInterval = o.MinInterval + } + } + + return calc +} + +func (i *Interval) Milliseconds() int64 { + return i.Value.Nanoseconds() / int64(time.Millisecond) +} + +func (ic *intervalCalculator) Calculate(timerange backend.TimeRange, minInterval time.Duration, maxDataPoints int64) Interval { + to := timerange.To.UnixNano() + from := timerange.From.UnixNano() + resolution := maxDataPoints + if resolution == 0 { + resolution = DefaultRes + } + + calculatedInterval := time.Duration((to - from) / resolution) + + if calculatedInterval < minInterval { + return Interval{Text: gtime.FormatInterval(minInterval), Value: minInterval} + } + + rounded := gtime.RoundInterval(calculatedInterval) + + return Interval{Text: gtime.FormatInterval(rounded), Value: rounded} +} + +func (ic *intervalCalculator) CalculateSafeInterval(timerange backend.TimeRange, safeRes int64) Interval { + to := timerange.To.UnixNano() + from := timerange.From.UnixNano() + safeInterval := time.Duration((to - from) / safeRes) + + rounded := gtime.RoundInterval(safeInterval) + return Interval{Text: gtime.FormatInterval(rounded), Value: rounded} +} diff --git a/pkg/tsdb/intervalv2/intervalv2_test.go b/pkg/services/publicdashboards/service/intervalv2/intervalv2_test.go similarity index 54% rename from pkg/tsdb/intervalv2/intervalv2_test.go rename to pkg/services/publicdashboards/service/intervalv2/intervalv2_test.go index 21b5a48abef..fe9900d90af 100644 --- a/pkg/tsdb/intervalv2/intervalv2_test.go +++ b/pkg/services/publicdashboards/service/intervalv2/intervalv2_test.go @@ -6,8 +6,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/stretchr/testify/assert" - - "github.com/grafana/grafana/pkg/services/datasources" ) func TestIntervalCalculator_Calculate(t *testing.T) { @@ -63,70 +61,3 @@ func TestIntervalCalculator_CalculateSafeInterval(t *testing.T) { }) } } - -func TestRoundInterval(t *testing.T) { - testCases := []struct { - name string - interval time.Duration - expected time.Duration - }{ - {"10ms", time.Millisecond * 10, time.Millisecond * 1}, - {"15ms", time.Millisecond * 15, time.Millisecond * 10}, - {"30ms", time.Millisecond * 30, time.Millisecond * 20}, - {"45ms", time.Millisecond * 45, time.Millisecond * 50}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.expected, roundInterval(tc.interval)) - }) - } -} - -func TestFormatDuration(t *testing.T) { - testCases := []struct { - name string - duration time.Duration - expected string - }{ - {"61s", time.Second * 61, "1m"}, - {"30ms", time.Millisecond * 30, "30ms"}, - {"23h", time.Hour * 23, "23h"}, - {"24h", time.Hour * 24, "1d"}, - {"367d", time.Hour * 24 * 367, "1y"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.expected, FormatDuration(tc.duration)) - }) - } -} - -func TestGetIntervalFrom(t *testing.T) { - testCases := []struct { - name string - dsInfo *datasources.DataSource - queryInterval string - queryIntervalMs int64 - defaultInterval time.Duration - expected time.Duration - }{ - {"45s", nil, "45s", 0, time.Second * 15, time.Second * 45}, - {"45", nil, "45", 0, time.Second * 15, time.Second * 45}, - {"2m", nil, "2m", 0, time.Second * 15, time.Minute * 2}, - {"1d", nil, "1d", 0, time.Second * 15, time.Hour * 24}, - {"intervalMs", nil, "", 45000, time.Second * 15, time.Second * 45}, - {"intervalMs sub-seconds", nil, "", 45200, time.Second * 15, time.Millisecond * 45200}, - {"defaultInterval when interval empty", nil, "", 0, time.Second * 15, time.Second * 15}, - {"defaultInterval when intervalMs 0", nil, "", 0, time.Second * 15, time.Second * 15}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - actual, err := GetIntervalFrom(tc.queryInterval, "", tc.queryIntervalMs, tc.defaultInterval) - assert.Nil(t, err) - assert.Equal(t, tc.expected, actual) - }) - } -} diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index 46775f5822f..f33a3cfaf55 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -23,12 +23,12 @@ import ( "github.com/grafana/grafana/pkg/services/publicdashboards/database" "github.com/grafana/grafana/pkg/services/publicdashboards/internal" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/publicdashboards/service/intervalv2" "github.com/grafana/grafana/pkg/services/query" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" "github.com/grafana/grafana/pkg/tsdb/legacydata" "github.com/grafana/grafana/pkg/util" diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go index c352e171a3f..eb8b3fcb0ad 100644 --- a/pkg/services/publicdashboards/service/service.go +++ b/pkg/services/publicdashboards/service/service.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" @@ -16,11 +17,11 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/publicdashboards" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/publicdashboards/service/intervalv2" "github.com/grafana/grafana/pkg/services/publicdashboards/validation" "github.com/grafana/grafana/pkg/services/query" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" "github.com/grafana/grafana/pkg/tsdb/legacydata" "github.com/grafana/grafana/pkg/util" ) diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index 39f6f0af647..bf982dcd5d8 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -25,12 +25,13 @@ import ( . "github.com/grafana/grafana/pkg/services/publicdashboards" "github.com/grafana/grafana/pkg/services/publicdashboards/database" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/publicdashboards/service/intervalv2" "github.com/grafana/grafana/pkg/services/publicdashboards/validation" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -40,6 +41,10 @@ var defaultPubdashTimeSettings = &TimeSettings{} var dashboardData = simplejson.NewFromAny(map[string]any{"time": map[string]any{"from": "now-8h", "to": "now"}}) var SignedInUser = &user.SignedInUser{UserID: 1234, Login: "user@login.com"} +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestLogPrefix(t *testing.T) { assert.Equal(t, LogPrefix, "publicdashboards.service") } diff --git a/pkg/services/query/query_test.go b/pkg/services/query/query_test.go index 3db35179074..c5786af6b37 100644 --- a/pkg/services/query/query_test.go +++ b/pkg/services/query/query_test.go @@ -41,9 +41,14 @@ import ( secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/web" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestParseMetricRequest(t *testing.T) { t.Run("Test a simple single datasource query", func(t *testing.T) { tc := setup(t) diff --git a/pkg/services/queryhistory/queryhistory_test.go b/pkg/services/queryhistory/queryhistory_test.go index 203ae361eef..80535378d86 100644 --- a/pkg/services/queryhistory/queryhistory_test.go +++ b/pkg/services/queryhistory/queryhistory_test.go @@ -23,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/web" ) @@ -33,6 +34,10 @@ var ( testDsUID2 = "ABch1a1" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type scenarioContext struct { ctx *web.Context service *QueryHistoryService diff --git a/pkg/services/quota/quotaimpl/store_test.go b/pkg/services/quota/quotaimpl/store_test.go index d332ab97851..2e8211871d1 100644 --- a/pkg/services/quota/quotaimpl/store_test.go +++ b/pkg/services/quota/quotaimpl/store_test.go @@ -8,8 +8,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationQuotaDataAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/searchV2/service_bench_test.go b/pkg/services/searchV2/service_bench_test.go index a6b503c1bcd..7c9c8e55b78 100644 --- a/pkg/services/searchV2/service_bench_test.go +++ b/pkg/services/searchV2/service_bench_test.go @@ -19,8 +19,13 @@ import ( "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + // setupBenchEnv will set up a database with folderCount folders and dashboardsPerFolder dashboards per folder // It will also set up and run the search service // and create a signed in user object with explicit permissions on each dashboard and folder. diff --git a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go index 275c7391a51..608c7cde49f 100644 --- a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go +++ b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go @@ -20,8 +20,13 @@ import ( secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func SetupTestDataSourceSecretMigrationService(t *testing.T, sqlStore db.DB, kvStore kvstore.KVStore, secretsStore secretskvs.SecretsKVStore, compatibility bool) *DataSourceSecretMigrationService { t.Helper() cfg := &setting.Cfg{} diff --git a/pkg/services/secrets/kvstore/plugin_test.go b/pkg/services/secrets/kvstore/plugin_test.go index 460adea650d..8cd70129df6 100644 --- a/pkg/services/secrets/kvstore/plugin_test.go +++ b/pkg/services/secrets/kvstore/plugin_test.go @@ -8,8 +8,13 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + // Set fatal flag to true, then simulate a plugin start failure // Should result in an error from the secret store provider func TestFatalPluginErr_PluginFailsToStartWithFatalFlagSet(t *testing.T) { diff --git a/pkg/services/secrets/manager/manager_test.go b/pkg/services/secrets/manager/manager_test.go index 7b83dc45ef9..50396397e07 100644 --- a/pkg/services/secrets/manager/manager_test.go +++ b/pkg/services/secrets/manager/manager_test.go @@ -20,9 +20,14 @@ import ( "github.com/grafana/grafana/pkg/services/secrets/database" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestSecretsService_EnvelopeEncryption(t *testing.T) { testDB := db.InitTestDB(t) store := database.ProvideSecretsStore(testDB) diff --git a/pkg/services/serviceaccounts/database/store_test.go b/pkg/services/serviceaccounts/database/store_test.go index c3cb7782496..57bbd8e4d46 100644 --- a/pkg/services/serviceaccounts/database/store_test.go +++ b/pkg/services/serviceaccounts/database/store_test.go @@ -20,8 +20,13 @@ import ( "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + // Service Account should not create an org on its own func TestStore_CreateServiceAccountOrgNonExistant(t *testing.T) { _, store := setupTestDatabase(t) diff --git a/pkg/services/shorturls/shorturlimpl/shorturl_test.go b/pkg/services/shorturls/shorturlimpl/shorturl_test.go index 37913f1833e..e55804bfcea 100644 --- a/pkg/services/shorturls/shorturlimpl/shorturl_test.go +++ b/pkg/services/shorturls/shorturlimpl/shorturl_test.go @@ -10,8 +10,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestShortURLService(t *testing.T) { user := &user.SignedInUser{UserID: 1} store := db.InitTestDB(t) diff --git a/pkg/services/signingkeys/signingkeystore/store_test.go b/pkg/services/signingkeys/signingkeystore/store_test.go index 2ddebe006cb..2cb8cadc60c 100644 --- a/pkg/services/signingkeys/signingkeystore/store_test.go +++ b/pkg/services/signingkeys/signingkeystore/store_test.go @@ -10,8 +10,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/signingkeys" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationSigningKeyStore(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go b/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go index c811260a588..f145a5b7b65 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/test/ac_test.go @@ -2,7 +2,6 @@ package test import ( "fmt" - "os" "testing" "time" @@ -105,37 +104,6 @@ func convertToRawPermissions(permissions []accesscontrol.Permission) []rawPermis return raw } -func getDBType() string { - dbType := migrator.SQLite - - // environment variable present for test db? - if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { - dbType = db - } - return dbType -} - -func getTestDB(t *testing.T, dbType string) sqlutil.TestDB { - switch dbType { - case "mysql": - return sqlutil.MySQLTestDB() - case "postgres": - return sqlutil.PostgresTestDB() - default: - f, err := os.CreateTemp(".", "grafana-test-db-") - require.NoError(t, err) - t.Cleanup(func() { - err := os.Remove(f.Name()) - require.NoError(t, err) - }) - - return sqlutil.TestDB{ - DriverName: "sqlite3", - ConnStr: f.Name(), - } - } -} - func TestMigrations(t *testing.T) { // Run initial migration to have a working DB x := setupTestDB(t) @@ -253,12 +221,21 @@ func TestMigrations(t *testing.T) { func setupTestDB(t *testing.T) *xorm.Engine { t.Helper() - dbType := getDBType() - testDB := getTestDB(t, dbType) + dbType := sqlutil.GetTestDBType() + testDB, err := sqlutil.GetTestDB(dbType) + require.NoError(t, err) + + t.Cleanup(testDB.Cleanup) x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) + t.Cleanup(func() { + if err := x.Close(); err != nil { + fmt.Printf("failed to close xorm engine: %v", err) + } + }) + err = migrator.NewDialect(x.DriverName()).CleanDB(x) require.NoError(t, err) diff --git a/pkg/services/sqlstore/migrations/migrations_test.go b/pkg/services/sqlstore/migrations/migrations_test.go index 4479e8c95bc..dc3be715b9e 100644 --- a/pkg/services/sqlstore/migrations/migrations_test.go +++ b/pkg/services/sqlstore/migrations/migrations_test.go @@ -3,7 +3,6 @@ package migrations import ( "errors" "fmt" - "os" "strings" "sync" "sync/atomic" @@ -21,13 +20,23 @@ import ( ) func TestMigrations(t *testing.T) { - testDB := sqlutil.SQLite3TestDB() + testDB, err := sqlutil.GetTestDB(SQLite) + require.NoError(t, err) + + t.Cleanup(testDB.Cleanup) + const query = `select count(*) as count from migration_log` result := struct{ Count int }{} x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) + t.Cleanup(func() { + if err := x.Close(); err != nil { + fmt.Printf("failed to close xorm engine: %v", err) + } + }) + err = NewDialect(x.DriverName()).CleanDB(x) require.NoError(t, err) @@ -61,16 +70,25 @@ func TestMigrations(t *testing.T) { } func TestMigrationLock(t *testing.T) { - dbType := getDBType() + dbType := sqlutil.GetTestDBType() if dbType == SQLite { t.Skip() } - testDB := getTestDB(t, dbType) + testDB, err := sqlutil.GetTestDB(dbType) + require.NoError(t, err) + + t.Cleanup(testDB.Cleanup) x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) + t.Cleanup(func() { + if err := x.Close(); err != nil { + fmt.Printf("failed to close xorm engine: %v", err) + } + }) + dialect := NewDialect(x.DriverName()) sess := x.NewSession() @@ -157,17 +175,28 @@ func TestMigrationLock(t *testing.T) { } func TestMigratorLocking(t *testing.T) { - dbType := getDBType() - testDB := getTestDB(t, dbType) + dbType := sqlutil.GetTestDBType() + // skip for SQLite for now since it occasionally fails for not clear reason // anyway starting migrations concurretly for the same migrator is impossible use case if dbType == SQLite { t.Skip() } + testDB, err := sqlutil.GetTestDB(dbType) + require.NoError(t, err) + + t.Cleanup(testDB.Cleanup) + x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) + t.Cleanup(func() { + if err := x.Close(); err != nil { + fmt.Printf("failed to close xorm engine: %v", err) + } + }) + err = NewDialect(x.DriverName()).CleanDB(x) require.NoError(t, err) @@ -194,17 +223,27 @@ func TestMigratorLocking(t *testing.T) { } func TestDatabaseLocking(t *testing.T) { - dbType := getDBType() + dbType := sqlutil.GetTestDBType() + // skip for SQLite since there is no database locking (only migrator locking) if dbType == SQLite { t.Skip() } - testDB := getTestDB(t, dbType) + testDB, err := sqlutil.GetTestDB(dbType) + require.NoError(t, err) + + t.Cleanup(testDB.Cleanup) x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) + t.Cleanup(func() { + if err := x.Close(); err != nil { + fmt.Printf("failed to close xorm engine: %v", err) + } + }) + err = NewDialect(x.DriverName()).CleanDB(x) require.NoError(t, err) @@ -280,37 +319,6 @@ func checkStepsAndDatabaseMatch(t *testing.T, mg *Migrator, expected []string) { require.Failf(t, "the number of migrations does not match log in database", msg) } -func getDBType() string { - dbType := SQLite - - // environment variable present for test db? - if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { - dbType = db - } - return dbType -} - -func getTestDB(t *testing.T, dbType string) sqlutil.TestDB { - switch dbType { - case "mysql": - return sqlutil.MySQLTestDB() - case "postgres": - return sqlutil.PostgresTestDB() - default: - f, err := os.CreateTemp(".", "grafana-test-db-") - require.NoError(t, err) - t.Cleanup(func() { - err := os.Remove(f.Name()) - require.NoError(t, err) - }) - - return sqlutil.TestDB{ - DriverName: "sqlite3", - ConnStr: f.Name(), - } - } -} - func replaceDBName(t *testing.T, connStr, dbType string) string { switch dbType { case "mysql": diff --git a/pkg/services/sqlstore/permissions/dashboard_test.go b/pkg/services/sqlstore/permissions/dashboard_test.go index 3992d9f3b24..a586e78b052 100644 --- a/pkg/services/sqlstore/permissions/dashboard_test.go +++ b/pkg/services/sqlstore/permissions/dashboard_test.go @@ -31,8 +31,13 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegration_DashboardPermissionFilter(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/sqlstore/searchstore/search_test.go b/pkg/services/sqlstore/searchstore/search_test.go index d2c06ccd795..5e9f9660b2a 100644 --- a/pkg/services/sqlstore/searchstore/search_test.go +++ b/pkg/services/sqlstore/searchstore/search_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) @@ -26,6 +27,10 @@ const ( page int64 = 1 ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestBuilder_EqualResults_Basic(t *testing.T) { user := &user.SignedInUser{ UserID: 1, diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 334c72ad4f5..a99603c40ad 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -91,8 +91,8 @@ func ProvideService(cfg *setting.Cfg, return s, nil } -func ProvideServiceForTests(cfg *setting.Cfg, features featuremgmt.FeatureToggles, migrations registry.DatabaseMigrator) (*SQLStore, error) { - return initTestDB(cfg, features, migrations, InitTestDBOpt{EnsureDefaultOrgAndUser: true}) +func ProvideServiceForTests(t sqlutil.ITestDB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, migrations registry.DatabaseMigrator) (*SQLStore, error) { + return initTestDB(t, cfg, features, migrations, InitTestDBOpt{EnsureDefaultOrgAndUser: true}) } func newSQLStore(cfg *setting.Cfg, engine *xorm.Engine, @@ -145,11 +145,6 @@ func (ss *SQLStore) Migrate(isDatabaseLockingEnabled bool) error { return migrator.Start(isDatabaseLockingEnabled, ss.dbCfg.MigrationLockAttemptTimeout) } -// Sync syncs changes to the database. -func (ss *SQLStore) Sync() error { - return ss.engine.Sync2() -} - // Reset resets database state. // If default org and user creation is enabled, it will be ensured they exist in the database. func (ss *SQLStore) Reset() error { @@ -388,16 +383,10 @@ func (ss *SQLStore) RecursiveQueriesAreSupported() (bool, error) { return *ss.recursiveQueriesAreSupported, nil } -// ITestDB is an interface of arguments for testing db -type ITestDB interface { - Helper() - Fatalf(format string, args ...any) - Logf(format string, args ...any) - Log(args ...any) -} - +var testSQLStoreSetup = false var testSQLStore *SQLStore var testSQLStoreMutex sync.Mutex +var testSQLStoreCleanup []func() // InitTestDBOpt contains options for InitTestDB. type InitTestDBOpt struct { @@ -407,10 +396,10 @@ type InitTestDBOpt struct { } // InitTestDBWithMigration initializes the test DB given custom migrations. -func InitTestDBWithMigration(t ITestDB, migration registry.DatabaseMigrator, opts ...InitTestDBOpt) *SQLStore { +func InitTestDBWithMigration(t sqlutil.ITestDB, migration registry.DatabaseMigrator, opts ...InitTestDBOpt) *SQLStore { t.Helper() features := getFeaturesForTesting(opts...) - store, err := initTestDB(setting.NewCfg(), features, migration, opts...) + store, err := initTestDB(t, setting.NewCfg(), features, migration, opts...) if err != nil { t.Fatalf("failed to initialize sql store: %s", err) } @@ -418,20 +407,46 @@ func InitTestDBWithMigration(t ITestDB, migration registry.DatabaseMigrator, opt } // InitTestDB initializes the test DB. -func InitTestDB(t ITestDB, opts ...InitTestDBOpt) *SQLStore { +func InitTestDB(t sqlutil.ITestDB, opts ...InitTestDBOpt) *SQLStore { t.Helper() features := getFeaturesForTesting(opts...) - store, err := initTestDB(setting.NewCfg(), features, migrations.ProvideOSSMigrations(features), opts...) + store, err := initTestDB(t, setting.NewCfg(), features, migrations.ProvideOSSMigrations(features), opts...) if err != nil { t.Fatalf("failed to initialize sql store: %s", err) } return store } -func InitTestDBWithCfg(t ITestDB, opts ...InitTestDBOpt) (*SQLStore, *setting.Cfg) { - store := InitTestDB(t, opts...) - return store, store.Cfg +func SetupTestDB() { + testSQLStoreMutex.Lock() + defer testSQLStoreMutex.Unlock() + if testSQLStoreSetup { + fmt.Printf("ERROR: Test DB already set up, SetupTestDB called twice\n") + os.Exit(1) + } + testSQLStoreSetup = true +} + +func CleanupTestDB() { + testSQLStoreMutex.Lock() + defer testSQLStoreMutex.Unlock() + if !testSQLStoreSetup { + fmt.Printf("ERROR: Test DB not set up, SetupTestDB not called\n") + os.Exit(1) + } + if testSQLStore != nil { + if err := testSQLStore.GetEngine().Close(); err != nil { + fmt.Printf("Failed to close testSQLStore engine: %s\n", err) + } + + for _, cleanup := range testSQLStoreCleanup { + cleanup() + } + + testSQLStoreCleanup = []func(){} + testSQLStore = nil + } } func getFeaturesForTesting(opts ...InitTestDBOpt) featuremgmt.FeatureToggles { @@ -450,24 +465,43 @@ func getFeaturesForTesting(opts ...InitTestDBOpt) featuremgmt.FeatureToggles { } //nolint:gocyclo -func initTestDB(testCfg *setting.Cfg, +func initTestDB(t sqlutil.ITestDB, testCfg *setting.Cfg, features featuremgmt.FeatureToggles, migration registry.DatabaseMigrator, opts ...InitTestDBOpt) (*SQLStore, error) { testSQLStoreMutex.Lock() defer testSQLStoreMutex.Unlock() + if !testSQLStoreSetup { + t.Fatalf(` + +ERROR: Test DB not set up, are you missing TestMain? + +https://github.com/grafana/grafana/blob/main/contribute/backend/style-guide.md + +Example: + +package mypkg + +import ( + "testing" + + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +`) + os.Exit(1) + } if len(opts) == 0 { opts = []InitTestDBOpt{{EnsureDefaultOrgAndUser: false, FeatureFlags: []string{}}} } if testSQLStore == nil { - dbType := migrator.SQLite - - // environment variable present for test db? - if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { - dbType = db - } + dbType := sqlutil.GetTestDBType() // set test db config cfg := setting.NewCfg() @@ -482,21 +516,21 @@ func initTestDB(testCfg *setting.Cfg, if _, err := sec.NewKey("type", dbType); err != nil { return nil, err } - switch dbType { - case "mysql": - if _, err := sec.NewKey("connection_string", sqlutil.MySQLTestDB().ConnStr); err != nil { - return nil, err - } - case "postgres": - if _, err := sec.NewKey("connection_string", sqlutil.PostgresTestDB().ConnStr); err != nil { - return nil, err - } - default: - if _, err := sec.NewKey("connection_string", sqlutil.SQLite3TestDB().ConnStr); err != nil { - return nil, err - } + + testDB, err := sqlutil.GetTestDB(dbType) + if err != nil { + return nil, err } + if _, err := sec.NewKey("connection_string", testDB.ConnStr); err != nil { + return nil, err + } + if _, err := sec.NewKey("path", testDB.Path); err != nil { + return nil, err + } + + testSQLStoreCleanup = append(testSQLStoreCleanup, testDB.Cleanup) + // useful if you already have a database that you want to use for tests. // cannot just set it on testSQLStore as it overrides the config in Init if _, present := os.LookupEnv("SKIP_MIGRATIONS"); present { @@ -539,24 +573,6 @@ func initTestDB(testCfg *setting.Cfg, if err := testSQLStore.Migrate(false); err != nil { return nil, err } - - if err := testSQLStore.Dialect.TruncateDBTables(engine); err != nil { - return nil, err - } - - if err := testSQLStore.Reset(); err != nil { - return nil, err - } - - // Make sure the changes are synced, so they get shared with eventual other DB connections - // XXX: Why is this only relevant when not skipping migrations? - if !testSQLStore.dbCfg.SkipMigrations { - if err := testSQLStore.Sync(); err != nil { - return nil, err - } - } - - return testSQLStore, nil } // nolint:staticcheck diff --git a/pkg/services/sqlstore/sqlstore_test.go b/pkg/services/sqlstore/sqlstore_test.go index 0441a0ff368..54da359d9f0 100644 --- a/pkg/services/sqlstore/sqlstore_test.go +++ b/pkg/services/sqlstore/sqlstore_test.go @@ -2,6 +2,7 @@ package sqlstore import ( "context" + "os" "testing" "time" @@ -11,6 +12,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" ) +func TestMain(m *testing.M) { + SetupTestDB() + code := m.Run() + CleanupTestDB() + os.Exit(code) +} + func TestIntegrationIsUniqueConstraintViolation(t *testing.T) { store := InitTestDB(t) diff --git a/pkg/services/sqlstore/sqlutil/sqlutil.go b/pkg/services/sqlstore/sqlutil/sqlutil.go index 2207bdd1450..38bf06b2476 100644 --- a/pkg/services/sqlstore/sqlutil/sqlutil.go +++ b/pkg/services/sqlstore/sqlutil/sqlutil.go @@ -1,25 +1,123 @@ package sqlutil import ( + "errors" "fmt" + "io/fs" "os" + "path/filepath" ) +// ITestDB is an interface of arguments for testing db +type ITestDB interface { + Helper() + Fatalf(format string, args ...any) + Logf(format string, args ...any) + Log(args ...any) + Cleanup(func()) +} + type TestDB struct { DriverName string ConnStr string + Path string + Cleanup func() } -func SQLite3TestDB() TestDB { - // To run all tests in a local test database, set ConnStr to "grafana_test.db" - return TestDB{ - DriverName: "sqlite3", - // ConnStr specifies an In-memory database shared between connections. - ConnStr: "file::memory:?cache=shared", +func GetTestDBType() string { + dbType := "sqlite3" + + // environment variable present for test db? + if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { + dbType = db } + return dbType } -func MySQLTestDB() TestDB { +func GetTestDB(dbType string) (*TestDB, error) { + switch dbType { + case "mysql": + return mySQLTestDB() + case "postgres": + return postgresTestDB() + case "sqlite3": + return sqLite3TestDB() + } + + return nil, fmt.Errorf("unknown test db type: %s", dbType) +} + +func sqLite3TestDB() (*TestDB, error) { + if os.Getenv("SQLITE_INMEMORY") == "true" { + return &TestDB{ + DriverName: "sqlite3", + ConnStr: "file::memory:", + Cleanup: func() {}, + }, nil + } + + ret := &TestDB{ + DriverName: "sqlite3", + Cleanup: func() {}, + } + + sqliteDb := os.Getenv("SQLITE_TEST_DB") + if sqliteDb == "" { + // try to create a database file in the user's cache directory + dir, err := os.UserCacheDir() + if err != nil { + return nil, err + } + + // if cache dir doesn't exist, fall back to temp dir + if _, err := os.Stat(dir); errors.Is(err, fs.ErrNotExist) { + dir = os.TempDir() + if _, err := os.Stat(dir); err != nil { + return nil, err + } + } + + err = os.Mkdir(filepath.Join(dir, "grafana-test"), 0750) + if err != nil && !errors.Is(err, fs.ErrExist) { + return nil, err + } + + f, err := os.CreateTemp(filepath.Join(dir, "grafana-test"), "grafana-test-*.db") + if err != nil { + return nil, err + } + + sqliteDb = f.Name() + + ret.Cleanup = func() { + // remove db file if it exists + err := os.Remove(sqliteDb) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + fmt.Printf("Error removing sqlite db file %s: %v\n", sqliteDb, err) + } + + // remove wal & shm files if they exist + err = os.Remove(sqliteDb + "-wal") + if err != nil && !errors.Is(err, fs.ErrNotExist) { + fmt.Printf("Error removing sqlite wal file %s: %v\n", sqliteDb+"-wal", err) + } + err = os.Remove(sqliteDb + "-shm") + if err != nil && !errors.Is(err, fs.ErrNotExist) { + fmt.Printf("Error removing sqlite shm file %s: %v\n", sqliteDb+"-shm", err) + } + } + } + + ret.ConnStr = "file:" + sqliteDb + "?cache=private&mode=rwc" + if os.Getenv("SQLITE_JOURNAL_MODE") != "false" { + ret.ConnStr += "&_journal_mode=WAL" + } + ret.Path = sqliteDb + + return ret, nil +} + +func mySQLTestDB() (*TestDB, error) { host := os.Getenv("MYSQL_HOST") if host == "" { host = "localhost" @@ -29,13 +127,14 @@ func MySQLTestDB() TestDB { port = "3306" } conn_str := fmt.Sprintf("grafana:password@tcp(%s:%s)/grafana_tests?collation=utf8mb4_unicode_ci&sql_mode='ANSI_QUOTES'&parseTime=true", host, port) - return TestDB{ + return &TestDB{ DriverName: "mysql", ConnStr: conn_str, - } + Cleanup: func() {}, + }, nil } -func PostgresTestDB() TestDB { +func postgresTestDB() (*TestDB, error) { host := os.Getenv("POSTGRES_HOST") if host == "" { host = "localhost" @@ -44,25 +143,10 @@ func PostgresTestDB() TestDB { if port == "" { port = "5432" } - connStr := fmt.Sprintf("user=grafanatest password=grafanatest host=%s port=%s dbname=grafanatest sslmode=disable", - host, port) - return TestDB{ + connStr := fmt.Sprintf("user=grafanatest password=grafanatest host=%s port=%s dbname=grafanatest sslmode=disable", host, port) + return &TestDB{ DriverName: "postgres", ConnStr: connStr, - } -} - -func MSSQLTestDB() TestDB { - host := os.Getenv("MSSQL_HOST") - if host == "" { - host = "localhost" - } - port := os.Getenv("MSSQL_PORT") - if port == "" { - port = "1433" - } - return TestDB{ - DriverName: "mssql", - ConnStr: fmt.Sprintf("server=%s;port=%s;database=grafanatest;user id=grafana;password=Password!", host, port), - } + Cleanup: func() {}, + }, nil } diff --git a/pkg/services/ssosettings/api/api.go b/pkg/services/ssosettings/api/api.go index 4ebc206a137..d45c2d0db57 100644 --- a/pkg/services/ssosettings/api/api.go +++ b/pkg/services/ssosettings/api/api.go @@ -178,7 +178,7 @@ func (api *Api) updateProviderSettings(c *contextmodel.ReqContext) response.Resp settings.Provider = key - err := api.SSOSettingsService.Upsert(c.Req.Context(), &settings) + err := api.SSOSettingsService.Upsert(c.Req.Context(), &settings, c.SignedInUser) if err != nil { return response.ErrOrFallback(http.StatusInternalServerError, "Failed to update provider settings", err) } diff --git a/pkg/services/ssosettings/api/api_test.go b/pkg/services/ssosettings/api/api_test.go index 3421c4e5dcb..2530f3cf0aa 100644 --- a/pkg/services/ssosettings/api/api_test.go +++ b/pkg/services/ssosettings/api/api_test.go @@ -132,19 +132,21 @@ func TestSSOSettingsAPI_Update(t *testing.T) { Settings: input.Settings, } + signedInUser := &user.SignedInUser{ + OrgRole: org.RoleAdmin, + OrgID: 1, + Permissions: getPermissionsForActionAndScope(tt.action, tt.scope), + } + service := ssosettingstests.NewMockService(t) if tt.expectedServiceCall { - service.On("Upsert", mock.Anything, &settings).Return(tt.expectedError).Once() + service.On("Upsert", mock.Anything, &settings, signedInUser).Return(tt.expectedError).Once() } server := setupTests(t, service) path := fmt.Sprintf("/api/v1/sso-settings/%s", tt.key) req := server.NewRequest(http.MethodPut, path, bytes.NewBufferString(tt.body)) - webtest.RequestWithSignedInUser(req, &user.SignedInUser{ - OrgRole: org.RoleEditor, - OrgID: 1, - Permissions: getPermissionsForActionAndScope(tt.action, tt.scope), - }) + webtest.RequestWithSignedInUser(req, signedInUser) res, err := server.SendJSON(req) require.NoError(t, err) diff --git a/pkg/services/ssosettings/database/database_test.go b/pkg/services/ssosettings/database/database_test.go index df62c3f4242..cc937721b03 100644 --- a/pkg/services/ssosettings/database/database_test.go +++ b/pkg/services/ssosettings/database/database_test.go @@ -12,12 +12,17 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/ssosettings" "github.com/grafana/grafana/pkg/services/ssosettings/models" + "github.com/grafana/grafana/pkg/tests/testsuite" ) const ( withinDuration = 5 * time.Minute ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationGetSSOSettings(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/ssosettings/ssosettings.go b/pkg/services/ssosettings/ssosettings.go index f31d30b4fde..be0bdaac718 100644 --- a/pkg/services/ssosettings/ssosettings.go +++ b/pkg/services/ssosettings/ssosettings.go @@ -4,6 +4,7 @@ import ( "context" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/ssosettings/models" ) @@ -28,7 +29,7 @@ type Service interface { // GetForProviderWithRedactedSecrets returns the SSO settings for a given provider (DB or config file) with secret values redacted GetForProviderWithRedactedSecrets(ctx context.Context, provider string) (*models.SSOSettings, error) // Upsert creates or updates the SSO settings for a given provider - Upsert(ctx context.Context, settings *models.SSOSettings) error + Upsert(ctx context.Context, settings *models.SSOSettings, requester identity.Requester) error // Delete deletes the SSO settings for a given provider (soft delete) Delete(ctx context.Context, provider string) error // Patch updates the specified SSO settings (key-value pairs) for a given provider @@ -44,7 +45,7 @@ type Service interface { //go:generate mockery --name Reloadable --structname MockReloadable --outpkg ssosettingstests --filename reloadable_mock.go --output ./ssosettingstests/ type Reloadable interface { Reload(ctx context.Context, settings models.SSOSettings) error - Validate(ctx context.Context, settings models.SSOSettings) error + Validate(ctx context.Context, settings models.SSOSettings, requester identity.Requester) error } // FallbackStrategy is an interface that can be implemented to allow a provider to load settings from a different source diff --git a/pkg/services/ssosettings/ssosettingsimpl/service.go b/pkg/services/ssosettings/ssosettingsimpl/service.go index 02b1319dd79..9466848d7ce 100644 --- a/pkg/services/ssosettings/ssosettingsimpl/service.go +++ b/pkg/services/ssosettings/ssosettingsimpl/service.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/usagestats" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/ssosettings" @@ -164,7 +165,7 @@ func (s *Service) ListWithRedactedSecrets(ctx context.Context) ([]*models.SSOSet return configurableSettings, nil } -func (s *Service) Upsert(ctx context.Context, settings *models.SSOSettings) error { +func (s *Service) Upsert(ctx context.Context, settings *models.SSOSettings, requester identity.Requester) error { if !s.isProviderConfigurable(settings.Provider) { return ssosettings.ErrNotConfigurable } @@ -174,7 +175,7 @@ func (s *Service) Upsert(ctx context.Context, settings *models.SSOSettings) erro return ssosettings.ErrInvalidProvider.Errorf("provider %s not found in reloadables", settings.Provider) } - err := social.Validate(ctx, *settings) + err := social.Validate(ctx, *settings, requester) if err != nil { return err } diff --git a/pkg/services/ssosettings/ssosettingsimpl/service_test.go b/pkg/services/ssosettings/ssosettingsimpl/service_test.go index 333efa33a04..6f736c5479e 100644 --- a/pkg/services/ssosettings/ssosettingsimpl/service_test.go +++ b/pkg/services/ssosettings/ssosettingsimpl/service_test.go @@ -22,6 +22,7 @@ import ( "github.com/grafana/grafana/pkg/services/ssosettings" "github.com/grafana/grafana/pkg/services/ssosettings/models" "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -796,7 +797,7 @@ func TestService_Upsert(t *testing.T) { wg.Add(1) reloadable := ssosettingstests.NewMockReloadable(t) - reloadable.On("Validate", mock.Anything, settings).Return(nil) + reloadable.On("Validate", mock.Anything, settings, mock.Anything).Return(nil) reloadable.On("Reload", mock.Anything, mock.MatchedBy(func(settings models.SSOSettings) bool { defer wg.Done() return settings.Provider == provider && @@ -830,7 +831,7 @@ func TestService_Upsert(t *testing.T) { }, }, nil } - err := env.service.Upsert(context.Background(), &settings) + err := env.service.Upsert(context.Background(), &settings, &user.SignedInUser{}) require.NoError(t, err) // Wait for the goroutine first to assert the Reload call @@ -859,7 +860,7 @@ func TestService_Upsert(t *testing.T) { reloadable := ssosettingstests.NewMockReloadable(t) env.reloadables[provider] = reloadable - err := env.service.Upsert(context.Background(), settings) + err := env.service.Upsert(context.Background(), settings, &user.SignedInUser{}) require.Error(t, err) }) @@ -883,7 +884,7 @@ func TestService_Upsert(t *testing.T) { // the reloadable is available for other provider env.reloadables["github"] = reloadable - err := env.service.Upsert(context.Background(), settings) + err := env.service.Upsert(context.Background(), settings, &user.SignedInUser{}) require.Error(t, err) }) @@ -904,10 +905,10 @@ func TestService_Upsert(t *testing.T) { } reloadable := ssosettingstests.NewMockReloadable(t) - reloadable.On("Validate", mock.Anything, settings).Return(errors.New("validation failed")) + reloadable.On("Validate", mock.Anything, settings, mock.Anything).Return(errors.New("validation failed")) env.reloadables[provider] = reloadable - err := env.service.Upsert(context.Background(), &settings) + err := env.service.Upsert(context.Background(), &settings, &user.SignedInUser{}) require.Error(t, err) }) @@ -928,7 +929,7 @@ func TestService_Upsert(t *testing.T) { env.fallbackStrategy.ExpectedIsMatch = false - err := env.service.Upsert(context.Background(), settings) + err := env.service.Upsert(context.Background(), settings, &user.SignedInUser{}) require.Error(t, err) }) @@ -949,11 +950,11 @@ func TestService_Upsert(t *testing.T) { } reloadable := ssosettingstests.NewMockReloadable(t) - reloadable.On("Validate", mock.Anything, settings).Return(nil) + reloadable.On("Validate", mock.Anything, settings, mock.Anything).Return(nil) env.reloadables[provider] = reloadable env.secrets.On("Encrypt", mock.Anything, []byte(settings.Settings["client_secret"].(string)), mock.Anything).Return(nil, errors.New("encryption failed")).Once() - err := env.service.Upsert(context.Background(), &settings) + err := env.service.Upsert(context.Background(), &settings, &user.SignedInUser{}) require.Error(t, err) }) @@ -981,13 +982,13 @@ func TestService_Upsert(t *testing.T) { } reloadable := ssosettingstests.NewMockReloadable(t) - reloadable.On("Validate", mock.Anything, settings).Return(nil) + reloadable.On("Validate", mock.Anything, settings, mock.Anything).Return(nil) reloadable.On("Reload", mock.Anything, mock.Anything).Return(nil).Maybe() env.reloadables[provider] = reloadable env.secrets.On("Decrypt", mock.Anything, []byte("current-client-secret"), mock.Anything).Return([]byte("encrypted-client-secret"), nil).Once() env.secrets.On("Encrypt", mock.Anything, []byte("encrypted-client-secret"), mock.Anything).Return([]byte("current-client-secret"), nil).Once() - err := env.service.Upsert(context.Background(), &settings) + err := env.service.Upsert(context.Background(), &settings, &user.SignedInUser{}) require.NoError(t, err) settings.Settings["client_secret"] = base64.RawStdEncoding.EncodeToString([]byte("current-client-secret")) @@ -1011,7 +1012,7 @@ func TestService_Upsert(t *testing.T) { } reloadable := ssosettingstests.NewMockReloadable(t) - reloadable.On("Validate", mock.Anything, settings).Return(nil) + reloadable.On("Validate", mock.Anything, settings, mock.Anything).Return(nil) env.reloadables[provider] = reloadable env.secrets.On("Encrypt", mock.Anything, []byte(settings.Settings["client_secret"].(string)), mock.Anything).Return([]byte("encrypted-client-secret"), nil).Once() env.store.GetFn = func(ctx context.Context, provider string) (*models.SSOSettings, error) { @@ -1022,7 +1023,7 @@ func TestService_Upsert(t *testing.T) { return errors.New("failed to upsert settings") } - err := env.service.Upsert(context.Background(), &settings) + err := env.service.Upsert(context.Background(), &settings, &user.SignedInUser{}) require.Error(t, err) }) @@ -1043,12 +1044,12 @@ func TestService_Upsert(t *testing.T) { } reloadable := ssosettingstests.NewMockReloadable(t) - reloadable.On("Validate", mock.Anything, settings).Return(nil) + reloadable.On("Validate", mock.Anything, settings, mock.Anything).Return(nil) reloadable.On("Reload", mock.Anything, mock.Anything).Return(errors.New("failed reloading new settings")).Maybe() env.reloadables[provider] = reloadable env.secrets.On("Encrypt", mock.Anything, []byte(settings.Settings["client_secret"].(string)), mock.Anything).Return([]byte("encrypted-client-secret"), nil).Once() - err := env.service.Upsert(context.Background(), &settings) + err := env.service.Upsert(context.Background(), &settings, &user.SignedInUser{}) require.NoError(t, err) settings.Settings["client_secret"] = base64.RawStdEncoding.EncodeToString([]byte("encrypted-client-secret")) diff --git a/pkg/services/ssosettings/ssosettingstests/reloadable_mock.go b/pkg/services/ssosettings/ssosettingstests/reloadable_mock.go index a6c0526cfa4..599fe1c5c19 100644 --- a/pkg/services/ssosettings/ssosettingstests/reloadable_mock.go +++ b/pkg/services/ssosettings/ssosettingstests/reloadable_mock.go @@ -5,8 +5,10 @@ package ssosettingstests import ( context "context" - models "github.com/grafana/grafana/pkg/services/ssosettings/models" + identity "github.com/grafana/grafana/pkg/services/auth/identity" mock "github.com/stretchr/testify/mock" + + models "github.com/grafana/grafana/pkg/services/ssosettings/models" ) // MockReloadable is an autogenerated mock type for the Reloadable type @@ -32,17 +34,17 @@ func (_m *MockReloadable) Reload(ctx context.Context, settings models.SSOSetting return r0 } -// Validate provides a mock function with given fields: ctx, settings -func (_m *MockReloadable) Validate(ctx context.Context, settings models.SSOSettings) error { - ret := _m.Called(ctx, settings) +// Validate provides a mock function with given fields: ctx, settings, requester +func (_m *MockReloadable) Validate(ctx context.Context, settings models.SSOSettings, requester identity.Requester) error { + ret := _m.Called(ctx, settings, requester) if len(ret) == 0 { panic("no return value specified for Validate") } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, models.SSOSettings) error); ok { - r0 = rf(ctx, settings) + if rf, ok := ret.Get(0).(func(context.Context, models.SSOSettings, identity.Requester) error); ok { + r0 = rf(ctx, settings, requester) } else { r0 = ret.Error(0) } diff --git a/pkg/services/ssosettings/ssosettingstests/service_mock.go b/pkg/services/ssosettings/ssosettingstests/service_mock.go index 42ef1091fb9..02837917856 100644 --- a/pkg/services/ssosettings/ssosettingstests/service_mock.go +++ b/pkg/services/ssosettings/ssosettingstests/service_mock.go @@ -5,9 +5,11 @@ package ssosettingstests import ( context "context" - models "github.com/grafana/grafana/pkg/services/ssosettings/models" + identity "github.com/grafana/grafana/pkg/services/auth/identity" mock "github.com/stretchr/testify/mock" + models "github.com/grafana/grafana/pkg/services/ssosettings/models" + ssosettings "github.com/grafana/grafana/pkg/services/ssosettings" ) @@ -182,17 +184,17 @@ func (_m *MockService) Reload(ctx context.Context, provider string) { _m.Called(ctx, provider) } -// Upsert provides a mock function with given fields: ctx, settings -func (_m *MockService) Upsert(ctx context.Context, settings *models.SSOSettings) error { - ret := _m.Called(ctx, settings) +// Upsert provides a mock function with given fields: ctx, settings, requester +func (_m *MockService) Upsert(ctx context.Context, settings *models.SSOSettings, requester identity.Requester) error { + ret := _m.Called(ctx, settings, requester) if len(ret) == 0 { panic("no return value specified for Upsert") } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.SSOSettings) error); ok { - r0 = rf(ctx, settings) + if rf, ok := ret.Get(0).(func(context.Context, *models.SSOSettings, identity.Requester) error); ok { + r0 = rf(ctx, settings, requester) } else { r0 = ret.Error(0) } diff --git a/pkg/services/star/starimpl/store_test.go b/pkg/services/star/starimpl/store_test.go index 52bb12cfde4..1b725ca5224 100644 --- a/pkg/services/star/starimpl/store_test.go +++ b/pkg/services/star/starimpl/store_test.go @@ -8,8 +8,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/star" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type getStore func(db.DB) store func testIntegrationUserStarsDataAccess(t *testing.T, fn getStore) { diff --git a/pkg/services/stats/statsimpl/stats_test.go b/pkg/services/stats/statsimpl/stats_test.go index 6b32590ed18..de191803ac5 100644 --- a/pkg/services/stats/statsimpl/stats_test.go +++ b/pkg/services/stats/statsimpl/stats_test.go @@ -19,8 +19,13 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationStatsDataAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/store/entity/sqlstash/sql_storage_server_test.go b/pkg/services/store/entity/sqlstash/sql_storage_server_test.go index 7851939deb8..17ca9701220 100644 --- a/pkg/services/store/entity/sqlstash/sql_storage_server_test.go +++ b/pkg/services/store/entity/sqlstash/sql_storage_server_test.go @@ -11,8 +11,13 @@ import ( "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestCreate(t *testing.T) { s := setUpTestServer(t) diff --git a/pkg/services/store/entity/tests/common.go b/pkg/services/store/entity/tests/common_test.go similarity index 96% rename from pkg/services/store/entity/tests/common.go rename to pkg/services/store/entity/tests/common_test.go index 7bd8b4fc9c2..1094f2d8cf6 100644 --- a/pkg/services/store/entity/tests/common.go +++ b/pkg/services/store/entity/tests/common_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/store/entity/sqlstash" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func createServiceAccountAdminToken(t *testing.T, env *server.TestEnv) (string, *user.SignedInUser) { t.Helper() diff --git a/pkg/services/store/entity/tests/server_integration_test.go b/pkg/services/store/entity/tests/server_integration_test.go index 2ac11cb6821..44eec13b401 100644 --- a/pkg/services/store/entity/tests/server_integration_test.go +++ b/pkg/services/store/entity/tests/server_integration_test.go @@ -110,9 +110,9 @@ func requireVersionMatch(t *testing.T, obj *entity.Entity, m objectVersionMatche } func TestIntegrationEntityServer(t *testing.T) { + // TODO figure out why this still runs into sqlite database locked error if true { - // TODO: enable this test once we fix test "database locked" issues - t.Skip() + t.Skip("skipping integration test") } if testing.Short() { diff --git a/pkg/services/store/service_test.go b/pkg/services/store/service_test.go index 0546461787a..298d41a0d91 100644 --- a/pkg/services/store/service_test.go +++ b/pkg/services/store/service_test.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" testdatasource "github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource" ) @@ -68,6 +69,10 @@ var ( }}) ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestListFiles(t *testing.T) { roots := []storageRuntime{publicStaticFilesStorage} diff --git a/pkg/services/tag/tagimpl/store_test.go b/pkg/services/tag/tagimpl/store_test.go index 3d631136d39..a3bf9c2783e 100644 --- a/pkg/services/tag/tagimpl/store_test.go +++ b/pkg/services/tag/tagimpl/store_test.go @@ -8,8 +8,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/tag" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + type getStore func(db.DB) store func testIntegrationSavingTags(t *testing.T, fn getStore) { diff --git a/pkg/services/team/teamimpl/store_test.go b/pkg/services/team/teamimpl/store_test.go index e864b08bf18..7c1a4c92c1c 100644 --- a/pkg/services/team/teamimpl/store_test.go +++ b/pkg/services/team/teamimpl/store_test.go @@ -22,8 +22,13 @@ import ( "github.com/grafana/grafana/pkg/services/team/sortopts" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationTeamCommandsAndQueries(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/temp_user/tempuserimpl/store_test.go b/pkg/services/temp_user/tempuserimpl/store_test.go index 7fa636110e5..a5db34a8167 100644 --- a/pkg/services/temp_user/tempuserimpl/store_test.go +++ b/pkg/services/temp_user/tempuserimpl/store_test.go @@ -9,8 +9,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" tempuser "github.com/grafana/grafana/pkg/services/temp_user" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationTempUserCommandsAndQueries(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/user/userimpl/store_test.go b/pkg/services/user/userimpl/store_test.go index 39d87e8d30d..33ffd7ded73 100644 --- a/pkg/services/user/userimpl/store_test.go +++ b/pkg/services/user/userimpl/store_test.go @@ -19,8 +19,13 @@ import ( "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationUserGet(t *testing.T) { testCases := []struct { name string diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 92127cc6314..5d6ed89392d 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -267,24 +267,7 @@ type Cfg struct { OAuthCookieMaxAge int OAuthAllowInsecureEmailLookup bool - // JWT Auth - JWTAuthEnabled bool - JWTAuthHeaderName string - JWTAuthURLLogin bool - JWTAuthEmailClaim string - JWTAuthUsernameClaim string - JWTAuthExpectClaims string - JWTAuthJWKSetURL string - JWTAuthCacheTTL time.Duration - JWTAuthKeyFile string - JWTAuthKeyID string - JWTAuthJWKSetFile string - JWTAuthAutoSignUp bool - JWTAuthRoleAttributePath string - JWTAuthRoleAttributeStrict bool - JWTAuthAllowAssignGrafanaAdmin bool - JWTAuthSkipOrgRoleSync bool - + JWTAuth AuthJWTSettings // Extended JWT Auth ExtendedJWTAuthEnabled bool ExtendedJWTExpectIssuer string @@ -1195,6 +1178,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { cfg.readLDAPConfig() cfg.handleAWSConfig() cfg.readAzureSettings() + cfg.readAuthJWTSettings() cfg.readSessionConfig() if err := cfg.readSmtpSettings(); err != nil { return err @@ -1608,25 +1592,6 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { authBasic := iniFile.Section("auth.basic") cfg.BasicAuthEnabled = authBasic.Key("enabled").MustBool(true) - // JWT auth - authJWT := iniFile.Section("auth.jwt") - cfg.JWTAuthEnabled = authJWT.Key("enabled").MustBool(false) - cfg.JWTAuthHeaderName = valueAsString(authJWT, "header_name", "") - cfg.JWTAuthURLLogin = authJWT.Key("url_login").MustBool(false) - cfg.JWTAuthEmailClaim = valueAsString(authJWT, "email_claim", "") - cfg.JWTAuthUsernameClaim = valueAsString(authJWT, "username_claim", "") - cfg.JWTAuthExpectClaims = valueAsString(authJWT, "expect_claims", "{}") - cfg.JWTAuthJWKSetURL = valueAsString(authJWT, "jwk_set_url", "") - cfg.JWTAuthCacheTTL = authJWT.Key("cache_ttl").MustDuration(time.Minute * 60) - cfg.JWTAuthKeyFile = valueAsString(authJWT, "key_file", "") - cfg.JWTAuthKeyID = authJWT.Key("key_id").MustString("") - cfg.JWTAuthJWKSetFile = valueAsString(authJWT, "jwk_set_file", "") - cfg.JWTAuthAutoSignUp = authJWT.Key("auto_sign_up").MustBool(false) - cfg.JWTAuthRoleAttributePath = valueAsString(authJWT, "role_attribute_path", "") - cfg.JWTAuthRoleAttributeStrict = authJWT.Key("role_attribute_strict").MustBool(false) - cfg.JWTAuthAllowAssignGrafanaAdmin = authJWT.Key("allow_assign_grafana_admin").MustBool(false) - cfg.JWTAuthSkipOrgRoleSync = authJWT.Key("skip_org_role_sync").MustBool(false) - // Extended JWT auth authExtendedJWT := cfg.SectionWithEnvOverrides("auth.extended_jwt") cfg.ExtendedJWTAuthEnabled = authExtendedJWT.Key("enabled").MustBool(false) diff --git a/pkg/setting/setting_jwt.go b/pkg/setting/setting_jwt.go new file mode 100644 index 00000000000..1f6a672e526 --- /dev/null +++ b/pkg/setting/setting_jwt.go @@ -0,0 +1,48 @@ +package setting + +import "time" + +type AuthJWTSettings struct { + // JWT Auth + Enabled bool + HeaderName string + URLLogin bool + EmailClaim string + UsernameClaim string + ExpectClaims string + JWKSetURL string + CacheTTL time.Duration + KeyFile string + KeyID string + JWKSetFile string + AutoSignUp bool + RoleAttributePath string + RoleAttributeStrict bool + AllowAssignGrafanaAdmin bool + SkipOrgRoleSync bool + GroupsAttributePath string +} + +func (cfg *Cfg) readAuthJWTSettings() { + jwtSettings := AuthJWTSettings{} + authJWT := cfg.Raw.Section("auth.jwt") + jwtSettings.Enabled = authJWT.Key("enabled").MustBool(false) + jwtSettings.HeaderName = valueAsString(authJWT, "header_name", "") + jwtSettings.URLLogin = authJWT.Key("url_login").MustBool(false) + jwtSettings.EmailClaim = valueAsString(authJWT, "email_claim", "") + jwtSettings.UsernameClaim = valueAsString(authJWT, "username_claim", "") + jwtSettings.ExpectClaims = valueAsString(authJWT, "expect_claims", "{}") + jwtSettings.JWKSetURL = valueAsString(authJWT, "jwk_set_url", "") + jwtSettings.CacheTTL = authJWT.Key("cache_ttl").MustDuration(time.Minute * 60) + jwtSettings.KeyFile = valueAsString(authJWT, "key_file", "") + jwtSettings.KeyID = authJWT.Key("key_id").MustString("") + jwtSettings.JWKSetFile = valueAsString(authJWT, "jwk_set_file", "") + jwtSettings.AutoSignUp = authJWT.Key("auto_sign_up").MustBool(false) + jwtSettings.RoleAttributePath = valueAsString(authJWT, "role_attribute_path", "") + jwtSettings.RoleAttributeStrict = authJWT.Key("role_attribute_strict").MustBool(false) + jwtSettings.AllowAssignGrafanaAdmin = authJWT.Key("allow_assign_grafana_admin").MustBool(false) + jwtSettings.SkipOrgRoleSync = authJWT.Key("skip_org_role_sync").MustBool(false) + jwtSettings.GroupsAttributePath = valueAsString(authJWT, "groups_attribute_path", "") + + cfg.JWTAuth = jwtSettings +} diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index ca27fb228ca..b42f0869fee 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -83,6 +83,7 @@ type UnifiedAlertingSettings struct { MaxAttempts int64 MinInterval time.Duration EvaluationTimeout time.Duration + DisableJitter bool ExecuteAlerts bool DefaultConfiguration string Enabled *bool // determines whether unified alerting is enabled. If it is nil then user did not define it and therefore its value will be determined during migration. Services should not use it directly. @@ -300,6 +301,10 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { uaCfg.BaseInterval = SchedulerBaseInterval + // TODO: This was promoted from a feature toggle and is now the default behavior. + // We can consider removing the knob entirely in a release after 10.4. + uaCfg.DisableJitter = ua.Key("disable_jitter").MustBool(false) + // The base interval of the scheduler for evaluating alerts. // 1. It is used by the internal scheduler's timer to tick at this interval. // 2. to spread evaluations of rules that need to be evaluated at the current tick T. In other words, the evaluation of rules at the tick T will be evenly spread in the interval from T to T+scheduler_tick_interval. diff --git a/pkg/tests/api/alerting/api_testing_test.go b/pkg/tests/api/alerting/api_testing_test.go index 47fe06df74d..6b01b4f163d 100644 --- a/pkg/tests/api/alerting/api_testing_test.go +++ b/pkg/tests/api/alerting/api_testing_test.go @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) @@ -31,6 +32,10 @@ const ( TESTDATA_UID = "testdata" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestGrafanaRuleConfig(t *testing.T) { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, diff --git a/pkg/tests/api/azuremonitor/azuremonitor_test.go b/pkg/tests/api/azuremonitor/azuremonitor_test.go index 099424f7c91..a7cfc3f0fb9 100644 --- a/pkg/tests/api/azuremonitor/azuremonitor_test.go +++ b/pkg/tests/api/azuremonitor/azuremonitor_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAzureMonitor(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/correlations/common_test.go b/pkg/tests/api/correlations/common_test.go index 01c166788f0..35696356f03 100644 --- a/pkg/tests/api/correlations/common_test.go +++ b/pkg/tests/api/correlations/common_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) type errorResponseBody struct { @@ -30,6 +31,10 @@ type TestContext struct { t *testing.T } +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func NewTestEnv(t *testing.T) TestContext { t.Helper() dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ diff --git a/pkg/tests/api/dashboards/api_dashboards_test.go b/pkg/tests/api/dashboards/api_dashboards_test.go index d6b9297db4a..60bd745e740 100644 --- a/pkg/tests/api/dashboards/api_dashboards_test.go +++ b/pkg/tests/api/dashboards/api_dashboards_test.go @@ -30,9 +30,14 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationDashboardQuota(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/elasticsearch/elasticsearch_test.go b/pkg/tests/api/elasticsearch/elasticsearch_test.go index 1bfc4ab9f26..d651be84d5a 100644 --- a/pkg/tests/api/elasticsearch/elasticsearch_test.go +++ b/pkg/tests/api/elasticsearch/elasticsearch_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationElasticsearch(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/folders/api_folders_test.go b/pkg/tests/api/folders/api_folders_test.go index 70af39036f4..d541757864e 100644 --- a/pkg/tests/api/folders/api_folders_test.go +++ b/pkg/tests/api/folders/api_folders_test.go @@ -15,11 +15,16 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util/retryer" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestGetFolders(t *testing.T) { // Setup Grafana and its Database dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ diff --git a/pkg/tests/api/graphite/graphite_test.go b/pkg/tests/api/graphite/graphite_test.go index 615209d625f..e003574ac1d 100644 --- a/pkg/tests/api/graphite/graphite_test.go +++ b/pkg/tests/api/graphite/graphite_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationGraphite(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/influxdb/influxdb_test.go b/pkg/tests/api/influxdb/influxdb_test.go index bdaf6fefe46..14a9b315fe9 100644 --- a/pkg/tests/api/influxdb/influxdb_test.go +++ b/pkg/tests/api/influxdb/influxdb_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationInflux(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/loki/loki_test.go b/pkg/tests/api/loki/loki_test.go index 8264c093670..f5cffd1c39b 100644 --- a/pkg/tests/api/loki/loki_test.go +++ b/pkg/tests/api/loki/loki_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationLoki(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/opentdsb/opentdsb_test.go b/pkg/tests/api/opentdsb/opentdsb_test.go index 2c04ac73ad1..284e36d9297 100644 --- a/pkg/tests/api/opentdsb/opentdsb_test.go +++ b/pkg/tests/api/opentdsb/opentdsb_test.go @@ -18,8 +18,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationOpenTSDB(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/plugins/api_plugins_test.go b/pkg/tests/api/plugins/api_plugins_test.go index 4fc2295ed8b..762c72e3f5c 100644 --- a/pkg/tests/api/plugins/api_plugins_test.go +++ b/pkg/tests/api/plugins/api_plugins_test.go @@ -22,6 +22,7 @@ import ( "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) const ( @@ -32,6 +33,10 @@ const ( var updateSnapshotFlag = false +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationPlugins(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/plugins/backendplugin/backendplugin_test.go b/pkg/tests/api/plugins/backendplugin/backendplugin_test.go index 31a53717ab0..9da079766e6 100644 --- a/pkg/tests/api/plugins/backendplugin/backendplugin_test.go +++ b/pkg/tests/api/plugins/backendplugin/backendplugin_test.go @@ -27,10 +27,15 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) const loginCookieName = "grafana_session" +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationBackendPlugins(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/prometheus/prometheus_test.go b/pkg/tests/api/prometheus/prometheus_test.go index a2de4708232..df2182ff743 100644 --- a/pkg/tests/api/prometheus/prometheus_test.go +++ b/pkg/tests/api/prometheus/prometheus_test.go @@ -17,8 +17,13 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationPrometheus(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/stats/admin_test.go b/pkg/tests/api/stats/admin_test.go index 5a9c9107115..0204f24d8fa 100644 --- a/pkg/tests/api/stats/admin_test.go +++ b/pkg/tests/api/stats/admin_test.go @@ -16,8 +16,13 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAdminStats(t *testing.T) { t.Run("with unified alerting enabled", func(t *testing.T) { url := grafanaSetup(t, testinfra.GrafanaOpts{ diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index c05f89f21c7..6a579f1a94d 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -50,9 +50,8 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes serverOpts := server.Options{Listener: listener, HomePath: grafDir} apiServerOpts := api.ServerOptions{Listener: listener} - env, err := server.InitializeForTest(cfg, serverOpts, apiServerOpts) + env, err := server.InitializeForTest(t, cfg, serverOpts, apiServerOpts) require.NoError(t, err) - require.NoError(t, env.SQLStore.Sync()) require.NotNil(t, env.SQLStore.Cfg) dbSec, err := env.SQLStore.Cfg.Raw.GetSection("database") @@ -99,21 +98,6 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes return addr, env } -// SetUpDatabase sets up the Grafana database. -func SetUpDatabase(t *testing.T, grafDir string) *sqlstore.SQLStore { - t.Helper() - - sqlStore := db.InitTestDB(t, sqlstore.InitTestDBOpt{ - EnsureDefaultOrgAndUser: true, - }) - - // Make sure changes are synced with other goroutines - err := sqlStore.Sync() - require.NoError(t, err) - - return sqlStore -} - // CreateGrafDir creates the Grafana directory. // The log by default is muted in the regression test, to activate it, pass option EnableLog = true func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { diff --git a/pkg/tests/testsuite/testsuite.go b/pkg/tests/testsuite/testsuite.go new file mode 100644 index 00000000000..87ce57eaa15 --- /dev/null +++ b/pkg/tests/testsuite/testsuite.go @@ -0,0 +1,15 @@ +package testsuite + +import ( + "os" + "testing" + + "github.com/grafana/grafana/pkg/infra/db" +) + +func Run(m *testing.M) { + db.SetupTestDB() + code := m.Run() + db.CleanupTestDB() + os.Exit(code) +} diff --git a/pkg/tests/web/index_view_test.go b/pkg/tests/web/index_view_test.go index e8208f083f2..330553c2638 100644 --- a/pkg/tests/web/index_view_test.go +++ b/pkg/tests/web/index_view_test.go @@ -19,8 +19,13 @@ import ( secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + // TestIntegrationIndexView tests the Grafana index view. func TestIntegrationIndexView(t *testing.T) { if testing.Short() { diff --git a/pkg/tsdb/azuremonitor/macros/macros.go b/pkg/tsdb/azuremonitor/macros/macros.go index 11304b7416d..953f5f5c914 100644 --- a/pkg/tsdb/azuremonitor/macros/macros.go +++ b/pkg/tsdb/azuremonitor/macros/macros.go @@ -8,9 +8,9 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/kinds/dataquery" - azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) @@ -126,7 +126,7 @@ func (m *kqlMacroEngine) evaluateMacro(name string, defaultTimeField string, arg if dsInterval, ok = dsInfo.JSONData["interval"].(string); !ok { dsInterval = "" } - it, err = azTime.GetIntervalFrom(dsInterval, queryInterval.Interval, queryInterval.IntervalMs, defaultInterval) + it, err = gtime.GetIntervalFrom(dsInterval, queryInterval.Interval, queryInterval.IntervalMs, defaultInterval) if err != nil { it = defaultInterval } diff --git a/pkg/tsdb/azuremonitor/time/interval.go b/pkg/tsdb/azuremonitor/time/interval.go deleted file mode 100644 index ed8a7587c8c..00000000000 --- a/pkg/tsdb/azuremonitor/time/interval.go +++ /dev/null @@ -1,93 +0,0 @@ -// Copied from https://github.com/grafana/grafana/blob/main/pkg/tsdb/intervalv2/intervalv2.go -// We're copying this to not block ourselves from decoupling until the conversation here is resolved -// https://raintank-corp.slack.com/archives/C05QFJUHUQ6/p1700064431005089 -package time - -import ( - "fmt" - "regexp" - "strings" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" -) - -var ( - year = time.Hour * 24 * 365 - day = time.Hour * 24 -) - -// GetIntervalFrom returns the minimum interval. -// dsInterval is the string representation of data source min interval, if configured. -// queryInterval is the string representation of query interval (min interval), e.g. "10ms" or "10s". -// queryIntervalMS is a pre-calculated numeric representation of the query interval in milliseconds. -func GetIntervalFrom(dsInterval, queryInterval string, queryIntervalMS int64, defaultInterval time.Duration) (time.Duration, error) { - // Apparently we are setting default value of queryInterval to 0s now - interval := queryInterval - if interval == "0s" { - interval = "" - } - if interval == "" { - if queryIntervalMS != 0 { - return time.Duration(queryIntervalMS) * time.Millisecond, nil - } - } - if interval == "" && dsInterval != "" { - interval = dsInterval - } - if interval == "" { - return defaultInterval, nil - } - - parsedInterval, err := ParseIntervalStringToTimeDuration(interval) - if err != nil { - return time.Duration(0), err - } - - return parsedInterval, nil -} - -func ParseIntervalStringToTimeDuration(interval string) (time.Duration, error) { - formattedInterval := strings.Replace(strings.Replace(interval, "<", "", 1), ">", "", 1) - isPureNum, err := regexp.MatchString(`^\d+$`, formattedInterval) - if err != nil { - return time.Duration(0), err - } - if isPureNum { - formattedInterval += "s" - } - parsedInterval, err := gtime.ParseDuration(formattedInterval) - if err != nil { - return time.Duration(0), err - } - return parsedInterval, nil -} - -// FormatDuration converts a duration into the kbn format e.g. 1m 2h or 3d -func FormatDuration(inter time.Duration) string { - if inter >= year { - return fmt.Sprintf("%dy", inter/year) - } - - if inter >= day { - return fmt.Sprintf("%dd", inter/day) - } - - if inter >= time.Hour { - return fmt.Sprintf("%dh", inter/time.Hour) - } - - if inter >= time.Minute { - return fmt.Sprintf("%dm", inter/time.Minute) - } - - if inter >= time.Second { - return fmt.Sprintf("%ds", inter/time.Second) - } - - if inter >= time.Millisecond { - return fmt.Sprintf("%dms", inter/time.Millisecond) - } - - return "1ms" -} diff --git a/pkg/tsdb/azuremonitor/time/time-grain.go b/pkg/tsdb/azuremonitor/time/time-grain.go index f7c5b1e4d1c..dfc62d9c1ff 100644 --- a/pkg/tsdb/azuremonitor/time/time-grain.go +++ b/pkg/tsdb/azuremonitor/time/time-grain.go @@ -5,6 +5,8 @@ import ( "strconv" "strings" "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" ) // TimeGrain handles conversions between @@ -15,7 +17,7 @@ var ( ) func CreateISO8601DurationFromIntervalMS(it int64) (string, error) { - formatted := FormatDuration(time.Duration(it) * time.Millisecond) + formatted := gtime.FormatInterval(time.Duration(it) * time.Millisecond) if strings.Contains(formatted, "ms") { return "PT1M", nil diff --git a/pkg/tsdb/cloud-monitoring/standalone/main.go b/pkg/tsdb/cloud-monitoring/standalone/main.go index 9d85afc8fb4..c1acbcad13e 100644 --- a/pkg/tsdb/cloud-monitoring/standalone/main.go +++ b/pkg/tsdb/cloud-monitoring/standalone/main.go @@ -16,7 +16,7 @@ func main() { // from Grafana to create different instances of SampleDatasource (per datasource // ID). When datasource configuration changed Dispose method will be called and // new datasource instance created using NewSampleDatasource factory. - if err := datasource.Manage("grafana-cloud-monitoring-datasource", NewDatasource, datasource.ManageOpts{}); err != nil { + if err := datasource.Manage("stackdriver", NewDatasource, datasource.ManageOpts{}); err != nil { log.DefaultLogger.Error(err.Error()) os.Exit(1) } diff --git a/pkg/tsdb/cloud-monitoring/time/interval.go b/pkg/tsdb/cloud-monitoring/time/interval.go index 3119d6fe624..c3a1c452f04 100644 --- a/pkg/tsdb/cloud-monitoring/time/interval.go +++ b/pkg/tsdb/cloud-monitoring/time/interval.go @@ -4,9 +4,6 @@ package time import ( - "fmt" - "regexp" - "strings" "time" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -16,8 +13,6 @@ import ( var ( DefaultRes int64 = 1500 defaultMinInterval = time.Millisecond * 1 - year = time.Hour * 24 * 365 - day = time.Hour * 24 ) type Interval struct { @@ -52,10 +47,6 @@ func NewCalculator(opts ...CalculatorOptions) *intervalCalculator { return calc } -func (i *Interval) Milliseconds() int64 { - return i.Value.Nanoseconds() / int64(time.Millisecond) -} - func (ic *intervalCalculator) Calculate(timerange backend.TimeRange, minInterval time.Duration, maxDataPoints int64) Interval { to := timerange.To.UnixNano() from := timerange.From.UnixNano() @@ -67,12 +58,12 @@ func (ic *intervalCalculator) Calculate(timerange backend.TimeRange, minInterval calculatedInterval := time.Duration((to - from) / resolution) if calculatedInterval < minInterval { - return Interval{Text: FormatDuration(minInterval), Value: minInterval} + return Interval{Text: gtime.FormatInterval(minInterval), Value: minInterval} } - rounded := roundInterval(calculatedInterval) + rounded := gtime.RoundInterval(calculatedInterval) - return Interval{Text: FormatDuration(rounded), Value: rounded} + return Interval{Text: gtime.FormatInterval(rounded), Value: rounded} } func (ic *intervalCalculator) CalculateSafeInterval(timerange backend.TimeRange, safeRes int64) Interval { @@ -80,179 +71,6 @@ func (ic *intervalCalculator) CalculateSafeInterval(timerange backend.TimeRange, from := timerange.From.UnixNano() safeInterval := time.Duration((to - from) / safeRes) - rounded := roundInterval(safeInterval) - return Interval{Text: FormatDuration(rounded), Value: rounded} -} - -// GetIntervalFrom returns the minimum interval. -// dsInterval is the string representation of data source min interval, if configured. -// queryInterval is the string representation of query interval (min interval), e.g. "10ms" or "10s". -// queryIntervalMS is a pre-calculated numeric representation of the query interval in milliseconds. -func GetIntervalFrom(dsInterval, queryInterval string, queryIntervalMS int64, defaultInterval time.Duration) (time.Duration, error) { - // Apparently we are setting default value of queryInterval to 0s now - interval := queryInterval - if interval == "0s" { - interval = "" - } - if interval == "" { - if queryIntervalMS != 0 { - return time.Duration(queryIntervalMS) * time.Millisecond, nil - } - } - if interval == "" && dsInterval != "" { - interval = dsInterval - } - if interval == "" { - return defaultInterval, nil - } - - parsedInterval, err := ParseIntervalStringToTimeDuration(interval) - if err != nil { - return time.Duration(0), err - } - - return parsedInterval, nil -} - -func ParseIntervalStringToTimeDuration(interval string) (time.Duration, error) { - formattedInterval := strings.Replace(strings.Replace(interval, "<", "", 1), ">", "", 1) - isPureNum, err := regexp.MatchString(`^\d+$`, formattedInterval) - if err != nil { - return time.Duration(0), err - } - if isPureNum { - formattedInterval += "s" - } - parsedInterval, err := gtime.ParseDuration(formattedInterval) - if err != nil { - return time.Duration(0), err - } - return parsedInterval, nil -} - -// FormatDuration converts a duration into the kbn format e.g. 1m 2h or 3d -func FormatDuration(inter time.Duration) string { - if inter >= year { - return fmt.Sprintf("%dy", inter/year) - } - - if inter >= day { - return fmt.Sprintf("%dd", inter/day) - } - - if inter >= time.Hour { - return fmt.Sprintf("%dh", inter/time.Hour) - } - - if inter >= time.Minute { - return fmt.Sprintf("%dm", inter/time.Minute) - } - - if inter >= time.Second { - return fmt.Sprintf("%ds", inter/time.Second) - } - - if inter >= time.Millisecond { - return fmt.Sprintf("%dms", inter/time.Millisecond) - } - - return "1ms" -} - -//nolint:gocyclo -func roundInterval(interval time.Duration) time.Duration { - switch { - // 0.01s - case interval <= 10*time.Millisecond: - return time.Millisecond * 1 // 0.001s - // 0.015s - case interval <= 15*time.Millisecond: - return time.Millisecond * 10 // 0.01s - // 0.035s - case interval <= 35*time.Millisecond: - return time.Millisecond * 20 // 0.02s - // 0.075s - case interval <= 75*time.Millisecond: - return time.Millisecond * 50 // 0.05s - // 0.15s - case interval <= 150*time.Millisecond: - return time.Millisecond * 100 // 0.1s - // 0.35s - case interval <= 350*time.Millisecond: - return time.Millisecond * 200 // 0.2s - // 0.75s - case interval <= 750*time.Millisecond: - return time.Millisecond * 500 // 0.5s - // 1.5s - case interval <= 1500*time.Millisecond: - return time.Millisecond * 1000 // 1s - // 3.5s - case interval <= 3500*time.Millisecond: - return time.Millisecond * 2000 // 2s - // 7.5s - case interval <= 7500*time.Millisecond: - return time.Millisecond * 5000 // 5s - // 12.5s - case interval <= 12500*time.Millisecond: - return time.Millisecond * 10000 // 10s - // 17.5s - case interval <= 17500*time.Millisecond: - return time.Millisecond * 15000 // 15s - // 25s - case interval <= 25000*time.Millisecond: - return time.Millisecond * 20000 // 20s - // 45s - case interval <= 45000*time.Millisecond: - return time.Millisecond * 30000 // 30s - // 1.5m - case interval <= 90000*time.Millisecond: - return time.Millisecond * 60000 // 1m - // 3.5m - case interval <= 210000*time.Millisecond: - return time.Millisecond * 120000 // 2m - // 7.5m - case interval <= 450000*time.Millisecond: - return time.Millisecond * 300000 // 5m - // 12.5m - case interval <= 750000*time.Millisecond: - return time.Millisecond * 600000 // 10m - // 17.5m - case interval <= 1050000*time.Millisecond: - return time.Millisecond * 900000 // 15m - // 25m - case interval <= 1500000*time.Millisecond: - return time.Millisecond * 1200000 // 20m - // 45m - case interval <= 2700000*time.Millisecond: - return time.Millisecond * 1800000 // 30m - // 1.5h - case interval <= 5400000*time.Millisecond: - return time.Millisecond * 3600000 // 1h - // 2.5h - case interval <= 9000000*time.Millisecond: - return time.Millisecond * 7200000 // 2h - // 4.5h - case interval <= 16200000*time.Millisecond: - return time.Millisecond * 10800000 // 3h - // 9h - case interval <= 32400000*time.Millisecond: - return time.Millisecond * 21600000 // 6h - // 24h - case interval <= 86400000*time.Millisecond: - return time.Millisecond * 43200000 // 12h - // 48h - case interval <= 172800000*time.Millisecond: - return time.Millisecond * 86400000 // 24h - // 1w - case interval <= 604800000*time.Millisecond: - return time.Millisecond * 86400000 // 24h - // 3w - case interval <= 1814400000*time.Millisecond: - return time.Millisecond * 604800000 // 1w - // 2y - case interval < 3628800000*time.Millisecond: - return time.Millisecond * 2592000000 // 30d - default: - return time.Millisecond * 31536000000 // 1y - } + rounded := gtime.RoundInterval(safeInterval) + return Interval{Text: gtime.FormatInterval(rounded), Value: rounded} } diff --git a/pkg/tsdb/cloud-monitoring/utils.go b/pkg/tsdb/cloud-monitoring/utils.go index aa03c7fc226..3b719ff2c7e 100644 --- a/pkg/tsdb/cloud-monitoring/utils.go +++ b/pkg/tsdb/cloud-monitoring/utils.go @@ -14,17 +14,17 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" "github.com/grafana/grafana-plugin-sdk-go/data" - gcmTime "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring/time" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) func addInterval(period string, field *data.Field) error { period = strings.TrimPrefix(period, "+") - p, err := gcmTime.ParseIntervalStringToTimeDuration(period) + p, err := gtime.ParseIntervalStringToTimeDuration(period) if err != nil { return err } diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres.go b/pkg/tsdb/grafana-postgresql-datasource/postgres.go index 911b9fe5e9d..e66989879e5 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres.go @@ -90,7 +90,7 @@ func newPostgres(cfg *setting.Cfg, dsInfo sqleng.DataSourceInfo, cnnstr string, db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns) db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second) - handler, err := sqleng.NewQueryDataHandler(cfg, db, config, &queryResultTransformer, newPostgresMacroEngine(dsInfo.JsonData.Timescaledb), + handler, err := sqleng.NewQueryDataHandler(cfg.UserFacingDefaultError, db, config, &queryResultTransformer, newPostgresMacroEngine(dsInfo.JsonData.Timescaledb), logger) if err != nil { logger.Error("Failed connecting to Postgres", "err", err) diff --git a/pkg/tsdb/influxdb/flux/macros.go b/pkg/tsdb/influxdb/flux/macros.go index ed3a34d2d20..4dce9d76822 100644 --- a/pkg/tsdb/influxdb/flux/macros.go +++ b/pkg/tsdb/influxdb/flux/macros.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" ) // $__interval_ms is the exact value in milliseconds @@ -15,7 +15,7 @@ import ( func interpolateInterval(flux string, interval time.Duration) string { intervalMs := int64(interval / time.Millisecond) - intervalText := intervalv2.FormatDuration(interval) + intervalText := gtime.FormatInterval(interval) flux = strings.ReplaceAll(flux, "$__interval_ms", strconv.FormatInt(intervalMs, 10)) flux = strings.ReplaceAll(flux, "$__interval", intervalText) diff --git a/pkg/tsdb/influxdb/models/query.go b/pkg/tsdb/influxdb/models/query.go index 64f3331a37f..fc6024fe074 100644 --- a/pkg/tsdb/influxdb/models/query.go +++ b/pkg/tsdb/influxdb/models/query.go @@ -8,8 +8,7 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" - - "github.com/grafana/grafana/pkg/tsdb/intervalv2" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" ) var ( @@ -36,7 +35,7 @@ func (query *Query) Build(queryContext *backend.QueryDataRequest) (string, error res += query.renderTz() } - intervalText := intervalv2.FormatDuration(query.Interval) + intervalText := gtime.FormatInterval(query.Interval) intervalMs := int64(query.Interval / time.Millisecond) res = strings.ReplaceAll(res, "$timeFilter", query.renderTimeFilter(queryContext)) diff --git a/pkg/tsdb/intervalv2/intervalv2.go b/pkg/tsdb/intervalv2/intervalv2.go deleted file mode 100644 index 9aadc011802..00000000000 --- a/pkg/tsdb/intervalv2/intervalv2.go +++ /dev/null @@ -1,228 +0,0 @@ -package intervalv2 - -import ( - "regexp" - "strings" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" -) - -var ( - DefaultRes int64 = 1500 - defaultMinInterval = time.Millisecond * 1 -) - -type Interval struct { - Text string - Value time.Duration -} - -type intervalCalculator struct { - minInterval time.Duration -} - -type Calculator interface { - Calculate(timerange backend.TimeRange, minInterval time.Duration, maxDataPoints int64) Interval - CalculateSafeInterval(timerange backend.TimeRange, resolution int64) Interval -} - -type CalculatorOptions struct { - MinInterval time.Duration -} - -func NewCalculator(opts ...CalculatorOptions) *intervalCalculator { - calc := &intervalCalculator{} - - for _, o := range opts { - if o.MinInterval == 0 { - calc.minInterval = defaultMinInterval - } else { - calc.minInterval = o.MinInterval - } - } - - return calc -} - -func (i *Interval) Milliseconds() int64 { - return i.Value.Nanoseconds() / int64(time.Millisecond) -} - -func (ic *intervalCalculator) Calculate(timerange backend.TimeRange, minInterval time.Duration, maxDataPoints int64) Interval { - to := timerange.To.UnixNano() - from := timerange.From.UnixNano() - resolution := maxDataPoints - if resolution == 0 { - resolution = DefaultRes - } - - calculatedInterval := time.Duration((to - from) / resolution) - - if calculatedInterval < minInterval { - return Interval{Text: FormatDuration(minInterval), Value: minInterval} - } - - rounded := roundInterval(calculatedInterval) - - return Interval{Text: FormatDuration(rounded), Value: rounded} -} - -func (ic *intervalCalculator) CalculateSafeInterval(timerange backend.TimeRange, safeRes int64) Interval { - to := timerange.To.UnixNano() - from := timerange.From.UnixNano() - safeInterval := time.Duration((to - from) / safeRes) - - rounded := roundInterval(safeInterval) - return Interval{Text: FormatDuration(rounded), Value: rounded} -} - -// GetIntervalFrom returns the minimum interval. -// dsInterval is the string representation of data source min interval, if configured. -// queryInterval is the string representation of query interval (min interval), e.g. "10ms" or "10s". -// queryIntervalMS is a pre-calculated numeric representation of the query interval in milliseconds. -func GetIntervalFrom(dsInterval, queryInterval string, queryIntervalMS int64, defaultInterval time.Duration) (time.Duration, error) { - // Apparently we are setting default value of queryInterval to 0s now - interval := queryInterval - if interval == "0s" { - interval = "" - } - if interval == "" { - if queryIntervalMS != 0 { - return time.Duration(queryIntervalMS) * time.Millisecond, nil - } - } - if interval == "" && dsInterval != "" { - interval = dsInterval - } - if interval == "" { - return defaultInterval, nil - } - - parsedInterval, err := ParseIntervalStringToTimeDuration(interval) - if err != nil { - return time.Duration(0), err - } - - return parsedInterval, nil -} - -func ParseIntervalStringToTimeDuration(interval string) (time.Duration, error) { - formattedInterval := strings.Replace(strings.Replace(interval, "<", "", 1), ">", "", 1) - isPureNum, err := regexp.MatchString(`^\d+$`, formattedInterval) - if err != nil { - return time.Duration(0), err - } - if isPureNum { - formattedInterval += "s" - } - parsedInterval, err := gtime.ParseDuration(formattedInterval) - if err != nil { - return time.Duration(0), err - } - return parsedInterval, nil -} - -// FormatDuration converts a duration into the kbn format e.g. 1m 2h or 3d -func FormatDuration(inter time.Duration) string { - return gtime.FormatInterval(inter) -} - -//nolint:gocyclo -func roundInterval(interval time.Duration) time.Duration { - switch { - // 0.01s - case interval <= 10*time.Millisecond: - return time.Millisecond * 1 // 0.001s - // 0.015s - case interval <= 15*time.Millisecond: - return time.Millisecond * 10 // 0.01s - // 0.035s - case interval <= 35*time.Millisecond: - return time.Millisecond * 20 // 0.02s - // 0.075s - case interval <= 75*time.Millisecond: - return time.Millisecond * 50 // 0.05s - // 0.15s - case interval <= 150*time.Millisecond: - return time.Millisecond * 100 // 0.1s - // 0.35s - case interval <= 350*time.Millisecond: - return time.Millisecond * 200 // 0.2s - // 0.75s - case interval <= 750*time.Millisecond: - return time.Millisecond * 500 // 0.5s - // 1.5s - case interval <= 1500*time.Millisecond: - return time.Millisecond * 1000 // 1s - // 3.5s - case interval <= 3500*time.Millisecond: - return time.Millisecond * 2000 // 2s - // 7.5s - case interval <= 7500*time.Millisecond: - return time.Millisecond * 5000 // 5s - // 12.5s - case interval <= 12500*time.Millisecond: - return time.Millisecond * 10000 // 10s - // 17.5s - case interval <= 17500*time.Millisecond: - return time.Millisecond * 15000 // 15s - // 25s - case interval <= 25000*time.Millisecond: - return time.Millisecond * 20000 // 20s - // 45s - case interval <= 45000*time.Millisecond: - return time.Millisecond * 30000 // 30s - // 1.5m - case interval <= 90000*time.Millisecond: - return time.Millisecond * 60000 // 1m - // 3.5m - case interval <= 210000*time.Millisecond: - return time.Millisecond * 120000 // 2m - // 7.5m - case interval <= 450000*time.Millisecond: - return time.Millisecond * 300000 // 5m - // 12.5m - case interval <= 750000*time.Millisecond: - return time.Millisecond * 600000 // 10m - // 17.5m - case interval <= 1050000*time.Millisecond: - return time.Millisecond * 900000 // 15m - // 25m - case interval <= 1500000*time.Millisecond: - return time.Millisecond * 1200000 // 20m - // 45m - case interval <= 2700000*time.Millisecond: - return time.Millisecond * 1800000 // 30m - // 1.5h - case interval <= 5400000*time.Millisecond: - return time.Millisecond * 3600000 // 1h - // 2.5h - case interval <= 9000000*time.Millisecond: - return time.Millisecond * 7200000 // 2h - // 4.5h - case interval <= 16200000*time.Millisecond: - return time.Millisecond * 10800000 // 3h - // 9h - case interval <= 32400000*time.Millisecond: - return time.Millisecond * 21600000 // 6h - // 24h - case interval <= 86400000*time.Millisecond: - return time.Millisecond * 43200000 // 12h - // 48h - case interval <= 172800000*time.Millisecond: - return time.Millisecond * 86400000 // 24h - // 1w - case interval <= 604800000*time.Millisecond: - return time.Millisecond * 86400000 // 24h - // 3w - case interval <= 1814400000*time.Millisecond: - return time.Millisecond * 604800000 // 1w - // 2y - case interval < 3628800000*time.Millisecond: - return time.Millisecond * 2592000000 // 30d - default: - return time.Millisecond * 31536000000 // 1y - } -} diff --git a/pkg/tsdb/legacydata/interval/interval.go b/pkg/tsdb/legacydata/interval/interval.go index 2ab255a3c5f..7f9769f4717 100644 --- a/pkg/tsdb/legacydata/interval/interval.go +++ b/pkg/tsdb/legacydata/interval/interval.go @@ -1,7 +1,6 @@ package interval import ( - "fmt" "regexp" "strings" "time" @@ -16,8 +15,6 @@ import ( var ( DefaultRes int64 = 1500 defaultMinInterval = time.Millisecond * 1 - year = time.Hour * 24 * 365 - day = time.Hour * 24 ) type Interval struct { @@ -62,11 +59,11 @@ func (ic *intervalCalculator) Calculate(timerange legacydata.DataTimeRange, minI calculatedInterval := time.Duration((to - from) / DefaultRes) if calculatedInterval < minInterval { - return Interval{Text: FormatDuration(minInterval), Value: minInterval} + return Interval{Text: gtime.FormatInterval(minInterval), Value: minInterval} } rounded := roundInterval(calculatedInterval) - return Interval{Text: FormatDuration(rounded), Value: rounded} + return Interval{Text: gtime.FormatInterval(rounded), Value: rounded} } func (ic *intervalCalculator) CalculateSafeInterval(timerange legacydata.DataTimeRange, safeRes int64) Interval { @@ -75,7 +72,7 @@ func (ic *intervalCalculator) CalculateSafeInterval(timerange legacydata.DataTim safeInterval := time.Duration((to - from) / safeRes) rounded := roundInterval(safeInterval) - return Interval{Text: FormatDuration(rounded), Value: rounded} + return Interval{Text: gtime.FormatInterval(rounded), Value: rounded} } func GetIntervalFrom(dsInfo *datasources.DataSource, queryModel *simplejson.Json, defaultInterval time.Duration) (time.Duration, error) { @@ -117,126 +114,11 @@ func GetIntervalFrom(dsInfo *datasources.DataSource, queryModel *simplejson.Json return parsedInterval, nil } -// FormatDuration converts a duration into the kbn format e.g. 1m 2h or 3d -func FormatDuration(inter time.Duration) string { - if inter >= year { - return fmt.Sprintf("%dy", inter/year) - } - - if inter >= day { - return fmt.Sprintf("%dd", inter/day) - } - - if inter >= time.Hour { - return fmt.Sprintf("%dh", inter/time.Hour) - } - - if inter >= time.Minute { - return fmt.Sprintf("%dm", inter/time.Minute) - } - - if inter >= time.Second { - return fmt.Sprintf("%ds", inter/time.Second) - } - - if inter >= time.Millisecond { - return fmt.Sprintf("%dms", inter/time.Millisecond) - } - - return "1ms" -} - //nolint:gocyclo func roundInterval(interval time.Duration) time.Duration { - switch { // 0.015s - case interval <= 15*time.Millisecond: + if interval <= 15*time.Millisecond { return time.Millisecond * 10 // 0.01s - // 0.035s - case interval <= 35*time.Millisecond: - return time.Millisecond * 20 // 0.02s - // 0.075s - case interval <= 75*time.Millisecond: - return time.Millisecond * 50 // 0.05s - // 0.15s - case interval <= 150*time.Millisecond: - return time.Millisecond * 100 // 0.1s - // 0.35s - case interval <= 350*time.Millisecond: - return time.Millisecond * 200 // 0.2s - // 0.75s - case interval <= 750*time.Millisecond: - return time.Millisecond * 500 // 0.5s - // 1.5s - case interval <= 1500*time.Millisecond: - return time.Millisecond * 1000 // 1s - // 3.5s - case interval <= 3500*time.Millisecond: - return time.Millisecond * 2000 // 2s - // 7.5s - case interval <= 7500*time.Millisecond: - return time.Millisecond * 5000 // 5s - // 12.5s - case interval <= 12500*time.Millisecond: - return time.Millisecond * 10000 // 10s - // 17.5s - case interval <= 17500*time.Millisecond: - return time.Millisecond * 15000 // 15s - // 25s - case interval <= 25000*time.Millisecond: - return time.Millisecond * 20000 // 20s - // 45s - case interval <= 45000*time.Millisecond: - return time.Millisecond * 30000 // 30s - // 1.5m - case interval <= 90000*time.Millisecond: - return time.Millisecond * 60000 // 1m - // 3.5m - case interval <= 210000*time.Millisecond: - return time.Millisecond * 120000 // 2m - // 7.5m - case interval <= 450000*time.Millisecond: - return time.Millisecond * 300000 // 5m - // 12.5m - case interval <= 750000*time.Millisecond: - return time.Millisecond * 600000 // 10m - // 12.5m - case interval <= 1050000*time.Millisecond: - return time.Millisecond * 900000 // 15m - // 25m - case interval <= 1500000*time.Millisecond: - return time.Millisecond * 1200000 // 20m - // 45m - case interval <= 2700000*time.Millisecond: - return time.Millisecond * 1800000 // 30m - // 1.5h - case interval <= 5400000*time.Millisecond: - return time.Millisecond * 3600000 // 1h - // 2.5h - case interval <= 9000000*time.Millisecond: - return time.Millisecond * 7200000 // 2h - // 4.5h - case interval <= 16200000*time.Millisecond: - return time.Millisecond * 10800000 // 3h - // 9h - case interval <= 32400000*time.Millisecond: - return time.Millisecond * 21600000 // 6h - // 24h - case interval <= 86400000*time.Millisecond: - return time.Millisecond * 43200000 // 12h - // 48h - case interval <= 172800000*time.Millisecond: - return time.Millisecond * 86400000 // 24h - // 1w - case interval <= 604800000*time.Millisecond: - return time.Millisecond * 86400000 // 24h - // 3w - case interval <= 1814400000*time.Millisecond: - return time.Millisecond * 604800000 // 1w - // 2y - case interval < 3628800000*time.Millisecond: - return time.Millisecond * 2592000000 // 30d - default: - return time.Millisecond * 31536000000 // 1y } + return gtime.RoundInterval(interval) } diff --git a/pkg/tsdb/legacydata/interval/interval_test.go b/pkg/tsdb/legacydata/interval/interval_test.go index fdd5deb2519..b76c36a6774 100644 --- a/pkg/tsdb/legacydata/interval/interval_test.go +++ b/pkg/tsdb/legacydata/interval/interval_test.go @@ -74,26 +74,6 @@ func TestRoundInterval(t *testing.T) { } } -func TestFormatDuration(t *testing.T) { - testCases := []struct { - name string - duration time.Duration - expected string - }{ - {"61s", time.Second * 61, "1m"}, - {"30ms", time.Millisecond * 30, "30ms"}, - {"23h", time.Hour * 23, "23h"}, - {"24h", time.Hour * 24, "1d"}, - {"367d", time.Hour * 24 * 367, "1y"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.expected, FormatDuration(tc.duration)) - }) - } -} - func TestGetIntervalFrom(t *testing.T) { dsJSON, err := simplejson.NewJson([]byte(`{"timeInterval": "60s"}`)) require.NoError(t, err) diff --git a/pkg/tsdb/legacydata/service/service_test.go b/pkg/tsdb/legacydata/service/service_test.go index 89d8b7c0c74..0abac279e1a 100644 --- a/pkg/tsdb/legacydata/service/service_test.go +++ b/pkg/tsdb/legacydata/service/service_test.go @@ -27,9 +27,14 @@ import ( secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/tsdb/legacydata" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestHandleRequest(t *testing.T) { t.Run("Should invoke plugin manager QueryData when handling request for query", func(t *testing.T) { client := &fakePluginsClient{} diff --git a/pkg/tsdb/loki/parse_query.go b/pkg/tsdb/loki/parse_query.go index 5e1c8e44d0b..60ace2430c8 100644 --- a/pkg/tsdb/loki/parse_query.go +++ b/pkg/tsdb/loki/parse_query.go @@ -8,8 +8,8 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" "github.com/grafana/grafana/pkg/tsdb/loki/kinds/dataquery" ) @@ -32,8 +32,8 @@ const ( ) func interpolateVariables(expr string, interval time.Duration, timeRange time.Duration, queryType dataquery.LokiQueryType, step time.Duration) string { - intervalText := intervalv2.FormatDuration(interval) - stepText := intervalv2.FormatDuration(step) + intervalText := gtime.FormatInterval(interval) + stepText := gtime.FormatInterval(step) intervalMsText := strconv.FormatInt(int64(interval/time.Millisecond), 10) rangeMs := timeRange.Milliseconds() diff --git a/pkg/tsdb/loki/step.go b/pkg/tsdb/loki/step.go index df66ebef466..4023abfe1db 100644 --- a/pkg/tsdb/loki/step.go +++ b/pkg/tsdb/loki/step.go @@ -4,7 +4,7 @@ import ( "math" "time" - "github.com/grafana/grafana/pkg/tsdb/intervalv2" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" ) // round the duration to the nearest millisecond larger-or-equal-to the duration @@ -31,7 +31,7 @@ func calculateStep(interval time.Duration, timeRange time.Duration, resolution i return ceilMs(chosenStep), nil } - step, err := intervalv2.ParseIntervalStringToTimeDuration(*queryStep) + step, err := gtime.ParseIntervalStringToTimeDuration(*queryStep) if err != nil { return step, err } diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index 9c8c4d3520d..9319c5f34de 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -145,7 +145,7 @@ func newInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns) db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second) - return sqleng.NewQueryDataHandler(cfg, db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) + return sqleng.NewQueryDataHandler(cfg.UserFacingDefaultError, db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) } } diff --git a/pkg/tsdb/mssql/mssql_test.go b/pkg/tsdb/mssql/mssql_test.go index df9e2f3903c..9c54e213d81 100644 --- a/pkg/tsdb/mssql/mssql_test.go +++ b/pkg/tsdb/mssql/mssql_test.go @@ -5,7 +5,7 @@ import ( "database/sql" "fmt" "math/rand" - "strings" + "os" "testing" "time" @@ -15,8 +15,6 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/sqleng" ) @@ -51,7 +49,7 @@ func TestMSSQL(t *testing.T) { db := initMSSQLTestDB(t, config.DSInfo.JsonData) - endpoint, err := sqleng.NewQueryDataHandler(setting.NewCfg(), db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) + endpoint, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) require.NoError(t, err) fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) @@ -804,7 +802,7 @@ func TestMSSQL(t *testing.T) { MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"}, RowLimit: 1000000, } - endpoint, err := sqleng.NewQueryDataHandler(setting.NewCfg(), db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) + endpoint, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) require.NoError(t, err) query := &backend.QueryDataRequest{ Queries: []backend.DataQuery{ @@ -1208,7 +1206,7 @@ func TestMSSQL(t *testing.T) { RowLimit: 1, } - handler, err := sqleng.NewQueryDataHandler(setting.NewCfg(), db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) + handler, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger) require.NoError(t, err) t.Run("When doing a table query that returns 2 rows should limit the result to 1 row", func(t *testing.T) { @@ -1475,9 +1473,16 @@ func TestGenerateConnectionString(t *testing.T) { func initMSSQLTestDB(t *testing.T, jsonData sqleng.JsonData) *sql.DB { t.Helper() - testDB := sqlutil.MSSQLTestDB() - db, err := sql.Open(testDB.DriverName, strings.Replace(testDB.ConnStr, "localhost", - serverIP, 1)) + host := os.Getenv("MSSQL_HOST") + if host == "" { + host = serverIP + } + port := os.Getenv("MSSQL_PORT") + if port == "" { + port = "1433" + } + + db, err := sql.Open("mssql", fmt.Sprintf("server=%s;port=%s;database=grafanatest;user id=grafana;password=Password!", host, port)) require.NoError(t, err) db.SetMaxOpenConns(jsonData.MaxOpenConns) diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 87bdd789527..9de5c50cef2 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -154,7 +154,7 @@ func newInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns) db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second) - return sqleng.NewQueryDataHandler(cfg, db, config, &rowTransformer, newMysqlMacroEngine(logger, cfg), logger) + return sqleng.NewQueryDataHandler(cfg.UserFacingDefaultError, db, config, &rowTransformer, newMysqlMacroEngine(logger, cfg), logger) } } diff --git a/pkg/tsdb/mysql/mysql_test.go b/pkg/tsdb/mysql/mysql_test.go index b5802883ad9..c41766db6c6 100644 --- a/pkg/tsdb/mysql/mysql_test.go +++ b/pkg/tsdb/mysql/mysql_test.go @@ -67,7 +67,7 @@ func TestIntegrationMySQL(t *testing.T) { db := InitMySQLTestDB(t, config.DSInfo.JsonData) - exe, err := sqleng.NewQueryDataHandler(setting.NewCfg(), db, config, &rowTransformer, newMysqlMacroEngine(logger, setting.NewCfg()), logger) + exe, err := sqleng.NewQueryDataHandler("", db, config, &rowTransformer, newMysqlMacroEngine(logger, setting.NewCfg()), logger) require.NoError(t, err) @@ -1179,7 +1179,7 @@ func TestIntegrationMySQL(t *testing.T) { queryResultTransformer := mysqlQueryResultTransformer{} - handler, err := sqleng.NewQueryDataHandler(setting.NewCfg(), db, config, &queryResultTransformer, newMysqlMacroEngine(logger, setting.NewCfg()), logger) + handler, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMysqlMacroEngine(logger, setting.NewCfg()), logger) require.NoError(t, err) t.Run("When doing a table query that returns 2 rows should limit the result to 1 row", func(t *testing.T) { diff --git a/pkg/tsdb/sqleng/sql_engine.go b/pkg/tsdb/sqleng/sql_engine.go index 319232fca82..543ceec0375 100644 --- a/pkg/tsdb/sqleng/sql_engine.go +++ b/pkg/tsdb/sqleng/sql_engine.go @@ -20,7 +20,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/setting" ) // MetaKeyExecutedQueryString is the key where the executed query should get stored @@ -115,7 +114,7 @@ func (e *DataSourceHandler) TransformQueryError(logger log.Logger, err error) er return e.queryResultTransformer.TransformQueryError(logger, err) } -func NewQueryDataHandler(cfg *setting.Cfg, db *sql.DB, config DataPluginConfiguration, queryResultTransformer SqlQueryResultTransformer, +func NewQueryDataHandler(userFacingDefaultError string, db *sql.DB, config DataPluginConfiguration, queryResultTransformer SqlQueryResultTransformer, macroEngine SQLMacroEngine, log log.Logger) (*DataSourceHandler, error) { queryDataHandler := DataSourceHandler{ queryResultTransformer: queryResultTransformer, @@ -124,7 +123,7 @@ func NewQueryDataHandler(cfg *setting.Cfg, db *sql.DB, config DataPluginConfigur log: log, dsInfo: config.DSInfo, rowLimit: config.RowLimit, - userError: cfg.UserFacingDefaultError, + userError: userFacingDefaultError, } if len(config.TimeColumnNames) > 0 { diff --git a/pkg/util/json.go b/pkg/util/json.go index 7268ff93798..b1e7503bf51 100644 --- a/pkg/util/json.go +++ b/pkg/util/json.go @@ -1,4 +1,112 @@ package util +import ( + "encoding/json" + + "github.com/jmespath/go-jmespath" + + "github.com/grafana/grafana/pkg/util/errutil" +) + // DynMap defines a dynamic map interface. type DynMap map[string]any + +var ( + // ErrEmptyJSON is an error for empty attribute in JSON. + ErrEmptyJSON = errutil.NewBase(errutil.StatusBadRequest, + "json-missing-body", errutil.WithPublicMessage("Empty JSON provided")) + + // ErrNoAttributePathSpecified is an error for no attribute path specified. + ErrNoAttributePathSpecified = errutil.NewBase(errutil.StatusBadRequest, + "json-no-attribute-path-specified", errutil.WithPublicMessage("No attribute path specified")) + + // ErrFailedToUnmarshalJSON is an error for failure in unmarshalling JSON. + ErrFailedToUnmarshalJSON = errutil.NewBase(errutil.StatusBadRequest, + "json-failed-to-unmarshal", errutil.WithPublicMessage("Failed to unmarshal JSON")) + + // ErrFailedToSearchJSON is an error for failure in searching JSON. + ErrFailedToSearchJSON = errutil.NewBase(errutil.StatusBadRequest, + "json-failed-to-search", errutil.WithPublicMessage("Failed to search JSON with provided path")) +) + +// SearchJSONForStringSliceAttr searches for a slice attribute in a JSON object and returns a string slice. +// The attributePath parameter is a string that specifies the path to the attribute. +// The data parameter is the JSON object that we're searching. It can be a byte slice or a go type. +func SearchJSONForStringSliceAttr(attributePath string, data any) ([]string, error) { + val, err := searchJSONForAttr(attributePath, data) + if err != nil { + return []string{}, err + } + + ifArr, ok := val.([]any) + if !ok { + return []string{}, nil + } + + result := []string{} + for _, v := range ifArr { + if strVal, ok := v.(string); ok { + result = append(result, strVal) + } + } + + return result, nil +} + +// SearchJSONForStringAttr searches for a specific attribute in a JSON object and returns a string. +// The attributePath parameter is a string that specifies the path to the attribute. +// The data parameter is the JSON object that we're searching. It can be a byte slice or a go type. +func SearchJSONForStringAttr(attributePath string, data any) (string, error) { + val, err := searchJSONForAttr(attributePath, data) + if err != nil { + return "", err + } + + strVal, ok := val.(string) + if ok { + return strVal, nil + } + + return "", nil +} + +// searchJSONForAttr searches for a specific attribute in a JSON object. +// The attributePath parameter is a string that specifies the path to the attribute. +// The data parameter is the JSON object that we're searching. +// The function returns the value of the attribute and an error if one occurred. +func searchJSONForAttr(attributePath string, data any) (any, error) { + // If no attribute path is specified, return an error + if attributePath == "" { + return "", ErrNoAttributePathSpecified.Errorf("attribute path: %q", attributePath) + } + + // If the data is nil, return an error + if data == nil { + return "", ErrEmptyJSON.Errorf("empty json, attribute path: %q", attributePath) + } + + // Copy the data to a new variable + var jsonData = data + + // If the data is a byte slice, try to unmarshal it into a JSON object + if dataBytes, ok := data.([]byte); ok { + // If the byte slice is empty, return an error + if len(dataBytes) == 0 { + return "", ErrEmptyJSON.Errorf("empty json, attribute path: %q", attributePath) + } + + // Try to unmarshal the byte slice + if err := json.Unmarshal(dataBytes, &jsonData); err != nil { + return "", ErrFailedToUnmarshalJSON.Errorf("%v: %w", "failed to unmarshal user info JSON response", err) + } + } + + // Search for the attribute in the JSON object + value, err := jmespath.Search(attributePath, jsonData) + if err != nil { + return "", ErrFailedToSearchJSON.Errorf("failed to search user info JSON response with provided path: %q: %w", attributePath, err) + } + + // Return the value and nil error + return value, nil +} diff --git a/pkg/util/json_test.go b/pkg/util/json_test.go new file mode 100644 index 00000000000..232043a05f5 --- /dev/null +++ b/pkg/util/json_test.go @@ -0,0 +1,155 @@ +package util_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/util" +) + +func TestSearchJSONForGroups(t *testing.T) { + t.Parallel() + tests := []struct { + Name string + searchObject any + GroupsAttributePath string + ExpectedResult []string + ExpectedError error + }{ + { + Name: "Given an invalid user info JSON response", + searchObject: []byte("{"), + GroupsAttributePath: "attributes.groups", + ExpectedResult: []string{}, + ExpectedError: util.ErrFailedToUnmarshalJSON, + }, + { + Name: "Given an empty user info JSON response and empty JMES path", + searchObject: []byte{}, + GroupsAttributePath: "", + ExpectedResult: []string{}, + ExpectedError: util.ErrNoAttributePathSpecified, + }, + { + Name: "Given an empty user info JSON response and valid JMES path", + searchObject: []byte{}, + GroupsAttributePath: "attributes.groups", + ExpectedResult: []string{}, + ExpectedError: util.ErrEmptyJSON, + }, + { + Name: "Given a nil JSON and valid JMES path", + searchObject: []byte{}, + GroupsAttributePath: "attributes.groups", + ExpectedResult: []string{}, + ExpectedError: util.ErrEmptyJSON, + }, + { + Name: "Given a simple user info JSON response and valid JMES path", + searchObject: []byte(`{ + "attributes": { + "groups": ["foo", "bar"] + } +}`), + GroupsAttributePath: "attributes.groups[]", + ExpectedResult: []string{"foo", "bar"}, + }, + { + Name: "Given a simple object and valid JMES path", + searchObject: map[string]any{ + "attributes": map[string]any{ + "groups": []string{"foo", "bar"}, + }, + }, + GroupsAttributePath: "attributes.groups[]", + ExpectedResult: []string{"foo", "bar"}, + }, + } + + for _, test := range tests { + test := test + t.Run(test.Name, func(t *testing.T) { + t.Parallel() + actualResult, err := util.SearchJSONForStringSliceAttr( + test.GroupsAttributePath, test.searchObject) + if test.ExpectedError == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.ExpectedError) + } + require.Equal(t, test.ExpectedResult, actualResult) + }) + } +} + +func TestSearchJSONForEmail(t *testing.T) { + t.Parallel() + tests := []struct { + Name string + UserInfoJSONResponse any + EmailAttributePath string + ExpectedResult string + ExpectedError error + }{ + { + Name: "Given a simple user info JSON response and valid JMES path", + UserInfoJSONResponse: []byte(`{ + "attributes": { + "email": "grafana@localhost" + } +}`), + EmailAttributePath: "attributes.email", + ExpectedResult: "grafana@localhost", + }, + { + Name: "Given a simple object and valid JMES path", + UserInfoJSONResponse: map[string]any{ + "attributes": map[string]any{ + "email": "grafana@localhost", + }, + }, + EmailAttributePath: "attributes.email", + ExpectedResult: "grafana@localhost", + }, + { + Name: "Given a user info JSON response with e-mails array and valid JMES path", + UserInfoJSONResponse: []byte(`{ + "attributes": { + "emails": ["grafana@localhost", "admin@localhost"] + } +}`), + EmailAttributePath: "attributes.emails[0]", + ExpectedResult: "grafana@localhost", + }, + { + Name: "Given a nested user info JSON response and valid JMES path", + UserInfoJSONResponse: []byte(`{ + "identities": [ + { + "userId": "grafana@localhost" + }, + { + "userId": "admin@localhost" + } + ] +}`), + EmailAttributePath: "identities[0].userId", + ExpectedResult: "grafana@localhost", + }, + } + + for _, test := range tests { + test := test + t.Run(test.Name, func(t *testing.T) { + t.Parallel() + actualResult, err := util.SearchJSONForStringAttr(test.EmailAttributePath, test.UserInfoJSONResponse) + if test.ExpectedError != nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.ExpectedError) + } + require.Equal(t, test.ExpectedResult, actualResult) + }) + } +} diff --git a/public/app/core/components/help/HelpModal.tsx b/public/app/core/components/help/HelpModal.tsx index 45c38d23a6c..1f050690717 100644 --- a/public/app/core/components/help/HelpModal.tsx +++ b/public/app/core/components/help/HelpModal.tsx @@ -27,7 +27,7 @@ const getShortcuts = (modKey: string) => { description: t('help-modal.shortcuts-description.exit-edit/setting-views', 'Exit edit/setting views'), }, { - keys: ['h'], + keys: [`${modKey} + h`], description: t('help-modal.shortcuts-description.show-all-shortcuts', 'Show all keyboard shortcuts'), }, { keys: ['c', 't'], description: t('help-modal.shortcuts-description.change-theme', 'Change theme') }, @@ -177,60 +177,55 @@ export const HelpModal = ({ onDismiss }: HelpModalProps): JSX.Element => { function getStyles(theme: GrafanaTheme2) { return { - titleDescription: css` - font-size: ${theme.typography.bodySmall.fontSize}; - font-weight: ${theme.typography.bodySmall.fontWeight}; - color: ${theme.colors.text.disabled}; - padding-bottom: ${theme.spacing(2)}; - `, - categories: css` - font-size: ${theme.typography.bodySmall.fontSize}; - display: flex; - flex-flow: row wrap; - justify-content: space-between; - align-items: flex-start; - `, - shortcutCategory: css` - width: 50%; - font-size: ${theme.typography.bodySmall.fontSize}; - `, - shortcutTable: css` - margin-bottom: ${theme.spacing(2)}; - `, - shortcutTableCategoryHeader: css` - font-weight: normal; - font-size: ${theme.typography.h6.fontSize}; - text-align: left; - `, - shortcutTableDescription: css` - text-align: left; - color: ${theme.colors.text.disabled}; - width: 99%; - padding: ${theme.spacing(1, 2)}; - `, - shortcutTableKeys: css` - white-space: nowrap; - width: 1%; - text-align: right; - color: ${theme.colors.text.primary}; - `, - shortcutTableKey: css` - display: inline-block; - text-align: center; - margin-right: ${theme.spacing(0.5)}; - padding: 3px 5px; - font: - 11px Consolas, - 'Liberation Mono', - Menlo, - Courier, - monospace; - line-height: 10px; - vertical-align: middle; - border: solid 1px ${theme.colors.border.medium}; - border-radius: ${theme.shape.borderRadius(3)}; - color: ${theme.colors.text.primary}; - background-color: ${theme.colors.background.secondary}; - `, + titleDescription: css({ + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.bodySmall.fontWeight, + color: theme.colors.text.disabled, + paddingBottom: theme.spacing(2), + }), + categories: css({ + fontSize: theme.typography.bodySmall.fontSize, + display: 'flex', + flexFlow: 'row wrap', + justifyContent: 'space-between', + alignItems: 'flex-start', + }), + shortcutCategory: css({ + width: '50%', + fontSize: theme.typography.bodySmall.fontSize, + }), + shortcutTable: css({ + marginBottom: theme.spacing(2), + }), + shortcutTableCategoryHeader: css({ + fontWeight: 'normal', + fontSize: theme.typography.h6.fontSize, + textAlign: 'left', + }), + shortcutTableDescription: css({ + textAlign: 'left', + color: theme.colors.text.disabled, + width: '99%', + padding: theme.spacing(1, 2), + }), + shortcutTableKeys: css({ + whiteSpace: 'nowrap', + width: '1%', + textAlign: 'right', + color: theme.colors.text.primary, + }), + shortcutTableKey: css({ + display: 'inline-block', + textAlign: 'center', + marginRight: theme.spacing(0.5), + padding: '3px 5px', + font: "11px Consolas, 'Liberation Mono', Menlo, Courier, monospace", + lineHeight: '10px', + verticalAlign: 'middle', + border: `solid 1px ${theme.colors.border.medium}`, + borderRadius: theme.shape.radius.default, + color: theme.colors.text.primary, + backgroundColor: theme.colors.background.secondary, + }), }; } diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 6e6dc593475..2d48dee13c6 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -40,7 +40,7 @@ export class KeybindingSrv { // Chromeless pages like login and signup page don't get any global bindings if (!route.chromeless) { - this.bind(['?', 'h'], this.showHelpModal); + this.bind(['?', 'mod+h'], this.showHelpModal); this.bind('g h', this.goToHome); this.bind('g d', this.goToDashboards); this.bind('g e', this.goToExplore); diff --git a/public/app/features/admin/AdminFeatureTogglesAPI.ts b/public/app/features/admin/AdminFeatureTogglesAPI.ts index 62f0c8b5f26..6ecba0c8b7d 100644 --- a/public/app/features/admin/AdminFeatureTogglesAPI.ts +++ b/public/app/features/admin/AdminFeatureTogglesAPI.ts @@ -4,6 +4,7 @@ export type FeatureToggle = { name: string; description?: string; enabled: boolean; + stage: string; readOnly?: boolean; hidden?: boolean; }; @@ -28,6 +29,7 @@ interface K8sToggleSpec { enabled: boolean; writeable: boolean; source: K8sToggleSource; + stage: string; } interface K8sToggleSource { @@ -53,6 +55,7 @@ class K8sAPI implements FeatureTogglesAPI { description: t.description!, enabled: t.enabled, readOnly: !Boolean(t.writeable), + stage: t.stage, hidden: false, // only return visible things })), }; diff --git a/public/app/features/admin/AdminFeatureTogglesPage.tsx b/public/app/features/admin/AdminFeatureTogglesPage.tsx index 4d231b644fd..0684405b3ee 100644 --- a/public/app/features/admin/AdminFeatureTogglesPage.tsx +++ b/public/app/features/admin/AdminFeatureTogglesPage.tsx @@ -10,15 +10,13 @@ import { getTogglesAPI } from './AdminFeatureTogglesAPI'; import { AdminFeatureTogglesTable } from './AdminFeatureTogglesTable'; export default function AdminFeatureTogglesPage() { - const [reload] = useState(1); + const [reload, setReload] = useState(1); const togglesApi = getTogglesAPI(); const featureState = useAsync(() => togglesApi.getFeatureToggles(), [reload]); - const [updateSuccessful, setUpdateSuccessful] = useState(false); const styles = useStyles2(getStyles); const handleUpdateSuccess = () => { - setUpdateSuccessful(true); - // setReload(reload+1); << would trigger updating the server state! + setReload(reload + 1); }; const EditingAlert = () => { @@ -28,7 +26,7 @@ export default function AdminFeatureTogglesPage() { - {featureState.value?.restartRequired || updateSuccessful + {featureState.value?.restartRequired ? 'A restart is pending for your Grafana instance to apply the latest feature toggle changes' : 'Saving feature toggle changes will prompt a restart of the instance, which may take a few minutes'} @@ -57,7 +55,7 @@ export default function AdminFeatureTogglesPage() { {featureState.error} {featureState.loading && 'Fetching feature toggles'} - {featureState.value?.restartRequired && } + {featureState.value && ( (featureToggles); const [localToggles, setLocalToggles] = useState(featureToggles); const [isSaving, setIsSaving] = useState(false); + const [showSaveModel, setShowSaveModal] = useState(false); const togglesApi = getTogglesAPI(); const handleToggleChange = (toggle: FeatureToggle, newValue: boolean) => { @@ -58,6 +59,14 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat } }; + const saveButtonRef = useRef(null); + const showSaveChangesModal = (show: boolean) => () => { + setShowSaveModal(show); + if (!show && saveButtonRef.current) { + saveButtonRef.current.focus(); + } + }; + const getModifiedToggles = (): FeatureToggle[] => { return localToggles.filter((toggle, index) => toggle.enabled !== serverToggles.current[index].enabled); }; @@ -72,11 +81,30 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat return 'Feature management is not configured for editing'; } if (readOnlyToggle) { - return 'Preview features are not editable'; + return 'This is a non-editable feature'; } return ''; }; + const getStageCell = (stage: string) => { + switch (stage) { + case 'GA': + return ( + +
GA
+
+ ); + case 'privatePreview': + case 'preview': + case 'experimental': + return 'Beta'; + case 'deprecated': + return 'Deprecated'; + default: + return stage; + } + }; + const columns = [ { id: 'name', @@ -90,20 +118,32 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat cell: ({ cell: { value } }: CellProps) =>
{value}
, sortType: sortByDescription, }, + { + id: 'stage', + header: 'Stage', + cell: ({ cell: { value } }: CellProps) =>
{getStageCell(value)}
, + }, { id: 'enabled', header: 'State', - cell: ({ row }: CellProps) => ( - + cell: ({ row }: CellProps) => { + const renderStateSwitch = (
handleToggleChange(row.original, e.currentTarget.checked)} + transparent={row.original.readOnly} />
-
- ), + ); + + return row.original.readOnly ? ( + {renderStateSwitch} + ) : ( + renderStateSwitch + ); + }, sortType: sortByEnabled, }, ]; @@ -112,9 +152,28 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat <> {allowEditing && (
- + +

+ Some features are stable (GA) and enabled by default, whereas some are currently in their preliminary + Beta phase, available for early adoption. +

+

We advise understanding the implications of each feature change before making modifications.

+
+ } + confirmText="Save changes" + onConfirm={async () => { + showSaveChangesModal(false)(); + handleSaveChanges(); + }} + onDismiss={showSaveChangesModal(false)} + /> )} featureToggle.name} /> diff --git a/public/app/features/alerting/unified/NotificationPolicies.tsx b/public/app/features/alerting/unified/NotificationPolicies.tsx index 2d29c11cdb3..5309c537cfc 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.tsx @@ -54,8 +54,8 @@ const AmRoutes = () => { const [contactPointFilter, setContactPointFilter] = useState(); const [labelMatchersFilter, setLabelMatchersFilter] = useState([]); + const { selectedAlertmanager, hasConfigurationAPI, isGrafanaAlertmanager } = useAlertmanager(); const { getRouteGroupsMap } = useRouteGroupsMatcher(); - const { selectedAlertmanager, hasConfigurationAPI } = useAlertmanager(); const contactPointsState = useGetContactPointsState(selectedAlertmanager ?? ''); @@ -93,9 +93,9 @@ const AmRoutes = () => { useEffect(() => { if (rootRoute && alertGroups) { - triggerGetRouteGroupsMap(rootRoute, alertGroups); + triggerGetRouteGroupsMap(rootRoute, alertGroups, { unquoteMatchers: !isGrafanaAlertmanager }); } - }, [rootRoute, alertGroups, triggerGetRouteGroupsMap]); + }, [rootRoute, alertGroups, triggerGetRouteGroupsMap, isGrafanaAlertmanager]); // these are computed from the contactPoint and labels matchers filter const routesMatchingFilters = useMemo(() => { diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx index da62e5d4c3e..3a57a2088ee 100644 --- a/public/app/features/alerting/unified/RuleViewer.tsx +++ b/public/app/features/alerting/unified/RuleViewer.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { NavModelItem } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { config, isFetchError } from '@grafana/runtime'; import { Alert, withErrorBoundary } from '@grafana/ui'; import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; +import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; @@ -52,7 +53,7 @@ const RuleViewerV2Wrapper = (props: RuleViewerProps) => { if (error) { return ( - {stringifyErrorLike(error)} + ); } @@ -76,4 +77,16 @@ const RuleViewerV2Wrapper = (props: RuleViewerProps) => { return null; }; +interface ErrorMessageProps { + error: unknown; +} + +function ErrorMessage({ error }: ErrorMessageProps) { + if (isFetchError(error) && error.status === 404) { + return ; + } + + return {stringifyErrorLike(error)}; +} + export default withErrorBoundary(RuleViewer, { style: 'page' }); diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index e97588813de..c0c71092210 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -11,7 +11,9 @@ import { ExternalAlertmanagerConfig, ExternalAlertmanagers, ExternalAlertmanagersResponse, + GrafanaManagedContactPoint, Matcher, + MuteTimeInterval, } from '../../../../plugins/datasource/alertmanager/types'; import { NotifierDTO } from '../../../../types'; import { withPerformanceLogging } from '../Analytics'; @@ -257,5 +259,12 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ })); }, }), + // Grafana Managed Alertmanager only + getContactPointsList: build.query({ + query: () => ({ url: '/api/v1/notifications/receivers' }), + }), + getMuteTimingList: build.query({ + query: () => ({ url: '/api/v1/notifications/time-intervals' }), + }), }), }); diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx index d5b3a841ed6..5ab23e150e3 100644 --- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx @@ -27,7 +27,13 @@ const RECEIVER_STATUS_POLLING_INTERVAL = 10 * 1000; // 10 seconds * 3. (if available) additional metadata about Grafana Managed contact points * 4. (if available) the OnCall plugin metadata */ -export function useContactPointsWithStatus() { +interface UseContactPointsWithStatusOptions { + includePoliciesCount: boolean; +} + +export function useContactPointsWithStatus( + { includePoliciesCount }: UseContactPointsWithStatusOptions = { includePoliciesCount: true } +) { const { selectedAlertmanager, isGrafanaAlertmanager } = useAlertmanager(); const { installed: onCallPluginInstalled, loading: onCallPluginStatusLoading } = usePluginBridge( SupportedPlugin.OnCall @@ -64,6 +70,7 @@ export function useContactPointsWithStatus() { } // fetch the latest config from the Alertmanager + // we use this endpoint only when we need to get the number of policies const fetchAlertmanagerConfiguration = alertmanagerApi.endpoints.getAlertmanagerConfiguration.useQuery( selectedAlertmanager!, { @@ -73,31 +80,56 @@ export function useContactPointsWithStatus() { ...result, contactPoints: result.data ? enhanceContactPointsWithMetadata( - result.data, fetchContactPointsStatus.data, fetchReceiverMetadata.data, - onCallMetadata + onCallMetadata, + result.data.alertmanager_config.receivers ?? [], + result.data ) : [], }), + skip: !includePoliciesCount, } ); + // for Grafana Managed Alertmanager, we use the new read-only endpoint for getting the list of contact points + const fetchGrafanaContactPoints = alertmanagerApi.endpoints.getContactPointsList.useQuery(undefined, { + refetchOnFocus: true, + refetchOnReconnect: true, + selectFromResult: (result) => ({ + ...result, + contactPoints: result.data + ? enhanceContactPointsWithMetadata( + fetchContactPointsStatus.data, + fetchReceiverMetadata.data, + onCallMetadata, + result.data, // contact points from the new readonly endpoint + undefined //no config data + ) + : [], + }), + skip: includePoliciesCount || !isGrafanaAlertmanager, + }); + // we will fail silently for fetching OnCall plugin status and integrations - const error = fetchAlertmanagerConfiguration.error ?? fetchContactPointsStatus.error; + const error = + fetchAlertmanagerConfiguration.error || fetchGrafanaContactPoints.error || fetchContactPointsStatus.error; const isLoading = fetchAlertmanagerConfiguration.isLoading || + fetchGrafanaContactPoints.isLoading || fetchContactPointsStatus.isLoading || onCallPluginStatusLoading || onCallPluginIntegrationsLoading; - const contactPoints = fetchAlertmanagerConfiguration.contactPoints.sort((a, b) => a.name.localeCompare(b.name)); - + const unsortedContactPoints = includePoliciesCount + ? fetchAlertmanagerConfiguration.contactPoints + : fetchGrafanaContactPoints.contactPoints; + const contactPoints = unsortedContactPoints.sort((a, b) => a.name.localeCompare(b.name)); return { error, isLoading, contactPoints, - refetchReceivers: fetchAlertmanagerConfiguration.refetch, + refetchReceivers: fetchGrafanaContactPoints.refetch, }; } diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts index 38a69fd3b72..201351e1451 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -7,6 +7,7 @@ import { GrafanaManagedContactPoint, GrafanaManagedReceiverConfig, MatcherOperator, + Receiver, Route, } from 'app/plugins/datasource/alertmanager/types'; import { NotifierDTO, NotifierStatus, ReceiversStateDTO } from 'app/types'; @@ -30,6 +31,9 @@ export function isProvisioned(contactPoint: GrafanaManagedContactPoint) { // TODO we should really add some type information to these receiver settings... export function getReceiverDescription(receiver: ReceiverConfigWithMetadata): ReactNode | undefined { + if (!receiver.settings) { + return undefined; + } switch (receiver.type) { case 'email': { const hasEmailAddresses = 'addresses' in receiver.settings; // when dealing with alertmanager email_configs we don't normalize the settings @@ -87,7 +91,7 @@ export interface ReceiverConfigWithMetadata extends GrafanaManagedReceiverConfig } export interface ContactPointWithMetadata extends GrafanaManagedContactPoint { - numberOfPolicies: number; + numberOfPolicies?: number; // now is optional as we don't have the data from the read-only endpoint grafana_managed_receiver_configs: ReceiverConfigWithMetadata[]; } @@ -95,30 +99,36 @@ export interface ContactPointWithMetadata extends GrafanaManagedContactPoint { * This function adds the status information for each of the integrations (contact point types) in a contact point * 1. we iterate over all contact points * 2. for each contact point we "enhance" it with the status or "undefined" for vanilla Alertmanager + * contactPoints: list of contact points + * alertmanagerConfiguration: optional as is passed when we need to get number of policies for each contact point + * and we prefer using the data from the read-only endpoint. */ export function enhanceContactPointsWithMetadata( - result: AlertManagerCortexConfig, status: ReceiversStateDTO[] = [], notifiers: NotifierDTO[] = [], - onCallIntegrations: OnCallIntegrationDTO[] | undefined | null + onCallIntegrations: OnCallIntegrationDTO[] | undefined | null, + contactPoints: Receiver[], + alertmanagerConfiguration?: AlertManagerCortexConfig ): ContactPointWithMetadata[] { - const contactPoints = result.alertmanager_config.receivers ?? []; - // compute the entire inherited tree before finding what notification policies are using a particular contact point - const fullyInheritedTree = computeInheritedTree(result?.alertmanager_config?.route ?? {}); + const fullyInheritedTree = computeInheritedTree(alertmanagerConfiguration?.alertmanager_config?.route ?? {}); const usedContactPoints = getUsedContactPoints(fullyInheritedTree); const usedContactPointsByName = countBy(usedContactPoints); - return contactPoints.map((contactPoint) => { + const contactPointsList = alertmanagerConfiguration + ? alertmanagerConfiguration?.alertmanager_config.receivers ?? [] + : contactPoints ?? []; + + return contactPointsList.map((contactPoint) => { const receivers = extractReceivers(contactPoint); const statusForReceiver = status.find((status) => status.name === contactPoint.name); return { ...contactPoint, - numberOfPolicies: usedContactPointsByName[contactPoint.name] ?? 0, + numberOfPolicies: + alertmanagerConfiguration && usedContactPointsByName && (usedContactPointsByName[contactPoint.name] ?? 0), grafana_managed_receiver_configs: receivers.map((receiver, index) => { const isOnCallReceiver = receiver.type === ReceiverTypes.OnCall; - return { ...receiver, [RECEIVER_STATUS_KEY]: statusForReceiver?.integrations[index], @@ -130,6 +140,7 @@ export function enhanceContactPointsWithMetadata( }; }); } + export function isAutoGeneratedPolicy(route: Route) { const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false; if (!simplifiedRoutingToggleEnabled) { diff --git a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx index f7eabb4896d..0d2ca067d4e 100644 --- a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx @@ -134,7 +134,7 @@ export const AmRoutesExpandedForm = ({ error={errors.object_matchers?.[index]?.value?.message} > diff --git a/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx b/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx index f379658d2c7..458dc4a0258 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx @@ -6,12 +6,13 @@ import { GrafanaTheme2 } from '@grafana/data'; import { getTagColorsFromName, useStyles2, Stack } from '@grafana/ui'; import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types'; +import { MatcherFormatter, matcherFormatter } from '../../utils/matchers'; import { HoverCard } from '../HoverCard'; -type MatchersProps = { matchers: ObjectMatcher[] }; +type MatchersProps = { matchers: ObjectMatcher[]; formatter?: MatcherFormatter }; // renders the first N number of matchers -const Matchers: FC = ({ matchers }) => { +const Matchers: FC = ({ matchers, formatter = 'default' }) => { const styles = useStyles2(getStyles); const NUM_MATCHERS = 5; @@ -24,7 +25,7 @@ const Matchers: FC = ({ matchers }) => { {firstFew.map((matcher) => ( - + ))} {/* TODO hover state to show all matchers we're not showing */} {hasMoreMatchers && ( @@ -51,15 +52,16 @@ const Matchers: FC = ({ matchers }) => { interface MatcherBadgeProps { matcher: ObjectMatcher; + formatter?: MatcherFormatter; } -const MatcherBadge: FC = ({ matcher: [label, operator, value] }) => { +const MatcherBadge: FC = ({ matcher, formatter = 'default' }) => { const styles = useStyles2(getStyles); return ( -
+
- {label} {operator} {value} + {matcherFormatter[formatter](matcher)}
); diff --git a/public/app/features/alerting/unified/components/notification-policies/Modals.tsx b/public/app/features/alerting/unified/components/notification-policies/Modals.tsx index 099642f87d3..9985b7261f3 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Modals.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Modals.tsx @@ -11,6 +11,7 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { FormAmRoute } from '../../types/amroutes'; +import { MatcherFormatter } from '../../utils/matchers'; import { AlertGroup } from '../alert-groups/AlertGroup'; import { useGetAmRouteReceiverWithGrafanaAppTypes } from '../receivers/grafanaAppReceivers/grafanaApp'; @@ -210,6 +211,7 @@ const useAlertGroupsModal = (): [ const [showModal, setShowModal] = useState(false); const [alertGroups, setAlertGroups] = useState([]); const [matchers, setMatchers] = useState([]); + const [formatter, setFormatter] = useState('default'); const handleDismiss = useCallback(() => { setShowModal(false); @@ -217,13 +219,19 @@ const useAlertGroupsModal = (): [ setMatchers([]); }, []); - const handleShow = useCallback((alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => { - setAlertGroups(alertGroups); - if (matchers) { - setMatchers(matchers); - } - setShowModal(true); - }, []); + const handleShow = useCallback( + (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[], formatter?: MatcherFormatter) => { + setAlertGroups(alertGroups); + if (matchers) { + setMatchers(matchers); + } + if (formatter) { + setFormatter(formatter); + } + setShowModal(true); + }, + [] + ); const instancesByState = useMemo(() => { const instances = alertGroups.flatMap((group) => group.alerts); @@ -242,7 +250,7 @@ const useAlertGroupsModal = (): [ Matchers - + } > @@ -265,7 +273,7 @@ const useAlertGroupsModal = (): [ ), - [alertGroups, handleDismiss, instancesByState, matchers, showModal] + [alertGroups, handleDismiss, instancesByState, matchers, formatter, showModal] ); return [modalElement, handleShow, handleDismiss]; diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx index b6ba61d28c5..c2069f80cf7 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx @@ -32,7 +32,8 @@ import { ReceiversState } from 'app/types'; import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities'; import { INTEGRATION_ICONS } from '../../types/contact-points'; -import { normalizeMatchers } from '../../utils/matchers'; +import { getAmMatcherFormatter } from '../../utils/alertmanager'; +import { MatcherFormatter, normalizeMatchers } from '../../utils/matchers'; import { createContactPointLink, createMuteTimingLink } from '../../utils/misc'; import { InheritableProperties, getInheritedProperties } from '../../utils/notification-policies'; import { Authorize } from '../Authorize'; @@ -55,7 +56,6 @@ interface PolicyComponentProps { provisioned?: boolean; inheritedProperties?: Partial; routesMatchingFilters?: RouteWithID[]; - // routeAlertGroupsMap?: Map; matchingInstancesPreview?: { groupsMap?: Map; enabled: boolean }; @@ -65,7 +65,11 @@ interface PolicyComponentProps { onEditPolicy: (route: RouteWithID, isDefault?: boolean, isAutogenerated?: boolean) => void; onAddPolicy: (route: RouteWithID) => void; onDeletePolicy: (route: RouteWithID) => void; - onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void; + onShowAlertInstances: ( + alertGroups: AlertmanagerGroup[], + matchers?: ObjectMatcher[], + formatter?: MatcherFormatter + ) => void; isAutoGenerated?: boolean; } @@ -194,7 +198,7 @@ const Policy = (props: PolicyComponentProps) => { ) ) : hasMatchers ? ( - + ) : ( No matchers )} @@ -325,7 +329,11 @@ interface MetadataRowProps { matchingAlertGroups?: AlertmanagerGroup[]; matchers?: ObjectMatcher[]; isDefaultPolicy: boolean; - onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void; + onShowAlertInstances: ( + alertGroups: AlertmanagerGroup[], + matchers?: ObjectMatcher[], + formatter?: MatcherFormatter + ) => void; } function MetadataRow({ @@ -361,7 +369,8 @@ function MetadataRow({ { - matchingAlertGroups && onShowAlertInstances(matchingAlertGroups, matchers); + matchingAlertGroups && + onShowAlertInstances(matchingAlertGroups, matchers, getAmMatcherFormatter(alertManagerSourceName)); }} data-testid="matching-instances" > diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index a7a5ef32199..e1f56127f28 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -1,11 +1,11 @@ import { css } from '@emotion/css'; import React, { useEffect, useMemo, useState } from 'react'; -import { FormProvider, SubmitErrorHandler, useForm, UseFormWatch } from 'react-hook-form'; +import { FormProvider, SubmitErrorHandler, UseFormWatch, useForm } from 'react-hook-form'; import { Link, useParams } from 'react-router-dom'; import { GrafanaTheme2 } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { Button, ConfirmModal, CustomScrollbar, HorizontalGroup, Spinner, useStyles2, Stack } from '@grafana/ui'; +import { Button, ConfirmModal, CustomScrollbar, HorizontalGroup, Spinner, Stack, useStyles2 } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { useAppNotification } from 'app/core/copy/appNotification'; import { contextSrv } from 'app/core/core'; @@ -14,17 +14,18 @@ import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { useDispatch } from 'app/types'; import { RuleWithLocation } from 'app/types/unified-alerting'; -import { logInfo, LogMessages, trackNewAlerRuleFormError } from '../../../Analytics'; +import { LogMessages, logInfo, trackNewAlerRuleFormError } from '../../../Analytics'; import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector'; import { deleteRuleAction, saveRuleFormAction } from '../../../state/actions'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { initialAsyncRequestState } from '../../../utils/redux'; import { + MANUAL_ROUTING_KEY, + MINUTE, formValuesFromExistingRule, getDefaultFormValues, getDefaultQueries, ignoreHiddenQueries, - MINUTE, normalizeDefaultAnnotations, } from '../../../utils/rule-form'; import * as ruleId from '../../../utils/rule-id'; @@ -108,6 +109,14 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { notifyApp.error(conditionErrorMsg); return; } + // when creating a new rule, we save the manual routing setting in local storage + if (!existing) { + if (values.manualRouting) { + localStorage.setItem(MANUAL_ROUTING_KEY, 'true'); + } else { + localStorage.setItem(MANUAL_ROUTING_KEY, 'false'); + } + } dispatch( saveRuleFormAction({ diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx index 4ec14cd359a..94ee0b3be3f 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx @@ -1,8 +1,10 @@ import { css } from '@emotion/css'; import React, { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; import { Alert, CollapsableSection, LoadingPlaceholder, Stack, useStyles2 } from '@grafana/ui'; +import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; import { AlertManagerDataSource } from 'app/features/alerting/unified/utils/datasource'; import { ContactPointReceiverSummary } from '../../../contact-points/ContactPoints'; @@ -22,7 +24,12 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo const styles = useStyles2(getStyles); const alertManagerName = alertManager.name; - const { isLoading, error: errorInContactPointStatus, contactPoints, refetchReceivers } = useContactPointsWithStatus(); + const { + isLoading, + error: errorInContactPointStatus, + contactPoints, + refetchReceivers, + } = useContactPointsWithStatus({ includePoliciesCount: false }); const [selectedContactPointWithMetadata, setSelectedContactPointWithMetadata] = useState< ContactPointWithMetadata | undefined >(); @@ -31,6 +38,12 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo setSelectedContactPointWithMetadata(contactPoint); }; + const { watch } = useFormContext(); + const hasRouteSettings = + watch(`contactPoints.${alertManagerName}.overrideGrouping`) || + watch(`contactPoints.${alertManagerName}.overrideTimings`) || + watch(`contactPoints.${alertManagerName}.muteTimeIntervals`)?.length > 0; + const options = contactPoints.map((receiver) => { const integrations = receiver?.grafana_managed_receiver_configs; const description = ; @@ -69,7 +82,7 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx index 70728a041d7..ddb93051902 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { useFormContext } from 'react-hook-form'; +import { SelectableValue } from '@grafana/data'; import { Field, InputControl, MultiSelect, useStyles2 } from '@grafana/ui'; -import { useMuteTimingOptions } from 'app/features/alerting/unified/hooks/useMuteTimingOptions'; +import { alertmanagerApi } from 'app/features/alerting/unified/api/alertmanagerApi'; import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; +import { timeIntervalToString } from 'app/features/alerting/unified/utils/alertmanager'; import { mapMultiSelectValueToStrings } from 'app/features/alerting/unified/utils/amroutes'; import { getFormStyles } from '../../../../notification-policies/formStyles'; @@ -19,7 +21,7 @@ export function MuteTimingFields({ alertManager }: MuteTimingFieldsProps) { formState: { errors }, } = useFormContext(); - const muteTimingOptions = useMuteTimingOptions(); + const muteTimingOptions = useSelectableMuteTimings(); return ( ); } + +function useSelectableMuteTimings(): Array> { + const fetchGrafanaMuteTimings = alertmanagerApi.endpoints.getMuteTimingList.useQuery(undefined, { + refetchOnFocus: true, + refetchOnReconnect: true, + selectFromResult: (result) => ({ + ...result, + mutetimings: result.data + ? result.data.map((value) => ({ + value: value.name, + label: value.name, + description: value.time_intervals.map((interval) => timeIntervalToString(interval)).join(', AND '), + })) + : [], + }), + }); + return fetchGrafanaMuteTimings.mutetimings; +} diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx index 298239dc15d..900ca46a35c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx @@ -1,8 +1,8 @@ import { css } from '@emotion/css'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Field, FieldValidationMessage, @@ -14,8 +14,7 @@ import { Text, useStyles2, } from '@grafana/ui'; -import { useAlertmanagerConfig } from 'app/features/alerting/unified/hooks/useAlertmanagerConfig'; -import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext'; +import { MultiValueRemove, MultiValueRemoveProps } from '@grafana/ui/src/components/Select/MultiValue'; import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; import { commonGroupByOptions, @@ -29,6 +28,15 @@ import { TIMING_OPTIONS_DEFAULTS } from '../../../../notification-policies/timin import { RouteTimings } from './RouteTimings'; +const REQUIRED_FIELDS_IN_GROUPBY = ['grafana_folder', 'alertname']; + +const DEFAULTS_TIMINGS = { + groupWaitValue: TIMING_OPTIONS_DEFAULTS.group_wait, + groupIntervalValue: TIMING_OPTIONS_DEFAULTS.group_interval, + repeatIntervalValue: TIMING_OPTIONS_DEFAULTS.repeat_interval, +}; +const DISABLE_GROUPING = '...'; + export interface RoutingSettingsProps { alertManager: string; } @@ -42,12 +50,18 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => { formState: { errors }, } = useFormContext(); const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues([])); - const { groupBy, groupIntervalValue, groupWaitValue, repeatIntervalValue } = useGetDefaultsForRoutingSettings(); + const { groupIntervalValue, groupWaitValue, repeatIntervalValue } = DEFAULTS_TIMINGS; const overrideGrouping = watch(`contactPoints.${alertManager}.overrideGrouping`); const overrideTimings = watch(`contactPoints.${alertManager}.overrideTimings`); - const requiredFieldsInGroupBy = ['grafana_folder', 'alertname']; - const disableGrouping = '...'; + const groupByCount = watch(`contactPoints.${alertManager}.groupBy`)?.length ?? 0; + const styles = useStyles2(getStyles); + useEffect(() => { + if (overrideGrouping && groupByCount === 0) { + setValue(`contactPoints.${alertManager}.groupBy`, REQUIRED_FIELDS_IN_GROUPBY); + } + }, [overrideGrouping, setValue, alertManager, groupByCount]); + return ( @@ -56,7 +70,7 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => { {!overrideGrouping && ( - Grouping: {groupBy.join(', ')} + Grouping: {REQUIRED_FIELDS_IN_GROUPBY.join(', ')} )} @@ -74,14 +88,13 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => { if (!value || value.length === 0) { return 'At least one group by option is required.'; } - // if value includes only the allowedSingleFieldInGroupBy, it is valid - if (value.length === 1 && value[0] === disableGrouping) { + if (value.length === 1 && value[0] === DISABLE_GROUPING) { return true; } // we need to make sure that the required fields are included - const requiredFieldsIncluded = requiredFieldsInGroupBy.every((field) => value.includes(field)); + const requiredFieldsIncluded = REQUIRED_FIELDS_IN_GROUPBY.every((field) => value.includes(field)); if (!requiredFieldsIncluded) { - return `Group by must include ${requiredFieldsInGroupBy.join(', ')}`; + return `Group by must include ${REQUIRED_FIELDS_IN_GROUPBY.join(', ')}`; } return true; }, @@ -99,8 +112,30 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => { // @ts-ignore-check: react-hook-form made me do this setValue(`contactPoints.${alertManager}.groupBy`, [...field.value, opt]); }} - onChange={(value) => onChange(mapMultiSelectValueToStrings(value))} + onChange={(value) => { + return onChange(mapMultiSelectValueToStrings(value)); + }} options={[...commonGroupByOptions, ...groupByOptions]} + components={{ + MultiValueRemove( + props: React.PropsWithChildren< + MultiValueRemoveProps & + Array> & { + data: { + label: string; + value: string; + isFixed: boolean; + }; + } + > + ) { + const { data } = props; + if (data.isFixed) { + return null; + } + return MultiValueRemove(props); + }, + }} /> {error && {error.message}} @@ -131,20 +166,6 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => { ); }; -function useGetDefaultsForRoutingSettings() { - const { selectedAlertmanager } = useAlertmanager(); - const { currentData } = useAlertmanagerConfig(selectedAlertmanager); - const config = currentData?.alertmanager_config; - return React.useMemo(() => { - return { - groupWaitValue: TIMING_OPTIONS_DEFAULTS.group_wait, - groupIntervalValue: TIMING_OPTIONS_DEFAULTS.group_interval, - repeatIntervalValue: TIMING_OPTIONS_DEFAULTS.repeat_interval, - groupBy: config?.route?.group_by ?? [], - }; - }, [config]); -} - const getStyles = (theme: GrafanaTheme2) => ({ switchElement: css({ flexFlow: 'row-reverse', @@ -153,5 +174,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ }), optionalContent: css({ marginLeft: '49px', + marginBottom: theme.spacing(1), }), }); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx index ad459deedb0..cb7c4492d1c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx @@ -4,18 +4,24 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; +import { MatcherFormatter } from '../../../utils/matchers'; import { Matchers } from '../../notification-policies/Matchers'; import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route'; -export function NotificationPolicyMatchers({ route }: { route: RouteWithPath }) { +interface Props { + route: RouteWithPath; + matcherFormatter: MatcherFormatter; +} + +export function NotificationPolicyMatchers({ route, matcherFormatter }: Props) { const styles = useStyles2(getStyles); if (isDefaultPolicy(route)) { return
Default policy
; } else if (hasEmptyMatchers(route)) { return
No matchers
; } else { - return ; + return ; } } diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx index 1a7f9a13944..a95e1bbb8a6 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx @@ -9,6 +9,7 @@ import { Button, getTagColorIndexFromName, TagList, useStyles2 } from '@grafana/ import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types'; import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack'; +import { getAmMatcherFormatter } from '../../../utils/alertmanager'; import { AlertInstanceMatch } from '../../../utils/notification-policies'; import { CollapseToggle } from '../../CollapseToggle'; import { MetaText } from '../../MetaText'; @@ -58,7 +59,10 @@ function NotificationRouteHeader({
onExpandRouteClick(!expandRoute)} className={styles.expandable}> Notification policy - +
diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx index f66e525f8a1..8de20bc227d 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx @@ -3,20 +3,26 @@ import { compact } from 'lodash'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, Icon, Modal, useStyles2 } from '@grafana/ui'; +import { Button, Icon, Modal, Stack, useStyles2 } from '@grafana/ui'; import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types'; -import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack'; import { AlertmanagerAction } from '../../../hooks/useAbilities'; import { AlertmanagerProvider } from '../../../state/AlertmanagerContext'; -import { GRAFANA_DATASOURCE_NAME } from '../../../utils/datasource'; +import { getAmMatcherFormatter } from '../../../utils/alertmanager'; +import { MatcherFormatter } from '../../../utils/matchers'; import { makeAMLink } from '../../../utils/misc'; import { Authorize } from '../../Authorize'; import { Matchers } from '../../notification-policies/Matchers'; import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route'; -function PolicyPath({ route, routesByIdMap }: { routesByIdMap: Map; route: RouteWithPath }) { +interface Props { + routesByIdMap: Map; + route: RouteWithPath; + matcherFormatter: MatcherFormatter; +} + +function PolicyPath({ route, routesByIdMap, matcherFormatter }: Props) { const styles = useStyles2(getStyles); const routePathIds = route.path?.slice(1) ?? []; const routePathObjects = [...compact(routePathIds.map((id) => routesByIdMap.get(id))), route]; @@ -31,7 +37,7 @@ function PolicyPath({ route, routesByIdMap }: { routesByIdMap: MapNo matchers
) : ( - + )}
@@ -60,7 +66,7 @@ export function NotificationRouteDetailsModal({ const isDefault = isDefaultPolicy(route); return ( - + {!isDefault && ( <> - + )}
diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts index 0762ae77aa2..a8df723da12 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts @@ -6,6 +6,7 @@ import { Labels } from '../../../../../../types/unified-alerting-dto'; import { useAlertmanagerConfig } from '../../../hooks/useAlertmanagerConfig'; import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher'; import { addUniqueIdentifierToRoute } from '../../../utils/amroutes'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; import { AlertInstanceMatch, computeInheritedTree, normalizeRoute } from '../../../utils/notification-policies'; import { getRoutesByIdMap, RouteWithPath } from './route'; @@ -55,7 +56,9 @@ export const useAlertmanagerNotificationRoutingPreview = ( if (!rootRoute) { return; } - return await matchInstancesToRoute(rootRoute, potentialInstances); + return await matchInstancesToRoute(rootRoute, potentialInstances, { + unquoteMatchers: alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME, + }); }, [rootRoute, potentialInstances]); return { diff --git a/public/app/features/alerting/unified/routeGroupsMatcher.ts b/public/app/features/alerting/unified/routeGroupsMatcher.ts index 80b951ffc14..bce330c7f78 100644 --- a/public/app/features/alerting/unified/routeGroupsMatcher.ts +++ b/public/app/features/alerting/unified/routeGroupsMatcher.ts @@ -6,11 +6,20 @@ import { findMatchingAlertGroups, findMatchingRoutes, normalizeRoute, + unquoteRouteMatchers, } from './utils/notification-policies'; +export interface MatchOptions { + unquoteMatchers?: boolean; +} + export const routeGroupsMatcher = { - getRouteGroupsMap(rootRoute: RouteWithID, groups: AlertmanagerGroup[]): Map { - const normalizedRootRoute = normalizeRoute(rootRoute); + getRouteGroupsMap( + rootRoute: RouteWithID, + groups: AlertmanagerGroup[], + options?: MatchOptions + ): Map { + const normalizedRootRoute = getNormalizedRoute(rootRoute, options); function addRouteGroups(route: RouteWithID, acc: Map) { const routeGroups = findMatchingAlertGroups(normalizedRootRoute, route, groups); @@ -25,10 +34,14 @@ export const routeGroupsMatcher = { return routeGroupsMap; }, - matchInstancesToRoute(routeTree: RouteWithID, instancesToMatch: Labels[]): Map { + matchInstancesToRoute( + routeTree: RouteWithID, + instancesToMatch: Labels[], + options?: MatchOptions + ): Map { const result = new Map(); - const normalizedRootRoute = normalizeRoute(routeTree); + const normalizedRootRoute = getNormalizedRoute(routeTree, options); instancesToMatch.forEach((instance) => { const matchingRoutes = findMatchingRoutes(normalizedRootRoute, Object.entries(instance)); @@ -47,4 +60,8 @@ export const routeGroupsMatcher = { }, }; +function getNormalizedRoute(route: RouteWithID, options?: MatchOptions): RouteWithID { + return options?.unquoteMatchers ? unquoteRouteMatchers(normalizeRoute(route)) : normalizeRoute(route); +} + export type RouteGroupsMatcher = typeof routeGroupsMatcher; diff --git a/public/app/features/alerting/unified/useRouteGroupsMatcher.ts b/public/app/features/alerting/unified/useRouteGroupsMatcher.ts index 2b57e4e0d34..a48d3e4c611 100644 --- a/public/app/features/alerting/unified/useRouteGroupsMatcher.ts +++ b/public/app/features/alerting/unified/useRouteGroupsMatcher.ts @@ -6,7 +6,7 @@ import { Labels } from '../../../types/unified-alerting-dto'; import { logError, logInfo } from './Analytics'; import { createWorker } from './createRouteGroupsMatcherWorker'; -import type { RouteGroupsMatcher } from './routeGroupsMatcher'; +import type { MatchOptions, RouteGroupsMatcher } from './routeGroupsMatcher'; let routeMatcher: comlink.Remote | undefined; @@ -55,43 +55,49 @@ export function useRouteGroupsMatcher() { return () => null; }, []); - const getRouteGroupsMap = useCallback(async (rootRoute: RouteWithID, alertGroups: AlertmanagerGroup[]) => { - validateWorker(routeMatcher); + const getRouteGroupsMap = useCallback( + async (rootRoute: RouteWithID, alertGroups: AlertmanagerGroup[], options?: MatchOptions) => { + validateWorker(routeMatcher); - const startTime = performance.now(); + const startTime = performance.now(); - const result = await routeMatcher.getRouteGroupsMap(rootRoute, alertGroups); + const result = await routeMatcher.getRouteGroupsMap(rootRoute, alertGroups, options); - const timeSpent = performance.now() - startTime; + const timeSpent = performance.now() - startTime; - logInfo(`Route Groups Matched in ${timeSpent} ms`, { - matchingTime: timeSpent.toString(), - alertGroupsCount: alertGroups.length.toString(), - // Counting all nested routes might be too time-consuming, so we only count the first level - topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', - }); + logInfo(`Route Groups Matched in ${timeSpent} ms`, { + matchingTime: timeSpent.toString(), + alertGroupsCount: alertGroups.length.toString(), + // Counting all nested routes might be too time-consuming, so we only count the first level + topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', + }); - return result; - }, []); + return result; + }, + [] + ); - const matchInstancesToRoute = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => { - validateWorker(routeMatcher); + const matchInstancesToRoute = useCallback( + async (rootRoute: RouteWithID, instancesToMatch: Labels[], options?: MatchOptions) => { + validateWorker(routeMatcher); - const startTime = performance.now(); + const startTime = performance.now(); - const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch); + const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch, options); - const timeSpent = performance.now() - startTime; + const timeSpent = performance.now() - startTime; - logInfo(`Instances Matched in ${timeSpent} ms`, { - matchingTime: timeSpent.toString(), - instancesToMatchCount: instancesToMatch.length.toString(), - // Counting all nested routes might be too time-consuming, so we only count the first level - topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', - }); + logInfo(`Instances Matched in ${timeSpent} ms`, { + matchingTime: timeSpent.toString(), + instancesToMatchCount: instancesToMatch.length.toString(), + // Counting all nested routes might be too time-consuming, so we only count the first level + topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', + }); - return result; - }, []); + return result; + }, + [] + ); return { getRouteGroupsMap, matchInstancesToRoute }; } diff --git a/public/app/features/alerting/unified/utils/alertmanager.ts b/public/app/features/alerting/unified/utils/alertmanager.ts index e81b9195ffc..2738b703844 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.ts @@ -15,7 +15,8 @@ import { Labels } from 'app/types/unified-alerting-dto'; import { MatcherFieldValue } from '../types/silence-form'; import { getAllDataSources } from './config'; -import { DataSourceType } from './datasource'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './datasource'; +import { MatcherFormatter, unquoteWithUnescape } from './matchers'; export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig { // add default receiver if it does not exist @@ -53,6 +54,10 @@ export function renameMuteTimings(newMuteTimingName: string, oldMuteTimingName: }; } +export function unescapeObjectMatchers(matchers: ObjectMatcher[]): ObjectMatcher[] { + return matchers.map(([name, operator, value]) => [name, operator, unquoteWithUnescape(value)]); +} + export function matcherToOperator(matcher: Matcher): MatcherOperator { if (matcher.isEqual) { if (matcher.isRegex) { @@ -177,6 +182,10 @@ export function combineMatcherStrings(...matcherStrings: string[]): string { return matchersToString(uniqueMatchers); } +export function getAmMatcherFormatter(alertmanagerSourceName?: string): MatcherFormatter { + return alertmanagerSourceName === GRAFANA_RULES_SOURCE_NAME ? 'default' : 'unquote'; +} + export function getAllAlertmanagerDataSources() { return getAllDataSources().filter((ds) => ds.type === DataSourceType.Alertmanager); } diff --git a/public/app/features/alerting/unified/utils/amroutes.test.ts b/public/app/features/alerting/unified/utils/amroutes.test.ts index 3c66e772d0b..dbb71f012c2 100644 --- a/public/app/features/alerting/unified/utils/amroutes.test.ts +++ b/public/app/features/alerting/unified/utils/amroutes.test.ts @@ -1,8 +1,9 @@ -import { Route } from 'app/plugins/datasource/alertmanager/types'; +import { MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; import { FormAmRoute } from '../types/amroutes'; import { amRouteToFormAmRoute, emptyRoute, formAmRouteToAmRoute } from './amroutes'; +import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; const emptyAmRoute: Route = { receiver: '', @@ -53,6 +54,58 @@ describe('formAmRouteToAmRoute', () => { expect(amRoute.group_by).toStrictEqual(['SHOULD BE SET']); }); }); + + it('should quote and escape matcher values', () => { + // Arrange + const route: FormAmRoute = buildFormAmRoute({ + id: '1', + object_matchers: [ + { name: 'foo', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar"baz' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar\\baz' }, + { name: 'foo', operator: MatcherOperator.equal, value: '\\bar\\baz"\\' }, + ], + }); + + // Act + const amRoute = formAmRouteToAmRoute('mimir-am', route, { id: 'root' }); + + // Assert + expect(amRoute.matchers).toStrictEqual([ + 'foo="bar"', + 'foo="bar\\"baz"', + 'foo="bar\\\\baz"', + 'foo="\\\\bar\\\\baz\\"\\\\"', + ]); + }); + + it('should allow matchers with empty values for cloud AM', () => { + // Arrange + const route: FormAmRoute = buildFormAmRoute({ + id: '1', + object_matchers: [{ name: 'foo', operator: MatcherOperator.equal, value: '' }], + }); + + // Act + const amRoute = formAmRouteToAmRoute('mimir-am', route, { id: 'root' }); + + // Assert + expect(amRoute.matchers).toStrictEqual(['foo=""']); + }); + + it('should allow matchers with empty values for Grafana AM', () => { + // Arrange + const route: FormAmRoute = buildFormAmRoute({ + id: '1', + object_matchers: [{ name: 'foo', operator: MatcherOperator.equal, value: '' }], + }); + + // Act + const amRoute = formAmRouteToAmRoute(GRAFANA_RULES_SOURCE_NAME, route, { id: 'root' }); + + // Assert + expect(amRoute.object_matchers).toStrictEqual([['foo', MatcherOperator.equal, '']]); + }); }); describe('amRouteToFormAmRoute', () => { @@ -101,4 +154,23 @@ describe('amRouteToFormAmRoute', () => { expect(formRoute.overrideGrouping).toBe(true); }); }); + + it('should unquote and unescape matchers values', () => { + // Arrange + const amRoute = buildAmRoute({ + matchers: ['foo=bar', 'foo="bar"', 'foo="bar"baz"', 'foo="bar\\\\baz"', 'foo="\\\\bar\\\\baz"\\\\"'], + }); + + // Act + const formRoute = amRouteToFormAmRoute(amRoute); + + // Assert + expect(formRoute.object_matchers).toStrictEqual([ + { name: 'foo', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar"baz' }, + { name: 'foo', operator: MatcherOperator.equal, value: 'bar\\baz' }, + { name: 'foo', operator: MatcherOperator.equal, value: '\\bar\\baz"\\' }, + ]); + }); }); diff --git a/public/app/features/alerting/unified/utils/amroutes.ts b/public/app/features/alerting/unified/utils/amroutes.ts index 9fc29f0c3d0..dbfbd58ad3b 100644 --- a/public/app/features/alerting/unified/utils/amroutes.ts +++ b/public/app/features/alerting/unified/utils/amroutes.ts @@ -8,7 +8,7 @@ import { MatcherFieldValue } from '../types/silence-form'; import { matcherToMatcherField } from './alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; -import { normalizeMatchers, parseMatcher } from './matchers'; +import { normalizeMatchers, parseMatcher, quoteWithEscape, unquoteWithUnescape } from './matchers'; import { findExistingRoute } from './routeTree'; import { isValidPrometheusDuration, safeParseDurationstr } from './time'; @@ -44,8 +44,8 @@ export const defaultGroupBy = ['grafana_folder', 'alertname']; // Common route group_by options for multiselect drop-down export const commonGroupByOptions = [ - { label: 'grafana_folder', value: 'grafana_folder' }, - { label: 'alertname', value: 'alertname' }, + { label: 'grafana_folder', value: 'grafana_folder', isFixed: true }, + { label: 'alertname', value: 'alertname', isFixed: true }, { label: 'Disable (...)', value: '...' }, ]; @@ -94,7 +94,14 @@ export const amRouteToFormAmRoute = (route: RouteWithID | Route | undefined): Fo const objectMatchers = route.object_matchers?.map((matcher) => ({ name: matcher[0], operator: matcher[1], value: matcher[2] })) ?? []; - const matchers = route.matchers?.map((matcher) => matcherToMatcherField(parseMatcher(matcher))) ?? []; + const matchers = + route.matchers + ?.map((matcher) => matcherToMatcherField(parseMatcher(matcher))) + .map(({ name, operator, value }) => ({ + name, + operator, + value: unquoteWithUnescape(value), + })) ?? []; return { id, @@ -149,8 +156,10 @@ export const formAmRouteToAmRoute = ( const overrideRepeatInterval = overrideTimings && repeatIntervalValue; const repeat_interval = overrideRepeatInterval ? repeatIntervalValue : INHERIT_FROM_PARENT; + + // Empty matcher values are valid. Such matchers require specified label to not exists const object_matchers: ObjectMatcher[] | undefined = formAmRoute.object_matchers - ?.filter((route) => route.name && route.value && route.operator) + ?.filter((route) => route.name && route.operator && route.value !== null && route.value !== undefined) .map(({ name, operator, value }) => [name, operator, value]); const routes = formAmRoute.routes?.map((subRoute) => @@ -176,7 +185,9 @@ export const formAmRouteToAmRoute = ( // Grafana maintains a fork of AM to support all utf-8 characters in the "object_matchers" property values but this // does not exist in upstream AlertManager if (alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME) { - amRoute.matchers = formAmRoute.object_matchers?.map(({ name, operator, value }) => `${name}${operator}${value}`); + amRoute.matchers = formAmRoute.object_matchers?.map( + ({ name, operator, value }) => `${name}${operator}${quoteWithEscape(value)}` + ); amRoute.object_matchers = undefined; } else { amRoute.object_matchers = normalizeMatchers(amRoute); diff --git a/public/app/features/alerting/unified/utils/matchers.test.ts b/public/app/features/alerting/unified/utils/matchers.test.ts index 43b3cbc2320..9f879789a22 100644 --- a/public/app/features/alerting/unified/utils/matchers.test.ts +++ b/public/app/features/alerting/unified/utils/matchers.test.ts @@ -1,6 +1,12 @@ import { MatcherOperator, Route } from '../../../../plugins/datasource/alertmanager/types'; -import { getMatcherQueryParams, normalizeMatchers, parseQueryParamMatchers } from './matchers'; +import { + getMatcherQueryParams, + normalizeMatchers, + parseQueryParamMatchers, + quoteWithEscape, + unquoteWithUnescape, +} from './matchers'; describe('Unified Alerting matchers', () => { describe('getMatcherQueryParams tests', () => { @@ -61,3 +67,37 @@ describe('Unified Alerting matchers', () => { }); }); }); + +describe('quoteWithEscape', () => { + const samples: string[][] = [ + ['bar', '"bar"'], + ['b"ar"', '"b\\"ar\\""'], + ['b\\ar\\', '"b\\\\ar\\\\"'], + ['wa{r}ni$ng!', '"wa{r}ni$ng!"'], + ]; + + it.each(samples)('should escape and quote %s to %s', (raw, quoted) => { + const quotedMatcher = quoteWithEscape(raw); + expect(quotedMatcher).toBe(quoted); + }); +}); + +describe('unquoteWithUnescape', () => { + const samples: string[][] = [ + ['bar', 'bar'], + ['"bar"', 'bar'], + ['"b\\"ar\\""', 'b"ar"'], + ['"b\\\\ar\\\\"', 'b\\ar\\'], + ['"wa{r}ni$ng!"', 'wa{r}ni$ng!'], + ]; + + it.each(samples)('should unquote and unescape %s to %s', (quoted, raw) => { + const unquotedMatcher = unquoteWithUnescape(quoted); + expect(unquotedMatcher).toBe(raw); + }); + + it('should not unescape unquoted string', () => { + const unquoted = unquoteWithUnescape('un\\"quo\\\\ted'); + expect(unquoted).toBe('un\\"quo\\\\ted'); + }); +}); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index aa1b7187f85..7b8c7ef4a05 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -108,4 +108,41 @@ export const normalizeMatchers = (route: Route): ObjectMatcher[] => { return matchers; }; +/** + * Quotes string and escapes double quote and backslash characters + */ +export function quoteWithEscape(input: string) { + const escaped = input.replace(/[\\"]/g, (c) => `\\${c}`); + return `"${escaped}"`; +} + +/** + * Unquotes and unescapes a string **if it has been quoted** + */ +export function unquoteWithUnescape(input: string) { + if (!/^"(.*)"$/.test(input)) { + return input; + } + + return input + .replace(/^"(.*)"$/, '$1') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); +} + +export const matcherFormatter = { + default: ([name, operator, value]: ObjectMatcher): string => { + // Value can be an empty string which we want to display as "" + const formattedValue = value || ''; + return `${name} ${operator} ${formattedValue}`; + }, + unquote: ([name, operator, value]: ObjectMatcher): string => { + // Unquoted value can be an empty string which we want to display as "" + const unquotedValue = unquoteWithUnescape(value) || '""'; + return `${name} ${operator} ${unquotedValue}`; + }, +} as const; + +export type MatcherFormatter = keyof typeof matcherFormatter; + export type Label = [string, string]; diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 21115d42ec7..e8e1a7e8dcb 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -2,7 +2,7 @@ import { sortBy } from 'lodash'; import { UrlQueryMap, Labels } from '@grafana/data'; import { GrafanaEdition } from '@grafana/data/src/types/config'; -import { config } from '@grafana/runtime'; +import { config, isFetchError } from '@grafana/runtime'; import { DataSourceRef } from '@grafana/schema'; import { escapePathSeparators } from 'app/features/alerting/unified/utils/rule-id'; import { alertInstanceKey } from 'app/features/alerting/unified/utils/rules'; @@ -231,5 +231,10 @@ export function isErrorLike(error: unknown): error is Error { } export function stringifyErrorLike(error: unknown): string { + const fetchError = isFetchError(error); + if (fetchError) { + return error.data.message; + } + return isErrorLike(error) ? error.message : String(error); } diff --git a/public/app/features/alerting/unified/utils/notification-policies.ts b/public/app/features/alerting/unified/utils/notification-policies.ts index d25de2728fa..a0fed72bd66 100644 --- a/public/app/features/alerting/unified/utils/notification-policies.ts +++ b/public/app/features/alerting/unified/utils/notification-policies.ts @@ -9,7 +9,7 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { Labels } from 'app/types/unified-alerting-dto'; -import { Label, normalizeMatchers } from './matchers'; +import { Label, normalizeMatchers, unquoteWithUnescape } from './matchers'; // If a policy has no matchers it still can be a match, hence matchers can be empty and match can be true // So we cannot use null as an indicator of no match @@ -124,6 +124,20 @@ export function normalizeRoute(rootRoute: RouteWithID): RouteWithID { return normalizedRootRoute; } +export function unquoteRouteMatchers(route: RouteWithID): RouteWithID { + function unquoteRoute(route: RouteWithID) { + route.object_matchers = route.object_matchers?.map(([name, operator, value]) => { + return [name, operator, unquoteWithUnescape(value)]; + }); + route.routes?.forEach(unquoteRoute); + } + + const unwrappedRootRoute = structuredClone(route); + unquoteRoute(unwrappedRootRoute); + + return unwrappedRootRoute; +} + /** * find all of the groups that have instances that match the route, thay way we can find all instances * (and their grouping) for the given route diff --git a/public/app/features/alerting/unified/utils/rule-form.test.ts b/public/app/features/alerting/unified/utils/rule-form.test.ts index 48911f86b6a..735a8f7087b 100644 --- a/public/app/features/alerting/unified/utils/rule-form.test.ts +++ b/public/app/features/alerting/unified/utils/rule-form.test.ts @@ -1,3 +1,4 @@ +import { config } from '@grafana/runtime'; import { PromQuery } from 'app/plugins/datasource/prometheus/types'; import { GrafanaAlertStateDecision, GrafanaRuleDefinition, RulerAlertingRuleDTO } from 'app/types/unified-alerting-dto'; @@ -5,11 +6,13 @@ import { AlertManagerManualRouting, RuleFormType, RuleFormValues } from '../type import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; import { + MANUAL_ROUTING_KEY, alertingRulerRuleToRuleForm, formValuesToRulerGrafanaRuleDTO, formValuesToRulerRuleDTO, getContactPointsFromDTO, getDefaultFormValues, + getDefautManualRouting, getNotificationSettingsForDTO, } from './rule-form'; @@ -207,3 +210,33 @@ describe('getNotificationSettingsForDTO', () => { }); }); }); + +describe('getDefautManualRouting', () => { + afterEach(() => { + window.localStorage.clear(); + }); + + it('returns false if the feature toggle is not enabled', () => { + config.featureToggles.alertingSimplifiedRouting = false; + expect(getDefautManualRouting()).toBe(false); + }); + + it('returns true if the feature toggle is enabled and localStorage is not set', () => { + config.featureToggles.alertingSimplifiedRouting = true; + expect(getDefautManualRouting()).toBe(true); + }); + + it('returns false if the feature toggle is enabled and localStorage is set to "false"', () => { + config.featureToggles.alertingSimplifiedRouting = true; + localStorage.setItem(MANUAL_ROUTING_KEY, 'false'); + expect(getDefautManualRouting()).toBe(false); + }); + + it('returns true if the feature toggle is enabled and localStorage is set to any value other than "false"', () => { + config.featureToggles.alertingSimplifiedRouting = true; + localStorage.setItem(MANUAL_ROUTING_KEY, 'true'); + expect(getDefautManualRouting()).toBe(true); + localStorage.removeItem(MANUAL_ROUTING_KEY); + expect(getDefautManualRouting()).toBe(true); + }); +}); diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index cd4d6abdaad..f7dd7d6c437 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -11,7 +11,7 @@ import { ScopedVars, TimeRange, } from '@grafana/data'; -import { getDataSourceSrv } from '@grafana/runtime'; +import { config, getDataSourceSrv } from '@grafana/runtime'; import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; import { sceneGraph, VizPanel } from '@grafana/scenes'; import { DataSourceJsonData } from '@grafana/schema'; @@ -54,6 +54,8 @@ export type PromOrLokiQuery = PromQuery | LokiQuery; export const MINUTE = '1m'; +export const MANUAL_ROUTING_KEY = 'grafana.alerting.manualRouting'; + export const getDefaultFormValues = (): RuleFormValues => { const { canCreateGrafanaRules, canCreateCloudRules } = getRulesAccess(); @@ -75,7 +77,7 @@ export const getDefaultFormValues = (): RuleFormValues => { execErrState: GrafanaAlertStateDecision.Error, evaluateFor: '5m', evaluateEvery: MINUTE, - manualRouting: false, // let's decide this later + manualRouting: getDefautManualRouting(), // we default to true if the feature toggle is enabled and the user hasn't set local storage to false contactPoints: {}, overrideGrouping: false, overrideTimings: false, @@ -89,6 +91,18 @@ export const getDefaultFormValues = (): RuleFormValues => { }); }; +export const getDefautManualRouting = () => { + // first check if feature toggle for simplified routing is enabled + const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false; + if (!simplifiedRoutingToggleEnabled) { + return false; + } + //then, check in local storage if the user has enabled simplified routing + // if it's not set, we'll default to true + const manualRouting = localStorage.getItem(MANUAL_ROUTING_KEY); + return manualRouting !== 'false'; +}; + export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO { const { name, expression, forTime, forTimeUnit, keepFiringForTime, keepFiringForTimeUnit, type } = values; if (type === RuleFormType.cloudAlerting) { diff --git a/public/app/features/auth-config/AuthProvidersListPage.tsx b/public/app/features/auth-config/AuthProvidersListPage.tsx index 77acdcc2c01..07a2e54fbb3 100644 --- a/public/app/features/auth-config/AuthProvidersListPage.tsx +++ b/public/app/features/auth-config/AuthProvidersListPage.tsx @@ -62,7 +62,10 @@ export const AuthConfigPageUnconnected = ({ subTitle={ <> Manage your auth settings and configure single sign-on. Find out more in our{' '} - + documentation . diff --git a/public/app/features/auth-config/FieldRenderer.tsx b/public/app/features/auth-config/FieldRenderer.tsx index 4c74ff5c0c8..856ac56b252 100644 --- a/public/app/features/auth-config/FieldRenderer.tsx +++ b/public/app/features/auth-config/FieldRenderer.tsx @@ -47,6 +47,10 @@ export const FieldRenderer = ({ return null; } + if (!!fieldData.hidden) { + return null; + } + // Dependant field means the field depends on another field's value and shouldn't be rendered if the parent field is false if (isDependantField) { const parentValue = watch(field.dependsOn); @@ -61,7 +65,7 @@ export const FieldRenderer = ({ error: fieldData.validation?.message, key: name, description: fieldData.description, - defaultValue: fieldData.defaultValue, + defaultValue: fieldData.defaultValue?.value, }; switch (fieldData.type) { diff --git a/public/app/features/auth-config/ProviderConfigForm.test.tsx b/public/app/features/auth-config/ProviderConfigForm.test.tsx index ac76732075c..0386ab6e710 100644 --- a/public/app/features/auth-config/ProviderConfigForm.test.tsx +++ b/public/app/features/auth-config/ProviderConfigForm.test.tsx @@ -44,6 +44,7 @@ jest.mock('app/core/components/FormPrompt/FormPrompt', () => ({ const testConfig: SSOProvider = { id: '300f9b7c-0488-40db-9763-a22ce8bf6b3e', provider: 'github', + source: 'database', settings: { ...emptySettings, name: 'GitHub', @@ -101,7 +102,8 @@ describe('ProviderConfigForm', () => { expect(putMock).toHaveBeenCalledWith( '/api/v1/sso-settings/github', { - ...testConfig, + id: '300f9b7c-0488-40db-9763-a22ce8bf6b3e', + provider: 'github', settings: { name: 'GitHub', allowedOrganizations: 'test-org1,test-org2', diff --git a/public/app/features/auth-config/ProviderConfigForm.tsx b/public/app/features/auth-config/ProviderConfigForm.tsx index 184bbce65d9..17e0f24a6d5 100644 --- a/public/app/features/auth-config/ProviderConfigForm.tsx +++ b/public/app/features/auth-config/ProviderConfigForm.tsx @@ -82,7 +82,6 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf payload: [message], }); setSubmitError(true); - } finally { setIsSaving(false); } }; @@ -182,7 +181,9 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf )} - + @@ -192,6 +193,7 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf */} - - - {isHasVariableOptions && ( +
+ + + {({ showModal, hideModal }) => ( )} - -
- - console.log('needs implementation')} - onDismiss={() => console.log('needs implementation')} - /> - + + + + {isHasVariableOptions && ( + + )} + +
+ ); } + +const getStyles = (theme: GrafanaTheme2) => ({ + buttonContainer: css({ + marginTop: theme.spacing(2), + }), +}); diff --git a/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx b/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx new file mode 100644 index 00000000000..e875ee2f06a --- /dev/null +++ b/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx @@ -0,0 +1,63 @@ +// Libraries +import React, { useEffect } from 'react'; + +import { Alert, Spinner } from '@grafana/ui'; +import PageLoader from 'app/core/components/PageLoader/PageLoader'; +import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { DashboardPageRouteParams } from 'app/features/dashboard/containers/types'; +import { DashboardRoutes } from 'app/types'; + +import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager'; +import { DashboardScene } from '../scene/DashboardScene'; + +import { useSoloPanel } from './useSoloPanel'; + +export interface Props extends GrafanaRouteComponentProps {} + +/** + * Used for iframe embedding and image rendering of single panels + */ +export function SoloPanelPage({ match, queryParams }: Props) { + const stateManager = getDashboardScenePageStateManager(); + const { dashboard } = stateManager.useState(); + + useEffect(() => { + stateManager.loadDashboard({ uid: match.params.uid!, route: DashboardRoutes.Embedded }); + return () => stateManager.clearState(); + }, [stateManager, match, queryParams]); + + if (!queryParams.panelId) { + return ; + } + + if (!dashboard) { + return ; + } + + return ; +} + +export default SoloPanelPage; + +export function SoloPanelRenderer({ dashboard, panelId }: { dashboard: DashboardScene; panelId: string }) { + const [panel, error] = useSoloPanel(dashboard, panelId); + + if (error) { + return ; + } + + if (!panel) { + return ( + + Loading + + ); + } + + return ( +
+ +
+ ); +} diff --git a/public/app/features/dashboard-scene/solo/useSoloPanel.ts b/public/app/features/dashboard-scene/solo/useSoloPanel.ts new file mode 100644 index 00000000000..9ca7d2139bd --- /dev/null +++ b/public/app/features/dashboard-scene/solo/useSoloPanel.ts @@ -0,0 +1,84 @@ +import { useState, useEffect } from 'react'; + +import { VizPanel, SceneObject, SceneGridRow, getUrlSyncManager } from '@grafana/scenes'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem'; +import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; +import { DashboardRepeatsProcessedEvent } from '../scene/types'; +import { findVizPanelByKey, isPanelClone } from '../utils/utils'; + +export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPanel | undefined, string | undefined] { + const [panel, setPanel] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + getUrlSyncManager().initSync(dashboard); + + const cleanUp = dashboard.activate(); + + const panel = findVizPanelByKey(dashboard, panelId); + if (panel) { + activateParents(panel); + setPanel(panel); + } else if (isPanelClone(panelId)) { + findRepeatClone(dashboard, panelId).then((panel) => { + if (panel) { + setPanel(panel); + } else { + setError('Panel not found'); + } + }); + } + + return cleanUp; + }, [dashboard, panelId]); + + return [panel, error]; +} + +function activateParents(panel: VizPanel) { + let parent = panel.parent; + + while (parent && !parent.isActive) { + parent.activate(); + parent = parent.parent; + } +} + +function findRepeatClone(dashboard: DashboardScene, panelId: string): Promise { + return new Promise((resolve) => { + dashboard.subscribeToEvent(DashboardRepeatsProcessedEvent, () => { + const panel = findVizPanelByKey(dashboard, panelId); + if (panel) { + resolve(panel); + } else { + // If rows are repeated they could add new panel repeaters that needs to be activated + activateAllRepeaters(dashboard.state.body); + } + }); + + activateAllRepeaters(dashboard.state.body); + }); +} + +function activateAllRepeaters(layout: SceneObject) { + layout.forEachChild((child) => { + if (child instanceof PanelRepeaterGridItem && !child.isActive) { + child.activate(); + return; + } + + if (child instanceof SceneGridRow && child.state.$behaviors) { + for (const behavior of child.state.$behaviors) { + if (behavior instanceof RowRepeaterBehavior && !child.isActive) { + child.activate(); + break; + } + } + + // Activate any panel PanelRepeaterGridItem inside the row + activateAllRepeaters(child); + } + }); +} diff --git a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts index 3bcf30bcc4d..b352806381c 100644 --- a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts +++ b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts @@ -32,6 +32,9 @@ export class DashboardModelCompatibilityWrapper { public constructor(private _scene: DashboardScene) { const timeRange = sceneGraph.getTimeRange(_scene); + // Copied from DashboardModel, as this function is passed around + this.formatDate = this.formatDate.bind(this); + this._subs.add( timeRange.subscribeToState((state, prev) => { if (state.value !== prev.value) { diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index 5e1823cd701..32cd4ba0fff 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -340,7 +340,7 @@ export class PanelStateWrapper extends PureComponent { onRefresh = () => { const { dashboard, panel, isInView, width } = this.props; - if (!isInView) { + if (!dashboard.snapshot && !isInView) { panel.refreshWhenInView = true; return; } diff --git a/public/app/features/panel/state/actions.ts b/public/app/features/panel/state/actions.ts index 2e01dd5a732..c326cf812ae 100644 --- a/public/app/features/panel/state/actions.ts +++ b/public/app/features/panel/state/actions.ts @@ -147,15 +147,16 @@ export function loadLibraryPanelAndUpdate(panel: PanelModel): ThunkResult try { const libPanel = await getLibraryPanel(uid, true); panel.initLibraryPanel(libPanel); - await dispatch(initPanelState(panel)); - const dashboard = getStore().dashboard.getModel(); + const dashboard = getStore().dashboard.getModel(); if (panel.repeat && dashboard) { const panelIndex = dashboard.panels.findIndex((p) => p.id === panel.id); dashboard.repeatPanel(panel, panelIndex); dashboard.sortPanelsByGridPos(); dashboard.events.publish(new DashboardPanelsChangedEvent()); } + + await dispatch(initPanelState(panel)); } catch (ex) { console.log('ERROR: ', ex); dispatch( diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 0287e6686de..d28c68ed1e7 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -30,9 +30,7 @@ const mssqlPlugin = async () => const testDataDSPlugin = async () => await import(/* webpackChunkName: "testDataDSPlugin" */ '@grafana-plugins/grafana-testdata-datasource/module'); const cloudMonitoringPlugin = async () => - await import( - /* webpackChunkName: "cloudMonitoringPlugin" */ '@grafana-plugins/grafana-cloud-monitoring-datasource/module' - ); + await import(/* webpackChunkName: "cloudMonitoringPlugin" */ '@grafana-plugins/stackdriver/module'); const azureMonitorPlugin = async () => await import(/* webpackChunkName: "azureMonitorPlugin" */ '@grafana-plugins/grafana-azure-monitor-datasource/module'); const tempoPlugin = async () => await import(/* webpackChunkName: "tempoPlugin" */ '@grafana-plugins/tempo/module'); diff --git a/public/app/plugins/datasource/azuremonitor/package.json b/public/app/plugins/datasource/azuremonitor/package.json index 1c4388ff2e9..1aed00090cd 100644 --- a/public/app/plugins/datasource/azuremonitor/package.json +++ b/public/app/plugins/datasource/azuremonitor/package.json @@ -29,7 +29,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.12", "@types/lodash": "4.14.202", - "@types/node": "20.11.16", + "@types/node": "20.11.17", "@types/prismjs": "1.26.3", "@types/react": "18.2.55", "@types/testing-library__jest-dom": "5.14.9", diff --git a/public/app/plugins/datasource/azuremonitor/tsconfig.json b/public/app/plugins/datasource/azuremonitor/tsconfig.json index 9ace7d78dbf..7daf2ee8aba 100644 --- a/public/app/plugins/datasource/azuremonitor/tsconfig.json +++ b/public/app/plugins/datasource/azuremonitor/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "@grafana/plugin-configs/tsconfig.json", - "include": ["."], + "include": ["."] } diff --git a/public/app/plugins/datasource/cloud-monitoring/package.json b/public/app/plugins/datasource/cloud-monitoring/package.json index 5af90024bfe..80bf3ebabba 100644 --- a/public/app/plugins/datasource/cloud-monitoring/package.json +++ b/public/app/plugins/datasource/cloud-monitoring/package.json @@ -1,5 +1,5 @@ { - "name": "@grafana-plugins/grafana-cloud-monitoring-datasource", + "name": "@grafana-plugins/stackdriver", "description": "Grafana data source for Google Cloud Monitoring", "private": true, "version": "10.4.0-pre", @@ -32,7 +32,7 @@ "@types/debounce-promise": "3.1.9", "@types/jest": "29.5.12", "@types/lodash": "4.14.202", - "@types/node": "20.11.16", + "@types/node": "20.11.17", "@types/prismjs": "1.26.3", "@types/react": "18.2.55", "@types/react-test-renderer": "18.0.7", diff --git a/public/app/plugins/datasource/cloud-monitoring/tsconfig.json b/public/app/plugins/datasource/cloud-monitoring/tsconfig.json index 9ace7d78dbf..7daf2ee8aba 100644 --- a/public/app/plugins/datasource/cloud-monitoring/tsconfig.json +++ b/public/app/plugins/datasource/cloud-monitoring/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "@grafana/plugin-configs/tsconfig.json", - "include": ["."], + "include": ["."] } diff --git a/public/app/plugins/datasource/cloud-monitoring/types/query.ts b/public/app/plugins/datasource/cloud-monitoring/types/query.ts index a1e5a2abe29..a202c85bd3e 100644 --- a/public/app/plugins/datasource/cloud-monitoring/types/query.ts +++ b/public/app/plugins/datasource/cloud-monitoring/types/query.ts @@ -1,19 +1,15 @@ import { CloudMonitoringQuery as CloudMonitoringQueryBase, QueryType } from '../dataquery.gen'; export { QueryType }; -export { - TimeSeriesList, - PreprocessorType, +export { PreprocessorType, MetricKind, AlignmentTypes, ValueTypes, MetricFindQueryTypes } from '../dataquery.gen'; +export type { TimeSeriesQuery, SLOQuery, + TimeSeriesList, MetricQuery, - MetricKind, + PromQLQuery, LegacyCloudMonitoringAnnotationQuery, Filter, - AlignmentTypes, - ValueTypes, - MetricFindQueryTypes, - PromQLQuery, } from '../dataquery.gen'; /** diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MathExpressionQueryField.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MathExpressionQueryField.tsx index 110d8171c10..5ac7ca8e226 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MathExpressionQueryField.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MathExpressionQueryField.tsx @@ -29,8 +29,9 @@ export function MathExpressionQueryField({ expression: query, onChange, datasour // We may wish to consider abstracting it into the grafana/ui repo in the future const updateElementHeight = () => { const containerDiv = containerRef.current; - if (containerDiv !== null && editor.getContentHeight() < 200) { - const pixelHeight = Math.max(32, editor.getContentHeight()); + if (containerDiv !== null) { + const maxPixelHeight = Math.min(200, editor.getContentHeight()); + const pixelHeight = Math.max(32, maxPixelHeight); containerDiv.style.height = `${pixelHeight}px`; containerDiv.style.width = '100%'; const pixelWidth = containerDiv.clientWidth; diff --git a/public/app/plugins/datasource/elasticsearch/ElasticResponse.test.ts b/public/app/plugins/datasource/elasticsearch/ElasticResponse.test.ts index e72e6b87a8c..0f8a9aa059d 100644 --- a/public/app/plugins/datasource/elasticsearch/ElasticResponse.test.ts +++ b/public/app/plugins/datasource/elasticsearch/ElasticResponse.test.ts @@ -1,9 +1,9 @@ import { DataFrame, DataFrameView, Field, FieldCache, FieldType, KeyValue, MutableDataFrame } from '@grafana/data'; -import flatten from 'app/core/utils/flatten'; import { ElasticResponse } from './ElasticResponse'; import { highlightTags } from './queryDef'; import { ElasticsearchQuery } from './types'; +import { flattenObject } from './utils'; function getTimeField(frame: DataFrame): Field { const field = frame.fields[0]; @@ -1445,7 +1445,7 @@ describe('ElasticResponse', () => { expect(r._id).toEqual(response.responses[0].hits.hits[i]._id); expect(r._type).toEqual(response.responses[0].hits.hits[i]._type); expect(r._index).toEqual(response.responses[0].hits.hits[i]._index); - expect(r._source).toEqual(flatten(response.responses[0].hits.hits[i]._source)); + expect(r._source).toEqual(flattenObject(response.responses[0].hits.hits[i]._source)); } // Make a map from the histogram results diff --git a/public/app/plugins/datasource/elasticsearch/ElasticResponse.ts b/public/app/plugins/datasource/elasticsearch/ElasticResponse.ts index 46994b11ba6..07ef4a2645a 100644 --- a/public/app/plugins/datasource/elasticsearch/ElasticResponse.ts +++ b/public/app/plugins/datasource/elasticsearch/ElasticResponse.ts @@ -10,13 +10,12 @@ import { } from '@grafana/data'; import { convertFieldType } from '@grafana/data/src/transformations/transformers/convertFieldType'; import TableModel from 'app/core/TableModel'; -import flatten from 'app/core/utils/flatten'; import { isMetricAggregationWithField } from './components/QueryEditor/MetricAggregationsEditor/aggregations'; import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils'; import * as queryDef from './queryDef'; import { ElasticsearchAggregation, ElasticsearchQuery, TopMetrics, ExtendedStatMetaType } from './types'; -import { describeMetric, getScriptValue } from './utils'; +import { describeMetric, flattenObject, getScriptValue } from './utils'; const HIGHLIGHT_TAGS_EXP = `${queryDef.highlightTags.pre}([^@]+)${queryDef.highlightTags.post}`; type TopMetricMetric = Record; @@ -678,7 +677,7 @@ const flattenHits = (hits: Doc[]): { docs: Array>; propNames let propNames: string[] = []; for (const hit of hits) { - const flattened = hit._source ? flatten(hit._source) : {}; + const flattened = hit._source ? flattenObject(hit._source) : {}; const doc = { _id: hit._id, _type: hit._type, diff --git a/public/app/plugins/datasource/elasticsearch/IndexPattern.test.ts b/public/app/plugins/datasource/elasticsearch/IndexPattern.test.ts index 38ecf5c07fa..30ee68f7be3 100644 --- a/public/app/plugins/datasource/elasticsearch/IndexPattern.test.ts +++ b/public/app/plugins/datasource/elasticsearch/IndexPattern.test.ts @@ -1,5 +1,3 @@ -/// - import { toUtc, getLocale, setLocale, dateTime } from '@grafana/data'; import { IndexPattern } from './IndexPattern'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx index 9389f779bf7..52b45832fbf 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.test.tsx @@ -1,10 +1,9 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; -import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; - -import { DateHistogram } from 'app/plugins/datasource/elasticsearch/types'; +import { select } from 'react-select-event'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; +import { DateHistogram } from '../../../../types'; import { DateHistogramSettingsEditor } from './DateHistogramSettingsEditor'; @@ -63,7 +62,7 @@ describe('DateHistogramSettingsEditor', () => { expect(await screen.findByText('Calendar interval')).toBeInTheDocument(); expect(await screen.findByText('1w')).toBeInTheDocument(); - await selectOptionInTest(screen.getByLabelText('Calendar interval'), '10s'); + await select(screen.getByLabelText('Calendar interval'), '10s', { container: document.body }); expect(dispatch).toHaveBeenCalledTimes(1); }); @@ -79,7 +78,7 @@ describe('DateHistogramSettingsEditor', () => { expect(await screen.findByText('Fixed interval')).toBeInTheDocument(); expect(await screen.findByText('1m')).toBeInTheDocument(); - await selectOptionInTest(screen.getByLabelText('Fixed interval'), '1q'); + await select(screen.getByLabelText('Fixed interval'), '1q', { container: document.body }); expect(dispatch).toHaveBeenCalledTimes(1); }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx index 7fd36121783..52a3081ac19 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx @@ -4,8 +4,8 @@ import { GroupBase, OptionsOrGroups } from 'react-select'; import { InternalTimeZones, SelectableValue } from '@grafana/data'; import { InlineField, Input, Select, TimeZonePicker } from '@grafana/ui'; -import { calendarIntervals } from 'app/plugins/datasource/elasticsearch/QueryBuilder'; +import { calendarIntervals } from '../../../../QueryBuilder'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { DateHistogram } from '../../../../types'; import { useCreatableSelectPersistedBehaviour } from '../../../hooks/useCreatableSelectPersistedBehaviour'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx index 87dedea1f6d..97442a66f35 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.test.tsx @@ -2,10 +2,9 @@ import { screen } from '@testing-library/react'; import React from 'react'; import selectEvent from 'react-select-event'; -import { describeMetric } from 'app/plugins/datasource/elasticsearch/utils'; - import { renderWithESProvider } from '../../../../test-helpers/render'; import { ElasticsearchQuery, Terms, Average, Derivative, TopMetrics } from '../../../../types'; +import { describeMetric } from '../../../../utils'; import { TermsSettingsEditor } from './TermsSettingsEditor'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts index 5def15faa55..282df98b144 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts @@ -1,5 +1,4 @@ -import { defaultGeoHashPrecisionString } from 'app/plugins/datasource/elasticsearch/queryDef'; - +import { defaultGeoHashPrecisionString } from '../../../../queryDef'; import { BucketAggregation } from '../../../../types'; import { describeMetric, convertOrderByToMetricId } from '../../../../utils'; import { useQuery } from '../../ElasticsearchQueryContext'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts index b39166c1093..bdb9616b91d 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts @@ -1,9 +1,6 @@ -import { reducerTester } from 'test/core/redux/reducerTester'; - -import { defaultBucketAgg } from 'app/plugins/datasource/elasticsearch/queryDef'; -import { ElasticsearchQuery } from 'app/plugins/datasource/elasticsearch/types'; - -import { BucketAggregation, DateHistogram } from '../../../../types'; +import { defaultBucketAgg } from '../../../../queryDef'; +import { BucketAggregation, DateHistogram, ElasticsearchQuery } from '../../../../types'; +import { reducerTester } from '../../../reducerTester'; import { changeMetricType } from '../../MetricAggregationsEditor/state/actions'; import { initQuery } from '../../state'; import { bucketAggregationConfig } from '../utils'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts index f21388ffc7f..b9ecf0749e7 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts @@ -1,6 +1,5 @@ -import { reducerTester } from 'test/core/redux/reducerTester'; - import { PipelineVariable } from '../../../../../../types'; +import { reducerTester } from '../../../../../reducerTester'; import { addPipelineVariable, diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx index 8d58b2960d1..5a0d48924c1 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx @@ -2,10 +2,10 @@ import { uniqueId } from 'lodash'; import React, { ComponentProps, useState } from 'react'; import { InlineField, Input } from '@grafana/ui'; -import { getScriptValue } from 'app/plugins/datasource/elasticsearch/utils'; import { useDispatch } from '../../../../hooks/useStatelessReducer'; import { MetricAggregationWithInlineScript, MetricAggregationWithSettings } from '../../../../types'; +import { getScriptValue } from '../../../../utils'; import { SettingKeyOf } from '../../../types'; import { changeMetricSetting } from '../state/actions'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts index 246642b7b5e..0719b2024c8 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts @@ -1,9 +1,6 @@ -import { reducerTester } from 'test/core/redux/reducerTester'; - -import { ElasticsearchQuery } from 'app/plugins/datasource/elasticsearch/types'; - import { defaultMetricAgg } from '../../../../queryDef'; -import { Derivative, ExtendedStats, MetricAggregation } from '../../../../types'; +import { Derivative, ElasticsearchQuery, ExtendedStats, MetricAggregation } from '../../../../types'; +import { reducerTester } from '../../../reducerTester'; import { initQuery } from '../../state'; import { metricAggregationConfig } from '../utils'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts index bb7ea5a161f..987551baf51 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts @@ -1,6 +1,5 @@ -import { reducerTester } from 'test/core/redux/reducerTester'; - import { ElasticsearchQuery } from '../../types'; +import { reducerTester } from '../reducerTester'; import { aliasPatternReducer, changeAliasPattern, changeQuery, initQuery, queryReducer } from './state'; diff --git a/public/app/plugins/datasource/elasticsearch/components/reducerTester.ts b/public/app/plugins/datasource/elasticsearch/components/reducerTester.ts new file mode 100644 index 00000000000..38c466afa2b --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/reducerTester.ts @@ -0,0 +1,109 @@ +import { AnyAction } from '@reduxjs/toolkit'; +import { cloneDeep } from 'lodash'; +import { Action } from 'redux'; + +import { StoreState } from 'app/types'; + +type GrafanaReducer = (state: S, action: A) => S; + +export interface Given { + givenReducer: ( + reducer: GrafanaReducer, + state: State, + showDebugOutput?: boolean, + disableDeepFreeze?: boolean + ) => When; +} + +export interface When { + whenActionIsDispatched: (action: AnyAction) => Then; +} + +export interface Then { + thenStateShouldEqual: (state: State) => When; + thenStatePredicateShouldEqual: (predicate: (resultingState: State) => boolean) => When; + whenActionIsDispatched: (action: AnyAction) => Then; +} + +const isNotException = (object: unknown, propertyName: string) => + typeof object === 'function' + ? propertyName !== 'caller' && propertyName !== 'callee' && propertyName !== 'arguments' + : true; + +export const deepFreeze = (obj: T): T => { + if (typeof obj === 'object') { + for (const key in obj) { + const prop = obj[key]; + + if ( + prop && + Object.hasOwn(obj, key) && + isNotException(obj, key) && + (typeof prop === 'object' || typeof prop === 'function') && + !Object.isFrozen(prop) + ) { + deepFreeze(prop); + } + } + } + + return Object.freeze(obj); +}; + +interface ReducerTester extends Given, When, Then {} + +export const reducerTester = (): Given => { + let reducerUnderTest: GrafanaReducer; + let resultingState: State; + let initialState: State; + let showDebugOutput = false; + + const givenReducer = ( + reducer: GrafanaReducer, + state: State, + debug = false, + disableDeepFreeze = false + ): When => { + reducerUnderTest = reducer; + initialState = cloneDeep(state); + if (!disableDeepFreeze && (typeof state === 'object' || typeof state === 'function')) { + deepFreeze(initialState); + } + showDebugOutput = debug; + + return instance; + }; + + const whenActionIsDispatched = (action: AnyAction): Then => { + resultingState = reducerUnderTest(resultingState || initialState, action); + + return instance; + }; + + const thenStateShouldEqual = (state: State): When => { + if (showDebugOutput) { + console.log(JSON.stringify(resultingState, null, 2)); + } + expect(resultingState).toEqual(state); + + return instance; + }; + + const thenStatePredicateShouldEqual = (predicate: (resultingState: State) => boolean): When => { + if (showDebugOutput) { + console.log(JSON.stringify(resultingState, null, 2)); + } + expect(predicate(resultingState)).toBe(true); + + return instance; + }; + + const instance: ReducerTester = { + thenStateShouldEqual, + thenStatePredicateShouldEqual, + givenReducer, + whenActionIsDispatched, + }; + + return instance; +}; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx index 850cdb470df..8438017aa40 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { ConfigEditor } from './ConfigEditor'; -import { createDefaultConfigOptions } from './mocks'; +import { createDefaultConfigOptions } from './__mocks__/configOptions'; describe('ConfigEditor', () => { it('should render without error', () => { diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx index 296a4e24b28..1dc4aa84780 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx @@ -11,8 +11,8 @@ import { convertLegacyAuthProps, DataSourceDescription, } from '@grafana/experimental'; +import { config } from '@grafana/runtime'; import { Alert, SecureSocksProxySettings, Divider, Stack } from '@grafana/ui'; -import { config } from 'app/core/config'; import { ElasticsearchOptions } from '../types'; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx b/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx index 87835825539..da2c231ad13 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx @@ -3,6 +3,7 @@ import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; import { usePrevious } from 'react-use'; import { DataSourceInstanceSettings, VariableSuggestion } from '@grafana/data'; +import { DataSourcePicker } from '@grafana/runtime'; import { Button, DataLinkInput, @@ -13,7 +14,6 @@ import { Input, useStyles2, } from '@grafana/ui'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { DataLinkConfig } from '../types'; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx index 42c69cd9cd9..0b7af77201d 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import selectEvent from 'react-select-event'; import { ElasticDetails } from './ElasticDetails'; -import { createDefaultConfigOptions } from './mocks'; +import { createDefaultConfigOptions } from './__mocks__/configOptions'; describe('ElasticDetails', () => { describe('Max concurrent Shard Requests', () => { diff --git a/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.test.tsx b/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.test.tsx index 861ed7cdfc1..97d3d130398 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.test.tsx @@ -2,7 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { LogsConfig } from './LogsConfig'; -import { createDefaultConfigOptions } from './mocks'; +import { createDefaultConfigOptions } from './__mocks__/configOptions'; describe('ElasticDetails', () => { it('should pass correct data to onChange', () => { diff --git a/public/app/plugins/datasource/elasticsearch/configuration/__mocks__/configOptions.ts b/public/app/plugins/datasource/elasticsearch/configuration/__mocks__/configOptions.ts new file mode 100644 index 00000000000..8f7e7963ceb --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/__mocks__/configOptions.ts @@ -0,0 +1,17 @@ +import { DataSourceSettings } from '@grafana/data'; + +import { ElasticsearchOptions } from '../../types'; + +export function createDefaultConfigOptions(): DataSourceSettings { + return { + jsonData: { + timeField: '@time', + interval: 'Hourly', + timeInterval: '10s', + maxConcurrentShardRequests: 300, + logMessageField: 'test.message', + logLevelField: 'test.level', + }, + secureJsonFields: {}, + } as DataSourceSettings; +} diff --git a/public/app/plugins/datasource/elasticsearch/configuration/mocks.ts b/public/app/plugins/datasource/elasticsearch/configuration/mocks.ts deleted file mode 100644 index c716a4d03ab..00000000000 --- a/public/app/plugins/datasource/elasticsearch/configuration/mocks.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { DataSourceSettings } from '@grafana/data'; -import { getMockDataSource } from 'app/features/datasources/__mocks__'; - -import { ElasticsearchOptions } from '../types'; - -export function createDefaultConfigOptions( - options?: Partial -): DataSourceSettings { - return getMockDataSource({ - jsonData: { - timeField: '@time', - interval: 'Hourly', - timeInterval: '10s', - maxConcurrentShardRequests: 300, - logMessageField: 'test.message', - logLevelField: 'test.level', - ...options, - }, - }); -} diff --git a/public/app/plugins/datasource/elasticsearch/datasource.test.ts b/public/app/plugins/datasource/elasticsearch/datasource.test.ts index 0a7d8a57469..7fd3446dcfb 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.test.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.test.ts @@ -1,6 +1,5 @@ import { map } from 'lodash'; import { Observable, of, throwError } from 'rxjs'; -import { getQueryOptions } from 'test/helpers/getQueryOptions'; import { CoreApp, @@ -18,9 +17,6 @@ import { toUtc, } from '@grafana/data'; import { BackendSrvRequest, FetchResponse, reportInteraction, config } from '@grafana/runtime'; -import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ - -import { createFetchResponse } from '../../../../test/helpers/createFetchResponse'; import { enhanceDataFrame } from './LegacyQueryRunner'; import { ElasticDatasource } from './datasource'; @@ -30,7 +26,9 @@ import { Filters, ElasticsearchOptions, ElasticsearchQuery } from './types'; const ELASTICSEARCH_MOCK_URL = 'http://elasticsearch.local'; const originalConsoleError = console.error; - +const backendSrv = { + fetch: jest.fn(), +}; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getBackendSrv: () => backendSrv, @@ -44,6 +42,15 @@ jest.mock('@grafana/runtime', () => ({ }, })); +const createTimeRange = (from: DateTime, to: DateTime): TimeRange => ({ + from, + to, + raw: { + from, + to, + }, +}); + const TIME_START = [2022, 8, 21, 6, 10, 10]; const TIME_END = [2022, 8, 24, 6, 10, 21]; const DATAQUERY_BASE = { @@ -56,16 +63,22 @@ const DATAQUERY_BASE = { timezone: '', app: 'test', startTime: 0, + range: createTimeRange(toUtc(TIME_START), toUtc(TIME_END)), }; -const createTimeRange = (from: DateTime, to: DateTime): TimeRange => ({ - from, - to, - raw: { - from, - to, - }, -}); +function createFetchResponse(data: T): FetchResponse { + return { + data, + status: 200, + url: 'http://localhost:3000/api/ds/query', + config: { url: 'http://localhost:3000/api/ds/query' }, + type: 'basic', + statusText: 'Ok', + redirected: false, + headers: {} as unknown as Headers, + ok: true, + }; +} interface TestContext { data?: Data; @@ -977,27 +990,29 @@ describe('ElasticDatasource', () => { }); it('does not create a logs sample provider for non time series query', () => { - const options = getQueryOptions({ + const options: DataQueryRequest = { + ...DATAQUERY_BASE, targets: [ { refId: 'A', metrics: [{ type: 'logs', id: '1', settings: { limit: '100' } }], }, ], - }); + }; expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsSample, options)).not.toBeDefined(); }); it('does create a logs sample provider for time series query', () => { - const options = getQueryOptions({ + const options: DataQueryRequest = { + ...DATAQUERY_BASE, targets: [ { refId: 'A', bucketAggs: [{ type: 'date_histogram', id: '1' }], }, ], - }); + }; expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsSample, options)).toBeDefined(); }); @@ -1010,27 +1025,29 @@ describe('ElasticDatasource', () => { }); it("doesn't return a logs sample provider given a non time series query", () => { - const request = getQueryOptions({ + const request: DataQueryRequest = { + ...DATAQUERY_BASE, targets: [ { refId: 'A', metrics: [{ type: 'logs', id: '1', settings: { limit: '100' } }], }, ], - }); + }; expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsSample, request)).not.toBeDefined(); }); it('returns a logs sample provider given a time series query', () => { - const request = getQueryOptions({ + const request: DataQueryRequest = { + ...DATAQUERY_BASE, targets: [ { refId: 'A', bucketAggs: [{ type: 'date_histogram', id: '1' }], }, ], - }); + }; expect(ds.getSupplementaryRequest(SupplementaryQueryType.LogsSample, request)).toBeDefined(); }); diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts index 52169626b2a..7fab51613c9 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -38,6 +38,7 @@ import { AdHocVariableFilter, DataSourceWithQueryModificationSupport, AdHocVariableModel, + TypedVariableModel, } from '@grafana/data'; import { DataSourceWithBackend, @@ -47,7 +48,6 @@ import { TemplateSrv, getTemplateSrv, } from '@grafana/runtime'; -import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { IndexPattern, intervalMap } from './IndexPattern'; import LanguageProvider from './LanguageProvider'; @@ -264,7 +264,8 @@ export class ElasticDatasource private prepareAnnotationRequest(options: { annotation: ElasticsearchAnnotationQuery; - dashboard: DashboardModel; + // Should be DashboardModel but cannot import that here from the main app. This is a temporary solution as we need to move from deprecated annotations. + dashboard: { getVariables: () => TypedVariableModel[] }; range: TimeRange; }) { const annotation = options.annotation; diff --git a/public/app/plugins/datasource/elasticsearch/tracking.ts b/public/app/plugins/datasource/elasticsearch/tracking.ts index a4d25158ec7..01e3c106dfc 100644 --- a/public/app/plugins/datasource/elasticsearch/tracking.ts +++ b/public/app/plugins/datasource/elasticsearch/tracking.ts @@ -1,10 +1,10 @@ import { CoreApp, DashboardLoadedEvent, DataQueryRequest, DataQueryResponse } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; -import { variableRegex } from 'app/features/variables/utils'; import { REF_ID_STARTER_LOG_VOLUME } from './datasource'; import pluginJson from './plugin.json'; import { ElasticsearchAnnotationQuery, ElasticsearchQuery } from './types'; +import { variableRegex } from './utils'; type ElasticSearchOnDashboardLoadedTrackingEvent = { grafana_version?: string; diff --git a/public/app/plugins/datasource/elasticsearch/utils.test.ts b/public/app/plugins/datasource/elasticsearch/utils.test.ts index dcf10900c64..731efa9aefe 100644 --- a/public/app/plugins/datasource/elasticsearch/utils.test.ts +++ b/public/app/plugins/datasource/elasticsearch/utils.test.ts @@ -1,5 +1,5 @@ import { ElasticsearchQuery } from './types'; -import { isTimeSeriesQuery, removeEmpty } from './utils'; +import { flattenObject, isTimeSeriesQuery, removeEmpty } from './utils'; describe('removeEmpty', () => { it('Should remove all empty', () => { @@ -79,3 +79,39 @@ describe('isTimeSeriesQuery', () => { expect(isTimeSeriesQuery(query)).toBe(true); }); }); + +describe('flattenObject', () => { + it('flattens objects of arbitrary depth', () => { + const nestedObject = { + a: { + b: { + c: 1, + d: { + e: 2, + f: 3, + }, + }, + g: 4, + }, + h: 5, + }; + + expect(flattenObject(nestedObject)).toEqual({ + 'a.b.c': 1, + 'a.b.d.e': 2, + 'a.b.d.f': 3, + 'a.g': 4, + h: 5, + }); + }); + + it('does not alter other objects', () => { + const nestedObject = { + a: 'uno', + b: 'dos', + c: 3, + }; + + expect(flattenObject(nestedObject)).toEqual(nestedObject); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/utils.ts b/public/app/plugins/datasource/elasticsearch/utils.ts index 63e3ac25feb..1ca679734cf 100644 --- a/public/app/plugins/datasource/elasticsearch/utils.ts +++ b/public/app/plugins/datasource/elasticsearch/utils.ts @@ -106,3 +106,53 @@ export const unsupportedVersionMessage = export const isTimeSeriesQuery = (query: ElasticsearchQuery): boolean => { return query?.bucketAggs?.slice(-1)[0]?.type === 'date_histogram'; }; + +/* + * This regex matches 3 types of variable reference with an optional format specifier + * There are 6 capture groups that replace will return + * \$(\w+) $var1 + * \[\[(\w+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] + * \${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?} ${var3} or ${var3.fieldPath} or ${var3:fmt3} (or ${var3.fieldPath:fmt3} but that is not a separate capture group) + */ +export const variableRegex = /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g; + +// Copyright (c) 2014, Hugh Kennedy +// Based on code from https://github.com/hughsk/flat/blob/master/index.js +// +export function flattenObject( + target: Record, + opts?: { delimiter?: string; maxDepth?: number; safe?: boolean } +): Record { + opts = opts || {}; + + const delimiter = opts.delimiter || '.'; + let maxDepth = opts.maxDepth || 3; + let currentDepth = 1; + const output: Record = {}; + + function step(object: Record, prev: string | null) { + Object.keys(object).forEach((key) => { + const value = object[key]; + const isarray = opts?.safe && Array.isArray(value); + const type = Object.prototype.toString.call(value); + const isobject = type === '[object Object]'; + + const newKey = prev ? prev + delimiter + key : key; + + if (!opts?.maxDepth) { + maxDepth = currentDepth + 1; + } + + if (!isarray && isobject && value && Object.keys(value).length && currentDepth < maxDepth) { + ++currentDepth; + return step({ ...value }, newKey); + } + + output[newKey] = value; + }); + } + + step(target, null); + + return output; +} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/tsconfig.json b/public/app/plugins/datasource/grafana-pyroscope-datasource/tsconfig.json index 9ace7d78dbf..7daf2ee8aba 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/tsconfig.json +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "@grafana/plugin-configs/tsconfig.json", - "include": ["."], + "include": ["."] } diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/package.json b/public/app/plugins/datasource/grafana-testdata-datasource/package.json index 11d0b7aa775..5595a328d48 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/package.json +++ b/public/app/plugins/datasource/grafana-testdata-datasource/package.json @@ -27,7 +27,7 @@ "@types/d3-random": "^3.0.2", "@types/jest": "29.5.12", "@types/lodash": "4.14.202", - "@types/node": "20.11.16", + "@types/node": "20.11.17", "@types/react": "18.2.55", "@types/testing-library__jest-dom": "5.14.9", "@types/uuid": "9.0.8", diff --git a/public/app/plugins/datasource/grafana/datasource.ts b/public/app/plugins/datasource/grafana/datasource.ts index 934eadf8708..698db42459d 100644 --- a/public/app/plugins/datasource/grafana/datasource.ts +++ b/public/app/plugins/datasource/grafana/datasource.ts @@ -216,7 +216,7 @@ export class GrafanaDatasource extends DataSourceWithBackend { if (target.type === GrafanaAnnotationType.Dashboard) { // if no dashboard id yet return - if (!options.dashboard.uid) { + if (!options.dashboard?.uid) { return Promise.resolve({ data: [] }); } // filter by dashboard id diff --git a/public/app/plugins/datasource/parca/tsconfig.json b/public/app/plugins/datasource/parca/tsconfig.json index 9ace7d78dbf..7daf2ee8aba 100644 --- a/public/app/plugins/datasource/parca/tsconfig.json +++ b/public/app/plugins/datasource/parca/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "@grafana/plugin-configs/tsconfig.json", - "include": ["."], + "include": ["."] } diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 5b3e9ec7f27..5a0264a4571 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -1,4 +1,4 @@ -import { cloneDeep, defaults } from 'lodash'; +import { defaults } from 'lodash'; import { lastValueFrom, Observable, throwError } from 'rxjs'; import { map, tap } from 'rxjs/operators'; import semver from 'semver/preload'; @@ -877,7 +877,7 @@ export class PrometheusDatasource // Used when running queries through backend applyTemplateVariables(target: PromQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[]) { - const variables = cloneDeep(scopedVars); + const variables = { ...scopedVars }; // We want to interpolate these variables on backend. // The pre-calculated values are replaced withe the variable strings. diff --git a/public/app/plugins/datasource/tempo/package.json b/public/app/plugins/datasource/tempo/package.json index 5e14de8ac28..41dc8b8ba05 100644 --- a/public/app/plugins/datasource/tempo/package.json +++ b/public/app/plugins/datasource/tempo/package.json @@ -47,7 +47,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.12", "@types/lodash": "4.14.202", - "@types/node": "20.11.16", + "@types/node": "20.11.17", "@types/prismjs": "1.26.3", "@types/react": "18.2.55", "@types/react-dom": "18.2.19", diff --git a/public/app/plugins/datasource/tempo/tsconfig.json b/public/app/plugins/datasource/tempo/tsconfig.json index 334e3949dfb..6dc8a770cba 100644 --- a/public/app/plugins/datasource/tempo/tsconfig.json +++ b/public/app/plugins/datasource/tempo/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "types": ["node", "jest", "@testing-library/jest-dom"], + "types": ["node", "jest", "@testing-library/jest-dom"] }, "extends": "@grafana/plugin-configs/tsconfig.json", - "include": ["."], + "include": ["."] } diff --git a/public/app/plugins/panel/table/module.tsx b/public/app/plugins/panel/table/module.tsx index 17f62aff423..5b626dc69fe 100644 --- a/public/app/plugins/panel/table/module.tsx +++ b/public/app/plugins/panel/table/module.tsx @@ -53,10 +53,10 @@ export const plugin = new PanelPlugin(TablePanel) name: 'Column alignment', settings: { options: [ - { label: 'auto', value: 'auto' }, - { label: 'left', value: 'left' }, - { label: 'center', value: 'center' }, - { label: 'right', value: 'right' }, + { label: 'Auto', value: 'auto' }, + { label: 'Left', value: 'left' }, + { label: 'Center', value: 'center' }, + { label: 'Right', value: 'right' }, ], }, defaultValue: defaultTableFieldOptions.align, diff --git a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx index 32b6da49ca8..c1e616aeb22 100644 --- a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx +++ b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useReducer } from 'react'; import { createPortal } from 'react-dom'; import tinycolor from 'tinycolor2'; import uPlot from 'uplot'; @@ -68,6 +68,8 @@ export const AnnotationsPlugin2 = ({ const styles = useStyles2(getStyles); const getColorByName = useTheme2().visualization.getColorByName; + const [_, forceUpdate] = useReducer((x) => x + 1, 0); + const annos = useMemo(() => { let annos = annotations.filter( (frame) => frame.name !== 'exemplar' && frame.length > 0 && frame.fields.some((f) => f.name === 'time') @@ -165,6 +167,13 @@ export const AnnotationsPlugin2 = ({ useEffect(() => { if (plot) { plot.redraw(); + + // this forces a second redraw after uPlot is updated (in the Plot.tsx didUpdate) with new data/scales + // and ensures the anno marker positions in the dom are re-rendered in correct places + // (this is temp fix until uPlot integrtion is refactored) + setTimeout(() => { + forceUpdate(); + }, 0); } }, [annos, plot]); diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 20c5d5e8fc8..846e354fb43 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -85,8 +85,10 @@ export function getAppRoutes(): RouteDescriptor[] { pageClass: 'dashboard-solo', routeName: DashboardRoutes.Normal, chromeless: true, - component: SafeDynamicImport( - () => import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage') + component: SafeDynamicImport(() => + config.featureToggles.dashboardSceneSolo + ? import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard-scene/solo/SoloPanelPage') + : import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage') ), }, // This route handles embedding of snapshot/scripted dashboard panels @@ -99,15 +101,6 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage') ), }, - { - path: '/d-solo/:uid', - pageClass: 'dashboard-solo', - routeName: DashboardRoutes.Normal, - chromeless: true, - component: SafeDynamicImport( - () => import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage') - ), - }, { path: '/dashboard/import', component: SafeDynamicImport( diff --git a/public/img/icons/unicons/frontend-observability.svg b/public/img/icons/unicons/frontend-observability.svg index 38ffbd70502..aaac7f2447b 100644 --- a/public/img/icons/unicons/frontend-observability.svg +++ b/public/img/icons/unicons/frontend-observability.svg @@ -1,4 +1,6 @@ - - - + + + + + diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 478b5597586..9bcd31e0641 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -1133,89 +1133,89 @@ "delete-modal": { "revoke-nonorphaned-body-text": "Sind Sie sicher, dass Sie diese URL widerrufen möchten? Das Dashboard wird nicht mehr öffentlich sein.", "revoke-orphaned-body-text": "Das verwaiste öffentliche Dashboard wird nicht mehr öffentlich sein.", - "revoke-title": "" + "revoke-title": "Öffentliche URL widerrufen" }, "email-sharing": { - "input-invalid-email-text": "", - "input-required-email-text": "", - "invite-button": "", - "invite-field-desc": "", - "invite-field-label": "", - "resend-button": "", - "resend-button-title": "", - "revoke-button": "", - "revoke-button-title": "" + "input-invalid-email-text": "Ungültige E-Mail", + "input-required-email-text": "E-Mail-Adresse ist erforderlich", + "invite-button": "Einladen", + "invite-field-desc": "Personen per E-Mail einladen", + "invite-field-label": "Einladen", + "resend-button": "Erneut senden", + "resend-button-title": "Erneut senden", + "revoke-button": "Widerrufen", + "revoke-button-title": "Widerrufen" }, "modal-alerts": { - "no-upsert-perm-alert-desc": "", - "no-upsert-perm-alert-title": "", - "save-dashboard-changes-alert-title": "", - "unsupport-data-source-alert-readmore-link": "", - "unsupported-data-source-alert-desc": "", - "unsupported-data-source-alert-title": "", - "unsupported-template-variable-alert-desc": "", - "unsupported-template-variable-alert-title": "" + "no-upsert-perm-alert-desc": "Kontaktieren Sie Ihren Administrator, um die Berechtigung zu {{mode}} öffentlichen Dashboards zu erhalten", + "no-upsert-perm-alert-title": "Sie sind nicht berechtigt, ein öffentliches Dashboard zu {{ mode }}", + "save-dashboard-changes-alert-title": "Bitte speichern Sie Ihre Dashboard-Änderungen bevor Sie die öffentliche Konfiguration aktualisieren", + "unsupport-data-source-alert-readmore-link": "Erfahren Sie mehr über unterstützte Datenquellen", + "unsupported-data-source-alert-desc": "In diesem Dashboard gibt es Datenquellen, die für öffentliche Dashboards nicht unterstützt werden. Panels, die diese Datenquellen nutzen, funktionieren möglicherweise nicht ordnungsgemäß: {{unsupportedDataSources}}.", + "unsupported-data-source-alert-title": "Nicht unterstützte Datenquellen", + "unsupported-template-variable-alert-desc": "Dieses öffentliche Dashboard funktioniert möglicherweise nicht, da es Template-Variablen verwendet", + "unsupported-template-variable-alert-title": "Template-Variablen werden nicht unterstützt" }, "settings-bar-header": { - "collapse-settings-tooltip": "", - "expand-settings-tooltip": "" + "collapse-settings-tooltip": "Einstellungen einklappen", + "expand-settings-tooltip": "Einstellungen aufklappen" }, "settings-configuration": { - "default-time-range-label": "", - "default-time-range-label-desc": "", - "show-annotations-label": "", - "show-annotations-label-desc": "", - "time-range-picker-label": "", - "time-range-picker-label-desc": "" + "default-time-range-label": "Standardzeitraum", + "default-time-range-label-desc": "Das öffentliche Dashboard verwendet die Standard-Zeitraumeinstellungen des Dashboards", + "show-annotations-label": "Anmerkungen anzeigen", + "show-annotations-label-desc": "Anmerkungen im öffentlichen Dashboard anzeigen", + "time-range-picker-label": "Zeitraumauswahl aktiviert", + "time-range-picker-label-desc": "Betrachtern erlauben, den Zeitraum zu ändern" }, "settings-summary": { - "annotations-hide-text": "", - "annotations-show-text": "", - "time-range-picker-disabled-text": "", - "time-range-picker-enabled-text": "", - "time-range-text": "" + "annotations-hide-text": "Anmerkungen = ausblenden", + "annotations-show-text": "Anmerkungen = anzeigen", + "time-range-picker-disabled-text": "Zeitraumauswahl = deaktiviert", + "time-range-picker-enabled-text": "Zeitraumauswahl = aktiviert", + "time-range-text": "Zeitraum = " } }, "public-dashboard-list": { "button": { - "config-button-tooltip": "", - "revoke-button-text": "", - "revoke-button-tooltip": "", - "view-button-tooltip": "" + "config-button-tooltip": "Öffentliches Dashboard konfigurieren", + "revoke-button-text": "Öffentliche URL widerrufen", + "revoke-button-tooltip": "Öffentliche Dashboard-URL widerrufen", + "view-button-tooltip": "Öffentliches Dashboard anzeigen" }, "dashboard-title": { - "orphaned-title": "", - "orphaned-tooltip": "" + "orphaned-title": "<0>Verwaistes öffentliches Dashboard", + "orphaned-tooltip": "Das verlinkte Dashboard wurde bereits gelöscht" }, "toggle": { - "pause-sharing-toggle-text": "" + "pause-sharing-toggle-text": "Teilen pausieren" } }, "public-dashboard-users-access-list": { "dashboard-modal": { - "loading-text": "", - "open-dashboard-list-text": "", - "public-dashboard-link": "", - "public-dashboard-setting": "" + "loading-text": "Wird geladen ...", + "open-dashboard-list-text": "Dashboard-Liste öffnen", + "public-dashboard-link": "Öffentliches Dashboard-URL", + "public-dashboard-setting": "Einstellungen des öffentlichen Dashboards" }, "delete-user-modal": { - "delete-user-button-text": "", - "delete-user-cancel-button": "", - "delete-user-revoke-access-button": "", - "revoke-access-title": "", - "revoke-user-access-modal-desc-line1": "", - "revoke-user-access-modal-desc-line2": "" + "delete-user-button-text": "Benutzer löschen", + "delete-user-cancel-button": "Abbrechen", + "delete-user-revoke-access-button": "Zugriff widerrufen", + "revoke-access-title": "Zugriff widerrufen", + "revoke-user-access-modal-desc-line1": "Sind Sie sicher, dass Sie den Zugriff für {{email}} widerrufen möchten?", + "revoke-user-access-modal-desc-line2": "Durch diese Aktion wird {{email}}' sofort der Zugriff auf alle öffentlichen Dashboards entzogen." }, "modal": { - "dashboard-modal-title": "" + "dashboard-modal-title": "Öffentliche Dashboards" }, "table-header": { - "activated-label": "", - "activated-tooltip": "", - "email-label": "", - "last-active-label": "", - "origin-label": "", - "role-label": "" + "activated-label": "Aktiviert", + "activated-tooltip": "Frühester Zeitpunkt, zu dem ein Benutzer ein aktiver Benutzer eines Dashboards war", + "email-label": "E-Mail-Adresse", + "last-active-label": "Zuletzt aktiv", + "origin-label": "Ursprung", + "role-label": "Rolle" } }, "query-operation": { @@ -1255,9 +1255,9 @@ }, "return-to-previous": { "button": { - "label": "" + "label": "Zurück zu {{title}}" }, - "dismissable-button": "" + "dismissable-button": "Schließen" }, "search": { "actions": { @@ -1353,7 +1353,7 @@ "link": "Link", "panel-embed": "Einbetten", "public-dashboard": "Öffentliches Dashboard", - "public-dashboard-title": "", + "public-dashboard-title": "Öffentliches Dashboard", "snapshot": "Schnappschuss" }, "theme-picker": { @@ -1424,7 +1424,7 @@ "calendar": { "apply-button": "Zeitbereich anwenden", "cancel-button": "Abbrechen", - "close": "", + "close": "Kalender schließen", "select-time": "Einen Zeitbereich auswählen" }, "content": { @@ -1433,11 +1433,11 @@ "filter-placeholder": "Schnellbereiche suchen" }, "copy-paste": { - "copy-success-message": "", - "default-error-message": "", - "default-error-title": "", - "tooltip-copy": "", - "tooltip-paste": "" + "copy-success-message": "Zeitraum in Zwischenablage kopiert", + "default-error-message": "{{error}} ist kein gültiger Zeitraum", + "default-error-title": "Ungültiger Zeitraum", + "tooltip-copy": "Zeitraum in Zwischenablage kopieren", + "tooltip-paste": "Zeitraum einfügen" }, "footer": { "change-settings-button": "Zeiteinstellungen ändern", @@ -1451,7 +1451,7 @@ "default-error": "Ein vergangenes Datum oder \"heutiges\" eingeben", "fiscal-year": "Geschäftsjahr", "from-input": "Von", - "open-input-calendar": "", + "open-input-calendar": "Kalender öffnen", "range-error": "„Von“ darf nicht nach „Bis“ sein", "to-input": "Bis" }, @@ -1476,8 +1476,8 @@ }, "transformations": { "empty": { - "add-transformation-body": "", - "add-transformation-header": "" + "add-transformation-body": "Mithilfe von Transformationen können Daten auf verschiedene Arten geändert werden, bevor Ihre Visualisierung angezeigt wird.<1>Dies beinhaltet die Verknüpfung von Daten, das Umbenennen von Feldern, die Erstellung von Berechnungen, das Formatieren von Daten für die Anzeige und mehr.", + "add-transformation-header": "Daten transformieren beginnen" } }, "user-orgs": { @@ -1496,7 +1496,7 @@ "username-label": "Benutzername" }, "tabs": { - "general": "" + "general": "Allgemein" } }, "user-session": { @@ -1511,7 +1511,7 @@ }, "users-access-list": { "tabs": { - "public-dashboard-users-tab-title": "" + "public-dashboard-users-tab-title": "Nutzer des öffentlichen Dashboards" } }, "variable": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index f53ae5fdf28..0c9fbc75b1f 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -1,5 +1,5 @@ { - "_comment": "", + "_comment": "Este archivo es la fuente original para las cadenas en inglés. Edita la base del código para cambiar los plurales y otras frases de la interfaz de usuario.", "access-control": { "add-permission": { "role-label": "Rol", @@ -26,7 +26,7 @@ } }, "bouncing-loader": { - "label": "" + "label": "Cargando" }, "browse-dashboards": { "action": { @@ -76,7 +76,7 @@ "folder-picker": { "accessible-label": "Seleccionar carpeta: {{ label }} seleccionada actualmente", "button-label": "Seleccionar carpeta", - "clear-selection": "", + "clear-selection": "Eliminar selección", "empty-message": "No se ha encontrado ninguna carpeta", "error-title": "Error al cargar las carpetas", "search-placeholder": "Buscar carpetas", @@ -238,8 +238,8 @@ "widget": "«Widget»" }, "alert-rules-drawer": { - "redirect-link": "", - "subtitle": "" + "redirect-link": "Lista en Alertas de Grafana", + "subtitle": "Reglas de alerta relacionadas con este tablero" }, "empty": { "add-library-panel-body": "Añadir las visualizaciones que se comparten con otros tableros.", @@ -309,7 +309,7 @@ }, "toolbar": { "add": "Añadir", - "alert-rules": "", + "alert-rules": "Reglas de alerta", "mark-favorite": "Marcar como favorito", "open-original": "Abrir el panel de control original", "playlist-next": "Ir al siguiente panel de control", @@ -319,7 +319,7 @@ "save": "Guardar panel de control", "settings": "Ajustes del panel de control", "share": "Compartir panel o panel de control", - "share-button": "", + "share-button": "Compartir", "unmark-favorite": "Deshacer marca como favorito" }, "validation": { @@ -369,7 +369,7 @@ "title-label": "Título" }, "json-editor": { - "apply-button": "", + "apply-button": "Aplicar cambios", "save-button": "Guardar cambios", "subtitle": "El siguiente modelo JSON es la estructura de datos que define el tablero. Incluye ajustes del tablero, ajustes del panel, diseño, consultas, etc.", "title": "Modelo JSON" @@ -540,13 +540,13 @@ }, "toolbar": { "aria-label": "Barra de herramientas de Explore", - "copy-link": "", - "copy-link-abs-time": "", - "copy-links-absolute-category": "", - "copy-links-normal-category": "", - "copy-shortened-link": "", - "copy-shortened-link-abs-time": "", - "copy-shortened-link-menu": "", + "copy-link": "Copiar URL", + "copy-link-abs-time": "Copiar URL absoluta", + "copy-links-absolute-category": "Enlaces URL de sincronización temporal (compartir con intervalo de tiempo intacto)", + "copy-links-normal-category": "Enlaces URL normales", + "copy-shortened-link": "Copiar URL acortada", + "copy-shortened-link-abs-time": "Copiar URL absoluta acortada", + "copy-shortened-link-menu": "Abrir menú de enlace acortado", "refresh-picker-cancel": "Cancelar", "refresh-picker-run": "Ejecutar consulta", "split-close": "Cerrar", @@ -562,7 +562,7 @@ }, "grafana-ui": { "drawer": { - "close": "" + "close": "Cerrar" }, "modal": { "close-tooltip": "Cerrar" @@ -595,7 +595,7 @@ "shortcuts-description": { "change-theme": "Cambiar tema", "collapse-all-rows": "Contraer todas las filas", - "copy-time-range": "", + "copy-time-range": "Copiar rango de tiempo", "dashboard-settings": "Ajustes del panel de control", "duplicate-panel": "Duplicar panel", "exit-edit/setting-views": "Salir de las opciones de edición/ajustes", @@ -609,7 +609,7 @@ "move-time-range-forward": "Mover el rango de tiempo hacia delante", "open-search": "Abrir búsqueda", "open-shared-modal": "Abrir el modo de panel compartido", - "paste-time-range": "", + "paste-time-range": "Pegar rango de tiempo", "refresh-all-panels": "Actualizar todos los paneles", "remove-panel": "Eliminar panel", "save-dashboard": "Guardar panel de control", @@ -700,7 +700,7 @@ "title": "Administrador del servidor" }, "alert-list-legacy": { - "title": "" + "title": "Reglas de alerta" }, "alerting": { "subtitle": "Conozca los problemas de sus sistemas justo después de que se produzcan", @@ -739,8 +739,8 @@ "title": "Silencios" }, "alerting-upgrade": { - "subtitle": "", - "title": "" + "subtitle": "Actualiza tus alertas y canales de notificación existentes a la nueva alerta de Grafana", + "title": "Actualización de alertas" }, "alerts-and-incidents": { "subtitle": "Aplicaciones de gestión de alertas e incidentes", @@ -761,7 +761,7 @@ "title": "Autenticación" }, "collector": { - "title": "" + "title": "Colector" }, "config": { "title": "Administración" @@ -823,11 +823,11 @@ "title": "Explorar" }, "frontend": { - "subtitle": "", + "subtitle": "Obtén información de seguimiento real del usuario", "title": "Interfaz" }, "frontend-app": { - "title": "" + "title": "Interfaz" }, "global-orgs": { "subtitle": "Instancias aisladas de Grafana ejecutándose en el mismo servidor", @@ -854,14 +854,14 @@ "title": "Incidentes" }, "infrastructure": { - "subtitle": "", - "title": "" + "subtitle": "Mantente al tanto de la salud de tu infraestructura", + "title": "Infraestructura" }, "integrations": { - "title": "" + "title": "Integraciones" }, "k6": { - "title": "" + "title": "Rendimiento" }, "kubernetes": { "title": "Kubernetes" @@ -908,8 +908,8 @@ "title": "Complementos" }, "private-data-source-connections": { - "subtitle": "", - "title": "" + "subtitle": "Consulta los datos existentes en una red segura sin abrirla al tráfico entrante de Grafana Cloud. Tienes más información en nuestros documentos.", + "title": "Conexión a la fuente de datos privada" }, "profile/notifications": { "title": "Historial de notificaciones" @@ -984,8 +984,8 @@ "title": "Equipos" }, "testing-and-synthetics": { - "subtitle": "", - "title": "" + "subtitle": "Optimiza el rendimiento con información de seguimiento k6 y sintética", + "title": "Pruebas y síntesis" }, "upgrading": { "title": "Estadísticas y licencia" @@ -1002,7 +1002,7 @@ "megamenu": { "close": "Cerrar menú", "dock": "Anclar el menú", - "list-label": "", + "list-label": "Navegación", "undock": "Desanclar el menú" }, "toolbar": { @@ -1014,7 +1014,7 @@ }, "news": { "drawer": { - "close": "" + "close": "Cerrar Drawer" }, "title": "Últimas entradas del blog" }, @@ -1106,116 +1106,116 @@ }, "public-dashboard": { "acknowledgment-checkboxes": { - "ack-title": "", - "data-src-ack-desc": "", - "data-src-ack-tooltip": "", - "public-ack-desc": "", - "public-ack-tooltip": "", - "usage-ack-desc": "", - "usage-ack-desc-tooltip": "" + "ack-title": "Antes de hacer público el tablero, ten en cuenta lo siguiente:", + "data-src-ack-desc": "Actualmente, la publicación solo funciona con un subconjunto de fuentes de datos*", + "data-src-ack-tooltip": "Más información sobre las fuentes de datos públicas", + "public-ack-desc": "Tu tablero será totalmente público*", + "public-ack-tooltip": "Más información sobre los tableros públicos", + "usage-ack-desc": "Convertir un tablero en público hará que las consultas se ejecuten cada vez que se visualice, lo que puede aumentar los costes*", + "usage-ack-desc-tooltip": "Más información sobre la caché de consultas" }, "config": { - "can-view-dashboard-radio-button-label": "", - "copy-button": "", - "dashboard-url-field-label": "", - "email-share-type-option-label": "", - "pause-sharing-dashboard-label": "", - "public-share-type-option-label": "", - "revoke-public-URL-button": "", - "revoke-public-URL-button-title": "", - "settings-title": "" + "can-view-dashboard-radio-button-label": "Puedes ver el tablero", + "copy-button": "Copiar", + "dashboard-url-field-label": "URL del tablero", + "email-share-type-option-label": "Solo personas especificadas", + "pause-sharing-dashboard-label": "Pausar el panel compartido", + "public-share-type-option-label": "Cualquiera con un enlace", + "revoke-public-URL-button": "Revocar la URL pública", + "revoke-public-URL-button-title": "Revocar URL pública", + "settings-title": "Configuración" }, "create-page": { - "generate-public-url-button": "", - "unsupported-features-desc": "", - "welcome-title": "" + "generate-public-url-button": "Generar URL pública", + "unsupported-features-desc": "Actualmente, no son compatibles las variables de plantillas o las fuentes de datos de la interfaz", + "welcome-title": "¡Bienvenidos al tablero público!" }, "delete-modal": { - "revoke-nonorphaned-body-text": "", - "revoke-orphaned-body-text": "", - "revoke-title": "" + "revoke-nonorphaned-body-text": "¿Seguro que quieres revocar esta URL? El tablero dejará de ser público.", + "revoke-orphaned-body-text": "El tablero público huérfano ya no será público.", + "revoke-title": "Revocar URL pública" }, "email-sharing": { - "input-invalid-email-text": "", - "input-required-email-text": "", - "invite-button": "", - "invite-field-desc": "", - "invite-field-label": "", - "resend-button": "", - "resend-button-title": "", - "revoke-button": "", - "revoke-button-title": "" + "input-invalid-email-text": "Correo no válido", + "input-required-email-text": "El correo electrónico es obligatorio", + "invite-button": "Invitar", + "invite-field-desc": "Invitar a otras personas por correo electrónico", + "invite-field-label": "Invitar", + "resend-button": "Reenviar", + "resend-button-title": "Reenviar", + "revoke-button": "Revocar", + "revoke-button-title": "Revocar" }, "modal-alerts": { - "no-upsert-perm-alert-desc": "", - "no-upsert-perm-alert-title": "", - "save-dashboard-changes-alert-title": "", - "unsupport-data-source-alert-readmore-link": "", - "unsupported-data-source-alert-desc": "", - "unsupported-data-source-alert-title": "", - "unsupported-template-variable-alert-desc": "", - "unsupported-template-variable-alert-title": "" + "no-upsert-perm-alert-desc": "Ponte en contacto con tu administrador para obtener permiso para {{mode}} los tableros públicos", + "no-upsert-perm-alert-title": "No tienes permiso para {{ mode }} un tablero público", + "save-dashboard-changes-alert-title": "Guarda los cambios realizados en el tablero antes de actualizar la configuración pública", + "unsupport-data-source-alert-readmore-link": "Leer más sobre las fuentes de datos compatibles", + "unsupported-data-source-alert-desc": "Hay fuentes de datos en este tablero que no son compatibles con los tableros públicos. Los paneles que utilizan estas fuentes de datos puede que no funcionen correctamente: {{unsupportedDataSources}}.", + "unsupported-data-source-alert-title": "Fuentes de datos no compatibles", + "unsupported-template-variable-alert-desc": "Este tablero público puede que no funcione, ya que utiliza variables de plantillas", + "unsupported-template-variable-alert-title": "Las variables de plantillas no son compatibles" }, "settings-bar-header": { - "collapse-settings-tooltip": "", - "expand-settings-tooltip": "" + "collapse-settings-tooltip": "Contraer ajustes", + "expand-settings-tooltip": "Expandir ajustes" }, "settings-configuration": { - "default-time-range-label": "", - "default-time-range-label-desc": "", - "show-annotations-label": "", - "show-annotations-label-desc": "", - "time-range-picker-label": "", - "time-range-picker-label-desc": "" + "default-time-range-label": "Rango de tiempo predeterminado", + "default-time-range-label-desc": "El tablero público utiliza la configuración predeterminada del rango de tiempo del tablero", + "show-annotations-label": "Mostrar anotaciones", + "show-annotations-label-desc": "Mostrar anotaciones en el tablero público", + "time-range-picker-label": "Selector de rango de tiempo activado", + "time-range-picker-label-desc": "Permitir que los usuarios cambien el rango de tiempo" }, "settings-summary": { - "annotations-hide-text": "", - "annotations-show-text": "", - "time-range-picker-disabled-text": "", - "time-range-picker-enabled-text": "", - "time-range-text": "" + "annotations-hide-text": "Anotaciones = ocultar", + "annotations-show-text": "Anotaciones = mostrar", + "time-range-picker-disabled-text": "Selector de rango de tiempo = desactivado", + "time-range-picker-enabled-text": "Selector de rango de tiempo = activado", + "time-range-text": "Rango de tiempo = " } }, "public-dashboard-list": { "button": { - "config-button-tooltip": "", - "revoke-button-text": "", - "revoke-button-tooltip": "", - "view-button-tooltip": "" + "config-button-tooltip": "Configurar tablero público", + "revoke-button-text": "Revocar URL pública", + "revoke-button-tooltip": "Revocar URL del tablero público", + "view-button-tooltip": "Ver tablero público" }, "dashboard-title": { - "orphaned-title": "", - "orphaned-tooltip": "" + "orphaned-title": "<0>Tablero público huérfano", + "orphaned-tooltip": "El tablero vinculado ya se ha eliminado" }, "toggle": { - "pause-sharing-toggle-text": "" + "pause-sharing-toggle-text": "Pausar el uso compartido" } }, "public-dashboard-users-access-list": { "dashboard-modal": { - "loading-text": "", - "open-dashboard-list-text": "", - "public-dashboard-link": "", - "public-dashboard-setting": "" + "loading-text": "Cargando...", + "open-dashboard-list-text": "Abrir lista de tableros", + "public-dashboard-link": "URL del tablero público", + "public-dashboard-setting": "Ajustes del tablero público" }, "delete-user-modal": { - "delete-user-button-text": "", - "delete-user-cancel-button": "", - "delete-user-revoke-access-button": "", - "revoke-access-title": "", - "revoke-user-access-modal-desc-line1": "", - "revoke-user-access-modal-desc-line2": "" + "delete-user-button-text": "Eliminar usuario", + "delete-user-cancel-button": "Cancelar", + "delete-user-revoke-access-button": "Revocar acceso", + "revoke-access-title": "Revocar acceso", + "revoke-user-access-modal-desc-line1": "¿Seguro que quieres revocar el acceso para {{email}}?", + "revoke-user-access-modal-desc-line2": "Esta acción revocará inmediatamente el acceso de {{email}} a todos los tableros públicos." }, "modal": { - "dashboard-modal-title": "" + "dashboard-modal-title": "Paneles de control públicos" }, "table-header": { - "activated-label": "", - "activated-tooltip": "", - "email-label": "", - "last-active-label": "", - "origin-label": "", - "role-label": "" + "activated-label": "Activado", + "activated-tooltip": "El usuario más antiguo ha estado activo en un tablero de control", + "email-label": "Correo electrónico", + "last-active-label": "Último activo", + "origin-label": "Origen", + "role-label": "Rol" } }, "query-operation": { @@ -1255,9 +1255,9 @@ }, "return-to-previous": { "button": { - "label": "" + "label": "Volver a {{title}}" }, - "dismissable-button": "" + "dismissable-button": "Cerrar" }, "search": { "actions": { @@ -1353,7 +1353,7 @@ "link": "Enlace", "panel-embed": "Incrustar", "public-dashboard": "Tablero público", - "public-dashboard-title": "", + "public-dashboard-title": "Tablero público", "snapshot": "Instantánea" }, "theme-picker": { @@ -1424,7 +1424,7 @@ "calendar": { "apply-button": "Aplicar intervalo de tiempo", "cancel-button": "Cancelar", - "close": "", + "close": "Cerrar calendario", "select-time": "Seleccionar un intervalo de tiempo" }, "content": { @@ -1433,11 +1433,11 @@ "filter-placeholder": "Buscar intervalos rápidos" }, "copy-paste": { - "copy-success-message": "", - "default-error-message": "", - "default-error-title": "", - "tooltip-copy": "", - "tooltip-paste": "" + "copy-success-message": "Rango de tiempo copiado al portapapeles", + "default-error-message": "{{error}} no es un rango de tiempo válido", + "default-error-title": "Rango de tiempo no válido", + "tooltip-copy": "Copiar rango de tiempo al portapapeles", + "tooltip-paste": "Pegar rango de tiempo" }, "footer": { "change-settings-button": "Cambiar ajustes de tiempo", @@ -1451,7 +1451,7 @@ "default-error": "Introducir una fecha anterior o «ahora»", "fiscal-year": "Ejercicio fiscal", "from-input": "Desde", - "open-input-calendar": "", + "open-input-calendar": "Abrir calendario", "range-error": "«Desde» no puede ser posterior a «hasta»", "to-input": "Hasta" }, @@ -1476,8 +1476,8 @@ }, "transformations": { "empty": { - "add-transformation-body": "", - "add-transformation-header": "" + "add-transformation-body": "Las transformaciones permiten cambiar los datos de varias maneras antes de que se muestre su visualización.<1>Aquí se incluyen acciones como unir datos, renombrar campos, hacer cálculos, dar formato a los datos para mostrarlos, etc.", + "add-transformation-header": "Empezar a transformar datos" } }, "user-orgs": { @@ -1496,7 +1496,7 @@ "username-label": "Nombre de usuario" }, "tabs": { - "general": "" + "general": "General" } }, "user-session": { @@ -1511,7 +1511,7 @@ }, "users-access-list": { "tabs": { - "public-dashboard-users-tab-title": "" + "public-dashboard-users-tab-title": "Usuarios del tablero público" } }, "variable": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index e125b6e4367..f59fe0416e8 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -1,5 +1,5 @@ { - "_comment": "", + "_comment": "Ce fichier est la source de vérité pour les chaînes anglaises. Modifiez-le dans la base de code pour adapter les pluriels et les autres phrases pour l'interface utilisateur.", "access-control": { "add-permission": { "role-label": "Rôle", @@ -26,7 +26,7 @@ } }, "bouncing-loader": { - "label": "" + "label": "Chargement" }, "browse-dashboards": { "action": { @@ -76,7 +76,7 @@ "folder-picker": { "accessible-label": "Sélectionnez un dossier : {{ label }} est sélectionné actuellement", "button-label": "Sélectionner un dossier", - "clear-selection": "", + "clear-selection": "Effacer la sélection", "empty-message": "Aucun dossier trouvé", "error-title": "Impossible de charger les dossiers", "search-placeholder": "Rechercher dans les dossiers", @@ -109,7 +109,7 @@ "light-theme": "Clair" }, "empty-state": { - "title": "" + "title": "Aucun résultat trouvé" }, "search-box": { "placeholder": "Rechercher ou aller à..." @@ -238,8 +238,8 @@ "widget": "Widget" }, "alert-rules-drawer": { - "redirect-link": "", - "subtitle": "" + "redirect-link": "Liste dans Alertes Grafana", + "subtitle": "Règles d'alerte liées à ce tableau de bord" }, "empty": { "add-library-panel-body": "Ajoutez des visualisations partagées avec d'autres tableaux de bord.", @@ -309,7 +309,7 @@ }, "toolbar": { "add": "Ajouter", - "alert-rules": "", + "alert-rules": "Règles d'alerte", "mark-favorite": "Marquer comme favori", "open-original": "Ouvrir le tableau de bord d'origine", "playlist-next": "Accéder au tableau de bord suivant", @@ -319,7 +319,7 @@ "save": "Enregistrer le tableau de bord", "settings": "Paramètres du tableau de bord", "share": "Partager le tableau de bord ou le panneau", - "share-button": "", + "share-button": "Partager", "unmark-favorite": "Supprimer des favoris" }, "validation": { @@ -369,7 +369,7 @@ "title-label": "Titre" }, "json-editor": { - "apply-button": "", + "apply-button": "Appliquer les modifications", "save-button": "Enregistrer les modifications", "subtitle": "Le modèle JSON ci-dessous est la structure de données qui définit le tableau de bord. Il inclut les paramètres du tableau de bord, les paramètres du panneau, la mise en page, les requêtes, etc.", "title": "Modèle JSON" @@ -540,13 +540,13 @@ }, "toolbar": { "aria-label": "Explorer la barre d'outils", - "copy-link": "", - "copy-link-abs-time": "", - "copy-links-absolute-category": "", - "copy-links-normal-category": "", - "copy-shortened-link": "", - "copy-shortened-link-abs-time": "", - "copy-shortened-link-menu": "", + "copy-link": "Copier l'URL", + "copy-link-abs-time": "Copier l'URL absolue", + "copy-links-absolute-category": "Liens URL de synchronisation temporelle (partager avec intervalle de temps intact)", + "copy-links-normal-category": "Liens URL normaux", + "copy-shortened-link": "Copier l'URL abrégée", + "copy-shortened-link-abs-time": "Copier l'URL abrégée absolue", + "copy-shortened-link-menu": "Ouvrir le lien de menu raccourci", "refresh-picker-cancel": "Annuler", "refresh-picker-run": "Exécuter la requête", "split-close": "Fermer", @@ -562,7 +562,7 @@ }, "grafana-ui": { "drawer": { - "close": "" + "close": "Fermer" }, "modal": { "close-tooltip": "Fermer" @@ -595,7 +595,7 @@ "shortcuts-description": { "change-theme": "Modifier le thème", "collapse-all-rows": "Réduire toutes les lignes", - "copy-time-range": "", + "copy-time-range": "Copier l'intervalle de temps", "dashboard-settings": "Paramètres du tableau de bord", "duplicate-panel": "Dupliquer le panneau", "exit-edit/setting-views": "Quitter les affichages de modification/configuration", @@ -609,7 +609,7 @@ "move-time-range-forward": "Avancer la plage de temps", "open-search": "Ouvrir la recherche", "open-shared-modal": "Ouvrir le panneau de partage modal", - "paste-time-range": "", + "paste-time-range": "Coller l'intervalle de temps", "refresh-all-panels": "Actualiser tous les panneaux", "remove-panel": "Retirer le panneau", "save-dashboard": "Enregistrer le tableau de bord", @@ -700,7 +700,7 @@ "title": "Administrateur de serveur" }, "alert-list-legacy": { - "title": "" + "title": "Règles d'alerte" }, "alerting": { "subtitle": "En savoir plus sur les problèmes dans vos systèmes quelques instants après qu'ils se produisent", @@ -739,8 +739,8 @@ "title": "Silences" }, "alerting-upgrade": { - "subtitle": "", - "title": "" + "subtitle": "Mettez à niveau vos alertes existantes et vos canaux de notification vers le nouveau système d'alerte Grafana", + "title": "Mise à niveau des alertes" }, "alerts-and-incidents": { "subtitle": "Applications de gestion des incidents et des alertes", @@ -761,7 +761,7 @@ "title": "Authentification" }, "collector": { - "title": "" + "title": "Collecteur" }, "config": { "title": "Administration" @@ -823,11 +823,11 @@ "title": "Explorer" }, "frontend": { - "subtitle": "", + "subtitle": "Obtenez des analyses de suivi des utilisateurs réels", "title": "Frontend" }, "frontend-app": { - "title": "" + "title": "Frontend" }, "global-orgs": { "subtitle": "Instances isolées de Grafana exécutées sur le même serveur", @@ -854,14 +854,14 @@ "title": "Incidents" }, "infrastructure": { - "subtitle": "", - "title": "" + "subtitle": "Connaissez l'état de santé de votre infrastructure", + "title": "Infrastructure" }, "integrations": { - "title": "" + "title": "Intégrations" }, "k6": { - "title": "" + "title": "Performance" }, "kubernetes": { "title": "Kubernetes" @@ -908,8 +908,8 @@ "title": "Plug-ins" }, "private-data-source-connections": { - "subtitle": "", - "title": "" + "subtitle": "Obtenez des données qui vivent au sein d'un réseau sécurisé sans ouvrir le réseau au trafic entrant à partir de Grafana Cloud. En savoir plus dans notre documentation.", + "title": "Connexion à la source de données privée" }, "profile/notifications": { "title": "Historique des notifications" @@ -984,8 +984,8 @@ "title": "Équipes" }, "testing-and-synthetics": { - "subtitle": "", - "title": "" + "subtitle": "Optimisez les performances grâce aux analyses de la surveillance synthétique et k6", + "title": "Tests et synthèses" }, "upgrading": { "title": "Statistiques et licence" @@ -1002,8 +1002,8 @@ "megamenu": { "close": "Fermer le menu", "dock": "Ancrer le menu", - "list-label": "", - "undock": "" + "list-label": "Navigation", + "undock": "Ancrer le menu" }, "toolbar": { "close-menu": "Fermer le menu", @@ -1014,7 +1014,7 @@ }, "news": { "drawer": { - "close": "" + "close": "Fermer le tiroir" }, "title": "Dernières nouvelles sur le blog" }, @@ -1106,116 +1106,116 @@ }, "public-dashboard": { "acknowledgment-checkboxes": { - "ack-title": "", - "data-src-ack-desc": "", - "data-src-ack-tooltip": "", - "public-ack-desc": "", - "public-ack-tooltip": "", - "usage-ack-desc": "", - "usage-ack-desc-tooltip": "" + "ack-title": "Avant de rendre le tableau de bord public, acceptez ce qui suit :", + "data-src-ack-desc": "La publication ne fonctionne actuellement qu'avec un sous-ensemble de sources de données*", + "data-src-ack-tooltip": "En savoir plus sur les sources de données publiques", + "public-ack-desc": "Votre tableau de bord sera entièrement public*", + "public-ack-tooltip": "En savoir plus sur les tableaux de bord publics", + "usage-ack-desc": "Si vous rendez le tableau de bord public, les requêtes seront exécutées à chaque fois qu'il sera affiché, ce qui peut augmenter les coûts*", + "usage-ack-desc-tooltip": "En savoir plus sur la mise en cache des requêtes" }, "config": { - "can-view-dashboard-radio-button-label": "", - "copy-button": "", - "dashboard-url-field-label": "", - "email-share-type-option-label": "", - "pause-sharing-dashboard-label": "", - "public-share-type-option-label": "", - "revoke-public-URL-button": "", - "revoke-public-URL-button-title": "", - "settings-title": "" + "can-view-dashboard-radio-button-label": "Peut voir le tableau de bord", + "copy-button": "Copier", + "dashboard-url-field-label": "URL du tableau de bord", + "email-share-type-option-label": "Uniquement les personnes spécifiées", + "pause-sharing-dashboard-label": "Interrompre le partage du tableau de bord", + "public-share-type-option-label": "Toute personne ayant un lien", + "revoke-public-URL-button": "Désactiver l'URL publique", + "revoke-public-URL-button-title": "Désactiver l'URL publique", + "settings-title": "Paramètres" }, "create-page": { - "generate-public-url-button": "", - "unsupported-features-desc": "", - "welcome-title": "" + "generate-public-url-button": "Générer une URL publique", + "unsupported-features-desc": "Actuellement, nous ne prenons pas en charge les variables de modèle ou les données provenant des utilisateurs", + "welcome-title": "Bienvenue dans les tableaux de bord publics !" }, "delete-modal": { - "revoke-nonorphaned-body-text": "", - "revoke-orphaned-body-text": "", - "revoke-title": "" + "revoke-nonorphaned-body-text": "Souhaitez-vous vraiment désactiver cette URL ? Le tableau de bord ne sera plus public.", + "revoke-orphaned-body-text": "Le tableau de bord public orphelin ne sera plus public.", + "revoke-title": "Désactiver l'URL publique" }, "email-sharing": { - "input-invalid-email-text": "", - "input-required-email-text": "", - "invite-button": "", - "invite-field-desc": "", - "invite-field-label": "", - "resend-button": "", - "resend-button-title": "", - "revoke-button": "", - "revoke-button-title": "" + "input-invalid-email-text": "E-mail non valide", + "input-required-email-text": "Une adresse e-mail est obligatoire", + "invite-button": "Inviter", + "invite-field-desc": "Invitez des personnes par e-mail", + "invite-field-label": "Inviter", + "resend-button": "Renvoyer", + "resend-button-title": "Renvoyer", + "revoke-button": "Retirer", + "revoke-button-title": "Retirer" }, "modal-alerts": { - "no-upsert-perm-alert-desc": "", - "no-upsert-perm-alert-title": "", - "save-dashboard-changes-alert-title": "", - "unsupport-data-source-alert-readmore-link": "", - "unsupported-data-source-alert-desc": "", - "unsupported-data-source-alert-title": "", - "unsupported-template-variable-alert-desc": "", - "unsupported-template-variable-alert-title": "" + "no-upsert-perm-alert-desc": "Contactez votre administrateur pour obtenir la permission d'accéder aux tableaux de bord publics {{mode}}", + "no-upsert-perm-alert-title": "Vous n'avez pas l'autorisation nécessaire pour {{ mode }} un tableau de bord public", + "save-dashboard-changes-alert-title": "Veuillez enregistrer les modifications de votre tableau de bord avant de modifier la configuration publique", + "unsupport-data-source-alert-readmore-link": "En savoir plus sur les sources de données prises en charge", + "unsupported-data-source-alert-desc": "Ce tableau de bord comprend des sources de données qui ne sont pas prises en charge pour les tableaux de bord publics. Les panneaux qui utilisent ces sources de données peuvent ne pas fonctionner correctement : {{unsupportedDataSources}}.", + "unsupported-data-source-alert-title": "Sources de données non prises en charge", + "unsupported-template-variable-alert-desc": "Ce tableau de bord public peut ne pas fonctionner car il utilise des variables de modèle", + "unsupported-template-variable-alert-title": "Les variables de modèle ne sont pas prises en charge" }, "settings-bar-header": { - "collapse-settings-tooltip": "", - "expand-settings-tooltip": "" + "collapse-settings-tooltip": "Réduire les paramètres", + "expand-settings-tooltip": "Développer les paramètres" }, "settings-configuration": { - "default-time-range-label": "", - "default-time-range-label-desc": "", - "show-annotations-label": "", - "show-annotations-label-desc": "", - "time-range-picker-label": "", - "time-range-picker-label-desc": "" + "default-time-range-label": "Intervalle de temps par défaut", + "default-time-range-label-desc": "Le tableau de bord public utilise les paramètres d'intervalle de temps par défaut du tableau de bord", + "show-annotations-label": "Afficher les annotations", + "show-annotations-label-desc": "Afficher les annotations sur le tableau de bord public", + "time-range-picker-label": "Sélecteur de plage de temps activé", + "time-range-picker-label-desc": "Permettre de modifier la plage de temps pendant l'affichage" }, "settings-summary": { - "annotations-hide-text": "", - "annotations-show-text": "", - "time-range-picker-disabled-text": "", - "time-range-picker-enabled-text": "", - "time-range-text": "" + "annotations-hide-text": "Annotations = masquer", + "annotations-show-text": "Annotations = montrer", + "time-range-picker-disabled-text": "Sélecteur de plage de temps = désactivé", + "time-range-picker-enabled-text": "Sélecteur de plage de temps = activé", + "time-range-text": "Plage de temps = " } }, "public-dashboard-list": { "button": { - "config-button-tooltip": "", - "revoke-button-text": "", - "revoke-button-tooltip": "", - "view-button-tooltip": "" + "config-button-tooltip": "Configurer le tableau de bord public", + "revoke-button-text": "Désactiver l'URL publique", + "revoke-button-tooltip": "Désactiver l'URL du tableau de bord public", + "view-button-tooltip": "Afficher le tableau de bord public" }, "dashboard-title": { - "orphaned-title": "", - "orphaned-tooltip": "" + "orphaned-title": "<0>Tableau de bord public orphelin", + "orphaned-tooltip": "Le tableau de bord lié a déjà été supprimé" }, "toggle": { - "pause-sharing-toggle-text": "" + "pause-sharing-toggle-text": "Suspendre le partage" } }, "public-dashboard-users-access-list": { "dashboard-modal": { - "loading-text": "", - "open-dashboard-list-text": "", - "public-dashboard-link": "", - "public-dashboard-setting": "" + "loading-text": "Chargement en cours...", + "open-dashboard-list-text": "Ouvrir la liste des tableaux de bord", + "public-dashboard-link": "URL du tableau de bord public", + "public-dashboard-setting": "Paramètres du tableau de bord public" }, "delete-user-modal": { - "delete-user-button-text": "", - "delete-user-cancel-button": "", - "delete-user-revoke-access-button": "", - "revoke-access-title": "", - "revoke-user-access-modal-desc-line1": "", - "revoke-user-access-modal-desc-line2": "" + "delete-user-button-text": "Supprimer l'utilisateur", + "delete-user-cancel-button": "Annuler", + "delete-user-revoke-access-button": "Retirer l'accès", + "revoke-access-title": "Retirer l'accès", + "revoke-user-access-modal-desc-line1": "Souhaitez-vous vraiment retirer l'accès pour {{email}} ?", + "revoke-user-access-modal-desc-line2": "Cette action retirera immédiatement l'accès de {{email}}'s à tous les tableaux de bord publics." }, "modal": { - "dashboard-modal-title": "" + "dashboard-modal-title": "Tableaux de bord publics" }, "table-header": { - "activated-label": "", - "activated-tooltip": "", - "email-label": "", - "last-active-label": "", - "origin-label": "", - "role-label": "" + "activated-label": "Activé", + "activated-tooltip": "Première heure à laquelle l'utilisateur a été un utilisateur actif sur un tableau de bord", + "email-label": "Adresse e-mail", + "last-active-label": "Dernière activité", + "origin-label": "Origine", + "role-label": "Rôle" } }, "query-operation": { @@ -1255,9 +1255,9 @@ }, "return-to-previous": { "button": { - "label": "" + "label": "Retour à {{title}}" }, - "dismissable-button": "" + "dismissable-button": "Fermer" }, "search": { "actions": { @@ -1353,7 +1353,7 @@ "link": "Lien", "panel-embed": "Intégrer", "public-dashboard": "Tableau de bord public", - "public-dashboard-title": "", + "public-dashboard-title": "Tableau de bord public", "snapshot": "Instantané" }, "theme-picker": { @@ -1424,7 +1424,7 @@ "calendar": { "apply-button": "Appliquer la plage de temps", "cancel-button": "Annuler", - "close": "", + "close": "Fermer le calendrier", "select-time": "Sélectionner une plage de temps" }, "content": { @@ -1433,11 +1433,11 @@ "filter-placeholder": "Rechercher dans les plages rapides" }, "copy-paste": { - "copy-success-message": "", - "default-error-message": "", - "default-error-title": "", - "tooltip-copy": "", - "tooltip-paste": "" + "copy-success-message": "Intervalle de temps copié dans le presse-papiers", + "default-error-message": "{{error}} n'est pas un intervalle de temps valide", + "default-error-title": "Intervalle de temps invalide", + "tooltip-copy": "Copier l'intervalle de temps dans le presse-papiers", + "tooltip-paste": "Coller l'intervalle de temps" }, "footer": { "change-settings-button": "Modifier les paramètres de l'heure", @@ -1451,7 +1451,7 @@ "default-error": "Entrez une date dans le passé ou « maintenant »", "fiscal-year": "Exercice fiscal", "from-input": "De", - "open-input-calendar": "", + "open-input-calendar": "Ouvrir le calendrier", "range-error": "« De » ne peut pas être ultérieur à « À »", "to-input": "À" }, @@ -1476,8 +1476,8 @@ }, "transformations": { "empty": { - "add-transformation-body": "", - "add-transformation-header": "" + "add-transformation-body": "Les transformations permettent de modifier les données de différentes manières avant l'affichage de votre visualisation.<1>Vous pouvez notamment rassembler des données, renommer des champs, faire des calculs, formater les données à afficher, etc.", + "add-transformation-header": "Commencer la transformation des données" } }, "user-orgs": { @@ -1496,7 +1496,7 @@ "username-label": "Nom d’utilisateur" }, "tabs": { - "general": "" + "general": "Général" } }, "user-session": { @@ -1511,7 +1511,7 @@ }, "users-access-list": { "tabs": { - "public-dashboard-users-tab-title": "" + "public-dashboard-users-tab-title": "Utilisateurs du tableau de bord public" } }, "variable": { diff --git a/tsconfig.json b/tsconfig.json index 5f95b68a673..4949f581e7a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,8 +11,8 @@ "incremental": true, "tsBuildInfoFile": "./tsconfig.tsbuildinfo", "paths": { - "@grafana/schema/dist/esm/*": ["../packages/grafana-schema/src/*"], - }, + "@grafana/schema/dist/esm/*": ["../packages/grafana-schema/src/*"] + } }, "extends": "@grafana/tsconfig/base.json", "include": [ @@ -21,7 +21,7 @@ "public/test/**/*.ts", "public/vendor/**/*.ts", "packages/grafana-data/typings", - "packages/grafana-ui/src/types", + "packages/grafana-ui/src/types" ], - "exclude": ["public/app/**/webpack.config.ts"], + "exclude": ["public/app/**/webpack.config.ts"] } diff --git a/yarn.lock b/yarn.lock index e44294cdd20..d42266b7a73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3172,7 +3172,7 @@ __metadata: languageName: node linkType: hard -"@gar/promisify@npm:^1.0.1": +"@gar/promisify@npm:^1.0.1, @gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" checksum: 10/052dd232140fa60e81588000cbe729a40146579b361f1070bce63e2a761388a22a16d00beeffc504bd3601cb8e055c57b21a185448b3ed550cf50716f4fd442e @@ -3213,7 +3213,7 @@ __metadata: "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.12" "@types/lodash": "npm:4.14.202" - "@types/node": "npm:20.11.16" + "@types/node": "npm:20.11.17" "@types/prismjs": "npm:1.26.3" "@types/react": "npm:18.2.55" "@types/testing-library__jest-dom": "npm:5.14.9" @@ -3236,51 +3236,6 @@ __metadata: languageName: unknown linkType: soft -"@grafana-plugins/grafana-cloud-monitoring-datasource@workspace:*, @grafana-plugins/grafana-cloud-monitoring-datasource@workspace:public/app/plugins/datasource/cloud-monitoring": - version: 0.0.0-use.local - resolution: "@grafana-plugins/grafana-cloud-monitoring-datasource@workspace:public/app/plugins/datasource/cloud-monitoring" - dependencies: - "@emotion/css": "npm:11.11.2" - "@grafana/data": "npm:10.4.0-pre" - "@grafana/e2e-selectors": "npm:10.4.0-pre" - "@grafana/experimental": "npm:1.7.10" - "@grafana/google-sdk": "npm:0.1.2" - "@grafana/plugin-configs": "npm:10.4.0-pre" - "@grafana/runtime": "npm:10.4.0-pre" - "@grafana/schema": "npm:10.4.0-pre" - "@grafana/ui": "npm:10.4.0-pre" - "@kusto/monaco-kusto": "npm:^7.4.0" - "@testing-library/react": "npm:14.2.1" - "@testing-library/user-event": "npm:14.5.2" - "@types/debounce-promise": "npm:3.1.9" - "@types/jest": "npm:29.5.12" - "@types/lodash": "npm:4.14.202" - "@types/node": "npm:20.11.16" - "@types/prismjs": "npm:1.26.3" - "@types/react": "npm:18.2.55" - "@types/react-test-renderer": "npm:18.0.7" - "@types/testing-library__jest-dom": "npm:5.14.9" - debounce-promise: "npm:3.1.2" - fast-deep-equal: "npm:^3.1.3" - i18next: "npm:^23.0.0" - immer: "npm:10.0.3" - lodash: "npm:4.17.21" - monaco-editor: "npm:0.34.0" - prismjs: "npm:1.29.0" - react: "npm:18.2.0" - react-select-event: "npm:5.5.1" - react-test-renderer: "npm:18.2.0" - react-use: "npm:17.5.0" - rxjs: "npm:7.8.1" - ts-node: "npm:10.9.2" - tslib: "npm:2.6.2" - typescript: "npm:5.3.3" - webpack: "npm:5.90.1" - peerDependencies: - "@grafana/runtime": "*" - languageName: unknown - linkType: soft - "@grafana-plugins/grafana-pyroscope-datasource@workspace:*, @grafana-plugins/grafana-pyroscope-datasource@workspace:public/app/plugins/datasource/grafana-pyroscope-datasource": version: 0.0.0-use.local resolution: "@grafana-plugins/grafana-pyroscope-datasource@workspace:public/app/plugins/datasource/grafana-pyroscope-datasource" @@ -3337,7 +3292,7 @@ __metadata: "@types/d3-random": "npm:^3.0.2" "@types/jest": "npm:29.5.12" "@types/lodash": "npm:4.14.202" - "@types/node": "npm:20.11.16" + "@types/node": "npm:20.11.17" "@types/react": "npm:18.2.55" "@types/testing-library__jest-dom": "npm:5.14.9" "@types/uuid": "npm:9.0.8" @@ -3406,6 +3361,51 @@ __metadata: languageName: unknown linkType: soft +"@grafana-plugins/stackdriver@workspace:*, @grafana-plugins/stackdriver@workspace:public/app/plugins/datasource/cloud-monitoring": + version: 0.0.0-use.local + resolution: "@grafana-plugins/stackdriver@workspace:public/app/plugins/datasource/cloud-monitoring" + dependencies: + "@emotion/css": "npm:11.11.2" + "@grafana/data": "npm:10.4.0-pre" + "@grafana/e2e-selectors": "npm:10.4.0-pre" + "@grafana/experimental": "npm:1.7.10" + "@grafana/google-sdk": "npm:0.1.2" + "@grafana/plugin-configs": "npm:10.4.0-pre" + "@grafana/runtime": "npm:10.4.0-pre" + "@grafana/schema": "npm:10.4.0-pre" + "@grafana/ui": "npm:10.4.0-pre" + "@kusto/monaco-kusto": "npm:^7.4.0" + "@testing-library/react": "npm:14.2.1" + "@testing-library/user-event": "npm:14.5.2" + "@types/debounce-promise": "npm:3.1.9" + "@types/jest": "npm:29.5.12" + "@types/lodash": "npm:4.14.202" + "@types/node": "npm:20.11.17" + "@types/prismjs": "npm:1.26.3" + "@types/react": "npm:18.2.55" + "@types/react-test-renderer": "npm:18.0.7" + "@types/testing-library__jest-dom": "npm:5.14.9" + debounce-promise: "npm:3.1.2" + fast-deep-equal: "npm:^3.1.3" + i18next: "npm:^23.0.0" + immer: "npm:10.0.3" + lodash: "npm:4.17.21" + monaco-editor: "npm:0.34.0" + prismjs: "npm:1.29.0" + react: "npm:18.2.0" + react-select-event: "npm:5.5.1" + react-test-renderer: "npm:18.2.0" + react-use: "npm:17.5.0" + rxjs: "npm:7.8.1" + ts-node: "npm:10.9.2" + tslib: "npm:2.6.2" + typescript: "npm:5.3.3" + webpack: "npm:5.90.1" + peerDependencies: + "@grafana/runtime": "*" + languageName: unknown + linkType: soft + "@grafana-plugins/tempo@workspace:*, @grafana-plugins/tempo@workspace:public/app/plugins/datasource/tempo": version: 0.0.0-use.local resolution: "@grafana-plugins/tempo@workspace:public/app/plugins/datasource/tempo" @@ -3433,7 +3433,7 @@ __metadata: "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.12" "@types/lodash": "npm:4.14.202" - "@types/node": "npm:20.11.16" + "@types/node": "npm:20.11.17" "@types/prismjs": "npm:1.26.3" "@types/react": "npm:18.2.55" "@types/react-dom": "npm:18.2.19" @@ -3507,7 +3507,7 @@ __metadata: "@types/jquery": "npm:3.5.29" "@types/lodash": "npm:4.14.202" "@types/marked": "npm:5.0.2" - "@types/node": "npm:20.11.16" + "@types/node": "npm:20.11.17" "@types/papaparse": "npm:5.3.14" "@types/react": "npm:18.2.55" "@types/react-dom": "npm:18.2.19" @@ -3569,7 +3569,7 @@ __metadata: "@grafana/tsconfig": "npm:^1.2.0-rc1" "@rollup/plugin-commonjs": "npm:25.0.7" "@rollup/plugin-node-resolve": "npm:15.2.3" - "@types/node": "npm:20.11.16" + "@types/node": "npm:20.11.17" esbuild: "npm:0.18.12" rimraf: "npm:5.0.5" rollup: "npm:2.79.1" @@ -3893,7 +3893,7 @@ __metadata: "@types/jquery": "npm:3.5.29" "@types/lodash": "npm:4.14.202" "@types/marked": "npm:5.0.2" - "@types/node": "npm:20.11.16" + "@types/node": "npm:20.11.17" "@types/pluralize": "npm:^0.0.33" "@types/prismjs": "npm:1.26.3" "@types/react": "npm:18.2.55" @@ -4011,9 +4011,9 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes@npm:2.6.6": - version: 2.6.6 - resolution: "@grafana/scenes@npm:2.6.6" +"@grafana/scenes@npm:^2.6.5": + version: 2.6.5 + resolution: "@grafana/scenes@npm:2.6.5" dependencies: "@grafana/e2e-selectors": "npm:10.0.2" react-grid-layout: "npm:1.3.4" @@ -4025,7 +4025,7 @@ __metadata: "@grafana/runtime": 10.0.3 "@grafana/schema": 10.0.3 "@grafana/ui": 10.0.3 - checksum: 10/13f5a7d77892ab68af00ee16887bbd101f00e6e2dbb547f697b0f472a296b8030a96f1596c416c24800367f83a469bc10d975d689a0ca8a7cd533efb7ef1bfc3 + checksum: 10/68fe91a5a0c8f80b679126f3525b74b29ce3f9ad92bc558eaaf39693235137348b90796c2b02aa7c1c7929586e60df6024e139993a416ef37d4c875e548dc855 languageName: node linkType: hard @@ -4151,7 +4151,7 @@ __metadata: "@types/jquery": "npm:3.5.29" "@types/lodash": "npm:4.14.202" "@types/mock-raf": "npm:1.0.6" - "@types/node": "npm:20.11.16" + "@types/node": "npm:20.11.17" "@types/prismjs": "npm:1.26.3" "@types/react": "npm:18.2.55" "@types/react-beautiful-dnd": "npm:13.1.8" @@ -4743,12 +4743,24 @@ __metadata: languageName: node linkType: hard -"@lerna/create@npm:8.1.2": - version: 8.1.2 - resolution: "@lerna/create@npm:8.1.2" +"@lerna/child-process@npm:7.4.1": + version: 7.4.1 + resolution: "@lerna/child-process@npm:7.4.1" dependencies: - "@npmcli/run-script": "npm:7.0.2" - "@nx/devkit": "npm:>=17.1.2 < 19" + chalk: "npm:^4.1.0" + execa: "npm:^5.0.0" + strong-log-transformer: "npm:^2.1.0" + checksum: 10/6be434a3d8aaf41e290dd0169133417cdb3b33ffd59fe77c7a927f28302fb8712a0be63fd261cf1b9c601000ed4dba1f86f8c0a8c3fa97fc665cd4e3458fc1ba + languageName: node + linkType: hard + +"@lerna/create@npm:7.4.1": + version: 7.4.1 + resolution: "@lerna/create@npm:7.4.1" + dependencies: + "@lerna/child-process": "npm:7.4.1" + "@npmcli/run-script": "npm:6.0.2" + "@nx/devkit": "npm:>=16.5.1 < 17" "@octokit/plugin-enterprise-rest": "npm:6.0.1" "@octokit/rest": "npm:19.0.11" byte-size: "npm:8.1.1" @@ -4785,12 +4797,12 @@ __metadata: npm-packlist: "npm:5.1.1" npm-registry-fetch: "npm:^14.0.5" npmlog: "npm:^6.0.2" - nx: "npm:>=17.1.2 < 19" + nx: "npm:>=16.5.1 < 17" p-map: "npm:4.0.0" p-map-series: "npm:2.1.0" p-queue: "npm:6.6.2" p-reduce: "npm:^2.1.0" - pacote: "npm:^17.0.5" + pacote: "npm:^15.2.0" pify: "npm:5.0.0" read-cmd-shim: "npm:4.0.0" read-package-json: "npm:6.0.4" @@ -4809,9 +4821,9 @@ __metadata: validate-npm-package-name: "npm:5.0.0" write-file-atomic: "npm:5.0.1" write-pkg: "npm:4.0.0" - yargs: "npm:17.7.2" - yargs-parser: "npm:21.1.1" - checksum: 10/d12b378cec4396d01f05127f9921dba83aae5f9c682d9cc01a15092bcd806625ad97126cd7b0dcc3cbfa851a9886c398d7562106ce9972603e00d02ddf6a6c61 + yargs: "npm:16.2.0" + yargs-parser: "npm:20.2.4" + checksum: 10/b475e41761a77c519d84711d80799db97c2844d1d100c91e0a7c9d8c500c3f34654d4915d7bbb08cd1c95d703a8b2b8cc92257ee14aaa64474ce8662977a4bcd languageName: node linkType: hard @@ -5183,19 +5195,6 @@ __metadata: languageName: node linkType: hard -"@npmcli/agent@npm:^2.0.0": - version: 2.2.1 - resolution: "@npmcli/agent@npm:2.2.1" - dependencies: - agent-base: "npm:^7.1.0" - http-proxy-agent: "npm:^7.0.0" - https-proxy-agent: "npm:^7.0.1" - lru-cache: "npm:^10.0.1" - socks-proxy-agent: "npm:^8.0.1" - checksum: 10/d4a48128f61e47f2f5c89315a5350e265dc619987e635bd62b52b29c7ed93536e724e721418c0ce352ceece86c13043c67aba1b70c3f5cc72fce6bb746706162 - languageName: node - linkType: hard - "@npmcli/fs@npm:^1.0.0": version: 1.0.0 resolution: "@npmcli/fs@npm:1.0.0" @@ -5206,6 +5205,16 @@ __metadata: languageName: node linkType: hard +"@npmcli/fs@npm:^2.1.0": + version: 2.1.0 + resolution: "@npmcli/fs@npm:2.1.0" + dependencies: + "@gar/promisify": "npm:^1.1.3" + semver: "npm:^7.3.5" + checksum: 10/1fe97efb5c1250c5986b46b6c8256b1eab8159a6d50fc8ace9f90937b3195541272faf77f18bdbf5eeb89bab68332c7846ac5ab9337e6099e63c6007388ebe84 + languageName: node + linkType: hard + "@npmcli/fs@npm:^3.1.0": version: 3.1.0 resolution: "@npmcli/fs@npm:3.1.0" @@ -5215,19 +5224,19 @@ __metadata: languageName: node linkType: hard -"@npmcli/git@npm:^5.0.0": - version: 5.0.4 - resolution: "@npmcli/git@npm:5.0.4" +"@npmcli/git@npm:^4.0.0": + version: 4.1.0 + resolution: "@npmcli/git@npm:4.1.0" dependencies: - "@npmcli/promise-spawn": "npm:^7.0.0" - lru-cache: "npm:^10.0.1" - npm-pick-manifest: "npm:^9.0.0" + "@npmcli/promise-spawn": "npm:^6.0.0" + lru-cache: "npm:^7.4.4" + npm-pick-manifest: "npm:^8.0.0" proc-log: "npm:^3.0.0" promise-inflight: "npm:^1.0.1" promise-retry: "npm:^2.0.1" semver: "npm:^7.3.5" - which: "npm:^4.0.0" - checksum: 10/136e71f4de73ef315285ebaf172b4681d1d22aff4c87ec526af1e57ab88ad7c864272523382009a2e3fab00f932bea204ed90cbdf187c7b7bd3d5c6e3d6c6d1a + which: "npm:^3.0.0" + checksum: 10/33512ce12758d67c0322eca25019c4d5ef03e83f5829e09a05389af485bab216cc4df408b8eba98f2d12c119c6dff84f0d8ff25a1ac5d8a46184e55ae8f53754 languageName: node linkType: hard @@ -5253,6 +5262,16 @@ __metadata: languageName: node linkType: hard +"@npmcli/move-file@npm:^2.0.0": + version: 2.0.0 + resolution: "@npmcli/move-file@npm:2.0.0" + dependencies: + mkdirp: "npm:^1.0.4" + rimraf: "npm:^3.0.2" + checksum: 10/1388777b507b0c592d53f41b9d182e1a8de7763bc625fc07999b8edbc22325f074e5b3ec90af79c89d6987fdb2325bc66d59f483258543c14a43661621f841b0 + languageName: node + linkType: hard + "@npmcli/node-gyp@npm:^3.0.0": version: 3.0.0 resolution: "@npmcli/node-gyp@npm:3.0.0" @@ -5260,161 +5279,132 @@ __metadata: languageName: node linkType: hard -"@npmcli/package-json@npm:^5.0.0": - version: 5.0.0 - resolution: "@npmcli/package-json@npm:5.0.0" +"@npmcli/promise-spawn@npm:^6.0.0, @npmcli/promise-spawn@npm:^6.0.1": + version: 6.0.2 + resolution: "@npmcli/promise-spawn@npm:6.0.2" dependencies: - "@npmcli/git": "npm:^5.0.0" - glob: "npm:^10.2.2" - hosted-git-info: "npm:^7.0.0" - json-parse-even-better-errors: "npm:^3.0.0" - normalize-package-data: "npm:^6.0.0" - proc-log: "npm:^3.0.0" - semver: "npm:^7.5.3" - checksum: 10/bb907e934e96dae3d3aa26aa45cbaa87b318cb64c4aaaacfa3596b1ca5147ad1b51c3281eb529df12116a163d33ca99f48c4593b0c168e38412dfbf2c5cced72 + which: "npm:^3.0.0" + checksum: 10/cc94a83ff1626ad93d42c2ea583dba1fb2d24cdab49caf0af77a3a0ff9bdbba34e09048b6821d4060ea7a58d4a41d49bece4ae3716929e2077c2fff0f5e94d94 languageName: node linkType: hard -"@npmcli/promise-spawn@npm:^7.0.0": - version: 7.0.1 - resolution: "@npmcli/promise-spawn@npm:7.0.1" - dependencies: - which: "npm:^4.0.0" - checksum: 10/7cbfc3c5e0bcad28e362dc34418b7507afea4fa82d692b802d9b8999ebdc99ceb2686f5959b5b9890e424983cee801401d3e972638f6942f75a2976a2c61774c - languageName: node - linkType: hard - -"@npmcli/run-script@npm:7.0.2": - version: 7.0.2 - resolution: "@npmcli/run-script@npm:7.0.2" +"@npmcli/run-script@npm:6.0.2, @npmcli/run-script@npm:^6.0.0": + version: 6.0.2 + resolution: "@npmcli/run-script@npm:6.0.2" dependencies: "@npmcli/node-gyp": "npm:^3.0.0" - "@npmcli/promise-spawn": "npm:^7.0.0" - node-gyp: "npm:^10.0.0" + "@npmcli/promise-spawn": "npm:^6.0.0" + node-gyp: "npm:^9.0.0" read-package-json-fast: "npm:^3.0.0" - which: "npm:^4.0.0" - checksum: 10/4549311f3b937ca81d147b72fbfd41aa6ed7daf70ecc4e9ee3838f9cce1749e9c62c301943a8a67364a96c31bbc67c49ee31526fb12ec2f4b15148f0ef472f98 + which: "npm:^3.0.0" + checksum: 10/9b22c4c53d4b2e014e7f990cf2e1d32d1830c5629d37a4ee56011bcdfb51424ca8dc3fb3fa550b4abe7e8f0efdd68468d733b754db371b06a5dd300663cf13a2 languageName: node linkType: hard -"@npmcli/run-script@npm:^7.0.0": - version: 7.0.4 - resolution: "@npmcli/run-script@npm:7.0.4" +"@nrwl/devkit@npm:16.10.0": + version: 16.10.0 + resolution: "@nrwl/devkit@npm:16.10.0" dependencies: - "@npmcli/node-gyp": "npm:^3.0.0" - "@npmcli/package-json": "npm:^5.0.0" - "@npmcli/promise-spawn": "npm:^7.0.0" - node-gyp: "npm:^10.0.0" - which: "npm:^4.0.0" - checksum: 10/f09268051f74af7d7be46e9911a23126d531160c338d3c05d53e6cd7994b88271fb4ec524139fe7f2d826525f15a281eafef3be02831adc1f68556a8a668621a + "@nx/devkit": "npm:16.10.0" + checksum: 10/2727b9927f8a7f3561c5eae72d3ca91d3ff0ea7c47da1d096d1654c85763acd580bbabb182db9e6f4f5df93152ae0972a0df7393f2dbad9d17b68549af313769 languageName: node linkType: hard -"@nrwl/devkit@npm:18.0.3": - version: 18.0.3 - resolution: "@nrwl/devkit@npm:18.0.3" +"@nrwl/tao@npm:16.10.0": + version: 16.10.0 + resolution: "@nrwl/tao@npm:16.10.0" dependencies: - "@nx/devkit": "npm:18.0.3" - checksum: 10/bb97aae663d573431a7fa8810e1b4e57913710e7611f4546f6bb6452b3c5c352b20f3b1fd61f8f4500e36cbcf621b461eeda93d85156145ee62dbca575c41b25 - languageName: node - linkType: hard - -"@nrwl/tao@npm:18.0.3": - version: 18.0.3 - resolution: "@nrwl/tao@npm:18.0.3" - dependencies: - nx: "npm:18.0.3" + nx: "npm:16.10.0" tslib: "npm:^2.3.0" bin: tao: index.js - checksum: 10/a9981bcceec72ba535e0c6be9ba2f333c6250a757774e73cd5711a507e7a58f0e66d04476fe0027c2f94d995178cc91a8123c01c80f24231fc8942f828d77add + checksum: 10/df495b60f98112ffbeb19ae3d9385ecc18b3b9a2dbde1c50a91d5111408afba218c32ba55e6a3adf7c935262f4c18417672712e14185de2ec61beb5ef23186dc languageName: node linkType: hard -"@nx/devkit@npm:18.0.3, @nx/devkit@npm:>=17.1.2 < 19": - version: 18.0.3 - resolution: "@nx/devkit@npm:18.0.3" +"@nx/devkit@npm:16.10.0, @nx/devkit@npm:>=16.5.1 < 17": + version: 16.10.0 + resolution: "@nx/devkit@npm:16.10.0" dependencies: - "@nrwl/devkit": "npm:18.0.3" + "@nrwl/devkit": "npm:16.10.0" ejs: "npm:^3.1.7" enquirer: "npm:~2.3.6" ignore: "npm:^5.0.4" - semver: "npm:^7.5.3" + semver: "npm:7.5.3" tmp: "npm:~0.2.1" tslib: "npm:^2.3.0" - yargs-parser: "npm:21.1.1" peerDependencies: - nx: ">= 16 <= 18" - checksum: 10/c13d5a50e975bb4feba47e7939227efa122f46b3d295c65732c6aa2aaad43002596c057001fdafe057d9167a40546c139f84786a0a35243b11279d9684fe9793 + nx: ">= 15 <= 17" + checksum: 10/d703e74d8360395dcafdc531e81c25ac6bbe46142169a5185f841067336b7dadce7f2102cfc2ef1c1826e3f9b92ca5b740a62ca4c32064265494dcd5a359ee21 languageName: node linkType: hard -"@nx/nx-darwin-arm64@npm:18.0.3": - version: 18.0.3 - resolution: "@nx/nx-darwin-arm64@npm:18.0.3" +"@nx/nx-darwin-arm64@npm:16.10.0": + version: 16.10.0 + resolution: "@nx/nx-darwin-arm64@npm:16.10.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@nx/nx-darwin-x64@npm:18.0.3": - version: 18.0.3 - resolution: "@nx/nx-darwin-x64@npm:18.0.3" +"@nx/nx-darwin-x64@npm:16.10.0": + version: 16.10.0 + resolution: "@nx/nx-darwin-x64@npm:16.10.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@nx/nx-freebsd-x64@npm:18.0.3": - version: 18.0.3 - resolution: "@nx/nx-freebsd-x64@npm:18.0.3" +"@nx/nx-freebsd-x64@npm:16.10.0": + version: 16.10.0 + resolution: "@nx/nx-freebsd-x64@npm:16.10.0" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@nx/nx-linux-arm-gnueabihf@npm:18.0.3": - version: 18.0.3 - resolution: "@nx/nx-linux-arm-gnueabihf@npm:18.0.3" +"@nx/nx-linux-arm-gnueabihf@npm:16.10.0": + version: 16.10.0 + resolution: "@nx/nx-linux-arm-gnueabihf@npm:16.10.0" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@nx/nx-linux-arm64-gnu@npm:18.0.3": - version: 18.0.3 - resolution: "@nx/nx-linux-arm64-gnu@npm:18.0.3" +"@nx/nx-linux-arm64-gnu@npm:16.10.0": + version: 16.10.0 + resolution: "@nx/nx-linux-arm64-gnu@npm:16.10.0" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@nx/nx-linux-arm64-musl@npm:18.0.3": - version: 18.0.3 - resolution: "@nx/nx-linux-arm64-musl@npm:18.0.3" +"@nx/nx-linux-arm64-musl@npm:16.10.0": + version: 16.10.0 + resolution: "@nx/nx-linux-arm64-musl@npm:16.10.0" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@nx/nx-linux-x64-gnu@npm:18.0.3": - version: 18.0.3 - resolution: "@nx/nx-linux-x64-gnu@npm:18.0.3" +"@nx/nx-linux-x64-gnu@npm:16.10.0": + version: 16.10.0 + resolution: "@nx/nx-linux-x64-gnu@npm:16.10.0" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@nx/nx-linux-x64-musl@npm:18.0.3": - version: 18.0.3 - resolution: "@nx/nx-linux-x64-musl@npm:18.0.3" +"@nx/nx-linux-x64-musl@npm:16.10.0": + version: 16.10.0 + resolution: "@nx/nx-linux-x64-musl@npm:16.10.0" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@nx/nx-win32-arm64-msvc@npm:18.0.3": - version: 18.0.3 - resolution: "@nx/nx-win32-arm64-msvc@npm:18.0.3" +"@nx/nx-win32-arm64-msvc@npm:16.10.0": + version: 16.10.0 + resolution: "@nx/nx-win32-arm64-msvc@npm:16.10.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@nx/nx-win32-x64-msvc@npm:18.0.3": - version: 18.0.3 - resolution: "@nx/nx-win32-x64-msvc@npm:18.0.3" +"@nx/nx-win32-x64-msvc@npm:16.10.0": + version: 16.10.0 + resolution: "@nx/nx-win32-x64-msvc@npm:16.10.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -5787,6 +5777,17 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher@npm:2.0.4": + version: 2.0.4 + resolution: "@parcel/watcher@npm:2.0.4" + dependencies: + node-addon-api: "npm:^3.2.1" + node-gyp: "npm:latest" + node-gyp-build: "npm:^4.3.0" + checksum: 10/ec3ba32c16856c34460d79bc95887f68869201e0cae68c5d1d4cd1f0358673d76dea56e194ede1e83af78656bde4eef2b17716a7396b54f63a40e4655c7a63c4 + languageName: node + linkType: hard + "@petamoriken/float16@npm:^3.4.7": version: 3.5.0 resolution: "@petamoriken/float16@npm:3.5.0" @@ -6995,23 +6996,7 @@ __metadata: languageName: node linkType: hard -"@sigstore/bundle@npm:^2.1.1": - version: 2.1.1 - resolution: "@sigstore/bundle@npm:2.1.1" - dependencies: - "@sigstore/protobuf-specs": "npm:^0.2.1" - checksum: 10/e29916ad3f37d4e1c5b98d7a614cddb1301d4bdfa5ebe0cb2733f4cbc78710b8320aa62ad033e4702c5ec7bcd9c371278b7934ce45f3df71bb3ffa07f5502742 - languageName: node - linkType: hard - -"@sigstore/core@npm:^0.2.0": - version: 0.2.0 - resolution: "@sigstore/core@npm:0.2.0" - checksum: 10/6a9e7f0dcbaad3e330207f6ce0aa0cb229416eb8ece71a31e427f71f021ce25ef8230faaca93c8abf428dab391f63ef7a08c8a88e0237dee3b15daf35c53a86a - languageName: node - linkType: hard - -"@sigstore/protobuf-specs@npm:^0.2.0, @sigstore/protobuf-specs@npm:^0.2.1": +"@sigstore/protobuf-specs@npm:^0.2.0": version: 0.2.1 resolution: "@sigstore/protobuf-specs@npm:0.2.1" checksum: 10/cb0b9d9b3ef44a9f1729d85616c5d7c2ebccde303836a5a345ec33a500c7bd5205ffcc31332e0a90831cccc581dafbdf5b868f050c84270c8df6a4a6f2ce0bcb @@ -7029,18 +7014,6 @@ __metadata: languageName: node linkType: hard -"@sigstore/sign@npm:^2.2.1": - version: 2.2.1 - resolution: "@sigstore/sign@npm:2.2.1" - dependencies: - "@sigstore/bundle": "npm:^2.1.1" - "@sigstore/core": "npm:^0.2.0" - "@sigstore/protobuf-specs": "npm:^0.2.1" - make-fetch-happen: "npm:^13.0.0" - checksum: 10/a829c479418a86f9919d85aec0349fd4a9c297aaacc4e838580bc9b5ba9a372fb318b4829b78cc5c9e56b8fd1b7d11a06e31384eff55bd0813f5d0993f5fb9db - languageName: node - linkType: hard - "@sigstore/tuf@npm:^1.0.3": version: 1.0.3 resolution: "@sigstore/tuf@npm:1.0.3" @@ -7051,27 +7024,6 @@ __metadata: languageName: node linkType: hard -"@sigstore/tuf@npm:^2.3.0": - version: 2.3.0 - resolution: "@sigstore/tuf@npm:2.3.0" - dependencies: - "@sigstore/protobuf-specs": "npm:^0.2.1" - tuf-js: "npm:^2.2.0" - checksum: 10/c4a9e87c1d4b48de87526fd37b154382dd7caf6fe784329b829270ed431741bb1a4ecde6d8aa2bbe72124a24ef1b616c098a4b036cd04965e02f039de11acd4f - languageName: node - linkType: hard - -"@sigstore/verify@npm:^0.1.0": - version: 0.1.0 - resolution: "@sigstore/verify@npm:0.1.0" - dependencies: - "@sigstore/bundle": "npm:^2.1.1" - "@sigstore/core": "npm:^0.2.0" - "@sigstore/protobuf-specs": "npm:^0.2.1" - checksum: 10/9dc208a4d0ace4d836aa1717cd02236b480d883e2a7a4f40fb87ccb0e7b7e6d4805c5628bb5cc3aec392bafe866e59f3ce55c2b16ef9ed224ae6a60c07984e65 - languageName: node - linkType: hard - "@sinclair/typebox@npm:^0.27.8": version: 0.27.8 resolution: "@sinclair/typebox@npm:0.27.8" @@ -8478,13 +8430,6 @@ __metadata: languageName: node linkType: hard -"@tufjs/canonical-json@npm:2.0.0": - version: 2.0.0 - resolution: "@tufjs/canonical-json@npm:2.0.0" - checksum: 10/cc719a1d0d0ae1aa1ba551a82c87dcbefac088e433c03a3d8a1d547ea721350e47dab4ab5b0fca40d5c7ab1f4882e72edc39c9eae15bf47c45c43bcb6ee39f4f - languageName: node - linkType: hard - "@tufjs/models@npm:1.0.4": version: 1.0.4 resolution: "@tufjs/models@npm:1.0.4" @@ -8495,16 +8440,6 @@ __metadata: languageName: node linkType: hard -"@tufjs/models@npm:2.0.0": - version: 2.0.0 - resolution: "@tufjs/models@npm:2.0.0" - dependencies: - "@tufjs/canonical-json": "npm:2.0.0" - minimatch: "npm:^9.0.3" - checksum: 10/d89d618c74c4eed3906d9ba5bd1bd9d0fa7a73ad6266b11c74c13102ee00bfdbd8e73fe786bd2e8e3ae347f9a66f044d973a7466dc7c2c2f98a7ff926ff275c4 - languageName: node - linkType: hard - "@types/angular-route@npm:1.7.6": version: 1.7.6 resolution: "@types/angular-route@npm:1.7.6" @@ -9438,12 +9373,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:20.11.16, @types/node@npm:>=13.7.0": - version: 20.11.16 - resolution: "@types/node@npm:20.11.16" +"@types/node@npm:*, @types/node@npm:20.11.17, @types/node@npm:>=13.7.0": + version: 20.11.17 + resolution: "@types/node@npm:20.11.17" dependencies: undici-types: "npm:~5.26.4" - checksum: 10/751f50ec5c9332b11515e82fe37c71479ac4449b711280aa3c7910edf67b1e3f5ac00041512add543f9a892096a68356406998bf02a2c809a73d176c44c28414 + checksum: 10/3342df87258d1c56154bcd4b85180f48675427b235971e6e6e2e037353f5a2ae9aaa05ba5df0fe1e2d2f1022c8d856fd39056b9d7f50ea30c0ca3214137cae1d languageName: node linkType: hard @@ -10940,13 +10875,6 @@ __metadata: languageName: node linkType: hard -"abbrev@npm:^2.0.0": - version: 2.0.0 - resolution: "abbrev@npm:2.0.0" - checksum: 10/ca0a54e35bea4ece0ecb68a47b312e1a9a6f772408d5bcb9051230aaa94b0460671c5b5c9cb3240eb5b7bc94c52476550eb221f65a0bbd0145bdc9f3113a6707 - languageName: node - linkType: hard - "accepts@npm:~1.3.4, accepts@npm:~1.3.5, accepts@npm:~1.3.8": version: 1.3.8 resolution: "accepts@npm:1.3.8" @@ -11063,15 +10991,6 @@ __metadata: languageName: node linkType: hard -"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0": - version: 7.1.0 - resolution: "agent-base@npm:7.1.0" - dependencies: - debug: "npm:^4.3.4" - checksum: 10/f7828f991470a0cc22cb579c86a18cbae83d8a3cbed39992ab34fc7217c4d126017f1c74d0ab66be87f71455318a8ea3e757d6a37881b8d0f2a2c6aa55e5418f - languageName: node - linkType: hard - "agentkeepalive@npm:^4.1.3, agentkeepalive@npm:^4.2.1": version: 4.2.1 resolution: "agentkeepalive@npm:4.2.1" @@ -11264,7 +11183,17 @@ __metadata: languageName: node linkType: hard -"anymatch@npm:^3.0.3, anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": +"anymatch@npm:^3.0.3, anymatch@npm:~3.1.2": + version: 3.1.2 + resolution: "anymatch@npm:3.1.2" + dependencies: + normalize-path: "npm:^3.0.0" + picomatch: "npm:^2.0.4" + checksum: 10/985163db2292fac9e5a1e072bf99f1b5baccf196e4de25a0b0b81865ebddeb3b3eb4480734ef0a2ac8c002845396b91aa89121f5b84f93981a4658164a9ec6e9 + languageName: node + linkType: hard + +"anymatch@npm:^3.1.3": version: 3.1.3 resolution: "anymatch@npm:3.1.3" dependencies: @@ -11717,14 +11646,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.6.0": - version: 1.6.7 - resolution: "axios@npm:1.6.7" +"axios@npm:^1.0.0": + version: 1.5.1 + resolution: "axios@npm:1.5.1" dependencies: - follow-redirects: "npm:^1.15.4" + follow-redirects: "npm:^1.15.0" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10/a1932b089ece759cd261f175d9ebf4d41c8994cf0c0767cda86055c7a19bcfdade8ae3464bf4cec4c8b142f4a657dc664fb77a41855e8376cf38b86d7a86518f + checksum: 10/67633db5867c789a6edb6e5229884501bef89584a6718220c243fd5a64de4ea7dcdfdf4f8368a672d582db78aaa9f8d7b619d39403b669f451e1242bbd4c7ee2 languageName: node linkType: hard @@ -12448,6 +12377,32 @@ __metadata: languageName: node linkType: hard +"cacache@npm:^16.1.0": + version: 16.1.1 + resolution: "cacache@npm:16.1.1" + dependencies: + "@npmcli/fs": "npm:^2.1.0" + "@npmcli/move-file": "npm:^2.0.0" + chownr: "npm:^2.0.0" + fs-minipass: "npm:^2.1.0" + glob: "npm:^8.0.1" + infer-owner: "npm:^1.0.4" + lru-cache: "npm:^7.7.1" + minipass: "npm:^3.1.6" + minipass-collect: "npm:^1.0.2" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + mkdirp: "npm:^1.0.4" + p-map: "npm:^4.0.0" + promise-inflight: "npm:^1.0.1" + rimraf: "npm:^3.0.2" + ssri: "npm:^9.0.0" + tar: "npm:^6.1.11" + unique-filename: "npm:^1.1.1" + checksum: 10/8356f969767ff11ed5e9dc6fcb3fc47d227431c6e68086a34ae08b2f3744909e6e22ae1868dc5ab094132a3d8dfc174f08bd7f3122abf50cf56fd789553d3d1f + languageName: node + linkType: hard + "cacache@npm:^17.0.0": version: 17.1.4 resolution: "cacache@npm:17.1.4" @@ -12468,26 +12423,6 @@ __metadata: languageName: node linkType: hard -"cacache@npm:^18.0.0": - version: 18.0.2 - resolution: "cacache@npm:18.0.2" - dependencies: - "@npmcli/fs": "npm:^3.1.0" - fs-minipass: "npm:^3.0.0" - glob: "npm:^10.2.2" - lru-cache: "npm:^10.0.1" - minipass: "npm:^7.0.3" - minipass-collect: "npm:^2.0.1" - minipass-flush: "npm:^1.0.5" - minipass-pipeline: "npm:^1.2.4" - p-map: "npm:^4.0.0" - ssri: "npm:^10.0.0" - tar: "npm:^6.1.11" - unique-filename: "npm:^3.0.0" - checksum: 10/5ca58464f785d4d64ac2019fcad95451c8c89bea25949f63acd8987fcc3493eaef1beccc0fa39e673506d879d3fc1ab420760f8a14f8ddf46ea2d121805a5e96 - languageName: node - linkType: hard - "cachedir@npm:^2.3.0": version: 2.3.0 resolution: "cachedir@npm:2.3.0" @@ -13354,12 +13289,12 @@ __metadata: languageName: node linkType: hard -"conventional-changelog-angular@npm:7.0.0": - version: 7.0.0 - resolution: "conventional-changelog-angular@npm:7.0.0" +"conventional-changelog-angular@npm:6.0.0": + version: 6.0.0 + resolution: "conventional-changelog-angular@npm:6.0.0" dependencies: compare-func: "npm:^2.0.0" - checksum: 10/e7966d2fee5475e76263f30f8b714b2b592b5bf556df225b7091e5090831fc9a20b99598a7d2997e19c2ef8118c0a3150b1eba290786367b0f55a5ccfa804ec9 + checksum: 10/ddc59ead53a45b817d83208200967f5340866782b8362d5e2e34105fdfa3d3a31585ebbdec7750bdb9de53da869f847e8ca96634a9801f51e27ecf4e7ffe2bad languageName: node linkType: hard @@ -16622,13 +16557,6 @@ __metadata: languageName: node linkType: hard -"exponential-backoff@npm:^3.1.1": - version: 3.1.1 - resolution: "exponential-backoff@npm:3.1.1" - checksum: 10/2d9bbb6473de7051f96790d5f9a678f32e60ed0aa70741dc7fdc96fec8d631124ec3374ac144387604f05afff9500f31a1d45bd9eee4cdc2e4f9ad2d9b9d5dbd - languageName: node - linkType: hard - "expose-loader@npm:5.0.0": version: 5.0.0 resolution: "expose-loader@npm:5.0.0" @@ -16760,6 +16688,13 @@ __metadata: languageName: node linkType: hard +"fast-fifo@npm:^1.0.0": + version: 1.1.0 + resolution: "fast-fifo@npm:1.1.0" + checksum: 10/895f4c9873a4d5059dfa244aa0dde2b22ee563fd673d85b638869715f92244f9d6469bc0873bcb40554d28c51cbc7590045718462cfda1da503b1c6985815209 + languageName: node + linkType: hard + "fast-fifo@npm:^1.1.0": version: 1.3.2 resolution: "fast-fifo@npm:1.3.2" @@ -16850,7 +16785,7 @@ __metadata: languageName: node linkType: hard -"fastq@npm:^1.13.0, fastq@npm:^1.6.0": +"fastq@npm:^1.13.0": version: 1.17.1 resolution: "fastq@npm:1.17.1" dependencies: @@ -16859,6 +16794,15 @@ __metadata: languageName: node linkType: hard +"fastq@npm:^1.6.0": + version: 1.13.0 + resolution: "fastq@npm:1.13.0" + dependencies: + reusify: "npm:^1.0.4" + checksum: 10/0902cb9b81accf34e5542612c8a1df6c6ea47674f85bcc9cdc38795a28b53e4a096f751cfcf4fb25d2ea42fee5447499ba6cf5af5d0209297e1d1fd4dd551bb6 + languageName: node + linkType: hard + "fault@npm:^1.0.0": version: 1.0.4 resolution: "fault@npm:1.0.4" @@ -17123,13 +17067,13 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.15.4": - version: 1.15.5 - resolution: "follow-redirects@npm:1.15.5" +"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.15.0": + version: 1.15.3 + resolution: "follow-redirects@npm:1.15.3" peerDependenciesMeta: debug: optional: true - checksum: 10/d467f13c1c6aa734599b8b369cd7a625b20081af358f6204ff515f6f4116eb440de9c4e0c49f10798eeb0df26c95dd05d5e0d9ddc5786ab1a8a8abefe92929b4 + checksum: 10/60d98693f4976892f8c654b16ef6d1803887a951898857ab0cdc009570b1c06314ad499505b7a040ac5b98144939f8597766e5e6a6859c0945d157b473aa6f5f languageName: node linkType: hard @@ -17348,7 +17292,7 @@ __metadata: languageName: node linkType: hard -"fs-minipass@npm:^2.0.0": +"fs-minipass@npm:^2.0.0, fs-minipass@npm:^2.1.0": version: 2.1.0 resolution: "fs-minipass@npm:2.1.0" dependencies: @@ -17820,7 +17764,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:10.3.10, glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.2.5, glob@npm:^10.2.7, glob@npm:^10.3.10, glob@npm:^10.3.7": +"glob@npm:10.3.10, glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.2.5, glob@npm:^10.2.7, glob@npm:^10.3.7": version: 10.3.10 resolution: "glob@npm:10.3.10" dependencies: @@ -17835,6 +17779,20 @@ __metadata: languageName: node linkType: hard +"glob@npm:7.1.4": + version: 7.1.4 + resolution: "glob@npm:7.1.4" + dependencies: + fs.realpath: "npm:^1.0.0" + inflight: "npm:^1.0.4" + inherits: "npm:2" + minimatch: "npm:^3.0.4" + once: "npm:^1.3.0" + path-is-absolute: "npm:^1.0.0" + checksum: 10/776bcc31371797eb5cf6b58c4618378f8df83d23f00aef8e98af5e7f0e59f5ee8b470c4e95e71cfa7a8682634849e21ea1f1ad38639c1828a2dbc2757bf7a63b + languageName: node + linkType: hard + "glob@npm:7.2.0": version: 7.2.0 resolution: "glob@npm:7.2.0" @@ -18039,10 +17997,10 @@ __metadata: "@floating-ui/react": "npm:0.26.9" "@glideapps/glide-data-grid": "npm:^6.0.0" "@grafana-plugins/grafana-azure-monitor-datasource": "workspace:*" - "@grafana-plugins/grafana-cloud-monitoring-datasource": "workspace:*" "@grafana-plugins/grafana-pyroscope-datasource": "workspace:*" "@grafana-plugins/grafana-testdata-datasource": "workspace:*" "@grafana-plugins/parca": "workspace:*" + "@grafana-plugins/stackdriver": "workspace:*" "@grafana-plugins/tempo": "workspace:*" "@grafana/aws-sdk": "npm:0.3.1" "@grafana/data": "workspace:*" @@ -18059,7 +18017,7 @@ __metadata: "@grafana/o11y-ds-frontend": "workspace:*" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" - "@grafana/scenes": "npm:2.6.6" + "@grafana/scenes": "npm:^2.6.5" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/tsconfig": "npm:^1.3.0-rc1" @@ -18125,7 +18083,7 @@ __metadata: "@types/lucene": "npm:^2" "@types/marked": "npm:5.0.2" "@types/mousetrap": "npm:1.6.15" - "@types/node": "npm:20.11.16" + "@types/node": "npm:20.11.17" "@types/node-forge": "npm:^1" "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.4" "@types/papaparse": "npm:5.3.14" @@ -18244,7 +18202,7 @@ __metadata: json-source-map: "npm:0.6.1" jsurl: "npm:^0.1.5" kbar: "npm:0.1.0-beta.45" - lerna: "npm:8.1.2" + lerna: "npm:7.4.1" leven: "npm:^4.0.0" lodash: "npm:4.17.21" logfmt: "npm:^1.3.2" @@ -18710,15 +18668,6 @@ __metadata: languageName: node linkType: hard -"hosted-git-info@npm:^7.0.0": - version: 7.0.1 - resolution: "hosted-git-info@npm:7.0.1" - dependencies: - lru-cache: "npm:^10.0.1" - checksum: 10/5f740ecf3c70838e27446ff433a9a9a583de8747f7b661390b373ad12ca47edb937136e79999a4f953d0953079025a11df173f1fd9f7d52b0277b2fb9433e1c7 - languageName: node - linkType: hard - "hpack.js@npm:^2.1.6": version: 2.1.6 resolution: "hpack.js@npm:2.1.6" @@ -18946,16 +18895,6 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^7.0.0": - version: 7.0.0 - resolution: "http-proxy-agent@npm:7.0.0" - dependencies: - agent-base: "npm:^7.1.0" - debug: "npm:^4.3.4" - checksum: 10/dbaaf3d9f3fc4df4a5d7ec45d456ec50f575240b557160fa63427b447d1f812dd7fe4a4f17d2e1ba003d231f07edf5a856ea6d91cb32d533062ff20a7803ccac - languageName: node - linkType: hard - "http-proxy-middleware@npm:^2.0.3": version: 2.0.4 resolution: "http-proxy-middleware@npm:2.0.4" @@ -19050,16 +18989,6 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^7.0.1": - version: 7.0.2 - resolution: "https-proxy-agent@npm:7.0.2" - dependencies: - agent-base: "npm:^7.0.2" - debug: "npm:4" - checksum: 10/9ec844f78fd643608239c9c3f6819918631df5cd3e17d104cc507226a39b5d4adda9d790fc9fd63ac0d2bb8a761b2f9f60faa80584a9bf9d7f2e8c5ed0acd330 - languageName: node - linkType: hard - "human-signals@npm:^1.1.1": version: 1.1.1 resolution: "human-signals@npm:1.1.1" @@ -19178,12 +19107,12 @@ __metadata: languageName: node linkType: hard -"ignore-walk@npm:^6.0.4": - version: 6.0.4 - resolution: "ignore-walk@npm:6.0.4" +"ignore-walk@npm:^6.0.0": + version: 6.0.3 + resolution: "ignore-walk@npm:6.0.3" dependencies: minimatch: "npm:^9.0.0" - checksum: 10/a56c3f929bb0890ffb6e87dfaca7d5ce97f9e179fd68d49711edea55760aaee367cea3845d7620689b706249053c4b1805e21158f6751c7333f9b2ffb3668272 + checksum: 10/3cbc0b52c7dc405a3525898d705029f084ef6218df2a82b95520d72e3a6fb3ff893a4c22b73f36d2b7cefbe786a9687c4396de3c628be2844bc0728dc4e455cf languageName: node linkType: hard @@ -19418,6 +19347,13 @@ __metadata: languageName: node linkType: hard +"ip@npm:^1.1.5": + version: 1.1.5 + resolution: "ip@npm:1.1.5" + checksum: 10/40a00572cf06b53f4c7b7fe6270a8427ef4c6c0820a380f9f1eb48a323eb09c7dbd16245b472cf5a2d083911d0deae4d712b6e6c88b346fa274e8ce07756a7d6 + languageName: node + linkType: hard + "ip@npm:^2.0.0": version: 2.0.0 resolution: "ip@npm:2.0.0" @@ -20060,13 +19996,6 @@ __metadata: languageName: node linkType: hard -"isexe@npm:^3.1.1": - version: 3.1.1 - resolution: "isexe@npm:3.1.1" - checksum: 10/7fe1931ee4e88eb5aa524cd3ceb8c882537bc3a81b02e438b240e47012eef49c86904d0f0e593ea7c3a9996d18d0f1f3be8d3eaa92333977b0c3a9d353d5563e - languageName: node - linkType: hard - "isobject@npm:^3.0.1": version: 3.0.1 resolution: "isobject@npm:3.0.1" @@ -21305,13 +21234,14 @@ __metadata: languageName: node linkType: hard -"lerna@npm:8.1.2": - version: 8.1.2 - resolution: "lerna@npm:8.1.2" +"lerna@npm:7.4.1": + version: 7.4.1 + resolution: "lerna@npm:7.4.1" dependencies: - "@lerna/create": "npm:8.1.2" - "@npmcli/run-script": "npm:7.0.2" - "@nx/devkit": "npm:>=17.1.2 < 19" + "@lerna/child-process": "npm:7.4.1" + "@lerna/create": "npm:7.4.1" + "@npmcli/run-script": "npm:6.0.2" + "@nx/devkit": "npm:>=16.5.1 < 17" "@octokit/plugin-enterprise-rest": "npm:6.0.1" "@octokit/rest": "npm:19.0.11" byte-size: "npm:8.1.1" @@ -21319,7 +21249,7 @@ __metadata: clone-deep: "npm:4.0.1" cmd-shim: "npm:6.0.1" columnify: "npm:1.6.0" - conventional-changelog-angular: "npm:7.0.0" + conventional-changelog-angular: "npm:6.0.0" conventional-changelog-core: "npm:5.0.1" conventional-recommended-bump: "npm:7.0.1" cosmiconfig: "npm:^8.2.0" @@ -21354,14 +21284,14 @@ __metadata: npm-packlist: "npm:5.1.1" npm-registry-fetch: "npm:^14.0.5" npmlog: "npm:^6.0.2" - nx: "npm:>=17.1.2 < 19" + nx: "npm:>=16.5.1 < 17" p-map: "npm:4.0.0" p-map-series: "npm:2.1.0" p-pipe: "npm:3.1.0" p-queue: "npm:6.6.2" p-reduce: "npm:2.1.0" p-waterfall: "npm:2.1.1" - pacote: "npm:^17.0.5" + pacote: "npm:^15.2.0" pify: "npm:5.0.0" read-cmd-shim: "npm:4.0.0" read-package-json: "npm:6.0.4" @@ -21381,11 +21311,11 @@ __metadata: validate-npm-package-name: "npm:5.0.0" write-file-atomic: "npm:5.0.1" write-pkg: "npm:4.0.0" - yargs: "npm:17.7.2" - yargs-parser: "npm:21.1.1" + yargs: "npm:16.2.0" + yargs-parser: "npm:20.2.4" bin: lerna: dist/cli.js - checksum: 10/5f4267bb059e00b294985e5cc4e783155e5be58ecd73c1791be0c24ff77561f2699550cdad4faee8a3a5d2866ae18bd6d7c4bba4b0dae1b26056d1187616c8a6 + checksum: 10/3b837bd48adefc962cd0c5ce79e6e7716580ec88784cbb3b8ecc9cbbfea79de6536576f42432e87b677c7c1267bfe47a13ce0ec4ab8687ecb721be58cedd547a languageName: node linkType: hard @@ -21780,7 +21710,7 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:10.2.0, lru-cache@npm:^10.0.1, lru-cache@npm:^9.1.1 || ^10.0.0": +"lru-cache@npm:10.2.0, lru-cache@npm:^9.1.1 || ^10.0.0": version: 10.2.0 resolution: "lru-cache@npm:10.2.0" checksum: 10/502ec42c3309c0eae1ce41afca471f831c278566d45a5273a0c51102dee31e0e250a62fa9029c3370988df33a14188a38e682c16143b794de78668de3643e302 @@ -21805,7 +21735,7 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^7.5.1, lru-cache@npm:^7.7.1": +"lru-cache@npm:^7.4.4, lru-cache@npm:^7.5.1, lru-cache@npm:^7.7.1": version: 7.12.0 resolution: "lru-cache@npm:7.12.0" checksum: 10/ac4e78bb5a04174389db377d325c9baecb5d5c5cf619894aa237891b3f5605e51426823b118753229b0bf4073db7091cc7593c096181e2ba20b4b5e2e59c9c9d @@ -21879,6 +21809,30 @@ __metadata: languageName: node linkType: hard +"make-fetch-happen@npm:^10.0.3": + version: 10.1.8 + resolution: "make-fetch-happen@npm:10.1.8" + dependencies: + agentkeepalive: "npm:^4.2.1" + cacache: "npm:^16.1.0" + http-cache-semantics: "npm:^4.1.0" + http-proxy-agent: "npm:^5.0.0" + https-proxy-agent: "npm:^5.0.0" + is-lambda: "npm:^1.0.1" + lru-cache: "npm:^7.7.1" + minipass: "npm:^3.1.6" + minipass-collect: "npm:^1.0.2" + minipass-fetch: "npm:^2.0.3" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + negotiator: "npm:^0.6.3" + promise-retry: "npm:^2.0.1" + socks-proxy-agent: "npm:^7.0.0" + ssri: "npm:^9.0.0" + checksum: 10/0f83e6814d2aae5e37fc20dc4e4657419ad6e79771d78f7689ad037633836e37a1e65f799bce47a71f0202e5fdbc346aca78804c3b4ccfee621f00b1cd3176a3 + languageName: node + linkType: hard + "make-fetch-happen@npm:^11.0.0, make-fetch-happen@npm:^11.0.1, make-fetch-happen@npm:^11.1.1": version: 11.1.1 resolution: "make-fetch-happen@npm:11.1.1" @@ -21902,25 +21856,6 @@ __metadata: languageName: node linkType: hard -"make-fetch-happen@npm:^13.0.0": - version: 13.0.0 - resolution: "make-fetch-happen@npm:13.0.0" - dependencies: - "@npmcli/agent": "npm:^2.0.0" - cacache: "npm:^18.0.0" - http-cache-semantics: "npm:^4.1.1" - is-lambda: "npm:^1.0.1" - minipass: "npm:^7.0.2" - minipass-fetch: "npm:^3.0.0" - minipass-flush: "npm:^1.0.5" - minipass-pipeline: "npm:^1.2.4" - negotiator: "npm:^0.6.3" - promise-retry: "npm:^2.0.1" - ssri: "npm:^10.0.0" - checksum: 10/ded5a91a02b76381b06a4ec4d5c1d23ebbde15d402b3c3e4533b371dac7e2f7ca071ae71ae6dae72aa261182557b7b1b3fd3a705b39252dc17f74fa509d3e76f - languageName: node - linkType: hard - "make-fetch-happen@npm:^9.1.0": version: 9.1.0 resolution: "make-fetch-happen@npm:9.1.0" @@ -22355,15 +22290,6 @@ __metadata: languageName: node linkType: hard -"minipass-collect@npm:^2.0.1": - version: 2.0.1 - resolution: "minipass-collect@npm:2.0.1" - dependencies: - minipass: "npm:^7.0.3" - checksum: 10/b251bceea62090f67a6cced7a446a36f4cd61ee2d5cea9aee7fff79ba8030e416327a1c5aa2908dc22629d06214b46d88fdab8c51ac76bacbf5703851b5ad342 - languageName: node - linkType: hard - "minipass-fetch@npm:^1.3.2": version: 1.4.1 resolution: "minipass-fetch@npm:1.4.1" @@ -22379,6 +22305,21 @@ __metadata: languageName: node linkType: hard +"minipass-fetch@npm:^2.0.3": + version: 2.1.0 + resolution: "minipass-fetch@npm:2.1.0" + dependencies: + encoding: "npm:^0.1.13" + minipass: "npm:^3.1.6" + minipass-sized: "npm:^1.0.3" + minizlib: "npm:^2.1.2" + dependenciesMeta: + encoding: + optional: true + checksum: 10/33b6927ef8a4516e27878e1e9966a6dee5c2efb844584b39712a8c222cf7cc586ae00c09897ce3b21e77b6600ad4c7503f8bd732ef1a8bf98137f18c45c6d6c4 + languageName: node + linkType: hard + "minipass-fetch@npm:^3.0.0": version: 3.0.4 resolution: "minipass-fetch@npm:3.0.4" @@ -22431,7 +22372,7 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^3.0.0, minipass@npm:^3.1.0, minipass@npm:^3.1.1, minipass@npm:^3.1.3": +"minipass@npm:^3.0.0, minipass@npm:^3.1.0, minipass@npm:^3.1.1, minipass@npm:^3.1.3, minipass@npm:^3.1.6": version: 3.3.4 resolution: "minipass@npm:3.3.4" dependencies: @@ -22454,7 +22395,7 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3": +"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.3": version: 7.0.4 resolution: "minipass@npm:7.0.4" checksum: 10/e864bd02ceb5e0707696d58f7ce3a0b89233f0d686ef0d447a66db705c0846a8dc6f34865cd85256c1472ff623665f616b90b8ff58058b2ad996c5de747d2d18 @@ -22907,6 +22848,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^3.2.1": + version: 3.2.1 + resolution: "node-addon-api@npm:3.2.1" + dependencies: + node-gyp: "npm:latest" + checksum: 10/681b52dfa3e15b0a8e5cf283cc0d8cd5fd2a57c559ae670fcfd20544cbb32f75de7648674110defcd17ab2c76ebef630aa7d2d2f930bc7a8cc439b20fe233518 + languageName: node + linkType: hard + "node-dir@npm:^0.1.10, node-dir@npm:^0.1.17": version: 0.1.17 resolution: "node-dir@npm:0.1.17" @@ -22958,23 +22908,34 @@ __metadata: languageName: node linkType: hard -"node-gyp@npm:^10.0.0": - version: 10.0.1 - resolution: "node-gyp@npm:10.0.1" +"node-gyp-build@npm:^4.3.0": + version: 4.5.0 + resolution: "node-gyp-build@npm:4.5.0" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 10/1f6c2b519cfbf13fc60589d40b65d9aa8c8bfaefe99763a9a982a6518a9292c83f41adf558628cfcb748e2a55418ac91718b68bb6be7e02cfac90c82e412de9b + languageName: node + linkType: hard + +"node-gyp@npm:^9.0.0": + version: 9.0.0 + resolution: "node-gyp@npm:9.0.0" dependencies: env-paths: "npm:^2.2.0" - exponential-backoff: "npm:^3.1.1" - glob: "npm:^10.3.10" + glob: "npm:^7.1.4" graceful-fs: "npm:^4.2.6" - make-fetch-happen: "npm:^13.0.0" - nopt: "npm:^7.0.0" - proc-log: "npm:^3.0.0" + make-fetch-happen: "npm:^10.0.3" + nopt: "npm:^5.0.0" + npmlog: "npm:^6.0.0" + rimraf: "npm:^3.0.2" semver: "npm:^7.3.5" tar: "npm:^6.1.2" - which: "npm:^4.0.0" + which: "npm:^2.0.2" bin: node-gyp: bin/node-gyp.js - checksum: 10/578cf0c821f258ce4b6ebce4461eca4c991a4df2dee163c0624f2fe09c7d6d37240be4942285a0048d307230248ee0b18382d6623b9a0136ce9533486deddfa8 + checksum: 10/7a9f184dda7bd53970ac52e138b091b417505bef5be0a7d9a902137a55246afaebbae1263a0545b6d7d94af131bcd49ac99f18db0b801c5b4c627dd291c08a7f languageName: node linkType: hard @@ -23044,17 +23005,6 @@ __metadata: languageName: node linkType: hard -"nopt@npm:^7.0.0": - version: 7.2.0 - resolution: "nopt@npm:7.2.0" - dependencies: - abbrev: "npm:^2.0.0" - bin: - nopt: bin/nopt.js - checksum: 10/1e7489f17cbda452c8acaf596a8defb4ae477d2a9953b76eb96f4ec3f62c6b421cd5174eaa742f88279871fde9586d8a1d38fb3f53fa0c405585453be31dff4c - languageName: node - linkType: hard - "normalize-package-data@npm:^2.3.2, normalize-package-data@npm:^2.5.0": version: 2.5.0 resolution: "normalize-package-data@npm:2.5.0" @@ -23091,18 +23041,6 @@ __metadata: languageName: node linkType: hard -"normalize-package-data@npm:^6.0.0": - version: 6.0.0 - resolution: "normalize-package-data@npm:6.0.0" - dependencies: - hosted-git-info: "npm:^7.0.0" - is-core-module: "npm:^2.8.1" - semver: "npm:^7.3.5" - validate-npm-package-license: "npm:^3.0.4" - checksum: 10/e31e31a2ebaef93ef107feb9408f105044eeae9cb7d0d4619544ab2323cd4b15ca648b0d558ac29db2fece161c7b8658206bb27ebe9340df723f7174b3e2759d - languageName: node - linkType: hard - "normalize-path@npm:3.0.0, normalize-path@npm:^3.0.0, normalize-path@npm:~3.0.0": version: 3.0.0 resolution: "normalize-path@npm:3.0.0" @@ -23190,18 +23128,6 @@ __metadata: languageName: node linkType: hard -"npm-package-arg@npm:^11.0.0": - version: 11.0.1 - resolution: "npm-package-arg@npm:11.0.1" - dependencies: - hosted-git-info: "npm:^7.0.0" - proc-log: "npm:^3.0.0" - semver: "npm:^7.3.5" - validate-npm-package-name: "npm:^5.0.0" - checksum: 10/a16e632703e106b3e9a6b4902d14a3493c8371745bcf8ba8f4ea9f152e12d5ed927487931e9adf817d05ba97b04941b33fec1d140dbd7da09181b546fde35b3c - languageName: node - linkType: hard - "npm-packlist@npm:5.1.1": version: 5.1.1 resolution: "npm-packlist@npm:5.1.1" @@ -23216,28 +23142,28 @@ __metadata: languageName: node linkType: hard -"npm-packlist@npm:^8.0.0": - version: 8.0.2 - resolution: "npm-packlist@npm:8.0.2" +"npm-packlist@npm:^7.0.0": + version: 7.0.4 + resolution: "npm-packlist@npm:7.0.4" dependencies: - ignore-walk: "npm:^6.0.4" - checksum: 10/707206e5c09a1b8aa04e590592715ba5ab8732add1bbb5eeaff54b9c6b2740764c9e94c99e390c13245970b51c2cc92b8d44594c2784fcd96f255c7109622322 + ignore-walk: "npm:^6.0.0" + checksum: 10/b24644eefa21d33c55a8f49c64eda4b06edfb7d25853be8ded7346e73c6c447be8a0482314b74f04f94e3f5712e467505dc030826ba55a71d1b948459fad6486 languageName: node linkType: hard -"npm-pick-manifest@npm:^9.0.0": - version: 9.0.0 - resolution: "npm-pick-manifest@npm:9.0.0" +"npm-pick-manifest@npm:^8.0.0": + version: 8.0.2 + resolution: "npm-pick-manifest@npm:8.0.2" dependencies: npm-install-checks: "npm:^6.0.0" npm-normalize-package-bin: "npm:^3.0.0" - npm-package-arg: "npm:^11.0.0" + npm-package-arg: "npm:^10.0.0" semver: "npm:^7.3.5" - checksum: 10/29dca2a838ed35c714df1a76f76616df2df51ce31bc3ca5943a0668b2eca2a5aab448f9f89cadf7a77eb5e3831c554cebaf7802f3e432838acb34c1a74fa2786 + checksum: 10/3f10a34e12cbb576edb694562a32730c6c0244b2929b91202d1be62ece76bc8b282dc7e9535d313d598963f8e3d06d19973611418a191fe3102be149a8fa0910 languageName: node linkType: hard -"npm-registry-fetch@npm:^14.0.3, npm-registry-fetch@npm:^14.0.5": +"npm-registry-fetch@npm:^14.0.0, npm-registry-fetch@npm:^14.0.3, npm-registry-fetch@npm:^14.0.5": version: 14.0.5 resolution: "npm-registry-fetch@npm:14.0.5" dependencies: @@ -23252,21 +23178,6 @@ __metadata: languageName: node linkType: hard -"npm-registry-fetch@npm:^16.0.0": - version: 16.1.0 - resolution: "npm-registry-fetch@npm:16.1.0" - dependencies: - make-fetch-happen: "npm:^13.0.0" - minipass: "npm:^7.0.2" - minipass-fetch: "npm:^3.0.0" - minipass-json-stream: "npm:^1.0.1" - minizlib: "npm:^2.1.2" - npm-package-arg: "npm:^11.0.0" - proc-log: "npm:^3.0.0" - checksum: 10/ba760c9cdacb1219ac5d8fecc26b1c55d502b55d45ab85ad556353b9bc5ba664c226fda54284c06df8c7eecfdcacb1aa065838ea7d1b0189d24c4d3f186309d2 - languageName: node - linkType: hard - "npm-run-path@npm:^4.0.0, npm-run-path@npm:^4.0.1": version: 4.0.1 resolution: "npm-run-path@npm:4.0.1" @@ -23300,7 +23211,7 @@ __metadata: languageName: node linkType: hard -"npmlog@npm:^6.0.2": +"npmlog@npm:^6.0.0, npmlog@npm:^6.0.2": version: 6.0.2 resolution: "npmlog@npm:6.0.2" dependencies: @@ -23335,25 +23246,26 @@ __metadata: languageName: node linkType: hard -"nx@npm:18.0.3, nx@npm:>=17.1.2 < 19": - version: 18.0.3 - resolution: "nx@npm:18.0.3" +"nx@npm:16.10.0, nx@npm:>=16.5.1 < 17": + version: 16.10.0 + resolution: "nx@npm:16.10.0" dependencies: - "@nrwl/tao": "npm:18.0.3" - "@nx/nx-darwin-arm64": "npm:18.0.3" - "@nx/nx-darwin-x64": "npm:18.0.3" - "@nx/nx-freebsd-x64": "npm:18.0.3" - "@nx/nx-linux-arm-gnueabihf": "npm:18.0.3" - "@nx/nx-linux-arm64-gnu": "npm:18.0.3" - "@nx/nx-linux-arm64-musl": "npm:18.0.3" - "@nx/nx-linux-x64-gnu": "npm:18.0.3" - "@nx/nx-linux-x64-musl": "npm:18.0.3" - "@nx/nx-win32-arm64-msvc": "npm:18.0.3" - "@nx/nx-win32-x64-msvc": "npm:18.0.3" + "@nrwl/tao": "npm:16.10.0" + "@nx/nx-darwin-arm64": "npm:16.10.0" + "@nx/nx-darwin-x64": "npm:16.10.0" + "@nx/nx-freebsd-x64": "npm:16.10.0" + "@nx/nx-linux-arm-gnueabihf": "npm:16.10.0" + "@nx/nx-linux-arm64-gnu": "npm:16.10.0" + "@nx/nx-linux-arm64-musl": "npm:16.10.0" + "@nx/nx-linux-x64-gnu": "npm:16.10.0" + "@nx/nx-linux-x64-musl": "npm:16.10.0" + "@nx/nx-win32-arm64-msvc": "npm:16.10.0" + "@nx/nx-win32-x64-msvc": "npm:16.10.0" + "@parcel/watcher": "npm:2.0.4" "@yarnpkg/lockfile": "npm:^1.1.0" "@yarnpkg/parsers": "npm:3.0.0-rc.46" "@zkochan/js-yaml": "npm:0.0.6" - axios: "npm:^1.6.0" + axios: "npm:^1.0.0" chalk: "npm:^4.1.0" cli-cursor: "npm:3.1.0" cli-spinners: "npm:2.6.1" @@ -23364,23 +23276,24 @@ __metadata: figures: "npm:3.2.0" flat: "npm:^5.0.2" fs-extra: "npm:^11.1.0" + glob: "npm:7.1.4" ignore: "npm:^5.0.4" jest-diff: "npm:^29.4.1" js-yaml: "npm:4.1.0" jsonc-parser: "npm:3.2.0" lines-and-columns: "npm:~2.0.3" - minimatch: "npm:9.0.3" + minimatch: "npm:3.0.5" node-machine-id: "npm:1.1.12" npm-run-path: "npm:^4.0.1" open: "npm:^8.4.0" - ora: "npm:5.3.0" - semver: "npm:^7.5.3" + semver: "npm:7.5.3" string-width: "npm:^4.2.3" strong-log-transformer: "npm:^2.1.0" tar-stream: "npm:~2.2.0" tmp: "npm:~0.2.1" tsconfig-paths: "npm:^4.1.2" tslib: "npm:^2.3.0" + v8-compile-cache: "npm:2.3.0" yargs: "npm:^17.6.2" yargs-parser: "npm:21.1.1" peerDependencies: @@ -23414,8 +23327,7 @@ __metadata: optional: true bin: nx: bin/nx.js - nx-cloud: bin/nx-cloud.js - checksum: 10/0ea7bbd0babf5897a4593a87d4107ad26340377a82dbd0c7e4fa414757234e90315f507a6874a3210e3d0e78fd0380956329b225399039ced3a5c000f07b1135 + checksum: 10/748a28491ac607e6d3ab1878dc17b0a78a74be1a272a2775336a8110660d6c39e3e993793391b5810d2e482156421a247cce47b9c3035e4f156129a4b595dd2e languageName: node linkType: hard @@ -23655,22 +23567,6 @@ __metadata: languageName: node linkType: hard -"ora@npm:5.3.0": - version: 5.3.0 - resolution: "ora@npm:5.3.0" - dependencies: - bl: "npm:^4.0.3" - chalk: "npm:^4.1.0" - cli-cursor: "npm:^3.1.0" - cli-spinners: "npm:^2.5.0" - is-interactive: "npm:^1.0.0" - log-symbols: "npm:^4.0.0" - strip-ansi: "npm:^6.0.0" - wcwidth: "npm:^1.0.1" - checksum: 10/989a075b596c297acfee647010e555709bd657dedd9eee9ff99d923cbc65c68b6189c2c9ea58167675b101433509f87d1674a84047c7b766babab15d9220f1d5 - languageName: node - linkType: hard - "ora@npm:^5.4.1": version: 5.4.1 resolution: "ora@npm:5.4.1" @@ -23888,31 +23784,31 @@ __metadata: languageName: node linkType: hard -"pacote@npm:^17.0.5": - version: 17.0.6 - resolution: "pacote@npm:17.0.6" +"pacote@npm:^15.2.0": + version: 15.2.0 + resolution: "pacote@npm:15.2.0" dependencies: - "@npmcli/git": "npm:^5.0.0" + "@npmcli/git": "npm:^4.0.0" "@npmcli/installed-package-contents": "npm:^2.0.1" - "@npmcli/promise-spawn": "npm:^7.0.0" - "@npmcli/run-script": "npm:^7.0.0" - cacache: "npm:^18.0.0" + "@npmcli/promise-spawn": "npm:^6.0.1" + "@npmcli/run-script": "npm:^6.0.0" + cacache: "npm:^17.0.0" fs-minipass: "npm:^3.0.0" - minipass: "npm:^7.0.2" - npm-package-arg: "npm:^11.0.0" - npm-packlist: "npm:^8.0.0" - npm-pick-manifest: "npm:^9.0.0" - npm-registry-fetch: "npm:^16.0.0" + minipass: "npm:^5.0.0" + npm-package-arg: "npm:^10.0.0" + npm-packlist: "npm:^7.0.0" + npm-pick-manifest: "npm:^8.0.0" + npm-registry-fetch: "npm:^14.0.0" proc-log: "npm:^3.0.0" promise-retry: "npm:^2.0.1" - read-package-json: "npm:^7.0.0" + read-package-json: "npm:^6.0.0" read-package-json-fast: "npm:^3.0.0" - sigstore: "npm:^2.2.0" + sigstore: "npm:^1.3.0" ssri: "npm:^10.0.0" tar: "npm:^6.1.11" bin: pacote: lib/bin.js - checksum: 10/fe96b362623128c67b4974bc2d0e8721515927c3546f04e9f3b0df0fe93ab74a8ed59c2896dec3ad1ed5395a8e439b3b64007b32d31b4b86796b50c75dffc924 + checksum: 10/57e18f4f963abb5f67f794158a55c01ad23f76e56dcdc74e6b843dfdda017515b0e8c0f56e60e842cd5af5ab9b351afdc49fc70633994f0e5fc0c6c9f4bcaebc languageName: node linkType: hard @@ -25203,7 +25099,7 @@ __metadata: languageName: node linkType: hard -"queue-tick@npm:^1.0.1": +"queue-tick@npm:^1.0.0, queue-tick@npm:^1.0.1": version: 1.0.1 resolution: "queue-tick@npm:1.0.1" checksum: 10/f447926c513b64a857906f017a3b350f7d11277e3c8d2a21a42b7998fa1a613d7a829091e12d142bb668905c8f68d8103416c7197856efb0c72fa835b8e254b5 @@ -26535,18 +26431,6 @@ __metadata: languageName: node linkType: hard -"read-package-json@npm:^7.0.0": - version: 7.0.0 - resolution: "read-package-json@npm:7.0.0" - dependencies: - glob: "npm:^10.2.2" - json-parse-even-better-errors: "npm:^3.0.0" - normalize-package-data: "npm:^6.0.0" - npm-normalize-package-bin: "npm:^3.0.0" - checksum: 10/b395d5330e9096cb533553e51c6dd123284a744e65c771fbd4d868ca600d2a61b867a4f10723e360608e839101fbe805448dd0079267b3232637ec8bb62bb080 - languageName: node - linkType: hard - "read-pkg-up@npm:^3.0.0": version: 3.0.0 resolution: "read-pkg-up@npm:3.0.0" @@ -27603,6 +27487,17 @@ __metadata: languageName: node linkType: hard +"semver@npm:7.5.3": + version: 7.5.3 + resolution: "semver@npm:7.5.3" + dependencies: + lru-cache: "npm:^6.0.0" + bin: + semver: bin/semver.js + checksum: 10/80b4b3784abff33bacf200727e012dc66768ed5835441e0a802ba9f3f5dd6b10ee366294711f5e7e13d73b82a6127ea55f11f9884d35e76a6a618dc11bc16ccf + languageName: node + linkType: hard + "semver@npm:7.6.0, semver@npm:7.x, semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0": version: 7.6.0 resolution: "semver@npm:7.6.0" @@ -27828,7 +27723,7 @@ __metadata: languageName: node linkType: hard -"sigstore@npm:^1.4.0": +"sigstore@npm:^1.3.0, sigstore@npm:^1.4.0": version: 1.9.0 resolution: "sigstore@npm:1.9.0" dependencies: @@ -27843,20 +27738,6 @@ __metadata: languageName: node linkType: hard -"sigstore@npm:^2.2.0": - version: 2.2.0 - resolution: "sigstore@npm:2.2.0" - dependencies: - "@sigstore/bundle": "npm:^2.1.1" - "@sigstore/core": "npm:^0.2.0" - "@sigstore/protobuf-specs": "npm:^0.2.1" - "@sigstore/sign": "npm:^2.2.1" - "@sigstore/tuf": "npm:^2.3.0" - "@sigstore/verify": "npm:^0.1.0" - checksum: 10/d8e1fda202d2572b3bfa3eded15c9b826429187f52a287549074645670778cbdb78111cb8e3d0274f051838ee500db382be6124c45068985d095df54a3a0bd74 - languageName: node - linkType: hard - "simple-git@npm:^3.6.0": version: 3.16.0 resolution: "simple-git@npm:3.16.0" @@ -28095,24 +27976,13 @@ __metadata: languageName: node linkType: hard -"socks-proxy-agent@npm:^8.0.1": - version: 8.0.2 - resolution: "socks-proxy-agent@npm:8.0.2" +"socks@npm:^2.6.1, socks@npm:^2.6.2": + version: 2.6.2 + resolution: "socks@npm:2.6.2" dependencies: - agent-base: "npm:^7.0.2" - debug: "npm:^4.3.4" - socks: "npm:^2.7.1" - checksum: 10/ea727734bd5b2567597aa0eda14149b3b9674bb44df5937bbb9815280c1586994de734d965e61f1dd45661183d7b41f115fb9e432d631287c9063864cfcc2ecc - languageName: node - linkType: hard - -"socks@npm:^2.6.1, socks@npm:^2.6.2, socks@npm:^2.7.1": - version: 2.7.1 - resolution: "socks@npm:2.7.1" - dependencies: - ip: "npm:^2.0.0" + ip: "npm:^1.1.5" smart-buffer: "npm:^4.2.0" - checksum: 10/5074f7d6a13b3155fa655191df1c7e7a48ce3234b8ccf99afa2ccb56591c195e75e8bb78486f8e9ea8168e95a29573cbaad55b2b5e195160ae4d2ea6811ba833 + checksum: 10/820232ddaeb847ef33312c429fb51aae03e1b774917f189ef491048bb4c4d7742924064f72d7730e3aa08a3ddb6cc2bdcd5949d34c35597e4f6a66eefd994f14 languageName: node linkType: hard @@ -28412,7 +28282,7 @@ __metadata: languageName: node linkType: hard -"ssri@npm:^9.0.1": +"ssri@npm:^9.0.0, ssri@npm:^9.0.1": version: 9.0.1 resolution: "ssri@npm:9.0.1" dependencies: @@ -28603,7 +28473,7 @@ __metadata: languageName: node linkType: hard -"streamx@npm:^2.12.0, streamx@npm:^2.12.5, streamx@npm:^2.13.2, streamx@npm:^2.14.0": +"streamx@npm:^2.12.0, streamx@npm:^2.13.2, streamx@npm:^2.14.0": version: 2.15.7 resolution: "streamx@npm:2.15.7" dependencies: @@ -28613,6 +28483,16 @@ __metadata: languageName: node linkType: hard +"streamx@npm:^2.12.5": + version: 2.12.5 + resolution: "streamx@npm:2.12.5" + dependencies: + fast-fifo: "npm:^1.0.0" + queue-tick: "npm:^1.0.0" + checksum: 10/daa5789ca31101684d9266f7ea77294908bd3e55607805ac1657f0cef1ee0a1966bc3988d2ec12c5f68a718d481147fa3ace2525486a1e39ca7155c598917cd1 + languageName: node + linkType: hard + "strict-event-emitter@npm:^0.2.4": version: 0.2.8 resolution: "strict-event-emitter@npm:0.2.8" @@ -29828,17 +29708,6 @@ __metadata: languageName: node linkType: hard -"tuf-js@npm:^2.2.0": - version: 2.2.0 - resolution: "tuf-js@npm:2.2.0" - dependencies: - "@tufjs/models": "npm:2.0.0" - debug: "npm:^4.3.4" - make-fetch-happen: "npm:^13.0.0" - checksum: 10/a513ce533c06390b7d8767fe68250adac2535bc63c460e9ab8cbae8253da5ccd6fd204448a460536a6e77f7cf5fcf5a3b104971610f9f319a9b8f95b3b574b95 - languageName: node - linkType: hard - "tunnel-agent@npm:^0.6.0": version: 0.6.0 resolution: "tunnel-agent@npm:0.6.0" @@ -30528,6 +30397,13 @@ __metadata: languageName: node linkType: hard +"v8-compile-cache@npm:2.3.0": + version: 2.3.0 + resolution: "v8-compile-cache@npm:2.3.0" + checksum: 10/7de7423db6f48d76cffae93d70d503e160c97fc85e55945036d719111e20b33c4be5c21aa8b123a3da203bbb3bc4c8180f9667d5ccafcff11d749fae204ec7be + languageName: node + linkType: hard + "v8-to-istanbul@npm:^9.0.0, v8-to-istanbul@npm:^9.0.1": version: 9.1.0 resolution: "v8-to-istanbul@npm:9.1.0" @@ -31276,14 +31152,14 @@ __metadata: languageName: node linkType: hard -"which@npm:^4.0.0": - version: 4.0.0 - resolution: "which@npm:4.0.0" +"which@npm:^3.0.0": + version: 3.0.1 + resolution: "which@npm:3.0.1" dependencies: - isexe: "npm:^3.1.1" + isexe: "npm:^2.0.0" bin: node-which: bin/which.js - checksum: 10/f17e84c042592c21e23c8195108cff18c64050b9efb8459589116999ea9da6dd1509e6a1bac3aeebefd137be00fabbb61b5c2bc0aa0f8526f32b58ee2f545651 + checksum: 10/adf720fe9d84be2d9190458194f814b5e9015ae4b88711b150f30d0f4d0b646544794b86f02c7ebeec1db2029bc3e83a7ff156f542d7521447e5496543e26890 languageName: node linkType: hard @@ -31600,7 +31476,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.3.1, yargs@npm:^17.5.1, yargs@npm:^17.6.2": +"yargs@npm:^17.3.1, yargs@npm:^17.5.1, yargs@npm:^17.6.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: