Merge remote-tracking branch 'origin/main' into serializer-improvements
This commit is contained in:
+6
-4
@@ -3103,7 +3103,8 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "3"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "4"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "4"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "5"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/saving/shared.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
@@ -3192,10 +3193,11 @@ exports[`better eslint`] = {
|
||||
],
|
||||
"public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "3"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
|
||||
@@ -61,7 +61,7 @@ If you want to group alerts by other labels, something other than the alert rule
|
||||
|
||||
### A single group for all alerts
|
||||
|
||||
If you want to group all alerts handled by the notification policy in a single group (without grouping notifications by alert rule or other labels), you can do so by leaving `Group by` empty.
|
||||
If you want to group all alerts handled by the notification policy in a single group (without grouping notifications by alert rule or other labels), leave `Group by` empty in the Default policy.
|
||||
|
||||
### Disable grouping
|
||||
|
||||
|
||||
@@ -52,6 +52,21 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/image-rendering/#configuration
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/image-rendering/#configuration
|
||||
max-width:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/image-rendering/#viewport-maximum-width
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/image-rendering/#viewport-maximum-width
|
||||
max-height:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/image-rendering/#viewport-maximum-height
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/image-rendering/#viewport-maximum-height
|
||||
max-scale:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/image-rendering/#maximum-device-scale-factor
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/image-rendering/#maximum-device-scale-factor
|
||||
---
|
||||
|
||||
# Share dashboards and panels
|
||||
@@ -239,20 +254,29 @@ To share a personalized, direct link to your panel within your organization, fol
|
||||
- **Lock time range** - Change the current relative time range to an absolute time range. This option is enabled by default.
|
||||
- **Shorten link** - Shorten the panel link. This option is disabled by default.
|
||||
1. Select the theme for the dashboard. Choose from **Current**, **Dark**, or **Light**.
|
||||
1. Do one or both of the following:
|
||||
- Click **Copy link**.
|
||||
- Click **Render image**, which [renders the panel as a PNG image](ref:image-rendering).
|
||||
1. Send the copied link or image URL to a Grafana user with authorization to view it.
|
||||
1. Click **Copy link**.
|
||||
1. Send the copied link to a Grafana user with authorization to view it.
|
||||
1. (Optional) To [generate an image of the panel as a PNG file](ref:image-rendering), customize the image settings:
|
||||
|
||||
- **Width** - In pixels. The default is 1000.
|
||||
- **Height** - In pixels. The default is 500.
|
||||
- **Scale factor** - The default is 1.
|
||||
|
||||
There are maximums for [width](ref:max-width), [height](ref:max-height), and [scale factor](ref:max-scale) in the image renderer configuration that you can customize if needed.
|
||||
|
||||
1. (Optional) Click **Generate image** to see a preview of the panel image.
|
||||
1. (Optional) Click **Download image**.
|
||||
1. Send the copied image to a Grafana user with authorization to view it.
|
||||
1. Click the **X** at the top-right corner to close the share drawer.
|
||||
|
||||
#### Query string parameters for server-side rendered images
|
||||
|
||||
When you click **Render image** in the panel link settings, Grafana generates a PNG image of the panel with the following default parameters:
|
||||
When you click **Generate image** in the panel link settings, Grafana generates a PNG image of the panel with the following default parameters:
|
||||
|
||||
| Parameter | Description |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| width | Width in pixels. Default is 800. |
|
||||
| height | Height in pixels. Default is 400. |
|
||||
| width | Width in pixels. Default is 1000. |
|
||||
| height | Height in pixels. Default is 500. |
|
||||
| tz | Timezone in the format `UTC%2BHH%3AMM` where HH and MM are offset in hours and minutes after UTC. |
|
||||
| timeout | Number of seconds. The timeout can be increased if the query for the panel needs more than the default 30 seconds. |
|
||||
| scale | Numeric value to configure device scale factor. Default is 1. Use a higher value to produce more detailed images (higher DPI). |
|
||||
|
||||
@@ -77,11 +77,17 @@ This feature was previously called **email sharing**.
|
||||
Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud).
|
||||
{{< /admonition >}}
|
||||
|
||||
{{< docs/private-preview product="Sharing externally with specific people" >}}
|
||||
<!-- {{< docs/private-preview product="Sharing externally with specific people" >}}-->
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Sharing externally with specific people is currently in [private preview](https://grafana.com/docs/release-life-cycle/#private-preview). Please contact support to have this feature enabled.
|
||||
|
||||
This feature will incur a cost once it is promoted to general availability.
|
||||
{{< /admonition >}}
|
||||
|
||||
To share a dashboard with specific external users, you can send them a link by email. Use this option when you only want to share your dashboard with specific people. When you share dashboards by email, recipients receive a one-time use link that's valid for **one hour**. Once the link is used, the viewer has access to the shared dashboard for **30 days**.
|
||||
|
||||
When you share a dashboard with an email link, your organization is billed per user, regardless of how many dashboards are shared. Grafana bills monthly per user until access is revoked.
|
||||
<!--When you share a dashboard with an email link, your organization is billed per user, regardless of how many dashboards are shared. Grafana bills monthly per user until access is revoked.-->
|
||||
|
||||
To share a dashboard with specific people, follow these steps:
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ refs:
|
||||
|
||||
Gauges are single-value visualizations that allow you to quickly visualize where a value falls within a defined or calculated min and max range. With repeat options, you can display multiple gauges, each corresponding to a different series, column, or row.
|
||||
|
||||
{{< figure src="/static/img/docs/v66/gauge_panel_cover.png" max-width="1025px" alt="A gauge visualization">}}
|
||||
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-gauge-visualization-v11.4.png" alt="A gauge visualization">}}
|
||||
|
||||
You can use gauges if you need to track:
|
||||
|
||||
@@ -35,11 +35,11 @@ You can use gauges if you need to track:
|
||||
- How full a piece of equipment is
|
||||
- How fast a vehicle is moving within a set of limits
|
||||
- Network latency
|
||||
- Equipment state with setpoint and alarm thresholds
|
||||
- Equipment state with set point and alarm thresholds
|
||||
- CPU consumption (0-100%)
|
||||
- RAM availability
|
||||
|
||||
## Configure a time series visualization
|
||||
## Configure a gauge visualization
|
||||
|
||||
The following video provides beginner steps for creating gauge panels. You'll learn the data requirements and caveats, special customizations, and much more:
|
||||
|
||||
@@ -57,13 +57,13 @@ To create a gauge visualization you need a dataset containing at least one numer
|
||||
| --------- | ---------- |
|
||||
| MyGauge | 5 |
|
||||
|
||||

|
||||

|
||||
|
||||
This dataset generates a visualization with one empty gauge showing the numeric value. This is because the gauge visualization automatically defines the upper and lower range from the minimum and maximum values in the dataset. This dataset has only one value, so it’s set as both minimum and maximum.
|
||||
|
||||
If you only have one value, but you want to define a different minimum and maximum, you can set them manually in the [Standard options](#standard-options) settings to generate a more typical looking gauge.
|
||||
|
||||

|
||||

|
||||
|
||||
### Example - One row, multiple values
|
||||
|
||||
@@ -73,7 +73,7 @@ The gauge visualization can support multiple fields in a dataset. <!-- In this c
|
||||
| ---------- | ------ | ------ | ------ |
|
||||
| Gauges | 5 | 3 | 10 |
|
||||
|
||||

|
||||

|
||||
|
||||
When there are multiple values in the dataset, the visualization displays multiple gauges and automatically defines the minimum and maximum. In this case, those are 3 and 10. Because the minimum and maximum values are defined, each gauge is shaded in to show that value in relation to the minimum and maximum.
|
||||
|
||||
@@ -87,7 +87,7 @@ The gauge visualization can display datasets with multiple rows of data or even
|
||||
| Indicators | 6 | 9 | 15 |
|
||||
| Defaults | 1 | 4 | 8 |
|
||||
|
||||

|
||||

|
||||
|
||||
By default, the visualization is configured to [calculate](#value-options) a single value per column or series and to display only the last row of data. However, it derives the minimum and maximum from the full dataset, even if those values aren’t visible.
|
||||
|
||||
@@ -95,7 +95,7 @@ In this example, that means only the last row of data is displayed in the gauges
|
||||
|
||||
If you want to show one gauge per table cell, you can change the **Show** setting from **Calculate** to **All values**, and each gauge is labeled by concatenating the text column with each value's column name.
|
||||
|
||||

|
||||

|
||||
|
||||
### Example - Defined min and max
|
||||
|
||||
@@ -105,118 +105,84 @@ You can also define minimum and maximum values as part of the dataset.
|
||||
| ---------- | ----- | --- | --- |
|
||||
| Gauges | 5 | 10 | 2 |
|
||||
|
||||

|
||||

|
||||
|
||||
If you don’t want to display gauges for the `min` and `max` values, you can configure only one field to be displayed as described in the [value options](#value-options) section.
|
||||
|
||||

|
||||

|
||||
|
||||
Even when minimum and maximum values aren’t displayed, the visualization still pulls the range from them.
|
||||
|
||||
## Panel options
|
||||
## Configuration options
|
||||
|
||||
{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
### Panel options
|
||||
|
||||
{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Value options
|
||||
### Value options
|
||||
|
||||
Use the following options to refine how your visualization displays the value:
|
||||
|
||||
### Show
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
Choose how Grafana displays your data.
|
||||
| Option | Description |
|
||||
| ------ | ----------- |
|
||||
| Show | Set how Grafana displays your data. Choose from:<ul><li>**Calculate** - Show a calculated value based on all rows.</li><li>**All values** - Show a separate value for every row. If you select this option, then you can also limit the number of rows to display.</li></ul> |
|
||||
| Calculation | If you chose **Calculate** as your **Show** option, select a reducer function that Grafana will use to reduce many fields to a single value. For a list of available calculations, refer to [Calculation types](ref:calculation-types). |
|
||||
| Limit | If you chose **All values** as your **Show** option, enter the maximum number of rows to display. The default is 5,000. |
|
||||
| Fields | Select the fields display in the panel. |
|
||||
|
||||
#### Calculate
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
Show a calculated value based on all rows.
|
||||
|
||||
- **Calculation -** Select a reducer function that Grafana will use to reduce many fields to a single value. For a list of available calculations, refer to [Calculation types](ref:calculation-types).
|
||||
- **Fields -** Select the fields display in the panel.
|
||||
|
||||
#### All values
|
||||
|
||||
Show a separate stat for every row. If you select this option, then you can also limit the number of rows to display.
|
||||
|
||||
- **Limit -** The maximum number of rows to display. Default is 5,000.
|
||||
- **Fields -** Select the fields display in the panel.
|
||||
|
||||
## Gauge
|
||||
### Gauge options
|
||||
|
||||
Adjust how the gauge is displayed.
|
||||
|
||||
### Orientation
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
Choose a stacking direction.
|
||||
| Option | Description |
|
||||
| ------ | ----------- |
|
||||
| Orientation | Choose a stacking direction:<ul><li>**Auto** - Gauges display in rows and columns.</li><li>**Horizontal** - Gauges display top to bottom.</li><li>**Vertical** - Gauges display left to right.</li></ul> |
|
||||
| Show threshold labels | Controls if threshold values are shown. |
|
||||
| [Show threshold markers](#show-threshold-markers) | Controls if a threshold band is shown outside the inner gauge value band. |
|
||||
| Gauge size | Choose a gauge size mode:<ul><li>**Auto** - Grafana determines the best gauge size.</li><li>**Manual** - Manually configure the gauge size.</li></ul>This option only applies when **Orientation** is set to **Horizontal** or **Vertical**. |
|
||||
| Min width | Set the minimum width of vertically-oriented gauges. If you set a minimum width, the x-axis scrollbar is automatically displayed when there's a large amount of data. This option only applies when **Gauge size** is set to **Manual**. |
|
||||
| Min height | Set the minimum height of horizontally-oriented gauges. If you set a minimum height, the y-axis scrollbar is automatically displayed when there's a large amount of data. This option only applies when **Gauge size** is set to **Manual**. |
|
||||
| Neutral | Set the starting value from which every gauge will be filled. |
|
||||
|
||||
- **Auto -** Gauges display in rows and columns.
|
||||
- **Horizontal -** Gauges display top to bottom.
|
||||
- **Vertical -** Gauges display left to right.
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
### Show threshold labels
|
||||
#### Show threshold markers
|
||||
|
||||
Controls if threshold values are shown.
|
||||
Controls if a threshold band is shown around the inner gauge value band.
|
||||
|
||||
### Show threshold markers
|
||||

|
||||
|
||||
Controls if a threshold band is shown outside the inner gauge value band.
|
||||
|
||||
### Gauge size
|
||||
|
||||
Choose a gauge size mode.
|
||||
|
||||
- **Auto -** Grafana determines the best gauge size.
|
||||
- **Manual -** Manually configure the gauge size.
|
||||
|
||||
### Min width
|
||||
|
||||
Set the minimum width of vertically-oriented gauges.
|
||||
|
||||
If you set a minimum width, the x-axis scrollbar is automatically displayed when there's a large amount of data.
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
This option only applies when gauge size is set to manual.
|
||||
{{% /admonition %}}
|
||||
|
||||
### Min height
|
||||
|
||||
Set the minimum height of horizontally-oriented gauges.
|
||||
|
||||
If you set a minimum height, the y-axis scrollbar is automatically displayed when there's a large amount of data.
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
This option only applies when gauge size is set to manual.
|
||||
{{% /admonition %}}
|
||||
|
||||
### Neutral
|
||||
|
||||
Set the starting value from which every gauge will be filled.
|
||||
|
||||
## Text size
|
||||
### Text size
|
||||
|
||||
Adjust the sizes of the gauge text.
|
||||
|
||||
- **Title -** Enter a numeric value for the gauge title size.
|
||||
- **Value -** Enter a numeric value for the gauge value size.
|
||||
- **Title** - Enter a numeric value for the gauge title size.
|
||||
- **Value** - Enter a numeric value for the gauge value size.
|
||||
|
||||
## Standard options
|
||||
### Standard options
|
||||
|
||||
{{< docs/shared lookup="visualizations/standard-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Data links
|
||||
### Data links
|
||||
|
||||
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Value mappings
|
||||
### Value mappings
|
||||
|
||||
{{< docs/shared lookup="visualizations/value-mappings-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Thresholds
|
||||
### Thresholds
|
||||
|
||||
{{< docs/shared lookup="visualizations/thresholds-options-2.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
Last, gauge colors and thresholds (the outer bar markers) of the gauge can be configured as described above.
|
||||
|
||||

|
||||
|
||||
## Field overrides
|
||||
### Field overrides
|
||||
|
||||
{{< docs/shared lookup="visualizations/overrides-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
@@ -220,8 +220,8 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `timeRangeProvider` | Enables time pickers sync |
|
||||
| `prometheusUsesCombobox` | Use new combobox component for Prometheus query editor |
|
||||
| `userStorageAPI` | Enables the user storage API |
|
||||
| `dashboardSchemaV2` | Enables the new dashboard schema version 2, implementing changes necessary for dynamic dashboards and dashboards as code. |
|
||||
| `playlistsWatcher` | Enables experimental watcher for playlists |
|
||||
| `prometheusSpecialCharsInLabelValues` | Adds support for quotes and special characters in label values for Prometheus queries |
|
||||
| `enableExtensionsAdminPage` | Enables the extension admin page regardless of development mode |
|
||||
| `enableSCIM` | Enables SCIM support for user and group management |
|
||||
| `crashDetection` | Enables browser crash detection reporting to Faro. |
|
||||
|
||||
@@ -10,10 +10,10 @@ labels:
|
||||
- cloud
|
||||
tags:
|
||||
- beginner
|
||||
title: Get started with Grafana Alerting - Part 2
|
||||
title: Get started with Grafana Alerting - Part 2 of 3
|
||||
weight: 50
|
||||
killercoda:
|
||||
title: Get started with Grafana Alerting - Part 2
|
||||
title: Get started with Grafana Alerting - Part 2 of 3
|
||||
description: Learn to use alert instances and route notifications by labels to contacts, building on your alerting skills in Grafana for more advanced workflows — Part 2.
|
||||
backend:
|
||||
imageid: ubuntu
|
||||
@@ -21,7 +21,7 @@ killercoda:
|
||||
|
||||
<!-- INTERACTIVE page intro.md START -->
|
||||
|
||||
# Get started with Grafana Alerting - Part 2
|
||||
# Get started with Grafana Alerting - Part 2 of 3
|
||||
|
||||
The Get started with Grafana Alerting tutorial Part 2 is a continuation of [Get started with Grafana Alerting tutorial Part 1](http://www.grafana.com/tutorials/alerting-get-started/).
|
||||
|
||||
@@ -169,7 +169,7 @@ In the above diagram, alert instances and notification policies are matched by l
|
||||
|
||||
## Create notification policies
|
||||
|
||||
Create a notification policy if you want to handle metrics returned by alert rules separately by routing each alert instance to a specific contact point. In Grafana, click on the icon at the top left corner of the screen to access the navigation menu.
|
||||
Create a notification policy if you want to handle metrics returned by alert rules separately by routing each alert instance to a specific contact point.
|
||||
|
||||
<!-- INTERACTIVE ignore START -->
|
||||
|
||||
@@ -237,7 +237,6 @@ Grafana includes a [test data source](https://grafana.com/docs/grafana/latest/da
|
||||
|
||||
1. Select **TestData** data source from the drop-down menu.
|
||||
1. From **Scenario** select **CSV Content**.
|
||||
1. In the Query editor, switch to **Code** mode by clicking the button on the right.
|
||||
1. Copy in the following CSV data:
|
||||
|
||||
```
|
||||
@@ -252,7 +251,7 @@ Grafana includes a [test data source](https://grafana.com/docs/grafana/latest/da
|
||||
|
||||
- Keep `Last` as the value for the reducer function (`WHEN`), and `1000` as the threshold value. This is the value above which the alert rule should trigger.
|
||||
|
||||
1. Click **Preview** to run the queries.
|
||||
1. Click **Preview alert rule condition** to run the queries.
|
||||
|
||||
It should return two series.`desktop` in Firing state, and `mobile` in Normal state. The values `1`, and `0` mean that the condition is either `true` or `false`.
|
||||
|
||||
@@ -267,8 +266,8 @@ In the [life cycle](http://grafana.com/docs/grafana/next/alerting/fundamentals/a
|
||||
|
||||
To set up evaluation behavior:
|
||||
|
||||
1. In **Folder**, click **+ New folder** and enter a name. For example: `web-traffic-alerts`. This folder will contain our alerts.
|
||||
1. In the **Evaluation group**, repeat the above step to create a new evaluation group. We will name it `1m` (referring to “1 minute”).
|
||||
1. In **Folder**, click **+ New folder** and enter a name. For example: `web-traffic-alerts`. This folder contains our alert rules.
|
||||
1. In **Evaluation group**, repeat the above step to create a new evaluation group. Name it `1m` (referring to “1 minute”).
|
||||
1. Choose an Evaluation interval (how often the alert will be evaluated). Choose `1m`.
|
||||
1. Set the pending period to `0s` (zero seconds), so the alert rule fires the moment the condition is met.
|
||||
|
||||
@@ -320,6 +319,22 @@ In this tutorial, you have learned how Grafana Alerting can route individual ale
|
||||
|
||||
If you run into any problems, you are welcome to post questions in our [Grafana Community forum](https://community.grafana.com/).
|
||||
|
||||
Enjoy your monitoring!
|
||||
## Learn more in [Grafana Alerting Part 3](http://www.grafana.com/tutorials/alerting-get-started-pt3/)
|
||||
|
||||
<!-- INTERACTIVE ignore START -->
|
||||
|
||||
{{< admonition type="tip" >}}
|
||||
|
||||
In [Get started with Grafana Alerting - Part 3](http://www.grafana.com/tutorials/alerting-get-started-pt3/) you learn how to group alert notifications effectively.
|
||||
|
||||
{{< /admonition >}}
|
||||
|
||||
<!-- INTERACTIVE ignore END -->
|
||||
|
||||
{{< docs/ignore >}}
|
||||
|
||||
In [Get started with Grafana Alerting - Part 3](http://www.grafana.com/tutorials/alerting-get-started-pt3/) you learn how to group alert notifications effectively.
|
||||
|
||||
{{< /docs/ignore >}}
|
||||
|
||||
<!-- INTERACTIVE page finish.md END -->
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
---
|
||||
Feedback Link: https://github.com/grafana/tutorials/issues/new
|
||||
categories:
|
||||
- alerting
|
||||
description: Learn how to group alert notifications effectively to reduce noise and streamline communication in Grafana Alerting — Part 3.
|
||||
labels:
|
||||
products:
|
||||
- enterprise
|
||||
- oss
|
||||
- cloud
|
||||
tags:
|
||||
- intermediate
|
||||
title: Get started with Grafana Alerting - Part 3
|
||||
weight: 60
|
||||
killercoda:
|
||||
title: Get started with Grafana Alerting - Part 3
|
||||
description: Learn how to group alert notifications effectively to reduce noise and streamline communication in Grafana Alerting — Part 3.
|
||||
backend:
|
||||
imageid: ubuntu
|
||||
refs:
|
||||
alert-labels:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/annotation-label/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label/
|
||||
notification-policies:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/notifications/notification-policies/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/notification-policies/
|
||||
alert-grouping:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/notifications/group-alert-notifications/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/group-alert-notifications/
|
||||
---
|
||||
|
||||
<!-- INTERACTIVE page intro.md START -->
|
||||
|
||||
# Get started with Grafana Alerting - Part 3
|
||||
|
||||
The Get started with Grafana Alerting tutorial Part 3 is a continuation of [Get started with Grafana Alerting tutorial Part 2](http://www.grafana.com/tutorials/alerting-get-started-pt2/).
|
||||
|
||||
Alert grouping in Grafana Alerting reduces notification noise by combining related alerts into a single, concise notification. This is essential for on-call engineers, ensuring they focus on resolving incidents instead of sorting through a flood of notifications.
|
||||
|
||||
Grouping is configured by using labels in the notification policy that reference the labels that are generated by the alert instances. With notification policies, you can also configure how often notifications are sent for each group of alerts.
|
||||
|
||||
In this tutorial, you will:
|
||||
|
||||
- Learn how alert rule grouping works.
|
||||
- Create a notification policy to handle grouping.
|
||||
- Define an alert rule for a real-world scenario.
|
||||
- Receive and review grouped alert notifications.
|
||||
|
||||
<!-- INTERACTIVE page intro.md END -->
|
||||
<!-- INTERACTIVE page step1.md START -->
|
||||
|
||||
<!-- INTERACTIVE ignore START -->
|
||||
|
||||
{{< docs/ignore >}}
|
||||
|
||||
## Set up the Grafana stack
|
||||
|
||||
{{< /docs/ignore >}}
|
||||
|
||||
## Before you begin
|
||||
|
||||
There are different ways you can follow along with this tutorial.
|
||||
|
||||
- **Grafana Cloud**
|
||||
|
||||
- As a Grafana Cloud user, you don't have to install anything. [Create your free account](http://www.grafana.com/auth/sign-up/create-user).
|
||||
|
||||
Continue to [How alert rule grouping works](#how-alert-rule-grouping-works).
|
||||
|
||||
- **Interactive learning environment**
|
||||
|
||||
- Alternatively, you can try out this example in our interactive learning environment: [Get started with Grafana Alerting - Part 3](https://killercoda.com/grafana-labs/course/grafana/alerting-get-started-pt3/). It's a fully configured environment with all the dependencies already installed.
|
||||
|
||||
- **Grafana OSS**
|
||||
|
||||
- If you opt to run a Grafana stack locally, ensure you have the following applications installed:
|
||||
|
||||
- [Docker Compose](https://docs.docker.com/get-docker/) (included in Docker for Desktop for macOS and Windows)
|
||||
- [Git](https://git-scm.com/)
|
||||
|
||||
### Set up the Grafana stack (OSS users)
|
||||
|
||||
<!-- INTERACTIVE ignore END -->
|
||||
|
||||
To demonstrate the observation of data using the Grafana stack, download and run the following files.
|
||||
|
||||
1. Clone the [tutorial environment repository](https://www.github.com/grafana/tutorial-environment).
|
||||
|
||||
<!-- INTERACTIVE exec START -->
|
||||
|
||||
```
|
||||
git clone https://github.com/grafana/tutorial-environment.git
|
||||
```
|
||||
|
||||
<!-- INTERACTIVE exec END -->
|
||||
|
||||
1. Change to the directory where you cloned the repository:
|
||||
|
||||
<!-- INTERACTIVE exec START -->
|
||||
|
||||
```
|
||||
cd tutorial-environment
|
||||
```
|
||||
|
||||
<!-- INTERACTIVE exec END -->
|
||||
|
||||
1. Run the Grafana stack:
|
||||
|
||||
<!-- INTERACTIVE ignore START -->
|
||||
|
||||
```
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
<!-- INTERACTIVE ignore END -->
|
||||
|
||||
{{< docs/ignore >}}
|
||||
|
||||
<!-- INTERACTIVE exec START -->
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
<!-- INTERACTIVE exec END -->
|
||||
|
||||
{{< /docs/ignore >}}
|
||||
|
||||
The first time you run `docker compose up -d`, Docker downloads all the necessary resources for the tutorial. This might take a few minutes, depending on your internet connection.
|
||||
|
||||
<!-- INTERACTIVE ignore START -->
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
If you already have Grafana, Loki, or Prometheus running on your system, you might see errors, because the Docker image is trying to use ports that your local installations are already using. If this is the case, stop the services, then run the command again.
|
||||
{{< /admonition >}}
|
||||
|
||||
<!-- INTERACTIVE ignore END -->
|
||||
|
||||
{{< docs/ignore >}}
|
||||
|
||||
NOTE:
|
||||
|
||||
If you already have Grafana, Loki, or Prometheus running on your system, you might see errors, because the Docker image is trying to use ports that your local installations are already using. If this is the case, stop the services, then run the command again.
|
||||
|
||||
{{< /docs/ignore >}}
|
||||
|
||||
<!-- INTERACTIVE page step1.md END -->
|
||||
<!-- INTERACTIVE page step2.md START -->
|
||||
|
||||
## How alert rule grouping works
|
||||
|
||||
Alert notification grouping is configured with **labels** and **timing options**:
|
||||
|
||||
- **Labels** map the alert rule with the notification policy and define the grouping.
|
||||
- **Timing options** control when and how often notifications are sent.
|
||||
|
||||
{{< figure src="/media/docs/alerting/alerting-notification-policy-diagram-with-labels-v3.png" max-width="750px" alt="A diagram about the components of a notification policy, including labels and groups" >}}
|
||||
|
||||
### Types of Labels
|
||||
|
||||
1. **Reserved labels** (default):
|
||||
|
||||
- Automatically generated by Grafana, e.g., `alertname`, `grafana_folder`.
|
||||
- Example: `alertname="High CPU usage"`.
|
||||
|
||||
1. **User-configured labels**:
|
||||
|
||||
- Added manually to the alert rule.
|
||||
- Example: `severity`, `priority`.
|
||||
|
||||
1. **Query labels**:
|
||||
- Returned by the data source query.
|
||||
- Example: `region`, `service`, `environment`.
|
||||
|
||||
### Timing Options
|
||||
|
||||
1. **Group wait**: Time before sending the first notification.
|
||||
1. **Group interval**: Time between notifications for a group.
|
||||
1. **Repeat interval**: Time before resending notifications for an unchanged group.
|
||||
|
||||
Alerts sharing the **same label values** are grouped together, and timing options determine notification frequency.
|
||||
|
||||
For more details, see:
|
||||
|
||||
- [Grouping Alerts](https://grafana.com/docs/grafana/latest/alerting/fundamentals/notifications/group-alert-notifications/)
|
||||
- [Alert Labels](https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/annotation-label/#label-types)
|
||||
|
||||
<!-- INTERACTIVE page step2.md END -->
|
||||
<!-- INTERACTIVE page step3.md START -->
|
||||
|
||||
## A real-world example of alert grouping in action
|
||||
|
||||
### Scenario: monitoring a distributed application
|
||||
|
||||
You’re monitoring metrics like CPU usage, memory utilization, and network latency across multiple regions. Alert rules include labels such as `region: us-west` and `region: us-east`. If multiple alerts trigger across these regions, they can result in notification floods.
|
||||
|
||||
### How to manage grouping
|
||||
|
||||
To group alert rule notifications:
|
||||
|
||||
1. **Define labels**: Use `region`, `metric`, or `instance` labels to categorize alerts.
|
||||
1. **Configure Notification policies**:
|
||||
- Group alerts by the `region` label.
|
||||
- Example:
|
||||
- Alerts for `region: us-west` go to the West Coast team.
|
||||
- Alerts for `region: us-east` go to the East Coast team.
|
||||
|
||||
<!-- INTERACTIVE page step3.md END -->
|
||||
<!-- INTERACTIVE page step4.md START -->
|
||||
|
||||
## Setting up alert rule grouping
|
||||
|
||||
### Notification Policy
|
||||
|
||||
[Notification policies](ref:notification-policies) group alert instances and route notifications to specific contact points.
|
||||
|
||||
To follow the above example, we will create notification policies that route alert instances based on the `region` label to specific contact points. This setup ensures that alerts for a given region are consolidated into a single notification. Additionally, we will fine-tune the **timing settings** for each region by overriding the default parent policy, allowing more granular control over when notifications are sent.
|
||||
|
||||
<!-- INTERACTIVE ignore START -->
|
||||
|
||||
1. Sign in to Grafana:
|
||||
|
||||
- **Grafana Cloud** users: Log in via Grafana Cloud.
|
||||
- **OSS users**: Go to [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
1. Navigate to **Notification Policies**:
|
||||
|
||||
- Go to **Alerts & IRM > Alerting > Notification Policies**.
|
||||
|
||||
1. Add a child policy:
|
||||
|
||||
- In the Default policy, click **+ New child policy**.
|
||||
- **Label**: `region`
|
||||
- **Operator**: `=`
|
||||
- **Value**: `us-west`
|
||||
|
||||
This label matches alert rules where the region label is us-west.
|
||||
|
||||
1. Choose a **Contact point**:
|
||||
|
||||
- Select **Webhook**.
|
||||
|
||||
If you don’t have any contact points, add a [Contact point](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/#add-a-contact-point).
|
||||
|
||||
1. Enable Continue matching:
|
||||
|
||||
- Turn on **Continue matching subsequent sibling nodes** so the evaluation continues even after one or more labels (i.e. region label) match.
|
||||
|
||||
1. Override grouping settings:
|
||||
|
||||
- Toggle **Override grouping**.
|
||||
- **Group by**: `region`.
|
||||
|
||||
**Group by** consolidates alerts that share the same grouping label into a single notification. For example, all alerts with `region=us-west` will be combined into one notification, making it easier to manage and reducing alert fatigue.
|
||||
|
||||
1. Set custom timing:
|
||||
|
||||
- Toggle **Override general timings**.
|
||||
- **Group interval**: `2m`. This ensures follow-up notifications for the same alert group will be sent at intervals of 2 minutes. While the default is 5 minutes, we chose 2 minutes here to provide faster feedback for demonstration purposes.
|
||||
|
||||
**Timing options** control how often notifications are sent and can help balance timely alerting with minimizing noise.
|
||||
|
||||
1. Save and repeat:
|
||||
|
||||
- Repeat for `region = us-east` with a different webhook or a different contact point.
|
||||
|
||||
{{< figure src="/media/docs/alerting/notificaiton-policies-region.png" max-width="750px" alt="Two nested notification policies to route and group alert notifications" >}}
|
||||
|
||||
These nested policies should route alert instances where the region label is either us-west or us-east.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
In Grafana, each label within a notification policy must have a unique key. If you attempt to add the same label key (e.g., region) with different values (us-west and us-east), only the last entry is saved, and the previous one is discarded. This is because labels are stored as associative arrays (maps), where each key must be unique.
|
||||
For identical label keys use regex matchers (e.g., _region=~"us-west|us-east"_).
|
||||
{{</ admonition >}}
|
||||
|
||||
<!-- INTERACTIVE ignore END -->
|
||||
|
||||
{{< docs/ignore >}}
|
||||
|
||||
1. Visit [http://localhost:3000](http://localhost:3000), where Grafana should be running
|
||||
1. Navigate to **Alerts & IRM > Alerting > Notification policies**.
|
||||
1. In the Default policy, click **+ New child policy**.
|
||||
|
||||
- In the Default policy, click **+ New child policy**.
|
||||
- **Label**: `region`
|
||||
- **Operator**: `=`
|
||||
- **Value**: `us-west`
|
||||
|
||||
This label matches alert rules where the region label is us-west
|
||||
|
||||
1. Choose a **Contact point**:
|
||||
|
||||
- Select **Webhook**.
|
||||
|
||||
If you don’t have any contact points, add a Contact point.
|
||||
|
||||
1. Enable Continue matching:
|
||||
|
||||
- Turn on **Continue matching subsequent sibling nodes** so the evaluation continues even after one or more labels (i.e. region label) match.
|
||||
|
||||
1. Override grouping settings:
|
||||
|
||||
- Toggle **Override grouping**.
|
||||
- **Group by**: `region`.
|
||||
|
||||
**Group by** consolidates alerts that share the same grouping label into a single notification. For example, all alerts with `region=us-west` will be combined into one notification, making it easier to manage and reducing alert fatigue.
|
||||
|
||||
1. Set custom timing:
|
||||
|
||||
- Toggle **Override general timings**.
|
||||
- **Group interval**: `2m`. This ensures follow-up notifications for the same alert group will be sent at intervals of 2 minutes. While the default is 5 minutes, we chose 2 minutes here to provide faster feedback for demonstration purposes.
|
||||
|
||||
**Timing options** control how often notifications are sent and can help balance timely alerting with minimizing noise.
|
||||
|
||||
1. Save and repeat:
|
||||
|
||||
- Repeat for `region = us-east` with a different webhook or a different contact point.
|
||||
|
||||
**Note**: In Grafana, each label within a notification policy must have a unique key. If you attempt to add the same label key (e.g., region) with different values (us-west and us-east), only the last entry is saved, and the previous one is discarded. This is because labels are stored as associative arrays (maps), where each key must be unique.
|
||||
For identical label keys use regex matchers (e.g., _region=~"us-west|us-east"_).
|
||||
|
||||
{{< /docs/ignore >}}
|
||||
|
||||
<!-- INTERACTIVE page step4.md END -->
|
||||
<!-- INTERACTIVE page step5.md START -->
|
||||
|
||||
## Create an alert rule
|
||||
|
||||
In this section we configure an alert rule based on our application monitoring example.
|
||||
|
||||
1. Navigate to **Alerting > Alert rules**.
|
||||
2. Click **New alert rule**.
|
||||
|
||||
### Enter an alert rule name
|
||||
|
||||
Make it short and descriptive as this appears in your alert notification. For instance, `High CPU usage - Multi-region`.
|
||||
|
||||
### Define query and alert condition
|
||||
|
||||
In this section, we use the default options for Grafana-managed alert rule creation. The default options let us define the query, a expression (used to manipulate the data -- the `WHEN` field in the UI), and the condition that must be met for the alert to be triggered (in default mode is the threshold).
|
||||
|
||||
Grafana includes a [test data source](https://grafana.com/docs/grafana/latest/datasources/testdata/) that creates simulated time series data. This data source is included in the demo environment for this tutorial. If you're working in Grafana Cloud or your own local Grafana instance, you can add the data source through the **Connections** menu.
|
||||
|
||||
1. From the drop-down menu, select **TestData** data source.
|
||||
1. From **Scenario** select **CSV Content**.
|
||||
1. Copy in the following CSV data:
|
||||
|
||||
- Select **TestData** as the data source.
|
||||
- Set **Scenario** to **CSV Content**.
|
||||
- Use the following CSV data:
|
||||
|
||||
```csv
|
||||
region,cpu-usage,service,instance
|
||||
us-west,35,web-server-1,server-01
|
||||
us-west,81,web-server-1,server-02
|
||||
us-east,79,web-server-2,server-03
|
||||
us-east,52,web-server-2,server-04
|
||||
us-west,45,db-server-1,server-05
|
||||
us-east,77,db-server-2,server-06
|
||||
us-west,82,db-server-1,server-07
|
||||
us-east,93,db-server-2,server-08
|
||||
```
|
||||
|
||||
The returned data simulates a data source returning multiple time series, each leading to the creation of an alert instance for that specific time series.
|
||||
|
||||
1. In the **Alert condition** section:
|
||||
|
||||
- Keep `Last` as the value for the reducer function (`WHEN`), and `75` as the threshold value. This is the value above which the alert rule should trigger.
|
||||
|
||||
1. Click **Preview alert rule condition** to run the queries.
|
||||
|
||||
It should return 5 series in Firing state, two firing instances from the us-west region, and three from the us-east region.
|
||||
|
||||
{{< figure src="/media/docs/alerting/regions-alert-instance-preview.png" max-width="750px" alt="Preview of a query returning alert instances." >}}
|
||||
|
||||
### Set evaluation behavior
|
||||
|
||||
Every alert rule is assigned to an evaluation group. You can assign the alert rule to an existing evaluation group or create a new one.
|
||||
|
||||
1. In **Folder**, click **+ New folder** and enter a name. For example: `Multi-region CPU alerts`. This folder contains our alert rules.
|
||||
1. In the **Evaluation group**, repeat the above step to create a new evaluation group. Name it `Multi-region CPU group`.
|
||||
1. Choose an **Evaluation interval** (how often the alert are evaluated). Choose `1m`.
|
||||
|
||||
The evaluation interval of 1 minute allows Grafana to detect changes quickly, while the longer **Group wait** (from our notification policy) and **Group interval** (inherited from the Default notification policy) allow for efficient grouping of alerts and minimize unnecessary notifications.
|
||||
|
||||
1. Set the pending period to `0s` (zero seconds), so the alert rule fires the moment the condition is met (this minimizes the waiting time for the demonstration).
|
||||
|
||||
### Configure labels and notifications
|
||||
|
||||
Choose the notification policy where you want to receive your alert notifications.
|
||||
|
||||
1. Select **Use notification policy**.
|
||||
1. Click **Preview routing** to ensure correct matching.
|
||||
|
||||
{{< figure src="/media/docs/alerting/region-notification-policy-routing-preview.png" max-width="750px" alt="Preview of alert instance routing with the region label matcher" >}}
|
||||
|
||||
The preview shows that the region label from our data source is successfully matching the notification policies that we created earlier thanks to the label matcher that we configured.
|
||||
|
||||
1. Click **Save rule and exit**.
|
||||
|
||||
<!-- INTERACTIVE page step5.md END -->
|
||||
<!-- INTERACTIVE page step6.md START -->
|
||||
|
||||
## Receiving grouped alert notifications
|
||||
|
||||
Now that the alert rule has been configured, you should receive alert notifications in the contact point whenever alerts trigger.
|
||||
|
||||
When the configured alert rule detects CPU usage higher than 75% across multiple regions, it will evaluate the metric every minute. If the condition persists, notifications will be grouped together, with a **Group wait** of 30 seconds before the first alert is sent. Follow-up notifications are sent every 2 minutes for quick updates in this demonstration, but for reducing alert frequency, consider using the default or increasing the interval. If the condition continues for an extended period, a **Repeat interval** of 4 hours ensures that the alert is only resent if the issue persists
|
||||
|
||||
As a result, our notification policy will route two notifications: one notification grouping the three alert instances from the `us-east` region and another grouping the two alert instances from the `us-west` region
|
||||
|
||||
Grouped notifications example:
|
||||
|
||||
Webhook - US East
|
||||
|
||||
```json
|
||||
{
|
||||
"receiver": "webhook-us-east",
|
||||
"status": "firing",
|
||||
"alerts": [{ "instance": "server-03" }, { "instance": "server-06" }, { "instance": "server-08" }]
|
||||
}
|
||||
```
|
||||
|
||||
Webhook - US West
|
||||
|
||||
```json
|
||||
{
|
||||
"receiver": "webhook-us-west",
|
||||
"status": "firing",
|
||||
"alerts": [{ "instance": "server-02" }, { "instance": "server-07" }]
|
||||
}
|
||||
```
|
||||
|
||||
<!-- INTERACTIVE page step6.md END -->
|
||||
|
||||
<!-- INTERACTIVE page finish.md START -->
|
||||
|
||||
## Conclusion
|
||||
|
||||
Alert rule grouping simplifies incident management by consolidating related alerts. By configuring **notification policies** and using **labels** (such as _region_), you can group alerts based on specific criteria and route them to the appropriate teams. Fine-tuning **timing options**—including group wait, group interval, and repeat interval—further reduces noise and ensures notifications remain actionable without overwhelming on-call engineers.
|
||||
|
||||
<!-- INTERACTIVE page finish.md END -->
|
||||
@@ -11,10 +11,10 @@ labels:
|
||||
- cloud
|
||||
tags:
|
||||
- beginner
|
||||
title: Get started with Grafana Alerting - Part 1 of 2
|
||||
title: Get started with Grafana Alerting - Part 1 of 3
|
||||
weight: 50
|
||||
killercoda:
|
||||
title: Get started with Grafana Alerting - Part 1 of 2
|
||||
title: Get started with Grafana Alerting - Part 1 of 3
|
||||
description: Get started with Grafana Alerting by creating your first alert rule, sending notifications to a webhook, and generating data to test it live — Part 1.
|
||||
backend:
|
||||
imageid: ubuntu
|
||||
@@ -22,7 +22,7 @@ killercoda:
|
||||
|
||||
<!-- INTERACTIVE page intro.md START -->
|
||||
|
||||
# Get started with Grafana Alerting - Part 1 of 2
|
||||
# Get started with Grafana Alerting - Part 1 of 3
|
||||
|
||||
In this guide, we walk you through the process of setting up your first alert in just a few minutes. You'll witness your alert in action with real-time data, as well as sending alert notifications.
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ require (
|
||||
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
|
||||
github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/alerting v0.0.0-20241211182001-0f317eb6b2f7 // @grafana/alerting-backend
|
||||
github.com/grafana/authlib v0.0.0-20241212110159-5ae10177a923 // @grafana/identity-access-team
|
||||
github.com/grafana/authlib v0.0.0-20241219093220-08aa05b9cf26 // @grafana/identity-access-team
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 // @grafana/identity-access-team
|
||||
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d // @grafana/dataviz-squad
|
||||
github.com/grafana/cuetsy v0.1.11 // @grafana/grafana-as-code
|
||||
|
||||
@@ -2293,8 +2293,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20241211182001-0f317eb6b2f7 h1:VGLUQ2mwzlF1NGwTxpSfv1RnuOsDlNh/NT5KRvhZ0sQ=
|
||||
github.com/grafana/alerting v0.0.0-20241211182001-0f317eb6b2f7/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
|
||||
github.com/grafana/authlib v0.0.0-20241212110159-5ae10177a923 h1:M5WFasIUNOO48W8OpmryvEKkfnlmPBPG10uWiAmeB1k=
|
||||
github.com/grafana/authlib v0.0.0-20241212110159-5ae10177a923/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI=
|
||||
github.com/grafana/authlib v0.0.0-20241219093220-08aa05b9cf26 h1:kWe1MsWAtFQvmnbwnQNJdaGCTqbGNVuowOtLmhO5qEY=
|
||||
github.com/grafana/authlib v0.0.0-20241219093220-08aa05b9cf26/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI=
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk=
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A=
|
||||
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d h1:hrXbGJ5jgp6yNITzs5o+zXq0V5yT3siNJ+uM8LGwWKk=
|
||||
|
||||
+4
-4
@@ -90,7 +90,7 @@
|
||||
"@react-types/overlays": "3.8.11",
|
||||
"@react-types/shared": "3.26.0",
|
||||
"@rsdoctor/webpack-plugin": "^0.4.6",
|
||||
"@rtk-query/codegen-openapi": "^1.2.0",
|
||||
"@rtk-query/codegen-openapi": "^2.0.0",
|
||||
"@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1",
|
||||
"@stylistic/eslint-plugin-ts": "^2.9.0",
|
||||
"@swc/core": "1.10.1",
|
||||
@@ -231,7 +231,7 @@
|
||||
"smtp-tester": "^2.1.0",
|
||||
"style-loader": "4.0.0",
|
||||
"stylelint": "16.12.0",
|
||||
"stylelint-config-sass-guidelines": "11.1.0",
|
||||
"stylelint-config-sass-guidelines": "12.1.0",
|
||||
"terser-webpack-plugin": "5.3.11",
|
||||
"testing-library-selector": "0.3.1",
|
||||
"tracelib": "1.0.1",
|
||||
@@ -345,7 +345,7 @@
|
||||
"lru-cache": "11.0.2",
|
||||
"lru-memoize": "^1.1.0",
|
||||
"lucene": "^2.1.1",
|
||||
"marked": "12.0.2",
|
||||
"marked": "15.0.4",
|
||||
"memoize-one": "6.0.0",
|
||||
"micro-memoize": "^4.1.2",
|
||||
"ml-regression-polynomial": "^3.0.0",
|
||||
@@ -372,7 +372,7 @@
|
||||
"react-grid-layout": "patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch",
|
||||
"react-highlight-words": "0.20.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-i18next": "^14.0.0",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-inlinesvg": "4.1.5",
|
||||
"react-loading-skeleton": "3.5.0",
|
||||
"react-moveable": "0.56.0",
|
||||
|
||||
@@ -41,12 +41,12 @@
|
||||
"@types/string-hash": "1.1.3",
|
||||
"d3-interpolate": "3.0.1",
|
||||
"date-fns": "4.1.0",
|
||||
"dompurify": "^3.0.0",
|
||||
"dompurify": "3.2.3",
|
||||
"eventemitter3": "5.0.1",
|
||||
"fast_array_intersect": "1.1.0",
|
||||
"history": "4.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"marked": "12.0.2",
|
||||
"marked": "15.0.4",
|
||||
"marked-mangle": "1.1.10",
|
||||
"moment": "2.30.1",
|
||||
"moment-timezone": "0.5.46",
|
||||
@@ -63,7 +63,6 @@
|
||||
"devDependencies": {
|
||||
"@grafana/tsconfig": "^2.0.0",
|
||||
"@rollup/plugin-node-resolve": "16.0.0",
|
||||
"@types/dompurify": "^3.0.0",
|
||||
"@types/history": "4.7.11",
|
||||
"@types/lodash": "4.17.13",
|
||||
"@types/node": "22.10.2",
|
||||
@@ -78,7 +77,7 @@
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-dts": "^6.1.1",
|
||||
"rollup-plugin-esbuild": "6.1.1",
|
||||
"rollup-plugin-node-externals": "^7.1.3",
|
||||
"rollup-plugin-node-externals": "^8.0.0",
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -95,7 +95,7 @@ class DarkColors implements ThemeColorsBase<Partial<ThemeRichColor>> {
|
||||
|
||||
border = {
|
||||
weak: `rgba(${this.whiteBase}, 0.12)`,
|
||||
medium: `rgba(${this.whiteBase}, 0.20)`,
|
||||
medium: `rgba(${this.whiteBase}, 0.2)`,
|
||||
strong: `rgba(${this.whiteBase}, 0.30)`,
|
||||
};
|
||||
|
||||
@@ -187,8 +187,8 @@ class LightColors implements ThemeColorsBase<Partial<ThemeRichColor>> {
|
||||
|
||||
border = {
|
||||
weak: `rgba(${this.blackBase}, 0.12)`,
|
||||
medium: `rgba(${this.blackBase}, 0.30)`,
|
||||
strong: `rgba(${this.blackBase}, 0.40)`,
|
||||
medium: `rgba(${this.blackBase}, 0.3)`,
|
||||
strong: `rgba(${this.blackBase}, 0.4)`,
|
||||
};
|
||||
|
||||
secondary = {
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface ThemeTypographyInput {
|
||||
htmlFontSize?: number;
|
||||
}
|
||||
|
||||
const defaultFontFamily = '"Inter", "Helvetica", "Arial", sans-serif';
|
||||
const defaultFontFamily = "'Inter', 'Helvetica', 'Arial', sans-serif";
|
||||
const defaultFontFamilyMonospace = "'Roboto Mono', monospace";
|
||||
|
||||
export function createTypography(colors: ThemeColors, typographyInput: ThemeTypographyInput = {}): ThemeTypography {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const palette = {
|
||||
white: '#FFFFFF',
|
||||
white: '#ffffff',
|
||||
black: '#000000',
|
||||
|
||||
gray25: '#2c3235',
|
||||
@@ -8,37 +8,37 @@ export const palette = {
|
||||
gray05: '#111217', // old '#0b0c0e',
|
||||
|
||||
// new from figma,
|
||||
darkLayer0: '#18181A',
|
||||
darkLayer0: '#18181a',
|
||||
darkLayer1: '#212124',
|
||||
darkLayer2: '#2a2a2f', // figma used #34343B but a bit too bright
|
||||
|
||||
darkBorder1: '#34343B',
|
||||
darkBorder2: '#64646B',
|
||||
darkBorder1: '#34343b',
|
||||
darkBorder2: '#64646b',
|
||||
|
||||
// Dashboard bg / layer 0 (light theme)
|
||||
gray90: '#F4F5F5',
|
||||
gray90: '#f4f5f5',
|
||||
// Card bg / layer 1
|
||||
gray100: '#F4F5F5',
|
||||
gray100: '#f4f5f5',
|
||||
// divider line
|
||||
gray80: '#D0D1D3',
|
||||
gray80: '#d0d1d3',
|
||||
// from figma
|
||||
lightBorder1: '#E4E7E7',
|
||||
lightBorder1: '#e4e7e7',
|
||||
|
||||
blueDarkMain: '#3D71D9', // '#4165F5',
|
||||
blueDarkText: '#6E9FFF', // '#58a6ff', //'#33a2e5', // '#5790FF',
|
||||
redDarkMain: '#D10E5C',
|
||||
redDarkText: '#FF5286',
|
||||
greenDarkMain: '#1A7F4B',
|
||||
greenDarkText: '#6CCF8E',
|
||||
orangeDarkMain: '#FF9900',
|
||||
blueDarkMain: '#3d71d9', // '#4165F5',
|
||||
blueDarkText: '#6e9fff', // '#58a6ff', //'#33a2e5', // '#5790FF',
|
||||
redDarkMain: '#d10e5c',
|
||||
redDarkText: '#ff5286',
|
||||
greenDarkMain: '#1a7f4b',
|
||||
greenDarkText: '#6ccf8e',
|
||||
orangeDarkMain: '#ff9900',
|
||||
orangeDarkText: '#fbad37',
|
||||
|
||||
blueLightMain: '#3871DC',
|
||||
blueLightText: '#1F62E0',
|
||||
redLightMain: '#E0226E',
|
||||
redLightText: '#CF0E5B',
|
||||
greenLightMain: '#1B855E',
|
||||
greenLightText: '#0A764E',
|
||||
orangeLightMain: '#FF9900',
|
||||
orangeLightText: '#B5510D',
|
||||
blueLightMain: '#3871dc',
|
||||
blueLightText: '#1f62e0',
|
||||
redLightMain: '#e0226e',
|
||||
redLightText: '#cf0e5B',
|
||||
greenLightMain: '#1b855e',
|
||||
greenLightText: '#0a764e',
|
||||
orangeLightMain: '#ff9900',
|
||||
orangeLightText: '#b5510d',
|
||||
};
|
||||
|
||||
@@ -105,11 +105,18 @@ describe('Grouping to Matrix', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('generates Matrix with empty entries', async () => {
|
||||
it.each([
|
||||
[undefined, ''],
|
||||
[SpecialValue.Null, null],
|
||||
[SpecialValue.False, false],
|
||||
[SpecialValue.True, true],
|
||||
[SpecialValue.Empty, ''],
|
||||
[SpecialValue.Zero, 0],
|
||||
])('generates Matrix with empty entries', async (emptyValue, expectedValue) => {
|
||||
const cfg: DataTransformerConfig<GroupingToMatrixTransformerOptions> = {
|
||||
id: DataTransformerID.groupingToMatrix,
|
||||
options: {
|
||||
emptyValue: SpecialValue.Null,
|
||||
emptyValue: emptyValue,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -133,13 +140,13 @@ describe('Grouping to Matrix', () => {
|
||||
{
|
||||
name: '1000',
|
||||
type: FieldType.number,
|
||||
values: [1, null],
|
||||
values: [1, expectedValue],
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: '1001',
|
||||
type: FieldType.number,
|
||||
values: [null, 2],
|
||||
values: [expectedValue, 2],
|
||||
config: {},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -183,6 +183,8 @@ function getSpecialValue(specialValue: SpecialValue) {
|
||||
return true;
|
||||
case SpecialValue.Null:
|
||||
return null;
|
||||
case SpecialValue.Zero:
|
||||
return 0;
|
||||
case SpecialValue.Empty:
|
||||
default:
|
||||
return '';
|
||||
|
||||
@@ -220,6 +220,7 @@ export interface FeatureToggles {
|
||||
useSessionStorageForRedirection?: boolean;
|
||||
rolePickerDrawer?: boolean;
|
||||
unifiedStorageSearch?: boolean;
|
||||
unifiedStorageSearchSprinkles?: boolean;
|
||||
pluginsSriChecks?: boolean;
|
||||
unifiedStorageBigObjectsSupport?: boolean;
|
||||
timeRangeProvider?: boolean;
|
||||
@@ -227,10 +228,10 @@ export interface FeatureToggles {
|
||||
userStorageAPI?: boolean;
|
||||
azureMonitorDisableLogLimit?: boolean;
|
||||
preinstallAutoUpdate?: boolean;
|
||||
dashboardSchemaV2?: boolean;
|
||||
playlistsWatcher?: boolean;
|
||||
passwordlessMagicLinkAuthentication?: boolean;
|
||||
exploreMetricsRelatedLogs?: boolean;
|
||||
prometheusSpecialCharsInLabelValues?: boolean;
|
||||
enableExtensionsAdminPage?: boolean;
|
||||
zipkinBackendMigration?: boolean;
|
||||
enableSCIM?: boolean;
|
||||
|
||||
@@ -115,4 +115,5 @@ export enum SpecialValue {
|
||||
False = 'false',
|
||||
Null = 'null',
|
||||
Empty = 'empty',
|
||||
Zero = 'zero',
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-dts": "^6.1.1",
|
||||
"rollup-plugin-esbuild": "6.1.1",
|
||||
"rollup-plugin-node-externals": "^7.1.3"
|
||||
"rollup-plugin-node-externals": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/tsconfig": "^2.0.0",
|
||||
|
||||
@@ -697,6 +697,26 @@ export const versionedPages = {
|
||||
copyUrlButton: {
|
||||
'11.3.0': 'data-testid share internally copy url button',
|
||||
},
|
||||
SharePanel: {
|
||||
preview: {
|
||||
'11.5.0': 'data-testid share panel internally image generation preview',
|
||||
},
|
||||
widthInput: {
|
||||
'11.5.0': 'data-testid share panel internally width input',
|
||||
},
|
||||
heightInput: {
|
||||
'11.5.0': 'data-testid share panel internally height input',
|
||||
},
|
||||
scaleFactorInput: {
|
||||
'11.5.0': 'data-testid share panel internally scale factor input',
|
||||
},
|
||||
generateImageButton: {
|
||||
'11.5.0': 'data-testid share panel internally generate image button',
|
||||
},
|
||||
downloadImageButton: {
|
||||
'11.5.0': 'data-testid share panel internally download image button',
|
||||
},
|
||||
},
|
||||
},
|
||||
ShareExternally: {
|
||||
container: {
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-dts": "^6.1.1",
|
||||
"rollup-plugin-esbuild": "6.1.1",
|
||||
"rollup-plugin-node-externals": "^7.1.3",
|
||||
"rollup-plugin-node-externals": "^8.0.0",
|
||||
"ts-jest": "29.2.5",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.5.4"
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-dts": "^6.1.1",
|
||||
"rollup-plugin-esbuild": "6.1.1",
|
||||
"rollup-plugin-node-externals": "7.1.3",
|
||||
"rollup-plugin-node-externals": "8.0.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"eventemitter3": "5.0.1",
|
||||
"lodash": "4.17.21",
|
||||
"lru-cache": "11.0.2",
|
||||
"marked": "12.0.2",
|
||||
"marked": "15.0.4",
|
||||
"marked-mangle": "1.1.10",
|
||||
"moment": "2.30.1",
|
||||
"moment-timezone": "0.5.46",
|
||||
@@ -128,7 +128,7 @@
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-dts": "^6.1.1",
|
||||
"rollup-plugin-esbuild": "6.1.1",
|
||||
"rollup-plugin-node-externals": "^7.1.3",
|
||||
"rollup-plugin-node-externals": "^8.0.0",
|
||||
"sass": "1.83.0",
|
||||
"sass-loader": "16.0.4",
|
||||
"style-loader": "4.0.0",
|
||||
|
||||
+164
@@ -274,3 +274,167 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat
|
||||
expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Label value completions', () => {
|
||||
let dataProvider: DataProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
dataProvider = {
|
||||
getAllMetricNames: jest.fn(),
|
||||
metricNamesToMetrics: jest.fn(),
|
||||
getHistory: jest.fn(),
|
||||
getLabelNames: jest.fn(),
|
||||
getLabelValues: jest.fn().mockResolvedValue(['value1', 'value"2', 'value\\3', "value'4"]),
|
||||
getSeriesLabels: jest.fn(),
|
||||
getSeriesValues: jest.fn(),
|
||||
monacoSettings: {
|
||||
setInputInRange: jest.fn(),
|
||||
inputInRange: '',
|
||||
suggestionsIncomplete: false,
|
||||
enableAutocompleteSuggestionsUpdate: jest.fn(),
|
||||
},
|
||||
metricNamesSuggestionLimit: 100,
|
||||
} as unknown as DataProvider;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('with prometheusSpecialCharsInLabelValues disabled', () => {
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(config, 'featureToggles', {
|
||||
prometheusSpecialCharsInLabelValues: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not escape special characters when between quotes', async () => {
|
||||
const situation: Situation = {
|
||||
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
|
||||
labelName: 'testLabel',
|
||||
betweenQuotes: true,
|
||||
otherLabels: [],
|
||||
};
|
||||
|
||||
const completions = await getCompletions(situation, dataProvider);
|
||||
|
||||
expect(completions).toHaveLength(4);
|
||||
expect(completions[0].insertText).toBe('value1');
|
||||
expect(completions[1].insertText).toBe('value"2');
|
||||
expect(completions[2].insertText).toBe('value\\3');
|
||||
expect(completions[3].insertText).toBe("value'4");
|
||||
});
|
||||
|
||||
it('should wrap in quotes but not escape special characters when not between quotes', async () => {
|
||||
const situation: Situation = {
|
||||
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
|
||||
labelName: 'testLabel',
|
||||
betweenQuotes: false,
|
||||
otherLabels: [],
|
||||
};
|
||||
|
||||
const completions = await getCompletions(situation, dataProvider);
|
||||
|
||||
expect(completions).toHaveLength(4);
|
||||
expect(completions[0].insertText).toBe('"value1"');
|
||||
expect(completions[1].insertText).toBe('"value"2"');
|
||||
expect(completions[2].insertText).toBe('"value\\3"');
|
||||
expect(completions[3].insertText).toBe('"value\'4"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with prometheusSpecialCharsInLabelValues enabled', () => {
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(config, 'featureToggles', {
|
||||
prometheusSpecialCharsInLabelValues: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should escape special characters when between quotes', async () => {
|
||||
const situation: Situation = {
|
||||
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
|
||||
labelName: 'testLabel',
|
||||
betweenQuotes: true,
|
||||
otherLabels: [],
|
||||
};
|
||||
|
||||
const completions = await getCompletions(situation, dataProvider);
|
||||
|
||||
expect(completions).toHaveLength(4);
|
||||
expect(completions[0].insertText).toBe('value1');
|
||||
expect(completions[1].insertText).toBe('value\\"2');
|
||||
expect(completions[2].insertText).toBe('value\\\\3');
|
||||
expect(completions[3].insertText).toBe("value'4");
|
||||
});
|
||||
|
||||
it('should wrap in quotes and escape special characters when not between quotes', async () => {
|
||||
const situation: Situation = {
|
||||
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
|
||||
labelName: 'testLabel',
|
||||
betweenQuotes: false,
|
||||
otherLabels: [],
|
||||
};
|
||||
|
||||
const completions = await getCompletions(situation, dataProvider);
|
||||
|
||||
expect(completions).toHaveLength(4);
|
||||
expect(completions[0].insertText).toBe('"value1"');
|
||||
expect(completions[1].insertText).toBe('"value\\"2"');
|
||||
expect(completions[2].insertText).toBe('"value\\\\3"');
|
||||
expect(completions[3].insertText).toBe('"value\'4"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('label value escaping edge cases', () => {
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(config, 'featureToggles', {
|
||||
prometheusSpecialCharsInLabelValues: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty values', async () => {
|
||||
jest.spyOn(dataProvider, 'getLabelValues').mockResolvedValue(['']);
|
||||
|
||||
const situation: Situation = {
|
||||
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
|
||||
labelName: 'testLabel',
|
||||
betweenQuotes: false,
|
||||
otherLabels: [],
|
||||
};
|
||||
|
||||
const completions = await getCompletions(situation, dataProvider);
|
||||
expect(completions).toHaveLength(1);
|
||||
expect(completions[0].insertText).toBe('""');
|
||||
});
|
||||
|
||||
it('should handle values with multiple special characters', async () => {
|
||||
jest.spyOn(dataProvider, 'getLabelValues').mockResolvedValue(['test"\\value']);
|
||||
|
||||
const situation: Situation = {
|
||||
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
|
||||
labelName: 'testLabel',
|
||||
betweenQuotes: true,
|
||||
otherLabels: [],
|
||||
};
|
||||
|
||||
const completions = await getCompletions(situation, dataProvider);
|
||||
expect(completions).toHaveLength(1);
|
||||
expect(completions[0].insertText).toBe('test\\"\\\\value');
|
||||
});
|
||||
|
||||
it('should handle non-string values', async () => {
|
||||
jest.spyOn(dataProvider, 'getLabelValues').mockResolvedValue([123 as unknown as string]);
|
||||
|
||||
const situation: Situation = {
|
||||
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
|
||||
labelName: 'testLabel',
|
||||
betweenQuotes: false,
|
||||
otherLabels: [],
|
||||
};
|
||||
|
||||
const completions = await getCompletions(situation, dataProvider);
|
||||
expect(completions).toHaveLength(1);
|
||||
expect(completions[0].insertText).toBe('"123"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+7
-1
@@ -3,6 +3,7 @@ import UFuzzy from '@leeoniya/ufuzzy';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { prometheusRegularEscape } from '../../../datasource';
|
||||
import { escapeLabelValueInExactSelector } from '../../../language_utils';
|
||||
import { FUNCTIONS } from '../../../promql';
|
||||
|
||||
@@ -208,10 +209,15 @@ async function getLabelValuesForMetricCompletions(
|
||||
return values.map((text) => ({
|
||||
type: 'LABEL_VALUE',
|
||||
label: text,
|
||||
insertText: betweenQuotes ? text : `"${text}"`, // FIXME: escaping strange characters?
|
||||
insertText: formatLabelValueForCompletion(text, betweenQuotes),
|
||||
}));
|
||||
}
|
||||
|
||||
function formatLabelValueForCompletion(value: string, betweenQuotes: boolean): string {
|
||||
const text = config.featureToggles.prometheusSpecialCharsInLabelValues ? prometheusRegularEscape(value) : value;
|
||||
return betweenQuotes ? text : `"${text}"`;
|
||||
}
|
||||
|
||||
export function getCompletions(situation: Situation, dataProvider: DataProvider): Promise<Completion[]> {
|
||||
switch (situation.type) {
|
||||
case 'IN_DURATION':
|
||||
|
||||
@@ -245,60 +245,118 @@ describe('PrometheusDatasource', () => {
|
||||
const DEFAULT_QUERY_EXPRESSION = 'metric{job="foo"} - metric';
|
||||
const target: PromQuery = { expr: DEFAULT_QUERY_EXPRESSION, refId: 'A' };
|
||||
|
||||
it('should not modify expression with no filters', async () => {
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({ expr: DEFAULT_QUERY_EXPRESSION });
|
||||
describe('with prometheusSpecialCharsInLabelValues disabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = false;
|
||||
});
|
||||
|
||||
it('should not modify expression with no filters', async () => {
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({ expr: DEFAULT_QUERY_EXPRESSION });
|
||||
});
|
||||
|
||||
it('should add filters to expression', () => {
|
||||
const filters = [
|
||||
{
|
||||
key: 'k1',
|
||||
operator: '=',
|
||||
value: 'v1',
|
||||
},
|
||||
{
|
||||
key: 'k2',
|
||||
operator: '!=',
|
||||
value: 'v2',
|
||||
},
|
||||
];
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
filters,
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({ expr: 'metric{job="foo", k1="v1", k2!="v2"} - metric{k1="v1", k2!="v2"}' });
|
||||
});
|
||||
|
||||
it('should add escaping if needed to regex filter expressions', () => {
|
||||
const filters = [
|
||||
{
|
||||
key: 'k1',
|
||||
operator: '=~',
|
||||
value: 'v.*',
|
||||
},
|
||||
{
|
||||
key: 'k2',
|
||||
operator: '=~',
|
||||
value: `v'.*`,
|
||||
},
|
||||
];
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
filters,
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({
|
||||
expr: `metric{job="foo", k1=~"v.*", k2=~"v\\\\'.*"} - metric{k1=~"v.*", k2=~"v\\\\'.*"}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should add filters to expression', () => {
|
||||
const filters = [
|
||||
{
|
||||
key: 'k1',
|
||||
operator: '=',
|
||||
value: 'v1',
|
||||
},
|
||||
{
|
||||
key: 'k2',
|
||||
operator: '!=',
|
||||
value: 'v2',
|
||||
},
|
||||
];
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
filters,
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({ expr: 'metric{job="foo", k1="v1", k2!="v2"} - metric{k1="v1", k2!="v2"}' });
|
||||
});
|
||||
it('should add escaping if needed to regex filter expressions', () => {
|
||||
const filters = [
|
||||
{
|
||||
key: 'k1',
|
||||
operator: '=~',
|
||||
value: 'v.*',
|
||||
},
|
||||
{
|
||||
key: 'k2',
|
||||
operator: '=~',
|
||||
value: `v'.*`,
|
||||
},
|
||||
];
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
filters,
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({
|
||||
expr: `metric{job="foo", k1=~"v.*", k2=~"v\\\\'.*"} - metric{k1=~"v.*", k2=~"v\\\\'.*"}`,
|
||||
describe('with prometheusSpecialCharsInLabelValues enabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = true;
|
||||
});
|
||||
|
||||
it('should not modify expression with no filters', async () => {
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({ expr: DEFAULT_QUERY_EXPRESSION });
|
||||
});
|
||||
|
||||
it('should add escaping if needed to regex filter expressions', () => {
|
||||
const filters = [
|
||||
{
|
||||
key: 'k1',
|
||||
operator: '=~',
|
||||
value: 'v.*',
|
||||
},
|
||||
{
|
||||
key: 'k2',
|
||||
operator: '=~',
|
||||
value: `v'.*`,
|
||||
},
|
||||
{
|
||||
key: 'k3',
|
||||
operator: '=~',
|
||||
value: `v".*`,
|
||||
},
|
||||
{
|
||||
key: 'k4',
|
||||
operator: '=~',
|
||||
value: `\\v.*`,
|
||||
},
|
||||
];
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
filters,
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({
|
||||
expr: `metric{job="foo", k1=~"v.*", k2=~"v'.*", k3=~"v\\".*", k4=~"\\\\v.*"} - metric{k1=~"v.*", k2=~"v'.*", k3=~"v\\".*", k4=~"\\\\v.*"}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -470,56 +528,126 @@ describe('PrometheusDatasource', () => {
|
||||
});
|
||||
|
||||
describe('Prometheus regular escaping', () => {
|
||||
it('should not escape non-string', () => {
|
||||
expect(prometheusRegularEscape(12)).toEqual(12);
|
||||
describe('with prometheusSpecialCharsInLabelValues disabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = false;
|
||||
});
|
||||
|
||||
it('should not escape non-string', () => {
|
||||
expect(prometheusRegularEscape(12)).toEqual(12);
|
||||
});
|
||||
|
||||
it('should not escape strings without special characters', () => {
|
||||
expect(prometheusRegularEscape('cryptodepression')).toEqual('cryptodepression');
|
||||
});
|
||||
|
||||
it('should escape single quotes', () => {
|
||||
expect(prometheusRegularEscape("looking'glass")).toEqual("looking\\\\'glass");
|
||||
});
|
||||
|
||||
it('should escape backslashes', () => {
|
||||
expect(prometheusRegularEscape('looking\\glass')).toEqual('looking\\\\glass');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not escape simple string', () => {
|
||||
expect(prometheusRegularEscape('cryptodepression')).toEqual('cryptodepression');
|
||||
});
|
||||
describe('with prometheusSpecialCharsInLabelValues enabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = true;
|
||||
});
|
||||
|
||||
it("should escape '", () => {
|
||||
expect(prometheusRegularEscape("looking'glass")).toEqual("looking\\\\'glass");
|
||||
});
|
||||
it('should not escape non-string', () => {
|
||||
expect(prometheusRegularEscape(12)).toEqual(12);
|
||||
});
|
||||
|
||||
it('should escape \\', () => {
|
||||
expect(prometheusRegularEscape('looking\\glass')).toEqual('looking\\\\glass');
|
||||
});
|
||||
it('should not escape strings without special characters', () => {
|
||||
expect(prometheusRegularEscape('cryptodepression')).toEqual('cryptodepression');
|
||||
});
|
||||
|
||||
it('should escape multiple characters', () => {
|
||||
expect(prometheusRegularEscape("'looking'glass'")).toEqual("\\\\'looking\\\\'glass\\\\'");
|
||||
});
|
||||
it('should not escape complete label matcher', () => {
|
||||
expect(prometheusRegularEscape('job="grafana"')).toEqual('job="grafana"');
|
||||
expect(prometheusRegularEscape('job!="grafana"')).toEqual('job!="grafana"');
|
||||
expect(prometheusRegularEscape('job=~"grafana"')).toEqual('job=~"grafana"');
|
||||
expect(prometheusRegularEscape('job!~"grafana"')).toEqual('job!~"grafana"');
|
||||
});
|
||||
|
||||
it('should escape multiple different characters', () => {
|
||||
expect(prometheusRegularEscape("'loo\\king'glass'")).toEqual("\\\\'loo\\\\king\\\\'glass\\\\'");
|
||||
it('should not escape single quotes', () => {
|
||||
expect(prometheusRegularEscape("looking'glass")).toEqual("looking'glass");
|
||||
});
|
||||
|
||||
it('should escape double quotes', () => {
|
||||
expect(prometheusRegularEscape('looking"glass')).toEqual('looking\\"glass');
|
||||
});
|
||||
|
||||
it('should escape backslashes', () => {
|
||||
expect(prometheusRegularEscape('looking\\glass')).toEqual('looking\\\\glass');
|
||||
});
|
||||
|
||||
it('should handle complete label matchers with escaped content', () => {
|
||||
expect(prometheusRegularEscape('job="my\\"service"')).toEqual('job="my\\"service"');
|
||||
expect(prometheusRegularEscape('job="\\\\server"')).toEqual('job="\\\\server"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prometheus regexes escaping', () => {
|
||||
it('should not escape simple string', () => {
|
||||
expect(prometheusSpecialRegexEscape('cryptodepression')).toEqual('cryptodepression');
|
||||
describe('with prometheusSpecialCharsInLabelValues disabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = false;
|
||||
});
|
||||
|
||||
it('should not escape strings without special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('cryptodepression')).toEqual('cryptodepression');
|
||||
});
|
||||
|
||||
it('should escape special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('looking{glass')).toEqual('looking\\\\{glass');
|
||||
expect(prometheusSpecialRegexEscape('looking$glass')).toEqual('looking\\\\$glass');
|
||||
expect(prometheusSpecialRegexEscape('looking\\glass')).toEqual('looking\\\\\\\\glass');
|
||||
expect(prometheusSpecialRegexEscape('looking|glass')).toEqual('looking\\\\|glass');
|
||||
});
|
||||
|
||||
it('should handle multiple special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('+looking$glass?')).toEqual('\\\\+looking\\\\$glass\\\\?');
|
||||
});
|
||||
});
|
||||
|
||||
it('should escape $^*+?.()|\\', () => {
|
||||
expect(prometheusSpecialRegexEscape("looking'glass")).toEqual("looking\\\\'glass");
|
||||
expect(prometheusSpecialRegexEscape('looking{glass')).toEqual('looking\\\\{glass');
|
||||
expect(prometheusSpecialRegexEscape('looking}glass')).toEqual('looking\\\\}glass');
|
||||
expect(prometheusSpecialRegexEscape('looking[glass')).toEqual('looking\\\\[glass');
|
||||
expect(prometheusSpecialRegexEscape('looking]glass')).toEqual('looking\\\\]glass');
|
||||
expect(prometheusSpecialRegexEscape('looking$glass')).toEqual('looking\\\\$glass');
|
||||
expect(prometheusSpecialRegexEscape('looking^glass')).toEqual('looking\\\\^glass');
|
||||
expect(prometheusSpecialRegexEscape('looking*glass')).toEqual('looking\\\\*glass');
|
||||
expect(prometheusSpecialRegexEscape('looking+glass')).toEqual('looking\\\\+glass');
|
||||
expect(prometheusSpecialRegexEscape('looking?glass')).toEqual('looking\\\\?glass');
|
||||
expect(prometheusSpecialRegexEscape('looking.glass')).toEqual('looking\\\\.glass');
|
||||
expect(prometheusSpecialRegexEscape('looking(glass')).toEqual('looking\\\\(glass');
|
||||
expect(prometheusSpecialRegexEscape('looking)glass')).toEqual('looking\\\\)glass');
|
||||
expect(prometheusSpecialRegexEscape('looking\\glass')).toEqual('looking\\\\\\\\glass');
|
||||
expect(prometheusSpecialRegexEscape('looking|glass')).toEqual('looking\\\\|glass');
|
||||
});
|
||||
describe('with prometheusSpecialCharsInLabelValues enabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = true;
|
||||
});
|
||||
|
||||
it('should escape multiple special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('+looking$glass?')).toEqual('\\\\+looking\\\\$glass\\\\?');
|
||||
it('should not escape strings without special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('cryptodepression')).toEqual('cryptodepression');
|
||||
});
|
||||
|
||||
it('should escape special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('looking{glass')).toEqual('looking\\\\{glass');
|
||||
expect(prometheusSpecialRegexEscape('looking}glass')).toEqual('looking\\\\}glass');
|
||||
expect(prometheusSpecialRegexEscape('looking[glass')).toEqual('looking\\\\[glass');
|
||||
expect(prometheusSpecialRegexEscape('looking]glass')).toEqual('looking\\\\]glass');
|
||||
expect(prometheusSpecialRegexEscape('looking$glass')).toEqual('looking\\\\$glass');
|
||||
expect(prometheusSpecialRegexEscape('looking^glass')).toEqual('looking\\\\^glass');
|
||||
expect(prometheusSpecialRegexEscape('looking*glass')).toEqual('looking\\\\*glass');
|
||||
expect(prometheusSpecialRegexEscape('looking+glass')).toEqual('looking\\\\+glass');
|
||||
expect(prometheusSpecialRegexEscape('looking?glass')).toEqual('looking\\\\?glass');
|
||||
expect(prometheusSpecialRegexEscape('looking.glass')).toEqual('looking\\\\.glass');
|
||||
expect(prometheusSpecialRegexEscape('looking(glass')).toEqual('looking\\\\(glass');
|
||||
expect(prometheusSpecialRegexEscape('looking)glass')).toEqual('looking\\\\)glass');
|
||||
expect(prometheusSpecialRegexEscape('looking\\glass')).toEqual('looking\\\\\\\\glass');
|
||||
expect(prometheusSpecialRegexEscape('looking|glass')).toEqual('looking\\\\|glass');
|
||||
});
|
||||
|
||||
it('should escape double quotes with special regex escaping', () => {
|
||||
expect(prometheusSpecialRegexEscape('looking"glass')).toEqual('looking\\\\\\"glass');
|
||||
});
|
||||
|
||||
it('should handle multiple special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('+looking$glass?')).toEqual('\\\\+looking\\\\$glass\\\\?');
|
||||
});
|
||||
|
||||
it('should handle mixed quotes and special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('+looking"$glass?')).toEqual('\\\\+looking\\\\\\"\\\\$glass\\\\?');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -548,9 +676,27 @@ describe('PrometheusDatasource', () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe('and value is a string', () => {
|
||||
it('should only escape single quotes', () => {
|
||||
expect(ds.interpolateQueryExpr("abc'$^*{}[]+?.()|", customVariable)).toEqual("abc\\\\'$^*{}[]+?.()|");
|
||||
describe('with prometheusSpecialCharsInLabelValues disabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = false;
|
||||
});
|
||||
|
||||
describe('and value is a string', () => {
|
||||
it('should escape single quotes', () => {
|
||||
expect(ds.interpolateQueryExpr("abc'$^*{}[]+?.()|", customVariable)).toEqual("abc\\\\'$^*{}[]+?.()|");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with prometheusSpecialCharsInLabelValues enabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = true;
|
||||
});
|
||||
|
||||
describe('and value is a string', () => {
|
||||
it('should only escape double quotes and backslashes', () => {
|
||||
expect(ds.interpolateQueryExpr('abc\'"$^*{}[]+?.()|\\', customVariable)).toEqual('abc\'\\"$^*{}[]+?.()|\\\\');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1051,9 +1197,13 @@ describe('PrometheusDatasource2', () => {
|
||||
},
|
||||
} as unknown as AnnotationQueryRequest;
|
||||
|
||||
async function runAnnotationQuery(data: number[][]) {
|
||||
async function runAnnotationQuery(data: number[][], overrideStep?: string) {
|
||||
let response = createAnnotationResponse();
|
||||
response.data.results['X'].frames[0].data.values = data;
|
||||
if (overrideStep) {
|
||||
const meta = response.data.results['X'].frames[0].schema.meta;
|
||||
meta.executedQueryString = meta.executedQueryString.replace('1m0s', overrideStep);
|
||||
}
|
||||
|
||||
options.annotation.useValueForTime = false;
|
||||
fetchMock.mockImplementation(() => of(response));
|
||||
@@ -1093,6 +1243,31 @@ describe('PrometheusDatasource2', () => {
|
||||
const results = await runAnnotationQuery([[2 * 60000], [1]]);
|
||||
expect(results.map((result) => [result.time, result.timeEnd])).toEqual([[120000, 120000]]);
|
||||
});
|
||||
|
||||
describe('should group annotations over wider range when the step grows larger', () => {
|
||||
const data: number[][] = [
|
||||
[1 * 120000, 2 * 120000, 3 * 120000, 4 * 120000, 5 * 120000, 6 * 120000],
|
||||
[1, 1, 0, 0, 1, 1],
|
||||
];
|
||||
|
||||
it('should not group annotations with the default step', async () => {
|
||||
const results = await runAnnotationQuery(data);
|
||||
expect(results.map((result) => [result.time, result.timeEnd])).toEqual([
|
||||
[120000, 120000],
|
||||
[240000, 240000],
|
||||
[600000, 600000],
|
||||
[720000, 720000],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should group annotations with larger step', async () => {
|
||||
const results = await runAnnotationQuery(data, '2m0s');
|
||||
expect(results.map((result) => [result.time, result.timeEnd])).toEqual([
|
||||
[120000, 240000],
|
||||
[600000, 720000],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with template variables', () => {
|
||||
|
||||
@@ -537,7 +537,11 @@ export class PrometheusDatasource
|
||||
const annotation = options.annotation;
|
||||
const { tagKeys = '', titleFormat = '', textFormat = '' } = annotation;
|
||||
|
||||
const step = rangeUtil.intervalToSeconds(annotation.step || ANNOTATION_QUERY_STEP_DEFAULT) * 1000;
|
||||
const input = frames[0].meta?.executedQueryString || '';
|
||||
const regex = /Step:\s*([\d\w]+)/;
|
||||
const match = input.match(regex);
|
||||
const stepValue = match ? match[1] : null;
|
||||
const step = rangeUtil.intervalToSeconds(stepValue || ANNOTATION_QUERY_STEP_DEFAULT) * 1000;
|
||||
const tagKeysArray = tagKeys.split(',');
|
||||
|
||||
const eventList: AnnotationEvent[] = [];
|
||||
@@ -1042,13 +1046,46 @@ export function extractRuleMappingFromGroups(groups: RawRecordingRules[]): RuleQ
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: these two functions are very similar to the escapeLabelValueIn* functions
|
||||
// NOTE: these two functions are similar to the escapeLabelValueIn* functions
|
||||
// in language_utils.ts, but they are not exactly the same algorithm, and we found
|
||||
// no way to reuse one in the another or vice versa.
|
||||
export function prometheusRegularEscape<T>(value: T) {
|
||||
return typeof value === 'string' ? value.replace(/\\/g, '\\\\').replace(/'/g, "\\\\'") : value;
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (config.featureToggles.prometheusSpecialCharsInLabelValues) {
|
||||
// if the string looks like a complete label matcher (e.g. 'job="grafana"' or 'job=~"grafana"'),
|
||||
// don't escape the encapsulating quotes
|
||||
if (/^\w+(=|!=|=~|!~)".*"$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/\\/g, '\\\\') // escape backslashes
|
||||
.replace(/"/g, '\\"'); // escape double quotes
|
||||
}
|
||||
|
||||
// classic behavior
|
||||
return value
|
||||
.replace(/\\/g, '\\\\') // escape backslashes
|
||||
.replace(/'/g, "\\\\'"); // escape single quotes
|
||||
}
|
||||
|
||||
export function prometheusSpecialRegexEscape<T>(value: T) {
|
||||
return typeof value === 'string' ? value.replace(/\\/g, '\\\\\\\\').replace(/[$^*{}\[\]\'+?.()|]/g, '\\\\$&') : value;
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (config.featureToggles.prometheusSpecialCharsInLabelValues) {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\\\\\') // escape backslashes
|
||||
.replace(/"/g, '\\\\\\"') // escape double quotes
|
||||
.replace(/[$^*{}\[\]\'+?.()|]/g, '\\\\$&'); // escape regex metacharacters
|
||||
}
|
||||
|
||||
// classic behavior
|
||||
return value
|
||||
.replace(/\\/g, '\\\\\\\\') // escape backslashes
|
||||
.replace(/[$^*{}\[\]+?.()|]/g, '\\\\$&'); // escape regex metacharacters
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/shared/LokiAndPromQueryModellerBase.ts
|
||||
import { Registry } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { prometheusRegularEscape } from '../../datasource';
|
||||
import { PromVisualQueryOperationCategory } from '../types';
|
||||
|
||||
import { QueryBuilderLabelFilter, QueryBuilderOperation, QueryBuilderOperationDef, VisualQueryModeller } from './types';
|
||||
@@ -89,7 +91,13 @@ export abstract class LokiAndPromQueryModellerBase implements VisualQueryModelle
|
||||
expr += ', ';
|
||||
}
|
||||
|
||||
expr += `${filter.label}${filter.op}"${filter.value}"`;
|
||||
let labelValue = filter.value;
|
||||
const usingRegexOperator = filter.op === '=~' || filter.op === '!~';
|
||||
|
||||
if (config.featureToggles.prometheusSpecialCharsInLabelValues && !usingRegexOperator) {
|
||||
labelValue = prometheusRegularEscape(labelValue);
|
||||
}
|
||||
expr += `${filter.label}${filter.op}"${labelValue}"`;
|
||||
}
|
||||
|
||||
return expr + `}`;
|
||||
|
||||
@@ -108,6 +108,10 @@ export function createAnnotationResponse() {
|
||||
schema: {
|
||||
name: 'bar',
|
||||
refId: 'X',
|
||||
meta: {
|
||||
typeVersion: [0, 0],
|
||||
executedQueryString: 'Expr: ALERTS{}\nStep: 1m0s',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'Time',
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-dts": "^6.1.1",
|
||||
"rollup-plugin-esbuild": "6.1.1",
|
||||
"rollup-plugin-node-externals": "^7.1.3",
|
||||
"rollup-plugin-node-externals": "^8.0.0",
|
||||
"rollup-plugin-sourcemaps": "0.6.3",
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-dts": "^6.1.1",
|
||||
"rollup-plugin-esbuild": "6.1.1",
|
||||
"rollup-plugin-node-externals": "^7.1.3",
|
||||
"rollup-plugin-node-externals": "^8.0.0",
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -27,10 +27,10 @@ export interface DashboardV2Spec {
|
||||
// Links with references to other dashboards or external websites.
|
||||
links: DashboardLink[];
|
||||
// Tags associated with dashboard.
|
||||
tags?: string[];
|
||||
tags: string[];
|
||||
timeSettings: TimeSettingsSpec;
|
||||
// Configured template variables.
|
||||
variables: (QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind)[];
|
||||
variables: VariableKind[];
|
||||
// |* more element types in the future
|
||||
elements: Record<string, PanelKind>;
|
||||
annotations: AnnotationQueryKind[];
|
||||
@@ -49,6 +49,7 @@ export const defaultDashboardV2Spec = (): DashboardV2Spec => ({
|
||||
preload: false,
|
||||
editable: true,
|
||||
links: [],
|
||||
tags: [],
|
||||
timeSettings: defaultTimeSettingsSpec(),
|
||||
variables: [],
|
||||
elements: {},
|
||||
@@ -801,6 +802,10 @@ export type VariableType = "query" | "adhoc" | "groupby" | "constant" | "datasou
|
||||
|
||||
export const defaultVariableType = (): VariableType => ("query");
|
||||
|
||||
export type VariableKind = QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind;
|
||||
|
||||
export const defaultVariableKind = (): VariableKind => (defaultQueryVariableKind());
|
||||
|
||||
// Sort variable options
|
||||
// Accepted values are:
|
||||
// `disabled`: No sorting
|
||||
|
||||
@@ -36,12 +36,12 @@ DashboardV2Spec: {
|
||||
links: [...DashboardLink]
|
||||
|
||||
// Tags associated with dashboard.
|
||||
tags?: [...string]
|
||||
tags: [...string]
|
||||
|
||||
timeSettings: TimeSettingsSpec
|
||||
|
||||
// Configured template variables.
|
||||
variables: [...QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind]
|
||||
variables: [...VariableKind]
|
||||
|
||||
elements: [ElementReference.name]: PanelKind // |* more element types in the future
|
||||
|
||||
@@ -548,6 +548,8 @@ VariableCustomFormatterFn: {
|
||||
VariableType: "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" |
|
||||
"system" | "snapshot"
|
||||
|
||||
VariableKind: QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind
|
||||
|
||||
// Sort variable options
|
||||
// Accepted values are:
|
||||
// `disabled`: No sorting
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
"react-dropzone": "14.3.5",
|
||||
"react-highlight-words": "0.20.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-i18next": "^14.0.0",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-inlinesvg": "4.1.5",
|
||||
"react-loading-skeleton": "3.5.0",
|
||||
"react-router-dom-v5-compat": "^6.26.1",
|
||||
@@ -175,7 +175,7 @@
|
||||
"rollup-plugin-copy": "3.5.0",
|
||||
"rollup-plugin-dts": "^6.1.1",
|
||||
"rollup-plugin-esbuild": "6.1.1",
|
||||
"rollup-plugin-node-externals": "^7.1.3",
|
||||
"rollup-plugin-node-externals": "^8.0.0",
|
||||
"rollup-plugin-svg-import": "3.0.0",
|
||||
"sass-loader": "16.0.4",
|
||||
"storybook": "^8.4.2",
|
||||
|
||||
@@ -14,6 +14,8 @@ const commonArgs = {
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' },
|
||||
{ label: 'Option 4', value: 'option4' },
|
||||
{ label: 'Option 5', value: 'option5' },
|
||||
],
|
||||
value: ['option2'],
|
||||
placeholder: 'Select multiple options...',
|
||||
|
||||
@@ -86,13 +86,13 @@ describe('MultiCombobox', () => {
|
||||
expect(onChange).toHaveBeenNthCalledWith(3, [third]);
|
||||
});
|
||||
|
||||
it('should be able to render a valie that is not in the options', async () => {
|
||||
it('should be able to render a value that is not in the options', async () => {
|
||||
const options = [
|
||||
{ label: 'A', value: 'a' },
|
||||
{ label: 'B', value: 'b' },
|
||||
{ label: 'C', value: 'c' },
|
||||
];
|
||||
render(<MultiCombobox options={options} value={['a', 'd', 'c']} onChange={jest.fn()} />);
|
||||
render(<MultiCombobox width={200} options={options} value={['a', 'd', 'c']} onChange={jest.fn()} />);
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
expect(await screen.findByText('d')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { useCombobox, useMultipleSelection } from 'downshift';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { Checkbox } from '../Forms/Checkbox';
|
||||
import { Box } from '../Layout/Box/Box';
|
||||
import { Portal } from '../Portal/Portal';
|
||||
import { Text } from '../Text/Text';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
|
||||
import { ComboboxOption, ComboboxBaseProps, AutoSizeConditionals, itemToString } from './Combobox';
|
||||
import { OptionListItem } from './OptionListItem';
|
||||
import { ValuePill } from './ValuePill';
|
||||
import { getMultiComboboxStyles } from './getMultiComboboxStyles';
|
||||
import { useMeasureMulti } from './useMeasureMulti';
|
||||
|
||||
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
|
||||
value?: T[] | Array<ComboboxOption<T>>;
|
||||
@@ -18,7 +23,7 @@ interface MultiComboboxBaseProps<T extends string | number> extends Omit<Combobo
|
||||
export type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
|
||||
|
||||
export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => {
|
||||
const { options, placeholder, onChange, value } = props;
|
||||
const { options, placeholder, onChange, value, width } = props;
|
||||
const isAsync = typeof options === 'function';
|
||||
|
||||
const selectedItems = useMemo(() => {
|
||||
@@ -30,11 +35,13 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
return getSelectedItemsFromValue<T>(value, options);
|
||||
}, [value, options, isAsync]);
|
||||
|
||||
const multiStyles = useStyles2(getMultiComboboxStyles);
|
||||
|
||||
const [items, _baseSetItems] = useState(isAsync ? [] : options);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const multiStyles = useStyles2(getMultiComboboxStyles, isOpen);
|
||||
|
||||
const { measureRef, suffixMeasureRef, shownItems } = useMeasureMulti(selectedItems, width);
|
||||
|
||||
const isOptionSelected = useCallback(
|
||||
(item: ComboboxOption<T>) => selectedItems.some((opt) => opt.value === item.value),
|
||||
[selectedItems]
|
||||
@@ -114,25 +121,60 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
},
|
||||
});
|
||||
|
||||
const visibleItems = isOpen ? selectedItems : selectedItems.slice(0, shownItems);
|
||||
|
||||
return (
|
||||
<div className={multiStyles.wrapper}>
|
||||
<span className={multiStyles.pillWrapper}>
|
||||
{selectedItems.map((item, index) => (
|
||||
<ValuePill
|
||||
onRemove={() => {
|
||||
removeSelectedItem(item);
|
||||
}}
|
||||
key={`${item.value}${index}`}
|
||||
{...getSelectedItemProps({ selectedItem: item, index })}
|
||||
>
|
||||
{itemToString(item)}
|
||||
</ValuePill>
|
||||
))}
|
||||
</span>
|
||||
<input
|
||||
className={multiStyles.input}
|
||||
{...getInputProps(getDropdownProps({ preventKeyAction: isOpen, placeholder, onFocus: () => setIsOpen(true) }))}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
style={{ width: width === 'auto' ? undefined : width }}
|
||||
className={multiStyles.wrapper}
|
||||
ref={measureRef}
|
||||
onClick={() => selectedItems.length > 0 && setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className={multiStyles.pillWrapper}>
|
||||
{visibleItems.map((item, index) => (
|
||||
<ValuePill
|
||||
onRemove={() => {
|
||||
removeSelectedItem(item);
|
||||
}}
|
||||
key={`${item.value}${index}`}
|
||||
{...getSelectedItemProps({ selectedItem: item, index })}
|
||||
>
|
||||
{itemToString(item)}
|
||||
</ValuePill>
|
||||
))}
|
||||
{selectedItems.length > shownItems && !isOpen && (
|
||||
<Box display="flex" direction="row" marginLeft={0.5} gap={1} ref={suffixMeasureRef}>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<Text>...</Text>
|
||||
<Tooltip
|
||||
interactive
|
||||
content={
|
||||
<>
|
||||
{selectedItems.slice(shownItems).map((item) => (
|
||||
<div key={item.value}>{itemToString(item)}</div>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={multiStyles.restNumber}>{selectedItems.length - shownItems}</div>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
<input
|
||||
className={cx(multiStyles.input, {
|
||||
[multiStyles.inputClosed]: !isOpen && selectedItems.length > 0,
|
||||
})}
|
||||
{...getInputProps(
|
||||
getDropdownProps({
|
||||
preventKeyAction: isOpen,
|
||||
placeholder: selectedItems.length > 0 ? undefined : placeholder,
|
||||
onFocus: () => setIsOpen(true),
|
||||
})
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div {...getMenuProps()}>
|
||||
<Portal>
|
||||
{isOpen && (
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ValuePill = forwardRef<HTMLSpanElement, ValuePillProps>(({ children
|
||||
const styles = useStyles2(getValuePillStyles);
|
||||
return (
|
||||
<span className={styles.wrapper} {...rest} ref={ref}>
|
||||
{children}
|
||||
<span className={styles.text}>{children}</span>
|
||||
<span className={styles.separator} />
|
||||
<IconButton
|
||||
name="times"
|
||||
@@ -39,6 +39,18 @@ const getValuePillStyles = (theme: GrafanaTheme2) => ({
|
||||
background: theme.colors.background.secondary,
|
||||
padding: theme.spacing(0.25),
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
flexShrink: 0,
|
||||
minWidth: '50px',
|
||||
|
||||
'&:first-child:has(+ div)': {
|
||||
flexShrink: 1,
|
||||
},
|
||||
}),
|
||||
|
||||
text: css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}),
|
||||
|
||||
separator: css({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { getFocusStyles } from '../../themes/mixins';
|
||||
import { getInputStyles } from '../Input/Input';
|
||||
|
||||
export const getMultiComboboxStyles = (theme: GrafanaTheme2) => {
|
||||
export const getMultiComboboxStyles = (theme: GrafanaTheme2, isOpen: boolean) => {
|
||||
const inputStyles = getInputStyles({ theme });
|
||||
const focusStyles = getFocusStyles(theme);
|
||||
|
||||
@@ -34,10 +34,30 @@ export const getMultiComboboxStyles = (theme: GrafanaTheme2) => {
|
||||
outline: 'none',
|
||||
},
|
||||
}),
|
||||
inputClosed: css({
|
||||
width: 0,
|
||||
flexGrow: 0,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
}),
|
||||
pillWrapper: css({
|
||||
display: 'inline-flex',
|
||||
flexWrap: 'wrap',
|
||||
flexWrap: isOpen ? 'wrap' : 'nowrap',
|
||||
flexGrow: 1,
|
||||
minWidth: '50px',
|
||||
gap: theme.spacing(0.5),
|
||||
}),
|
||||
restNumber: css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: theme.spacing(0, 1),
|
||||
borderRadius: theme.shape.radius.default,
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.colors.action.hover,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
|
||||
import { measureText } from '../../utils';
|
||||
|
||||
import { ComboboxOption } from './Combobox';
|
||||
|
||||
const FONT_SIZE = 12;
|
||||
const EXTRA_PILL_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Updates the number of shown items in the multi combobox based on the available width.
|
||||
*/
|
||||
export function useMeasureMulti<T extends string | number>(
|
||||
selectedItems: Array<ComboboxOption<T>>,
|
||||
width?: number | 'auto'
|
||||
) {
|
||||
const [shownItems, setShownItems] = useState<number>(selectedItems.length);
|
||||
const [measureRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
|
||||
const [suffixMeasureRef, { width: suffixWidth }] = useMeasure<HTMLDivElement>();
|
||||
|
||||
const finalWidth = width && width !== 'auto' ? width : containerWidth;
|
||||
|
||||
useEffect(() => {
|
||||
const maxWidth = finalWidth - suffixWidth;
|
||||
let currWidth = 0;
|
||||
for (let i = 0; i < selectedItems.length; i++) {
|
||||
// Measure text width and add size of padding, separator and close button
|
||||
currWidth += measureText(selectedItems[i].label || '', FONT_SIZE).width + EXTRA_PILL_SIZE;
|
||||
if (currWidth > maxWidth) {
|
||||
// If there is no space for that item, show the current number of items,
|
||||
// but always show at least 1 item
|
||||
setShownItems(i || 1);
|
||||
break;
|
||||
}
|
||||
if (i === selectedItems.length - 1) {
|
||||
// If it is the last item, show all items
|
||||
setShownItems(selectedItems.length);
|
||||
}
|
||||
}
|
||||
}, [finalWidth, suffixWidth, selectedItems, setShownItems]);
|
||||
|
||||
return { measureRef, suffixMeasureRef, shownItems };
|
||||
}
|
||||
@@ -414,7 +414,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
"axes": [
|
||||
{
|
||||
"filter": undefined,
|
||||
"font": "12px "Inter", "Helvetica", "Arial", sans-serif",
|
||||
"font": "12px 'Inter', 'Helvetica', 'Arial', sans-serif",
|
||||
"gap": 5,
|
||||
"grid": {
|
||||
"show": false,
|
||||
@@ -423,7 +423,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
},
|
||||
"incrs": undefined,
|
||||
"label": "test label",
|
||||
"labelFont": "12px "Inter", "Helvetica", "Arial", sans-serif",
|
||||
"labelFont": "12px 'Inter', 'Helvetica', 'Arial', sans-serif",
|
||||
"labelGap": 8,
|
||||
"labelSize": 20,
|
||||
"rotate": undefined,
|
||||
|
||||
@@ -5,7 +5,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
|
||||
"axes": [
|
||||
{
|
||||
"filter": undefined,
|
||||
"font": "12px "Inter", "Helvetica", "Arial", sans-serif",
|
||||
"font": "12px 'Inter', 'Helvetica', 'Arial', sans-serif",
|
||||
"gap": 5,
|
||||
"grid": {
|
||||
"show": true,
|
||||
@@ -33,7 +33,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
|
||||
},
|
||||
{
|
||||
"filter": undefined,
|
||||
"font": "12px "Inter", "Helvetica", "Arial", sans-serif",
|
||||
"font": "12px 'Inter', 'Helvetica', 'Arial', sans-serif",
|
||||
"gap": 5,
|
||||
"grid": {
|
||||
"show": true,
|
||||
|
||||
@@ -148,7 +148,7 @@ $popover-shadow: ${theme.shadows.z3};
|
||||
$graph-tooltip-bg: $gray-5;
|
||||
|
||||
$tooltipLinkColor: color.adjust($tooltipColor, $lightness: 5%);
|
||||
$tooltipExternalLinkColor: #6E9FFF;
|
||||
$tooltipExternalLinkColor: #6e9fff;
|
||||
|
||||
$popover-error-bg: $red-base;
|
||||
$popover-help-bg: $tooltipBackground;
|
||||
|
||||
+65
-3
@@ -49,7 +49,6 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz
|
||||
folderRoute.Get("/id/:id", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID))
|
||||
|
||||
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
|
||||
folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder))
|
||||
folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) {
|
||||
folderPermissionRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsRead, uidScope)), routing.Wrap(hs.GetFolderPermissionList))
|
||||
folderPermissionRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions))
|
||||
@@ -65,6 +64,7 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz
|
||||
folderUidRoute.Delete("/", handler.deleteFolder)
|
||||
folderUidRoute.Get("/", handler.getFolder)
|
||||
folderUidRoute.Get("/counts", handler.countFolderContent)
|
||||
folderUidRoute.Post("/move", handler.moveFolder)
|
||||
})
|
||||
} else {
|
||||
folderRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder))
|
||||
@@ -74,6 +74,7 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz
|
||||
folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder))
|
||||
folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID))
|
||||
folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts))
|
||||
folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder))
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -776,7 +777,24 @@ func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) {
|
||||
return // error is already sent
|
||||
}
|
||||
uid := web.Params(c.Req)[":uid"]
|
||||
out, err := client.Get(c.Req.Context(), uid, v1.GetOptions{})
|
||||
|
||||
var out *unstructured.Unstructured
|
||||
var err error
|
||||
|
||||
if uid == accesscontrol.GeneralFolderUID {
|
||||
out = &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"spec": map[string]interface{}{
|
||||
"title": folder.RootFolder.Title,
|
||||
"description": folder.RootFolder.Description,
|
||||
},
|
||||
},
|
||||
}
|
||||
out.SetName(folder.RootFolder.UID)
|
||||
} else {
|
||||
out, err = client.Get(c.Req.Context(), uid, v1.GetOptions{})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
@@ -827,7 +845,51 @@ func (fk8s *folderK8sHandler) updateFolder(c *contextmodel.ReqContext) {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := client.Update(c.Req.Context(), &obj, v1.UpdateOptions{})
|
||||
out, err := client.Update(c.Req.Context(), obj, v1.UpdateOptions{})
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
folderDTO, err := fk8s.newToFolderDto(c, *out, c.SignedInUser.GetOrgID())
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, folderDTO)
|
||||
}
|
||||
|
||||
func (fk8s *folderK8sHandler) moveFolder(c *contextmodel.ReqContext) {
|
||||
client, ok := fk8s.getClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Req.Context()
|
||||
|
||||
cmd := folder.MoveFolderCommand{}
|
||||
if err := web.Bind(c.Req, &cmd); err != nil {
|
||||
c.JsonApiErr(http.StatusBadRequest, "bad request data", err)
|
||||
return
|
||||
}
|
||||
cmd.OrgID = c.SignedInUser.GetOrgID()
|
||||
cmd.UID = web.Params(c.Req)[":uid"]
|
||||
cmd.SignedInUser = c.SignedInUser
|
||||
|
||||
obj, err := client.Get(ctx, cmd.UID, v1.GetOptions{})
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
obj, err = internalfolders.LegacyMoveCommandToUnstructured(obj, cmd)
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := client.Update(c.Req.Context(), obj, v1.UpdateOptions{})
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
|
||||
@@ -863,3 +863,152 @@ func TestToFolderCounts(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// for now, test only the general folder
|
||||
func TestGetFolderLegacyAndUnifiedStorage(t *testing.T) {
|
||||
testuser := &user.User{ID: 99, UID: "fdxsqt7t5ryf4a", Login: "testuser"}
|
||||
|
||||
legacyFolder := *folder.RootFolder
|
||||
|
||||
expectedFolder := dtos.Folder{
|
||||
UID: legacyFolder.UID,
|
||||
OrgID: 0,
|
||||
Title: legacyFolder.Title,
|
||||
URL: legacyFolder.URL,
|
||||
HasACL: false,
|
||||
CanSave: false,
|
||||
CanEdit: true,
|
||||
CanAdmin: false,
|
||||
CanDelete: false,
|
||||
CreatedBy: "Anonymous",
|
||||
UpdatedBy: "Anonymous",
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
folderApiServerMock := httptest.NewServer(mux)
|
||||
defer folderApiServerMock.Close()
|
||||
|
||||
t.Run("happy path", func(t *testing.T) {
|
||||
type testCase struct {
|
||||
description string
|
||||
folderUID string
|
||||
legacyFolder folder.Folder
|
||||
expectedFolder dtos.Folder
|
||||
expectedFolderServiceError error
|
||||
unifiedStorageEnabled bool
|
||||
unifiedStorageMode grafanarest.DualWriterMode
|
||||
expectedCode int
|
||||
}
|
||||
|
||||
tcs := []testCase{
|
||||
{
|
||||
description: "General folder - Legacy",
|
||||
expectedCode: http.StatusOK,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: legacyFolder.UID,
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: false,
|
||||
},
|
||||
{
|
||||
description: "General folder - Unified storage, mode 1",
|
||||
expectedCode: http.StatusOK,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: legacyFolder.UID,
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: true,
|
||||
unifiedStorageMode: grafanarest.Mode1,
|
||||
},
|
||||
{
|
||||
description: "General folder - Unified storage, mode 2",
|
||||
expectedCode: http.StatusOK,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: legacyFolder.UID,
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: true,
|
||||
unifiedStorageMode: grafanarest.Mode2,
|
||||
},
|
||||
{
|
||||
description: "General folder - Unified storage, mode 3",
|
||||
expectedCode: http.StatusOK,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: legacyFolder.UID,
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: true,
|
||||
unifiedStorageMode: grafanarest.Mode3,
|
||||
},
|
||||
{
|
||||
description: "General folder - Unified storage, mode 4",
|
||||
expectedCode: http.StatusOK,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: legacyFolder.UID,
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: true,
|
||||
unifiedStorageMode: grafanarest.Mode4,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
setUpRBACGuardian(t)
|
||||
|
||||
cfg := setting.NewCfg()
|
||||
cfg.UnifiedStorage = map[string]setting.UnifiedStorageConfig{
|
||||
folderv0alpha1.RESOURCEGROUP: {
|
||||
DualWriterMode: tc.unifiedStorageMode,
|
||||
},
|
||||
}
|
||||
|
||||
featuresArr := []any{featuremgmt.FlagNestedFolders}
|
||||
if tc.unifiedStorageEnabled {
|
||||
featuresArr = append(featuresArr, featuremgmt.FlagKubernetesFolders)
|
||||
}
|
||||
|
||||
server := SetupAPITestServer(t, func(hs *HTTPServer) {
|
||||
hs.Cfg = cfg
|
||||
hs.folderService = &foldertest.FakeService{
|
||||
ExpectedFolder: &tc.legacyFolder,
|
||||
ExpectedError: tc.expectedFolderServiceError,
|
||||
}
|
||||
hs.QuotaService = quotatest.New(false, nil)
|
||||
hs.SearchService = &mockSearchService{
|
||||
ExpectedResult: model.HitList{},
|
||||
}
|
||||
hs.userService = &usertest.FakeUserService{
|
||||
ExpectedUser: testuser,
|
||||
}
|
||||
hs.Features = featuremgmt.WithFeatures(
|
||||
featuresArr...,
|
||||
)
|
||||
hs.clientConfigProvider = mockClientConfigProvider{
|
||||
host: folderApiServerMock.URL,
|
||||
}
|
||||
})
|
||||
|
||||
req := server.NewRequest(http.MethodGet, fmt.Sprintf("/api/folders/%s", tc.folderUID), nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{
|
||||
1: accesscontrol.GroupScopesByActionContext(context.Background(), []accesscontrol.Permission{
|
||||
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll},
|
||||
}),
|
||||
}})
|
||||
|
||||
res, err := server.Send(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, tc.expectedCode, res.StatusCode)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
|
||||
if tc.expectedCode == http.StatusOK {
|
||||
body := dtos.Folder{}
|
||||
require.NoError(t, json.NewDecoder(res.Body).Decode(&body))
|
||||
|
||||
//nolint:staticcheck
|
||||
body.ID = 0
|
||||
body.Version = 0
|
||||
tc.expectedFolder.Version = 0
|
||||
require.Equal(t, tc.expectedFolder, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/grafana/grafana/pkg/apimachinery
|
||||
go 1.23.1
|
||||
|
||||
require (
|
||||
github.com/grafana/authlib v0.0.0-20241212110159-5ae10177a923 // @grafana/identity-access-team
|
||||
github.com/grafana/authlib v0.0.0-20241219093220-08aa05b9cf26 // @grafana/identity-access-team
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 // @grafana/identity-access-team
|
||||
github.com/stretchr/testify v1.10.0
|
||||
k8s.io/apimachinery v0.31.3
|
||||
|
||||
@@ -28,8 +28,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/grafana/authlib v0.0.0-20241212110159-5ae10177a923 h1:M5WFasIUNOO48W8OpmryvEKkfnlmPBPG10uWiAmeB1k=
|
||||
github.com/grafana/authlib v0.0.0-20241212110159-5ae10177a923/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI=
|
||||
github.com/grafana/authlib v0.0.0-20241219093220-08aa05b9cf26 h1:kWe1MsWAtFQvmnbwnQNJdaGCTqbGNVuowOtLmhO5qEY=
|
||||
github.com/grafana/authlib v0.0.0-20241219093220-08aa05b9cf26/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI=
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk=
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
|
||||
@@ -41,9 +41,9 @@ func LegacyCreateCommandToUnstructured(cmd folder.CreateFolderCommand) (unstruct
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
func LegacyUpdateCommandToUnstructured(cmd folder.UpdateFolderCommand) (unstructured.Unstructured, error) {
|
||||
func LegacyUpdateCommandToUnstructured(cmd folder.UpdateFolderCommand) (*unstructured.Unstructured, error) {
|
||||
// #TODO add other fields ; do we support updating the UID/orgID?
|
||||
obj := unstructured.Unstructured{
|
||||
obj := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"spec": map[string]interface{}{
|
||||
"title": cmd.NewTitle,
|
||||
@@ -56,8 +56,16 @@ func LegacyUpdateCommandToUnstructured(cmd folder.UpdateFolderCommand) (unstruct
|
||||
if cmd.NewParentUID == nil {
|
||||
return obj, nil
|
||||
}
|
||||
if err := setParentUID(&obj, *cmd.NewParentUID); err != nil {
|
||||
return unstructured.Unstructured{}, err
|
||||
if err := setParentUID(obj, *cmd.NewParentUID); err != nil {
|
||||
return &unstructured.Unstructured{}, err
|
||||
}
|
||||
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
func LegacyMoveCommandToUnstructured(obj *unstructured.Unstructured, cmd folder.MoveFolderCommand) (*unstructured.Unstructured, error) {
|
||||
if err := setParentUID(obj, cmd.NewParentUID); err != nil {
|
||||
return &unstructured.Unstructured{}, err
|
||||
}
|
||||
|
||||
return obj, nil
|
||||
@@ -92,13 +100,20 @@ func UnstructuredToLegacyFolder(item unstructured.Unstructured, orgID int64) (*f
|
||||
createdTime = (*created).UTC()
|
||||
}
|
||||
|
||||
url := getURL(meta, title)
|
||||
|
||||
// RootFolder does not have URL
|
||||
if uid == folder.RootFolder.UID {
|
||||
url = ""
|
||||
}
|
||||
|
||||
f := &folder.Folder{
|
||||
UID: uid,
|
||||
Title: title,
|
||||
ID: id,
|
||||
ParentUID: meta.GetFolder(),
|
||||
// #TODO add created by field if necessary
|
||||
URL: getURL(meta, title),
|
||||
URL: url,
|
||||
// #TODO get Created in format "2024-09-12T15:37:41.09466+02:00"
|
||||
Created: createdTime,
|
||||
// #TODO figure out whether we want to set "updated" and "updated by". Could replace with
|
||||
|
||||
@@ -257,7 +257,11 @@ func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes,
|
||||
case admission.Delete:
|
||||
return b.validateOnDelete(ctx, f)
|
||||
case admission.Update:
|
||||
return nil
|
||||
old := a.GetOldObject()
|
||||
if old == nil {
|
||||
return fmt.Errorf("old object is nil")
|
||||
}
|
||||
return b.validateOnUpdate(ctx, obj, old)
|
||||
case admission.Connect:
|
||||
return nil
|
||||
}
|
||||
@@ -302,22 +306,12 @@ func (b *FolderAPIBuilder) validateOnCreate(ctx context.Context, id string, obj
|
||||
return dashboards.ErrFolderTitleEmpty
|
||||
}
|
||||
|
||||
for i := 1; i <= folderValidationRules.maxDepth; i++ {
|
||||
parent := getParent(obj)
|
||||
if parent == "" {
|
||||
break
|
||||
}
|
||||
if i == folderValidationRules.maxDepth {
|
||||
return folder.ErrMaximumDepthReached
|
||||
}
|
||||
|
||||
parentObj, err := b.storage.Get(ctx, parent, &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj = parentObj
|
||||
_, err := b.checkFolderMaxDepth(ctx, obj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func getParent(o runtime.Object) string {
|
||||
@@ -327,3 +321,66 @@ func getParent(o runtime.Object) string {
|
||||
}
|
||||
return meta.GetFolder()
|
||||
}
|
||||
|
||||
func (b *FolderAPIBuilder) checkFolderMaxDepth(ctx context.Context, obj runtime.Object) ([]string, error) {
|
||||
var parents = []string{}
|
||||
for i := 0; i < folderValidationRules.maxDepth; i++ {
|
||||
parent := getParent(obj)
|
||||
if parent == "" {
|
||||
break
|
||||
}
|
||||
parents = append(parents, parent)
|
||||
if i+1 == folderValidationRules.maxDepth {
|
||||
return parents, folder.ErrMaximumDepthReached
|
||||
}
|
||||
|
||||
parentObj, err := b.storage.Get(ctx, parent, &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return parents, err
|
||||
}
|
||||
obj = parentObj
|
||||
}
|
||||
return parents, nil
|
||||
}
|
||||
|
||||
func (b *FolderAPIBuilder) validateOnUpdate(ctx context.Context, obj, old runtime.Object) error {
|
||||
f, ok := obj.(*v0alpha1.Folder)
|
||||
if !ok {
|
||||
return fmt.Errorf("obj is not v0alpha1.Folder")
|
||||
}
|
||||
|
||||
fOld, ok := old.(*v0alpha1.Folder)
|
||||
if !ok {
|
||||
return fmt.Errorf("obj is not v0alpha1.Folder")
|
||||
}
|
||||
var newParent = getParent(obj)
|
||||
if newParent != getParent(fOld) {
|
||||
// it's a move operation
|
||||
return b.validateMove(ctx, obj, newParent)
|
||||
}
|
||||
// it's a spec update
|
||||
if f.Spec.Title == "" {
|
||||
return dashboards.ErrFolderTitleEmpty
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *FolderAPIBuilder) validateMove(ctx context.Context, obj runtime.Object, newParent string) error {
|
||||
// folder cannot be moved to a k6 folder
|
||||
if newParent == accesscontrol.K6FolderUID {
|
||||
return fmt.Errorf("k6 project may not be moved")
|
||||
}
|
||||
|
||||
//FIXME: until we have a way to represent the tree, we can only
|
||||
// look at folder parents to check how deep the new folder tree will be
|
||||
parents, err := b.checkFolderMaxDepth(ctx, obj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if by moving a folder we exceed the max depth, return an error
|
||||
if len(parents)+1 >= folderValidationRules.maxDepth {
|
||||
return folder.ErrMaximumDepthReached
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
@@ -257,7 +258,7 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should return error when creating a nested folder higher than max depth",
|
||||
name: "should not allow creating a folder in a tree that is too deep",
|
||||
input: input{
|
||||
obj: &v0alpha1.Folder{
|
||||
Spec: v0alpha1.Spec{
|
||||
@@ -410,3 +411,169 @@ func TestFolderAPIBuilder_Validate_Delete(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
|
||||
var circularObj = &v0alpha1.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "stacks-123",
|
||||
Name: "new-parent",
|
||||
Annotations: map[string]string{"grafana.app/folder": "new-parent"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
updatedObj *v0alpha1.Folder
|
||||
expected *v0alpha1.Folder
|
||||
setupFn func(*mock.Mock)
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "should allow updating a folder spec",
|
||||
updatedObj: &v0alpha1.Folder{
|
||||
Spec: v0alpha1.Spec{
|
||||
Title: "different title",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "stacks-123",
|
||||
Name: "valid-name",
|
||||
Annotations: map[string]string{"grafana.app/folder": "valid-parent"},
|
||||
},
|
||||
},
|
||||
expected: &v0alpha1.Folder{
|
||||
Spec: v0alpha1.Spec{
|
||||
Title: "different title",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "stacks-123",
|
||||
Name: "valid-name",
|
||||
Annotations: map[string]string{"grafana.app/folder": "valid-parent"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "updated title should not be empty",
|
||||
updatedObj: &v0alpha1.Folder{
|
||||
Spec: v0alpha1.Spec{
|
||||
Title: "",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "stacks-123",
|
||||
Name: "valid-name",
|
||||
Annotations: map[string]string{"grafana.app/folder": "valid-parent"},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "should allow moving to a valid parent",
|
||||
updatedObj: &v0alpha1.Folder{
|
||||
Spec: v0alpha1.Spec{
|
||||
Title: "foo",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "stacks-123",
|
||||
Name: "valid-name",
|
||||
Annotations: map[string]string{"grafana.app/folder": "new-parent"},
|
||||
},
|
||||
},
|
||||
setupFn: func(m *mock.Mock) {
|
||||
m.On("Get", mock.Anything, "new-parent", mock.Anything).Return(
|
||||
&v0alpha1.Folder{},
|
||||
nil).Once()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should not allow moving to a k6 folder",
|
||||
updatedObj: &v0alpha1.Folder{
|
||||
Spec: v0alpha1.Spec{
|
||||
Title: "foo",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "stacks-123",
|
||||
Name: "valid-name",
|
||||
Annotations: map[string]string{"grafana.app/folder": accesscontrol.K6FolderUID},
|
||||
},
|
||||
},
|
||||
setupFn: func(m *mock.Mock) {
|
||||
m.On("Get", mock.Anything, accesscontrol.K6FolderUID, mock.Anything).Return(
|
||||
&v0alpha1.Folder{},
|
||||
nil).Once()
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "should not allow moving to a folder that is too deep",
|
||||
updatedObj: &v0alpha1.Folder{
|
||||
Spec: v0alpha1.Spec{
|
||||
Title: "foo",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "stacks-123",
|
||||
Name: "valid-name",
|
||||
Annotations: map[string]string{"grafana.app/folder": "new-parent"},
|
||||
},
|
||||
},
|
||||
setupFn: func(m *mock.Mock) {
|
||||
m.On("Get", mock.Anything, "new-parent", mock.Anything).Return(
|
||||
circularObj,
|
||||
nil)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
s := (grafanarest.Storage)(nil)
|
||||
m := &mock.Mock{}
|
||||
us := storageMock{m, s}
|
||||
sm := searcherMock{Mock: m}
|
||||
|
||||
obj := &v0alpha1.Folder{
|
||||
Spec: v0alpha1.Spec{
|
||||
Title: "foo",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "stacks-123",
|
||||
Name: "valid-name",
|
||||
Annotations: map[string]string{"grafana.app/folder": "valid-parent"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if tt.setupFn != nil {
|
||||
tt.setupFn(m)
|
||||
}
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := &FolderAPIBuilder{
|
||||
gv: resourceInfo.GroupVersion(),
|
||||
features: nil,
|
||||
namespacer: func(_ int64) string { return "123" },
|
||||
folderSvc: foldertest.NewFakeService(),
|
||||
storage: us,
|
||||
accessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures("nestedFolders"), zanzana.NewNoopClient()),
|
||||
searcher: sm,
|
||||
}
|
||||
|
||||
err := b.Validate(context.Background(), admission.NewAttributesRecord(
|
||||
tt.updatedObj,
|
||||
obj,
|
||||
v0alpha1.SchemeGroupVersion.WithKind("folder"),
|
||||
tt.updatedObj.Namespace,
|
||||
tt.updatedObj.Name,
|
||||
v0alpha1.SchemeGroupVersion.WithResource("folders"),
|
||||
"",
|
||||
"UPDATE",
|
||||
nil,
|
||||
true,
|
||||
&user.SignedInUser{},
|
||||
),
|
||||
nil)
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,11 @@ func (s *ModuleServer) Run() error {
|
||||
//}
|
||||
|
||||
m.RegisterModule(modules.StorageServer, func() (services.Service, error) {
|
||||
return sql.ProvideUnifiedStorageGrpcService(s.cfg, s.features, nil, s.log, nil)
|
||||
docBuilders, err := InitializeDocumentBuilders(s.cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sql.ProvideUnifiedStorageGrpcService(s.cfg, s.features, nil, s.log, nil, docBuilders)
|
||||
})
|
||||
|
||||
m.RegisterModule(modules.ZanzanaServer, func() (services.Service, error) {
|
||||
|
||||
+6
-1
@@ -8,6 +8,7 @@ package server
|
||||
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
|
||||
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||
|
||||
@@ -229,7 +230,6 @@ var wireBasicSet = wire.NewSet(
|
||||
wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)),
|
||||
authinfoimpl.ProvideStore,
|
||||
datasourceproxy.ProvideService,
|
||||
unifiedsearch.ProvideDocumentBuilders,
|
||||
search.ProvideService,
|
||||
searchV2.ProvideService,
|
||||
searchV2.ProvideSearchHTTPService,
|
||||
@@ -475,3 +475,8 @@ func InitializeAPIServerFactory() (standalone.APIServerFactory, error) {
|
||||
wire.Build(wireExtsStandaloneAPIServerSet)
|
||||
return &standalone.NoOpAPIServerFactory{}, nil // Wire will replace this with a real interface
|
||||
}
|
||||
|
||||
func InitializeDocumentBuilders(cfg *setting.Cfg) (resource.DocumentBuilderSupplier, error) {
|
||||
wire.Build(wireExtsSet)
|
||||
return &unifiedsearch.StandardDocumentBuilders{}, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package server
|
||||
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
search2 "github.com/grafana/grafana/pkg/storage/unified/search"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
@@ -106,6 +107,9 @@ var wireExtsBasicSet = wire.NewSet(
|
||||
wire.Bind(new(auth.IDSigner), new(*idimpl.LocalSigner)),
|
||||
manager.ProvideInstaller,
|
||||
wire.Bind(new(plugins.Installer), new(*manager.PluginInstaller)),
|
||||
search2.ProvideDashboardStats,
|
||||
wire.Bind(new(search2.DashboardStats), new(*search2.OssDashboardStats)),
|
||||
search2.ProvideDocumentBuilders,
|
||||
)
|
||||
|
||||
var wireExtsSet = wire.NewSet(
|
||||
|
||||
@@ -1525,6 +1525,14 @@ var (
|
||||
HideFromDocs: true,
|
||||
HideFromAdminPage: true,
|
||||
},
|
||||
{
|
||||
Name: "unifiedStorageSearchSprinkles",
|
||||
Description: "Enable sprinkles on unified storage search",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaSearchAndStorageSquad,
|
||||
HideFromDocs: true,
|
||||
HideFromAdminPage: true,
|
||||
},
|
||||
{
|
||||
Name: "pluginsSriChecks",
|
||||
Description: "Enables SRI checks for plugin assets",
|
||||
@@ -1569,13 +1577,6 @@ var (
|
||||
Owner: grafanaPluginsPlatformSquad,
|
||||
Expression: "true", // enabled by default
|
||||
},
|
||||
{
|
||||
Name: "dashboardSchemaV2",
|
||||
Description: "Enables the new dashboard schema version 2, implementing changes necessary for dynamic dashboards and dashboards as code.",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "playlistsWatcher",
|
||||
Description: "Enables experimental watcher for playlists",
|
||||
@@ -1600,6 +1601,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "prometheusSpecialCharsInLabelValues",
|
||||
Description: "Adds support for quotes and special characters in label values for Prometheus queries",
|
||||
FrontendOnly: true,
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaObservabilityMetricsSquad,
|
||||
},
|
||||
{
|
||||
Name: "enableExtensionsAdminPage",
|
||||
Description: "Enables the extension admin page regardless of development mode",
|
||||
|
||||
@@ -201,6 +201,7 @@ improvedExternalSessionHandling,experimental,@grafana/identity-access-team,false
|
||||
useSessionStorageForRedirection,GA,@grafana/identity-access-team,false,false,false
|
||||
rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false
|
||||
unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false
|
||||
unifiedStorageSearchSprinkles,experimental,@grafana/search-and-storage,false,false,false
|
||||
pluginsSriChecks,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false
|
||||
timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false
|
||||
@@ -208,10 +209,10 @@ prometheusUsesCombobox,experimental,@grafana/observability-metrics,false,false,f
|
||||
userStorageAPI,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false
|
||||
preinstallAutoUpdate,GA,@grafana/plugins-platform-backend,false,false,false
|
||||
dashboardSchemaV2,experimental,@grafana/dashboards-squad,false,false,true
|
||||
playlistsWatcher,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false
|
||||
exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true
|
||||
prometheusSpecialCharsInLabelValues,experimental,@grafana/observability-metrics,false,false,true
|
||||
enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false
|
||||
zipkinBackendMigration,GA,@grafana/oss-big-tent,false,false,false
|
||||
enableSCIM,experimental,@grafana/identity-access-team,false,false,false
|
||||
|
||||
|
@@ -815,6 +815,10 @@ const (
|
||||
// Enable unified storage search
|
||||
FlagUnifiedStorageSearch = "unifiedStorageSearch"
|
||||
|
||||
// FlagUnifiedStorageSearchSprinkles
|
||||
// Enable sprinkles on unified storage search
|
||||
FlagUnifiedStorageSearchSprinkles = "unifiedStorageSearchSprinkles"
|
||||
|
||||
// FlagPluginsSriChecks
|
||||
// Enables SRI checks for plugin assets
|
||||
FlagPluginsSriChecks = "pluginsSriChecks"
|
||||
@@ -843,10 +847,6 @@ const (
|
||||
// Enables automatic updates for pre-installed plugins
|
||||
FlagPreinstallAutoUpdate = "preinstallAutoUpdate"
|
||||
|
||||
// FlagDashboardSchemaV2
|
||||
// Enables the new dashboard schema version 2, implementing changes necessary for dynamic dashboards and dashboards as code.
|
||||
FlagDashboardSchemaV2 = "dashboardSchemaV2"
|
||||
|
||||
// FlagPlaylistsWatcher
|
||||
// Enables experimental watcher for playlists
|
||||
FlagPlaylistsWatcher = "playlistsWatcher"
|
||||
@@ -859,6 +859,10 @@ const (
|
||||
// Display Related Logs in Explore Metrics
|
||||
FlagExploreMetricsRelatedLogs = "exploreMetricsRelatedLogs"
|
||||
|
||||
// FlagPrometheusSpecialCharsInLabelValues
|
||||
// Adds support for quotes and special characters in label values for Prometheus queries
|
||||
FlagPrometheusSpecialCharsInLabelValues = "prometheusSpecialCharsInLabelValues"
|
||||
|
||||
// FlagEnableExtensionsAdminPage
|
||||
// Enables the extension admin page regardless of development mode
|
||||
FlagEnableExtensionsAdminPage = "enableExtensionsAdminPage"
|
||||
|
||||
@@ -1026,7 +1026,8 @@
|
||||
"metadata": {
|
||||
"name": "dashboardSchemaV2",
|
||||
"resourceVersion": "1730192092473",
|
||||
"creationTimestamp": "2024-10-29T08:54:52Z"
|
||||
"creationTimestamp": "2024-10-29T08:54:52Z",
|
||||
"deletionTimestamp": "2024-12-19T12:03:44Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the new dashboard schema version 2, implementing changes necessary for dynamic dashboards and dashboards as code.",
|
||||
@@ -2931,6 +2932,19 @@
|
||||
"codeowner": "@grafana/observability-metrics"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "prometheusSpecialCharsInLabelValues",
|
||||
"resourceVersion": "1734047568531",
|
||||
"creationTimestamp": "2024-12-12T23:52:48Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Adds support for quotes and special characters in label values for Prometheus queries",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/observability-metrics",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "prometheusUsesCombobox",
|
||||
@@ -3618,6 +3632,20 @@
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "unifiedStorageSearchSprinkles",
|
||||
"resourceVersion": "1734123247356",
|
||||
"creationTimestamp": "2024-12-13T20:54:07Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable sprinkles on unified storage search",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/search-and-storage",
|
||||
"hideFromAdminPage": true,
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "useSeessionStorageForRedirection",
|
||||
|
||||
@@ -523,12 +523,14 @@ type Cfg struct {
|
||||
ShortLinkExpiration int
|
||||
|
||||
// Unified Storage
|
||||
UnifiedStorage map[string]UnifiedStorageConfig
|
||||
IndexPath string
|
||||
IndexWorkers int
|
||||
IndexMaxBatchSize int
|
||||
IndexFileThreshold int
|
||||
IndexMinCount int
|
||||
UnifiedStorage map[string]UnifiedStorageConfig
|
||||
IndexPath string
|
||||
IndexWorkers int
|
||||
IndexMaxBatchSize int
|
||||
IndexFileThreshold int
|
||||
IndexMinCount int
|
||||
SprinklesApiServer string
|
||||
SprinklesApiServerPageLimit int
|
||||
}
|
||||
|
||||
type UnifiedStorageConfig struct {
|
||||
|
||||
@@ -52,4 +52,6 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
|
||||
cfg.IndexMaxBatchSize = section.Key("index_max_batch_size").MustInt(100)
|
||||
cfg.IndexFileThreshold = section.Key("index_file_threshold").MustInt(10)
|
||||
cfg.IndexMinCount = section.Key("index_min_count").MustInt(1)
|
||||
cfg.SprinklesApiServer = section.Key("sprinkles_api_server").String()
|
||||
cfg.SprinklesApiServerPageLimit = section.Key("sprinkles_api_server_page_limit").MustInt(100)
|
||||
}
|
||||
|
||||
@@ -100,9 +100,11 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti
|
||||
if previous.GetUID() == "" {
|
||||
klog.Errorf("object is missing UID: %s, %s", obj.GetGroupVersionKind().String(), obj.GetName())
|
||||
} else if obj.GetUID() != previous.GetUID() {
|
||||
if obj.GetUID() != "" {
|
||||
klog.Errorf("object UID mismatch: %s, was:%s, now: %s", obj.GetGroupVersionKind().String(), previous.GetName(), obj.GetUID())
|
||||
}
|
||||
// Eventually this should be a real error or logged
|
||||
// However the dashboard dual write behavior hits this every time, so we will ignore it
|
||||
// if obj.GetUID() != "" {
|
||||
// klog.Errorf("object UID mismatch: %s, was:%s, now: %s", obj.GetGroupVersionKind().String(), previous.GetName(), obj.GetUID())
|
||||
// }
|
||||
obj.SetUID(previous.GetUID())
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,12 @@ func (s *LegacyStatsGetter) GetStats(ctx context.Context, in *resource.ResourceS
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Legacy library_elements table
|
||||
err = fn("library_element", "org_id=? AND folder_uid=?", group, "library_elements")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
@@ -133,6 +133,10 @@ func TestDirectSQLStats(t *testing.T) {
|
||||
"group": "sql-fallback",
|
||||
"resource": "folders",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"group": "sql-fallback",
|
||||
"resource": "library_elements"
|
||||
}
|
||||
]`, string(jj))
|
||||
})
|
||||
@@ -161,6 +165,10 @@ func TestDirectSQLStats(t *testing.T) {
|
||||
{
|
||||
"group": "sql-fallback",
|
||||
"resource": "folders"
|
||||
},
|
||||
{
|
||||
"group": "sql-fallback",
|
||||
"resource": "library_elements"
|
||||
}
|
||||
]`, string(jj))
|
||||
})
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestBleveBackend(t *testing.T) {
|
||||
return &DashboardDocumentBuilder{
|
||||
Namespace: namespace,
|
||||
Blob: blob,
|
||||
Stats: NewDashboardStatsLookup(nil), // empty stats
|
||||
Stats: make(map[string]map[string]int64), // empty stats
|
||||
DatasourceLookup: dashboard.CreateDatasourceLookup([]*dashboard.DatasourceQueryResult{{}}),
|
||||
}, nil
|
||||
})
|
||||
|
||||
@@ -75,7 +75,7 @@ func DashboardBuilder(namespaced resource.NamespacedDocumentSupplier) (resource.
|
||||
return &DashboardDocumentBuilder{
|
||||
Namespace: namespace,
|
||||
Blob: blob,
|
||||
Stats: NewDashboardStatsLookup(nil),
|
||||
Stats: nil,
|
||||
DatasourceLookup: dashboard.CreateDatasourceLookup([]*dashboard.DatasourceQueryResult{
|
||||
// empty values (does not resolve anything)
|
||||
}),
|
||||
@@ -94,8 +94,8 @@ type DashboardDocumentBuilder struct {
|
||||
Namespace string
|
||||
|
||||
// Cached stats for this namespace
|
||||
// TODO, load this from apiserver request
|
||||
Stats DashboardStatsLookup
|
||||
// maps dashboard UID to stats
|
||||
Stats map[string]map[string]int64
|
||||
|
||||
// data source lookup
|
||||
DatasourceLookup dashboard.DatasourceLookup
|
||||
@@ -104,17 +104,12 @@ type DashboardDocumentBuilder struct {
|
||||
Blob resource.BlobSupport
|
||||
}
|
||||
|
||||
type DashboardStatsLookup = func(ctx context.Context, uid string) map[string]int64
|
||||
|
||||
func NewDashboardStatsLookup(stats map[string]map[string]int64) DashboardStatsLookup {
|
||||
return func(ctx context.Context, uid string) map[string]int64 {
|
||||
if stats == nil {
|
||||
return nil
|
||||
}
|
||||
return stats[uid]
|
||||
}
|
||||
type DashboardStats interface {
|
||||
GetStats(ctx context.Context, namespace string) (map[string]map[string]int64, error)
|
||||
}
|
||||
|
||||
type DashboardStatsLookup = func(ctx context.Context, uid string) map[string]int64
|
||||
|
||||
var _ resource.DocumentBuilder = &DashboardDocumentBuilder{}
|
||||
|
||||
func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resource.ResourceKey, rv int64, value []byte) (*resource.IndexableDocument, error) {
|
||||
@@ -150,6 +145,9 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// metadata name is the dashboard uid
|
||||
summary.UID = obj.GetName()
|
||||
|
||||
doc := resource.NewIndexableDocument(key, rv, obj)
|
||||
doc.Title = summary.Title
|
||||
doc.Description = summary.Description
|
||||
@@ -211,8 +209,7 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou
|
||||
}
|
||||
|
||||
// Add the stats fields
|
||||
stats := s.Stats(ctx, key.Name) // summary.UID
|
||||
for k, v := range stats {
|
||||
for k, v := range s.Stats[summary.UID] {
|
||||
doc.Fields[k] = v
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type OssDashboardStats struct{}
|
||||
|
||||
func ProvideDashboardStats() *OssDashboardStats {
|
||||
return &OssDashboardStats{}
|
||||
}
|
||||
|
||||
func (s *OssDashboardStats) GetStats(ctx context.Context, namespace string) (map[string]map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -5,23 +5,25 @@ import (
|
||||
|
||||
"github.com/grafana/authlib/claims"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/store/kind/dashboard"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
)
|
||||
|
||||
// The default list of open source document builders
|
||||
type StandardDocumentBuilders struct {
|
||||
sql db.DB
|
||||
sql db.DB
|
||||
sprinkles DashboardStats
|
||||
}
|
||||
|
||||
// Hooked up so wire can fill in different sprinkles
|
||||
func ProvideDocumentBuilders(sql db.DB) resource.DocumentBuilderSupplier {
|
||||
return &StandardDocumentBuilders{sql}
|
||||
func ProvideDocumentBuilders(sql db.DB, sprinkles DashboardStats) resource.DocumentBuilderSupplier {
|
||||
return &StandardDocumentBuilders{sql, sprinkles}
|
||||
}
|
||||
|
||||
func (s *StandardDocumentBuilders) GetDocumentBuilders() ([]resource.DocumentBuilderInfo, error) {
|
||||
dashboards, err := DashboardBuilder(func(ctx context.Context, namespace string, blob resource.BlobSupport) (resource.DocumentBuilder, error) {
|
||||
stats := NewDashboardStatsLookup(nil) // empty stats
|
||||
logger := log.New("dashboard_builder", "namespace", namespace)
|
||||
dsinfo := []*dashboard.DatasourceQueryResult{{}}
|
||||
ns, err := claims.ParseNamespace(namespace)
|
||||
if err != nil && s.sql != nil {
|
||||
@@ -43,6 +45,18 @@ func (s *StandardDocumentBuilders) GetDocumentBuilders() ([]resource.DocumentBui
|
||||
dsinfo = append(dsinfo, info)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch dashboard sprinkles for the namespace
|
||||
// This could take a while if namespace has a lot of dashboards
|
||||
var stats map[string]map[string]int64
|
||||
if s.sprinkles != nil {
|
||||
stats, err = s.sprinkles.GetStats(ctx, namespace)
|
||||
if err != nil {
|
||||
// only log a warning. Don't need to fail the indexer if we can't get sprinkles
|
||||
logger.Warn("Failed to get sprinkles", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &DashboardDocumentBuilder{
|
||||
Namespace: namespace,
|
||||
Blob: blob,
|
||||
|
||||
@@ -56,12 +56,12 @@ func TestDashboardDocumentBuilder(t *testing.T) {
|
||||
return &DashboardDocumentBuilder{
|
||||
Namespace: namespace,
|
||||
Blob: blob,
|
||||
Stats: NewDashboardStatsLookup(map[string]map[string]int64{
|
||||
Stats: map[string]map[string]int64{
|
||||
"aaa": {
|
||||
DASHBOARD_ERRORS_LAST_1_DAYS: 1,
|
||||
DASHBOARD_ERRORS_LAST_7_DAYS: 1,
|
||||
},
|
||||
}),
|
||||
},
|
||||
DatasourceLookup: dashboard.CreateDatasourceLookup([]*dashboard.DatasourceQueryResult{{
|
||||
Name: "TheDisplayName", // used to be the unique ID!
|
||||
Type: "my-custom-plugin",
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource/grpc"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/search"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -51,6 +50,8 @@ type service struct {
|
||||
|
||||
log log.Logger
|
||||
reg prometheus.Registerer
|
||||
|
||||
docBuilders resource.DocumentBuilderSupplier
|
||||
}
|
||||
|
||||
func ProvideUnifiedStorageGrpcService(
|
||||
@@ -59,6 +60,7 @@ func ProvideUnifiedStorageGrpcService(
|
||||
db infraDB.DB,
|
||||
log log.Logger,
|
||||
reg prometheus.Registerer,
|
||||
docBuilders resource.DocumentBuilderSupplier,
|
||||
) (UnifiedStorageGrpcService, error) {
|
||||
tracingCfg, err := tracing.ProvideTracingConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -78,7 +80,7 @@ func ProvideUnifiedStorageGrpcService(
|
||||
|
||||
// FIXME: This is a temporary solution while we are migrating to the new authn interceptor
|
||||
// grpcutils.NewGrpcAuthenticator should be used instead.
|
||||
authn, err := grpcutils.NewGrpcAuthenticatorWithFallback(cfg, prometheus.DefaultRegisterer, tracing, &grpc.Authenticator{})
|
||||
authn, err := grpcutils.NewGrpcAuthenticatorWithFallback(cfg, reg, tracing, &grpc.Authenticator{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -92,6 +94,7 @@ func ProvideUnifiedStorageGrpcService(
|
||||
db: db,
|
||||
log: log,
|
||||
reg: reg,
|
||||
docBuilders: docBuilders,
|
||||
}
|
||||
|
||||
// This will be used when running as a dskit service
|
||||
@@ -106,11 +109,7 @@ func (s *service) start(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO, for standalone this will need to be started from enterprise
|
||||
// Connecting to the correct remote services (cloudconfig for DS info and usage stats)
|
||||
docs := search.ProvideDocumentBuilders(nil)
|
||||
|
||||
server, err := NewResourceServer(ctx, s.db, s.cfg, s.features, docs, s.tracing, s.reg, authzClient)
|
||||
server, err := NewResourceServer(ctx, s.db, s.cfg, s.features, s.docBuilders, s.tracing, s.reg, authzClient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -366,7 +366,7 @@ func TestClientServer(t *testing.T) {
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
|
||||
svc, err := sql.ProvideUnifiedStorageGrpcService(cfg, features, dbstore, nil, prometheus.NewPedanticRegistry())
|
||||
svc, err := sql.ProvideUnifiedStorageGrpcService(cfg, features, dbstore, nil, prometheus.NewPedanticRegistry(), nil)
|
||||
require.NoError(t, err)
|
||||
var client resource.ResourceStoreClient
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes
|
||||
var storage sql.UnifiedStorageGrpcService
|
||||
if runstore {
|
||||
storage, err = sql.ProvideUnifiedStorageGrpcService(env.Cfg, env.FeatureToggles, env.SQLStore,
|
||||
env.Cfg.Logger, prometheus.NewPedanticRegistry())
|
||||
env.Cfg.Logger, prometheus.NewPedanticRegistry(), nil)
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
err = storage.StartAsync(ctx)
|
||||
|
||||
@@ -10,9 +10,10 @@ import { isShallowEqual } from 'app/core/utils/isShallowEqual';
|
||||
import { KioskMode } from 'app/types';
|
||||
|
||||
import { RouteDescriptor } from '../../navigation/types';
|
||||
import { buildBreadcrumbs } from '../Breadcrumbs/utils';
|
||||
|
||||
import { ReturnToPreviousProps } from './ReturnToPrevious/ReturnToPrevious';
|
||||
import { TOP_BAR_LEVEL_HEIGHT } from './types';
|
||||
import { HistoryEntry, TOP_BAR_LEVEL_HEIGHT } from './types';
|
||||
|
||||
export interface AppChromeState {
|
||||
chromeless?: boolean;
|
||||
@@ -31,6 +32,7 @@ export interface AppChromeState {
|
||||
|
||||
export const DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.docked';
|
||||
export const DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY = 'grafana.navigation.open';
|
||||
export const HISTORY_LOCAL_STORAGE_KEY = 'grafana.navigation.history';
|
||||
|
||||
export class AppChromeService {
|
||||
searchBarStorageKey = 'SearchBar_Hidden';
|
||||
@@ -99,6 +101,8 @@ export class AppChromeService {
|
||||
newState.chromeless = newState.kioskMode === KioskMode.Full || this.currentRoute?.chromeless;
|
||||
|
||||
if (!this.ignoreStateUpdate(newState, current)) {
|
||||
config.featureToggles.unifiedHistory &&
|
||||
store.setObject(HISTORY_LOCAL_STORAGE_KEY, this.getUpdatedHistory(newState));
|
||||
this.state.next(newState);
|
||||
}
|
||||
}
|
||||
@@ -127,6 +131,29 @@ export class AppChromeService {
|
||||
window.sessionStorage.removeItem('returnToPrevious');
|
||||
};
|
||||
|
||||
private getUpdatedHistory(newState: AppChromeState): HistoryEntry[] {
|
||||
const breadcrumbs = buildBreadcrumbs(newState.sectionNav.node, newState.pageNav, { text: 'Home', url: '/' }, true);
|
||||
const newPageNav = newState.pageNav || newState.sectionNav.node;
|
||||
|
||||
let entries = store.getObject<HistoryEntry[]>(HISTORY_LOCAL_STORAGE_KEY, []);
|
||||
const clickedHistory = store.getObject<boolean>('CLICKING_HISTORY');
|
||||
if (clickedHistory) {
|
||||
store.setObject('CLICKING_HISTORY', false);
|
||||
return entries;
|
||||
}
|
||||
if (!newPageNav) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
let lastEntry = entries[0];
|
||||
if (!lastEntry || lastEntry.name !== newPageNav.text) {
|
||||
lastEntry = { name: newPageNav.text, views: [], breadcrumbs, time: Date.now(), url: window.location.href };
|
||||
}
|
||||
if (lastEntry !== entries[0]) {
|
||||
entries = [lastEntry, ...entries];
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
private ignoreStateUpdate(newState: AppChromeState, current: AppChromeState) {
|
||||
if (isShallowEqual(newState, current)) {
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect } from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, store } from '@grafana/data';
|
||||
import { Drawer, ToolbarButton, useStyles2 } from '@grafana/ui';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { RecordHistoryEntryEvent } from 'app/types/events';
|
||||
|
||||
import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService';
|
||||
import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator';
|
||||
import { HistoryEntry } from '../types';
|
||||
|
||||
import { HistoryWrapper } from './HistoryWrapper';
|
||||
|
||||
export function HistoryContainer() {
|
||||
const [showHistoryDrawer, onToggleShowHistoryDrawer] = useToggle(false);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = appEvents.subscribe(RecordHistoryEntryEvent, (ev) => {
|
||||
const clickedHistory = store.getObject<boolean>('CLICKING_HISTORY');
|
||||
if (clickedHistory) {
|
||||
store.setObject('CLICKING_HISTORY', false);
|
||||
return;
|
||||
}
|
||||
const history = store.getObject<HistoryEntry[]>(HISTORY_LOCAL_STORAGE_KEY, []);
|
||||
let lastEntry = history[0];
|
||||
const newUrl = ev.payload.url;
|
||||
const lastUrl = lastEntry.views[0]?.url;
|
||||
if (lastUrl !== newUrl) {
|
||||
lastEntry.views = [
|
||||
{
|
||||
name: ev.payload.name,
|
||||
description: ev.payload.description,
|
||||
url: newUrl,
|
||||
time: Date.now(),
|
||||
},
|
||||
...lastEntry.views,
|
||||
];
|
||||
store.setObject(HISTORY_LOCAL_STORAGE_KEY, [...history]);
|
||||
}
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolbarButton
|
||||
onClick={onToggleShowHistoryDrawer}
|
||||
iconOnly
|
||||
icon="history"
|
||||
aria-label={t('nav.history-container.drawer-tittle', 'History')}
|
||||
/>
|
||||
<NavToolbarSeparator className={styles.separator} />
|
||||
{showHistoryDrawer && (
|
||||
<Drawer
|
||||
title={t('nav.history-container.drawer-tittle', 'History')}
|
||||
onClose={onToggleShowHistoryDrawer}
|
||||
size="md"
|
||||
>
|
||||
<HistoryWrapper onClose={() => onToggleShowHistoryDrawer(false)} />
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
separator: css({
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
display: 'none',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
import { css } from '@emotion/css';
|
||||
import moment from 'moment';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { FieldType, GrafanaTheme2, store } from '@grafana/data';
|
||||
import { Button, Card, IconButton, Space, Stack, Text, useStyles2, Box, Sparkline, useTheme2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService';
|
||||
import { HistoryEntry } from '../types';
|
||||
|
||||
export function HistoryWrapper({ onClose }: { onClose: () => void }) {
|
||||
const history = store.getObject<HistoryEntry[]>(HISTORY_LOCAL_STORAGE_KEY, []).filter((entry) => {
|
||||
return moment(entry.time).isAfter(moment().subtract(2, 'day').startOf('day'));
|
||||
});
|
||||
const [numItemsToShow, setNumItemsToShow] = useState(5);
|
||||
|
||||
const selectedTime = history.find((entry) => {
|
||||
return entry.url === window.location.href || entry.views.some((view) => view.url === window.location.href);
|
||||
})?.time;
|
||||
|
||||
const hist = history.slice(0, numItemsToShow).reduce((acc: { [key: string]: HistoryEntry[] }, entry) => {
|
||||
const date = moment(entry.time);
|
||||
let key = '';
|
||||
if (date.isSame(moment(), 'day')) {
|
||||
key = t('nav.history-wrapper.today', 'Today');
|
||||
} else if (date.isSame(moment().subtract(1, 'day'), 'day')) {
|
||||
key = t('nav.history-wrapper.yesterday', 'Yesterday');
|
||||
} else {
|
||||
key = date.format('YYYY-MM-DD');
|
||||
}
|
||||
acc[key] = [...(acc[key] || []), entry];
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<Stack direction="column" alignItems="flex-start">
|
||||
<Box width="100%">
|
||||
{Object.keys(hist).map((entries, date) => {
|
||||
return (
|
||||
<Stack key={date} direction="column" gap={1}>
|
||||
<Text color="secondary" variant="bodySmall">
|
||||
{entries}
|
||||
</Text>
|
||||
{hist[entries].map((entry, index) => {
|
||||
return (
|
||||
<HistoryEntryAppView
|
||||
key={index}
|
||||
entry={entry}
|
||||
isSelected={entry.time === selectedTime}
|
||||
onClick={() => onClose()}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{history.length > numItemsToShow && (
|
||||
<Button variant="secondary" fill="text" onClick={() => setNumItemsToShow(numItemsToShow + 5)}>
|
||||
{t('nav.history-wrapper.show-more', 'Show more')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
interface ItemProps {
|
||||
entry: HistoryEntry;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function HistoryEntryAppView({ entry, isSelected, onClick }: ItemProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const theme = useTheme2();
|
||||
const [isExpanded, setIsExpanded] = useState(isSelected && entry.views.length > 0);
|
||||
const { breadcrumbs, views, time, url, sparklineData } = entry;
|
||||
const expandedLabel = isExpanded
|
||||
? t('nav.history-wrapper.collapse', 'Collapse')
|
||||
: t('nav.history-wrapper.expand', 'Expand');
|
||||
|
||||
const selectedViewTime =
|
||||
isSelected &&
|
||||
entry.views.find((entry) => {
|
||||
return entry.url === window.location.href;
|
||||
})?.time;
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={1}>
|
||||
<Stack>
|
||||
{views.length > 0 ? (
|
||||
<IconButton
|
||||
name={isExpanded ? 'angle-down' : 'angle-right'}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
aria-label={expandedLabel}
|
||||
className={styles.iconButton}
|
||||
/>
|
||||
) : (
|
||||
<Space h={2} />
|
||||
)}
|
||||
|
||||
<Card
|
||||
onClick={() => {
|
||||
store.setObject('CLICKING_HISTORY', true);
|
||||
onClick();
|
||||
}}
|
||||
href={url}
|
||||
isCompact={true}
|
||||
className={isSelected ? undefined : styles.card}
|
||||
>
|
||||
<Stack direction="column">
|
||||
<div>
|
||||
{breadcrumbs.map((breadcrumb, index) => (
|
||||
<Text key={index}>
|
||||
{breadcrumb.text} {index !== breadcrumbs.length - 1 ? '> ' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
<Text color="secondary">{moment(time).format('h:mm A')}</Text>
|
||||
{sparklineData && (
|
||||
<Sparkline
|
||||
theme={theme}
|
||||
width={240}
|
||||
height={40}
|
||||
config={{
|
||||
custom: {
|
||||
fillColor: 'rgba(130, 181, 216, 0.1)',
|
||||
lineColor: '#82B5D8',
|
||||
},
|
||||
}}
|
||||
sparkline={{
|
||||
y: {
|
||||
type: FieldType.number,
|
||||
name: 'test',
|
||||
config: {},
|
||||
values: sparklineData.values,
|
||||
state: {
|
||||
range: {
|
||||
...sparklineData.range,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
{isExpanded && (
|
||||
<div className={styles.expanded}>
|
||||
{views.map((view, index) => {
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
href={view.url}
|
||||
onClick={() => {
|
||||
store.setObject('CLICKING_HISTORY', true);
|
||||
onClick();
|
||||
}}
|
||||
isCompact={true}
|
||||
className={view.time === selectedViewTime ? undefined : styles.card}
|
||||
>
|
||||
<Stack direction="column" gap={0}>
|
||||
<Text variant="bodySmall">{view.name}</Text>
|
||||
{view.description && (
|
||||
<Text color="secondary" variant="bodySmall">
|
||||
{view.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
card: css({
|
||||
background: 'none',
|
||||
}),
|
||||
iconButton: css({
|
||||
margin: 0,
|
||||
}),
|
||||
expanded: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginLeft: theme.spacing(5),
|
||||
gap: theme.spacing(1),
|
||||
position: 'relative',
|
||||
'&:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: theme.spacing(-2),
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: '1px',
|
||||
background: theme.colors.border.weak,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { useSelector } from 'app/types';
|
||||
import { Branding } from '../../Branding/Branding';
|
||||
import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs';
|
||||
import { buildBreadcrumbs } from '../../Breadcrumbs/utils';
|
||||
import { HistoryContainer } from '../History/HistoryContainer';
|
||||
import { enrichHelpItem } from '../MegaMenu/utils';
|
||||
import { NewsContainer } from '../News/NewsContainer';
|
||||
import { QuickAdd } from '../QuickAdd/QuickAdd';
|
||||
@@ -49,6 +50,7 @@ export const SingleTopBar = memo(function SingleTopBar({
|
||||
const profileNode = navIndex['profile'];
|
||||
const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID];
|
||||
const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav);
|
||||
const unifiedHistoryEnabled = config.featureToggles.unifiedHistory;
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
@@ -72,6 +74,7 @@ export const SingleTopBar = memo(function SingleTopBar({
|
||||
<Stack gap={0.5} alignItems="center">
|
||||
<TopSearchBarCommandPaletteTrigger />
|
||||
<QuickAdd />
|
||||
{unifiedHistoryEnabled && <HistoryContainer />}
|
||||
{enrichedHelpNode && (
|
||||
<Dropdown overlay={() => <TopNavBarMenu node={enrichedHelpNode} />} placement="bottom-end">
|
||||
<ToolbarButton iconOnly icon="question-circle" aria-label="Help" />
|
||||
|
||||
@@ -5,3 +5,28 @@ export interface ToolbarUpdateProps {
|
||||
pageNav?: NavModelItem;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface HistoryEntryView {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface HistoryEntrySparkline {
|
||||
values: number[];
|
||||
range: {
|
||||
min: number;
|
||||
max: number;
|
||||
delta: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
name: string;
|
||||
time: number;
|
||||
breadcrumbs: NavModelItem[];
|
||||
url: string;
|
||||
views: HistoryEntryView[];
|
||||
sparklineData?: HistoryEntrySparkline;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import { NavModelItem } from '@grafana/data';
|
||||
|
||||
import { Breadcrumb } from './types';
|
||||
|
||||
export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelItem, homeNav?: NavModelItem) {
|
||||
export function buildBreadcrumbs(
|
||||
sectionNav: NavModelItem,
|
||||
pageNav?: NavModelItem,
|
||||
homeNav?: NavModelItem,
|
||||
skipHome?: boolean
|
||||
) {
|
||||
const crumbs: Breadcrumb[] = [];
|
||||
let foundHome = false;
|
||||
let lastPath: string | undefined = undefined;
|
||||
@@ -22,7 +27,9 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte
|
||||
|
||||
// Check if we found home/root if if so return early
|
||||
if (homeNav && urlToMatch === homeNav.url) {
|
||||
crumbs.unshift({ text: homeNav.text, href: node.url ?? '' });
|
||||
if (!skipHome) {
|
||||
crumbs.unshift({ text: homeNav.text, href: node.url ?? '' });
|
||||
}
|
||||
foundHome = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
|
||||
"axes": [
|
||||
{
|
||||
"filter": undefined,
|
||||
"font": "12px "Inter", "Helvetica", "Arial", sans-serif",
|
||||
"font": "12px 'Inter', 'Helvetica', 'Arial', sans-serif",
|
||||
"gap": 5,
|
||||
"grid": {
|
||||
"show": true,
|
||||
@@ -33,7 +33,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
|
||||
},
|
||||
{
|
||||
"filter": undefined,
|
||||
"font": "12px "Inter", "Helvetica", "Arial", sans-serif",
|
||||
"font": "12px 'Inter', 'Helvetica', 'Arial', sans-serif",
|
||||
"gap": 5,
|
||||
"grid": {
|
||||
"show": true,
|
||||
|
||||
+9
-9
@@ -11,7 +11,7 @@ exports[`getThresholdsForQueries should work for classic_condition 1`] = `
|
||||
"value": -Infinity,
|
||||
},
|
||||
{
|
||||
"color": "#D10E5C",
|
||||
"color": "#d10e5c",
|
||||
"value": 0,
|
||||
},
|
||||
],
|
||||
@@ -32,7 +32,7 @@ exports[`getThresholdsForQueries should work for lt and gt 1`] = `
|
||||
"value": -Infinity,
|
||||
},
|
||||
{
|
||||
"color": "#D10E5C",
|
||||
"color": "#d10e5c",
|
||||
"value": 0,
|
||||
},
|
||||
],
|
||||
@@ -53,7 +53,7 @@ exports[`getThresholdsForQueries should work for lt and gt 2`] = `
|
||||
"value": -Infinity,
|
||||
},
|
||||
{
|
||||
"color": "#D10E5C",
|
||||
"color": "#d10e5c",
|
||||
"value": 0,
|
||||
},
|
||||
],
|
||||
@@ -70,11 +70,11 @@ exports[`getThresholdsForQueries should work for outside_range 1`] = `
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "#D10E5C",
|
||||
"color": "#d10e5c",
|
||||
"value": -Infinity,
|
||||
},
|
||||
{
|
||||
"color": "#D10E5C",
|
||||
"color": "#d10e5c",
|
||||
"value": 0,
|
||||
},
|
||||
{
|
||||
@@ -82,7 +82,7 @@ exports[`getThresholdsForQueries should work for outside_range 1`] = `
|
||||
"value": 0,
|
||||
},
|
||||
{
|
||||
"color": "#D10E5C",
|
||||
"color": "#d10e5c",
|
||||
"value": 10,
|
||||
},
|
||||
],
|
||||
@@ -103,7 +103,7 @@ exports[`getThresholdsForQueries should work for threshold condition 1`] = `
|
||||
"value": -Infinity,
|
||||
},
|
||||
{
|
||||
"color": "#D10E5C",
|
||||
"color": "#d10e5c",
|
||||
"value": 0,
|
||||
},
|
||||
],
|
||||
@@ -124,11 +124,11 @@ exports[`getThresholdsForQueries should work for within_range 1`] = `
|
||||
"value": -Infinity,
|
||||
},
|
||||
{
|
||||
"color": "#D10E5C",
|
||||
"color": "#d10e5c",
|
||||
"value": 0,
|
||||
},
|
||||
{
|
||||
"color": "#D10E5C",
|
||||
"color": "#d10e5c",
|
||||
"value": 10,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
import { AdHocVariableModel } from '@grafana/data';
|
||||
import { Dashboard, Panel } from '@grafana/schema';
|
||||
|
||||
import { adHocVariableFiltersEqual, getRawDashboardChanges, getPanelChanges } from './getDashboardChanges';
|
||||
|
||||
describe('adHocVariableFiltersEqual', () => {
|
||||
it('should compare empty filters', () => {
|
||||
expect(
|
||||
adHocVariableFiltersEqual(
|
||||
{ filters: [] } as unknown as AdHocVariableModel,
|
||||
{ filters: [] } as unknown as AdHocVariableModel
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(adHocVariableFiltersEqual([], [])).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should compare different length filter arrays', () => {
|
||||
expect(
|
||||
adHocVariableFiltersEqual(
|
||||
{ filters: [] } as unknown as AdHocVariableModel,
|
||||
{ filters: [{ value: '', key: '', operator: '' }] } as unknown as AdHocVariableModel
|
||||
)
|
||||
).toBeFalsy();
|
||||
expect(adHocVariableFiltersEqual([], [{ value: '', key: '', operator: '' }])).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should compare equal filter arrays', () => {
|
||||
expect(
|
||||
adHocVariableFiltersEqual(
|
||||
{ filters: [{ value: 'asd', key: 'qwe', operator: 'wer' }] } as unknown as AdHocVariableModel,
|
||||
{ filters: [{ value: 'asd', key: 'qwe', operator: 'wer' }] } as unknown as AdHocVariableModel
|
||||
[{ value: 'asd', key: 'qwe', operator: 'wer' }],
|
||||
[{ value: 'asd', key: 'qwe', operator: 'wer' }]
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
@@ -34,8 +23,8 @@ describe('adHocVariableFiltersEqual', () => {
|
||||
it('should compare different filter arrays where operator differs', () => {
|
||||
expect(
|
||||
adHocVariableFiltersEqual(
|
||||
{ filters: [{ value: 'asd', key: 'qwe', operator: 'wer' }] } as unknown as AdHocVariableModel,
|
||||
{ filters: [{ value: 'asd', key: 'qwe', operator: 'weee' }] } as unknown as AdHocVariableModel
|
||||
[{ value: 'asd', key: 'qwe', operator: 'wer' }],
|
||||
[{ value: 'asd', key: 'qwe', operator: 'weee' }]
|
||||
)
|
||||
).toBeFalsy();
|
||||
});
|
||||
@@ -43,8 +32,8 @@ describe('adHocVariableFiltersEqual', () => {
|
||||
it('should compare different filter arrays where key differs', () => {
|
||||
expect(
|
||||
adHocVariableFiltersEqual(
|
||||
{ filters: [{ value: 'asd', key: 'qwe', operator: 'wer' }] } as unknown as AdHocVariableModel,
|
||||
{ filters: [{ value: 'asd', key: 'qwer', operator: 'wer' }] } as unknown as AdHocVariableModel
|
||||
[{ value: 'asd', key: 'qwe', operator: 'wer' }],
|
||||
[{ value: 'asd', key: 'qwer', operator: 'wer' }]
|
||||
)
|
||||
).toBeFalsy();
|
||||
});
|
||||
@@ -52,8 +41,8 @@ describe('adHocVariableFiltersEqual', () => {
|
||||
it('should compare different filter arrays where value differs', () => {
|
||||
expect(
|
||||
adHocVariableFiltersEqual(
|
||||
{ filters: [{ value: 'asd', key: 'qwe', operator: 'wer' }] } as unknown as AdHocVariableModel,
|
||||
{ filters: [{ value: 'asdio', key: 'qwe', operator: 'wer' }] } as unknown as AdHocVariableModel
|
||||
[{ value: 'asd', key: 'qwe', operator: 'wer' }],
|
||||
[{ value: 'asdio', key: 'qwe', operator: 'wer' }]
|
||||
)
|
||||
).toBeFalsy();
|
||||
});
|
||||
@@ -65,23 +54,14 @@ describe('adHocVariableFiltersEqual', () => {
|
||||
|
||||
it('should compare two adhoc variables where both are missing the filter property and return true', () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementationOnce(() => {});
|
||||
expect(
|
||||
adHocVariableFiltersEqual({} as unknown as AdHocVariableModel, {} as unknown as AdHocVariableModel)
|
||||
).toBeTruthy();
|
||||
expect(adHocVariableFiltersEqual(undefined, undefined)).toBeTruthy();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Adhoc variable filter property is undefined');
|
||||
});
|
||||
|
||||
it('should compare two adhoc variables where one has no filter property and return false', () => {
|
||||
it('should compare two adhoc variables where one is undefined and return false', () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementationOnce(() => {});
|
||||
expect(
|
||||
adHocVariableFiltersEqual(
|
||||
{} as unknown as AdHocVariableModel,
|
||||
{
|
||||
filters: [{ value: 'asdio', key: 'qwe', operator: 'wer' }],
|
||||
} as unknown as AdHocVariableModel
|
||||
)
|
||||
).toBeFalsy();
|
||||
expect(adHocVariableFiltersEqual(undefined, [{ value: 'asdio', key: 'qwe', operator: 'wer' }])).toBeFalsy();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Adhoc variable filter property is undefined');
|
||||
});
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import type { AdHocVariableModel, TypedVariableModel } from '@grafana/data';
|
||||
import { Dashboard, Panel, VariableOption } from '@grafana/schema';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import {
|
||||
AdHocFilterWithLabels,
|
||||
AdhocVariableSpec,
|
||||
DashboardV2Spec,
|
||||
VariableKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
|
||||
import { jsonDiff } from '../settings/version-history/utils';
|
||||
|
||||
@@ -30,7 +35,6 @@ export function isEqual(a: VariableOption | undefined, b: VariableOption | undef
|
||||
return a === b || (a && b && a.selected === b.selected && deepEqual(a.text, b.text) && deepEqual(a.value, b.value));
|
||||
}
|
||||
|
||||
// TODO[schema v2]
|
||||
export function getRawDashboardV2Changes(
|
||||
initial: DashboardV2Spec,
|
||||
changed: DashboardV2Spec,
|
||||
@@ -38,16 +42,33 @@ export function getRawDashboardV2Changes(
|
||||
saveVariables?: boolean,
|
||||
saveRefresh?: boolean
|
||||
) {
|
||||
const initialSaveModel = initial;
|
||||
const changedSaveModel = changed;
|
||||
const hasTimeChanged = getHasTimeChanged(changedSaveModel.timeSettings, initialSaveModel.timeSettings);
|
||||
const hasVariableValueChanges = applyVariableChangesV2(changedSaveModel, initialSaveModel, saveVariables);
|
||||
const hasRefreshChanged = changedSaveModel.timeSettings.autoRefresh !== initialSaveModel.timeSettings.autoRefresh;
|
||||
|
||||
if (!saveTimeRange) {
|
||||
changedSaveModel.timeSettings.from = initialSaveModel.timeSettings.from;
|
||||
changedSaveModel.timeSettings.to = initialSaveModel.timeSettings.to;
|
||||
}
|
||||
|
||||
if (!saveRefresh) {
|
||||
changedSaveModel.timeSettings.autoRefresh = initialSaveModel.timeSettings.autoRefresh;
|
||||
}
|
||||
|
||||
const diff = jsonDiff(initialSaveModel, changedSaveModel);
|
||||
const diffCount = Object.values(diff).reduce((acc, cur) => acc + cur.length, 0);
|
||||
|
||||
return {
|
||||
changedSaveModel: changed,
|
||||
initialSaveModel: initial,
|
||||
diffs: jsonDiff(initial, changed),
|
||||
diffCount: 0,
|
||||
hasChanges: false,
|
||||
hasTimeChanges: false,
|
||||
isNew: false,
|
||||
hasVariableValueChanges: false,
|
||||
hasRefreshChange: false,
|
||||
changedSaveModel,
|
||||
initialSaveModel,
|
||||
diffs: diff,
|
||||
diffCount,
|
||||
hasChanges: diffCount > 0,
|
||||
hasTimeChanges: hasTimeChanged,
|
||||
hasVariableValueChanges,
|
||||
hasRefreshChange: hasRefreshChanged,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,7 +81,7 @@ export function getRawDashboardChanges(
|
||||
) {
|
||||
const initialSaveModel = initial;
|
||||
const changedSaveModel = changed;
|
||||
const hasTimeChanged = getHasTimeChanged(changedSaveModel, initialSaveModel);
|
||||
const hasTimeChanged = getHasTimeChanged(changedSaveModel.time, initialSaveModel.time);
|
||||
const hasVariableValueChanges = applyVariableChanges(changedSaveModel, initialSaveModel, saveVariables);
|
||||
const hasRefreshChanged = changedSaveModel.refresh !== initialSaveModel.refresh;
|
||||
|
||||
@@ -88,35 +109,107 @@ export function getRawDashboardChanges(
|
||||
};
|
||||
}
|
||||
|
||||
export function getHasTimeChanged(saveModel: Dashboard, originalSaveModel: Dashboard) {
|
||||
return saveModel.time?.from !== originalSaveModel.time?.from || saveModel.time?.to !== originalSaveModel.time?.to;
|
||||
interface DefaultPersistedTimeValue {
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
export function getHasTimeChanged(
|
||||
newRange: DefaultPersistedTimeValue = {},
|
||||
previousRange: DefaultPersistedTimeValue = {}
|
||||
) {
|
||||
return newRange.from !== previousRange.from || newRange.to !== previousRange.to;
|
||||
}
|
||||
|
||||
export function adHocVariableFiltersEqual(a: AdHocVariableModel, b: AdHocVariableModel) {
|
||||
if (a.filters === undefined && b.filters === undefined) {
|
||||
export function adHocVariableFiltersEqual(filtersA?: AdHocFilterWithLabels[], filtersB?: AdHocFilterWithLabels[]) {
|
||||
if (filtersA === undefined && filtersB === undefined) {
|
||||
console.warn('Adhoc variable filter property is undefined');
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((a.filters === undefined && b.filters !== undefined) || (b.filters === undefined && a.filters !== undefined)) {
|
||||
if ((filtersA === undefined && filtersB !== undefined) || (filtersB === undefined && filtersA !== undefined)) {
|
||||
console.warn('Adhoc variable filter property is undefined');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (a.filters.length !== b.filters.length) {
|
||||
if (filtersA?.length !== filtersB?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < a.filters.length; i++) {
|
||||
const aFilter = a.filters[i];
|
||||
const bFilter = b.filters[i];
|
||||
if (aFilter.key !== bFilter.key || aFilter.operator !== bFilter.operator || aFilter.value !== bFilter.value) {
|
||||
for (let i = 0; i < (filtersA?.length ?? 0); i++) {
|
||||
const aFilter = filtersA?.[i];
|
||||
const bFilter = filtersB?.[i];
|
||||
if (aFilter?.key !== bFilter?.key || aFilter?.operator !== bFilter?.operator || aFilter?.value !== bFilter?.value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function applyVariableChangesV2(
|
||||
saveModel: DashboardV2Spec,
|
||||
originalSaveModel: DashboardV2Spec,
|
||||
saveVariables?: boolean
|
||||
) {
|
||||
const originalVariables = originalSaveModel.variables ?? [];
|
||||
const variablesToSave = saveModel.variables ?? [];
|
||||
let hasVariableValueChanges = false;
|
||||
|
||||
for (const variable of variablesToSave) {
|
||||
const hasCurrentValueToSave = (v: VariableKind) =>
|
||||
v.kind === 'QueryVariable' ||
|
||||
v.kind === 'CustomVariable' ||
|
||||
v.kind === 'DatasourceVariable' ||
|
||||
v.kind === 'ConstantVariable' ||
|
||||
v.kind === 'IntervalVariable' ||
|
||||
v.kind === 'TextVariable' ||
|
||||
v.kind === 'GroupByVariable';
|
||||
|
||||
const hasOptionsToSave = (v: VariableKind) =>
|
||||
v.kind === 'QueryVariable' ||
|
||||
v.kind === 'CustomVariable' ||
|
||||
v.kind === 'DatasourceVariable' ||
|
||||
v.kind === 'IntervalVariable' ||
|
||||
v.kind === 'GroupByVariable';
|
||||
|
||||
const original = originalVariables.find(
|
||||
({ spec, kind }) => spec.name === variable.spec.name && kind === variable.kind
|
||||
);
|
||||
|
||||
if (!original) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
hasCurrentValueToSave(variable) &&
|
||||
hasCurrentValueToSave(original) &&
|
||||
!isEqual(variable.spec.current, original.spec.current)
|
||||
) {
|
||||
hasVariableValueChanges = true;
|
||||
} else if (
|
||||
variable.kind === 'AdhocVariable' &&
|
||||
original.kind === 'AdhocVariable' &&
|
||||
!adHocVariableFiltersEqual(variable.spec.filters, original.spec.filters)
|
||||
) {
|
||||
hasVariableValueChanges = true;
|
||||
}
|
||||
|
||||
if (!saveVariables) {
|
||||
if (variable.kind === 'AdhocVariable') {
|
||||
variable.spec.filters = (original.spec as AdhocVariableSpec).filters;
|
||||
} else {
|
||||
if (hasCurrentValueToSave(variable) && hasCurrentValueToSave(original)) {
|
||||
variable.spec.current = original.spec.current;
|
||||
}
|
||||
if (hasOptionsToSave(variable) && hasOptionsToSave(original)) {
|
||||
variable.spec.options = original.spec.options;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasVariableValueChanges;
|
||||
}
|
||||
|
||||
export function applyVariableChanges(saveModel: Dashboard, originalSaveModel: Dashboard, saveVariables?: boolean) {
|
||||
const originalVariables = originalSaveModel.templating?.list ?? [];
|
||||
const variablesToSave = saveModel.templating?.list ?? [];
|
||||
@@ -138,7 +231,10 @@ export function applyVariableChanges(saveModel: Dashboard, originalSaveModel: Da
|
||||
hasVariableValueChanges = true;
|
||||
} else if (
|
||||
variable.type === 'adhoc' &&
|
||||
!adHocVariableFiltersEqual(variable as AdHocVariableModel, original as AdHocVariableModel)
|
||||
!adHocVariableFiltersEqual(
|
||||
(variable as AdHocVariableModel | undefined)?.filters,
|
||||
(original as AdHocVariableModel | undefined)?.filters
|
||||
)
|
||||
) {
|
||||
hasVariableValueChanges = true;
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
private _prevScrollPos?: number;
|
||||
|
||||
// TODO: use feature toggle to allow v2 serializer
|
||||
private _serializer: DashboardSceneSerializerLike<Dashboard | DashboardV2Spec> = getDashboardSceneSerializer(true);
|
||||
private _serializer: DashboardSceneSerializerLike<Dashboard | DashboardV2Spec> = getDashboardSceneSerializer();
|
||||
|
||||
public constructor(state: Partial<DashboardSceneState>) {
|
||||
super({
|
||||
|
||||
@@ -637,10 +637,10 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
// Will open a schema v2 editor drawer. Only available with dashboardSchemaV2 feature toggle on.
|
||||
// Will open a schema v2 editor drawer. Only available with useV2DashboardsAPI feature toggle on.
|
||||
toolbarActions.push({
|
||||
group: 'main-buttons',
|
||||
condition: uid && config.featureToggles.dashboardSchemaV2,
|
||||
condition: uid && config.featureToggles.useV2DashboardsAPI,
|
||||
render: () => {
|
||||
return (
|
||||
<ToolbarButton
|
||||
|
||||
@@ -19,7 +19,7 @@ import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { getMessageFromError } from 'app/core/utils/errors';
|
||||
import { getCreateAlertInMenuAvailability } from 'app/features/alerting/unified/utils/access-control';
|
||||
import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form';
|
||||
import { shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
|
||||
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
|
||||
import { InspectTab } from 'app/features/inspector/types';
|
||||
import { getScenePanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
|
||||
import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils';
|
||||
@@ -90,6 +90,11 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) {
|
||||
iconClassName: 'link',
|
||||
shortcut: 'p u',
|
||||
onClick: () => {
|
||||
DashboardInteractions.sharingCategoryClicked({
|
||||
item: shareDashboardType.link,
|
||||
shareResource: getTrackingSource(panel?.getRef()),
|
||||
});
|
||||
|
||||
const drawer = new ShareDrawer({
|
||||
shareView: shareDashboardType.link,
|
||||
panelRef: panel.getRef(),
|
||||
@@ -103,6 +108,11 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) {
|
||||
iconClassName: 'arrow',
|
||||
shortcut: 'p e',
|
||||
onClick: () => {
|
||||
DashboardInteractions.sharingCategoryClicked({
|
||||
item: shareDashboardType.embed,
|
||||
shareResource: getTrackingSource(panel.getRef()),
|
||||
});
|
||||
|
||||
const drawer = new ShareDrawer({
|
||||
shareView: shareDashboardType.embed,
|
||||
panelRef: panel.getRef(),
|
||||
@@ -122,6 +132,11 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) {
|
||||
iconClassName: 'camera',
|
||||
shortcut: 'p s',
|
||||
onClick: () => {
|
||||
DashboardInteractions.sharingCategoryClicked({
|
||||
item: shareDashboardType.snapshot,
|
||||
shareResource: getTrackingSource(panel.getRef()),
|
||||
});
|
||||
|
||||
const drawer = new ShareDrawer({
|
||||
shareView: shareDashboardType.snapshot,
|
||||
panelRef: panel.getRef(),
|
||||
|
||||
+322
-17
@@ -7,7 +7,12 @@ import {
|
||||
SceneRefreshPicker,
|
||||
} from '@grafana/scenes';
|
||||
import { Dashboard, VariableModel } from '@grafana/schema';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import {
|
||||
DashboardV2Spec,
|
||||
defaultDashboardV2Spec,
|
||||
defaultPanelSpec,
|
||||
defaultTimeSettingsSpec,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
|
||||
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
|
||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
@@ -15,9 +20,24 @@ import { transformSceneToSaveModel } from '../serialization/transformSceneToSave
|
||||
import { findVizPanelByKey } from '../utils/utils';
|
||||
|
||||
import { V1DashboardSerializer, V2DashboardSerializer } from './DashboardSceneSerializer';
|
||||
import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene';
|
||||
import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getDataSourceSrv: () => {
|
||||
return {
|
||||
getInstanceSettings: jest.fn(),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DashboardSceneSerializer', () => {
|
||||
describe('v1 schema', () => {
|
||||
beforeEach(() => {
|
||||
config.featureToggles.useV2DashboardsAPI = false;
|
||||
});
|
||||
|
||||
it('Can detect no changes', () => {
|
||||
const dashboard = setup();
|
||||
const result = dashboard.getDashboardChanges(false);
|
||||
@@ -336,18 +356,226 @@ describe('DashboardSceneSerializer', () => {
|
||||
});
|
||||
|
||||
describe('v2 schema', () => {
|
||||
beforeEach(() => {
|
||||
config.featureToggles.useV2DashboardsAPI = true;
|
||||
});
|
||||
|
||||
it('Can detect no changes', () => {
|
||||
const dashboard = setupV2();
|
||||
const result = dashboard.getDashboardChanges(false);
|
||||
expect(result.hasChanges).toBe(false);
|
||||
expect(result.diffCount).toBe(0);
|
||||
});
|
||||
|
||||
it('Can detect time changed', () => {
|
||||
const dashboard = setupV2();
|
||||
|
||||
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-10h', to: 'now' });
|
||||
|
||||
const result = dashboard.getDashboardChanges(false);
|
||||
expect(result.hasChanges).toBe(false);
|
||||
expect(result.diffCount).toBe(0);
|
||||
expect(result.hasTimeChanges).toBe(true);
|
||||
});
|
||||
|
||||
it('Can save time change', () => {
|
||||
const dashboard = setupV2();
|
||||
|
||||
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-10h', to: 'now' });
|
||||
|
||||
const result = dashboard.getDashboardChanges(true);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(1);
|
||||
});
|
||||
|
||||
it('Can detect folder change', () => {
|
||||
const dashboard = setupV2();
|
||||
|
||||
dashboard.state.meta.folderUid = 'folder-2';
|
||||
|
||||
const result = dashboard.getDashboardChanges(false);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(0); // Diff count is 0 because the diff contemplate only the model
|
||||
expect(result.hasFolderChanges).toBe(true);
|
||||
});
|
||||
|
||||
it('Can detect refresh changed', () => {
|
||||
const dashboard = setupV2();
|
||||
|
||||
const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker);
|
||||
if (refreshPicker instanceof SceneRefreshPicker) {
|
||||
refreshPicker.setState({ refresh: '10m' });
|
||||
}
|
||||
|
||||
const result = dashboard.getDashboardChanges(false, false, false);
|
||||
expect(result.hasChanges).toBe(false);
|
||||
expect(result.diffCount).toBe(0);
|
||||
expect(result.hasRefreshChange).toBe(true);
|
||||
});
|
||||
|
||||
it('Can save refresh change', () => {
|
||||
const dashboard = setupV2();
|
||||
|
||||
const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker);
|
||||
if (refreshPicker instanceof SceneRefreshPicker) {
|
||||
refreshPicker.setState({ refresh: '10m' });
|
||||
}
|
||||
|
||||
const result = dashboard.getDashboardChanges(false, false, true);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(1);
|
||||
});
|
||||
|
||||
describe('variable changes', () => {
|
||||
it('Can detect variable change', () => {
|
||||
const dashboard = setupV2();
|
||||
|
||||
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
|
||||
appVar.changeValueTo('app2');
|
||||
|
||||
const result = dashboard.getDashboardChanges(false, false);
|
||||
|
||||
expect(result.hasVariableValueChanges).toBe(true);
|
||||
expect(result.hasChanges).toBe(false);
|
||||
expect(result.diffCount).toBe(0);
|
||||
});
|
||||
|
||||
it('Can save variable value change', () => {
|
||||
const dashboard = setupV2();
|
||||
|
||||
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
|
||||
appVar.changeValueTo('app2');
|
||||
|
||||
const result = dashboard.getDashboardChanges(false, true);
|
||||
|
||||
expect(result.hasVariableValueChanges).toBe(true);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(2);
|
||||
});
|
||||
|
||||
describe('Experimental variables', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.groupByVariable = true;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
config.featureToggles.groupByVariable = false;
|
||||
});
|
||||
|
||||
it('Can detect group by static options change', () => {
|
||||
const dashboard = setupV2({
|
||||
variables: [
|
||||
{
|
||||
kind: 'GroupByVariable',
|
||||
spec: {
|
||||
current: {
|
||||
text: 'Host',
|
||||
value: 'host',
|
||||
},
|
||||
datasource: {
|
||||
type: 'ds',
|
||||
uid: 'ds-uid',
|
||||
},
|
||||
name: 'GroupBy',
|
||||
options: [
|
||||
{
|
||||
text: 'Host',
|
||||
value: 'host',
|
||||
},
|
||||
{
|
||||
text: 'Region',
|
||||
value: 'region',
|
||||
},
|
||||
],
|
||||
multi: false,
|
||||
includeAll: false,
|
||||
hide: 'dontHide',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const variable = sceneGraph.lookupVariable('GroupBy', dashboard) as GroupByVariable;
|
||||
variable.setState({ defaultOptions: [{ text: 'Host', value: 'host' }] });
|
||||
const result = dashboard.getDashboardChanges(false, true);
|
||||
|
||||
expect(result.hasVariableValueChanges).toBe(false);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(1);
|
||||
});
|
||||
|
||||
it('Can detect adhoc filter static options change', () => {
|
||||
const dashboard = setupV2({
|
||||
variables: [
|
||||
{
|
||||
kind: 'AdhocVariable',
|
||||
spec: {
|
||||
name: 'adhoc',
|
||||
label: 'Adhoc Label',
|
||||
description: 'Adhoc Description',
|
||||
datasource: {
|
||||
uid: 'gdev-prometheus',
|
||||
type: 'prometheus',
|
||||
},
|
||||
hide: 'dontHide',
|
||||
skipUrlSync: false,
|
||||
filters: [],
|
||||
baseFilters: [],
|
||||
defaultKeys: [
|
||||
{
|
||||
text: 'Host',
|
||||
value: 'host',
|
||||
},
|
||||
{
|
||||
text: 'Region',
|
||||
value: 'region',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const variable = sceneGraph.lookupVariable('adhoc', dashboard) as AdHocFiltersVariable;
|
||||
variable.setState({ defaultKeys: [{ text: 'Host', value: 'host' }] });
|
||||
const result = dashboard.getDashboardChanges(false, false);
|
||||
|
||||
expect(result.hasVariableValueChanges).toBe(false);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Saving from panel edit', () => {
|
||||
it('Should commit panel edit changes', () => {
|
||||
const dashboard = setupV2();
|
||||
const panel = findVizPanelByKey(dashboard, 'panel-1')!;
|
||||
const editScene = buildPanelEditScene(panel);
|
||||
|
||||
dashboard.onEnterEditMode();
|
||||
dashboard.setState({ editPanel: editScene });
|
||||
|
||||
editScene.state.panelRef.resolve().setState({ title: 'changed title' });
|
||||
|
||||
const result = dashboard.getDashboardChanges(false, true);
|
||||
const panelSaveModel = (result.changedSaveModel as DashboardV2Spec).elements['panel-1'].spec;
|
||||
expect(panelSaveModel.title).toBe('changed title');
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw on getTrackingInformation', () => {
|
||||
const serializer = new V2DashboardSerializer();
|
||||
expect(() => serializer.getTrackingInformation()).toThrow('Method not implemented.');
|
||||
});
|
||||
|
||||
it('should throw on getSaveAsModel', () => {
|
||||
const serializer = new V2DashboardSerializer();
|
||||
const dashboard = setup();
|
||||
expect(() => serializer.getSaveAsModel(dashboard, {})).toThrow('Method not implemented.');
|
||||
});
|
||||
|
||||
it('should throw on getDashboardChangesFromScene', () => {
|
||||
const serializer = new V2DashboardSerializer();
|
||||
const dashboard = setup();
|
||||
expect(() => serializer.getDashboardChangesFromScene(dashboard)).toThrow('Method not implemented.');
|
||||
});
|
||||
|
||||
it('should throw on onSaveComplete', () => {
|
||||
const serializer = new V2DashboardSerializer();
|
||||
|
||||
@@ -363,11 +591,6 @@ describe('DashboardSceneSerializer', () => {
|
||||
).toThrow('Method not implemented.');
|
||||
});
|
||||
|
||||
it('should throw on getDashboardChangesFromScene', () => {
|
||||
const serializer = new V2DashboardSerializer();
|
||||
expect(() => serializer.getTrackingInformation()).toThrow('Method not implemented.');
|
||||
});
|
||||
|
||||
it('should throw on getSnapshotUrl', () => {
|
||||
const serializer = new V2DashboardSerializer();
|
||||
expect(() => serializer.getSnapshotUrl()).toThrow('Method not implemented.');
|
||||
@@ -375,11 +598,7 @@ describe('DashboardSceneSerializer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
interface ScenarioOptions {
|
||||
fromPanelEdit?: boolean;
|
||||
}
|
||||
|
||||
function setup(options: ScenarioOptions = {}) {
|
||||
function setup() {
|
||||
const dashboard = transformSaveModelToScene({
|
||||
dashboard: {
|
||||
title: 'hello',
|
||||
@@ -414,3 +633,89 @@ function setup(options: ScenarioOptions = {}) {
|
||||
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
function setupV2(spec?: Partial<DashboardV2Spec>) {
|
||||
const dashboard = transformSaveModelSchemaV2ToScene({
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
spec: {
|
||||
...defaultDashboardV2Spec(),
|
||||
title: 'hello',
|
||||
schemaVersion: 30,
|
||||
timeSettings: {
|
||||
...defaultTimeSettingsSpec(),
|
||||
autoRefresh: '10s',
|
||||
from: 'now-1h',
|
||||
to: 'now',
|
||||
},
|
||||
elements: {
|
||||
'panel-1': {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
...defaultPanelSpec(),
|
||||
id: 1,
|
||||
title: 'Panel 1',
|
||||
},
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
kind: 'GridLayout',
|
||||
spec: {
|
||||
items: [
|
||||
{
|
||||
kind: 'GridLayoutItem',
|
||||
spec: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 12,
|
||||
height: 8,
|
||||
element: {
|
||||
kind: 'ElementReference',
|
||||
name: 'panel-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
kind: 'CustomVariable',
|
||||
spec: {
|
||||
name: 'app',
|
||||
label: 'Query Variable',
|
||||
description: 'A query variable',
|
||||
skipUrlSync: false,
|
||||
hide: 'dontHide',
|
||||
options: [],
|
||||
multi: false,
|
||||
current: {
|
||||
text: 'app1',
|
||||
value: 'app1',
|
||||
},
|
||||
query: 'app1',
|
||||
allValue: '',
|
||||
includeAll: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
...spec,
|
||||
},
|
||||
apiVersion: 'v1',
|
||||
metadata: {
|
||||
name: 'dashboard-test',
|
||||
resourceVersion: '1',
|
||||
creationTimestamp: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
access: {
|
||||
canEdit: true,
|
||||
canSave: true,
|
||||
canStar: true,
|
||||
canShare: true,
|
||||
},
|
||||
});
|
||||
|
||||
const initialSaveModel = transformSceneToSaveModelSchemaV2(dashboard);
|
||||
dashboard.setInitialSaveModel(initialSaveModel);
|
||||
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDa
|
||||
import { getV1SchemaPanelCounts, getV1SchemaVariables } from 'app/features/dashboard/utils/tracking';
|
||||
import { SaveDashboardResponseDTO } from 'app/types';
|
||||
|
||||
import { getRawDashboardChanges } from '../saving/getDashboardChanges';
|
||||
import { getRawDashboardChanges, getRawDashboardV2Changes } from '../saving/getDashboardChanges';
|
||||
import { DashboardChangeInfo } from '../saving/shared';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
|
||||
@@ -131,10 +131,28 @@ export class V2DashboardSerializer implements DashboardSceneSerializerLike<Dashb
|
||||
return {} as DashboardV2Spec;
|
||||
}
|
||||
|
||||
getDashboardChangesFromScene(scene: DashboardScene) {
|
||||
throw new Error('v2 schema: Method not implemented.');
|
||||
// eslint-disable-next-line
|
||||
return {} as DashboardChangeInfo;
|
||||
getDashboardChangesFromScene(
|
||||
scene: DashboardScene,
|
||||
options: { saveTimeRange?: boolean; saveVariables?: boolean; saveRefresh?: boolean }
|
||||
) {
|
||||
const changedSaveModel = this.getSaveModel(scene);
|
||||
const changeInfo = getRawDashboardV2Changes(
|
||||
this.initialSaveModel!,
|
||||
changedSaveModel,
|
||||
options.saveTimeRange,
|
||||
options.saveVariables,
|
||||
options.saveRefresh
|
||||
);
|
||||
|
||||
const hasFolderChanges = scene.getInitialState()?.meta.folderUid !== scene.state.meta.folderUid;
|
||||
const isNew = scene.getInitialState()?.meta.isNew;
|
||||
|
||||
return {
|
||||
...changeInfo,
|
||||
hasFolderChanges,
|
||||
hasChanges: changeInfo.hasChanges || hasFolderChanges,
|
||||
isNew,
|
||||
};
|
||||
}
|
||||
|
||||
onSaveComplete(saveModel: DashboardV2Spec, result: SaveDashboardResponseDTO): void {
|
||||
@@ -152,14 +170,7 @@ export class V2DashboardSerializer implements DashboardSceneSerializerLike<Dashb
|
||||
}
|
||||
}
|
||||
|
||||
export function getDashboardSceneSerializer(
|
||||
forceLegacy?: boolean
|
||||
): DashboardSceneSerializerLike<Dashboard | DashboardV2Spec> {
|
||||
// When we have end-to-end v2 API integration, this will be controlled by a feature toggle, no need for forceLegacy
|
||||
if (forceLegacy) {
|
||||
return new V1DashboardSerializer();
|
||||
}
|
||||
|
||||
export function getDashboardSceneSerializer(): DashboardSceneSerializerLike<Dashboard | DashboardV2Spec> {
|
||||
if (config.featureToggles.useV2DashboardsAPI) {
|
||||
return new V2DashboardSerializer();
|
||||
}
|
||||
|
||||
+36
-34
@@ -114,11 +114,13 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
|
||||
// EOF layout
|
||||
};
|
||||
|
||||
if (isDashboardSchemaV2(dashboardSchemaV2)) {
|
||||
return dashboardSchemaV2;
|
||||
try {
|
||||
validateDashboardSchemaV2(dashboardSchemaV2);
|
||||
return dashboardSchemaV2 as DashboardV2Spec;
|
||||
} catch (reason) {
|
||||
console.error('Error transforming dashboard to schema v2: ' + reason, dashboardSchemaV2);
|
||||
throw new Error('Error transforming dashboard to schema v2: ' + reason);
|
||||
}
|
||||
console.error('Error transforming dashboard to schema v2');
|
||||
throw new Error('Error transforming dashboard to schema v2');
|
||||
}
|
||||
|
||||
function getCursorSync(state: DashboardSceneState) {
|
||||
@@ -436,7 +438,7 @@ function getDefaultDataSourceRef(): DataSourceRef | undefined {
|
||||
const defaultDatasource = config.bootData.settings.defaultDatasource;
|
||||
|
||||
// get default datasource type
|
||||
const dsList = config.bootData.settings.datasources;
|
||||
const dsList = config.bootData.settings.datasources ?? {};
|
||||
const ds = dsList[defaultDatasource];
|
||||
|
||||
if (ds) {
|
||||
@@ -447,98 +449,98 @@ function getDefaultDataSourceRef(): DataSourceRef | undefined {
|
||||
}
|
||||
|
||||
// Function to know if the dashboard transformed is a valid DashboardV2Spec
|
||||
function isDashboardSchemaV2(dash: any): dash is DashboardV2Spec {
|
||||
function validateDashboardSchemaV2(dash: any): dash is DashboardV2Spec {
|
||||
if (typeof dash !== 'object' || dash === null) {
|
||||
return false;
|
||||
throw new Error('Dashboard is not an object or is null');
|
||||
}
|
||||
|
||||
if (typeof dash.title !== 'string') {
|
||||
return false;
|
||||
throw new Error('Title is not a string');
|
||||
}
|
||||
if (typeof dash.description !== 'string') {
|
||||
return false;
|
||||
throw new Error('Description is not a string');
|
||||
}
|
||||
if (typeof dash.cursorSync !== 'string') {
|
||||
return false;
|
||||
throw new Error('CursorSync is not a string');
|
||||
}
|
||||
if (typeof dash.liveNow !== 'boolean') {
|
||||
return false;
|
||||
throw new Error('LiveNow is not a boolean');
|
||||
}
|
||||
if (typeof dash.preload !== 'boolean') {
|
||||
return false;
|
||||
throw new Error('Preload is not a boolean');
|
||||
}
|
||||
if (typeof dash.editable !== 'boolean') {
|
||||
return false;
|
||||
throw new Error('Editable is not a boolean');
|
||||
}
|
||||
if (!Array.isArray(dash.links)) {
|
||||
return false;
|
||||
throw new Error('Links is not an array');
|
||||
}
|
||||
if (!Array.isArray(dash.tags)) {
|
||||
return false;
|
||||
throw new Error('Tags is not an array');
|
||||
}
|
||||
|
||||
if (dash.id !== undefined && typeof dash.id !== 'number') {
|
||||
return false;
|
||||
throw new Error('ID is not a number');
|
||||
}
|
||||
|
||||
// Time settings
|
||||
if (typeof dash.timeSettings !== 'object' || dash.timeSettings === null) {
|
||||
return false;
|
||||
throw new Error('TimeSettings is not an object or is null');
|
||||
}
|
||||
if (typeof dash.timeSettings.timezone !== 'string') {
|
||||
return false;
|
||||
throw new Error('Timezone is not a string');
|
||||
}
|
||||
if (typeof dash.timeSettings.from !== 'string') {
|
||||
return false;
|
||||
throw new Error('From is not a string');
|
||||
}
|
||||
if (typeof dash.timeSettings.to !== 'string') {
|
||||
return false;
|
||||
throw new Error('To is not a string');
|
||||
}
|
||||
if (typeof dash.timeSettings.autoRefresh !== 'string') {
|
||||
return false;
|
||||
throw new Error('AutoRefresh is not a string');
|
||||
}
|
||||
if (!Array.isArray(dash.timeSettings.autoRefreshIntervals)) {
|
||||
return false;
|
||||
throw new Error('AutoRefreshIntervals is not an array');
|
||||
}
|
||||
if (!Array.isArray(dash.timeSettings.quickRanges)) {
|
||||
return false;
|
||||
throw new Error('QuickRanges is not an array');
|
||||
}
|
||||
if (typeof dash.timeSettings.hideTimepicker !== 'boolean') {
|
||||
return false;
|
||||
throw new Error('HideTimepicker is not a boolean');
|
||||
}
|
||||
if (typeof dash.timeSettings.weekStart !== 'string') {
|
||||
return false;
|
||||
throw new Error('WeekStart is not a string');
|
||||
}
|
||||
if (typeof dash.timeSettings.fiscalYearStartMonth !== 'number') {
|
||||
return false;
|
||||
throw new Error('FiscalYearStartMonth is not a number');
|
||||
}
|
||||
if (dash.timeSettings.nowDelay !== undefined && typeof dash.timeSettings.nowDelay !== 'string') {
|
||||
return false;
|
||||
throw new Error('NowDelay is not a string');
|
||||
}
|
||||
|
||||
// Other sections
|
||||
if (!Array.isArray(dash.variables)) {
|
||||
return false;
|
||||
throw new Error('Variables is not an array');
|
||||
}
|
||||
if (typeof dash.elements !== 'object' || dash.elements === null) {
|
||||
return false;
|
||||
throw new Error('Elements is not an object or is null');
|
||||
}
|
||||
if (!Array.isArray(dash.annotations)) {
|
||||
return false;
|
||||
throw new Error('Annotations is not an array');
|
||||
}
|
||||
|
||||
// Layout
|
||||
if (typeof dash.layout !== 'object' || dash.layout === null) {
|
||||
return false;
|
||||
throw new Error('Layout is not an object or is null');
|
||||
}
|
||||
if (dash.layout.kind !== 'GridLayout') {
|
||||
return false;
|
||||
throw new Error('Layout kind is not GridLayout');
|
||||
}
|
||||
if (typeof dash.layout.spec !== 'object' || dash.layout.spec === null) {
|
||||
return false;
|
||||
throw new Error('Layout spec is not an object or is null');
|
||||
}
|
||||
if (!Array.isArray(dash.layout.spec.items)) {
|
||||
return false;
|
||||
throw new Error('Layout spec items is not an array');
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { dateTime } from '@grafana/data';
|
||||
import { dateTime, UrlQueryMap } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectRef, VizPanel, sceneGraph } from '@grafana/scenes';
|
||||
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes';
|
||||
import { TimeZone } from '@grafana/schema';
|
||||
import { Alert, ClipboardButton, Field, FieldSet, Icon, Input, Switch } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
@@ -14,6 +14,7 @@ import { getDashboardUrl } from '../utils/urlBuilders';
|
||||
import { getDashboardSceneFor } from '../utils/utils';
|
||||
|
||||
import { SceneShareTabState, ShareView } from './types';
|
||||
|
||||
export interface ShareLinkTabState extends SceneShareTabState, ShareOptions {
|
||||
panelRef?: SceneObjectRef<VizPanel>;
|
||||
}
|
||||
@@ -55,7 +56,7 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> implements
|
||||
this.onThemeChange = this.onThemeChange.bind(this);
|
||||
}
|
||||
|
||||
async buildUrl() {
|
||||
buildUrl = async (queryOptions?: UrlQueryMap) => {
|
||||
this.setState({ isBuildUrlLoading: true });
|
||||
const { panelRef, useLockedTime: useAbsoluteTimeRange, useShortUrl, selectedTheme } = this.state;
|
||||
const dashboard = getDashboardSceneFor(this);
|
||||
@@ -83,7 +84,7 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> implements
|
||||
const imageUrl = getDashboardUrl({
|
||||
uid: dashboard.state.uid,
|
||||
currentQueryParams: location.search,
|
||||
updateQuery: { ...urlParamsUpdate, panelId: panel?.state.key },
|
||||
updateQuery: { ...urlParamsUpdate, ...queryOptions, panelId: panel?.state.key },
|
||||
absolute: true,
|
||||
soloRoute: true,
|
||||
render: true,
|
||||
@@ -91,7 +92,7 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> implements
|
||||
});
|
||||
|
||||
this.setState({ shareUrl, imageUrl, isBuildUrlLoading: false });
|
||||
}
|
||||
};
|
||||
|
||||
public getTabLabel() {
|
||||
return t('share-modal.tab-title.link', 'Link');
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { config, setPluginImportUtils } from '@grafana/runtime';
|
||||
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
|
||||
|
||||
import { userEvent } from '../../../../../test/test-utils';
|
||||
import { DashboardScene } from '../../scene/DashboardScene';
|
||||
import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager';
|
||||
import { activateFullSceneTree } from '../../utils/test-utils';
|
||||
|
||||
import { SharePanelInternally } from './SharePanelInternally';
|
||||
|
||||
setPluginImportUtils({
|
||||
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
|
||||
getPanelPluginFromCache: (id: string) => undefined,
|
||||
});
|
||||
|
||||
const selector = e2eSelectors.pages.ShareDashboardDrawer.ShareInternally.SharePanel;
|
||||
|
||||
describe('SharePanelInternally', () => {
|
||||
it('should disable all image generation inputs when renderer is not available', async () => {
|
||||
config.rendererAvailable = false;
|
||||
buildAndRenderScenario();
|
||||
|
||||
expect(await screen.findByTestId(selector.preview)).toBeInTheDocument();
|
||||
[
|
||||
selector.widthInput,
|
||||
selector.heightInput,
|
||||
selector.scaleFactorInput,
|
||||
selector.generateImageButton,
|
||||
selector.downloadImageButton,
|
||||
].forEach((selector) => {
|
||||
expect(screen.getByTestId(selector)).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable all image generation inputs when renderer is available', async () => {
|
||||
config.rendererAvailable = true;
|
||||
buildAndRenderScenario();
|
||||
|
||||
expect(await screen.findByTestId(selector.preview)).toBeInTheDocument();
|
||||
[selector.widthInput, selector.heightInput, selector.scaleFactorInput].forEach((selector) => {
|
||||
expect(screen.getByTestId(selector)).toBeEnabled();
|
||||
});
|
||||
|
||||
await userEvent.type(screen.getByTestId(selector.widthInput), '1000');
|
||||
await userEvent.type(screen.getByTestId(selector.widthInput), '2000');
|
||||
expect(screen.getByTestId(selector.generateImageButton)).toBeEnabled();
|
||||
expect(screen.getByTestId(selector.downloadImageButton)).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
function buildAndRenderScenario() {
|
||||
const panel = new VizPanel({
|
||||
title: 'Panel A',
|
||||
pluginId: 'table',
|
||||
key: 'panel-12',
|
||||
});
|
||||
const tab = new SharePanelInternally({ panelRef: panel.getRef() });
|
||||
const scene = new DashboardScene({
|
||||
title: 'hello',
|
||||
uid: 'dash-1',
|
||||
meta: {
|
||||
canEdit: true,
|
||||
},
|
||||
$timeRange: new SceneTimeRange({}),
|
||||
body: DefaultGridLayoutManager.fromVizPanels([panel]),
|
||||
overlay: tab,
|
||||
});
|
||||
|
||||
activateFullSceneTree(scene);
|
||||
|
||||
render(<tab.Component model={tab} />);
|
||||
|
||||
return tab;
|
||||
}
|
||||
@@ -3,13 +3,15 @@ import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
import { Alert, ClipboardButton, Divider, LinkButton, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { Alert, ClipboardButton, Divider, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { getDashboardSceneFor } from '../../utils/utils';
|
||||
import ShareInternallyConfiguration from '../ShareInternallyConfiguration';
|
||||
import { ShareLinkTab, ShareLinkTabState } from '../ShareLinkTab';
|
||||
|
||||
import { SharePanelPreview } from './SharePanelPreview';
|
||||
|
||||
export class SharePanelInternally extends ShareLinkTab {
|
||||
static Component = SharePanelInternallyRenderer;
|
||||
|
||||
@@ -24,56 +26,44 @@ export class SharePanelInternally extends ShareLinkTab {
|
||||
|
||||
function SharePanelInternallyRenderer({ model }: SceneComponentProps<SharePanelInternally>) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { useLockedTime, useShortUrl, selectedTheme, isBuildUrlLoading, imageUrl } = model.useState();
|
||||
|
||||
const { useLockedTime, useShortUrl, selectedTheme, isBuildUrlLoading, imageUrl, panelRef } = model.useState();
|
||||
|
||||
const panelTitle = panelRef?.resolve().state.title;
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
const isDashboardSaved = Boolean(dashboard.state.uid);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.configDescription}>
|
||||
<Text variant="body">
|
||||
<Trans i18nKey="link.share-panel.config-description">
|
||||
Create a personalized, direct link to share your panel within your organization, with the following
|
||||
customization settings:
|
||||
</Trans>
|
||||
</Text>
|
||||
<div>
|
||||
<Text variant="body">
|
||||
<Trans i18nKey="link.share-panel.config-description">
|
||||
Create a personalized, direct link to share your panel within your organization, with the following
|
||||
customization settings:
|
||||
</Trans>
|
||||
</Text>
|
||||
<div className={styles.configurationContainer}>
|
||||
<ShareInternallyConfiguration
|
||||
useLockedTime={useLockedTime}
|
||||
onToggleLockedTime={model.onToggleLockedTime}
|
||||
useShortUrl={useShortUrl}
|
||||
onUrlShorten={model.onUrlShorten}
|
||||
selectedTheme={selectedTheme}
|
||||
onChangeTheme={model.onThemeChange}
|
||||
isLoading={isBuildUrlLoading}
|
||||
/>
|
||||
<ClipboardButton
|
||||
icon="link"
|
||||
variant="primary"
|
||||
fill="outline"
|
||||
disabled={isBuildUrlLoading}
|
||||
getText={model.getShareUrl}
|
||||
onClipboardCopy={model.onCopy}
|
||||
>
|
||||
<Trans i18nKey="link.share.copy-link-button">Copy link</Trans>
|
||||
</ClipboardButton>
|
||||
</div>
|
||||
<ShareInternallyConfiguration
|
||||
useLockedTime={useLockedTime}
|
||||
onToggleLockedTime={() => model.onToggleLockedTime()}
|
||||
useShortUrl={useShortUrl}
|
||||
onUrlShorten={() => model.onUrlShorten()}
|
||||
selectedTheme={selectedTheme}
|
||||
onChangeTheme={(t) => model.onThemeChange(t)}
|
||||
isLoading={isBuildUrlLoading}
|
||||
/>
|
||||
<Divider spacing={1} />
|
||||
<Divider spacing={2} />
|
||||
<Stack gap={2} direction="column">
|
||||
<div className={styles.buttonsContainer}>
|
||||
<Stack gap={1} flex={1} direction={{ xs: 'column', sm: 'row' }}>
|
||||
<ClipboardButton
|
||||
icon="link"
|
||||
variant="primary"
|
||||
fill="outline"
|
||||
disabled={isBuildUrlLoading}
|
||||
getText={model.getShareUrl}
|
||||
onClipboardCopy={model.onCopy}
|
||||
>
|
||||
<Trans i18nKey="link.share.copy-link-button">Copy link</Trans>
|
||||
</ClipboardButton>
|
||||
<LinkButton
|
||||
href={imageUrl}
|
||||
icon="external-link-alt"
|
||||
target="_blank"
|
||||
variant="secondary"
|
||||
fill="solid"
|
||||
disabled={!config.rendererAvailable || !isDashboardSaved}
|
||||
>
|
||||
<Trans i18nKey="link.share-panel.render-image">Render image</Trans>
|
||||
</LinkButton>
|
||||
</Stack>
|
||||
</div>
|
||||
{!isDashboardSaved && (
|
||||
<Alert severity="info" title={t('share-modal.link.save-alert', 'Dashboard is not saved')} bottomSpacing={0}>
|
||||
<Trans i18nKey="share-modal.link.save-dashboard">
|
||||
@@ -101,16 +91,20 @@ function SharePanelInternallyRenderer({ model }: SceneComponentProps<SharePanelI
|
||||
</Trans>
|
||||
</Alert>
|
||||
)}
|
||||
<SharePanelPreview
|
||||
title={panelTitle || ''}
|
||||
buildUrl={model.buildUrl}
|
||||
imageUrl={imageUrl}
|
||||
disabled={!isDashboardSaved}
|
||||
theme={selectedTheme}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
configDescription: css({
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
buttonsContainer: css({
|
||||
configurationContainer: css({
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { css } from '@emotion/css';
|
||||
import saveAs from 'file-saver';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { GrafanaTheme2, UrlQueryMap } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { config, getBackendSrv, isFetchError } from '@grafana/runtime';
|
||||
import { Alert, Button, Field, FieldSet, Icon, Input, LoadingBar, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { DashboardInteractions } from '../../utils/interactions';
|
||||
|
||||
type ImageSettingsForm = {
|
||||
width: number;
|
||||
height: number;
|
||||
scaleFactor: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
buildUrl: (urlParams: UrlQueryMap) => void;
|
||||
imageUrl: string;
|
||||
disabled: boolean;
|
||||
theme: string;
|
||||
};
|
||||
|
||||
const selector = e2eSelectors.pages.ShareDashboardDrawer.ShareInternally.SharePanel;
|
||||
|
||||
export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
watch,
|
||||
formState: { errors, isValid },
|
||||
} = useForm<ImageSettingsForm>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
width: 1000,
|
||||
height: 500,
|
||||
scaleFactor: 1,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
buildUrl({ width: watch('width'), height: watch('height'), scale: watch('scaleFactor') });
|
||||
}, [buildUrl, watch]);
|
||||
|
||||
const [{ loading, value: image, error }, renderImage] = useAsyncFn(async () => {
|
||||
const { width, height, scaleFactor } = watch();
|
||||
DashboardInteractions.generatePanelImageClicked({
|
||||
width,
|
||||
height,
|
||||
scaleFactor,
|
||||
theme,
|
||||
shareResource: 'panel',
|
||||
});
|
||||
const response = await lastValueFrom(getBackendSrv().fetch<BlobPart>({ url: imageUrl, responseType: 'blob' }));
|
||||
return new Blob([response.data], { type: 'image/png' });
|
||||
}, [imageUrl, watch('width'), watch('height'), watch('scaleFactor'), theme]);
|
||||
|
||||
const onDownloadImageClick = () => {
|
||||
DashboardInteractions.downloadPanelImageClicked({ shareResource: 'panel' });
|
||||
saveAs(image!, `${title}.png`);
|
||||
};
|
||||
|
||||
const onChange = () => {
|
||||
buildUrl({ width: watch('width'), height: watch('height'), scale: watch('scaleFactor') });
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid={selector.preview}>
|
||||
<Stack gap={2} direction="column">
|
||||
<Text element="h4">
|
||||
<Trans i18nKey="share-panel-image.preview.title">Panel preview</Trans>
|
||||
</Text>
|
||||
<form onSubmit={handleSubmit(renderImage)}>
|
||||
<FieldSet
|
||||
disabled={!config.rendererAvailable}
|
||||
label={
|
||||
<Stack gap={1} alignItems="center">
|
||||
<Text element="h5">
|
||||
<Trans i18nKey="share-panel-image.settings.title">Image settings</Trans>
|
||||
</Text>
|
||||
<Tooltip
|
||||
content={t(
|
||||
'share-panel-image.settings.max-warning',
|
||||
'Setting maximums are limited by the image renderer service'
|
||||
)}
|
||||
>
|
||||
<Icon name="info-circle" size="sm" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Stack gap={1} justifyContent="space-between" direction={{ xs: 'column', sm: 'row' }}>
|
||||
<Field
|
||||
label={t('share-panel-image.settings.width-label', 'Width')}
|
||||
className={styles.imageConfigurationField}
|
||||
required
|
||||
invalid={!!errors.width}
|
||||
error={errors.width?.message}
|
||||
>
|
||||
<Input
|
||||
{...register('width', {
|
||||
required: t('share-panel-image.settings.width-required', 'Width is required'),
|
||||
min: {
|
||||
value: 1,
|
||||
message: t('share-panel-image.settings.width-min', 'Width must be equal or greater than 1'),
|
||||
},
|
||||
onChange: onChange,
|
||||
})}
|
||||
placeholder={t('share-panel-image.settings.width-placeholder', '1000')}
|
||||
type="number"
|
||||
suffix="px"
|
||||
data-testid={selector.widthInput}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t('share-panel-image.settings.height-label', 'Height')}
|
||||
className={styles.imageConfigurationField}
|
||||
required
|
||||
invalid={!!errors.height}
|
||||
error={errors.height?.message}
|
||||
>
|
||||
<Input
|
||||
{...register('height', {
|
||||
required: t('share-panel-image.settings.height-required', 'Height is required'),
|
||||
min: {
|
||||
value: 1,
|
||||
message: t('share-panel-image.settings.height-min', 'Height must be equal or greater than 1'),
|
||||
},
|
||||
onChange: onChange,
|
||||
})}
|
||||
placeholder={t('share-panel-image.settings.height-placeholder', '500')}
|
||||
type="number"
|
||||
suffix="px"
|
||||
data-testid={selector.heightInput}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t('share-panel-image.settings.scale-factor-label', 'Scale factor')}
|
||||
className={styles.imageConfigurationField}
|
||||
required
|
||||
invalid={!!errors.scaleFactor}
|
||||
error={errors.scaleFactor?.message}
|
||||
>
|
||||
<Input
|
||||
{...register('scaleFactor', {
|
||||
required: t('share-panel-image.settings.scale-factor-required', 'Scale factor is required'),
|
||||
min: {
|
||||
value: 1,
|
||||
message: t(
|
||||
'share-panel-image.settings.scale-factor-min',
|
||||
'Scale factor must be equal or greater than 1'
|
||||
),
|
||||
},
|
||||
onChange: onChange,
|
||||
})}
|
||||
placeholder={t('share-panel-image.settings.scale-factor-placeholder', '1')}
|
||||
type="number"
|
||||
data-testid={selector.scaleFactorInput}
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
<Stack gap={1} direction={{ xs: 'column', sm: 'row' }}>
|
||||
<Button
|
||||
icon="gf-layout-simple"
|
||||
variant="secondary"
|
||||
fill="solid"
|
||||
type="submit"
|
||||
disabled={disabled || loading || !isValid}
|
||||
data-testid={selector.generateImageButton}
|
||||
>
|
||||
<Trans i18nKey="link.share-panel.render-image">Generate image</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onDownloadImageClick}
|
||||
icon={'download-alt'}
|
||||
variant="secondary"
|
||||
disabled={!image || loading || disabled}
|
||||
data-testid={selector.downloadImageButton}
|
||||
>
|
||||
<Trans i18nKey="link.share-panel.download-image">Download image</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</FieldSet>
|
||||
</form>
|
||||
{loading && (
|
||||
<div>
|
||||
<LoadingBar width={128} />
|
||||
<div className={styles.imageLoadingContainer}>
|
||||
<Text variant="body">{title || ''}</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{image && !loading && <img src={URL.createObjectURL(image)} alt="panel-preview-img" className={styles.image} />}
|
||||
{error && !loading && (
|
||||
<Alert severity="error" title={t('link.share-panel.render-image-error', 'Failed to render panel image')}>
|
||||
{isFetchError(error)
|
||||
? error.statusText
|
||||
: t('link.share-panel.render-image-error-description', 'An error occurred when generating the image')}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
imageConfigurationField: css({
|
||||
flex: 1,
|
||||
}),
|
||||
image: css({
|
||||
maxWidth: '100%',
|
||||
width: 'max-content',
|
||||
}),
|
||||
imageLoadingContainer: css({
|
||||
maxWidth: '100%',
|
||||
height: 362,
|
||||
border: `1px solid ${theme.components.input.borderColor}`,
|
||||
padding: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
@@ -45,6 +45,12 @@ export const DashboardInteractions = {
|
||||
embedSnippetCopy: (properties?: Record<string, unknown>) => {
|
||||
reportDashboardInteraction('sharing_embed_copy_clicked', properties);
|
||||
},
|
||||
generatePanelImageClicked: (properties?: Record<string, unknown>) => {
|
||||
reportDashboardInteraction('sharing_link_generate_image_clicked', properties);
|
||||
},
|
||||
downloadPanelImageClicked: (properties?: Record<string, unknown>) => {
|
||||
reportDashboardInteraction('sharing_link_download_image_clicked', properties);
|
||||
},
|
||||
publishSnapshotClicked: (properties?: Record<string, unknown>) => {
|
||||
reportDashboardInteraction('sharing_snapshot_publish_clicked', properties);
|
||||
},
|
||||
|
||||
@@ -50,8 +50,8 @@ export function getDashboardUrl(options: DashboardUrlOptions) {
|
||||
|
||||
options.updateQuery = {
|
||||
...options.updateQuery,
|
||||
width: 1000,
|
||||
height: 500,
|
||||
width: options.updateQuery?.width || 1000,
|
||||
height: options.updateQuery?.height || 500,
|
||||
tz: options.timeZone,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function ensureV2Response(
|
||||
const spec: DashboardV2Spec = {
|
||||
title: dashboard.title,
|
||||
description: dashboard.description,
|
||||
tags: dashboard.tags,
|
||||
tags: dashboard.tags ?? [],
|
||||
schemaVersion: dashboard.schemaVersion,
|
||||
cursorSync: transformCursorSynctoEnum(dashboard.graphTooltip),
|
||||
preload: dashboard.preload || dashboardDefaults.preload,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { useEffectOnce } from 'react-use';
|
||||
|
||||
import { RawTimeRange, TimeRange } from '@grafana/data';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Button, ClipboardButton, Field, Label, Modal, Stack, Switch, TextArea } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
|
||||
@@ -24,10 +23,6 @@ export function ShareEmbed({ panel, dashboard, range, onCancelClick, buildIframe
|
||||
const [selectedTheme, setSelectedTheme] = useState('current');
|
||||
const [iframeHtml, setIframeHtml] = useState('');
|
||||
|
||||
useEffectOnce(() => {
|
||||
reportInteraction('grafana_dashboards_embed_share_viewed', { shareResource: getTrackingSource(panel) });
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const newIframeHtml = buildIframe(useCurrentTimeRange, dashboard.uid, selectedTheme, panel, range);
|
||||
setIframeHtml(newIframeHtml);
|
||||
|
||||
@@ -323,6 +323,10 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
};
|
||||
};
|
||||
|
||||
onPinLineCallback = () => {
|
||||
this.setState({ contentOutlineVisible: true });
|
||||
};
|
||||
|
||||
renderEmptyState(exploreContainerStyles: string) {
|
||||
return (
|
||||
<div className={cx(exploreContainerStyles)}>
|
||||
@@ -414,6 +418,8 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
);
|
||||
}
|
||||
|
||||
splitOpenFnLogs = this.onSplitOpen('logs');
|
||||
|
||||
renderLogsPanel(width: number) {
|
||||
const { exploreId, syncedTimes, theme, queryResponse } = this.props;
|
||||
const spacing = parseInt(theme.spacing(2).slice(0, -2), 10);
|
||||
@@ -435,14 +441,12 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
onStartScanning={this.onStartScanning}
|
||||
onStopScanning={this.onStopScanning}
|
||||
eventBus={this.logsEventBus}
|
||||
splitOpenFn={this.onSplitOpen('logs')}
|
||||
splitOpenFn={this.splitOpenFnLogs}
|
||||
scrollElement={this.scrollElement}
|
||||
isFilterLabelActive={this.isFilterLabelActive}
|
||||
onClickFilterString={this.onClickFilterString}
|
||||
onClickFilterOutString={this.onClickFilterOutString}
|
||||
onPinLineCallback={() => {
|
||||
this.setState({ contentOutlineVisible: true });
|
||||
}}
|
||||
onPinLineCallback={this.onPinLineCallback}
|
||||
/>
|
||||
</ContentOutlineItem>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { capitalize, groupBy } from 'lodash';
|
||||
import memoizeOne from 'memoize-one';
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import * as React from 'react';
|
||||
import { usePrevious, useUnmount } from 'react-use';
|
||||
|
||||
@@ -189,6 +188,8 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
loadMoreLogs,
|
||||
panelState,
|
||||
eventBus,
|
||||
onPinLineCallback,
|
||||
scrollElement,
|
||||
} = props;
|
||||
const [showLabels, setShowLabels] = useState<boolean>(store.getBool(SETTINGS_KEYS.showLabels, false));
|
||||
const [showTime, setShowTime] = useState<boolean>(store.getBool(SETTINGS_KEYS.showTime, true));
|
||||
@@ -210,8 +211,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
const [visualisationType, setVisualisationType] = useState<LogsVisualisationType | undefined>(
|
||||
panelState?.logs?.visualisationType ?? getDefaultVisualisationType()
|
||||
);
|
||||
const [scrollIntoView, setScrollIntoView] = useState<((element: HTMLElement) => void) | undefined>(undefined);
|
||||
const logsContainerRef = useRef<HTMLDivElement | undefined>(undefined);
|
||||
const logsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const dispatch = useDispatch();
|
||||
const previousLoading = usePrevious(loading);
|
||||
|
||||
@@ -230,9 +230,13 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
|
||||
// Get pinned log lines
|
||||
const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root');
|
||||
const pinnedLogs = logsParent?.children
|
||||
?.filter((outlines) => outlines.title === PINNED_LOGS_TITLE)
|
||||
.map((pinnedLogs) => pinnedLogs.id);
|
||||
const pinnedLogs = useMemo(
|
||||
() =>
|
||||
logsParent?.children
|
||||
?.filter((outlines) => outlines.title === PINNED_LOGS_TITLE)
|
||||
.map((pinnedLogs) => pinnedLogs.id),
|
||||
[logsParent?.children]
|
||||
);
|
||||
|
||||
const getPinnedLogsCount = useCallback(() => {
|
||||
const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root');
|
||||
@@ -433,39 +437,28 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
[props.eventBus]
|
||||
);
|
||||
|
||||
const onLogsContainerRef = useCallback(
|
||||
(node: HTMLDivElement) => {
|
||||
logsContainerRef.current = node;
|
||||
|
||||
// In theory this should be just a function passed down to LogRows but:
|
||||
// - LogRow.componentDidMount which calls scrollIntoView is called BEFORE the logsContainerRef is set
|
||||
// - the if check below if (logsContainerRef.current) was falsy and scrolling doesn't happen
|
||||
// - and LogRow.scrollToLogRow marks the line as scrolled anyway (and won't perform scrolling when the ref is set)
|
||||
// - see more details in https://github.com/facebook/react/issues/29897
|
||||
// We can change it once LogRow is converted into a functional component
|
||||
setScrollIntoView(() => (element: HTMLElement) => {
|
||||
if (config.featureToggles.logsInfiniteScrolling) {
|
||||
if (logsContainerRef.current) {
|
||||
topLogsRef.current?.scrollIntoView();
|
||||
logsContainerRef.current.scroll({
|
||||
behavior: 'smooth',
|
||||
top: logsContainerRef.current.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
const scrollElement = props.scrollElement;
|
||||
|
||||
if (scrollElement) {
|
||||
scrollElement.scroll({
|
||||
const scrollIntoView = useCallback(
|
||||
(element: HTMLElement) => {
|
||||
if (config.featureToggles.logsInfiniteScrolling) {
|
||||
if (logsContainerRef.current) {
|
||||
topLogsRef.current?.scrollIntoView();
|
||||
logsContainerRef.current.scroll({
|
||||
behavior: 'smooth',
|
||||
top: scrollElement.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2,
|
||||
top: logsContainerRef.current.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollElement) {
|
||||
scrollElement.scroll({
|
||||
behavior: 'smooth',
|
||||
top: scrollElement.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2,
|
||||
});
|
||||
}
|
||||
},
|
||||
[props.scrollElement]
|
||||
[scrollElement]
|
||||
);
|
||||
|
||||
const onChangeLogsSortOrder = () => {
|
||||
@@ -644,7 +637,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
onCloseCallbackRef?.current();
|
||||
}, [contextRow?.datasourceType, contextRow?.uid, onCloseCallbackRef]);
|
||||
|
||||
const onOpenContext = (row: LogRowModel, onClose: () => void) => {
|
||||
const onOpenContext = useCallback((row: LogRowModel, onClose: () => void) => {
|
||||
// we are setting the `contextOpen` open state and passing it down to the `LogRow` in order to highlight the row when a LogContext is open
|
||||
setContextOpen(true);
|
||||
setContextRow(row);
|
||||
@@ -653,37 +646,40 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
logRowUid: row.uid,
|
||||
});
|
||||
onCloseCallbackRef.current = onClose;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onPermalinkClick = async (row: LogRowModel) => {
|
||||
// this is an extra check, to be sure that we are not
|
||||
// creating permalinks for logs without an id-field.
|
||||
// normally it should never happen, because we do not
|
||||
// display the permalink button in such cases.
|
||||
if (row.rowId === undefined) {
|
||||
return;
|
||||
}
|
||||
const onPermalinkClick = useCallback(
|
||||
async (row: LogRowModel) => {
|
||||
// this is an extra check, to be sure that we are not
|
||||
// creating permalinks for logs without an id-field.
|
||||
// normally it should never happen, because we do not
|
||||
// display the permalink button in such cases.
|
||||
if (row.rowId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// get explore state, add log-row-id and make timerange absolute
|
||||
const urlState = getUrlStateFromPaneState(getState().explore.panes[exploreId]!);
|
||||
urlState.panelsState = {
|
||||
...panelState,
|
||||
logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType(), displayedFields },
|
||||
};
|
||||
urlState.range = getLogsPermalinkRange(row, logRows, absoluteRange);
|
||||
// get explore state, add log-row-id and make timerange absolute
|
||||
const urlState = getUrlStateFromPaneState(getState().explore.panes[exploreId]!);
|
||||
urlState.panelsState = {
|
||||
...panelState,
|
||||
logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType(), displayedFields },
|
||||
};
|
||||
urlState.range = getLogsPermalinkRange(row, logRows, absoluteRange);
|
||||
|
||||
// append changed urlState to baseUrl
|
||||
const serializedState = serializeStateToUrlParam(urlState);
|
||||
const baseUrl = /.*(?=\/explore)/.exec(`${window.location.href}`)![0];
|
||||
const url = urlUtil.renderUrl(`${baseUrl}/explore`, { left: serializedState });
|
||||
await createAndCopyShortLink(url);
|
||||
// append changed urlState to baseUrl
|
||||
const serializedState = serializeStateToUrlParam(urlState);
|
||||
const baseUrl = /.*(?=\/explore)/.exec(`${window.location.href}`)![0];
|
||||
const url = urlUtil.renderUrl(`${baseUrl}/explore`, { left: serializedState });
|
||||
await createAndCopyShortLink(url);
|
||||
|
||||
reportInteraction('grafana_explore_logs_permalink_clicked', {
|
||||
datasourceType: row.datasourceType ?? 'unknown',
|
||||
logRowUid: row.uid,
|
||||
logRowLevel: row.logLevel,
|
||||
});
|
||||
};
|
||||
reportInteraction('grafana_explore_logs_permalink_clicked', {
|
||||
datasourceType: row.datasourceType ?? 'unknown',
|
||||
logRowUid: row.uid,
|
||||
logRowLevel: row.logLevel,
|
||||
});
|
||||
},
|
||||
[absoluteRange, displayedFields, exploreId, logRows, panelState, visualisationType]
|
||||
);
|
||||
|
||||
const scrollToTopLogs = useCallback(() => {
|
||||
if (config.featureToggles.logsInfiniteScrolling) {
|
||||
@@ -697,55 +693,62 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
topLogsRef.current?.scrollIntoView();
|
||||
}, [logsContainerRef, topLogsRef]);
|
||||
|
||||
const onPinToContentOutlineClick = (row: LogRowModel, allowUnPin = true) => {
|
||||
if (getPinnedLogsCount() === PINNED_LOGS_LIMIT && !allowUnPin) {
|
||||
contentOutlineTrackPinLimitReached();
|
||||
return;
|
||||
}
|
||||
const onPinToContentOutlineClick = useCallback(
|
||||
(row: LogRowModel, allowUnPin = true) => {
|
||||
if (getPinnedLogsCount() === PINNED_LOGS_LIMIT && !allowUnPin) {
|
||||
contentOutlineTrackPinLimitReached();
|
||||
return;
|
||||
}
|
||||
|
||||
// find the Logs parent item
|
||||
const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root');
|
||||
// find the Logs parent item
|
||||
const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root');
|
||||
|
||||
//update the parent's expanded state
|
||||
if (logsParent && updateItem) {
|
||||
updateItem(logsParent.id, { expanded: true });
|
||||
}
|
||||
//update the parent's expanded state
|
||||
if (logsParent && updateItem) {
|
||||
updateItem(logsParent.id, { expanded: true });
|
||||
}
|
||||
|
||||
const alreadyPinned = pinnedLogs?.find((pin) => pin === row.rowId);
|
||||
if (alreadyPinned && row.rowId && allowUnPin) {
|
||||
unregister?.(row.rowId);
|
||||
contentOutlineTrackPinRemoved();
|
||||
} else if (getPinnedLogsCount() !== PINNED_LOGS_LIMIT && !alreadyPinned) {
|
||||
register?.({
|
||||
id: row.rowId,
|
||||
icon: 'gf-logs',
|
||||
title: PINNED_LOGS_TITLE,
|
||||
panelId: PINNED_LOGS_PANELID,
|
||||
level: 'child',
|
||||
ref: null,
|
||||
color: LogLevelColor[row.logLevel],
|
||||
childOnTop: true,
|
||||
onClick: () => {
|
||||
onOpenContext(row, () => {});
|
||||
contentOutlineTrackPinClicked();
|
||||
},
|
||||
onRemove: (id: string) => {
|
||||
unregister?.(id);
|
||||
contentOutlineTrackUnpinClicked();
|
||||
},
|
||||
});
|
||||
contentOutlineTrackPinAdded();
|
||||
}
|
||||
const alreadyPinned = pinnedLogs?.find((pin) => pin === row.rowId);
|
||||
if (alreadyPinned && row.rowId && allowUnPin) {
|
||||
unregister?.(row.rowId);
|
||||
contentOutlineTrackPinRemoved();
|
||||
} else if (getPinnedLogsCount() !== PINNED_LOGS_LIMIT && !alreadyPinned) {
|
||||
register?.({
|
||||
id: row.rowId,
|
||||
icon: 'gf-logs',
|
||||
title: PINNED_LOGS_TITLE,
|
||||
panelId: PINNED_LOGS_PANELID,
|
||||
level: 'child',
|
||||
ref: null,
|
||||
color: LogLevelColor[row.logLevel],
|
||||
childOnTop: true,
|
||||
onClick: () => {
|
||||
onOpenContext(row, () => {});
|
||||
contentOutlineTrackPinClicked();
|
||||
},
|
||||
onRemove: (id: string) => {
|
||||
unregister?.(id);
|
||||
contentOutlineTrackUnpinClicked();
|
||||
},
|
||||
});
|
||||
contentOutlineTrackPinAdded();
|
||||
}
|
||||
|
||||
props.onPinLineCallback?.();
|
||||
};
|
||||
onPinLineCallback?.();
|
||||
},
|
||||
[getPinnedLogsCount, onOpenContext, onPinLineCallback, outlineItems, pinnedLogs, register, unregister, updateItem]
|
||||
);
|
||||
|
||||
const hasUnescapedContent = checkUnescapedContent(logRows);
|
||||
const filteredLogs = filterRows(logRows, hiddenLogLevels);
|
||||
const { dedupedRows, dedupCount } = dedupRows(filteredLogs, dedupStrategy);
|
||||
const navigationRange = createNavigationRange(logRows);
|
||||
const infiniteScrollAvailable = !logsQueries?.some(
|
||||
(query) => 'direction' in query && query.direction === LokiQueryDirection.Scan
|
||||
const hasUnescapedContent = useMemo(() => checkUnescapedContent(logRows), [logRows]);
|
||||
const filteredLogs = useMemo(() => filterRows(logRows, hiddenLogLevels), [hiddenLogLevels, logRows]);
|
||||
const { dedupedRows, dedupCount } = useMemo(
|
||||
() => dedupRows(filteredLogs, dedupStrategy),
|
||||
[dedupStrategy, filteredLogs]
|
||||
);
|
||||
const navigationRange = useMemo(() => createNavigationRange(logRows), [logRows]);
|
||||
const infiniteScrollAvailable = useMemo(
|
||||
() => !logsQueries?.some((query) => 'direction' in query && query.direction === LokiQueryDirection.Scan),
|
||||
[logsQueries]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -938,58 +941,61 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visualisationType === 'logs' && hasData && (
|
||||
{visualisationType === 'logs' && (
|
||||
<div
|
||||
className={config.featureToggles.logsInfiniteScrolling ? styles.scrollableLogRows : styles.logRows}
|
||||
data-testid="logRows"
|
||||
ref={onLogsContainerRef}
|
||||
ref={logsContainerRef}
|
||||
>
|
||||
<InfiniteScroll
|
||||
loading={loading}
|
||||
loadMoreLogs={infiniteScrollAvailable ? loadMoreLogs : undefined}
|
||||
range={props.range}
|
||||
timeZone={timeZone}
|
||||
rows={logRows}
|
||||
scrollElement={logsContainerRef.current}
|
||||
sortOrder={logsSortOrder}
|
||||
app={CoreApp.Explore}
|
||||
>
|
||||
<LogRows
|
||||
pinnedLogs={pinnedLogs}
|
||||
logRows={logRows}
|
||||
deduplicatedRows={dedupedRows}
|
||||
dedupStrategy={dedupStrategy}
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||
showContextToggle={showContextToggle}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
showLabels={showLabels}
|
||||
showTime={showTime}
|
||||
enableLogDetails={true}
|
||||
forceEscape={forceEscape}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
prettifyLogMessage={prettifyLogMessage}
|
||||
{hasData && (
|
||||
<InfiniteScroll
|
||||
loading={loading}
|
||||
loadMoreLogs={infiniteScrollAvailable ? loadMoreLogs : undefined}
|
||||
range={props.range}
|
||||
timeZone={timeZone}
|
||||
getFieldLinks={getFieldLinks}
|
||||
logsSortOrder={logsSortOrder}
|
||||
displayedFields={displayedFields}
|
||||
onClickShowField={showField}
|
||||
onClickHideField={hideField}
|
||||
rows={logRows}
|
||||
scrollElement={logsContainerRef.current}
|
||||
sortOrder={logsSortOrder}
|
||||
app={CoreApp.Explore}
|
||||
onLogRowHover={onLogRowHover}
|
||||
onOpenContext={onOpenContext}
|
||||
onPermalinkClick={onPermalinkClick}
|
||||
permalinkedRowId={panelState?.logs?.id}
|
||||
scrollIntoView={scrollIntoView}
|
||||
isFilterLabelActive={props.isFilterLabelActive}
|
||||
containerRendered={!!logsContainerRef}
|
||||
onClickFilterString={props.onClickFilterString}
|
||||
onClickFilterOutString={props.onClickFilterOutString}
|
||||
onUnpinLine={onPinToContentOutlineClick}
|
||||
onPinLine={onPinToContentOutlineClick}
|
||||
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
|
||||
/>
|
||||
</InfiniteScroll>
|
||||
>
|
||||
<LogRows
|
||||
pinnedLogs={pinnedLogs}
|
||||
logRows={logRows}
|
||||
deduplicatedRows={dedupedRows}
|
||||
dedupStrategy={dedupStrategy}
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||
showContextToggle={showContextToggle}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
showLabels={showLabels}
|
||||
showTime={showTime}
|
||||
enableLogDetails={true}
|
||||
forceEscape={forceEscape}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
prettifyLogMessage={prettifyLogMessage}
|
||||
timeZone={timeZone}
|
||||
getFieldLinks={getFieldLinks}
|
||||
logsSortOrder={logsSortOrder}
|
||||
displayedFields={displayedFields}
|
||||
onClickShowField={showField}
|
||||
onClickHideField={hideField}
|
||||
app={CoreApp.Explore}
|
||||
onLogRowHover={onLogRowHover}
|
||||
onOpenContext={onOpenContext}
|
||||
onPermalinkClick={onPermalinkClick}
|
||||
permalinkedRowId={panelState?.logs?.id}
|
||||
scrollIntoView={scrollIntoView}
|
||||
isFilterLabelActive={props.isFilterLabelActive}
|
||||
scrollElement={logsContainerRef.current}
|
||||
onClickFilterString={props.onClickFilterString}
|
||||
onClickFilterOutString={props.onClickFilterOutString}
|
||||
onUnpinLine={onPinToContentOutlineClick}
|
||||
onPinLine={onPinToContentOutlineClick}
|
||||
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
|
||||
renderPreview
|
||||
/>
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !hasData && !scanning && (
|
||||
@@ -1098,21 +1104,21 @@ const getStyles = (theme: GrafanaTheme2, wrapLogMessage: boolean, tableHeight: n
|
||||
};
|
||||
};
|
||||
|
||||
const checkUnescapedContent = memoizeOne((logRows: LogRowModel[]) => {
|
||||
const checkUnescapedContent = (logRows: LogRowModel[]) => {
|
||||
return logRows.some((r) => r.hasUnescapedContent);
|
||||
});
|
||||
};
|
||||
|
||||
const dedupRows = memoizeOne((logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) => {
|
||||
const dedupRows = (logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) => {
|
||||
const dedupedRows = dedupLogRows(logRows, dedupStrategy);
|
||||
const dedupCount = dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0);
|
||||
return { dedupedRows, dedupCount };
|
||||
});
|
||||
};
|
||||
|
||||
const filterRows = memoizeOne((logRows: LogRowModel[], hiddenLogLevels: LogLevel[]) => {
|
||||
const filterRows = (logRows: LogRowModel[], hiddenLogLevels: LogLevel[]) => {
|
||||
return filterLogLevels(logRows, new Set(hiddenLogLevels));
|
||||
});
|
||||
};
|
||||
|
||||
const createNavigationRange = memoizeOne((logRows: LogRowModel[]): { from: number; to: number } | undefined => {
|
||||
const createNavigationRange = (logRows: LogRowModel[]): { from: number; to: number } | undefined => {
|
||||
if (!logRows || logRows.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -1124,4 +1130,4 @@ const createNavigationRange = memoizeOne((logRows: LogRowModel[]): { from: numbe
|
||||
}
|
||||
|
||||
return { from: firstTimeStamp, to: lastTimeStamp };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -259,6 +259,14 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState
|
||||
this.props.clearCache(this.props.exploreId);
|
||||
};
|
||||
|
||||
loadLogsVolumeData = () => {
|
||||
this.props.loadSupplementaryQueryData(this.props.exploreId, SupplementaryQueryType.LogsVolume);
|
||||
};
|
||||
|
||||
onSetLogsVolumeEnabled = (enabled: boolean) => {
|
||||
this.props.setSupplementaryQueryEnabled(this.props.exploreId, enabled, SupplementaryQueryType.LogsVolume);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
loading,
|
||||
@@ -267,8 +275,6 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState
|
||||
logsMeta,
|
||||
logsSeries,
|
||||
logsQueries,
|
||||
loadSupplementaryQueryData,
|
||||
setSupplementaryQueryEnabled,
|
||||
onClickFilterLabel,
|
||||
onClickFilterOutLabel,
|
||||
onStartScanning,
|
||||
@@ -319,16 +325,14 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState
|
||||
logsMeta={logsMeta}
|
||||
logsSeries={logsSeries}
|
||||
logsVolumeEnabled={logsVolume.enabled}
|
||||
onSetLogsVolumeEnabled={(enabled) =>
|
||||
setSupplementaryQueryEnabled(exploreId, enabled, SupplementaryQueryType.LogsVolume)
|
||||
}
|
||||
onSetLogsVolumeEnabled={this.onSetLogsVolumeEnabled}
|
||||
logsVolumeData={logsVolume.data}
|
||||
logsQueries={logsQueries}
|
||||
width={width}
|
||||
splitOpen={splitOpenFn}
|
||||
loading={loading}
|
||||
loadingState={loadingState}
|
||||
loadLogsVolumeData={() => loadSupplementaryQueryData(exploreId, SupplementaryQueryType.LogsVolume)}
|
||||
loadLogsVolumeData={this.loadLogsVolumeData}
|
||||
onChangeTime={this.onChangeTime}
|
||||
loadMoreLogs={this.loadMoreLogs}
|
||||
onClickFilterLabel={this.logDetailsFilterAvailable() ? onClickFilterLabel : undefined}
|
||||
|
||||
@@ -101,6 +101,7 @@ export function LogsSamplePanel(props: Props) {
|
||||
prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)}
|
||||
timeZone={timeZone}
|
||||
enableLogDetails={true}
|
||||
scrollElement={null}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user