mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge remote-tracking branch 'origin/main' into resource-store
This commit is contained in:
commit
7b6e4d5da4
@ -7998,12 +7998,6 @@ exports[`no gf-form usage`] = {
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
"public/app/plugins/datasource/alertmanager/ConfigEditor.tsx:5381": [
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
"public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.tsx:5381": [
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
@ -8037,14 +8031,6 @@ exports[`no gf-form usage`] = {
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
"public/app/plugins/datasource/graphite/components/AnnotationsEditor.tsx:5381": [
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
"public/app/plugins/datasource/graphite/configuration/MappingsConfiguration.tsx:5381": [
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
"public/app/plugins/datasource/influxdb/components/editor/annotation/AnnotationEditor.tsx:5381": [
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
|
@ -1,9 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
Are you looking to take on contributions with bigger impact? These guides help you get a better understanding of the structure and design of the Grafana codebase.
|
||||
Do you want to make Grafana contributions with bigger impact? These guides help you get a better understanding of the structure and design of the Grafana codebase.
|
||||
|
||||
Learn more about the backend architecture in [contribute/backend](/contribute/backend/README.md).
|
||||
|
||||
Learn more about the frontend architecture:
|
||||
|
||||
- Part 1: [Data requests](frontend-data-requests.md)
|
||||
- To learn more about the frontend architecture, refer to [Frontend data requests](frontend-data-requests.md).
|
||||
- To learn more about the backend architecture, refer to [contribute/backend](/contribute/backend/README.md).
|
||||
|
@ -1,16 +1,18 @@
|
||||
# Data requests
|
||||
# Frontend data requests
|
||||
|
||||
[BackendSrv](https://github.com/grafana/grafana/blob/main/packages/grafana-runtime/src/services/backendSrv.ts) handles all outgoing HTTP requests from Grafana. This document explains the high-level concepts used by `BackendSrv`.
|
||||
|
||||
## Canceling requests
|
||||
## Cancel requests
|
||||
|
||||
This section describes how canceling requests work in Grafana. While data sources can implement their own cancellation concept, we recommend that you use the method we describe here.
|
||||
While data sources can implement their own cancellation concept, we recommend that you use the method described in this section.
|
||||
|
||||
A data request can take a long time to finish. During the time between when a request starts and finishes, the user can change context. For example, the user may navigate away or issue the same request again.
|
||||
|
||||
If we wait for canceled requests to complete, it might create unnecessary load on data sources.
|
||||
If we wait for canceled requests to complete, they might create unnecessary load on the data sources.
|
||||
|
||||
Grafana uses a concept called _request cancellation_ to cancel any ongoing request that Grafana doesn't need.
|
||||
### Request cancellation by Grafana version
|
||||
|
||||
Grafana uses a concept called _request cancellation_ to cancel any ongoing request that Grafana doesn't need. The process for canceling requests in this manner varies by Grafana version.
|
||||
|
||||
#### Before Grafana 7.2
|
||||
|
||||
@ -25,22 +27,22 @@ The cancellation logic is as follows:
|
||||
|
||||
Grafana 7.2 introduced an additional way of canceling requests using [RxJs](https://github.com/ReactiveX/rxjs). To support the new cancellation functionality, the data source needs to use the new `fetch` function in [BackendSrv](https://github.com/grafana/grafana/blob/main/packages/grafana-runtime/src/services/backendSrv.ts).
|
||||
|
||||
Migrating the core data sources to the new `fetch` function [is an ongoing process that you can read about in this issue.](https://github.com/grafana/grafana/issues/27222)
|
||||
Migrating the core data sources to the new `fetch` function is an ongoing process. To learn more, refer to [this issue](https://github.com/grafana/grafana/issues/27222).
|
||||
|
||||
## Request queue
|
||||
|
||||
If Grafana isn't configured to support HTTP/2, browsers connecting with HTTP 1.1 enforce a limit of 4 - 8 parallel requests (the specific limit varies). Because of this limit, if some requests take a long time, they will block later requests and make interacting with Grafana very slow.
|
||||
If Grafana isn't configured to support HTTP/2, browsers connecting with HTTP 1.1 enforce a limit of 4 to 8 parallel requests (the specific limit varies). Because of this limit, if some requests take a long time, they will block later requests and make interacting with Grafana very slow.
|
||||
|
||||
[Enabling HTTP/2 support in Grafana](https://grafana.com/docs/grafana/latest/administration/configuration/#protocol) allows far more parallel requests.
|
||||
|
||||
#### Before Grafana 7.2
|
||||
### Before Grafana 7.2
|
||||
|
||||
Not supported.
|
||||
|
||||
#### After Grafana 7.2
|
||||
### After Grafana 7.2
|
||||
|
||||
Grafana uses a _request queue_ to process all incoming data requests in order while reserving a free "spot" for any requests to the Grafana API.
|
||||
|
||||
Since the first implementation of the request queue doesn't take into account what browser the user uses, the _request queue_ limit for parallel data source requests is hard-coded to 5.
|
||||
Since the first implementation of the request queue doesn't take into account what browser the user uses, the request queue's limit for parallel data source requests is hard-coded to 5.
|
||||
|
||||
> **Note:** Grafana instances [configured with HTTP2](https://grafana.com/docs/grafana/latest/administration/configuration/#protocol) will have a hard coded limit of 1000.
|
||||
> **Note:** Grafana instances [configured with HTTP/2](https://grafana.com/docs/grafana/latest/administration/configuration/#protocol) have a hard-coded limit of 1000.
|
||||
|
@ -1,81 +1,80 @@
|
||||
# Handling breaking changes in Grafana frontend APIs
|
||||
# Handle breaking changes in Grafana frontend APIs
|
||||
|
||||
This guide tries to help you identify and communicate breaking changes introduced to our frontend API.
|
||||
Follow this guide to identify and communicate breaking changes introduced to our frontend API.
|
||||
|
||||
- [What are our public APIs?](#what-are-our-public-apis)
|
||||
- [What is Levitate?](#what-is-levitate)
|
||||
- [How does the CI workflow look like?](#how-does-the-ci-workflow-look-like)
|
||||
- [I received a comment on my PR, what does it mean?](#i-received-a-comment-on-my-pr-what-does-it-mean)
|
||||
- [**I know it's a breaking change, what's next?**](#i-know-its-a-breaking-change-whats-next)
|
||||
- [What does the CI workflow look like?](#what-does-the-ci-workflow-look-like)
|
||||
- [What do comments on my PR mean?](#what-do-comments-on-my-pr-mean)
|
||||
- [I know it's a breaking change, what's next?](#i-know-its-a-breaking-change-whats-next)
|
||||
- [Introduce breaking changes only in major versions](#introduce-breaking-changes-only-in-major-versions)
|
||||
- [Deprecate first](#deprecate-first)
|
||||
- [Communicate](#communicate)
|
||||
- [I still have questions, who can help me out?](#i-still-have-questions-who-can-help-me-out)
|
||||
- [Who can help with other questions?](#who-can-help-with-other-questions)
|
||||
|
||||
---
|
||||
|
||||
## What are our public APIs?
|
||||
|
||||
The Grafana frontend codebase is exposing functionality through NPM packages to make plugin development easier and faster.
|
||||
The Grafana frontend codebase exposes functionality through NPM packages to make plugin development easier and faster.
|
||||
These packages live in the `/packages` folder and contain packages like:
|
||||
|
||||
- `@grafana/data`
|
||||
- `@grafana/runtime`
|
||||
- `@grafana/ui`
|
||||
- etc. ([They can be viewed here](https://github.com/grafana/grafana/tree/main/packages)
|
||||
- [(more packages...)](https://github.com/grafana/grafana/tree/main/packages)
|
||||
|
||||
Any change that causes dependent software to behave differently is considered to be breaking.
|
||||
|
||||
## What is Levitate?
|
||||
|
||||
[`@grafana/levitate`](https://github.com/grafana/levitate) is a tool created by Grafana that can show breaking changes between two versions of a **TypeScript** package or a source file.
|
||||
[`@grafana/levitate`](https://github.com/grafana/levitate) is a tool created by Grafana that can show breaking changes between two versions of a TypeScript package or a source file.
|
||||
|
||||
It can list exported members of an NPM package or imports used by an NPM package,
|
||||
**but we are mainly using it for comparing different versions of the same package to see changes in the exported members.**
|
||||
Levitate can list exported members of an NPM package or imports used by an NPM package, _but it is most commonly used for comparing different versions of the same package to see changes in the exported members._
|
||||
|
||||
A Github workflow runs against every pull request and comments a hint in case there are
|
||||
possible breaking changes. It also adds the `"breaking change"` label to the pull request.
|
||||
A GitHub workflow runs against every pull request and comments a hint if there are possible breaking changes.
|
||||
It also adds the `breaking change` label to the pull request.
|
||||
|
||||
## How does the CI workflow look like?
|
||||
## What does the CI workflow look like?
|
||||
|
||||
<img src="./breaking-changes-workflow.png" alt="CI workflow" width="700" />
|
||||
|
||||
## I received a comment on my PR, what does it mean?
|
||||
## What do comments on my PR mean?
|
||||
|
||||

|
||||

|
||||
|
||||
Receiving a comment like the one above does not necessarily mean that you actually introduced breaking
|
||||
changes (as certain edge cases are still not covered by the tool), but as there is a good chance we rather raise attention.
|
||||
Receiving a comment like this does not necessarily mean that you actually introduced breaking
|
||||
changes. This is because certain edge cases are still not covered by the tool, but there is a good chance that they may happen, so we call it to your attention.
|
||||
|
||||
By clicking the links in the comment ("more info" or "Check console output") you can view more detailed information about what triggered the notification.
|
||||
|
||||
**Removed exported members** (console view):<br />
|
||||
This means that some previously exported members won't be available in the newer version of the package which can break dependent plugins.
|
||||
This means that some previously exported members won't be available in the newer version of the package, so dependent plugins can break.
|
||||
|
||||

|
||||

|
||||
|
||||
**Changed an existing member** (console view):<br />
|
||||
This means that a member was changed in a way that can break dependent plugins.
|
||||
|
||||

|
||||

|
||||
|
||||
**No breaking changes** (console view):<br />
|
||||
Seeing this suggests that whilst changes were made, most probably none of them were breaking. You are good to go! 👏
|
||||
Seeing this suggests that while changes were made, most probably none of them were breaking. You are good to go! 👏
|
||||
|
||||

|
||||

|
||||
|
||||
## How can I decide if it is really a breaking change?
|
||||
|
||||
First go to the console output of the workflow and make sure that the diffs make sense.
|
||||
First, go to the console output of the workflow and make sure that the diffs make sense.
|
||||
|
||||
It can happen that Levitate highlights a change which is marked with TSDoc tags `// @alpha` or `// @internal` in
|
||||
which case you can choose to ignore it - keep in mind though that these flags won't really hold developers back
|
||||
It can happen that Levitate highlights a change which is marked with TSDoc tags `// @alpha` or `// @internal`, in
|
||||
which case you can choose to ignore it. Keep in mind though that these flags won't really hold developers back
|
||||
from using your code and most likely it is going to cause them problems if we are breaking them.
|
||||
|
||||
It can also happen that Levitate marks changing an interface as a possible breaking change.
|
||||
For anyone that implements that interface introducing a new property will break their code. Whilst this is correctly marked as a breaking change maybe it is an interface that is never implemented by other developers. In which case you can choose to ignore Levitate's message.
|
||||
Introducing a new property will break the code of anyone who implements that interface. While this is correctly marked as a breaking change maybe it is an interface that is never implemented by other developers. If this is the case, then you can choose to ignore Levitate's message.
|
||||
|
||||
These notifications are only warnings though, and **in the end it's up to the author of the PR to make a decision that makes the most sense.**
|
||||
These notifications are only warnings though, and _in the end it's up to the author of the PR to make a decision that makes the most sense._
|
||||
|
||||
## I know it's a breaking change, what's next?
|
||||
|
||||
@ -99,20 +98,20 @@ myOldFunction(name: string) {
|
||||
}
|
||||
```
|
||||
|
||||
1. Add a deprecation comment `// @deprecated`
|
||||
2. Add info in the comment about **when it is going to be removed**
|
||||
3. Add info in the comment about **what should be used instead**
|
||||
4. In case it's a function or a method, use `deprecationWarning(<file name>, <old name>, <new name>)` to raise attention during runtime as well
|
||||
5. Update the [migration guide](/developers/plugin-tools/migration-guides/) with your instructions
|
||||
1. Add a deprecation comment `// @deprecated`.
|
||||
2. Add info in the comment about _when it is going to be removed_.
|
||||
3. Add info in the comment about _what should be used instead_.
|
||||
4. If it's a function or a method, use `deprecationWarning(<FILENAME>, <OLD NAME>, <NEW NAME>)` to raise attention during runtime.
|
||||
5. Update the [migration guide](https://grafana.com/developers/plugin-tools/migration-guides/) with your instructions.
|
||||
|
||||
### Communicate
|
||||
|
||||
Reach out to **@grafana/plugins-platform-frontend** to help finding which plugins are using the code that is just about to change, so we try making it smoother by communicating it to them.
|
||||
Reach out to `@grafana/plugins-platform-frontend` to help find which plugins are using the code that is just about to change, so we try making it smoother by communicating it to the developers.
|
||||
|
||||
---
|
||||
|
||||
## I still have questions, who can help me out?
|
||||
## Who can help with other questions?
|
||||
|
||||
We are here to help.
|
||||
|
||||
Please either ping us in the pull request by using the **@grafana/plugins-platform-frontend** handle or reach out to us on the internal Slack in `#grafana-plugins-platform`.
|
||||
Please either ping us in the pull request by using the `@grafana/plugins-platform-frontend` handle or reach out to us on the internal Slack in `#grafana-plugins-platform`.
|
||||
|
@ -69,22 +69,117 @@ If you want to receive every alert as a separate notification, you can do so by
|
||||
|
||||
## Timing options
|
||||
|
||||
The timing options decide how often notifications are sent for each group of alerts. There are three timers that you need to know about: Group wait, Group interval, and Repeat interval.
|
||||
In the notification policy, you can also configure how often notifications are sent for each [group of alerts](#group-notifications). There are three distinct timers applied to groups within the notification policy:
|
||||
|
||||
#### Group wait
|
||||
- **[Group wait](#group-wait)**: the time to wait before sending the first notification for a new group of alerts.
|
||||
- **[Group interval](#group-interval)**: the time to wait before sending a notification about changes in the alert group.
|
||||
- **[Repeat interval](#repeat-interval)**: the time to wait before sending a notification if the group has not changed since the last notification.
|
||||
|
||||
Group wait is the amount of time Grafana waits before sending the first notification for a new group of alerts. The longer Group wait is the more time you have for other alerts to arrive. The shorter Group wait is the earlier the first notification is sent, but at the risk of sending incomplete notifications. You should always choose a Group wait that makes the most sense for your use case.
|
||||
These timers reduce the number of notifications sent. By delaying the delivery of notifications, incoming alerts can be grouped into just one notification instead of many.
|
||||
|
||||
**Default** 30 seconds
|
||||
{{< figure src="/media/docs/alerting/alerting-timing-options-flowchart-v2.png" max-width="750px" alt="A basic sequence diagram of the the notification policy timers" caption="A basic sequence diagram of the notification policy timers" >}}
|
||||
|
||||
#### Group interval
|
||||
<!--
|
||||
flowchart LR
|
||||
A((First alert)) -///-> B
|
||||
B[Group wait <br/> notification] -///-> C
|
||||
B -- no changes -///-> D
|
||||
C[Group interval <br/> notification] -- no changes -///-> D
|
||||
C -- group changes -///-> C
|
||||
D[Repeat interval <br/> notification]
|
||||
-->
|
||||
|
||||
Once the first notification has been sent for a new group of alerts, the Group interval timer starts. This is the amount of wait time before notifications about changes to the group are sent. For example, another firing alert might have just been added to the group while an existing alert might have resolved. If an alert was too late to be included in the first notification due to Group wait, it is included in subsequent notifications after Group interval. Once Group interval has elapsed, Grafana resets the Group interval timer. This repeats until there are no more alerts in the group after which the group is deleted.
|
||||
### Group wait
|
||||
|
||||
**Default** 5 minutes
|
||||
**Default**: 30 seconds
|
||||
|
||||
#### Repeat interval
|
||||
Group wait is the duration Grafana waits before sending the first notification for a new group of alerts.
|
||||
|
||||
Repeat interval decides how often notifications are repeated if the group has not changed since the last notification. You can think of these as reminders that some alerts are still firing. Repeat interval is closely related to Group interval, which means your Repeat interval must not only be greater than or equal to Group interval, but also must be a multiple of Group interval. If Repeat interval is not a multiple of Group interval it is coerced into one. For example, if your Group interval is 5 minutes, and your Repeat interval is 9 minutes, the Repeat interval is rounded up to the nearest multiple of 5 which is 10 minutes.
|
||||
The longer the group wait, the more time other alerts have to be included in the initial notification of the new group. The shorter the group wait, the earlier the first notification is sent, but at the risk of not including some alerts.
|
||||
|
||||
**Default** 4 hours
|
||||
**Example**
|
||||
|
||||
Consider a notification policy that:
|
||||
|
||||
- Matches all alert instances with the `team` label—matching labels equals to `team=~".*"`.
|
||||
- Groups notifications by the `team` label—one group for each distinct `team`.
|
||||
- Sets the Group wait timer to `30s`.
|
||||
|
||||
| Time | Incoming alert instance | Notification policy group | Number of instances | |
|
||||
| ------------------ | ------------------------------- | ------------------------- | ------------------- | ----------------------------------------------------------------------- |
|
||||
| 00:00 | `alert_name=f1` `team=frontend` | `frontend` | 1 | Starts the group wait timer of the `frontend` group. |
|
||||
| 00:10 | `alert_name=f2` `team=frontend` | `frontend` | 2 | |
|
||||
| 00:20 | `alert_name=b1` `team=backend` | `backend` | 1 | Starts the group wait timer of the `backend` group. |
|
||||
| 00:30<sup>\*</sup> | | `frontend` | 2 | Group wait elapsed. <br/> Send initial notification reporting 2 alerts. |
|
||||
| 00:35 | `alert_name=b2` `team=backend` | `backend` | 2 | |
|
||||
| 00:40 | `alert_name=b3` `team=backend` | `backend` | 3 | |
|
||||
| 00:50<sup>\*</sup> | | `backend` | 3 | Group wait elapsed. <br/> Send initial notification reporting 3 alerts. |
|
||||
|
||||
### Group interval
|
||||
|
||||
**Default**: 5 minutes
|
||||
|
||||
If an alert was too late to be included in the first notification due to group wait, it is included in subsequent notifications after group interval.
|
||||
|
||||
Group interval is the duration to wait before sending notifications about group changes. For instance, a group change may be adding a new firing alert to the group, or resolving an existing alert.
|
||||
|
||||
**Example**
|
||||
|
||||
Here are the related excerpts from the previous example:
|
||||
|
||||
| Time | Incoming alert instance | Notification policy group | Number of instances | |
|
||||
| ------------------ | ----------------------- | ------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| 00:30<sup>\*</sup> | | `frontend` | 2 | Group wait elapsed and starts Group interval timer. <br/> Send initial notification reporting 2 alerts. |
|
||||
| 00:50<sup>\*</sup> | | `backend` | 3 | Group wait elapsed and starts Group interval timer. <br/> Send initial notification reporting 3 alerts. |
|
||||
|
||||
And below is the continuation of the example setting the Group interval timer to 5 minutes:
|
||||
|
||||
| Time | Incoming alert instance | Notification policy group | Number of instances | |
|
||||
| ------------------ | ------------------------------- | ------------------------- | ------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| 01:30 | `alert_name=f3` `team=frontend` | `frontend` | 3 | |
|
||||
| 02:30 | `alert_name=f4` `team=frontend` | `frontend` | 4 | |
|
||||
| 05:30<sup>\*</sup> | | `frontend` | 4 | Group interval elapsed and resets timer. <br/> Send one notification reporting 4 alerts. |
|
||||
| 05:50<sup>\*</sup> | | `backend` | 3 | Group interval elapsed and resets timer. <br/> No group changes, and do not send notification. |
|
||||
| 08:00 | `alert_name=f4` `team=backend` | `backend` | 4 | |
|
||||
| 10:30<sup>\*</sup> | | `frontend` | 4 | Group interval elapsed and resets timer. <br/> No group changes, and do not send notification. |
|
||||
| 10:50<sup>\*</sup> | | `backend` | 4 | Group interval elapsed and resets timer. <br/> Send one notification reporting 4 alerts. |
|
||||
|
||||
**How it works**
|
||||
|
||||
Once the first notification has been sent for a new group of alerts, the group interval timer starts.
|
||||
|
||||
When the group interval timer elapses, the system resets the group interval timer and sends a notification only if there were group changes. This process repeats until there are no more alerts.
|
||||
|
||||
It's important to note that an alert instance exits the group after being resolved and notified of its state change. When no alerts remain, the group is deleted, and then the group wait timer handles the first notification for the next incoming alert once again.
|
||||
|
||||
### Repeat interval
|
||||
|
||||
**Default**: 4 hours
|
||||
|
||||
Repeat interval acts as a reminder that alerts in the group are still firing.
|
||||
|
||||
The repeat interval timer decides how often notifications are sent (or repeated) if the group has not changed since the last notification.
|
||||
|
||||
**How it works**
|
||||
|
||||
Repeat interval is evaluated every time the group interval resets. If the alert group has not changed and the time since the last notification was longer than the repeat interval, then a notification is sent as a reminder that the alerts are still firing.
|
||||
|
||||
Repeat interval must not only be greater than or equal to group interval, but also must be a multiple of Group interval. If Repeat interval is not a multiple of group interval it is coerced into one. For example, if your Group interval is 5 minutes, and your Repeat interval is 9 minutes, the Repeat interval is rounded up to the nearest multiple of 5 which is 10 minutes.
|
||||
|
||||
**Example**
|
||||
|
||||
Here are the related excerpts from the previous example:
|
||||
|
||||
| Time | Incoming alert instance | Notification policy group | Number of instances | |
|
||||
| ------------------ | ----------------------- | ------------------------- | ------------------- | -------------------------------------------------------- |
|
||||
| 05:30<sup>\*</sup> | | `frontend` | 4 | Group interval resets. <br/> Send the last notification. |
|
||||
| 10:50<sup>\*</sup> | | `backend` | 4 | Group interval resets. <br/> Send the last notification. |
|
||||
|
||||
And below is the continuation of the example setting the Repeat interval timer to 4 hours:
|
||||
|
||||
| Time | Incoming alert instance | Notification policy group | Number of instances | |
|
||||
| -------- | ----------------------- | ------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 04:05:30 | | `frontend` | 4 | Group interval resets. The time since the last notification was no longer than the repeat interval. |
|
||||
| 04:10:30 | | `frontend` | 4 | Group interval resets. The time since the last notification was longer than the repeat interval. <br/> Send one notification reminding the 4 firing alerts. |
|
||||
| 04:10:50 | | `backend` | 4 | Group interval resets. The time since the last notification was no longer than the repeat interval. |
|
||||
| 04:15:50 | | `backend` | 4 | Group interval resets. The time since the last notification was longer than the repeat interval. <br/> Send one notification reminding the 4 firing alerts. |
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { expect, test, DashboardPage } from '@grafana/plugin-e2e';
|
||||
import { expect, test } from '@grafana/plugin-e2e';
|
||||
|
||||
import { formatExpectError } from '../errors';
|
||||
import { successfulDataQuery } from '../mocks/queries';
|
||||
@ -71,7 +71,7 @@ test.describe('dashboard page', () => {
|
||||
test('getting panel by id', async ({ gotoDashboardPage }) => {
|
||||
const dashboardPage = await gotoDashboardPage(REACT_TABLE_DASHBOARD);
|
||||
await dashboardPage.goto();
|
||||
const panel = await dashboardPage.getPanelById('4');
|
||||
const panel = await dashboardPage.getPanelByTitle('Colored background');
|
||||
await expect(panel.fieldNames, formatExpectError('Could not locate header elements in table panel')).toContainText([
|
||||
'Field',
|
||||
'Max',
|
||||
|
20
e2e/scenes/various-suite/bar-gauge.spec.ts
Normal file
20
e2e/scenes/various-suite/bar-gauge.spec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { e2e } from '../utils';
|
||||
|
||||
describe('Bar Gauge Panel', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
it('Bar Gauge rendering e2e tests', () => {
|
||||
// open Panel Tests - Bar Gauge
|
||||
e2e.flows.openDashboard({ uid: 'O6f11TZWk' });
|
||||
|
||||
cy.get(
|
||||
`[data-viz-panel-key="panel-6"] [data-testid^="${selectors.components.Panels.Visualization.BarGauge.valueV2}"]`
|
||||
)
|
||||
.should('have.css', 'color', 'rgb(242, 73, 92)')
|
||||
.contains('100');
|
||||
});
|
||||
});
|
76
e2e/scenes/various-suite/exemplars.spec.ts
Normal file
76
e2e/scenes/various-suite/exemplars.spec.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { e2e } from '../utils';
|
||||
import { waitForMonacoToLoad } from '../utils/support/monaco';
|
||||
|
||||
const dataSourceName = 'PromExemplar';
|
||||
const addDataSource = () => {
|
||||
e2e.flows.addDataSource({
|
||||
type: 'Prometheus',
|
||||
expectedAlertMessage: 'Prometheus',
|
||||
name: dataSourceName,
|
||||
form: () => {
|
||||
e2e.components.DataSource.Prometheus.configPage.exemplarsAddButton().click();
|
||||
e2e.components.DataSource.Prometheus.configPage.internalLinkSwitch().check({ force: true });
|
||||
e2e.components.DataSource.Prometheus.configPage.connectionSettings().type('http://prom-url:9090');
|
||||
cy.get('[data-testid="data-testid Data source picker select container"]').click();
|
||||
|
||||
cy.contains('gdev-tempo').scrollIntoView().should('be.visible').click();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('Exemplars', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
|
||||
cy.request({
|
||||
url: `${Cypress.env('BASE_URL')}/api/datasources/name/${dataSourceName}`,
|
||||
failOnStatusCode: false,
|
||||
}).then((response) => {
|
||||
if (response.isOkStatusCode) {
|
||||
return;
|
||||
}
|
||||
addDataSource();
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to navigate to configured data source', () => {
|
||||
cy.intercept(
|
||||
{
|
||||
pathname: '/api/ds/query',
|
||||
},
|
||||
(req) => {
|
||||
const datasourceType = req.body.queries[0].datasource.type;
|
||||
if (datasourceType === 'prometheus') {
|
||||
req.reply({ fixture: 'exemplars-query-response.json' });
|
||||
} else if (datasourceType === 'tempo') {
|
||||
req.reply({ fixture: 'tempo-response.json' });
|
||||
} else {
|
||||
req.reply({});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
e2e.pages.Explore.visit();
|
||||
|
||||
e2e.components.DataSourcePicker.container().should('be.visible').click();
|
||||
cy.contains(dataSourceName).scrollIntoView().should('be.visible').click();
|
||||
|
||||
// Switch to code editor
|
||||
e2e.components.RadioButton.container().filter(':contains("Code")').click();
|
||||
|
||||
// Wait for lazy loading Monaco
|
||||
waitForMonacoToLoad();
|
||||
|
||||
e2e.components.TimePicker.openButton().click();
|
||||
e2e.components.TimePicker.fromField().clear().type('2021-07-10 17:10:00');
|
||||
e2e.components.TimePicker.toField().clear().type('2021-07-10 17:30:00');
|
||||
e2e.components.TimePicker.applyTimeRange().click();
|
||||
e2e.components.QueryField.container().should('be.visible').type('exemplar-query_bucket{shift}{enter}');
|
||||
|
||||
cy.get(`[data-testid="time-series-zoom-to-data"]`).click();
|
||||
|
||||
e2e.components.DataSource.Prometheus.exemplarMarker().first().trigger('mousemove', { force: true });
|
||||
cy.contains('Query with gdev-tempo').click();
|
||||
e2e.components.TraceViewer.spanBar().should('have.length', 11);
|
||||
});
|
||||
});
|
22
e2e/scenes/various-suite/explore.spec.ts
Normal file
22
e2e/scenes/various-suite/explore.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { e2e } from '../utils';
|
||||
|
||||
describe('Explore', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
it('Basic path through Explore.', () => {
|
||||
e2e.pages.Explore.visit();
|
||||
e2e.pages.Explore.General.container().should('have.length', 1);
|
||||
e2e.components.RefreshPicker.runButtonV2().should('have.length', 1);
|
||||
|
||||
e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer()
|
||||
.scrollIntoView()
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
cy.get('input[id*="test-data-scenario-select-"]').should('be.visible').click();
|
||||
});
|
||||
|
||||
cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click();
|
||||
});
|
||||
});
|
72
e2e/scenes/various-suite/filter-annotations.spec.ts
Normal file
72
e2e/scenes/various-suite/filter-annotations.spec.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { e2e } from '../utils';
|
||||
const DASHBOARD_ID = 'ed155665';
|
||||
|
||||
describe('Annotations filtering', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
it('Tests switching filter type updates the UI accordingly', () => {
|
||||
e2e.flows.openDashboard({ uid: DASHBOARD_ID });
|
||||
|
||||
e2e.components.NavToolbar.editDashboard.editButton().should('be.visible').click();
|
||||
e2e.components.NavToolbar.editDashboard.settingsButton().should('be.visible').click();
|
||||
|
||||
e2e.components.Tab.title('Annotations').click();
|
||||
cy.contains('New query').click();
|
||||
e2e.pages.Dashboard.Settings.Annotations.Settings.name().clear().type('Red - Panel two');
|
||||
|
||||
e2e.pages.Dashboard.Settings.Annotations.NewAnnotation.showInLabel()
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
// All panels
|
||||
e2e.components.Annotations.annotationsTypeInput().find('input').type('All panels{enter}', { force: true });
|
||||
e2e.components.Annotations.annotationsChoosePanelInput().should('not.exist');
|
||||
|
||||
// All panels except
|
||||
e2e.components.Annotations.annotationsTypeInput()
|
||||
.find('input')
|
||||
.type('All panels except{enter}', { force: true });
|
||||
e2e.components.Annotations.annotationsChoosePanelInput().should('be.visible');
|
||||
|
||||
// Selected panels
|
||||
e2e.components.Annotations.annotationsTypeInput().find('input').type('Selected panels{enter}', { force: true });
|
||||
e2e.components.Annotations.annotationsChoosePanelInput()
|
||||
.should('be.visible')
|
||||
.find('input')
|
||||
.type('Panel two{enter}', { force: true });
|
||||
});
|
||||
|
||||
cy.get('body').click();
|
||||
|
||||
e2e.components.NavToolbar.editDashboard.backToDashboardButton().should('be.visible').click();
|
||||
|
||||
e2e.pages.Dashboard.Controls()
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemLabels('Red - Panel two')
|
||||
.should('be.visible')
|
||||
.parent()
|
||||
.within((el) => {
|
||||
cy.get('input')
|
||||
.should('be.checked')
|
||||
.uncheck({ force: true })
|
||||
.should('not.be.checked')
|
||||
.check({ force: true });
|
||||
});
|
||||
|
||||
e2e.pages.Dashboard.SubMenu.submenuItemLabels('Red, only panel 1')
|
||||
.should('be.visible')
|
||||
.parent()
|
||||
.within((el) => {
|
||||
cy.get('input').should('be.checked');
|
||||
});
|
||||
});
|
||||
|
||||
e2e.components.Panels.Panel.title('Panel one')
|
||||
.should('exist')
|
||||
.within(() => {
|
||||
e2e.pages.Dashboard.Annotations.marker().should('exist').should('have.length', 4);
|
||||
});
|
||||
});
|
||||
});
|
71
e2e/scenes/various-suite/frontend-sandbox-app.spec.ts
Normal file
71
e2e/scenes/various-suite/frontend-sandbox-app.spec.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { e2e } from '../utils';
|
||||
|
||||
const APP_ID = 'sandbox-app-test';
|
||||
|
||||
describe('Datasource sandbox', () => {
|
||||
before(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true);
|
||||
cy.request({
|
||||
url: `${Cypress.env('BASE_URL')}/api/plugins/${APP_ID}/settings`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true);
|
||||
});
|
||||
|
||||
describe('App Page', () => {
|
||||
describe('Sandbox disabled', () => {
|
||||
beforeEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0');
|
||||
});
|
||||
});
|
||||
|
||||
it('Loads the app page without the sandbox div wrapper', () => {
|
||||
cy.visit(`/a/${APP_ID}`);
|
||||
cy.wait(200); // wait to prevent false positives because cypress checks too fast
|
||||
cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('not.exist');
|
||||
cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist');
|
||||
});
|
||||
|
||||
it('Loads the app configuration without the sandbox div wrapper', () => {
|
||||
cy.visit(`/plugins/${APP_ID}`);
|
||||
cy.wait(200); // wait to prevent false positives because cypress checks too fast
|
||||
cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('not.exist');
|
||||
cy.get('div[data-testid="sandbox-app-test-config-page"]').should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sandbox enabled', () => {
|
||||
beforeEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1');
|
||||
});
|
||||
});
|
||||
|
||||
it('Loads the app page with the sandbox div wrapper', () => {
|
||||
cy.visit(`/a/${APP_ID}`);
|
||||
cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist');
|
||||
cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist');
|
||||
});
|
||||
|
||||
it('Loads the app configuration with the sandbox div wrapper', () => {
|
||||
cy.visit(`/plugins/${APP_ID}`);
|
||||
cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist');
|
||||
cy.get('div[data-testid="sandbox-app-test-config-page"]').should('exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
e2e.flows.revertAllChanges();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cy.clearCookies();
|
||||
});
|
||||
});
|
145
e2e/scenes/various-suite/frontend-sandbox-datasource.spec.ts
Normal file
145
e2e/scenes/various-suite/frontend-sandbox-datasource.spec.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { random } from 'lodash';
|
||||
|
||||
import { e2e } from '../utils';
|
||||
|
||||
const DATASOURCE_ID = 'sandbox-test-datasource';
|
||||
let DATASOURCE_CONNECTION_ID = '';
|
||||
const DATASOURCE_TYPED_NAME = 'SandboxDatasourceInstance';
|
||||
|
||||
describe('Datasource sandbox', () => {
|
||||
before(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true);
|
||||
|
||||
e2e.pages.AddDataSource.visit();
|
||||
e2e.pages.AddDataSource.dataSourcePluginsV2('Sandbox datasource test plugin')
|
||||
.scrollIntoView()
|
||||
.should('be.visible') // prevents flakiness
|
||||
.click();
|
||||
e2e.pages.DataSource.name().clear();
|
||||
e2e.pages.DataSource.name().type(DATASOURCE_TYPED_NAME);
|
||||
e2e.pages.DataSource.saveAndTest().click();
|
||||
cy.url().then((url) => {
|
||||
const split = url.split('/');
|
||||
DATASOURCE_CONNECTION_ID = split[split.length - 1];
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true);
|
||||
});
|
||||
|
||||
describe('Config Editor', () => {
|
||||
describe('Sandbox disabled', () => {
|
||||
beforeEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0');
|
||||
});
|
||||
});
|
||||
it('Should not render a sandbox wrapper around the datasource config editor', () => {
|
||||
e2e.pages.EditDataSource.visit(DATASOURCE_CONNECTION_ID);
|
||||
cy.wait(300); // wait to prevent false positives because cypress checks too fast
|
||||
cy.get(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`).should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sandbox enabled', () => {
|
||||
beforeEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1');
|
||||
});
|
||||
});
|
||||
|
||||
it('Should render a sandbox wrapper around the datasource config editor', () => {
|
||||
e2e.pages.EditDataSource.visit(DATASOURCE_CONNECTION_ID);
|
||||
cy.get(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`).should('exist');
|
||||
});
|
||||
|
||||
it('Should store values in jsonData and secureJsonData correctly', () => {
|
||||
e2e.pages.EditDataSource.visit(DATASOURCE_CONNECTION_ID);
|
||||
|
||||
const valueToStore = 'test' + random(100);
|
||||
|
||||
cy.get('[data-testid="sandbox-config-editor-query-input"]').should('not.be.disabled');
|
||||
cy.get('[data-testid="sandbox-config-editor-query-input"]').type(valueToStore);
|
||||
cy.get('[data-testid="sandbox-config-editor-query-input"]').should('have.value', valueToStore);
|
||||
|
||||
e2e.pages.DataSource.saveAndTest().click();
|
||||
e2e.pages.DataSource.alert().should('exist').contains('Sandbox Success', {});
|
||||
|
||||
// validate the value was stored
|
||||
e2e.pages.EditDataSource.visit(DATASOURCE_CONNECTION_ID);
|
||||
cy.get('[data-testid="sandbox-config-editor-query-input"]').should('not.be.disabled');
|
||||
cy.get('[data-testid="sandbox-config-editor-query-input"]').should('have.value', valueToStore);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Explore Page', () => {
|
||||
describe('Sandbox disabled', () => {
|
||||
beforeEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0');
|
||||
});
|
||||
});
|
||||
|
||||
it('Should not wrap the query editor in a sandbox wrapper', () => {
|
||||
e2e.pages.Explore.visit();
|
||||
e2e.components.DataSourcePicker.container().should('be.visible').click();
|
||||
cy.contains(DATASOURCE_TYPED_NAME).scrollIntoView().should('be.visible').click();
|
||||
|
||||
cy.wait(300); // wait to prevent false positives because cypress checks too fast
|
||||
cy.get(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`).should('not.exist');
|
||||
});
|
||||
|
||||
it('Should accept values when typed', () => {
|
||||
e2e.pages.Explore.visit();
|
||||
e2e.components.DataSourcePicker.container().should('be.visible').click();
|
||||
cy.contains(DATASOURCE_TYPED_NAME).scrollIntoView().should('be.visible').click();
|
||||
|
||||
const valueToType = 'test' + random(100);
|
||||
|
||||
cy.get('[data-testid="sandbox-query-editor-query-input"]').should('not.be.disabled');
|
||||
cy.get('[data-testid="sandbox-query-editor-query-input"]').type(valueToType);
|
||||
cy.get('[data-testid="sandbox-query-editor-query-input"]').should('have.value', valueToType);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sandbox enabled', () => {
|
||||
beforeEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1');
|
||||
});
|
||||
});
|
||||
|
||||
it('Should wrap the query editor in a sandbox wrapper', () => {
|
||||
e2e.pages.Explore.visit();
|
||||
e2e.components.DataSourcePicker.container().should('be.visible').click();
|
||||
cy.contains(DATASOURCE_TYPED_NAME).scrollIntoView().should('be.visible').click();
|
||||
|
||||
cy.get(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`).should('exist');
|
||||
});
|
||||
|
||||
it('Should accept values when typed', () => {
|
||||
e2e.pages.Explore.visit();
|
||||
e2e.components.DataSourcePicker.container().should('be.visible').click();
|
||||
cy.contains(DATASOURCE_TYPED_NAME).scrollIntoView().should('be.visible').click();
|
||||
|
||||
const valueToType = 'test' + random(100);
|
||||
|
||||
cy.get('[data-testid="sandbox-query-editor-query-input"]').should('not.be.disabled');
|
||||
cy.get('[data-testid="sandbox-query-editor-query-input"]').type(valueToType);
|
||||
cy.get('[data-testid="sandbox-query-editor-query-input"]').should('have.value', valueToType);
|
||||
|
||||
// typing the query editor should reflect in the url
|
||||
cy.url().should('include', valueToType);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
e2e.flows.revertAllChanges();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cy.clearCookies();
|
||||
});
|
||||
});
|
18
e2e/scenes/various-suite/gauge.spec.ts
Normal file
18
e2e/scenes/various-suite/gauge.spec.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { e2e } from '../utils';
|
||||
|
||||
describe('Gauge Panel', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
it('Gauge rendering e2e tests', () => {
|
||||
// open Panel Tests - Gauge
|
||||
e2e.flows.openDashboard({ uid: '_5rDmaQiz' });
|
||||
|
||||
// check that gauges are rendered
|
||||
cy.get('body').find(`.flot-base`).should('have.length', 16);
|
||||
|
||||
// check that no panel errors exist
|
||||
e2e.components.Panels.Panel.headerCornerInfo('error').should('not.exist');
|
||||
});
|
||||
});
|
81
e2e/scenes/various-suite/graph-auto-migrate.spec.ts
Normal file
81
e2e/scenes/various-suite/graph-auto-migrate.spec.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { e2e } from '../utils';
|
||||
|
||||
const DASHBOARD_ID = 'XMjIZPmik';
|
||||
const DASHBOARD_NAME = 'Panel Tests - Graph Time Regions';
|
||||
const UPLOT_MAIN_DIV_SELECTOR = '[data-testid="uplot-main-div"]';
|
||||
|
||||
describe('Auto-migrate graph panel', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
it('Graph panel is migrated with `autoMigrateOldPanels` feature toggle', () => {
|
||||
e2e.flows.openDashboard({ uid: DASHBOARD_ID });
|
||||
cy.contains(DASHBOARD_NAME).should('be.visible');
|
||||
cy.get(UPLOT_MAIN_DIV_SELECTOR).should('not.exist');
|
||||
|
||||
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.autoMigrateOldPanels': true } });
|
||||
|
||||
cy.get(UPLOT_MAIN_DIV_SELECTOR).should('exist');
|
||||
});
|
||||
|
||||
it('Graph panel is migrated with config `disableAngular` feature toggle', () => {
|
||||
e2e.flows.openDashboard({ uid: DASHBOARD_ID });
|
||||
cy.contains(DASHBOARD_NAME).should('be.visible');
|
||||
cy.get(UPLOT_MAIN_DIV_SELECTOR).should('not.exist');
|
||||
|
||||
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.disableAngular': true } });
|
||||
|
||||
cy.get(UPLOT_MAIN_DIV_SELECTOR).should('exist');
|
||||
});
|
||||
|
||||
it('Graph panel is migrated with `autoMigrateGraphPanel` feature toggle', () => {
|
||||
e2e.flows.openDashboard({ uid: DASHBOARD_ID });
|
||||
cy.contains(DASHBOARD_NAME).should('be.visible');
|
||||
cy.get(UPLOT_MAIN_DIV_SELECTOR).should('not.exist');
|
||||
|
||||
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.autoMigrateGraphPanel': true } });
|
||||
|
||||
cy.get(UPLOT_MAIN_DIV_SELECTOR).should('exist');
|
||||
});
|
||||
|
||||
it('Annotation markers exist for time regions', () => {
|
||||
e2e.flows.openDashboard({ uid: DASHBOARD_ID });
|
||||
cy.contains(DASHBOARD_NAME).should('be.visible');
|
||||
cy.get(UPLOT_MAIN_DIV_SELECTOR).should('not.exist');
|
||||
|
||||
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.autoMigrateGraphPanel': true } });
|
||||
|
||||
e2e.components.Panels.Panel.title('Business Hours')
|
||||
.should('exist')
|
||||
.within(() => {
|
||||
e2e.pages.Dashboard.Annotations.marker().should('exist');
|
||||
});
|
||||
|
||||
e2e.components.Panels.Panel.title("Sunday's 20-23")
|
||||
.should('exist')
|
||||
.within(() => {
|
||||
e2e.pages.Dashboard.Annotations.marker().should('exist');
|
||||
});
|
||||
|
||||
e2e.components.Panels.Panel.title('Each day of week')
|
||||
.should('exist')
|
||||
.within(() => {
|
||||
e2e.pages.Dashboard.Annotations.marker().should('exist');
|
||||
});
|
||||
|
||||
cy.get('#pageContent .scrollbar-view').first().scrollTo('bottom');
|
||||
|
||||
e2e.components.Panels.Panel.title('05:00')
|
||||
.should('exist')
|
||||
.within(() => {
|
||||
e2e.pages.Dashboard.Annotations.marker().should('exist');
|
||||
});
|
||||
|
||||
e2e.components.Panels.Panel.title('From 22:00 to 00:30 (crossing midnight)')
|
||||
.should('exist')
|
||||
.within(() => {
|
||||
e2e.pages.Dashboard.Annotations.marker().should('exist');
|
||||
});
|
||||
});
|
||||
});
|
62
e2e/scenes/various-suite/helpers/prometheus-helpers.ts
Normal file
62
e2e/scenes/various-suite/helpers/prometheus-helpers.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { e2e } from '../../utils';
|
||||
|
||||
/**
|
||||
* Create a Prom data source
|
||||
*/
|
||||
export function createPromDS(dataSourceID: string, name: string): void {
|
||||
// login
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true);
|
||||
|
||||
// select the prometheus DS
|
||||
e2e.pages.AddDataSource.visit();
|
||||
e2e.pages.AddDataSource.dataSourcePluginsV2(dataSourceID)
|
||||
.scrollIntoView()
|
||||
.should('be.visible') // prevents flakiness
|
||||
.click();
|
||||
|
||||
// add url for DS to save without error
|
||||
e2e.components.DataSource.Prometheus.configPage.connectionSettings().type('http://prom-url:9090');
|
||||
|
||||
// name the DS
|
||||
e2e.pages.DataSource.name().clear();
|
||||
e2e.pages.DataSource.name().type(name);
|
||||
e2e.pages.DataSource.saveAndTest().click();
|
||||
}
|
||||
|
||||
export function getResources() {
|
||||
cy.intercept(/__name__/g, metricResponse);
|
||||
|
||||
cy.intercept(/metadata/g, metadataResponse);
|
||||
|
||||
cy.intercept(/labels/g, labelsResponse);
|
||||
}
|
||||
|
||||
const metricResponse = {
|
||||
status: 'success',
|
||||
data: ['metric1', 'metric2'],
|
||||
};
|
||||
|
||||
const metadataResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
metric1: [
|
||||
{
|
||||
type: 'counter',
|
||||
help: 'metric1 help',
|
||||
unit: '',
|
||||
},
|
||||
],
|
||||
metric2: [
|
||||
{
|
||||
type: 'counter',
|
||||
help: 'metric2 help',
|
||||
unit: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const labelsResponse = {
|
||||
status: 'success',
|
||||
data: ['__name__', 'action', 'active', 'backend'],
|
||||
};
|
123
e2e/scenes/various-suite/inspect-drawer.spec.ts
Normal file
123
e2e/scenes/various-suite/inspect-drawer.spec.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { e2e } from '../utils';
|
||||
|
||||
const PANEL_UNDER_TEST = 'Value reducers 1';
|
||||
|
||||
describe('Inspect drawer tests', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
it('Tests various Inspect Drawer scenarios', () => {
|
||||
// @ts-ignore some typing issue
|
||||
cy.on('uncaught:exception', (err) => {
|
||||
if (err.stack?.indexOf("TypeError: Cannot read property 'getText' of null") !== -1) {
|
||||
// On occasion monaco editor will not have the time to be properly unloaded when we change the tab
|
||||
// and then the e2e test fails with the uncaught:exception:
|
||||
// TypeError: Cannot read property 'getText' of null
|
||||
// at Object.ai [as getFoldingRanges] (http://localhost:3001/public/build/monaco-json.worker.js:2:215257)
|
||||
// at e.getFoldingRanges (http://localhost:3001/public/build/monaco-json.worker.js:2:221188)
|
||||
// at e.fmr (http://localhost:3001/public/build/monaco-json.worker.js:2:116605)
|
||||
// at e._handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:7414)
|
||||
// at Object.handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:7018)
|
||||
// at e._handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:5038)
|
||||
// at e.handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:4606)
|
||||
// at e.onmessage (http://localhost:3001/public/build/monaco-json.worker.js:2:7097)
|
||||
// at Tt.self.onmessage (http://localhost:3001/public/build/monaco-json.worker.js:2:117109)
|
||||
|
||||
// return false to prevent the error from
|
||||
// failing this test
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
e2e.flows.openDashboard({ uid: 'wfTJJL5Wz' });
|
||||
|
||||
// testing opening inspect drawer directly by clicking on Inspect in header menu
|
||||
e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Inspect, PANEL_UNDER_TEST);
|
||||
|
||||
expectDrawerTabsAndContent();
|
||||
|
||||
expectDrawerClose();
|
||||
|
||||
expectSubMenuScenario('Data');
|
||||
expectSubMenuScenario('Query');
|
||||
expectSubMenuScenario('Panel JSON', 'JSON');
|
||||
|
||||
e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST);
|
||||
|
||||
e2e.components.QueryTab.queryInspectorButton().should('be.visible').click();
|
||||
|
||||
e2e.components.Drawer.General.title(`Inspect: ${PANEL_UNDER_TEST}`)
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
e2e.components.Tab.title('Query').should('be.visible');
|
||||
// query should be the active tab
|
||||
e2e.components.Tab.active().should('have.text', 'Query');
|
||||
});
|
||||
|
||||
e2e.components.PanelInspector.Query.content().should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
const expectDrawerTabsAndContent = () => {
|
||||
e2e.components.Drawer.General.title(`Inspect: ${PANEL_UNDER_TEST}`)
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
e2e.components.Tab.title('Data').should('be.visible');
|
||||
// data should be the active tab
|
||||
e2e.components.Tab.active().within((li: JQuery<HTMLLIElement>) => {
|
||||
expect(li.text()).equals('Data');
|
||||
});
|
||||
e2e.components.PanelInspector.Data.content().should('be.visible');
|
||||
e2e.components.PanelInspector.Stats.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Json.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Query.content().should('not.exist');
|
||||
|
||||
// other tabs should also be visible, click on each to see if we get any console errors
|
||||
e2e.components.Tab.title('Stats').should('be.visible').click();
|
||||
e2e.components.PanelInspector.Stats.content().should('be.visible');
|
||||
e2e.components.PanelInspector.Data.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Json.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Query.content().should('not.exist');
|
||||
|
||||
e2e.components.Tab.title('JSON').should('be.visible').click();
|
||||
e2e.components.PanelInspector.Json.content().should('be.visible');
|
||||
e2e.components.PanelInspector.Data.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Stats.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Query.content().should('not.exist');
|
||||
|
||||
e2e.components.Tab.title('Query').should('be.visible').click();
|
||||
|
||||
e2e.components.PanelInspector.Query.content().should('be.visible');
|
||||
e2e.components.PanelInspector.Data.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Stats.content().should('not.exist');
|
||||
e2e.components.PanelInspector.Json.content().should('not.exist');
|
||||
});
|
||||
};
|
||||
|
||||
const expectDrawerClose = () => {
|
||||
// close using close button
|
||||
e2e.components.Drawer.General.close().click();
|
||||
e2e.components.Drawer.General.title(`Inspect: ${PANEL_UNDER_TEST}`).should('not.exist');
|
||||
};
|
||||
|
||||
const expectSubMenuScenario = (subMenu: string, tabTitle?: string) => {
|
||||
tabTitle = tabTitle ?? subMenu;
|
||||
// testing opening inspect drawer from sub menus under Inspect in header menu
|
||||
e2e.components.Panels.Panel.title(PANEL_UNDER_TEST).scrollIntoView().should('be.visible');
|
||||
e2e.components.Panels.Panel.menu(PANEL_UNDER_TEST).click({ force: true }); // force click because menu is hidden and show on hover
|
||||
// sub menus are in the DOM but not visible and because there is no hover support in Cypress force click
|
||||
// https://github.com/cypress-io/cypress-example-recipes/blob/master/examples/testing-dom__hover-hidden-elements/cypress/integration/hover-hidden-elements-spec.js
|
||||
|
||||
// simulate hover on Inspector menu item to display sub menus
|
||||
e2e.components.Panels.Panel.menuItems('Inspect').trigger('mouseover', { force: true });
|
||||
e2e.components.Panels.Panel.menuItems(subMenu).click({ force: true });
|
||||
|
||||
// data should be the default tab
|
||||
e2e.components.Tab.title(tabTitle).should('be.visible');
|
||||
e2e.components.Tab.active().should('have.text', tabTitle);
|
||||
|
||||
expectDrawerClose();
|
||||
};
|
79
e2e/scenes/various-suite/keybinds.spec.ts
Normal file
79
e2e/scenes/various-suite/keybinds.spec.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { e2e } from '../utils';
|
||||
import { fromBaseUrl } from '../utils/support/url';
|
||||
|
||||
describe('Keyboard shortcuts', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
|
||||
cy.visit(fromBaseUrl('/'));
|
||||
|
||||
// wait for the page to load
|
||||
e2e.components.Panels.Panel.title('Latest from the blog').should('be.visible');
|
||||
});
|
||||
|
||||
it('sequence shortcuts should work', () => {
|
||||
cy.get('body').type('ge');
|
||||
e2e.pages.Explore.General.container().should('be.visible');
|
||||
|
||||
cy.get('body').type('gp');
|
||||
e2e.components.UserProfile.preferencesSaveButton().should('be.visible');
|
||||
|
||||
cy.get('body').type('gh');
|
||||
e2e.components.Panels.Panel.title('Latest from the blog').should('be.visible');
|
||||
});
|
||||
|
||||
it('ctrl+z should zoom out the time range', () => {
|
||||
cy.get('body').type('ge');
|
||||
e2e.pages.Explore.General.container().should('be.visible');
|
||||
|
||||
// Time range is 1 minute, so each shortcut press should jump back or forward by 1 minute
|
||||
e2e.flows.setTimeRange({
|
||||
from: '2024-06-05 10:05:00',
|
||||
to: '2024-06-05 10:06:00',
|
||||
zone: 'Browser',
|
||||
});
|
||||
e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
|
||||
|
||||
cy.get('body').type('{ctrl}z');
|
||||
e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
|
||||
let expectedRange = `Time range selected: 2024-06-05 10:03:30 to 2024-06-05 10:07:30`;
|
||||
e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
|
||||
});
|
||||
|
||||
it('multiple time range shortcuts should work', () => {
|
||||
cy.get('body').type('ge');
|
||||
e2e.pages.Explore.General.container().should('be.visible');
|
||||
|
||||
// Time range is 1 minute, so each shortcut press should jump back or forward by 1 minute
|
||||
e2e.flows.setTimeRange({
|
||||
from: '2024-06-05 10:05:00',
|
||||
to: '2024-06-05 10:06:00',
|
||||
zone: 'Browser',
|
||||
});
|
||||
e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
|
||||
|
||||
cy.log('Trying one shift-left');
|
||||
cy.get('body').type('t{leftarrow}');
|
||||
e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
|
||||
let expectedRange = `Time range selected: 2024-06-05 10:04:00 to 2024-06-05 10:05:00`; // 1 min back
|
||||
e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
|
||||
|
||||
cy.log('Trying two shift-lefts');
|
||||
cy.get('body').type('t{leftarrow}');
|
||||
e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
|
||||
cy.get('body').type('t{leftarrow}');
|
||||
e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
|
||||
expectedRange = `Time range selected: 2024-06-05 10:02:00 to 2024-06-05 10:03:00`; // 2 mins back
|
||||
e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
|
||||
|
||||
cy.log('Trying two shift-lefts and a shift-right');
|
||||
cy.get('body').type('t{leftarrow}');
|
||||
e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
|
||||
cy.get('body').type('t{leftarrow}');
|
||||
e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
|
||||
cy.get('body').type('t{rightarrow}');
|
||||
e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query');
|
||||
expectedRange = `Time range selected: 2024-06-05 10:01:00 to 2024-06-05 10:02:00`; // 2 mins back, 1 min forward (1 min back total)
|
||||
e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange);
|
||||
});
|
||||
});
|
88
e2e/scenes/various-suite/loki-editor.spec.ts
Normal file
88
e2e/scenes/various-suite/loki-editor.spec.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { e2e } from '../utils';
|
||||
import { waitForMonacoToLoad } from '../utils/support/monaco';
|
||||
|
||||
const dataSourceName = 'LokiEditor';
|
||||
const addDataSource = () => {
|
||||
e2e.flows.addDataSource({
|
||||
type: 'Loki',
|
||||
expectedAlertMessage: 'Unable to connect with Loki. Please check the server logs for more details.',
|
||||
name: dataSourceName,
|
||||
form: () => {
|
||||
cy.get('#connection-url').type('http://loki-url:3100');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('Loki Query Editor', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
e2e.flows.revertAllChanges();
|
||||
});
|
||||
|
||||
it('Autocomplete features should work as expected.', () => {
|
||||
addDataSource();
|
||||
|
||||
cy.intercept(/labels?/, (req) => {
|
||||
req.reply({ status: 'success', data: ['instance', 'job', 'source'] });
|
||||
});
|
||||
|
||||
cy.intercept(/series?/, (req) => {
|
||||
req.reply({ status: 'success', data: [{ instance: 'instance1' }] });
|
||||
});
|
||||
|
||||
// Go to Explore and choose Loki data source
|
||||
e2e.pages.Explore.visit();
|
||||
e2e.components.DataSourcePicker.container().should('be.visible').click();
|
||||
cy.contains(dataSourceName).scrollIntoView().should('be.visible').click();
|
||||
|
||||
e2e.components.RadioButton.container().filter(':contains("Code")').click();
|
||||
|
||||
waitForMonacoToLoad();
|
||||
|
||||
// adds closing braces around empty value
|
||||
e2e.components.QueryField.container().type('time(');
|
||||
cy.get('.monaco-editor textarea:first').should(($el) => {
|
||||
expect($el.val()).to.eq('time()');
|
||||
});
|
||||
|
||||
// removes closing brace when opening brace is removed
|
||||
e2e.components.QueryField.container().type('{selectall}{backspace}avg_over_time({backspace}');
|
||||
cy.get('.monaco-editor textarea:first').should(($el) => {
|
||||
expect($el.val()).to.eq('avg_over_time');
|
||||
});
|
||||
|
||||
// keeps closing brace when opening brace is removed and inner values exist
|
||||
e2e.components.QueryField.container().type(
|
||||
'{selectall}{backspace}time(test{leftArrow}{leftArrow}{leftArrow}{leftArrow}{backspace}'
|
||||
);
|
||||
cy.get('.monaco-editor textarea:first').should(($el) => {
|
||||
expect($el.val()).to.eq('timetest)');
|
||||
});
|
||||
|
||||
// overrides an automatically inserted brace
|
||||
e2e.components.QueryField.container().type('{selectall}{backspace}time()');
|
||||
cy.get('.monaco-editor textarea:first').should(($el) => {
|
||||
expect($el.val()).to.eq('time()');
|
||||
});
|
||||
|
||||
// does not override manually inserted braces
|
||||
e2e.components.QueryField.container().type('{selectall}{backspace}))');
|
||||
cy.get('.monaco-editor textarea:first').should(($el) => {
|
||||
expect($el.val()).to.eq('))');
|
||||
});
|
||||
|
||||
/** Runner plugin */
|
||||
|
||||
// Should execute the query when enter with shift is pressed
|
||||
e2e.components.QueryField.container().type('{selectall}{backspace}{shift+enter}');
|
||||
cy.get('[data-testid="explore-no-data"]').should('be.visible');
|
||||
|
||||
/** Suggestions plugin */
|
||||
e2e.components.QueryField.container().type('{selectall}av');
|
||||
cy.contains('avg').should('be.visible');
|
||||
cy.contains('avg_over_time').should('be.visible');
|
||||
});
|
||||
});
|
106
e2e/scenes/various-suite/loki-query-builder.spec.ts
Normal file
106
e2e/scenes/various-suite/loki-query-builder.spec.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { e2e } from '../utils';
|
||||
|
||||
const MISSING_LABEL_FILTER_ERROR_MESSAGE = 'Select at least 1 label filter (label and value)';
|
||||
const dataSourceName = 'LokiBuilder';
|
||||
const addDataSource = () => {
|
||||
e2e.flows.addDataSource({
|
||||
type: 'Loki',
|
||||
expectedAlertMessage: 'Unable to connect with Loki. Please check the server logs for more details.',
|
||||
name: dataSourceName,
|
||||
form: () => {
|
||||
cy.get('#connection-url').type('http://loki-url:3100');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const finalQuery = 'rate({instance=~"instance1|instance2"} | logfmt | __error__=`` [$__auto]';
|
||||
|
||||
describe('Loki query builder', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
|
||||
cy.request({
|
||||
url: `${Cypress.env('BASE_URL')}/api/datasources/name/${dataSourceName}`,
|
||||
failOnStatusCode: false,
|
||||
}).then((response) => {
|
||||
if (response.isOkStatusCode) {
|
||||
return;
|
||||
}
|
||||
addDataSource();
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to use all modes', () => {
|
||||
cy.intercept(/labels\?/, (req) => {
|
||||
req.reply({ status: 'success', data: ['instance', 'job', 'source'] });
|
||||
}).as('labelsRequest');
|
||||
|
||||
cy.intercept(/series?/, (req) => {
|
||||
req.reply({ status: 'success', data: [{ instance: 'instance1' }] });
|
||||
});
|
||||
|
||||
cy.intercept(/values/, (req) => {
|
||||
req.reply({ status: 'success', data: ['instance1', 'instance2'] });
|
||||
}).as('valuesRequest');
|
||||
|
||||
cy.intercept(/index\/stats/, (req) => {
|
||||
req.reply({ streams: 2, chunks: 2660, bytes: 2721792, entries: 14408 });
|
||||
});
|
||||
|
||||
// Go to Explore and choose Loki data source
|
||||
e2e.pages.Explore.visit();
|
||||
e2e.components.DataSourcePicker.container().should('be.visible').click();
|
||||
cy.contains(dataSourceName).scrollIntoView().should('be.visible').click();
|
||||
|
||||
// Start in builder mode, click and choose query pattern
|
||||
e2e.components.QueryBuilder.queryPatterns().click();
|
||||
cy.contains('Log query starters').click();
|
||||
cy.contains('Use this query').click();
|
||||
cy.contains('No pipeline errors').should('be.visible');
|
||||
cy.contains('Logfmt').should('be.visible');
|
||||
cy.contains('{} | logfmt | __error__=``').should('be.visible');
|
||||
|
||||
// Add operation
|
||||
cy.contains('Operations').should('be.visible').click();
|
||||
cy.contains('Range functions').should('be.visible').click();
|
||||
cy.contains('Rate').should('be.visible').click();
|
||||
cy.contains('rate({} | logfmt | __error__=`` [$__auto]').should('be.visible');
|
||||
|
||||
// Check for expected error
|
||||
cy.contains(MISSING_LABEL_FILTER_ERROR_MESSAGE).should('be.visible');
|
||||
|
||||
// Add labels to remove error
|
||||
e2e.components.QueryBuilder.labelSelect().should('be.visible').click();
|
||||
// wait until labels are loaded and set on the component before starting to type
|
||||
e2e.components.QueryBuilder.inputSelect().type('i');
|
||||
cy.wait('@labelsRequest');
|
||||
e2e.components.QueryBuilder.inputSelect().type('nstance{enter}');
|
||||
e2e.components.QueryBuilder.matchOperatorSelect()
|
||||
.should('be.visible')
|
||||
.click({ force: true })
|
||||
.children('div')
|
||||
.children('input')
|
||||
.type('=~{enter}', { force: true });
|
||||
e2e.components.QueryBuilder.valueSelect().should('be.visible').click();
|
||||
e2e.components.QueryBuilder.valueSelect().children('div').children('input').type('instance1{enter}');
|
||||
cy.wait('@valuesRequest');
|
||||
e2e.components.QueryBuilder.valueSelect().children('div').children('input').type('instance2{enter}');
|
||||
|
||||
cy.contains(MISSING_LABEL_FILTER_ERROR_MESSAGE).should('not.exist');
|
||||
cy.contains(finalQuery).should('be.visible');
|
||||
|
||||
// Change to code editor
|
||||
e2e.components.RadioButton.container().filter(':contains("Code")').click();
|
||||
|
||||
// We need to test this manually because the final query is split into separate DOM elements using cy.contains(finalQuery).should('be.visible'); does not detect the query.
|
||||
cy.contains('rate').should('be.visible');
|
||||
cy.contains('instance1|instance2').should('be.visible');
|
||||
cy.contains('logfmt').should('be.visible');
|
||||
cy.contains('__error__').should('be.visible');
|
||||
cy.contains('$__auto').should('be.visible');
|
||||
|
||||
// Checks the explain mode toggle
|
||||
cy.contains('label', 'Explain').click();
|
||||
cy.contains('Fetch all log lines matching label filters.').should('be.visible');
|
||||
});
|
||||
});
|
226
e2e/scenes/various-suite/loki-table-explore-to-dash.spec.ts
Normal file
226
e2e/scenes/various-suite/loki-table-explore-to-dash.spec.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import { e2e } from '../utils';
|
||||
|
||||
const dataSourceName = 'LokiEditor';
|
||||
const addDataSource = () => {
|
||||
e2e.flows.addDataSource({
|
||||
type: 'Loki',
|
||||
expectedAlertMessage: 'Unable to connect with Loki. Please check the server logs for more details.',
|
||||
name: dataSourceName,
|
||||
form: () => {
|
||||
cy.get('#connection-url').type('http://loki-url:3100');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const lokiQueryResult = {
|
||||
status: 'success',
|
||||
results: {
|
||||
A: {
|
||||
status: 200,
|
||||
frames: [
|
||||
{
|
||||
schema: {
|
||||
refId: 'A',
|
||||
meta: {
|
||||
typeVersion: [0, 0],
|
||||
custom: {
|
||||
frameType: 'LabeledTimeValues',
|
||||
},
|
||||
stats: [
|
||||
{
|
||||
displayName: 'Summary: bytes processed per second',
|
||||
unit: 'Bps',
|
||||
value: 223921,
|
||||
},
|
||||
{
|
||||
displayName: 'Summary: total bytes processed',
|
||||
unit: 'decbytes',
|
||||
value: 4156,
|
||||
},
|
||||
{
|
||||
displayName: 'Summary: exec time',
|
||||
unit: 's',
|
||||
value: 0.01856,
|
||||
},
|
||||
],
|
||||
executedQueryString: 'Expr: {targetLabelName="targetLabelValue"}',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'labels',
|
||||
type: 'other',
|
||||
typeInfo: {
|
||||
frame: 'json.RawMessage',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Time',
|
||||
type: 'time',
|
||||
typeInfo: {
|
||||
frame: 'time.Time',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Line',
|
||||
type: 'string',
|
||||
typeInfo: {
|
||||
frame: 'string',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tsNs',
|
||||
type: 'string',
|
||||
typeInfo: {
|
||||
frame: 'string',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
typeInfo: {
|
||||
frame: 'string',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
data: {
|
||||
values: [
|
||||
[
|
||||
{
|
||||
targetLabelName: 'targetLabelValue',
|
||||
instance: 'server\\1',
|
||||
job: '"grafana/data"',
|
||||
nonIndexed: 'value',
|
||||
place: 'moon',
|
||||
re: 'one.two$three^four',
|
||||
source: 'data',
|
||||
},
|
||||
],
|
||||
[1700077283237],
|
||||
[
|
||||
'{"_entry":"log text with ANSI \\u001b[31mpart of the text\\u001b[0m [149702545]","counter":"22292","float":"NaN","wave":-0.5877852522916832,"label":"val3","level":"info"}',
|
||||
],
|
||||
['1700077283237000000'],
|
||||
['1700077283237000000_9b025d35'],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('Loki Query Editor', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
e2e.flows.revertAllChanges();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('grafana.featureToggles', 'logsExploreTableVisualisation=1');
|
||||
});
|
||||
});
|
||||
it('Should be able to add explore table to dashboard', () => {
|
||||
addDataSource();
|
||||
|
||||
cy.intercept(/labels?/, (req) => {
|
||||
req.reply({ status: 'success', data: ['instance', 'job', 'source'] });
|
||||
});
|
||||
|
||||
cy.intercept(/series?/, (req) => {
|
||||
req.reply({ status: 'success', data: [{ instance: 'instance1' }] });
|
||||
});
|
||||
|
||||
cy.intercept(/\/api\/ds\/query\?ds_type=loki?/, (req) => {
|
||||
req.reply(lokiQueryResult);
|
||||
});
|
||||
|
||||
// Go to Explore and choose Loki data source
|
||||
e2e.pages.Explore.visit();
|
||||
e2e.components.DataSourcePicker.container().should('be.visible').click();
|
||||
cy.contains(dataSourceName).scrollIntoView().should('be.visible').click();
|
||||
|
||||
cy.contains('Code').click({ force: true });
|
||||
|
||||
// Wait for lazy loading
|
||||
// const monacoLoadingText = 'Loading...';
|
||||
|
||||
// e2e.components.QueryField.container().should('be.visible').should('have.text', monacoLoadingText);
|
||||
e2e.components.QueryField.container()
|
||||
.find('.view-overlays[role="presentation"]')
|
||||
.get('.cdr')
|
||||
.then(($el) => {
|
||||
const win = $el[0].ownerDocument.defaultView;
|
||||
const after = win.getComputedStyle($el[0], '::after');
|
||||
const content = after.getPropertyValue('content');
|
||||
expect(content).to.eq('"Enter a Loki query (run with Shift+Enter)"');
|
||||
});
|
||||
|
||||
// Write a simple query
|
||||
e2e.components.QueryField.container().type('query').type('{instance="instance1"');
|
||||
cy.get('.monaco-editor textarea:first').should(($el) => {
|
||||
expect($el.val()).to.eq('query{instance="instance1"}');
|
||||
});
|
||||
|
||||
// Submit the query
|
||||
e2e.components.QueryField.container().type('{shift+enter}');
|
||||
// Assert the no-data message is not visible
|
||||
cy.get('[data-testid="explore-no-data"]').should('not.exist');
|
||||
|
||||
// Click on the table toggle
|
||||
cy.contains('Table').click({ force: true });
|
||||
|
||||
// One row with two cells
|
||||
cy.get('[role="cell"]').should('have.length', 2);
|
||||
|
||||
cy.contains('label', 'targetLabelName').should('be.visible');
|
||||
cy.contains('label', 'targetLabelName').click();
|
||||
cy.contains('label', 'targetLabelName').within(() => {
|
||||
cy.get('input[type="checkbox"]').check({ force: true });
|
||||
});
|
||||
|
||||
cy.contains('label', 'targetLabelName').within(() => {
|
||||
cy.get('input[type="checkbox"]').should('be.checked');
|
||||
});
|
||||
|
||||
const exploreCells = cy.get('[role="cell"]');
|
||||
|
||||
// Now we should have a row with 3 columns
|
||||
exploreCells.should('have.length', 3);
|
||||
// And a value of "targetLabelValue"
|
||||
exploreCells.should('contain', 'targetLabelValue');
|
||||
|
||||
const addToButton = cy.get('[aria-label="Add"]');
|
||||
addToButton.should('be.visible');
|
||||
addToButton.click();
|
||||
|
||||
const addToDashboardButton = cy.get('[aria-label="Add to dashboard"]');
|
||||
|
||||
// Now let's add this to a dashboard
|
||||
addToDashboardButton.should('be.visible');
|
||||
addToDashboardButton.click();
|
||||
|
||||
const addPanelToDashboardButton = cy.contains('Add panel to dashboard');
|
||||
addPanelToDashboardButton.should('be.visible');
|
||||
|
||||
const openDashboardButton = cy.contains('Open dashboard');
|
||||
openDashboardButton.should('be.visible');
|
||||
openDashboardButton.click();
|
||||
|
||||
const panel = cy.get('[data-panelid="1"]');
|
||||
panel.should('be.visible');
|
||||
|
||||
const cells = panel.find('[role="table"] [role="cell"]');
|
||||
// Should have 3 columns
|
||||
cells.should('have.length', 3);
|
||||
// Cells contain strings found in log line
|
||||
cells.contains('"wave":-0.5877852522916832');
|
||||
|
||||
// column has correct value of "targetLabelValue", need to requery the DOM because of the .contains call above
|
||||
cy.get('[data-panelid="1"]').find('[role="table"] [role="cell"]').contains('targetLabelValue');
|
||||
});
|
||||
});
|
43
e2e/scenes/various-suite/navigation.spec.ts
Normal file
43
e2e/scenes/various-suite/navigation.spec.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { e2e } from '../utils';
|
||||
import { fromBaseUrl } from '../utils/support/url';
|
||||
|
||||
describe('Docked Navigation', () => {
|
||||
beforeEach(() => {
|
||||
cy.viewport(1280, 800);
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
|
||||
cy.visit(fromBaseUrl('/'));
|
||||
});
|
||||
|
||||
it('should remain docked when reloading the page', () => {
|
||||
// Expand, then dock the mega menu
|
||||
cy.get('[aria-label="Open menu"]').click();
|
||||
cy.get('[aria-label="Dock menu"]').click();
|
||||
|
||||
e2e.components.NavMenu.Menu().should('be.visible');
|
||||
|
||||
cy.reload();
|
||||
e2e.components.NavMenu.Menu().should('be.visible');
|
||||
});
|
||||
|
||||
it('should remain docked when navigating to another page', () => {
|
||||
// Expand, then dock the mega menu
|
||||
cy.get('[aria-label="Open menu"]').click();
|
||||
cy.get('[aria-label="Dock menu"]').click();
|
||||
|
||||
cy.contains('a', 'Administration').click();
|
||||
e2e.components.NavMenu.Menu().should('be.visible');
|
||||
|
||||
cy.contains('a', 'Users').click();
|
||||
e2e.components.NavMenu.Menu().should('be.visible');
|
||||
});
|
||||
|
||||
it('should become docked at larger viewport sizes', () => {
|
||||
e2e.components.NavMenu.Menu().should('not.exist');
|
||||
|
||||
cy.viewport(1920, 1080);
|
||||
cy.reload();
|
||||
|
||||
e2e.components.NavMenu.Menu().should('be.visible');
|
||||
});
|
||||
});
|
18
e2e/scenes/various-suite/pie-chart.spec.ts
Normal file
18
e2e/scenes/various-suite/pie-chart.spec.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { e2e } from '../utils';
|
||||
|
||||
describe('Pie Chart Panel', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
it('Pie Chart rendering e2e tests', () => {
|
||||
// open Panel Tests - Pie Chart
|
||||
e2e.flows.openDashboard({ uid: 'lVE-2YFMz' });
|
||||
|
||||
cy.get(
|
||||
`[data-viz-panel-key="panel-11"] [data-testid^="${selectors.components.Panels.Visualization.PieChart.svgSlice}"]`
|
||||
).should('have.length', 5);
|
||||
});
|
||||
});
|
76
e2e/scenes/various-suite/prometheus-annotations.spec.ts
Normal file
76
e2e/scenes/various-suite/prometheus-annotations.spec.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { e2e } from '../utils';
|
||||
import { addDashboard } from '../utils/flows';
|
||||
|
||||
import { createPromDS, getResources } from './helpers/prometheus-helpers';
|
||||
|
||||
const DATASOURCE_ID = 'Prometheus';
|
||||
|
||||
const DATASOURCE_NAME = 'aprometheusAnnotationDS';
|
||||
|
||||
/**
|
||||
* Click dashboard settings and then the variables tab
|
||||
*
|
||||
*/
|
||||
function navigateToAnnotations() {
|
||||
e2e.components.NavToolbar.editDashboard.editButton().should('be.visible').click();
|
||||
e2e.components.NavToolbar.editDashboard.settingsButton().should('be.visible').click();
|
||||
e2e.components.Tab.title('Annotations').click();
|
||||
}
|
||||
|
||||
function addPrometheusAnnotation(annotationName: string) {
|
||||
e2e.pages.Dashboard.Settings.Annotations.List.addAnnotationCTAV2().click();
|
||||
getResources();
|
||||
e2e.pages.Dashboard.Settings.Annotations.Settings.name().clear().type(annotationName);
|
||||
e2e.components.DataSourcePicker.container().should('be.visible').click();
|
||||
cy.contains(DATASOURCE_NAME).scrollIntoView().should('be.visible').click();
|
||||
}
|
||||
|
||||
describe('Prometheus annotations', () => {
|
||||
beforeEach(() => {
|
||||
createPromDS(DATASOURCE_ID, DATASOURCE_NAME);
|
||||
});
|
||||
|
||||
it('should navigate to variable query editor', () => {
|
||||
const annotationName = 'promAnnotation';
|
||||
addDashboard();
|
||||
navigateToAnnotations();
|
||||
addPrometheusAnnotation(annotationName);
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser
|
||||
.openButton()
|
||||
.contains('Metrics browser')
|
||||
.click();
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.selectMetric().should('exist').type('met');
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser
|
||||
.metricList()
|
||||
.should('exist')
|
||||
.contains('metric1')
|
||||
.click();
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useQuery().should('exist').click();
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.queryField().should('exist').contains('metric1');
|
||||
|
||||
// check for other parts of the annotations
|
||||
// min step
|
||||
cy.get(`#${selectors.components.DataSource.Prometheus.annotations.minStep}`);
|
||||
|
||||
// title
|
||||
e2e.components.DataSource.Prometheus.annotations.title().scrollIntoView().should('exist');
|
||||
// tags
|
||||
e2e.components.DataSource.Prometheus.annotations.tags().scrollIntoView().should('exist');
|
||||
// text
|
||||
e2e.components.DataSource.Prometheus.annotations.text().scrollIntoView().should('exist');
|
||||
// series value as timestamp
|
||||
e2e.components.DataSource.Prometheus.annotations.seriesValueAsTimestamp().scrollIntoView().should('exist');
|
||||
|
||||
e2e.components.NavToolbar.editDashboard.backToDashboardButton().should('be.visible').click();
|
||||
|
||||
// check that annotation exists
|
||||
cy.get('body').contains(annotationName);
|
||||
});
|
||||
});
|
111
e2e/scenes/various-suite/prometheus-config.spec.ts
Normal file
111
e2e/scenes/various-suite/prometheus-config.spec.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { e2e } from '../utils';
|
||||
|
||||
const DATASOURCE_ID = 'Prometheus';
|
||||
const DATASOURCE_TYPED_NAME = 'PrometheusDatasourceInstance';
|
||||
|
||||
describe('Prometheus config', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true);
|
||||
|
||||
e2e.pages.AddDataSource.visit();
|
||||
e2e.pages.AddDataSource.dataSourcePluginsV2(DATASOURCE_ID)
|
||||
.scrollIntoView()
|
||||
.should('be.visible') // prevents flakiness
|
||||
.click({ force: true });
|
||||
});
|
||||
|
||||
it(`should have the following components:
|
||||
connection settings
|
||||
managed alerts
|
||||
scrape interval
|
||||
query timeout
|
||||
default editor
|
||||
disable metric lookup
|
||||
prometheus type
|
||||
cache level
|
||||
incremental querying
|
||||
disable recording rules
|
||||
custom query parameters
|
||||
http method
|
||||
`, () => {
|
||||
// connection settings
|
||||
e2e.components.DataSource.Prometheus.configPage.connectionSettings().should('be.visible');
|
||||
// managed alerts
|
||||
cy.get(`#${selectors.components.DataSource.Prometheus.configPage.manageAlerts}`).scrollIntoView().should('exist');
|
||||
// scrape interval
|
||||
e2e.components.DataSource.Prometheus.configPage.scrapeInterval().scrollIntoView().should('exist');
|
||||
// query timeout
|
||||
e2e.components.DataSource.Prometheus.configPage.queryTimeout().scrollIntoView().should('exist');
|
||||
// default editor
|
||||
e2e.components.DataSource.Prometheus.configPage.defaultEditor().scrollIntoView().should('exist');
|
||||
// disable metric lookup
|
||||
cy.get(`#${selectors.components.DataSource.Prometheus.configPage.disableMetricLookup}`)
|
||||
.scrollIntoView()
|
||||
.should('exist');
|
||||
// prometheus type
|
||||
e2e.components.DataSource.Prometheus.configPage.prometheusType().scrollIntoView().should('exist');
|
||||
// cache level
|
||||
e2e.components.DataSource.Prometheus.configPage.cacheLevel().scrollIntoView().should('exist');
|
||||
// incremental querying
|
||||
cy.get(`#${selectors.components.DataSource.Prometheus.configPage.incrementalQuerying}`)
|
||||
.scrollIntoView()
|
||||
.should('exist');
|
||||
// disable recording rules
|
||||
cy.get(`#${selectors.components.DataSource.Prometheus.configPage.disableRecordingRules}`)
|
||||
.scrollIntoView()
|
||||
.should('exist');
|
||||
// custom query parameters
|
||||
e2e.components.DataSource.Prometheus.configPage.customQueryParameters().scrollIntoView().should('exist');
|
||||
// http method
|
||||
e2e.components.DataSource.Prometheus.configPage.httpMethod().scrollIntoView().should('exist');
|
||||
});
|
||||
|
||||
it('should save the default editor when navigating to explore', () => {
|
||||
e2e.components.DataSource.Prometheus.configPage.defaultEditor().scrollIntoView().should('exist').click();
|
||||
|
||||
selectOption('Builder');
|
||||
|
||||
e2e.components.DataSource.Prometheus.configPage.connectionSettings().type('http://prom-url:9090');
|
||||
|
||||
e2e.pages.DataSource.name().clear();
|
||||
e2e.pages.DataSource.name().type(DATASOURCE_TYPED_NAME);
|
||||
e2e.pages.DataSource.saveAndTest().click();
|
||||
|
||||
e2e.pages.Explore.visit();
|
||||
|
||||
e2e.components.DataSourcePicker.container().should('be.visible').click();
|
||||
|
||||
e2e.components.DataSourcePicker.container().type(`${DATASOURCE_TYPED_NAME}{enter}`);
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist');
|
||||
});
|
||||
|
||||
it('should allow a user to add the version when the Prom type is selected', () => {
|
||||
e2e.components.DataSource.Prometheus.configPage.prometheusType().scrollIntoView().should('exist').click();
|
||||
|
||||
selectOption('Prometheus');
|
||||
|
||||
e2e.components.DataSource.Prometheus.configPage.prometheusVersion().scrollIntoView().should('exist');
|
||||
});
|
||||
|
||||
it('should have a cache level component', () => {
|
||||
e2e.components.DataSource.Prometheus.configPage.cacheLevel().scrollIntoView().should('exist');
|
||||
});
|
||||
|
||||
it('should allow a user to select a query overlap window when incremental querying is selected', () => {
|
||||
cy.get(`#${selectors.components.DataSource.Prometheus.configPage.incrementalQuerying}`)
|
||||
.scrollIntoView()
|
||||
.should('exist')
|
||||
.check({ force: true });
|
||||
|
||||
e2e.components.DataSource.Prometheus.configPage.queryOverlapWindow().scrollIntoView().should('exist');
|
||||
});
|
||||
|
||||
// exemplars tested in exemplar.spec
|
||||
});
|
||||
|
||||
export function selectOption(option: string) {
|
||||
e2e.components.Select.option().contains(option).should('be.visible').click();
|
||||
}
|
182
e2e/scenes/various-suite/prometheus-editor.spec.ts
Normal file
182
e2e/scenes/various-suite/prometheus-editor.spec.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { e2e } from '../utils';
|
||||
|
||||
import { getResources } from './helpers/prometheus-helpers';
|
||||
|
||||
const DATASOURCE_ID = 'Prometheus';
|
||||
|
||||
type editorType = 'Code' | 'Builder';
|
||||
|
||||
/**
|
||||
* Login, create and save a Prometheus data source, navigate to code or builder
|
||||
*
|
||||
* @param editorType 'Code' or 'Builder'
|
||||
*/
|
||||
function navigateToEditor(editorType: editorType, name: string): void {
|
||||
// login
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true);
|
||||
|
||||
// select the prometheus DS
|
||||
e2e.pages.AddDataSource.visit();
|
||||
e2e.pages.AddDataSource.dataSourcePluginsV2(DATASOURCE_ID)
|
||||
.scrollIntoView()
|
||||
.should('be.visible') // prevents flakiness
|
||||
.click();
|
||||
|
||||
// choose default editor
|
||||
e2e.components.DataSource.Prometheus.configPage.defaultEditor().scrollIntoView().should('exist').click();
|
||||
selectOption(editorType);
|
||||
|
||||
// add url for DS to save without error
|
||||
e2e.components.DataSource.Prometheus.configPage.connectionSettings().type('http://prom-url:9090');
|
||||
|
||||
// name the DS
|
||||
e2e.pages.DataSource.name().clear();
|
||||
e2e.pages.DataSource.name().type(name);
|
||||
e2e.pages.DataSource.saveAndTest().click();
|
||||
|
||||
// visit explore
|
||||
e2e.pages.Explore.visit();
|
||||
|
||||
// choose the right DS
|
||||
e2e.components.DataSourcePicker.container().should('be.visible').click();
|
||||
cy.contains(name).scrollIntoView().should('be.visible').click();
|
||||
}
|
||||
|
||||
describe('Prometheus query editor', () => {
|
||||
it('should have a kickstart component', () => {
|
||||
navigateToEditor('Code', 'prometheus');
|
||||
e2e.components.QueryBuilder.queryPatterns().scrollIntoView().should('exist');
|
||||
});
|
||||
|
||||
it('should have an explain component', () => {
|
||||
navigateToEditor('Code', 'prometheus');
|
||||
e2e.components.DataSource.Prometheus.queryEditor.explain().scrollIntoView().should('exist');
|
||||
});
|
||||
|
||||
it('should have an editor toggle component', () => {
|
||||
navigateToEditor('Code', 'prometheus');
|
||||
e2e.components.DataSource.Prometheus.queryEditor.editorToggle().scrollIntoView().should('exist');
|
||||
});
|
||||
|
||||
it('should have an options component with legend, format, step, type and exemplars', () => {
|
||||
navigateToEditor('Code', 'prometheus');
|
||||
// open options
|
||||
e2e.components.DataSource.Prometheus.queryEditor.options().scrollIntoView().should('exist').click();
|
||||
// check options
|
||||
e2e.components.DataSource.Prometheus.queryEditor.legend().scrollIntoView().should('exist');
|
||||
e2e.components.DataSource.Prometheus.queryEditor.format().scrollIntoView().should('exist');
|
||||
cy.get(`#${selectors.components.DataSource.Prometheus.queryEditor.step}`).scrollIntoView().should('exist');
|
||||
e2e.components.DataSource.Prometheus.queryEditor.type().scrollIntoView().should('exist');
|
||||
cy.get(`#${selectors.components.DataSource.Prometheus.queryEditor.exemplars}`).scrollIntoView().should('exist');
|
||||
});
|
||||
|
||||
describe('Code editor', () => {
|
||||
it('navigates to the code editor with editor type as code', () => {
|
||||
navigateToEditor('Code', 'prometheusCode');
|
||||
});
|
||||
|
||||
it('navigates to the code editor and opens the metrics browser with metric search, labels, label values, and all components', () => {
|
||||
navigateToEditor('Code', 'prometheusCode');
|
||||
|
||||
getResources();
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.queryField().should('exist');
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser
|
||||
.openButton()
|
||||
.contains('Metrics browser')
|
||||
.click();
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.selectMetric().should('exist');
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelNamesFilter().should('exist');
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelValuesFilter().should('exist');
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useQuery().should('exist');
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useAsRateQuery().should('exist');
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.validateSelector().should('exist');
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.clear().should('exist');
|
||||
});
|
||||
|
||||
it('selects a metric in the metrics browser and uses the query', () => {
|
||||
navigateToEditor('Code', 'prometheusCode');
|
||||
|
||||
getResources();
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser
|
||||
.openButton()
|
||||
.contains('Metrics browser')
|
||||
.click();
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.selectMetric().should('exist').type('met');
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser
|
||||
.metricList()
|
||||
.should('exist')
|
||||
.contains('metric1')
|
||||
.click();
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useQuery().should('exist').click();
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.code.queryField().should('exist').contains('metric1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Query builder', () => {
|
||||
it('navigates to the query builder with editor type as code', () => {
|
||||
navigateToEditor('Builder', 'prometheusBuilder');
|
||||
});
|
||||
|
||||
it('the query builder contains metric select, label filters and operations', () => {
|
||||
navigateToEditor('Builder', 'prometheusBuilder');
|
||||
|
||||
getResources();
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist');
|
||||
e2e.components.QueryBuilder.labelSelect().should('exist');
|
||||
e2e.components.QueryBuilder.matchOperatorSelect().should('exist');
|
||||
e2e.components.QueryBuilder.valueSelect().should('exist');
|
||||
});
|
||||
|
||||
it('can select a metric and provide a hint', () => {
|
||||
navigateToEditor('Builder', 'prometheusBuilder');
|
||||
|
||||
getResources();
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist').click();
|
||||
|
||||
selectOption('metric1');
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.builder.hints().contains('hint: add rate');
|
||||
});
|
||||
|
||||
it('should have the metrics explorer opened via the metric select', () => {
|
||||
navigateToEditor('Builder', 'prometheusBuilder');
|
||||
|
||||
getResources();
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist').click();
|
||||
|
||||
selectOption('Metrics explorer');
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.builder.metricsExplorer().should('exist');
|
||||
});
|
||||
|
||||
// NEED TO COMPLETE QUEY ADVISOR WORK OR FIGURE OUT HOW TO ENABLE EXPERIMENTAL FEATURE TOGGLES
|
||||
// it('should have a query advisor when enabled with feature toggle', () => {
|
||||
// cy.window().then((win) => {
|
||||
// win.localStorage.setItem('grafana.featureToggles', 'prometheusPromQAIL=0');
|
||||
|
||||
// navigateToEditor('Builder', 'prometheusBuilder');
|
||||
|
||||
// getResources();
|
||||
|
||||
// e2e.components.DataSource.Prometheus.queryEditor.builder.queryAdvisor().should('exist');
|
||||
// });
|
||||
// });
|
||||
});
|
||||
});
|
||||
|
||||
function selectOption(option: string) {
|
||||
e2e.components.Select.option().contains(option).should('be.visible').click();
|
||||
}
|
123
e2e/scenes/various-suite/prometheus-variable-editor.spec.ts
Normal file
123
e2e/scenes/various-suite/prometheus-variable-editor.spec.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { e2e } from '../utils';
|
||||
import { addDashboard } from '../utils/flows';
|
||||
|
||||
import { createPromDS, getResources } from './helpers/prometheus-helpers';
|
||||
|
||||
const DATASOURCE_ID = 'Prometheus';
|
||||
|
||||
const DATASOURCE_NAME = 'prometheusVariableDS';
|
||||
|
||||
/**
|
||||
* Click dashboard settings and then the variables tab
|
||||
*/
|
||||
function navigateToVariables() {
|
||||
e2e.components.NavToolbar.editDashboard.editButton().should('be.visible').click();
|
||||
e2e.components.NavToolbar.editDashboard.settingsButton().should('be.visible').click();
|
||||
e2e.components.Tab.title('Variables').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin the process of adding a query type variable for a Prometheus data source
|
||||
*
|
||||
* @param variableName the name of the variable as a label of the variable dropdown
|
||||
*/
|
||||
function addPrometheusQueryVariable(variableName: string) {
|
||||
e2e.pages.Dashboard.Settings.Variables.List.addVariableCTAV2().click();
|
||||
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type(variableName);
|
||||
e2e.components.DataSourcePicker.container().should('be.visible').click();
|
||||
cy.contains(DATASOURCE_NAME).scrollIntoView().should('be.visible').click();
|
||||
|
||||
getResources();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Prometheus variable and navigate to the query editor to check that it is available to use.
|
||||
*
|
||||
* @param variableName name the variable
|
||||
* @param queryType query type of 'Label names', 'Label values', 'Metrics', 'Query result', 'Series query' or 'Classic query'. These types should be imported from the Prometheus library eventually but not now because we are in the process of decoupling the DS from core grafana.
|
||||
*/
|
||||
function variableFlowToQueryEditor(variableName: string, queryType: string) {
|
||||
addDashboard();
|
||||
navigateToVariables();
|
||||
addPrometheusQueryVariable(variableName);
|
||||
|
||||
// select query type
|
||||
e2e.components.DataSource.Prometheus.variableQueryEditor.queryType().click();
|
||||
selectOption(queryType);
|
||||
|
||||
// apply the variable
|
||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.applyButton().click();
|
||||
|
||||
// close to return to dashboard
|
||||
e2e.components.NavToolbar.editDashboard.backToDashboardButton().should('be.visible').click();
|
||||
|
||||
// add visualization
|
||||
e2e.pages.AddDashboard.itemButton('Create new panel button').should('be.visible').click();
|
||||
|
||||
// close the data source picker modal
|
||||
cy.get('[aria-label="Close"]').click();
|
||||
|
||||
// select prom data source from the data source list with the useful data-testid
|
||||
e2e.components.DataSourcePicker.inputV2().click({ force: true }).type(`${DATASOURCE_NAME}{enter}`);
|
||||
|
||||
// confirm the variable exists in the correct input
|
||||
// use the variable query type from the library in the future
|
||||
switch (queryType) {
|
||||
case 'Label names':
|
||||
e2e.components.QueryBuilder.labelSelect().should('exist').click({ force: true });
|
||||
selectOption(`${variableName}`);
|
||||
case 'Label values':
|
||||
e2e.components.QueryBuilder.valueSelect().should('exist').click({ force: true });
|
||||
selectOption(`${variableName}`);
|
||||
case 'Metrics':
|
||||
e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist').click({ force: true });
|
||||
selectOption(`${variableName}`);
|
||||
default:
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
describe('Prometheus variable query editor', () => {
|
||||
beforeEach(() => {
|
||||
createPromDS(DATASOURCE_ID, DATASOURCE_NAME);
|
||||
});
|
||||
|
||||
it('should navigate to variable query editor', () => {
|
||||
addDashboard();
|
||||
navigateToVariables();
|
||||
});
|
||||
|
||||
it('should select a query type for a Prometheus variable query', () => {
|
||||
addDashboard();
|
||||
navigateToVariables();
|
||||
addPrometheusQueryVariable('labelsVariable');
|
||||
|
||||
// select query type
|
||||
e2e.components.DataSource.Prometheus.variableQueryEditor.queryType().click();
|
||||
|
||||
selectOption('Label names');
|
||||
});
|
||||
|
||||
it('should create a label names variable that is selectable in the label select in query builder', () => {
|
||||
addDashboard();
|
||||
navigateToVariables();
|
||||
variableFlowToQueryEditor('labelnames', 'Label names');
|
||||
});
|
||||
|
||||
it('should create a label values variable that is selectable in the label values select in query builder', () => {
|
||||
addDashboard();
|
||||
navigateToVariables();
|
||||
variableFlowToQueryEditor('labelvalues', 'Label values');
|
||||
});
|
||||
|
||||
it('should create a metric names variable that is selectable in the metric select in query builder', () => {
|
||||
addDashboard();
|
||||
navigateToVariables();
|
||||
variableFlowToQueryEditor('metrics', 'Metrics');
|
||||
});
|
||||
});
|
||||
|
||||
function selectOption(option: string) {
|
||||
e2e.components.Select.option().contains(option).should('be.visible').click();
|
||||
}
|
32
e2e/scenes/various-suite/query-editor.spec.ts
Normal file
32
e2e/scenes/various-suite/query-editor.spec.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { e2e } from '../utils';
|
||||
import { waitForMonacoToLoad } from '../utils/support/monaco';
|
||||
|
||||
describe('Query editor', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
// x-ing to bypass this flaky test.
|
||||
// Will rewrite in plugin-e2e with this issue
|
||||
xit('Undo should work in query editor for prometheus -- test CI.', () => {
|
||||
e2e.pages.Explore.visit();
|
||||
e2e.components.DataSourcePicker.container().should('be.visible').click();
|
||||
|
||||
cy.contains('gdev-prometheus').scrollIntoView().should('be.visible').click();
|
||||
const queryText = `rate(http_requests_total{job="grafana"}[5m])`;
|
||||
|
||||
e2e.components.RadioButton.container().filter(':contains("Code")').should('be.visible').click();
|
||||
|
||||
waitForMonacoToLoad();
|
||||
|
||||
e2e.components.QueryField.container().type(queryText, { parseSpecialCharSequences: false }).type('{backspace}');
|
||||
|
||||
cy.contains(queryText.slice(0, -1)).should('be.visible');
|
||||
|
||||
e2e.components.QueryField.container().type(e2e.typings.undo());
|
||||
|
||||
cy.contains(queryText).should('be.visible');
|
||||
|
||||
e2e.components.Alert.alertV2('error').should('not.be.visible');
|
||||
});
|
||||
});
|
71
e2e/scenes/various-suite/return-to-previous.spec.ts
Normal file
71
e2e/scenes/various-suite/return-to-previous.spec.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { e2e } from '../utils';
|
||||
|
||||
describe('ReturnToPrevious button', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
|
||||
cy.visit('/alerting/list');
|
||||
e2e.components.AlertRules.groupToggle().first().click();
|
||||
e2e.components.AlertRules.toggle().click();
|
||||
cy.get('a[title="View"]').click();
|
||||
cy.url().as('alertRuleUrl');
|
||||
cy.get('a').contains('View panel').click();
|
||||
});
|
||||
|
||||
it('should appear when changing context and go back to alert rule when clicking "Back"', () => {
|
||||
// check whether all elements of RTP are available
|
||||
e2e.components.ReturnToPrevious.buttonGroup().should('be.visible');
|
||||
e2e.components.ReturnToPrevious.dismissButton().should('be.visible');
|
||||
e2e.components.ReturnToPrevious.backButton()
|
||||
.find('span')
|
||||
.contains('Back to e2e-ReturnToPrevious-test')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
|
||||
// check whether the RTP button leads back to alert rule
|
||||
cy.get('@alertRuleUrl').then((alertRuleUrl) => {
|
||||
cy.url().should('eq', alertRuleUrl);
|
||||
});
|
||||
});
|
||||
|
||||
it('should disappear when clicking "Dismiss"', () => {
|
||||
e2e.components.ReturnToPrevious.dismissButton().should('be.visible').click();
|
||||
e2e.components.ReturnToPrevious.buttonGroup().should('not.exist');
|
||||
});
|
||||
|
||||
it('should not persist when going back to the alert rule details view', () => {
|
||||
e2e.components.ReturnToPrevious.buttonGroup().should('be.visible');
|
||||
|
||||
cy.visit('/alerting/list');
|
||||
e2e.components.AlertRules.groupToggle().first().click();
|
||||
cy.get('a[title="View"]').click();
|
||||
e2e.components.ReturnToPrevious.buttonGroup().should('not.exist');
|
||||
});
|
||||
|
||||
it('should override the button label and change the href when user changes alert rules', () => {
|
||||
e2e.components.ReturnToPrevious.backButton()
|
||||
.find('span')
|
||||
.contains('Back to e2e-ReturnToPrevious-test')
|
||||
.should('be.visible');
|
||||
|
||||
cy.visit('/alerting/list');
|
||||
|
||||
e2e.components.AlertRules.groupToggle().last().click();
|
||||
cy.get('a[title="View"]').click();
|
||||
cy.url().as('alertRule2Url');
|
||||
cy.get('a').contains('View panel').click();
|
||||
|
||||
e2e.components.ReturnToPrevious.backButton()
|
||||
.find('span')
|
||||
.contains('Back to e2e-ReturnToPrevious-test-2')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
|
||||
e2e.components.ReturnToPrevious.buttonGroup().should('not.exist');
|
||||
|
||||
// check whether the RTP button leads back to alert rule
|
||||
cy.get('@alertRule2Url').then((alertRule2Url) => {
|
||||
cy.url().should('eq', alertRule2Url);
|
||||
});
|
||||
});
|
||||
});
|
34
e2e/scenes/various-suite/select-focus.spec.ts
Normal file
34
e2e/scenes/various-suite/select-focus.spec.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { e2e } from '../utils';
|
||||
|
||||
describe('Select focus/unfocus tests', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
it.skip('Tests select focus/unfocus scenarios', () => {
|
||||
e2e.flows.openDashboard({ uid: '5SdHCadmz' });
|
||||
e2e.components.PageToolbar.item('Dashboard settings').click();
|
||||
|
||||
e2e.components.FolderPicker.containerV2()
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
cy.get('#dashboard-folder-input').should('be.visible').click();
|
||||
});
|
||||
|
||||
e2e.components.Select.option().should('be.visible').first().click();
|
||||
|
||||
e2e.components.FolderPicker.containerV2()
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
cy.get('#dashboard-folder-input').should('exist').should('have.focus');
|
||||
});
|
||||
|
||||
e2e.pages.Dashboard.Settings.General.title().click();
|
||||
|
||||
e2e.components.FolderPicker.containerV2()
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
cy.get('#dashboard-folder-input').should('exist').should('not.have.focus');
|
||||
});
|
||||
});
|
||||
});
|
44
e2e/scenes/various-suite/solo-route.spec.ts
Normal file
44
e2e/scenes/various-suite/solo-route.spec.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { e2e } from '../utils';
|
||||
|
||||
describe('Solo Route', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
it('Can view panels with shared queries in fullsceen', () => {
|
||||
// open Panel Tests - Bar Gauge
|
||||
e2e.pages.SoloPanel.visit('ZqZnVvFZz/datasource-tests-shared-queries?orgId=1&panelId=4');
|
||||
|
||||
cy.get('canvas').should('have.length', 6);
|
||||
});
|
||||
|
||||
it('Can view solo panel in scenes', () => {
|
||||
// open Panel Tests - Graph NG
|
||||
e2e.pages.SoloPanel.visit(
|
||||
'TkZXxlNG3/panel-tests-graph-ng?orgId=1&from=1699954597665&to=1699956397665&panelId=54&__feature.dashboardSceneSolo=true'
|
||||
);
|
||||
|
||||
e2e.components.Panels.Panel.title('Interpolation: Step before').should('exist');
|
||||
cy.contains('uplot-main-div').should('not.exist');
|
||||
});
|
||||
|
||||
it('Can view solo repeated panel in scenes', () => {
|
||||
// open Panel Tests - Graph NG
|
||||
e2e.pages.SoloPanel.visit(
|
||||
'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true'
|
||||
);
|
||||
|
||||
e2e.components.Panels.Panel.title('server=B').should('exist');
|
||||
cy.contains('uplot-main-div').should('not.exist');
|
||||
});
|
||||
|
||||
it('Can view solo in repeaterd row and panel in scenes', () => {
|
||||
// open Panel Tests - Graph NG
|
||||
e2e.pages.SoloPanel.visit(
|
||||
'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-2-clone-D-clone-2&__feature.dashboardSceneSolo=true'
|
||||
);
|
||||
|
||||
e2e.components.Panels.Panel.title('server = D, pod = Sod').should('exist');
|
||||
cy.contains('uplot-main-div').should('not.exist');
|
||||
});
|
||||
});
|
43
e2e/scenes/various-suite/trace-view-scrolling.spec.ts
Normal file
43
e2e/scenes/various-suite/trace-view-scrolling.spec.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { e2e } from '../utils';
|
||||
|
||||
describe('Trace view', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
it('Can lazy load big traces', () => {
|
||||
cy.intercept('GET', '**/api/traces/trace', {
|
||||
fixture: 'long-trace-response.json',
|
||||
}).as('longTrace');
|
||||
|
||||
e2e.pages.Explore.visit();
|
||||
|
||||
e2e.components.DataSourcePicker.container().should('be.visible').type('gdev-jaeger{enter}');
|
||||
// Wait for the query editor to be set correctly
|
||||
e2e.components.QueryEditorRows.rows().within(() => {
|
||||
cy.contains('gdev-jaeger').should('be.visible');
|
||||
});
|
||||
|
||||
// type this with 0 delay to prevent flaky tests due to cursor position changing between rerenders
|
||||
e2e.components.QueryField.container().should('be.visible').type('trace', {
|
||||
delay: 0,
|
||||
});
|
||||
// Use shift+enter to execute the query as it's more stable than clicking the execute button
|
||||
e2e.components.QueryField.container().type('{shift+enter}');
|
||||
|
||||
cy.wait('@longTrace');
|
||||
|
||||
e2e.components.TraceViewer.spanBar().should('be.visible');
|
||||
|
||||
e2e.components.TraceViewer.spanBar()
|
||||
.its('length')
|
||||
.then((oldLength) => {
|
||||
e2e.pages.Explore.General.scrollView().children('.scrollbar-view').scrollTo('center');
|
||||
|
||||
// After scrolling we should load more spans
|
||||
e2e.components.TraceViewer.spanBar().should(($span) => {
|
||||
expect($span.length).to.be.gt(oldLength);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
27
e2e/scenes/various-suite/visualization-suggestions.spec.ts
Normal file
27
e2e/scenes/various-suite/visualization-suggestions.spec.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { e2e } from '../utils';
|
||||
|
||||
describe('Visualization suggestions', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
});
|
||||
|
||||
it('Should be shown and clickable', () => {
|
||||
e2e.flows.openDashboard({ uid: 'aBXrJ0R7z', queryParams: { editPanel: 9 } });
|
||||
|
||||
// Try visualization suggestions
|
||||
e2e.components.PanelEditor.toggleVizPicker().click();
|
||||
e2e.components.RadioButton.container().filter(':contains("Suggestions")').click();
|
||||
|
||||
// Verify we see suggestions
|
||||
e2e.components.VisualizationPreview.card('Line chart').should('be.visible');
|
||||
|
||||
// Verify search works
|
||||
cy.get('[placeholder="Search for..."]').type('Table');
|
||||
// Should no longer see line chart
|
||||
e2e.components.VisualizationPreview.card('Line chart').should('not.exist');
|
||||
|
||||
// Select a visualisation
|
||||
e2e.components.VisualizationPreview.card('Table').click();
|
||||
e2e.components.Panels.Visualization.Table.header().should('be.visible');
|
||||
});
|
||||
});
|
34
go.work.sum
34
go.work.sum
@ -272,10 +272,7 @@ github.com/99designs/httpsignatures-go v0.0.0-20170731043157-88528bf4ca7e h1:rl2
|
||||
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9 h1:HD8gA2tkByhMAwYaFAX9w2l7vxvBQ5NMoxDrkhqhtn4=
|
||||
github.com/Azure/azure-amqp-common-go/v3 v3.2.2 h1:CJpxNAGxP7UBhDusRUoaOn0uOorQyAYhQYLnNgkRhlY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2 h1:mLY+pNLjCUeKhgnAJWAKhEUQM+RJQo2H1fuGSw1Ky1E=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.0.0 h1:ECsQtyERDVz3NP3kvDOTLvbQhqWp/x9EsGKtb4ogUr8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.1.1 h1:7CBQ+Ei8SP2c6ydQTGCCrS35bDxgTMfoP2miAwK++OU=
|
||||
github.com/Azure/azure-service-bus-go v0.11.5 h1:EVMicXGNrSX+rHRCBgm/TRQ4VUZ1m3yAYM/AB2R/SOs=
|
||||
github.com/Azure/go-amqp v0.16.4 h1:/1oIXrq5zwXLHaoYDliJyiFjJSpJZMWGgtMX9e0/Z30=
|
||||
github.com/Azure/go-autorest/autorest/azure/auth v0.5.11 h1:P6bYXFoao05z5uhOQzbC3Qd8JqF3jUoocoTeIxkp2cA=
|
||||
@ -292,7 +289,6 @@ github.com/DataDog/datadog-go v3.2.0+incompatible h1:qSG2N4FghB1He/r2mFrWKCaL7dX
|
||||
github.com/GoogleCloudPlatform/cloudsql-proxy v1.29.0 h1:YNu23BtH0PKF+fg3ykSorCp6jSTjcEtfnYLzbmcjVRA=
|
||||
github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk=
|
||||
github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM=
|
||||
github.com/KimMachineGun/automemlimit v0.6.0 h1:p/BXkH+K40Hax+PuWWPQ478hPjsp9h1CPDhLlA3Z37E=
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw=
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
|
||||
github.com/OneOfOne/xxhash v1.2.6 h1:U68crOE3y3MPttCMQGywZOLrTeF5HHJ3/vDBCJn9/bA=
|
||||
@ -369,7 +365,6 @@ github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3I
|
||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||
github.com/cilium/ebpf v0.11.0 h1:V8gS/bTCCjX9uUnkUFUpPsksM8n1lXBAvHcpiFk1X2Y=
|
||||
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible h1:C29Ae4G5GtYyYMm1aztcyj/J5ckgJm2zwdDajFbx1NY=
|
||||
github.com/circonus-labs/circonusllhist v0.1.3 h1:TJH+oke8D16535+jHExHj4nQvzlZrj7ug5D7I/orNUA=
|
||||
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
||||
@ -382,10 +377,8 @@ github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa h1:OaNxuTZr
|
||||
github.com/cockroachdb/datadriven v1.0.2 h1:H9MtNqVoVhvd9nCBwOyDjUEdZCREqbIdCJD93PBm/jA=
|
||||
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd h1:qMd81Ts1T2OTKmB4acZcyKaMtRnY5Y44NuXGX2GFJ1w=
|
||||
github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0 h1:sDMmm+q/3+BukdIpxwO365v/Rbspp2Nt5XntgQRXq8Q=
|
||||
github.com/containerd/cgroups/v3 v3.0.3 h1:S5ByHZ/h9PMe5IOQoN7E+nMc2UcLEM/V48DGDJ9kip0=
|
||||
github.com/containerd/containerd v1.6.8 h1:h4dOFDwzHmqFEP754PgfgTeVXFnLiRc6kiqC7tplDJs=
|
||||
github.com/containerd/containerd v1.6.8/go.mod h1:By6p5KqPK0/7/CgO/A6t/Gz+CUYUu2zf1hUaaymVXB0=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/coreos/etcd v3.3.10+incompatible h1:jFneRYjIvLMLhDLCzuTuU4rSJUjRplcJQ7pD7MnhC04=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible h1:bXhRBIXoTm9BYHS3gE0TtQuyNZyeEMux2sDi4oo5YOo=
|
||||
github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk=
|
||||
@ -408,13 +401,6 @@ github.com/cznic/ql v1.2.0 h1:lcKp95ZtdF0XkWhGnVIXGF8dVD2X+ClS08tglKtf+ak=
|
||||
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65 h1:hxuZop6tSoOi0sxFzoGGYdRqNrPubyaIf9KoBG9tPiE=
|
||||
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186 h1:0rkFMAbn5KBKNpJyHQ6Prb95vIKanmAe62KxsrN+sqA=
|
||||
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc h1:YKKpTb2BrXN2GYyGaygIdis1vXbE7SSAG9axGWIMClg=
|
||||
github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14 h1:YI1gOOdmMk3xodBao7fehcvoZsEeOyy/cfhlpCSPgM4=
|
||||
github.com/dave/brenda v1.1.0 h1:Sl1LlwXnbw7xMhq3y2x11McFu43AjDcwkllxxgZ3EZw=
|
||||
github.com/dave/courtney v0.3.0 h1:8aR1os2ImdIQf3Zj4oro+lD/L4Srb5VwGefqZ/jzz7U=
|
||||
github.com/dave/gopackages v0.0.0-20170318123100-46e7023ec56e h1:l99YKCdrK4Lvb/zTupt0GMPfNbncAGf8Cv/t1sYLOg0=
|
||||
github.com/dave/kerr v0.0.0-20170318121727-bc25dd6abe8e h1:xURkGi4RydhyaYR6PzcyHTueQudxY4LgxN1oYEPJHa0=
|
||||
github.com/dave/patsy v0.0.0-20210517141501-957256f50cba h1:1o36L4EKbZzazMk8iGC4kXpVnZ6TPxR2mZ9qVKjNNAs=
|
||||
github.com/dave/rebecca v0.9.1 h1:jxVfdOxRirbXL28vXMvUvJ1in3djwkVKXCq339qhBL0=
|
||||
github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g=
|
||||
github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
|
||||
@ -431,7 +417,6 @@ github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczC
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dhui/dktest v0.3.0 h1:kwX5a7EkLcjo7VpsPQSYJcKGbXBXdjI9FGjuUj1jn6I=
|
||||
github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
|
||||
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
|
||||
github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE=
|
||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
|
||||
github.com/drone/drone-runtime v1.1.0 h1:IsKbwiLY6+ViNBzX0F8PERJVZZcEJm9rgxEh3uZP5IE=
|
||||
@ -567,7 +552,6 @@ github.com/grafana/grafana-plugin-sdk-go v0.228.0/go.mod h1:u4K9vVN6eU86loO68977
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.229.0/go.mod h1:6V6ikT4ryva8MrAp7Bdz5fTJx3/ztzKvpMJFfpzr4CI=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.231.1-0.20240523124942-62dae9836284/go.mod h1:bNgmNmub1I7Mc8dzIncgNqHC5jTgSZPPHlZ3aG8HKJQ=
|
||||
github.com/grafana/grafana/pkg/promlib v0.0.3/go.mod h1:3El4NlsfALz8QQCbEGHGFvJUG+538QLMuALRhZ3pcoo=
|
||||
github.com/grafana/grafana/pkg/promlib v0.0.6/go.mod h1:shFkrG1fQ/PPNRGhxAPNMLp0SAeG/jhqaLoG6n2191M=
|
||||
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1/go.mod h1:YvJ2f6MplWDhfxiUC3KpyTy76kYUZA4W3pTv/wdKQ9Y=
|
||||
github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU=
|
||||
@ -578,7 +562,6 @@ github.com/hamba/avro/v2 v2.17.2/go.mod h1:Q9YK+qxAhtVrNqOhwlZTATLgLA8qxG2vtvkhK
|
||||
github.com/hanwen/go-fuse v1.0.0 h1:GxS9Zrn6c35/BnfiVsZVWmsG803xwE7eVRDvcf/BEVc=
|
||||
github.com/hanwen/go-fuse/v2 v2.1.0 h1:+32ffteETaLYClUj0a3aHjZ1hOPxxaNEHiZiujuDaek=
|
||||
github.com/hashicorp/consul/sdk v0.15.0 h1:2qK9nDrr4tiJKRoxPGhm6B7xJjLVIQqkjiab2M4aKjU=
|
||||
github.com/hashicorp/consul/sdk v0.16.0 h1:SE9m0W6DEfgIVCJX7xU+iv/hUl4m/nxqMTnCdMxDpJ8=
|
||||
github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
|
||||
github.com/hashicorp/go-hclog v0.16.1 h1:IVQwpTGNRRIHafnTs2dQLIk4ENtneRIEEJWOVDqz99o=
|
||||
github.com/hashicorp/go-hclog v0.16.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
|
||||
@ -594,7 +577,6 @@ github.com/hydrogen18/memlistener v0.0.0-20200120041712-dcc25e7acd91 h1:KyZDvZ/G
|
||||
github.com/iancoleman/strcase v0.2.0 h1:05I4QRnGpI0m37iZQRuskXh+w77mr6Z41lwQzuHLwW0=
|
||||
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab h1:BA4a7pe6ZTd9F8kXETBoijjFJ/ntaa//1wiH9BZu4zU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465 h1:KwWnWVWCNtNq/ewIX7HIKnELmEx2nDP42yskD/pi7QE=
|
||||
github.com/influxdata/influxdb v1.7.6 h1:8mQ7A/V+3noMGCt/P9pD09ISaiz9XvgCk303UYA3gcs=
|
||||
github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab h1:HqW4xhhynfjrtEiiSGcQUd6vrK23iMam1FO8rI7mwig=
|
||||
github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q=
|
||||
@ -625,12 +607,10 @@ github.com/jackspirou/syscerts v0.0.0-20160531025014-b68f5469dff1/go.mod h1:zuHl
|
||||
github.com/jaegertracing/jaeger v1.41.0 h1:vVNky8dP46M2RjGaZ7qRENqylW+tBFay3h57N16Ip7M=
|
||||
github.com/jaegertracing/jaeger v1.41.0/go.mod h1:SIkAT75iVmA9U+mESGYuMH6UQv6V9Qy4qxo0lwfCQAc=
|
||||
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
|
||||
github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
|
||||
github.com/jedib0t/go-pretty/v6 v6.2.4 h1:wdaj2KHD2W+mz8JgJ/Q6L/T5dB7kyqEFI16eLq7GEmk=
|
||||
github.com/jedib0t/go-pretty/v6 v6.2.4/go.mod h1:+nE9fyyHGil+PuISTCrp7avEdo6bqoMwqZnuiK2r2a0=
|
||||
github.com/jhump/gopoet v0.1.0 h1:gYjOPnzHd2nzB37xYQZxj4EIQNpBrBskRqQQ3q4ZgSg=
|
||||
github.com/jhump/goprotoc v0.5.0 h1:Y1UgUX+txUznfqcGdDef8ZOVlyQvnV0pKWZH08RmZuo=
|
||||
github.com/jmattheis/goverter v1.4.0 h1:SrboBYMpGkj1XSgFhWwqzdP024zIa1+58YzUm+0jcBE=
|
||||
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
|
||||
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o=
|
||||
@ -718,9 +698,7 @@ github.com/mitchellh/gox v0.4.0 h1:lfGJxY7ToLJQjHHwi0EX6uYBdK78egf954SQl13PQJc=
|
||||
github.com/mitchellh/iochan v1.0.0 h1:C+X3KsSTLFVBr/tK1eYN/vs4rJcvsiLU338UhYPJWeY=
|
||||
github.com/mithrandie/readline-csvq v1.2.1 h1:4cfeYeVSrqKEWi/1t7CjyhFD2yS6fm+l+oe+WyoSNlI=
|
||||
github.com/mithrandie/readline-csvq v1.2.1/go.mod h1:ydD9Eyp3/wn8KPSNbKmMZe4RQQauCuxi26yEo4N40dk=
|
||||
github.com/mithrandie/readline-csvq v1.3.0 h1:VTJEOGouJ8j27jJCD4kBBbNTxM0OdBvE1aY1tMhlqE8=
|
||||
github.com/mithrandie/readline-csvq v1.3.0/go.mod h1:FKyYqDgf/G4SNov7SMFXRWO6LQLXIOeTog/NB97FZl0=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5 h1:8Q0qkMVC/MmWkpIdlvZgcv2o2jrlF6zqVOh7W5YHdMA=
|
||||
github.com/montanaflynn/stats v0.7.0 h1:r3y12KyNxj/Sb/iOE46ws+3mS1+MZca1wlHQFPsY/JU=
|
||||
github.com/mostynb/go-grpc-compression v1.1.17 h1:N9t6taOJN3mNTTi0wDf4e3lp/G/ON1TP67Pn0vTUA9I=
|
||||
@ -737,7 +715,6 @@ github.com/nats-io/nkeys v0.3.0 h1:cgM5tL53EvYRU+2YLXIK0G2mJtK12Ft9oeooSZMA2G8=
|
||||
github.com/nats-io/nkeys v0.4.4/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/nsf/jsondiff v0.0.0-20230430225905-43f6cf3098c1 h1:dOYG7LS/WK00RWZc8XGgcUTlTxpp3mKhdR2Q9z9HbXM=
|
||||
github.com/oapi-codegen/testutil v1.0.0/go.mod h1:ttCaYbHvJtHuiyeBF0tPIX+4uhEPTeizXKx28okijLw=
|
||||
github.com/oklog/oklog v0.3.2 h1:wVfs8F+in6nTBMkA7CbRw+zZMIB7nNM825cM1wuzoTk=
|
||||
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
|
||||
@ -767,7 +744,6 @@ github.com/open-telemetry/opentelemetry-collector-contrib/receiver/opencensusrec
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/receiver/opencensusreceiver v0.74.0/go.mod h1:uiW3V9EX8A5DOoxqDLuSh++ewHr+owtonCSiqMcpy3w=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.74.0 h1:2uysjsaqkf9STFeJN/M6i/sSYEN5pZJ94Qd2/Hg1pKE=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.74.0/go.mod h1:qoGuayD7cAtshnKosIQHd6dobcn6/sqgUn0v/Cg2UB8=
|
||||
github.com/opencontainers/runtime-spec v1.0.2 h1:UfAcuLBJB9Coz72x1hgl8O5RVzTdNiaglX6v2DM6FI0=
|
||||
github.com/opentracing-contrib/go-grpc v0.0.0-20210225150812-73cb765af46e h1:4cPxUYdgaGzZIT5/j0IfqOrrXmq6bG8AwvwisMXpdrg=
|
||||
github.com/opentracing-contrib/go-grpc v0.0.0-20210225150812-73cb765af46e/go.mod h1:DYR5Eij8rJl8h7gblRrOZ8g0kW1umSpKqYIBTgeDtLo=
|
||||
github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492 h1:lM6RxxfUMrYL/f8bWEUqdXrANWtrL7Nndbm9iFN0DlU=
|
||||
@ -777,7 +753,6 @@ github.com/openzipkin/zipkin-go v0.4.1 h1:kNd/ST2yLLWhaWrkgchya40TJabe8Hioj9udfP
|
||||
github.com/openzipkin/zipkin-go v0.4.1/go.mod h1:qY0VqDSN1pOBN94dBc6w2GJlWLiovAyg7Qt6/I9HecM=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/pact-foundation/pact-go v1.0.4 h1:OYkFijGHoZAYbOIb1LWXrwKQbMMRUv1oQ89blD2Mh2Q=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
|
||||
github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml/v2 v2.0.5 h1:ipoSadvV8oGUjnUbMub59IDPPwfxF694nG/jwbMiyQg=
|
||||
@ -837,7 +812,6 @@ github.com/shirou/gopsutil/v3 v3.23.2/go.mod h1:gv0aQw33GLo3pG8SiWKiQrbDzbRY1K80
|
||||
github.com/shirou/gopsutil/v3 v3.23.8/go.mod h1:7hmCaBn+2ZwaZOr6jmPBZDfawwMGuo1id3C6aM8EDqQ=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.6 h1:Oe8TPH9wAbv++YPNDKJWUnI8Q4PPWCx3UbOfH+FxiMU=
|
||||
github.com/shoenig/test v1.7.1 h1:UJcjSAI3aUKx52kfcfhblgyhZceouhvvs3OYdWgn+PY=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/sony/gobreaker v0.4.1 h1:oMnRNZXX5j85zso6xCPRNPtmAycat+WcoKbklScLDgQ=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
@ -886,8 +860,6 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/vburenin/ifacemaker v1.2.1 h1:3Vq8B/bfBgjWTkv+jDg4dVL1KHt3k1K4lO7XRxYA2sk=
|
||||
github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae/go.mod h1:PnwzbSst7KD3vpBzzlntZU5gjVa455Uqa5QPiKSYJzQ=
|
||||
github.com/vinzenz/yaml v0.0.0-20170920082545-91409cdd725d h1:3wDi6J5APMqaHBVPuVd7RmHD2gRTfqbdcVSpCNoUWtk=
|
||||
github.com/vinzenz/yaml v0.0.0-20170920082545-91409cdd725d/go.mod h1:mb5taDqMnJiZNRQ3+02W2IFG+oEz1+dTuCXkp4jpkfo=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
|
||||
@ -940,13 +912,11 @@ go.opentelemetry.io/collector/exporter v0.74.0/go.mod h1:kw5YoorpKqEpZZ/a5ODSoYF
|
||||
go.opentelemetry.io/collector/exporter/otlpexporter v0.74.0 h1:YKvTeYcBrJwbcXNy65fJ/xytUSMurpYn/KkJD0x+DAY=
|
||||
go.opentelemetry.io/collector/exporter/otlpexporter v0.74.0/go.mod h1:cRbvsnpSxzySoTSnXbOGPQZu9KHlEyKkTeE21f9Q1p4=
|
||||
go.opentelemetry.io/collector/featuregate v1.0.0 h1:5MGqe2v5zxaoo73BUOvUTunftX5J8RGrbFsC2Ha7N3g=
|
||||
go.opentelemetry.io/collector/featuregate v1.5.0 h1:uK8qnYQKz1TMkK+FDTFsywg/EybW/gbnOUaPNUkRznM=
|
||||
go.opentelemetry.io/collector/receiver v0.74.0 h1:jlgBFa0iByvn8VuX27UxtqiPiZE8ejmU5lb1nSptWD8=
|
||||
go.opentelemetry.io/collector/receiver v0.74.0/go.mod h1:SQkyATvoZCJefNkI2jnrR63SOdrmDLYCnQqXJ7ACqn0=
|
||||
go.opentelemetry.io/collector/receiver/otlpreceiver v0.74.0 h1:e/X/W0z2Jtpy3Yd3CXkmEm9vSpKq/P3pKUrEVMUFBRw=
|
||||
go.opentelemetry.io/collector/receiver/otlpreceiver v0.74.0/go.mod h1:9X9/RYFxJIaK0JLlRZ0PpmQSSlYpY+r4KsTOj2jWj14=
|
||||
go.opentelemetry.io/collector/semconv v0.90.1 h1:2fkQZbefQBbIcNb9Rk1mRcWlFZgQOk7CpST1e1BK8eg=
|
||||
go.opentelemetry.io/collector/semconv v0.98.0 h1:zO4L4TmlxXoYu8UgPeYElGY19BW7wPjM+quL5CzoOoY=
|
||||
go.opentelemetry.io/contrib v0.18.0 h1:uqBh0brileIvG6luvBjdxzoFL8lxDGuhxJWsvK3BveI=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.42.0/go.mod h1:5z+/ZWJQKXa9YT34fQNx5K8Hd1EoIhvtUygUQPqEOgQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.48.0/go.mod h1:tIKj3DbO8N9Y2xo52og3irLsPI4GW02DSMtrVgNMgxg=
|
||||
@ -960,7 +930,6 @@ go.opentelemetry.io/otel/bridge/opencensus v0.37.0/go.mod h1:ddiK+1PE68l/Xk04BGT
|
||||
go.opentelemetry.io/otel/bridge/opentracing v1.10.0 h1:WzAVGovpC1s7KD5g4taU6BWYZP3QGSDVTlbRu9fIHw8=
|
||||
go.opentelemetry.io/otel/bridge/opentracing v1.10.0/go.mod h1:J7GLR/uxxqMAzZptsH0pjte3Ep4GacTCrbGBoDuHBqk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 h1:digkEZCJWobwBqMwC0cwCq8/wkkRy/OowZg5OArWZrM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0 h1:Mbi5PKN7u322woPa85d7ebZ+SOvEoPvoiBu+ryHWgfA=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.37.0 h1:NQc0epfL0xItsmGgSXgfbH2C1fq2VLXkZoDFsfRNHpc=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.37.0/go.mod h1:hB8qWjsStK36t50/R0V2ULFb4u95X/Q6zupXLgvjTh8=
|
||||
go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc=
|
||||
@ -979,14 +948,11 @@ golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/image v0.0.0-20220302094943-723b81ca9867 h1:TcHcE0vrmgzNH1v3ppjcMGbhG5+9fMuvOmUYwNEF4q4=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/telemetry v0.0.0-20240208230135-b75ee8823808 h1:+Kc94D8UVEVxJnLXp/+FMfqQARZtWHfVrcRtcG8aT3g=
|
||||
golang.org/x/telemetry v0.0.0-20240208230135-b75ee8823808/go.mod h1:KG1lNk5ZFNssSZLrpVb4sMXKMpGwGXOxSG3rnu2gZQQ=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2 h1:IRJeR9r1pYWsHKTRe/IInb7lYvbBVIqOgsX/u0mbOWY=
|
||||
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk=
|
||||
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc=
|
||||
gonum.org/v1/plot v0.10.1 h1:dnifSs43YJuNMDzB7v8wV64O4ABBHReuAVAoBxqBqS4=
|
||||
google.golang.org/api v0.166.0/go.mod h1:4FcBc686KFi7QI/U51/2GKKevfZMpM17sCdibqe/bSA=
|
||||
|
@ -76,6 +76,7 @@ type Options struct {
|
||||
type SearchOptions struct {
|
||||
ActionPrefix string // Needed for the PoC v1, it's probably going to be removed.
|
||||
Action string
|
||||
ActionSets []string
|
||||
Scope string
|
||||
NamespacedID string // ID of the identity (ex: user:3, service-account:4)
|
||||
wildcards Wildcards // private field computed based on the Scope
|
||||
|
@ -422,7 +422,16 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO potential changes needed here?
|
||||
func GetActionFilter(options accesscontrol.SearchOptions) func(action string) bool {
|
||||
return func(action string) bool {
|
||||
if options.ActionPrefix != "" {
|
||||
return strings.HasPrefix(action, options.ActionPrefix)
|
||||
} else {
|
||||
return action == options.Action
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SearchUsersPermissions returns all users' permissions filtered by action prefixes
|
||||
func (s *Service) SearchUsersPermissions(ctx context.Context, usr identity.Requester,
|
||||
options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) {
|
||||
@ -437,7 +446,6 @@ func (s *Service) SearchUsersPermissions(ctx context.Context, usr identity.Reque
|
||||
|
||||
// Reroute to the user specific implementation of search permissions
|
||||
// because it leverages the user permission cache.
|
||||
// TODO
|
||||
userPerms, err := s.SearchUserPermissions(ctx, usr.GetOrgID(), options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -463,6 +471,12 @@ func (s *Service) SearchUsersPermissions(ctx context.Context, usr identity.Reque
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.features.IsEnabled(ctx, featuremgmt.FlagAccessActionSets) {
|
||||
options.ActionSets = s.actionResolver.ResolveAction(options.Action)
|
||||
options.ActionSets = append(options.ActionSets,
|
||||
s.actionResolver.ResolveActionPrefix(options.ActionPrefix)...)
|
||||
}
|
||||
|
||||
// Get managed permissions (DB)
|
||||
usersPermissions, err := s.store.SearchUsersPermissions(ctx, usr.GetOrgID(), options)
|
||||
if err != nil {
|
||||
@ -522,6 +536,12 @@ func (s *Service) SearchUsersPermissions(ctx context.Context, usr identity.Reque
|
||||
}
|
||||
}
|
||||
|
||||
if s.features.IsEnabled(ctx, featuremgmt.FlagAccessActionSets) && len(options.ActionSets) > 0 {
|
||||
for id, perms := range res {
|
||||
res[id] = s.actionResolver.ExpandActionSetsWithFilter(perms, GetActionFilter(options))
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@ -566,6 +586,12 @@ func (s *Service) searchUserPermissions(ctx context.Context, orgID int64, search
|
||||
}
|
||||
}
|
||||
|
||||
if s.features.IsEnabled(ctx, featuremgmt.FlagAccessActionSets) {
|
||||
searchOptions.ActionSets = s.actionResolver.ResolveAction(searchOptions.Action)
|
||||
searchOptions.ActionSets = append(searchOptions.ActionSets,
|
||||
s.actionResolver.ResolveActionPrefix(searchOptions.ActionPrefix)...)
|
||||
}
|
||||
|
||||
// Get permissions from the DB
|
||||
dbPermissions, err := s.store.SearchUsersPermissions(ctx, orgID, searchOptions)
|
||||
if err != nil {
|
||||
@ -573,6 +599,10 @@ func (s *Service) searchUserPermissions(ctx context.Context, orgID int64, search
|
||||
}
|
||||
permissions = append(permissions, dbPermissions[userID]...)
|
||||
|
||||
if s.features.IsEnabled(ctx, featuremgmt.FlagAccessActionSets) && len(searchOptions.ActionSets) != 0 {
|
||||
permissions = s.actionResolver.ExpandActionSetsWithFilter(permissions, GetActionFilter(searchOptions))
|
||||
}
|
||||
|
||||
key := accesscontrol.GetSearchPermissionCacheKey(&user.SignedInUser{UserID: userID, OrgID: orgID}, searchOptions)
|
||||
s.cache.Set(key, permissions, cacheTTL)
|
||||
|
||||
|
@ -3,6 +3,7 @@ package acimpl
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@ -599,13 +600,15 @@ func TestService_SearchUsersPermissions(t *testing.T) {
|
||||
func TestService_SearchUserPermissions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tests := []struct {
|
||||
name string
|
||||
searchOption accesscontrol.SearchOptions
|
||||
ramRoles map[string]*accesscontrol.RoleDTO // BasicRole => RBAC BasicRole
|
||||
storedPerms map[int64][]accesscontrol.Permission // UserID => Permissions
|
||||
storedRoles map[int64][]string // UserID => Roles
|
||||
want []accesscontrol.Permission
|
||||
wantErr bool
|
||||
name string
|
||||
searchOption accesscontrol.SearchOptions
|
||||
withActionSets bool
|
||||
actionSets map[string][]string
|
||||
ramRoles map[string]*accesscontrol.RoleDTO // BasicRole => RBAC BasicRole
|
||||
storedPerms map[int64][]accesscontrol.Permission // UserID => Permissions
|
||||
storedRoles map[int64][]string // UserID => Roles
|
||||
want []accesscontrol.Permission
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "ram only",
|
||||
@ -726,10 +729,86 @@ func TestService_SearchUserPermissions(t *testing.T) {
|
||||
{Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "check action sets are correctly included if an action is specified",
|
||||
searchOption: accesscontrol.SearchOptions{
|
||||
Action: "dashboards:read",
|
||||
NamespacedID: fmt.Sprintf("%s:1", identity.NamespaceUser),
|
||||
},
|
||||
withActionSets: true,
|
||||
actionSets: map[string][]string{
|
||||
"dashboards:view": {"dashboards:read"},
|
||||
"dashboards:edit": {"dashboards:read", "dashboards:write", "dashboards:read-advanced"},
|
||||
},
|
||||
ramRoles: map[string]*accesscontrol.RoleDTO{
|
||||
string(roletype.RoleEditor): {Permissions: []accesscontrol.Permission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:ram"},
|
||||
}},
|
||||
},
|
||||
storedRoles: map[int64][]string{
|
||||
1: {string(roletype.RoleEditor)},
|
||||
},
|
||||
storedPerms: map[int64][]accesscontrol.Permission{
|
||||
1: {
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:stored"},
|
||||
{Action: "dashboards:edit", Scope: "dashboards:uid:stored2"},
|
||||
{Action: "dashboards:view", Scope: "dashboards:uid:stored3"},
|
||||
},
|
||||
},
|
||||
want: []accesscontrol.Permission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:ram"},
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:stored"},
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:stored2"},
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:stored3"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "check action sets are correctly included if an action prefix is specified",
|
||||
searchOption: accesscontrol.SearchOptions{
|
||||
ActionPrefix: "dashboards",
|
||||
NamespacedID: fmt.Sprintf("%s:1", identity.NamespaceUser),
|
||||
},
|
||||
withActionSets: true,
|
||||
actionSets: map[string][]string{
|
||||
"dashboards:view": {"dashboards:read"},
|
||||
"folders:view": {"dashboards:read", "folders:read"},
|
||||
"dashboards:edit": {"dashboards:read", "dashboards:write"},
|
||||
},
|
||||
ramRoles: map[string]*accesscontrol.RoleDTO{
|
||||
string(roletype.RoleEditor): {Permissions: []accesscontrol.Permission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:ram"},
|
||||
}},
|
||||
},
|
||||
storedRoles: map[int64][]string{
|
||||
1: {string(roletype.RoleEditor)},
|
||||
},
|
||||
storedPerms: map[int64][]accesscontrol.Permission{
|
||||
1: {
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:stored"},
|
||||
{Action: "folders:view", Scope: "folders:uid:stored2"},
|
||||
{Action: "dashboards:edit", Scope: "dashboards:uid:stored3"},
|
||||
},
|
||||
},
|
||||
want: []accesscontrol.Permission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:ram"},
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:stored"},
|
||||
{Action: "dashboards:read", Scope: "folders:uid:stored2"},
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:stored3"},
|
||||
{Action: "dashboards:write", Scope: "dashboards:uid:stored3"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac := setupTestEnv(t)
|
||||
if tt.withActionSets {
|
||||
ac.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets)
|
||||
actionSetSvc := resourcepermissions.NewActionSetService()
|
||||
for set, actions := range tt.actionSets {
|
||||
actionSetSvc.StoreActionSet(strings.Split(set, ":")[0], strings.Split(set, ":")[1], actions)
|
||||
}
|
||||
ac.actionResolver = actionSetSvc
|
||||
}
|
||||
|
||||
ac.roles = tt.ramRoles
|
||||
ac.store = actest.FakeStore{
|
||||
|
@ -228,10 +228,24 @@ func (s *AccessControlStore) SearchUsersPermissions(ctx context.Context, orgID i
|
||||
if options.ActionPrefix != "" {
|
||||
q += ` AND p.action LIKE ?`
|
||||
params = append(params, options.ActionPrefix+"%")
|
||||
if len(options.ActionSets) > 0 {
|
||||
q += ` OR p.action IN ( ? ` + strings.Repeat(", ?", len(options.ActionSets)-1) + ")"
|
||||
for _, a := range options.ActionSets {
|
||||
params = append(params, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
if options.Action != "" {
|
||||
q += ` AND p.action = ?`
|
||||
params = append(params, options.Action)
|
||||
if len(options.ActionSets) == 0 {
|
||||
q += ` AND p.action = ?`
|
||||
params = append(params, options.Action)
|
||||
} else {
|
||||
actions := append(options.ActionSets, options.Action)
|
||||
q += ` AND p.action IN ( ? ` + strings.Repeat(", ?", len(actions)-1) + ")"
|
||||
for _, a := range actions {
|
||||
params = append(params, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
if options.Scope != "" {
|
||||
// Search for scope and wildcard that include the scope
|
||||
|
@ -16,7 +16,15 @@ type ScopeAttributeResolver interface {
|
||||
}
|
||||
|
||||
type ActionResolver interface {
|
||||
// ExpandActionSets takes a set of permissions that might include some action set permissions, and returns a set of permissions with action sets expanded into underlying permissions
|
||||
ExpandActionSets(permissions []Permission) []Permission
|
||||
// ExpandActionSetsWithFilter works like ExpandActionSets, but it also takes a function for action filtering. When action sets are expanded into the underlying permissions,
|
||||
// only those permissions whose action is matched by actionMatcher are included.
|
||||
ExpandActionSetsWithFilter(permissions []Permission, actionMatcher func(action string) bool) []Permission
|
||||
// ResolveAction returns all action sets that include the given action
|
||||
ResolveAction(action string) []string
|
||||
// ResolveActionPrefix returns all action sets that include at least one action with the specified prefix
|
||||
ResolveActionPrefix(prefix string) []string
|
||||
}
|
||||
|
||||
// ScopeAttributeResolverFunc is an adapter to allow functions to implement ScopeAttributeResolver interface
|
||||
|
@ -13,6 +13,10 @@ func (f *FakeActionSetSvc) ResolveAction(action string) []string {
|
||||
return f.ExpectedActionSets
|
||||
}
|
||||
|
||||
func (f *FakeActionSetSvc) ResolveActionPrefix(prefix string) []string {
|
||||
return f.ExpectedActionSets
|
||||
}
|
||||
|
||||
func (f *FakeActionSetSvc) ResolveActionSet(actionSet string) []string {
|
||||
return f.ExpectedActions
|
||||
}
|
||||
@ -21,4 +25,8 @@ func (f *FakeActionSetSvc) ExpandActionSets(permissions []accesscontrol.Permissi
|
||||
return f.ExpectedPermissions
|
||||
}
|
||||
|
||||
func (f *FakeActionSetSvc) ExpandActionSetsWithFilter(permissions []accesscontrol.Permission, actionMatcher func(action string) bool) []accesscontrol.Permission {
|
||||
return f.ExpectedPermissions
|
||||
}
|
||||
|
||||
func (f *FakeActionSetSvc) StoreActionSet(resource, permission string, actions []string) {}
|
||||
|
@ -737,6 +737,31 @@ func managedPermission(action, resource string, resourceID, resourceAttribute st
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveActionPrefix returns all action sets that include at least one action with the specified prefix
|
||||
func (s *InMemoryActionSets) ResolveActionPrefix(prefix string) []string {
|
||||
if prefix == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
sets := make([]string, 0, len(s.actionSetToActions))
|
||||
|
||||
for set, actions := range s.actionSetToActions {
|
||||
// Only use action sets for folders and dashboards for now
|
||||
// We need to verify that action sets for other resources do not share names with actions (eg, `datasources:read`)
|
||||
if !isFolderOrDashboardAction(set) {
|
||||
continue
|
||||
}
|
||||
for _, action := range actions {
|
||||
if strings.HasPrefix(action, prefix) {
|
||||
sets = append(sets, set)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sets
|
||||
}
|
||||
|
||||
func (s *InMemoryActionSets) ResolveAction(action string) []string {
|
||||
actionSets := s.actionToActionSets[action]
|
||||
sets := make([]string, 0, len(actionSets))
|
||||
@ -766,7 +791,17 @@ func isFolderOrDashboardAction(action string) bool {
|
||||
return strings.HasPrefix(action, dashboards.ScopeDashboardsRoot) || strings.HasPrefix(action, dashboards.ScopeFoldersRoot)
|
||||
}
|
||||
|
||||
// ExpandActionSets takes a set of permissions that might include some action set permissions, and returns a set of permissions with action sets expanded into underlying permissions
|
||||
func (s *InMemoryActionSets) ExpandActionSets(permissions []accesscontrol.Permission) []accesscontrol.Permission {
|
||||
actionMatcher := func(_ string) bool {
|
||||
return true
|
||||
}
|
||||
return s.ExpandActionSetsWithFilter(permissions, actionMatcher)
|
||||
}
|
||||
|
||||
// ExpandActionSetsWithFilter works like ExpandActionSets, but it also takes a function for action filtering. When action sets are expanded into the underlying permissions,
|
||||
// only those permissions whose action is matched by actionMatcher are included.
|
||||
func (s *InMemoryActionSets) ExpandActionSetsWithFilter(permissions []accesscontrol.Permission, actionMatcher func(action string) bool) []accesscontrol.Permission {
|
||||
var expandedPermissions []accesscontrol.Permission
|
||||
for _, permission := range permissions {
|
||||
resolvedActions := s.ResolveActionSet(permission.Action)
|
||||
@ -775,6 +810,9 @@ func (s *InMemoryActionSets) ExpandActionSets(permissions []accesscontrol.Permis
|
||||
continue
|
||||
}
|
||||
for _, action := range resolvedActions {
|
||||
if !actionMatcher(action) {
|
||||
continue
|
||||
}
|
||||
permission.Action = action
|
||||
expandedPermissions = append(expandedPermissions, permission)
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
ac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/image"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
@ -172,6 +173,13 @@ func (ng *AlertNG) init() error {
|
||||
remotePrimary := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemotePrimary)
|
||||
remoteSecondary := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemoteSecondary)
|
||||
if ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Enable {
|
||||
autogenFn := remote.NoopAutogenFn
|
||||
if ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertingSimplifiedRouting) {
|
||||
autogenFn = func(ctx context.Context, logger log.Logger, orgID int64, cfg *definitions.PostableApiAlertingConfig, skipInvalid bool) error {
|
||||
return notifier.AddAutogenConfig(ctx, logger, ng.store, orgID, cfg, skipInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case remoteOnly:
|
||||
ng.Log.Debug("Starting Grafana with remote only mode enabled")
|
||||
@ -190,8 +198,9 @@ func (ng *AlertNG) init() error {
|
||||
TenantID: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.TenantID,
|
||||
URL: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.URL,
|
||||
PromoteConfig: true,
|
||||
SyncInterval: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.SyncInterval,
|
||||
}
|
||||
remoteAM, err := createRemoteAlertmanager(cfg, ng.KVStore, ng.SecretsService.Decrypt, m)
|
||||
remoteAM, err := createRemoteAlertmanager(cfg, ng.KVStore, ng.SecretsService.Decrypt, autogenFn, m)
|
||||
if err != nil {
|
||||
moaLogger.Error("Failed to create remote Alertmanager", "err", err)
|
||||
return nil, err
|
||||
@ -225,7 +234,7 @@ func (ng *AlertNG) init() error {
|
||||
TenantID: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.TenantID,
|
||||
URL: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.URL,
|
||||
}
|
||||
remoteAM, err := createRemoteAlertmanager(cfg, ng.KVStore, ng.SecretsService.Decrypt, m)
|
||||
remoteAM, err := createRemoteAlertmanager(cfg, ng.KVStore, ng.SecretsService.Decrypt, autogenFn, m)
|
||||
if err != nil {
|
||||
moaLogger.Error("Failed to create remote Alertmanager, falling back to using only the internal one", "err", err)
|
||||
return internalAM, nil
|
||||
@ -259,8 +268,9 @@ func (ng *AlertNG) init() error {
|
||||
OrgID: orgID,
|
||||
TenantID: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.TenantID,
|
||||
URL: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.URL,
|
||||
SyncInterval: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.SyncInterval,
|
||||
}
|
||||
remoteAM, err := createRemoteAlertmanager(cfg, ng.KVStore, ng.SecretsService.Decrypt, m)
|
||||
remoteAM, err := createRemoteAlertmanager(cfg, ng.KVStore, ng.SecretsService.Decrypt, autogenFn, m)
|
||||
if err != nil {
|
||||
moaLogger.Error("Failed to create remote Alertmanager, falling back to using only the internal one", "err", err)
|
||||
return internalAM, nil
|
||||
@ -611,6 +621,6 @@ func ApplyStateHistoryFeatureToggles(cfg *setting.UnifiedAlertingStateHistorySet
|
||||
}
|
||||
}
|
||||
|
||||
func createRemoteAlertmanager(cfg remote.AlertmanagerConfig, kvstore kvstore.KVStore, decryptFn remote.DecryptFn, m *metrics.RemoteAlertmanager) (*remote.Alertmanager, error) {
|
||||
return remote.NewAlertmanager(cfg, notifier.NewFileStore(cfg.OrgID, kvstore), decryptFn, m)
|
||||
func createRemoteAlertmanager(cfg remote.AlertmanagerConfig, kvstore kvstore.KVStore, decryptFn remote.DecryptFn, autogenFn remote.AutogenFn, m *metrics.RemoteAlertmanager) (*remote.Alertmanager, error) {
|
||||
return remote.NewAlertmanager(cfg, notifier.NewFileStore(cfg.OrgID, kvstore), decryptFn, autogenFn, m)
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ func TestMultiorgAlertmanager_RemoteSecondaryMode(t *testing.T) {
|
||||
DefaultConfig: setting.GetAlertmanagerDefaultConfiguration(),
|
||||
}
|
||||
m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry())
|
||||
remoteAM, err := remote.NewAlertmanager(externalAMCfg, notifier.NewFileStore(orgID, kvStore), secretsService.Decrypt, m)
|
||||
remoteAM, err := remote.NewAlertmanager(externalAMCfg, notifier.NewFileStore(orgID, kvStore), secretsService.Decrypt, remote.NoopAutogenFn, m)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use both Alertmanager implementations in the forked Alertmanager.
|
||||
|
@ -38,10 +38,19 @@ type stateStore interface {
|
||||
GetNotificationLog(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// AutogenFn is a function that adds auto-generated routes to a configuration.
|
||||
type AutogenFn func(ctx context.Context, logger log.Logger, orgId int64, config *apimodels.PostableApiAlertingConfig, skipInvalid bool) error
|
||||
|
||||
// NoopAutogenFn is used to skip auto-generating routes.
|
||||
func NoopAutogenFn(_ context.Context, _ log.Logger, _ int64, _ *apimodels.PostableApiAlertingConfig, _ bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecryptFn is a function that takes in an encrypted value and returns it decrypted.
|
||||
type DecryptFn func(ctx context.Context, payload []byte) ([]byte, error)
|
||||
|
||||
type Alertmanager struct {
|
||||
autogenFn AutogenFn
|
||||
decrypt DecryptFn
|
||||
defaultConfig string
|
||||
defaultConfigHash string
|
||||
@ -54,6 +63,9 @@ type Alertmanager struct {
|
||||
tenantID string
|
||||
url string
|
||||
|
||||
lastConfigSync time.Time
|
||||
syncInterval time.Duration
|
||||
|
||||
amClient *remoteClient.Alertmanager
|
||||
mimirClient remoteClient.MimirClient
|
||||
}
|
||||
@ -68,6 +80,9 @@ type AlertmanagerConfig struct {
|
||||
// PromoteConfig is a flag that determines whether the configuration should be used in the remote Alertmanager.
|
||||
// The same flag is used for promoting state.
|
||||
PromoteConfig bool
|
||||
|
||||
// SyncInterval determines how often we should attempt to synchronize configuration.
|
||||
SyncInterval time.Duration
|
||||
}
|
||||
|
||||
func (cfg *AlertmanagerConfig) Validate() error {
|
||||
@ -85,7 +100,7 @@ func (cfg *AlertmanagerConfig) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewAlertmanager(cfg AlertmanagerConfig, store stateStore, decryptFn DecryptFn, metrics *metrics.RemoteAlertmanager) (*Alertmanager, error) {
|
||||
func NewAlertmanager(cfg AlertmanagerConfig, store stateStore, decryptFn DecryptFn, autogenFn AutogenFn, metrics *metrics.RemoteAlertmanager) (*Alertmanager, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -151,6 +166,7 @@ func NewAlertmanager(cfg AlertmanagerConfig, store stateStore, decryptFn Decrypt
|
||||
|
||||
return &Alertmanager{
|
||||
amClient: amc,
|
||||
autogenFn: autogenFn,
|
||||
decrypt: decryptFn,
|
||||
defaultConfig: string(rawCfg),
|
||||
defaultConfigHash: fmt.Sprintf("%x", md5.Sum(rawCfg)),
|
||||
@ -160,42 +176,43 @@ func NewAlertmanager(cfg AlertmanagerConfig, store stateStore, decryptFn Decrypt
|
||||
orgID: cfg.OrgID,
|
||||
state: store,
|
||||
sender: s,
|
||||
syncInterval: cfg.SyncInterval,
|
||||
tenantID: cfg.TenantID,
|
||||
url: cfg.URL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ApplyConfig is called everytime we've determined we need to apply an existing configuration to the Alertmanager,
|
||||
// including the first time the Alertmanager is started. In the context of a "remote Alertmanager" it's as good of a heuristic,
|
||||
// for "a function that gets called when the Alertmanager starts". As a result we do two things:
|
||||
// ApplyConfig is called by the multi-org Alertmanager on startup and on every sync loop iteration (1m default).
|
||||
// We do two things on startup:
|
||||
// 1. Execute a readiness check to make sure the remote Alertmanager we're about to communicate with is up and ready.
|
||||
// 2. Upload the configuration and state we currently hold.
|
||||
// On each subsequent call to ApplyConfig we compare and upload only the configuration.
|
||||
func (am *Alertmanager) ApplyConfig(ctx context.Context, config *models.AlertConfiguration) error {
|
||||
if am.ready {
|
||||
am.log.Debug("Alertmanager previously marked as ready, skipping readiness check and config + state update")
|
||||
am.log.Debug("Alertmanager previously marked as ready, skipping readiness check and state sync")
|
||||
} else {
|
||||
am.log.Debug("Start readiness check for remote Alertmanager", "url", am.url)
|
||||
if err := am.checkReadiness(ctx); err != nil {
|
||||
return fmt.Errorf("unable to pass the readiness check: %w", err)
|
||||
}
|
||||
am.log.Debug("Completed readiness check for remote Alertmanager, starting state upload", "url", am.url)
|
||||
|
||||
if err := am.CompareAndSendState(ctx); err != nil {
|
||||
return fmt.Errorf("unable to upload the state to the remote Alertmanager: %w", err)
|
||||
}
|
||||
am.log.Debug("Completed state upload to remote Alertmanager", "url", am.url)
|
||||
}
|
||||
|
||||
if time.Since(am.lastConfigSync) < am.syncInterval {
|
||||
am.log.Debug("Not syncing configuration to remote Alertmanager, last sync was too recent")
|
||||
return nil
|
||||
}
|
||||
|
||||
// First, execute a readiness check to make sure the remote Alertmanager is ready.
|
||||
am.log.Debug("Start readiness check for remote Alertmanager", "url", am.url)
|
||||
if err := am.checkReadiness(ctx); err != nil {
|
||||
am.log.Error("Unable to pass the readiness check", "err", err)
|
||||
return err
|
||||
}
|
||||
am.log.Debug("Completed readiness check for remote Alertmanager", "url", am.url)
|
||||
|
||||
// Send configuration and base64-encoded state if necessary.
|
||||
am.log.Debug("Start configuration upload to remote Alertmanager", "url", am.url)
|
||||
if err := am.CompareAndSendConfiguration(ctx, config); err != nil {
|
||||
am.log.Error("Unable to upload the configuration to the remote Alertmanager", "err", err)
|
||||
return fmt.Errorf("unable to upload the configuration to the remote Alertmanager: %w", err)
|
||||
}
|
||||
am.log.Debug("Completed configuration upload to remote Alertmanager", "url", am.url)
|
||||
|
||||
am.log.Debug("Start state upload to remote Alertmanager", "url", am.url)
|
||||
if err := am.CompareAndSendState(ctx); err != nil {
|
||||
am.log.Error("Unable to upload the state to the remote Alertmanager", "err", err)
|
||||
}
|
||||
am.log.Debug("Completed state upload to remote Alertmanager", "url", am.url)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -223,7 +240,10 @@ func (am *Alertmanager) CompareAndSendConfiguration(ctx context.Context, config
|
||||
return err
|
||||
}
|
||||
|
||||
// Decrypt the configuration before comparing.
|
||||
// Add auto-generated routes and decrypt before comparing.
|
||||
if err := am.autogenFn(ctx, am.log, am.orgID, &c.AlertmanagerConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
decrypted, err := am.decryptConfiguration(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -261,6 +281,7 @@ func (am *Alertmanager) sendConfiguration(ctx context.Context, decrypted *apimod
|
||||
return err
|
||||
}
|
||||
am.metrics.LastConfigSync.SetToCurrentTime()
|
||||
am.lastConfigSync = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -292,11 +313,15 @@ func (am *Alertmanager) SaveAndApplyConfig(ctx context.Context, cfg *apimodels.P
|
||||
}
|
||||
hash := fmt.Sprintf("%x", md5.Sum(rawCfg))
|
||||
|
||||
// Decrypt and send.
|
||||
// Add auto-generated routes and decrypt before sending.
|
||||
if err := am.autogenFn(ctx, am.log, am.orgID, &cfg.AlertmanagerConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
decrypted, err := am.decryptConfiguration(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return am.sendConfiguration(ctx, decrypted, hash, time.Now().Unix(), false)
|
||||
}
|
||||
|
||||
@ -307,7 +332,10 @@ func (am *Alertmanager) SaveAndApplyDefaultConfig(ctx context.Context) error {
|
||||
return fmt.Errorf("unable to parse the default configuration: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt before sending.
|
||||
// Add auto-generated routes and decrypt before sending.
|
||||
if err := am.autogenFn(ctx, am.log, am.orgID, &c.AlertmanagerConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
decrypted, err := am.decryptConfiguration(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -20,7 +20,9 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/alerting/definition"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
@ -51,6 +53,7 @@ const (
|
||||
|
||||
var (
|
||||
defaultGrafanaConfig = setting.GetAlertmanagerDefaultConfiguration()
|
||||
errTest = errors.New("test")
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@ -102,7 +105,7 @@ func TestNewAlertmanager(t *testing.T) {
|
||||
DefaultConfig: defaultGrafanaConfig,
|
||||
}
|
||||
m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry())
|
||||
am, err := NewAlertmanager(cfg, nil, secretsService.Decrypt, m)
|
||||
am, err := NewAlertmanager(cfg, nil, secretsService.Decrypt, NoopAutogenFn, m)
|
||||
if test.expErr != "" {
|
||||
require.EqualError(tt, err, test.expErr)
|
||||
return
|
||||
@ -118,16 +121,26 @@ func TestNewAlertmanager(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApplyConfig(t *testing.T) {
|
||||
// errorHandler returns an error response for the readiness check and state sync.
|
||||
errorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("content-type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"status": "error"}))
|
||||
})
|
||||
|
||||
var configSent client.UserGrafanaConfig
|
||||
var lastConfigSync, lastStateSync time.Time
|
||||
okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/config") {
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&configSent))
|
||||
if r.Method == http.MethodPost {
|
||||
if strings.Contains(r.URL.Path, "/config") {
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&configSent))
|
||||
lastConfigSync = time.Now()
|
||||
} else {
|
||||
lastStateSync = time.Now()
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Add("content-type", "application/json")
|
||||
require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"status": "success"}))
|
||||
})
|
||||
|
||||
// Encrypt receivers to save secrets in the database.
|
||||
@ -153,6 +166,7 @@ func TestApplyConfig(t *testing.T) {
|
||||
URL: server.URL,
|
||||
DefaultConfig: defaultGrafanaConfig,
|
||||
PromoteConfig: true,
|
||||
SyncInterval: 1 * time.Hour,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
@ -163,7 +177,7 @@ func TestApplyConfig(t *testing.T) {
|
||||
|
||||
// An error response from the remote Alertmanager should result in the readiness check failing.
|
||||
m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry())
|
||||
am, err := NewAlertmanager(cfg, fstore, secretsService.Decrypt, m)
|
||||
am, err := NewAlertmanager(cfg, fstore, secretsService.Decrypt, NoopAutogenFn, m)
|
||||
require.NoError(t, err)
|
||||
|
||||
config := &ngmodels.AlertConfiguration{
|
||||
@ -183,22 +197,35 @@ func TestApplyConfig(t *testing.T) {
|
||||
require.JSONEq(t, testGrafanaConfigWithSecret, string(amCfg))
|
||||
require.True(t, configSent.Promoted)
|
||||
|
||||
// If we already got a 200 status code response, we shouldn't make the HTTP request again.
|
||||
server.Config.Handler = errorHandler
|
||||
// If we already got a 200 status code response and the sync interval hasn't elapsed,
|
||||
// we shouldn't send the state/configuration again.
|
||||
expStateSync := lastStateSync
|
||||
expConfigSync := lastConfigSync
|
||||
require.NoError(t, am.ApplyConfig(ctx, config))
|
||||
require.True(t, am.Ready())
|
||||
require.Equal(t, expStateSync, lastStateSync)
|
||||
require.Equal(t, expConfigSync, lastConfigSync)
|
||||
|
||||
// Changing the sync interval and calling ApplyConfig again
|
||||
// should result in us sending the configuration but not the state.
|
||||
am.syncInterval = 0
|
||||
require.NoError(t, am.ApplyConfig(ctx, config))
|
||||
require.Equal(t, lastStateSync, expStateSync)
|
||||
require.Greater(t, lastConfigSync, expConfigSync)
|
||||
|
||||
// Failing to add the auto-generated routes should result in an error.
|
||||
am.autogenFn = errAutogenFn
|
||||
require.ErrorIs(t, am.ApplyConfig(ctx, config), errTest)
|
||||
}
|
||||
|
||||
func TestCompareAndSendConfiguration(t *testing.T) {
|
||||
cfgWithSecret, err := notifier.Load([]byte(testGrafanaConfigWithSecret))
|
||||
require.NoError(t, err)
|
||||
testValue := []byte("test")
|
||||
testErr := errors.New("test error")
|
||||
decryptFn := func(_ context.Context, payload []byte) ([]byte, error) {
|
||||
if string(payload) == string(testValue) {
|
||||
return testValue, nil
|
||||
}
|
||||
return nil, testErr
|
||||
return nil, errTest
|
||||
}
|
||||
|
||||
var got string
|
||||
@ -222,40 +249,48 @@ func TestCompareAndSendConfiguration(t *testing.T) {
|
||||
URL: server.URL,
|
||||
DefaultConfig: defaultGrafanaConfig,
|
||||
}
|
||||
am, err := NewAlertmanager(cfg,
|
||||
fstore,
|
||||
decryptFn,
|
||||
m,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config string
|
||||
expCfg *client.UserGrafanaConfig
|
||||
expErr string
|
||||
name string
|
||||
config string
|
||||
autogenFn AutogenFn
|
||||
expCfg *client.UserGrafanaConfig
|
||||
expErr string
|
||||
}{
|
||||
{
|
||||
"invalid config",
|
||||
"{}",
|
||||
NoopAutogenFn,
|
||||
nil,
|
||||
"unable to parse Alertmanager configuration: no route provided in config",
|
||||
},
|
||||
{
|
||||
"invalid base-64 in key",
|
||||
strings.Replace(testGrafanaConfigWithSecret, `"password":"test"`, `"password":"!"`, 1),
|
||||
NoopAutogenFn,
|
||||
nil,
|
||||
"unable to decrypt the configuration: failed to decode value for key 'password': illegal base64 data at input byte 0",
|
||||
},
|
||||
{
|
||||
"decrypt error",
|
||||
testGrafanaConfigWithSecret,
|
||||
NoopAutogenFn,
|
||||
nil,
|
||||
fmt.Sprintf("unable to decrypt the configuration: failed to decrypt value for key 'password': %s", testErr.Error()),
|
||||
fmt.Sprintf("unable to decrypt the configuration: failed to decrypt value for key 'password': %s", errTest.Error()),
|
||||
},
|
||||
{
|
||||
"error from autogen function",
|
||||
strings.Replace(testGrafanaConfigWithSecret, `"password":"test"`, fmt.Sprintf("%q:%q", "password", base64.StdEncoding.EncodeToString(testValue)), 1),
|
||||
errAutogenFn,
|
||||
&client.UserGrafanaConfig{
|
||||
GrafanaAlertmanagerConfig: cfgWithSecret,
|
||||
},
|
||||
errTest.Error(),
|
||||
},
|
||||
{
|
||||
"no error",
|
||||
strings.Replace(testGrafanaConfigWithSecret, `"password":"test"`, fmt.Sprintf("%q:%q", "password", base64.StdEncoding.EncodeToString(testValue)), 1),
|
||||
NoopAutogenFn,
|
||||
&client.UserGrafanaConfig{
|
||||
GrafanaAlertmanagerConfig: cfgWithSecret,
|
||||
},
|
||||
@ -265,6 +300,14 @@ func TestCompareAndSendConfiguration(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(tt *testing.T) {
|
||||
am, err := NewAlertmanager(cfg,
|
||||
fstore,
|
||||
decryptFn,
|
||||
test.autogenFn,
|
||||
m,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := ngmodels.AlertConfiguration{
|
||||
AlertmanagerConfiguration: test.config,
|
||||
}
|
||||
@ -321,7 +364,7 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) {
|
||||
|
||||
secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(db.InitTestDB(t)))
|
||||
m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry())
|
||||
am, err := NewAlertmanager(cfg, fstore, secretsService.Decrypt, m)
|
||||
am, err := NewAlertmanager(cfg, fstore, secretsService.Decrypt, NoopAutogenFn, m)
|
||||
require.NoError(t, err)
|
||||
|
||||
encodedFullState, err := am.getFullState(ctx)
|
||||
@ -417,6 +460,11 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) {
|
||||
require.JSONEq(t, testGrafanaConfigWithSecret, string(got))
|
||||
require.Equal(t, fmt.Sprintf("%x", md5.Sum(encryptedConfig)), config.Hash)
|
||||
require.False(t, config.Default)
|
||||
|
||||
// An error while adding auto-generated rutes should be returned.
|
||||
am.autogenFn = errAutogenFn
|
||||
require.ErrorIs(t, am.SaveAndApplyConfig(ctx, postableCfg), errTest)
|
||||
am.autogenFn = NoopAutogenFn
|
||||
}
|
||||
|
||||
// `SaveAndApplyDefaultConfig` should send the default Alertmanager configuration to the remote Alertmanager.
|
||||
@ -439,6 +487,11 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) {
|
||||
require.JSONEq(t, string(want), string(got))
|
||||
require.Equal(t, fmt.Sprintf("%x", md5.Sum(want)), config.Hash)
|
||||
require.True(t, config.Default)
|
||||
|
||||
// An error while adding auto-generated rutes should be returned.
|
||||
am.autogenFn = errAutogenFn
|
||||
require.ErrorIs(t, am.SaveAndApplyDefaultConfig(ctx), errTest)
|
||||
am.autogenFn = NoopAutogenFn
|
||||
}
|
||||
|
||||
// TODO: Now, shutdown the Alertmanager and we expect the latest configuration to be uploaded.
|
||||
@ -468,7 +521,7 @@ func TestIntegrationRemoteAlertmanagerGetStatus(t *testing.T) {
|
||||
|
||||
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry())
|
||||
am, err := NewAlertmanager(cfg, nil, secretsService.Decrypt, m)
|
||||
am, err := NewAlertmanager(cfg, nil, secretsService.Decrypt, NoopAutogenFn, m)
|
||||
require.NoError(t, err)
|
||||
|
||||
// We should get the default Cloud Alertmanager configuration.
|
||||
@ -502,7 +555,7 @@ func TestIntegrationRemoteAlertmanagerSilences(t *testing.T) {
|
||||
|
||||
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry())
|
||||
am, err := NewAlertmanager(cfg, nil, secretsService.Decrypt, m)
|
||||
am, err := NewAlertmanager(cfg, nil, secretsService.Decrypt, NoopAutogenFn, m)
|
||||
require.NoError(t, err)
|
||||
|
||||
// We should have no silences at first.
|
||||
@ -587,7 +640,7 @@ func TestIntegrationRemoteAlertmanagerAlerts(t *testing.T) {
|
||||
|
||||
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry())
|
||||
am, err := NewAlertmanager(cfg, nil, secretsService.Decrypt, m)
|
||||
am, err := NewAlertmanager(cfg, nil, secretsService.Decrypt, NoopAutogenFn, m)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait until the Alertmanager is ready to send alerts.
|
||||
@ -656,7 +709,7 @@ func TestIntegrationRemoteAlertmanagerReceivers(t *testing.T) {
|
||||
|
||||
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
m := metrics.NewRemoteAlertmanagerMetrics(prometheus.NewRegistry())
|
||||
am, err := NewAlertmanager(cfg, nil, secretsService.Decrypt, m)
|
||||
am, err := NewAlertmanager(cfg, nil, secretsService.Decrypt, NoopAutogenFn, m)
|
||||
require.NoError(t, err)
|
||||
|
||||
// We should start with the default config.
|
||||
@ -682,6 +735,11 @@ func genAlert(active bool, labels map[string]string) amv2.PostableAlert {
|
||||
}
|
||||
}
|
||||
|
||||
// errAutogenFn is an AutogenFn that always returns an error.
|
||||
func errAutogenFn(_ context.Context, _ log.Logger, _ int64, _ *definition.PostableApiAlertingConfig, _ bool) error {
|
||||
return errTest
|
||||
}
|
||||
|
||||
const defaultCloudAMConfig = `
|
||||
global:
|
||||
resolve_timeout: 5m
|
||||
|
@ -1,8 +1,6 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/auth/identity"
|
||||
@ -219,27 +217,6 @@ type CompleteEmailVerifyCommand struct {
|
||||
Code string
|
||||
}
|
||||
|
||||
type ErrCaseInsensitiveLoginConflict struct {
|
||||
Users []User
|
||||
}
|
||||
|
||||
func (e *ErrCaseInsensitiveLoginConflict) Unwrap() error {
|
||||
return ErrCaseInsensitive
|
||||
}
|
||||
|
||||
func (e *ErrCaseInsensitiveLoginConflict) Error() string {
|
||||
n := len(e.Users)
|
||||
|
||||
userStrings := make([]string, 0, n)
|
||||
for _, v := range e.Users {
|
||||
userStrings = append(userStrings, fmt.Sprintf("%s (email:%s, id:%d)", v.Login, v.Email, v.ID))
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Found a conflict in user login information. %d users already exist with either the same login or email: [%s].",
|
||||
n, strings.Join(userStrings, ", "))
|
||||
}
|
||||
|
||||
type Filter interface {
|
||||
WhereCondition() *WhereCondition
|
||||
InCondition() *InCondition
|
||||
|
@ -185,14 +185,14 @@ func (ss *sqlStore) GetByEmail(ctx context.Context, query *user.GetUserByEmailQu
|
||||
}
|
||||
|
||||
// LoginConflict returns an error if the provided email or login are already
|
||||
// associated with a user. If caseInsensitive is true the search is not case
|
||||
// sensitive.
|
||||
// associated with a user.
|
||||
func (ss *sqlStore) LoginConflict(ctx context.Context, login, email string) error {
|
||||
// enforcement of lowercase due to forcement of caseinsensitive login
|
||||
login = strings.ToLower(login)
|
||||
email = strings.ToLower(email)
|
||||
|
||||
err := ss.db.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
users := make([]user.User, 0)
|
||||
where := "email=? OR login=?"
|
||||
login = strings.ToLower(login)
|
||||
email = strings.ToLower(email)
|
||||
|
||||
exists, err := sess.Where(where, email, login).Get(&user.User{})
|
||||
if err != nil {
|
||||
@ -201,14 +201,7 @@ func (ss *sqlStore) LoginConflict(ctx context.Context, login, email string) erro
|
||||
if exists {
|
||||
return user.ErrUserAlreadyExists
|
||||
}
|
||||
if err := sess.Where("LOWER(email)=LOWER(?) OR LOWER(login)=LOWER(?)",
|
||||
email, login).Find(&users); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(users) > 1 {
|
||||
return &user.ErrCaseInsensitiveLoginConflict{Users: users}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
|
@ -116,7 +116,7 @@ describe('PanelOptions', () => {
|
||||
|
||||
expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('Panel options Title'))).toBeInTheDocument();
|
||||
|
||||
const input = screen.getByTestId('panel-edit-panel-title-input');
|
||||
const input = screen.getByTestId(selectors.components.PanelEditor.OptionsPane.fieldInput('Title'));
|
||||
fireEvent.change(input, { target: { value: 'New title' } });
|
||||
|
||||
expect(vizManager.state.panel.state.title).toBe('New title');
|
||||
@ -127,7 +127,7 @@ describe('PanelOptions', () => {
|
||||
|
||||
expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('Panel options Title'))).toBeInTheDocument();
|
||||
|
||||
const input = screen.getByTestId('panel-edit-panel-title-input');
|
||||
const input = screen.getByTestId(selectors.components.PanelEditor.OptionsPane.fieldInput('Title'));
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
|
||||
expect(vizManager.state.panel.state.title).toBe('');
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
import { RadioButtonGroup, Select, DataLinksInlineEditor, Input, TextArea, Switch } from '@grafana/ui';
|
||||
@ -187,7 +188,7 @@ function PanelFrameTitle({ vizManager }: { vizManager: VizPanelManager }) {
|
||||
|
||||
return (
|
||||
<Input
|
||||
data-testid="panel-edit-panel-title-input"
|
||||
data-testid={selectors.components.PanelEditor.OptionsPane.fieldInput('Title')}
|
||||
value={title}
|
||||
onChange={(e) => vizManager.setPanelTitle(e.currentTarget.value)}
|
||||
/>
|
||||
|
@ -8,6 +8,8 @@ import { SceneVariable, SceneVariableState } from '@grafana/scenes';
|
||||
import { useStyles2, Stack, Button, EmptyState, TextLink } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { VariablesDependenciesButton } from '../../variables/VariablesDependenciesButton';
|
||||
|
||||
import { VariableEditorListRow } from './VariableEditorListRow';
|
||||
|
||||
export interface Props {
|
||||
@ -81,6 +83,7 @@ export function VariableEditorList({
|
||||
</table>
|
||||
</div>
|
||||
<Stack>
|
||||
<VariablesDependenciesButton variables={variables} />
|
||||
<Button
|
||||
data-testid={selectors.pages.Dashboard.Settings.Variables.List.newButton}
|
||||
onClick={onAdd}
|
||||
|
@ -0,0 +1,46 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { SceneVariable, SceneVariableState } from '@grafana/scenes';
|
||||
import { Button } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { NetworkGraphModal } from 'app/features/variables/inspect/NetworkGraphModal';
|
||||
|
||||
import { createDependencyEdges, createDependencyNodes, filterNodesWithDependencies } from './utils';
|
||||
|
||||
interface Props {
|
||||
variables: Array<SceneVariable<SceneVariableState>>;
|
||||
}
|
||||
|
||||
export const VariablesDependenciesButton = ({ variables }: Props) => {
|
||||
const nodes = useMemo(() => createDependencyNodes(variables), [variables]);
|
||||
const edges = useMemo(() => createDependencyEdges(variables), [variables]);
|
||||
|
||||
if (!edges.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NetworkGraphModal
|
||||
show={false}
|
||||
title={t('dashboards.settings.variables.dependencies.title', 'Dependencies')}
|
||||
nodes={filterNodesWithDependencies(nodes, edges)}
|
||||
edges={edges}
|
||||
>
|
||||
{({ showModal }) => {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
reportInteraction('Show variable dependencies');
|
||||
showModal();
|
||||
}}
|
||||
icon="channel-add"
|
||||
variant="secondary"
|
||||
>
|
||||
<Trans i18nKey={'dashboards.settings.variables.dependencies.button'}>Show dependencies</Trans>
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</NetworkGraphModal>
|
||||
);
|
||||
};
|
40
public/app/features/dashboard-scene/variables/utils.test.ts
Normal file
40
public/app/features/dashboard-scene/variables/utils.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { TestVariable } from '@grafana/scenes';
|
||||
import { variableAdapters } from 'app/features/variables/adapters';
|
||||
import { createCustomVariableAdapter } from 'app/features/variables/custom/adapter';
|
||||
import { createDataSourceVariableAdapter } from 'app/features/variables/datasource/adapter';
|
||||
import { createQueryVariableAdapter } from 'app/features/variables/query/adapter';
|
||||
|
||||
import { createDependencyEdges, createDependencyNodes } from './utils';
|
||||
|
||||
variableAdapters.setInit(() => [
|
||||
createDataSourceVariableAdapter(),
|
||||
createCustomVariableAdapter(),
|
||||
createQueryVariableAdapter(),
|
||||
]);
|
||||
|
||||
describe('createDependencyNodes', () => {
|
||||
it('should create node for each variable', () => {
|
||||
const variables = [
|
||||
new TestVariable({ name: 'A', query: 'A.*', value: '', text: '', options: [] }),
|
||||
new TestVariable({ name: 'B', query: 'B.*', value: '', text: '', options: [] }),
|
||||
new TestVariable({ name: 'C', query: 'C.*', value: '', text: '', options: [] }),
|
||||
];
|
||||
const graphNodes = createDependencyNodes(variables);
|
||||
expect(graphNodes[0].id).toBe('A');
|
||||
expect(graphNodes[1].id).toBe('B');
|
||||
expect(graphNodes[2].id).toBe('C');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDependencyEdges', () => {
|
||||
it('should create edges for variable dependencies', () => {
|
||||
const variables = [
|
||||
new TestVariable({ name: 'A', query: 'A.*', value: '', text: '', options: [] }),
|
||||
new TestVariable({ name: 'B', query: '${A}.*', value: '', text: '', options: [] }),
|
||||
new TestVariable({ name: 'C', query: '${B}.*', value: '', text: '', options: [] }),
|
||||
];
|
||||
const graphEdges = createDependencyEdges(variables);
|
||||
expect(graphEdges).toContainEqual({ from: 'B', to: 'A' });
|
||||
expect(graphEdges).toContainEqual({ from: 'C', to: 'B' });
|
||||
});
|
||||
});
|
28
public/app/features/dashboard-scene/variables/utils.ts
Normal file
28
public/app/features/dashboard-scene/variables/utils.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { SceneVariable, SceneVariableState } from '@grafana/scenes';
|
||||
import { GraphEdge, GraphNode } from 'app/features/variables/inspect/utils';
|
||||
|
||||
export function createDependencyNodes(variables: Array<SceneVariable<SceneVariableState>>): GraphNode[] {
|
||||
return variables.map((variable) => ({ id: variable.state.name, label: `${variable.state.name}` }));
|
||||
}
|
||||
|
||||
export function filterNodesWithDependencies(nodes: GraphNode[], edges: GraphEdge[]): GraphNode[] {
|
||||
return nodes.filter((node) => edges.some((edge) => edge.from === node.id || edge.to === node.id));
|
||||
}
|
||||
|
||||
export const createDependencyEdges = (variables: Array<SceneVariable<SceneVariableState>>): GraphEdge[] => {
|
||||
const edges: GraphEdge[] = [];
|
||||
for (const variable of variables) {
|
||||
for (const other of variables) {
|
||||
if (variable === other) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dependsOn = variable.variableDependency?.hasDependencyOn(other.state.name);
|
||||
if (dependsOn) {
|
||||
edges.push({ from: variable.state.name, to: other.state.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
};
|
@ -1,26 +1,17 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { TypedVariableModel } from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { Button } from '@grafana/ui';
|
||||
|
||||
import { store } from '../../../store/store';
|
||||
import { VariableModel } from '../types';
|
||||
|
||||
import { NetworkGraphModal } from './NetworkGraphModal';
|
||||
import { createDependencyEdges, createDependencyNodes, filterNodesWithDependencies } from './utils';
|
||||
|
||||
interface OwnProps {
|
||||
variables: VariableModel[];
|
||||
interface Props {
|
||||
variables: TypedVariableModel[];
|
||||
}
|
||||
|
||||
interface ConnectedProps {}
|
||||
|
||||
interface DispatchProps {}
|
||||
|
||||
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||
|
||||
export const UnProvidedVariablesDependenciesButton = ({ variables }: Props) => {
|
||||
export const VariablesDependenciesButton = ({ variables }: Props) => {
|
||||
const nodes = useMemo(() => createDependencyNodes(variables), [variables]);
|
||||
const edges = useMemo(() => createDependencyEdges(variables), [variables]);
|
||||
|
||||
@ -52,9 +43,3 @@ export const UnProvidedVariablesDependenciesButton = ({ variables }: Props) => {
|
||||
</NetworkGraphModal>
|
||||
);
|
||||
};
|
||||
|
||||
export const VariablesDependenciesButton = (props: Props) => (
|
||||
<Provider store={store}>
|
||||
<UnProvidedVariablesDependenciesButton {...props} />
|
||||
</Provider>
|
||||
);
|
||||
|
@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
import { SIGV4ConnectionConfig } from '@grafana/aws-sdk';
|
||||
import { DataSourcePluginOptionsEditorProps, SelectableValue } from '@grafana/data';
|
||||
import { DataSourceHttpSettings, InlineField, InlineFormLabel, InlineSwitch, Select, Text } from '@grafana/ui';
|
||||
import { Box, DataSourceHttpSettings, InlineField, InlineSwitch, Select, Text } from '@grafana/ui';
|
||||
import { config } from 'app/core/config';
|
||||
|
||||
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from './types';
|
||||
@ -47,50 +47,45 @@ export const ConfigEditor = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<h3 className="page-heading">Alertmanager</h3>
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<InlineFormLabel width={13}>Implementation</InlineFormLabel>
|
||||
<Select
|
||||
width={40}
|
||||
options={IMPL_OPTIONS}
|
||||
value={options.jsonData.implementation || AlertManagerImplementation.mimir}
|
||||
onChange={(value) =>
|
||||
onOptionsChange({
|
||||
...options,
|
||||
jsonData: {
|
||||
...options.jsonData,
|
||||
implementation: value.value,
|
||||
},
|
||||
<Box marginBottom={5}>
|
||||
<InlineField label="Implementation" labelWidth={26}>
|
||||
<Select
|
||||
width={40}
|
||||
options={IMPL_OPTIONS}
|
||||
value={options.jsonData.implementation || AlertManagerImplementation.mimir}
|
||||
onChange={(value) =>
|
||||
onOptionsChange({
|
||||
...options,
|
||||
jsonData: {
|
||||
...options.jsonData,
|
||||
implementation: value.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField
|
||||
label="Receive Grafana Alerts"
|
||||
tooltip="When enabled, Grafana-managed alerts are sent to this Alertmanager."
|
||||
labelWidth={26}
|
||||
>
|
||||
<InlineSwitch
|
||||
value={options.jsonData.handleGrafanaManagedAlerts ?? false}
|
||||
onChange={(e) => {
|
||||
onOptionsChange(
|
||||
produce(options, (draft) => {
|
||||
draft.jsonData.handleGrafanaManagedAlerts = e.currentTarget.checked;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gf-form-inline">
|
||||
<InlineField
|
||||
label="Receive Grafana Alerts"
|
||||
tooltip="When enabled, Grafana-managed alerts are sent to this Alertmanager."
|
||||
labelWidth={26}
|
||||
>
|
||||
<InlineSwitch
|
||||
value={options.jsonData.handleGrafanaManagedAlerts ?? false}
|
||||
onChange={(e) => {
|
||||
onOptionsChange(
|
||||
produce(options, (draft) => {
|
||||
draft.jsonData.handleGrafanaManagedAlerts = e.currentTarget.checked;
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</InlineField>
|
||||
{options.jsonData.handleGrafanaManagedAlerts && (
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
Make sure to enable the alert forwarding on the <Link to="/alerting/admin">settings page</Link>.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
<DataSourceHttpSettings
|
||||
defaultUrl={''}
|
||||
dataSourceConfig={options}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { QueryEditorProps } from '@grafana/data';
|
||||
import { InlineFormLabel, Input, TagsInput } from '@grafana/ui';
|
||||
import { Box, InlineField, Input, TagsInput } from '@grafana/ui';
|
||||
|
||||
import { GraphiteDatasource } from '../datasource';
|
||||
import { GraphiteQuery, GraphiteOptions } from '../types';
|
||||
@ -34,23 +34,21 @@ export const AnnotationEditor = (props: QueryEditorProps<GraphiteDatasource, Gra
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form">
|
||||
<InlineFormLabel width={12}>Graphite Query</InlineFormLabel>
|
||||
<Box marginBottom={5}>
|
||||
<InlineField label="Graphite Query" labelWidth={24} grow>
|
||||
<Input
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.currentTarget.value || '')}
|
||||
onBlur={() => updateValue('target', target)}
|
||||
placeholder="Example: statsd.application.counters.*.count"
|
||||
/>
|
||||
</div>
|
||||
</InlineField>
|
||||
|
||||
<h5 className="section-heading">Or</h5>
|
||||
|
||||
<div className="gf-form">
|
||||
<InlineFormLabel width={12}>Graphite events tags</InlineFormLabel>
|
||||
<InlineField label="Graphite events tags" labelWidth={24}>
|
||||
<TagsInput id="tags-input" width={50} tags={tags} onChange={onTagsChange} placeholder="Example: event_tag" />
|
||||
</div>
|
||||
</div>
|
||||
</InlineField>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { ChangeEvent, useState } from 'react';
|
||||
|
||||
import { Button, Icon, InlineField, InlineFieldRow, Input } from '@grafana/ui';
|
||||
import { Box, Button, Icon, InlineField, InlineFieldRow, Input } from '@grafana/ui';
|
||||
|
||||
import MappingsHelp from './MappingsHelp';
|
||||
|
||||
@ -27,7 +27,7 @@ export const MappingsConfiguration = (props: Props): JSX.Element => {
|
||||
)}
|
||||
{props.showHelp && <MappingsHelp onDismiss={props.onDismiss} />}
|
||||
|
||||
<div className="gf-form-group">
|
||||
<Box marginBottom={5}>
|
||||
{mappings.map((mapping, i) => (
|
||||
<InlineFieldRow key={i}>
|
||||
<InlineField label={`Mapping (${i + 1})`}>
|
||||
@ -71,7 +71,7 @@ export const MappingsConfiguration = (props: Props): JSX.Element => {
|
||||
>
|
||||
Add label mapping
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -92,7 +92,7 @@ export const BarChartPanel = (props: PanelProps<Options>) => {
|
||||
|
||||
const xGroupsCount = vizSeries[0]?.length ?? 0;
|
||||
const seriesCount = vizSeries[0]?.fields.length ?? 0;
|
||||
const totalSeries = info.series[0].fields.length - 1;
|
||||
const totalSeries = Math.max(0, (info.series[0]?.fields.length ?? 0) - 1);
|
||||
|
||||
let { builder, prepData } = useMemo(
|
||||
() => {
|
||||
|
@ -453,6 +453,16 @@
|
||||
"title": "Versionen"
|
||||
}
|
||||
},
|
||||
"dashboards": {
|
||||
"settings": {
|
||||
"variables": {
|
||||
"dependencies": {
|
||||
"button": "",
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"data-source-list": {
|
||||
"empty-state": {
|
||||
"button-title": "",
|
||||
@ -497,6 +507,12 @@
|
||||
"title": "",
|
||||
"visibility": ""
|
||||
},
|
||||
"logs": {
|
||||
"maximum-pinned-logs": "",
|
||||
"no-logs-found": "",
|
||||
"scan-for-older-logs": "",
|
||||
"stop-scan": ""
|
||||
},
|
||||
"rich-history": {
|
||||
"close-tooltip": "Abfrageverlauf schließen",
|
||||
"datasource-a-z": "Datenquelle A-Z",
|
||||
@ -1479,11 +1495,6 @@
|
||||
},
|
||||
"query-editor-not-exported": "Datenquellen-Plugin exportiert keine Komponente des Abfrageeditors"
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
"filter": {
|
||||
"placeholder": ""
|
||||
}
|
||||
},
|
||||
"refresh-picker": {
|
||||
"aria-label": {
|
||||
"choose-interval": "Automatische Aktualisierung ausgeschaltet. Aktualisierungszeitintervall auswählen",
|
||||
|
@ -453,6 +453,16 @@
|
||||
"title": "Versions"
|
||||
}
|
||||
},
|
||||
"dashboards": {
|
||||
"settings": {
|
||||
"variables": {
|
||||
"dependencies": {
|
||||
"button": "Show dependencies",
|
||||
"title": "Dependencies"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"data-source-list": {
|
||||
"empty-state": {
|
||||
"button-title": "Add data source",
|
||||
|
@ -453,6 +453,16 @@
|
||||
"title": "Versiones"
|
||||
}
|
||||
},
|
||||
"dashboards": {
|
||||
"settings": {
|
||||
"variables": {
|
||||
"dependencies": {
|
||||
"button": "",
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"data-source-list": {
|
||||
"empty-state": {
|
||||
"button-title": "",
|
||||
@ -497,6 +507,12 @@
|
||||
"title": "",
|
||||
"visibility": ""
|
||||
},
|
||||
"logs": {
|
||||
"maximum-pinned-logs": "",
|
||||
"no-logs-found": "",
|
||||
"scan-for-older-logs": "",
|
||||
"stop-scan": ""
|
||||
},
|
||||
"rich-history": {
|
||||
"close-tooltip": "Cerrar el historial de consultas",
|
||||
"datasource-a-z": "Fuente de datos A-Z",
|
||||
@ -1479,11 +1495,6 @@
|
||||
},
|
||||
"query-editor-not-exported": "El complemento de la fuente de datos no exporta ningún componente del editor de consultas"
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
"filter": {
|
||||
"placeholder": ""
|
||||
}
|
||||
},
|
||||
"refresh-picker": {
|
||||
"aria-label": {
|
||||
"choose-interval": "Actualización automática desactivada. Elija un intervalo de tiempo de actualización",
|
||||
|
@ -453,6 +453,16 @@
|
||||
"title": "Versions"
|
||||
}
|
||||
},
|
||||
"dashboards": {
|
||||
"settings": {
|
||||
"variables": {
|
||||
"dependencies": {
|
||||
"button": "",
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"data-source-list": {
|
||||
"empty-state": {
|
||||
"button-title": "",
|
||||
@ -497,6 +507,12 @@
|
||||
"title": "",
|
||||
"visibility": ""
|
||||
},
|
||||
"logs": {
|
||||
"maximum-pinned-logs": "",
|
||||
"no-logs-found": "",
|
||||
"scan-for-older-logs": "",
|
||||
"stop-scan": ""
|
||||
},
|
||||
"rich-history": {
|
||||
"close-tooltip": "Fermer l'historique des requêtes",
|
||||
"datasource-a-z": "Source de données A-Z",
|
||||
@ -1479,11 +1495,6 @@
|
||||
},
|
||||
"query-editor-not-exported": "Le plugin source de données n'exporte aucun composant de l'éditeur de requête"
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
"filter": {
|
||||
"placeholder": ""
|
||||
}
|
||||
},
|
||||
"refresh-picker": {
|
||||
"aria-label": {
|
||||
"choose-interval": "Actualisation automatique désactivée. Choisir un intervalle de temps d'actualisation",
|
||||
|
@ -453,6 +453,16 @@
|
||||
"title": "Vęřşįőʼnş"
|
||||
}
|
||||
},
|
||||
"dashboards": {
|
||||
"settings": {
|
||||
"variables": {
|
||||
"dependencies": {
|
||||
"button": "Ŝĥőŵ đępęʼnđęʼnčįęş",
|
||||
"title": "Đępęʼnđęʼnčįęş"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"data-source-list": {
|
||||
"empty-state": {
|
||||
"button-title": "Åđđ đäŧä şőūřčę",
|
||||
|
@ -453,6 +453,16 @@
|
||||
"title": "Versões"
|
||||
}
|
||||
},
|
||||
"dashboards": {
|
||||
"settings": {
|
||||
"variables": {
|
||||
"dependencies": {
|
||||
"button": "",
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"data-source-list": {
|
||||
"empty-state": {
|
||||
"button-title": "",
|
||||
@ -497,6 +507,12 @@
|
||||
"title": "",
|
||||
"visibility": ""
|
||||
},
|
||||
"logs": {
|
||||
"maximum-pinned-logs": "",
|
||||
"no-logs-found": "",
|
||||
"scan-for-older-logs": "",
|
||||
"stop-scan": ""
|
||||
},
|
||||
"rich-history": {
|
||||
"close-tooltip": "Fechar histórico de consultas",
|
||||
"datasource-a-z": "Fonte de dados A-Z",
|
||||
@ -1479,11 +1495,6 @@
|
||||
},
|
||||
"query-editor-not-exported": "O plug-in de origem de dados não exporta nenhum componente de editor de consulta"
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
"filter": {
|
||||
"placeholder": ""
|
||||
}
|
||||
},
|
||||
"refresh-picker": {
|
||||
"aria-label": {
|
||||
"choose-interval": "Atualização automática desativada. Escolha o intervalo de tempo de atualização",
|
||||
|
@ -448,6 +448,16 @@
|
||||
"title": "版本"
|
||||
}
|
||||
},
|
||||
"dashboards": {
|
||||
"settings": {
|
||||
"variables": {
|
||||
"dependencies": {
|
||||
"button": "",
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"data-source-list": {
|
||||
"empty-state": {
|
||||
"button-title": "",
|
||||
@ -492,6 +502,12 @@
|
||||
"title": "",
|
||||
"visibility": ""
|
||||
},
|
||||
"logs": {
|
||||
"maximum-pinned-logs": "",
|
||||
"no-logs-found": "",
|
||||
"scan-for-older-logs": "",
|
||||
"stop-scan": ""
|
||||
},
|
||||
"rich-history": {
|
||||
"close-tooltip": "关闭查询历史记录",
|
||||
"datasource-a-z": "数据源 A-Z",
|
||||
@ -1473,11 +1489,6 @@
|
||||
},
|
||||
"query-editor-not-exported": "数据源插件不导出任何查询编辑器组件"
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
"filter": {
|
||||
"placeholder": ""
|
||||
}
|
||||
},
|
||||
"refresh-picker": {
|
||||
"aria-label": {
|
||||
"choose-interval": "自动刷新已关闭。选择刷新时间间隔",
|
||||
|
Loading…
Reference in New Issue
Block a user