Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0f2bdbcc1 | ||
|
|
27a3503e61 | ||
|
|
b473524787 | ||
|
|
cb05a4ae1b | ||
|
|
84a07be6e4 | ||
|
|
a8aef11926 | ||
|
|
f116539541 | ||
|
|
a455f9700d | ||
|
|
399370bc2c | ||
|
|
b08e7bc373 | ||
|
|
7eb467e561 | ||
|
|
d732bb2751 | ||
|
|
c29ed31c7a | ||
|
|
ebaccc781b | ||
|
|
ca8cad68c8 | ||
|
|
51d562eb81 | ||
|
|
4130bd9cd3 | ||
|
|
759d49a1df | ||
|
|
84fbe6bc7b | ||
|
|
9606e9c51c | ||
|
|
d49993ddab | ||
|
|
f0a394e67b | ||
|
|
c94bf34d0b | ||
|
|
5ba3139d4a | ||
|
|
e1a2f178e7 | ||
|
|
ae2e5f0df7 | ||
|
|
21c1d9aedd | ||
|
|
8c7170727b | ||
|
|
cef4449f14 | ||
|
|
a3dacabedf | ||
|
|
291e3ea9cf | ||
|
|
5538dfe73d | ||
|
|
513b81a531 | ||
|
|
136a0eb4d6 | ||
|
|
bfda534825 | ||
|
|
d226c35904 | ||
|
|
a99946d921 | ||
|
|
2544a3441e |
@@ -185,6 +185,7 @@
|
||||
/pkg/services/search/ @grafana/grafana-search-and-storage
|
||||
/pkg/services/searchusers/ @grafana/grafana-search-and-storage
|
||||
/pkg/services/secrets/ @grafana/grafana-operator-experience-squad
|
||||
/pkg/services/setting/ @grafana/grafana-backend-services-squad
|
||||
/pkg/services/shorturls/ @grafana/sharing-squad
|
||||
/pkg/services/sqlstore/ @grafana/grafana-search-and-storage
|
||||
/pkg/services/ssosettings/ @grafana/identity-squad
|
||||
|
||||
@@ -71,6 +71,7 @@ public/css/*.min.css
|
||||
.vs/
|
||||
.cursor/
|
||||
.devcontainer/
|
||||
.claude/
|
||||
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
|
||||
@@ -14,10 +14,10 @@ weight: 400
|
||||
The Grafana Cloud Migration Assistant, generally available from Grafana v12.0, automatically migrates resources from your Grafana OSS/Enterprise instance to Grafana Cloud. It provides the following functionality:
|
||||
|
||||
- Securely connect your self-managed instance to a Grafana Cloud instance.
|
||||
- Seamlessly migrate resources such as dashboards, data sources, and folders to your cloud instance in a few easy steps.
|
||||
- Migrate resources such as dashboards, data sources, and folders to your cloud instance in a few easy steps.
|
||||
- View the migration status of your resources in real-time.
|
||||
|
||||
Some of the benefits of the migration assistant are:
|
||||
Some benefits of the migration assistant are:
|
||||
|
||||
Ease of use
|
||||
: Follow the steps provided by the UI to easily migrate all your resources to Grafana Cloud without using Grafana APIs or scripts.
|
||||
@@ -44,7 +44,7 @@ The following resources are supported by the migration assistant:
|
||||
|
||||
To use the Grafana migration assistant, you need:
|
||||
|
||||
- Grafana v11.2 or above with the `onPremToCloudMigrations` feature toggle enabled. In Grafana 11.5, this is enabled by default. For more information on how to enable a feature toggle, refer to [Configure feature toggles](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/#configure-feature-toggles).
|
||||
- A self-managed Grafana instance version v11.2 or above with the `onPremToCloudMigrations` feature toggle enabled. In Grafana 11.5, this is enabled by default. For more information on how to enable a feature toggle, refer to [Configure feature toggles](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/#configure-feature-toggles).
|
||||
- A [Grafana Cloud Stack](https://grafana.com/docs/grafana-cloud/get-started/) you intend to migrate your resources to.
|
||||
- [`Admin`](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/cloud-roles/) access to the Grafana Cloud Stack. To check your access level, go to `https://grafana.com/orgs/<YOUR-ORG-NAME>/members`.
|
||||
- [Grafana server administrator](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/#grafana-server-administrators) access to your existing Grafana OSS/Enterprise instance. To check your access level, go to `https://<GRAFANA-ONPREM-URL>/admin/users`.
|
||||
@@ -64,7 +64,7 @@ In Grafana Enterprise, the server administrator has access to the migration assi
|
||||
|
||||
### Grant access in Grafana Enterprise
|
||||
|
||||
{{< admonition type="important">}}
|
||||
{{< admonition type="note" >}}
|
||||
You must [configure RBAC](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/configure-rbac/) before you can grant other administrators access to the Grafana Migration Assistant.
|
||||
{{< /admonition >}}
|
||||
|
||||
|
||||
@@ -74,6 +74,21 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/
|
||||
configure-grafana-azure-auth:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-access/configure-authentication/azuread/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-access/configure-authentication/azuread/
|
||||
configure-grafana-azure:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#azure
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#azure
|
||||
configure-grafana-azure-auth-scopes:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana
|
||||
---
|
||||
|
||||
# Configure the Microsoft SQL Server data source
|
||||
@@ -138,14 +153,19 @@ If you're using an older version of Microsoft SQL Server like 2008 and 2008R2, y
|
||||
|
||||
**Authentication:**
|
||||
|
||||
| Authentication Type | Description | Credentials / Fields |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| **SQL Server Authentication** | Default method to connect to MSSQL. Use a SQL Server or Windows login in `DOMAIN\User` format. | - **Username**: SQL Server username<br>- **Password**: SQL Server password |
|
||||
| **Windows Authentication**<br>(Integrated Security) | Uses the logged-in Windows user's credentials via single sign-on. Available only when SQL Server allows Windows Authentication. | No input required; uses the logged-in Windows user's credentials |
|
||||
| **Windows AD**<br>(Username/Password) | Authenticates a domain user with their Active Directory username and password. | - **Username**: `user@example.com`<br>- **Password**: Active Directory password |
|
||||
| **Windows AD**<br>(Keytab) | Authenticates a domain user using a keytab file. | - **Username**: `user@example.com`<br>- **Keytab file path**: Path to your keytab file |
|
||||
| **Windows AD**<br>(Credential Cache) | Uses a Kerberos credential cache already loaded in memory (e.g., from a prior `kinit` command). No file needed. | - **Credential cache path**: Path to in-memory credential (e.g., `/tmp/krb5cc_1000`) |
|
||||
| **Windows AD**<br>(Credential Cache File) | Authenticates a domain user using a credential cache file (`.ccache`). | - **Username**: `user@example.com`<br>- **Credential cache file path**: e.g., `/home/grot/cache.json` |
|
||||
{{< admonition type="note" >}}
|
||||
In order to use Azure AD Authentication the toggle `auth.azure_auth_enabled` must be set to `true` in the Grafana configuration file.
|
||||
{{< /admonition >}}
|
||||
|
||||
| Authentication Type | Description | Credentials / Fields |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **SQL Server Authentication** | Default method to connect to MSSQL. Use a SQL Server or Windows login in `DOMAIN\User` format. | - **Username**: SQL Server username<br>- **Password**: SQL Server password |
|
||||
| **Windows Authentication**<br>(Integrated Security) | Uses the logged-in Windows user's credentials via single sign-on. Available only when SQL Server allows Windows Authentication. | No input required; uses the logged-in Windows user's credentials |
|
||||
| **Windows AD**<br>(Username/Password) | Authenticates a domain user with their Active Directory username and password. | - **Username**: `user@example.com`<br>- **Password**: Active Directory password |
|
||||
| **Windows AD**<br>(Keytab) | Authenticates a domain user using a keytab file. | - **Username**: `user@example.com`<br>- **Keytab file path**: Path to your keytab file |
|
||||
| **Windows AD**<br>(Credential Cache) | Uses a Kerberos credential cache already loaded in memory (e.g., from a prior `kinit` command). No file needed. | - **Credential cache path**: Path to in-memory credential (e.g., `/tmp/krb5cc_1000`) |
|
||||
| **Windows AD**<br>(Credential Cache File) | Authenticates a domain user using a credential cache file (`.ccache`). | - **Username**: `user@example.com`<br>- **Credential cache file path**: e.g., `/home/grot/cache.json` |
|
||||
| **Azure Entra ID (formerly Azure AD) Authentication** | Authenticates the data source using Azure authentication methods. | Details on the supported authentication methods and how to configure them can be found in the [Azure authentication section](./index.md#azure-entra-id-formerly-azure-ad-authentication). |
|
||||
|
||||
**Additional settings:**
|
||||
|
||||
@@ -185,6 +205,123 @@ After configuring your MSSQL data source options, click **Save & test** at the b
|
||||
|
||||
**Database Connection OK**
|
||||
|
||||
### Azure Entra ID (formerly Azure AD) Authentication
|
||||
|
||||
The following Azure authentication methods are supported:
|
||||
|
||||
- Current User authentication
|
||||
- App Registration
|
||||
- Managed Identity
|
||||
- Azure Entra Password
|
||||
|
||||
The Azure SQL Server that you are connecting to should support Azure Entra authentication to support adding the App Registration as a user in the database. For configuration details, refer to the [Azure SQL documentation](https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-aad-configure?view=azuresql&tabs=azure-portal).
|
||||
|
||||
#### Current User authentication
|
||||
|
||||
This is the recommended authentication mechanism when working with SQL Server instances that are hosted in Azure. It allows users to be authenticated to and query the database using their own credentials rather than long-lived credentials.
|
||||
|
||||
This authentication method requires your Grafana instance to be configured with Azure Entra ID (formerly Active Directory) authentication for login. With Azure Entra ID login, this method can be used to forward the currently logged in user’s credentials to the data source. The users credentials will then be used when requesting data from the data source. For details on how to configure your Grafana instance using Azure Entra refer to the [documentation](ref:configure-grafana-azure-auth).
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Additional configuration is required to ensure that the App Registration used to login a user via Azure provides an access token with the permissions required by the data source.
|
||||
|
||||
The App Registration must be configured to issue both **Access Tokens** and **ID Tokens**.
|
||||
|
||||
1. In the Azure Portal, open the App Registration that requires configuration.
|
||||
2. Select **Authentication** in the side menu.
|
||||
3. Under **Implicit grant and hybrid flows** check both the **Access tokens** and **ID tokens** boxes.
|
||||
4. Save the changes to ensure the App Registration is updated.
|
||||
|
||||
The App Registration must also be configured with additional **API Permissions** to provide authenticated users with access to the APIs utilised by the data source.
|
||||
|
||||
1. In the Azure Portal, open the App Registration that requires configuration.
|
||||
1. Select **API Permissions** in the side menu.
|
||||
1. Ensure the `openid`, `profile`, `email`, and `offline_access` permissions are present under the **Microsoft Graph** section. If not, they must be added.
|
||||
1. Select **Add a permission** and choose the following permissions. They must be added individually. Refer to the [Azure documentation](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-configure-app-access-web-apis) for more information.
|
||||
- Select **APIs my organization uses** > Search for **Azure SQL** and select it > **Delegated permissions** > `user_impersonation` > **Add permissions**
|
||||
|
||||
After all permissions have been added, the Azure authentication section in Grafana must be updated. The `scopes` section must be updated to include the `.default` scope to ensure that a token with access to all APIs declared on the App Registration is requested by Grafana. After updated the scopes value should equal: `.default openid email profile`.
|
||||
{{< /admonition >}}
|
||||
|
||||
This method of authentication doesn't inherently support all backend functionality as a user's credentials won't be in scope. Affected functionality includes alerting, reporting, and recorded queries. Also, note that query and resource caching is disabled by default for data sources using current user authentication.
|
||||
|
||||
**To enable current user authentication for Grafana:**
|
||||
|
||||
1. Set the `user_identity_enabled` flag in the `[azure]` section of the [Grafana server configuration](ref:configure-grafana-azure).
|
||||
|
||||
```ini
|
||||
[azure]
|
||||
user_identity_enabled = true
|
||||
```
|
||||
|
||||
2. In the SQL Server data source configuration, set **Authentication** to **Azure AD Authentication** and the Azure Authentication type to **Current User**.
|
||||
|
||||
### App Registration
|
||||
|
||||
You must create an app registration and service principal in Azure Entra to authenticate the data source.
|
||||
For configuration details, refer to the [Azure documentation for service principals](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal#get-tenant-and-app-id-values-for-signing-in).
|
||||
|
||||
After the app registration has been created, make note of the tenant ID, client ID, and client secret. Take the following steps to add the app registration as a SQL user:
|
||||
|
||||
1. Connect to your Azure SQL database as a user with administrative permissions (the user used here must have the ability to read your Azure Entra directory e.g. by possessing the `Directory Readers` role).
|
||||
2. Run `CREATE USER [$IDENTITY_NAME] FROM EXTERNAL PROVIDER;`, substituting `IDENTITY_NAME` with the app registration name.
|
||||
3. Grant the created user the appropriate level of permissions for your use-case. It is recommended that users configured for data sources only have reader permissions.
|
||||
|
||||
After the appropriate permissions have been granted, configure the SQL Server data source to use the app registration:
|
||||
|
||||
1. In the SQL Server data source configuration, set **Authentication** to **Azure AD Authentication** and the Azure Authentication type to **App Registration**.
|
||||
2. Set the **Azure Cloud** value to the correct value. If you are using the Azure public cloud this will be **Azure**.
|
||||
3. Set the **Directory (tenant) ID**, **Application (client) ID**, and **Client Secret** values to those for your app registration.
|
||||
|
||||
### Managed Identity
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Managed Identity is available only in [Azure Managed Grafana](https://azure.microsoft.com/en-us/products/managed-grafana) or Grafana OSS/Enterprise when deployed in Azure. It is not available in Grafana Cloud.
|
||||
{{< /admonition >}}
|
||||
|
||||
You can use managed identity to configure SQL Server in Grafana if you host Grafana in Azure (such as an App Service or with Azure Virtual Machines) and have managed identity enabled on your VM.
|
||||
This lets you securely authenticate data sources without manually configuring credentials via Azure AD App Registrations.
|
||||
For details on Azure managed identities, refer to the [Azure documentation](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview).
|
||||
|
||||
**To enable managed identity for Grafana:**
|
||||
|
||||
1. Set the `managed_identity_enabled` flag in the `[azure]` section of the [Grafana server configuration](ref:configure-grafana-azure).
|
||||
|
||||
```ini
|
||||
[azure]
|
||||
managed_identity_enabled = true
|
||||
```
|
||||
|
||||
2. In the SQL Server data source configuration, set **Authentication** to **Azure AD Authentication** and the Azure Authentication type to **Managed Identity**.
|
||||
|
||||
This hides the directory ID, application ID, and client secret fields, and the data source uses managed identity to authenticate to SQL Server.
|
||||
|
||||
3. You can set the `managed_identity_client_id` field in the `[azure]` section of the [Grafana server configuration](ref:configure-grafana-azure) to allow a user-assigned managed identity to be used instead of the default system-assigned identity.
|
||||
|
||||
Ensure that the managed identity used is added to your Azure SQL instance as a user.
|
||||
|
||||
### Azure Entra Password
|
||||
|
||||
{{< admonition type="warning" >}}
|
||||
Azure Entra Password is not a recommended authentication mechanism as it requires configuration using a single users password. Consider an alternative authentication method such as current user authentication or app registration.
|
||||
{{< /admonition >}}
|
||||
|
||||
You can connect to an Azure SQL database using the username and password of a user that has permissions in the desired database. This also requires an app registration to be configured with access to the database.
|
||||
|
||||
**To enable Azure Entra password for Grafana:**
|
||||
|
||||
1. Set the `azure_entra_password_credentials_enabled` flag in the `[azure]` section of the [Grafana server configuration](ref:configure-grafana-azure).
|
||||
|
||||
```ini
|
||||
[azure]
|
||||
azure_entra_password_credentials_enabled = true
|
||||
```
|
||||
|
||||
2. In the SQL Server data source configuration, set **Authentication** to **Azure AD Authentication** and the Azure Authentication type to **Azure Entra Password**.
|
||||
3. Set the **User ID** value to the username of the user in the Azure SQL database.
|
||||
4. Set the **Application Client ID** to the client ID of the app registration that has been added to the Azure SQL database
|
||||
5. Set the **Password** value to the password of the user in the Azure SQL database.
|
||||
|
||||
### Min time interval
|
||||
|
||||
The **Min time interval** setting defines a lower limit for the [`$__interval`](ref:add-template-variables-interval) and [`$__interval_ms`][add-template-variables-interval_ms] variables.
|
||||
|
||||
@@ -767,12 +767,12 @@ Status Codes:
|
||||
|
||||
Deletes a dashboard via the dashboard uid.
|
||||
|
||||
- namespace: to read more about the namespace to use, see the [API overview](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/developers/http_api/apis/).
|
||||
- uid: the unique identifier of the dashboard to update. this will be the _name_ in the dashboard response
|
||||
- **`namespace`**: To read more about the namespace to use, see the [API overview](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/developers/http_api/apis/).
|
||||
- **`uid`**: The unique identifier of the dashboard to update. This is the `metadata.name` field in the dashboard response and _not_ the `metadata.uid` field.
|
||||
|
||||
**Required permissions**
|
||||
|
||||
See note in the [introduction]({{< ref "#dashboard-api" >}}) for an explanation.
|
||||
See note in the [introduction](#new-dashboard-apis) for an explanation.
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
| Action | Scope |
|
||||
|
||||
@@ -1933,6 +1933,10 @@ The initial delay before retrying a failed alert evaluation. Default is `1s`.
|
||||
|
||||
This value is the starting point for exponential backoff.
|
||||
|
||||
#### `initialization_timeout`
|
||||
|
||||
Allows the context deadline for the `AlertNG` service to be configurable. The default timeout is 30s.
|
||||
|
||||
#### `max_retry_delay`
|
||||
|
||||
The maximum delay between retries during exponential backoff. Default is `4s`.
|
||||
|
||||
@@ -66,6 +66,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `grafanaAssistantInProfilesDrilldown` | Enables integration with Grafana Assistant in Profiles Drilldown | Yes |
|
||||
| `sharingDashboardImage` | Enables image sharing functionality for dashboards | Yes |
|
||||
| `tabularNumbers` | Use fixed-width numbers globally in the UI | |
|
||||
| `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker | Yes |
|
||||
| `tempoSearchBackendMigration` | Run search queries through the tempo backend | |
|
||||
|
||||
## Public preview feature toggles
|
||||
@@ -95,9 +96,9 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `localeFormatPreference` | Specifies the locale so the correct format for numbers and dates can be shown |
|
||||
| `logsPanelControls` | Enables a control component for the logs panel in Explore |
|
||||
| `interactiveLearning` | Enables the interactive learning app |
|
||||
| `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker |
|
||||
| `newVizSuggestions` | Enable new visualization suggestions |
|
||||
| `preventPanelChromeOverflow` | Restrict PanelChrome contents with overflow: hidden; |
|
||||
| `newPanelPadding` | Increases panel padding globally |
|
||||
| `transformationsEmptyPlaceholder` | Show transformation quick-start cards in empty transformations state |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
@@ -414,13 +414,13 @@ test.describe(
|
||||
).toBeVisible();
|
||||
|
||||
// Go back to dashboard options
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click({ force: true });
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
// Expand layouts section
|
||||
await page.getByLabel('Expand Group layout category').click();
|
||||
|
||||
// Select tabs layout
|
||||
await page.getByLabel('Tabs').click();
|
||||
await page.getByLabel('layout-selection-option-Tabs').click();
|
||||
|
||||
await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New row'))).toBeVisible();
|
||||
await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New row 1'))).toBeVisible();
|
||||
@@ -518,14 +518,14 @@ test.describe(
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput)
|
||||
.fill('Test row 1');
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
|
||||
|
||||
// clear the title input to simulate no title and click away to trigger onBlur
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('Test row 1')).click();
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput)
|
||||
.fill('');
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
|
||||
|
||||
// title should be set to a default name
|
||||
await expect(
|
||||
@@ -543,14 +543,14 @@ test.describe(
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput)
|
||||
.fill('Test row 2');
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
|
||||
|
||||
// clear the title input to simulate no title and click away to trigger onBlur
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('Test row 2')).click();
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput)
|
||||
.fill('');
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
|
||||
|
||||
// title should be set to a default name + 1 to avoid duplicates
|
||||
await expect(
|
||||
@@ -755,13 +755,13 @@ test.describe(
|
||||
await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New tab 2'))).toBeVisible();
|
||||
|
||||
// Go back to dashboard options
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click({ force: true });
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
// Expand layouts section
|
||||
await page.getByLabel('Expand Group layout category').click();
|
||||
|
||||
// Select rows layout
|
||||
await page.getByLabel('Rows').click();
|
||||
await page.getByLabel('layout-selection-option-Rows').click();
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New tab 1'))
|
||||
@@ -903,14 +903,14 @@ test.describe(
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput)
|
||||
.fill('Test tab 1');
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
|
||||
|
||||
// clear the title input to simulate no title and click away to trigger onBlur
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('Test tab 1')).click();
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput)
|
||||
.fill('');
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
|
||||
|
||||
// title should be set to a default name
|
||||
await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New tab'))).toBeVisible();
|
||||
@@ -923,14 +923,14 @@ test.describe(
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput)
|
||||
.fill('Test tab 2');
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
|
||||
|
||||
// clear the title input to simulate no title and click away to trigger onBlur
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('Test tab 2')).click();
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput)
|
||||
.fill('');
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
|
||||
|
||||
// title should be set to a default name + 1 to avoid duplicates
|
||||
await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New tab 1'))).toBeVisible();
|
||||
|
||||
@@ -21,6 +21,7 @@ test.describe(
|
||||
const dashboardPage = await gotoDashboardPage({ uid: PAGE_UNDER_TEST });
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.outlineButton).click();
|
||||
|
||||
// Should be able to click Variables item in outline to see add variable button
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('Variables')).click();
|
||||
@@ -28,6 +29,8 @@ test.describe(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.addVariableButton)
|
||||
).toBeVisible();
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.outlineButton).click();
|
||||
|
||||
// Clicking a panel should scroll that panel in view
|
||||
await expect(page.getByText('Dashboard panel 48')).toBeHidden();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('Panel #48')).click();
|
||||
|
||||
@@ -22,6 +22,9 @@ test.describe(
|
||||
const dashboardPage = await gotoDashboardPage({ uid: PAGE_UNDER_TEST });
|
||||
await expect(page.getByText(DASHBOARD_NAME)).toBeVisible();
|
||||
|
||||
const undockButton = page.getByRole('button', { name: 'Undock menu' });
|
||||
await undockButton.click();
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
|
||||
await page.evaluate(() => {
|
||||
|
||||
@@ -199,6 +199,7 @@ test.describe(
|
||||
.click();
|
||||
|
||||
// Open the modal editor in the side pane
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.outlineButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.node('Variables')).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('foo')).click();
|
||||
await openModal(dashboardPage, selectors);
|
||||
|
||||
@@ -32,9 +32,9 @@ test.describe(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
await page.getByLabel('Expand Panel layout category').click();
|
||||
|
||||
await page.getByLabel('Auto grid').click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
|
||||
@@ -50,6 +50,7 @@ test.describe(
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await checkAutoGridLayoutInputs(dashboardPage, selectors);
|
||||
});
|
||||
@@ -63,9 +64,10 @@ test.describe(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
await page.getByLabel('Expand Panel layout category').click();
|
||||
|
||||
await page.getByLabel('Auto grid').click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
|
||||
// Get initial positions - standard width should have panels on different rows
|
||||
const firstPanelTop = await getPanelTop(dashboardPage, selectors);
|
||||
@@ -98,6 +100,7 @@ test.describe(
|
||||
await page.reload();
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(
|
||||
@@ -123,9 +126,10 @@ test.describe(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
await page.getByLabel('Expand Panel layout category').click();
|
||||
|
||||
await page.getByLabel('Auto grid').click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth)
|
||||
@@ -134,7 +138,7 @@ test.describe(
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.customMinColumnWidth)
|
||||
.fill('900');
|
||||
.fill('1100');
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.customMinColumnWidth)
|
||||
.blur();
|
||||
@@ -148,12 +152,13 @@ test.describe(
|
||||
await verifyPanelsStackedVertically(dashboardPage, selectors);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(
|
||||
selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.customMinColumnWidth
|
||||
)
|
||||
).toHaveValue('900');
|
||||
).toHaveValue('1100');
|
||||
|
||||
await verifyPanelsStackedVertically(dashboardPage, selectors);
|
||||
|
||||
@@ -180,9 +185,9 @@ test.describe(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
await page.getByLabel('Expand Panel layout category').click();
|
||||
|
||||
await page.getByLabel('Auto grid').click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns)
|
||||
@@ -198,6 +203,7 @@ test.describe(
|
||||
await verifyPanelsStackedVertically(dashboardPage, selectors);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns)
|
||||
@@ -215,9 +221,9 @@ test.describe(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
await page.getByLabel('Expand Panel layout category').click();
|
||||
|
||||
await page.getByLabel('Auto grid').click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
|
||||
const regularRowHeight = await getPanelHeight(dashboardPage, selectors);
|
||||
|
||||
@@ -250,6 +256,7 @@ test.describe(
|
||||
}).toPass();
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight)
|
||||
@@ -270,9 +277,9 @@ test.describe(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
await page.getByLabel('Expand Panel layout category').click();
|
||||
|
||||
await page.getByLabel('Auto grid').click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
|
||||
const regularRowHeight = await getPanelHeight(dashboardPage, selectors);
|
||||
|
||||
@@ -303,6 +310,7 @@ test.describe(
|
||||
}).toPass();
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(
|
||||
@@ -327,9 +335,9 @@ test.describe(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
await page.getByLabel('Expand Panel layout category').click();
|
||||
|
||||
await page.getByLabel('Auto grid').click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
|
||||
// Set narrow column width first to ensure panels fit horizontally
|
||||
await dashboardPage
|
||||
@@ -357,6 +365,7 @@ test.describe(
|
||||
}).toPass();
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.fillScreen)
|
||||
|
||||
@@ -37,9 +37,10 @@ test.describe(
|
||||
},
|
||||
() => {
|
||||
test('can enable repeats', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(page, selectors, 'Auto grid repeats - add repeats');
|
||||
await importTestDashboard(page, selectors, 'Auto-grid repeats - add repeats');
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
|
||||
@@ -70,11 +71,12 @@ test.describe(
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Auto grid repeats - update on variable change',
|
||||
'Auto-grid repeats - update on variable change',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await saveDashboard(dashboardPage, page, selectors);
|
||||
@@ -113,6 +115,7 @@ test.describe(
|
||||
);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
|
||||
@@ -138,11 +141,13 @@ test.describe(
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Auto grid repeats - update through panel editor',
|
||||
'Auto-grid repeats - update through panel editor',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await saveDashboard(dashboardPage, page, selectors);
|
||||
await page.reload();
|
||||
@@ -202,11 +207,13 @@ test.describe(
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Auto grid repeats - update through directly loaded panel editor',
|
||||
'Auto-grid repeats - update through directly loaded panel editor',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await saveDashboard(dashboardPage, page, selectors);
|
||||
|
||||
@@ -257,11 +264,12 @@ test.describe(
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Auto grid repeats - move repeated panels',
|
||||
'Auto-grid repeats - move repeated panels',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
|
||||
@@ -304,11 +312,13 @@ test.describe(
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Auto grid repeats - move repeated panels',
|
||||
'Auto-grid repeats - move repeated panels 2',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await saveDashboard(dashboardPage, page, selectors);
|
||||
await page.reload();
|
||||
@@ -332,9 +342,7 @@ test.describe(
|
||||
|
||||
const repeatedPanelUrl = page.url();
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.Panels.Panel.title(`${repeatTitleBase}${repeatOptions.at(0)}`))
|
||||
@@ -367,11 +375,13 @@ test.describe(
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Auto grid repeats - view embedded repeated panel',
|
||||
'Auto-grid repeats - view embedded repeated panel',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await saveDashboard(dashboardPage, page, selectors);
|
||||
await page.reload();
|
||||
@@ -393,11 +403,13 @@ test.describe(
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Auto grid repeats - remove repeats',
|
||||
'Auto-grid repeats - remove repeats',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await saveDashboard(dashboardPage, page, selectors);
|
||||
await page.reload();
|
||||
@@ -453,5 +465,5 @@ test.describe(
|
||||
|
||||
async function switchToAutoGrid(page: Page) {
|
||||
await page.getByLabel('Expand Panel layout category').click();
|
||||
await page.getByLabel('Auto grid').click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
}
|
||||
|
||||
@@ -303,9 +303,7 @@ test.describe(
|
||||
|
||||
const repeatedPanelUrl = page.url();
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.Panels.Panel.title(`${repeatTitleBase}${repeatOptions.at(0)}`))
|
||||
|
||||
@@ -316,9 +316,7 @@ test.describe(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
|
||||
).toBeVisible();
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// repeated panel in original tab repeat
|
||||
await dashboardPage
|
||||
@@ -341,9 +339,7 @@ test.describe(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Tab 1 - Row 2 - Panel repeat 2'))
|
||||
).toBeVisible();
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// repeated panel in repeated tab
|
||||
await dashboardPage
|
||||
|
||||
@@ -21,11 +21,7 @@ test.describe(
|
||||
const dashboardPage = await gotoDashboardPage({ uid: PAGE_UNDER_TEST });
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
|
||||
// Check that current dashboard title is visible in breadcrumb
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Breadcrumbs.breadcrumb('Annotation filtering'))
|
||||
).toBeVisible();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
const titleInput = page.locator('[aria-label="dashboard-options Title field property editor"] input');
|
||||
await expect(titleInput).toHaveValue('Annotation filtering');
|
||||
|
||||
@@ -48,6 +48,7 @@ export const flows = {
|
||||
},
|
||||
async newEditPaneVariableClick(dashboardPage: DashboardPage, selectors: E2ESelectorGroups) {
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.outlineButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('Variables')).click();
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.addVariableButton)
|
||||
|
||||
@@ -1907,11 +1907,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx": {
|
||||
"react-hooks/rules-of-hooks": {
|
||||
"count": 4
|
||||
|
||||
+2
-2
@@ -612,8 +612,8 @@ export type GetSearchApiArg = {
|
||||
tags?: string[];
|
||||
/** find dashboards that reference a given libraryPanel */
|
||||
libraryPanel?: string;
|
||||
/** permission needed for the resource (View, Edit, Admin) */
|
||||
permission?: 'View' | 'Edit' | 'Admin';
|
||||
/** permission needed for the resource (view, edit, admin) */
|
||||
permission?: 'view' | 'edit' | 'admin';
|
||||
/** sortable field */
|
||||
sort?: string;
|
||||
/** number of results to return */
|
||||
|
||||
@@ -381,6 +381,11 @@ export class PanelPlugin<
|
||||
const appender = builder.getListAppender<TOptions, TFieldConfigOptions>({
|
||||
pluginId: this.meta.id,
|
||||
name: this.meta.name,
|
||||
options: {},
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = supplier(builder.dataSummary);
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { PreferredVisualisationType } from '../../types/data';
|
||||
import { DataFrame, FieldType } from '../../types/dataFrame';
|
||||
import { DataFrameType } from '../../types/dataFrameTypes';
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export interface PanelDataSummary {
|
||||
hasData?: boolean;
|
||||
rowCountTotal: number;
|
||||
/** max number of rows in any given dataframe in the panel data */
|
||||
rowCountMax: number;
|
||||
frameCount: number;
|
||||
fieldCount: number;
|
||||
/** max number of fields in any given dataframe in the panel data */
|
||||
fieldCountMax: number;
|
||||
/** given a field type, return the number of fields across all dataframes which match this type */
|
||||
fieldCountByType: (type: FieldType) => number;
|
||||
/** returns true if any fields in any frames match the field type */
|
||||
hasFieldType: (type: FieldType) => boolean;
|
||||
/** The first frame that set's this value */
|
||||
preferredVisualisationType?: PreferredVisualisationType;
|
||||
/* returns true if any of the frames in this panel data summary have the type */
|
||||
hasDataFrameType: (type: DataFrameType) => boolean;
|
||||
/* returns true if any of the frames in this panel data summary have the type */
|
||||
hasPreferredVisualisationType: (type: PreferredVisualisationType) => boolean;
|
||||
|
||||
/** pass along a reference to the DataFrame array in case it's needed by the plugin */
|
||||
rawFrames?: DataFrame[];
|
||||
|
||||
/* --- DEPRECATED FIELDS BELOW --- */
|
||||
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.number) */
|
||||
@@ -23,60 +31,114 @@ export interface PanelDataSummary {
|
||||
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.string) */
|
||||
stringFieldCount: number;
|
||||
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.number) */
|
||||
hasNumberField?: boolean;
|
||||
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.time) */
|
||||
hasTimeField?: boolean;
|
||||
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.time) */
|
||||
hasNumberField?: boolean;
|
||||
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.string) */
|
||||
hasStringField?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
class PanelDataSummaryImpl implements PanelDataSummary {
|
||||
public rowCountTotal = 0;
|
||||
/** max number of rows in any single dataframe in the panel data */
|
||||
public rowCountMax = 0;
|
||||
public fieldCount = 0;
|
||||
/** max number of fields in any single dataframe in the panel data */
|
||||
public fieldCountMax = 0;
|
||||
|
||||
private countByType: Partial<Record<FieldType, number>> = {};
|
||||
private preferredVisualisationTypes: Set<PreferredVisualisationType> = new Set<PreferredVisualisationType>();
|
||||
private dataFrameTypes: Set<DataFrameType> = new Set<DataFrameType>();
|
||||
|
||||
public get hasData(): boolean {
|
||||
return this.rowCountTotal > 0;
|
||||
}
|
||||
|
||||
public get frameCount(): number {
|
||||
return this.rawFrames?.length ?? 0;
|
||||
}
|
||||
|
||||
constructor(public rawFrames?: DataFrame[]) {
|
||||
this._processFrames();
|
||||
}
|
||||
|
||||
private _processFrames() {
|
||||
for (const frame of this.rawFrames ?? []) {
|
||||
this.rowCountTotal += frame.length;
|
||||
|
||||
if (frame.meta?.preferredVisualisationType) {
|
||||
this.preferredVisualisationTypes.add(frame.meta.preferredVisualisationType);
|
||||
}
|
||||
if (frame.meta?.type) {
|
||||
this.dataFrameTypes.add(frame.meta.type);
|
||||
}
|
||||
|
||||
for (const field of frame.fields) {
|
||||
this.fieldCount++;
|
||||
this.countByType[field.type] = (this.countByType[field.type] || 0) + 1;
|
||||
}
|
||||
|
||||
if (frame.length > this.rowCountMax) {
|
||||
this.rowCountMax = frame.length;
|
||||
}
|
||||
if (frame.fields.length > this.fieldCountMax) {
|
||||
this.fieldCountMax = frame.fields.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public fieldCountByType(type: FieldType): number {
|
||||
return this.countByType[type] ?? 0;
|
||||
}
|
||||
|
||||
public hasFieldType(type: FieldType): boolean {
|
||||
return this.fieldCountByType(type) > 0;
|
||||
}
|
||||
|
||||
public hasPreferredVisualisationType(type: PreferredVisualisationType): boolean {
|
||||
return this.preferredVisualisationTypes.has(type);
|
||||
}
|
||||
|
||||
public hasDataFrameType(type: DataFrameType): boolean {
|
||||
return this.dataFrameTypes.has(type);
|
||||
}
|
||||
|
||||
/**** DEPRECATED ****/
|
||||
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.number) */
|
||||
public get numberFieldCount(): number {
|
||||
return this.fieldCountByType(FieldType.number);
|
||||
}
|
||||
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.time) */
|
||||
public get timeFieldCount(): number {
|
||||
return this.fieldCountByType(FieldType.time);
|
||||
}
|
||||
/** @deprecated use PanelDataSummary.fieldCountByType(FieldType.string) */
|
||||
public get stringFieldCount() {
|
||||
return this.fieldCountByType(FieldType.string);
|
||||
}
|
||||
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.number) */
|
||||
public get hasTimeField() {
|
||||
return this.fieldCountByType(FieldType.time) > 0;
|
||||
}
|
||||
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.time) */
|
||||
public get hasNumberField() {
|
||||
return this.fieldCountByType(FieldType.number) > 0;
|
||||
}
|
||||
/** @deprecated use PanelDataSummary.hasFieldType(FieldType.string) */
|
||||
public get hasStringField() {
|
||||
return this.fieldCountByType(FieldType.string) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
* given a list of dataframes, summarize attributes of those frames for features like suggestions.
|
||||
* @param frames - dataframes to summarize
|
||||
* @returns summary of the dataframes
|
||||
*/
|
||||
export function getPanelDataSummary(frames: DataFrame[] = []): PanelDataSummary {
|
||||
let rowCountTotal = 0;
|
||||
let rowCountMax = 0;
|
||||
let fieldCount = 0;
|
||||
const countByType: Partial<Record<FieldType, number>> = {};
|
||||
let preferredVisualisationType: PreferredVisualisationType | undefined;
|
||||
|
||||
for (const frame of frames) {
|
||||
rowCountTotal += frame.length;
|
||||
|
||||
if (frame.meta?.preferredVisualisationType) {
|
||||
preferredVisualisationType = frame.meta.preferredVisualisationType;
|
||||
}
|
||||
|
||||
for (const field of frame.fields) {
|
||||
fieldCount++;
|
||||
countByType[field.type] = (countByType[field.type] || 0) + 1;
|
||||
}
|
||||
|
||||
if (frame.length > rowCountMax) {
|
||||
rowCountMax = frame.length;
|
||||
}
|
||||
}
|
||||
|
||||
const fieldCountByType = (f: FieldType) => countByType[f] ?? 0;
|
||||
|
||||
return {
|
||||
rowCountTotal,
|
||||
rowCountMax,
|
||||
fieldCount,
|
||||
preferredVisualisationType,
|
||||
frameCount: frames.length,
|
||||
hasData: rowCountTotal > 0,
|
||||
hasFieldType: (f: FieldType) => fieldCountByType(f) > 0,
|
||||
fieldCountByType,
|
||||
// deprecated
|
||||
numberFieldCount: fieldCountByType(FieldType.number),
|
||||
timeFieldCount: fieldCountByType(FieldType.time),
|
||||
stringFieldCount: fieldCountByType(FieldType.string),
|
||||
hasTimeField: fieldCountByType(FieldType.time) > 0,
|
||||
hasNumberField: fieldCountByType(FieldType.number) > 0,
|
||||
hasStringField: fieldCountByType(FieldType.string) > 0,
|
||||
};
|
||||
export function getPanelDataSummary(frames?: DataFrame[]): PanelDataSummary {
|
||||
return new PanelDataSummaryImpl(frames);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ interface IndexOptions {
|
||||
asPercentile: boolean;
|
||||
}
|
||||
|
||||
const defaultReduceOptions: ReduceOptions = {
|
||||
const defaultNumericVizOptions: ReduceOptions = {
|
||||
reducer: ReducerID.sum,
|
||||
};
|
||||
|
||||
@@ -149,10 +149,10 @@ export const calculateFieldTransformer: DataTransformerInfo<CalculateFieldTransf
|
||||
|
||||
switch (mode) {
|
||||
case CalculateFieldMode.ReduceRow:
|
||||
creator = getReduceRowCreator(defaults(options.reduce, defaultReduceOptions), data);
|
||||
creator = getReduceRowCreator(defaults(options.reduce, defaultNumericVizOptions), data);
|
||||
break;
|
||||
case CalculateFieldMode.CumulativeFunctions:
|
||||
creator = getCumulativeCreator(defaults(options.cumulative, defaultReduceOptions), data);
|
||||
creator = getCumulativeCreator(defaults(options.cumulative, defaultNumericVizOptions), data);
|
||||
break;
|
||||
case CalculateFieldMode.WindowFunctions:
|
||||
creator = getWindowCreator(defaults(options.window, defaultWindowOptions), data);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
import { DataQuery, LogsSortOrder } from '@grafana/schema';
|
||||
|
||||
import { PreferredVisualisationType } from './data';
|
||||
import { SelectableValue } from './select';
|
||||
@@ -84,6 +84,7 @@ export interface ExploreLogsPanelState {
|
||||
// Used for logs table visualisation, contains the refId of the dataFrame that is currently visualized
|
||||
refId?: string;
|
||||
displayedFields?: string[];
|
||||
sortOrder?: LogsSortOrder;
|
||||
}
|
||||
|
||||
export interface SplitOpenOptions<T extends AnyQuery = AnyQuery> {
|
||||
|
||||
+2
-2
@@ -1091,7 +1091,7 @@ export interface FeatureToggles {
|
||||
graphiteBackendMode?: boolean;
|
||||
/**
|
||||
* Enables the updated Azure Monitor resource picker
|
||||
* @default false
|
||||
* @default true
|
||||
*/
|
||||
azureResourcePickerUpdates?: boolean;
|
||||
/**
|
||||
@@ -1150,7 +1150,7 @@ export interface FeatureToggles {
|
||||
pluginStoreServiceLoading?: boolean;
|
||||
/**
|
||||
* Increases panel padding globally
|
||||
* @default false
|
||||
* @default true
|
||||
*/
|
||||
newPanelPadding?: boolean;
|
||||
/**
|
||||
|
||||
@@ -75,7 +75,9 @@ export interface VisualizationSuggestion<TOptions extends unknown = {}, TFieldCo
|
||||
* mutate the suggestion object which is passed in as the first argument.
|
||||
*/
|
||||
previewModifier?: (suggestion: VisualizationSuggestion<TOptions, TFieldConfig>) => void;
|
||||
/** @deprecated this will no longer be supported in the new Suggestions UI. */
|
||||
icon?: string;
|
||||
/** @deprecated this will no longer be supported in the new Suggestions UI. */
|
||||
imgSrc?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const components = {
|
||||
|
||||
A few things to keep in mind:
|
||||
|
||||
- Strive to use e2e selector for all components in grafana/ui.
|
||||
- Strive to use e2e selectors for all components in grafana/ui.
|
||||
- Don't ever delete selectors. Even though a selector may not be used in the Grafana repository, it can still be used in external plugins.
|
||||
- Only create new selector in case you're creating a new piece of UI. If you're changing an existing piece of UI that already has a selector defined, you need to keep using that selector. Otherwise you might break plugin end-to-end tests.
|
||||
- Prefer using string selectors in favour of function selectors. The purpose of the selectors is to provide a canonical way to select elements.
|
||||
|
||||
@@ -57,6 +57,11 @@ export const versionedComponents = {
|
||||
'12.1.0': 'data-testid DashboardEditPaneSplitter primary body',
|
||||
},
|
||||
},
|
||||
Sidebar: {
|
||||
closePane: {
|
||||
'12.4.0': 'data-testid Sidebar close pane',
|
||||
},
|
||||
},
|
||||
EditPaneHeader: {
|
||||
deleteButton: {
|
||||
'12.1.0': 'data-testid EditPaneHeader delete panel',
|
||||
@@ -70,9 +75,6 @@ export const versionedComponents = {
|
||||
duplicate: {
|
||||
'12.1.0': 'data-testid EditPaneHeader duplicate',
|
||||
},
|
||||
backButton: {
|
||||
'12.1.0': 'data-testid EditPaneHeader back',
|
||||
},
|
||||
},
|
||||
TimePicker: {
|
||||
openButton: {
|
||||
|
||||
@@ -183,6 +183,14 @@ export const versionedPages = {
|
||||
url: {
|
||||
[MIN_GRAFANA_VERSION]: (uid: string) => `/d/${uid}`,
|
||||
},
|
||||
Sidebar: {
|
||||
optionsButton: {
|
||||
'12.4.0': 'data-testid Dashboard Sidebar options button',
|
||||
},
|
||||
outlineButton: {
|
||||
'12.4.0': 'data-testid Dashboard Sidebar outline button',
|
||||
},
|
||||
},
|
||||
DashNav: {
|
||||
nav: {
|
||||
[MIN_GRAFANA_VERSION]: 'Dashboard navigation',
|
||||
|
||||
@@ -831,9 +831,17 @@ export interface TableBarGaugeCellOptions {
|
||||
*/
|
||||
export interface TableSparklineCellOptions extends GraphFieldConfig {
|
||||
hideValue?: boolean;
|
||||
/**
|
||||
* Enable interactive hover to inspect values along the sparkline
|
||||
*/
|
||||
interactionEnabled?: boolean;
|
||||
type: TableCellDisplayMode.Sparkline;
|
||||
}
|
||||
|
||||
export const defaultTableSparklineCellOptions: Partial<TableSparklineCellOptions> = {
|
||||
interactionEnabled: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Colored background cell options
|
||||
*/
|
||||
|
||||
@@ -63,6 +63,8 @@ TableSparklineCellOptions: {
|
||||
GraphFieldConfig
|
||||
type: TableCellDisplayMode & "sparkline"
|
||||
hideValue?: bool
|
||||
// Enable interactive hover to inspect values along the sparkline
|
||||
interactionEnabled?: bool | *true
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
// Colored background cell options
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { dateTime, makeTimeRange, TimeRange } from '@grafana/data';
|
||||
import { dateTime, makeTimeRange, TimeRange, BootData } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { TimeRangeProvider } from './TimeRangeContext';
|
||||
@@ -152,6 +152,58 @@ it('does not submit wrapping forms', async () => {
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows CTRL+Z in zoom out tooltip when feature flag is disabled', async () => {
|
||||
window.grafanaBootData = {
|
||||
settings: {
|
||||
featureToggles: {
|
||||
newTimeRangeZoomShortcuts: false,
|
||||
},
|
||||
},
|
||||
} as BootData;
|
||||
|
||||
render(
|
||||
<TimeRangePicker
|
||||
onChangeTimeZone={() => {}}
|
||||
onChange={(value) => {}}
|
||||
value={value}
|
||||
onMoveBackward={() => {}}
|
||||
onMoveForward={() => {}}
|
||||
onZoom={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
const zoomButton = screen.getByLabelText('Zoom out time range');
|
||||
await userEvent.hover(zoomButton);
|
||||
|
||||
expect(await screen.findByText(/CTRL\+Z/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows t - in zoom out tooltip when feature flag is enabled', async () => {
|
||||
window.grafanaBootData = {
|
||||
settings: {
|
||||
featureToggles: {
|
||||
newTimeRangeZoomShortcuts: true,
|
||||
},
|
||||
},
|
||||
} as BootData;
|
||||
|
||||
render(
|
||||
<TimeRangePicker
|
||||
onChangeTimeZone={() => {}}
|
||||
onChange={(value) => {}}
|
||||
value={value}
|
||||
onMoveBackward={() => {}}
|
||||
onMoveForward={() => {}}
|
||||
onZoom={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
const zoomButton = screen.getByLabelText('Zoom out time range');
|
||||
await userEvent.hover(zoomButton);
|
||||
|
||||
expect(await screen.findByText(/t -/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('TimePickerTooltip', () => {
|
||||
beforeAll(() => {
|
||||
const mockIntl = {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
|
||||
import { useStyles2 } from '../../themes/ThemeContext';
|
||||
import { getFeatureToggle } from '../../utils/featureToggle';
|
||||
import { ButtonGroup } from '../Button/ButtonGroup';
|
||||
import { getModalStyles } from '../Modal/getModalStyles';
|
||||
import { getPortalContainer } from '../Portal/Portal';
|
||||
@@ -243,13 +244,22 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
|
||||
|
||||
TimeRangePicker.displayName = 'TimeRangePicker';
|
||||
|
||||
const ZoomOutTooltip = () => (
|
||||
<>
|
||||
<Trans i18nKey="time-picker.range-picker.zoom-out-tooltip">
|
||||
Time range zoom out <br /> CTRL+Z
|
||||
</Trans>
|
||||
</>
|
||||
);
|
||||
const ZoomOutTooltip = () => {
|
||||
const newShortcuts = getFeatureToggle('newTimeRangeZoomShortcuts');
|
||||
return (
|
||||
<>
|
||||
{newShortcuts ? (
|
||||
<Trans i18nKey="time-picker.range-picker.zoom-out-tooltip-new">
|
||||
Time range zoom out <br /> t -
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans i18nKey="time-picker.range-picker.zoom-out-tooltip">
|
||||
Time range zoom out <br /> CTRL+Z
|
||||
</Trans>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const TimePickerTooltip = ({ timeRange, timeZone }: { timeRange: TimeRange; timeZone?: TimeZone }) => {
|
||||
const styles = useStyles2(getLabelStyles);
|
||||
|
||||
@@ -59,8 +59,9 @@ export function SiderbarToolbar({ children }: SiderbarToolbarProps) {
|
||||
{context.hasOpenPane && (
|
||||
<SidebarButton
|
||||
icon={'web-section-alt'}
|
||||
onClick={context.onDockChange}
|
||||
onClick={context.onToggleDock}
|
||||
title={context.isDocked ? t('grafana-ui.sidebar.undock', 'Undock') : t('grafana-ui.sidebar.dock', 'Dock')}
|
||||
data-testid="sidebar-dock-toggle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useContext } from 'react';
|
||||
import React, { ButtonHTMLAttributes, useContext } from 'react';
|
||||
|
||||
import { GrafanaTheme2, IconName, isIconName } from '@grafana/data';
|
||||
|
||||
@@ -11,38 +11,48 @@ import { Tooltip } from '../Tooltip/Tooltip';
|
||||
|
||||
import { SidebarContext } from './useSidebar';
|
||||
|
||||
export interface Props {
|
||||
export interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon: IconName;
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
title: string;
|
||||
tooltip?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function SidebarButton({ icon, active, onClick, title, tooltip }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const context = useContext(SidebarContext);
|
||||
export const SidebarButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
({ icon, active, onClick, title, tooltip, ...restProps }, ref) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const context = useContext(SidebarContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Sidebar.Button must be used within a Sidebar component');
|
||||
if (!context) {
|
||||
throw new Error('Sidebar.Button must be used within a Sidebar component');
|
||||
}
|
||||
|
||||
const buttonClass = cx(
|
||||
styles.button,
|
||||
context.compact && styles.compact,
|
||||
active && styles.active,
|
||||
context.position === 'left' && styles.leftButton
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip ref={ref} content={tooltip ?? title} placement={context.position === 'left' ? 'right' : 'left'}>
|
||||
<button
|
||||
className={buttonClass}
|
||||
aria-label={title}
|
||||
aria-expanded={active}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
{...restProps}
|
||||
>
|
||||
<div className={styles.iconWrapper}>{renderIcon(icon, context.compact)}</div>
|
||||
{!context.compact && <div className={cx(styles.title, active && styles.titleActive)}>{title}</div>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const buttonClass = cx(
|
||||
styles.button,
|
||||
context.compact && styles.compact,
|
||||
active && styles.active,
|
||||
context.position === 'left' && styles.leftButton
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip ?? title} placement={context.position === 'left' ? 'right' : 'left'}>
|
||||
<button className={buttonClass} aria-label={title} aria-expanded={active} type="button" onClick={onClick}>
|
||||
<div className={styles.iconWrapper}>{renderIcon(icon, context.compact)}</div>
|
||||
{!context.compact && <div className={cx(styles.title, active && styles.titleActive)}>{title}</div>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
SidebarButton.displayName = 'SidebarButton';
|
||||
|
||||
function renderIcon(icon: IconName | React.ReactNode, compact?: boolean) {
|
||||
if (!icon) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t } from '@grafana/i18n';
|
||||
|
||||
import { useStyles2 } from '../../themes/ThemeContext';
|
||||
@@ -27,6 +28,7 @@ export function SidebarPaneHeader({ children, onClose, title }: Props) {
|
||||
onClick={onClose}
|
||||
aria-label={t('grafana-ui.sidebar.close', 'Close')}
|
||||
tooltip={t('grafana-ui.sidebar.close', 'Close')}
|
||||
data-testid={selectors.components.Sidebar.closePane}
|
||||
/>
|
||||
)}
|
||||
<Text weight="medium" variant="h6" truncate data-testid="sidebar-pane-header-title">
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface SidebarContextValue {
|
||||
bottomMargin: number;
|
||||
edgeMargin: number;
|
||||
contentMargin: number;
|
||||
onDockChange: () => void;
|
||||
onToggleDock: () => void;
|
||||
onResize: (diff: number) => void;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function useSidebar({
|
||||
// Used to accumulate drag distance to know when to change compact mode
|
||||
const [_, setCompactDrag] = React.useState(0);
|
||||
|
||||
const onDockChange = useCallback(() => setIsDocked((prev) => !prev), []);
|
||||
const onToggleDock = useCallback(() => setIsDocked((prev) => !prev), []);
|
||||
|
||||
const prop = position === 'right' ? 'paddingRight' : 'paddingLeft';
|
||||
const toolbarWidth =
|
||||
@@ -98,7 +98,7 @@ export function useSidebar({
|
||||
|
||||
return {
|
||||
isDocked,
|
||||
onDockChange,
|
||||
onToggleDock,
|
||||
onResize,
|
||||
outerWrapperProps,
|
||||
position,
|
||||
|
||||
@@ -1,30 +1,264 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { createTheme, FieldSparkline, FieldType } from '@grafana/data';
|
||||
import { createTheme, FieldConfig, FieldSparkline, FieldType } from '@grafana/data';
|
||||
import { GraphFieldConfig } from '@grafana/schema';
|
||||
|
||||
import { Sparkline } from './Sparkline';
|
||||
|
||||
describe('Sparkline', () => {
|
||||
const mockSparkline: FieldSparkline = {
|
||||
x: {
|
||||
name: 'x',
|
||||
values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000],
|
||||
type: FieldType.time,
|
||||
config: {},
|
||||
},
|
||||
y: {
|
||||
name: 'y',
|
||||
values: [1, 2, 3, 4, 5],
|
||||
type: FieldType.number,
|
||||
config: {},
|
||||
state: {
|
||||
range: { min: 1, max: 5, delta: 1 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('should render without throwing an error', () => {
|
||||
const sparkline: FieldSparkline = {
|
||||
x: {
|
||||
name: 'x',
|
||||
values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000],
|
||||
type: FieldType.time,
|
||||
config: {},
|
||||
},
|
||||
y: {
|
||||
name: 'y',
|
||||
values: [1, 2, 3, 4, 5],
|
||||
type: FieldType.number,
|
||||
config: {},
|
||||
state: {
|
||||
range: { min: 1, max: 5, delta: 1 },
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
render(<Sparkline width={800} height={600} theme={createTheme()} sparkline={sparkline} />)
|
||||
render(<Sparkline width={800} height={600} theme={createTheme()} sparkline={mockSparkline} />)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
describe('hover interaction', () => {
|
||||
it('should call onHover with value when interaction is enabled and cursor moves', () => {
|
||||
const onHover = jest.fn();
|
||||
const config: FieldConfig<GraphFieldConfig> = {
|
||||
custom: {
|
||||
interactionEnabled: true,
|
||||
} as GraphFieldConfig & { interactionEnabled?: boolean },
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<Sparkline
|
||||
width={800}
|
||||
height={600}
|
||||
theme={createTheme()}
|
||||
sparkline={mockSparkline}
|
||||
config={config}
|
||||
onHover={onHover}
|
||||
/>
|
||||
);
|
||||
|
||||
// Get the Sparkline instance to access the config builder
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
|
||||
if (instance?.state?.configBuilder) {
|
||||
const builder = instance.state.configBuilder;
|
||||
const hooks = builder.getConfig().hooks;
|
||||
|
||||
// Find and execute the setLegend hook
|
||||
const setLegendHook = hooks?.setLegend?.[0];
|
||||
if (setLegendHook) {
|
||||
// Simulate hover over data point at index 2 (value: 3)
|
||||
const mockUPlot = {
|
||||
cursor: { idxs: [2, 2] },
|
||||
data: [mockSparkline.x!.values, mockSparkline.y.values],
|
||||
} as Partial<uPlot>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setLegendHook(mockUPlot as any);
|
||||
|
||||
expect(onHover).toHaveBeenCalledWith(3, 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should call onHover with null when cursor leaves', () => {
|
||||
const onHover = jest.fn();
|
||||
const config: FieldConfig<GraphFieldConfig> = {
|
||||
custom: {
|
||||
interactionEnabled: true,
|
||||
} as GraphFieldConfig & { interactionEnabled?: boolean },
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<Sparkline
|
||||
width={800}
|
||||
height={600}
|
||||
theme={createTheme()}
|
||||
sparkline={mockSparkline}
|
||||
config={config}
|
||||
onHover={onHover}
|
||||
/>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
|
||||
if (instance?.state?.configBuilder) {
|
||||
const builder = instance.state.configBuilder;
|
||||
const hooks = builder.getConfig().hooks;
|
||||
|
||||
const setLegendHook = hooks?.setLegend?.[0];
|
||||
if (setLegendHook) {
|
||||
// Simulate cursor leaving (no valid index)
|
||||
const mockUPlot = {
|
||||
cursor: { idxs: [null, null] },
|
||||
data: [mockSparkline.x!.values, mockSparkline.y.values],
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setLegendHook(mockUPlot as any);
|
||||
|
||||
expect(onHover).toHaveBeenCalledWith(null, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should not set up hover hooks when interaction is disabled', () => {
|
||||
const onHover = jest.fn();
|
||||
const config: FieldConfig<GraphFieldConfig> = {
|
||||
custom: {
|
||||
interactionEnabled: false,
|
||||
} as GraphFieldConfig & { interactionEnabled?: boolean },
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<Sparkline
|
||||
width={800}
|
||||
height={600}
|
||||
theme={createTheme()}
|
||||
sparkline={mockSparkline}
|
||||
config={config}
|
||||
onHover={onHover}
|
||||
/>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
|
||||
if (instance?.state?.configBuilder) {
|
||||
const builder = instance.state.configBuilder;
|
||||
const hooks = builder.getConfig().hooks;
|
||||
|
||||
// setLegend hook should not be registered
|
||||
expect(hooks?.setLegend).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not set up hover hooks when onHover is not provided', () => {
|
||||
const config: FieldConfig<GraphFieldConfig> = {
|
||||
custom: {
|
||||
interactionEnabled: true,
|
||||
} as GraphFieldConfig & { interactionEnabled?: boolean },
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<Sparkline
|
||||
width={800}
|
||||
height={600}
|
||||
theme={createTheme()}
|
||||
sparkline={mockSparkline}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
|
||||
if (instance?.state?.configBuilder) {
|
||||
const builder = instance.state.configBuilder;
|
||||
const hooks = builder.getConfig().hooks;
|
||||
|
||||
// setLegend hook should not be registered when no onHover callback
|
||||
expect(hooks?.setLegend).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should enable interaction by default when not explicitly configured', () => {
|
||||
const onHover = jest.fn();
|
||||
const config: FieldConfig<GraphFieldConfig> = {
|
||||
custom: {} as GraphFieldConfig & { interactionEnabled?: boolean },
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<Sparkline
|
||||
width={800}
|
||||
height={600}
|
||||
theme={createTheme()}
|
||||
sparkline={mockSparkline}
|
||||
config={config}
|
||||
onHover={onHover}
|
||||
/>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
|
||||
if (instance?.state?.configBuilder) {
|
||||
const builder = instance.state.configBuilder;
|
||||
const hooks = builder.getConfig().hooks;
|
||||
|
||||
// setLegend hook should be registered (interaction enabled by default)
|
||||
expect(hooks?.setLegend).toBeDefined();
|
||||
expect(hooks?.setLegend?.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle non-finite values correctly during hover', () => {
|
||||
const onHover = jest.fn();
|
||||
const config: FieldConfig<GraphFieldConfig> = {
|
||||
custom: {
|
||||
interactionEnabled: true,
|
||||
} as GraphFieldConfig & { interactionEnabled?: boolean },
|
||||
};
|
||||
|
||||
const sparklineWithNaN: FieldSparkline = {
|
||||
...mockSparkline,
|
||||
y: {
|
||||
...mockSparkline.y,
|
||||
values: [1, NaN, 3, Infinity, 5],
|
||||
},
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<Sparkline
|
||||
width={800}
|
||||
height={600}
|
||||
theme={createTheme()}
|
||||
sparkline={sparklineWithNaN}
|
||||
config={config}
|
||||
onHover={onHover}
|
||||
/>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
|
||||
if (instance?.state?.configBuilder) {
|
||||
const builder = instance.state.configBuilder;
|
||||
const hooks = builder.getConfig().hooks;
|
||||
|
||||
const setLegendHook = hooks?.setLegend?.[0];
|
||||
if (setLegendHook) {
|
||||
// Hover over NaN value at index 1
|
||||
const mockUPlot1 = {
|
||||
cursor: { idxs: [1, 1] },
|
||||
data: [sparklineWithNaN.x!.values, sparklineWithNaN.y.values],
|
||||
} as Partial<uPlot>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setLegendHook(mockUPlot1 as any);
|
||||
expect(onHover).toHaveBeenCalledWith(null, null);
|
||||
|
||||
onHover.mockClear();
|
||||
|
||||
// Hover over Infinity value at index 3
|
||||
const mockUPlot3 = {
|
||||
cursor: { idxs: [3, 3] },
|
||||
data: [sparklineWithNaN.x!.values, sparklineWithNaN.y.values],
|
||||
} as Partial<uPlot>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setLegendHook(mockUPlot3 as any);
|
||||
expect(onHover).toHaveBeenCalledWith(null, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { isEqual } from 'lodash';
|
||||
import { PureComponent } from 'react';
|
||||
import { AlignedData, Range } from 'uplot';
|
||||
@@ -33,6 +34,7 @@ export interface SparklineProps extends Themeable2 {
|
||||
height: number;
|
||||
config?: FieldConfig<GraphFieldConfig>;
|
||||
sparkline: FieldSparkline;
|
||||
onHover?: (value: number | null, index: number | null) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -106,14 +108,14 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
||||
}
|
||||
|
||||
prepareConfig(data: DataFrame) {
|
||||
const { theme } = this.props;
|
||||
const { theme, onHover, config } = this.props;
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.setCursor({
|
||||
show: false,
|
||||
x: false, // no crosshairs
|
||||
y: false,
|
||||
});
|
||||
// Check if interaction is enabled (default to true)
|
||||
// interactionEnabled is on TableSparklineCellOptions which extends GraphFieldConfig
|
||||
const customConfig = config?.custom;
|
||||
const interactionEnabled =
|
||||
customConfig && 'interactionEnabled' in customConfig ? customConfig.interactionEnabled : true;
|
||||
|
||||
// X is the first field in the alligned frame
|
||||
const xField = data.fields[0];
|
||||
@@ -141,6 +143,9 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
||||
placement: AxisPlacement.Hidden,
|
||||
});
|
||||
|
||||
// Track the series color for cursor point styling
|
||||
let seriesColor: string | undefined;
|
||||
|
||||
for (let i = 0; i < data.fields.length; i++) {
|
||||
const field = data.fields[i];
|
||||
const config: FieldConfig<GraphFieldConfig> = field.config;
|
||||
@@ -168,7 +173,7 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
||||
});
|
||||
|
||||
const colorMode = getFieldColorModeForField(field);
|
||||
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
|
||||
seriesColor = colorMode.getCalculator(field, theme)(0, 0);
|
||||
const pointsMode =
|
||||
customConfig.drawStyle === GraphDrawStyle.Points ? VisibilityMode.Always : customConfig.showPoints;
|
||||
|
||||
@@ -183,7 +188,7 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
||||
lineWidth: customConfig.lineWidth,
|
||||
lineInterpolation: customConfig.lineInterpolation,
|
||||
showPoints: pointsMode,
|
||||
pointSize: customConfig.pointSize,
|
||||
pointSize: customConfig.pointSize || 5, // Ensure minimum size for cursor point calculation
|
||||
fillOpacity: customConfig.fillOpacity,
|
||||
fillColor: customConfig.fillColor,
|
||||
lineStyle: customConfig.lineStyle,
|
||||
@@ -192,12 +197,72 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
||||
});
|
||||
}
|
||||
|
||||
// Configure cursor after series so we have the series color
|
||||
if (interactionEnabled && onHover) {
|
||||
// Enable cursor with vertical bar indicator for hover interaction
|
||||
// Vertical bar is more visible on small sparklines (25-30px height) than a dot
|
||||
builder.setCursor({
|
||||
show: true,
|
||||
x: true, // show vertical line (bar indicator)
|
||||
y: false, // no horizontal line
|
||||
points: {
|
||||
show: false, // don't show dots - use vertical bar instead
|
||||
// Provide safe functions that don't access this.frames (which is undefined for Sparkline)
|
||||
stroke: () => 'transparent',
|
||||
fill: () => 'transparent',
|
||||
size: () => 0,
|
||||
width: () => 0,
|
||||
},
|
||||
focus: {
|
||||
prox: 30, // proximity in CSS pixels for hover detection
|
||||
},
|
||||
});
|
||||
|
||||
// Track cursor position and call onHover with the value at that position
|
||||
// Using setLegend hook which fires on hover (not just drag-to-select like setSelect)
|
||||
builder.addHook('setLegend', (u: uPlot) => {
|
||||
const dataIdx = u.cursor.idxs?.[1]; // Get the data index from the cursor
|
||||
if (dataIdx != null) {
|
||||
const yData = u.data[1]; // Y-axis data (values)
|
||||
if (yData && dataIdx < yData.length) {
|
||||
const value = yData[dataIdx];
|
||||
if (value != null && isFinite(value)) {
|
||||
onHover(value, dataIdx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reset on mouse leave or when no valid data point
|
||||
onHover(null, null);
|
||||
});
|
||||
} else {
|
||||
// Default behavior: cursor disabled
|
||||
builder.setCursor({
|
||||
show: false,
|
||||
x: false, // no crosshairs
|
||||
y: false,
|
||||
});
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { data, configBuilder } = this.state;
|
||||
const { width, height } = this.props;
|
||||
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
|
||||
|
||||
// Style the vertical cursor bar to be more visible on small sparklines
|
||||
const cursorStyles = css({
|
||||
'.u-cursor-x': {
|
||||
borderLeft: '2px solid !important',
|
||||
opacity: '1 !important',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cursorStyles}>
|
||||
<UPlotChart data={data} config={configBuilder} width={width} height={height} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
import * as React from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import { FieldConfig, getMinMaxAndDelta, Field, isDataFrameWithValue } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
@@ -32,12 +33,20 @@ export const defaultSparklineCellConfig: TableSparklineCellOptions = {
|
||||
barAlignment: BarAlignment.Center,
|
||||
showPoints: VisibilityMode.Never,
|
||||
hideValue: false,
|
||||
interactionEnabled: true,
|
||||
};
|
||||
|
||||
export const SparklineCell = (props: SparklineCellProps) => {
|
||||
const { field, value, theme, timeRange, rowIdx, width } = props;
|
||||
const sparkline = prepareSparklineValue(value, field);
|
||||
|
||||
// Hover state management for interactive sparklines
|
||||
const [hoverValue, setHoverValue] = useState<number | null>(null);
|
||||
|
||||
const handleHover = useCallback((value: number | null, index: number | null) => {
|
||||
setHoverValue(value);
|
||||
}, []);
|
||||
|
||||
if (!sparkline) {
|
||||
return (
|
||||
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
|
||||
@@ -80,8 +89,10 @@ export const SparklineCell = (props: SparklineCellProps) => {
|
||||
let valueWidth = 0;
|
||||
let valueElement: React.ReactNode = null;
|
||||
if (!hideValue) {
|
||||
const newValue = isDataFrameWithValue(value) ? value.value : null;
|
||||
const displayValue = field.display!(newValue);
|
||||
// Use hover value if hovering, otherwise use the default value
|
||||
const defaultValue = isDataFrameWithValue(value) ? value.value : null;
|
||||
const displayRawValue = hoverValue ?? defaultValue;
|
||||
const displayValue = field.display!(displayRawValue);
|
||||
const alignmentFactor = getAlignmentFactor(field, displayValue, rowIdx!);
|
||||
|
||||
valueWidth =
|
||||
@@ -94,7 +105,14 @@ export const SparklineCell = (props: SparklineCellProps) => {
|
||||
return (
|
||||
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
|
||||
{valueElement}
|
||||
<Sparkline width={width - valueWidth} height={25} sparkline={sparkline} config={config} theme={theme} />
|
||||
<Sparkline
|
||||
width={width - valueWidth}
|
||||
height={25}
|
||||
sparkline={sparkline}
|
||||
config={config}
|
||||
theme={theme}
|
||||
onHover={handleHover}
|
||||
/>
|
||||
</MaybeWrapWithLink>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ const cursorDefaults: Cursor = {
|
||||
type PrepData = (frames: DataFrame[]) => AlignedData | FacetedData;
|
||||
type PreDataStacked = (frames: DataFrame[], stackingGroups: StackingGroup[]) => AlignedData | FacetedData;
|
||||
|
||||
type PlotState = { isPanning: false } | { isPanning: true; min: number; max: number };
|
||||
type PlotState = { isPanning: false } | { isPanning: true; min: number; max: number; isTimeRangePending?: boolean };
|
||||
|
||||
export class UPlotConfigBuilder {
|
||||
readonly uid = Math.random().toString(36).slice(2);
|
||||
|
||||
+15
-1
@@ -137,7 +137,7 @@ describe('XAxisInteractionAreaPlugin', () => {
|
||||
expect(mockQueryZoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set isPanning state during drag and clear on mouseup', () => {
|
||||
it('should set isPanning state during drag and mark isTimeRangePending on mouseup', () => {
|
||||
setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom);
|
||||
|
||||
xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true }));
|
||||
@@ -153,6 +153,20 @@ describe('XAxisInteractionAreaPlugin', () => {
|
||||
|
||||
document.dispatchEvent(new MouseEvent('mouseup', { clientX: 350, bubbles: true }));
|
||||
|
||||
expect(mockConfigBuilder.setState).toHaveBeenCalledWith({
|
||||
isPanning: true,
|
||||
min: expectedRange.from,
|
||||
max: expectedRange.to,
|
||||
isTimeRangePending: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear isPanning state immediately for small drags below threshold', () => {
|
||||
setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom);
|
||||
|
||||
xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true }));
|
||||
document.dispatchEvent(new MouseEvent('mouseup', { clientX: 402, bubbles: true }));
|
||||
|
||||
expect(mockConfigBuilder.setState).toHaveBeenCalledWith({ isPanning: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,11 +96,14 @@ export const setupXAxisPan = (
|
||||
|
||||
xAxisEl.style.cursor = 'grab';
|
||||
|
||||
config.setState({ isPanning: false });
|
||||
const isSignificantDrag = Math.abs(dragPixels) >= MIN_PAN_DIST;
|
||||
|
||||
if (Math.abs(dragPixels) >= MIN_PAN_DIST) {
|
||||
if (isSignificantDrag) {
|
||||
const newRange = calculatePanRange(startMin, startMax, dragPixels, u.bbox.width);
|
||||
config.setState({ isPanning: true, min: newRange.from, max: newRange.to, isTimeRangePending: true });
|
||||
queryZoom(newRange);
|
||||
} else {
|
||||
config.setState({ isPanning: false });
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -200,6 +202,11 @@ func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response.
|
||||
// 404: notFoundError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "GetDataSourceByUID"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
ds, err := hs.getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.GetOrgID())
|
||||
|
||||
if err != nil {
|
||||
@@ -231,6 +238,11 @@ func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Re
|
||||
// 404: notFoundError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "DeleteDataSourceByUID"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
uid := web.Params(c.Req)[":uid"]
|
||||
|
||||
if uid == "" {
|
||||
@@ -361,6 +373,11 @@ func validateJSONData(jsonData *simplejson.Json, cfg *setting.Cfg) error {
|
||||
// 409: conflictError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Response {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "AddDataSource"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
cmd := datasources.AddDataSourceCommand{}
|
||||
if err := web.Bind(c.Req, &cmd); err != nil {
|
||||
return response.Error(http.StatusBadRequest, "bad request data", err)
|
||||
@@ -478,6 +495,10 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response.
|
||||
// 409: conflictError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "UpdateDataSourceByUID"), time.Since(start).Seconds())
|
||||
}()
|
||||
cmd := datasources.UpdateDataSourceCommand{}
|
||||
if err := web.Bind(c.Req, &cmd); err != nil {
|
||||
return response.Error(http.StatusBadRequest, "bad request data", err)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db/dbtest"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
@@ -81,6 +83,19 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) {
|
||||
}, mockSQLStore)
|
||||
}
|
||||
|
||||
// setupDsConfigMetrics creates and registers the prometheus metrics needed for HTTPServer tests
|
||||
// that call methods using dsConfigHandlerRequestsDuration.
|
||||
func setupDsConfigHandlerMetrics() (prometheus.Registerer, *prometheus.HistogramVec) {
|
||||
promRegister := prometheus.NewRegistry()
|
||||
dsConfigHandlerRequestsDuration := metricutil.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "grafana",
|
||||
Name: "ds_config_handler_requests_duration_seconds",
|
||||
Help: "Duration of requests handled by datasource configuration handlers",
|
||||
}, []string{"code_path", "handler"})
|
||||
promRegister.MustRegister(dsConfigHandlerRequestsDuration)
|
||||
return promRegister, dsConfigHandlerRequestsDuration
|
||||
}
|
||||
|
||||
// Adding data sources with invalid URLs should lead to an error.
|
||||
func TestAddDataSource_InvalidURL(t *testing.T) {
|
||||
sc := setupScenarioContext(t, "/api/datasources")
|
||||
@@ -88,6 +103,7 @@ func TestAddDataSource_InvalidURL(t *testing.T) {
|
||||
DataSourcesService: &dataSourcesServiceMock{},
|
||||
Cfg: setting.NewCfg(),
|
||||
}
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
|
||||
sc.m.Post(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response {
|
||||
c.Req.Body = mockRequestBody(datasources.AddDataSourceCommand{
|
||||
@@ -118,6 +134,7 @@ func TestAddDataSource_URLWithoutProtocol(t *testing.T) {
|
||||
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
|
||||
accesscontrolService: actest.FakeService{},
|
||||
}
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
|
||||
sc := setupScenarioContext(t, "/api/datasources")
|
||||
|
||||
@@ -143,6 +160,7 @@ func TestAddDataSource_InvalidJSONData(t *testing.T) {
|
||||
DataSourcesService: &dataSourcesServiceMock{},
|
||||
Cfg: setting.NewCfg(),
|
||||
}
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
|
||||
sc := setupScenarioContext(t, "/api/datasources")
|
||||
|
||||
@@ -175,6 +193,7 @@ func TestUpdateDataSource_InvalidURL(t *testing.T) {
|
||||
DataSourcesService: &dataSourcesServiceMock{},
|
||||
Cfg: setting.NewCfg(),
|
||||
}
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
sc := setupScenarioContext(t, "/api/datasources/1234")
|
||||
|
||||
sc.m.Put(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response {
|
||||
@@ -199,6 +218,7 @@ func TestUpdateDataSource_InvalidJSONData(t *testing.T) {
|
||||
DataSourcesService: &dataSourcesServiceMock{},
|
||||
Cfg: setting.NewCfg(),
|
||||
}
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
sc := setupScenarioContext(t, "/api/datasources/1234")
|
||||
|
||||
hs.Cfg.AuthProxy.Enabled = true
|
||||
@@ -236,6 +256,7 @@ func TestAddDataSourceTeamHTTPHeaders(t *testing.T) {
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
}
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
sc := setupScenarioContext(t, fmt.Sprintf("/api/datasources/%s", tenantID))
|
||||
hs.Cfg.AuthProxy.Enabled = true
|
||||
|
||||
@@ -289,6 +310,7 @@ func TestUpdateDataSource_URLWithoutProtocol(t *testing.T) {
|
||||
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
|
||||
accesscontrolService: actest.FakeService{},
|
||||
}
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
|
||||
sc := setupScenarioContext(t, "/api/datasources/1234")
|
||||
|
||||
@@ -429,6 +451,7 @@ func TestAPI_datasources_AccessControl(t *testing.T) {
|
||||
hs.DataSourcesService = &dataSourcesServiceMock{expectedDatasource: &datasources.DataSource{}}
|
||||
hs.accesscontrolService = actest.FakeService{}
|
||||
hs.Live = newTestLive(t, hs.SQLStore)
|
||||
hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics()
|
||||
})
|
||||
|
||||
for _, url := range tt.urls {
|
||||
|
||||
+28
-21
@@ -203,27 +203,28 @@ type HTTPServer struct {
|
||||
pluginsCDNService *pluginscdn.Service
|
||||
managedPluginsService managedplugins.Manager
|
||||
|
||||
userService user.Service
|
||||
tempUserService tempUser.Service
|
||||
loginAttemptService loginAttempt.Service
|
||||
orgService org.Service
|
||||
orgDeletionService org.DeletionService
|
||||
TeamService team.Service
|
||||
accesscontrolService accesscontrol.Service
|
||||
annotationsRepo annotations.Repository
|
||||
tagService tag.Service
|
||||
oauthTokenService oauthtoken.OAuthTokenService
|
||||
statsService stats.Service
|
||||
authnService authn.Service
|
||||
starApi *starApi.API
|
||||
promRegister prometheus.Registerer
|
||||
promGatherer prometheus.Gatherer
|
||||
clientConfigProvider grafanaapiserver.DirectRestConfigProvider
|
||||
namespacer request.NamespaceMapper
|
||||
anonService anonymous.Service
|
||||
userVerifier user.Verifier
|
||||
tlsCerts TLSCerts
|
||||
htmlHandlerRequestsDuration *prometheus.HistogramVec
|
||||
userService user.Service
|
||||
tempUserService tempUser.Service
|
||||
loginAttemptService loginAttempt.Service
|
||||
orgService org.Service
|
||||
orgDeletionService org.DeletionService
|
||||
TeamService team.Service
|
||||
accesscontrolService accesscontrol.Service
|
||||
annotationsRepo annotations.Repository
|
||||
tagService tag.Service
|
||||
oauthTokenService oauthtoken.OAuthTokenService
|
||||
statsService stats.Service
|
||||
authnService authn.Service
|
||||
starApi *starApi.API
|
||||
promRegister prometheus.Registerer
|
||||
promGatherer prometheus.Gatherer
|
||||
clientConfigProvider grafanaapiserver.DirectRestConfigProvider
|
||||
namespacer request.NamespaceMapper
|
||||
anonService anonymous.Service
|
||||
userVerifier user.Verifier
|
||||
tlsCerts TLSCerts
|
||||
htmlHandlerRequestsDuration *prometheus.HistogramVec
|
||||
dsConfigHandlerRequestsDuration *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
type TLSCerts struct {
|
||||
@@ -382,9 +383,15 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
||||
Name: "html_handler_requests_duration_seconds",
|
||||
Help: "Duration of requests handled by the index.go HTML handler",
|
||||
}, []string{"handler"}),
|
||||
dsConfigHandlerRequestsDuration: metricutil.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "grafana",
|
||||
Name: "ds_config_handler_requests_duration_seconds",
|
||||
Help: "Duration of requests handled by datasource configuration handlers",
|
||||
}, []string{"code_path", "handler"}),
|
||||
}
|
||||
|
||||
promRegister.MustRegister(hs.htmlHandlerRequestsDuration)
|
||||
promRegister.MustRegister(hs.dsConfigHandlerRequestsDuration)
|
||||
|
||||
if hs.Listener != nil {
|
||||
hs.log.Debug("Using provided listener")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -21,6 +22,13 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
)
|
||||
|
||||
var openfeatureClient = openfeature.NewDefaultClient()
|
||||
|
||||
const (
|
||||
pluginPageFeatureFlagPrefix = "plugin-page-visible."
|
||||
)
|
||||
|
||||
type AuthOptions struct {
|
||||
@@ -146,6 +154,12 @@ func RoleAppPluginAuth(accessControl ac.AccessControl, ps pluginstore.Store, log
|
||||
return
|
||||
}
|
||||
|
||||
if !PageIsFeatureToggleEnabled(c.Req.Context(), c.Req.URL.Path) {
|
||||
logger.Debug("Forbidden experimental plugin page", "plugin", pluginID, "path", c.Req.URL.Path)
|
||||
accessForbidden(c)
|
||||
return
|
||||
}
|
||||
|
||||
permitted := true
|
||||
path := normalizeIncludePath(c.Req.URL.Path)
|
||||
hasAccess := ac.HasAccess(accessControl, c)
|
||||
@@ -294,3 +308,18 @@ func shouldForceLogin(c *contextmodel.ReqContext) bool {
|
||||
|
||||
return forceLogin
|
||||
}
|
||||
|
||||
// PageIsFeatureToggleEnabled checks if a page is enabled via OpenFeature feature flags.
|
||||
// It returns false if the feature flag is set and set to false.
|
||||
// The feature flag key format is: "plugin-page-visible.<path>"
|
||||
func PageIsFeatureToggleEnabled(ctx context.Context, path string) bool {
|
||||
flagKey := pluginPageFeatureFlagPrefix + filepath.Clean(path)
|
||||
enabled := openfeatureClient.Boolean(
|
||||
ctx,
|
||||
flagKey,
|
||||
true,
|
||||
openfeature.TransactionContext(ctx),
|
||||
)
|
||||
|
||||
return enabled
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
oftesting "github.com/open-feature/go-sdk/openfeature/testing"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -28,6 +33,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
var openfeatureTestMutex sync.Mutex
|
||||
|
||||
func setupAuthMiddlewareTest(t *testing.T, identity *authn.Identity, authErr error) *contexthandler.ContextHandler {
|
||||
return contexthandler.ProvideService(setting.NewCfg(), &authntest.FakeService{
|
||||
ExpectedErr: authErr,
|
||||
@@ -422,6 +429,60 @@ func TestCanAdminPlugin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageIsFeatureToggleEnabled(t *testing.T) {
|
||||
type testCase struct {
|
||||
desc string
|
||||
path string
|
||||
flags map[string]bool
|
||||
expectedResult bool
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
desc: "returns true when feature flag is enabled",
|
||||
path: "/a/my-plugin/settings",
|
||||
flags: map[string]bool{
|
||||
pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": true,
|
||||
},
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
desc: "returns false when feature flag is disabled",
|
||||
path: "/a/my-plugin/settings",
|
||||
flags: map[string]bool{
|
||||
pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": false,
|
||||
},
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
desc: "returns false when feature flag is disabled with trailing slash",
|
||||
path: "/a/my-plugin/settings/",
|
||||
flags: map[string]bool{
|
||||
pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": false,
|
||||
},
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
desc: "returns true when feature flag does not exist",
|
||||
path: "/a/my-plugin/settings",
|
||||
flags: map[string]bool{},
|
||||
expectedResult: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
setupTestProvider(t, tt.flags)
|
||||
|
||||
result := PageIsFeatureToggleEnabled(ctx, tt.path)
|
||||
|
||||
assert.Equal(t, tt.expectedResult, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func contextProvider(modifiers ...func(c *contextmodel.ReqContext)) web.Handler {
|
||||
return func(c *web.Context) {
|
||||
reqCtx := &contextmodel.ReqContext{
|
||||
@@ -437,3 +498,38 @@ func contextProvider(modifiers ...func(c *contextmodel.ReqContext)) web.Handler
|
||||
c.Req = c.Req.WithContext(ctxkey.Set(c.Req.Context(), reqCtx))
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestProvider creates a test OpenFeature provider with the given flags.
|
||||
// Uses a global lock to prevent concurrent provider changes across tests.
|
||||
func setupTestProvider(t *testing.T, flags map[string]bool) oftesting.TestProvider {
|
||||
t.Helper()
|
||||
|
||||
// Lock to prevent concurrent provider changes
|
||||
openfeatureTestMutex.Lock()
|
||||
|
||||
testProvider := oftesting.NewTestProvider()
|
||||
flagsMap := map[string]memprovider.InMemoryFlag{}
|
||||
|
||||
for key, value := range flags {
|
||||
flagsMap[key] = memprovider.InMemoryFlag{
|
||||
DefaultVariant: "defaultVariant",
|
||||
Variants: map[string]any{
|
||||
"defaultVariant": value,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
testProvider.UsingFlags(t, flagsMap)
|
||||
|
||||
err := openfeature.SetProviderAndWait(testProvider)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
testProvider.Cleanup()
|
||||
_ = openfeature.SetProviderAndWait(openfeature.NoopProvider{})
|
||||
// Unlock after cleanup to allow other tests to run
|
||||
openfeatureTestMutex.Unlock()
|
||||
})
|
||||
|
||||
return testProvider
|
||||
}
|
||||
|
||||
@@ -136,9 +136,9 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "permission",
|
||||
In: "query",
|
||||
Description: "permission needed for the resource (View, Edit, Admin)",
|
||||
Description: "permission needed for the resource (view, edit, admin)",
|
||||
Required: false,
|
||||
Schema: spec.StringProperty().WithEnum("View", "Edit", "Admin"),
|
||||
Schema: spec.StringProperty().WithEnum("view", "edit", "admin"),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,7 +3,9 @@ package datasource
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -26,8 +29,9 @@ var (
|
||||
)
|
||||
|
||||
type legacyStorage struct {
|
||||
datasources PluginDatasourceProvider
|
||||
resourceInfo *utils.ResourceInfo
|
||||
datasources PluginDatasourceProvider
|
||||
resourceInfo *utils.ResourceInfo
|
||||
dsConfigHandlerRequestsDuration *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
func (s *legacyStorage) New() runtime.Object {
|
||||
@@ -57,11 +61,21 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
return s.datasources.GetDataSource(ctx, name)
|
||||
}
|
||||
|
||||
// Create implements rest.Creater.
|
||||
func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
ds, ok := obj.(*v0alpha1.DataSource)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected a datasource object")
|
||||
@@ -71,6 +85,11 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa
|
||||
|
||||
// Update implements rest.Updater.
|
||||
func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
old, err := s.Get(ctx, name, &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
@@ -107,6 +126,11 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up
|
||||
|
||||
// Delete implements rest.GracefulDeleter.
|
||||
func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
err := s.datasources.DeleteDataSource(ctx, name)
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
|
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/sources"
|
||||
"github.com/grafana/grafana/pkg/promlib/models"
|
||||
@@ -218,6 +219,11 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
|
||||
legacyStore := &legacyStorage{
|
||||
datasources: b.datasources,
|
||||
resourceInfo: &ds,
|
||||
dsConfigHandlerRequestsDuration: metricutil.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "grafana",
|
||||
Name: "ds_config_handler_requests_duration_seconds",
|
||||
Help: "Duration of requests handled by datasource configuration handlers",
|
||||
}, []string{"code_path", "handler"}),
|
||||
}
|
||||
unified, err := grafanaregistry.NewRegistryStore(opts.Scheme, ds, opts.OptsGetter)
|
||||
if err != nil {
|
||||
|
||||
@@ -219,7 +219,9 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
||||
}
|
||||
|
||||
storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.store)
|
||||
storage[teamResource.StoragePath("groups")] = b.teamGroupsHandler
|
||||
if b.teamGroupsHandler != nil {
|
||||
storage[teamResource.StoragePath("groups")] = b.teamGroupsHandler
|
||||
}
|
||||
|
||||
teamBindingResource := iamv0.TeamBindingResourceInfo
|
||||
teamBindingUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, teamBindingResource, opts.OptsGetter)
|
||||
|
||||
@@ -64,6 +64,10 @@ func NewAPIBuilder(providerType string, url *url.URL, insecure bool, caFile stri
|
||||
}
|
||||
|
||||
func RegisterAPIService(apiregistration builder.APIRegistrar, cfg *setting.Cfg) (*APIBuilder, error) {
|
||||
if !cfg.OpenFeature.APIEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var staticEvaluator featuremgmt.StaticFlagEvaluator // No static evaluator needed for non-static provider
|
||||
var err error
|
||||
if cfg.OpenFeature.ProviderType == setting.StaticProviderType {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// The maximum size of a secure value in bytes when written as raw input.
|
||||
const SecureValueRawInputMaxSizeBytes = 24576 // 24 KiB
|
||||
const SecureValueRawInputMaxSizeBytes = 24 << 10 // 24 KiB
|
||||
|
||||
type DecryptSecureValue struct {
|
||||
Keeper *string
|
||||
@@ -47,7 +47,6 @@ type SecureValueService interface {
|
||||
List(ctx context.Context, namespace xkube.Namespace) (*secretv1beta1.SecureValueList, error)
|
||||
Update(ctx context.Context, newSecureValue *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, bool, error)
|
||||
Delete(ctx context.Context, namespace xkube.Namespace, name string) (*secretv1beta1.SecureValue, error)
|
||||
SetKeeperAsActive(ctx context.Context, namespace xkube.Namespace, keeperName string) error
|
||||
}
|
||||
|
||||
type SecureValueClient interface {
|
||||
|
||||
@@ -27,7 +27,6 @@ var _ contracts.SecureValueService = (*SecureValueService)(nil)
|
||||
type SecureValueService struct {
|
||||
tracer trace.Tracer
|
||||
accessClient claims.AccessClient
|
||||
database contracts.Database
|
||||
secureValueMetadataStorage contracts.SecureValueMetadataStorage
|
||||
secureValueValidator contracts.SecureValueValidator
|
||||
secureValueMutator contracts.SecureValueMutator
|
||||
@@ -39,7 +38,6 @@ type SecureValueService struct {
|
||||
func ProvideSecureValueService(
|
||||
tracer trace.Tracer,
|
||||
accessClient claims.AccessClient,
|
||||
database contracts.Database,
|
||||
secureValueMetadataStorage contracts.SecureValueMetadataStorage,
|
||||
secureValueValidator contracts.SecureValueValidator,
|
||||
secureValueMutator contracts.SecureValueMutator,
|
||||
@@ -50,7 +48,6 @@ func ProvideSecureValueService(
|
||||
return &SecureValueService{
|
||||
tracer: tracer,
|
||||
accessClient: accessClient,
|
||||
database: database,
|
||||
secureValueMetadataStorage: secureValueMetadataStorage,
|
||||
secureValueValidator: secureValueValidator,
|
||||
secureValueMutator: secureValueMutator,
|
||||
@@ -369,10 +366,3 @@ func (s *SecureValueService) Delete(ctx context.Context, namespace xkube.Namespa
|
||||
|
||||
return sv, nil
|
||||
}
|
||||
|
||||
func (s *SecureValueService) SetKeeperAsActive(ctx context.Context, namespace xkube.Namespace, name string) error {
|
||||
if err := s.keeperMetadataStorage.SetAsActive(ctx, namespace, name); err != nil {
|
||||
return fmt.Errorf("calling keeper metadata storage to set keeper as active: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut {
|
||||
secureValueValidator := validator.ProvideSecureValueValidator()
|
||||
secureValueMutator := mutator.ProvideSecureValueMutator()
|
||||
|
||||
secureValueService := service.ProvideSecureValueService(tracer, accessClient, database, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, keeperService, nil)
|
||||
secureValueService := service.ProvideSecureValueService(tracer, accessClient, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, keeperService, nil)
|
||||
|
||||
decryptAuthorizer := decrypt.ProvideDecryptAuthorizer(tracer, nil)
|
||||
|
||||
|
||||
Generated
+6
-6
@@ -459,8 +459,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
}
|
||||
ossDashboardStats := builders.ProvideDashboardStats()
|
||||
documentBuilderSupplier := search.ProvideDocumentBuilders(sqlStore, ossDashboardStats)
|
||||
databaseDatabase := database4.ProvideDatabase(sqlStore, tracer)
|
||||
clockClock := clock.ProvideClock()
|
||||
databaseDatabase := database4.ProvideDatabase(sqlStore, tracer)
|
||||
secureValueMetadataStorage, err := metadata.ProvideSecureValueMetadataStorage(clockClock, databaseDatabase, tracer, registerer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -508,7 +508,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secureValueService := service5.ProvideSecureValueService(tracer, accessClient, databaseDatabase, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, ossKeeperService, registerer)
|
||||
secureValueService := service5.ProvideSecureValueService(tracer, accessClient, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, ossKeeperService, registerer)
|
||||
inlineSecureValueSupport, err := inline.ProvideInlineSecureValueService(cfg, tracer, secureValueService, accessClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -668,7 +668,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
azurePromMigrationService := promtypemigration.ProvideAzurePromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
||||
amazonPromMigrationService := promtypemigration.ProvideAmazonPromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
||||
promTypeMigrationProviderImpl := promtypemigration.ProvidePromTypeMigrationProvider(serverLockService, featureToggles, azurePromMigrationService, amazonPromMigrationService)
|
||||
provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl)
|
||||
provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl, serverLockService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1107,8 +1107,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
}
|
||||
ossDashboardStats := builders.ProvideDashboardStats()
|
||||
documentBuilderSupplier := search.ProvideDocumentBuilders(sqlStore, ossDashboardStats)
|
||||
databaseDatabase := database4.ProvideDatabase(sqlStore, tracer)
|
||||
clockClock := clock.ProvideClock()
|
||||
databaseDatabase := database4.ProvideDatabase(sqlStore, tracer)
|
||||
secureValueMetadataStorage, err := metadata.ProvideSecureValueMetadataStorage(clockClock, databaseDatabase, tracer, registerer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1156,7 +1156,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secureValueService := service5.ProvideSecureValueService(tracer, accessClient, databaseDatabase, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, ossKeeperService, registerer)
|
||||
secureValueService := service5.ProvideSecureValueService(tracer, accessClient, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, ossKeeperService, registerer)
|
||||
inlineSecureValueSupport, err := inline.ProvideInlineSecureValueService(cfg, tracer, secureValueService, accessClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1312,7 +1312,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
azurePromMigrationService := promtypemigration.ProvideAzurePromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
||||
amazonPromMigrationService := promtypemigration.ProvideAmazonPromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg)
|
||||
promTypeMigrationProviderImpl := promtypemigration.ProvidePromTypeMigrationProvider(serverLockService, featureToggles, azurePromMigrationService, amazonPromMigrationService)
|
||||
provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl)
|
||||
provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl, serverLockService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
k8srequest "k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
serverstorage "k8s.io/apiserver/pkg/server/storage"
|
||||
"k8s.io/apiserver/pkg/util/openapi"
|
||||
k8sscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
k8stracing "k8s.io/component-base/tracing"
|
||||
@@ -73,36 +72,13 @@ var PathRewriters = []filters.PathRewriter{
|
||||
},
|
||||
}
|
||||
|
||||
// GetDefaultBuildHandlerChainFuncForAggregator is a replica of GetDefaultBuildHandlerChainFunc except it skips custom routes handling
|
||||
func GetDefaultBuildHandlerChainFuncForAggregator() BuildHandlerChainFunc {
|
||||
return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler {
|
||||
// filters.WithRequester needs to be after the K8s chain because it depends on the K8s user in context
|
||||
handler := filters.WithRequester(delegateHandler)
|
||||
|
||||
// Call DefaultBuildHandlerChain on the main entrypoint http.Handler
|
||||
// See https://github.com/kubernetes/apiserver/blob/v0.28.0/pkg/server/config.go#L906
|
||||
// DefaultBuildHandlerChain provides many things, notably CORS, HSTS, cache-control, authz and latency tracking
|
||||
handler = genericapiserver.DefaultBuildHandlerChain(handler, c)
|
||||
|
||||
handler = filters.WithAcceptHeader(handler)
|
||||
handler = filters.WithPathRewriters(handler, PathRewriters)
|
||||
handler = k8stracing.WithTracing(handler, c.TracerProvider, "KubernetesAPI")
|
||||
handler = filters.WithExtractJaegerTrace(handler)
|
||||
// Configure filters.WithPanicRecovery to not crash on panic
|
||||
utilruntime.ReallyCrash = false
|
||||
|
||||
return handler
|
||||
}
|
||||
}
|
||||
func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.Registerer) BuildHandlerChainFunc {
|
||||
return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler {
|
||||
requestHandler, err := GetCustomRoutesHandler(
|
||||
delegateHandler,
|
||||
c.LoopbackClientConfig,
|
||||
builders,
|
||||
reg,
|
||||
c.MergedResourceConfig,
|
||||
)
|
||||
reg)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("could not build the request handler for specified API builders: %s", err.Error()))
|
||||
}
|
||||
@@ -129,8 +105,6 @@ func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.
|
||||
}
|
||||
}
|
||||
|
||||
// SetupConfig sets up the server config for the API server
|
||||
// specify isAggregator=true, if the chain is being constructed for kube-aggregator
|
||||
func SetupConfig(
|
||||
scheme *runtime.Scheme,
|
||||
serverConfig *genericapiserver.RecommendedConfig,
|
||||
@@ -140,7 +114,6 @@ func SetupConfig(
|
||||
gvs []schema.GroupVersion,
|
||||
additionalOpenAPIDefGetters []common.GetOpenAPIDefinitions,
|
||||
reg prometheus.Registerer,
|
||||
apiResourceConfig *serverstorage.ResourceConfig,
|
||||
) error {
|
||||
serverConfig.AdmissionControl = NewAdmissionFromBuilders(builders)
|
||||
defsGetter := GetOpenAPIDefinitions(builders, additionalOpenAPIDefGetters...)
|
||||
@@ -153,7 +126,7 @@ func SetupConfig(
|
||||
openapinamer.NewDefinitionNamer(scheme, k8sscheme.Scheme))
|
||||
|
||||
// Add the custom routes to service discovery
|
||||
serverConfig.OpenAPIV3Config.PostProcessSpec = getOpenAPIPostProcessor(buildVersion, builders, gvs, apiResourceConfig)
|
||||
serverConfig.OpenAPIV3Config.PostProcessSpec = getOpenAPIPostProcessor(buildVersion, builders, gvs)
|
||||
serverConfig.OpenAPIV3Config.GetOperationIDAndTagsFromRoute = func(r common.Route) (string, []string, error) {
|
||||
meta := r.Metadata()
|
||||
kind := ""
|
||||
@@ -314,7 +287,6 @@ func InstallAPIs(
|
||||
features featuremgmt.FeatureToggles,
|
||||
dualWriterMetrics *grafanarest.DualWriterMetrics,
|
||||
builderMetrics *BuilderMetrics,
|
||||
apiResourceConfig *serverstorage.ResourceConfig,
|
||||
) error {
|
||||
// dual writing is only enabled when the storage type is not legacy.
|
||||
// this is needed to support setting a default RESTOptionsGetter for new APIs that don't
|
||||
@@ -429,9 +401,34 @@ func InstallAPIs(
|
||||
for group, buildersForGroup := range buildersGroupMap {
|
||||
g := genericapiserver.NewDefaultAPIGroupInfo(group, scheme, metav1.ParameterCodec, codecs)
|
||||
for _, b := range buildersForGroup {
|
||||
if err := installAPIGroupsForBuilder(&g, group, b, apiResourceConfig, scheme, optsGetter, dualWrite, reg, optsregister, storageOpts, features); err != nil {
|
||||
if err := b.UpdateAPIGroupInfo(&g, APIGroupOptions{
|
||||
Scheme: scheme,
|
||||
OptsGetter: optsGetter,
|
||||
DualWriteBuilder: dualWrite,
|
||||
MetricsRegister: reg,
|
||||
StorageOptsRegister: optsregister,
|
||||
StorageOpts: storageOpts,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(g.PrioritizedVersions) < 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
// if grafanaAPIServerWithExperimentalAPIs is not enabled, remove v0alpha1 resources unless explicitly allowed
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
if resources, ok := g.VersionedResourcesStorageMap["v0alpha1"]; ok {
|
||||
for name := range resources {
|
||||
if !allowRegisteringResourceByInfo(b.AllowedV0Alpha1Resources(), name) {
|
||||
delete(resources, name)
|
||||
}
|
||||
}
|
||||
if len(resources) == 0 {
|
||||
delete(g.VersionedResourcesStorageMap, "v0alpha1")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// skip installing the group if there are no resources left after filtering
|
||||
@@ -448,53 +445,6 @@ func InstallAPIs(
|
||||
return nil
|
||||
}
|
||||
|
||||
func installAPIGroupsForBuilder(g *genericapiserver.APIGroupInfo, group string, b APIGroupBuilder, apiResourceConfig *serverstorage.ResourceConfig, scheme *runtime.Scheme,
|
||||
optsGetter generic.RESTOptionsGetter, dualWrite grafanarest.DualWriteBuilder, reg prometheus.Registerer, optsregister apistore.StorageOptionsRegister,
|
||||
storageOpts *options.StorageOptions, features featuremgmt.FeatureToggles) error {
|
||||
if err := b.UpdateAPIGroupInfo(g, APIGroupOptions{
|
||||
Scheme: scheme,
|
||||
OptsGetter: optsGetter,
|
||||
DualWriteBuilder: dualWrite,
|
||||
MetricsRegister: reg,
|
||||
StorageOptsRegister: optsregister,
|
||||
StorageOpts: storageOpts,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(g.PrioritizedVersions) < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// filter out api groups that are disabled in APIEnablementOptions
|
||||
for version := range g.VersionedResourcesStorageMap {
|
||||
gvr := schema.GroupVersionResource{
|
||||
Group: group,
|
||||
Version: version,
|
||||
}
|
||||
if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) {
|
||||
klog.InfoS("Skipping storage for disabled resource", "gvr", gvr.String())
|
||||
delete(g.VersionedResourcesStorageMap, version)
|
||||
}
|
||||
}
|
||||
|
||||
// if grafanaAPIServerWithExperimentalAPIs is not enabled, remove v0alpha1 resources unless explicitly allowed
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
if resources, ok := g.VersionedResourcesStorageMap["v0alpha1"]; ok {
|
||||
for name := range resources {
|
||||
if !allowRegisteringResourceByInfo(b.AllowedV0Alpha1Resources(), name) {
|
||||
delete(resources, name)
|
||||
}
|
||||
}
|
||||
if len(resources) == 0 {
|
||||
delete(g.VersionedResourcesStorageMap, "v0alpha1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddPostStartHooks adds post start hooks to a generic API server config
|
||||
func AddPostStartHooks(
|
||||
config *genericapiserver.RecommendedConfig,
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
|
||||
apiequality "k8s.io/apimachinery/pkg/api/equality"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
serverstorage "k8s.io/apiserver/pkg/server/storage"
|
||||
"k8s.io/klog/v2"
|
||||
openapi "k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
spec "k8s.io/kube-openapi/pkg/validation/spec"
|
||||
@@ -78,7 +76,6 @@ func addBuilderRoutes(
|
||||
targetGroupVersion schema.GroupVersion,
|
||||
openAPISpec *spec3.OpenAPI,
|
||||
apiGroupBuilders []APIGroupBuilder,
|
||||
apiResourceConfig *serverstorage.ResourceConfig,
|
||||
) (*spec3.OpenAPI, error) {
|
||||
for _, apiGroupBuilder := range apiGroupBuilders {
|
||||
// Optionally include raw http handlers for all builders
|
||||
@@ -110,24 +107,12 @@ func addBuilderRoutes(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// filter out api groups that are disabled in APIEnablementOptions
|
||||
for path := range openAPISpec.Paths.Paths {
|
||||
if strings.HasPrefix(path, "/apis/"+targetGroupVersion.String()+"/") {
|
||||
gv := targetGroupVersion.WithResource("")
|
||||
if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gv) {
|
||||
klog.InfoS("removing openapi routes for disabled resource", "gv", gv.String())
|
||||
delete(openAPISpec.Paths.Paths, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return openAPISpec, nil
|
||||
}
|
||||
|
||||
// Modify the OpenAPI spec to include the additional routes.
|
||||
// nolint:gocyclo
|
||||
func getOpenAPIPostProcessor(version string, builders []APIGroupBuilder, gvs []schema.GroupVersion, apiResourceConfig *serverstorage.ResourceConfig) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
||||
func getOpenAPIPostProcessor(version string, builders []APIGroupBuilder, gvs []schema.GroupVersion) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
||||
return func(s *spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
||||
if s.Paths == nil {
|
||||
return s, nil
|
||||
@@ -242,7 +227,7 @@ func getOpenAPIPostProcessor(version string, builders []APIGroupBuilder, gvs []s
|
||||
}
|
||||
}
|
||||
}
|
||||
return addBuilderRoutes(gv, ©, builders, apiResourceConfig)
|
||||
return addBuilderRoutes(gv, ©, builders)
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
|
||||
@@ -6,9 +6,7 @@ import (
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
serverstorage "k8s.io/apiserver/pkg/server/storage"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
klog "k8s.io/klog/v2"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
)
|
||||
|
||||
@@ -16,7 +14,7 @@ type requestHandler struct {
|
||||
router *mux.Router
|
||||
}
|
||||
|
||||
func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder, metricsRegistry prometheus.Registerer, apiResourceConfig *serverstorage.ResourceConfig) (http.Handler, error) {
|
||||
func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder, metricsRegistry prometheus.Registerer) (http.Handler, error) {
|
||||
useful := false // only true if any routes exist anywhere
|
||||
router := mux.NewRouter()
|
||||
|
||||
@@ -29,12 +27,6 @@ func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient
|
||||
}
|
||||
|
||||
for _, gv := range GetGroupVersions(builder) {
|
||||
// filter out api groups that are disabled in APIEnablementOptions
|
||||
gvr := gv.WithResource("")
|
||||
if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) {
|
||||
klog.InfoS("Skipping custom route handler for disabled group version", "gv", gv.String())
|
||||
continue
|
||||
}
|
||||
routes := provider.GetAPIRoutes(gv)
|
||||
if routes == nil {
|
||||
continue
|
||||
|
||||
@@ -316,11 +316,7 @@ func (s *service) start(ctx context.Context) error {
|
||||
s.cfg.BuildBranch,
|
||||
)
|
||||
|
||||
apiResourceConfig := appinstaller.NewAPIResourceConfig(s.appInstallers)
|
||||
// add the builder group versions to the api resource config
|
||||
apiResourceConfig.EnableVersions(groupVersions...)
|
||||
|
||||
if err := o.APIEnablementOptions.ApplyTo(&serverConfig.Config, apiResourceConfig, s.scheme); err != nil {
|
||||
if err := o.APIEnablementOptions.ApplyTo(&serverConfig.Config, appinstaller.NewAPIResourceConfig(s.appInstallers), s.scheme); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -363,7 +359,6 @@ func (s *service) start(ctx context.Context) error {
|
||||
groupVersions,
|
||||
defGetters,
|
||||
s.metrics,
|
||||
apiResourceConfig,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -405,7 +400,6 @@ func (s *service) start(ctx context.Context) error {
|
||||
s.features,
|
||||
s.dualWriterMetrics,
|
||||
s.builderMetrics,
|
||||
apiResourceConfig,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,8 @@ message MutateOperation {
|
||||
UpdateUserOrgRoleOperation update_user_org_role = 5;
|
||||
DeleteUserOrgRoleOperation delete_user_org_role = 6;
|
||||
AddUserOrgRoleOperation add_user_org_role = 7;
|
||||
CreateRoleBindingOperation create_role_binding = 8;
|
||||
DeleteRoleBindingOperation delete_role_binding = 9;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +89,28 @@ message DeleteUserOrgRoleOperation {
|
||||
string role = 2;
|
||||
}
|
||||
|
||||
message CreateRoleBindingOperation {
|
||||
// kind of the identity getting the permission (User/Team/ServiceAccount/BasicRole)
|
||||
string subject_kind = 1;
|
||||
// uid of the identity
|
||||
string subject_name = 2;
|
||||
// kind of the role (Role/CoreRole/GlobalRole)
|
||||
string role_kind = 3;
|
||||
// uid of the role
|
||||
string role_name = 4;
|
||||
}
|
||||
|
||||
message DeleteRoleBindingOperation {
|
||||
// kind of the identity getting the permission (User/Team/ServiceAccount/BasicRole)
|
||||
string subject_kind = 1;
|
||||
// uid of the identity
|
||||
string subject_name = 2;
|
||||
// kind of the role (Role/CoreRole/GlobalRole)
|
||||
string role_kind = 3;
|
||||
// uid of the role
|
||||
string role_name = 4;
|
||||
}
|
||||
|
||||
message Resource {
|
||||
// group of the resource (e.g: "dashboard.grafana.app")
|
||||
string group = 1;
|
||||
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
OperationGroupFolder OperationGroup = "folder"
|
||||
OperationGroupPermission OperationGroup = "permission"
|
||||
OperationGroupUserOrgRole OperationGroup = "user_org_role"
|
||||
OperationGroupRoleBinding OperationGroup = "role_binding"
|
||||
)
|
||||
|
||||
func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
|
||||
@@ -63,6 +64,10 @@ func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au
|
||||
if err := s.mutateOrgRoles(ctx, storeInf, operations); err != nil {
|
||||
return nil, fmt.Errorf("failed to mutate org roles: %w", err)
|
||||
}
|
||||
case OperationGroupRoleBinding:
|
||||
if err := s.mutateRoleBindings(ctx, storeInf, operations); err != nil {
|
||||
return nil, fmt.Errorf("failed to mutate role bindings: %w", err)
|
||||
}
|
||||
default:
|
||||
s.logger.Warn("unsupported operation group", "operationGroup", operationGroup)
|
||||
}
|
||||
@@ -79,6 +84,8 @@ func getOperationGroup(operation *authzextv1.MutateOperation) (OperationGroup, e
|
||||
return OperationGroupPermission, nil
|
||||
case *authzextv1.MutateOperation_UpdateUserOrgRole, *authzextv1.MutateOperation_DeleteUserOrgRole, *authzextv1.MutateOperation_AddUserOrgRole:
|
||||
return OperationGroupUserOrgRole, nil
|
||||
case *authzextv1.MutateOperation_CreateRoleBinding, *authzextv1.MutateOperation_DeleteRoleBinding:
|
||||
return OperationGroupRoleBinding, nil
|
||||
}
|
||||
return OperationGroup(""), errors.New("unsupported mutate operation type")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
zanzana "github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func (s *Server) mutateRoleBindings(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error {
|
||||
ctx, span := s.tracer.Start(ctx, "server.mutateRoleBindings")
|
||||
defer span.End()
|
||||
|
||||
writeTuples := make([]*openfgav1.TupleKey, 0)
|
||||
deleteTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0)
|
||||
|
||||
for _, operation := range operations {
|
||||
switch op := operation.Operation.(type) {
|
||||
case *authzextv1.MutateOperation_CreateRoleBinding:
|
||||
r := op.CreateRoleBinding
|
||||
tuple, err := s.getRoleBindingTuple(ctx, r.SubjectKind, r.SubjectName, r.RoleName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeTuples = append(writeTuples, tuple)
|
||||
case *authzextv1.MutateOperation_DeleteRoleBinding:
|
||||
r := op.DeleteRoleBinding
|
||||
tuple, err := s.getRoleBindingTuple(ctx, r.SubjectKind, r.SubjectName, r.RoleName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeTuple := &openfgav1.TupleKeyWithoutCondition{
|
||||
User: tuple.User,
|
||||
Relation: tuple.Relation,
|
||||
Object: tuple.Object,
|
||||
}
|
||||
deleteTuples = append(deleteTuples, writeTuple)
|
||||
default:
|
||||
s.logger.Debug("unsupported mutate operation", "operation", op)
|
||||
}
|
||||
}
|
||||
|
||||
writeReq := &openfgav1.WriteRequest{
|
||||
StoreId: store.ID,
|
||||
AuthorizationModelId: store.ModelID,
|
||||
}
|
||||
if len(writeTuples) > 0 {
|
||||
writeReq.Writes = &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: writeTuples,
|
||||
OnDuplicate: "ignore",
|
||||
}
|
||||
}
|
||||
if len(deleteTuples) > 0 {
|
||||
writeReq.Deletes = &openfgav1.WriteRequestDeletes{
|
||||
TupleKeys: deleteTuples,
|
||||
OnMissing: "ignore",
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.openfga.Write(ctx, writeReq)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to write resource role binding tuples", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getRoleBindingTuple(ctx context.Context, subjectKind string, subjectName string, roleName string) (*openfgav1.TupleKey, error) {
|
||||
zanzanaType := ""
|
||||
subjectRelation := ""
|
||||
|
||||
switch subjectKind {
|
||||
case string(iamv0.RoleBindingSpecSubjectKindUser):
|
||||
zanzanaType = zanzana.TypeUser
|
||||
case string(iamv0.RoleBindingSpecSubjectKindTeam):
|
||||
zanzanaType = zanzana.TypeTeam
|
||||
subjectRelation = zanzana.RelationTeamMember
|
||||
case string(iamv0.RoleBindingSpecSubjectKindServiceAccount):
|
||||
zanzanaType = zanzana.TypeServiceAccount
|
||||
case string(iamv0.RoleBindingSpecSubjectKindBasicRole):
|
||||
zanzanaType = zanzana.TypeRole
|
||||
subjectRelation = zanzana.RelationAssignee
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid subject kind: %s", subjectKind)
|
||||
}
|
||||
|
||||
tuple := &openfgav1.TupleKey{
|
||||
User: zanzana.NewTupleEntry(zanzanaType, subjectName, subjectRelation),
|
||||
Relation: zanzana.RelationAssignee,
|
||||
Object: zanzana.NewTupleEntry(zanzana.TypeRole, roleName, ""),
|
||||
}
|
||||
|
||||
return tuple, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func setupMutateRoleBindings(t *testing.T, srv *Server) *Server {
|
||||
t.Helper()
|
||||
|
||||
// seed tuples
|
||||
tuples := []*openfgav1.TupleKey{
|
||||
common.NewTuple("user:1", common.RelationAssignee, "role:foo_viewer"),
|
||||
}
|
||||
|
||||
return setupOpenFGADatabase(t, srv, tuples)
|
||||
}
|
||||
|
||||
func testMutateRoleBindings(t *testing.T, srv *Server) {
|
||||
setupMutateRoleBindings(t, srv)
|
||||
|
||||
t.Run("should update user role and delete old role", func(t *testing.T) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreateRoleBinding{
|
||||
CreateRoleBinding: &v1.CreateRoleBindingOperation{
|
||||
SubjectKind: "User",
|
||||
SubjectName: "1",
|
||||
RoleKind: "Role",
|
||||
RoleName: "foo_editor",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeleteRoleBinding{
|
||||
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
|
||||
SubjectKind: "User",
|
||||
SubjectName: "1",
|
||||
RoleKind: "Role",
|
||||
RoleName: "foo_viewer",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Relation: common.RelationAssignee,
|
||||
Object: "role:foo_editor",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 1)
|
||||
require.Equal(t, "user:1", res.Tuples[0].Key.User)
|
||||
|
||||
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Relation: common.RelationAssignee,
|
||||
Object: "role:foo_viewer",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 0)
|
||||
})
|
||||
|
||||
t.Run("should assign role to basic role", func(t *testing.T) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreateRoleBinding{
|
||||
CreateRoleBinding: &v1.CreateRoleBindingOperation{
|
||||
SubjectKind: "BasicRole",
|
||||
SubjectName: "basic_viewer",
|
||||
RoleKind: "Role",
|
||||
RoleName: "foo_bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Relation: common.RelationAssignee,
|
||||
Object: "role:foo_bar",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 1)
|
||||
require.Equal(t, "role:basic_viewer#assignee", res.Tuples[0].Key.User)
|
||||
})
|
||||
}
|
||||
@@ -136,6 +136,10 @@ func TestIntegrationServer(t *testing.T) {
|
||||
t.Run("test query folders", func(t *testing.T) {
|
||||
testQueryFolders(t, srv)
|
||||
})
|
||||
|
||||
t.Run("test mutate role bindings", func(t *testing.T) {
|
||||
testMutateRoleBindings(t, srv)
|
||||
})
|
||||
}
|
||||
|
||||
func setupOpenFGAServer(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
|
||||
|
||||
@@ -2009,14 +2009,7 @@ func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Contex
|
||||
request.Limit = query.Limit
|
||||
request.Page = query.Page
|
||||
request.Offset = (query.Page - 1) * query.Limit // only relevant when running in modes 3+
|
||||
request.Fields = append(
|
||||
dashboardsearch.IncludeFields,
|
||||
// Include the dashboard legacy ID in the results, as it is needed when
|
||||
// determining whether a provisioned dashboard exists or not, see
|
||||
// `(*DashboardServiceImpl).searchProvisionedDashboardsThroughK8s`.
|
||||
resource.SEARCH_FIELD_LEGACY_ID,
|
||||
resource.SEARCH_FIELD_LABELS+"."+resource.SEARCH_FIELD_LEGACY_ID,
|
||||
)
|
||||
request.Fields = dashboardsearch.IncludeFields
|
||||
|
||||
namespace := dr.k8sclient.GetNamespace(query.OrgId)
|
||||
var err error
|
||||
|
||||
@@ -40,6 +40,9 @@ var (
|
||||
resource.SEARCH_FIELD_SOURCE_PATH,
|
||||
resource.SEARCH_FIELD_SOURCE_CHECKSUM,
|
||||
resource.SEARCH_FIELD_SOURCE_TIME,
|
||||
// below is needed to determine whether a provisioned dashboard exists or not
|
||||
resource.SEARCH_FIELD_LEGACY_ID,
|
||||
resource.SEARCH_FIELD_LABELS + "." + resource.SEARCH_FIELD_LEGACY_ID,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1801,10 +1801,10 @@ var (
|
||||
{
|
||||
Name: "azureResourcePickerUpdates",
|
||||
Description: "Enables the updated Azure Monitor resource picker",
|
||||
Stage: FeatureStagePublicPreview,
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaPartnerPluginsSquad,
|
||||
Expression: "false",
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "prometheusTypeMigration",
|
||||
@@ -1895,10 +1895,10 @@ var (
|
||||
{
|
||||
Name: "newPanelPadding",
|
||||
Description: "Increases panel padding globally",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: false,
|
||||
Stage: FeatureStagePublicPreview,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
Expression: "false",
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "onlyStoreActionSets",
|
||||
|
||||
Generated
+2
-2
@@ -245,7 +245,7 @@ teamFolders,experimental,@grafana/grafana-search-navigate-organise,false,false,f
|
||||
interactiveLearning,preview,@grafana/pathfinder,false,false,false
|
||||
alertingTriage,experimental,@grafana/alerting-squad,false,false,false
|
||||
graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,false
|
||||
azureResourcePickerUpdates,preview,@grafana/partner-datasources,false,false,true
|
||||
azureResourcePickerUpdates,GA,@grafana/partner-datasources,false,false,true
|
||||
prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false
|
||||
pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false
|
||||
tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false
|
||||
@@ -257,7 +257,7 @@ newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true
|
||||
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
|
||||
jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
|
||||
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
newPanelPadding,experimental,@grafana/dashboards-squad,false,false,false
|
||||
newPanelPadding,preview,@grafana/dashboards-squad,false,false,true
|
||||
onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false
|
||||
panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false
|
||||
kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false
|
||||
|
||||
|
Generated
-4
@@ -742,10 +742,6 @@ const (
|
||||
// Load plugins on store service startup instead of wire provider, and call RegisterFixedRoles after all plugins are loaded
|
||||
FlagPluginStoreServiceLoading = "pluginStoreServiceLoading"
|
||||
|
||||
// FlagNewPanelPadding
|
||||
// Increases panel padding globally
|
||||
FlagNewPanelPadding = "newPanelPadding"
|
||||
|
||||
// FlagOnlyStoreActionSets
|
||||
// When storing dashboard and folder resource permissions, only store action sets and not the full list of underlying permission
|
||||
FlagOnlyStoreActionSets = "onlyStoreActionSets"
|
||||
|
||||
+15
-10
@@ -551,7 +551,6 @@
|
||||
"description": "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"hideFromAdminPage": true,
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
@@ -565,7 +564,6 @@
|
||||
"description": "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"hideFromAdminPage": true,
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
@@ -768,16 +766,19 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "azureResourcePickerUpdates",
|
||||
"resourceVersion": "1763734583253",
|
||||
"resourceVersion": "1764153435365",
|
||||
"creationTimestamp": "2025-07-31T22:56:50Z",
|
||||
"deletionTimestamp": "2025-08-01T11:30:17Z"
|
||||
"deletionTimestamp": "2025-08-01T11:30:17Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-11-26 10:37:15.365919 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the updated Azure Monitor resource picker",
|
||||
"stage": "preview",
|
||||
"stage": "GA",
|
||||
"codeowner": "@grafana/partner-datasources",
|
||||
"frontend": true,
|
||||
"expression": "false"
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2361,14 +2362,18 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "newPanelPadding",
|
||||
"resourceVersion": "1763734583253",
|
||||
"creationTimestamp": "2025-11-12T15:40:46Z"
|
||||
"resourceVersion": "1764168915089",
|
||||
"creationTimestamp": "2025-11-12T15:40:46Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-11-26 14:55:15.089551 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Increases panel padding globally",
|
||||
"stage": "experimental",
|
||||
"stage": "preview",
|
||||
"codeowner": "@grafana/dashboards-squad",
|
||||
"expression": "false"
|
||||
"frontend": true,
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
@@ -128,6 +129,10 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode
|
||||
}
|
||||
|
||||
if include.Type == "page" {
|
||||
if !middleware.PageIsFeatureToggleEnabled(c.Req.Context(), include.Path) {
|
||||
s.log.Debug("Skipping page", "plugin", plugin.ID, "path", include.Path)
|
||||
continue
|
||||
}
|
||||
link := &navtree.NavLink{
|
||||
Text: include.Name,
|
||||
Icon: include.Icon,
|
||||
|
||||
@@ -165,6 +165,21 @@ func (aq *AlertQuery) setMaxDatapoints() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setRefID sets the model refId if it's missing or invalid
|
||||
func (aq *AlertQuery) setRefID() error {
|
||||
if aq.modelProps == nil {
|
||||
err := aq.setModelProps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if refID, ok := aq.modelProps["refId"].(string); !ok || refID != aq.RefID {
|
||||
aq.modelProps["refId"] = aq.RefID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (aq *AlertQuery) GetMaxDatapoints() (int64, error) {
|
||||
err := aq.setMaxDatapoints()
|
||||
if err != nil {
|
||||
@@ -256,6 +271,11 @@ func (aq *AlertQuery) GetModel() ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = aq.setRefID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = aq.setIntervalMS()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -2,6 +2,7 @@ package dashboards
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
@@ -9,10 +10,12 @@ import (
|
||||
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
|
||||
folderV1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/provisioning/utils"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||
)
|
||||
|
||||
@@ -28,7 +31,7 @@ type DashboardProvisioner interface {
|
||||
}
|
||||
|
||||
// DashboardProvisionerFactory creates DashboardProvisioners based on input
|
||||
type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service) (DashboardProvisioner, error)
|
||||
type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, *setting.Cfg, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service, *serverlock.ServerLockService) (DashboardProvisioner, error)
|
||||
|
||||
// Provisioner is responsible for syncing dashboard from disk to Grafana's database.
|
||||
type Provisioner struct {
|
||||
@@ -38,6 +41,8 @@ type Provisioner struct {
|
||||
duplicateValidator duplicateValidator
|
||||
provisioner dashboards.DashboardProvisioningService
|
||||
dual dualwrite.Service
|
||||
serverLock *serverlock.ServerLockService
|
||||
cfg *setting.Cfg
|
||||
}
|
||||
|
||||
func (provider *Provisioner) HasDashboardSources() bool {
|
||||
@@ -45,7 +50,7 @@ func (provider *Provisioner) HasDashboardSources() bool {
|
||||
}
|
||||
|
||||
// New returns a new DashboardProvisioner
|
||||
func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, orgService org.Service, dashboardStore utils.DashboardStore, folderService folder.Service, dual dualwrite.Service) (DashboardProvisioner, error) {
|
||||
func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, cfg *setting.Cfg, orgService org.Service, dashboardStore utils.DashboardStore, folderService folder.Service, dual dualwrite.Service, serverLockService *serverlock.ServerLockService) (DashboardProvisioner, error) {
|
||||
logger := log.New("provisioning.dashboard")
|
||||
cfgReader := &configReader{path: configDirectory, log: logger, orgExists: utils.NewOrgExistsChecker(orgService)}
|
||||
configs, err := cfgReader.readConfig(ctx)
|
||||
@@ -78,6 +83,8 @@ func New(ctx context.Context, configDirectory string, provisioner dashboards.Das
|
||||
duplicateValidator: newDuplicateValidator(logger, fileReaders),
|
||||
provisioner: provisioner,
|
||||
dual: dual,
|
||||
serverLock: serverLockService,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
return d, nil
|
||||
@@ -95,23 +102,53 @@ func (provider *Provisioner) Provision(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
provider.log.Info("starting to provision dashboards")
|
||||
var errProvisioning error
|
||||
|
||||
for _, reader := range provider.fileReaders {
|
||||
if err := reader.walkDisk(ctx); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// don't stop the provisioning service in case the folder is missing. The folder can appear after the startup
|
||||
provider.log.Warn("Failed to provision config", "name", reader.Cfg.Name, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to provision config %v: %w", reader.Cfg.Name, err)
|
||||
// retry obtaining the lock for 20 attempts
|
||||
retryOpt := func(attempts int) error {
|
||||
if attempts < 20 {
|
||||
return nil
|
||||
}
|
||||
return errors.New("retries exhausted")
|
||||
}
|
||||
|
||||
provider.duplicateValidator.validate()
|
||||
provider.log.Info("finished to provision dashboards")
|
||||
return nil
|
||||
lockTimeConfig := serverlock.LockTimeConfig{
|
||||
// if a replica crashes while holding the lock, other replicas can obtain the
|
||||
// lock after this duration (15s default value, might be configured via config file)
|
||||
MaxInterval: time.Duration(provider.cfg.ClassicProvisioningDashboardsServerLockMaxIntervalSeconds) * time.Second,
|
||||
|
||||
// wait beetween 100ms and 1s before retrying to obtain the lock (default values, might be configured via config file)
|
||||
MinWait: time.Duration(provider.cfg.ClassicProvisioningDashboardsServerLockMinWaitMs) * time.Millisecond,
|
||||
MaxWait: time.Duration(provider.cfg.ClassicProvisioningDashboardsServerLockMaxWaitMs) * time.Millisecond,
|
||||
}
|
||||
|
||||
// this means that if we fail to obtain the lock after ~10 seconds, we return an error
|
||||
lockErr := provider.serverLock.LockExecuteAndReleaseWithRetries(ctx, "provisioning_dashboards", lockTimeConfig, func(ctx context.Context) {
|
||||
provider.log.Info("starting to provision dashboards")
|
||||
|
||||
for _, reader := range provider.fileReaders {
|
||||
if err := reader.walkDisk(ctx); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// don't stop the provisioning service in case the folder is missing. The folder can appear after the startup
|
||||
provider.log.Warn("Failed to provision config", "name", reader.Cfg.Name, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
errProvisioning = fmt.Errorf("failed to provision config %v: %w", reader.Cfg.Name, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
provider.duplicateValidator.validate()
|
||||
provider.log.Info("finished to provision dashboards")
|
||||
}, retryOpt)
|
||||
|
||||
if lockErr != nil {
|
||||
provider.log.Error("Failed to obtain dashboard provisioning lock", "error", lockErr)
|
||||
return lockErr
|
||||
}
|
||||
|
||||
return errProvisioning
|
||||
}
|
||||
|
||||
// CleanUpOrphanedDashboards deletes provisioned dashboards missing a linked reader.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/grafana/dskit/services"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
@@ -64,6 +65,7 @@ func ProvideService(
|
||||
tracer tracing.Tracer,
|
||||
dual dualwrite.Service,
|
||||
promTypeMigrationProvider promtypemigration.PromTypeMigrationProvider,
|
||||
serverLockService *serverlock.ServerLockService,
|
||||
) (*ProvisioningServiceImpl, error) {
|
||||
s := &ProvisioningServiceImpl{
|
||||
Cfg: cfg,
|
||||
@@ -92,6 +94,7 @@ func ProvideService(
|
||||
tracer: tracer,
|
||||
migratePrometheusType: promTypeMigrationProvider.Run,
|
||||
dual: dual,
|
||||
serverLock: serverLockService,
|
||||
}
|
||||
|
||||
s.NamedService = services.NewBasicService(s.starting, s.running, nil).WithName(ServiceName)
|
||||
@@ -166,7 +169,7 @@ func (ps *ProvisioningServiceImpl) running(ctx context.Context) error {
|
||||
|
||||
func (ps *ProvisioningServiceImpl) setDashboardProvisioner() error {
|
||||
dashboardPath := filepath.Join(ps.Cfg.ProvisioningPath, "dashboards")
|
||||
dashProvisioner, err := ps.newDashboardProvisioner(context.Background(), dashboardPath, ps.dashboardProvisioningService, ps.orgService, ps.dashboardService, ps.folderService, ps.dual)
|
||||
dashProvisioner, err := ps.newDashboardProvisioner(context.Background(), dashboardPath, ps.dashboardProvisioningService, ps.Cfg, ps.orgService, ps.dashboardService, ps.folderService, ps.dual, ps.serverLock)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %w", "Failed to create provisioner", err)
|
||||
}
|
||||
@@ -242,6 +245,7 @@ type ProvisioningServiceImpl struct {
|
||||
resourcePermissions accesscontrol.ReceiverPermissionsService
|
||||
tracer tracing.Tracer
|
||||
dual dualwrite.Service
|
||||
serverLock *serverlock.ServerLockService
|
||||
migratePrometheusType func(context.Context) error
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||
dashboardstore "github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/provisioning/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/provisioning/utils"
|
||||
"github.com/grafana/grafana/pkg/services/searchV2"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||
)
|
||||
|
||||
@@ -160,7 +162,7 @@ func setup(t *testing.T) *serviceTestStruct {
|
||||
searchStub := searchV2.NewStubSearchService()
|
||||
|
||||
service, err := newProvisioningServiceImpl(
|
||||
func(context.Context, string, dashboardstore.DashboardProvisioningService, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service) (dashboards.DashboardProvisioner, error) {
|
||||
func(context.Context, string, dashboardstore.DashboardProvisioningService, *setting.Cfg, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service, *serverlock.ServerLockService) (dashboards.DashboardProvisioner, error) {
|
||||
serviceTest.dashboardProvisionerInstantiations++
|
||||
return serviceTest.mock, nil
|
||||
},
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"gopkg.in/ini.v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
utilnet "k8s.io/apimachinery/pkg/util/net"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/client-go/dynamic"
|
||||
clientrest "k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/transport"
|
||||
|
||||
authlib "github.com/grafana/authlib/authn"
|
||||
logging "github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/semconv"
|
||||
)
|
||||
|
||||
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/setting")
|
||||
|
||||
const LogPrefix = "setting.service"
|
||||
|
||||
const DefaultPageSize = int64(500)
|
||||
const DefaultQPS = float32(10)
|
||||
const DefaultBurst = 25
|
||||
|
||||
const (
|
||||
ApiGroup = "setting.grafana.app"
|
||||
apiVersion = "v0alpha1"
|
||||
resource = "settings"
|
||||
kind = "Setting"
|
||||
listKind = "SettingList"
|
||||
)
|
||||
|
||||
var settingGroupVersion = schema.GroupVersionResource{
|
||||
Group: ApiGroup,
|
||||
Version: apiVersion,
|
||||
Resource: resource,
|
||||
}
|
||||
|
||||
var settingGroupListKind = map[schema.GroupVersionResource]string{
|
||||
settingGroupVersion: listKind,
|
||||
}
|
||||
|
||||
type remoteSettingServiceMetrics struct {
|
||||
listDuration *prometheus.HistogramVec
|
||||
listResultSize *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
// Service retrieves configuration settings from a remote settings service.
|
||||
//
|
||||
// The service uses label selectors to filter settings. Settings are labeled with
|
||||
// "section" and "key" labels matching their spec fields.
|
||||
//
|
||||
// Example - Select all settings:
|
||||
//
|
||||
// ctx := request.WithNamespace(context.Background(), "my-namespace")
|
||||
// ini, err := service.ListAsIni(ctx, metav1.LabelSelector{})
|
||||
//
|
||||
// Example - Select settings from specific sections:
|
||||
//
|
||||
// selector := metav1.LabelSelector{
|
||||
// MatchExpressions: []metav1.LabelSelectorRequirement{
|
||||
// {
|
||||
// Key: "section",
|
||||
// Operator: metav1.LabelSelectorOpIn,
|
||||
// Values: []string{"database", "server"},
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
// ini, err := service.ListAsIni(ctx, selector)
|
||||
//
|
||||
// Example - Select settings from a single section with specific labels:
|
||||
//
|
||||
// selector := metav1.LabelSelector{
|
||||
// MatchLabels: map[string]string{
|
||||
// "section": "database",
|
||||
// },
|
||||
// }
|
||||
// settings, err := service.List(ctx, selector)
|
||||
type Service interface {
|
||||
prometheus.Collector
|
||||
// ListAsIni retrieves settings filtered by a label selector from the namespace in context
|
||||
// and returns them as an ini.File.
|
||||
//
|
||||
// The namespace must be present in the context, ie: via request.WithNamespace.
|
||||
// An empty selector returns all settings in the namespace.
|
||||
ListAsIni(ctx context.Context, selector metav1.LabelSelector) (*ini.File, error)
|
||||
|
||||
// List retrieves settings filtered by a label selector from the namespace in context
|
||||
// and returns them as a slice of Setting structs.
|
||||
//
|
||||
// The namespace must be present in the context, ie: via request.WithNamespace.
|
||||
// An empty selector returns all settings in the namespace.
|
||||
List(ctx context.Context, selector metav1.LabelSelector) ([]*Setting, error)
|
||||
}
|
||||
|
||||
type remoteSettingService struct {
|
||||
dynamicClient dynamic.Interface
|
||||
log logging.Logger
|
||||
pageSize int64
|
||||
metrics remoteSettingServiceMetrics
|
||||
}
|
||||
|
||||
var _ Service = (*remoteSettingService)(nil)
|
||||
var _ prometheus.Collector = (*remoteSettingService)(nil)
|
||||
|
||||
// Config configures a Service.
|
||||
type Config struct {
|
||||
// URL is the base URL for the remote settings service (required).
|
||||
URL string
|
||||
// TokenExchangeClient authenticates requests (required if WrapTransport is not set).
|
||||
TokenExchangeClient *authlib.TokenExchangeClient
|
||||
// WrapTransport wraps the HTTP transport for authentication.
|
||||
// Takes precedence over TokenExchangeClient when both are set.
|
||||
// At least one of WrapTransport or TokenExchangeClient is required.
|
||||
WrapTransport transport.WrapperFunc
|
||||
// TLSClientConfig configures TLS for the client connection.
|
||||
TLSClientConfig clientrest.TLSClientConfig
|
||||
// QPS limits requests per second (defaults to DefaultQPS).
|
||||
QPS float32
|
||||
// Burst allows request bursts above QPS (defaults to DefaultBurst).
|
||||
Burst int
|
||||
// PageSize sets the number of items per API page (defaults to DefaultPageSize).
|
||||
PageSize int64
|
||||
}
|
||||
|
||||
// Setting represents the parsed spec of a Setting resource.
|
||||
type Setting struct {
|
||||
// Setting section
|
||||
Section string `json:"section"`
|
||||
// Setting key
|
||||
Key string `json:"key"`
|
||||
// Setting value
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// New creates a Service from the provided configuration.
|
||||
func New(config Config) (Service, error) {
|
||||
log := logging.New(LogPrefix)
|
||||
dynamicClient, err := getDynamicClient(config, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pageSize := DefaultPageSize
|
||||
if config.PageSize > 0 {
|
||||
pageSize = config.PageSize
|
||||
}
|
||||
|
||||
metrics := initMetrics()
|
||||
|
||||
return &remoteSettingService{
|
||||
dynamicClient: dynamicClient,
|
||||
pageSize: pageSize,
|
||||
log: log,
|
||||
metrics: metrics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *remoteSettingService) ListAsIni(ctx context.Context, labelSelector metav1.LabelSelector) (*ini.File, error) {
|
||||
namespace, ok := request.NamespaceFrom(ctx)
|
||||
ns := semconv.GrafanaNamespaceName(namespace)
|
||||
ctx, span := tracer.Start(ctx, "remoteSettingService.ListAsIni",
|
||||
trace.WithAttributes(ns))
|
||||
defer span.End()
|
||||
|
||||
if !ok || namespace == "" {
|
||||
return nil, tracing.Errorf(span, "missing namespace in context")
|
||||
}
|
||||
|
||||
settings, err := m.List(ctx, labelSelector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iniFile, err := m.toIni(settings)
|
||||
if err != nil {
|
||||
return nil, tracing.Error(span, err)
|
||||
}
|
||||
return iniFile, nil
|
||||
}
|
||||
|
||||
func (m *remoteSettingService) List(ctx context.Context, labelSelector metav1.LabelSelector) ([]*Setting, error) {
|
||||
namespace, ok := request.NamespaceFrom(ctx)
|
||||
ns := semconv.GrafanaNamespaceName(namespace)
|
||||
ctx, span := tracer.Start(ctx, "remoteSettingService.List",
|
||||
trace.WithAttributes(ns))
|
||||
defer span.End()
|
||||
if !ok || namespace == "" {
|
||||
return nil, tracing.Errorf(span, "missing namespace in context")
|
||||
}
|
||||
log := m.log.FromContext(ctx).New(ns.Key, ns.Value, "function", "remoteSettingService.List", "traceId", span.SpanContext().TraceID())
|
||||
|
||||
startTime := time.Now()
|
||||
var status string
|
||||
defer func() {
|
||||
duration := time.Since(startTime).Seconds()
|
||||
m.metrics.listDuration.WithLabelValues(status).Observe(duration)
|
||||
}()
|
||||
|
||||
selector, err := metav1.LabelSelectorAsSelector(&labelSelector)
|
||||
if err != nil {
|
||||
status = "error"
|
||||
return nil, tracing.Error(span, err)
|
||||
}
|
||||
if selector.Empty() {
|
||||
log.Debug("empty selector. Fetching all settings")
|
||||
}
|
||||
|
||||
var allSettings []*Setting
|
||||
var continueToken string
|
||||
hasNext := true
|
||||
totalPages := 0
|
||||
// Using an upper limit to prevent infinite loops
|
||||
for hasNext && totalPages < 1000 {
|
||||
totalPages++
|
||||
opts := metav1.ListOptions{
|
||||
Limit: m.pageSize,
|
||||
Continue: continueToken,
|
||||
}
|
||||
if !selector.Empty() {
|
||||
opts.LabelSelector = selector.String()
|
||||
}
|
||||
|
||||
settingsList, lErr := m.dynamicClient.Resource(settingGroupVersion).Namespace(namespace).List(ctx, opts)
|
||||
if lErr != nil {
|
||||
status = "error"
|
||||
return nil, tracing.Error(span, lErr)
|
||||
}
|
||||
for i := range settingsList.Items {
|
||||
setting, pErr := parseSettingResource(&settingsList.Items[i])
|
||||
if pErr != nil {
|
||||
status = "error"
|
||||
return nil, tracing.Error(span, pErr)
|
||||
}
|
||||
allSettings = append(allSettings, setting)
|
||||
}
|
||||
continueToken = settingsList.GetContinue()
|
||||
if continueToken == "" {
|
||||
hasNext = false
|
||||
}
|
||||
}
|
||||
|
||||
status = "success"
|
||||
m.metrics.listResultSize.WithLabelValues(status).Observe(float64(len(allSettings)))
|
||||
|
||||
return allSettings, nil
|
||||
}
|
||||
|
||||
func parseSettingResource(setting *unstructured.Unstructured) (*Setting, error) {
|
||||
spec, found, err := unstructured.NestedMap(setting.Object, "spec")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get spec from setting: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("spec not found in setting %s", setting.GetName())
|
||||
}
|
||||
|
||||
var result Setting
|
||||
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(spec, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to convert spec to Setting: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (m *remoteSettingService) toIni(settings []*Setting) (*ini.File, error) {
|
||||
conf := ini.Empty()
|
||||
for _, setting := range settings {
|
||||
if !conf.HasSection(setting.Section) {
|
||||
_, _ = conf.NewSection(setting.Section)
|
||||
}
|
||||
_, err := conf.Section(setting.Section).NewKey(setting.Key, setting.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
func getDynamicClient(config Config, log logging.Logger) (dynamic.Interface, error) {
|
||||
if config.URL == "" {
|
||||
return nil, fmt.Errorf("URL cannot be empty")
|
||||
}
|
||||
if config.WrapTransport == nil && config.TokenExchangeClient == nil {
|
||||
return nil, fmt.Errorf("must set either TokenExchangeClient or WrapTransport")
|
||||
}
|
||||
|
||||
wrapTransport := config.WrapTransport
|
||||
if config.WrapTransport == nil {
|
||||
log.Debug("using default wrapTransport with TokenExchangeClient")
|
||||
wrapTransport = func(rt http.RoundTripper) http.RoundTripper {
|
||||
return &authRoundTripper{
|
||||
tokenClient: config.TokenExchangeClient,
|
||||
transport: rt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
qps := DefaultQPS
|
||||
if config.QPS > 0 {
|
||||
qps = config.QPS
|
||||
}
|
||||
|
||||
burst := DefaultBurst
|
||||
if config.Burst > 0 {
|
||||
burst = config.Burst
|
||||
}
|
||||
|
||||
return dynamic.NewForConfig(&clientrest.Config{
|
||||
Host: config.URL,
|
||||
WrapTransport: wrapTransport,
|
||||
TLSClientConfig: config.TLSClientConfig,
|
||||
QPS: qps,
|
||||
Burst: burst,
|
||||
})
|
||||
}
|
||||
|
||||
// authRoundTripper wraps an HTTP transport with token-based authentication.
|
||||
type authRoundTripper struct {
|
||||
tokenClient *authlib.TokenExchangeClient
|
||||
transport http.RoundTripper
|
||||
}
|
||||
|
||||
var _ http.RoundTripper = (*authRoundTripper)(nil)
|
||||
|
||||
func (a *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
token, err := a.tokenClient.Exchange(req.Context(), authlib.TokenExchangeRequest{
|
||||
Audiences: []string{ApiGroup},
|
||||
Namespace: "*",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange token: %w", err)
|
||||
}
|
||||
req = utilnet.CloneRequest(req)
|
||||
|
||||
req.Header.Set("X-Access-Token", fmt.Sprintf("Bearer %s", token.Token))
|
||||
return a.transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
func initMetrics() remoteSettingServiceMetrics {
|
||||
metrics := remoteSettingServiceMetrics{
|
||||
listDuration: prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Namespace: "settings",
|
||||
Subsystem: "service",
|
||||
Name: "list_settings_duration_seconds",
|
||||
Help: "Duration of remote settings service List operations",
|
||||
NativeHistogramBucketFactor: 1.1,
|
||||
},
|
||||
[]string{"status"}, // status: "success" or "error"
|
||||
),
|
||||
listResultSize: prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Namespace: "settings",
|
||||
Subsystem: "service",
|
||||
Name: "list_settings_result_size",
|
||||
Help: "Number of settings returned by remote settings service List operations",
|
||||
NativeHistogramBucketFactor: 1.1,
|
||||
},
|
||||
[]string{"status"}, // status: "success" or "error"
|
||||
),
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func (m *remoteSettingService) Describe(descs chan<- *prometheus.Desc) {
|
||||
m.metrics.listDuration.Describe(descs)
|
||||
m.metrics.listResultSize.Describe(descs)
|
||||
}
|
||||
|
||||
func (m *remoteSettingService) Collect(metrics chan<- prometheus.Metric) {
|
||||
m.metrics.listDuration.Collect(metrics)
|
||||
m.metrics.listResultSize.Collect(metrics)
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/client-go/dynamic/fake"
|
||||
k8testing "k8s.io/client-go/testing"
|
||||
|
||||
authlib "github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
)
|
||||
|
||||
func TestRemoteSettingService_ListAsIni(t *testing.T) {
|
||||
t.Run("should filter settings by label selector", func(t *testing.T) {
|
||||
// Create multiple settings, only some matching the selector
|
||||
setting1 := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "type", Value: "postgres"})
|
||||
setting2 := newUnstructuredSetting("test-namespace", Setting{Section: "server", Key: "port", Value: "3000"})
|
||||
setting3 := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "host", Value: "localhost"})
|
||||
|
||||
client := newTestClient(500, setting1, setting2, setting3)
|
||||
|
||||
// Create a selector that should match only database settings
|
||||
selector := metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"section": "database",
|
||||
},
|
||||
}
|
||||
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
result, err := client.ListAsIni(ctx, selector)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
// Should only have database settings, not server settings
|
||||
assert.True(t, result.HasSection("database"))
|
||||
assert.Equal(t, "postgres", result.Section("database").Key("type").String())
|
||||
assert.Equal(t, "localhost", result.Section("database").Key("host").String())
|
||||
// Should NOT have server settings
|
||||
assert.False(t, result.HasSection("server"))
|
||||
})
|
||||
|
||||
t.Run("should return all settings with empty selector", func(t *testing.T) {
|
||||
// Create multiple settings across different sections
|
||||
setting1 := newUnstructuredSetting("test-namespace", Setting{Section: "server", Key: "port", Value: "3000"})
|
||||
setting2 := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "type", Value: "mysql"})
|
||||
|
||||
client := newTestClient(500, setting1, setting2)
|
||||
|
||||
// Empty selector should select everything
|
||||
selector := metav1.LabelSelector{}
|
||||
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
result, err := client.ListAsIni(ctx, selector)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
// Should have all settings from all sections
|
||||
assert.True(t, result.HasSection("server"))
|
||||
assert.Equal(t, "3000", result.Section("server").Key("port").String())
|
||||
assert.True(t, result.HasSection("database"))
|
||||
assert.Equal(t, "mysql", result.Section("database").Key("type").String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoteSettingService_List(t *testing.T) {
|
||||
t.Run("should handle single page response", func(t *testing.T) {
|
||||
setting := newUnstructuredSetting("test-namespace", Setting{Section: "server", Key: "port", Value: "3000"})
|
||||
|
||||
client := newTestClient(500, setting)
|
||||
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
result, err := client.List(ctx, metav1.LabelSelector{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 1)
|
||||
|
||||
spec := result[0]
|
||||
assert.Equal(t, "server", spec.Section)
|
||||
assert.Equal(t, "port", spec.Key)
|
||||
assert.Equal(t, "3000", spec.Value)
|
||||
})
|
||||
|
||||
t.Run("should handle multiple pages", func(t *testing.T) {
|
||||
totalPages := 3
|
||||
pageSize := 5
|
||||
|
||||
pages := make([][]*unstructured.Unstructured, totalPages)
|
||||
for pageNum := 0; pageNum < totalPages; pageNum++ {
|
||||
for idx := 0; idx < pageSize; idx++ {
|
||||
item := newUnstructuredSetting(
|
||||
"test-namespace",
|
||||
Setting{
|
||||
Section: fmt.Sprintf("section-%d", pageNum),
|
||||
Key: fmt.Sprintf("key-%d", idx),
|
||||
Value: fmt.Sprintf("val-%d-%d", pageNum, idx),
|
||||
},
|
||||
)
|
||||
pages[pageNum] = append(pages[pageNum], item)
|
||||
}
|
||||
}
|
||||
|
||||
scheme := runtime.NewScheme()
|
||||
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind)
|
||||
listCallCount := 0
|
||||
dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) {
|
||||
listCallCount++
|
||||
|
||||
continueToken := fmt.Sprintf("continue-%d", listCallCount)
|
||||
if listCallCount == totalPages {
|
||||
continueToken = ""
|
||||
}
|
||||
|
||||
if listCallCount <= totalPages {
|
||||
list := &unstructured.UnstructuredList{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": ApiGroup + "/" + apiVersion,
|
||||
"kind": listKind,
|
||||
},
|
||||
}
|
||||
list.SetContinue(continueToken)
|
||||
for _, item := range pages[listCallCount-1] {
|
||||
list.Items = append(list.Items, *item)
|
||||
}
|
||||
return true, list, nil
|
||||
}
|
||||
|
||||
return false, nil, nil
|
||||
})
|
||||
|
||||
client := &remoteSettingService{
|
||||
dynamicClient: dynamicClient,
|
||||
pageSize: int64(pageSize),
|
||||
log: log.NewNopLogger(),
|
||||
metrics: initMetrics(),
|
||||
}
|
||||
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
result, err := client.List(ctx, metav1.LabelSelector{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, totalPages*pageSize)
|
||||
assert.Equal(t, totalPages, listCallCount)
|
||||
})
|
||||
|
||||
t.Run("should pass label selector when provided", func(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind)
|
||||
dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) {
|
||||
listAction := action.(k8testing.ListActionImpl)
|
||||
assert.Equal(t, "app=grafana", listAction.ListOptions.LabelSelector)
|
||||
return true, &unstructured.UnstructuredList{}, nil
|
||||
})
|
||||
|
||||
client := &remoteSettingService{
|
||||
dynamicClient: dynamicClient,
|
||||
pageSize: 500,
|
||||
log: log.NewNopLogger(),
|
||||
metrics: initMetrics(),
|
||||
}
|
||||
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
_, err := client.List(ctx, metav1.LabelSelector{MatchLabels: map[string]string{"app": "grafana"}})
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("should stop pagination at 1000 pages", func(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind)
|
||||
listCallCount := 0
|
||||
dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) {
|
||||
listCallCount++
|
||||
// Always return a continue token to simulate infinite pagination
|
||||
list := &unstructured.UnstructuredList{}
|
||||
list.SetContinue("continue-forever")
|
||||
return true, list, nil
|
||||
})
|
||||
|
||||
client := &remoteSettingService{
|
||||
dynamicClient: dynamicClient,
|
||||
pageSize: 10,
|
||||
log: log.NewNopLogger(),
|
||||
metrics: initMetrics(),
|
||||
}
|
||||
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
_, err := client.List(ctx, metav1.LabelSelector{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1000, listCallCount, "Should stop at 1000 pages to prevent infinite loops")
|
||||
})
|
||||
|
||||
t.Run("should return error when parsing setting fails", func(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind)
|
||||
dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) {
|
||||
// Return a malformed setting without spec
|
||||
list := &unstructured.UnstructuredList{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": ApiGroup + "/" + apiVersion,
|
||||
"kind": listKind,
|
||||
},
|
||||
}
|
||||
malformedSetting := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": ApiGroup + "/" + apiVersion,
|
||||
"kind": kind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "malformed",
|
||||
"namespace": "test-namespace",
|
||||
},
|
||||
// Missing spec
|
||||
},
|
||||
}
|
||||
list.Items = append(list.Items, *malformedSetting)
|
||||
return true, list, nil
|
||||
})
|
||||
|
||||
client := &remoteSettingService{
|
||||
dynamicClient: dynamicClient,
|
||||
pageSize: 500,
|
||||
log: log.NewNopLogger(),
|
||||
metrics: initMetrics(),
|
||||
}
|
||||
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
result, err := client.List(ctx, metav1.LabelSelector{})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "spec not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseSettingResource(t *testing.T) {
|
||||
t.Run("should parse valid setting resource", func(t *testing.T) {
|
||||
setting := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "type", Value: "postgres"})
|
||||
|
||||
result, err := parseSettingResource(setting)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "database", result.Section)
|
||||
assert.Equal(t, "type", result.Key)
|
||||
assert.Equal(t, "postgres", result.Value)
|
||||
})
|
||||
|
||||
t.Run("should return error when spec is missing", func(t *testing.T) {
|
||||
setting := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": ApiGroup + "/" + apiVersion,
|
||||
"kind": kind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "test-setting",
|
||||
"namespace": "test-namespace",
|
||||
},
|
||||
// No spec
|
||||
},
|
||||
}
|
||||
|
||||
result, err := parseSettingResource(setting)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "spec not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoteSettingService_ToIni(t *testing.T) {
|
||||
t.Run("should convert settings to ini format", func(t *testing.T) {
|
||||
settings := []*Setting{
|
||||
{Section: "database", Key: "type", Value: "postgres"},
|
||||
{Section: "database", Key: "host", Value: "localhost"},
|
||||
{Section: "server", Key: "http_port", Value: "3000"},
|
||||
}
|
||||
|
||||
client := &remoteSettingService{
|
||||
pageSize: 500,
|
||||
log: log.NewNopLogger(),
|
||||
}
|
||||
|
||||
result, err := client.toIni(settings)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.HasSection("database"))
|
||||
assert.True(t, result.HasSection("server"))
|
||||
assert.Equal(t, "postgres", result.Section("database").Key("type").String())
|
||||
assert.Equal(t, "localhost", result.Section("database").Key("host").String())
|
||||
assert.Equal(t, "3000", result.Section("server").Key("http_port").String())
|
||||
})
|
||||
|
||||
t.Run("should handle empty settings list", func(t *testing.T) {
|
||||
var settings []*Setting
|
||||
|
||||
client := &remoteSettingService{
|
||||
pageSize: 500,
|
||||
log: log.NewNopLogger(),
|
||||
}
|
||||
|
||||
result, err := client.toIni(settings)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
sections := result.Sections()
|
||||
assert.Len(t, sections, 1) // Only default section
|
||||
})
|
||||
|
||||
t.Run("should create section if it does not exist", func(t *testing.T) {
|
||||
settings := []*Setting{
|
||||
{Section: "new_section", Key: "new_key", Value: "new_value"},
|
||||
}
|
||||
|
||||
client := &remoteSettingService{
|
||||
pageSize: 500,
|
||||
log: log.NewNopLogger(),
|
||||
}
|
||||
|
||||
result, err := client.toIni(settings)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.HasSection("new_section"))
|
||||
assert.Equal(t, "new_value", result.Section("new_section").Key("new_key").String())
|
||||
})
|
||||
|
||||
t.Run("should handle multiple keys in same section", func(t *testing.T) {
|
||||
settings := []*Setting{
|
||||
{Section: "auth", Key: "disable_login_form", Value: "false"},
|
||||
{Section: "auth", Key: "disable_signout_menu", Value: "true"},
|
||||
}
|
||||
|
||||
client := &remoteSettingService{
|
||||
pageSize: 500,
|
||||
log: log.NewNopLogger(),
|
||||
}
|
||||
|
||||
result, err := client.toIni(settings)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.HasSection("auth"))
|
||||
authSection := result.Section("auth")
|
||||
assert.Equal(t, "false", authSection.Key("disable_login_form").String())
|
||||
assert.Equal(t, "true", authSection.Key("disable_signout_menu").String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Run("should create client with default page size", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
|
||||
}
|
||||
|
||||
client, err := New(config)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
remoteClient := client.(*remoteSettingService)
|
||||
assert.Equal(t, DefaultPageSize, remoteClient.pageSize)
|
||||
})
|
||||
|
||||
t.Run("should create client with custom page size", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
|
||||
PageSize: 100,
|
||||
}
|
||||
|
||||
client, err := New(config)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
remoteClient := client.(*remoteSettingService)
|
||||
assert.Equal(t, int64(100), remoteClient.pageSize)
|
||||
})
|
||||
|
||||
t.Run("should use default page size when zero is provided", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
|
||||
PageSize: 0,
|
||||
}
|
||||
|
||||
client, err := New(config)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
remoteClient := client.(*remoteSettingService)
|
||||
assert.Equal(t, DefaultPageSize, remoteClient.pageSize)
|
||||
})
|
||||
|
||||
t.Run("should return error when config is invalid", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "", // Invalid: empty URL
|
||||
}
|
||||
|
||||
client, err := New(config)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, client)
|
||||
assert.Contains(t, err.Error(), "URL cannot be empty")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetDynamicClient(t *testing.T) {
|
||||
logger := log.NewNopLogger()
|
||||
|
||||
t.Run("should return error when SettingServiceURL is empty", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "",
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
|
||||
}
|
||||
|
||||
client, err := getDynamicClient(config, logger)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, client)
|
||||
assert.Contains(t, err.Error(), "URL cannot be empty")
|
||||
})
|
||||
|
||||
t.Run("should return error when both TokenExchangeClient and WrapTransport are nil", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
TokenExchangeClient: nil,
|
||||
WrapTransport: nil,
|
||||
}
|
||||
|
||||
client, err := getDynamicClient(config, logger)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, client)
|
||||
assert.Contains(t, err.Error(), "must set either TokenExchangeClient or WrapTransport")
|
||||
})
|
||||
|
||||
t.Run("should create client with WrapTransport", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
|
||||
}
|
||||
|
||||
client, err := getDynamicClient(config, logger)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
})
|
||||
|
||||
t.Run("should not fail when QPS and Burst are not provided", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
|
||||
}
|
||||
|
||||
client, err := getDynamicClient(config, logger)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
})
|
||||
|
||||
t.Run("should not fail when custom QPS and Burst are provided", func(t *testing.T) {
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
|
||||
QPS: 10.0,
|
||||
Burst: 20,
|
||||
}
|
||||
|
||||
client, err := getDynamicClient(config, logger)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
})
|
||||
|
||||
t.Run("should use WrapTransport when both WrapTransport and TokenExchangeClient are provided", func(t *testing.T) {
|
||||
wrapTransportCalled := false
|
||||
tokenExchangeClient := &authlib.TokenExchangeClient{}
|
||||
|
||||
config := Config{
|
||||
URL: "https://example.com",
|
||||
TokenExchangeClient: tokenExchangeClient,
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper {
|
||||
wrapTransportCalled = true
|
||||
return rt
|
||||
},
|
||||
}
|
||||
|
||||
client, err := getDynamicClient(config, logger)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
assert.True(t, wrapTransportCalled, "WrapTransport should be called and take precedence over TokenExchangeClient")
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to create an unstructured Setting object for tests
|
||||
func newUnstructuredSetting(namespace string, spec Setting) *unstructured.Unstructured {
|
||||
// Generate resource name in the format {section}--{key}
|
||||
name := fmt.Sprintf("%s--%s", spec.Section, spec.Key)
|
||||
|
||||
obj := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": ApiGroup + "/" + apiVersion,
|
||||
"kind": kind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": name,
|
||||
"namespace": namespace,
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"section": spec.Section,
|
||||
"key": spec.Key,
|
||||
"value": spec.Value,
|
||||
},
|
||||
},
|
||||
}
|
||||
// Always set section and key labels
|
||||
obj.SetLabels(map[string]string{
|
||||
"section": spec.Section,
|
||||
"key": spec.Key,
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
// Helper function to create a test client with the dynamic fake client
|
||||
func newTestClient(pageSize int64, objects ...runtime.Object) *remoteSettingService {
|
||||
scheme := runtime.NewScheme()
|
||||
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind, objects...)
|
||||
|
||||
return &remoteSettingService{
|
||||
dynamicClient: dynamicClient,
|
||||
pageSize: pageSize,
|
||||
log: log.NewNopLogger(),
|
||||
metrics: initMetrics(),
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,11 @@ type Cfg struct {
|
||||
PluginsPath string
|
||||
EnterpriseLicensePath string
|
||||
|
||||
// Classic Provisioning settings
|
||||
ClassicProvisioningDashboardsServerLockMaxIntervalSeconds int64
|
||||
ClassicProvisioningDashboardsServerLockMinWaitMs int64
|
||||
ClassicProvisioningDashboardsServerLockMaxWaitMs int64
|
||||
|
||||
// SMTP email settings
|
||||
Smtp SmtpSettings
|
||||
|
||||
@@ -1221,6 +1226,8 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.readClassicProvisioningSettings(iniFile)
|
||||
|
||||
// read dashboard settings
|
||||
dashboards := iniFile.Section("dashboards")
|
||||
cfg.DashboardVersionsToKeep = dashboards.Key("versions_to_keep").MustInt(20)
|
||||
@@ -2107,6 +2114,12 @@ func (cfg *Cfg) readLiveSettings(iniFile *ini.File) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Cfg) readClassicProvisioningSettings(iniFile *ini.File) {
|
||||
cfg.ClassicProvisioningDashboardsServerLockMinWaitMs = iniFile.Section("classic_provisioning").Key("dashboards_server_lock_min_wait_ms").MustInt64(100)
|
||||
cfg.ClassicProvisioningDashboardsServerLockMaxWaitMs = iniFile.Section("classic_provisioning").Key("dashboards_server_lock_max_wait_ms").MustInt64(1000)
|
||||
cfg.ClassicProvisioningDashboardsServerLockMaxIntervalSeconds = iniFile.Section("classic_provisioning").Key("dashboards_server_lock_max_interval_seconds").MustInt64(15)
|
||||
}
|
||||
|
||||
func (cfg *Cfg) readProvisioningSettings(iniFile *ini.File) error {
|
||||
provisioning := valueAsString(iniFile.Section("paths"), "provisioning", "")
|
||||
cfg.ProvisioningPath = makeAbsolute(provisioning, cfg.HomePath)
|
||||
|
||||
@@ -618,7 +618,7 @@ func TestSecureValueServiceExampleBased(t *testing.T) {
|
||||
}, "actor-uid")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sut.SecureValueService.SetKeeperAsActive(t.Context(), xkube.Namespace(k1.Namespace), k1.Name))
|
||||
require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(k1.Namespace), k1.Name))
|
||||
|
||||
value := secretv1beta1.NewExposedSecureValue("v1")
|
||||
sv1, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(&secretv1beta1.SecureValue{
|
||||
@@ -643,7 +643,7 @@ func TestSecureValueServiceExampleBased(t *testing.T) {
|
||||
},
|
||||
}, "actor-uid")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, sut.SecureValueService.SetKeeperAsActive(t.Context(), xkube.Namespace(k2.Namespace), k2.Name))
|
||||
require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(k2.Namespace), k2.Name))
|
||||
|
||||
// - Read secure value created with inactive keeper
|
||||
readSv, err := sut.SecureValueService.Read(t.Context(), xkube.Namespace(sv1.Namespace), sv1.Name)
|
||||
|
||||
@@ -202,30 +202,58 @@ then run:
|
||||
kubectl --kubeconfig=./grafana.kubeconfig create -f folder-generate.yaml
|
||||
```
|
||||
|
||||
### Run as a GRPC service
|
||||
### Run as a separate GRPC service
|
||||
|
||||
#### Start GRPC storage-server
|
||||
It is recommended to use a separate config file for the storage-server. Create a file `conf/storage-server.ini` with the following content:
|
||||
|
||||
Make sure you have the gRPC address in the `[grafana-apiserver]` section of your config file:
|
||||
```ini
|
||||
app_mode = development
|
||||
|
||||
target = storage-server
|
||||
|
||||
[database]
|
||||
type = mysql
|
||||
host = 127.0.0.1:3306
|
||||
name = unified-storage
|
||||
user = root
|
||||
password = rootpass
|
||||
skip_migrations = true
|
||||
ensure_default_org_and_user = false
|
||||
|
||||
[grpc_server]
|
||||
network = "tcp"
|
||||
address = "127.0.0.1:10000"
|
||||
|
||||
[grafana-apiserver]
|
||||
; your gRPC server address
|
||||
address = localhost:10000
|
||||
```
|
||||
storage_type = unified
|
||||
|
||||
You also need the `[grpc_server_authentication]` section to authenticate incoming requests:
|
||||
```ini
|
||||
[grpc_server_authentication]
|
||||
; http url to Grafana's signing keys to validate incoming id tokens
|
||||
signing_keys_url = http://localhost:3000/api/signing-keys/keys
|
||||
signing_keys_url = http://localhost:3011/api/signing-keys/keys
|
||||
mode = "on-prem"
|
||||
|
||||
[feature_toggles]
|
||||
kubernetesDashboards = true
|
||||
kubernetesFolders = true
|
||||
unifiedStorage = true
|
||||
unifiedStorageHistoryPruner = true
|
||||
unifiedStorageSearch = true
|
||||
unifiedStorageSearchPermissionFiltering = false
|
||||
unifiedStorageSearchSprinkles = false
|
||||
|
||||
[unified_storage]
|
||||
enable_search = true
|
||||
https_skip_verify = true
|
||||
```
|
||||
|
||||
This currently only works with a separate database configuration (see previous section).
|
||||
You should also have a MySQL database running. You can create one with our docker blocks by running:
|
||||
```bash
|
||||
make devenv sources=mysql
|
||||
```
|
||||
The database credentials in the example above will work with the default mysql docker block. You'll also need to create a database named `unified-storage`.
|
||||
|
||||
Start the storage-server with:
|
||||
```sh
|
||||
GF_DEFAULT_TARGET=storage-server ./bin/grafana server target
|
||||
./bin/grafana server target --config conf/storage-server.ini
|
||||
```
|
||||
|
||||
The GRPC service will listen on port 10000
|
||||
|
||||
@@ -252,7 +252,7 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
||||
"rules": [{
|
||||
"state": "inactive",
|
||||
"name": "AlwaysFiring",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]",
|
||||
"duration": 10,
|
||||
"folderUid": "default",
|
||||
"uid": "%s",
|
||||
@@ -270,7 +270,7 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
||||
}, {
|
||||
"state": "inactive",
|
||||
"name": "AlwaysFiringButSilenced",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]",
|
||||
"folderUid": "default",
|
||||
"uid": "%s",
|
||||
"health": "ok",
|
||||
@@ -317,7 +317,7 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
||||
"rules": [{
|
||||
"state": "inactive",
|
||||
"name": "AlwaysFiring",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]",
|
||||
"duration": 10,
|
||||
"folderUid": "default",
|
||||
"uid": "%s",
|
||||
@@ -335,7 +335,7 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
||||
}, {
|
||||
"state": "inactive",
|
||||
"name": "AlwaysFiringButSilenced",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]",
|
||||
"folderUid": "default",
|
||||
"uid": "%s",
|
||||
"health": "ok",
|
||||
@@ -639,7 +639,7 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
|
||||
"name": "AlwaysFiring",
|
||||
"uid": "%s",
|
||||
"folderUid": "default",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]",
|
||||
"duration": 10,
|
||||
"keepFiringFor": 15,
|
||||
"annotations": {
|
||||
@@ -656,7 +656,7 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
|
||||
"name": "AlwaysFiringButSilenced",
|
||||
"uid": "%s",
|
||||
"folderUid": "default",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]",
|
||||
"health": "ok",
|
||||
"isPaused": false,
|
||||
"type": "alerting",
|
||||
@@ -688,7 +688,7 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
|
||||
"name": "AlwaysFiring",
|
||||
"uid": "%s",
|
||||
"folderUid": "default",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]",
|
||||
"duration": 10,
|
||||
"keepFiringFor": 15,
|
||||
"annotations": {
|
||||
|
||||
@@ -1166,6 +1166,7 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
|
||||
"expression": "2 + 3 \u003e 1",
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": "A",
|
||||
"type": "math"
|
||||
}
|
||||
}],
|
||||
@@ -1209,6 +1210,7 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
|
||||
"expression": "2 + 3 \u003e 1",
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": "A",
|
||||
"type": "math"
|
||||
}
|
||||
}],
|
||||
@@ -1264,6 +1266,7 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
|
||||
"expression": "2 + 3 \u003e 1",
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": "A",
|
||||
"type": "math"
|
||||
}
|
||||
}],
|
||||
@@ -1610,7 +1613,7 @@ func TestIntegrationRuleCreate(t *testing.T) {
|
||||
To: apimodels.Duration(15 * time.Minute),
|
||||
},
|
||||
DatasourceUID: expr.DatasourceUID,
|
||||
Model: json.RawMessage(`{"expression":"1","intervalMs":1000,"maxDataPoints":43200,"type":"math"}`),
|
||||
Model: json.RawMessage(`{"expression":"1","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}`),
|
||||
},
|
||||
},
|
||||
UpdatedBy: &apimodels.UserInfo{
|
||||
@@ -2681,6 +2684,7 @@ func TestIntegrationQuota(t *testing.T) {
|
||||
"expression":"2 + 4 \u003E 1",
|
||||
"intervalMs":1000,
|
||||
"maxDataPoints":43200,
|
||||
"refId":"A",
|
||||
"type":"math"
|
||||
}
|
||||
}
|
||||
@@ -2798,6 +2802,7 @@ func TestIntegrationDeleteFolderWithRules(t *testing.T) {
|
||||
"expression": "2 + 3 > 1",
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": "A",
|
||||
"type": "math"
|
||||
}
|
||||
}
|
||||
@@ -3285,6 +3290,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
"expression":"2 + 3 \u003e 1",
|
||||
"intervalMs":1000,
|
||||
"maxDataPoints":43200,
|
||||
"refId":"A",
|
||||
"type":"math"
|
||||
}
|
||||
}
|
||||
@@ -3331,6 +3337,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
"expression":"2 + 3 \u003e 1",
|
||||
"intervalMs":1000,
|
||||
"maxDataPoints":43200,
|
||||
"refId":"A",
|
||||
"type":"math"
|
||||
}
|
||||
}
|
||||
@@ -3683,6 +3690,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
"expression":"2 + 3 \u003e 1",
|
||||
"intervalMs":1000,
|
||||
"maxDataPoints":43200,
|
||||
"refId":"A",
|
||||
"type":"math"
|
||||
}
|
||||
}
|
||||
@@ -3729,6 +3737,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
"expression":"2 + 3 \u003e 1",
|
||||
"intervalMs":1000,
|
||||
"maxDataPoints":43200,
|
||||
"refId":"A",
|
||||
"type":"math"
|
||||
}
|
||||
}
|
||||
@@ -3872,6 +3881,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
"expression":"2 + 3 \u003C 1",
|
||||
"intervalMs":1000,
|
||||
"maxDataPoints":43200,
|
||||
"refId":"A",
|
||||
"type":"math"
|
||||
}
|
||||
}
|
||||
@@ -3995,6 +4005,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
"expression":"2 + 3 \u003C 1",
|
||||
"intervalMs":1000,
|
||||
"maxDataPoints":43200,
|
||||
"refId":"A",
|
||||
"type":"math"
|
||||
}
|
||||
}
|
||||
@@ -4093,6 +4104,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
"expression":"2 + 3 \u003C 1",
|
||||
"intervalMs":1000,
|
||||
"maxDataPoints":43200,
|
||||
"refId":"A",
|
||||
"type":"math"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"expression": "0 \u003e 0",
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": "A",
|
||||
"type": "math"
|
||||
}
|
||||
}
|
||||
@@ -55,6 +56,7 @@
|
||||
"expression": "0 == 0",
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": "A",
|
||||
"type": "math"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"expression": "0/0",
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": "A",
|
||||
"type": "math"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"expression": "0 \u003e 0",
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": "A",
|
||||
"type": "math"
|
||||
}
|
||||
}
|
||||
@@ -54,6 +55,7 @@
|
||||
"expression": "0 == 0",
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": "A",
|
||||
"type": "math"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) {
|
||||
|
||||
// 2. Viewer searching with permission=View should find it
|
||||
{
|
||||
res := callSearch(helper.Org1.Viewer, "permission=View")
|
||||
res := callSearch(helper.Org1.Viewer, "permission=view")
|
||||
found := false
|
||||
for _, h := range res.Hits {
|
||||
if h.Name == folderUID { // Verify it's our folder
|
||||
@@ -134,12 +134,12 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) {
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "Viewer should find folder with permission=View")
|
||||
require.True(t, found, "Viewer should find folder with permission=view")
|
||||
}
|
||||
|
||||
// 3. Viewer searching with permission=Edit should NOT find it
|
||||
{
|
||||
res := callSearch(helper.Org1.Viewer, "permission=Edit")
|
||||
res := callSearch(helper.Org1.Viewer, "permission=edit")
|
||||
found := false
|
||||
for _, h := range res.Hits {
|
||||
if h.Name == folderUID { // Verify it's our folder
|
||||
@@ -147,12 +147,12 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) {
|
||||
break
|
||||
}
|
||||
}
|
||||
require.False(t, found, "Viewer should NOT find folder with permission=Edit")
|
||||
require.False(t, found, "Viewer should NOT find folder with permission=edit")
|
||||
}
|
||||
|
||||
// 4. Editor searching with permission=Edit should find it
|
||||
{
|
||||
res := callSearch(helper.Org1.Editor, "permission=Edit")
|
||||
res := callSearch(helper.Org1.Editor, "permission=edit")
|
||||
found := false
|
||||
for _, h := range res.Hits {
|
||||
if h.Name == folderUID { // Verify it's our folder
|
||||
@@ -160,12 +160,12 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) {
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "Editor should find folder with permission=Edit")
|
||||
require.True(t, found, "Editor should find folder with permission=edit")
|
||||
}
|
||||
|
||||
// 5. Editor searching with permission=View should find it (Edit permission includes View)
|
||||
{
|
||||
res := callSearch(helper.Org1.Editor, "permission=View")
|
||||
res := callSearch(helper.Org1.Editor, "permission=view")
|
||||
found := false
|
||||
for _, h := range res.Hits {
|
||||
if h.Name == folderUID { // Verify it's our folder
|
||||
@@ -173,7 +173,7 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) {
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "Editor should find folder with permission=View (Edit includes View)")
|
||||
require.True(t, found, "Editor should find folder with permission=view (Edit includes View)")
|
||||
}
|
||||
|
||||
// 6. Editor searching without permission parameter should find it (has Edit access)
|
||||
@@ -191,7 +191,7 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) {
|
||||
|
||||
// 7. Admin searching with permission=View should find it (Admin has full access)
|
||||
{
|
||||
res := callSearch(helper.Org1.Admin, "permission=View")
|
||||
res := callSearch(helper.Org1.Admin, "permission=view")
|
||||
found := false
|
||||
for _, h := range res.Hits {
|
||||
if h.Name == folderUID { // Verify it's our folder
|
||||
@@ -199,12 +199,12 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) {
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "Admin should find folder with permission=View")
|
||||
require.True(t, found, "Admin should find folder with permission=view")
|
||||
}
|
||||
|
||||
// 8. Admin searching with permission=Edit should find it (Admin has full access)
|
||||
{
|
||||
res := callSearch(helper.Org1.Admin, "permission=Edit")
|
||||
res := callSearch(helper.Org1.Admin, "permission=edit")
|
||||
found := false
|
||||
for _, h := range res.Hits {
|
||||
if h.Name == folderUID { // Verify it's our folder
|
||||
@@ -212,12 +212,12 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) {
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "Admin should find folder with permission=Edit")
|
||||
require.True(t, found, "Admin should find folder with permission=edit")
|
||||
}
|
||||
|
||||
// 9. Admin searching with permission=Admin should find it (Admin has full access)
|
||||
{
|
||||
res := callSearch(helper.Org1.Admin, "permission=Admin")
|
||||
res := callSearch(helper.Org1.Admin, "permission=admin")
|
||||
found := false
|
||||
for _, h := range res.Hits {
|
||||
if h.Name == folderUID { // Verify it's our folder
|
||||
@@ -225,7 +225,7 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) {
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "Admin should find folder with permission=Admin")
|
||||
require.True(t, found, "Admin should find folder with permission=admin")
|
||||
}
|
||||
|
||||
// 10. Admin searching without permission parameter should find it (has Admin access)
|
||||
|
||||
@@ -1823,13 +1823,13 @@
|
||||
{
|
||||
"name": "permission",
|
||||
"in": "query",
|
||||
"description": "permission needed for the resource (View, Edit, Admin)",
|
||||
"description": "permission needed for the resource (view, edit, admin)",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"View",
|
||||
"Edit",
|
||||
"Admin"
|
||||
"view",
|
||||
"edit",
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,14 +4,15 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azsettings"
|
||||
)
|
||||
|
||||
func GetAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentials, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool) (string, error) {
|
||||
func GetAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentials, azureSettings *azsettings.AzureSettings, userAssertion string) (string, error) {
|
||||
connStr := ""
|
||||
switch c := azureCredentials.(type) {
|
||||
case *azcredentials.AzureManagedIdentityCredentials:
|
||||
if azureManagedIdentityClientId != "" {
|
||||
connStr += fmt.Sprintf("user id=%s;", azureManagedIdentityClientId)
|
||||
if azureSettings.ManagedIdentityClientId != "" {
|
||||
connStr += fmt.Sprintf("user id=%s;", azureSettings.ManagedIdentityClientId)
|
||||
}
|
||||
connStr += fmt.Sprintf("fedauth=%s;",
|
||||
"ActiveDirectoryManagedIdentity")
|
||||
@@ -23,7 +24,7 @@ func GetAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentia
|
||||
"ActiveDirectoryApplication",
|
||||
)
|
||||
case *azcredentials.AzureEntraPasswordCredentials:
|
||||
if azureEntraPasswordCredentialsEnabled {
|
||||
if azureSettings.AzureEntraPasswordCredentialsEnabled {
|
||||
connStr += fmt.Sprintf("user id=%s;password=%s;applicationclientid=%s;fedauth=%s;",
|
||||
c.UserId,
|
||||
c.Password,
|
||||
@@ -33,6 +34,12 @@ func GetAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentia
|
||||
} else {
|
||||
return "", fmt.Errorf("azure entra password authentication is not enabled")
|
||||
}
|
||||
case *azcredentials.AadCurrentUserCredentials:
|
||||
if userAssertion == "" {
|
||||
return "", fmt.Errorf("user ID token is empty but required for current user authentication")
|
||||
}
|
||||
connStr += fmt.Sprintf("user id=%s;userassertion=%s;password=%s;fedauth=%s;",
|
||||
azureSettings.UserIdentityTokenEndpoint.ClientId, userAssertion, azureSettings.UserIdentityTokenEndpoint.ClientSecret, "ActiveDirectoryOnBehalfOf")
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported azure authentication type")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azsettings"
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azusercontext"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
|
||||
@@ -43,7 +44,8 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dsHandler.QueryData(ctx, req)
|
||||
|
||||
return dsHandler.QueryData(azusercontext.WithUserFromQueryReq(ctx, req), req)
|
||||
}
|
||||
|
||||
func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.InstanceFactoryFunc {
|
||||
@@ -53,6 +55,8 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pluginCfg := backend.PluginConfigFromContext(ctx)
|
||||
|
||||
jsonData := sqleng.JsonData{
|
||||
MaxOpenConns: sqlCfg.DefaultMaxOpenConns,
|
||||
MaxIdleConns: sqlCfg.DefaultMaxIdleConns,
|
||||
@@ -87,6 +91,7 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
|
||||
Updated: settings.Updated,
|
||||
UID: settings.UID,
|
||||
DecryptedSecureJSONData: settings.DecryptedSecureJSONData,
|
||||
OrgID: pluginCfg.OrgID,
|
||||
}
|
||||
|
||||
userFacingDefaultError, err := grafCfg.UserFacingDefaultError()
|
||||
@@ -117,5 +122,5 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dsHandler.CheckHealth(ctx, req)
|
||||
return dsHandler.CheckHealth(azusercontext.WithUserFromHealthCheckReq(ctx, req), req)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azsettings"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/proxy"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/azure"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/utils"
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"github.com/microsoft/go-mssqldb/azuread"
|
||||
)
|
||||
|
||||
func newMSSQL(ctx context.Context, driverName string, rowLimit int64, dsInfo DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*sql.DB, error) {
|
||||
func newMSSQL(driverName string, rowLimit int64, dsInfo DataSourceInfo, cnnstr string, logger log.Logger, proxyClient proxy.Client) (*sql.DB, error) {
|
||||
var connector *mssql.Connector
|
||||
var err error
|
||||
if driverName == "azuresql" {
|
||||
@@ -31,12 +31,6 @@ func newMSSQL(ctx context.Context, driverName string, rowLimit int64, dsInfo Dat
|
||||
return nil, fmt.Errorf("mssql connector creation failed")
|
||||
}
|
||||
|
||||
proxyClient, err := settings.ProxyClient(ctx)
|
||||
if err != nil {
|
||||
logger.Error("mssql proxy creation failed", "error", err)
|
||||
return nil, fmt.Errorf("mssql proxy creation failed")
|
||||
}
|
||||
|
||||
if proxyClient.SecureSocksProxyEnabled() {
|
||||
dialer, err := proxyClient.NewSecureSocksProxyContextDialer()
|
||||
if err != nil {
|
||||
@@ -81,7 +75,7 @@ const (
|
||||
kerberosCredentialCacheFile = "Windows AD: Credential cache file" // #nosec G101
|
||||
)
|
||||
|
||||
func generateConnectionString(dsInfo DataSourceInfo, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool, azureCredentials azcredentials.AzureCredentials, kerberosAuth kerberos.KerberosAuth, logger log.Logger) (string, error) {
|
||||
func generateConnectionString(dsInfo DataSourceInfo, azureCredentials azcredentials.AzureCredentials, kerberosAuth kerberos.KerberosAuth, logger log.Logger, azureSettings *azsettings.AzureSettings, userAssertion string) (string, error) {
|
||||
const dfltPort = "0"
|
||||
var addr util.NetworkAddress
|
||||
if dsInfo.URL != "" {
|
||||
@@ -119,7 +113,7 @@ func generateConnectionString(dsInfo DataSourceInfo, azureManagedIdentityClientI
|
||||
|
||||
switch dsInfo.JsonData.AuthenticationType {
|
||||
case azureAuthentication:
|
||||
azureCredentialDSNFragment, err := azure.GetAzureCredentialDSNFragment(azureCredentials, azureManagedIdentityClientId, azureEntraPasswordCredentialsEnabled)
|
||||
azureCredentialDSNFragment, err := azure.GetAzureCredentialDSNFragment(azureCredentials, azureSettings, userAssertion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -13,7 +13,15 @@ import (
|
||||
)
|
||||
|
||||
func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
|
||||
if err := e.db.Ping(); err != nil {
|
||||
db, err := e.getDB(ctx)
|
||||
if err != nil {
|
||||
logCheckHealthError(ctx, e.dsInfo, err)
|
||||
if strings.EqualFold(req.PluginContext.User.Role, "Admin") {
|
||||
return ErrToHealthCheckResult(err)
|
||||
}
|
||||
return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: e.TransformQueryError(e.log, err).Error()}, nil
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
logCheckHealthError(ctx, e.dsInfo, err)
|
||||
if strings.EqualFold(req.PluginContext.User.Role, "Admin") {
|
||||
return ErrToHealthCheckResult(err)
|
||||
|
||||
@@ -2,6 +2,7 @@ package sqleng
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -16,9 +17,11 @@ import (
|
||||
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azsettings"
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azusercontext"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/proxy"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||
@@ -74,6 +77,7 @@ type DataSourceInfo struct {
|
||||
Updated time.Time
|
||||
UID string
|
||||
DecryptedSecureJSONData map[string]string
|
||||
OrgID int64
|
||||
}
|
||||
|
||||
type DataPluginConfiguration struct {
|
||||
@@ -97,6 +101,8 @@ type DataSourceHandler struct {
|
||||
azureCredentials azcredentials.AzureCredentials
|
||||
kerberosAuth kerberos.KerberosAuth
|
||||
driverName string
|
||||
proxyClient proxy.Client
|
||||
dbConnections sync.Map
|
||||
}
|
||||
|
||||
type QueryJson struct {
|
||||
@@ -142,6 +148,12 @@ func NewQueryDataHandler(ctx context.Context, settings backend.DataSourceInstanc
|
||||
return nil, fmt.Errorf("error getting kerberos settings: %w", err)
|
||||
}
|
||||
|
||||
proxyClient, err := settings.ProxyClient(ctx)
|
||||
if err != nil {
|
||||
logger.Error("mssql proxy creation failed", "error", err)
|
||||
return nil, fmt.Errorf("mssql proxy creation failed")
|
||||
}
|
||||
|
||||
queryDataHandler := DataSourceHandler{
|
||||
queryResultTransformer: &queryResultTransformer,
|
||||
macroEngine: newMssqlMacroEngine(),
|
||||
@@ -154,6 +166,7 @@ func NewQueryDataHandler(ctx context.Context, settings backend.DataSourceInstanc
|
||||
azureCredentials: azureCredentials,
|
||||
kerberosAuth: kerberosAuth,
|
||||
driverName: driverName,
|
||||
proxyClient: proxyClient,
|
||||
}
|
||||
|
||||
if len(config.TimeColumnNames) > 0 {
|
||||
@@ -164,18 +177,21 @@ func NewQueryDataHandler(ctx context.Context, settings backend.DataSourceInstanc
|
||||
queryDataHandler.metricColumnTypes = config.MetricColumnTypes
|
||||
}
|
||||
|
||||
cnnstr, err := generateConnectionString(config.DSInfo, azureSettings.ManagedIdentityClientId, azureSettings.AzureEntraPasswordCredentialsEnabled, azureCredentials, kerberosAuth, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Every auth method besides Azure AD Current User Identity can use a persistent DB connection
|
||||
if config.DSInfo.JsonData.AuthenticationType != azureAuthentication || azureCredentials.AzureAuthType() != azcredentials.AzureAuthCurrentUserIdentity {
|
||||
cnnstr, err := generateConnectionString(config.DSInfo, azureCredentials, kerberosAuth, log, azureSettings, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := newMSSQL(ctx, driverName, config.RowLimit, config.DSInfo, cnnstr, log, settings)
|
||||
if err != nil {
|
||||
logger.Error("Failed connecting to MSSQL", "err", err)
|
||||
return nil, err
|
||||
}
|
||||
db, err := newMSSQL(driverName, config.RowLimit, config.DSInfo, cnnstr, log, proxyClient)
|
||||
if err != nil {
|
||||
logger.Error("Failed connecting to MSSQL", "err", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
queryDataHandler.db = db
|
||||
queryDataHandler.db = db
|
||||
}
|
||||
|
||||
return &queryDataHandler, nil
|
||||
}
|
||||
@@ -192,9 +208,52 @@ func (e *DataSourceHandler) Dispose() {
|
||||
e.log.Error("Failed to dispose db", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any cached user-specific connections
|
||||
e.dbConnections.Range(func(_, conn interface{}) bool {
|
||||
_ = conn.(*sql.DB).Close()
|
||||
return true
|
||||
})
|
||||
e.dbConnections.Clear()
|
||||
|
||||
e.log.Debug("DB disposed")
|
||||
}
|
||||
|
||||
func (e *DataSourceHandler) getDB(ctx context.Context) (*sql.DB, error) {
|
||||
e.log.Debug("Getting DB...")
|
||||
if e.dsInfo.JsonData.AuthenticationType != azureAuthentication || e.azureCredentials.AzureAuthType() != azcredentials.AzureAuthCurrentUserIdentity {
|
||||
if e.db == nil {
|
||||
return nil, fmt.Errorf("database connection is not initialized")
|
||||
}
|
||||
return e.db, nil
|
||||
}
|
||||
|
||||
userCtx, ok := azusercontext.GetCurrentUser(ctx)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to get user from context for Azure Current User authentication")
|
||||
}
|
||||
cacheKey := fmt.Sprintf("mssql-%d-%x-%x-%s", e.dsInfo.OrgID, sha256.Sum256([]byte(userCtx.User.Email)), sha256.Sum256([]byte(userCtx.IdToken)), e.dsInfo.UID)
|
||||
|
||||
conn, ok := e.dbConnections.Load(cacheKey)
|
||||
if ok {
|
||||
return conn.(*sql.DB), nil
|
||||
}
|
||||
|
||||
cnnstr, err := generateConnectionString(e.dsInfo, e.azureCredentials, e.kerberosAuth, e.log, e.azureSettings, userCtx.IdToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := newMSSQL(e.driverName, e.rowLimit, e.dsInfo, cnnstr, e.log, e.proxyClient)
|
||||
if err != nil {
|
||||
logger.Error("Failed connecting to MSSQL", "err", err)
|
||||
return nil, err
|
||||
}
|
||||
e.dbConnections.Store(cacheKey, db)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (e *DataSourceHandler) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
|
||||
result := backend.NewQueryDataResponse()
|
||||
ch := make(chan DBDataResponse, len(req.Queries))
|
||||
@@ -293,7 +352,12 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := e.db.QueryContext(queryContext, interpolatedQuery)
|
||||
db, err := e.getDB(queryContext)
|
||||
if err != nil {
|
||||
errAppendDebug("retrieving database connection failed", e.TransformQueryError(logger, err), interpolatedQuery, backend.ErrorSourcePlugin)
|
||||
return
|
||||
}
|
||||
rows, err := db.QueryContext(queryContext, interpolatedQuery)
|
||||
if err != nil {
|
||||
errAppendDebug("db query error", e.TransformQueryError(logger, err), interpolatedQuery, backend.ErrorSourceDownstream)
|
||||
return
|
||||
@@ -310,12 +374,19 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG
|
||||
return
|
||||
}
|
||||
|
||||
frame := e.processResponse(qm, rows, interpolatedQuery, errAppendDebug)
|
||||
|
||||
queryResult.dataResponse.Frames = data.Frames{frame}
|
||||
ch <- queryResult
|
||||
}
|
||||
|
||||
func (e *DataSourceHandler) processResponse(qm *dataQueryModel, rows *sql.Rows, interpolatedQuery string, errAppendDebug func(string, error, string, backend.ErrorSource)) *data.Frame {
|
||||
// Convert row.Rows to dataframe
|
||||
stringConverters := e.queryResultTransformer.GetConverterList()
|
||||
frame, err := sqlutil.FrameFromRows(rows, e.rowLimit, sqlutil.ToConverters(stringConverters...)...)
|
||||
if err != nil {
|
||||
errAppendDebug("convert frame from rows error", err, interpolatedQuery, backend.ErrorSourcePlugin)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
if frame.Meta == nil {
|
||||
@@ -330,21 +401,19 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG
|
||||
// additionally-needed frame data stays intact and is correctly passed to our visulization.
|
||||
if frame.Rows() == 0 {
|
||||
frame.Fields = []*data.Field{}
|
||||
queryResult.dataResponse.Frames = data.Frames{frame}
|
||||
ch <- queryResult
|
||||
return
|
||||
return frame
|
||||
}
|
||||
|
||||
if err := convertSQLTimeColumnsToEpochMS(frame, qm); err != nil {
|
||||
errAppendDebug("converting time columns failed", err, interpolatedQuery, backend.ErrorSourcePlugin)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
if qm.Format == dataQueryFormatSeries {
|
||||
// time series has to have time column
|
||||
if qm.timeIndex == -1 {
|
||||
errAppendDebug("db has no time column", errors.New("time column is missing; make sure your data includes a time column for time series format or switch to a table format that doesn't require it"), interpolatedQuery, backend.ErrorSourceDownstream)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// Make sure to name the time field 'Time' to be backward compatible with Grafana pre-v8.
|
||||
@@ -362,7 +431,7 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG
|
||||
var err error
|
||||
if frame, err = convertSQLValueColumnToFloat(frame, i); err != nil {
|
||||
errAppendDebug("convert value to float failed", err, interpolatedQuery, backend.ErrorSourcePlugin)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,7 +442,7 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG
|
||||
frame, err = data.LongToWide(frame, qm.FillMissing)
|
||||
if err != nil {
|
||||
errAppendDebug("failed to convert long to wide series when converting from dataframe", err, interpolatedQuery, backend.ErrorSourcePlugin)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// Before 8x, a special metric column was used to name time series. The LongToWide transforms that into a metric label on the value field.
|
||||
@@ -408,8 +477,7 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG
|
||||
}
|
||||
}
|
||||
|
||||
queryResult.dataResponse.Frames = data.Frames{frame}
|
||||
ch <- queryResult
|
||||
return frame
|
||||
}
|
||||
|
||||
// Interpolate provides global macros/substitutions for all sql datasources.
|
||||
|
||||
@@ -697,7 +697,7 @@ func TestGenerateConnectionString(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
connStr, err := generateConnectionString(tc.dataSource, "", false, nil, tc.kerberosCfg, logger)
|
||||
connStr, err := generateConnectionString(tc.dataSource, nil, tc.kerberosCfg, logger, nil, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expConnStr, connStr)
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { getExternalUserMngLinkUrl, getUpgradeUrl } from 'app/features/users/utils';
|
||||
import { getExternalUserMngLinkUrl } from 'app/features/users/utils';
|
||||
|
||||
import { InviteUserButton } from './InviteUserButton';
|
||||
|
||||
@@ -13,12 +12,6 @@ jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
config: {
|
||||
externalUserMngLinkUrl: 'https://example.com/invite',
|
||||
namespace: 'default', // on-prem by default
|
||||
bootData: {
|
||||
user: {
|
||||
orgName: 'test-org',
|
||||
},
|
||||
},
|
||||
},
|
||||
reportInteraction: jest.fn(),
|
||||
}));
|
||||
@@ -31,18 +24,12 @@ jest.mock('app/core/services/context_srv', () => ({
|
||||
|
||||
jest.mock('app/features/users/utils', () => ({
|
||||
getExternalUserMngLinkUrl: jest.fn(),
|
||||
getUpgradeUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('app/api/clients/legacy', () => ({
|
||||
useGetCurrentOrgQuotaQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockContextSrv = jest.mocked(contextSrv);
|
||||
const mockConfig = jest.mocked(config);
|
||||
const mockReportInteraction = jest.mocked(reportInteraction);
|
||||
const mockGetExternalUserMngLinkUrl = jest.mocked(getExternalUserMngLinkUrl);
|
||||
const mockGetUpgradeUrl = jest.mocked(getUpgradeUrl);
|
||||
|
||||
// Mock window.open
|
||||
const mockWindowOpen = jest.fn();
|
||||
@@ -65,27 +52,10 @@ const mockMatchMedia = (matches: boolean) => {
|
||||
|
||||
describe('InviteUserButton', () => {
|
||||
const mockInviteUrl = 'https://example.com/invite?cnt=invite-user-top-bar';
|
||||
const mockUpgradeUrl = 'https://grafana.com/orgs/test-org/my-account/manage-plan?cnt=upgrade-user-top-bar';
|
||||
|
||||
// Import the mocked hook
|
||||
const { useGetCurrentOrgQuotaQuery } = require('app/api/clients/legacy');
|
||||
const mockUseGetCurrentOrgQuotaQuery = jest.mocked(useGetCurrentOrgQuotaQuery);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetExternalUserMngLinkUrl.mockReturnValue(mockInviteUrl);
|
||||
mockGetUpgradeUrl.mockReturnValue(mockUpgradeUrl);
|
||||
|
||||
// Default mock: no quotas, no error (on-prem scenario)
|
||||
mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
// Default to on-prem
|
||||
mockConfig.namespace = 'default';
|
||||
});
|
||||
|
||||
describe('Business Logic - When button should appear', () => {
|
||||
@@ -158,109 +128,11 @@ describe('InviteUserButton', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Upgrade functionality - Grafana Cloud', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig.externalUserMngLinkUrl = 'https://example.com/invite';
|
||||
mockContextSrv.hasPermission.mockReturnValue(true);
|
||||
mockMatchMedia(true);
|
||||
// Simulate Grafana Cloud
|
||||
mockConfig.namespace = 'stacks-12345';
|
||||
});
|
||||
|
||||
it('should show invite button when quota is not reached', () => {
|
||||
mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
|
||||
data: [{ target: 'org_user', limit: 5, used: 2, org_id: 1 }],
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
render(<InviteUserButton />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /invite user/i });
|
||||
expect(button).toHaveTextContent('Invite');
|
||||
});
|
||||
|
||||
it('should show upgrade button when quota is reached', () => {
|
||||
mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
|
||||
data: [{ target: 'org_user', limit: 5, used: 5, org_id: 1 }],
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
render(<InviteUserButton />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /upgrade to invite more users/i });
|
||||
expect(button).toHaveTextContent('Upgrade');
|
||||
});
|
||||
|
||||
it('should open upgrade URL when upgrade button is clicked', async () => {
|
||||
mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
|
||||
data: [{ target: 'org_user', limit: 5, used: 5, org_id: 1 }],
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<InviteUserButton />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /upgrade to invite more users/i }));
|
||||
|
||||
expect(mockReportInteraction).toHaveBeenCalledWith('upgrade_user_button_clicked', {
|
||||
placement: 'top_bar_right',
|
||||
});
|
||||
expect(mockGetUpgradeUrl).toHaveBeenCalledWith('upgrade-user-top-bar');
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(mockUpgradeUrl, '_blank');
|
||||
});
|
||||
|
||||
it('should not fetch quotas on on-prem instances', () => {
|
||||
mockConfig.namespace = 'default'; // on-prem
|
||||
|
||||
render(<InviteUserButton />);
|
||||
|
||||
// Should skip the query with skipToken
|
||||
expect(mockUseGetCurrentOrgQuotaQuery).toHaveBeenCalledWith(skipToken);
|
||||
});
|
||||
|
||||
it('should fetch quotas on cloud instances when button will render', () => {
|
||||
mockConfig.namespace = 'stacks-12345'; // cloud
|
||||
|
||||
render(<InviteUserButton />);
|
||||
|
||||
// Should not skip the query (pass undefined)
|
||||
expect(mockUseGetCurrentOrgQuotaQuery).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling - Preventing crashes', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig.externalUserMngLinkUrl = 'https://example.com/invite';
|
||||
mockContextSrv.hasPermission.mockReturnValue(true);
|
||||
mockMatchMedia(true);
|
||||
mockConfig.namespace = 'default'; // on-prem
|
||||
});
|
||||
|
||||
it('should handle quota API errors gracefully', () => {
|
||||
mockConfig.namespace = 'stacks-12345';
|
||||
mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
error: { message: 'API Error' },
|
||||
isLoading: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
render(<InviteUserButton />);
|
||||
|
||||
// Should still render the invite button (no quota check)
|
||||
expect(screen.getByRole('button', { name: /invite user/i })).toBeInTheDocument();
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch org quotas:', { message: 'API Error' });
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle URL generation errors gracefully', async () => {
|
||||
@@ -276,7 +148,7 @@ describe('InviteUserButton', () => {
|
||||
// Should not crash when URL generation fails
|
||||
await user.click(screen.getByRole('button', { name: /invite user/i }));
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to handle button click:', expect.any(Error));
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to handle invite user click:', expect.any(Error));
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
@@ -294,7 +166,7 @@ describe('InviteUserButton', () => {
|
||||
// Should not crash when popup is blocked
|
||||
await user.click(screen.getByRole('button', { name: /invite user/i }));
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to handle button click:', expect.any(Error));
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to handle invite user click:', expect.any(Error));
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -1,66 +1,33 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ToolbarButton } from '@grafana/ui';
|
||||
import { useGetCurrentOrgQuotaQuery } from 'app/api/clients/legacy';
|
||||
import { useMediaQueryMinWidth } from 'app/core/hooks/useMediaQueryMinWidth';
|
||||
import { isOnPrem } from 'app/features/provisioning/utils/isOnPrem';
|
||||
|
||||
import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator';
|
||||
|
||||
import { performInviteUserClick, performUpgradeClick, shouldRenderInviteUserButton } from './InviteUserButtonUtils';
|
||||
import { performInviteUserClick, shouldRenderInviteUserButton } from './InviteUserButtonUtils';
|
||||
|
||||
export function InviteUserButton() {
|
||||
const isLargeScreen = useMediaQueryMinWidth('lg');
|
||||
const shouldRender = shouldRenderInviteUserButton();
|
||||
const isCloudInstance = !isOnPrem();
|
||||
|
||||
// Only fetch quotas when button will render AND on Grafana Cloud
|
||||
const { data: quotas, error } = useGetCurrentOrgQuotaQuery(!shouldRender || !isCloudInstance ? skipToken : undefined);
|
||||
|
||||
// Check if org_user quota is reached
|
||||
const userQuota = quotas?.find((quota) => quota.target === 'org_user');
|
||||
const isQuotaReached =
|
||||
userQuota != null && userQuota.used != null && userQuota.limit != null && userQuota.used >= userQuota.limit;
|
||||
|
||||
// Only show upgrade button on Grafana Cloud when quota is reached
|
||||
const shouldShowUpgrade = isCloudInstance && isQuotaReached;
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to fetch org quotas:', error);
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
try {
|
||||
if (shouldShowUpgrade) {
|
||||
performUpgradeClick('top_bar_right', 'upgrade-user-top-bar');
|
||||
} else {
|
||||
performInviteUserClick('top_bar_right', 'invite-user-top-bar');
|
||||
}
|
||||
performInviteUserClick('top_bar_right', 'invite-user-top-bar');
|
||||
} catch (error) {
|
||||
console.error('Failed to handle button click:', error);
|
||||
console.error('Failed to handle invite user click:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonLabel = shouldShowUpgrade
|
||||
? t('navigation.invite-user.upgrade-tooltip', 'Upgrade to invite more users')
|
||||
: t('navigation.invite-user.invite-tooltip', 'Invite user');
|
||||
|
||||
return (
|
||||
shouldRender && (
|
||||
shouldRenderInviteUserButton() && (
|
||||
<>
|
||||
<ToolbarButton
|
||||
icon={shouldShowUpgrade ? 'rocket' : 'add-user'}
|
||||
icon="add-user"
|
||||
iconOnly={!isLargeScreen}
|
||||
onClick={handleClick}
|
||||
tooltip={buttonLabel}
|
||||
aria-label={buttonLabel}
|
||||
tooltip={t('navigation.invite-user.invite-tooltip', 'Invite user')}
|
||||
aria-label={t('navigation.invite-user.invite-tooltip', 'Invite user')}
|
||||
>
|
||||
{isLargeScreen
|
||||
? shouldShowUpgrade
|
||||
? t('navigation.invite-user.upgrade-button', 'Upgrade')
|
||||
: t('navigation.invite-user.invite-button', 'Invite')
|
||||
: undefined}
|
||||
{isLargeScreen ? t('navigation.invite-user.invite-button', 'Invite') : undefined}
|
||||
</ToolbarButton>
|
||||
<NavToolbarSeparator />
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { reportInteraction, config } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { getExternalUserMngLinkUrl, getUpgradeUrl } from 'app/features/users/utils';
|
||||
import { getExternalUserMngLinkUrl } from 'app/features/users/utils';
|
||||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
|
||||
export const shouldRenderInviteUserButton = () =>
|
||||
@@ -12,14 +12,5 @@ export const performInviteUserClick = (placement: string, cnt: string) => {
|
||||
});
|
||||
|
||||
const url = getExternalUserMngLinkUrl(cnt);
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
export const performUpgradeClick = (placement: string, cnt: string) => {
|
||||
reportInteraction('upgrade_user_button_clicked', {
|
||||
placement,
|
||||
});
|
||||
|
||||
const url = getUpgradeUrl(cnt);
|
||||
window.open(url, '_blank');
|
||||
window.open(url.toString(), '_blank');
|
||||
};
|
||||
|
||||
@@ -22,7 +22,13 @@ export function useFoldersQuery({
|
||||
rootFolderItem,
|
||||
}: UseFoldersQueryProps) {
|
||||
const resultLegacy = useFoldersQueryLegacy({ isBrowsing, openFolders, permission, rootFolderUID, rootFolderItem });
|
||||
const resultAppPlatform = useFoldersQueryAppPlatform({ isBrowsing, openFolders, rootFolderUID, rootFolderItem });
|
||||
const resultAppPlatform = useFoldersQueryAppPlatform({
|
||||
isBrowsing,
|
||||
openFolders,
|
||||
permission,
|
||||
rootFolderUID,
|
||||
rootFolderItem,
|
||||
});
|
||||
|
||||
// Running the hooks themselves don't have any side effects, so we can just conditionally use one or the other
|
||||
// requestNextPage function from the result
|
||||
|
||||
@@ -27,14 +27,14 @@ const collator = new Intl.Collator();
|
||||
* does not have pagination at the moment.
|
||||
*/
|
||||
|
||||
type Props = Omit<UseFoldersQueryProps, 'permission'>;
|
||||
export function useFoldersQueryAppPlatform({
|
||||
isBrowsing,
|
||||
openFolders,
|
||||
/* rootFolderUID: configure which folder to start browsing from */
|
||||
rootFolderUID,
|
||||
rootFolderItem,
|
||||
}: Props) {
|
||||
permission,
|
||||
}: UseFoldersQueryProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Keep a list of all request subscriptions so we can unsubscribe from them when the component is unmounted
|
||||
@@ -89,7 +89,7 @@ export function useFoldersQueryAppPlatform({
|
||||
return;
|
||||
}
|
||||
|
||||
const args = { folder: finalParentUid, type: 'folder' } as const;
|
||||
const args = { folder: finalParentUid, type: 'folder', permission } as const;
|
||||
|
||||
// Make a request
|
||||
const subscription = dispatch(dashboardAPIv0alpha1.endpoints.getSearch.initiate(args));
|
||||
@@ -101,7 +101,7 @@ export function useFoldersQueryAppPlatform({
|
||||
// the subscriptions are saved in a ref so they can be unsubscribed on unmount
|
||||
requestsRef.current = requestsRef.current.concat([subscription]);
|
||||
},
|
||||
[state, dispatch]
|
||||
[state, dispatch, permission]
|
||||
);
|
||||
|
||||
// Unsubscribe from all requests when the component is unmounted
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user