Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
commit
16ea50c647
107 files changed
+1852
-5474
No files matched your search
@@ -131,14 +131,12 @@ else
|
||||
i18n-extract-enterprise:
|
||||
@echo "Extracting i18n strings for Enterprise"
|
||||
yarn run i18next --config public/locales/i18next-parser-enterprise.config.cjs
|
||||
node ./public/locales/pseudo.mjs --mode enterprise
|
||||
endif
|
||||
|
||||
.PHONY: i18n-extract
|
||||
i18n-extract: i18n-extract-enterprise
|
||||
@echo "Extracting i18n strings for OSS"
|
||||
yarn run i18next --config public/locales/i18next-parser.config.cjs
|
||||
node ./public/locales/pseudo.mjs --mode oss
|
||||
|
||||
##@ Building
|
||||
.PHONY: gen-cue
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checks"
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checkscheduler"
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checktyperegisterer"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
@@ -24,6 +25,12 @@ func New(cfg app.Config) (app.App, error) {
|
||||
return nil, fmt.Errorf("invalid config type")
|
||||
}
|
||||
checkRegistry := specificConfig.CheckRegistry
|
||||
stackID := specificConfig.StackID
|
||||
namespace, err := checks.GetNamespace(stackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log := log.New("advisor.app")
|
||||
|
||||
// Prepare storage client
|
||||
clientGenerator := k8s.NewClientRegistry(cfg.KubeConfig, k8s.ClientConfig{})
|
||||
@@ -60,6 +67,13 @@ func New(cfg app.Config) (app.App, error) {
|
||||
},
|
||||
Watcher: &simple.Watcher{
|
||||
AddFunc: func(ctx context.Context, obj resource.Object) error {
|
||||
log.Debug("Adding check", "namespace", obj.GetNamespace())
|
||||
if obj.GetNamespace() != namespace {
|
||||
log.Debug("Skipping check in namespace", "namespace", obj.GetNamespace())
|
||||
return nil
|
||||
} else {
|
||||
log.Debug("Processing check in namespace", "namespace", obj.GetNamespace())
|
||||
}
|
||||
check, err := getCheck(obj, checkMap)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ To learn how to use the roles API to determine the role UUIDs, refer to [Manage
|
||||
| `fixed:authentication.config:writer` | `fixed_0rYhZ2Qnzs8AdB1nX7gexk3fHDw` | `settings:read` for scope `settings:auth.saml:*` <br> `settings:write` for scope `settings:auth.saml:*` | Read and update authentication and SAML settings. |
|
||||
| `fixed:dashboards:creator` | `fixed_ZorKUcEPCM01A1fPakEzGBUyU64` | `dashboards:create`<br>`folders:read` | Create dashboards. |
|
||||
| `fixed:dashboards:reader` | `fixed_Sgr67JTOhjQGFlzYRahOe45TdWM` | `dashboards:read` | Read all dashboards. |
|
||||
| `fixed:dashboards:writer` | `fixed_OK2YOQGIoI1G031hVzJB6rAJQAs` | All permissions from `fixed:dashboards:reader` and <br>`dashboards:write`<br>`dashboards:edit`<br>`dashboards:delete`<br>`dashboards:create`<br>`dashboards.permissions:read`<br>`dashboards.permissions:write` | Read, create, update, and delete all dashboards. |
|
||||
| `fixed:dashboards:writer` | `fixed_OK2YOQGIoI1G031hVzJB6rAJQAs` | All permissions from `fixed:dashboards:reader` and <br>`dashboards:write`<br>`dashboards:delete`<br>`dashboards:create`<br>`dashboards.permissions:read`<br>`dashboards.permissions:write` | Read, create, update, and delete all dashboards. |
|
||||
| `fixed:dashboards.insights:reader` | `fixed_JlBJ2_gizP8zhgaeGE2rjyZe2Rs` | `dashboards.insights:read` | Read dashboard insights data and see presence indicators. |
|
||||
| `fixed:dashboards.permissions:reader` | `fixed_f17oxuXW_58LL8mYJsm4T_mCeIw` | `dashboards.permissions:read` | Read all dashboard permissions. |
|
||||
| `fixed:dashboards.permissions:writer` | `fixed_CcznxhWX_Yqn8uWMXMQ-b5iFW9k` | All permissions from `fixed:dashboards.permissions:reader` and <br>`dashboards.permissions:write` | Read and update all dashboard permissions. |
|
||||
|
||||
+67
-53
@@ -22,23 +22,23 @@ weight: 500
|
||||
# Configure SAML authentication using the configuration file
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
Available in [Grafana Enterprise](../../../../introduction/grafana-enterprise/) and [Grafana Cloud](/docs/grafana-cloud).
|
||||
Available in [Grafana Enterprise](/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and [Grafana Cloud](/docs/grafana-cloud).
|
||||
{{% /admonition %}}
|
||||
|
||||
SAML authentication integration allows your Grafana users to log in by using an external SAML 2.0 Identity Provider (IdP). To enable this, Grafana becomes a Service Provider (SP) in the authentication flow, interacting with the IdP to exchange user information.
|
||||
|
||||
You can configure SAML authentication in Grafana through one of the following methods:
|
||||
|
||||
- the Grafana configuration file
|
||||
- the API (refer to [SSO Settings API](../../../../developers/http_api/sso-settings/))
|
||||
- the user interface (refer to [Configure SAML authentication using the Grafana user interface](../saml-ui/))
|
||||
- the Terraform provider (refer to [Terraform docs](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/sso_settings))
|
||||
- The Grafana configuration file
|
||||
- The API (refer to [SSO Settings API](/docs/grafana/<GRAFANA_VERSION>/developers/http_api/sso-settings/)
|
||||
- The user interface (refer to [Configure SAML authentication using the Grafana user interface](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/saml-ui/)
|
||||
- The Terraform provider (refer to [Terraform docs](https://registry.terraform.io/providers/grafana/grafana/<GRAFANA_VERSION>/docs/resources/sso_settings))
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
The API and Terraform support are available in Public Preview in Grafana v11.1 behind the `ssoSettingsSAML` feature toggle. You must also enable the `ssoSettingsApi` flag.
|
||||
{{% /admonition %}}
|
||||
|
||||
All methods offer the same configuration options, but you might prefer using the Grafana configuration file or the Terraform provider if you want to keep all of Grafana's authentication settings in one place. Grafana Cloud users do not have access to Grafana configuration file, so they should configure SAML through the other methods.
|
||||
All methods offer the same configuration options. However, if you want to keep all of Grafana authentication settings in one place, use the Grafana configuration file or the Terraform provider. If you are a Grafana Cloud user, you do not have access to Grafana configuration file. Instead, configure SAML through the other methods.
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
Configuration in the API takes precedence over the configuration in the Grafana configuration file. SAML settings from the API will override any SAML configuration set in the Grafana configuration file.
|
||||
@@ -46,6 +46,10 @@ Configuration in the API takes precedence over the configuration in the Grafana
|
||||
|
||||
## Supported SAML
|
||||
|
||||
The following indicate what Grafana supports.
|
||||
|
||||
### Bindings
|
||||
|
||||
Grafana supports the following SAML 2.0 bindings:
|
||||
|
||||
- From the Service Provider (SP) to the Identity Provider (IdP):
|
||||
@@ -56,12 +60,13 @@ Grafana supports the following SAML 2.0 bindings:
|
||||
- From the Identity Provider (IdP) to the Service Provider (SP):
|
||||
- `HTTP-POST` binding
|
||||
|
||||
In terms of security:
|
||||
### Security
|
||||
|
||||
- Grafana supports signed and encrypted assertions.
|
||||
- Grafana does not support signed or encrypted requests.
|
||||
Grafana supports signed and encrypted assertions, and does _not_ support encrypted requests.
|
||||
|
||||
In terms of initiation, Grafana supports:
|
||||
### Initiation
|
||||
|
||||
Grafana supports:
|
||||
|
||||
- SP-initiated requests
|
||||
- IdP-initiated requests
|
||||
@@ -71,7 +76,7 @@ By default, SP-initiated requests are enabled. For instructions on how to enable
|
||||
{{% admonition type="note" %}}
|
||||
It is possible to set up Grafana with SAML authentication using Azure AD. However, if an Azure AD user belongs to more than 150 groups, a Graph API endpoint is shared instead.
|
||||
|
||||
Grafana versions 11.1 and below, do not support fetching the groups from the Graph API endpoint. As a result, users with more than 150 groups will not be able to retrieve their groups. Instead, it is recommended that you use OIDC/OAuth workflows,.
|
||||
Grafana versions 11.1 and below, do not support fetching the groups from the Graph API endpoint. As a result, users with more than 150 groups will not be able to retrieve their groups. Instead, it is recommended that you use OIDC/OAuth workflows.
|
||||
|
||||
As of Grafana 11.2, the SAML integration offers a mechanism to retrieve user groups from the Graph API.
|
||||
|
||||
@@ -84,16 +89,16 @@ Related links:
|
||||
|
||||
### Edit SAML options in the Grafana config file
|
||||
|
||||
1. In the `[auth.saml]` section in the Grafana configuration file, set [`enabled`](../../../configure-grafana/enterprise-configuration/#enabled) to `true`.
|
||||
1. Configure the [certificate and private key](#certificate-and-private-key).
|
||||
1. In the `[auth.saml]` section in the Grafana configuration file, set [`enabled`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#enabled-3) to `true`.
|
||||
1. Optionally, configure the [certificate and private key](#certificate-and-private-key").
|
||||
1. On the Okta application page where you have been redirected after application created, navigate to the **Sign On** tab and find **Identity Provider metadata** link in the **Settings** section.
|
||||
1. Set the [`idp_metadata_url`](../../../configure-grafana/enterprise-configuration/#idp_metadata_url) to the URL obtained from the previous step. The URL should look like `https://<your-org-id>.okta.com/app/<application-id>/sso/saml/metadata`.
|
||||
1. Set the [`idp_metadata_url`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#idp_metadata_url) to the URL obtained from the previous step. The URL should look like `https://<your-org-id>.okta.com/app/<application-id>/sso/saml/metadata`.
|
||||
1. Set the following options to the attribute names configured at the **step 10** of the SAML integration setup. You can find this attributes on the **General** tab of the application page (**ATTRIBUTE STATEMENTS** and **GROUP ATTRIBUTE STATEMENTS** in the **SAML Settings** section).
|
||||
- [`assertion_attribute_login`](../../../configure-grafana/enterprise-configuration/#assertion_attribute_login)
|
||||
- [`assertion_attribute_email`](../../../configure-grafana/enterprise-configuration/#assertion_attribute_email)
|
||||
- [`assertion_attribute_name`](../../../configure-grafana/enterprise-configuration/#assertion_attribute_name)
|
||||
- [`assertion_attribute_groups`](../../../configure-grafana/enterprise-configuration/#assertion_attribute_groups)
|
||||
1. (Optional) Set the `name` parameter in the `[auth.saml]` section in the Grafana configuration file. This parameter replaces SAML in the Grafana user interface in locations such as the sign-in button.
|
||||
- [`assertion_attribute_login`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#assertion_attribute_login)
|
||||
- [`assertion_attribute_email`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#assertion_attribute_email)
|
||||
- [`assertion_attribute_name`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#assertion_attribute_name)
|
||||
- [`assertion_attribute_groups`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#assertion_attribute_groups)
|
||||
1. Optionally, set the `name` parameter in the `[auth.saml]` section in the Grafana configuration file. This parameter replaces SAML in the Grafana user interface in locations such as the sign-in button.
|
||||
1. Save the configuration file and then restart the Grafana server.
|
||||
|
||||
When you are finished, the Grafana configuration might look like this example:
|
||||
@@ -119,7 +124,7 @@ assertion_attribute_groups = Group
|
||||
|
||||
To use the SAML integration, in the `auth.saml` section of in the Grafana custom configuration file, set `enabled` to `true`.
|
||||
|
||||
Refer to [Configuration](../../../configure-grafana/) for more information about configuring Grafana.
|
||||
Refer to [Configuration](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/) for more information about configuring Grafana.
|
||||
|
||||
## Additional configuration for HTTP-Post binding
|
||||
|
||||
@@ -133,9 +138,11 @@ For Grafana Cloud instances, please contact Grafana Support to update the `conte
|
||||
|
||||
## Certificate and private key
|
||||
|
||||
Commonly, the certificate and key are embedded in the [IDP metadata](#configure-the-saml-toolkit-application-endpoints) and refreshed as needed by Grafana automatically. However, if your IdP expects signed requests, you must supply a certificate and private key.
|
||||
|
||||
The SAML SSO standard uses asymmetric encryption to exchange information between the SP (Grafana) and the IdP. To perform such encryption, you need a public part and a private part. In this case, the X.509 certificate provides the public part, while the private key provides the private part. The private key needs to be issued in a [PKCS#8](https://en.wikipedia.org/wiki/PKCS_8) format.
|
||||
|
||||
Grafana supports two ways of specifying both the `certificate` and `private_key`.
|
||||
If you are directly supplying the certificate and key, Grafana supports two ways of specifying both the `certificate` and `private_key`:
|
||||
|
||||
- Without a suffix (`certificate` or `private_key`), the configuration assumes you've supplied the base64-encoded file contents.
|
||||
- With the `_path` suffix (`certificate_path` or `private_key_path`), then Grafana treats the value entered as a file path and attempts to read the file from the file system.
|
||||
@@ -144,9 +151,9 @@ Grafana supports two ways of specifying both the `certificate` and `private_key`
|
||||
You can only use one form of each configuration option. Using multiple forms, such as both `certificate` and `certificate_path`, results in an error.
|
||||
{{% /admonition %}}
|
||||
|
||||
---
|
||||
Always work with your company's security team on setting up certificates and private keys. If you need to generate them yourself (such as in the short term, for testing purposes, and so on), use the following example to generate your certificate and private key, including the step of ensuring that the key is generated with the [PKCS#8](https://en.wikipedia.org/wiki/PKCS_8) format.
|
||||
|
||||
### Generate private key for SAML authentication:
|
||||
### Example of private key generation for SAML authentication
|
||||
|
||||
An example of how to generate a self-signed certificate and private key that's valid for one year:
|
||||
|
||||
@@ -154,7 +161,15 @@ An example of how to generate a self-signed certificate and private key that's v
|
||||
$ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
|
||||
```
|
||||
|
||||
The generated `key.pem` and `cert.pem` files are then used for certificate and private_key.
|
||||
Base64-encode the cert.pem and key.pem files:
|
||||
(-w0 switch is not needed on Mac, only for Linux)
|
||||
|
||||
```sh
|
||||
$ base64 -i key.pem -o key.pem.base64
|
||||
$ base64 -i cert.pem -o cert.pem.base64
|
||||
```
|
||||
|
||||
The base64-encoded values (`key.pem.base64, cert.pem.base64` files) are then used for certificate and private key.
|
||||
|
||||
The key you provide should look like:
|
||||
|
||||
@@ -169,16 +184,15 @@ The key you provide should look like:
|
||||
|
||||
Grafana supports user authentication through Azure AD, which is useful when you want users to access Grafana using single sign-on. This topic shows you how to configure SAML authentication in Grafana with [Azure AD](https://azure.microsoft.com/en-us/services/active-directory/).
|
||||
|
||||
**Before you begin:**
|
||||
**Before you begin**
|
||||
|
||||
- Ensure you have permission to administer SAML authentication. For more information about roles and permissions in Grafana.
|
||||
- [Roles and permissions](../../../../administration/roles-and-permissions/).
|
||||
- Learn the limitations of Azure AD SAML integration.
|
||||
- [Azure AD SAML limitations](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference#groups-overage-claim)
|
||||
- Configure SAML integration with Azure AD, create an app integration inside the Azure AD organization first.
|
||||
- [Add app integration in Azure AD](https://docs.microsoft.com/en-us/azure/active-directory/manage-apps/add-application-portal-configure)
|
||||
- If you have users that belong to more than 150 groups, you need to configure a registered application to provide an Azure Graph API to retrieve the groups.
|
||||
- [Setup Azure AD Graph API applications](#set-up-saml-with-azure-ad)
|
||||
Ensure you have permission to administer SAML authentication. For more information about roles and permissions in Grafana, refer to [Roles and permissions](/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/).
|
||||
|
||||
Learn the [limitations of Azure AD SAML] (https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference#groups-overage-claim) integration.
|
||||
|
||||
Configure SAML integration with Azure AD, [creating an Enterprise Application](#add-microsoft-entra-saml-toolkit-from-the-gallery) inside the Azure AD organization first and then [enable single sign-on](#configure-the-saml-toolkit-application-endpoints).
|
||||
|
||||
If you have users that belong to more than 150 groups, configure a registered application to provide an Azure Graph API to retrieve the groups. Refer to [Setup Azure AD Graph API applications](#configure-a-graph-api-application-in-azure-ad).
|
||||
|
||||
### Generate self-signed certificates
|
||||
|
||||
@@ -277,7 +291,7 @@ Grafana supports user authentication through Okta, which is useful when you want
|
||||
**Before you begin:**
|
||||
|
||||
- To configure SAML integration with Okta, create an app integration inside the Okta organization first. [Add app integration in Okta](https://help.okta.com/en/prod/Content/Topics/Apps/apps-overview-add-apps.htm)
|
||||
- Ensure you have permission to administer SAML authentication. For more information about roles and permissions in Grafana, refer to [Roles and permissions](../../../../administration/roles-and-permissions/).
|
||||
- Ensure you have permission to administer SAML authentication. For more information about roles and permissions in Grafana, refer to [Roles and permissions](/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/).
|
||||
|
||||
**To set up SAML with Okta:**
|
||||
|
||||
@@ -391,7 +405,7 @@ Additionally, Grafana did not support IdP sessions and could not include the `Se
|
||||
Starting from Grafana version 11.5, Grafana uses the `NameID` from the SAML assertion to create the logout request. If the `NameID` is not present in the assertion, Grafana defaults to using the user's `Login` attribute. Additionally, Grafana supports including the `SessionIndex` in the logout request if it is provided in the SAML assertion by the IdP.
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
These improvements are available in public preview behind the `improvedExternalSessionHandling` feature toggle, starting from Grafana v11.5. To enable it, refer to the [Configure feature toggles](../../../configure-grafana/feature-toggles/)
|
||||
These improvements are available in public preview behind the `improvedExternalSessionHandlingSAML` feature toggle, starting from Grafana v11.5. To enable it, refer to the [Configure feature toggles](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/)
|
||||
{{% /admonition %}}
|
||||
|
||||
### Assertion mapping
|
||||
@@ -435,11 +449,11 @@ auto_login = true
|
||||
|
||||
Group synchronization allows you to map user groups from an identity provider to Grafana teams and roles.
|
||||
|
||||
To use SAML group synchronization, set [`assertion_attribute_groups`](../../../configure-grafana/enterprise-configuration/#assertion_attribute_groups) to the attribute name where you store user groups.
|
||||
To use SAML group synchronization, set [`assertion_attribute_groups`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#assertion_attribute_groups) to the attribute name where you store user groups.
|
||||
Then Grafana will use attribute values extracted from SAML assertion to add user to Grafana teams and grant them roles.
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
Team sync allows you sync users from SAML to Grafana teams. It does not automatically create teams in Grafana. You need to create teams in Grafana before you can use this feature.
|
||||
Team sync allows you sync users from SAML to Grafana teams, but you must create teams in Grafana before you can use this feature. It does not automatically create teams in Grafana.
|
||||
{{% /admonition %}}
|
||||
|
||||
Given the following partial SAML assertion:
|
||||
@@ -474,22 +488,22 @@ The following `External Group ID`s would be valid for configuring team sync or r
|
||||
- `admins_group`
|
||||
- `division_1`
|
||||
|
||||
To learn more about how to configure group synchronization, refer to [Configure team sync](../../configure-team-sync/) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync) documentation.
|
||||
To learn more about how to configure group synchronization, refer to [Configure team sync](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync/) and [Configure group attribute sync](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync) documentation.
|
||||
|
||||
### Configure role sync
|
||||
|
||||
Role sync allows you to map user roles from an identity provider to Grafana. To enable role sync, configure role attribute and possible values for the Editor, Admin, and Grafana Admin roles. For more information about user roles, refer to [Roles and permissions](../../../../administration/roles-and-permissions/).
|
||||
Role sync allows you to map user roles from an identity provider to Grafana. To enable role sync, configure role attribute and possible values for the Editor, Admin, and Grafana Admin roles. For more information about user roles, refer to [Roles and permissions](/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/).
|
||||
|
||||
1. In the configuration file, set [`assertion_attribute_role`](../../../configure-grafana/enterprise-configuration/#assertion_attribute_role) option to the attribute name where the role information will be extracted from.
|
||||
1. Set the [`role_values_none`](../../../configure-grafana/enterprise-configuration/#role_values_none) option to the values mapped to the `None` role.
|
||||
1. Set the [`role_values_viewer`](../../../configure-grafana/enterprise-configuration/#role_values_viewer) option to the values mapped to the `Viewer` role.
|
||||
1. Set the [`role_values_editor`](../../../configure-grafana/enterprise-configuration/#role_values_editor) option to the values mapped to the `Editor` role.
|
||||
1. Set the [`role_values_admin`](../../../configure-grafana/enterprise-configuration/#role_values_admin) option to the values mapped to the organization `Admin` role.
|
||||
1. Set the [`role_values_grafana_admin`](../../../configure-grafana/enterprise-configuration/#role_values_grafana_admin) option to the values mapped to the `Grafana Admin` role.
|
||||
1. In the configuration file, set [`assertion_attribute_role`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#assertion_attribute_role) option to the attribute name where the role information will be extracted from.
|
||||
1. Set the [`role_values_none`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#role_values_none) option to the values mapped to the `None` role.
|
||||
1. Set the [`role_values_viewer`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#role_values_viewer) option to the values mapped to the `Viewer` role.
|
||||
1. Set the [`role_values_editor`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#role_values_editor) option to the values mapped to the `Editor` role.
|
||||
1. Set the [`role_values_admin`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#role_values_admin) option to the values mapped to the organization `Admin` role.
|
||||
1. Set the [`role_values_grafana_admin`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#role_values_grafana_admin) option to the values mapped to the `Grafana Admin` role.
|
||||
|
||||
If a user role doesn't match any of configured values, then the role specified by the `auto_assign_org_role` config option will be assigned. If the `auto_assign_org_role` field is not set then the user role will default to `Viewer`.
|
||||
|
||||
For more information about roles and permissions in Grafana, refer to [Roles and permissions](../../../../administration/roles-and-permissions/).
|
||||
For more information about roles and permissions in Grafana, refer to [Roles and permissions](/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/).
|
||||
|
||||
Example configuration:
|
||||
|
||||
@@ -518,8 +532,8 @@ skip_org_role_sync = true
|
||||
|
||||
Organization mapping allows you to assign users to particular organization in Grafana depending on attribute value obtained from identity provider.
|
||||
|
||||
1. In configuration file, set [`assertion_attribute_org`](../../../configure-grafana/enterprise-configuration/#assertion_attribute_org) to the attribute name you store organization info in. This attribute can be an array if you want a user to be in multiple organizations.
|
||||
1. Set [`org_mapping`](../../../configure-grafana/enterprise-configuration/#org_mapping) option to the comma-separated list of `Organization:OrgId` pairs to map organization from IdP to Grafana organization specified by ID. If you want users to have different roles in multiple organizations, you can set this option to a comma-separated list of `Organization:OrgId:Role` mappings.
|
||||
1. In configuration file, set [`assertion_attribute_org`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#assertion_attribute_org) to the attribute name you store organization info in. This attribute can be an array if you want a user to be in multiple organizations.
|
||||
1. Set [`org_mapping`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#org_mapping) option to the comma-separated list of `Organization:OrgId` pairs to map organization from IdP to Grafana organization specified by ID. If you want users to have different roles in multiple organizations, you can set this option to a comma-separated list of `Organization:OrgId:Role` mappings.
|
||||
|
||||
For example, use following configuration to assign users from `Engineering` organization to the Grafana organization with ID `2` as Editor and users from `Sales` - to the org with ID `3` as Admin, based on `Org` assertion attribute value:
|
||||
|
||||
@@ -566,7 +580,7 @@ You can use `*` as the Grafana organization in the mapping if you want all users
|
||||
|
||||
### Configure allowed organizations
|
||||
|
||||
With the [`allowed_organizations`](../../../configure-grafana/enterprise-configuration/#allowed_organizations) option you can specify a list of organizations where the user must be a member of at least one of them to be able to log in to Grafana.
|
||||
With the [`allowed_organizations`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/enterprise-configuration/#allowed_organizations) option you can specify a list of organizations where the user must be a member of at least one of them to be able to log in to Grafana.
|
||||
|
||||
To put values containing spaces in the list, use the following JSON syntax:
|
||||
|
||||
@@ -632,11 +646,11 @@ resource "grafana_sso_settings" "saml_sso_settings" {
|
||||
}
|
||||
```
|
||||
|
||||
Go to [Terraform Registry](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/sso_settings) for a complete reference on using the `grafana_sso_settings` resource.
|
||||
Go to [Terraform Registry](https://registry.terraform.io/providers/grafana/grafana/<GRAFANA_VERSION>/docs/resources/sso_settings) for a complete reference on using the `grafana_sso_settings` resource.
|
||||
|
||||
## Troubleshoot SAML authentication in Grafana
|
||||
|
||||
To troubleshoot and get more log information, enable SAML debug logging in the configuration file. Refer to [Configuration](../../../configure-grafana/#filters) for more information.
|
||||
To troubleshoot and get more log information, enable SAML debug logging in the configuration file. Refer to [Configuration](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#filters) for more information.
|
||||
|
||||
```ini
|
||||
[log]
|
||||
@@ -692,7 +706,7 @@ The keys you provide should look like:
|
||||
|
||||
When the user logs in using SAML and gets presented with "origin not allowed", the user might be issuing the login from an IdP (identity provider) service or the user is behind a reverse proxy. This potentially happens as Grafana's CSRF checks deem the requests to be invalid. For more information [CSRF](https://owasp.org/www-community/attacks/csrf).
|
||||
|
||||
To solve this issue, you can configure either the [`csrf_trusted_origins`](../../../configure-grafana/#csrf_trusted_origins) or [`csrf_additional_headers`](../../../configure-grafana/#csrf_additional_headers) option in the SAML configuration.
|
||||
To solve this issue, you can configure either the [`csrf_trusted_origins`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#csrf_trusted_origins) or [`csrf_additional_headers`](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#csrf_additional_headers) option in the SAML configuration.
|
||||
|
||||
Example of a configuration file:
|
||||
|
||||
@@ -735,7 +749,7 @@ Ensure cookie_secure is set to true to ensure that cookies are only sent over HT
|
||||
|
||||
## Configure SAML authentication in Grafana
|
||||
|
||||
The table below describes all SAML configuration options. Continue reading below for details on specific options. Like any other Grafana configuration, you can apply these options as [environment variables](../../../configure-grafana/#override-configuration-with-environment-variables).
|
||||
The table below describes all SAML configuration options. Continue reading below for details on specific options. Like any other Grafana configuration, you can apply these options as [environment variables](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#override-configuration-with-environment-variables).
|
||||
|
||||
| Setting | Required | Description | Default |
|
||||
| ---------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
|
||||
|
||||
@@ -76,7 +76,7 @@ require (
|
||||
github.com/googleapis/go-sql-spanner v1.11.1 // @grafana/grafana-search-and-storage
|
||||
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
|
||||
github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356 // @grafana/alerting-backend
|
||||
github.com/grafana/alerting v0.0.0-20250310104713-16b885f1c79e // @grafana/alerting-backend
|
||||
github.com/grafana/authlib v0.0.0-20250305132846-37f49eb947fa // @grafana/identity-access-team
|
||||
github.com/grafana/authlib/types v0.0.0-20250224151205-5ef97131cc82 // @grafana/identity-access-team
|
||||
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
|
||||
|
||||
@@ -1559,8 +1559,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356 h1:71o8Bxw/wg+aBRUASiGux67gDEUC2bqq3I+x2hw8eIc=
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/alerting v0.0.0-20250310104713-16b885f1c79e h1:noJzp/qZGIto4XdZkvj2EKQ1bQeqCRs0bedxdJN17sQ=
|
||||
github.com/grafana/alerting v0.0.0-20250310104713-16b885f1c79e/go.mod h1:HfvjmU3UqCIpoy9Z2wgKGrZ4A5vz+yQlP9ZXvCfEkiA=
|
||||
github.com/grafana/authlib v0.0.0-20250305132846-37f49eb947fa h1:08Wh/svkv8WpDuOBBKAzSPa14gKjYLZvQJsHWXLjPuc=
|
||||
github.com/grafana/authlib v0.0.0-20250305132846-37f49eb947fa/go.mod h1:XVpdLhaeYqz414FmGnW00/0vTe1x8c0GRH3KaeRtyg0=
|
||||
github.com/grafana/authlib/types v0.0.0-20250224151205-5ef97131cc82 h1:DnRUYiAotHXnrfYJCvhH1NkiyWVcPm5Pd+P7Ugqt/d8=
|
||||
|
||||
+1
-1
@@ -223,7 +223,6 @@
|
||||
"postcss-reporter": "7.1.0",
|
||||
"postcss-scss": "4.0.9",
|
||||
"prettier": "3.4.2",
|
||||
"pseudoizer": "^0.1.0",
|
||||
"react-refresh": "0.14.0",
|
||||
"react-select-event": "5.5.1",
|
||||
"redux-mock-store": "1.5.5",
|
||||
@@ -336,6 +335,7 @@
|
||||
"history": "4.10.1",
|
||||
"i18next": "^24.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-pseudo": "^2.2.1",
|
||||
"immer": "10.1.1",
|
||||
"immutable": "5.0.3",
|
||||
"ix": "^7.0.0",
|
||||
|
||||
@@ -5,10 +5,11 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { StringSelector, selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { useStyles2 } from '../../../themes';
|
||||
import { getFocusStyles, getMouseFocusStyles } from '../../../themes/mixins';
|
||||
import { getFocusStyles, getInternalRadius, getMouseFocusStyles } from '../../../themes/mixins';
|
||||
import { Tooltip } from '../../Tooltip/Tooltip';
|
||||
import { getPropertiesForButtonSize } from '../commonStyles';
|
||||
|
||||
export const RADIO_GROUP_PADDING = 2;
|
||||
export type RadioButtonSize = 'sm' | 'md';
|
||||
|
||||
export interface RadioButtonProps {
|
||||
@@ -130,7 +131,7 @@ const getRadioButtonStyles = (theme: GrafanaTheme2, size: RadioButtonSize, fullW
|
||||
lineHeight: `${labelHeight}px`,
|
||||
color: textColor,
|
||||
padding: theme.spacing(0, padding),
|
||||
borderRadius: theme.shape.radius.default,
|
||||
borderRadius: getInternalRadius(theme, RADIO_GROUP_PADDING),
|
||||
background: theme.colors.background.primary,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
|
||||
@@ -7,8 +7,7 @@ import { GrafanaTheme2, SelectableValue, toIconName } from '@grafana/data';
|
||||
import { useStyles2 } from '../../../themes';
|
||||
import { Icon } from '../../Icon/Icon';
|
||||
|
||||
import { RadioButtonSize, RadioButton } from './RadioButton';
|
||||
|
||||
import { RadioButtonSize, RadioButton, RADIO_GROUP_PADDING } from './RadioButton';
|
||||
export interface RadioButtonGroupProps<T> {
|
||||
value?: T;
|
||||
id?: string;
|
||||
@@ -119,7 +118,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
flexWrap: 'nowrap',
|
||||
border: `1px solid ${theme.components.input.borderColor}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
padding: '2px',
|
||||
padding: RADIO_GROUP_PADDING,
|
||||
'&:hover': {
|
||||
borderColor: theme.components.input.borderHover,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
|
||||
import { BorderRadiusContainer } from './BorderRadius';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Docs Overview/Theme',
|
||||
component: BorderRadiusContainer,
|
||||
decorators: [],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
args: {
|
||||
referenceBorderRadius: 20,
|
||||
referenceBorderWidth: 10,
|
||||
offset: 0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
argTypes: {
|
||||
offset: {
|
||||
control: {
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
referenceBorderRadius: {
|
||||
control: {
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
referenceBorderWidth: {
|
||||
control: {
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
borderWidth: {
|
||||
control: {
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const OffsetBorderRadius: StoryFn<typeof BorderRadiusContainer> = (args) => {
|
||||
return <BorderRadiusContainer {...args} />;
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { getInternalRadius, getExternalRadius } from '../../themes/mixins';
|
||||
import { Stack } from '../Layout/Stack/Stack';
|
||||
import { Text } from '../Text/Text';
|
||||
|
||||
interface DemoBoxProps {
|
||||
referenceBorderRadius: number;
|
||||
referenceBorderWidth: number;
|
||||
offset: number;
|
||||
borderWidth: number;
|
||||
}
|
||||
|
||||
export const BorderRadiusContainer = ({
|
||||
referenceBorderRadius,
|
||||
referenceBorderWidth,
|
||||
offset,
|
||||
borderWidth,
|
||||
}: DemoBoxProps) => {
|
||||
const styles = useStyles2(getStyles, referenceBorderRadius, referenceBorderWidth, offset, borderWidth);
|
||||
return (
|
||||
<Stack direction="column" alignItems="center" gap={4}>
|
||||
<Stack alignItems="center">
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<Text variant="code">getInternalRadius</Text>
|
||||
<div className={styles.baseForInternal}>
|
||||
<div className={styles.internalContainer} />
|
||||
</div>
|
||||
</Stack>
|
||||
<Stack alignItems="center">
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<Text variant="code">getExternalRadius</Text>
|
||||
<div className={styles.externalContainer}>
|
||||
<div className={styles.baseForExternal} />
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (
|
||||
theme: GrafanaTheme2,
|
||||
referenceBorderRadius: number,
|
||||
referenceBorderWidth: number,
|
||||
offset: number,
|
||||
borderWidth: number
|
||||
) => ({
|
||||
baseForInternal: css({
|
||||
backgroundColor: theme.colors.action.disabledBackground,
|
||||
border: `${referenceBorderWidth}px dashed ${theme.colors.action.disabledText}`,
|
||||
borderRadius: referenceBorderRadius,
|
||||
display: 'flex',
|
||||
height: '80px',
|
||||
padding: offset,
|
||||
width: '300px',
|
||||
}),
|
||||
baseForExternal: css({
|
||||
backgroundColor: theme.colors.action.disabledBackground,
|
||||
border: `${referenceBorderWidth}px dashed ${theme.colors.action.disabledText}`,
|
||||
borderRadius: referenceBorderRadius,
|
||||
height: '80px',
|
||||
flex: 1,
|
||||
width: '300px',
|
||||
}),
|
||||
internalContainer: css({
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
border: `${borderWidth}px solid ${theme.colors.primary.main}`,
|
||||
borderRadius: getInternalRadius(theme, offset, {
|
||||
parentBorderRadius: referenceBorderRadius,
|
||||
parentBorderWidth: referenceBorderWidth,
|
||||
}),
|
||||
flex: 1,
|
||||
}),
|
||||
externalContainer: css({
|
||||
border: `${borderWidth}px solid ${theme.colors.primary.main}`,
|
||||
borderRadius: getExternalRadius(theme, offset, {
|
||||
childBorderRadius: referenceBorderRadius,
|
||||
selfBorderWidth: borderWidth,
|
||||
}),
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
padding: offset,
|
||||
}),
|
||||
});
|
||||
@@ -82,3 +82,55 @@ export const getTooltipContainerStyles = (theme: GrafanaTheme2) => ({
|
||||
borderRadius: theme.shape.radius.default,
|
||||
zIndex: theme.zIndex.tooltip,
|
||||
});
|
||||
|
||||
interface ExternalRadiusAdditionalOptions {
|
||||
selfBorderWidth?: number;
|
||||
childBorderRadius?: number;
|
||||
}
|
||||
/**
|
||||
* Calculates a border radius for an element, based on border radius of its child.
|
||||
*
|
||||
* @param theme
|
||||
* @param offset - The distance to offset from the child element, should be >= 0.
|
||||
* @param additionalOptions
|
||||
* @param additionalOptions.selfBorderWidth - The border width of the element itself (default: 1)
|
||||
* @param additionalOptions.childBorderRadius - The border radius of the child element (default: theme default radius)
|
||||
* @returns A CSS calc() expression that returns the relative external radius value
|
||||
*/
|
||||
export const getExternalRadius = (
|
||||
theme: GrafanaTheme2,
|
||||
offset: number,
|
||||
additionalOptions: ExternalRadiusAdditionalOptions = {}
|
||||
) => {
|
||||
const { selfBorderWidth = 1, childBorderRadius } = additionalOptions;
|
||||
|
||||
const childBorderRadiusPx = childBorderRadius !== undefined ? `${childBorderRadius}px` : theme.shape.radius.default;
|
||||
return `calc(max(0px, ${childBorderRadiusPx} + ${offset}px + ${selfBorderWidth}px))`;
|
||||
};
|
||||
|
||||
interface InternalRadiusAdditionalOptions {
|
||||
parentBorderWidth?: number;
|
||||
parentBorderRadius?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a border radius for an element, based on border radius of its parent.
|
||||
*
|
||||
* @param theme
|
||||
* @param offset - The distance to offset from the parent element, should be >= 0.
|
||||
* @param additionalOptions
|
||||
* @param additionalOptions.parentBorderWidth - The border width of the parent element (default: 1)
|
||||
* @param additionalOptions.parentBorderRadius - The border radius of the parent element (default: theme default radius)
|
||||
* @returns A CSS calc() expression that returns the relative internal radius value
|
||||
*/
|
||||
export const getInternalRadius = (
|
||||
theme: GrafanaTheme2,
|
||||
offset: number,
|
||||
additionalOptions: InternalRadiusAdditionalOptions = {}
|
||||
) => {
|
||||
const { parentBorderWidth = 1, parentBorderRadius } = additionalOptions;
|
||||
|
||||
const parentBorderRadiusPx =
|
||||
parentBorderRadius !== undefined ? `${parentBorderRadius}px` : theme.shape.radius.default;
|
||||
return `calc(max(0px, ${parentBorderRadiusPx} - ${offset}px - ${parentBorderWidth}px))`;
|
||||
};
|
||||
@@ -20,6 +20,23 @@ function initI18n() {
|
||||
resources: {},
|
||||
returnEmptyString: false,
|
||||
lng: 'en-US', // this should be the locale of the phrases in our source JSX
|
||||
postProcess: [
|
||||
// Add pseudo processing even if we aren't necessarily going to use it
|
||||
'pseudo',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
import('i18next-pseudo').then((module) => {
|
||||
const Pseudo = module.default;
|
||||
i18next.use(
|
||||
new Pseudo({
|
||||
languageToPseudo: 'pseudo',
|
||||
enabled: true,
|
||||
wrapped: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func (sb *SQLBuilder) WriteDashboardPermissionFilter(user identity.Requester, pe
|
||||
leftJoin string
|
||||
)
|
||||
|
||||
filterRBAC := permissions.NewAccessControlDashboardPermissionFilter(user, permission, queryType, sb.features, sb.recursiveQueriesAreSupported)
|
||||
filterRBAC := permissions.NewAccessControlDashboardPermissionFilter(user, permission, queryType, sb.features, sb.recursiveQueriesAreSupported, sb.dialect)
|
||||
leftJoin = filterRBAC.LeftJoin()
|
||||
sql, params = filterRBAC.Where()
|
||||
recQry, recQryParams = filterRBAC.With()
|
||||
|
||||
@@ -52,12 +52,12 @@ func (d *directResourceClient) List(ctx context.Context, in *resource.ListReques
|
||||
return d.server.List(ctx, in)
|
||||
}
|
||||
|
||||
func (d *directResourceClient) ListRepositoryObjects(ctx context.Context, in *resource.ListRepositoryObjectsRequest, opts ...grpc.CallOption) (*resource.ListRepositoryObjectsResponse, error) {
|
||||
return d.server.ListRepositoryObjects(ctx, in)
|
||||
func (d *directResourceClient) ListManagedObjects(ctx context.Context, in *resource.ListManagedObjectsRequest, opts ...grpc.CallOption) (*resource.ListManagedObjectsResponse, error) {
|
||||
return d.server.ListManagedObjects(ctx, in)
|
||||
}
|
||||
|
||||
func (d *directResourceClient) CountRepositoryObjects(ctx context.Context, in *resource.CountRepositoryObjectsRequest, opts ...grpc.CallOption) (*resource.CountRepositoryObjectsResponse, error) {
|
||||
return d.server.CountRepositoryObjects(ctx, in)
|
||||
func (d *directResourceClient) CountManagedObjects(ctx context.Context, in *resource.CountManagedObjectsRequest, opts ...grpc.CallOption) (*resource.CountManagedObjectsResponse, error) {
|
||||
return d.server.CountManagedObjects(ctx, in)
|
||||
}
|
||||
|
||||
// PutBlob implements ResourceClient.
|
||||
|
||||
@@ -262,11 +262,11 @@ func (a *dashboardSqlAccess) Search(ctx context.Context, req *resource.ResourceS
|
||||
return a.dashboardSearchClient.Search(ctx, req)
|
||||
}
|
||||
|
||||
func (a *dashboardSqlAccess) ListRepositoryObjects(ctx context.Context, req *resource.ListRepositoryObjectsRequest) (*resource.ListRepositoryObjectsResponse, error) {
|
||||
func (a *dashboardSqlAccess) ListManagedObjects(ctx context.Context, req *resource.ListManagedObjectsRequest) (*resource.ListManagedObjectsResponse, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (a *dashboardSqlAccess) CountRepositoryObjects(context.Context, *resource.CountRepositoryObjectsRequest) (*resource.CountRepositoryObjectsResponse, error) {
|
||||
func (a *dashboardSqlAccess) CountManagedObjects(context.Context, *resource.CountManagedObjectsRequest) (*resource.CountManagedObjectsResponse, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
|
||||
@@ -674,7 +674,7 @@ func (m *MockClient) Search(ctx context.Context, in *resource.ResourceSearchRequ
|
||||
func (m *MockClient) GetStats(ctx context.Context, in *resource.ResourceStatsRequest, opts ...grpc.CallOption) (*resource.ResourceStatsResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) CountRepositoryObjects(ctx context.Context, in *resource.CountRepositoryObjectsRequest, opts ...grpc.CallOption) (*resource.CountRepositoryObjectsResponse, error) {
|
||||
func (m *MockClient) CountManagedObjects(ctx context.Context, in *resource.CountManagedObjectsRequest, opts ...grpc.CallOption) (*resource.CountManagedObjectsResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) Watch(ctx context.Context, in *resource.WatchRequest, opts ...grpc.CallOption) (resource.ResourceStore_WatchClient, error) {
|
||||
@@ -704,7 +704,7 @@ func (m *MockClient) PutBlob(ctx context.Context, in *resource.PutBlobRequest, o
|
||||
func (m *MockClient) List(ctx context.Context, in *resource.ListRequest, opts ...grpc.CallOption) (*resource.ListResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) ListRepositoryObjects(ctx context.Context, in *resource.ListRepositoryObjectsRequest, opts ...grpc.CallOption) (*resource.ListRepositoryObjectsResponse, error) {
|
||||
func (m *MockClient) ListManagedObjects(ctx context.Context, in *resource.ListManagedObjectsRequest, opts ...grpc.CallOption) (*resource.ListManagedObjectsResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) IsHealthy(ctx context.Context, in *resource.HealthCheckRequest, opts ...grpc.CallOption) (*resource.HealthCheckResponse, error) {
|
||||
|
||||
@@ -6,9 +6,10 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"go.opentelemetry.io/otel"
|
||||
)
|
||||
|
||||
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/accesscontrol/database")
|
||||
@@ -58,7 +59,7 @@ func (s *AccessControlStore) GetUserPermissions(ctx context.Context, query acces
|
||||
return nil
|
||||
}
|
||||
|
||||
filter, params := accesscontrol.UserRolesFilter(query.OrgID, query.UserID, query.TeamIDs, query.Roles)
|
||||
filter, params := accesscontrol.UserRolesFilter(query.OrgID, query.UserID, query.TeamIDs, query.Roles, s.sql.GetDialect())
|
||||
|
||||
q := `
|
||||
SELECT
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
)
|
||||
|
||||
var sqlIDAcceptList = map[string]struct{}{
|
||||
@@ -127,7 +128,7 @@ func SetAcceptListForTest(list map[string]struct{}) func() {
|
||||
}
|
||||
}
|
||||
|
||||
func UserRolesFilter(orgID, userID int64, teamIDs []int64, roles []string) (string, []any) {
|
||||
func UserRolesFilter(orgID, userID int64, teamIDs []int64, roles []string, dialect migrator.Dialect) (string, []any) {
|
||||
var params []any
|
||||
builder := strings.Builder{}
|
||||
|
||||
@@ -145,7 +146,7 @@ func UserRolesFilter(orgID, userID int64, teamIDs []int64, roles []string) (stri
|
||||
|
||||
if len(teamIDs) > 0 {
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteString("UNION")
|
||||
builder.WriteString(dialect.UnionDistinct())
|
||||
}
|
||||
builder.WriteString(`
|
||||
SELECT tr.role_id FROM team_role as tr
|
||||
@@ -160,7 +161,7 @@ func UserRolesFilter(orgID, userID int64, teamIDs []int64, roles []string) (stri
|
||||
|
||||
if len(roles) != 0 {
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteString("UNION")
|
||||
builder.WriteString(dialect.UnionDistinct())
|
||||
}
|
||||
|
||||
builder.WriteString(`
|
||||
|
||||
@@ -121,7 +121,7 @@ func (authz *AuthService) dashboardsWithVisibleAnnotations(ctx context.Context,
|
||||
}
|
||||
|
||||
filters := []any{
|
||||
permissions.NewAccessControlDashboardPermissionFilter(query.SignedInUser, dashboardaccess.PERMISSION_VIEW, filterType, authz.features, recursiveQueriesSupported),
|
||||
permissions.NewAccessControlDashboardPermissionFilter(query.SignedInUser, dashboardaccess.PERMISSION_VIEW, filterType, authz.features, recursiveQueriesSupported, authz.db.GetDialect()),
|
||||
searchstore.OrgFilter{OrgId: query.OrgID},
|
||||
}
|
||||
|
||||
|
||||
@@ -1002,7 +1002,7 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F
|
||||
}
|
||||
|
||||
if !query.SkipAccessControlFilter {
|
||||
filters = append(filters, permissions.NewAccessControlDashboardPermissionFilter(query.SignedInUser, query.Permission, query.Type, d.features, recursiveQueriesAreSupported))
|
||||
filters = append(filters, permissions.NewAccessControlDashboardPermissionFilter(query.SignedInUser, query.Permission, query.Type, d.features, recursiveQueriesAreSupported, d.store.GetDialect()))
|
||||
}
|
||||
|
||||
filters = append(filters, searchstore.DeletedFilter{Deleted: query.IsDeleted})
|
||||
|
||||
@@ -455,7 +455,9 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI
|
||||
writeSearchStringSQL(query, l.SQLStore, &builder)
|
||||
writeExcludeSQL(query, &builder)
|
||||
writeTypeFilterSQL(typeFilter, &builder)
|
||||
builder.Write(" UNION ")
|
||||
builder.Write(" ")
|
||||
builder.Write(l.SQLStore.GetDialect().UnionDistinct())
|
||||
builder.Write(" ")
|
||||
}
|
||||
builder.Write(selectLibraryElementDTOWithMeta)
|
||||
builder.Write(", le.folder_uid as folder_uid ")
|
||||
@@ -544,7 +546,9 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI
|
||||
writeSearchStringSQL(query, l.SQLStore, &countBuilder)
|
||||
writeExcludeSQL(query, &countBuilder)
|
||||
writeTypeFilterSQL(typeFilter, &countBuilder)
|
||||
countBuilder.Write(" UNION ")
|
||||
countBuilder.Write(" ")
|
||||
countBuilder.Write(l.SQLStore.GetDialect().UnionDistinct())
|
||||
countBuilder.Write(" ")
|
||||
}
|
||||
countBuilder.Write(selectLibraryElementDTOWithMeta)
|
||||
countBuilder.Write(getFromLibraryElementDTOWithMeta(l.SQLStore.GetDialect()))
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
//go:build enterprise || pro
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/ini.v1"
|
||||
"xorm.io/core"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) (*migrator.Migrator, *xorm.Engine) {
|
||||
t.Helper()
|
||||
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)
|
||||
|
||||
mg := migrator.NewMigrator(x, &setting.Cfg{
|
||||
Logger: log.New("users.test"),
|
||||
Raw: ini.Empty(),
|
||||
})
|
||||
migrations := &OSSMigrations{}
|
||||
migrations.AddMigration(mg)
|
||||
|
||||
err = mg.Start(false, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
return mg, x
|
||||
}
|
||||
|
||||
// This "test" migrates database from scratch, and then generates Spanner DDL statements for re-creating the same database.
|
||||
func TestMigrateToSpannerDialect(t *testing.T) {
|
||||
t.Skip("Skipping because test returns panic: unknown column type: INTEGER")
|
||||
mg, eng := setupTestDB(t)
|
||||
tables, err := eng.DBMetas()
|
||||
require.NoError(t, err)
|
||||
|
||||
var statements []string
|
||||
|
||||
spannerDialect := migrator.NewSpannerDialect()
|
||||
for _, table := range tables {
|
||||
t := &migrator.Table{
|
||||
Name: table.Name,
|
||||
Columns: nil,
|
||||
PrimaryKeys: table.PrimaryKeys,
|
||||
Indices: nil,
|
||||
}
|
||||
|
||||
for _, c := range table.Columns() {
|
||||
col := &migrator.Column{
|
||||
Name: c.Name,
|
||||
Type: c.SQLType.Name,
|
||||
Length: c.Length,
|
||||
Length2: c.Length2,
|
||||
Nullable: c.Nullable,
|
||||
IsPrimaryKey: c.IsPrimaryKey,
|
||||
IsAutoIncrement: c.IsAutoIncrement,
|
||||
IsLatin: false,
|
||||
Default: c.Default,
|
||||
}
|
||||
if (col.Type == core.Bool || col.Type == core.TinyInt) && c.Default != "" {
|
||||
b, err := strconv.ParseBool(c.Default)
|
||||
if err == nil {
|
||||
// Format bool values as true/false.
|
||||
col.Default = strconv.FormatBool(b)
|
||||
}
|
||||
}
|
||||
t.Columns = append(t.Columns, col)
|
||||
}
|
||||
|
||||
for _, ix := range table.Indexes {
|
||||
nix := &migrator.Index{
|
||||
Name: ix.Name,
|
||||
Type: ix.Type,
|
||||
Cols: ix.Cols,
|
||||
}
|
||||
t.Indices = append(t.Indices, nix)
|
||||
}
|
||||
|
||||
statements = append(statements, spannerDialect.CreateTableSQL(t))
|
||||
|
||||
for _, nix := range t.Indices {
|
||||
if nix.Name != "PRIMARY_KEY" {
|
||||
statements = append(statements, spannerDialect.CreateIndexSQL(table.Name, nix))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
|
||||
require.NoError(t, enc.Encode(statements))
|
||||
fmt.Println()
|
||||
require.NoError(t, enc.Encode(mg.GetMigrationIDs(true)))
|
||||
}
|
||||
@@ -32,6 +32,8 @@ type Dialect interface {
|
||||
BooleanStr(bool) string
|
||||
DateTimeFunc(string) string
|
||||
BatchSize() int
|
||||
UnionDistinct() string // this is the default UNION type
|
||||
UnionAll() string
|
||||
|
||||
OrderBy(order string) string
|
||||
|
||||
@@ -467,3 +469,11 @@ func (b *BaseDialect) Update(ctx context.Context, tx *session.SessionTx, tableNa
|
||||
func (b *BaseDialect) Concat(strs ...string) string {
|
||||
return fmt.Sprintf("CONCAT(%s)", strings.Join(strs, ", "))
|
||||
}
|
||||
|
||||
func (b *BaseDialect) UnionDistinct() string {
|
||||
return "UNION"
|
||||
}
|
||||
|
||||
func (b *BaseDialect) UnionAll() string {
|
||||
return "UNION ALL"
|
||||
}
|
||||
@@ -240,19 +240,19 @@ func (mg *Migrator) run(ctx context.Context) (err error) {
|
||||
|
||||
migrationLogExists, err := mg.DBEngine.IsTableExist(mg.tableName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %w", "failed to check table existence", err)
|
||||
return fmt.Errorf("failed to check table existence: %w", err)
|
||||
}
|
||||
|
||||
if !migrationLogExists {
|
||||
// Check if dialect can initialize database from a snapshot.
|
||||
err := mg.Dialect.CreateDatabaseFromSnapshot(ctx, mg.DBEngine, mg.tableName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %w", "failed to create database from snapshot", err)
|
||||
return fmt.Errorf("failed to create database from snapshot: %w", err)
|
||||
}
|
||||
|
||||
migrationLogExists, err = mg.DBEngine.IsTableExist(mg.tableName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %w", "failed to check table existence after applying snapshot", err)
|
||||
return fmt.Errorf("failed to check table existence after applying snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"CREATE TABLE `alert_image` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `token` STRING(190) NOT NULL, `path` STRING(190) NOT NULL, `url` STRING(2048) NOT NULL, `created_at` TIMESTAMP NOT NULL, `expires_at` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_image_token` ON `alert_image` (token)",
|
||||
"CREATE TABLE `alert_instance` (`rule_org_id` INT64 NOT NULL, `rule_uid` STRING(40) NOT NULL, `labels` STRING(MAX) NOT NULL, `labels_hash` STRING(190) NOT NULL, `current_state` STRING(190) NOT NULL, `current_state_since` INT64 NOT NULL, `last_eval_time` INT64 NOT NULL, `current_state_end` INT64 NOT NULL DEFAULT (0), `current_reason` STRING(190), `result_fingerprint` STRING(16), `resolved_at` INT64, `last_sent_at` INT64) PRIMARY KEY (rule_org_id,rule_uid,labels_hash)",
|
||||
"CREATE INDEX `IDX_alert_instance_rule_org_id_rule_uid_current_state` ON `alert_instance` (rule_org_id, rule_uid, current_state)",
|
||||
"CREATE INDEX `IDX_alert_instance_rule_org_id_current_state` ON `alert_instance` (rule_org_id, current_state)",
|
||||
"CREATE INDEX `IDX_alert_instance_rule_org_id_rule_uid_current_state` ON `alert_instance` (rule_org_id, rule_uid, current_state)",
|
||||
"CREATE TABLE `alert_notification` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `name` STRING(190) NOT NULL, `type` STRING(255) NOT NULL, `settings` STRING(MAX) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `is_default` BOOL NOT NULL DEFAULT (false), `frequency` INT64, `send_reminder` BOOL DEFAULT (false), `disable_resolve_message` BOOL NOT NULL DEFAULT (false), `uid` STRING(40), `secure_settings` STRING(MAX)) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_notification_org_id_uid` ON `alert_notification` (org_id, uid)",
|
||||
"CREATE TABLE `alert_notification_state` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `alert_id` INT64 NOT NULL, `notifier_id` INT64 NOT NULL, `state` STRING(50) NOT NULL, `version` INT64 NOT NULL, `updated_at` INT64 NOT NULL, `alert_rule_state_updated_version` INT64 NOT NULL) PRIMARY KEY (id)",
|
||||
@@ -48,11 +48,12 @@
|
||||
"CREATE INDEX `IDX_api_key_org_id` ON `api_key` (org_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_api_key_key` ON `api_key` (key)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_api_key_org_id_name` ON `api_key` (org_id, name)",
|
||||
"CREATE TABLE `autoincrement_sequences` (`name` STRING(128) NOT NULL, `next_value` INT64 NOT NULL) PRIMARY KEY (name)",
|
||||
"CREATE TABLE `builtin_role` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `role` STRING(190) NOT NULL, `role_id` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `org_id` INT64 NOT NULL DEFAULT (0)) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_builtin_role_org_id_role_id_role` ON `builtin_role` (org_id, role_id, role)",
|
||||
"CREATE INDEX `IDX_builtin_role_org_id` ON `builtin_role` (org_id)",
|
||||
"CREATE INDEX `IDX_builtin_role_role` ON `builtin_role` (role)",
|
||||
"CREATE INDEX `IDX_builtin_role_role_id` ON `builtin_role` (role_id)",
|
||||
"CREATE INDEX `IDX_builtin_role_role` ON `builtin_role` (role)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_builtin_role_org_id_role_id_role` ON `builtin_role` (org_id, role_id, role)",
|
||||
"CREATE TABLE `cache_data` (`cache_key` STRING(168) NOT NULL, `data` BYTES(MAX) NOT NULL, `expires` INT64 NOT NULL, `created_at` INT64 NOT NULL) PRIMARY KEY (cache_key)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_cache_data_cache_key` ON `cache_data` (cache_key)",
|
||||
"CREATE TABLE `cloud_migration_resource` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `uid` STRING(40) NOT NULL, `resource_type` STRING(40) NOT NULL, `resource_uid` STRING(255), `status` STRING(20) NOT NULL, `error_string` STRING(MAX), `snapshot_uid` STRING(40) NOT NULL, `name` STRING(MAX), `parent_name` STRING(MAX), `error_code` STRING(MAX)) PRIMARY KEY (id)",
|
||||
@@ -61,48 +62,67 @@
|
||||
"CREATE TABLE `cloud_migration_snapshot` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `uid` STRING(40), `session_uid` STRING(40), `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `finished` TIMESTAMP, `upload_url` STRING(MAX), `status` STRING(MAX) NOT NULL, `local_directory` STRING(MAX), `gms_snapshot_uid` STRING(MAX), `encryption_key` STRING(MAX), `error_string` STRING(MAX)) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_cloud_migration_snapshot_uid` ON `cloud_migration_snapshot` (uid)",
|
||||
"CREATE TABLE `correlation` (`uid` STRING(40) NOT NULL, `org_id` INT64 NOT NULL DEFAULT (0), `source_uid` STRING(40) NOT NULL, `target_uid` STRING(40), `label` STRING(MAX) NOT NULL, `description` STRING(MAX) NOT NULL, `config` STRING(MAX), `provisioned` BOOL NOT NULL DEFAULT (false), `type` STRING(40) NOT NULL DEFAULT ('query')) PRIMARY KEY (uid,org_id,source_uid)",
|
||||
"CREATE INDEX `IDX_correlation_org_id` ON `correlation` (org_id)",
|
||||
"CREATE INDEX `IDX_correlation_source_uid` ON `correlation` (source_uid)",
|
||||
"CREATE INDEX `IDX_correlation_uid` ON `correlation` (uid)",
|
||||
"CREATE INDEX `IDX_correlation_org_id` ON `correlation` (org_id)",
|
||||
"CREATE TABLE `dashboard` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `version` INT64 NOT NULL, `slug` STRING(189) NOT NULL, `title` STRING(189) NOT NULL, `data` STRING(MAX) NOT NULL, `org_id` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `updated_by` INT64, `created_by` INT64, `gnet_id` INT64, `plugin_id` STRING(189), `folder_id` INT64 NOT NULL DEFAULT (0), `is_folder` BOOL NOT NULL DEFAULT (false), `has_acl` BOOL NOT NULL DEFAULT (false), `uid` STRING(40), `is_public` BOOL NOT NULL DEFAULT (false), `deleted` TIMESTAMP, `api_version` STRING(16), `folder_uid` STRING(40)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_dashboard_deleted` ON `dashboard` (deleted)",
|
||||
"CREATE INDEX `IDX_dashboard_gnet_id` ON `dashboard` (gnet_id)",
|
||||
"CREATE INDEX `IDX_dashboard_is_folder` ON `dashboard` (is_folder)",
|
||||
"CREATE INDEX `IDX_dashboard_org_id` ON `dashboard` (org_id)",
|
||||
"CREATE INDEX `IDX_dashboard_org_id_folder_id_title` ON `dashboard` (org_id, folder_id, title)",
|
||||
"CREATE INDEX `IDX_dashboard_org_id_plugin_id` ON `dashboard` (org_id, plugin_id)",
|
||||
"CREATE INDEX `IDX_dashboard_gnet_id` ON `dashboard` (gnet_id)",
|
||||
"CREATE INDEX `IDX_dashboard_org_id` ON `dashboard` (org_id)",
|
||||
"CREATE INDEX `IDX_dashboard_title` ON `dashboard` (title)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_org_id_uid` ON `dashboard` (org_id, uid)",
|
||||
"CREATE TABLE `dashboard_acl` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `dashboard_id` INT64 NOT NULL, `user_id` INT64, `team_id` INT64, `permission` INT64 NOT NULL DEFAULT (4), `role` STRING(20), `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_dashboard_acl_dashboard_id` ON `dashboard_acl` (dashboard_id)",
|
||||
"CREATE INDEX `IDX_dashboard_acl_org_id_role` ON `dashboard_acl` (org_id, role)",
|
||||
"CREATE INDEX `IDX_dashboard_acl_permission` ON `dashboard_acl` (permission)",
|
||||
"CREATE INDEX `IDX_dashboard_acl_team_id` ON `dashboard_acl` (team_id)",
|
||||
"CREATE INDEX `IDX_dashboard_acl_user_id` ON `dashboard_acl` (user_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_acl_dashboard_id_team_id` ON `dashboard_acl` (dashboard_id, team_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_acl_dashboard_id_user_id` ON `dashboard_acl` (dashboard_id, user_id)",
|
||||
"CREATE INDEX `IDX_dashboard_acl_dashboard_id` ON `dashboard_acl` (dashboard_id)",
|
||||
"CREATE INDEX `IDX_dashboard_acl_org_id_role` ON `dashboard_acl` (org_id, role)",
|
||||
"CREATE INDEX `IDX_dashboard_acl_permission` ON `dashboard_acl` (permission)",
|
||||
"CREATE TABLE `dashboard_provisioning` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `dashboard_id` INT64, `name` STRING(150) NOT NULL, `external_id` STRING(MAX) NOT NULL, `updated` INT64 NOT NULL DEFAULT (0), `check_sum` STRING(32)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_dashboard_provisioning_dashboard_id` ON `dashboard_provisioning` (dashboard_id)",
|
||||
"CREATE INDEX `IDX_dashboard_provisioning_dashboard_id_name` ON `dashboard_provisioning` (dashboard_id, name)",
|
||||
"CREATE INDEX `IDX_dashboard_provisioning_dashboard_id` ON `dashboard_provisioning` (dashboard_id)",
|
||||
"CREATE TABLE `dashboard_public` (`uid` STRING(40) NOT NULL, `dashboard_uid` STRING(40) NOT NULL, `org_id` INT64 NOT NULL, `time_settings` STRING(MAX), `template_variables` STRING(MAX), `access_token` STRING(32) NOT NULL, `created_by` INT64 NOT NULL, `updated_by` INT64, `created_at` TIMESTAMP NOT NULL, `updated_at` TIMESTAMP, `is_enabled` BOOL NOT NULL DEFAULT (false), `annotations_enabled` BOOL NOT NULL DEFAULT (false), `time_selection_enabled` BOOL NOT NULL DEFAULT (false), `share` STRING(64) NOT NULL DEFAULT ('public')) PRIMARY KEY (uid)",
|
||||
"CREATE INDEX `IDX_dashboard_public_config_org_id_dashboard_uid` ON `dashboard_public` (org_id, dashboard_uid)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_public_config_access_token` ON `dashboard_public` (access_token)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_public_config_uid` ON `dashboard_public` (uid)",
|
||||
"CREATE TABLE `dashboard_public_email_share` (`uid` STRING(40) NOT NULL, `public_dashboard_uid` STRING(64) NOT NULL, `recipient` STRING(255) NOT NULL, `type` STRING(64) NOT NULL DEFAULT ('email'), `created_at` TIMESTAMP NOT NULL, `updated_at` TIMESTAMP NOT NULL) PRIMARY KEY (uid)",
|
||||
"CREATE TABLE `dashboard_public_magic_link` (`uid` STRING(40) NOT NULL, `token_uuid` STRING(64) NOT NULL, `public_dashboard_uid` STRING(64) NOT NULL, `email` STRING(255) NOT NULL, `created_at` TIMESTAMP NOT NULL, `updated_at` TIMESTAMP NOT NULL) PRIMARY KEY (uid)",
|
||||
"CREATE TABLE `dashboard_public_session` (`uid` STRING(40) NOT NULL, `cookie_uuid` STRING(64) NOT NULL, `public_dashboard_uid` STRING(64) NOT NULL, `email` STRING(255) NOT NULL, `created_at` TIMESTAMP NOT NULL, `updated_at` TIMESTAMP NOT NULL, `last_seen_at` TIMESTAMP) PRIMARY KEY (uid)",
|
||||
"CREATE TABLE `dashboard_public_usage_by_day` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `public_dashboard_uid` STRING(255) NOT NULL, `day` STRING(40) NOT NULL, `views` INT64 NOT NULL, `queries` INT64 NOT NULL, `errors` INT64 NOT NULL, `load_duration` FLOAT64 NOT NULL, `cached_queries` INT64 NOT NULL DEFAULT (0)) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `dashboard_snapshot` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `name` STRING(255) NOT NULL, `key` STRING(190) NOT NULL, `delete_key` STRING(190) NOT NULL, `org_id` INT64 NOT NULL, `user_id` INT64 NOT NULL, `external` BOOL NOT NULL, `external_url` STRING(255) NOT NULL, `dashboard` STRING(MAX) NOT NULL, `expires` TIMESTAMP NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `external_delete_url` STRING(255), `dashboard_encrypted` BYTES(MAX)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_dashboard_snapshot_user_id` ON `dashboard_snapshot` (user_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_snapshot_delete_key` ON `dashboard_snapshot` (delete_key)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_snapshot_key` ON `dashboard_snapshot` (key)",
|
||||
"CREATE TABLE `dashboard_tag` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `dashboard_id` INT64 NOT NULL, `term` STRING(50) NOT NULL, `dashboard_uid` STRING(40), `org_id` INT64 DEFAULT (1)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_dashboard_tag_dashboard_id` ON `dashboard_tag` (dashboard_id)",
|
||||
"CREATE TABLE `dashboard_usage_by_day` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `dashboard_id` INT64 NOT NULL, `day` STRING(40) NOT NULL, `views` INT64 NOT NULL, `queries` INT64 NOT NULL, `errors` INT64 NOT NULL, `load_duration` FLOAT64 NOT NULL, `cached_queries` INT64 NOT NULL DEFAULT (0), `dashboard_uid` STRING(40), `org_id` INT64 DEFAULT (1)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_dashboard_usage_by_day_dashboard_id` ON `dashboard_usage_by_day` (dashboard_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_usage_by_day_dashboard_id_day` ON `dashboard_usage_by_day` (dashboard_id, day)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_usage_by_day_dashboard_uid_org_id_day` ON `dashboard_usage_by_day` (dashboard_uid, org_id, day)",
|
||||
"CREATE TABLE `dashboard_usage_sums` (`dashboard_id` INT64 NOT NULL, `updated` TIMESTAMP NOT NULL, `views_last_1_days` INT64 NOT NULL, `views_last_7_days` INT64 NOT NULL, `views_last_30_days` INT64 NOT NULL, `views_total` INT64 NOT NULL, `queries_last_1_days` INT64 NOT NULL, `queries_last_7_days` INT64 NOT NULL, `queries_last_30_days` INT64 NOT NULL, `queries_total` INT64 NOT NULL, `errors_last_1_days` INT64 NOT NULL DEFAULT (0), `errors_last_7_days` INT64 NOT NULL DEFAULT (0), `errors_last_30_days` INT64 NOT NULL DEFAULT (0), `errors_total` INT64 NOT NULL DEFAULT (0), `dashboard_uid` STRING(40), `org_id` INT64 DEFAULT (1)) PRIMARY KEY (dashboard_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_usage_sums_org_id_dashboard_uid` ON `dashboard_usage_sums` (org_id, dashboard_uid)",
|
||||
"CREATE TABLE `dashboard_version` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `dashboard_id` INT64 NOT NULL, `parent_version` INT64 NOT NULL, `restored_from` INT64 NOT NULL, `version` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `created_by` INT64 NOT NULL, `message` STRING(MAX) NOT NULL, `data` STRING(MAX), `api_version` STRING(16)) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_version_dashboard_id_version` ON `dashboard_version` (dashboard_id, version)",
|
||||
"CREATE INDEX `IDX_dashboard_version_dashboard_id` ON `dashboard_version` (dashboard_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_dashboard_version_dashboard_id_version` ON `dashboard_version` (dashboard_id, version)",
|
||||
"CREATE TABLE `data_keys` (`name` STRING(100) NOT NULL, `active` BOOL NOT NULL, `scope` STRING(30) NOT NULL, `provider` STRING(50) NOT NULL, `encrypted_data` BYTES(MAX) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `label` STRING(100)) PRIMARY KEY (name)",
|
||||
"CREATE TABLE `data_source` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `version` INT64 NOT NULL, `type` STRING(255) NOT NULL, `name` STRING(190) NOT NULL, `access` STRING(255) NOT NULL, `url` STRING(255) NOT NULL, `password` STRING(255), `user` STRING(255), `database` STRING(255), `basic_auth` BOOL NOT NULL, `basic_auth_user` STRING(255), `basic_auth_password` STRING(255), `is_default` BOOL NOT NULL, `json_data` STRING(MAX), `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `with_credentials` BOOL NOT NULL DEFAULT (false), `secure_json_data` STRING(MAX), `read_only` BOOL, `uid` STRING(40) NOT NULL DEFAULT ('0'), `is_prunable` BOOL DEFAULT (false), `api_version` STRING(20)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_data_source_org_id` ON `data_source` (org_id)",
|
||||
"CREATE INDEX `IDX_data_source_org_id_is_default` ON `data_source` (org_id, is_default)",
|
||||
"CREATE INDEX `IDX_data_source_org_id` ON `data_source` (org_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_data_source_org_id_name` ON `data_source` (org_id, name)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_data_source_org_id_uid` ON `data_source` (org_id, uid)",
|
||||
"CREATE TABLE `data_source_acl` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `data_source_id` INT64 NOT NULL, `team_id` INT64 NOT NULL, `user_id` INT64 NOT NULL, `permission` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_data_source_acl_data_source_id` ON `data_source_acl` (data_source_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_data_source_acl_data_source_id_team_id_user_id` ON `data_source_acl` (data_source_id, team_id, user_id)",
|
||||
"CREATE TABLE `data_source_cache` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `data_source_id` INT64 NOT NULL, `enabled` BOOL NOT NULL, `ttl_ms` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `use_default_ttl` BOOL NOT NULL DEFAULT (true), `data_source_uid` STRING(40) NOT NULL DEFAULT ('0'), `ttl_resources_ms` INT64 NOT NULL DEFAULT (300000)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_data_source_cache_data_source_id` ON `data_source_cache` (data_source_id)",
|
||||
"CREATE INDEX `IDX_data_source_cache_data_source_uid` ON `data_source_cache` (data_source_uid)",
|
||||
"CREATE TABLE `data_source_usage_by_day` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `data_source_id` INT64 NOT NULL, `day` STRING(40) NOT NULL, `queries` INT64 NOT NULL, `errors` INT64 NOT NULL, `load_duration_ms` INT64 NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_data_source_usage_by_day_data_source_id` ON `data_source_usage_by_day` (data_source_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_data_source_usage_by_day_data_source_id_day` ON `data_source_usage_by_day` (data_source_id, day)",
|
||||
"CREATE TABLE `entity_event` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `entity_id` STRING(1024) NOT NULL, `event_type` STRING(8) NOT NULL, `created` INT64 NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `file` (`path` STRING(1024) NOT NULL, `path_hash` STRING(64) NOT NULL, `parent_folder_path_hash` STRING(64) NOT NULL, `contents` BYTES(MAX), `etag` STRING(32) NOT NULL, `cache_control` STRING(128) NOT NULL, `content_disposition` STRING(128) NOT NULL, `updated` TIMESTAMP NOT NULL, `created` TIMESTAMP NOT NULL, `size` INT64 NOT NULL, `mime_type` STRING(255) NOT NULL) PRIMARY KEY (path_hash)",
|
||||
"CREATE INDEX `IDX_file_parent_folder_path_hash` ON `file` (parent_folder_path_hash)",
|
||||
@@ -119,6 +139,7 @@
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_library_element_org_id_uid` ON `library_element` (org_id, uid)",
|
||||
"CREATE TABLE `library_element_connection` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `element_id` INT64 NOT NULL, `kind` INT64 NOT NULL, `connection_id` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `created_by` INT64 NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_library_element_connection_element_id_kind_connection_id` ON `library_element_connection` (element_id, kind, connection_id)",
|
||||
"CREATE TABLE `license_token` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `token` STRING(MAX) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `login_attempt` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `username` STRING(190) NOT NULL, `ip_address` STRING(30) NOT NULL, `created` INT64 NOT NULL DEFAULT (0)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_login_attempt_username` ON `login_attempt` (username)",
|
||||
"CREATE TABLE `migration_log` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `migration_id` STRING(255) NOT NULL, `sql` STRING(MAX) NOT NULL, `success` BOOL NOT NULL, `error` STRING(MAX) NOT NULL, `timestamp` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
@@ -127,13 +148,13 @@
|
||||
"CREATE TABLE `org` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `version` INT64 NOT NULL, `name` STRING(190) NOT NULL, `address1` STRING(255), `address2` STRING(255), `city` STRING(255), `state` STRING(255), `zip_code` STRING(50), `country` STRING(255), `billing_email` STRING(255), `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_org_name` ON `org` (name)",
|
||||
"CREATE TABLE `org_user` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `user_id` INT64 NOT NULL, `role` STRING(20) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_org_user_org_id_user_id` ON `org_user` (org_id, user_id)",
|
||||
"CREATE INDEX `IDX_org_user_org_id` ON `org_user` (org_id)",
|
||||
"CREATE INDEX `IDX_org_user_user_id` ON `org_user` (user_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_org_user_org_id_user_id` ON `org_user` (org_id, user_id)",
|
||||
"CREATE TABLE `permission` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `role_id` INT64 NOT NULL, `action` STRING(190) NOT NULL, `scope` STRING(190) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `kind` STRING(40) NOT NULL DEFAULT (''), `attribute` STRING(40) NOT NULL DEFAULT (''), `identifier` STRING(40) NOT NULL DEFAULT ('')) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_permission_action_scope_role_id` ON `permission` (action, scope, role_id)",
|
||||
"CREATE INDEX `IDX_permission_identifier` ON `permission` (identifier)",
|
||||
"CREATE INDEX `IDX_permission_role_id` ON `permission` (role_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_permission_action_scope_role_id` ON `permission` (action, scope, role_id)",
|
||||
"CREATE TABLE `playlist` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `name` STRING(255) NOT NULL, `interval` STRING(255) NOT NULL, `org_id` INT64 NOT NULL, `created_at` INT64 NOT NULL DEFAULT (0), `updated_at` INT64 NOT NULL DEFAULT (0), `uid` STRING(80) NOT NULL DEFAULT ('0')) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_playlist_org_id_uid` ON `playlist` (org_id, uid)",
|
||||
"CREATE TABLE `playlist_item` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `playlist_id` INT64 NOT NULL, `type` STRING(255) NOT NULL, `value` STRING(MAX) NOT NULL, `title` STRING(MAX) NOT NULL, `order` INT64 NOT NULL) PRIMARY KEY (id)",
|
||||
@@ -151,6 +172,16 @@
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_query_history_star_user_id_query_uid` ON `query_history_star` (user_id, query_uid)",
|
||||
"CREATE TABLE `quota` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64, `user_id` INT64, `target` STRING(190) NOT NULL, `limit` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_quota_org_id_user_id_target` ON `quota` (org_id, user_id, target)",
|
||||
"CREATE TABLE `recording_rules` (`id` STRING(128) NOT NULL, `target_ref_id` STRING(128) NOT NULL, `name` STRING(128) NOT NULL, `description` STRING(MAX) NOT NULL, `org_id` INT64 NOT NULL, `interval` INT64 NOT NULL, `range` INT64 NOT NULL, `active` BOOL NOT NULL DEFAULT (false), `count` BOOL NOT NULL DEFAULT (false), `queries` BYTES(MAX) NOT NULL, `created_at` TIMESTAMP NOT NULL, `prom_name` STRING(128)) PRIMARY KEY (id,target_ref_id)",
|
||||
"CREATE TABLE `remote_write_targets` (`id` STRING(128) NOT NULL, `data_source_uid` STRING(128) NOT NULL, `write_path` STRING(128) NOT NULL, `org_id` INT64 NOT NULL) PRIMARY KEY (id,data_source_uid,write_path)",
|
||||
"CREATE TABLE `report` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_id` INT64 NOT NULL, `org_id` INT64 NOT NULL, `dashboard_id` INT64 NOT NULL, `name` STRING(MAX) NOT NULL, `recipients` STRING(MAX) NOT NULL, `reply_to` STRING(MAX), `message` STRING(MAX), `schedule_frequency` STRING(32) NOT NULL, `schedule_day` STRING(32) NOT NULL, `schedule_hour` INT64 NOT NULL, `schedule_minute` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `schedule_timezone` STRING(50) NOT NULL DEFAULT ('Europe/Stockholm'), `time_from` STRING(255), `time_to` STRING(255), `pdf_landscape` BOOL, `schedule_day_of_month` STRING(32), `pdf_layout` STRING(255), `pdf_orientation` STRING(32), `dashboard_uid` STRING(40), `template_vars` STRING(MAX), `enable_dashboard_url` BOOL, `state` STRING(32), `enable_csv` BOOL, `schedule_start` INT64, `schedule_end` INT64, `schedule_interval_frequency` STRING(32), `schedule_interval_amount` INT64, `schedule_workdays_only` BOOL, `formats` STRING(190) NOT NULL DEFAULT ('[\"pdf\"]'), `scale_factor` INT64 NOT NULL DEFAULT (2), `uid` STRING(40), `pdf_show_template_variables` BOOL NOT NULL DEFAULT (false), `pdf_combine_one_file` BOOL NOT NULL DEFAULT (true), `subject` STRING(MAX)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_report_dashboard_id` ON `report` (dashboard_id)",
|
||||
"CREATE INDEX `IDX_report_org_id` ON `report` (org_id)",
|
||||
"CREATE INDEX `IDX_report_user_id` ON `report` (user_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_report_org_id_uid` ON `report` (org_id, uid)",
|
||||
"CREATE TABLE `report_dashboards` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `report_id` INT64 NOT NULL, `dashboard_uid` STRING(40) NOT NULL DEFAULT (''), `report_variables` STRING(MAX), `time_to` STRING(255), `time_from` STRING(255), `created` TIMESTAMP) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_report_dashboards_report_id` ON `report_dashboards` (report_id)",
|
||||
"CREATE TABLE `report_settings` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_id` INT64 NOT NULL, `org_id` INT64 NOT NULL, `branding_report_logo_url` STRING(MAX), `branding_email_logo_url` STRING(MAX), `branding_email_footer_link` STRING(MAX), `branding_email_footer_text` STRING(MAX), `branding_email_footer_mode` STRING(50), `pdf_theme` STRING(40) NOT NULL DEFAULT ('light'), `embedded_image_theme` STRING(40) NOT NULL DEFAULT ('dark')) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `role` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `name` STRING(190) NOT NULL, `description` STRING(MAX), `version` INT64 NOT NULL, `org_id` INT64 NOT NULL, `uid` STRING(40) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `display_name` STRING(190), `group_name` STRING(190), `hidden` BOOL NOT NULL DEFAULT (false)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_role_org_id` ON `role` (org_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_role_org_id_name` ON `role` (org_id, name)",
|
||||
@@ -162,6 +193,8 @@
|
||||
"CREATE TABLE `server_lock` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `operation_uid` STRING(100) NOT NULL, `version` INT64 NOT NULL, `last_execution` INT64 NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_server_lock_operation_uid` ON `server_lock` (operation_uid)",
|
||||
"CREATE TABLE `session` (`key` STRING(16) NOT NULL, `data` BYTES(MAX) NOT NULL, `expiry` INT64 NOT NULL) PRIMARY KEY (key)",
|
||||
"CREATE TABLE `setting` (`section` STRING(100) NOT NULL, `key` STRING(100) NOT NULL, `value` STRING(MAX) NOT NULL, `encrypted_value` STRING(MAX)) PRIMARY KEY (section,key)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_setting_section_key` ON `setting` (section, key)",
|
||||
"CREATE TABLE `short_url` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `uid` STRING(40) NOT NULL, `path` STRING(MAX) NOT NULL, `created_by` INT64, `created_at` INT64 NOT NULL, `last_seen_at` INT64) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_short_url_org_id_uid` ON `short_url` (org_id, uid)",
|
||||
"CREATE TABLE `signing_key` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `key_id` STRING(255) NOT NULL, `private_key` STRING(MAX) NOT NULL, `added_at` TIMESTAMP NOT NULL, `expires_at` TIMESTAMP, `alg` STRING(255) NOT NULL) PRIMARY KEY (id)",
|
||||
@@ -176,20 +209,24 @@
|
||||
"CREATE INDEX `IDX_team_org_id` ON `team` (org_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_team_org_id_name` ON `team` (org_id, name)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_team_org_id_uid` ON `team` (org_id, uid)",
|
||||
"CREATE TABLE `team_group` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `team_id` INT64 NOT NULL, `group_id` STRING(190) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_team_group_group_id` ON `team_group` (group_id)",
|
||||
"CREATE INDEX `IDX_team_group_org_id` ON `team_group` (org_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_team_group_org_id_team_id_group_id` ON `team_group` (org_id, team_id, group_id)",
|
||||
"CREATE TABLE `team_member` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `team_id` INT64 NOT NULL, `user_id` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `external` BOOL, `permission` INT64) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_team_member_org_id_team_id_user_id` ON `team_member` (org_id, team_id, user_id)",
|
||||
"CREATE INDEX `IDX_team_member_org_id` ON `team_member` (org_id)",
|
||||
"CREATE INDEX `IDX_team_member_team_id` ON `team_member` (team_id)",
|
||||
"CREATE INDEX `IDX_team_member_user_id_org_id` ON `team_member` (user_id, org_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_team_member_org_id_team_id_user_id` ON `team_member` (org_id, team_id, user_id)",
|
||||
"CREATE TABLE `team_role` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `team_id` INT64 NOT NULL, `role_id` INT64 NOT NULL, `created` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_team_role_org_id` ON `team_role` (org_id)",
|
||||
"CREATE INDEX `IDX_team_role_team_id` ON `team_role` (team_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_team_role_org_id_team_id_role_id` ON `team_role` (org_id, team_id, role_id)",
|
||||
"CREATE TABLE `temp_user` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `version` INT64 NOT NULL, `email` STRING(190) NOT NULL, `name` STRING(255), `role` STRING(20), `code` STRING(190) NOT NULL, `status` STRING(20) NOT NULL, `invited_by_user_id` INT64, `email_sent` BOOL NOT NULL, `email_sent_on` TIMESTAMP, `remote_addr` STRING(255), `created` INT64 NOT NULL DEFAULT (0), `updated` INT64 NOT NULL DEFAULT (0)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_temp_user_org_id` ON `temp_user` (org_id)",
|
||||
"CREATE INDEX `IDX_temp_user_status` ON `temp_user` (status)",
|
||||
"CREATE INDEX `IDX_temp_user_code` ON `temp_user` (code)",
|
||||
"CREATE INDEX `IDX_temp_user_email` ON `temp_user` (email)",
|
||||
"CREATE INDEX `IDX_temp_user_org_id` ON `temp_user` (org_id)",
|
||||
"CREATE INDEX `IDX_temp_user_status` ON `temp_user` (status)",
|
||||
"CREATE TABLE `test_data` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `metric1` STRING(20), `metric2` STRING(150), `value_big_int` INT64, `value_double` FLOAT64, `value_float` FLOAT64, `value_int` INT64, `time_epoch` INT64 NOT NULL, `time_date_time` TIMESTAMP NOT NULL, `time_time_stamp` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `user` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `version` INT64 NOT NULL, `login` STRING(190) NOT NULL, `email` STRING(190) NOT NULL, `name` STRING(255), `password` STRING(255), `salt` STRING(50), `rands` STRING(50), `company` STRING(255), `org_id` INT64 NOT NULL, `is_admin` BOOL NOT NULL, `email_verified` BOOL, `theme` STRING(255), `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL, `help_flags1` INT64 NOT NULL DEFAULT (0), `last_seen_at` TIMESTAMP, `is_disabled` BOOL NOT NULL DEFAULT (false), `is_service_account` BOOL DEFAULT (false), `uid` STRING(40), `is_provisioned` BOOL NOT NULL DEFAULT (false)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_user_login_email` ON `user` (login, email)",
|
||||
@@ -200,13 +237,21 @@
|
||||
"CREATE INDEX `IDX_user_auth_auth_module_auth_id` ON `user_auth` (auth_module, auth_id)",
|
||||
"CREATE INDEX `IDX_user_auth_user_id` ON `user_auth` (user_id)",
|
||||
"CREATE TABLE `user_auth_token` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_id` INT64 NOT NULL, `auth_token` STRING(100) NOT NULL, `prev_auth_token` STRING(100) NOT NULL, `user_agent` STRING(255) NOT NULL, `client_ip` STRING(255) NOT NULL, `auth_token_seen` BOOL NOT NULL, `seen_at` INT64, `rotated_at` INT64 NOT NULL, `created_at` INT64 NOT NULL, `updated_at` INT64 NOT NULL, `revoked_at` INT64, `external_session_id` INT64) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_auth_token_auth_token` ON `user_auth_token` (auth_token)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_auth_token_prev_auth_token` ON `user_auth_token` (prev_auth_token)",
|
||||
"CREATE INDEX `IDX_user_auth_token_revoked_at` ON `user_auth_token` (revoked_at)",
|
||||
"CREATE INDEX `IDX_user_auth_token_user_id` ON `user_auth_token` (user_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_auth_token_auth_token` ON `user_auth_token` (auth_token)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_auth_token_prev_auth_token` ON `user_auth_token` (prev_auth_token)",
|
||||
"CREATE TABLE `user_dashboard_views` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_id` INT64 NOT NULL, `dashboard_id` INT64 NOT NULL, `viewed` TIMESTAMP NOT NULL, `org_id` INT64, `dashboard_uid` STRING(40)) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_user_dashboard_views_dashboard_id` ON `user_dashboard_views` (dashboard_id)",
|
||||
"CREATE INDEX `IDX_user_dashboard_views_org_id_dashboard_uid` ON `user_dashboard_views` (org_id, dashboard_uid)",
|
||||
"CREATE INDEX `IDX_user_dashboard_views_user_id` ON `user_dashboard_views` (user_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_dashboard_views_user_id_dashboard_id` ON `user_dashboard_views` (user_id, dashboard_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_dashboard_views_user_id_org_id_dashboard_uid` ON `user_dashboard_views` (user_id, org_id, dashboard_uid)",
|
||||
"CREATE TABLE `user_external_session` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_auth_id` INT64 NOT NULL, `user_id` INT64 NOT NULL, `auth_module` STRING(190) NOT NULL, `access_token` STRING(MAX), `id_token` STRING(MAX), `refresh_token` STRING(MAX), `session_id` STRING(1024), `session_id_hash` STRING(44), `name_id` STRING(1024), `name_id_hash` STRING(44), `expires_at` TIMESTAMP, `created_at` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE TABLE `user_role` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `user_id` INT64 NOT NULL, `role_id` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `group_mapping_uid` STRING(40) DEFAULT ('')) PRIMARY KEY (id)",
|
||||
"CREATE INDEX `IDX_user_role_org_id` ON `user_role` (org_id)",
|
||||
"CREATE INDEX `IDX_user_role_user_id` ON `user_role` (user_id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_role_org_id_user_id_role_id_group_mapping_uid` ON `user_role` (org_id, user_id, role_id, group_mapping_uid)"
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_role_org_id_user_id_role_id_group_mapping_uid` ON `user_role` (org_id, user_id, role_id, group_mapping_uid)",
|
||||
"CREATE TABLE `user_stats` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_id` INT64 NOT NULL, `billing_role` STRING(40) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
|
||||
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_stats_user_id` ON `user_stats` (user_id)"
|
||||
]
|
||||
@@ -577,6 +577,7 @@
|
||||
"create sso_setting table",
|
||||
"copy kvstore migration status to each org",
|
||||
"add back entry for orgid=0 migrated status",
|
||||
"managed dashboard permissions annotation actions migration",
|
||||
"create cloud_migration table v1",
|
||||
"create cloud_migration_run table v1",
|
||||
"add stack_id column",
|
||||
@@ -640,5 +641,119 @@
|
||||
"populate rule guid in alert rule table",
|
||||
"add index in alert_rule_version table on rule_org_id, rule_uid, rule_guid and version columns",
|
||||
"add index in alert_rule_version table on rule_guid and version columns",
|
||||
"add index in alert_rule table on guid columns"
|
||||
"add index in alert_rule table on guid columns",
|
||||
"create data_source_usage_by_day table",
|
||||
"create data_source_usage_by_day(data_source_id) index",
|
||||
"create data_source_usage_by_day(data_source_id, day) unique index",
|
||||
"create dashboard_usage_by_day table",
|
||||
"create dashboard_usage_sums table",
|
||||
"create dashboard_usage_by_day(dashboard_id) index",
|
||||
"create dashboard_usage_by_day(dashboard_id, day) index",
|
||||
"add column errors_last_1_days to dashboard_usage_sums",
|
||||
"add column errors_last_7_days to dashboard_usage_sums",
|
||||
"add column errors_last_30_days to dashboard_usage_sums",
|
||||
"add column errors_total to dashboard_usage_sums",
|
||||
"create dashboard_public_usage_by_day table",
|
||||
"add column cached_queries to dashboard_usage_by_day table",
|
||||
"add column cached_queries to dashboard_public_usage_by_day table",
|
||||
"add column dashboard_uid to dashboard_usage_sums",
|
||||
"add column org_id to dashboard_usage_sums",
|
||||
"add column dashboard_uid to dashboard_usage_by_day",
|
||||
"add column org_id to dashboard_usage_by_day",
|
||||
"create dashboard_usage_by_day(dashboard_uid, org_id, day) unique index",
|
||||
"Add missing dashboard_uid and org_id to dashboard_usage_by_day and dashboard_usage_sums",
|
||||
"Add dashboard_usage_sums(org_id, dashboard_uid) index",
|
||||
"create user_dashboard_views table",
|
||||
"add index user_dashboard_views.user_id",
|
||||
"add index user_dashboard_views.dashboard_id",
|
||||
"add unique index user_dashboard_views_user_id_dashboard_id",
|
||||
"add org_id column to user_dashboard_views",
|
||||
"add dashboard_uid column to user_dashboard_views",
|
||||
"add unique index user_dashboard_views_org_id_dashboard_uid",
|
||||
"add unique index user_dashboard_views_org_id_user_id_dashboard_uid",
|
||||
"populate user_dashboard_views.dashboard_uid and user_dashboard_views.org_id from dashboard table",
|
||||
"create user_stats table",
|
||||
"add unique index user_stats(user_id)",
|
||||
"create data_source_cache table",
|
||||
"add index data_source_cache.data_source_id",
|
||||
"add use_default_ttl column",
|
||||
"add data_source_cache.data_source_uid column",
|
||||
"remove abandoned data_source_cache records",
|
||||
"update data_source_cache.data_source_uid value",
|
||||
"add index data_source_cache.data_source_uid",
|
||||
"add data_source_cache.ttl_resources_ms column",
|
||||
"update data_source_cache.ttl_resources_ms to have the same value as ttl_ms",
|
||||
"create data_source_acl table",
|
||||
"add index data_source_acl.data_source_id",
|
||||
"add unique index datasource_acl.unique",
|
||||
"create license_token table",
|
||||
"drop recorded_queries table v14",
|
||||
"drop recording_rules table v14",
|
||||
"create recording_rules table v14",
|
||||
"create remote_write_targets table v1",
|
||||
"Add prom_name to recording_rules table",
|
||||
"ensure remote_write_targets table",
|
||||
"create report config table v1",
|
||||
"Add index report.user_id",
|
||||
"add index to dashboard_id",
|
||||
"add index to org_id",
|
||||
"Add timezone to the report",
|
||||
"Add time_from to the report",
|
||||
"Add time_to to the report",
|
||||
"Add PDF landscape option to the report",
|
||||
"Add monthly day scheduling option to the report",
|
||||
"Add PDF layout option to the report",
|
||||
"Add PDF orientation option to the report",
|
||||
"Update report pdf_orientation from pdf_landscape",
|
||||
"create report settings table",
|
||||
"Add dashboard_uid field to the report",
|
||||
"Add template_vars field to the report",
|
||||
"Add option to include dashboard url in the report",
|
||||
"Add state field to the report",
|
||||
"Add option to add CSV files to the report",
|
||||
"Add scheduling start date",
|
||||
"Add missing schedule_start date for old reports",
|
||||
"Add scheduling end date",
|
||||
"Add schedulinng custom interval frequency",
|
||||
"Add scheduling custom interval amount",
|
||||
"Add workdays only flag to report",
|
||||
"create report dashboards table",
|
||||
"Add index report_dashboards.report_id",
|
||||
"Migrate report fields into report_dashboards",
|
||||
"Add formats option to the report",
|
||||
"Migrate reports with csv enabled",
|
||||
"Migrate ancient reports",
|
||||
"Add created column in report_dashboards",
|
||||
"Add scale_factor to the report",
|
||||
"Alter scale_factor from TINYINT to SMALLINT",
|
||||
"Add uid column to report",
|
||||
"Add unique index reports_org_id_uid",
|
||||
"Add pdf show template variable values to the report",
|
||||
"Add pdf combine in one file",
|
||||
"Add pdf theme to report settings table",
|
||||
"Add email subject to the report",
|
||||
"Populate email subject with report name",
|
||||
"Add embedded image theme to report settings table",
|
||||
"create team group table",
|
||||
"add index team_group.org_id",
|
||||
"add unique index team_group.org_id_team_id_group_id",
|
||||
"add index team_group.group_id",
|
||||
"create settings table",
|
||||
"add unique index settings.section_key",
|
||||
"add setting.encrypted_value",
|
||||
"migrate role names",
|
||||
"rename orgs roles",
|
||||
"remove duplicated org role",
|
||||
"migrate alerting role names",
|
||||
"data source permissions",
|
||||
"data source uid permissions",
|
||||
"rename permissions:delegate scope",
|
||||
"remove invalid managed permissions",
|
||||
"builtin role migration",
|
||||
"seed permissions migration",
|
||||
"managed permissions migration enterprise",
|
||||
"create table dashboard_public_email_share",
|
||||
"create table dashboard_public_magic_link",
|
||||
"create table dashboard_public_session",
|
||||
"add last_seen_at column"
|
||||
]
|
||||
@@ -325,3 +325,7 @@ func confToClientOptions(connectorConfig spannerdriver.ConnectorConfig) []option
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func (s *SpannerDialect) UnionDistinct() string {
|
||||
return "UNION DISTINCT"
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/login"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
|
||||
)
|
||||
|
||||
@@ -44,14 +45,14 @@ type PermissionsFilter interface {
|
||||
With() (string, []any)
|
||||
Where() (string, []any)
|
||||
|
||||
buildClauses()
|
||||
buildClauses(dialect migrator.Dialect)
|
||||
nestedFoldersSelectors(permSelector string, permSelectorArgs []any, leftTable string, Col string, rightTableCol string, orgID int64) (string, []any)
|
||||
}
|
||||
|
||||
// NewAccessControlDashboardPermissionFilter creates a new AccessControlDashboardPermissionFilter that is configured with specific actions calculated based on the dashboardaccess.PermissionType and query type
|
||||
// The filter is configured to use the new permissions filter (without subqueries) if the feature flag is enabled
|
||||
// The filter is configured to use the old permissions filter (with subqueries) if the feature flag is disabled
|
||||
func NewAccessControlDashboardPermissionFilter(user identity.Requester, permissionLevel dashboardaccess.PermissionType, queryType string, features featuremgmt.FeatureToggles, recursiveQueriesAreSupported bool) PermissionsFilter {
|
||||
func NewAccessControlDashboardPermissionFilter(user identity.Requester, permissionLevel dashboardaccess.PermissionType, queryType string, features featuremgmt.FeatureToggles, recursiveQueriesAreSupported bool, dialect migrator.Dialect) PermissionsFilter {
|
||||
needEdit := permissionLevel > dashboardaccess.PERMISSION_VIEW
|
||||
|
||||
var folderAction string
|
||||
@@ -129,7 +130,7 @@ func NewAccessControlDashboardPermissionFilter(user identity.Requester, permissi
|
||||
features: features, recursiveQueriesAreSupported: recursiveQueriesAreSupported,
|
||||
}
|
||||
}
|
||||
f.buildClauses()
|
||||
f.buildClauses(dialect)
|
||||
return f
|
||||
}
|
||||
|
||||
@@ -157,7 +158,7 @@ func (f *accessControlDashboardPermissionFilter) hasRequiredActions() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *accessControlDashboardPermissionFilter) buildClauses() {
|
||||
func (f *accessControlDashboardPermissionFilter) buildClauses(dialect migrator.Dialect) {
|
||||
if f.user == nil || f.user.IsNil() || !f.hasRequiredActions() {
|
||||
f.where = clause{string: "(1 = 0)"}
|
||||
return
|
||||
@@ -171,7 +172,7 @@ func (f *accessControlDashboardPermissionFilter) buildClauses() {
|
||||
}
|
||||
|
||||
orgID := f.user.GetOrgID()
|
||||
filter, params := accesscontrol.UserRolesFilter(orgID, userID, f.user.GetTeams(), accesscontrol.GetOrgRoles(f.user))
|
||||
filter, params := accesscontrol.UserRolesFilter(orgID, userID, f.user.GetTeams(), accesscontrol.GetOrgRoles(f.user), dialect)
|
||||
rolesFilter := " AND role_id IN(SELECT id FROM role " + filter + ") "
|
||||
var args []any
|
||||
builder := strings.Builder{}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/login"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
)
|
||||
|
||||
type accessControlDashboardPermissionFilterNoFolderSubquery struct {
|
||||
@@ -25,7 +26,7 @@ func (f *accessControlDashboardPermissionFilterNoFolderSubquery) LeftJoin() stri
|
||||
return " dashboard AS folder ON dashboard.org_id = folder.org_id AND dashboard.folder_id = folder.id"
|
||||
}
|
||||
|
||||
func (f *accessControlDashboardPermissionFilterNoFolderSubquery) buildClauses() {
|
||||
func (f *accessControlDashboardPermissionFilterNoFolderSubquery) buildClauses(dialect migrator.Dialect) {
|
||||
if f.user == nil || f.user.IsNil() || len(f.user.GetPermissions()) == 0 {
|
||||
f.where = clause{string: "(1 = 0)"}
|
||||
return
|
||||
@@ -39,7 +40,7 @@ func (f *accessControlDashboardPermissionFilterNoFolderSubquery) buildClauses()
|
||||
}
|
||||
|
||||
orgID := f.user.GetOrgID()
|
||||
filter, params := accesscontrol.UserRolesFilter(orgID, userID, f.user.GetTeams(), accesscontrol.GetOrgRoles(f.user))
|
||||
filter, params := accesscontrol.UserRolesFilter(orgID, userID, f.user.GetTeams(), accesscontrol.GetOrgRoles(f.user), dialect)
|
||||
rolesFilter := " AND role_id IN(SELECT id FROM role " + filter + ") "
|
||||
var args []any
|
||||
builder := strings.Builder{}
|
||||
|
||||
@@ -183,7 +183,7 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
t.Run(tt.desc+" with features "+strings.Join(keys, ","), func(t *testing.T) {
|
||||
filter := permissions.NewAccessControlDashboardPermissionFilter(usr, tt.permission, tt.queryType, features, recursiveQueriesAreSupported)
|
||||
filter := permissions.NewAccessControlDashboardPermissionFilter(usr, tt.permission, tt.queryType, features, recursiveQueriesAreSupported, store.GetDialect())
|
||||
|
||||
var result int
|
||||
err = store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
|
||||
@@ -355,7 +355,7 @@ func TestIntegration_DashboardPermissionFilter_WithSelfContainedPermissions(t *t
|
||||
keys = append(keys, k)
|
||||
}
|
||||
t.Run(tt.desc+" with features "+strings.Join(keys, ","), func(t *testing.T) {
|
||||
filter := permissions.NewAccessControlDashboardPermissionFilter(usr, tt.permission, tt.queryType, features, recursiveQueriesAreSupported)
|
||||
filter := permissions.NewAccessControlDashboardPermissionFilter(usr, tt.permission, tt.queryType, features, recursiveQueriesAreSupported, store.GetDialect())
|
||||
|
||||
var result int
|
||||
err = store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
|
||||
@@ -470,7 +470,7 @@ func TestIntegration_DashboardNestedPermissionFilter(t *testing.T) {
|
||||
db := setupNestedTest(t, usr, tc.permissions, orgID, features)
|
||||
recursiveQueriesAreSupported, err := db.RecursiveQueriesAreSupported()
|
||||
require.NoError(t, err)
|
||||
filter := permissions.NewAccessControlDashboardPermissionFilter(usr, tc.permission, tc.queryType, features, recursiveQueriesAreSupported)
|
||||
filter := permissions.NewAccessControlDashboardPermissionFilter(usr, tc.permission, tc.queryType, features, recursiveQueriesAreSupported, db.GetDialect())
|
||||
var result []string
|
||||
err = db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
|
||||
q, params := filter.Where()
|
||||
@@ -588,7 +588,7 @@ func TestIntegration_DashboardNestedPermissionFilter_WithSelfContainedPermission
|
||||
db := setupNestedTest(t, helperUser, []accesscontrol.Permission{}, orgID, features)
|
||||
recursiveQueriesAreSupported, err := db.RecursiveQueriesAreSupported()
|
||||
require.NoError(t, err)
|
||||
filter := permissions.NewAccessControlDashboardPermissionFilter(usr, tc.permission, tc.queryType, features, recursiveQueriesAreSupported)
|
||||
filter := permissions.NewAccessControlDashboardPermissionFilter(usr, tc.permission, tc.queryType, features, recursiveQueriesAreSupported, db.GetDialect())
|
||||
var result []string
|
||||
err = db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
|
||||
q, params := filter.Where()
|
||||
@@ -707,7 +707,7 @@ func TestIntegration_DashboardNestedPermissionFilter_WithActionSets(t *testing.T
|
||||
db := setupNestedTest(t, usr, tc.signedInUserPermissions, orgID, features)
|
||||
recursiveQueriesAreSupported, err := db.RecursiveQueriesAreSupported()
|
||||
require.NoError(t, err)
|
||||
filter := permissions.NewAccessControlDashboardPermissionFilter(usr, tc.permission, tc.queryType, features, recursiveQueriesAreSupported)
|
||||
filter := permissions.NewAccessControlDashboardPermissionFilter(usr, tc.permission, tc.queryType, features, recursiveQueriesAreSupported, db.GetDialect())
|
||||
var result []string
|
||||
err = db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
|
||||
q, params := filter.Where()
|
||||
|
||||
@@ -56,7 +56,7 @@ func benchmarkDashboardPermissionFilter(b *testing.B, numUsers, numDashboards, n
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
filter := permissions.NewAccessControlDashboardPermissionFilter(&usr, dashboardaccess.PERMISSION_VIEW, "", features, recursiveQueriesAreSupported)
|
||||
filter := permissions.NewAccessControlDashboardPermissionFilter(&usr, dashboardaccess.PERMISSION_VIEW, "", features, recursiveQueriesAreSupported, store.GetDialect())
|
||||
var result int
|
||||
err := store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
|
||||
q, params := filter.Where()
|
||||
|
||||
@@ -334,6 +334,7 @@ func TestBuilder_RBAC(t *testing.T) {
|
||||
"",
|
||||
tc.features,
|
||||
recursiveQueriesAreSupported,
|
||||
store.GetDialect(),
|
||||
),
|
||||
},
|
||||
Dialect: store.GetDialect(),
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
@@ -140,14 +141,18 @@ func (sess *DBSession) InsertId(bean any, dialect migrator.Dialect) error {
|
||||
}
|
||||
|
||||
func (sess *DBSession) WithReturningID(driverName string, query string, args []any) (int64, error) {
|
||||
supported := driverName != migrator.Postgres
|
||||
var id int64
|
||||
if !supported {
|
||||
if driverName == migrator.Postgres {
|
||||
query = fmt.Sprintf("%s RETURNING id", query)
|
||||
if _, err := sess.SQL(query, args...).Get(&id); err != nil {
|
||||
return id, err
|
||||
}
|
||||
} else {
|
||||
if driverName == migrator.Spanner {
|
||||
// Only works with INSERT statements.
|
||||
query = fmt.Sprintf("%s THEN RETURN id", query)
|
||||
}
|
||||
|
||||
sqlOrArgs := append([]any{query}, args...)
|
||||
res, err := sess.Exec(sqlOrArgs...)
|
||||
if err != nil {
|
||||
|
||||
@@ -82,13 +82,13 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida
|
||||
createdFromLegacy, err := d.legacy.Create(ctx, in, createValidation, options)
|
||||
if err != nil {
|
||||
log.Error("unable to create object in legacy storage", "err", err)
|
||||
return createdFromLegacy, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdCopy := createdFromLegacy.DeepCopyObject()
|
||||
accCreated, err := meta.Accessor(createdCopy)
|
||||
if err != nil {
|
||||
return createdFromLegacy, err
|
||||
return nil, err
|
||||
}
|
||||
accCreated.SetResourceVersion("")
|
||||
accCreated.SetUID("")
|
||||
@@ -105,13 +105,13 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida
|
||||
if err != nil {
|
||||
log.Error("unable to cleanup object in legacy storage", "err", err)
|
||||
}
|
||||
return storageObj, errObjectSt
|
||||
return nil, errObjectSt
|
||||
}
|
||||
|
||||
if d.readUnified {
|
||||
return storageObj, errObjectSt
|
||||
return storageObj, nil
|
||||
}
|
||||
return createdFromLegacy, err
|
||||
return createdFromLegacy, nil
|
||||
}
|
||||
|
||||
func (d *dualWriter) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
@@ -123,19 +123,19 @@ func (d *dualWriter) Delete(ctx context.Context, name string, deleteValidation r
|
||||
// as they would not be able to see the object in unistore anymore.
|
||||
objFromLegacy, asyncLegacy, err := d.legacy.Delete(ctx, name, deleteValidation, options)
|
||||
if err != nil && (!d.readUnified || !d.errorIsOK && !apierrors.IsNotFound(err)) {
|
||||
return objFromLegacy, asyncLegacy, err
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
objFromStorage, asyncStorage, err := d.unified.Delete(ctx, name, deleteValidation, options)
|
||||
if err != nil && apierrors.IsNotFound(err) || d.errorIsOK {
|
||||
err = nil // clear the error
|
||||
if err != nil && !apierrors.IsNotFound(err) && !d.errorIsOK {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if d.readUnified {
|
||||
return objFromStorage, asyncStorage, err
|
||||
return objFromStorage, asyncStorage, nil
|
||||
}
|
||||
|
||||
return objFromLegacy, asyncLegacy, err
|
||||
return objFromLegacy, asyncLegacy, nil
|
||||
}
|
||||
|
||||
// Update overrides the behavior of the generic DualWriter and writes first to Storage and then to LegacyStorage.
|
||||
@@ -155,7 +155,7 @@ func (d *dualWriter) Update(ctx context.Context, name string, objInfo rest.Updat
|
||||
objFromLegacy, createdLegacy, err := d.legacy.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options)
|
||||
if err != nil {
|
||||
log.With("object", objFromLegacy).Error("could not update in legacy storage", "err", err)
|
||||
return objFromLegacy, createdLegacy, err
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
objFromStorage, created, err := d.unified.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options)
|
||||
@@ -164,13 +164,14 @@ func (d *dualWriter) Update(ctx context.Context, name string, objInfo rest.Updat
|
||||
if d.errorIsOK {
|
||||
return objFromLegacy, createdLegacy, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if d.readUnified {
|
||||
return objFromStorage, created, err
|
||||
return objFromStorage, created, nil
|
||||
}
|
||||
|
||||
return objFromLegacy, createdLegacy, err
|
||||
return objFromLegacy, createdLegacy, nil
|
||||
}
|
||||
|
||||
// DeleteCollection overrides the behavior of the generic DualWriter and deletes from both LegacyStorage and Storage.
|
||||
@@ -186,7 +187,7 @@ func (d *dualWriter) DeleteCollection(ctx context.Context, deleteValidation rest
|
||||
deletedLegacy, err := d.legacy.DeleteCollection(ctx, deleteValidation, options, listOptions)
|
||||
if err != nil {
|
||||
log.With("deleted", deletedLegacy).Error("failed to delete collection successfully from legacy storage", "err", err)
|
||||
return deletedLegacy, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deletedStorage, err := d.unified.DeleteCollection(ctx, deleteValidation, options, listOptions)
|
||||
@@ -195,13 +196,14 @@ func (d *dualWriter) DeleteCollection(ctx context.Context, deleteValidation rest
|
||||
if d.errorIsOK {
|
||||
return deletedLegacy, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if d.readUnified {
|
||||
return deletedStorage, err
|
||||
return deletedStorage, nil
|
||||
}
|
||||
|
||||
return deletedLegacy, err
|
||||
return deletedLegacy, nil
|
||||
}
|
||||
|
||||
func (d *dualWriter) Destroy() {
|
||||
|
||||
@@ -202,7 +202,7 @@ require (
|
||||
github.com/googleapis/go-sql-spanner v1.11.1 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250310104713-16b885f1c79e // indirect
|
||||
github.com/grafana/authlib v0.0.0-20250305132846-37f49eb947fa // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040 // indirect
|
||||
|
||||
@@ -1253,8 +1253,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356 h1:71o8Bxw/wg+aBRUASiGux67gDEUC2bqq3I+x2hw8eIc=
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/alerting v0.0.0-20250310104713-16b885f1c79e h1:noJzp/qZGIto4XdZkvj2EKQ1bQeqCRs0bedxdJN17sQ=
|
||||
github.com/grafana/alerting v0.0.0-20250310104713-16b885f1c79e/go.mod h1:HfvjmU3UqCIpoy9Z2wgKGrZ4A5vz+yQlP9ZXvCfEkiA=
|
||||
github.com/grafana/authlib v0.0.0-20250305132846-37f49eb947fa h1:08Wh/svkv8WpDuOBBKAzSPa14gKjYLZvQJsHWXLjPuc=
|
||||
github.com/grafana/authlib v0.0.0-20250305132846-37f49eb947fa/go.mod h1:XVpdLhaeYqz414FmGnW00/0vTe1x8c0GRH3KaeRtyg0=
|
||||
github.com/grafana/authlib/types v0.0.0-20250224151205-5ef97131cc82 h1:DnRUYiAotHXnrfYJCvhH1NkiyWVcPm5Pd+P7Ugqt/d8=
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestDeleteWithSuggestionAndConflict(t *testing.T) {
|
||||
type resourceClientMock struct {
|
||||
resource.ResourceStoreClient
|
||||
resource.ResourceIndexClient
|
||||
resource.RepositoryIndexClient
|
||||
resource.ManagedObjectIndexClient
|
||||
resource.BulkStoreClient
|
||||
resource.BlobStoreClient
|
||||
resource.DiagnosticsClient
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
type ResourceClient interface {
|
||||
ResourceStoreClient
|
||||
ResourceIndexClient
|
||||
RepositoryIndexClient
|
||||
ManagedObjectIndexClient
|
||||
BulkStoreClient
|
||||
BlobStoreClient
|
||||
DiagnosticsClient
|
||||
@@ -34,7 +34,7 @@ type ResourceClient interface {
|
||||
type resourceClient struct {
|
||||
ResourceStoreClient
|
||||
ResourceIndexClient
|
||||
RepositoryIndexClient
|
||||
ManagedObjectIndexClient
|
||||
BulkStoreClient
|
||||
BlobStoreClient
|
||||
DiagnosticsClient
|
||||
@@ -43,12 +43,12 @@ type resourceClient struct {
|
||||
func NewLegacyResourceClient(channel *grpc.ClientConn) ResourceClient {
|
||||
cc := grpchan.InterceptClientConn(channel, grpcUtils.UnaryClientInterceptor, grpcUtils.StreamClientInterceptor)
|
||||
return &resourceClient{
|
||||
ResourceStoreClient: NewResourceStoreClient(cc),
|
||||
ResourceIndexClient: NewResourceIndexClient(cc),
|
||||
RepositoryIndexClient: NewRepositoryIndexClient(cc),
|
||||
BulkStoreClient: NewBulkStoreClient(cc),
|
||||
BlobStoreClient: NewBlobStoreClient(cc),
|
||||
DiagnosticsClient: NewDiagnosticsClient(cc),
|
||||
ResourceStoreClient: NewResourceStoreClient(cc),
|
||||
ResourceIndexClient: NewResourceIndexClient(cc),
|
||||
ManagedObjectIndexClient: NewManagedObjectIndexClient(cc),
|
||||
BulkStoreClient: NewBulkStoreClient(cc),
|
||||
BlobStoreClient: NewBlobStoreClient(cc),
|
||||
DiagnosticsClient: NewDiagnosticsClient(cc),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func NewLocalResourceClient(server ResourceServer) ResourceClient {
|
||||
for _, desc := range []*grpc.ServiceDesc{
|
||||
&ResourceStore_ServiceDesc,
|
||||
&ResourceIndex_ServiceDesc,
|
||||
&RepositoryIndex_ServiceDesc,
|
||||
&ManagedObjectIndex_ServiceDesc,
|
||||
&BlobStore_ServiceDesc,
|
||||
&BulkStore_ServiceDesc,
|
||||
&Diagnostics_ServiceDesc,
|
||||
@@ -82,12 +82,12 @@ func NewLocalResourceClient(server ResourceServer) ResourceClient {
|
||||
|
||||
cc := grpchan.InterceptClientConn(channel, clientInt.UnaryClientInterceptor, clientInt.StreamClientInterceptor)
|
||||
return &resourceClient{
|
||||
ResourceStoreClient: NewResourceStoreClient(cc),
|
||||
ResourceIndexClient: NewResourceIndexClient(cc),
|
||||
RepositoryIndexClient: NewRepositoryIndexClient(cc),
|
||||
BulkStoreClient: NewBulkStoreClient(cc),
|
||||
BlobStoreClient: NewBlobStoreClient(cc),
|
||||
DiagnosticsClient: NewDiagnosticsClient(cc),
|
||||
ResourceStoreClient: NewResourceStoreClient(cc),
|
||||
ResourceIndexClient: NewResourceIndexClient(cc),
|
||||
ManagedObjectIndexClient: NewManagedObjectIndexClient(cc),
|
||||
BulkStoreClient: NewBulkStoreClient(cc),
|
||||
BlobStoreClient: NewBlobStoreClient(cc),
|
||||
DiagnosticsClient: NewDiagnosticsClient(cc),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,12 +124,12 @@ func NewRemoteResourceClient(tracer tracing.Tracer, conn *grpc.ClientConn, cfg R
|
||||
|
||||
cc := grpchan.InterceptClientConn(conn, clientInt.UnaryClientInterceptor, clientInt.StreamClientInterceptor)
|
||||
return &resourceClient{
|
||||
ResourceStoreClient: NewResourceStoreClient(cc),
|
||||
ResourceIndexClient: NewResourceIndexClient(cc),
|
||||
BlobStoreClient: NewBlobStoreClient(cc),
|
||||
BulkStoreClient: NewBulkStoreClient(cc),
|
||||
RepositoryIndexClient: NewRepositoryIndexClient(cc),
|
||||
DiagnosticsClient: NewDiagnosticsClient(cc),
|
||||
ResourceStoreClient: NewResourceStoreClient(cc),
|
||||
ResourceIndexClient: NewResourceIndexClient(cc),
|
||||
BlobStoreClient: NewBlobStoreClient(cc),
|
||||
BulkStoreClient: NewBulkStoreClient(cc),
|
||||
ManagedObjectIndexClient: NewManagedObjectIndexClient(cc),
|
||||
DiagnosticsClient: NewDiagnosticsClient(cc),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -107,10 +107,22 @@ type IndexableDocument struct {
|
||||
// When the resource is managed by an upstream repository
|
||||
Manager *utils.ManagerProperties `json:"manager,omitempty"`
|
||||
|
||||
// indexed only field for faceting manager info
|
||||
ManagedBy string `json:"managedBy,omitempty"`
|
||||
|
||||
// When the manager knows about file paths
|
||||
Source *utils.SourceProperties `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
func (m *IndexableDocument) UpdateCopyFields() *IndexableDocument {
|
||||
m.TitleNgram = m.Title
|
||||
m.TitlePhrase = strings.ToLower(m.Title) // Lowercase for case-insensitive sorting ?? in the analyzer?
|
||||
if m.Manager != nil {
|
||||
m.ManagedBy = fmt.Sprintf("%s:%s", m.Manager.Kind, m.Manager.Identity)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *IndexableDocument) Type() string {
|
||||
return m.Key.Resource
|
||||
}
|
||||
@@ -169,20 +181,19 @@ func NewIndexableDocument(key *ResourceKey, rv int64, obj utils.GrafanaMetaAcces
|
||||
}
|
||||
}
|
||||
doc := &IndexableDocument{
|
||||
Key: key,
|
||||
RV: rv,
|
||||
Name: key.Name,
|
||||
Title: title, // We always want *something* to display
|
||||
TitleNgram: title,
|
||||
TitlePhrase: strings.ToLower(title), // Lowercase for case-insensitive sorting
|
||||
Labels: obj.GetLabels(),
|
||||
Folder: obj.GetFolder(),
|
||||
CreatedBy: obj.GetCreatedBy(),
|
||||
UpdatedBy: obj.GetUpdatedBy(),
|
||||
Key: key,
|
||||
RV: rv,
|
||||
Name: key.Name,
|
||||
Title: title, // We always want *something* to display
|
||||
Labels: obj.GetLabels(),
|
||||
Folder: obj.GetFolder(),
|
||||
CreatedBy: obj.GetCreatedBy(),
|
||||
UpdatedBy: obj.GetUpdatedBy(),
|
||||
}
|
||||
m, ok := obj.GetManagerProperties()
|
||||
if ok {
|
||||
doc.Manager = &m
|
||||
doc.ManagedBy = fmt.Sprintf("%s:%s", m.Kind, m.Identity)
|
||||
}
|
||||
s, ok := obj.GetSourceProperties()
|
||||
if ok {
|
||||
@@ -196,7 +207,7 @@ func NewIndexableDocument(key *ResourceKey, rv int64, obj utils.GrafanaMetaAcces
|
||||
if err != nil && tt != nil {
|
||||
doc.Updated = tt.UnixMilli()
|
||||
}
|
||||
return doc
|
||||
return doc.UpdateCopyFields()
|
||||
}
|
||||
|
||||
func StandardDocumentBuilder() DocumentBuilder {
|
||||
@@ -280,6 +291,7 @@ const SEARCH_FIELD_CREATED_BY = "createdBy"
|
||||
const SEARCH_FIELD_UPDATED = "updated"
|
||||
const SEARCH_FIELD_UPDATED_BY = "updatedBy"
|
||||
|
||||
const SEARCH_FIELD_MANAGED_BY = "managedBy" // {kind}:{id}
|
||||
const SEARCH_FIELD_MANAGER_KIND = "manager.kind"
|
||||
const SEARCH_FIELD_MANAGER_ID = "manager.id"
|
||||
const SEARCH_FIELD_SOURCE_PATH = "source.path"
|
||||
|
||||
@@ -35,9 +35,9 @@ func TestStandardDocumentBuilder(t *testing.T) {
|
||||
},
|
||||
"name": "test1",
|
||||
"rv": 10,
|
||||
"title": "test playlist unified storage",
|
||||
"title_phrase": "test playlist unified storage",
|
||||
"title_ngram": "test playlist unified storage",
|
||||
"title": "Test Playlist from Unified Storage",
|
||||
"title_ngram": "Test Playlist from Unified Storage",
|
||||
"title_phrase": "test playlist from unified storage",
|
||||
"created": 1717236672000,
|
||||
"createdBy": "user:ABC",
|
||||
"updatedBy": "user:XYZ",
|
||||
@@ -45,6 +45,7 @@ func TestStandardDocumentBuilder(t *testing.T) {
|
||||
"kind": "repo",
|
||||
"id": "something"
|
||||
},
|
||||
"managedBy": "repo:something",
|
||||
"source": {
|
||||
"path": "path/in/system.json",
|
||||
"checksum": "xyz"
|
||||
|
||||
@@ -131,7 +131,7 @@ require (
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/googleapis/go-sql-spanner v1.11.1 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250310104713-16b885f1c79e // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
github.com/grafana/grafana-app-sdk/logging v0.30.0 // indirect
|
||||
github.com/grafana/grafana-aws-sdk v0.31.5 // indirect
|
||||
|
||||
@@ -1150,8 +1150,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
|
||||
github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356 h1:71o8Bxw/wg+aBRUASiGux67gDEUC2bqq3I+x2hw8eIc=
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/alerting v0.0.0-20250310104713-16b885f1c79e h1:noJzp/qZGIto4XdZkvj2EKQ1bQeqCRs0bedxdJN17sQ=
|
||||
github.com/grafana/alerting v0.0.0-20250310104713-16b885f1c79e/go.mod h1:HfvjmU3UqCIpoy9Z2wgKGrZ4A5vz+yQlP9ZXvCfEkiA=
|
||||
github.com/grafana/authlib v0.0.0-20250305132846-37f49eb947fa h1:08Wh/svkv8WpDuOBBKAzSPa14gKjYLZvQJsHWXLjPuc=
|
||||
github.com/grafana/authlib v0.0.0-20250305132846-37f49eb947fa/go.mod h1:XVpdLhaeYqz414FmGnW00/0vTe1x8c0GRH3KaeRtyg0=
|
||||
github.com/grafana/authlib/types v0.0.0-20250224151205-5ef97131cc82 h1:DnRUYiAotHXnrfYJCvhH1NkiyWVcPm5Pd+P7Ugqt/d8=
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -503,18 +503,21 @@ message ResourceSearchResponse {
|
||||
|
||||
// List items within a resource type & repository name
|
||||
// Access control is managed above this request
|
||||
message ListRepositoryObjectsRequest {
|
||||
message ListManagedObjectsRequest {
|
||||
// Starting from the requested page (other query parameters must match!)
|
||||
string next_page_token = 1;
|
||||
|
||||
// Namespace (tenant)
|
||||
string namespace = 2;
|
||||
|
||||
// The name of the repository
|
||||
string name = 3;
|
||||
// The manager type (eg, terraform vs repo)
|
||||
string kind = 3;
|
||||
|
||||
// The name of the manager
|
||||
string id = 4;
|
||||
}
|
||||
|
||||
message ListRepositoryObjectsResponse {
|
||||
message ListManagedObjectsResponse {
|
||||
message Item {
|
||||
// The resource object key
|
||||
ResourceKey object = 1;
|
||||
@@ -546,22 +549,25 @@ message ListRepositoryObjectsResponse {
|
||||
}
|
||||
|
||||
// Count the items that exist with
|
||||
message CountRepositoryObjectsRequest {
|
||||
message CountManagedObjectsRequest {
|
||||
// Namespace (tenant)
|
||||
string namespace = 1;
|
||||
|
||||
// The name of the repository
|
||||
// empty to count across all repositories
|
||||
string name = 2;
|
||||
// Manager kind: terraform, plugin, kubectl, repo
|
||||
string kind = 2;
|
||||
|
||||
// Name of the manager (meaningful inside kind)
|
||||
string id = 3;
|
||||
}
|
||||
|
||||
// Count the items that exist with
|
||||
message CountRepositoryObjectsResponse {
|
||||
message CountManagedObjectsResponse {
|
||||
message ResourceCount {
|
||||
string repository = 1;
|
||||
string group = 2;
|
||||
string resource = 3;
|
||||
int64 count = 4;
|
||||
string kind = 1;
|
||||
string id = 2;
|
||||
string group = 3;
|
||||
string resource = 4;
|
||||
int64 count = 5;
|
||||
}
|
||||
|
||||
// Resource counts
|
||||
@@ -834,14 +840,14 @@ service ResourceIndex {
|
||||
rpc GetStats(ResourceStatsRequest) returns (ResourceStatsResponse);
|
||||
}
|
||||
|
||||
// Query repository info from the search index.
|
||||
// Query managed objects
|
||||
// Results access control is based on access to the repository *not* the items
|
||||
service RepositoryIndex {
|
||||
service ManagedObjectIndex {
|
||||
// Describe how many resources of each type exist within a repository
|
||||
rpc CountRepositoryObjects(CountRepositoryObjectsRequest) returns (CountRepositoryObjectsResponse);
|
||||
rpc CountManagedObjects(CountManagedObjectsRequest) returns (CountManagedObjectsResponse);
|
||||
|
||||
// List the resources of a specific kind within a repository
|
||||
rpc ListRepositoryObjects(ListRepositoryObjectsRequest) returns (ListRepositoryObjectsResponse);
|
||||
rpc ListManagedObjects(ListManagedObjectsRequest) returns (ListManagedObjectsResponse);
|
||||
}
|
||||
|
||||
service BlobStore {
|
||||
|
||||
@@ -650,136 +650,136 @@ var ResourceIndex_ServiceDesc = grpc.ServiceDesc{
|
||||
}
|
||||
|
||||
const (
|
||||
RepositoryIndex_CountRepositoryObjects_FullMethodName = "/resource.RepositoryIndex/CountRepositoryObjects"
|
||||
RepositoryIndex_ListRepositoryObjects_FullMethodName = "/resource.RepositoryIndex/ListRepositoryObjects"
|
||||
ManagedObjectIndex_CountManagedObjects_FullMethodName = "/resource.ManagedObjectIndex/CountManagedObjects"
|
||||
ManagedObjectIndex_ListManagedObjects_FullMethodName = "/resource.ManagedObjectIndex/ListManagedObjects"
|
||||
)
|
||||
|
||||
// RepositoryIndexClient is the client API for RepositoryIndex service.
|
||||
// ManagedObjectIndexClient is the client API for ManagedObjectIndex service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// Query repository info from the search index.
|
||||
// Query managed objects
|
||||
// Results access control is based on access to the repository *not* the items
|
||||
type RepositoryIndexClient interface {
|
||||
type ManagedObjectIndexClient interface {
|
||||
// Describe how many resources of each type exist within a repository
|
||||
CountRepositoryObjects(ctx context.Context, in *CountRepositoryObjectsRequest, opts ...grpc.CallOption) (*CountRepositoryObjectsResponse, error)
|
||||
CountManagedObjects(ctx context.Context, in *CountManagedObjectsRequest, opts ...grpc.CallOption) (*CountManagedObjectsResponse, error)
|
||||
// List the resources of a specific kind within a repository
|
||||
ListRepositoryObjects(ctx context.Context, in *ListRepositoryObjectsRequest, opts ...grpc.CallOption) (*ListRepositoryObjectsResponse, error)
|
||||
ListManagedObjects(ctx context.Context, in *ListManagedObjectsRequest, opts ...grpc.CallOption) (*ListManagedObjectsResponse, error)
|
||||
}
|
||||
|
||||
type repositoryIndexClient struct {
|
||||
type managedObjectIndexClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewRepositoryIndexClient(cc grpc.ClientConnInterface) RepositoryIndexClient {
|
||||
return &repositoryIndexClient{cc}
|
||||
func NewManagedObjectIndexClient(cc grpc.ClientConnInterface) ManagedObjectIndexClient {
|
||||
return &managedObjectIndexClient{cc}
|
||||
}
|
||||
|
||||
func (c *repositoryIndexClient) CountRepositoryObjects(ctx context.Context, in *CountRepositoryObjectsRequest, opts ...grpc.CallOption) (*CountRepositoryObjectsResponse, error) {
|
||||
func (c *managedObjectIndexClient) CountManagedObjects(ctx context.Context, in *CountManagedObjectsRequest, opts ...grpc.CallOption) (*CountManagedObjectsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CountRepositoryObjectsResponse)
|
||||
err := c.cc.Invoke(ctx, RepositoryIndex_CountRepositoryObjects_FullMethodName, in, out, cOpts...)
|
||||
out := new(CountManagedObjectsResponse)
|
||||
err := c.cc.Invoke(ctx, ManagedObjectIndex_CountManagedObjects_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *repositoryIndexClient) ListRepositoryObjects(ctx context.Context, in *ListRepositoryObjectsRequest, opts ...grpc.CallOption) (*ListRepositoryObjectsResponse, error) {
|
||||
func (c *managedObjectIndexClient) ListManagedObjects(ctx context.Context, in *ListManagedObjectsRequest, opts ...grpc.CallOption) (*ListManagedObjectsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListRepositoryObjectsResponse)
|
||||
err := c.cc.Invoke(ctx, RepositoryIndex_ListRepositoryObjects_FullMethodName, in, out, cOpts...)
|
||||
out := new(ListManagedObjectsResponse)
|
||||
err := c.cc.Invoke(ctx, ManagedObjectIndex_ListManagedObjects_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RepositoryIndexServer is the server API for RepositoryIndex service.
|
||||
// All implementations should embed UnimplementedRepositoryIndexServer
|
||||
// ManagedObjectIndexServer is the server API for ManagedObjectIndex service.
|
||||
// All implementations should embed UnimplementedManagedObjectIndexServer
|
||||
// for forward compatibility
|
||||
//
|
||||
// Query repository info from the search index.
|
||||
// Query managed objects
|
||||
// Results access control is based on access to the repository *not* the items
|
||||
type RepositoryIndexServer interface {
|
||||
type ManagedObjectIndexServer interface {
|
||||
// Describe how many resources of each type exist within a repository
|
||||
CountRepositoryObjects(context.Context, *CountRepositoryObjectsRequest) (*CountRepositoryObjectsResponse, error)
|
||||
CountManagedObjects(context.Context, *CountManagedObjectsRequest) (*CountManagedObjectsResponse, error)
|
||||
// List the resources of a specific kind within a repository
|
||||
ListRepositoryObjects(context.Context, *ListRepositoryObjectsRequest) (*ListRepositoryObjectsResponse, error)
|
||||
ListManagedObjects(context.Context, *ListManagedObjectsRequest) (*ListManagedObjectsResponse, error)
|
||||
}
|
||||
|
||||
// UnimplementedRepositoryIndexServer should be embedded to have forward compatible implementations.
|
||||
type UnimplementedRepositoryIndexServer struct {
|
||||
// UnimplementedManagedObjectIndexServer should be embedded to have forward compatible implementations.
|
||||
type UnimplementedManagedObjectIndexServer struct {
|
||||
}
|
||||
|
||||
func (UnimplementedRepositoryIndexServer) CountRepositoryObjects(context.Context, *CountRepositoryObjectsRequest) (*CountRepositoryObjectsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CountRepositoryObjects not implemented")
|
||||
func (UnimplementedManagedObjectIndexServer) CountManagedObjects(context.Context, *CountManagedObjectsRequest) (*CountManagedObjectsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CountManagedObjects not implemented")
|
||||
}
|
||||
func (UnimplementedRepositoryIndexServer) ListRepositoryObjects(context.Context, *ListRepositoryObjectsRequest) (*ListRepositoryObjectsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListRepositoryObjects not implemented")
|
||||
func (UnimplementedManagedObjectIndexServer) ListManagedObjects(context.Context, *ListManagedObjectsRequest) (*ListManagedObjectsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListManagedObjects not implemented")
|
||||
}
|
||||
|
||||
// UnsafeRepositoryIndexServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to RepositoryIndexServer will
|
||||
// UnsafeManagedObjectIndexServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to ManagedObjectIndexServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeRepositoryIndexServer interface {
|
||||
mustEmbedUnimplementedRepositoryIndexServer()
|
||||
type UnsafeManagedObjectIndexServer interface {
|
||||
mustEmbedUnimplementedManagedObjectIndexServer()
|
||||
}
|
||||
|
||||
func RegisterRepositoryIndexServer(s grpc.ServiceRegistrar, srv RepositoryIndexServer) {
|
||||
s.RegisterService(&RepositoryIndex_ServiceDesc, srv)
|
||||
func RegisterManagedObjectIndexServer(s grpc.ServiceRegistrar, srv ManagedObjectIndexServer) {
|
||||
s.RegisterService(&ManagedObjectIndex_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _RepositoryIndex_CountRepositoryObjects_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CountRepositoryObjectsRequest)
|
||||
func _ManagedObjectIndex_CountManagedObjects_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CountManagedObjectsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RepositoryIndexServer).CountRepositoryObjects(ctx, in)
|
||||
return srv.(ManagedObjectIndexServer).CountManagedObjects(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RepositoryIndex_CountRepositoryObjects_FullMethodName,
|
||||
FullMethod: ManagedObjectIndex_CountManagedObjects_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RepositoryIndexServer).CountRepositoryObjects(ctx, req.(*CountRepositoryObjectsRequest))
|
||||
return srv.(ManagedObjectIndexServer).CountManagedObjects(ctx, req.(*CountManagedObjectsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RepositoryIndex_ListRepositoryObjects_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListRepositoryObjectsRequest)
|
||||
func _ManagedObjectIndex_ListManagedObjects_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListManagedObjectsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RepositoryIndexServer).ListRepositoryObjects(ctx, in)
|
||||
return srv.(ManagedObjectIndexServer).ListManagedObjects(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RepositoryIndex_ListRepositoryObjects_FullMethodName,
|
||||
FullMethod: ManagedObjectIndex_ListManagedObjects_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RepositoryIndexServer).ListRepositoryObjects(ctx, req.(*ListRepositoryObjectsRequest))
|
||||
return srv.(ManagedObjectIndexServer).ListManagedObjects(ctx, req.(*ListManagedObjectsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// RepositoryIndex_ServiceDesc is the grpc.ServiceDesc for RepositoryIndex service.
|
||||
// ManagedObjectIndex_ServiceDesc is the grpc.ServiceDesc for ManagedObjectIndex service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var RepositoryIndex_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "resource.RepositoryIndex",
|
||||
HandlerType: (*RepositoryIndexServer)(nil),
|
||||
var ManagedObjectIndex_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "resource.ManagedObjectIndex",
|
||||
HandlerType: (*ManagedObjectIndexServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "CountRepositoryObjects",
|
||||
Handler: _RepositoryIndex_CountRepositoryObjects_Handler,
|
||||
MethodName: "CountManagedObjects",
|
||||
Handler: _ManagedObjectIndex_CountManagedObjects_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListRepositoryObjects",
|
||||
Handler: _RepositoryIndex_ListRepositoryObjects_Handler,
|
||||
MethodName: "ListManagedObjects",
|
||||
Handler: _ManagedObjectIndex_ListManagedObjects_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
|
||||
@@ -48,10 +48,10 @@ type ResourceIndex interface {
|
||||
Search(ctx context.Context, access types.AccessClient, req *ResourceSearchRequest, federate []ResourceIndex) (*ResourceSearchResponse, error)
|
||||
|
||||
// List within an response
|
||||
ListRepositoryObjects(ctx context.Context, req *ListRepositoryObjectsRequest) (*ListRepositoryObjectsResponse, error)
|
||||
ListManagedObjects(ctx context.Context, req *ListManagedObjectsRequest) (*ListManagedObjectsResponse, error)
|
||||
|
||||
// Counts the values in a repo
|
||||
CountRepositoryObjects(ctx context.Context) ([]*CountRepositoryObjectsResponse_ResourceCount, error)
|
||||
CountManagedObjects(ctx context.Context) ([]*CountManagedObjectsResponse_ResourceCount, error)
|
||||
|
||||
// Get the number of documents in the index
|
||||
DocCount(ctx context.Context, folder string) (int64, error)
|
||||
@@ -99,8 +99,8 @@ type searchSupport struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ ResourceIndexServer = (*searchSupport)(nil)
|
||||
_ RepositoryIndexServer = (*searchSupport)(nil)
|
||||
_ ResourceIndexServer = (*searchSupport)(nil)
|
||||
_ ManagedObjectIndexServer = (*searchSupport)(nil)
|
||||
)
|
||||
|
||||
func newSearchSupport(opts SearchOptions, storage StorageBackend, access types.AccessClient, blob BlobSupport, tracer trace.Tracer) (support *searchSupport, err error) {
|
||||
@@ -139,14 +139,14 @@ func newSearchSupport(opts SearchOptions, storage StorageBackend, access types.A
|
||||
return support, err
|
||||
}
|
||||
|
||||
func (s *searchSupport) ListRepositoryObjects(ctx context.Context, req *ListRepositoryObjectsRequest) (*ListRepositoryObjectsResponse, error) {
|
||||
func (s *searchSupport) ListManagedObjects(ctx context.Context, req *ListManagedObjectsRequest) (*ListManagedObjectsResponse, error) {
|
||||
if req.NextPageToken != "" {
|
||||
return &ListRepositoryObjectsResponse{
|
||||
return &ListManagedObjectsResponse{
|
||||
Error: NewBadRequestError("multiple pages not yet supported"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
rsp := &ListRepositoryObjectsResponse{}
|
||||
rsp := &ListManagedObjectsResponse{}
|
||||
stats, err := s.storage.GetResourceStats(ctx, req.Namespace, 0)
|
||||
if err != nil {
|
||||
rsp.Error = AsErrorResult(err)
|
||||
@@ -164,7 +164,7 @@ func (s *searchSupport) ListRepositoryObjects(ctx context.Context, req *ListRepo
|
||||
return rsp, nil
|
||||
}
|
||||
|
||||
kind, err := idx.ListRepositoryObjects(ctx, req)
|
||||
kind, err := idx.ListManagedObjects(ctx, req)
|
||||
if err != nil {
|
||||
rsp.Error = AsErrorResult(err)
|
||||
return rsp, nil
|
||||
@@ -179,15 +179,15 @@ func (s *searchSupport) ListRepositoryObjects(ctx context.Context, req *ListRepo
|
||||
}
|
||||
|
||||
// Sort based on path
|
||||
slices.SortFunc(rsp.Items, func(a, b *ListRepositoryObjectsResponse_Item) int {
|
||||
slices.SortFunc(rsp.Items, func(a, b *ListManagedObjectsResponse_Item) int {
|
||||
return cmp.Compare(a.Path, b.Path)
|
||||
})
|
||||
|
||||
return rsp, nil
|
||||
}
|
||||
|
||||
func (s *searchSupport) CountRepositoryObjects(ctx context.Context, req *CountRepositoryObjectsRequest) (*CountRepositoryObjectsResponse, error) {
|
||||
rsp := &CountRepositoryObjectsResponse{}
|
||||
func (s *searchSupport) CountManagedObjects(ctx context.Context, req *CountManagedObjectsRequest) (*CountManagedObjectsResponse, error) {
|
||||
rsp := &CountManagedObjectsResponse{}
|
||||
stats, err := s.storage.GetResourceStats(ctx, req.Namespace, 0)
|
||||
if err != nil {
|
||||
rsp.Error = AsErrorResult(err)
|
||||
@@ -205,27 +205,27 @@ func (s *searchSupport) CountRepositoryObjects(ctx context.Context, req *CountRe
|
||||
return rsp, nil
|
||||
}
|
||||
|
||||
counts, err := idx.CountRepositoryObjects(ctx)
|
||||
counts, err := idx.CountManagedObjects(ctx)
|
||||
if err != nil {
|
||||
rsp.Error = AsErrorResult(err)
|
||||
return rsp, nil
|
||||
}
|
||||
if req.Name == "" {
|
||||
if req.Id == "" {
|
||||
rsp.Items = append(rsp.Items, counts...)
|
||||
} else {
|
||||
for _, k := range counts {
|
||||
if k.Repository == req.Name {
|
||||
k.Repository = "" // avoid duplicate response metadata
|
||||
if k.Id == req.Id {
|
||||
rsp.Items = append(rsp.Items, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort based on repo/group/resource
|
||||
slices.SortFunc(rsp.Items, func(a, b *CountRepositoryObjectsResponse_ResourceCount) int {
|
||||
// Sort based on manager/group/resource
|
||||
slices.SortFunc(rsp.Items, func(a, b *CountManagedObjectsResponse_ResourceCount) int {
|
||||
return cmp.Or(
|
||||
cmp.Compare(a.Repository, b.Repository),
|
||||
cmp.Compare(a.Kind, b.Kind),
|
||||
cmp.Compare(a.Id, b.Id),
|
||||
cmp.Compare(a.Group, b.Group),
|
||||
cmp.Compare(a.Resource, b.Resource),
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ type ResourceServer interface {
|
||||
ResourceStoreServer
|
||||
BulkStoreServer
|
||||
ResourceIndexServer
|
||||
RepositoryIndexServer
|
||||
ManagedObjectIndexServer
|
||||
BlobStoreServer
|
||||
DiagnosticsServer
|
||||
}
|
||||
@@ -1105,12 +1105,12 @@ func (s *server) GetStats(ctx context.Context, req *ResourceStatsRequest) (*Reso
|
||||
return s.search.GetStats(ctx, req)
|
||||
}
|
||||
|
||||
func (s *server) ListRepositoryObjects(ctx context.Context, req *ListRepositoryObjectsRequest) (*ListRepositoryObjectsResponse, error) {
|
||||
return s.search.ListRepositoryObjects(ctx, req)
|
||||
func (s *server) ListManagedObjects(ctx context.Context, req *ListManagedObjectsRequest) (*ListManagedObjectsResponse, error) {
|
||||
return s.search.ListManagedObjects(ctx, req)
|
||||
}
|
||||
|
||||
func (s *server) CountRepositoryObjects(ctx context.Context, req *CountRepositoryObjectsRequest) (*CountRepositoryObjectsResponse, error) {
|
||||
return s.search.CountRepositoryObjects(ctx, req)
|
||||
func (s *server) CountManagedObjects(ctx context.Context, req *CountManagedObjectsRequest) (*CountManagedObjectsResponse, error) {
|
||||
return s.search.CountManagedObjects(ctx, req)
|
||||
}
|
||||
|
||||
// IsHealthy implements ResourceServer.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"title": "test playlist unified storage",
|
||||
"title": "Test Playlist from Unified Storage",
|
||||
"description": "description for the test playlist"
|
||||
}
|
||||
}
|
||||
@@ -263,6 +263,8 @@ type bleveIndex struct {
|
||||
|
||||
// Write implements resource.DocumentIndex.
|
||||
func (b *bleveIndex) Write(v *resource.IndexableDocument) error {
|
||||
v = v.UpdateCopyFields()
|
||||
|
||||
// remove references (for now!)
|
||||
v.References = nil
|
||||
if b.batch != nil {
|
||||
@@ -297,21 +299,33 @@ func (b *bleveIndex) Flush() (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *bleveIndex) ListRepositoryObjects(ctx context.Context, req *resource.ListRepositoryObjectsRequest) (*resource.ListRepositoryObjectsResponse, error) {
|
||||
func (b *bleveIndex) ListManagedObjects(ctx context.Context, req *resource.ListManagedObjectsRequest) (*resource.ListManagedObjectsResponse, error) {
|
||||
if req.NextPageToken != "" {
|
||||
return nil, fmt.Errorf("next page not implemented yet")
|
||||
}
|
||||
if req.Name == "" {
|
||||
return &resource.ListRepositoryObjectsResponse{
|
||||
Error: resource.NewBadRequestError("empty repository name"),
|
||||
if req.Kind == "" {
|
||||
return &resource.ListManagedObjectsResponse{
|
||||
Error: resource.NewBadRequestError("empty manager kind"),
|
||||
}, nil
|
||||
}
|
||||
if req.Id == "" {
|
||||
return &resource.ListManagedObjectsResponse{
|
||||
Error: resource.NewBadRequestError("empty manager id"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
q := bleve.NewBooleanQuery()
|
||||
q.AddMust(&query.TermQuery{
|
||||
Term: req.Kind,
|
||||
FieldVal: resource.SEARCH_FIELD_MANAGER_KIND,
|
||||
})
|
||||
q.AddMust(&query.TermQuery{
|
||||
Term: req.Id,
|
||||
FieldVal: resource.SEARCH_FIELD_MANAGER_ID,
|
||||
})
|
||||
|
||||
found, err := b.index.SearchInContext(ctx, &bleve.SearchRequest{
|
||||
Query: &query.TermQuery{
|
||||
Term: req.Name,
|
||||
FieldVal: resource.SEARCH_FIELD_MANAGER_ID,
|
||||
},
|
||||
Query: q,
|
||||
Fields: []string{
|
||||
resource.SEARCH_FIELD_TITLE,
|
||||
resource.SEARCH_FIELD_FOLDER,
|
||||
@@ -366,9 +380,9 @@ func (b *bleveIndex) ListRepositoryObjects(ctx context.Context, req *resource.Li
|
||||
return 0
|
||||
}
|
||||
|
||||
rsp := &resource.ListRepositoryObjectsResponse{}
|
||||
rsp := &resource.ListManagedObjectsResponse{}
|
||||
for _, hit := range found.Hits {
|
||||
item := &resource.ListRepositoryObjectsResponse_Item{
|
||||
item := &resource.ListManagedObjectsResponse_Item{
|
||||
Object: &resource.ResourceKey{},
|
||||
Hash: asString(hit.Fields[resource.SEARCH_FIELD_SOURCE_CHECKSUM]),
|
||||
Path: asString(hit.Fields[resource.SEARCH_FIELD_SOURCE_PATH]),
|
||||
@@ -385,27 +399,32 @@ func (b *bleveIndex) ListRepositoryObjects(ctx context.Context, req *resource.Li
|
||||
return rsp, nil
|
||||
}
|
||||
|
||||
func (b *bleveIndex) CountRepositoryObjects(ctx context.Context) ([]*resource.CountRepositoryObjectsResponse_ResourceCount, error) {
|
||||
func (b *bleveIndex) CountManagedObjects(ctx context.Context) ([]*resource.CountManagedObjectsResponse_ResourceCount, error) {
|
||||
found, err := b.index.SearchInContext(ctx, &bleve.SearchRequest{
|
||||
Query: bleve.NewMatchAllQuery(),
|
||||
Size: 0,
|
||||
Facets: bleve.FacetsRequest{
|
||||
"count": bleve.NewFacetRequest(resource.SEARCH_FIELD_MANAGER_ID, 1000), // typically less then 5
|
||||
"count": bleve.NewFacetRequest(resource.SEARCH_FIELD_MANAGED_BY, 1000), // typically less then 5
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vals := make([]*resource.CountRepositoryObjectsResponse_ResourceCount, 0)
|
||||
vals := make([]*resource.CountManagedObjectsResponse_ResourceCount, 0)
|
||||
f, ok := found.Facets["count"]
|
||||
if ok && f.Terms != nil {
|
||||
for _, v := range f.Terms.Terms() {
|
||||
vals = append(vals, &resource.CountRepositoryObjectsResponse_ResourceCount{
|
||||
Repository: v.Term,
|
||||
Group: b.key.Group,
|
||||
Resource: b.key.Resource,
|
||||
Count: int64(v.Count),
|
||||
})
|
||||
val := v.Term
|
||||
idx := strings.Index(val, ":")
|
||||
if idx > 0 {
|
||||
vals = append(vals, &resource.CountManagedObjectsResponse_ResourceCount{
|
||||
Kind: val[0:idx],
|
||||
Id: val[idx+1:],
|
||||
Group: b.key.Group,
|
||||
Resource: b.key.Resource,
|
||||
Count: int64(v.Count),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return vals, nil
|
||||
|
||||
@@ -34,7 +34,7 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
|
||||
// for searching by title - uses an edge ngram token filter
|
||||
titleSearchMapping := bleve.NewTextFieldMapping()
|
||||
titleSearchMapping.Analyzer = TITLE_ANALYZER
|
||||
titleSearchMapping.Store = true
|
||||
titleSearchMapping.Store = false // already stored in title
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE_NGRAM, titleSearchMapping)
|
||||
|
||||
// mapping for title to search on words/tokens larger than the ngram size
|
||||
@@ -45,6 +45,7 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
|
||||
|
||||
// for filtering/sorting by title full phrase
|
||||
titlePhraseMapping := bleve.NewKeywordFieldMapping()
|
||||
titleSearchMapping.Store = false // already stored in title
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE_PHRASE, titlePhraseMapping)
|
||||
|
||||
descriptionMapping := &mapping.FieldMapping{
|
||||
@@ -124,8 +125,17 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
|
||||
})
|
||||
source.AddFieldMappingsAt("timestampMillis", mapping.NewNumericFieldMapping())
|
||||
|
||||
mapper.AddSubDocumentMapping("manager", manager)
|
||||
mapper.AddSubDocumentMapping("source", source)
|
||||
mapper.AddSubDocumentMapping("manager", manager)
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_MANAGED_BY, &mapping.FieldMapping{
|
||||
Name: "managedBy",
|
||||
Type: "text",
|
||||
Analyzer: keyword.Name,
|
||||
Index: true, // only used for faceting
|
||||
Store: false,
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: false,
|
||||
})
|
||||
|
||||
labelMapper := bleve.NewDocumentMapping()
|
||||
mapper.AddSubDocumentMapping(resource.SEARCH_FIELD_LABELS, labelMapper)
|
||||
|
||||
@@ -36,6 +36,7 @@ func TestDocumentMapping(t *testing.T) {
|
||||
TimestampMillis: 1234,
|
||||
},
|
||||
}
|
||||
data.UpdateCopyFields()
|
||||
|
||||
doc := document.NewDocument("id")
|
||||
err = mappings.MapDocument(doc, data)
|
||||
@@ -47,5 +48,5 @@ func TestDocumentMapping(t *testing.T) {
|
||||
|
||||
fmt.Printf("DOC: fields %d\n", len(doc.Fields))
|
||||
fmt.Printf("DOC: size %d\n", doc.Size())
|
||||
require.Equal(t, 15, len(doc.Fields))
|
||||
require.Equal(t, 16, len(doc.Fields))
|
||||
}
|
||||
@@ -8,13 +8,14 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/store/kind/dashboard"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCanSearchByTitle(t *testing.T) {
|
||||
@@ -35,9 +36,7 @@ func TestCanSearchByTitle(t *testing.T) {
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "bbb",
|
||||
TitleNgram: "bbb",
|
||||
TitlePhrase: "bbb",
|
||||
Title: "bbb",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = index.Write(&resource.IndexableDocument{
|
||||
@@ -49,9 +48,7 @@ func TestCanSearchByTitle(t *testing.T) {
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "aaa",
|
||||
TitleNgram: "aaa",
|
||||
TitlePhrase: "aaa",
|
||||
Title: "aaa",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -74,9 +71,7 @@ func TestCanSearchByTitle(t *testing.T) {
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "I want to say a hello",
|
||||
TitleNgram: "I want to say a hello",
|
||||
TitlePhrase: "I want to say a hello",
|
||||
Title: "I want to say a hello",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = index.Write(&resource.IndexableDocument{
|
||||
@@ -88,9 +83,7 @@ func TestCanSearchByTitle(t *testing.T) {
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "we want hello",
|
||||
TitleNgram: "we want hello",
|
||||
TitlePhrase: "we want hello",
|
||||
Title: "we want hello",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -113,9 +106,7 @@ func TestCanSearchByTitle(t *testing.T) {
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "Asserts Dashboards",
|
||||
TitleNgram: "Asserts Dashboards",
|
||||
TitlePhrase: "Asserts Dashboards",
|
||||
Title: "Asserts Dashboards",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = index.Write(&resource.IndexableDocument{
|
||||
@@ -127,9 +118,7 @@ func TestCanSearchByTitle(t *testing.T) {
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "New dashboard 10",
|
||||
TitleNgram: "New dashboard 10",
|
||||
TitlePhrase: "New dashboard 10",
|
||||
Title: "New dashboard 10",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -151,9 +140,7 @@ func TestCanSearchByTitle(t *testing.T) {
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "we want hello pls",
|
||||
TitleNgram: "we want hello pls",
|
||||
TitlePhrase: "we want hello pls",
|
||||
Title: "we want hello pls",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = index.Write(&resource.IndexableDocument{
|
||||
@@ -165,9 +152,7 @@ func TestCanSearchByTitle(t *testing.T) {
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "we want hello",
|
||||
TitleNgram: "we want hello",
|
||||
TitlePhrase: "we want hello",
|
||||
Title: "we want hello",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -190,8 +175,7 @@ func TestCanSearchByTitle(t *testing.T) {
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "A123456",
|
||||
TitleNgram: "A123456",
|
||||
Title: "A123456",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -219,9 +203,7 @@ func TestCanSearchByTitle(t *testing.T) {
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "I want to say a wonderfully Hello to the WORLD! Hello-world",
|
||||
TitleNgram: "I want to say a wonderfully Hello to the WORLD! Hello-world",
|
||||
TitlePhrase: "I want to say a wonderfully Hello to the WORLD! Hello-world",
|
||||
Title: "I want to say a wonderfully Hello to the WORLD! Hello-world",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -279,8 +261,7 @@ func TestCanSearchByTitle(t *testing.T) {
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "I want to say a wonderful Hello to the WORLD! Hello-world",
|
||||
TitleNgram: "I want to say a wonderful Hello to the WORLD! Hello-world",
|
||||
Title: "I want to say a wonderful Hello to the WORLD! Hello-world",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = index.Write(&resource.IndexableDocument{
|
||||
@@ -292,8 +273,7 @@ func TestCanSearchByTitle(t *testing.T) {
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "A0456",
|
||||
TitleNgram: "A0456",
|
||||
Title: "A0456",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = index.Write(&resource.IndexableDocument{
|
||||
@@ -305,9 +285,7 @@ func TestCanSearchByTitle(t *testing.T) {
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "mash-A02382-10",
|
||||
TitleNgram: "mash-A02382-10",
|
||||
TitlePhrase: "mash-A02382-10",
|
||||
Title: "mash-A02382-10",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -76,9 +76,8 @@ func TestBleveBackend(t *testing.T) {
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
Title: "aaa (dash)",
|
||||
TitlePhrase: "aaa (dash)",
|
||||
Folder: "xxx",
|
||||
Title: "aaa (dash)",
|
||||
Folder: "xxx",
|
||||
Fields: map[string]any{
|
||||
DASHBOARD_PANEL_TYPES: []string{"timeseries", "table"},
|
||||
DASHBOARD_ERRORS_TODAY: 25,
|
||||
@@ -107,9 +106,8 @@ func TestBleveBackend(t *testing.T) {
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
Title: "bbb (dash)",
|
||||
TitlePhrase: "bbb (dash)",
|
||||
Folder: "xxx",
|
||||
Title: "bbb (dash)",
|
||||
Folder: "xxx",
|
||||
Fields: map[string]any{
|
||||
DASHBOARD_PANEL_TYPES: []string{"timeseries"},
|
||||
DASHBOARD_ERRORS_TODAY: 40,
|
||||
@@ -138,10 +136,9 @@ func TestBleveBackend(t *testing.T) {
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
Name: "ccc",
|
||||
Title: "ccc (dash)",
|
||||
TitlePhrase: "ccc (dash)",
|
||||
Folder: "zzz",
|
||||
Name: "ccc",
|
||||
Title: "ccc (dash)",
|
||||
Folder: "zzz",
|
||||
Manager: &utils.ManagerProperties{
|
||||
Kind: utils.ManagerKindRepo,
|
||||
Identity: "repo2",
|
||||
@@ -263,8 +260,9 @@ func TestBleveBackend(t *testing.T) {
|
||||
require.Equal(t, 0, len(rsp.Results.Rows))
|
||||
|
||||
// Now look for repositories
|
||||
found, err := index.ListRepositoryObjects(ctx, &resource.ListRepositoryObjectsRequest{
|
||||
Name: "repo-1",
|
||||
found, err := index.ListManagedObjects(ctx, &resource.ListManagedObjectsRequest{
|
||||
Kind: "repo",
|
||||
Id: "repo-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
jj, err := json.MarshalIndent(found, "", " ")
|
||||
@@ -302,20 +300,22 @@ func TestBleveBackend(t *testing.T) {
|
||||
]
|
||||
}`, string(jj))
|
||||
|
||||
counts, err := index.CountRepositoryObjects(ctx)
|
||||
counts, err := index.CountManagedObjects(ctx)
|
||||
require.NoError(t, err)
|
||||
jj, err = json.MarshalIndent(counts, "", " ")
|
||||
require.NoError(t, err)
|
||||
fmt.Printf("%s\n", string(jj))
|
||||
require.JSONEq(t, `[
|
||||
{
|
||||
"repository": "repo-1",
|
||||
"kind": "repo",
|
||||
"id": "repo-1",
|
||||
"group": "dashboard.grafana.app",
|
||||
"resource": "dashboards",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"repository": "repo2",
|
||||
"kind": "repo",
|
||||
"id": "repo2",
|
||||
"group": "dashboard.grafana.app",
|
||||
"resource": "dashboards",
|
||||
"count": 1
|
||||
@@ -340,8 +340,7 @@ func TestBleveBackend(t *testing.T) {
|
||||
Group: "folder.grafana.app",
|
||||
Resource: "folders",
|
||||
},
|
||||
Title: "zzz (folder)",
|
||||
TitlePhrase: "zzz (folder)",
|
||||
Title: "zzz (folder)",
|
||||
Manager: &utils.ManagerProperties{
|
||||
Kind: utils.ManagerKindRepo,
|
||||
Identity: "repo-1",
|
||||
@@ -360,8 +359,7 @@ func TestBleveBackend(t *testing.T) {
|
||||
Group: "folder.grafana.app",
|
||||
Resource: "folders",
|
||||
},
|
||||
Title: "yyy (folder)",
|
||||
TitlePhrase: "yyy (folder)",
|
||||
Title: "yyy (folder)",
|
||||
Labels: map[string]string{
|
||||
"region": "west",
|
||||
},
|
||||
|
||||
@@ -15,5 +15,6 @@
|
||||
"manager": {
|
||||
"kind": "repo",
|
||||
"id": "MyGIT"
|
||||
}
|
||||
},
|
||||
"managedBy": "repo:MyGIT"
|
||||
}
|
||||
@@ -15,5 +15,6 @@
|
||||
"manager": {
|
||||
"kind": "repo",
|
||||
"id": "MyGIT"
|
||||
}
|
||||
},
|
||||
"managedBy": "repo:MyGIT"
|
||||
}
|
||||
@@ -53,11 +53,11 @@ func (b *backend) GetStats(ctx context.Context, req *resource.ResourceStatsReque
|
||||
return rsp, nil
|
||||
}
|
||||
|
||||
func (b *backend) RepositoryList(ctx context.Context, req *resource.ListRepositoryObjectsRequest) (*resource.ListRepositoryObjectsResponse, error) {
|
||||
func (b *backend) RepositoryList(ctx context.Context, req *resource.ListManagedObjectsRequest) (*resource.ListManagedObjectsResponse, error) {
|
||||
return nil, fmt.Errorf("SQL backend does not implement RepositoryList")
|
||||
}
|
||||
|
||||
func (b *backend) RepositoryStats(context.Context, *resource.CountRepositoryObjectsRequest) (*resource.CountRepositoryObjectsResponse, error) {
|
||||
func (b *backend) RepositoryStats(context.Context, *resource.CountManagedObjectsRequest) (*resource.CountManagedObjectsResponse, error) {
|
||||
return nil, fmt.Errorf("SQL backend does not implement RepositoryStats")
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ func (s *service) start(ctx context.Context) error {
|
||||
resource.RegisterResourceStoreServer(srv, server)
|
||||
resource.RegisterBulkStoreServer(srv, server)
|
||||
resource.RegisterResourceIndexServer(srv, server)
|
||||
resource.RegisterRepositoryIndexServer(srv, server)
|
||||
resource.RegisterManagedObjectIndexServer(srv, server)
|
||||
resource.RegisterBlobStoreServer(srv, server)
|
||||
resource.RegisterDiagnosticsServer(srv, server)
|
||||
grpc_health_v1.RegisterHealthServer(srv, healthService)
|
||||
|
||||
@@ -42,7 +42,8 @@ type Engine struct {
|
||||
|
||||
tagHandlers map[string]tagHandler
|
||||
|
||||
defaultContext context.Context
|
||||
defaultContext context.Context
|
||||
sequenceGenerator *sequenceGenerator // If not nil, this generator is used to generate auto-increment values for inserts.
|
||||
}
|
||||
|
||||
// CondDeleted returns the conditions whether a record is soft deleted.
|
||||
@@ -237,6 +238,9 @@ func (engine *Engine) NewSession() *Session {
|
||||
|
||||
// Close the engine
|
||||
func (engine *Engine) Close() error {
|
||||
if engine.sequenceGenerator != nil {
|
||||
engine.sequenceGenerator.close()
|
||||
}
|
||||
return engine.db.Close()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ module github.com/grafana/grafana/pkg/util/xorm
|
||||
go 1.23.7
|
||||
|
||||
require (
|
||||
cloud.google.com/go/spanner v1.75.0
|
||||
github.com/googleapis/go-sql-spanner v1.11.1
|
||||
github.com/grafana/grafana v5.4.5+incompatible
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
github.com/stretchr/testify v1.10.0
|
||||
xorm.io/builder v0.3.6
|
||||
@@ -19,9 +21,9 @@ require (
|
||||
cloud.google.com/go/iam v1.3.1 // indirect
|
||||
cloud.google.com/go/longrunning v0.6.4 // indirect
|
||||
cloud.google.com/go/monitoring v1.23.0 // indirect
|
||||
cloud.google.com/go/spanner v1.75.0 // indirect
|
||||
github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.2 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
|
||||
@@ -632,6 +632,8 @@ github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kd
|
||||
github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0=
|
||||
github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI=
|
||||
github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
@@ -832,6 +834,8 @@ github.com/googleapis/go-sql-spanner v1.11.1 h1:z3ThtKV5HFvaNv9UGc26+ggS+lS0dsCA
|
||||
github.com/googleapis/go-sql-spanner v1.11.1/go.mod h1:fuA5q4yMS3SZiVfRr5bvksPNk7zUn/irbQW62H/ffZw=
|
||||
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||
github.com/grafana/grafana v5.4.5+incompatible h1:xNuhSBxLgwDwesuQIAhQu1QCk6tD0TAghKHE36/hxrs=
|
||||
github.com/grafana/grafana v5.4.5+incompatible/go.mod h1:U8QyUclJHj254BFcuw45p6sg7eeGYX44qn1ShYo5rGE=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w=
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package xorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type sequenceGenerator struct {
|
||||
db *sql.DB
|
||||
sequencesTable string
|
||||
}
|
||||
|
||||
func newSequenceGenerator(db *sql.DB) *sequenceGenerator {
|
||||
return &sequenceGenerator{
|
||||
db: db,
|
||||
sequencesTable: "autoincrement_sequences",
|
||||
}
|
||||
}
|
||||
|
||||
func (sg *sequenceGenerator) Next(ctx context.Context, table, column string) (int64, error) {
|
||||
// Current implementation fetches new value for each Next call.
|
||||
key := fmt.Sprintf("%s:%s", table, column)
|
||||
|
||||
tx, err := sg.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// TODO: "FOR UPDATE". (Somehow this doesn't seem to be supported in Spanner emulator?)
|
||||
r, err := tx.QueryContext(ctx, "SELECT next_value FROM "+sg.sequencesTable+" WHERE name = ?", key)
|
||||
if err != nil {
|
||||
err2 := tx.Rollback()
|
||||
return 0, errors.Join(err, err2)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
// Sequence doesn't exist yet. Return 1, and put 2 into the table.
|
||||
if !r.Next() {
|
||||
if err := r.Err(); err != nil {
|
||||
return 0, errors.Join(err, tx.Rollback())
|
||||
}
|
||||
val := int64(1)
|
||||
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO "+sg.sequencesTable+" (name, next_value) VALUES(?, ?)", key, val+1)
|
||||
if err != nil {
|
||||
return 0, errors.Join(err, tx.Rollback())
|
||||
}
|
||||
|
||||
return val, tx.Commit()
|
||||
}
|
||||
|
||||
var val int64
|
||||
if err := r.Scan(&val); err != nil {
|
||||
return 0, errors.Join(err, tx.Rollback())
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, "UPDATE "+sg.sequencesTable+" SET next_value = ? WHERE name = ?", val+1, key)
|
||||
if err != nil {
|
||||
return 0, errors.Join(err, tx.Rollback())
|
||||
}
|
||||
|
||||
return val, tx.Commit()
|
||||
}
|
||||
|
||||
func (sg *sequenceGenerator) close() {
|
||||
// Nothing to do just yet.
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package xorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSequenceGenerator(t *testing.T) {
|
||||
eng, err := NewEngine("sqlite3", ":memory:")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, eng)
|
||||
require.Equal(t, "sqlite3", eng.DriverName())
|
||||
|
||||
_, err = eng.Exec("CREATE TABLE `autoincrement_sequences` (`name` STRING(128) NOT NULL PRIMARY KEY, `next_value` INT64 NOT NULL)")
|
||||
require.NoError(t, err)
|
||||
|
||||
sg := newSequenceGenerator(eng.db.DB)
|
||||
val, err := sg.Next(context.Background(), "test", "test")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), val)
|
||||
|
||||
val, err = sg.Next(context.Background(), "test", "different")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), val)
|
||||
|
||||
val, err = sg.Next(context.Background(), "test", "different")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), val)
|
||||
}
|
||||
@@ -345,20 +345,25 @@ func (session *Session) innerInsert(bean any) (int64, error) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
//// XXX: hack to handle autoincrement in spanner
|
||||
//if len(table.AutoIncrement) > 0 && session.engine.dialect.DBType() == "spanner" {
|
||||
// var found bool
|
||||
// for _, col := range colNames {
|
||||
// if col == table.AutoIncrement {
|
||||
// found = true
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// if !found {
|
||||
// colNames = append(colNames, table.AutoIncrement)
|
||||
// args = append(args, rand.Int63n(9e15))
|
||||
// }
|
||||
//}
|
||||
// If engine has a sequence number generator, use it to produce values for auto-increment columns.
|
||||
if len(table.AutoIncrement) > 0 && session.engine.sequenceGenerator != nil {
|
||||
var found bool
|
||||
for _, col := range colNames {
|
||||
if col == table.AutoIncrement {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
seq, err := session.engine.sequenceGenerator.Next(session.ctx, table.Name, table.AutoIncrement)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to generate next value for auto_increment columns: %v", err)
|
||||
}
|
||||
|
||||
colNames = append(colNames, table.AutoIncrement)
|
||||
args = append(args, seq)
|
||||
}
|
||||
}
|
||||
|
||||
exprs := session.statement.exprColumns
|
||||
colPlaces := strings.Repeat("?, ", len(colNames))
|
||||
@@ -438,9 +443,11 @@ func (session *Session) innerInsert(bean any) (int64, error) {
|
||||
}
|
||||
|
||||
if len(table.AutoIncrement) > 0 && session.engine.dialect.DBType() == core.POSTGRES {
|
||||
if _, err := buf.WriteString(" RETURNING " + session.engine.Quote(table.AutoIncrement)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
buf.WriteString(" RETURNING " + session.engine.Quote(table.AutoIncrement))
|
||||
}
|
||||
|
||||
if len(table.AutoIncrement) > 0 && session.engine.dialect.DBType() == "spanner" {
|
||||
buf.WriteString(" THEN RETURN " + session.engine.Quote(table.AutoIncrement))
|
||||
}
|
||||
|
||||
sqlStr := buf.String()
|
||||
@@ -476,6 +483,7 @@ func (session *Session) innerInsert(bean any) (int64, error) {
|
||||
|
||||
// for postgres, many of them didn't implement lastInsertId, so we should
|
||||
// implemented it ourself.
|
||||
var insertID, rowsAffected int64
|
||||
if session.engine.dialect.DBType() == core.ORACLE && len(table.AutoIncrement) > 0 {
|
||||
res, err := session.queryBytes("select seq_atable.currval from dual", args...)
|
||||
if err != nil {
|
||||
@@ -498,23 +506,11 @@ func (session *Session) innerInsert(bean any) (int64, error) {
|
||||
}
|
||||
|
||||
idByte := res[0][table.AutoIncrement]
|
||||
id, err := strconv.ParseInt(string(idByte), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
insertID, err = strconv.ParseInt(string(idByte), 10, 64)
|
||||
if err != nil || insertID <= 0 {
|
||||
return 1, err
|
||||
}
|
||||
|
||||
aiValue, err := table.AutoIncrColumn().ValueOf(bean)
|
||||
if err != nil {
|
||||
session.engine.logger.Error(err)
|
||||
}
|
||||
|
||||
if aiValue == nil || !aiValue.IsValid() || !aiValue.CanSet() {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
aiValue.Set(int64ToIntValue(id, aiValue.Type()))
|
||||
|
||||
return 1, nil
|
||||
rowsAffected = 1
|
||||
} else if len(table.AutoIncrement) > 0 && (session.engine.dialect.DBType() == core.POSTGRES) {
|
||||
res, err := session.queryBytes(sqlStr, args...)
|
||||
|
||||
@@ -537,23 +533,11 @@ func (session *Session) innerInsert(bean any) (int64, error) {
|
||||
}
|
||||
|
||||
idByte := res[0][table.AutoIncrement]
|
||||
id, err := strconv.ParseInt(string(idByte), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
insertID, err = strconv.ParseInt(string(idByte), 10, 64)
|
||||
if err != nil || insertID <= 0 {
|
||||
return 1, err
|
||||
}
|
||||
|
||||
aiValue, err := table.AutoIncrColumn().ValueOf(bean)
|
||||
if err != nil {
|
||||
session.engine.logger.Error(err)
|
||||
}
|
||||
|
||||
if aiValue == nil || !aiValue.IsValid() || !aiValue.CanSet() {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
aiValue.Set(int64ToIntValue(id, aiValue.Type()))
|
||||
|
||||
return 1, nil
|
||||
rowsAffected = 1
|
||||
} else {
|
||||
res, err := session.exec(sqlStr, args...)
|
||||
if err != nil {
|
||||
@@ -575,25 +559,29 @@ func (session *Session) innerInsert(bean any) (int64, error) {
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
var id int64
|
||||
id, err = res.LastInsertId()
|
||||
if err != nil || id <= 0 {
|
||||
insertID, err = res.LastInsertId()
|
||||
if err != nil || insertID <= 0 {
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
aiValue, err := table.AutoIncrColumn().ValueOf(bean)
|
||||
rowsAffected, err = res.RowsAffected()
|
||||
if err != nil {
|
||||
session.engine.logger.Error(err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if aiValue == nil || !aiValue.IsValid() || !aiValue.CanSet() {
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
aiValue.Set(int64ToIntValue(id, aiValue.Type()))
|
||||
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// Set insertID back to the bean.
|
||||
aiValue, err := table.AutoIncrColumn().ValueOf(bean)
|
||||
if err != nil {
|
||||
session.engine.logger.Error(err)
|
||||
}
|
||||
|
||||
if aiValue == nil || !aiValue.IsValid() || !aiValue.CanSet() {
|
||||
return rowsAffected, nil
|
||||
}
|
||||
|
||||
aiValue.Set(int64ToIntValue(insertID, aiValue.Type()))
|
||||
return rowsAffected, nil
|
||||
}
|
||||
|
||||
// InsertOne insert only one struct into database as a record.
|
||||
|
||||
@@ -320,7 +320,11 @@ func (statement *Statement) buildUpdates(bean any,
|
||||
if err != nil {
|
||||
engine.logger.Error(err)
|
||||
} else {
|
||||
val = data
|
||||
if col.SQLType.IsText() {
|
||||
val = string(data)
|
||||
} else {
|
||||
val = data
|
||||
}
|
||||
}
|
||||
goto APPEND
|
||||
}
|
||||
@@ -331,7 +335,11 @@ func (statement *Statement) buildUpdates(bean any,
|
||||
if err != nil {
|
||||
engine.logger.Error(err)
|
||||
} else {
|
||||
val = data
|
||||
if col.SQLType.IsText() {
|
||||
val = string(data)
|
||||
} else {
|
||||
val = data
|
||||
}
|
||||
}
|
||||
goto APPEND
|
||||
}
|
||||
|
||||
@@ -106,5 +106,9 @@ func NewEngine(driverName string, dataSourceName string) (*Engine, error) {
|
||||
|
||||
runtime.SetFinalizer(engine, close)
|
||||
|
||||
if dialect.DBType() == "spanner" {
|
||||
engine.sequenceGenerator = newSequenceGenerator(db.DB)
|
||||
}
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//go:build enterprise || pro
|
||||
|
||||
package xorm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"cloud.google.com/go/spanner/spannertest"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBasicOperationsWithSpanner(t *testing.T) {
|
||||
span, err := spannertest.NewServer("localhost:0")
|
||||
require.NoError(t, err)
|
||||
defer span.Close()
|
||||
|
||||
eng, err := NewEngine("spanner", fmt.Sprintf("%s/projects/test/instances/test/databases/test;usePlainText=true", span.Addr))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, eng)
|
||||
require.Equal(t, "spanner", eng.DriverName())
|
||||
|
||||
_, err = eng.Exec("CREATE TABLE test_struct (id int64, comment string(max), json string(max)) primary key (id)")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Currently broken because simple INSERT into spannertest doesn't work: https://github.com/googleapis/go-sql-spanner/issues/392
|
||||
// testBasicOperations(t, eng)
|
||||
}
|
||||
@@ -5,13 +5,47 @@ import (
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
)
|
||||
|
||||
func TestNewEngine(t *testing.T) {
|
||||
t.Run("successfully create a new engine", func(t *testing.T) {
|
||||
eng, err := NewEngine("sqlite3", "./test.db")
|
||||
func TestBasicOperationsWithSqlite(t *testing.T) {
|
||||
eng, err := NewEngine("sqlite3", ":memory:")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, eng)
|
||||
require.Equal(t, "sqlite3", eng.DriverName())
|
||||
|
||||
_, err = eng.Exec("CREATE TABLE test_struct (id int primary key, comment text, json text)")
|
||||
require.NoError(t, err)
|
||||
|
||||
testBasicOperations(t, eng)
|
||||
}
|
||||
|
||||
func testBasicOperations(t *testing.T, eng *Engine) {
|
||||
t.Run("insert object", func(t *testing.T) {
|
||||
obj := &TestStruct{Comment: "test comment"}
|
||||
_, err := eng.Insert(obj)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), obj.Id)
|
||||
})
|
||||
|
||||
t.Run("update object with json field", func(t *testing.T) {
|
||||
sess := eng.NewSession()
|
||||
defer sess.Close()
|
||||
|
||||
obj := &TestStruct{Comment: "new comment"}
|
||||
_, err := sess.Insert(obj)
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, obj.Id)
|
||||
|
||||
obj.Json = simplejson.MustJson([]byte(`{"test": "test", "key": null}`))
|
||||
_, err = sess.Update(obj)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, eng)
|
||||
require.Equal(t, "sqlite3", eng.DriverName())
|
||||
})
|
||||
}
|
||||
|
||||
type TestStruct struct {
|
||||
Id int64
|
||||
Comment string
|
||||
Json *simplejson.Json
|
||||
}
|
||||
@@ -79,7 +79,9 @@ if (process.env.NODE_ENV === 'development') {
|
||||
code: PSEUDO_LOCALE,
|
||||
name: 'Pseudo-locale',
|
||||
loader: {
|
||||
grafana: () => import('../../../locales/pseudo-LOCALE/grafana.json'),
|
||||
// Load the English locale as the pseudo-locale,
|
||||
// as it will be post-processed by i18next-pseudo library
|
||||
grafana: () => import('../../../locales/en-US/grafana.json'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Field, Input, Stack, Text } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
|
||||
|
||||
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
|
||||
import { isCloudRecordingRuleByType, isGrafanaRecordingRuleByType, isRecordingRuleByType } from '../../utils/rules';
|
||||
@@ -21,9 +25,11 @@ const recordingRuleNameValidationPattern = (type: RuleFormType) => ({
|
||||
*/
|
||||
export const AlertRuleNameAndMetric = () => {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
watch,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
} = useFormContext<RuleFormValues>();
|
||||
|
||||
const ruleFormType = watch('type');
|
||||
@@ -76,6 +82,39 @@ export const AlertRuleNameAndMetric = () => {
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isGrafanaRecordingRule && config.featureToggles.grafanaManagedRecordingRulesDatasources && (
|
||||
<Field
|
||||
id="target-data-source"
|
||||
label={t('alerting.recording-rules.label-target-data-source', 'Target data source')}
|
||||
description={t(
|
||||
'alerting.recording-rules.description-target-data-source',
|
||||
'The Prometheus data source to store the recording rule in'
|
||||
)}
|
||||
error={errors.targetDatasourceUid?.message}
|
||||
invalid={!!errors.targetDatasourceUid?.message}
|
||||
>
|
||||
<Controller
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<DataSourcePicker
|
||||
{...field}
|
||||
current={field.value}
|
||||
noDefault
|
||||
// Filter with `filter` prop instead of `type` prop to avoid showing the `-- Grafana --` data source
|
||||
filter={(ds: DataSourceInstanceSettings) => ds.type === 'prometheus'}
|
||||
onChange={(ds: DataSourceInstanceSettings) => {
|
||||
setValue('targetDatasourceUid', ds.uid);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
name="targetDatasourceUid"
|
||||
control={control}
|
||||
rules={{
|
||||
required: { value: true, message: 'Please select a data source' },
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</Stack>
|
||||
</RuleEditorSection>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,10 @@ import { css } from '@emotion/css';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
|
||||
import { GrafanaTheme2, dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
|
||||
import { Icon, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Icon, Link, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { useDatasource } from 'app/features/datasources/hooks';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
|
||||
import { usePendingPeriod } from '../../../hooks/rules/usePendingPeriod';
|
||||
@@ -53,6 +55,17 @@ export const Details = ({ rule }: DetailsProps) => {
|
||||
determinedRuleType = RuleType.CloudRecordingRule;
|
||||
}
|
||||
|
||||
const targetDatasourceUid = rulerRuleType.grafana.recordingRule(rule.rulerRule)
|
||||
? rule.rulerRule.grafana_alert.record?.target_datasource_uid
|
||||
: null;
|
||||
|
||||
const datasource = useDatasource(targetDatasourceUid);
|
||||
|
||||
const showTargetDatasource =
|
||||
config.featureToggles.grafanaManagedRecordingRulesDatasources &&
|
||||
targetDatasourceUid &&
|
||||
targetDatasourceUid !== 'grafana';
|
||||
|
||||
const evaluationDuration = rule.promRule?.evaluationTime;
|
||||
const evaluationTimestamp = rule.promRule?.lastEvaluation;
|
||||
|
||||
@@ -101,6 +114,20 @@ export const Details = ({ rule }: DetailsProps) => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showTargetDatasource && (
|
||||
<DetailText
|
||||
id="target-datasource-uid"
|
||||
label={t('alerting.alert.target-datasource-uid', 'Target data source')}
|
||||
value={
|
||||
<Link href={`/connections/datasources/edit/${datasource?.uid}`}>
|
||||
<Stack direction="row" gap={1}>
|
||||
<img style={{ width: '16px' }} src={datasource?.meta.info.logos.small} alt="datasource logo" />
|
||||
{datasource?.name}
|
||||
</Stack>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DetailGroup>
|
||||
|
||||
<DetailGroup title={t('alerting.alert.evaluation', 'Evaluation')}>
|
||||
|
||||
@@ -54,7 +54,7 @@ export interface RuleFormValues {
|
||||
contactPoints?: AlertManagerManualRouting;
|
||||
editorSettings?: SimplifiedEditor;
|
||||
metric?: string;
|
||||
|
||||
targetDatasourceUid?: string;
|
||||
// cortex / loki rules
|
||||
namespace: string;
|
||||
forTime: number;
|
||||
|
||||
@@ -28,6 +28,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu
|
||||
"record": {
|
||||
"from": "A",
|
||||
"metric": "",
|
||||
"target_datasource_uid": undefined,
|
||||
},
|
||||
"title": "",
|
||||
},
|
||||
|
||||
@@ -148,6 +148,7 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl
|
||||
manualRouting,
|
||||
type,
|
||||
metric,
|
||||
targetDatasourceUid,
|
||||
} = values;
|
||||
if (!condition) {
|
||||
throw new Error('You cannot create an alert rule without specifying the alert condition');
|
||||
@@ -196,6 +197,7 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl
|
||||
record: {
|
||||
metric: metric ?? name,
|
||||
from: condition,
|
||||
target_datasource_uid: targetDatasourceUid,
|
||||
},
|
||||
},
|
||||
annotations,
|
||||
@@ -284,6 +286,7 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
|
||||
folder: { title: namespace, uid: ga.namespace_uid },
|
||||
isPaused: ga.is_paused,
|
||||
metric: ga.record?.metric,
|
||||
targetDatasourceUid: ga.record?.target_datasource_uid,
|
||||
};
|
||||
} else if (rulerRuleType.grafana.rule(rule)) {
|
||||
// grafana alerting rule
|
||||
|
||||
@@ -263,7 +263,7 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
}),
|
||||
tabsbar: css({
|
||||
padding: theme.spacing(0, 1),
|
||||
margin: theme.spacing(0.5, 1),
|
||||
margin: theme.spacing(0.5, 0),
|
||||
}),
|
||||
expandOptionsWrapper: css({
|
||||
display: 'flex',
|
||||
|
||||
@@ -15,7 +15,11 @@ export class DashboardEditableElement implements EditableDashboardElement {
|
||||
public constructor(private dashboard: DashboardScene) {}
|
||||
|
||||
public getEditableElementInfo(): EditableDashboardElementInfo {
|
||||
return { typeId: 'dashboard', icon: 'apps', name: t('dashboard.edit-pane.elements.dashboard', 'Dashboard') };
|
||||
return {
|
||||
typeName: t('dashboard.edit-pane.elements.dashboard', 'Dashboard'),
|
||||
icon: 'apps',
|
||||
instanceName: this.dashboard.state.title,
|
||||
};
|
||||
}
|
||||
|
||||
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
|
||||
@@ -25,11 +29,7 @@ export class DashboardEditableElement implements EditableDashboardElement {
|
||||
const { body } = dashboard.useState();
|
||||
|
||||
const dashboardOptions = useMemo(() => {
|
||||
const editPaneHeaderOptions = new OptionsPaneCategoryDescriptor({
|
||||
title: t('dashboard.options.title', 'Dashboard options'),
|
||||
id: 'dashboard-options',
|
||||
isOpenable: false,
|
||||
})
|
||||
const editPaneHeaderOptions = new OptionsPaneCategoryDescriptor({ title: '', id: 'dashboard-options' })
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.options.title-option', 'Title'),
|
||||
|
||||
@@ -65,7 +65,7 @@ function DashboardOutlineNode({ sceneObject, expandable }: { sceneObject: SceneO
|
||||
onPointerDown={(evt) => onSelect?.(evt)}
|
||||
>
|
||||
<Icon name={elementInfo.icon} />
|
||||
<span>{elementInfo.name}</span>
|
||||
<span>{elementInfo.instanceName}</span>
|
||||
</button>
|
||||
</Stack>
|
||||
{expandable && isExpanded && (
|
||||
|
||||
@@ -1,57 +1,86 @@
|
||||
import { Dropdown, Button, IconButton, Menu, Stack, Icon } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, Menu, Stack, Text, useStyles2, ConfirmButton, Dropdown, Icon } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
|
||||
|
||||
interface EditPaneHeaderProps {
|
||||
title: string;
|
||||
onDelete?: () => void;
|
||||
onCopy?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
element: EditableDashboardElement;
|
||||
}
|
||||
|
||||
export const EditPaneHeader = ({ title, onDelete, onCopy, onDuplicate }: EditPaneHeaderProps) => {
|
||||
const addCopyOrDuplicate = onCopy || onDuplicate;
|
||||
export function EditPaneHeader({ element }: EditPaneHeaderProps) {
|
||||
const elementInfo = element.getEditableElementInfo();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const onCopy = element.onCopy?.bind(element);
|
||||
const onDuplicate = element.onDuplicate?.bind(element);
|
||||
const onDelete = element.onDelete?.bind(element);
|
||||
|
||||
return (
|
||||
<Stack justifyContent="space-between" alignItems="center" width="100%">
|
||||
<span>{title}</span>
|
||||
<Stack alignItems="center">
|
||||
{addCopyOrDuplicate ? (
|
||||
<Dropdown overlay={<MenuItems onCopy={onCopy} onDuplicate={onDuplicate} />}>
|
||||
<div className={styles.wrapper}>
|
||||
<Text variant="h5">{elementInfo.typeName}</Text>
|
||||
<Stack direction="row" gap={1}>
|
||||
{(onCopy || onDelete) && (
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
{onCopy ? (
|
||||
<Menu.Item icon="copy" label={t('dashboard.layout.common.copy', 'Copy')} onClick={onCopy} />
|
||||
) : null}
|
||||
{onDuplicate ? (
|
||||
<Menu.Item
|
||||
icon="file-copy-alt"
|
||||
label={t('dashboard.layout.common.duplicate', 'Duplicate')}
|
||||
onClick={onDuplicate}
|
||||
/>
|
||||
) : null}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
tooltip={t('dashboard.layout.common.copy-or-duplicate', 'Copy or Duplicate')}
|
||||
tooltipPlacement="bottom"
|
||||
variant="secondary"
|
||||
fill="text"
|
||||
size="md"
|
||||
size="sm"
|
||||
icon="copy"
|
||||
>
|
||||
<Icon name="copy" /> <Icon name="angle-down" />
|
||||
<Icon name="angle-down" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="secondary"
|
||||
onClick={onDelete}
|
||||
name="trash-alt"
|
||||
tooltip={t('dashboard.layout.common.delete', 'Delete')}
|
||||
/>
|
||||
{onDelete && (
|
||||
<ConfirmButton
|
||||
onConfirm={onDelete}
|
||||
confirmText="Confirm"
|
||||
confirmVariant="destructive"
|
||||
size="sm"
|
||||
closeOnConfirm={true}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
fill="outline"
|
||||
icon="trash-alt"
|
||||
tooltip={t('dashboard.layout.common.delete', 'Delete')}
|
||||
/>
|
||||
</ConfirmButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
type MenuItemsProps = {
|
||||
onCopy?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
};
|
||||
|
||||
const MenuItems = ({ onCopy, onDuplicate }: MenuItemsProps) => {
|
||||
return (
|
||||
<Menu>
|
||||
{onCopy ? <Menu.Item label={t('dashboard.layout.common.copy', 'Copy')} onClick={onCopy} /> : null}
|
||||
{onDuplicate ? (
|
||||
<Menu.Item label={t('dashboard.layout.common.duplicate', 'Duplicate')} onClick={onDuplicate} />
|
||||
) : null}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
wrapper: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: theme.spacing(2),
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,50 +1,20 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Stack, useStyles2 } from '@grafana/ui';
|
||||
import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory';
|
||||
import { Stack } from '@grafana/ui';
|
||||
|
||||
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
|
||||
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
|
||||
|
||||
import { EditPaneHeader } from './EditPaneHeader';
|
||||
|
||||
export interface Props {
|
||||
element: EditableDashboardElement | MultiSelectedEditableDashboardElement;
|
||||
element: EditableDashboardElement;
|
||||
}
|
||||
|
||||
export function ElementEditPane({ element }: Props) {
|
||||
const categories = element.useEditPaneOptions ? element.useEditPaneOptions() : [];
|
||||
const styles = useStyles2(getStyles);
|
||||
const elementInfo = element.getEditableElementInfo();
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={0}>
|
||||
{element.renderActions && (
|
||||
<OptionsPaneCategory
|
||||
id="selected-item"
|
||||
title={elementInfo.name}
|
||||
isOpenDefault={true}
|
||||
className={styles.noBorderTop}
|
||||
renderTitle={element.renderTitle}
|
||||
isOpenable={element.isOpenable}
|
||||
>
|
||||
<div className={styles.actionsBox}>{element.renderActions()}</div>
|
||||
</OptionsPaneCategory>
|
||||
)}
|
||||
<EditPaneHeader element={element} />
|
||||
{categories.map((cat) => cat.render())}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
noBorderTop: css({
|
||||
borderTop: 'none',
|
||||
}),
|
||||
actionsBox: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import { SceneObject, SceneObjectRef, VizPanel } from '@grafana/scenes';
|
||||
import { SceneObject, SceneObjectRef } from '@grafana/scenes';
|
||||
import { ElementSelectionContextItem } from '@grafana/ui';
|
||||
|
||||
import { isBulkActionElement } from '../scene/types/BulkActionElement';
|
||||
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types/EditableDashboardElement';
|
||||
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
|
||||
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
|
||||
|
||||
import { MultiSelectedObjectsEditableElement } from './MultiSelectedObjectsEditableElement';
|
||||
import { MultiSelectedVizPanelsEditableElement } from './MultiSelectedVizPanelsEditableElement';
|
||||
import { VizPanelEditableElement } from './VizPanelEditableElement';
|
||||
import { getEditableElementFor } from './shared';
|
||||
|
||||
export class ElementSelection {
|
||||
private selectedObjects?: Map<string, SceneObjectRef<SceneObject>>;
|
||||
private selectedObjects: Map<string, SceneObjectRef<SceneObject>>;
|
||||
private sameType?: boolean;
|
||||
|
||||
private _isMultiSelection: boolean;
|
||||
@@ -26,15 +23,15 @@ export class ElementSelection {
|
||||
}
|
||||
|
||||
private checkSameType() {
|
||||
const values = this.selectedObjects?.values();
|
||||
const firstType = values?.next().value?.resolve()?.constructor.name;
|
||||
const values = this.selectedObjects.values();
|
||||
const firstType = values.next().value?.resolve().constructor.name;
|
||||
|
||||
if (!firstType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let obj of values ?? []) {
|
||||
if (obj.resolve()?.constructor.name !== firstType) {
|
||||
if (obj.resolve().constructor.name !== firstType) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -43,13 +40,13 @@ export class ElementSelection {
|
||||
}
|
||||
|
||||
public hasValue(id: string) {
|
||||
return this.selectedObjects?.has(id);
|
||||
return this.selectedObjects.has(id);
|
||||
}
|
||||
|
||||
public removeValue(id: string) {
|
||||
this.selectedObjects?.delete(id);
|
||||
this.selectedObjects.delete(id);
|
||||
|
||||
if (this.selectedObjects && this.selectedObjects.size < 2) {
|
||||
if (this.selectedObjects.size < 2) {
|
||||
this.sameType = undefined;
|
||||
this._isMultiSelection = false;
|
||||
}
|
||||
@@ -95,11 +92,11 @@ export class ElementSelection {
|
||||
}
|
||||
|
||||
public getSelectionEntries(): Array<[string, SceneObjectRef<SceneObject>]> {
|
||||
return Array.from(this.selectedObjects?.entries() ?? []);
|
||||
return Array.from(this.selectedObjects.entries());
|
||||
}
|
||||
|
||||
public getFirstObject(): SceneObject | undefined {
|
||||
return this.selectedObjects?.values().next().value?.resolve();
|
||||
return this.selectedObjects.values().next().value?.resolve();
|
||||
}
|
||||
|
||||
public get isMultiSelection(): boolean {
|
||||
@@ -107,51 +104,38 @@ export class ElementSelection {
|
||||
}
|
||||
|
||||
private getSceneObjects(): SceneObject[] {
|
||||
return Array.from(this.selectedObjects?.values() ?? []).map((obj) => obj.resolve());
|
||||
return Array.from(this.selectedObjects.values() ?? []).map((obj) => obj.resolve());
|
||||
}
|
||||
|
||||
public createSelectionElement() {
|
||||
if (this.isMultiSelection) {
|
||||
return this.createMultiSelectedElement();
|
||||
}
|
||||
|
||||
return this.createSingleSelectedElement();
|
||||
}
|
||||
|
||||
private createSingleSelectedElement(): EditableDashboardElement | undefined {
|
||||
const sceneObj = this.selectedObjects?.values().next().value?.resolve();
|
||||
return getEditableElementFor(sceneObj);
|
||||
}
|
||||
|
||||
private createMultiSelectedElement(): MultiSelectedEditableDashboardElement | undefined {
|
||||
if (!this.isMultiSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
public createSelectionElement(): EditableDashboardElement | undefined {
|
||||
const sceneObjects = this.getSceneObjects();
|
||||
|
||||
if (this.sameType) {
|
||||
const firstObj = this.selectedObjects?.values().next().value?.resolve();
|
||||
if (sceneObjects.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (firstObj instanceof VizPanel) {
|
||||
return new MultiSelectedVizPanelsEditableElement(sceneObjects.filter((obj) => obj instanceof VizPanel));
|
||||
}
|
||||
const firstElement = getEditableElementFor(sceneObjects[0]);
|
||||
|
||||
if (isEditableDashboardElement(firstObj!)) {
|
||||
return firstObj.createMultiSelectedElement?.(sceneObjects);
|
||||
}
|
||||
if (!firstElement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (sceneObjects.length === 1) {
|
||||
return firstElement;
|
||||
}
|
||||
|
||||
if (this.sameType && firstElement.createMultiSelectedElement) {
|
||||
const elements = sceneObjects.map((obj) => getEditableElementFor(obj)!);
|
||||
return firstElement.createMultiSelectedElement(elements);
|
||||
}
|
||||
|
||||
const bulkActionElements = [];
|
||||
|
||||
for (const sceneObject of sceneObjects) {
|
||||
if (sceneObject instanceof VizPanel) {
|
||||
const editableElement = new VizPanelEditableElement(sceneObject);
|
||||
bulkActionElements.push(editableElement);
|
||||
}
|
||||
const element = getEditableElementFor(sceneObject);
|
||||
|
||||
if (isBulkActionElement(sceneObject)) {
|
||||
bulkActionElements.push(sceneObject);
|
||||
if (element && isBulkActionElement(element)) {
|
||||
bulkActionElements.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-32
@@ -1,42 +1,20 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { Stack, Text, Button } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { BulkActionElement } from '../scene/types/BulkActionElement';
|
||||
import { EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
|
||||
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
|
||||
import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
|
||||
|
||||
export class MultiSelectedObjectsEditableElement implements MultiSelectedEditableDashboardElement {
|
||||
public readonly isMultiSelectedEditableDashboardElement = true;
|
||||
public readonly key: string;
|
||||
export class MultiSelectedObjectsEditableElement implements EditableDashboardElement {
|
||||
public readonly isEditableDashboardElement = true;
|
||||
|
||||
constructor(private _elements: BulkActionElement[]) {
|
||||
this.key = uuidv4();
|
||||
constructor(private _elements: BulkActionElement[]) {}
|
||||
|
||||
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
public getEditableElementInfo(): EditableDashboardElementInfo {
|
||||
return { name: t('dashboard.edit-pane.elements.objects', 'Objects'), typeId: 'objects', icon: 'folder' };
|
||||
}
|
||||
|
||||
public renderActions(): ReactNode {
|
||||
return (
|
||||
<Stack direction="column">
|
||||
<Text>
|
||||
<Trans
|
||||
i18nKey="dashboard.edit-pane.objects.multi-select.selection-number"
|
||||
values={{ length: this._elements.length }}
|
||||
>
|
||||
No. of objects selected: {{ length }}
|
||||
</Trans>
|
||||
</Text>
|
||||
<Stack direction="row">
|
||||
<Button size="sm" variant="secondary" icon="copy" />
|
||||
<Button size="sm" variant="destructive" fill="outline" onClick={() => this.onDelete()} icon="trash-alt" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
return { typeName: t('dashboard.edit-pane.elements.objects', 'Objects'), icon: 'folder', instanceName: '' };
|
||||
}
|
||||
|
||||
public onDelete() {
|
||||
|
||||
+8
-21
@@ -1,40 +1,28 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
|
||||
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
|
||||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
|
||||
|
||||
import { EditPaneHeader } from './EditPaneHeader';
|
||||
import { VizPanelEditableElement } from './VizPanelEditableElement';
|
||||
|
||||
export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEditableDashboardElement {
|
||||
public readonly isMultiSelectedEditableDashboardElement = true;
|
||||
export class MultiSelectedVizPanelsEditableElement implements EditableDashboardElement {
|
||||
public readonly isEditableDashboardElement = true;
|
||||
public readonly key: string;
|
||||
|
||||
constructor(private _panels: VizPanel[]) {
|
||||
constructor(private _panels: VizPanelEditableElement[]) {
|
||||
this.key = uuidv4();
|
||||
}
|
||||
|
||||
public getEditableElementInfo(): EditableDashboardElementInfo {
|
||||
return { name: t('dashboard.edit-pane.elements.panels', 'Panels'), typeId: 'panels', icon: 'folder' };
|
||||
return { typeName: t('dashboard.edit-pane.elements.panels', 'Panels'), icon: 'folder', instanceName: '' };
|
||||
}
|
||||
|
||||
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
|
||||
const header = new OptionsPaneCategoryDescriptor({
|
||||
title: ``,
|
||||
id: 'panel-header',
|
||||
isOpenable: false,
|
||||
renderTitle: () => (
|
||||
<EditPaneHeader
|
||||
title={t('dashboard.layout.common.panels-title', '{{length}} panels selected', {
|
||||
length: this._panels.length,
|
||||
})}
|
||||
onDelete={() => this.onDelete()}
|
||||
/>
|
||||
),
|
||||
id: '',
|
||||
});
|
||||
|
||||
return [header];
|
||||
@@ -42,8 +30,7 @@ export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEdita
|
||||
|
||||
public onDelete() {
|
||||
this._panels.forEach((panel) => {
|
||||
const layout = dashboardSceneGraph.getLayoutManagerFor(panel);
|
||||
layout.removePanel?.(panel);
|
||||
panel.onDelete();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,9 @@ import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
|
||||
import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
|
||||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
import { getEditPanelUrl } from '../utils/urlBuilders';
|
||||
import { getPanelIdForVizPanel } from '../utils/utils';
|
||||
import { getDashboardSceneFor, getPanelIdForVizPanel } from '../utils/utils';
|
||||
|
||||
import { EditPaneHeader } from './EditPaneHeader';
|
||||
import { MultiSelectedVizPanelsEditableElement } from './MultiSelectedVizPanelsEditableElement';
|
||||
|
||||
export class VizPanelEditableElement implements EditableDashboardElement, BulkActionElement {
|
||||
public readonly isEditableDashboardElement = true;
|
||||
@@ -30,9 +30,9 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
|
||||
|
||||
public getEditableElementInfo(): EditableDashboardElementInfo {
|
||||
return {
|
||||
typeId: 'panel',
|
||||
typeName: t('dashboard.edit-pane.elements.panel', 'Panel'),
|
||||
icon: 'chart-line',
|
||||
name: sceneGraph.interpolate(this.panel, this.panel.state.title, undefined, 'text'),
|
||||
instanceName: sceneGraph.interpolate(this.panel, this.panel.state.title, undefined, 'text'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,14 +41,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
|
||||
const layoutElement = panel.parent!;
|
||||
|
||||
const panelOptions = useMemo(() => {
|
||||
return new OptionsPaneCategoryDescriptor({
|
||||
title: ``,
|
||||
id: 'panel-header',
|
||||
isOpenable: false,
|
||||
renderTitle: () => (
|
||||
<EditPaneHeader title={t('dashboard.viz-panel.options.title', 'Panel')} onDelete={() => this.onDelete()} />
|
||||
),
|
||||
})
|
||||
return new OptionsPaneCategoryDescriptor({ title: '', id: 'panel-options' })
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: '',
|
||||
@@ -97,6 +90,20 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
|
||||
const layout = dashboardSceneGraph.getLayoutManagerFor(this.panel);
|
||||
layout.removePanel?.(this.panel);
|
||||
}
|
||||
|
||||
public onDuplicate() {
|
||||
const layout = dashboardSceneGraph.getLayoutManagerFor(this.panel);
|
||||
layout.duplicatePanel?.(this.panel);
|
||||
}
|
||||
|
||||
public onCopy() {
|
||||
const dashboard = getDashboardSceneFor(this.panel);
|
||||
dashboard.copyPanel(this.panel);
|
||||
}
|
||||
|
||||
public createMultiSelectedElement(items: VizPanelEditableElement[]) {
|
||||
return new MultiSelectedVizPanelsEditableElement(items);
|
||||
}
|
||||
}
|
||||
|
||||
type OpenPanelEditVizProps = {
|
||||
@@ -112,7 +119,7 @@ const OpenPanelEditViz = ({ panel }: OpenPanelEditVizProps) => {
|
||||
return (
|
||||
<Stack alignItems="center" width="100%">
|
||||
{plugin ? (
|
||||
<Tooltip content={t('dashboard.viz-panel.options.open-edit', 'Open Panel Edit')}>
|
||||
<Tooltip content={t('dashboard.viz-panel.options.open-edit', 'Open panel editor')}>
|
||||
<a
|
||||
href={textUtil.sanitizeUrl(getEditPanelUrl(getPanelIdForVizPanel(panel)))}
|
||||
className={cx(styles.pluginDescriptionWrapper)}
|
||||
@@ -139,12 +146,15 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
columnGap: theme.spacing(1),
|
||||
rowGap: theme.spacing(0.5),
|
||||
minHeight: theme.spacing(4),
|
||||
backgroundColor: theme.components.input.background,
|
||||
border: `1px solid ${theme.colors.border.strong}`,
|
||||
backgroundColor: theme.colors.secondary.main,
|
||||
border: `1px solid ${theme.colors.secondary.border}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
paddingInline: theme.spacing(1),
|
||||
paddingBlock: theme.spacing(0.5),
|
||||
flexGrow: 1,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.colors.secondary.shade,
|
||||
},
|
||||
}),
|
||||
panelVizImg: css({
|
||||
width: '16px',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
|
||||
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
|
||||
import { getDashboardSceneFor } from '../utils/utils';
|
||||
|
||||
import { DashboardEditPane } from './DashboardEditPane';
|
||||
@@ -10,7 +9,7 @@ import { ElementSelection } from './ElementSelection';
|
||||
export function useEditableElement(
|
||||
selection: ElementSelection | undefined,
|
||||
editPane: DashboardEditPane
|
||||
): EditableDashboardElement | MultiSelectedEditableDashboardElement | undefined {
|
||||
): EditableDashboardElement | undefined {
|
||||
return useMemo(() => {
|
||||
if (!selection) {
|
||||
const dashboard = getDashboardSceneFor(editPane);
|
||||
|
||||
@@ -22,7 +22,6 @@ import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
|
||||
import { VizPanelLinks } from '../scene/PanelLinks';
|
||||
import { PanelTimeRange } from '../scene/PanelTimeRange';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
|
||||
import { isSceneVariableInstance } from '../settings/variables/utils';
|
||||
|
||||
import { DashboardChangeInfo } from './shared';
|
||||
@@ -122,7 +121,7 @@ export class DashboardSceneChangeTracker {
|
||||
}
|
||||
|
||||
private detectSaveModelChanges() {
|
||||
const changedDashboard = transformSceneToSaveModel(this._dashboard);
|
||||
const changedDashboard = this._dashboard.getSaveModel();
|
||||
const initialDashboard = this._dashboard.getInitialSaveModel();
|
||||
|
||||
// Objects must be stringify to ensure they are clonable, so they don't contain functions
|
||||
|
||||
+4
-12
@@ -1,8 +1,8 @@
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { sceneGraph, SceneGridRow, VizPanel } from '@grafana/scenes';
|
||||
import { Alert, Button, Input, TextLink } from '@grafana/ui';
|
||||
import { Alert, Input, TextLink } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
@@ -25,9 +25,9 @@ export class SceneGridRowEditableElement implements EditableDashboardElement, Bu
|
||||
|
||||
public getEditableElementInfo(): EditableDashboardElementInfo {
|
||||
return {
|
||||
typeId: 'panel',
|
||||
typeName: t('dashboard.edit-pane.elements.row', 'Row'),
|
||||
instanceName: sceneGraph.interpolate(this._row, this._row.state.title, undefined, 'text'),
|
||||
icon: 'line-alt',
|
||||
name: sceneGraph.interpolate(this._row, this._row.state.title, undefined, 'text'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,14 +72,6 @@ export class SceneGridRowEditableElement implements EditableDashboardElement, Bu
|
||||
layoutManager.removeRow(this._row);
|
||||
}
|
||||
}
|
||||
|
||||
public renderActions(): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="destructive" fill="outline" onClick={() => this.onDelete()} icon="trash-alt" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function RowTitleInput({ row }: { row: SceneGridRow }) {
|
||||
|
||||
@@ -44,7 +44,11 @@ export class RowItem
|
||||
}
|
||||
|
||||
public getEditableElementInfo(): EditableDashboardElementInfo {
|
||||
return { typeId: 'row', icon: 'line-alt', name: sceneGraph.interpolate(this, this.state.title, undefined, 'text') };
|
||||
return {
|
||||
typeName: t('dashboard.edit-pane.elements.row', 'Row'),
|
||||
instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'),
|
||||
icon: 'line-alt',
|
||||
};
|
||||
}
|
||||
|
||||
public getLayout(): DashboardLayoutManager {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSel
|
||||
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
|
||||
import { EditPaneHeader } from '../../edit-pane/EditPaneHeader';
|
||||
import { getDashboardSceneFor, getQueryRunnerFor } from '../../utils/utils';
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
import { DashboardLayoutSelector } from '../layouts-shared/DashboardLayoutSelector';
|
||||
@@ -23,14 +22,7 @@ export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[]
|
||||
const rowOptions = useMemo(() => {
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
|
||||
const editPaneHeaderOptions = new OptionsPaneCategoryDescriptor({
|
||||
title: t('dashboard.rows-layout.item-name', 'Row'),
|
||||
id: 'row-options',
|
||||
isOpenable: false,
|
||||
renderTitle: () => (
|
||||
<EditPaneHeader title={t('dashboard.rows-layout.item-name', 'Row')} onDelete={() => model.onDelete()} />
|
||||
),
|
||||
})
|
||||
const editPaneHeaderOptions = new OptionsPaneCategoryDescriptor({ title: '', id: 'row-options' })
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.rows-layout.option.title', 'Title'),
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { EditableDashboardElementInfo } from '../types/EditableDashboardElement';
|
||||
import { MultiSelectedEditableDashboardElement } from '../types/MultiSelectedEditableDashboardElement';
|
||||
import { EditableDashboardElementInfo, EditableDashboardElement } from '../types/EditableDashboardElement';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
import { getEditOptions } from './RowItemsEditor';
|
||||
|
||||
export class RowItems implements MultiSelectedEditableDashboardElement {
|
||||
public readonly isMultiSelectedEditableDashboardElement = true;
|
||||
public readonly key: string;
|
||||
export class RowItems implements EditableDashboardElement {
|
||||
public readonly isEditableDashboardElement = true;
|
||||
|
||||
public constructor(private _rows: RowItem[]) {
|
||||
this.key = uuidv4();
|
||||
}
|
||||
public constructor(private _rows: RowItem[]) {}
|
||||
|
||||
public getEditableElementInfo(): EditableDashboardElementInfo {
|
||||
return { name: t('dashboard.edit-pane.elements.rows', 'Rows'), typeId: 'rows', icon: 'folder' };
|
||||
return { typeName: t('dashboard.edit-pane.elements.rows', 'Rows'), icon: 'folder', instanceName: '' };
|
||||
}
|
||||
|
||||
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
|
||||
@@ -36,6 +30,4 @@ export class RowItems implements MultiSelectedEditableDashboardElement {
|
||||
public onHeaderHiddenToggle(value: boolean, indeterminate: boolean) {
|
||||
this._rows.forEach((row) => row.onHeaderHiddenToggle(indeterminate ? true : !value));
|
||||
}
|
||||
|
||||
public getNumberOfRowsSelected = () => this._rows.length;
|
||||
}
|
||||
@@ -3,24 +3,10 @@ import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
import { EditPaneHeader } from '../../edit-pane/EditPaneHeader';
|
||||
|
||||
import { RowItems } from './RowItems';
|
||||
|
||||
export function getEditOptions(model: RowItems): OptionsPaneCategoryDescriptor[] {
|
||||
const options = new OptionsPaneCategoryDescriptor({
|
||||
title: '',
|
||||
id: `ms-row-options-${model.key}`,
|
||||
isOpenable: false,
|
||||
renderTitle: () => (
|
||||
<EditPaneHeader
|
||||
title={t('dashboard.edit-pane.row.multi-select.title', '{{length}} rows selected', {
|
||||
length: model.getNumberOfRowsSelected(),
|
||||
})}
|
||||
onDelete={() => model.onDelete()}
|
||||
/>
|
||||
),
|
||||
}).addItem(
|
||||
const options = new OptionsPaneCategoryDescriptor({ title: '', id: `rows-options` }).addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.edit-pane.row.header.title', 'Row header'),
|
||||
render: () => <RowHeaderCheckboxMulti model={model} />,
|
||||
|
||||
@@ -40,7 +40,11 @@ export class TabItem
|
||||
}
|
||||
|
||||
public getEditableElementInfo(): EditableDashboardElementInfo {
|
||||
return { typeId: 'tab', icon: 'tag-alt', name: sceneGraph.interpolate(this, this.state.title, undefined, 'text') };
|
||||
return {
|
||||
typeName: t('dashboard.edit-pane.elements.tab', 'Tab'),
|
||||
instanceName: sceneGraph.interpolate(this, this.state.title, undefined, 'text'),
|
||||
icon: 'tag-alt',
|
||||
};
|
||||
}
|
||||
|
||||
public getLayout(): DashboardLayoutManager {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
import { EditPaneHeader } from '../../edit-pane/EditPaneHeader';
|
||||
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
|
||||
import { useEditPaneInputAutoFocus } from '../layouts-shared/utils';
|
||||
|
||||
@@ -13,14 +12,7 @@ import { TabItem } from './TabItem';
|
||||
|
||||
export function getEditOptions(model: TabItem): OptionsPaneCategoryDescriptor[] {
|
||||
const tabOptions = useMemo(() => {
|
||||
return new OptionsPaneCategoryDescriptor({
|
||||
title: '',
|
||||
id: 'tab-options',
|
||||
isOpenable: false,
|
||||
renderTitle: () => (
|
||||
<EditPaneHeader title={t('dashboard.tabs-layout.tab-options.title', 'Tab')} onDelete={() => model.onDelete()} />
|
||||
),
|
||||
}).addItem(
|
||||
return new OptionsPaneCategoryDescriptor({ title: '', id: 'tab-item-options' }).addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.tabs-layout.tab-options.title-option', 'Title'),
|
||||
render: () => <TabTitleInput tab={model} />,
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { EditableDashboardElementInfo } from '../types/EditableDashboardElement';
|
||||
import { MultiSelectedEditableDashboardElement } from '../types/MultiSelectedEditableDashboardElement';
|
||||
import { EditableDashboardElement, EditableDashboardElementInfo } from '../types/EditableDashboardElement';
|
||||
|
||||
import { TabItem } from './TabItem';
|
||||
import { getEditOptions } from './TabItemsEditor';
|
||||
|
||||
export class TabItems implements MultiSelectedEditableDashboardElement {
|
||||
public readonly isMultiSelectedEditableDashboardElement = true;
|
||||
public readonly key: string;
|
||||
export class TabItems implements EditableDashboardElement {
|
||||
public readonly isEditableDashboardElement = true;
|
||||
|
||||
public constructor(private _tabs: TabItem[]) {
|
||||
this.key = uuidv4();
|
||||
}
|
||||
public constructor(private _tabs: TabItem[]) {}
|
||||
|
||||
public getEditableElementInfo(): EditableDashboardElementInfo {
|
||||
return { name: t('dashboard.edit-pane.elements.tabs', 'Tabs'), typeId: 'tabs', icon: 'folder' };
|
||||
return { typeName: t('dashboard.edit-pane.elements.tabs', 'Tabs'), icon: 'folder', instanceName: '' };
|
||||
}
|
||||
|
||||
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
|
||||
return getEditOptions(this);
|
||||
return [];
|
||||
}
|
||||
|
||||
public getTabs(): TabItem[] {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { EditPaneHeader } from '../../edit-pane/EditPaneHeader';
|
||||
|
||||
import { TabItems } from './TabItems';
|
||||
|
||||
export function getEditOptions(model: TabItems): OptionsPaneCategoryDescriptor[] {
|
||||
const tabOptions = useMemo(() => {
|
||||
const tabs = model.getTabs();
|
||||
return new OptionsPaneCategoryDescriptor({
|
||||
title: ``,
|
||||
id: 'ms-tab-options',
|
||||
isOpenable: false,
|
||||
renderTitle: () => (
|
||||
<EditPaneHeader
|
||||
title={t('dashboard.tabs-layout.multi-select.title', '{{length}} tabs selected', { length: tabs.length })}
|
||||
onDelete={() => model.onDelete()}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [model]);
|
||||
|
||||
return [tabOptions];
|
||||
}
|
||||
+10
-7
@@ -19,8 +19,8 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
|
||||
const { isEditing } = dashboard.useState();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabsBar className={styles.tabsWrapper}>
|
||||
<div className={styles.tabLayoutContainer}>
|
||||
<TabsBar className={styles.tabsBar}>
|
||||
<div className={styles.tabsRow}>
|
||||
<div className={styles.tabsContainer}>
|
||||
{tabs.map((tab) => (
|
||||
@@ -35,12 +35,17 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
|
||||
<TabContent className={styles.tabContentContainer}>
|
||||
{currentTab && <layout.Component model={layout} />}
|
||||
</TabContent>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
tabsWrapper: css({
|
||||
tabLayoutContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: '1 1 auto',
|
||||
}),
|
||||
tabsBar: css({
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
tabsRow: css({
|
||||
@@ -60,9 +65,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
backgroundColor: 'transparent',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
scrollbarWidth: 'thin',
|
||||
minHeight: 0,
|
||||
padding: '2px 2px 0 2px',
|
||||
}),
|
||||
});
|
||||
@@ -1,11 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { IconName } from '@grafana/data';
|
||||
import { SceneObject } from '@grafana/scenes';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { MultiSelectedEditableDashboardElement } from './MultiSelectedEditableDashboardElement';
|
||||
|
||||
/**
|
||||
* Interface for elements that have options
|
||||
*/
|
||||
@@ -24,29 +19,29 @@ export interface EditableDashboardElement {
|
||||
useEditPaneOptions(): OptionsPaneCategoryDescriptor[];
|
||||
|
||||
/**
|
||||
* Panel Actions
|
||||
**/
|
||||
renderActions?(): ReactNode;
|
||||
* Supports delete action
|
||||
*/
|
||||
onDelete?(): void;
|
||||
|
||||
/**
|
||||
* Supports duplicate action
|
||||
*/
|
||||
onDuplicate?(): void;
|
||||
|
||||
/**
|
||||
* Supports copy action
|
||||
*/
|
||||
onCopy?(): void;
|
||||
|
||||
/**
|
||||
* creates a new multi-selection element from a list of selected items
|
||||
*/
|
||||
createMultiSelectedElement?(items: SceneObject[]): MultiSelectedEditableDashboardElement;
|
||||
|
||||
/**
|
||||
* Return custom title for the edit panel header
|
||||
*/
|
||||
renderTitle?(): ReactNode;
|
||||
|
||||
/**
|
||||
* determines if first edit panel header can be collapsed
|
||||
*/
|
||||
isOpenable?: Readonly<boolean>;
|
||||
createMultiSelectedElement?(elements: this[]): EditableDashboardElement;
|
||||
}
|
||||
|
||||
export interface EditableDashboardElementInfo {
|
||||
name: string;
|
||||
typeId: string;
|
||||
instanceName: string;
|
||||
typeName: string;
|
||||
icon: IconName;
|
||||
}
|
||||
|
||||
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { EditableDashboardElementInfo } from './EditableDashboardElement';
|
||||
|
||||
export interface MultiSelectedEditableDashboardElement {
|
||||
/**
|
||||
* Marks this object as an element that can be selected and edited directly on the canvas
|
||||
*/
|
||||
isMultiSelectedEditableDashboardElement: true;
|
||||
|
||||
/** A descriptor used by editing pane */
|
||||
getEditableElementInfo(): EditableDashboardElementInfo;
|
||||
|
||||
/**
|
||||
* Extremely useful for being able to access the useState inside the contained items
|
||||
*/
|
||||
key: Readonly<string>;
|
||||
|
||||
/**
|
||||
* Hook that returns edit pane options
|
||||
*/
|
||||
useEditPaneOptions?(): OptionsPaneCategoryDescriptor[];
|
||||
|
||||
/**
|
||||
* Panel Actions
|
||||
**/
|
||||
renderActions?(): ReactNode;
|
||||
|
||||
/**
|
||||
* Return custom title for the edit panel header
|
||||
*/
|
||||
renderTitle?(): ReactNode;
|
||||
|
||||
/**
|
||||
* determines if first edit panel header can be collapsed
|
||||
*/
|
||||
isOpenable?: Readonly<boolean>;
|
||||
}
|
||||
|
||||
export function isMultiSelectedEditableDashboardElement(obj: object): obj is MultiSelectedEditableDashboardElement {
|
||||
return 'isMultiSelectedEditableDashboardElement' in obj;
|
||||
}
|
||||
@@ -21,7 +21,6 @@ export interface OptionsPaneCategoryProps {
|
||||
isNested?: boolean;
|
||||
children: ReactNode;
|
||||
sandboxId?: string;
|
||||
isOpenable?: boolean;
|
||||
}
|
||||
|
||||
const CATEGORY_PARAM_NAME = 'showCategory' as const;
|
||||
@@ -38,13 +37,12 @@ export const OptionsPaneCategory = React.memo(
|
||||
itemsCount,
|
||||
isNested = false,
|
||||
sandboxId,
|
||||
isOpenable = true,
|
||||
}: OptionsPaneCategoryProps) => {
|
||||
const [savedState, setSavedState] = useLocalStorage(getOptionGroupStorageKey(id), {
|
||||
isExpanded: isOpenDefault,
|
||||
});
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(!isOpenable || (savedState?.isExpanded ?? isOpenDefault));
|
||||
const [isExpanded, setIsExpanded] = useState(savedState?.isExpanded ?? isOpenDefault);
|
||||
const manualClickTime = useRef(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [queryParams, updateQueryParams] = useQueryParams();
|
||||
@@ -68,9 +66,6 @@ export const OptionsPaneCategory = React.memo(
|
||||
}, [forceOpen, isExpanded, isOpenFromUrl]);
|
||||
|
||||
const onToggle = useCallback(() => {
|
||||
if (!isOpenable) {
|
||||
return;
|
||||
}
|
||||
manualClickTime.current = Date.now();
|
||||
updateQueryParams(
|
||||
{
|
||||
@@ -80,7 +75,7 @@ export const OptionsPaneCategory = React.memo(
|
||||
);
|
||||
setSavedState({ isExpanded: !isExpanded });
|
||||
setIsExpanded(!isExpanded);
|
||||
}, [isOpenable, updateQueryParams, isExpanded, id, setSavedState]);
|
||||
}, [updateQueryParams, isExpanded, id, setSavedState]);
|
||||
|
||||
if (!renderTitle) {
|
||||
renderTitle = function defaultTitle(isExpanded: boolean) {
|
||||
@@ -106,7 +101,6 @@ export const OptionsPaneCategory = React.memo(
|
||||
);
|
||||
|
||||
const headerStyles = cx(styles.header, {
|
||||
[styles.headerHover]: isOpenable,
|
||||
[styles.headerExpanded]: isExpanded,
|
||||
[styles.headerNested]: isNested,
|
||||
});
|
||||
@@ -129,19 +123,18 @@ export const OptionsPaneCategory = React.memo(
|
||||
<h6 id={`button-${id}`} className={styles.title}>
|
||||
{renderTitle(isExpanded)}
|
||||
</h6>
|
||||
{isOpenable ? (
|
||||
<Button
|
||||
data-testid={selectors.components.OptionsGroup.toggle(id)}
|
||||
type="button"
|
||||
fill="text"
|
||||
size="md"
|
||||
variant="secondary"
|
||||
aria-expanded={isExpanded}
|
||||
className={styles.toggleButton}
|
||||
icon={isExpanded ? 'angle-up' : 'angle-down'}
|
||||
onClick={onToggle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
data-testid={selectors.components.OptionsGroup.toggle(id)}
|
||||
type="button"
|
||||
fill="text"
|
||||
size="md"
|
||||
variant="secondary"
|
||||
aria-expanded={isExpanded}
|
||||
className={styles.toggleButton}
|
||||
icon={isExpanded ? 'angle-up' : 'angle-down'}
|
||||
onClick={onToggle}
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className={bodyStyles} id={id} aria-labelledby={`button-${id}`}>
|
||||
@@ -179,8 +172,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
padding: theme.spacing(0.5, 1.5),
|
||||
color: theme.colors.text.primary,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
}),
|
||||
headerHover: css({
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
background: theme.colors.emphasize(theme.colors.background.primary, 0.03),
|
||||
|
||||
+6
-3
@@ -16,7 +16,6 @@ export interface OptionsPaneCategoryDescriptorProps {
|
||||
itemsCount?: number;
|
||||
customRender?: () => React.ReactNode;
|
||||
sandboxId?: string;
|
||||
isOpenable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,8 +59,12 @@ export class OptionsPaneCategoryDescriptor {
|
||||
return this.props.customRender();
|
||||
}
|
||||
|
||||
if (this.props.id === '') {
|
||||
return <Box padding={2}>{this.items.map((item) => item.render(searchQuery))}</Box>;
|
||||
if (this.props.title === '') {
|
||||
return (
|
||||
<Box padding={2} key={this.props.title}>
|
||||
{this.items.map((item) => item.render(searchQuery))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -312,7 +312,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
||||
isTraceQlMetricsQuery(query: string): boolean {
|
||||
// Check whether this is a metrics query by checking if it contains a metrics function
|
||||
const metricsFnRegex =
|
||||
/\|\s*(rate|count_over_time|avg_over_time|max_over_time|min_over_time|quantile_over_time|histogram_over_time|compare)\s*\(/;
|
||||
/\|\s*(rate|count_over_time|avg_over_time|max_over_time|min_over_time|sum_over_time|quantile_over_time|histogram_over_time|compare)\s*\(/;
|
||||
return !!query.trim().match(metricsFnRegex);
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user