Merge remote-tracking branch 'origin/main' into apiextensions-2

This commit is contained in:
konsalex
2025-11-26 15:46:51 +01:00
96 changed files with 1577 additions and 915 deletions
@@ -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 |
@@ -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)
-5
View File
@@ -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
@@ -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 */
+2 -1
View File
@@ -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> {
@@ -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',
@@ -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,
+29
View File
@@ -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
}
+96
View File
@@ -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
}
+2 -2
View File
@@ -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 -1
View File
@@ -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)
@@ -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)
+4 -4
View File
@@ -460,8 +460,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
@@ -509,7 +509,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
@@ -1113,8 +1113,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
@@ -1162,7 +1162,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
@@ -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,
@@ -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)
+14 -14
View File
@@ -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"
]
}
},
+11 -4
View File
@@ -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")
}
+7 -2
View File
@@ -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)
}
+5 -11
View File
@@ -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
}
+9 -1
View File
@@ -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)
+89 -21
View File
@@ -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.
+1 -1
View File
@@ -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)
})
@@ -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
@@ -28,6 +28,9 @@ import { Annotation } from './utils/constants';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
jest.mock('./api/ruler');
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
jest.spyOn(alertingAbilities, 'useAlertRuleAbility');
const prometheusModuleSettings = { alerting: true, module: 'core:plugin/prometheus' };
@@ -45,6 +45,9 @@ jest.mock('@grafana/runtime', () => ({
jest.mock('./api/buildInfo');
jest.mock('./api/prometheus');
jest.mock('./api/ruler');
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
jest.spyOn(actions, 'rulesInSameGroupHaveInvalidFor').mockReturnValue([]);
jest.spyOn(apiRuler, 'rulerUrlBuilder');
@@ -0,0 +1,139 @@
import { useMemo } from 'react';
import { OpenAssistantProps, createAssistantContextItem, useAssistant } from '@grafana/assistant';
import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Menu } from '@grafana/ui';
import { GrafanaAlertingRule, GrafanaRecordingRule, GrafanaRule } from 'app/types/unified-alerting';
import { prometheusRuleType } from '../../utils/rules';
interface AnalyzeRuleButtonProps {
/** Alert rule to analyze */
rule: GrafanaRule;
}
/**
* A menu item component that analyze an alert rule.
* Automatically creates context from alert data and opens the assistant in assistant mode.
*/
export function AnalyzeRuleButton(props: AnalyzeRuleButtonProps) {
const { isAvailable, openAssistant } = useAssistant();
if (!isAvailable || !openAssistant) {
return null;
}
return <AnalyzeRuleButtonView {...props} openAssistant={openAssistant} />;
}
function AnalyzeRuleButtonView({
rule,
openAssistant,
}: AnalyzeRuleButtonProps & {
openAssistant: (props: OpenAssistantProps) => void;
}) {
// Create alert rule context from alert rule data
const alertContext = useMemo(() => {
return createAssistantContextItem('structured', {
title: `Alert: ${rule.name}`,
data: {
rule: {
name: rule.name,
uid: rule.uid,
labels: rule.labels,
query: rule.query,
},
},
});
}, [rule]);
// Generate default prompt
const analyzeRulePrompt = useMemo(() => buildAnalyzeRulePrompt(rule), [rule]);
const handleClick = () => {
reportInteraction('grafana_assistant_app_analyze_rule_button_clicked', {
origin: 'alerting',
alertName: rule.name,
alertState: prometheusRuleType.grafana.alertingRule(rule) ? rule.state : undefined,
});
openAssistant({
origin: 'alerting',
mode: 'assistant',
prompt: analyzeRulePrompt,
context: [alertContext],
autoSend: true,
});
};
return (
<Menu.Item
label={t('alerting.alert-menu.analyze-rule', 'Analyze rule')}
icon="ai-sparkle"
onClick={handleClick}
data-testid="analyze-rule-menu-item"
/>
);
}
/**
* Builds a prompt for analyzing a rule (alerting or recording).
* Automatically detects the rule type and uses the appropriate prompt builder.
*/
function buildAnalyzeRulePrompt(rule: GrafanaRule): string {
if (prometheusRuleType.grafana.alertingRule(rule)) {
return buildAnalyzeAlertingRulePrompt(rule);
} else if (prometheusRuleType.grafana.recordingRule(rule)) {
return buildAnalyzeRecordingRulePrompt(rule);
}
// Fallback (should not happen for GrafanaRule, but TypeScript requires it)
return `Analyze the rule "${rule.name}".`;
}
/**
* Builds a prompt for analyzing an alerting rule.
* Includes state, activeAt timestamp, annotations, and labels.
*/
function buildAnalyzeAlertingRulePrompt(rule: GrafanaAlertingRule): string {
const state = rule.state || 'firing';
const timeInfo = rule.activeAt ? ` starting at ${new Date(rule.activeAt).toISOString()}` : '';
let prompt = `Analyze the ${state} alert "${rule.name}"${timeInfo}.`;
const description = rule.annotations?.description || rule.annotations?.summary || '';
if (description) {
prompt += ` ${description}`;
}
const labelsStr = rule.labels
? Object.entries(rule.labels)
.map(([k, v]) => `${k}="${v}"`)
.join(', ')
: '';
if (labelsStr) {
prompt += ` Labels: ${labelsStr}.`;
}
return prompt;
}
/**
* Builds a prompt for analyzing a recording rule.
* Includes name, query, and labels (no state or activeAt).
*/
function buildAnalyzeRecordingRulePrompt(rule: GrafanaRecordingRule): string {
const labelsStr = rule.labels
? Object.entries(rule.labels)
.map(([k, v]) => `${k}="${v}"`)
.join(', ')
: '';
let prompt = `Analyze the recording rule "${rule.name}".`;
if (labelsStr) {
prompt += ` Labels: ${labelsStr}.`;
}
return prompt;
}
@@ -48,7 +48,6 @@ interface ExpressionProps {
onSetCondition: (refId: string) => void;
onUpdateRefId: (oldRefId: string, newRefId: string) => void;
onRemoveExpression: (refId: string) => void;
onUpdateExpressionType: (refId: string, type: ExpressionQueryType) => void;
onChangeQuery: (query: ExpressionQuery) => void;
}
@@ -62,7 +61,6 @@ export const Expression: FC<ExpressionProps> = ({
onSetCondition,
onUpdateRefId,
onRemoveExpression,
onUpdateExpressionType, // this method is not used? maybe we should remove it
onChangeQuery,
}) => {
const styles = useStyles2(getStyles);
@@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { GrafanaTheme2, PanelData } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
import { ExpressionQuery } from 'app/features/expressions/types';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { Expression } from '../expressions/Expression';
@@ -18,7 +18,6 @@ interface Props {
queries: AlertQuery[];
onRemoveExpression: (refId: string) => void;
onUpdateRefId: (oldRefId: string, newRefId: string) => void;
onUpdateExpressionType: (refId: string, type: ExpressionQueryType) => void;
onUpdateQueryExpression: (query: ExpressionQuery) => void;
}
@@ -29,12 +28,15 @@ export const ExpressionsEditor = ({
panelData,
onUpdateRefId,
onRemoveExpression,
onUpdateExpressionType,
onUpdateQueryExpression,
}: Props) => {
const expressionQueries = useMemo(() => {
return queries.reduce((acc: ExpressionQuery[], query) => {
return isExpressionQuery(query.model) ? acc.concat(query.model) : acc;
if (isExpressionQuery(query.model)) {
acc.push(query.model);
}
return acc;
}, []);
}, [queries]);
const styles = useStyles2(getStyles);
@@ -64,7 +66,6 @@ export const ExpressionsEditor = ({
onSetCondition={onSetCondition}
onRemoveExpression={onRemoveExpression}
onUpdateRefId={onUpdateRefId}
onUpdateExpressionType={onUpdateExpressionType}
onChangeQuery={onUpdateQueryExpression}
/>
);
@@ -73,7 +73,6 @@ import {
updateExpression,
updateExpressionRefId,
updateExpressionTimeRange,
updateExpressionType,
} from './reducer';
import { useAdvancedMode } from './useAdvancedMode';
import { useAlertQueryRunner } from './useAlertQueryRunner';
@@ -591,9 +590,6 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mod
dispatch(removeExpression(refId));
}}
onUpdateRefId={onUpdateRefId}
onUpdateExpressionType={(refId, type) => {
dispatch(updateExpressionType({ refId, type }));
}}
onUpdateQueryExpression={(model) => {
dispatch(updateExpression(model));
}}
@@ -442,55 +442,3 @@ exports[`Query and expressions reducer should update an expression refId and rew
],
}
`;
exports[`Query and expressions reducer should update expression type 1`] = `
{
"queries": [
{
"datasourceUid": "abc123",
"model": {
"refId": "A",
},
"queryType": "query",
"refId": "A",
},
{
"datasourceUid": "__expr__",
"model": {
"conditions": [
{
"evaluator": {
"params": [
0,
0,
],
"type": "gt",
},
"operator": {
"type": "and",
},
"query": {
"params": [],
},
"reducer": {
"params": [],
"type": "avg",
},
"type": "query",
},
],
"datasource": {
"name": "Expression",
"type": "__expr__",
"uid": "__expr__",
},
"expression": "",
"refId": "B",
"type": "reduce",
},
"queryType": "",
"refId": "B",
},
],
}
`;
@@ -22,7 +22,6 @@ import {
updateExpression,
updateExpressionRefId,
updateExpressionTimeRange,
updateExpressionType,
} from './reducer';
const reduceExpression: AlertQuery<ExpressionQuery> = {
@@ -388,22 +387,6 @@ describe('Query and expressions reducer', () => {
expect(newState).toMatchSnapshot();
});
it('should update expression type', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery, expressionQuery],
};
const newState = queriesAndExpressionsReducer(
initialState,
updateExpressionType({
refId: 'B',
type: ExpressionQueryType.reduce,
})
);
expect(newState).toMatchSnapshot();
});
it('should remove first reducer', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery, reduceExpression, thresholdExpression],
@@ -283,23 +283,6 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder
queryType: 'expression',
});
}
})
.addCase(updateExpressionType, (state, action) => {
state.queries = state.queries.map((query) => {
return query.refId === action.payload.refId
? {
...query,
model: {
...expressionDatasource.newQuery({
type: action.payload.type,
conditions: [{ ...defaultCondition, query: { params: [] } }],
expression: '',
}),
refId: action.payload.refId,
},
}
: query;
});
});
});
@@ -1,5 +1,6 @@
import { PropsOf } from '@emotion/react';
import { useAssistant } from '@grafana/assistant';
import { AppEvents } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
@@ -29,6 +30,7 @@ import {
rulerRuleType,
} from '../../utils/rules';
import { createRelativeUrl } from '../../utils/url';
import { AnalyzeRuleButton } from '../assistant/AnalizeRuleButton';
import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton';
interface Props {
@@ -126,6 +128,9 @@ const AlertRuleMenu = ({
prometheusRuleType.alertingRule(promRule) &&
promRule.state === PromAlertingRuleState.Firing;
const { isAvailable: isAssistantAvailable } = useAssistant();
const shouldShowAnalyzeRuleButton = isAssistantAvailable && prometheusRuleType.grafana.rule(promRule);
const shareUrl = createShareLink(identifier);
const showDivider =
@@ -172,6 +177,7 @@ const AlertRuleMenu = ({
)}
{/* TODO Migrate Declare Incident to plugin links extensions */}
{shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={promRule.name} url={''} />}
{shouldShowAnalyzeRuleButton && <AnalyzeRuleButton rule={promRule} />}
{canDuplicate && (
<Menu.Item
label={t('alerting.alert-menu.duplicate', 'Duplicate')}
@@ -36,6 +36,10 @@ import { AlertRuleProvider } from './RuleContext';
import RuleViewer, { ActiveTab } from './RuleViewer';
import { addRulePageEnrichmentSection } from './tabs/extensions/RuleViewerExtension';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
// metadata and interactive elements
const ELEMENTS = {
loading: byText(/Loading rule/i),
@@ -21,6 +21,10 @@ import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { setupDataSources } from '../../testSetup/datasources';
import { fromCombinedRule, stringifyIdentifier } from '../../utils/rule-id';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
setupMswServer();
jest.mock('app/core/services/context_srv');
const mockContextSrv = jest.mocked(contextSrv);
@@ -15,6 +15,10 @@ import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { RuleListGroupView } from './RuleListGroupView';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
jest.spyOn(analytics, 'logInfo');
const ui = {
@@ -10,6 +10,10 @@ import {
} from 'app/features/alerting/unified/mocks';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
setPluginLinksHook(() => ({
links: [],
isLoading: false,
@@ -18,6 +18,10 @@ import { mimirDataSource } from '../../mocks/server/configure';
import { RulesTable } from './RulesTable';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
jest.mock('../../hooks/useAbilities');
const mocks = {
@@ -23,6 +23,10 @@ import { alertingFactory } from '../mocks/server/db';
import GroupDetailsPage from './GroupDetailsPage';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
jest.mock('react-virtualized-auto-sizer', () => {
return ({ children }: Props) =>
children({
@@ -17,6 +17,10 @@ import { fromRulerRuleAndGroupIdentifierV2 } from '../utils/rule-id';
import { DataSourceGroupLoader } from './DataSourceGroupLoader';
import { createViewLinkFromIdentifier } from './DataSourceRuleListItem';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
setPluginLinksHook(() => ({ links: [], isLoading: false }));
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
@@ -12,6 +12,10 @@ import { RulesFilter } from '../search/rulesSearchParser';
import { FilterView } from './FilterView';
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
setPluginLinksHook(() => ({ links: [], isLoading: false }));
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
@@ -1,6 +1,7 @@
import { render } from 'test/test-utils';
import { byLabelText, byRole } from 'testing-library-selector';
import { useAssistant } from '@grafana/assistant';
import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
import { AccessControlAction } from 'app/types/accessControl';
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
@@ -22,6 +23,12 @@ import { intervalToSeconds } from '../utils/time';
import { GrafanaGroupLoader } from './GrafanaGroupLoader';
jest.mock('@grafana/assistant', () => ({
useAssistant: jest.fn(),
createAssistantContextItem: jest.fn((type, data) => ({ type, ...data })),
}));
const mockUseAssistant = jest.mocked(useAssistant);
setPluginLinksHook(() => ({ links: [], isLoading: false }));
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
@@ -41,11 +48,18 @@ const ui = {
export: () => byRole('menuitem', { name: /export/i }),
delete: () => byRole('menuitem', { name: /delete/i }),
pause: () => byRole('menuitem', { name: /pause/i }),
analyzeRule: () => byRole('menuitem', { name: /analyze rule/i }),
},
};
describe('GrafanaGroupLoader', () => {
beforeEach(() => {
mockUseAssistant.mockReturnValue({
isAvailable: false,
openAssistant: jest.fn(),
closeAssistant: jest.fn(),
toggleAssistant: jest.fn(),
});
grantUserPermissions([
AccessControlAction.AlertingRuleUpdate,
AccessControlAction.AlertingRuleDelete,
@@ -213,6 +227,68 @@ describe('GrafanaGroupLoader', () => {
const menuItems = byRole('menuitem').getAll();
expect(menuItems.length).toBe(6);
});
it('should render Analyze rule menu item when assistant is available', async () => {
mockUseAssistant.mockReturnValue({
isAvailable: true,
openAssistant: jest.fn(),
closeAssistant: jest.fn(),
toggleAssistant: jest.fn(),
});
setGrafanaPromRules([rulerGroupToPromGroup(grafanaRulerGroup)]);
const groupIdentifier = getGroupIdentifier(grafanaRulerGroup);
const { user } = render(
<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={grafanaRulerNamespace.name} />
);
const [rule1] = grafanaRulerGroup.rules;
const ruleListItem = await ui.ruleItem(rule1.grafana_alert.title).find();
// Click the More button to open the menu
const moreButton = ui.moreButton().get(ruleListItem);
await user.click(moreButton);
// Check that Analyze rule menu item is present
expect(ui.menuItems.analyzeRule().get()).toBeInTheDocument();
// With assistant enabled, there should be 7 menu items (6 + Analyze rule)
const menuItems = byRole('menuitem').getAll();
expect(menuItems.length).toBe(7);
});
it('should not render Analyze rule menu item when assistant is not available', async () => {
mockUseAssistant.mockReturnValue({
isAvailable: false,
openAssistant: jest.fn(),
closeAssistant: jest.fn(),
toggleAssistant: jest.fn(),
});
setGrafanaPromRules([rulerGroupToPromGroup(grafanaRulerGroup)]);
const groupIdentifier = getGroupIdentifier(grafanaRulerGroup);
const { user } = render(
<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={grafanaRulerNamespace.name} />
);
const [rule1] = grafanaRulerGroup.rules;
const ruleListItem = await ui.ruleItem(rule1.grafana_alert.title).find();
// Click the More button to open the menu
const moreButton = ui.moreButton().get(ruleListItem);
await user.click(moreButton);
// Check that Analyze rule menu item is NOT present
expect(ui.menuItems.analyzeRule().query()).not.toBeInTheDocument();
// Without assistant, there should be 6 menu items
const menuItems = byRole('menuitem').getAll();
expect(menuItems.length).toBe(6);
});
});
function rulerGroupToPromGroup(group: RulerRuleGroupDTO<RulerGrafanaRuleDTO>): GrafanaPromRuleGroupDTO {
@@ -7,6 +7,7 @@ import {
GrafanaAlertStateDecision,
GrafanaRuleDefinition,
RulerAlertingRuleDTO,
RulerGrafanaRuleDTO,
} from 'app/types/unified-alerting-dto';
import { EvalFunction } from '../../state/alertDef';
@@ -20,6 +21,7 @@ import {
alertingRulerRuleToRuleForm,
cleanAnnotations,
cleanLabels,
fixMissingRefIdsInExpressionModel,
formValuesToRulerGrafanaRuleDTO,
formValuesToRulerRuleDTO,
getContactPointsFromDTO,
@@ -520,3 +522,70 @@ describe('getDefaultExpressions', () => {
expect(thresholdModel.expression).toBe('X');
});
});
describe('fixMissingRefIdsInExpressionModel', () => {
it('should return non-Grafana managed rules unchanged', () => {
const cloudAlertingRule: RulerAlertingRuleDTO = {
alert: 'CloudAlert',
expr: 'up == 0',
for: '5m',
labels: { severity: 'critical' },
annotations: { summary: 'Instance down' },
};
const result = fixMissingRefIdsInExpressionModel(cloudAlertingRule);
expect(result).toEqual(cloudAlertingRule);
expect(result).toBe(cloudAlertingRule); // should be the exact same reference
});
it('should copy refId from query to model when model.refId is missing in Grafana managed rules', () => {
const ruleWithMissingRefId: RulerGrafanaRuleDTO = {
grafana_alert: {
uid: 'test-uid',
title: 'Test Alert',
namespace_uid: 'namespace-uid',
rule_group: 'test-group',
condition: 'B',
no_data_state: GrafanaAlertStateDecision.NoData,
exec_err_state: GrafanaAlertStateDecision.Alerting,
is_paused: false,
data: [
{
refId: 'A',
datasourceUid: 'datasource-uid',
queryType: '',
relativeTimeRange: { from: 600, to: 0 },
// @ts-ignore
model: {
// refId is missing here
datasource: {
type: 'grafana-testdata-datasource',
uid: 'PD8C576611E62080A',
},
},
},
{
refId: 'B',
datasourceUid: ExpressionDatasourceUID,
queryType: '',
// @ts-ignore
model: {
// refId is missing here
type: ExpressionQueryType.reduce,
expression: 'A',
},
},
],
},
for: '5m',
labels: {},
annotations: {},
};
const result = fixMissingRefIdsInExpressionModel(ruleWithMissingRefId);
expect(result.grafana_alert.data[0].model.refId).toBe('A');
expect(result.grafana_alert.data[1].model.refId).toBe('B');
});
});
@@ -1,3 +1,5 @@
import { produce } from 'immer';
import {
DataSourceInstanceSettings,
IntervalValues,
@@ -278,14 +280,16 @@ function getEditorSettingsFromDTO(ga: GrafanaRuleDefinition) {
export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleFormValues {
const { ruleSourceName, namespace, group, rule } = ruleWithLocation;
const isGrafanaRecordingRule = rulerRuleType.grafana.recordingRule(rule);
const normalizedRule = fixMissingRefIdsInExpressionModel(rule);
const isGrafanaRecordingRule = rulerRuleType.grafana.recordingRule(normalizedRule);
const defaultFormValues = getDefaultFormValues(isGrafanaRecordingRule ? RuleFormType.grafanaRecording : undefined);
if (isGrafanaRulesSource(ruleSourceName)) {
// GRAFANA-MANAGED RULES
if (isGrafanaRecordingRule) {
// grafana recording rule
const ga = rule.grafana_alert;
const ga = normalizedRule.grafana_alert;
return {
...defaultFormValues,
name: ga.title,
@@ -294,16 +298,16 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
queries: ga.data,
condition: ga.condition,
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)),
labels: listifyLabelsOrAnnotations(rule.labels, true),
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(normalizedRule.annotations, false)),
labels: listifyLabelsOrAnnotations(normalizedRule.labels, true),
folder: { title: namespace, uid: ga.namespace_uid },
isPaused: ga.is_paused,
metric: ga.record?.metric,
targetDatasourceUid: ga.record?.target_datasource_uid || defaultFormValues.targetDatasourceUid,
};
} else if (rulerRuleType.grafana.rule(rule)) {
} else if (rulerRuleType.grafana.rule(normalizedRule)) {
// grafana alerting rule
const ga = rule.grafana_alert;
const ga = normalizedRule.grafana_alert;
const routingSettings: AlertManagerManualRouting | undefined = getContactPointsFromDTO(ga);
if (ga.no_data_state !== undefined && ga.exec_err_state !== undefined) {
return {
@@ -312,14 +316,14 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
type: RuleFormType.grafana,
group: group.name,
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
evaluateFor: rule.for || '0',
keepFiringFor: rule.keep_firing_for || '0',
evaluateFor: normalizedRule.for || '0',
keepFiringFor: normalizedRule.keep_firing_for || '0',
noDataState: ga.no_data_state,
execErrState: ga.exec_err_state,
queries: ga.data,
condition: ga.condition,
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)),
labels: listifyLabelsOrAnnotations(rule.labels, true),
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(normalizedRule.annotations, false)),
labels: listifyLabelsOrAnnotations(normalizedRule.labels, true),
folder: { title: namespace, uid: ga.namespace_uid },
isPaused: ga.is_paused,
@@ -338,7 +342,7 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
}
} else {
// DATASOURCE-MANAGED RULES
if (rulerRuleType.dataSource.alertingRule(rule)) {
if (rulerRuleType.dataSource.alertingRule(normalizedRule)) {
const datasourceUid = getDataSourceSrv().getInstanceSettings(ruleSourceName)?.uid ?? '';
const defaultQuery = {
@@ -346,27 +350,27 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
datasourceUid,
queryType: '',
relativeTimeRange: getDefaultRelativeTimeRange(),
expr: rule.expr,
expr: normalizedRule.expr,
model: {
refId: 'A',
hide: false,
expr: rule.expr,
expr: normalizedRule.expr,
},
};
const alertingRuleValues = alertingRulerRuleToRuleForm(rule);
const alertingRuleValues = alertingRulerRuleToRuleForm(normalizedRule);
return {
...defaultFormValues,
...alertingRuleValues,
queries: [defaultQuery],
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)),
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(normalizedRule.annotations, false)),
type: RuleFormType.cloudAlerting,
dataSourceName: ruleSourceName,
namespace,
group: group.name,
};
} else if (rulerRuleType.dataSource.recordingRule(rule)) {
} else if (rulerRuleType.dataSource.recordingRule(normalizedRule)) {
const datasourceUid = getDataSourceSrv().getInstanceSettings(ruleSourceName)?.uid ?? '';
const defaultQuery = {
@@ -374,15 +378,15 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
datasourceUid,
queryType: '',
relativeTimeRange: getDefaultRelativeTimeRange(),
expr: rule.expr,
expr: normalizedRule.expr,
model: {
refId: 'A',
hide: false,
expr: rule.expr,
expr: normalizedRule.expr,
},
};
const recordingRuleValues = recordingRulerRuleToRuleForm(rule);
const recordingRuleValues = recordingRulerRuleToRuleForm(normalizedRule);
return {
...defaultFormValues,
@@ -399,6 +403,23 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
}
}
/**
* This function isn't supposed to be needed, but we've noticed some customers are creating rules via Provisioning or
* other interfaces where they aren't including the RefId in the "model" of the expression so copy the refId from the query definition.
*/
export function fixMissingRefIdsInExpressionModel<T extends RulerRuleDTO>(rule: T): T {
// non-Grafana managed rules don't use expression nodes so we return the rule as-is
if (!rulerRuleType.grafana.rule(rule)) {
return rule;
}
return produce(rule, (draft) => {
draft.grafana_alert.data.forEach((query) => {
query.model.refId = query.model.refId ?? query.refId;
});
});
}
export function grafanaRuleDtoToFormValues(rule: RulerGrafanaRuleDTO, namespace: string): RuleFormValues {
const isGrafanaRecordingRule = rulerRuleType.grafana.recordingRule(rule);
const defaultFormValues = getDefaultFormValues(isGrafanaRecordingRule ? RuleFormType.grafanaRecording : undefined);
@@ -26,8 +26,12 @@ export interface DashboardEditPaneState extends SceneObjectState {
undoStack: DashboardEditActionEventPayload[];
redoStack: DashboardEditActionEventPayload[];
openPane?: DashboardSidebarPaneName;
isDocked?: boolean;
}
export type DashboardSidebarPaneName = 'element' | 'outline' | 'filters';
export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
public constructor() {
super({
@@ -192,6 +196,7 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
this.setState({
selectionContext: { ...this.state.selectionContext, selected: [], enabled: false },
selection: undefined,
openPane: this.state.openPane === 'element' ? undefined : this.state.openPane,
});
}
@@ -227,7 +232,6 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
}
const elementSelection = this.state.selection ?? new ElementSelection([[id, obj.getRef()]]);
const { selection, contextItems: selected } = elementSelection.getStateWithValue(id, obj, !!multi);
this.updateSelection(new ElementSelection(selection), selected);
@@ -255,17 +259,58 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
document.activeElement.blur();
}
this.setState({ selection, selectionContext: { ...this.state.selectionContext, selected } });
this.setState({
selection,
selectionContext: { ...this.state.selectionContext, selected },
openPane: selection ? 'element' : undefined,
});
}
public clearSelection() {
/**
* @param force If force = true it will clear selection even when docked
* @returns
*/
public clearSelection(force = false) {
if (!this.state.selection) {
return;
}
// If we are docked then clearing selection should select dashboard itself
// Unless the user explicitly closes pane
if (this.state.isDocked && !force) {
const obj = this.state.selection?.getFirstObject();
const dashboard = getDashboardSceneFor(this);
if (obj !== dashboard) {
this.selectObject(dashboard, dashboard.state.key!);
}
return;
}
this.updateSelection(undefined, []);
}
public openPane(openPane: DashboardSidebarPaneName) {
if (this.state.selection) {
this.clearSelection(true);
}
if (openPane === this.state.openPane) {
this.setState({ openPane: undefined });
} else {
this.setState({ openPane });
}
}
public closePane() {
if (this.state.selection) {
this.clearSelection(true);
}
if (this.state.openPane) {
this.setState({ openPane: undefined });
}
}
private newObjectAddedToCanvas(obj: SceneObject) {
this.selectObject(obj, obj.state.key!);
this.state.selection?.markAsNewElement();
@@ -1,18 +1,17 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { act, screen } from '@testing-library/react';
import { render } from 'test/test-utils';
import { getPanelPlugin } from '@grafana/data/test';
import { selectors } from '@grafana/e2e-selectors';
import { setPluginImportUtils } from '@grafana/runtime';
import { setPluginImportUtils, config } from '@grafana/runtime';
import { SceneGridLayout, SceneTimeRange, SceneVariableSet, VizPanel } from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { DashboardInteractions } from '../utils/interactions';
import { activateFullSceneTree } from '../utils/test-utils';
import { DashboardEditPaneRenderer } from './DashboardEditPaneRenderer';
import { DashboardEditPaneSplitter } from './DashboardEditPaneSplitter';
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
@@ -26,14 +25,9 @@ jest.mock('../utils/interactions', () => ({
},
}));
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useLocation: () => ({
pathname: '/dashboard/test',
search: '',
hash: '',
state: null,
}),
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
useChromeHeaderHeight: jest.fn().mockReturnValue(80),
}));
export function buildTestScene() {
@@ -47,26 +41,43 @@ export function buildTestScene() {
}),
}),
});
activateFullSceneTree(testScene);
return testScene;
}
describe('DashboardEditPaneRenderer', () => {
describe('outline interactions tracking', () => {
it('should call DashboardInteractions.outlineClicked when clicking on dashboard outline', async () => {
const user = userEvent.setup();
const scene = buildTestScene();
render(
<DashboardEditPaneRenderer
editPane={scene.state.editPane}
isEditPaneCollapsed={false}
onToggleCollapse={() => {}}
/>
);
const outlineButton = screen.getByTestId(selectors.components.PanelEditor.Outline.section);
await user.click(outlineButton);
config.featureToggles.dashboardNewLayouts = true;
expect(DashboardInteractions.dashboardOutlineClicked).toHaveBeenCalled();
});
it('Should render sidebar', async () => {
const scene = buildTestScene();
act(() => activateFullSceneTree(scene));
render(<DashboardEditPaneSplitter dashboard={scene} />);
expect(await screen.findByTestId(selectors.pages.Dashboard.Sidebar.outlineButton)).toBeInTheDocument();
});
it('Should sync sidebar docked state with edit pane state', async () => {
const scene = buildTestScene();
render(<DashboardEditPaneSplitter dashboard={scene} />);
act(() => screen.getByLabelText('Outline').click());
expect(await screen.findByTestId('sidebar-dock-toggle')).toBeInTheDocument();
act(() => screen.getByTestId('sidebar-dock-toggle').click());
expect(scene.state.editPane.state.isDocked).toBe(true);
});
// describe('outline interactions tracking', () => {
// it('should call DashboardInteractions.outlineClicked when clicking on dashboard outline', async () => {
// const user = userEvent.setup();
// const scene = buildTestScene();
// render(<DashboardEditPaneRenderer editPane={scene.state.editPane} dashboard={scene} />);
// const outlineButton = screen.getByTestId(selectors.components.PanelEditor.Outline.section);
// await user.click(outlineButton);
// expect(DashboardInteractions.dashboardOutlineClicked).toHaveBeenCalled();
// });
// });
});
@@ -1,211 +1,160 @@
import { css, cx } from '@emotion/css';
import { Resizable } from 're-resizable';
import { useLocalStorage } from 'react-use';
import { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { useSceneObjectState } from '@grafana/scenes';
import { useStyles2, useSplitter, ToolbarButton, ScrollContainer, Text, Icon, clearButtonStyles } from '@grafana/ui';
import { Sidebar } from '@grafana/ui';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardInteractions } from '../utils/interactions';
import { DashboardScene } from '../scene/DashboardScene';
import { onOpenSnapshotOriginalDashboard } from '../scene/GoToSnapshotOriginButton';
import { ManagedDashboardNavBarBadge } from '../scene/ManagedDashboardNavBarBadge';
import { ToolbarActionProps } from '../scene/new-toolbar/types';
import { dynamicDashNavActions } from '../utils/registerDynamicDashNavAction';
import { DashboardEditPane } from './DashboardEditPane';
import { ShareExportDashboardButton } from './DashboardExportButton';
import { DashboardOutline } from './DashboardOutline';
import { ElementEditPane } from './ElementEditPane';
import { useEditableElement } from './useEditableElement';
export interface Props {
editPane: DashboardEditPane;
isEditPaneCollapsed: boolean;
openOverlay?: boolean;
onToggleCollapse: () => void;
dashboard: DashboardScene;
isDocked?: boolean;
}
/**
* Making the EditPane rendering completely standalone (not using editPane.Component) in order to pass custom react props
*/
export function DashboardEditPaneRenderer({ editPane, isEditPaneCollapsed, onToggleCollapse, openOverlay }: Props) {
const { selection } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true });
const styles = useStyles2(getStyles);
const clearButton = useStyles2(clearButtonStyles);
const editableElement = useEditableElement(selection, editPane);
export function DashboardEditPaneRenderer({ editPane, dashboard, isDocked }: Props) {
const { selection, openPane } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true });
const { isEditing, meta, uid } = dashboard.useState();
const hasUid = Boolean(uid);
const selectedObject = selection?.getFirstObject();
const isNewElement = selection?.isNewElement() ?? false;
const [outlineCollapsed, setOutlineCollapsed] = useLocalStorage(
'grafana.dashboard.edit-pane.outline.collapsed',
false
);
const [outlinePaneSize = 0.4, setOutlinePaneSize] = useLocalStorage('grafana.dashboard.edit-pane.outline.size', 0.4);
// splitter for template and payload editor
const splitter = useSplitter({
direction: 'column',
handleSize: 'sm',
// if Grafana Alertmanager, split 50/50, otherwise 100/0 because there is no payload editor
initialSize: 1 - outlinePaneSize,
dragPosition: 'middle',
onSizeChanged: (size) => {
setOutlinePaneSize(1 - size);
},
});
const editableElement = useMemo(() => {
if (selection) {
return selection.createSelectionElement();
}
if (!editableElement) {
return null;
}
if (isEditPaneCollapsed) {
return (
<>
<div className={styles.expandOptionsWrapper}>
<ToolbarButton
tooltip={t('dashboard.edit-pane.open', 'Open options pane')}
icon="arrow-to-right"
onClick={onToggleCollapse}
variant="canvas"
narrow={true}
className={styles.rotate180}
aria-label={t('dashboard.edit-pane.open', 'Open options pane')}
/>
</div>
{openOverlay && (
<Resizable className={styles.overlayWrapper} defaultSize={{ height: '100%', width: '300px' }}>
<ElementEditPane
element={editableElement}
key={selectedObject?.state.key}
editPane={editPane}
isNewElement={isNewElement}
/>
</Resizable>
)}
</>
);
}
if (outlineCollapsed) {
splitter.primaryProps.style.flexGrow = 1;
splitter.primaryProps.style.minHeight = 'unset';
splitter.secondaryProps.style.flexGrow = 0;
splitter.secondaryProps.style.minHeight = 'min-content';
} else {
splitter.primaryProps.style.minHeight = 'unset';
splitter.secondaryProps.style.minHeight = 'unset';
}
return undefined;
}, [selection]);
return (
<div className={styles.wrapper}>
<div {...splitter.containerProps}>
<div {...splitter.primaryProps} className={cx(splitter.primaryProps.className, styles.paneContent)}>
<>
{editableElement && (
<Sidebar.OpenPane>
<ElementEditPane
element={editableElement}
key={selectedObject?.state.key}
editPane={editPane}
element={editableElement}
isNewElement={isNewElement}
/>
</div>
<div
{...splitter.splitterProps}
className={cx(splitter.splitterProps.className, styles.splitter)}
data-edit-pane-splitter={true}
/>
<div {...splitter.secondaryProps} className={cx(splitter.secondaryProps.className, styles.paneContent)}>
<button
type="button"
onClick={() => {
DashboardInteractions.dashboardOutlineClicked();
setOutlineCollapsed(!outlineCollapsed);
}}
className={cx(clearButton, styles.outlineCollapseButton)}
data-testid={selectors.components.PanelEditor.Outline.section}
>
<Text weight="medium">
<Trans i18nKey="dashboard-scene.dashboard-edit-pane-renderer.outline">Outline</Trans>
</Text>
<Icon name={outlineCollapsed ? 'angle-up' : 'angle-down'} />
</button>
{!outlineCollapsed && (
<div className={styles.outlineContainer}>
<ScrollContainer showScrollIndicators={true}>
<DashboardOutline editPane={editPane} />
</ScrollContainer>
</div>
)}
</div>
</div>
</div>
</Sidebar.OpenPane>
)}
{openPane === 'outline' && (
<Sidebar.OpenPane>
<DashboardOutline editPane={editPane} isEditing={isEditing} />
</Sidebar.OpenPane>
)}
<Sidebar.Toolbar>
{isEditing && (
<>
{config.featureToggles.dashboardUndoRedo && (
<>
<UndoButton dashboard={dashboard} />
<RedoButton dashboard={dashboard} />
</>
)}
<Sidebar.Button
icon="cog"
onClick={() => editPane.selectObject(dashboard, dashboard.state.key!)}
title={t('dashboard.sidebar.dashboard-options.title', 'Options')}
tooltip={t('dashboard.sidebar.dashboard-options.tooltip', 'Dashboard options')}
data-testid={selectors.pages.Dashboard.Sidebar.optionsButton}
active={selectedObject === dashboard ? true : false}
/>
<Sidebar.Button
tooltip={t('dashboard.sidebar.edit-schema.tooltip', 'Edit as code')}
title={t('dashboard.sidebar.edit-schema.title', 'Code')}
icon="brackets-curly"
onClick={() => dashboard.openV2SchemaEditor()}
/>
<Sidebar.Divider />
</>
)}
{hasUid && <ShareExportDashboardButton dashboard={dashboard} />}
<Sidebar.Button
icon="list-ui-alt"
onClick={() => editPane.openPane('outline')}
title={t('dashboard.sidebar.outline.title', 'Outline')}
tooltip={t('dashboard.sidebar.outline.tooltip', 'Content outline')}
data-testid={selectors.pages.Dashboard.Sidebar.outlineButton}
active={openPane === 'outline'}
></Sidebar.Button>
{dashboard.isManaged() && Boolean(meta.canEdit) && <ManagedDashboardNavBarBadge dashboard={dashboard} />}
{renderEnterpriseItems()}
{Boolean(meta.isSnapshot) && (
<Sidebar.Button
data-testid="button-snapshot"
tooltip={t('dashboard.sidebar.snapshot.tooltip', 'Open original dashboard')}
title={t('dashboard.toolbar.snapshot.title', 'Source')}
icon="link"
onClick={() => onOpenSnapshotOriginalDashboard(dashboard.getSnapshotUrl())}
/>
)}
</Sidebar.Toolbar>
</>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
display: 'flex',
flexDirection: 'column',
flex: '1 1 0',
marginTop: theme.spacing(2),
borderLeft: `1px solid ${theme.colors.border.weak}`,
borderTop: `1px solid ${theme.colors.border.weak}`,
background: theme.colors.background.primary,
borderTopLeftRadius: theme.shape.radius.default,
}),
overlayWrapper: css({
right: 0,
bottom: 0,
top: theme.spacing(2),
position: 'absolute !important' as 'absolute',
background: theme.colors.background.primary,
borderLeft: `1px solid ${theme.colors.border.weak}`,
borderTop: `1px solid ${theme.colors.border.weak}`,
boxShadow: theme.shadows.z3,
zIndex: theme.zIndex.navbarFixed,
flexGrow: 1,
}),
paneContent: css({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}),
rotate180: css({
rotate: '180deg',
}),
tabsbar: css({
padding: theme.spacing(0, 1),
margin: theme.spacing(0.5, 0),
}),
expandOptionsWrapper: css({
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(2, 1, 2, 0),
}),
splitter: css({
'&::after': {
background: 'transparent',
transform: 'unset',
width: '100%',
height: '1px',
top: '100%',
left: '0',
},
}),
outlineCollapseButton: css({
display: 'flex',
padding: theme.spacing(0.5, 2),
gap: theme.spacing(1),
justifyContent: 'space-between',
alignItems: 'center',
background: theme.colors.background.secondary,
function renderEnterpriseItems() {
const dashboard = getDashboardSrv().getCurrent()!;
const showProps = { dashboard };
'&:hover': {
background: theme.colors.action.hover,
},
}),
outlineContainer: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
overflow: 'hidden',
}),
};
return dynamicDashNavActions.right.map((action, index) => {
if (action.show(showProps)) {
const ActionComponent = action.component;
return <ActionComponent key={index} dashboard={dashboard} />;
}
return null;
});
}
function UndoButton({ dashboard }: ToolbarActionProps) {
const editPane = dashboard.state.editPane;
const { undoStack } = editPane.useState();
const undoAction = undoStack[undoStack.length - 1];
const undoWord = t('dashboard.sidebar.undo', 'Undo');
const tooltip = `${undoWord}${undoAction?.description ? ` ${undoAction.description}` : ''}`;
return (
<Sidebar.Button
icon="corner-up-left"
disabled={undoStack.length === 0}
onClick={() => editPane.undoAction()}
title={undoWord}
tooltip={tooltip}
/>
);
}
function RedoButton({ dashboard }: ToolbarActionProps) {
const editPane = dashboard.state.editPane;
const { redoStack } = editPane.useState();
const redoAction = redoStack[redoStack.length - 1];
const redoWord = t('dashboard.sidebar.redo', 'Redo');
const tooltip = `${redoWord}${redoAction?.description ? ` ${redoAction.description}` : ''}`;
return (
<Sidebar.Button
icon="corner-up-right"
disabled={redoStack.length === 0}
title={redoWord}
tooltip={tooltip}
onClick={() => editPane.redoAction()}
/>
);
}
@@ -1,19 +1,22 @@
import { css, cx } from '@emotion/css';
import React, { CSSProperties, useEffect } from 'react';
import React, { useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config, useChromeHeaderHeight } from '@grafana/runtime';
import { useSceneObjectState } from '@grafana/scenes';
import { ElementSelectionContext, useStyles2 } from '@grafana/ui';
import { ElementSelectionContext, useSidebar, useStyles2, Sidebar } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import NativeScrollbar, { DivScrollElement } from 'app/core/components/NativeScrollbar';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { useSnappingSplitter } from '../panel-edit/splitter/useSnappingSplitter';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { PublicDashboardBadge } from '../scene/new-toolbar/actions/PublicDashboardBadge';
import { StarButton } from '../scene/new-toolbar/actions/StarButton';
import { dynamicDashNavActions } from '../utils/registerDynamicDashNavAction';
import { DashboardEditPaneRenderer } from './DashboardEditPaneRenderer';
import { useEditPaneCollapsed } from './shared';
interface Props {
dashboard: DashboardScene;
@@ -26,7 +29,10 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
const headerHeight = useChromeHeaderHeight();
const { editPane } = dashboard.state;
const styles = useStyles2(getStyles, headerHeight ?? 0);
const [isCollapsed, setIsCollapsed] = useEditPaneCollapsed();
const hasUid = Boolean(dashboard.state.uid);
const canStar = Boolean(dashboard.state.meta.canStar);
//const [isCollapsed, setIsCollapsed] = useEditPaneCollapsed();
if (!config.featureToggles.dashboardNewLayouts) {
return (
@@ -40,21 +46,6 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
);
}
const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } =
useSnappingSplitter({
direction: 'row',
dragPosition: 'end',
initialSize: 330,
handleSize: 'sm',
usePixels: true,
collapseBelowPixels: 250,
collapsed: isCollapsed,
});
useEffect(() => {
setIsCollapsed(splitterState.collapsed);
}, [splitterState.collapsed, setIsCollapsed]);
/**
* Enable / disable selection based on dashboard isEditing state
*/
@@ -66,15 +57,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
}
}, [isEditing, editPane]);
const { selectionContext } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true });
const containerStyle: CSSProperties = {};
if (!isEditing) {
primaryProps.style.flexGrow = 1;
primaryProps.style.width = '100%';
primaryProps.style.minWidth = 'unset';
containerStyle.overflow = 'unset';
}
const { selectionContext, openPane } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true });
const onBodyRef = (ref: HTMLDivElement | null) => {
if (ref) {
@@ -82,54 +65,73 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
}
};
return (
<div {...containerProps} style={containerStyle}>
<ElementSelectionContext.Provider value={selectionContext}>
<div
{...primaryProps}
className={cx(primaryProps.className, styles.canvasWithSplitter)}
onPointerDown={(evt) => {
if (evt.shiftKey) {
return;
}
const sidebarContext = useSidebar({
hasOpenPane: Boolean(openPane),
contentMargin: 1,
position: 'right',
});
editPane.clearSelection();
}}
>
<NavToolbarActions dashboard={dashboard} />
<div className={cx(!isEditing && styles.controlsWrapperSticky)}>{controls}</div>
<div className={styles.bodyWrapper}>
<div
className={cx(styles.body, isEditing && styles.bodyEditing)}
data-testid={selectors.components.DashboardEditPaneSplitter.primaryBody}
ref={onBodyRef}
>
{body}
</div>
/**
* Sync docked state to editPane state
*/
useEffect(() => {
editPane.setState({ isDocked: sidebarContext.isDocked });
}, [sidebarContext.isDocked, editPane]);
const onClearSelection: React.PointerEventHandler<HTMLDivElement> = (evt) => {
if (evt.shiftKey) {
return;
}
editPane.clearSelection();
};
return (
<div className={styles.container}>
<ElementSelectionContext.Provider value={selectionContext}>
<AppChromeUpdate
breadcrumbActions={
<>
{hasUid && canStar && <StarButton dashboard={dashboard} />}
{hasUid && canStar && <PublicDashboardBadge dashboard={dashboard} />}
{renderDynamicNavActions()}
</>
}
/>
<div className={cx(styles.controlsWrapperSticky)} onPointerDown={onClearSelection}>
{controls}
</div>
<div className={styles.bodyWrapper} {...sidebarContext.outerWrapperProps}>
<div
className={styles.bodyWithToolbar}
data-testid={selectors.components.DashboardEditPaneSplitter.primaryBody}
ref={onBodyRef}
onPointerDown={onClearSelection}
>
{body}
</div>
<Sidebar contextValue={sidebarContext}>
<DashboardEditPaneRenderer editPane={editPane} dashboard={dashboard} isDocked={sidebarContext.isDocked} />
</Sidebar>
</div>
{isEditing && (
<>
<div
{...splitterProps}
className={cx(splitterProps.className, styles.splitter)}
data-edit-pane-splitter={true}
/>
<div {...secondaryProps} className={cx(secondaryProps.className, styles.editPane)}>
<DashboardEditPaneRenderer
editPane={editPane}
isEditPaneCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
openOverlay={selectionContext.selected.length > 0}
/>
</div>
</>
)}
</ElementSelectionContext.Provider>
</div>
);
}
function renderDynamicNavActions() {
const dashboard = getDashboardSrv().getCurrent()!;
const showProps = { dashboard };
return dynamicDashNavActions.left.map((action, index) => {
if (action.show(showProps)) {
const ActionComponent = action.component;
return <ActionComponent key={index} dashboard={dashboard} />;
}
return null;
});
}
function getStyles(theme: GrafanaTheme2, headerHeight: number) {
return {
canvasWrappperOld: css({
@@ -138,22 +140,33 @@ function getStyles(theme: GrafanaTheme2, headerHeight: number) {
flexDirection: 'column',
flexGrow: 1,
}),
canvasWithSplitter: css({
overflow: 'unset',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
}),
canvasWithSplitterEditing: css({
overflow: 'unset',
}),
bodyWrapper: css({
label: 'body-wrapper',
container: css({
label: 'container',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
position: 'relative',
}),
bodyWrapper: css({
label: 'body-wrapper',
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
position: 'relative',
flex: '1 1 0',
overflow: 'hidden',
}),
bodyWithToolbar: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
minHeight: 0,
overflow: 'auto',
scrollbarWidth: 'thin',
scrollbarGutter: 'stable',
// without top padding the fixed controls headers is rendered over the selection outline.
padding: theme.spacing(0.125, 1, 2, 2),
}),
body: css({
label: 'body',
display: 'flex',
@@ -181,11 +194,6 @@ function getStyles(theme: GrafanaTheme2, headerHeight: number) {
// borderLeft: `1px solid ${theme.colors.border.weak}`,
// background: theme.colors.background.primary,
}),
splitter: css({
'&:after': {
display: 'none',
},
}),
controlsWrapperSticky: css({
[theme.breakpoints.up('md')]: {
position: 'sticky',
@@ -8,10 +8,9 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan
import { DashboardScene } from '../scene/DashboardScene';
import { useLayoutCategory } from '../scene/layouts-shared/DashboardLayoutSelector';
import { EditSchemaV2Button } from '../scene/new-toolbar/actions/EditSchemaV2Button';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
import { dashboardEditActions, undoRedoWasClicked } from './shared';
import { dashboardEditActions } from './shared';
function useEditPaneOptions(
this: DashboardEditableElement,
@@ -69,19 +68,16 @@ export class DashboardEditableElement implements EditableDashboardElement {
public renderActions(): ReactNode {
return (
<>
<EditSchemaV2Button dashboard={this.dashboard} />
<Button
variant="secondary"
size="sm"
onClick={() => this.dashboard.onOpenSettings()}
tooltip={t('dashboard.toolbar.dashboard-settings.tooltip', 'Dashboard settings')}
icon="sliders-v-alt"
iconPlacement="right"
>
<Trans i18nKey="dashboard.actions.open-settings">Settings</Trans>
</Button>
</>
<Button
variant="secondary"
size="sm"
onClick={() => this.dashboard.onOpenSettings()}
tooltip={t('dashboard.toolbar.dashboard-settings.tooltip', 'Dashboard settings')}
icon="sliders-v-alt"
iconPlacement="right"
>
<Trans i18nKey="dashboard.actions.open-settings">Settings</Trans>
</Button>
);
}
}
@@ -104,7 +100,7 @@ export function DashboardTitleInput({ dashboard, id }: { dashboard: DashboardSce
}}
onBlur={(e) => {
const titleUnchanged = valueBeforeEdit.current === e.currentTarget.value;
const shouldSkip = titleUnchanged || undoRedoWasClicked(e);
const shouldSkip = titleUnchanged;
if (shouldSkip) {
return;
}
@@ -135,7 +131,7 @@ export function DashboardDescriptionInput({ dashboard, id }: { dashboard: Dashbo
}}
onBlur={(e) => {
const descriptionUnchanged = valueBeforeEdit.current === e.currentTarget.value;
const shouldSkip = descriptionUnchanged || undoRedoWasClicked(e);
const shouldSkip = descriptionUnchanged;
if (shouldSkip) {
return;
}
@@ -0,0 +1,57 @@
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { locationService } from '@grafana/runtime';
import { Dropdown, Sidebar } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import { ShowConfirmModalEvent } from 'app/types/events';
import { DashboardScene } from '../scene/DashboardScene';
import ExportMenu from '../sharing/ExportButton/ExportMenu';
import { DashboardInteractions } from '../utils/interactions';
interface Props {
dashboard: DashboardScene;
}
const newExportButtonSelector = selectors.pages.Dashboard.DashNav.NewExportButton;
export function ShareExportDashboardButton({ dashboard }: Props) {
return (
<Dropdown overlay={<ExportMenu dashboard={dashboard} />} placement="left-end">
<Sidebar.Button
icon="download-alt"
data-testid={newExportButtonSelector.Menu.container}
title={t('dashboard.sidebar.export.title', 'Export')}
onPointerDown={(evt) => {
if (dashboard.state.isEditing && dashboard.state.isDirty) {
evt.preventDefault();
evt.stopPropagation();
appEvents.publish(
new ShowConfirmModalEvent({
title: t('dashboard.sidebar.export.unsaved-modal.title', 'Save changes to dashboard?'),
text: t(
'dashboard.sidebar.export.unsaved-modal.text',
'You have unsaved changes to this dashboard. You need to save them before you can share it.'
),
icon: 'exclamation-triangle',
noText: t('common.discard', 'Discard'),
yesText: t('common.save', 'Save'),
yesButtonVariant: 'primary',
onConfirm: () => dashboard.openSaveDrawer({}),
})
);
} else {
locationService.partial({ shareView: shareDashboardType.export });
DashboardInteractions.sharingCategoryClicked({
item: shareDashboardType.export,
shareResource: getTrackingSource(),
});
}
}}
/>
</Dropdown>
);
}
@@ -101,7 +101,7 @@ describe('DashboardOutline', () => {
render(
<ElementSelectionContext.Provider value={scene.state.editPane.state.selectionContext}>
<DashboardOutline editPane={scene.state.editPane} />
<DashboardOutline editPane={scene.state.editPane} isEditing={true} />
</ElementSelectionContext.Provider>
);
// select Row lvl 1
@@ -5,7 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { SceneObject } from '@grafana/scenes';
import { Box, Icon, Stack, Text, useElementSelection, useStyles2 } from '@grafana/ui';
import { Box, Icon, Sidebar, Stack, Text, useElementSelection, useStyles2 } from '@grafana/ui';
import { isRepeatCloneOrChildOf } from '../utils/clone';
import { DashboardInteractions } from '../utils/interactions';
@@ -17,28 +17,36 @@ import { useOutlineRename } from './useOutlineRename';
export interface Props {
editPane: DashboardEditPane;
isEditing: boolean | undefined;
}
export function DashboardOutline({ editPane }: Props) {
export function DashboardOutline({ editPane, isEditing }: Props) {
const dashboard = getDashboardSceneFor(editPane);
return (
<Box padding={1} gap={0} display="flex" direction="column" element="ul" role="tree" position="relative">
<DashboardOutlineNode sceneObject={dashboard} editPane={editPane} depth={0} index={0} />
</Box>
<>
<Sidebar.PaneHeader
title={t('dashboard.outline.pane-header', 'Content outline')}
onClose={() => editPane.closePane()}
/>
<Box padding={1} gap={0} display="flex" direction="column" element="ul" role="tree" position="relative">
<DashboardOutlineNode sceneObject={dashboard} isEditing={isEditing} editPane={editPane} depth={0} index={0} />
</Box>
</>
);
}
interface DashboardOutlineNodeProps {
sceneObject: SceneObject;
editPane: DashboardEditPane;
isEditing: boolean | undefined;
depth: number;
index: number;
}
function DashboardOutlineNode({ sceneObject, editPane, depth, index }: DashboardOutlineNodeProps) {
function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }: DashboardOutlineNodeProps) {
const styles = useStyles2(getStyles);
const { key } = sceneObject.useState();
const key = sceneObject.state.key;
const [isCollapsed, setIsCollapsed] = useState(depth > 0);
const { isSelected, onSelect } = useElementSelection(key);
const isCloned = useMemo(() => isRepeatCloneOrChildOf(sceneObject), [sceneObject]);
@@ -49,7 +57,7 @@ function DashboardOutlineNode({ sceneObject, editPane, depth, index }: Dashboard
const children = editableElement.getOutlineChildren?.() ?? [];
const elementInfo = editableElement.getEditableElementInfo();
const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName;
const outlineRename = useOutlineRename(editableElement);
const outlineRename = useOutlineRename(editableElement, isEditing);
const isContainer = editableElement.getOutlineChildren ? true : false;
const onNodeClicked = (e: React.MouseEvent) => {
@@ -131,6 +139,7 @@ function DashboardOutlineNode({ sceneObject, editPane, depth, index }: Dashboard
sceneObject={child}
editPane={editPane}
depth={depth + 1}
isEditing={isEditing}
index={i}
/>
))
@@ -1,9 +1,6 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { Button, Menu, Stack, Text, useStyles2, Dropdown, Icon, IconButton } from '@grafana/ui';
import { Button, Menu, Stack, Dropdown, Icon, Sidebar } from '@grafana/ui';
import { trackDeleteDashboardElement } from 'app/features/dashboard-scene/utils/tracking';
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
@@ -17,15 +14,11 @@ interface EditPaneHeaderProps {
export function EditPaneHeader({ element, editPane }: EditPaneHeaderProps) {
const elementInfo = element.getEditableElementInfo();
const styles = useStyles2(getStyles);
const onCopy = element.onCopy?.bind(element);
const onDuplicate = element.onDuplicate?.bind(element);
const onDelete = element.onDelete?.bind(element);
const onConfirmDelete = element.onConfirmDelete?.bind(element);
// temporary simple solution, should select parent element
const onGoBack = () => editPane.clearSelection();
const canGoBack = editPane.state.selection;
const onDeleteElement = () => {
if (onConfirmDelete) {
@@ -37,20 +30,7 @@ export function EditPaneHeader({ element, editPane }: EditPaneHeaderProps) {
};
return (
<div className={styles.wrapper}>
<Stack direction="row" gap={0.5}>
{canGoBack && (
<IconButton
name="arrow-left"
size="lg"
onClick={onGoBack}
tooltip={t('grafana.dashboard.edit-pane.go-back', 'Go back')}
aria-label={t('grafana.dashboard.edit-pane.go-back', 'Go back')}
data-testid={selectors.components.EditPaneHeader.backButton}
/>
)}
<Text>{elementInfo.typeName}</Text>
</Stack>
<Sidebar.PaneHeader title={elementInfo.typeName} onClose={() => editPane.closePane()}>
<Stack direction="row" gap={1}>
{element.renderActions && element.renderActions()}
{(onCopy || onDuplicate) && (
@@ -95,18 +75,6 @@ export function EditPaneHeader({ element, editPane }: EditPaneHeaderProps) {
/>
)}
</Stack>
</div>
</Sidebar.PaneHeader>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing(1, 2),
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
};
}
@@ -14,7 +14,6 @@ import {
import { DashboardScene } from '../scene/DashboardScene';
import { SceneGridRowEditableElement } from '../scene/layout-default/SceneGridRowEditableElement';
import { redoButtonId, undoButtonID } from '../scene/new-toolbar/RightActions';
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types/EditableDashboardElement';
import { LocalVariableEditableElement } from '../settings/variables/LocalVariableEditableElement';
import { VariableAdd, VariableAddEditableElement } from '../settings/variables/VariableAddEditableElement';
@@ -299,7 +298,3 @@ function makeEditAction<Source extends SceneObject, T extends keyof Source['stat
});
};
}
export function undoRedoWasClicked(e: React.FocusEvent) {
return e.relatedTarget && (e.relatedTarget.id === undoButtonID || e.relatedTarget.id === redoButtonId);
}
@@ -1,21 +0,0 @@
import { useMemo } from 'react';
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
import { getDashboardSceneFor } from '../utils/utils';
import { DashboardEditPane } from './DashboardEditPane';
import { ElementSelection } from './ElementSelection';
export function useEditableElement(
selection: ElementSelection | undefined,
editPane: DashboardEditPane
): EditableDashboardElement | undefined {
return useMemo(() => {
if (!selection) {
const dashboard = getDashboardSceneFor(editPane);
return new ElementSelection([[dashboard.state.uid!, dashboard.getRef()]]).createSelectionElement();
}
return selection.createSelectionElement();
}, [selection, editPane]);
}
@@ -8,10 +8,14 @@ export interface OutlineRenameState {
error?: string;
}
export function useOutlineRename(editableElement: EditableDashboardElement) {
export function useOutlineRename(editableElement: EditableDashboardElement, isEditing: boolean | undefined) {
const [state, setState] = useState<OutlineRenameState>({});
const onNameDoubleClicked = (evt: React.MouseEvent) => {
if (!isEditing) {
return;
}
if (!editableElement.onChangeName) {
return;
}
@@ -40,14 +40,15 @@ import { PanelDataAlertingTab, PanelDataAlertingTabRendered } from './PanelDataA
jest.mock('app/features/alerting/unified/api/prometheus');
jest.mock('app/features/alerting/unified/api/ruler');
jest.mock('@grafana/assistant', () => ({
useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }),
}));
jest.spyOn(ruleActionButtons, 'matchesWidth').mockReturnValue(false);
jest.spyOn(ruler, 'rulerUrlBuilder');
jest.spyOn(alertingAbilities, 'useAlertRuleAbility');
setPluginLinksHook(() => ({
links: [],
isLoading: false,
}));
setPluginLinksHook(() => ({ links: [], isLoading: false }));
const dataSources = {
prometheus: mockDataSource<PromOptions>(
@@ -2,6 +2,8 @@ import { css, cx } from '@emotion/css';
import { GrafanaTheme2, VariableHide } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import {
SceneObjectState,
SceneObjectBase,
@@ -15,7 +17,8 @@ import {
SceneObjectUrlValues,
CancelActivationHandler,
} from '@grafana/scenes';
import { Box, useStyles2 } from '@grafana/ui';
import { Box, Button, useStyles2 } from '@grafana/ui';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { PanelEditControls } from '../panel-edit/PanelEditControls';
import { getDashboardSceneFor } from '../utils/utils';
@@ -25,6 +28,9 @@ import { DashboardDataLayerControls } from './DashboardDataLayerControls';
import { DashboardLinksControls } from './DashboardLinksControls';
import { DashboardScene } from './DashboardScene';
import { VariableControls } from './VariableControls';
import { EditDashboardSwitch } from './new-toolbar/actions/EditDashboardSwitch';
import { SaveDashboard } from './new-toolbar/actions/SaveDashboard';
import { ShareDashboardButton } from './new-toolbar/actions/ShareDashboardButton';
export interface DashboardControlsState extends SceneObjectState {
timePicker: SceneTimePicker;
@@ -32,7 +38,7 @@ export interface DashboardControlsState extends SceneObjectState {
hideTimeControls?: boolean;
hideVariableControls?: boolean;
hideLinksControls?: boolean;
// Hides the dashbaord-controls dropdown menu
// Hides the dashboard-controls dropdown menu
hideDashboardControls?: boolean;
}
@@ -171,6 +177,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
</div>
)}
{!hideDashboardControls && model.hasDashboardControls() && <DashboardControlsButton dashboard={dashboard} />}
{config.featureToggles.dashboardNewLayouts && <DashboardControlActions dashboard={dashboard} />}
</div>
{!hideVariableControls && (
<>
@@ -185,6 +192,37 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
);
}
function DashboardControlActions({ dashboard }: { dashboard: DashboardScene }) {
const { isEditing, editPanel, uid, meta } = dashboard.useState();
const { isPlaying } = playlistSrv.useState();
if (editPanel) {
return null;
}
const canEditDashboard = dashboard.canEditDashboard();
const hasUid = Boolean(uid);
const isSnapshot = Boolean(meta.isSnapshot);
const showShareButton = hasUid && !isSnapshot && !isPlaying;
return (
<>
{showShareButton && <ShareDashboardButton dashboard={dashboard} />}
{isEditing && <SaveDashboard dashboard={dashboard} />}
{!isPlaying && canEditDashboard && <EditDashboardSwitch dashboard={dashboard} />}
{isPlaying && (
<Button
variant="secondary"
onClick={() => playlistSrv.stop()}
data-testid={selectors.pages.Dashboard.DashNav.playlistControls.stop}
>
<Trans i18nKey="dashboard.toolbar.new.playlist-stop">Stop playlist</Trans>
</Button>
)}
</>
);
}
function renderHiddenVariables(dashboard: DashboardScene) {
const { variables } = sceneGraph.getVariables(dashboard).useState();
const renderAsHiddenVariables = variables.filter((v) => v.UNSAFE_renderAsHidden);
@@ -275,7 +275,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
this._initialUrlState = locationService.getLocation();
// Switch to edit mode
this.setState({ isEditing: true });
this.setState({ isEditing: true, editable: true });
// Propagate change edit mode change to children
this.state.body.editModeChanged?.(true);
@@ -692,16 +692,16 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
canEditDashboard() {
const { meta } = this.state;
return Boolean(meta.canEdit || meta.canMakeEditable || config.viewersCanEdit);
return !meta.isSnapshot && Boolean(meta.canEdit || meta.canMakeEditable || config.viewersCanEdit);
}
public getInitialSaveModel() {
return this.serializer.initialSaveModel;
}
public getSnapshotUrl = () => {
return this.serializer.getSnapshotUrl();
};
public getSnapshotUrl() {
return this.serializer.getSnapshotUrl() ?? '';
}
/** Hacky temp function until we refactor transformSaveModelToScene a bit */
setInitialSaveModel(model?: Dashboard, meta?: DashboardMeta, apiVersion?: string): void;
@@ -20,7 +20,7 @@ export function GoToSnapshotOriginButton(props: { originalURL: string }) {
);
}
const onOpenSnapshotOriginalDashboard = (originalUrl: string) => {
export const onOpenSnapshotOriginalDashboard = (originalUrl: string) => {
const relativeURL = originalUrl ?? '';
const sanitizedRelativeURL = textUtil.sanitizeUrl(relativeURL);
try {
@@ -153,9 +153,7 @@ export function ToolbarActions({ dashboard }: Props) {
toolbarActions.push({
group: 'icon-actions',
condition: meta.isSnapshot && !isEditing,
render: () => (
<GoToSnapshotOriginButton key="go-to-snapshot-origin" originalURL={dashboard.getSnapshotUrl() ?? ''} />
),
render: () => <GoToSnapshotOriginButton key="go-to-snapshot-origin" originalURL={dashboard.getSnapshotUrl()} />,
});
if (!isEditingPanel && !isEditing) {
@@ -1,8 +1,7 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { ToolbarButton, ToolbarButtonRow, useStyles2 } from '@grafana/ui';
import { ToolbarButtonRow, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
@@ -11,45 +10,35 @@ import { isLibraryPanel } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { BackToDashboardButton } from './actions/BackToDashboardButton';
import { DashboardSettingsButton } from './actions/DashboardSettingsButton';
import { DiscardLibraryPanelButton } from './actions/DiscardLibraryPanelButton';
import { DiscardPanelButton } from './actions/DiscardPanelButton';
import { EditDashboardSwitch } from './actions/EditDashboardSwitch';
import { ExportDashboardButton } from './actions/ExportDashboardButton';
import { MakeDashboardEditableButton } from './actions/MakeDashboardEditableButton';
import { PlayListNextButton } from './actions/PlayListNextButton';
import { PlayListPreviousButton } from './actions/PlayListPreviousButton';
import { PlayListStopButton } from './actions/PlayListStopButton';
import { SaveDashboard } from './actions/SaveDashboard';
import { SaveLibraryPanelButton } from './actions/SaveLibraryPanelButton';
import { ShareDashboardButton } from './actions/ShareDashboardButton';
import { UnlinkLibraryPanelButton } from './actions/UnlinkLibraryPanelButton';
import { ToolbarActionProps } from './types';
import { getDynamicActions, renderActionElements } from './utils';
export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => {
const { editPanel, editable, editview, isEditing, uid, meta, viewPanel } = dashboard.useState();
const { editPanel, editable, editview, isEditing, meta, viewPanel } = dashboard.useState();
const { isPlaying } = playlistSrv.useState();
const styles = useStyles2(getStyles);
const isEditable = Boolean(editable);
const canSave = Boolean(meta.canSave);
const hasUid = Boolean(uid);
const isEditingDashboard = Boolean(isEditing);
const hasEditView = Boolean(editview);
const isEditingPanel = Boolean(editPanel);
const isViewingPanel = Boolean(viewPanel);
const isEditingLibraryPanel = isEditingPanel && isLibraryPanel(editPanel!.state.panelRef.resolve());
const isShowingDashboard = !hasEditView && !isViewingPanel && !isEditingPanel;
const isEditingAndShowingDashboard = isEditingDashboard && isShowingDashboard;
const isSnapshot = Boolean(meta.isSnapshot);
const canSaveInFolder = contextSrv.hasEditPermissionInFolders;
const canEditDashboard = dashboard.canEditDashboard();
const showPanelButtons = isEditingPanel && !hasEditView && !isViewingPanel;
const showPlayButtons = isPlaying && isShowingDashboard && !isEditingDashboard;
const showShareButton = hasUid && !isSnapshot && !isPlaying && !isEditingPanel;
const showUndoRedoButtons = isEditingAndShowingDashboard && !!config.featureToggles.dashboardUndoRedo;
return (
<ToolbarButtonRow alignment="right" className={styles.container}>
@@ -106,28 +95,10 @@ export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => {
group: 'panel',
condition: showPanelButtons && isEditingLibraryPanel,
},
{
key: 'dashboard-undo',
component: UndoButton,
group: 'dashboard',
condition: showUndoRedoButtons,
},
{
key: 'dashboard-redo',
component: RedoButton,
group: 'dashboard',
condition: showUndoRedoButtons,
},
{
key: 'dashboard-settings',
component: DashboardSettingsButton,
group: 'dashboard',
condition: isEditingAndShowingDashboard && canEditDashboard,
},
{
key: 'save-dashboard',
component: SaveDashboard,
group: 'save-edit',
group: 'panel',
condition: isEditingDashboard && !isEditingLibraryPanel && (canSave || canSaveInFolder),
},
{
@@ -136,31 +107,6 @@ export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => {
group: 'save-edit',
condition: !isEditing && canEditDashboard && !isViewingPanel && !isEditable && !isPlaying,
},
{
key: 'edit-dashboard-switch',
component: EditDashboardSwitch,
group: 'save-edit',
condition:
canEditDashboard &&
!isEditingPanel &&
!isEditingLibraryPanel &&
!isViewingPanel &&
isEditable &&
!isPlaying &&
!isEditingPanel,
},
{
key: 'new-export-dashboard-button',
component: ExportDashboardButton,
group: 'export-share',
condition: showShareButton,
},
{
key: 'new-share-dashboard-button',
component: ShareDashboardButton,
group: 'export-share',
condition: showShareButton,
},
],
dashboard
)}
@@ -168,42 +114,6 @@ export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => {
);
};
export const undoButtonID = 'undo-button';
function UndoButton({ dashboard }: ToolbarActionProps) {
const editPane = dashboard.state.editPane;
const { undoStack } = editPane.useState();
const undoAction = undoStack[undoStack.length - 1];
const tooltip = `Undo${undoAction?.description ? ` '${undoAction.description}'` : ''}`;
return (
<ToolbarButton
id={undoButtonID}
icon="corner-up-left"
disabled={undoStack.length === 0}
onClick={() => editPane.undoAction()}
tooltip={tooltip}
/>
);
}
export const redoButtonId = 'redo-button';
function RedoButton({ dashboard }: ToolbarActionProps) {
const editPane = dashboard.state.editPane;
const { redoStack } = editPane.useState();
const redoAction = redoStack[redoStack.length - 1];
const tooltip = `Redo${redoAction?.description ? ` '${redoAction.description}'` : ''}`;
return (
<ToolbarButton
id={redoButtonId}
icon="corner-up-right"
disabled={redoStack.length === 0}
tooltip={tooltip}
onClick={() => editPane.redoAction()}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({ paddingLeft: theme.spacing(0.5) }),
});
@@ -1,39 +0,0 @@
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { config, locationService } from '@grafana/runtime';
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import ExportMenu from '../../../sharing/ExportButton/ExportMenu';
import { DashboardInteractions } from '../../../utils/interactions';
import { ToolbarActionProps } from '../types';
import { ShareExportDashboardButton } from './ShareExportDashboardButton';
const newExportButtonSelector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton;
export const ExportDashboardButton = ({ dashboard }: ToolbarActionProps) => {
const buttonTooltip = config.featureToggles.kubernetesDashboards
? t('dashboard.toolbar.new.export.tooltip.as-code', 'Export as code')
: t('dashboard.toolbar.new.export.tooltip.json', 'Export as JSON');
return (
<ShareExportDashboardButton
menu={() => <ExportMenu dashboard={dashboard} />}
groupTestId={newExportButtonSelector.container}
buttonLabel={t('dashboard.toolbar.new.export.title', 'Export')}
buttonTooltip={buttonTooltip}
buttonTestId={newExportButtonSelector.container}
onButtonClick={() => {
locationService.partial({ shareView: shareDashboardType.export });
DashboardInteractions.sharingCategoryClicked({
item: shareDashboardType.export,
shareResource: getTrackingSource(),
});
}}
arrowLabel={t('dashboard.toolbar.new.export.arrow', 'Export')}
arrowTestId={newExportButtonSelector.arrowMenu}
dashboard={dashboard}
/>
);
};
@@ -2,5 +2,5 @@ import { GoToSnapshotOriginButton } from '../../GoToSnapshotOriginButton';
import { ToolbarActionProps } from '../types';
export const OpenSnapshotOriginButton = ({ dashboard }: ToolbarActionProps) => (
<GoToSnapshotOriginButton originalURL={dashboard.getSnapshotUrl() ?? ''} />
<GoToSnapshotOriginButton originalURL={dashboard.getSnapshotUrl()} />
);
@@ -6,10 +6,12 @@ import { contextSrv } from 'app/core/services/context_srv';
import { ToolbarActionProps } from '../types';
export const SaveDashboard = ({ dashboard }: ToolbarActionProps) => {
const { meta, isDirty, uid } = dashboard.state;
const { meta, isDirty, uid, editview, editPanel } = dashboard.state;
const isNew = !Boolean(uid || dashboard.isManaged());
const isManaged = dashboard.isManaged();
// In dashboard settings we still use the nav toolbar for a short while
const buttonSize = Boolean(editview) || editPanel ? 'sm' : 'md';
// if we only can save
if (isNew) {
@@ -17,7 +19,7 @@ export const SaveDashboard = ({ dashboard }: ToolbarActionProps) => {
<Button
onClick={() => dashboard.openSaveDrawer({})}
tooltip={t('dashboard.toolbar.new.save-dashboard.tooltip', 'Save changes')}
size="sm"
size={buttonSize}
variant="primary"
data-testid={selectors.components.NavToolbar.editDashboard.saveButton}
>
@@ -32,7 +34,7 @@ export const SaveDashboard = ({ dashboard }: ToolbarActionProps) => {
<Button
onClick={() => dashboard.openSaveDrawer({ saveAsCopy: true })}
tooltip={t('dashboard.toolbar.new.save-dashboard-copy.tooltip', 'Save as copy')}
size="sm"
size={buttonSize}
variant={isDirty ? 'primary' : 'secondary'}
>
<Trans i18nKey="dashboard.toolbar.new.save-dashboard-copy.label">Save as copy</Trans>
@@ -45,7 +47,7 @@ export const SaveDashboard = ({ dashboard }: ToolbarActionProps) => {
<Button
onClick={() => dashboard.openSaveDrawer({})}
tooltip={t('dashboard.toolbar.new.save-dashboard.tooltip', 'Save changes')}
size="sm"
size={buttonSize}
data-testid={selectors.components.NavToolbar.editDashboard.saveButton}
variant={isDirty ? 'primary' : 'secondary'}
>
@@ -71,7 +73,7 @@ export const SaveDashboard = ({ dashboard }: ToolbarActionProps) => {
aria-label={t('dashboard.toolbar.new.more-save-options', 'More save options')}
icon="angle-down"
variant={isDirty ? 'primary' : 'secondary'}
size="sm"
size={buttonSize}
/>
</Dropdown>
</ButtonGroup>
@@ -60,7 +60,7 @@ export const ShareExportDashboardButton = ({
}
}}
>
<Button data-testid={buttonTestId} size="sm" tooltip={buttonTooltip} variant={variant} onClick={onButtonClick}>
<Button data-testid={buttonTestId} size="md" tooltip={buttonTooltip} variant={variant} onClick={onButtonClick}>
{buttonLabel}
</Button>
<Dropdown
@@ -79,7 +79,7 @@ export const ShareExportDashboardButton = ({
<Button
aria-label={arrowLabel}
data-testid={arrowTestId}
size="sm"
size="md"
icon={isOpen ? 'angle-up' : 'angle-down'}
variant={variant}
/>
@@ -43,7 +43,7 @@ export const ToolbarSwitch = ({
onClick={disabled ? undefined : onClick}
>
<div className={cx(styles.box, checked && styles.boxChecked)}>
<Icon name={iconName} size="xs" />
<Icon name={iconName} size="md" />
</div>
</button>
</Tooltip>
@@ -53,11 +53,11 @@ export const ToolbarSwitch = ({
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
border: `1px solid ${theme.components.input.borderColor}`,
padding: theme.spacing(0.25),
padding: theme.spacing(0.5),
backgroundColor: theme.components.input.background,
borderRadius: theme.shape.radius.default,
width: theme.spacing(5.5),
height: theme.spacing(3),
width: theme.spacing(6.5),
height: theme.spacing(theme.components.height.md),
cursor: 'pointer',
display: 'flex',
flexDirection: 'row',
@@ -90,19 +90,19 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: theme.spacing(2.5),
width: theme.spacing(3.5),
height: '100%',
transform: 'translateX(0)',
position: 'relative',
borderRadius: styleMixins.getInternalRadius(theme, 2),
border: `1px solid ${theme.colors.secondary.border}`,
border: `1px solid ${theme.colors.border.weak}`,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'all 0.2s ease-in-out',
},
}),
boxChecked: css({
transform: `translateX(calc(100% - ${theme.spacing(0.25)}))`,
transform: `translateX(calc(100% - 14px))`,
borderColor: 'transparent',
}),
});
@@ -9,7 +9,7 @@ import { Input, TextArea, Button, Field, Box, Stack } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { dashboardEditActions, undoRedoWasClicked } from '../../edit-pane/shared';
import { dashboardEditActions } from '../../edit-pane/shared';
import { useEditPaneInputAutoFocus } from '../../scene/layouts-shared/utils';
import { BulkActionElement } from '../../scene/types/BulkActionElement';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../../scene/types/EditableDashboardElement';
@@ -161,7 +161,7 @@ function VariableNameInput({ variable, isNewElement }: { variable: SceneVariable
onChange={onChange}
onBlur={(e) => {
const labelUnchanged = oldName.current === name;
const shouldSkip = labelUnchanged || undoRedoWasClicked(e);
const shouldSkip = labelUnchanged;
if (nameError) {
setNameError(undefined);
@@ -200,7 +200,7 @@ function VariableLabelInput({ variable, id }: VariableInputProps) {
onChange={(e) => variable.setState({ label: e.currentTarget.value })}
onBlur={(e) => {
const labelUnchanged = oldLabel.current === e.currentTarget.value;
const shouldSkip = labelUnchanged || undoRedoWasClicked(e);
const shouldSkip = labelUnchanged;
if (shouldSkip) {
return;
@@ -232,7 +232,7 @@ function VariableDescriptionTextArea({ variable, id }: VariableInputProps) {
onChange={(e) => variable.setState({ description: e.currentTarget.value })}
onBlur={(e) => {
const labelUnchanged = oldDescription.current === e.currentTarget.value;
const shouldSkip = labelUnchanged || undoRedoWasClicked(e);
const shouldSkip = labelUnchanged;
if (shouldSkip) {
return;
@@ -15,7 +15,7 @@ import { SuggestedDashboards } from '../DashboardLibrary/SuggestedDashboards';
import { DashboardEmptyExtensionPoint } from './DashboardEmptyExtensionPoint';
import {
useIsReadOnlyRepo,
useRepositoryStatus,
useOnAddVisualization,
useOnAddLibraryPanel,
useOnImportDashboard,
@@ -149,10 +149,10 @@ export interface Props {
// We pass the default empty UI through to the extension point so that the extension can conditionally render it if needed.
// For example, an extension might want to render custom UI for a specific experiment cohort, and the default UI for everyone else.
const DashboardEmpty = (props: Props) => {
const isReadOnlyRepo = useIsReadOnlyRepo(props);
const onAddVisualization = useOnAddVisualization({ ...props, isReadOnlyRepo });
const onAddLibraryPanel = useOnAddLibraryPanel({ ...props, isReadOnlyRepo });
const onImportDashboard = useOnImportDashboard({ ...props, isReadOnlyRepo });
const { isReadOnlyRepo, isProvisioned } = useRepositoryStatus(props);
const onAddVisualization = useOnAddVisualization({ ...props, isReadOnlyRepo, isProvisioned });
const onAddLibraryPanel = useOnAddLibraryPanel({ ...props, isReadOnlyRepo, isProvisioned });
const onImportDashboard = useOnImportDashboard({ ...props, isReadOnlyRepo, isProvisioned });
return (
<DashboardEmptyExtensionPoint
@@ -16,16 +16,23 @@ import {
import type { Props } from './DashboardEmpty';
export const useIsReadOnlyRepo = ({ dashboard }: Props) => {
const { isReadOnlyRepo } = useGetResourceRepositoryView({
export const useRepositoryStatus = ({ dashboard }: Props) => {
const { isReadOnlyRepo, repository } = useGetResourceRepositoryView({
folderName: dashboard instanceof DashboardScene ? dashboard.state.meta.folderUid : dashboard.meta.folderUid,
});
return isReadOnlyRepo;
const isFolderProvisioned = Boolean(repository);
const isProvisioned = isFolderProvisioned || (dashboard instanceof DashboardScene && dashboard.isManagedRepository());
return {
isReadOnlyRepo,
isProvisioned,
};
};
interface HookProps extends Props {
isReadOnlyRepo: boolean;
isProvisioned: boolean;
}
export const useOnAddVisualization = ({ dashboard, canCreate, isReadOnlyRepo }: HookProps) => {
@@ -53,9 +60,7 @@ export const useOnAddVisualization = ({ dashboard, canCreate, isReadOnlyRepo }:
}, [canCreate, isReadOnlyRepo, dashboard, dispatch, initialDatasource]);
};
export const useOnAddLibraryPanel = ({ dashboard, canCreate, isReadOnlyRepo }: HookProps) => {
const isProvisioned = dashboard instanceof DashboardScene && dashboard.isManagedRepository();
export const useOnAddLibraryPanel = ({ dashboard, canCreate, isReadOnlyRepo, isProvisioned }: HookProps) => {
return useMemo(() => {
if (!canCreate || isProvisioned || isReadOnlyRepo) {
return undefined;
@@ -72,8 +77,7 @@ export const useOnAddLibraryPanel = ({ dashboard, canCreate, isReadOnlyRepo }: H
}, [canCreate, isProvisioned, isReadOnlyRepo, dashboard]);
};
export const useOnImportDashboard = ({ dashboard, canCreate, isReadOnlyRepo }: HookProps) => {
const isProvisioned = dashboard instanceof DashboardScene && dashboard.isManagedRepository();
export const useOnImportDashboard = ({ canCreate, isReadOnlyRepo, isProvisioned }: HookProps) => {
return useMemo(() => {
if (!canCreate || isProvisioned || isReadOnlyRepo) {
return undefined;
+38 -21
View File
@@ -197,7 +197,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
);
const [dedupStrategy, setDedupStrategy] = useState<LogsDedupStrategy>(LogsDedupStrategy.none);
const [logsSortOrder, setLogsSortOrder] = useState<LogsSortOrder>(
store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending
panelState?.logs?.sortOrder ?? store.get(SETTINGS_KEYS.logsSortOrder) ?? LogsSortOrder.Descending
);
const [isFlipping, setIsFlipping] = useState<boolean>(false);
const [displayedFields, setDisplayedFields] = useState<string[]>(panelState?.logs?.displayedFields ?? []);
@@ -269,6 +269,18 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
}
}, [dispatch, exploreId, loading, panelState, previousLoading]);
useEffect(() => {
// Initialize URL sort order
if (!panelState?.logs?.sortOrder) {
dispatch(
changePanelState(exploreId, 'logs', {
...panelState,
sortOrder: logsSortOrder,
})
);
}
}, [dispatch, exploreId, logsSortOrder, panelState]);
useEffect(() => {
const visualisationType = panelState?.logs?.visualisationType ?? getDefaultVisualisationType();
setVisualisationType(visualisationType);
@@ -287,23 +299,17 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
useUnmount(() => {
// If we're unmounting logs (e.g. switching to another datasource), we need to remove the logs specific panel state, otherwise it will persist in the explore url
if (
panelState?.logs?.columns ||
panelState?.logs?.refId ||
panelState?.logs?.labelFieldName ||
panelState?.logs?.displayedFields
) {
dispatch(
changePanelState(exploreId, 'logs', {
...panelState?.logs,
columns: undefined,
visualisationType: visualisationType,
labelFieldName: undefined,
refId: undefined,
displayedFields: undefined,
})
);
}
dispatch(
changePanelState(exploreId, 'logs', {
...panelState?.logs,
columns: undefined,
visualisationType: visualisationType,
labelFieldName: undefined,
refId: undefined,
displayedFields: undefined,
sortOrder: undefined,
})
);
});
const updatePanelState = useCallback(
@@ -398,8 +404,14 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
dispatch(changeQueries({ exploreId, queries: newQueries }));
dispatch(runQueries({ exploreId }));
}
dispatch(
changePanelState(exploreId, 'logs', {
...panelState?.logs,
sortOrder: newSortOrder,
})
);
},
[dispatch, exploreId, logsQueries]
[dispatch, exploreId, logsQueries, panelState?.logs]
);
const onChangeLogsSortOrder = useCallback(
@@ -588,7 +600,12 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const urlState = getUrlStateFromPaneState(getState().explore.panes[exploreId]!);
urlState.panelsState = {
...panelState,
logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType(), displayedFields },
logs: {
id: row.uid,
visualisationType: visualisationType ?? getDefaultVisualisationType(),
displayedFields,
sortOrder: logsSortOrder,
},
};
urlState.range = getLogsPermalinkRange(row, logRows, absoluteRange);
@@ -604,7 +621,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
logRowLevel: row.logLevel,
});
},
[absoluteRange, displayedFields, exploreId, logRows, panelState, visualisationType]
[absoluteRange, displayedFields, exploreId, logRows, logsSortOrder, panelState, visualisationType]
);
const scrollToTopLogs = useCallback(() => {
@@ -3,9 +3,9 @@ import userEvent from '@testing-library/user-event';
import { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { PluginExtensionPoints, PluginExtensionTypes } from '@grafana/data';
import { ExplorePanelsState, PluginExtensionPoints, PluginExtensionTypes } from '@grafana/data';
import { usePluginLinks } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { DataQuery, LogsSortOrder } from '@grafana/schema';
import { contextSrv } from 'app/core/services/context_srv';
import { configureStore } from 'app/store/configureStore';
import { ExplorePanelData, ExploreState } from 'app/types/explore';
@@ -27,13 +27,14 @@ const usePluginLinksMock = jest.mocked(usePluginLinks);
type storeOptions = {
targets: DataQuery[];
data: ExplorePanelData;
panelsState?: ExplorePanelsState;
};
function renderWithExploreStore(
children: ReactNode,
options: storeOptions = { targets: [{ refId: 'A' }], data: createEmptyQueryResponse() }
) {
const { targets, data } = options;
const { targets, data, panelsState } = options;
const store = configureStore({
explore: {
panes: {
@@ -43,6 +44,7 @@ function renderWithExploreStore(
range: {
raw: { from: 'now-1h', to: 'now' },
},
panelsState,
},
},
} as unknown as ExploreState,
@@ -90,6 +92,9 @@ describe('ToolbarExtensionPoint', () => {
isLoading: false,
});
});
beforeEach(() => {
jest.mocked(usePluginLinksMock).mockClear();
});
it('should render "Add" extension point menu button', () => {
renderWithExploreStore(setupToolbarExtensionPoint());
@@ -180,6 +185,20 @@ describe('ToolbarExtensionPoint', () => {
expect(extensionPointId).toBe(PluginExtensionPoints.ExploreToolbarAction);
});
it('should pass panelsState to the extensions', async () => {
const panelsState: ExplorePanelsState = {
logs: { sortOrder: LogsSortOrder.Ascending, displayedFields: ['time', 'body'] },
};
const targets = [{ refId: 'A' }];
const data = createEmptyQueryResponse();
renderWithExploreStore(setupToolbarExtensionPoint(), { targets, data, panelsState });
const [options] = usePluginLinksMock.mock.calls[0];
const { context } = options;
expect(context).toHaveProperty('panelsState', panelsState);
});
});
describe('with extension points without categories', () => {
@@ -1,6 +1,12 @@
import { ReactElement, useMemo, useState } from 'react';
import { type PluginExtensionLink, PluginExtensionPoints, RawTimeRange, getTimeZone } from '@grafana/data';
import {
type ExplorePanelsState,
type PluginExtensionLink,
PluginExtensionPoints,
RawTimeRange,
getTimeZone,
} from '@grafana/data';
import { reportInteraction, usePluginLinks } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema';
import { contextSrv } from 'app/core/services/context_srv';
@@ -89,13 +95,14 @@ export type PluginExtensionExploreContext = {
timeRange: RawTimeRange;
timeZone: TimeZone;
shouldShowAddCorrelation: boolean;
panelsSate?: ExplorePanelsState;
};
function useExtensionPointContext(props: Props): PluginExtensionExploreContext {
const { exploreId, timeZone } = props;
const isCorrelationDetails = useSelector(selectCorrelationDetails);
const isCorrelationsEditorMode = isCorrelationDetails?.editorMode || false;
const { queries, queryResponse, range } = useSelector(getExploreItemSelector(exploreId))!;
const { queries, queryResponse, range, panelsState } = useSelector(getExploreItemSelector(exploreId))!;
const isLeftPane = useSelector(isLeftPaneSelector(exploreId));
const datasourceUids = queries.map((query) => query?.datasource?.uid).filter((uid) => uid !== undefined);
@@ -110,16 +117,18 @@ function useExtensionPointContext(props: Props): PluginExtensionExploreContext {
timeRange: range.raw,
timeZone: getTimeZone({ timeZone }),
shouldShowAddCorrelation: canWriteCorrelations && !isCorrelationsEditorMode && isLeftPane && numUniqueIds === 1,
panelsState,
};
}, [
canWriteCorrelations,
exploreId,
isCorrelationsEditorMode,
isLeftPane,
numUniqueIds,
panelsState,
queries,
queryResponse,
range.raw,
timeZone,
canWriteCorrelations,
isCorrelationsEditorMode,
isLeftPane,
numUniqueIds,
]);
}
@@ -15,6 +15,7 @@ export const AzureAuthSettings = (props: HttpSettingsBaseProps) => {
const { dataSourceConfig: dsSettings, onChange } = props;
const managedIdentityEnabled = config.azure.managedIdentityEnabled;
const azureEntraPasswordCredentialsEnabled = config.azure.azureEntraPasswordCredentialsEnabled;
const userIdentityEnabled = config.azure.userIdentityEnabled;
const credentials = useMemo(() => getCredentials(dsSettings), [dsSettings]);
@@ -33,6 +34,7 @@ export const AzureAuthSettings = (props: HttpSettingsBaseProps) => {
<AzureCredentialsForm
managedIdentityEnabled={managedIdentityEnabled}
azureEntraPasswordCredentialsEnabled={azureEntraPasswordCredentialsEnabled}
userIdentityEnabled={userIdentityEnabled}
credentials={credentials}
azureCloudOptions={KnownAzureClouds}
onCredentialsChange={onCredentialsChange}
@@ -8,6 +8,7 @@ import { Button, Field, Select, Input } from '@grafana/ui';
export interface Props {
managedIdentityEnabled: boolean;
azureEntraPasswordCredentialsEnabled: boolean;
userIdentityEnabled: boolean;
credentials: AzureCredentials;
azureCloudOptions?: SelectableValue[];
onCredentialsChange: (updatedCredentials: AzureCredentials) => void;
@@ -22,13 +23,25 @@ export const AzureCredentialsForm = (props: Props) => {
azureCloudOptions,
onCredentialsChange,
disabled,
userIdentityEnabled,
} = props;
const onAuthTypeChange = (selected: SelectableValue<AzureAuthType>) => {
const defaultAuthType = (() => {
if (managedIdentityEnabled) {
return 'msi';
}
if (userIdentityEnabled) {
return 'currentuser';
}
return 'clientsecret';
})();
if (onCredentialsChange) {
const updated: AzureCredentials = {
...credentials,
authType: selected.value || 'msi',
authType: selected.value || defaultAuthType,
};
onCredentialsChange(updated);
}
@@ -131,6 +144,13 @@ export const AzureCredentialsForm = (props: Props) => {
value: 'ad-password',
label: t('azureauth.azure-credentials-form.auth-options-azure-entra', 'Azure Entra Password'),
});
if (userIdentityEnabled) {
authTypeOptions.unshift({
value: 'currentuser',
label: 'Current User',
});
}
}
return (
+33 -15
View File
@@ -477,6 +477,7 @@
"noOptionsMessage-no-datasources-found": "No datasources found"
},
"alert-menu": {
"analyze-rule": "Analyze rule",
"copy-link": "Copy link",
"duplicate": "Duplicate",
"export": "Export",
@@ -4172,6 +4173,7 @@
"clear": "Clear",
"collapse": "Collapse",
"disabled": "Disabled",
"discard": "Discard",
"edit": "Edit",
"help": "Help",
"loading": "Loading...",
@@ -4789,7 +4791,6 @@
"variable": "{{type}} variable",
"variable-set": "Variables"
},
"open": "Open options pane",
"row": {
"header": {
"hide": "Hide",
@@ -5140,6 +5141,7 @@
"title-matched_other": "Matched {{count}}/{{totalCount}} options"
},
"outline": {
"pane-header": "Content outline",
"repeated-item": "Repeat",
"tree-item": {
"empty": "(empty)",
@@ -5351,6 +5353,32 @@
"share-public-dashboard-loader": {
"loading-configuration": "Loading configuration"
},
"sidebar": {
"dashboard-options": {
"title": "Options",
"tooltip": "Dashboard options"
},
"edit-schema": {
"title": "Code",
"tooltip": "Edit as code"
},
"export": {
"title": "Export",
"unsaved-modal": {
"text": "You have unsaved changes to this dashboard. You need to save them before you can share it.",
"title": "Save changes to dashboard?"
}
},
"outline": {
"title": "Outline",
"tooltip": "Content outline"
},
"redo": "Redo",
"snapshot": {
"tooltip": "Open original dashboard"
},
"undo": "Undo"
},
"solo-panel": {
"loading-initializing-dashboard": "Loading & initializing dashboard",
"title-not-found": "Panel with id {{panelId}} not found"
@@ -5454,11 +5482,8 @@
"tooltip": "This dashboard was marked as read only"
},
"export": {
"arrow": "Export",
"title": "Export",
"tooltip": {
"as-code": "Export as code",
"json": "Export as JSON"
"as-code": "Export as code"
}
},
"more-save-options": "More save options",
@@ -5510,6 +5535,9 @@
"save-library-panel": "Save library panel",
"settings": "Dashboard settings",
"share-button": "Share",
"snapshot": {
"title": "Source"
},
"star-add-error": "Failed to add to starred",
"star-added": "Added to starred",
"star-remove-error": "Failed to remove from starred",
@@ -5839,9 +5867,6 @@
"name-values-separated-comma": "Values separated by comma",
"selection-options": "Selection options"
},
"dashboard-edit-pane-renderer": {
"outline": "Outline"
},
"dashboard-link-form": {
"back-to-list": "Back to list",
"label-icon": "Icon",
@@ -8121,13 +8146,6 @@
}
}
},
"grafana": {
"dashboard": {
"edit-pane": {
"go-back": "Go back"
}
}
},
"grafana-data": {
"datetime": {
"rangeutils": {
+1 -1
View File
@@ -60,7 +60,7 @@ if (( ${#failed_packages[@]} > 0 )); then
fi
# Check if any files in packages/grafana-e2e-selectors were changed. If so, add a 'modified' tag to the package
CHANGES_COUNT=$(git diff HEAD~1..HEAD --name-only -- packages/grafana-e2e-selectors | awk 'END{print NR}')
CHANGES_COUNT=$(git show --name-only --format= HEAD -- packages/grafana-e2e-selectors | awk 'END{print NR}')
if (( CHANGES_COUNT > 0 )); then
# Wait a little bit to allow the package to be published to the registry
sleep 5s