Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Ryan McKinley
2025-02-13 21:22:26 +03:00
83 changed files with 724 additions and 904 deletions
+5
View File
@@ -384,6 +384,11 @@
/crowdin.yml @grafana/grafana-frontend-platform
/public/locales/ @grafana/grafana-frontend-platform
/public/locales/de-DE @grafanabot
/public/locales/es-ES @grafanabot
/public/locales/fr-FR @grafanabot
/public/locales/pt-BR @grafanabot
/public/locales/zh-Hans @grafanabot
/public/app/core/internationalization/ @grafana/grafana-frontend-platform
/e2e/ @grafana/grafana-frontend-platform
/e2e/cloud-plugins-suite/ @grafana/partner-datasources
+47 -10
View File
@@ -3,7 +3,7 @@ name: Crowdin Download Action
on:
workflow_dispatch:
schedule:
- cron: "0 * * * *"
- cron: "0 0 * * *"
jobs:
download-sources-from-crowdin:
@@ -12,6 +12,7 @@ jobs:
permissions:
contents: write # needed to commit changes into the PR
pull-requests: write # needed to update PR description, labels, etc
id-token: write # needed to get vault secrets
steps:
- name: Generate token
@@ -41,17 +42,11 @@ jobs:
pull_request_body: |
:robot: Automatic download of translations from Crowdin.
Steps for merging:
1. A quick sanity check of the changes and approve. Things to look out for:
- No changes in the English file. The source of truth is in the main branch, NOT in Crowdin.
- Translations maybe be removed if the English phrase was removed, but there should not be many of these
- Anything else that looks 'funky'. Ask if you're not sure.
2. Approve & (Auto-)merge. :tada:
This runs once per day and will merge automatically if all the required checks pass.
If there's a conflict, close the pull request and **delete the branch**. A GH action will recreate the pull request.
Remember, the longer this pull request is open, the more likely it is that it'll get conflicts.
If there's a conflict, close the pull request and **delete the branch**.
You can then either wait for the schedule to trigger a new PR, or rerun the action manually.
pull_request_labels: 'area/frontend, area/internationalization, no-changelog, no-backport'
pull_request_reviewers: 'grafana-frontend-platform'
pull_request_base_branch_name: 'main'
base_url: 'https://grafana.api.crowdin.com'
config: 'crowdin.yml'
@@ -119,3 +114,45 @@ jobs:
with:
pr: ${{ steps.crowdin-download.outputs.pull_request_number }}
token: ${{ steps.generate_token.outputs.token }}
- name: Get vault secrets
id: vault-secrets
uses: grafana/shared-workflows/actions/get-vault-secrets@main
with:
# Secrets placed in ci/repo/grafana/grafana/grafana-pr-approver
repo_secrets: |
GRAFANA_PR_APPROVER_APP_ID=grafana-pr-approver:app-id
GRAFANA_PR_APPROVER_APP_PEM=grafana-pr-approver:private-key
- name: Generate approver token
if: steps.crowdin-download.outputs.pull_request_url
id: generate_approver_token
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92
with:
app_id: ${{ env.GRAFANA_PR_APPROVER_APP_ID }}
private_key: ${{ env.GRAFANA_PR_APPROVER_APP_PEM }}
- name: Approve and automerge PR
if: steps.crowdin-download.outputs.pull_request_url
shell: bash
# Only approve if:
# - the PR does not modify files other than json files under the public/locales/ directory
# - the PR does not modify the en-US locale
run: |
filesChanged=$(gh pr diff --name-only ${{ steps.crowdin-download.outputs.pull_request_url }})
if [[ $(echo $filesChanged | grep -v 'public/locales/[a-zA-Z\-]*/grafana.json' | wc -l) -ne 0 ]]; then
echo "Non-i18n changes detected, not approving"
exit 1
fi
if [[ $(echo $filesChanged | grep "public/locales/en-US" | wc -l) -ne 0 ]]; then
echo "public/locales/en-US changes detected, not approving"
exit 1
fi
echo "Approving and enabling automerge"
gh pr review ${{ steps.crowdin-download.outputs.pull_request_url }} --approve
gh pr merge --auto --squash ${{ steps.crowdin-download.outputs.pull_request_url }}
env:
GITHUB_TOKEN: ${{ steps.generate_approver_token.outputs.token }}
@@ -369,9 +369,11 @@ Here are two ways to achieve this:
# Update the role
curl -H 'Authorization: Bearer glsa_kcVxDhZtu5ISOZIEt' -H 'Content-Type: application/json' \
-X PUT-d @/tmp/basic_viewer.json '<grafana_url>/api/access-control/roles/basic_viewer'
-X PUT -d @/tmp/basic_viewer.json '<grafana_url>/api/access-control/roles/basic_viewer'
```
The token that is used in this request is the [service account token](ref:service-accounts).
- Or use the `role > from` list and `permission > state` option of your provisioning file:
```yaml
@@ -394,6 +396,20 @@ Here are two ways to achieve this:
state: 'present'
```
If your goal is to remove an access to an app you should remove it from the role and update it. For example:
```bash
# Fetch the role, modify it to remove permissions to kentik-connect-app and increment role version
curl -H 'Authorization: Bearer glsa_kcVxDhZtu5ISOZIEt' \
-X GET '<grafana_url>/api/access-control/roles/basic_viewer' | \
jq 'del(.created)| del(.updated) | del(.permissions[].created) | del(.permissions[].updated) | .version += 1' | \
jq 'del(.permissions[] | select (.action == "plugins.app:access" and .scope == "plugins:id:kentik-connect-app"))'
# Update the role
curl -H 'Authorization: Bearer glsa_kcVxDhZtu5ISOZIEt' -H 'Content-Type: application/json' \
-X PUT -d @/tmp/basic_viewer.json '<grafana_url>/api/access-control/roles/basic_viewer'
```
### Manage user permissions through teams
In the scenario where you want users to grant access by the team they belong to, we recommend to set users role to `No Basic Role` and let the team assignment assign the role instead.
@@ -140,13 +140,13 @@ Each contact point integration has its own configuration options and setup proce
- [Discord](ref:discord)
- [Email](ref:email)
- [Google Chat](ref:gchat)
- [Grafana Oncall](ref:oncall)
- [Grafana OnCall](ref:oncall)
- Kafka REST Proxy
- Line
- [Microsoft Teams](ref:teams)
- [MQTT](ref:mqtt)
- [Opsgenie](ref:opsgenie)
- [Pagerduty](ref:pagerduty)
- [PagerDuty](ref:pagerduty)
- Pushover
- Sensu Go
- [Slack](ref:slack)
@@ -3,6 +3,7 @@ labels:
products:
- enterprise
- oss
- cloud
title: Correlations Editor in Explore
weight: 20
---
@@ -13,22 +14,22 @@ weight: 20
The Explore editor is available in 10.1 and later versions. In the editor, transformations is available in Grafana 10.3 and later versions.
{{% /admonition %}}
Correlations allow users to build a link between any two data sources. For more information about correlations in general, please see the [correlations]({{< relref "../administration/correlations" >}}) topic in the administration page.
Correlations allow users to build a link between any two data sources. For more information about correlations in general, please see the [correlations](/docs/grafana/<GRAFANA_VERSION>/administration/correlations/) topic in the administration page.
## Create a correlation
1. In Grafana, navigate to the Explore page.
1. Select a data source that you would like to be [the source data source]({{< relref "../administration/correlations/correlation-configuration#source-data-source-and-result-field" >}}) for a new correlation.
1. Run a query producing data in [a supported visualization]({{< relref "../administration/correlations#correlations" >}}).
1. Click **+ Add** in the top toolbar and select **Add correlation** (you can also select **Correlations Editor** from the [Command Palette]({{< relref "../search#command-palette" >}})).
1. Select a data source that you would like to be [the source data source](/docs/grafana/<GRAFANA_VERSION>/administration/correlations/correlation-configuration/#source-data-source-and-result-field) for a new correlation.
1. Run a query producing data in [a supported visualization](/docs/grafana/<GRAFANA_VERSION>/administration/correlations/#correlations).
1. Click **+ Add** in the top toolbar and select **Add correlation** (you can also select **Correlations Editor** from the [Command Palette](/docs/grafana/<GRAFANA_VERSION>/search/#command-palette)).
1. Explore is now in Correlations Editor mode indicated by a blue border and top bar. You can exit Correlations Editor by clicking **Exit** in the top bar.
1. You can now create the following new correlations for the visualization with links that are attached to the data that you can use to build a new query:
- Logs: links are displayed next to field values inside log details for each log row
- Table: every table cell is a link
1. Click on a link to add a new correlation.
Links are associated with a field that is used as a [result field of a correlation]({{< relref "../administration/correlations/correlation-configuration" >}}).
1. In the split view that opens, use the right pane to set up [the target query source of the correlation]({{< relref "../administration/correlations/correlation-configuration#target-query" >}}).
1. Build a target query using [variables syntax]({{< relref "../dashboards/variables/variable-syntax" >}}) with variables from the list provided at the top of the pane. The list contains sample values from the selected data row.
Links are associated with a field that is used as a [result field of a correlation](/docs/grafana/<GRAFANA_VERSION>/administration/correlations/correlation-configuration/).
1. In the split view that opens, use the right pane to set up [the target query source of the correlation](/docs/grafana/<GRAFANA_VERSION>/administration/correlations/correlation-configuration/#target-query).
1. Build a target query using [variables syntax](/docs/grafana/<GRAFANA_VERSION>/dashboards/variables/variable-syntax/) with variables from the list provided at the top of the pane. The list contains sample values from the selected data row.
1. Provide a label and description (optional).
A label will be used as the name of the link inside the visualization and can contain variables.
1. Provide transformations (optional; see below for details).
@@ -37,7 +38,7 @@ Correlations allow users to build a link between any two data sources. For more
## Transformations
Transformations allow you to extract values that exist in a field with other data. For example, using a transformation, you can extract one portion of a log line to use in a correlation. For more details on transformations in correlations, see [Correlations]({{< relref "../administration/correlations/correlation-configuration/#correlation-transformations" >}}).
Transformations allow you to extract values that exist in a field with other data. For example, using a transformation, you can extract one portion of a log line to use in a correlation. For more details on transformations in correlations, see [Correlations](/docs/grafana/<GRAFANA_VERSION>/explore/correlations-editor-in-explore/#transformations).
After clicking one of the generated links in the editor mode, you can add transformations by clicking **Add transformation** in the Transformations dropdown menu.
@@ -47,7 +48,7 @@ You can use a transformation in your correlation with the following steps:
Select the portion of the field that you want to use for the transformation. For example, a log line.
Once selected, the value of this field will be used to assist you in building the transformation.
1. Select the type of the transformation.
See [correlations]({{< relref "../administration/correlations/correlation-configuration/#correlation-transformations" >}}) for the options and relevant settings.
See [correlations](/docs/grafana/<GRAFANA_VERSION>/explore/correlations-editor-in-explore/#transformations) for the options and relevant settings.
1. Based on your selection, you might see one or more variables populate, or you might need to provide more specifications in options that are displayed.
1. Select **Add transformation to correlation** to add the specified variables to the list of available variables.
@@ -57,7 +58,7 @@ For regular expressions in this dialog box, the `mapValue` referred to in other
## Correlations examples
The following examples show how to create correlations using the Correlations Editor in Explore. If you'd like to follow these examples, make sure to set up a [test data source]({{< relref "../datasources/testdata#testdata-data-source" >}}).
The following examples show how to create correlations using the Correlations Editor in Explore. If you'd like to follow these examples, make sure to set up a [test data source](/docs/grafana/<GRAFANA_VERSION>/datasources/testdata/#testdata-data-source).
### Create a text to graph correlation
@@ -65,7 +66,7 @@ This example shows how to create a correlation using Correlations Editor in Expl
Correlations allow you to use results of one query to run a new query in any data source. In this example, you will run a query that renders tabular data. The data will be used to run a different query that yields a graph result.
To follow this example, make sure you have set up [a test data source]({{< relref "../datasources/testdata#testdata-data-source" >}}).
To follow this example, make sure you have set up [a test data source](/docs/grafana/<GRAFANA_VERSION>/datasources/testdata/#testdata-data-source).
1. In Grafana, navigate to **Explore**.
1. Select the **test data source** from the dropdown menu at the top left of the page.
@@ -100,7 +101,7 @@ You can apply the same steps to any data source. Correlations allow you to creat
In this example, you will create a correlation to demonstrate how to use transformations to extract values from the log line and another field.
To follow this example, make sure you have set up [a test data source]({{< relref "../datasources/testdata#testdata-data-source" >}}).
To follow this example, make sure you have set up [a test data source](/docs/grafana/<GRAFANA_VERSION>/datasources/testdata/#testdata-data-source).
1. In Grafana, navigate to **Explore**.
1. Select the **test data source** from the dropdown menu at the top left of the page.
@@ -25,9 +25,9 @@ weight: 80
refs:
global-variables:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#**from-and-**to
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#__from-and-__to
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/visualizations/dashboards/variables/add-template-variables/#**from-and-**to
destination: /docs/grafana-cloud/visualizations/dashboards/variables/add-template-variables/#__from-and-__to
heatmap:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/heatmap/
@@ -253,13 +253,17 @@ If you want to add all of the current dashboard's variables to the URL, then use
1. Hover over any part of the panel to display the menu icon in the upper-right corner.
1. Click the menu icon and select **Edit** to open the panel editor.
1. In the panel edit pane, scroll down to the **Data links** section and expand it.
1. Click **Add link**.
1. In the dialog box that opens, enter a **Title**. This is a human-readable label for the link, which will be displayed in the UI.
1. Enter the **URL** or variable to which you want to link.
1. Click **+ Add link**.
1. In the dialog box that opens, enter a **Title**. This is a human-readable label for the link displayed in the UI. This is a required field.
1. Enter the **URL** or variable to which you want to link. This is a required field.
To add a data link variable, click in the **URL** field and enter `$` or press Ctrl+Space or Cmd+Space to see a list of available variables.
1. If you want the link to open in a new tab, toggle the **Open in a new tab** switch.
1. If you want the data link to open with a single click, toggle the **One click** switch.
Only one data link can have **One click** enabled at a time. **One click** is only supported for some visualizations.
1. Click **Save** to save changes and close the dialog box.
1. Click **Save dashboard**.
1. Click **Back to dashboard** and then **Exit edit**.
@@ -201,7 +201,7 @@ You can set standard min/max options to define hard limits of the Y-axis. For mo
### Data links
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
{{< docs/shared lookup="visualizations/datalink-options-2.md" source="grafana" version="<GRAFANA_VERSION>" >}}
### Value mappings
@@ -213,7 +213,7 @@ This option only applies when bar size is set to manual.
## Data links
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
{{< docs/shared lookup="visualizations/datalink-options-1.md" source="grafana" version="<GRAFANA_VERSION>" >}}
## Value mappings
@@ -138,7 +138,7 @@ The candlestick visualization is based on the time series visualization. It can
## Data links
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
{{< docs/shared lookup="visualizations/datalink-options-2.md" source="grafana" version="<GRAFANA_VERSION>" >}}
## Thresholds
@@ -330,14 +330,14 @@ Use the following options to control the border of the canvas:
The following options allow you to control the appearance of the element you've selected. To access an element so that you can edit it, expand the **Layer** section and select the desired element.
| Option | Description |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| [Element type](#element-type) | Change the selected element type. |
| [Element](#element) | Control the appearance of text on the element. This section is named based on the element type. |
| [Layout](#layout) | Control the placement of elements on the canvas. |
| [Background (element)](#background-element) | Set the background of the element. |
| [Border (element)](#border-element) | Set the border of the element. |
| [Data links](#data-links) | Configure data links for elements. |
| Option | Description |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| [Element type](#element-type) | Change the selected element type. |
| [Element](#element) | Control the appearance of text on the element. This section is named based on the element type. |
| [Layout](#layout) | Control the placement of elements on the canvas. |
| [Background (element)](#background-element) | Set the background of the element. |
| [Border (element)](#border-element) | Set the border of the element. |
| [Data links and actions](#data-links-and-actions) | Configure data links and actions for elements. |
#### Element type
@@ -405,40 +405,66 @@ Use the following options to set the border of the element:
- **Color** - Set the border color. This option is only displayed when the border width is greater than zero.
- **Radius** - Add rounded corners to the element border and control the degree of curve.
#### Data links
#### Data links and actions
Canvases support [data links](ref:data-links) for all elements except drone and button elements. You can add a data link by following these steps:
Canvases support [data links](ref:data-links) and actions for all elements.
1. Enable inline editing.
1. Click the element you to which you want to add the data link.
1. In either the inline editor or panel editor, expand the **Selected element** editor.
1. Scroll down to the **Data links** section and expand it.
1. Click **Add link**.
1. In the dialog box that opens, enter a **Title**. This is a human-readable label for the link, which will be displayed in the UI.
1. Enter the **URL** or variable to which you want to link.
If you add multiple data links or actions to an element, you can control the order in which they appear in the element tooltip.
To do this, click and drag the link or action to the desired position.
To add a data link variable, click in the **URL** field and enter `$` or press Ctrl+Space or Cmd+Space to see a list of available variables.
The following tasks describe how to configure data links and actions.
1. If you want the link to open in a new tab, toggle the **Open in a new tab** switch.
1. Click **Save** to save changes and close the dialog box.
1. Disable inline editing.
If you add multiple data links, you can control the order in which they appear in the visualization. To do this, click and drag the data link to the desired position.
##### One-click data link
You can configure a canvas data link to open with a single click on the element. To enable this feature, follow these steps:
{{< tabs >}}
{{< tab-content name="Add data links" >}}
To add a data link, follow these steps:
1. Enable inline editing.
1. Click the element to which you want to add the data link.
1. In either the inline editor or panel editor, expand the **Selected element** editor.
1. Scroll down to the **Data links** section and expand it.
1. In the **One-click** section, choose **Link**.
1. Scroll down to the **Data links and actions** section and expand it.
1. Click **+ Add link**.
1. In the dialog box that opens, enter a **Title**.
This is a human-readable label for the link displayed in the UI. This is a required field.
1. Enter the **URL** or variable to which you want to link.
To add a data link variable, click in the **URL** field and enter `$` or press Ctrl+Space or Cmd+Space to see a list of available variables. This is a required field.
1. If you want the link to open in a new tab, toggle the **Open in a new tab** switch.
1. If you want the data link to open with a single click on the element, toggle the **One click** switch.
Only one data link or action can have **One click** enabled at a time.
1. Click **Save** to save changes and close the dialog box.
1. Disable inline editing.
{{< /tab-content >}}
{{< tab-content name="Add actions" >}}
To add an action, by follow these steps:
The first data link in the list will be configured as your one-click data link. If you want to change the one-click data link, simply drag the desired data link to the top of the list.
1. Enable inline editing.
1. Click the element to which you want to add the data link.
1. In either the inline editor or panel editor, expand the **Selected element** editor.
1. Scroll down to the **Data links and actions** section and expand it.
1. Click **+ Add action**.
1. In the dialog box that opens, set the action options:
{{< video-embed src="/media/docs/grafana/panels-visualizations/canvas-one-click-datalink-.mp4" >}}
| Option | Description |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Title | A human-readable label for the action that's displayed in the UI. |
| Confirmation message | A descriptive prompt to confirm or cancel the action. |
| One click | If you want the action to be triggered by a single click on the element, toggle the switch.</p><p>Only one data link or action can have **One click** enabled at a time. |
| Method | Select from **POST**, **PUT**, or **GET**. |
| URL | The request URL or variable to which you want to link.</p><p>To add a variable, click in the **URL** field and enter `$` or press Ctrl+Space or Cmd+Space to see a list of available variables. |
| Query parameters | **Key** and **Value** pairs. Click the **+** icon to add as many key/value pairs as you need. |
| Headers | Comprised of **Key** and **Value** pairs and a **Content-Type**.</p><p>Click the **+** icon to add as many key/value pairs as you need. |
| Content-Type | Select from the following: **application/json**, **text/plain**, **application/XML**, and **application/x-www-form-urlencoded**. |
| Body | The body of the request. |
1. Click **Save** to save changes and close the dialog box.
1. Disable inline editing.
{{< /tab-content >}}
{{< /tabs >}}
### Selected connection options
@@ -173,7 +173,7 @@ Adjust the sizes of the gauge text.
### Data links
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
{{< docs/shared lookup="visualizations/datalink-options-1.md" source="grafana" version="<GRAFANA_VERSION>" >}}
### Value mappings
@@ -176,7 +176,7 @@ Use these settings to define how the legend appears in your visualization. For m
### Data links
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
{{< docs/shared lookup="visualizations/datalink-options-1.md" source="grafana" version="<GRAFANA_VERSION>" >}}
### Value mappings
@@ -174,7 +174,7 @@ Adjust the sizes of the gauge text.
### Data links
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
{{< docs/shared lookup="visualizations/datalink-options-1.md" source="grafana" version="<GRAFANA_VERSION>" >}}
### Value mappings
@@ -159,7 +159,7 @@ The **Page size** option lets you paginate the state timeline visualization to l
### Data links
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
{{< docs/shared lookup="visualizations/datalink-options-2.md" source="grafana" version="<GRAFANA_VERSION>" >}}
### Value mappings
@@ -145,7 +145,7 @@ Controls the opacity of state regions.
## Data links
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
{{< docs/shared lookup="visualizations/datalink-options-2.md" source="grafana" version="<GRAFANA_VERSION>" >}}
## Value mappings
@@ -364,7 +364,7 @@ If you want to apply this setting to only some fields instead of all fields, you
### Data links
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
{{< docs/shared lookup="visualizations/datalink-options-1.md" source="grafana" version="<GRAFANA_VERSION>" >}}
### Value mappings
@@ -364,7 +364,7 @@ Set the position of the bar relative to a data point. In the examples below, **S
### Data links
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
{{< docs/shared lookup="visualizations/datalink-options-2.md" source="grafana" version="<GRAFANA_VERSION>" >}}
### Value mappings
@@ -62,7 +62,7 @@ For example, you could represent engine power and torque versus speed where spee
## Data links
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
{{< docs/shared lookup="visualizations/datalink-options-2.md" source="grafana" version="<GRAFANA_VERSION>" >}}
## Thresholds
@@ -310,7 +310,7 @@ To learn more, refer to [Configure standard options](ref:configure-standard-opti
## Data links
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
{{< docs/shared lookup="visualizations/datalink-options-2.md" source="grafana" version="<GRAFANA_VERSION>" >}}
## Field overrides
@@ -0,0 +1,17 @@
---
title: Data link options
comments: |
This file is used in the following visualizations: bar gauge, gauge, pie chart, stat, table
---
Data links allow you to link to other panels, dashboards, and external resources while maintaining the context of the source panel.
You can create links that include the series name or even the value under the cursor.
To learn more, refer to [Configure data links](../../configure-data-links/).
For each data link, set the following options:
- **Title**
- **URL**
- **Open in new tab**
Data links for this visualization don't include the **One click** switch, however, if there's only one data link configured, that data link has single-click functionality. If multiple data links are configured, then clicking the visualization opens a menu that displays all the data links.
@@ -0,0 +1,16 @@
---
title: Data link options
comments: |
This file is used in the following visualizations: bar chart, candlestick, state timeline, status history, time series, trend, xy chart
---
Data links allow you to link to other panels, dashboards, and external resources while maintaining the context of the source panel. You can create links that include the series name or even the value under the cursor.
For each data link, set the following options:
- **Title**
- **URL**
- **Open in new tab**
- **One click** - Opens the data link with a single click. Only one data link can have **One click** enabled at a time.
To learn more, refer to [Configure data links](../../configure-data-links/).
@@ -1,7 +1,7 @@
---
title: Data link options
comments: |
This file is used in the following visualizations: bar chart, bar gauge, candlestick, gauge, geomap, heatmap, histogram, stat, state timeline, status history, table, time series, trend, xy chart
This file is used in the following visualizations: geomap, heatmap, histogram
---
Data links allow you to link to other panels, dashboards, and external resources while maintaining the context of the source panel. You can create links that include the series name or even the value under the cursor.
@@ -25,7 +25,7 @@ describe('Solo Route', () => {
it('Can view solo repeated panel in scenes', () => {
// open Panel Tests - Graph NG
e2e.pages.SoloPanel.visit(
'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-16-clone-0/grid-item-2/panel-2-clone-0&__feature.dashboardSceneSolo=true'
'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-0&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('server=A').should('exist');
@@ -38,7 +38,7 @@ describe('Solo Route', () => {
'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('server = A, pod = Rob').should('exist');
e2e.components.Panels.Panel.title('server = B, pod = Rob').should('exist');
cy.contains('uplot-main-div').should('not.exist');
});
});
+2 -2
View File
@@ -25,7 +25,7 @@ describe('Solo Route', () => {
it('Can view solo repeated panel in scenes', () => {
// open Panel Tests - Graph NG
e2e.pages.SoloPanel.visit(
'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-16-clone-0/grid-item-2/panel-2-clone-0&__feature.dashboardSceneSolo=true'
'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-0&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('server=A').should('exist');
@@ -38,7 +38,7 @@ describe('Solo Route', () => {
'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('server = A, pod = Rob').should('exist');
e2e.components.Panels.Panel.title('server = B, pod = Rob').should('exist');
cy.contains('uplot-main-div').should('not.exist');
});
});
+4 -4
View File
@@ -275,8 +275,8 @@
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/saga-icons": "workspace:*",
"@grafana/scenes": "6.0.1",
"@grafana/scenes-react": "6.0.1",
"@grafana/scenes": "6.0.2",
"@grafana/scenes-react": "6.0.2",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
@@ -380,8 +380,8 @@
"react-redux": "9.2.0",
"react-resizable": "3.0.5",
"react-responsive-carousel": "^3.2.23",
"react-router": "5.3.3",
"react-router-dom": "5.3.3",
"react-router": "5.3.4",
"react-router-dom": "5.3.4",
"react-router-dom-v5-compat": "^6.26.1",
"react-select": "5.10.0",
"react-split-pane": "0.1.92",
+1 -1
View File
@@ -97,7 +97,7 @@
"react-i18next": "^15.0.0",
"react-inlinesvg": "4.1.5",
"react-loading-skeleton": "3.5.0",
"react-router-dom": "5.3.3",
"react-router-dom": "5.3.4",
"react-router-dom-v5-compat": "^6.26.1",
"react-select": "5.10.0",
"react-table": "7.8.0",
@@ -1,11 +1,13 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { ActionModel, Field, GrafanaTheme2, LinkModel } from '@grafana/data';
import { ActionModel, Field, GrafanaTheme2, LinkModel, ThemeSpacingTokens } from '@grafana/data';
import { Button, DataLinkButton, Icon, Stack } from '..';
import { useStyles2 } from '../../themes';
import { Trans } from '../../utils/i18n';
import { ActionButton } from '../Actions/ActionButton';
import { ResponsiveProp } from '../Layout/utils/responsiveness';
interface VizTooltipFooterProps {
dataLinks: Array<LinkModel<Field>>;
@@ -15,50 +17,75 @@ interface VizTooltipFooterProps {
export const ADD_ANNOTATION_ID = 'add-annotation-button';
const renderDataLinks = (dataLinks: LinkModel[], styles: ReturnType<typeof getStyles>) => {
const oneClickLink = dataLinks.find((link) => link.oneClick === true);
type RenderOneClickTrans = (title: string) => React.ReactNode;
type RenderItem<T extends LinkModel | ActionModel> = (
item: T,
idx: number,
styles: ReturnType<typeof getStyles>
) => React.ReactNode;
function makeRenderLinksOrActions<T extends LinkModel | ActionModel>(
renderOneClickTrans: RenderOneClickTrans,
renderItem: RenderItem<T>,
itemGap?: ResponsiveProp<ThemeSpacingTokens>
) {
const renderLinksOrActions = (items: T[], styles: ReturnType<typeof getStyles>) => {
if (items.length === 0) {
return;
}
const oneClickItem = items.find((item) => item.oneClick === true);
if (oneClickItem != null) {
return (
<div className={styles.dataLinks}>
<Stack direction="column" justifyContent="flex-start" gap={0.5}>
<span className={styles.oneClickWrapper}>
<Icon name="info-circle" size="lg" className={styles.infoIcon} />
{renderOneClickTrans(oneClickItem.title)}
</span>
</Stack>
</div>
);
}
if (oneClickLink != null) {
return (
<Stack direction="column" justifyContent="flex-start" gap={0.5}>
<span className={styles.oneClickWrapper}>
<Icon name="info-circle" size="lg" className={styles.infoIcon} />
<Trans i18nKey="grafana-ui.viz-tooltip.footer-click-to-navigate">
Click to open {{ linkTitle: oneClickLink.title }}
</Trans>
</span>
</Stack>
<div className={styles.dataLinks}>
<Stack direction="column" justifyContent="flex-start" gap={itemGap}>
{items.map((item, i) => renderItem(item, i, styles))}
</Stack>
</div>
);
}
};
return (
<Stack direction="column" justifyContent="flex-start" gap={0.5}>
{dataLinks.map((link, i) => (
<DataLinkButton link={link} key={i} buttonProps={{ className: styles.dataLinkButton, fill: 'text' }} />
))}
</Stack>
);
};
return renderLinksOrActions;
}
const renderActions = (actions: ActionModel[]) => {
return (
<Stack direction="column" justifyContent="flex-start">
{actions.map((action, i) => (
<ActionButton key={i} action={action} variant="secondary" />
))}
</Stack>
);
};
const renderDataLinks = makeRenderLinksOrActions<LinkModel>(
(title) => (
<Trans i18nKey="grafana-ui.viz-tooltip.footer-click-to-navigate">Click to open {{ linkTitle: title }}</Trans>
),
(item, i, styles) => (
<DataLinkButton link={item} key={i} buttonProps={{ className: styles.dataLinkButton, fill: 'text' }} />
),
0.5
);
const renderActions = makeRenderLinksOrActions<ActionModel>(
(title) => <Trans i18nKey="grafana-ui.viz-tooltip.footer-click-to-action">Click to {{ actionTitle: title }}</Trans>,
(item, i, styles) => <ActionButton key={i} action={item} variant="secondary" />
);
export const VizTooltipFooter = ({ dataLinks, actions = [], annotate }: VizTooltipFooterProps) => {
const styles = useStyles2(getStyles);
const hasOneClickLink = dataLinks.some((link) => link.oneClick === true);
const hasOneClickLink = useMemo(() => dataLinks.some((link) => link.oneClick === true), [dataLinks]);
const hasOneClickAction = useMemo(() => actions.some((action) => action.oneClick === true), [actions]);
return (
<div className={styles.wrapper}>
{dataLinks.length > 0 && <div className={styles.dataLinks}>{renderDataLinks(dataLinks, styles)}</div>}
{!hasOneClickLink && actions.length > 0 && <div className={styles.dataLinks}>{renderActions(actions)}</div>}
{!hasOneClickLink && annotate != null && (
{!hasOneClickAction && renderDataLinks(dataLinks, styles)}
{!hasOneClickLink && renderActions(actions, styles)}
{!hasOneClickLink && !hasOneClickAction && annotate != null && (
<div className={styles.addAnnotations}>
<Button icon="comment-alt" variant="secondary" size="sm" id={ADD_ANNOTATION_ID} onClick={annotate}>
<Trans i18nKey="grafana-ui.viz-tooltip.footer-add-annotation">Add annotation</Trans>
+2
View File
@@ -100,6 +100,7 @@ func getWildcardPermissions(actions ...string) map[string][]string {
// serviceIdentityPermissions is a list of wildcard permissions for provided actions.
// We should add every action required "internally" here.
var serviceIdentityPermissions = getWildcardPermissions(
"annotations:read",
"folders:read",
"folders:write",
"folders:create",
@@ -107,6 +108,7 @@ var serviceIdentityPermissions = getWildcardPermissions(
"dashboards:read",
"dashboards:write",
"dashboards:create",
"datasources:query",
"datasources:read",
"datasources:delete",
"alert.provisioning:write",
+4 -2
View File
@@ -26,12 +26,13 @@ func (lw *logWrapper) Println(v ...any) {
lw.logger.Info("graphite metric bridge", v...)
}
func ProvideService(cfg *setting.Cfg, reg prometheus.Registerer) (*InternalMetricsService, error) {
func ProvideService(cfg *setting.Cfg, reg prometheus.Registerer, gatherer prometheus.Gatherer) (*InternalMetricsService, error) {
initMetricVars(reg)
initFrontendMetrics(reg)
s := &InternalMetricsService{
Cfg: cfg,
Cfg: cfg,
gatherer: gatherer,
}
return s, s.readSettings()
}
@@ -41,6 +42,7 @@ type InternalMetricsService struct {
intervalSeconds int64
graphiteCfg *graphitebridge.Config
gatherer prometheus.Gatherer
}
func (im *InternalMetricsService) Run(ctx context.Context) error {
+1 -3
View File
@@ -5,8 +5,6 @@ import (
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/grafana/grafana/pkg/infra/metrics/graphitebridge"
)
@@ -40,7 +38,7 @@ func (im *InternalMetricsService) parseGraphiteSettings() error {
URL: address,
Prefix: graphiteSection.Key("prefix").MustString("prod.grafana.%(instance_name)s"),
CountersAsDelta: true,
Gatherer: prometheus.DefaultGatherer,
Gatherer: im.gatherer,
Interval: time.Duration(im.intervalSeconds) * time.Second,
Timeout: 10 * time.Second,
Logger: &logWrapper{logger: metricsLogger},
+16 -4
View File
@@ -88,10 +88,17 @@ type keySetJWKS struct {
}
func NewAzureADProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles, cache remotecache.CacheStorage) *SocialAzureAD {
s := newSocialBase(social.AzureADProviderName, orgRoleMapper, info, features, cfg)
allowedOrganizations, err := util.SplitStringWithError(info.Extra[allowedOrganizationsKey])
if err != nil {
s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.AzureADProviderName, "error", err)
}
provider := &SocialAzureAD{
SocialBase: newSocialBase(social.AzureADProviderName, orgRoleMapper, info, features, cfg),
SocialBase: s,
cache: cache,
allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]),
allowedOrganizations: allowedOrganizations,
forceUseGraphAPI: MustBool(info.Extra[forceUseGraphAPIKey], ExtraAzureADSettingKeys[forceUseGraphAPIKey].DefaultValue.(bool)),
}
@@ -236,7 +243,7 @@ func (s *SocialAzureAD) managedIdentityCallback(ctx context.Context) (string, er
}
func (s *SocialAzureAD) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.AzureADProviderName, settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
@@ -250,7 +257,12 @@ func (s *SocialAzureAD) Reload(ctx context.Context, settings ssoModels.SSOSettin
appendUniqueScope(s.Config, social.OfflineAccessScope)
}
s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey])
allowedOrganizations, err := util.SplitStringWithError(newInfo.Extra[allowedOrganizationsKey])
if err != nil {
s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.AzureADProviderName, "error", err)
}
s.allowedOrganizations = allowedOrganizations
s.forceUseGraphAPI = MustBool(newInfo.Extra[forceUseGraphAPIKey], false)
return nil
+24 -1
View File
@@ -2,6 +2,7 @@ package connectors
import (
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -13,6 +14,7 @@ import (
"github.com/mitchellh/mapstructure"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
@@ -165,9 +167,25 @@ func MustBool(value any, defaultValue bool) bool {
return result
}
// CreateOAuthInfoFromKeyValuesWithLogging creates an OAuthInfo struct from a map[string]any using mapstructure
// it puts all extra key values into OAuthInfo's Extra map.
// It logs as errors any parsing errors that are not critical
func CreateOAuthInfoFromKeyValuesWithLogging(l log.Logger, provider string, settingsKV map[string]any) (*social.OAuthInfo, error) {
parsingWarns := []error{}
info, err := createOAuthInfoFromKeyValues(settingsKV, &parsingWarns)
if len(parsingWarns) > 0 {
l.Error("Invalid auth configuration setting", "error", errors.Join(parsingWarns...), "provider", provider)
}
return info, err
}
// CreateOAuthInfoFromKeyValues creates an OAuthInfo struct from a map[string]any using mapstructure
// it puts all extra key values into OAuthInfo's Extra map
func CreateOAuthInfoFromKeyValues(settingsKV map[string]any) (*social.OAuthInfo, error) {
return createOAuthInfoFromKeyValues(settingsKV, nil)
}
func createOAuthInfoFromKeyValues(settingsKV map[string]any, parsingWarns *[]error) (*social.OAuthInfo, error) {
emptyStrToSliceDecodeHook := func(from reflect.Type, to reflect.Type, data any) (any, error) {
if from.Kind() == reflect.String && to.Kind() == reflect.Slice {
strData, ok := data.(string)
@@ -178,7 +196,12 @@ func CreateOAuthInfoFromKeyValues(settingsKV map[string]any) (*social.OAuthInfo,
if strData == "" {
return []string{}, nil
}
return util.SplitString(strData), nil
splitStr, err := util.SplitStringWithError(strData)
if err != nil && parsingWarns != nil {
*parsingWarns = append(*parsingWarns, err)
}
return splitStr, nil
}
return data, nil
}
+26 -5
View File
@@ -53,6 +53,18 @@ type SocialGenericOAuth struct {
}
func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGenericOAuth {
s := newSocialBase(social.GenericOAuthProviderName, orgRoleMapper, info, features, cfg)
teamIds, err := util.SplitStringWithError(info.Extra[teamIdsKey])
if err != nil {
s.log.Error("Invalid auth configuration setting", "config", teamIdsKey, "provider", social.GenericOAuthProviderName, "error", err)
}
allowedOrganizations, err := util.SplitStringWithError(info.Extra[allowedOrganizationsKey])
if err != nil {
s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GenericOAuthProviderName, "error", err)
}
provider := &SocialGenericOAuth{
SocialBase: newSocialBase(social.GenericOAuthProviderName, orgRoleMapper, info, features, cfg),
teamsUrl: info.TeamsUrl,
@@ -63,8 +75,8 @@ func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMa
loginAttributePath: info.Extra[loginAttributePathKey],
idTokenAttributeName: info.Extra[idTokenAttributeNameKey],
teamIdsAttributePath: info.TeamIdsAttributePath,
teamIds: util.SplitString(info.Extra[teamIdsKey]),
allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]),
teamIds: teamIds,
allowedOrganizations: allowedOrganizations,
}
if features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) {
@@ -118,7 +130,7 @@ func validateTeamsUrlWhenNotEmpty(info *social.OAuthInfo, requester identity.Req
}
func (s *SocialGenericOAuth) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GenericOAuthProviderName, settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
@@ -128,6 +140,15 @@ func (s *SocialGenericOAuth) Reload(ctx context.Context, settings ssoModels.SSOS
s.updateInfo(ctx, social.GenericOAuthProviderName, newInfo)
teamIds, err := util.SplitStringWithError(newInfo.Extra[teamIdsKey])
if err != nil {
s.log.Error("Invalid auth configuration setting", "config", teamIdsKey, "provider", social.GenericOAuthProviderName, "error", err)
}
allowedOrganizations, err := util.SplitStringWithError(newInfo.Extra[allowedOrganizationsKey])
if err != nil {
s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GenericOAuthProviderName, "error", err)
}
s.teamsUrl = newInfo.TeamsUrl
s.emailAttributeName = newInfo.EmailAttributeName
s.emailAttributePath = newInfo.EmailAttributePath
@@ -136,8 +157,8 @@ func (s *SocialGenericOAuth) Reload(ctx context.Context, settings ssoModels.SSOS
s.loginAttributePath = newInfo.Extra[loginAttributePathKey]
s.idTokenAttributeName = newInfo.Extra[idTokenAttributeNameKey]
s.teamIdsAttributePath = newInfo.TeamIdsAttributePath
s.teamIds = util.SplitString(newInfo.Extra[teamIdsKey])
s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey])
s.teamIds = teamIds
s.allowedOrganizations = allowedOrganizations
return nil
}
+24 -6
View File
@@ -62,13 +62,23 @@ var (
)
func NewGitHubProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGithub {
teamIdsSplitted := util.SplitString(info.Extra[teamIdsKey])
s := newSocialBase(social.GitHubProviderName, orgRoleMapper, info, features, cfg)
teamIdsSplitted, err := util.SplitStringWithError(info.Extra[teamIdsKey])
if err != nil {
s.log.Error("Invalid auth configuration setting", "config", teamIdsKey, "provider", social.GitHubProviderName, "error", err)
}
teamIds := mustInts(teamIdsSplitted)
allowedOrganizations, err := util.SplitStringWithError(info.Extra[allowedOrganizationsKey])
if err != nil {
s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GitHubProviderName, "error", err)
}
provider := &SocialGithub{
SocialBase: newSocialBase(social.GitHubProviderName, orgRoleMapper, info, features, cfg),
SocialBase: s,
teamIds: teamIds,
allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]),
allowedOrganizations: allowedOrganizations,
}
if len(teamIdsSplitted) != len(teamIds) {
@@ -117,14 +127,22 @@ func teamIdsNumbersValidator(info *social.OAuthInfo, requester identity.Requeste
}
func (s *SocialGithub) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GitHubProviderName, settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
teamIdsSplitted := util.SplitString(newInfo.Extra[teamIdsKey])
teamIdsSplitted, err := util.SplitStringWithError(newInfo.Extra[teamIdsKey])
if err != nil {
s.log.Error("Invalid auth configuration setting", "config", teamIdsKey, "provider", social.GitHubProviderName, "error", err)
}
teamIds := mustInts(teamIdsSplitted)
allowedOrganizations, err := util.SplitStringWithError(newInfo.Extra[allowedOrganizationsKey])
if err != nil {
s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GitHubProviderName, "error", err)
}
if len(teamIdsSplitted) != len(teamIds) {
s.log.Warn("Failed to parse team ids. Team ids must be a list of numbers.", "teamIds", teamIdsSplitted)
}
@@ -135,7 +153,7 @@ func (s *SocialGithub) Reload(ctx context.Context, settings ssoModels.SSOSetting
s.updateInfo(ctx, social.GitHubProviderName, newInfo)
s.teamIds = teamIds
s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey])
s.allowedOrganizations = allowedOrganizations
return nil
}
+1 -1
View File
@@ -87,7 +87,7 @@ func (s *SocialGitlab) Validate(ctx context.Context, newSettings ssoModels.SSOSe
}
func (s *SocialGitlab) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GitlabProviderName, settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
+1 -1
View File
@@ -87,7 +87,7 @@ func (s *SocialGoogle) Validate(ctx context.Context, newSettings ssoModels.SSOSe
}
func (s *SocialGoogle) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GoogleProviderName, settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
@@ -39,15 +39,22 @@ type OrgRecord struct {
}
func NewGrafanaComProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGrafanaCom {
s := newSocialBase(social.GrafanaComProviderName, orgRoleMapper, info, features, cfg)
// Override necessary settings
info.AuthUrl = cfg.GrafanaComURL + "/oauth2/authorize"
info.TokenUrl = cfg.GrafanaComURL + "/api/oauth2/token"
info.AuthStyle = "inheader"
allowedOrganizations, err := util.SplitStringWithError(info.Extra[allowedOrganizationsKey])
if err != nil {
s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GrafanaComProviderName, "error", err)
}
provider := &SocialGrafanaCom{
SocialBase: newSocialBase(social.GrafanaComProviderName, orgRoleMapper, info, features, cfg),
SocialBase: s,
url: cfg.GrafanaComURL,
allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]),
allowedOrganizations: allowedOrganizations,
}
if features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) {
@@ -80,11 +87,16 @@ func (s *SocialGrafanaCom) Validate(ctx context.Context, newSettings ssoModels.S
}
func (s *SocialGrafanaCom) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GrafanaComProviderName, settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
allowedOrganizations, err := util.SplitStringWithError(newInfo.Extra[allowedOrganizationsKey])
if err != nil {
s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GrafanaComProviderName, "error", err)
}
// Override necessary settings
newInfo.AuthUrl = s.cfg.GrafanaComURL + "/oauth2/authorize"
newInfo.TokenUrl = s.cfg.GrafanaComURL + "/api/oauth2/token"
@@ -96,7 +108,7 @@ func (s *SocialGrafanaCom) Reload(ctx context.Context, settings ssoModels.SSOSet
s.updateInfo(ctx, social.GrafanaComProviderName, newInfo)
s.url = s.cfg.GrafanaComURL
s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey])
s.allowedOrganizations = allowedOrganizations
return nil
}
+1 -1
View File
@@ -84,7 +84,7 @@ func (s *SocialOkta) Validate(ctx context.Context, newSettings ssoModels.SSOSett
}
func (s *SocialOkta) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.OktaProviderName, settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
+2 -2
View File
@@ -65,7 +65,7 @@ func ProvideService(cfg *setting.Cfg,
continue
}
info, err := connectors.CreateOAuthInfoFromKeyValues(ssoSetting.Settings)
info, err := connectors.CreateOAuthInfoFromKeyValuesWithLogging(ss.log, ssoSetting.Provider, ssoSetting.Settings)
if err != nil {
ss.log.Error("Failed to create OAuthInfo for provider", "error", err, "provider", ssoSetting.Provider)
continue
@@ -85,7 +85,7 @@ func ProvideService(cfg *setting.Cfg,
settingsKVs := convertIniSectionToMap(sec)
info, err := connectors.CreateOAuthInfoFromKeyValues(settingsKVs)
info, err := connectors.CreateOAuthInfoFromKeyValuesWithLogging(ss.log, name, settingsKVs)
if err != nil {
ss.log.Error("Failed to create OAuthInfo for provider", "error", err, "provider", name)
continue
@@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/apis/dashboard"
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
@@ -40,9 +41,6 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
req.Query = strings.ReplaceAll(req.Query, "*", "")
}
// TODO add missing support for the following query params:
// - folderIds (won't support, must use folderUIDs)
// - permission
query := &dashboards.FindPersistedDashboardsQuery{
Title: req.Query,
Limit: req.Limit,
@@ -51,6 +49,10 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
IsDeleted: req.IsDeleted,
}
if req.Permission == int64(dashboardaccess.PERMISSION_EDIT) {
query.Permission = dashboardaccess.PERMISSION_EDIT
}
var queryType string
if req.Options.Key.Resource == dashboard.DASHBOARD_RESOURCE {
queryType = searchstore.TypeDashboard
@@ -123,22 +125,11 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
}
}
// TODO need to test this
// emptyResponse, err := a.dashService.GetSharedDashboardUIDsQuery(ctx, query)
// if err != nil {
// return nil, err
// } else if emptyResponse {
// return nil, nil
// }
res, err := c.dashboardStore.FindDashboards(ctx, query)
if err != nil {
return nil, err
}
// TODO sort if query.Sort == "" see sortedHits in services/search/service.go
searchFields := resource.StandardSearchFields()
list := &resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
@@ -1755,6 +1755,10 @@ func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Contex
request.IsDeleted = query.IsDeleted
}
if query.Permission > 0 {
request.Permission = int64(query.Permission)
}
if query.Limit < 1 {
query.Limit = 1000
}
+24 -17
View File
@@ -2,27 +2,34 @@ package interceptors
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
"google.golang.org/grpc"
)
func LoggingUnaryInterceptor(logger log.Logger, enabled bool) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (resp any, err error) {
resp, err = handler(ctx, req)
if enabled {
ctxLogger := logger.FromContext(ctx)
if err != nil {
ctxLogger.Error("gRPC call", "method", info.FullMethod, "req", req, "err", err)
} else {
ctxLogger.Info("gRPC call", "method", info.FullMethod, "req", req, "resp", resp)
}
func InterceptorLogger(l log.Logger, enabled bool) logging.Logger {
return logging.LoggerFunc(func(ctx context.Context, lvl logging.Level, msg string, fields ...any) {
if !enabled {
return
}
return resp, err
}
l := l.FromContext(ctx)
switch lvl {
case logging.LevelDebug:
l.Debug(msg, fields...)
case logging.LevelInfo:
l.Info(msg, fields...)
case logging.LevelWarn:
l.Warn(msg, fields...)
case logging.LevelError:
l.Error(msg, fields...)
default:
panic(fmt.Sprintf("unknown level %v", lvl))
}
})
}
func LoggingUnaryInterceptor(logger log.Logger, enabled bool) grpc.UnaryServerInterceptor {
return logging.UnaryServerInterceptor(InterceptorLogger(logger, enabled))
}
+8 -96
View File
@@ -8,16 +8,13 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/expr"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/publicdashboards/models"
"github.com/grafana/grafana/pkg/services/publicdashboards/validation"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/tsdb/grafanads"
)
@@ -37,8 +34,8 @@ func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDT
return nil, models.ErrInternalServerError.Errorf("FindAnnotations: failed to unmarshal dashboard annotations: %w", err)
}
anonymousUser := buildAnonymousUser(ctx, dash, pd.features)
// We don't have a signed in user for public dashboards. We are using Grafana's Identity to query the annotations.
svcCtx, svcIdent := identity.WithServiceIdentity(ctx, dash.OrgID)
uniqueEvents := make(map[int64]models.AnnotationEvent, 0)
for _, anno := range annoDto.Annotations.List {
// skip annotations that are not enabled or are not a grafana datasource
@@ -51,7 +48,7 @@ func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDT
OrgID: dash.OrgID,
DashboardID: dash.ID,
DashboardUID: dash.UID,
SignedInUser: anonymousUser,
SignedInUser: svcIdent,
}
if anno.Target != nil {
@@ -63,7 +60,7 @@ func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDT
}
}
annotationItems, err := pd.AnnotationsRepo.Find(ctx, annoQuery)
annotationItems, err := pd.AnnotationsRepo.Find(svcCtx, annoQuery)
if err != nil {
return nil, models.ErrInternalServerError.Errorf("FindAnnotations: failed to find annotations: %w", err)
}
@@ -139,8 +136,9 @@ func (pd *PublicDashboardServiceImpl) GetQueryDataResponse(ctx context.Context,
return nil, models.ErrPanelQueriesNotFound.Errorf("GetQueryDataResponse: failed to extract queries from panel")
}
anonymousUser := buildAnonymousUser(ctx, dashboard, pd.features)
res, err := pd.QueryDataService.QueryData(ctx, anonymousUser, skipDSCache, metricReq)
// We don't have a signed in user for public dashboards. We are using Grafana's Identity to query the datasource.
svcCtx, svcIdent := identity.WithServiceIdentity(ctx, dashboard.OrgID)
res, err := pd.QueryDataService.QueryData(svcCtx, svcIdent, skipDSCache, metricReq)
reqDatasources := metricReq.GetUniqueDatasourceTypes()
if err != nil {
@@ -180,92 +178,6 @@ func (pd *PublicDashboardServiceImpl) buildMetricRequest(dashboard *dashboards.D
}, nil
}
// buildAnonymousUser creates a user with permissions to read from all datasources used in the dashboard
func buildAnonymousUser(ctx context.Context, dashboard *dashboards.Dashboard, features featuremgmt.FeatureToggles) *user.SignedInUser {
datasourceUids := getUniqueDashboardDatasourceUids(dashboard.Data)
// Create a user with blank permissions
anonymousUser := &user.SignedInUser{OrgID: dashboard.OrgID, Permissions: make(map[int64]map[string][]string)}
// Scopes needed for Annotation queries
annotationScopes := []string{accesscontrol.ScopeAnnotationsTypeDashboard}
// Need to access all dashboards since tags annotations span across all dashboards
dashboardScopes := []string{dashboards.ScopeDashboardsProvider.GetResourceAllScope()}
// Scopes needed for datasource queries
queryScopes := make([]string, 0)
readScopes := make([]string, 0)
for _, uid := range datasourceUids {
scope := datasources.ScopeProvider.GetResourceScopeUID(uid)
queryScopes = append(queryScopes, scope)
readScopes = append(readScopes, scope)
}
// Apply all scopes to the actions we need the user to be able to perform
permissions := make(map[string][]string)
permissions[datasources.ActionQuery] = queryScopes
permissions[datasources.ActionRead] = readScopes
permissions[dashboards.ActionDashboardsRead] = dashboardScopes
permissions[accesscontrol.ActionAnnotationsRead] = annotationScopes
if features.IsEnabled(ctx, featuremgmt.FlagAnnotationPermissionUpdate) {
permissions[accesscontrol.ActionAnnotationsRead] = dashboardScopes
}
anonymousUser.Permissions[dashboard.OrgID] = permissions
return anonymousUser
}
func getUniqueDashboardDatasourceUids(dashboard *simplejson.Json) []string {
var datasourceUids []string
exists := map[string]bool{}
// collapsed rows contain panels in a nested structure, so we need to flatten them before calculate unique uids
flattenedPanels := getFlattenedPanels(dashboard)
for _, panelObj := range flattenedPanels {
panel := simplejson.NewFromAny(panelObj)
uid := getDataSourceUidFromJson(panel)
// if uid is for a mixed datasource, get the datasource uids from the targets
if uid == "-- Mixed --" {
for _, targetObj := range panel.Get("targets").MustArray() {
target := simplejson.NewFromAny(targetObj)
datasourceUid := getDataSourceUidFromJson(target)
if _, ok := exists[datasourceUid]; !ok {
datasourceUids = append(datasourceUids, datasourceUid)
exists[datasourceUid] = true
}
}
} else {
if _, ok := exists[uid]; !ok {
datasourceUids = append(datasourceUids, uid)
exists[uid] = true
}
}
}
return datasourceUids
}
func getFlattenedPanels(dashboard *simplejson.Json) []any {
var flatPanels []any
for _, panelObj := range dashboard.Get("panels").MustArray() {
panel := simplejson.NewFromAny(panelObj)
// if the panel is a row and it is collapsed, get the queries from the panels inside the row
// if it is not collapsed, the row does not have any panels
if panel.Get("type").MustString() == "row" {
if panel.Get("collapsed").MustBool() {
flatPanels = append(flatPanels, panel.Get("panels").MustArray()...)
}
} else {
flatPanels = append(flatPanels, panelObj)
}
}
return flatPanels
}
func groupQueriesByPanelId(dashboard *simplejson.Json) map[int64][]*simplejson.Json {
result := make(map[int64][]*simplejson.Json)
@@ -11,8 +11,8 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
dashboard2 "github.com/grafana/grafana/pkg/kinds/dashboard"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/dashboards"
@@ -110,161 +110,6 @@ const (
"schemaVersion": 35
}`
dashboardWithMixedDatasource = `
{
"panels": [
{
"datasource": {
"type": "datasource",
"uid": "-- Mixed --"
},
"id": 1,
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "abc123"
},
"exemplar": true,
"expr": "go_goroutines{job=\"$job\"}",
"interval": "",
"legendFormat": "",
"refId": "A"
},
{
"datasource": "6SOeCRrVk",
"exemplar": true,
"expr": "test{id=\"f0dd9b69-ad04-4342-8e79-ced8c245683b\", name=\"test\"}",
"hide": false,
"interval": "",
"legendFormat": "",
"refId": "B"
}
],
"title": "Panel Title",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "_yxMP8Ynk"
},
"id": 2,
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "_yxMP8Ynk"
},
"exemplar": true,
"expr": "go_goroutines{job=\"$job\"}",
"interval": "",
"legendFormat": "",
"refId": "A"
}
],
"title": "Panel Title",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "_yxMP8Ynk"
},
"id": 3,
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "_yxMP8Ynk"
},
"exemplar": true,
"expr": "go_goroutines{job=\"$job\"}",
"interval": "",
"legendFormat": "",
"refId": "A"
}
],
"title": "Panel Title",
"type": "timeseries"
}
],
"schemaVersion": 35
}`
dashboardWithDuplicateDatasources = `
{
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "abc123"
},
"id": 1,
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "abc123"
},
"exemplar": true,
"expr": "go_goroutines{job=\"$job\"}",
"interval": "",
"legendFormat": "",
"refId": "A"
}
],
"title": "Panel Title",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "_yxMP8Ynk"
},
"id": 2,
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "_yxMP8Ynk"
},
"exemplar": true,
"expr": "go_goroutines{job=\"$job\"}",
"interval": "",
"legendFormat": "",
"refId": "A"
}
],
"title": "Panel Title",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "_yxMP8Ynk"
},
"id": 3,
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "_yxMP8Ynk"
},
"exemplar": true,
"expr": "go_goroutines{job=\"$job\"}",
"interval": "",
"legendFormat": "",
"refId": "A"
}
],
"title": "Panel Title",
"type": "timeseries"
}
],
"schemaVersion": 35
}`
oldStyleDashboard = `
{
"panels": [
@@ -460,218 +305,6 @@ const (
],
"schemaVersion": 35
}`
dashboardWithCollapsedRows = `
{
"panels": [
{
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 12,
"title": "Row title",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "qCbTUC37k"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 1
},
"id": 11,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "qCbTUC37k"
},
"editorMode": "builder",
"expr": "access_evaluation_duration_bucket",
"legendFormat": "__auto",
"range": true,
"refId": "A"
}
],
"title": "Panel Title",
"type": "timeseries"
},
{
"collapsed": true,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 9
},
"id": 10,
"panels": [
{
"datasource": {
"type": "influxdb",
"uid": "P49A45DF074423DFB"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 10
},
"id": 8,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "9.4.0-pre",
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "P49A45DF074423DFB"
},
"query": "// v.bucket, v.timeRangeStart, and v.timeRange stop are all variables supported by the flux plugin and influxdb\nfrom(bucket: v.bucket)\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_value\"] >= 10 and r[\"_value\"] <= 20)",
"refId": "A"
}
],
"title": "Panel Title",
"type": "timeseries"
}
],
"title": "Row title 1",
"type": "row"
}
]
}`
)
func TestGetQueryDataResponse(t *testing.T) {
@@ -731,8 +364,7 @@ func TestGetQueryDataResponse(t *testing.T) {
func TestFindAnnotations(t *testing.T) {
color := "red"
name := "annoName"
features := featuremgmt.WithFeatures(featuremgmt.FlagAnnotationPermissionUpdate)
t.Run("will build anonymous user with correct permissions to get annotations", func(t *testing.T) {
t.Run("service identity has correct permissions to get annotations dashboards and query datasources", func(t *testing.T) {
fakeStore := &FakePublicDashboardStore{}
fakeStore.On("FindByAccessToken", mock.Anything, mock.AnythingOfType("string")).
Return(&PublicDashboard{Uid: "uid1", IsEnabled: true}, nil)
@@ -746,11 +378,14 @@ func TestFindAnnotations(t *testing.T) {
}
dash := dashboards.NewDashboard("testDashboard")
items, _ := service.FindAnnotations(context.Background(), reqDTO, "abc123")
anonUser := buildAnonymousUser(context.Background(), dash, features)
assert.Equal(t, "dashboards:*", anonUser.Permissions[0]["dashboards:read"][0])
items, err := service.FindAnnotations(context.Background(), reqDTO, "abc123")
require.NoError(t, err)
assert.Len(t, items, 0)
_, svcIdent := identity.WithServiceIdentity(context.Background(), dash.OrgID)
require.Equal(t, "*", svcIdent.GetPermissions()["datasources:query"][0])
require.Equal(t, "*", svcIdent.GetPermissions()["dashboards:read"][0])
require.Equal(t, "*", svcIdent.GetPermissions()["annotations:read"][0])
})
t.Run("Test events from tag queries overwrite built-in annotation queries and duplicate events are not returned", func(t *testing.T) {
@@ -1121,47 +756,6 @@ func TestGetMetricRequest(t *testing.T) {
})
}
func TestGetUniqueDashboardDatasourceUids(t *testing.T) {
t.Run("can get unique datasource ids from dashboard", func(t *testing.T) {
json, err := simplejson.NewJson([]byte(dashboardWithDuplicateDatasources))
require.NoError(t, err)
uids := getUniqueDashboardDatasourceUids(json)
require.Len(t, uids, 2)
require.Equal(t, "abc123", uids[0])
require.Equal(t, "_yxMP8Ynk", uids[1])
})
t.Run("can get unique datasource ids from dashboard with a mixed datasource", func(t *testing.T) {
json, err := simplejson.NewJson([]byte(dashboardWithMixedDatasource))
require.NoError(t, err)
uids := getUniqueDashboardDatasourceUids(json)
require.Len(t, uids, 3)
require.Equal(t, "abc123", uids[0])
require.Equal(t, "6SOeCRrVk", uids[1])
require.Equal(t, "_yxMP8Ynk", uids[2])
})
t.Run("can get no datasource uids from empty dashboard", func(t *testing.T) {
json, err := simplejson.NewJson([]byte(`{"panels": {}}`))
require.NoError(t, err)
uids := getUniqueDashboardDatasourceUids(json)
require.Len(t, uids, 0)
})
t.Run("can get unique datasource ids from dashboard with rows", func(t *testing.T) {
json, err := simplejson.NewJson([]byte(dashboardWithCollapsedRows))
require.NoError(t, err)
uids := getUniqueDashboardDatasourceUids(json)
require.Len(t, uids, 2)
require.Equal(t, "qCbTUC37k", uids[0])
require.Equal(t, "P49A45DF074423DFB", uids[1])
})
}
func TestBuildMetricRequest(t *testing.T) {
fakeDashboardService := &dashboards.FakeDashboardService{}
service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, nil, nil, fakeDashboardService, nil)
@@ -1318,39 +912,6 @@ func TestBuildMetricRequest(t *testing.T) {
})
}
func TestBuildAnonymousUser(t *testing.T) {
sqlStore, cfg := db.InitTestDBWithCfg(t)
dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore))
require.NoError(t, err)
dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]interface{}{}, nil)
features := featuremgmt.WithFeatures()
t.Run("will add datasource read and query permissions to user for each datasource in dashboard", func(t *testing.T) {
user := buildAnonymousUser(context.Background(), dashboard, features)
require.Equal(t, dashboard.OrgID, user.OrgID)
require.Equal(t, "datasources:uid:ds1", user.Permissions[user.OrgID]["datasources:query"][0])
require.Equal(t, "datasources:uid:ds3", user.Permissions[user.OrgID]["datasources:query"][1])
require.Equal(t, "datasources:uid:ds1", user.Permissions[user.OrgID]["datasources:read"][0])
require.Equal(t, "datasources:uid:ds3", user.Permissions[user.OrgID]["datasources:read"][1])
})
t.Run("will add dashboard and annotation permissions needed for getting annotations", func(t *testing.T) {
user := buildAnonymousUser(context.Background(), dashboard, features)
require.Equal(t, dashboard.OrgID, user.OrgID)
require.Equal(t, "annotations:type:dashboard", user.Permissions[user.OrgID]["annotations:read"][0])
require.Equal(t, "dashboards:*", user.Permissions[user.OrgID]["dashboards:read"][0])
})
t.Run("will add dashboard and annotation permissions needed for getting annotations when FlagAnnotationPermissionUpdate is enabled", func(t *testing.T) {
features = featuremgmt.WithFeatures(featuremgmt.FlagAnnotationPermissionUpdate)
user := buildAnonymousUser(context.Background(), dashboard, features)
require.Equal(t, dashboard.OrgID, user.OrgID)
require.Equal(t, "dashboards:*", user.Permissions[user.OrgID]["annotations:read"][0])
require.Equal(t, "dashboards:*", user.Permissions[user.OrgID]["dashboards:read"][0])
})
}
func TestGroupQueriesByPanelId(t *testing.T) {
t.Run("can extract queries from dashboard with panel datasource string that has no datasource on panel targets", func(t *testing.T) {
json, err := simplejson.NewJson([]byte(oldStyleDashboard))
@@ -13,6 +13,7 @@ import (
"go.opentelemetry.io/otel"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -136,7 +137,11 @@ func (pd *PublicDashboardServiceImpl) Find(ctx context.Context, uid string) (*Pu
func (pd *PublicDashboardServiceImpl) FindDashboard(ctx context.Context, orgId int64, dashboardUid string) (*dashboards.Dashboard, error) {
ctx, span := tracer.Start(ctx, "publicdashboards.FindDashboard")
defer span.End()
dash, err := pd.dashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{UID: dashboardUid, OrgID: orgId})
// We don't have a signed in user for public dashboards. We are using Grafana's Identity to query the dashboard.
dash, err := identity.WithServiceIdentityFn(ctx, orgId, func(ctx context.Context) (*dashboards.Dashboard, error) {
return pd.dashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{UID: dashboardUid, OrgID: orgId})
})
if err != nil {
var dashboardErr dashboards.DashboardErr
if ok := errors.As(err, &dashboardErr); ok {
+13 -3
View File
@@ -2016,6 +2016,7 @@ type ResourceSearchRequest struct {
Explain bool `protobuf:"varint,9,opt,name=explain,proto3" json:"explain,omitempty"`
IsDeleted bool `protobuf:"varint,10,opt,name=is_deleted,json=isDeleted,proto3" json:"is_deleted,omitempty"`
Page int64 `protobuf:"varint,11,opt,name=page,proto3" json:"page,omitempty"`
Permission int64 `protobuf:"varint,12,opt,name=permission,proto3" json:"permission,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -2127,6 +2128,13 @@ func (x *ResourceSearchRequest) GetPage() int64 {
return 0
}
func (x *ResourceSearchRequest) GetPermission() int64 {
if x != nil {
return x.Permission
}
return 0
}
type ResourceSearchResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Error details
@@ -4238,8 +4246,8 @@ var file_resource_proto_rawDesc = string([]byte{
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08,
0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e,
0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0xee,
0x04, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63,
0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x8e,
0x05, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63,
0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69,
0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73,
@@ -4265,7 +4273,9 @@ var file_resource_proto_rawDesc = string([]byte{
0x6c, 0x61, 0x69, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x73, 0x5f, 0x64, 0x65, 0x6c, 0x65, 0x74,
0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x65, 0x6c, 0x65,
0x74, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x67, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28,
0x03, 0x52, 0x04, 0x70, 0x61, 0x67, 0x65, 0x1a, 0x30, 0x0a, 0x04, 0x53, 0x6f, 0x72, 0x74, 0x12,
0x03, 0x52, 0x04, 0x70, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69,
0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70, 0x65, 0x72,
0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0x30, 0x0a, 0x04, 0x53, 0x6f, 0x72, 0x74, 0x12,
0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, 0x18, 0x02, 0x20,
0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x1a, 0x33, 0x0a, 0x05, 0x46, 0x61, 0x63,
@@ -457,6 +457,8 @@ message ResourceSearchRequest {
bool is_deleted = 10;
int64 page = 11;
int64 permission = 12;
}
message ResourceSearchResponse {
+7 -1
View File
@@ -18,6 +18,7 @@ import (
"github.com/blevesearch/bleve/v2/search/query"
bleveSearch "github.com/blevesearch/bleve/v2/search/searcher"
index "github.com/blevesearch/bleve_index_api"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"go.opentelemetry.io/otel/trace"
"k8s.io/apimachinery/pkg/selection"
@@ -611,11 +612,16 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resource.Res
if !ok {
return nil, resource.AsErrorResult(fmt.Errorf("missing auth info"))
}
verb := utils.VerbList
if req.Permission == int64(dashboardaccess.PERMISSION_EDIT) {
verb = utils.VerbPatch
}
checker, err := access.Compile(ctx, auth, authlib.ListRequest{
Namespace: b.key.Namespace,
Group: b.key.Group,
Resource: b.key.Resource,
Verb: utils.VerbList,
Verb: verb,
})
if err != nil {
return nil, resource.AsErrorResult(err)
@@ -13,6 +13,7 @@ func TestAlertmanagerIntegration_ExtraDedupStage(t *testing.T) {
}
t.Run("assert no flapping alerts when stopOnExtraDedup is enabled", func(t *testing.T) {
t.Skip("skipping flaky test")
s, err := NewAlertmanagerScenario()
require.NoError(t, err)
defer s.Close()
+13 -4
View File
@@ -33,9 +33,18 @@ func stringsFallback(vals ...string) string {
// SplitString splits a string and returns a list of strings. It supports JSON list syntax and strings separated by commas or spaces.
// It supports quoted strings with spaces, e.g. "foo bar", "baz".
// It will return an empty list if it fails to parse the string.
func SplitString(str string) []string {
result, _ := SplitStringWithError(str)
return result
}
// SplitStringWithError splits a string and returns a list of strings. It supports JSON list syntax and strings separated by commas or spaces.
// It supports quoted strings with spaces, e.g. "foo bar", "baz".
// It returns an error if it cannot parse the string.
func SplitStringWithError(str string) ([]string, error) {
if len(str) == 0 {
return []string{}
return []string{}, nil
}
// JSON list syntax support
@@ -43,9 +52,9 @@ func SplitString(str string) []string {
var res []string
err := json.Unmarshal([]byte(str), &res)
if err != nil {
return []string{}
return []string{}, fmt.Errorf("incorrect format: %s", str)
}
return res
return res, nil
}
matches := stringListItemMatcher.FindAllString(str, -1)
@@ -55,7 +64,7 @@ func SplitString(str string) []string {
result[i] = strings.Trim(match, "\"")
}
return result
return result, nil
}
// GetAgeString returns a string representing certain time from years to minutes.
@@ -38,7 +38,7 @@ export class ServiceAccountPicker extends Component<Props, State> {
}
return getBackendSrv()
.get(`/api/serviceaccounts/search`)
.get(`/api/serviceaccounts/search?query=${query}&perpage=100`)
.then((result: ServiceAccountsState) => {
return result.serviceAccounts.map((sa) => ({
id: sa.id,
+1
View File
@@ -62,6 +62,7 @@ export const getActions = (
onClick: (evt: MouseEvent, origin: Field) => {
buildActionOnClick(action, boundReplaceVariables);
},
oneClick: action.oneClick ?? false,
};
return actionModel;
@@ -56,7 +56,10 @@ const strToValue = (val: string | string[]): SelectableValue[] => {
}
// Stored as JSON Array
if (val.startsWith('[') && val.endsWith(']')) {
return JSON.parse(val).map((v: string) => ({ label: v, value: v }));
// Fallback to parsing it like a non-json string if it is not valid json, instead of crashing.
try {
return JSON.parse(val).map((v: string) => ({ label: v, value: v }));
} catch {}
}
return val.split(/[\s,]/).map((s) => ({ label: s, value: s }));
+1 -2
View File
@@ -1,6 +1,6 @@
import { ComponentType } from 'react';
import { DataLink, RegistryItem, OneClickMode, Action } from '@grafana/data';
import { DataLink, RegistryItem, Action } from '@grafana/data';
import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
import { ColorDimensionConfig, ScaleDimensionConfig } from '@grafana/schema';
import { config } from 'app/core/config';
@@ -34,7 +34,6 @@ export interface CanvasElementOptions<TConfig = any> {
connections?: CanvasConnection[];
links?: DataLink[];
actions?: Action[];
oneClickMode?: OneClickMode;
}
// Unit is percentage from the middle of the element
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -99,7 +99,6 @@ export const cloudItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { ScalarDimensionConfig } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions';
@@ -94,7 +94,6 @@ export const droneFrontItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { ScalarDimensionConfig } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions';
@@ -93,7 +93,6 @@ export const droneSideItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { ScalarDimensionConfig } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions';
@@ -98,7 +98,6 @@ export const droneTopItem: CanvasElementItem = {
fixed: 'transparent',
},
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -106,7 +106,6 @@ export const ellipseItem: CanvasElementItem<CanvasElementConfig, CanvasElementDa
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
+1 -2
View File
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import { isString } from 'lodash';
import { CSSProperties } from 'react';
import { LinkModel, OneClickMode } from '@grafana/data';
import { LinkModel } from '@grafana/data';
import { ColorDimensionConfig, ResourceDimensionConfig, ResourceDimensionMode } from '@grafana/schema';
import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG';
import { getPublicOrAbsoluteUrl } from 'app/features/dimensions';
@@ -80,7 +80,6 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = {
left: options?.placement?.left ?? 100,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
@@ -3,13 +3,7 @@ import { useCallback } from 'react';
import { useObservable } from 'react-use';
import { of } from 'rxjs';
import {
DataFrame,
FieldNamePickerConfigSettings,
GrafanaTheme2,
OneClickMode,
StandardEditorsRegistryItem,
} from '@grafana/data';
import { DataFrame, FieldNamePickerConfigSettings, GrafanaTheme2, StandardEditorsRegistryItem } from '@grafana/data';
import { TextDimensionMode } from '@grafana/schema';
import { usePanelContext, useStyles2 } from '@grafana/ui';
import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker';
@@ -181,7 +175,6 @@ export const metricValueItem: CanvasElementItem<TextConfig, TextData> = {
left: options?.placement?.left ?? 100,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -99,7 +99,6 @@ export const parallelogramItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { PureComponent } from 'react';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { stylesFactory } from '@grafana/ui';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
@@ -72,7 +72,6 @@ export const rectangleItem: CanvasElementItem<TextConfig, TextData> = {
fixed: defaultBgColor,
},
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, LinkModel, OneClickMode } from '@grafana/data';
import { GrafanaTheme2, LinkModel } from '@grafana/data';
import { ColorDimensionConfig, ScalarDimensionConfig } from '@grafana/schema';
import config from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions';
@@ -83,7 +83,6 @@ export const serverItem: CanvasElementItem<ServerConfig, ServerData> = {
config: {
type: ServerType.Single,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
+1 -2
View File
@@ -4,7 +4,7 @@ import * as React from 'react';
import { useObservable } from 'react-use';
import { of } from 'rxjs';
import { DataFrame, GrafanaTheme2, OneClickMode } from '@grafana/data';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { Input, usePanelContext, useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -148,7 +148,6 @@ export const textItem: CanvasElementItem<TextConfig, TextData> = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -100,7 +100,6 @@ export const triangleItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, LinkModel, OneClickMode } from '@grafana/data';
import { GrafanaTheme2, LinkModel } from '@grafana/data';
import { ScalarDimensionConfig } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions';
@@ -90,7 +90,6 @@ export const windTurbineItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
+100 -48
View File
@@ -2,8 +2,18 @@ import * as React from 'react';
import { CSSProperties } from 'react';
import { OnDrag, OnResize, OnRotate } from 'react-moveable/declaration/types';
import { FieldType, getLinksSupplier, LinkModel, OneClickMode, ScopedVars, ValueLinkConfig } from '@grafana/data';
import {
FieldType,
getLinksSupplier,
LinkModel,
ScopedVars,
ValueLinkConfig,
OneClickMode,
ActionModel,
} from '@grafana/data';
import { ConfirmModal } from '@grafana/ui';
import { LayerElement } from 'app/core/components/Layers/types';
import { t } from 'app/core/internationalization';
import { notFoundItem } from 'app/features/canvas/elements/notFound';
import { DimensionContext } from 'app/features/dimensions';
import {
@@ -46,6 +56,10 @@ export class ElementState implements LayerElement {
getLinks?: (config: ValueLinkConfig) => LinkModel[];
// cached for tooltips/mousemove
oneClickMode = OneClickMode.Off;
showConfirmation = false;
constructor(
public item: CanvasElementItem,
public options: CanvasElementOptions,
@@ -63,7 +77,7 @@ export class ElementState implements LayerElement {
options.placement = options.placement ?? { width: 100, height: 100, top: 0, left: 0, rotation: 0 };
options.background = options.background ?? { color: { fixed: 'transparent' } };
options.border = options.border ?? { color: { fixed: 'dark-green' } };
options.oneClickMode = options.oneClickMode ?? OneClickMode.Off;
const scene = this.getScene();
if (!options.name) {
const newName = scene?.getNextElementName();
@@ -376,6 +390,12 @@ export class ElementState implements LayerElement {
this.options.links = this.options.links?.filter((link) => link !== null);
if (this.options.links?.some((link) => link.oneClick === true)) {
this.oneClickMode = OneClickMode.Link;
} else if (this.options.actions?.some((action) => action.oneClick === true)) {
this.oneClickMode = OneClickMode.Action;
}
if (frames) {
const defaultField = {
name: 'Default field',
@@ -588,31 +608,26 @@ export class ElementState implements LayerElement {
handleMouseEnter = (event: React.MouseEvent, isSelected: boolean | undefined) => {
const scene = this.getScene();
const shouldHandleTooltip =
!scene?.isEditingEnabled && !scene?.tooltip?.isOpen && this.options.oneClickMode === OneClickMode.Off;
const shouldHandleTooltip = !scene?.isEditingEnabled && !scene?.tooltip?.isOpen;
if (shouldHandleTooltip) {
this.handleTooltip(event);
} else if (!isSelected) {
scene?.connections.handleMouseEnter(event);
}
const shouldHandleOneClickLink =
this.options.oneClickMode === OneClickMode.Link && this.options.links && this.options.links.length > 0;
const shouldHandleOneClickAction =
this.options.oneClickMode === OneClickMode.Action && this.options.actions && this.options.actions.length > 0;
if (shouldHandleOneClickLink && this.div) {
const primaryDataLink = this.getPrimaryDataLink();
if (primaryDataLink) {
this.div.style.cursor = 'pointer';
this.div.title = `Navigate to ${primaryDataLink.title === '' ? 'data link' : primaryDataLink.title}`;
}
} else if (shouldHandleOneClickAction && this.div) {
const primaryAction = this.getPrimaryAction();
if (primaryAction) {
this.div.style.cursor = 'pointer';
this.div.title = primaryAction.title;
if (this.div != null) {
if (this.oneClickMode === OneClickMode.Link) {
const primaryDataLink = this.getPrimaryDataLink();
if (primaryDataLink) {
this.div.style.cursor = 'pointer';
this.div.title = `Navigate to ${primaryDataLink.title === '' ? 'data link' : primaryDataLink.title}`;
}
} else if (this.oneClickMode === OneClickMode.Action) {
const primaryAction = this.getPrimaryAction();
if (primaryAction) {
this.div.style.cursor = 'pointer';
this.div.title = primaryAction.title;
}
}
}
};
@@ -620,7 +635,7 @@ export class ElementState implements LayerElement {
getPrimaryDataLink = () => {
if (this.getLinks) {
const links = this.getLinks({ valueRowIndex: getRowIndex(this.data.field, this.getScene()!) });
return links[0];
return links.find((link) => link.oneClick === true);
}
return undefined;
@@ -652,7 +667,7 @@ export class ElementState implements LayerElement {
actionsDefaultFieldConfig.actions,
config
);
return actions[0];
return actions.find((action) => action.oneClick === true);
}
return undefined;
@@ -672,11 +687,11 @@ export class ElementState implements LayerElement {
handleMouseLeave = (event: React.MouseEvent) => {
const scene = this.getScene();
if (scene?.tooltipCallback && !scene?.tooltip?.isOpen && this.options.oneClickMode === OneClickMode.Off) {
if (scene?.tooltipCallback && !scene?.tooltip?.isOpen) {
scene.tooltipCallback(undefined);
}
if (this.options.oneClickMode !== OneClickMode.Off && this.div) {
if (this.oneClickMode !== OneClickMode.Off && this.div) {
this.div.style.cursor = 'auto';
this.div.title = '';
}
@@ -684,16 +699,14 @@ export class ElementState implements LayerElement {
onElementClick = (event: React.MouseEvent) => {
// If one-click access is enabled, open the primary link
if (this.options.oneClickMode === OneClickMode.Link) {
if (this.oneClickMode === OneClickMode.Link) {
let primaryDataLink = this.getPrimaryDataLink();
if (primaryDataLink) {
window.open(primaryDataLink.href, primaryDataLink.target ?? '_self');
}
} else if (this.options.oneClickMode === OneClickMode.Action) {
let primaryAction = this.getPrimaryAction();
if (primaryAction && primaryAction.onClick) {
primaryAction.onClick(event);
}
} else if (this.oneClickMode === OneClickMode.Action) {
this.showConfirmation = true;
this.forceUpdate();
} else {
this.handleTooltip(event);
this.onTooltipCallback();
@@ -721,29 +734,68 @@ export class ElementState implements LayerElement {
}
};
forceUpdate = () => {
const scene = this.getScene();
if (scene?.actionConfirmationCallback) {
scene.actionConfirmationCallback();
}
};
renderActionsConfirmModal = (action: ActionModel | undefined) => {
if (!action) {
return;
}
return (
<>
{this.showConfirmation && action && (
<ConfirmModal
isOpen={true}
title={t('grafana-ui.action-editor.button.confirm-action', 'Confirm action')}
body={action.confirmation}
confirmText={t('grafana-ui.action-editor.button.confirm', 'Confirm')}
confirmButtonVariant="primary"
onConfirm={() => {
this.showConfirmation = false;
action.onClick(new MouseEvent('click'));
this.forceUpdate();
}}
onDismiss={() => {
this.showConfirmation = false;
this.forceUpdate();
}}
/>
)}
</>
);
};
render() {
const { item, div } = this;
const scene = this.getScene();
const isSelected = div && scene && scene.selecto && scene.selecto.getSelectedTargets().includes(div);
return (
<div
key={this.UID}
ref={this.initElement}
onMouseEnter={(e: React.MouseEvent) => this.handleMouseEnter(e, isSelected)}
onMouseLeave={!scene?.isEditingEnabled ? this.handleMouseLeave : undefined}
onClick={!scene?.isEditingEnabled ? this.onElementClick : undefined}
onKeyDown={!scene?.isEditingEnabled ? this.onElementKeyDown : undefined}
role="button"
tabIndex={0}
>
<item.display
key={`${this.UID}/${this.revId}`}
config={this.options.config}
data={this.data}
isSelected={isSelected}
/>
</div>
<>
<div
key={this.UID}
ref={this.initElement}
onMouseEnter={(e: React.MouseEvent) => this.handleMouseEnter(e, isSelected)}
onMouseLeave={!scene?.isEditingEnabled ? this.handleMouseLeave : undefined}
onClick={!scene?.isEditingEnabled ? this.onElementClick : undefined}
onKeyDown={!scene?.isEditingEnabled ? this.onElementKeyDown : undefined}
role="button"
tabIndex={0}
>
<item.display
key={`${this.UID}/${this.revId}`}
config={this.options.config}
data={this.data}
isSelected={isSelected}
/>
</div>
{this.showConfirmation && this.renderActionsConfirmModal(this.getPrimaryAction())}
</>
);
}
}
@@ -97,6 +97,8 @@ export class Scene {
moveableActionCallback?: (moved: boolean) => void;
actionConfirmationCallback?: () => void;
readonly editModeEnabled = new BehaviorSubject<boolean>(false);
subscription: Subscription;
@@ -22,7 +22,8 @@ import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutMana
import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcessedEvent';
export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
private _eventSub?: Unsubscribable;
private _viewEventSub?: Unsubscribable;
private _inspectEventSub?: Unsubscribable;
constructor(private _scene: DashboardScene) {}
@@ -78,6 +79,14 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
if (typeof values.inspect === 'string') {
let panel = findVizPanelByKey(this._scene, values.inspect);
if (!panel) {
// If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet
// Here we check if the key contains the clone key so we force the repeat processing
// It doesn't matter if the element or the ancestors are clones or not, just that the key contains the clone key
if (containsCloneKey(values.inspect)) {
this._handleInspectRepeatClone(values.inspect);
return;
}
appEvents.emit(AppEvents.alertError, ['Panel not found']);
locationService.partial({ inspect: null });
return;
@@ -177,12 +186,27 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
}
}
private _handleInspectRepeatClone(inspect: string) {
if (!this._inspectEventSub) {
this._inspectEventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => {
const panel = findVizPanelByKey(this._scene, inspect);
if (panel) {
this._inspectEventSub?.unsubscribe();
this._scene.setState({
inspectPanelKey: inspect,
overlay: new PanelInspectDrawer({ panelRef: panel.getRef() }),
});
}
});
}
}
private _handleViewRepeatClone(viewPanel: string) {
if (!this._eventSub) {
this._eventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => {
if (!this._viewEventSub) {
this._viewEventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => {
const panel = findVizPanelByKey(this._scene, viewPanel);
if (panel) {
this._eventSub?.unsubscribe();
this._viewEventSub?.unsubscribe();
this._scene.setState({ viewPanelScene: new ViewPanelScene({ panelRef: panel.getRef() }) });
}
});
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import classNames from 'classnames';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneGridRow, VizPanel, sceneGraph } from '@grafana/scenes';
@@ -12,6 +12,7 @@ import { forceActivateFullSceneObjectTree } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcessedEvent';
export interface Props {
dashboard: DashboardScene;
@@ -25,6 +26,7 @@ export function PanelSearchLayout({ dashboard, panelSearch = '', panelsPerRow }:
const { body } = dashboard.state;
const filteredPanels: VizPanel[] = [];
const styles = useStyles2(getStyles);
const [_, setRepeatsUpdated] = useState('');
const bodyGrid = body instanceof DefaultGridLayoutManager ? body.state.grid : null;
@@ -34,11 +36,11 @@ export function PanelSearchLayout({ dashboard, panelSearch = '', panelsPerRow }:
for (const gridItem of bodyGrid.state.children) {
if (gridItem instanceof DashboardGridItem) {
filterPanels(gridItem, dashboard, panelSearch, filteredPanels);
filterPanels(gridItem, dashboard, panelSearch, filteredPanels, setRepeatsUpdated);
} else if (gridItem instanceof SceneGridRow) {
for (const rowItem of gridItem.state.children) {
if (rowItem instanceof DashboardGridItem) {
filterPanels(rowItem, dashboard, panelSearch, filteredPanels);
filterPanels(rowItem, dashboard, panelSearch, filteredPanels, setRepeatsUpdated);
}
}
}
@@ -98,7 +100,8 @@ function filterPanels(
gridItem: DashboardGridItem,
dashboard: DashboardScene,
searchString: string,
filteredPanels: VizPanel[]
filteredPanels: VizPanel[],
setRepeatsUpdated: (updated: string) => void
) {
const interpolatedSearchString = sceneGraph.interpolate(dashboard, searchString).toLowerCase();
@@ -107,6 +110,12 @@ function filterPanels(
const panel = gridItem.state.body;
const interpolatedTitle = panel.interpolate(panel.state.title, undefined, 'text').toLowerCase();
if (interpolatedTitle.includes(interpolatedSearchString)) {
gridItem.subscribeToEvent(DashboardRepeatsProcessedEvent, (event) => {
const source = event.payload.source;
if (source instanceof DashboardGridItem) {
setRepeatsUpdated(event.payload.source.state.key ?? '');
}
});
gridItem.activate();
}
}
@@ -252,6 +252,14 @@ export class DefaultGridLayoutManager
}
public activateRepeaters() {
if (!this.isActive) {
this.activate();
}
if (!this.state.grid.isActive) {
this.state.grid.activate();
}
this.state.grid.forEachChild((child) => {
if (child instanceof DashboardGridItem && !child.isActive) {
child.activate();
@@ -30,6 +30,8 @@ describe('clone', () => {
expect(getOriginalKey('panel-clone-1')).toBe('panel');
expect(getOriginalKey('row-clone-1/panel-clone-2')).toBe('panel');
expect(getOriginalKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe('panel');
expect(getOriginalKey('panel-2-clone-3')).toBe('panel-2');
expect(getOriginalKey('panel-2')).toBe('panel-2');
});
});
@@ -21,7 +21,7 @@ import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types/DashboardLayoutManager';
import { getLastKeyFromClone, getOriginalKey } from './clone';
import { getOriginalKey, isClonedKey } from './clone';
export const NEW_PANEL_HEIGHT = 8;
export const NEW_PANEL_WIDTH = 12;
@@ -64,7 +64,16 @@ function findVizPanelInternal(scene: SceneObject, key: string | undefined): VizP
const panel = sceneGraph.findObject(scene, (obj) => {
const objKey = obj.state.key!;
if (objKey === key || getLastKeyFromClone(objKey) === getLastKeyFromClone(key) || getOriginalKey(objKey) === key) {
if (objKey === key) {
return true;
}
// It might be possible to have the keys changed in the meantime from `panel-2` to `panel-2-clone-0`
// We need to check this as well
const originalObjectKey = !isClonedKey(objKey) ? getOriginalKey(objKey) : objKey;
const originalKey = !isClonedKey(key) ? getOriginalKey(key) : key;
if (originalObjectKey === originalKey) {
return true;
}
@@ -85,6 +85,7 @@ export class CanvasPanel extends Component<Props, State> {
this.scene.setBackgroundCallback = this.openSetBackground;
this.scene.tooltipCallback = this.tooltipCallback;
this.scene.moveableActionCallback = this.moveableActionCallback;
this.scene.actionConfirmationCallback = this.actionConfirmationCallback;
this.subs.add(
this.props.eventBus.subscribe(PanelEditEnteredEvent, (evt: PanelEditEnteredEvent) => {
@@ -302,6 +303,10 @@ export class CanvasPanel extends Component<Props, State> {
this.forceUpdate();
};
actionConfirmationCallback = () => {
this.forceUpdate();
};
closeInlineEdit = () => {
this.setState({ openInlineEdit: false });
isInlineEditOpen = false;
@@ -88,7 +88,6 @@ export const CanvasTooltip = ({ scene }: Props) => {
}
});
}
// ---------
if (scene.data?.series) {
getElementFields(scene.data?.series, element.options).forEach((field) => {
@@ -1,6 +1,5 @@
import { capitalize, get as lodashGet } from 'lodash';
import { get as lodashGet } from 'lodash';
import { OneClickMode } from '@grafana/data';
import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
import { config } from '@grafana/runtime';
import { CanvasElementOptions } from 'app/features/canvas/element';
@@ -123,36 +122,10 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions<
optionBuilder.addBorder(builder, ctx);
}
const oneClickModeOptions: Array<{
value: OneClickMode;
label: string;
}> = [
{ value: OneClickMode.Off, label: capitalize(OneClickMode.Off) },
{ value: OneClickMode.Link, label: capitalize(OneClickMode.Link) },
];
let oneClickCategory = 'Data links';
let oneClickDescription = 'When enabled, a single click opens the first link';
if (actionsEnabled) {
oneClickModeOptions.push({ value: OneClickMode.Action, label: capitalize(OneClickMode.Action) });
oneClickCategory += ' and actions';
oneClickDescription += ' or action';
}
builder.addRadio({
category: [oneClickCategory],
path: 'oneClickMode',
name: 'One-click',
description: oneClickDescription,
settings: {
options: oneClickModeOptions,
},
defaultValue: OneClickMode.Off,
});
optionBuilder.addDataLinks(builder, ctx);
optionBuilder.addActions(builder, ctx);
if (actionsEnabled) {
optionBuilder.addActions(builder, ctx);
}
},
};
}
@@ -1,4 +1,4 @@
import { OneClickMode, PanelModel } from '@grafana/data';
import { PanelModel } from '@grafana/data';
import { canvasMigrationHandler } from './migrations';
@@ -20,6 +20,16 @@ describe('Canvas migration', () => {
},
},
],
links: [
{
title: 'Link1',
url: 'www.link1.com',
},
{
title: 'Link2',
url: 'www.link2.com',
},
],
},
],
},
@@ -29,7 +39,7 @@ describe('Canvas migration', () => {
panel.options = canvasMigrationHandler(panel);
expect(panel.options.root.elements[0].oneClickMode).toBe(OneClickMode.Link);
expect(panel.options.root.elements[0].links[0].oneClick).toBe(true);
expect(panel.options.root.elements[0].actions[0].fetch.url).toBe('http://test.com');
});
});
+17 -6
View File
@@ -46,12 +46,6 @@ export const canvasMigrationHandler = (panel: PanelModel): Partial<Options> => {
const root = panel.options?.root;
if (root?.elements) {
for (const element of root.elements) {
// migrate oneClickLinks to oneClickMode
if (element.oneClickLinks) {
element.oneClickMode = OneClickMode.Link;
delete element.oneClickLinks;
}
// migrate action options to new format (fetch)
if (element.actions) {
for (const action of element.actions) {
@@ -65,5 +59,22 @@ export const canvasMigrationHandler = (panel: PanelModel): Partial<Options> => {
}
}
// migrate oneClickMode to first link/action oneClick
if (parseFloat(pluginVersion) <= 11.6) {
const root = panel.options?.root;
if (root?.elements) {
for (const element of root.elements) {
if (element.oneClickMode === OneClickMode.Link || element.oneClickLinks) {
element.links[0].oneClick = true;
} else if (element.oneClickMode === OneClickMode.Action) {
element.actions[0].oneClick = true;
}
delete element.oneClickMode;
delete element.oneClickLinks;
}
}
}
return panel.options;
};
@@ -64,7 +64,6 @@ export const plugin = new PanelPlugin<Options>(CanvasPanel)
},
},
[FieldConfigProperty.Actions]: {
hideFromDefaults: true,
settings: {
showOneClick: false,
},
+1
View File
@@ -1699,6 +1699,7 @@
"actions-confirmation-label": "Confirmation message",
"actions-confirmation-message": "Provide a descriptive prompt to confirm or cancel the action.",
"footer-add-annotation": "Add annotation",
"footer-click-to-action": "Click to {{actionTitle}}",
"footer-click-to-navigate": "Click to open {{linkTitle}}"
}
},
@@ -1699,6 +1699,7 @@
"actions-confirmation-label": "Cőʼnƒįřmäŧįőʼn męşşäģę",
"actions-confirmation-message": "Přővįđę ä đęşčřįpŧįvę přőmpŧ ŧő čőʼnƒįřm őř čäʼnčęľ ŧĥę äčŧįőʼn.",
"footer-add-annotation": "Åđđ äʼnʼnőŧäŧįőʼn",
"footer-click-to-action": "Cľįčĸ ŧő {{actionTitle}}",
"footer-click-to-navigate": "Cľįčĸ ŧő őpęʼn {{linkTitle}}"
}
},
+24 -38
View File
@@ -3814,11 +3814,11 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes-react@npm:6.0.1":
version: 6.0.1
resolution: "@grafana/scenes-react@npm:6.0.1"
"@grafana/scenes-react@npm:6.0.2":
version: 6.0.2
resolution: "@grafana/scenes-react@npm:6.0.2"
dependencies:
"@grafana/scenes": "npm:6.0.1"
"@grafana/scenes": "npm:6.0.2"
lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0"
peerDependencies:
@@ -3830,13 +3830,13 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/e4ad83cc628f17232fe9c8d74f641c65e2e289c177ce88a6990d00f6bea4e1a091115e7b98200de7bcff14ace0fe20eb816141fe533fee7d2ad5f7f665404d2c
checksum: 10/9744e01f2ff912229e43cedfa41d626ccdfd034f5b9718b57c593bc90edadade960f76baf1d8ad19eed03709c17c62397df1871b89acc635172aa14f6a20e096
languageName: node
linkType: hard
"@grafana/scenes@npm:6.0.1":
version: 6.0.1
resolution: "@grafana/scenes@npm:6.0.1"
"@grafana/scenes@npm:6.0.2":
version: 6.0.2
resolution: "@grafana/scenes@npm:6.0.2"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16"
@@ -3854,7 +3854,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/6862e57358ba2e63f139e7f3bb977b19945f67eb070aa2c85c073a55dc460d3ccfeecfee22aea92c660a7632ac997e6cd945f9466b64103436a221979e6e8fcb
checksum: 10/2584f296db6299ef0a09d51f5c267ebcf7e44bd17b4d6516e38d3220f8f1d7aebc63c5fc6523979c4ac4d3f555416ca573e85e03bd36eb33a11941a5b3497149
languageName: node
linkType: hard
@@ -4124,7 +4124,7 @@ __metadata:
react-i18next: "npm:^15.0.0"
react-inlinesvg: "npm:4.1.5"
react-loading-skeleton: "npm:3.5.0"
react-router-dom: "npm:5.3.3"
react-router-dom: "npm:5.3.4"
react-router-dom-v5-compat: "npm:^6.26.1"
react-select: "npm:5.10.0"
react-select-event: "npm:^5.1.0"
@@ -18151,8 +18151,8 @@ __metadata:
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/saga-icons": "workspace:*"
"@grafana/scenes": "npm:6.0.1"
"@grafana/scenes-react": "npm:6.0.1"
"@grafana/scenes": "npm:6.0.2"
"@grafana/scenes-react": "npm:6.0.2"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/tsconfig": "npm:^2.0.0"
@@ -18399,8 +18399,8 @@ __metadata:
react-refresh: "npm:0.14.0"
react-resizable: "npm:3.0.5"
react-responsive-carousel: "npm:^3.2.23"
react-router: "npm:5.3.3"
react-router-dom: "npm:5.3.3"
react-router: "npm:5.3.4"
react-router-dom: "npm:5.3.4"
react-router-dom-v5-compat: "npm:^6.26.1"
react-select: "npm:5.10.0"
react-select-event: "npm:5.5.1"
@@ -22463,19 +22463,6 @@ __metadata:
languageName: node
linkType: hard
"mini-create-react-context@npm:^0.4.0":
version: 0.4.1
resolution: "mini-create-react-context@npm:0.4.1"
dependencies:
"@babel/runtime": "npm:^7.12.1"
tiny-warning: "npm:^1.0.3"
peerDependencies:
prop-types: ^15.0.0
react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
checksum: 10/c816c785b7dccd67fdfa6a5edc673363b11845b6abca8a9d9f3ffa74520266d979b56f5db0dfc62ed912a90553c15be28c816311fc9c7856ab66a81d461d50e6
languageName: node
linkType: hard
"mini-css-extract-plugin@npm:2.9.2":
version: 2.9.2
resolution: "mini-css-extract-plugin@npm:2.9.2"
@@ -26768,20 +26755,20 @@ __metadata:
languageName: node
linkType: hard
"react-router-dom@npm:5.3.3":
version: 5.3.3
resolution: "react-router-dom@npm:5.3.3"
"react-router-dom@npm:5.3.4":
version: 5.3.4
resolution: "react-router-dom@npm:5.3.4"
dependencies:
"@babel/runtime": "npm:^7.12.13"
history: "npm:^4.9.0"
loose-envify: "npm:^1.3.1"
prop-types: "npm:^15.6.2"
react-router: "npm:5.3.3"
react-router: "npm:5.3.4"
tiny-invariant: "npm:^1.0.2"
tiny-warning: "npm:^1.0.0"
peerDependencies:
react: ">=15"
checksum: 10/49552596f1a4c753b99324a5f4345b3ee91fbb780aa65851a7113f053044ef96c083d2ded12937e593b23a0fcdf58b9e49780df6bf6e27d9eeb348b3c85ae611
checksum: 10/5e0696ae2d86f466ff700944758a227e1dcd79b48797d567776506e4e3b4a08b81336155feb86a33be9f38c17c4d3d94212b5c60c8ee9a086022e4fd3961db29
languageName: node
linkType: hard
@@ -26798,15 +26785,14 @@ __metadata:
languageName: node
linkType: hard
"react-router@npm:5.3.3":
version: 5.3.3
resolution: "react-router@npm:5.3.3"
"react-router@npm:5.3.4":
version: 5.3.4
resolution: "react-router@npm:5.3.4"
dependencies:
"@babel/runtime": "npm:^7.12.13"
history: "npm:^4.9.0"
hoist-non-react-statics: "npm:^3.1.0"
loose-envify: "npm:^1.3.1"
mini-create-react-context: "npm:^0.4.0"
path-to-regexp: "npm:^1.7.0"
prop-types: "npm:^15.6.2"
react-is: "npm:^16.6.0"
@@ -26814,7 +26800,7 @@ __metadata:
tiny-warning: "npm:^1.0.0"
peerDependencies:
react: ">=15"
checksum: 10/4631eed91020c73950804c7c7454e74b2eb495f803c5ca60c8b5572ca72cc06e336f3b08d9ee3fa730128a52c4d9e16d1aa7e8b7f85560629117e16d99a01cef
checksum: 10/99d54a99af6bc6d7cad2e5ea7eee9485b62a8b8e16a1182b18daa7fad7dafa5e526850eaeebff629848b297ae055a9cb5b4aba8760e81af8b903efc049d48f5c
languageName: node
linkType: hard
@@ -30309,7 +30295,7 @@ __metadata:
languageName: node
linkType: hard
"tiny-warning@npm:^1.0.0, tiny-warning@npm:^1.0.3":
"tiny-warning@npm:^1.0.0":
version: 1.0.3
resolution: "tiny-warning@npm:1.0.3"
checksum: 10/da62c4acac565902f0624b123eed6dd3509bc9a8d30c06e017104bedcf5d35810da8ff72864400ad19c5c7806fc0a8323c68baf3e326af7cb7d969f846100d71