merge main
This commit is contained in:
commit
f7639f4520
107 files changed
+3786
-1047
No files matched your search
@@ -86,6 +86,10 @@ module.exports = [
|
||||
importNames: ['Layout', 'HorizontalGroup', 'VerticalGroup'],
|
||||
message: 'Use Stack component instead.',
|
||||
},
|
||||
{
|
||||
group: ['@grafana/ui/src/*', '@grafana/runtime/src/*', '@grafana/data/src/*'],
|
||||
message: 'Import from the public export instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+958
-309
File diff suppressed because it is too large.
Load diff
+5
-5
@@ -32,7 +32,7 @@
|
||||
/devenv/README.md @grafana/docs-grafana
|
||||
|
||||
# START Technical documentation
|
||||
/.vale.ini
|
||||
/.vale.ini @grafana/docs-tooling
|
||||
# `make docs` procedure and related workflows are owned @grafana/docs-tooling. Slack #docs.
|
||||
/docs/ @grafana/docs-tooling
|
||||
|
||||
@@ -786,10 +786,10 @@ embed.go @grafana/grafana-as-code
|
||||
/.github/workflows/stale.yml @grafana/grafana-developer-enablement-squad
|
||||
/.github/workflows/update-changelog.yml @grafana/grafana-developer-enablement-squad
|
||||
/.github/workflows/update-make-docs.yml @grafana/docs-tooling
|
||||
/.github/workflows/scripts/kinds/verify-kinds.go @grafana/platform-cat
|
||||
/.github/workflows/publish-kinds-next.yml @grafana/platform-cat
|
||||
/.github/workflows/publish-kinds-release.yml @grafana/platform-cat
|
||||
/.github/workflows/verify-kinds.yml @grafana/platform-cat
|
||||
/.github/workflows/scripts/kinds/verify-kinds.go @grafana/platform-monitoring
|
||||
/.github/workflows/publish-kinds-next.yml @grafana/platform-monitoring
|
||||
/.github/workflows/publish-kinds-release.yml @grafana/platform-monitoring
|
||||
/.github/workflows/verify-kinds.yml @grafana/platform-monitoring
|
||||
/.github/workflows/dashboards-issue-add-label.yml @grafana/dashboards-squad
|
||||
/.github/workflows/ephemeral-instances-pr-comment.yml @grafana/grafana-backend-services-squad
|
||||
/.github/workflows/create-security-patch-from-security-mirror.yml @grafana/grafana-developer-enablement-squad
|
||||
|
||||
@@ -12,8 +12,7 @@ jobs:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: grafana/writers-toolkit/vale-action@4b1248585248751e3b12fd020cf7ac91540ca09c # vale-action/v1.0.0
|
||||
- uses: grafana/writers-toolkit/vale-action@13205961f20ad13843505a9b84fdf032f911a3f4 # vale-action/v1.1.0
|
||||
with:
|
||||
filter: |
|
||||
'.Name in ["Grafana.WordList", "Grafana.Spelling", "Grafana.ProductPossessives"]'
|
||||
filter: '.Name in ["Grafana.WordList", "Grafana.Spelling", "Grafana.ProductPossessives"]'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -2,3 +2,4 @@ MinAlertLevel = warning
|
||||
|
||||
[*]
|
||||
BasedOnStyles = Grafana
|
||||
TokenIgnores = (<http[^\n]+>+?), \*\*[^\n]+\*\*
|
||||
+12
-6
@@ -17,7 +17,9 @@ weight: 400
|
||||
|
||||
# Grafana CLI
|
||||
|
||||
Grafana CLI is a small executable that is bundled with Grafana server. It can be executed on the same machine Grafana server is running on. Grafana CLI has `plugins` and `admin` commands, as well as global options.
|
||||
Grafana CLI is a small executable that's bundled with Grafana server.
|
||||
You can run it on the same machine Grafana server is running on.
|
||||
Grafana CLI has `plugins` and `admin` commands, as well as global options.
|
||||
|
||||
To list all commands and options:
|
||||
|
||||
@@ -25,13 +27,17 @@ To list all commands and options:
|
||||
grafana cli -h
|
||||
```
|
||||
|
||||
## Invoking Grafana CLI
|
||||
## Run Grafana CLI
|
||||
|
||||
To invoke Grafana CLI, add the path to the grafana binaries in your `PATH` environment variable. Alternately, if your current directory is the `bin` directory, use `./grafana cli`. Otherwise, you can specify full path to the CLI. For example, on Linux `/usr/share/grafana/bin/grafana` and on Windows `C:\Program Files\GrafanaLabs\grafana\bin\grafana.exe`, and invoke it with `grafana cli`.
|
||||
To run Grafana CLI, add the path to the Grafana binaries in your `PATH` environment variable.
|
||||
Alternately, if your current directory is the `bin` directory, run `./grafana cli`.
|
||||
Otherwise, you can specify full path to the binary.
|
||||
For example, on Linux `/usr/share/grafana/bin/grafana` and on Windows `C:\Program Files\GrafanaLabs\grafana\bin\grafana.exe`, and run it with `grafana cli`.
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
Some commands, such as installing or removing plugins, require `sudo` on Linux. If you are on Windows, run Windows PowerShell as Administrator.
|
||||
{{% /admonition %}}
|
||||
{{< admonition type="note" >}}
|
||||
Some commands, such as installing or removing plugins, require `sudo` on Linux.
|
||||
If you're on Windows, run Windows PowerShell as Administrator.
|
||||
{{< /admonition >}}
|
||||
|
||||
## Grafana CLI command syntax
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ labels:
|
||||
- cloud
|
||||
tags:
|
||||
- beginner
|
||||
title: Get started with Grafana Alerting - Part 2 of 3
|
||||
title: Get started with Grafana Alerting - Part 2 of 4
|
||||
weight: 50
|
||||
killercoda:
|
||||
title: Get started with Grafana Alerting - Part 2 of 3
|
||||
title: Get started with Grafana Alerting - Part 2 of 4
|
||||
description: Learn to use alert instances and route notifications by labels to contacts, building on your alerting skills in Grafana for more advanced workflows — Part 2.
|
||||
backend:
|
||||
imageid: ubuntu
|
||||
@@ -21,7 +21,7 @@ killercoda:
|
||||
|
||||
<!-- INTERACTIVE page intro.md START -->
|
||||
|
||||
# Get started with Grafana Alerting - Part 2 of 3
|
||||
# Get started with Grafana Alerting - Part 2 of 4
|
||||
|
||||
The Get started with Grafana Alerting tutorial Part 2 is a continuation of [Get started with Grafana Alerting tutorial Part 1](http://www.grafana.com/tutorials/alerting-get-started/).
|
||||
|
||||
@@ -222,7 +222,7 @@ The alert rule that you are about to create is meant to monitor web traffic page
|
||||
|
||||
### Create an alert rule
|
||||
|
||||
1. Navigate to **Alerting > Alert rules**.
|
||||
1. Navigate to **Alerts & IRM > Alerting > Alert rules**.
|
||||
1. Click **New alert rule**.
|
||||
|
||||
### Enter an alert rule name
|
||||
@@ -260,18 +260,21 @@ It should return two series.`desktop` in Firing state, and `mobile` in Normal st
|
||||
<!-- INTERACTIVE page step6.md END -->
|
||||
<!-- INTERACTIVE page step7.md START -->
|
||||
|
||||
### Add folders and labels
|
||||
|
||||
1. In **Folder**, click **+ New folder** and enter a name. For example: `web-traffic-alerts` . This folder contains our alert rules.
|
||||
|
||||
### Set evaluation behavior
|
||||
|
||||
In the [life cycle](http://grafana.com/docs/grafana/next/alerting/fundamentals/alert-rule-evaluation/) of alert instances, when an alert condition (threshold) is not met, the alert instance state is **Normal**. Similarly, when the condition is breached (for longer than the pending period, which in this tutorial will be 0), the alert instance state switches back to **Alerting**, which means that the alert rule state is **Firing**, and a notification is sent.
|
||||
|
||||
To set up evaluation behavior:
|
||||
|
||||
1. In **Folder**, click **+ New folder** and enter a name. For example: `web-traffic-alerts`. This folder contains our alert rules.
|
||||
1. In **Evaluation group**, repeat the above step to create a new evaluation group. Name it `1m` (referring to “1 minute”).
|
||||
1. Choose an Evaluation interval (how often the alert will be evaluated). Choose `1m`.
|
||||
1. Set the pending period to `0s` (zero seconds), so the alert rule fires the moment the condition is met.
|
||||
1. In **Evaluation group and interval**, repeat the above step to create a new evaluation group. Name it `1m` (referring to “1 minute”).
|
||||
1. Choose an **Evaluation interval** (how often the alert will be evaluated). Choose `1m`.
|
||||
1. Set the **pending period** to `0s` (zero seconds), so the alert rule fires the moment the condition is met.
|
||||
|
||||
### Configure labels and notifications
|
||||
### Configure notifications
|
||||
|
||||
In this section, you can select how you want to route your alert instances. Since we want to route by notification policy, we need to ensure that the labels match the alert instance.
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ labels:
|
||||
- cloud
|
||||
tags:
|
||||
- intermediate
|
||||
title: Get started with Grafana Alerting - Part 3
|
||||
title: Get started with Grafana Alerting - Part 3 of 4
|
||||
weight: 60
|
||||
killercoda:
|
||||
title: Get started with Grafana Alerting - Part 3
|
||||
title: Get started with Grafana Alerting - Part 3 of 4
|
||||
description: Learn how to group alert notifications effectively to reduce noise and streamline communication in Grafana Alerting — Part 3.
|
||||
backend:
|
||||
imageid: ubuntu
|
||||
@@ -37,7 +37,7 @@ refs:
|
||||
|
||||
<!-- INTERACTIVE page intro.md START -->
|
||||
|
||||
# Get started with Grafana Alerting - Part 3
|
||||
# Get started with Grafana Alerting - Part 3 of 4
|
||||
|
||||
The Get started with Grafana Alerting tutorial Part 3 is a continuation of [Get started with Grafana Alerting tutorial Part 2](http://www.grafana.com/tutorials/alerting-get-started-pt2/).
|
||||
|
||||
@@ -338,7 +338,7 @@ Following the above example, [notification policies](ref:notification-policies)
|
||||
|
||||
In this section we configure an alert rule based on our application monitoring example.
|
||||
|
||||
1. Navigate to **Alerting > Alert rules**.
|
||||
1. Navigate to **Alerts & IRM > Alerting > Alert rules**.
|
||||
2. Click **New alert rule**.
|
||||
|
||||
### Enter an alert rule name
|
||||
@@ -383,16 +383,19 @@ Grafana includes a [test data source](https://grafana.com/docs/grafana/latest/da
|
||||
|
||||
{{< figure src="/media/docs/alerting/regions-alert-instance-preview.png" max-width="750px" alt="Preview of a query returning alert instances." >}}
|
||||
|
||||
### Add folders and labels
|
||||
|
||||
1. In **Folder**, click **+ New folder** and enter a name. For example: `Multi-region alerts` . This folder contains our alert rules.
|
||||
|
||||
### Set evaluation behavior
|
||||
|
||||
Every alert rule is assigned to an evaluation group. You can assign the alert rule to an existing evaluation group or create a new one.
|
||||
|
||||
1. In **Folder**, click **+ New folder** and enter a name. For example: `Multi-region alerts`. This folder contains our alert rules.
|
||||
1. In the **Evaluation group**, repeat the above step to create a new evaluation group. Name it `Multi-region group`.
|
||||
1. In the **Evaluation group and interval**, repeat the above step to create a new evaluation group. Name it `Multi-region group`.
|
||||
1. Choose an **Evaluation interval** (how often the alert are evaluated). Choose `1m`.
|
||||
1. Set the pending period to `0s` (zero seconds), so the alert rule fires the moment the condition is met (this minimizes the waiting time for the demonstration).
|
||||
1. Set the **pending period** to `0s` (zero seconds), so the alert rule fires the moment the condition is met (this minimizes the waiting time for the demonstration).
|
||||
|
||||
### Configure labels and notifications
|
||||
### Configure notifications
|
||||
|
||||
Select who should receive a notification when an alert rule fires.
|
||||
|
||||
@@ -508,4 +511,24 @@ _Detail of memory alert instances grouped into a separate notification for us-ea
|
||||
|
||||
By configuring **notification policies** and using **labels** (such as _region_), you can group alert notifications based on specific criteria and route them to the appropriate teams. Fine-tuning **timing options**—including group wait, group interval, and repeat interval—further can reduce noise and ensures notifications remain actionable without overwhelming on-call engineers.
|
||||
|
||||
## Learn more in [Grafana Alerting Part 4](http://www.grafana.com/tutorials/alerting-get-started-pt4/)
|
||||
|
||||
<!-- INTERACTIVE ignore START -->
|
||||
|
||||
{{< admonition type="tip" >}}
|
||||
|
||||
In [Get started with Grafana Alerting - Part 4](http://www.grafana.com/tutorials/alerting-get-started-pt4/) you learn how to use templates to create customized and concise notifications.
|
||||
|
||||
{{< /admonition >}}
|
||||
|
||||
<!-- INTERACTIVE ignore END -->
|
||||
|
||||
{{< docs/ignore >}}
|
||||
|
||||
In [Get started with Grafana Alerting - Part 4](http://www.grafana.com/tutorials/alerting-get-started-pt4/) you learn how to use templates to create customized and concise notifications.
|
||||
|
||||
{{< /docs/ignore >}}
|
||||
|
||||
<!-- INTERACTIVE page finish.md END -->
|
||||
|
||||
<!-- INTERACTIVE page finish.md END -->
|
||||
@@ -0,0 +1,421 @@
|
||||
---
|
||||
Feedback Link: https://github.com/grafana/tutorials/issues/new
|
||||
categories:
|
||||
- alerting
|
||||
description: Learn how to use templates to create customized and concise notifications — Part 4
|
||||
labels:
|
||||
products:
|
||||
- enterprise
|
||||
- oss
|
||||
- cloud
|
||||
tags:
|
||||
- intermediate
|
||||
title: Get started with Grafana Alerting - Part 4
|
||||
weight: 60
|
||||
killercoda:
|
||||
title: Get started with Grafana Alerting - Part 4
|
||||
description: Learn how to use templates to create customized and concise notifications — Part 4.
|
||||
backend:
|
||||
imageid: ubuntu
|
||||
refs:
|
||||
alert-labels:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/annotation-label/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label/
|
||||
template-labels-annotations:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/templates/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/templates/
|
||||
template-labels-annotations-ref:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/templates/reference/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/templates/reference/
|
||||
template-labels-annotations-ref-labels-variable:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/templates/reference/#labels
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/templates/reference/#labels
|
||||
template-labels-annotations-ref-values-variable:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/templates/reference/#values
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/templates/reference/#values
|
||||
template-labels-annotations-lang:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/templates/language/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/templates/language/
|
||||
template-notifications:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/template-notifications/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications/
|
||||
template-notifications-ref:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/template-notifications/reference/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications/reference/
|
||||
template-notifications-lang:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/template-notifications/language/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/template-notifications/language/
|
||||
templates:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/templates/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/templates/
|
||||
---
|
||||
|
||||
<!-- INTERACTIVE page intro.md START -->
|
||||
|
||||
# Get started with Grafana Alerting - Part 4
|
||||
|
||||
The Get started with Grafana Alerting tutorial Part 4 is a continuation of [Get started with Grafana Alerting tutorial Part 3](http://www.grafana.com/tutorials/alerting-get-started-pt3/).
|
||||
|
||||
In this tutorial, you will learn:
|
||||
|
||||
- The two types of templates in Grafana Alerting: labels and annotations and notification templates.
|
||||
- How to configure alert rules with summary and description annotations.
|
||||
- How to create a notification template that integrates with alert rule annotations.
|
||||
- How to use a built-in notification template to group and format multiple alert instances.
|
||||
- How to preview alert notifications by leveraging alert instances in the notification template payload.
|
||||
|
||||
<!-- INTERACTIVE page intro.md END -->
|
||||
<!-- INTERACTIVE page step1.md START -->
|
||||
|
||||
<!-- INTERACTIVE ignore START -->
|
||||
|
||||
{{< docs/ignore >}}
|
||||
|
||||
## Set up the Grafana stack
|
||||
|
||||
{{< /docs/ignore >}}
|
||||
|
||||
## Before you begin
|
||||
|
||||
There are different ways you can follow along with this tutorial.
|
||||
|
||||
- **Grafana Cloud**
|
||||
|
||||
- As a Grafana Cloud user, you don't have to install anything. [Create your free account](http://www.grafana.com/auth/sign-up/create-user).
|
||||
|
||||
Continue to [how templating works](#how-templating-works).
|
||||
|
||||
- **Interactive learning environment**
|
||||
|
||||
- Alternatively, you can try out this example in our interactive learning environment: [Get started with Grafana Alerting - Part 4](https://killercoda.com/grafana-labs/course/grafana/alerting-get-started-pt4/). It's a fully configured environment with all the dependencies already installed.
|
||||
|
||||
- **Grafana OSS**
|
||||
|
||||
- If you opt to run a Grafana stack locally, ensure you have the following applications installed:
|
||||
|
||||
- [Docker Compose](https://docs.docker.com/get-docker/) (included in Docker for Desktop for macOS and Windows)
|
||||
- [Git](https://git-scm.com/)
|
||||
|
||||
### Set up the Grafana stack (OSS users)
|
||||
|
||||
<!-- INTERACTIVE ignore END -->
|
||||
|
||||
To demonstrate the observation of data using the Grafana stack, download and run the following files.
|
||||
|
||||
1. Clone the [tutorial environment repository](https://www.github.com/grafana/tutorial-environment).
|
||||
|
||||
<!-- INTERACTIVE exec START -->
|
||||
|
||||
```
|
||||
git clone https://github.com/grafana/tutorial-environment.git
|
||||
```
|
||||
|
||||
<!-- INTERACTIVE exec END -->
|
||||
|
||||
1. Change to the directory where you cloned the repository:
|
||||
|
||||
<!-- INTERACTIVE exec START -->
|
||||
|
||||
```
|
||||
cd tutorial-environment
|
||||
```
|
||||
|
||||
<!-- INTERACTIVE exec END -->
|
||||
|
||||
1. Run the Grafana stack:
|
||||
|
||||
<!-- INTERACTIVE ignore START -->
|
||||
|
||||
```
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
<!-- INTERACTIVE ignore END -->
|
||||
|
||||
{{< docs/ignore >}}
|
||||
|
||||
<!-- INTERACTIVE exec START -->
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
<!-- INTERACTIVE exec END -->
|
||||
|
||||
{{< /docs/ignore >}}
|
||||
|
||||
The first time you run `docker compose up -d`, Docker downloads all the necessary resources for the tutorial. This might take a few minutes, depending on your internet connection.
|
||||
|
||||
<!-- INTERACTIVE ignore START -->
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
If you already have Grafana, Loki, or Prometheus running on your system, you might see errors, because the Docker image is trying to use ports that your local installations are already using. If this is the case, stop the services, then run the command again.
|
||||
{{< /admonition >}}
|
||||
|
||||
<!-- INTERACTIVE ignore END -->
|
||||
|
||||
{{< docs/ignore >}}
|
||||
|
||||
NOTE:
|
||||
|
||||
If you already have Grafana, Loki, or Prometheus running on your system, you might see errors, because the Docker image is trying to use ports that your local installations are already using. If this is the case, stop the services, then run the command again.
|
||||
|
||||
{{< /docs/ignore >}}
|
||||
|
||||
<!-- INTERACTIVE page step1.md END -->
|
||||
<!-- INTERACTIVE page step2.md START -->
|
||||
|
||||
## How templating works
|
||||
|
||||
In Grafana, you can use [templates](https://grafana.com/docs/grafana/latest/alerting/fundamentals/templates/) to dynamically pull in specific data about the alert rule. This results in more flexible and informative alert notification messages. You can template either alert rule labels and annotations, or notification templates. Both use the Go template language.
|
||||
|
||||
{{< figure src="/media/docs/alerting/how-notification-templates-works.png" max-width="1200px" caption="How templating works" >}}
|
||||
|
||||
### Templating alert rule labels and annotations
|
||||
|
||||
[Labels and annotations](https://grafana.com/docs/grafana/latest/alerting/alerting-rules/templates/) are key fields where templates are applied. One of the main advantages of using templating in annotations is the ability to incorporate dynamic data from queries, allowing alerts to reflect real-time information relevant to the triggered condition. By using templating in annotations, you can customize the content of each alert instance, such as including instance names and metric values, so the notification becomes more informative.
|
||||
|
||||
### Notification templates
|
||||
|
||||
The real power of templating lies in how it helps you format notifications with dynamic alert data. [Notification templates](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/template-notifications/) let you pull in details from annotations to create clear and consistent messages. They also make it simple to reuse the same format across different contact points, saving time and effort.
|
||||
|
||||
Notification templates allow you to customize how information is presented in each notification. For example, you can use templates to organize and format details about firing or resolved alerts, making it easier for recipients to understand the status of each alert at a glance—all within a single notification.
|
||||
|
||||
This particular notification template pulls in summary and description annotations for each alert instance and organizes them into separate sections, such as "firing" and "resolved." This way, instead of getting a long list of individual alert notifications, users can receive one well-structured message with all the relevant details grouped together.
|
||||
|
||||
This approach is helpful when you want to reduce notification noise, especially in situations where multiple instances of an alert are firing at the same time (e.g., high CPU usage across several instances). You can leverage templates to create a unified, easy-to-read notification that includes all the pertinent details.
|
||||
|
||||
<!-- INTERACTIVE page step2.md END -->
|
||||
<!-- INTERACTIVE page step3.md START -->
|
||||
|
||||
## Step 1: Template labels and annotations
|
||||
|
||||
Now that we've introduced how templating works, let’s move on to the next step. We guide you through creating an alert rule with a summary and description annotation. In doing so, we incorporate CPU usage and instance names, which we later use in our notification template.
|
||||
|
||||
### Create an alert rule
|
||||
|
||||
1. Sign in to Grafana:
|
||||
|
||||
- **Grafana Cloud** users: Log in via Grafana Cloud.
|
||||
- **OSS users**: Go to [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
1. Create an alert rule that includes a summary and description annotation:
|
||||
- Navigate to **Alerts & IRM > Alerting > Alert rules**.
|
||||
- Click **+ New alert rule**.
|
||||
- Enter an **alert rule name**. Name it `High CPU usage`
|
||||
1. **Define query an alert condition** section:
|
||||
|
||||
- Select TestData data source from the drop-down menu.
|
||||
|
||||
[TestData](https://grafana.com/docs/grafana/latest/datasources/testdata/) is included in the demo environment. If you’re working in Grafana Cloud or your own local Grafana instance, you can add the data source through the Connections menu.
|
||||
|
||||
- From **Scenario** select **CSV Content**.
|
||||
- Copy in the following CSV data:
|
||||
|
||||
```
|
||||
region,cpu-usage,service,instance
|
||||
us-west,88,web-server-1,server-01
|
||||
us-west,81,web-server-1,server-02
|
||||
us-east,79,web-server-2,server-03
|
||||
us-east,52,web-server-2,server-04
|
||||
```
|
||||
|
||||
This dataset simulates a data source returning multiple time series, with each time series generating a separate alert instance.
|
||||
|
||||
1. **Alert condition** section:
|
||||
|
||||
- Keep Last as the value for the reducer function (`WHEN`), and `75` as the threshold value, representing CPU usage above 75% .This is the value above which the alert rule should trigger.
|
||||
- Click **Preview alert rule condition** to run the queries.
|
||||
|
||||
It should return 3 series in Firing state, and 1 in Normal state.
|
||||
|
||||
{{< figure src="/media/docs/alerting/part-4-firing-instances-preview.png" max-width="1200px" caption="Preview of a query returning alert instances" >}}
|
||||
|
||||
1. Add folders and labels section:
|
||||
|
||||
- In **Folder**, click **+ New folder** and enter a name. For example: `System metrics` . This folder contains our alert rules.
|
||||
|
||||
Note: while it's possible to template labels here, in this tutorial, we focus on templating the summary and annotations fields instead.
|
||||
|
||||
1. **Set evaluation behaviour** section:
|
||||
- In the **Evaluation group and interval**, repeat the above step to create a new evaluation group. Name it `High usage`.
|
||||
- Choose an **Evaluation interval** (how often the alert will be evaluated). Choose `1m`.
|
||||
- Set the **pending period** to 0s (zero seconds), so the alert rule fires the moment the condition is met (this minimizes the waiting time for the demonstration.).
|
||||
1. **Configure notifications** section:
|
||||
|
||||
Select who should receive a notification when an alert rule fires.
|
||||
|
||||
- Select a **Contact point**. If you don’t have any contact points, click _View or create contact points_.
|
||||
|
||||
1. **Configure notification message** section:
|
||||
|
||||
In this step, you’ll configure the **summary** and **description** annotations to make your alert notifications informative and easy to understand. These annotations use templates to dynamically include key information about the alert.
|
||||
|
||||
- **Summary** annotation: Enter the following code as the value for the annotation.:
|
||||
|
||||
```go
|
||||
{{- "\n" -}}
|
||||
Instance: {{ index $labels "instance" }}
|
||||
{{- "\t" -}} Usage: {{ index $values "A"}}%{{- "\n" -}}
|
||||
```
|
||||
|
||||
This template automatically adds the instance name (from the [$labels](https://grafana.com/docs/grafana/latest/alerting/alerting-rules/templates/reference/#labels) data) and its current CPU usage (from [$values["A"]](https://grafana.com/docs/grafana/latest/alerting/alerting-rules/templates/reference/#values)) into the alert summary. `\t`: Adds a tab space between the instance name and the value. And, `\n`: Inserts a new line after the value.
|
||||
|
||||
Output example:
|
||||
|
||||
```
|
||||
server-01 88
|
||||
```
|
||||
|
||||
This output helps you quickly see which instance is affected and its usage level.
|
||||
|
||||
1. Optional: Add a description to help the on-call engineer to better understand what the alert rule is about. Eg. This alert monitors CPU usage across instances and triggers if any instance exceeds a usage threshold of 75%.
|
||||
1. Click **Save rule and exit**.
|
||||
|
||||
Now that we’ve configured an alert rule with dynamic templates for the **summary** annotation, the next step is to customize the alert notifications themselves. While the default notification message includes the summary annotation and works well, it can often be too verbose.
|
||||
|
||||
{{< figure src="/media/docs/alerting/templated-annotation-alert.png" max-width="1200px" caption="Default email alert notification with templated annotation" >}}
|
||||
|
||||
To make our alert notifications more concise and tailored to our needs, we’ll create a custom **notification template** that references the summary annotation we just set up. Notification templates are especially useful because they can be reused across multiple contact points, ensuring consistent alert messages.
|
||||
|
||||
<!-- INTERACTIVE page step3.md END -->
|
||||
<!-- INTERACTIVE page step4.md START -->
|
||||
|
||||
## Step 2: Template notifications
|
||||
|
||||
In this step, we use a built-in notification template to format alert notifications in a clear and organized way. Notification templates allow us to customize the structure of alert messages, making them easier to read and more relevant.
|
||||
|
||||
Without a notification template, the alert messages would include the default Grafana formatting (`default.message`, see image above).
|
||||
|
||||
### Adding a notification template:
|
||||
|
||||
1. Navigate to **Alerts & IRM** > **Alerting** > **Contact point**s.
|
||||
1. Select the **Notification Templates** tab.
|
||||
1. Click **+ Add notification template group**.
|
||||
1. Enter a name. E.g `instance-cpu-summary`.
|
||||
1. From the **Add example** dropdown menu, choose `Print firing and resolved alerts`.
|
||||
|
||||
This template prints out alert instances into two sections: **firing alerts** and **resolved alerts**, and includes only the key details for each. In addition, it adds our summary and description annotations.
|
||||
|
||||
```
|
||||
{{- /* Example displaying firing and resolved alerts separately in the notification. */ -}}
|
||||
{{- /* Edit the template name and template content as needed. */ -}}
|
||||
{{ define "custom.firing_and_resolved_alerts" -}}
|
||||
{{ len .Alerts.Resolved }} resolved alert(s)
|
||||
{{ range .Alerts.Resolved -}}
|
||||
{{ template "alert.summary_and_description" . -}}
|
||||
{{ end }}
|
||||
{{ len .Alerts.Firing }} firing alert(s)
|
||||
{{ range .Alerts.Firing -}}
|
||||
{{ template "alert.summary_and_description" . -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
{{ define "alert.summary_and_description" }}
|
||||
Summary: {{.Annotations.summary}}
|
||||
Status: {{ .Status }}
|
||||
Description: {{.Annotations.description}}
|
||||
{{ end -}}
|
||||
```
|
||||
|
||||
{{< docs/ignore >}}
|
||||
Note: Your notification template name (`{{define "<NAME>"}}`) must be unique. You cannot have two templates with the same name in the same notification template group or in different notification template groups.
|
||||
{{< /docs/ignore >}}
|
||||
|
||||
<!-- INTERACTIVE ignore START -->
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Your notification template name (`{{define "<NAME>"}}`) must be unique. You cannot have two templates with the same name in the same notification template group or in different notification template groups.
|
||||
{{< /admonition >}}
|
||||
|
||||
<!-- INTERACTIVE ignore END -->
|
||||
|
||||
Here’s a breakdown of the template:
|
||||
|
||||
- `{{ define "custom.firing_and_resolved_alerts" -}}` section: Displays the number of resolved alerts and their summaries, using the `alert.summary_and_description` template to include the summary, status, and description for each alert.
|
||||
- `.Alerts.Firing` section: Similarly lists the number of firing alert instances and their details.
|
||||
- `alert.summary_and_description`: This sub-template pulls the summary annotation you configured earlier.
|
||||
|
||||
In the **Preview** area, you can see a sample of how the notification would look. Since we’ve already created our alert rule, you can take it a step further by previewing how an actual alert instance from your rule would appear in the notification.
|
||||
|
||||
1. Click **Edit payload**.
|
||||
1. Click **Use existing alert instance**.
|
||||
|
||||
You should see our alert rule listed on the left.
|
||||
|
||||
1. Click the alert rule.
|
||||
1. Select an instance.
|
||||
1. Click **Add alert data to payload**.
|
||||
|
||||
The alert instance is added to the bottom of the preview.
|
||||
|
||||
{{< figure src="/media/docs/alerting/alert-instance-preview-in-template.png" max-width="1200px" caption="Preview of an alert instance in a notification template" >}}
|
||||
|
||||
1. Click **Save**.
|
||||
|
||||
With the notification template ready, the next step is to apply it to your contact point to see it in action.
|
||||
|
||||
<!-- INTERACTIVE page step4.md END -->
|
||||
<!-- INTERACTIVE page step5.md START -->
|
||||
|
||||
### Apply the template to your contact point
|
||||
|
||||
1. Apply the template to your contact point.
|
||||
- Navigate to **Alerts & IRM** > **Alerting** > **Contact points**.
|
||||
- Edit your contact point.
|
||||
1. **Optional** [email] **settings** section:
|
||||
- Click **Edit Message**.
|
||||
- Under **Select notification template**, search `custom.firing_and_resolved_alerts`.
|
||||
- Click **Save**.
|
||||
1. Save your contact point.
|
||||
|
||||
<!-- INTERACTIVE page step5.md END -->
|
||||
<!-- INTERACTIVE page step6.md START -->
|
||||
|
||||
### Receiving notifications
|
||||
|
||||
Now that the template has been applied to the contact point, you should receive notifications in the specified contact point.
|
||||
|
||||
Note: you might need to pause the alert rule evaluation and resume it to trigger the notification.
|
||||
|
||||
{{< figure src="/media/docs/alerting/templated-notification-cpu.png" max-width="1200px" caption="Templated email notification for CPU and memory usage" >}}
|
||||
|
||||
In the screen capture, you can see how the notification template groups the alert instances into two sections: **firing alerts** and **resolved alerts**. Each section includes only the key details for each alert, ensuring the message remains concise and focused. Additionally, the summary and description annotations we created earlier are included, providing affected instance and CPU usage.
|
||||
|
||||
<!-- INTERACTIVE page step6.md END -->
|
||||
<!-- INTERACTIVE page finish.md START -->
|
||||
|
||||
## Conclusion
|
||||
|
||||
In this tutorial, we learned how to use templating in Grafana Alerting to create dynamic and actionable alert notifications. We explored how to configure alert rules with annotations, design custom notification templates, and apply them to contact points to enhance the clarity and efficiency of alert messages. By organizing alert instances into concise notifications, you can reduce noise and ensure on-call engineers quickly understand and address critical issues.
|
||||
|
||||
To deepen your understanding of Grafana’s templating, explore the following resources:
|
||||
|
||||
- **Overview of the functions and operators used in templates**:
|
||||
|
||||
- [Notification template language](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/template-notifications/language/)
|
||||
- [Alert rule template language](https://grafana.com/docs/grafana/latest/alerting/alerting-rules/templates/language/)
|
||||
|
||||
- [**Notification template reference**](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/template-notifications/reference/): Lists the data available for use in notification templates and explores specific functions.
|
||||
- [**Alert rule template reference**](https://grafana.com/docs/grafana/latest/alerting/alerting-rules/templates/reference/): Covers the specifics of creating dynamic labels and annotations for alert rules using elements such as variables and functions.
|
||||
|
||||
<!-- INTERACTIVE page finish.md END -->
|
||||
@@ -11,10 +11,10 @@ labels:
|
||||
- cloud
|
||||
tags:
|
||||
- beginner
|
||||
title: Get started with Grafana Alerting - Part 1 of 3
|
||||
title: Get started with Grafana Alerting - Part 1 of 4
|
||||
weight: 50
|
||||
killercoda:
|
||||
title: Get started with Grafana Alerting - Part 1 of 3
|
||||
title: Get started with Grafana Alerting - Part 1 of 4
|
||||
description: Get started with Grafana Alerting by creating your first alert rule, sending notifications to a webhook, and generating data to test it live — Part 1.
|
||||
backend:
|
||||
imageid: ubuntu
|
||||
@@ -22,7 +22,7 @@ killercoda:
|
||||
|
||||
<!-- INTERACTIVE page intro.md START -->
|
||||
|
||||
# Get started with Grafana Alerting - Part 1 of 3
|
||||
# Get started with Grafana Alerting - Part 1 of 4
|
||||
|
||||
In this guide, we walk you through the process of setting up your first alert in just a few minutes. You'll witness your alert in action with real-time data, as well as sending alert notifications.
|
||||
|
||||
@@ -181,7 +181,7 @@ We have created a dummy Webhook endpoint and created a new Alerting contact poin
|
||||
|
||||
Next, we establish an [alert rule](https://grafana.com/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule/) within Grafana Alerting to notify us whenever alert rules are triggered and resolved.
|
||||
|
||||
1. In Grafana, **navigate to Alerting** > **Alert rules**. Click on **New alert rule**.
|
||||
1. In Grafana, navigate to **Alerts & IRM > Alerting > Alert rules**. Click on **New alert rule**.
|
||||
|
||||
1. Enter alert rule name for your alert rule. Make it short and descriptive as this appears in your alert notification. For instance, **database-metrics**
|
||||
|
||||
@@ -203,6 +203,10 @@ Grafana includes a [test data source](https://grafana.com/docs/grafana/latest/da
|
||||
|
||||
{{< figure src="/media/docs/alerting/random-walk-firing-alert-rule.png" max-width="1200px" caption="A preview of a firing alert" >}}
|
||||
|
||||
### Add folders and labels
|
||||
|
||||
1. In **Folder**, click **+ New folder** and enter a name. For example: `metric-alerts` . This folder contains our alert rules.
|
||||
|
||||
### Set evaluation behavior
|
||||
|
||||
The [alert rule evaluation](https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/rule-evaluation/) defines the conditions under which an alert rule triggers, based on the following settings:
|
||||
@@ -213,13 +217,12 @@ The [alert rule evaluation](https://grafana.com/docs/grafana/latest/alerting/fun
|
||||
|
||||
To set up the evaluation:
|
||||
|
||||
1. In **Folder**, click **+ New folder** and enter a name. For example: _metric-alerts_. This folder contains our alerts.
|
||||
1. In the **Evaluation group**, repeat the above step to create a new evaluation group. Name it _1m-evaluation_.
|
||||
1. In the **Evaluation group and interval**, repeat the above step to create a new evaluation group. Name it _1m-evaluation_.
|
||||
1. Choose an **Evaluation interval** (how often the alert are evaluated).
|
||||
For example, every `1m` (1 minute).
|
||||
1. Set the pending period to, `0s` (zero seconds), so the alert rule fires the moment the condition is met.
|
||||
1. Set the **pending period** to, `0s` (zero seconds), so the alert rule fires the moment the condition is met.
|
||||
|
||||
### Configure labels and notifications
|
||||
### Configure notifications
|
||||
|
||||
Choose the contact point where you want to receive your alert notifications.
|
||||
|
||||
|
||||
@@ -372,6 +372,7 @@ require (
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/jpillora/backoff v1.0.0 // indirect
|
||||
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 // indirect
|
||||
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||
github.com/karlseguin/ccache/v3 v3.0.5 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
|
||||
@@ -1766,8 +1766,9 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 h1:SwcnSwBR7X/5EHJQlXBockkJVIMRVt5yKaesBPMtyZQ=
|
||||
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6/go.mod h1:WrYiIuiXUMIvTDAQw97C+9l0CnBmCcvosPjN3XDqS/o=
|
||||
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
|
||||
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
|
||||
+33
@@ -440,6 +440,7 @@ cloud.google.com/go/functions v1.15.4/go.mod h1:CAsTc3VlRMVvx+XqXxKqVevguqJpnVip
|
||||
cloud.google.com/go/functions v1.16.0/go.mod h1:nbNpfAG7SG7Duw/o1iZ6ohvL7mc6MapWQVpqtM29n8k=
|
||||
cloud.google.com/go/functions v1.19.1 h1:eWjTZohtJX/9rckZYXaYVViGi06JkNJRKvm0aO+ce+g=
|
||||
cloud.google.com/go/functions v1.19.1/go.mod h1:18RszySpwRg6aH5UTTVsRfdCwDooSf/5mvSnU7NAk4A=
|
||||
cloud.google.com/go/gaming v1.9.0 h1:7vEhFnZmd931Mo7sZ6pJy7uQPDxF7m7v8xtBheG08tc=
|
||||
cloud.google.com/go/gaming v1.10.1 h1:5qZmZEWzMf8GEFgm9NeC3bjFRpt7x4S6U7oLbxaf7N8=
|
||||
cloud.google.com/go/gaming v1.10.1/go.mod h1:XQQvtfP8Rb9Rxnxm5wFVpAp9zCQkJi2bLIb7iHGwB3s=
|
||||
cloud.google.com/go/gkebackup v1.3.0/go.mod h1:vUDOu++N0U5qs4IhG1pcOnD1Mac79xWy6GoBFlWCWBU=
|
||||
@@ -473,6 +474,7 @@ cloud.google.com/go/gkemulticloud v1.1.0/go.mod h1:7NpJBN94U6DY1xHIbsDqB2+TFZUfj
|
||||
cloud.google.com/go/gkemulticloud v1.1.1/go.mod h1:C+a4vcHlWeEIf45IB5FFR5XGjTeYhF83+AYIpTy4i2Q=
|
||||
cloud.google.com/go/gkemulticloud v1.4.0 h1:t2HXXYrICui+rZXScietjU1YdrQDLXpfqqrTo7zWSYQ=
|
||||
cloud.google.com/go/gkemulticloud v1.4.0/go.mod h1:rg8YOQdRKEtMimsiNCzZUP74bOwImhLRv9wQ0FwBUP4=
|
||||
cloud.google.com/go/grafeas v0.2.0 h1:CYjC+xzdPvbV65gi6Dr4YowKcmLo045pm18L0DhdELM=
|
||||
cloud.google.com/go/grafeas v0.3.0/go.mod h1:P7hgN24EyONOTMyeJH6DxG4zD7fwiYa5Q6GUgyFSOU8=
|
||||
cloud.google.com/go/grafeas v0.3.4 h1:D4x32R/cHX3MTofKwirz015uEdVk4uAxvZkZCZkOrF4=
|
||||
cloud.google.com/go/grafeas v0.3.4/go.mod h1:A5m316hcG+AulafjAbPKXBO/+I5itU4LOdKO2R/uDIc=
|
||||
@@ -955,6 +957,7 @@ cloud.google.com/go/workflows v1.13.1 h1:DkxrZ4HyXvjQLZWsYAUOV1w7d2a43XscM9dmkIG
|
||||
cloud.google.com/go/workflows v1.13.1/go.mod h1:xNdYtD6Sjoug+khNCAtBMK/rdh8qkjyL6aBas2XlkNc=
|
||||
contrib.go.opencensus.io/exporter/aws v0.0.0-20230502192102-15967c811cec h1:CSNP8nIEQt4sZEo2sGUiWSmVJ9c5QdyIQvwzZAsn+8Y=
|
||||
contrib.go.opencensus.io/exporter/aws v0.0.0-20230502192102-15967c811cec/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA=
|
||||
contrib.go.opencensus.io/exporter/ocagent v0.6.0 h1:Z1n6UAyr0QwM284yUuh5Zd8JlvxUGAhFZcgMJkMPrGM=
|
||||
contrib.go.opencensus.io/exporter/stackdriver v0.13.14 h1:zBakwHardp9Jcb8sQHcHpXy/0+JIb1M8KjigCJzx7+4=
|
||||
contrib.go.opencensus.io/exporter/stackdriver v0.13.14/go.mod h1:5pSSGY0Bhuk7waTHuDf4aQ8D2DrhgETRo9fy6k3Xlzc=
|
||||
contrib.go.opencensus.io/integrations/ocsql v0.1.7 h1:G3k7C0/W44zcqkpRSFyjU9f6HZkbwIrL//qqnlqWZ60=
|
||||
@@ -1038,6 +1041,8 @@ 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/KimMachineGun/automemlimit v0.6.0/go.mod h1:T7xYht7B8r6AG/AqFcUdc7fzd2bIdBKmepfP2S1svPY=
|
||||
github.com/KimMachineGun/automemlimit v0.6.1 h1:ILa9j1onAAMadBsyyUJv5cack8Y1WT26yLj/V+ulKp8=
|
||||
github.com/KimMachineGun/automemlimit v0.6.1/go.mod h1:T7xYht7B8r6AG/AqFcUdc7fzd2bIdBKmepfP2S1svPY=
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw=
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
|
||||
github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k=
|
||||
@@ -1047,6 +1052,8 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEV
|
||||
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0=
|
||||
github.com/RoaringBitmap/gocroaring v0.4.0 h1:5nufXUgWpBEUNEJXw7926YAA58ZAQRpWPrQV1xCoSjc=
|
||||
github.com/RoaringBitmap/real-roaring-datasets v0.0.0-20190726190000-eb7c87156f76 h1:ZYlhPbqQFU+AHfgtCdHGDTtRW1a8geZyiE8c6Q+Sl1s=
|
||||
@@ -1169,12 +1176,14 @@ github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgf
|
||||
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
|
||||
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
|
||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
|
||||
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
|
||||
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
@@ -1257,6 +1266,7 @@ github.com/dave/rebecca v0.9.1/go.mod h1:N6XYdMD/OKw3lkF3ywh8Z6wPGuwNFDNtWYEMFWE
|
||||
github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g=
|
||||
github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3 h1:tkum0XDgfR0jcVVXuTsYv/erY2NnEDqwRojbxR1rBYA=
|
||||
github.com/dgryski/go-sip13 v0.0.0-20190329191031-25c5027a8c7b h1:Yqiad0+sloMPdd/0Fg22actpFx0dekpzt1xJmVNVkU0=
|
||||
github.com/dhui/dktest v0.3.0 h1:kwX5a7EkLcjo7VpsPQSYJcKGbXBXdjI9FGjuUj1jn6I=
|
||||
github.com/digitalocean/godo v1.113.0/go.mod h1:Z2mTP848Vi3IXXl5YbPekUgr4j4tOePomA+OE1Ag98w=
|
||||
github.com/digitalocean/godo v1.118.0 h1:lkzGFQmACrVCp7UqH1sAi4JK/PWwlc5aaxubgorKmC4=
|
||||
@@ -1275,6 +1285,7 @@ github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQ
|
||||
github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
|
||||
github.com/docker/go-plugins-helpers v0.0.0-20240701071450-45e2431495c8 h1:IMfrF5LCzP2Vhw7j4IIH3HxPsCLuZYjDqFAM/C88ulg=
|
||||
github.com/docker/go-plugins-helpers v0.0.0-20240701071450-45e2431495c8/go.mod h1:LFyLie6XcDbyKGeVK6bHe+9aJTYCxWLBg5IrJZOaXKA=
|
||||
github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96 h1:cenwrSVm+Z7QLSV/BsnenAOcDXdX4cMv4wP0B/5QbPg=
|
||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
|
||||
github.com/dolthub/swiss v0.2.1 h1:gs2osYs5SJkAaH5/ggVJqXQxRXtWshF6uE0lgR/Y3Gw=
|
||||
github.com/dolthub/swiss v0.2.1/go.mod h1:8AhKZZ1HK7g18j7v7k6c5cYIGEZJcPn0ARsai8cUrh0=
|
||||
@@ -1295,6 +1306,7 @@ github.com/elastic/go-sysinfo v1.11.2 h1:mcm4OSYVMyws6+n2HIVMGkln5HOpo5Ie1ZmbbNn
|
||||
github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJa7AfT8HpBFQ=
|
||||
github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0=
|
||||
github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss=
|
||||
github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633 h1:H2pdYOb3KQ1/YsqVWoWNLQO+fusocsw354rqGTZtAgw=
|
||||
github.com/emicklei/go-restful/v3 v3.8.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/envoyproxy/go-control-plane v0.11.1-0.20230524094728-9239064ad72f/go.mod h1:sfYdkwUW4BA3PbKjySwjJy+O4Pu0h62rlqCMHNk+K+Q=
|
||||
github.com/envoyproxy/go-control-plane v0.11.1/go.mod h1:uhMcXKCQMEJHiAb0w+YGefQLaTEw+YhGluxZkrTmD0g=
|
||||
@@ -1335,6 +1347,7 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8 h1:DujepqpGd1hyOd7aW59XpK7Qymp8iy83xq74fLr21is=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
@@ -1433,6 +1446,7 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksP
|
||||
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
|
||||
github.com/googleapis/gax-go/v2 v2.12.2/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc=
|
||||
github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4=
|
||||
github.com/googleapis/gnostic v0.3.0 h1:CcQijm0XKekKjP/YCz28LXVSpgguuB+nCxaSjCe09y0=
|
||||
github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA=
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720 h1:zC34cGQu69FG7qzJ3WiKW244WfhDC3xxYMeNOX2gtUQ=
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||
@@ -1476,6 +1490,7 @@ github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjG
|
||||
github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwMrUAE=
|
||||
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go.net v0.0.1 h1:sNCoNyDEvN1xa+X0baata4RdcpKwcMS6DH+xwfqPgjw=
|
||||
github.com/hashicorp/golang-lru v0.6.0/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=
|
||||
github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY=
|
||||
@@ -1506,6 +1521,7 @@ github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJ
|
||||
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
|
||||
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
|
||||
github.com/influxdata/influxdb v1.7.6 h1:8mQ7A/V+3noMGCt/P9pD09ISaiz9XvgCk303UYA3gcs=
|
||||
github.com/influxdata/influxdb v1.7.7 h1:UvNzAPfBrKMENVbQ4mr4ccA9sW+W1Ihl0Yh1s0BiVAg=
|
||||
github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab h1:HqW4xhhynfjrtEiiSGcQUd6vrK23iMam1FO8rI7mwig=
|
||||
github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
|
||||
github.com/influxdata/tdigest v0.0.2-0.20210216194612-fc98d27c9e8b h1:i44CesU68ZBRvtCjBi3QSosCIKrjmMbYlQMFAwVLds4=
|
||||
@@ -1582,6 +1598,7 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJ
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY=
|
||||
github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
|
||||
github.com/kr/pty v1.1.5 h1:hyz3dwM5QLc1Rfoz4FuWJQG5BN7tc6K1MndAUnGpQr4=
|
||||
github.com/kshvakov/clickhouse v1.3.5 h1:PDTYk9VYgbjPAWry3AoDREeMgOVUFij6bh6IjlloHL0=
|
||||
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
|
||||
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
|
||||
@@ -1633,6 +1650,8 @@ github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
|
||||
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
|
||||
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
|
||||
github.com/mitchellh/gox v0.4.0 h1:lfGJxY7ToLJQjHHwi0EX6uYBdK78egf954SQl13PQJc=
|
||||
github.com/mitchellh/iochan v1.0.0 h1:C+X3KsSTLFVBr/tK1eYN/vs4rJcvsiLU338UhYPJWeY=
|
||||
github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mithrandie/readline-csvq v1.3.0 h1:VTJEOGouJ8j27jJCD4kBBbNTxM0OdBvE1aY1tMhlqE8=
|
||||
github.com/mithrandie/readline-csvq v1.3.0/go.mod h1:FKyYqDgf/G4SNov7SMFXRWO6LQLXIOeTog/NB97FZl0=
|
||||
@@ -1769,6 +1788,7 @@ github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/En
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30 h1:BHT1/DKsYDGkUgQ2jmMaozVcdk+sVfz0+1ZJq4zkWgw=
|
||||
github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/performancecopilot/speed/v4 v4.0.0 h1:VxEDCmdkfbQYDlcr/GC9YoN9PQ6p8ulk9xVsepYy9ZY=
|
||||
github.com/performancecopilot/speed/v4 v4.0.0/go.mod h1:qxrSyuDGrTOWfV+uKRFhfxw6h/4HXRGUiZiufxo49BM=
|
||||
@@ -1816,6 +1836,7 @@ github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFo
|
||||
github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk=
|
||||
github.com/sagikazarmark/crypt v0.19.0 h1:WMyLTjHBo64UvNcWqpzY3pbZTYgnemZU8FBZigKc42E=
|
||||
github.com/sagikazarmark/crypt v0.19.0/go.mod h1:c6vimRziqqERhtSe0MhIvzE1w54FrCHtrXb5NH/ja78=
|
||||
github.com/samuel/go-zookeeper v0.0.0-20190810000440-0ceca61e4d75 h1:cA+Ubq9qEVIQhIWvP2kNuSZ2CmnfBJFSRq+kO1pu2cc=
|
||||
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.26 h1:F+GIVtGqCFxPxO46ujf8cEOP574MBoRm3gNbPXECbxs=
|
||||
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.26/go.mod h1:fCa7OJZ/9DRTnOKmxvT6pn+LPWUptQAmHF/SBJUGEcg=
|
||||
@@ -2003,6 +2024,8 @@ go.opentelemetry.io/collector/receiver/otlpreceiver v0.102.1/go.mod h1:0hmxfFSSq
|
||||
go.opentelemetry.io/collector/semconv v0.98.0/go.mod h1:8ElcRZ8Cdw5JnvhTOQOdYizkJaQ10Z2fS+R6djOnj6A=
|
||||
go.opentelemetry.io/collector/semconv v0.105.0 h1:8p6dZ3JfxFTjbY38d8xlQGB1TQ3nPUvs+D0RERniZ1g=
|
||||
go.opentelemetry.io/collector/semconv v0.105.0/go.mod h1:yMVUCNoQPZVq/IPfrHrnntZTWsLf5YGZ7qwKulIl5hw=
|
||||
go.opentelemetry.io/collector/semconv v0.116.0 h1:63xCZomsKJAWmKGWD3lnORiE3WKW6AO4LjnzcHzGx3Y=
|
||||
go.opentelemetry.io/collector/semconv v0.116.0/go.mod h1:N6XE8Q0JKgBN2fAhkUQtqK9LT7rEGR6+Wu/Rtbal1iI=
|
||||
go.opentelemetry.io/collector/service v0.102.1 h1:Lg7qrC4Zctd/OAlkpdsaZaUY+jLEGLLnOigfBLP2GW8=
|
||||
go.opentelemetry.io/collector/service v0.102.1/go.mod h1:L5Sh3461B1Zij7vpMMbi6M/SZicgrLB3UgbG0oUK0pA=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.53.0 h1:BdkKDtcrHThgjcEia1737OUuFdP6xzBKAMx2sNZCkvE=
|
||||
@@ -2063,6 +2086,8 @@ go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7e
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
|
||||
go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
|
||||
@@ -2316,6 +2341,7 @@ gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/gcfg.v1 v1.2.3 h1:m8OOJ4ccYHnx2f4gQwpno8nAX5OGOh7RLaaz0pj3Ogs=
|
||||
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
|
||||
gopkg.in/ini.v1 v1.66.6/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI=
|
||||
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
|
||||
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
|
||||
gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
|
||||
@@ -2334,6 +2360,7 @@ k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQI
|
||||
k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0=
|
||||
k8s.io/code-generator v0.32.0 h1:s0lNN8VSWny8LBz5t5iy7MCdgwdOhdg7vAGVxvS+VWU=
|
||||
k8s.io/code-generator v0.32.0/go.mod h1:b7Q7KMZkvsYFy72A79QYjiv4aTz3GvW0f1T3UfhFq4s=
|
||||
k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6 h1:4s3/R4+OYYYUKptXPhZKjQ04WJ6EhQQVFdjOFvCazDk=
|
||||
k8s.io/gengo v0.0.0-20230829151522-9cce18d56c01 h1:pWEwq4Asjm4vjW7vcsmijwBhOr1/shsbSYiWXmNGlks=
|
||||
k8s.io/gengo v0.0.0-20230829151522-9cce18d56c01/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E=
|
||||
k8s.io/gengo/v2 v2.0.0-20240228010128-51d4e06bde70/go.mod h1:VH3AT8AaQOqiGjMF9p0/IM1Dj+82ZwjfxUP1IxaHE+8=
|
||||
@@ -2350,14 +2377,17 @@ k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdz
|
||||
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
|
||||
k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
|
||||
k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
|
||||
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg=
|
||||
modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
||||
modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0=
|
||||
modernc.org/ccgo/v3 v3.16.9 h1:AXquSwg7GuMk11pIdw7fmO1Y/ybgazVkMhsZWCV0mHM=
|
||||
modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g=
|
||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
@@ -2376,10 +2406,12 @@ modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.18.2/go.mod h1:kvrTLEWgxUcHa2GfHBQtanR1H9ht3hTJNtKpzH9k1u0=
|
||||
modernc.org/sqlite v1.21.2/go.mod h1:cxbLkB5WS32DnQqeH4h4o1B0eMr8W/y8/RGuxQ3JsC0=
|
||||
modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao=
|
||||
modernc.org/tcl v1.13.2/go.mod h1:7CLiGIPo1M8Rv1Mitpv5akc2+8fxUd2y2UzC/MfMzy0=
|
||||
modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws=
|
||||
modernc.org/tcl v1.15.1/go.mod h1:aEjeGJX2gz1oWKOLDVZ2tnEWLUrIn8H+GFu+akoDhqs=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM=
|
||||
modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE=
|
||||
modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
|
||||
rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE=
|
||||
@@ -2387,6 +2419,7 @@ rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
|
||||
rsc.io/quote/v3 v3.1.0 h1:9JKUTTIUgS6kzR9mK1YuGKv6Nl+DijDNIc0ghT58FaY=
|
||||
rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4=
|
||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
|
||||
sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e h1:4Z09Hglb792X0kfOBBJUPFEyvVfQWrYT/l8h5EKA6JQ=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
|
||||
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
|
||||
@@ -70,6 +70,12 @@ export type BackendSrvRequest = {
|
||||
*/
|
||||
responseType?: 'json' | 'text' | 'arraybuffer' | 'blob' | 'stream';
|
||||
|
||||
/**
|
||||
* Used to cancel an open connection
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/AbortController
|
||||
*/
|
||||
abortSignal?: AbortSignal;
|
||||
|
||||
/**
|
||||
* The credentials read-only property of the Request interface indicates whether the user agent should send cookies from the other domain in the case of cross-origin requests.
|
||||
*/
|
||||
@@ -173,6 +179,14 @@ export interface BackendSrv {
|
||||
* Observable http request interface
|
||||
*/
|
||||
fetch<T>(options: BackendSrvRequest): Observable<FetchResponse<T>>;
|
||||
|
||||
/**
|
||||
* Observe each raw chunk in the response. This is useful when reading values from
|
||||
* a long living HTTP connection like the kubernetes WATCH command.
|
||||
*
|
||||
* Each chunk includes the full response headers and the `data` property is filled with the chunk.
|
||||
*/
|
||||
chunked(options: BackendSrvRequest): Observable<FetchResponse<Uint8Array | undefined>>;
|
||||
}
|
||||
|
||||
let singletonInstance: BackendSrv;
|
||||
|
||||
@@ -7,11 +7,6 @@ go 1.23.1
|
||||
// github.com/docker/docker@v0.0.0-00010101000000-000000000000: invalid version: unknown revision 000000000000
|
||||
replace github.com/docker/docker => github.com/moby/moby v26.0.0+incompatible
|
||||
|
||||
// Override Prometheus version because Prometheus v2.X is tagged as v0.X for Go modules purposes and Go assumes
|
||||
// that v1.Y is higher than v0.X, so when we resolve dependencies if any dependency imports v1.Y we'd
|
||||
// import that instead of v0.X even though v0.X is newer.
|
||||
replace github.com/prometheus/prometheus => github.com/prometheus/prometheus v0.52.0
|
||||
|
||||
require (
|
||||
cloud.google.com/go/storage v1.43.0 // @grafana/grafana-backend-group
|
||||
github.com/Masterminds/semver/v3 v3.3.0 // @grafana/grafana-developer-enablement-squad
|
||||
|
||||
@@ -90,6 +90,7 @@ type pluginInstallOpts struct {
|
||||
repoURL string
|
||||
pluginURL string
|
||||
pluginDir string
|
||||
gcomToken string
|
||||
}
|
||||
|
||||
func newInstallPluginOpts(c utils.CommandLine) pluginInstallOpts {
|
||||
@@ -98,6 +99,7 @@ func newInstallPluginOpts(c utils.CommandLine) pluginInstallOpts {
|
||||
repoURL: c.PluginRepoURL(),
|
||||
pluginURL: c.PluginURL(),
|
||||
pluginDir: c.PluginDirectory(),
|
||||
gcomToken: c.GcomToken(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +134,10 @@ func doInstallPlugin(ctx context.Context, pluginID, version string, o pluginInst
|
||||
}
|
||||
|
||||
repository := repo.NewManager(repo.ManagerCfg{
|
||||
SkipTLSVerify: o.insecure,
|
||||
BaseURL: o.repoURL,
|
||||
Logger: services.Logger,
|
||||
SkipTLSVerify: o.insecure,
|
||||
BaseURL: o.repoURL,
|
||||
Logger: services.Logger,
|
||||
GrafanaComAPIToken: o.gcomToken,
|
||||
})
|
||||
|
||||
// FIXME: Re-enable grafanaVersion. This check was broken in 10.2 so disabling it for the moment.
|
||||
|
||||
@@ -106,7 +106,8 @@ func TestValidatePluginRepoConfig(t *testing.T) {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
grafDir, cfgPath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
|
||||
GrafanaComAPIURL: "https://grafana-dev.com",
|
||||
GrafanaComAPIURL: "https://grafana-dev.com",
|
||||
GrafanaComSSOAPIToken: "token3",
|
||||
})
|
||||
|
||||
c, err := commandstest.NewCliContext(map[string]string{
|
||||
@@ -116,6 +117,9 @@ func TestValidatePluginRepoConfig(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
repoURL := c.PluginRepoURL()
|
||||
require.Equal(t, "https://grafana-dev.com/plugins", repoURL)
|
||||
|
||||
token := c.GcomToken()
|
||||
require.Equal(t, "token3", token)
|
||||
})
|
||||
|
||||
t.Run("Should use config overrides parameter if it is set alongside config parameter", func(t *testing.T) {
|
||||
|
||||
@@ -26,6 +26,7 @@ type CommandLine interface {
|
||||
PluginDirectory() string
|
||||
PluginRepoURL() string
|
||||
PluginURL() string
|
||||
GcomToken() string
|
||||
}
|
||||
|
||||
type ApiClient interface {
|
||||
@@ -75,12 +76,7 @@ func (c *ContextCommandLine) PluginRepoURL() string {
|
||||
|
||||
// if --config flag is set, try to get the GrafanaComAPIURL setting
|
||||
if c.ConfigFile() != "" {
|
||||
configOptions := strings.Split(c.String("configOverrides"), " ")
|
||||
cfg, err := setting.NewCfgFromArgs(setting.CommandLineArgs{
|
||||
Config: c.ConfigFile(),
|
||||
HomePath: c.HomePath(),
|
||||
Args: append(configOptions, c.Args().Slice()...),
|
||||
})
|
||||
cfg, err := c.Config()
|
||||
|
||||
if err != nil {
|
||||
logger.Debug("Could not parse config file", err)
|
||||
@@ -92,6 +88,25 @@ func (c *ContextCommandLine) PluginRepoURL() string {
|
||||
return c.String("repo")
|
||||
}
|
||||
|
||||
func (c *ContextCommandLine) Config() (*setting.Cfg, error) {
|
||||
configOptions := strings.Split(c.String("configOverrides"), " ")
|
||||
return setting.NewCfgFromArgs(setting.CommandLineArgs{
|
||||
Config: c.ConfigFile(),
|
||||
HomePath: c.HomePath(),
|
||||
Args: append(configOptions, c.Args().Slice()...),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *ContextCommandLine) GcomToken() string {
|
||||
cfg, err := c.Config()
|
||||
|
||||
if err != nil {
|
||||
logger.Debug("Could not parse config file", err)
|
||||
return ""
|
||||
}
|
||||
return cfg.GrafanaComSSOAPIToken
|
||||
}
|
||||
|
||||
func (c *ContextCommandLine) PluginURL() string {
|
||||
return c.String("pluginUrl")
|
||||
}
|
||||
@@ -146,6 +146,19 @@ func (_m *MockCommandLine) PluginURL() string {
|
||||
return r0
|
||||
}
|
||||
|
||||
func (_m *MockCommandLine) GcomToken() string {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 string
|
||||
if rf, ok := ret.Get(0).(func() string); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// ShowHelp provides a mock function with given fields:
|
||||
func (_m *MockCommandLine) ShowHelp() error {
|
||||
ret := _m.Called()
|
||||
|
||||
@@ -199,7 +199,7 @@ func (s *SocialAzureAD) Exchange(ctx context.Context, code string, authOptions .
|
||||
case social.ClientSecretPost:
|
||||
// Default behavior for ClientSecretPost, no additional setup needed
|
||||
default:
|
||||
s.log.Debug("ClientAuthentication is not set. Using default client authentication method")
|
||||
s.log.Debug("ClientAuthentication is not set. Using default client authentication method: none")
|
||||
}
|
||||
|
||||
// Default token exchange
|
||||
@@ -379,6 +379,9 @@ func validateClientAuthentication(info *social.OAuthInfo, requester identity.Req
|
||||
}
|
||||
return nil
|
||||
|
||||
case social.None:
|
||||
return nil
|
||||
|
||||
default:
|
||||
return ssosettings.ErrInvalidOAuthConfig("Invalid client authentication method.")
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
|
||||
// Values for ClientAuthentication under OAuthInfo (based on oidc spec)
|
||||
ClientSecretPost = "client_secret_post"
|
||||
None = "none"
|
||||
// Azure AD
|
||||
ManagedIdentity = "managed_identity"
|
||||
// Other providers...
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins/log"
|
||||
@@ -24,16 +25,18 @@ type Client struct {
|
||||
httpClientNoTimeout http.Client
|
||||
retryCount int
|
||||
grafanaComAPIToken string
|
||||
grafanaComAPIURL string
|
||||
|
||||
log log.PrettyLogger
|
||||
}
|
||||
|
||||
func NewClient(skipTLSVerify bool, grafanaComAPIToken string, logger log.PrettyLogger) *Client {
|
||||
func NewClient(skipTLSVerify bool, grafanaComAPIToken, grafanaComAPIURL string, logger log.PrettyLogger) *Client {
|
||||
return &Client{
|
||||
httpClient: MakeHttpClient(skipTLSVerify, 10*time.Second),
|
||||
httpClientNoTimeout: MakeHttpClient(skipTLSVerify, 0),
|
||||
log: logger,
|
||||
grafanaComAPIToken: grafanaComAPIToken,
|
||||
grafanaComAPIURL: grafanaComAPIURL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +236,7 @@ func (c *Client) createReq(ctx context.Context, url *url.URL, compatOpts CompatO
|
||||
req.Header.Set("grafana-origin", orig.(string))
|
||||
}
|
||||
|
||||
if c.grafanaComAPIToken != "" {
|
||||
if strings.HasPrefix(url.String(), c.grafanaComAPIURL) && c.grafanaComAPIToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.grafanaComAPIToken)
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ func Test_Download(t *testing.T) {
|
||||
require.Equal(t, 2, count, "should retry on error")
|
||||
})
|
||||
|
||||
t.Run("it should use gcom token when it's available", func(t *testing.T) {
|
||||
t.Run("it should use gcom token when the token is available and request is to GCOM", func(t *testing.T) {
|
||||
expectedToken := "token-test"
|
||||
var gcomCalled bool
|
||||
fakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -82,9 +82,29 @@ func Test_Download(t *testing.T) {
|
||||
}))
|
||||
defer fakeServer.Close()
|
||||
cli := fakeServer.Client()
|
||||
repo := Client{httpClient: *cli, httpClientNoTimeout: *cli, log: log.NewPrettyLogger("test"), grafanaComAPIToken: expectedToken}
|
||||
_, err := repo.Download(context.Background(), fakeServer.URL, "", CompatOpts{})
|
||||
pluginURL := fakeServer.URL + "/api/plugins/test-datasource"
|
||||
gcomAPIURL := fakeServer.URL + "/api/plugins"
|
||||
repo := Client{httpClient: *cli, httpClientNoTimeout: *cli, log: log.NewPrettyLogger("test"), grafanaComAPIToken: expectedToken, grafanaComAPIURL: gcomAPIURL}
|
||||
_, err := repo.Download(context.Background(), pluginURL, "", CompatOpts{})
|
||||
require.NoError(t, err)
|
||||
require.True(t, gcomCalled)
|
||||
})
|
||||
|
||||
t.Run("it should not set gcom token when the token is available but request is NOT to GCOM", func(t *testing.T) {
|
||||
expectedToken := "token-test"
|
||||
var serverCalled bool
|
||||
fakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
require.Empty(t, token, "token should not be set")
|
||||
err := writeFakeZip(w)
|
||||
require.NoError(t, err)
|
||||
serverCalled = true
|
||||
}))
|
||||
defer fakeServer.Close()
|
||||
cli := fakeServer.Client()
|
||||
repo := Client{httpClient: *cli, httpClientNoTimeout: *cli, log: log.NewPrettyLogger("test"), grafanaComAPIToken: expectedToken, grafanaComAPIURL: "https://grafana.com/api/plugins"}
|
||||
_, err := repo.Download(context.Background(), fakeServer.URL, "", CompatOpts{})
|
||||
require.NoError(t, err)
|
||||
require.True(t, serverCalled)
|
||||
})
|
||||
}
|
||||
@@ -14,8 +14,7 @@ import (
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
client *Client
|
||||
baseURL string
|
||||
client *Client
|
||||
|
||||
log log.PrettyLogger
|
||||
}
|
||||
@@ -43,9 +42,8 @@ type ManagerCfg struct {
|
||||
|
||||
func NewManager(cfg ManagerCfg) *Manager {
|
||||
return &Manager{
|
||||
baseURL: cfg.BaseURL,
|
||||
client: NewClient(cfg.SkipTLSVerify, cfg.GrafanaComAPIToken, cfg.Logger),
|
||||
log: cfg.Logger,
|
||||
client: NewClient(cfg.SkipTLSVerify, cfg.GrafanaComAPIToken, cfg.BaseURL, cfg.Logger),
|
||||
log: cfg.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,12 +99,12 @@ func (m *Manager) PluginVersion(ctx context.Context, pluginID, version string, c
|
||||
}
|
||||
|
||||
func (m *Manager) downloadURL(pluginID, version string) string {
|
||||
return fmt.Sprintf("%s/%s/versions/%s/download", m.baseURL, pluginID, version)
|
||||
return fmt.Sprintf("%s/%s/versions/%s/download", m.client.grafanaComAPIURL, pluginID, version)
|
||||
}
|
||||
|
||||
// grafanaCompatiblePluginVersions will get version info from /api/plugins/$pluginID/versions
|
||||
func (m *Manager) grafanaCompatiblePluginVersions(ctx context.Context, pluginID string, compatOpts CompatOpts) ([]Version, error) {
|
||||
u, err := url.Parse(m.baseURL)
|
||||
u, err := url.Parse(m.client.grafanaComAPIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+1
-1
@@ -109,9 +109,9 @@ require (
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
golang.org/x/tools v0.29.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/api v0.216.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241216192217-9240e9c98484 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d // indirect
|
||||
google.golang.org/grpc v1.69.2 // indirect
|
||||
|
||||
+4
-4
@@ -111,8 +111,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||
github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o=
|
||||
github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk=
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
|
||||
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
@@ -384,8 +384,8 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
|
||||
gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
|
||||
google.golang.org/api v0.213.0 h1:KmF6KaDyFqB417T68tMPbVmmwtIXs2VB60OJKIHB0xQ=
|
||||
google.golang.org/api v0.213.0/go.mod h1:V0T5ZhNUUNpYAlL306gFZPFt5F5D/IeyLoktduYYnvQ=
|
||||
google.golang.org/api v0.216.0 h1:xnEHy+xWFrtYInWPy8OdGFsyIfWJjtVnO39g7pz2BFY=
|
||||
google.golang.org/api v0.216.0/go.mod h1:K9wzQMvWi47Z9IU7OgdOofvZuw75Ge3PPITImZR/UyI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241216192217-9240e9c98484 h1:ChAdCYNQFDk5fYvFZMywKLIijG7TC2m1C2CMEu11G3o=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241216192217-9240e9c98484/go.mod h1:KRUmxRI4JmbpAm8gcZM4Jsffi859fo5LQjILwuqj9z8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw=
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/grafana/authlib/claims"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apis/dashboard"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
@@ -27,12 +26,6 @@ func GetAuthorizer(dashboardService dashboards.DashboardService, l log.Logger) a
|
||||
}
|
||||
|
||||
if attr.GetName() == "" {
|
||||
// Discourage use of the "list" command for non super admin users
|
||||
if attr.GetVerb() == "list" && attr.GetResource() == dashboard.DashboardResourceInfo.GroupResource().Resource {
|
||||
if !user.GetIsGrafanaAdmin() {
|
||||
return authorizer.DecisionDeny, "list summary objects (or connect as GrafanaAdmin)", err
|
||||
}
|
||||
}
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/storage/unified/search"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -226,12 +228,17 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: int64(limit),
|
||||
Offset: int64(offset),
|
||||
Explain: queryParams.Has("explain") && queryParams.Get("explain") != "false",
|
||||
Fields: []string{
|
||||
"title",
|
||||
"folder",
|
||||
"tags",
|
||||
},
|
||||
}
|
||||
fields := []string{"title", "folder", "tags"}
|
||||
if queryParams.Has("field") {
|
||||
// add fields to search and exclude duplicates
|
||||
for _, f := range queryParams["field"] {
|
||||
if f != "" && !slices.Contains(fields, f) {
|
||||
fields = append(fields, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
searchRequest.Fields = fields
|
||||
|
||||
// Add the folder constraint. Note this does not do recursive search
|
||||
folder := queryParams.Get("folder")
|
||||
@@ -277,6 +284,9 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// Add sorting
|
||||
if queryParams.Has("sort") {
|
||||
for _, sort := range queryParams["sort"] {
|
||||
if slices.Contains(search.DashboardFields(), sort) {
|
||||
sort = "fields." + sort
|
||||
}
|
||||
s := &resource.ResourceSearchRequest_Sort{Field: sort}
|
||||
if strings.HasPrefix(sort, "-") {
|
||||
s.Desc = true
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
func TestSearchHandlerFields(t *testing.T) {
|
||||
// Create a mock client
|
||||
mockClient := &MockClient{}
|
||||
|
||||
// Initialize the search handler with the mock client
|
||||
searchHandler := SearchHandler{
|
||||
log: log.New("test", "test"),
|
||||
client: mockClient,
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
}
|
||||
|
||||
t.Run("Multiple comma separated fields will be appended to default dashboard search fields", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/search?field=field1&field=field2&field=field3", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
|
||||
|
||||
searchHandler.DoSearch(rr, req)
|
||||
|
||||
if mockClient.LastSearchRequest == nil {
|
||||
t.Fatalf("expected Search to be called, but it was not")
|
||||
}
|
||||
expectedFields := []string{"title", "folder", "tags", "field1", "field2", "field3"}
|
||||
if fmt.Sprintf("%v", mockClient.LastSearchRequest.Fields) != fmt.Sprintf("%v", expectedFields) {
|
||||
t.Errorf("expected fields %v, got %v", expectedFields, mockClient.LastSearchRequest.Fields)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Single field will be appended to default dashboard search fields", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/search?field=field1", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
|
||||
|
||||
searchHandler.DoSearch(rr, req)
|
||||
|
||||
if mockClient.LastSearchRequest == nil {
|
||||
t.Fatalf("expected Search to be called, but it was not")
|
||||
}
|
||||
expectedFields := []string{"title", "folder", "tags", "field1"}
|
||||
if fmt.Sprintf("%v", mockClient.LastSearchRequest.Fields) != fmt.Sprintf("%v", expectedFields) {
|
||||
t.Errorf("expected fields %v, got %v", expectedFields, mockClient.LastSearchRequest.Fields)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Passing no fields will search using default dashboard fields", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/search", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
|
||||
|
||||
searchHandler.DoSearch(rr, req)
|
||||
|
||||
if mockClient.LastSearchRequest == nil {
|
||||
t.Fatalf("expected Search to be called, but it was not")
|
||||
}
|
||||
expectedFields := []string{"title", "folder", "tags"}
|
||||
if fmt.Sprintf("%v", mockClient.LastSearchRequest.Fields) != fmt.Sprintf("%v", expectedFields) {
|
||||
t.Errorf("expected fields %v, got %v", expectedFields, mockClient.LastSearchRequest.Fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// MockClient implements the ResourceIndexClient interface for testing
|
||||
type MockClient struct {
|
||||
resource.ResourceIndexClient
|
||||
|
||||
// Capture the last SearchRequest for assertions
|
||||
LastSearchRequest *resource.ResourceSearchRequest
|
||||
}
|
||||
|
||||
func (m *MockClient) Search(ctx context.Context, in *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) {
|
||||
m.LastSearchRequest = in
|
||||
|
||||
return &resource.ResourceSearchResponse{}, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) GetStats(ctx context.Context, in *resource.ResourceStatsRequest, opts ...grpc.CallOption) (*resource.ResourceStatsResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) History(ctx context.Context, in *resource.HistoryRequest, opts ...grpc.CallOption) (*resource.HistoryResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/authn/authnimpl"
|
||||
"github.com/grafana/grafana/pkg/services/authz"
|
||||
"github.com/grafana/grafana/pkg/services/cleanup"
|
||||
"github.com/grafana/grafana/pkg/services/cloudmigration"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
@@ -73,7 +72,7 @@ func ProvideBackgroundServiceRegistry(
|
||||
_ dashboardsnapshots.Service,
|
||||
_ serviceaccounts.Service, _ *guardian.Provider,
|
||||
_ *plugindashboardsservice.DashboardUpdater, _ *sanitizer.Provider,
|
||||
_ *grpcserver.HealthService, _ authz.Client, _ *grpcserver.ReflectionService,
|
||||
_ *grpcserver.HealthService, _ *grpcserver.ReflectionService,
|
||||
_ *ldapapi.Service, _ *apiregistry.Service, _ auth.IDService, _ *teamapi.TeamAPI, _ ssosettings.Service,
|
||||
_ cloudmigration.Service, _ authnimpl.Registration,
|
||||
) *BackgroundServiceRegistry {
|
||||
|
||||
@@ -2,6 +2,7 @@ package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/fullstorydev/grpchan"
|
||||
"github.com/fullstorydev/grpchan/inprocgrpc"
|
||||
@@ -27,56 +28,40 @@ import (
|
||||
// `authzService` is hardcoded in authz-service
|
||||
const authzServiceAudience = "authzService"
|
||||
|
||||
type Client interface {
|
||||
authzlib.AccessClient
|
||||
}
|
||||
|
||||
// ProvideAuthZClient provides an AuthZ client and creates the AuthZ service.
|
||||
func ProvideAuthZClient(
|
||||
cfg *setting.Cfg, features featuremgmt.FeatureToggles, grpcServer grpcserver.Provider,
|
||||
tracer tracing.Tracer, db db.DB,
|
||||
) (Client, error) {
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagAuthZGRPCServer) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
) (authzlib.AccessClient, error) {
|
||||
authCfg, err := ReadCfg(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var client Client
|
||||
isRemoteServer := authCfg.mode == ModeCloud || authCfg.mode == ModeGRPC
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagAuthZGRPCServer) && isRemoteServer {
|
||||
return nil, errors.New("authZGRPCServer feature toggle is required for cloud and grpc mode")
|
||||
}
|
||||
|
||||
// Register the server
|
||||
sql := legacysql.NewDatabaseProvider(db)
|
||||
server := rbac.NewService(sql, legacy.NewLegacySQLStores(sql), log.New("authz-grpc-server"), tracer)
|
||||
|
||||
switch authCfg.mode {
|
||||
case ModeInProc:
|
||||
client, err = newInProcLegacyClient(server, tracer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ModeGRPC:
|
||||
client, err = newGrpcLegacyClient(authCfg, tracer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newGrpcLegacyClient(authCfg, tracer)
|
||||
case ModeCloud:
|
||||
client, err = newCloudLegacyClient(authCfg, tracer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newCloudLegacyClient(authCfg, tracer)
|
||||
default:
|
||||
return newInProcLegacyClient(server, tracer)
|
||||
}
|
||||
|
||||
return client, err
|
||||
}
|
||||
|
||||
// ProvideStandaloneAuthZClient provides a standalone AuthZ client, without registering the AuthZ service.
|
||||
// You need to provide a remote address in the configuration
|
||||
func ProvideStandaloneAuthZClient(
|
||||
cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer,
|
||||
) (Client, error) {
|
||||
) (authzlib.AccessClient, error) {
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagAuthZGRPCServer) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -198,7 +198,6 @@ func (_m *FakeDashboardService) GetAllDashboards(ctx context.Context) ([]*Dashbo
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
|
||||
func (_m *FakeDashboardService) GetAllDashboardsByOrgId(ctx context.Context, orgID int64) ([]*Dashboard, error) {
|
||||
ret := _m.Called(ctx, orgID)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel"
|
||||
"golang.org/x/exp/slices"
|
||||
@@ -71,6 +72,16 @@ var (
|
||||
tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/dashboards/service")
|
||||
)
|
||||
|
||||
var (
|
||||
excludedFields = map[string]string{
|
||||
resource.SEARCH_FIELD_EXPLAIN: "",
|
||||
resource.SEARCH_FIELD_SCORE: "",
|
||||
resource.SEARCH_FIELD_TITLE: "",
|
||||
resource.SEARCH_FIELD_FOLDER: "",
|
||||
resource.SEARCH_FIELD_TAGS: "",
|
||||
}
|
||||
)
|
||||
|
||||
type DashboardServiceImpl struct {
|
||||
cfg *setting.Cfg
|
||||
log log.Logger
|
||||
@@ -1949,11 +1960,11 @@ func ParseResults(result *resource.ResourceSearchResponse, offset int64) (*v0alp
|
||||
explainIDX = i
|
||||
case resource.SEARCH_FIELD_SCORE:
|
||||
scoreIDX = i
|
||||
case "title":
|
||||
case resource.SEARCH_FIELD_TITLE:
|
||||
titleIDX = i
|
||||
case "folder":
|
||||
case resource.SEARCH_FIELD_FOLDER:
|
||||
folderIDX = i
|
||||
case "tags":
|
||||
case resource.SEARCH_FIELD_TAGS:
|
||||
tagsIDX = i
|
||||
}
|
||||
}
|
||||
@@ -1967,11 +1978,28 @@ func ParseResults(result *resource.ResourceSearchResponse, offset int64) (*v0alp
|
||||
}
|
||||
|
||||
for i, row := range result.Results.Rows {
|
||||
fields := &common.Unstructured{}
|
||||
for colIndex, col := range result.Results.Columns {
|
||||
if _, ok := excludedFields[col.Name]; ok {
|
||||
val, err := resource.DecodeCell(col, colIndex, row.Cells[colIndex])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Some of the dashboard fields come in as int32, but we need to convert them to int64 or else fields.Set() will panic
|
||||
int32Val, ok := val.(int32)
|
||||
if ok {
|
||||
val = int64(int32Val)
|
||||
}
|
||||
fields.Set(col.Name, val)
|
||||
}
|
||||
}
|
||||
|
||||
hit := &v0alpha1.DashboardHit{
|
||||
Resource: row.Key.Resource, // folders | dashboards
|
||||
Name: row.Key.Name, // The Grafana UID
|
||||
Title: string(row.Cells[titleIDX]),
|
||||
Folder: string(row.Cells[folderIDX]),
|
||||
Field: fields,
|
||||
}
|
||||
if tagsIDX > 0 && row.Cells[tagsIDX] != nil {
|
||||
_ = json.Unmarshal(row.Cells[tagsIDX], &hit.Tags)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/storage/unified/search"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -417,9 +418,11 @@ func TestGetDashboard(t *testing.T) {
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
@@ -636,9 +639,11 @@ func TestGetProvisionedDashboardData(t *testing.T) {
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
@@ -729,9 +734,11 @@ func TestGetProvisionedDashboardDataByDashboardID(t *testing.T) {
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
@@ -810,9 +817,11 @@ func TestGetProvisionedDashboardDataByDashboardUID(t *testing.T) {
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
@@ -917,9 +926,11 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
@@ -946,9 +957,11 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
@@ -1039,9 +1052,11 @@ func TestUnprovisionDashboard(t *testing.T) {
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
@@ -1095,9 +1110,11 @@ func TestGetDashboardsByPluginID(t *testing.T) {
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
@@ -1303,9 +1320,11 @@ func TestDeleteDashboard(t *testing.T) {
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
@@ -1422,12 +1441,15 @@ func TestSearchDashboards(t *testing.T) {
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "tags",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
@@ -1521,9 +1543,11 @@ func TestGetDashboards(t *testing.T) {
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
@@ -1599,9 +1623,11 @@ func TestGetDashboardUIDByID(t *testing.T) {
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
@@ -1877,3 +1903,47 @@ func TestToUID(t *testing.T) {
|
||||
assert.Equal(t, "", result)
|
||||
})
|
||||
}
|
||||
|
||||
// regression test - parsing int32 values from search results was causing a panic
|
||||
func TestParseResults(t *testing.T) {
|
||||
resSearchResp := &resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "folder",
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: search.DASHBOARD_ERRORS_LAST_1_DAYS,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
},
|
||||
{
|
||||
Name: search.DASHBOARD_LINK_COUNT,
|
||||
Type: resource.ResourceTableColumnDefinition_INT32,
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
{
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "uid",
|
||||
Resource: "dashboard",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("Dashboard 1"),
|
||||
[]byte("folder1"),
|
||||
[]byte("100"),
|
||||
[]byte("25"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}
|
||||
|
||||
_, err := ParseResults(resSearchResp, 0)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
const (
|
||||
selectLibraryElementDTOWithMeta = `
|
||||
SELECT DISTINCT
|
||||
le.name, le.id, le.org_id, le.folder_id, le.uid, le.kind, le.type, le.description, le.model, le.created, le.created_by, le.updated, le.updated_by, le.version
|
||||
le.name, le.id, le.org_id, le.folder_id, le.folder_uid, le.uid, le.kind, le.type, le.description, le.model, le.created, le.created_by, le.updated, le.updated_by, le.version
|
||||
, u1.login AS created_by_name
|
||||
, u1.email AS created_by_email
|
||||
, u2.login AS updated_by_name
|
||||
@@ -93,10 +93,7 @@ func syncFieldsWithModel(libraryElement *model.LibraryElement) error {
|
||||
func GetLibraryElement(dialect migrator.Dialect, session *db.Session, uid string, orgID int64) (model.LibraryElementWithMeta, error) {
|
||||
elements := make([]model.LibraryElementWithMeta, 0)
|
||||
sql := selectLibraryElementDTOWithMeta +
|
||||
", coalesce(dashboard.title, 'General') AS folder_name" +
|
||||
", coalesce(dashboard.uid, '') AS folder_uid" +
|
||||
getFromLibraryElementDTOWithMeta(dialect) +
|
||||
" LEFT JOIN dashboard AS dashboard ON dashboard.id = le.folder_id" +
|
||||
" WHERE le.uid=? AND le.org_id=?"
|
||||
sess := session.SQL(sql, uid, orgID)
|
||||
err := sess.Find(&elements)
|
||||
@@ -110,6 +107,15 @@ func GetLibraryElement(dialect migrator.Dialect, session *db.Session, uid string
|
||||
return model.LibraryElementWithMeta{}, fmt.Errorf("found %d elements, while expecting at most one", len(elements))
|
||||
}
|
||||
|
||||
for _, el := range elements {
|
||||
if el.FolderName == "" {
|
||||
el.FolderName = dashboards.RootFolderName
|
||||
}
|
||||
if el.FolderUID == "" {
|
||||
el.FolderUID = ac.GeneralFolderUID
|
||||
}
|
||||
}
|
||||
|
||||
return elements[0], nil
|
||||
}
|
||||
|
||||
@@ -678,12 +684,12 @@ func (l *LibraryElementService) getConnections(c context.Context, signedInUser i
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var libraryElementConnections []model.LibraryElementConnectionWithMeta
|
||||
builder := db.NewSqlBuilder(l.Cfg, l.features, l.SQLStore.GetDialect(), recursiveQueriesAreSupported)
|
||||
builder.Write("SELECT lec.*, u1.login AS created_by_name, u1.email AS created_by_email, dashboard.uid AS connection_uid")
|
||||
builder.Write("SELECT lec.*, u1.login AS created_by_name, u1.email AS created_by_email")
|
||||
builder.Write(" FROM " + model.LibraryElementConnectionTableName + " AS lec")
|
||||
builder.Write(" LEFT JOIN " + l.SQLStore.GetDialect().Quote("user") + " AS u1 ON lec.created_by = u1.id")
|
||||
builder.Write(" INNER JOIN dashboard AS dashboard on lec.connection_id = dashboard.id")
|
||||
builder.Write(` WHERE lec.element_id=?`, element.ID)
|
||||
if signedInUser.GetOrgRole() != org.RoleAdmin {
|
||||
builder.WriteDashboardPermissionFilter(signedInUser, dashboardaccess.PERMISSION_VIEW, "")
|
||||
@@ -694,12 +700,11 @@ func (l *LibraryElementService) getConnections(c context.Context, signedInUser i
|
||||
|
||||
for _, connection := range libraryElementConnections {
|
||||
connections = append(connections, model.LibraryElementConnectionDTO{
|
||||
ID: connection.ID,
|
||||
Kind: connection.Kind,
|
||||
ElementID: connection.ElementID,
|
||||
ConnectionID: connection.ConnectionID,
|
||||
ConnectionUID: connection.ConnectionUID,
|
||||
Created: connection.Created,
|
||||
ID: connection.ID,
|
||||
Kind: connection.Kind,
|
||||
ElementID: connection.ElementID,
|
||||
ConnectionID: connection.ConnectionID,
|
||||
Created: connection.Created,
|
||||
CreatedBy: librarypanel.LibraryElementDTOMetaUser{
|
||||
Id: connection.CreatedBy,
|
||||
Name: connection.CreatedByName,
|
||||
|
||||
@@ -116,11 +116,13 @@ func TestLibraryElementPermissionsGeneralFolder(t *testing.T) {
|
||||
sc.reqContext.Req.Body = mockRequestBody(cmd)
|
||||
resp := sc.service.createHandler(sc.reqContext)
|
||||
result := validateAndUnMarshalResponse(t, resp)
|
||||
result.Result.FolderUID = "general"
|
||||
result.Result.Meta.CreatedBy.Name = userInDbName
|
||||
result.Result.Meta.CreatedBy.AvatarUrl = userInDbAvatar
|
||||
result.Result.Meta.UpdatedBy.Name = userInDbName
|
||||
result.Result.Meta.UpdatedBy.AvatarUrl = userInDbAvatar
|
||||
result.Result.Meta.FolderName = "General"
|
||||
result.Result.Meta.FolderUID = "general"
|
||||
sc.reqContext.SignedInUser.OrgRole = testCase.role
|
||||
|
||||
resp = sc.service.getAllHandler(sc.reqContext)
|
||||
|
||||
@@ -171,12 +171,11 @@ func TestGetLibraryPanelConnections(t *testing.T) {
|
||||
return model.LibraryElementConnectionsResponse{
|
||||
Result: []model.LibraryElementConnectionDTO{
|
||||
{
|
||||
ID: sc.initialResult.Result.ID,
|
||||
Kind: sc.initialResult.Result.Kind,
|
||||
ElementID: 1,
|
||||
ConnectionID: dashInDB.ID,
|
||||
ConnectionUID: dashInDB.UID,
|
||||
Created: res.Result[0].Created,
|
||||
ID: sc.initialResult.Result.ID,
|
||||
Kind: sc.initialResult.Result.Kind,
|
||||
ElementID: 1,
|
||||
ConnectionID: dashInDB.ID,
|
||||
Created: res.Result[0].Created,
|
||||
CreatedBy: librarypanel.LibraryElementDTOMetaUser{
|
||||
Id: 1,
|
||||
Name: userInDbName,
|
||||
|
||||
@@ -489,7 +489,8 @@ func toRuleGroup(log log.Logger, manager state.AlertInstanceManager, sr StatusRe
|
||||
newGroup := &apimodels.RuleGroup{
|
||||
Name: groupKey.RuleGroup,
|
||||
// file is what Prometheus uses for provisioning, we replace it with namespace which is the folder in Grafana.
|
||||
File: folderFullPath,
|
||||
File: folderFullPath,
|
||||
FolderUID: groupKey.NamespaceUID,
|
||||
}
|
||||
|
||||
rulesTotals := make(map[string]int64, len(rules))
|
||||
@@ -514,7 +515,9 @@ func toRuleGroup(log log.Logger, manager state.AlertInstanceManager, sr StatusRe
|
||||
}
|
||||
|
||||
newRule := apimodels.Rule{
|
||||
UID: rule.UID,
|
||||
Name: rule.Title,
|
||||
FolderUID: rule.NamespaceUID,
|
||||
Labels: apimodels.LabelsFromMap(rule.GetLabels(labelOptions...)),
|
||||
Health: status.Health,
|
||||
LastError: errorOrEmpty(status.LastError),
|
||||
|
||||
@@ -314,9 +314,12 @@ func TestRouteGetRuleStatuses(t *testing.T) {
|
||||
"groups": [{
|
||||
"name": "rule-group",
|
||||
"file": "%s",
|
||||
"folderUid": "namespaceUID",
|
||||
"rules": [{
|
||||
"state": "inactive",
|
||||
"name": "AlwaysFiring",
|
||||
"folderUid": "namespaceUID",
|
||||
"uid": "RuleUID",
|
||||
"query": "vector(1)",
|
||||
"alerts": [{
|
||||
"labels": {
|
||||
@@ -377,10 +380,13 @@ func TestRouteGetRuleStatuses(t *testing.T) {
|
||||
"groups": [{
|
||||
"name": "rule-group",
|
||||
"file": "%s",
|
||||
"folderUid": "namespaceUID",
|
||||
"rules": [{
|
||||
"state": "inactive",
|
||||
"name": "AlwaysFiring",
|
||||
"query": "vector(1)",
|
||||
"folderUid": "namespaceUID",
|
||||
"uid": "RuleUID",
|
||||
"alerts": [{
|
||||
"labels": {
|
||||
"job": "prometheus",
|
||||
@@ -439,10 +445,13 @@ func TestRouteGetRuleStatuses(t *testing.T) {
|
||||
"groups": [{
|
||||
"name": "rule-group",
|
||||
"file": "%s",
|
||||
"folderUid": "namespaceUID",
|
||||
"rules": [{
|
||||
"state": "inactive",
|
||||
"name": "AlwaysFiring",
|
||||
"query": "vector(1) | vector(1)",
|
||||
"folderUid": "namespaceUID",
|
||||
"uid": "RuleUID",
|
||||
"alerts": [{
|
||||
"labels": {
|
||||
"job": "prometheus"
|
||||
|
||||
@@ -171,7 +171,7 @@ func (srv *ProvisioningSrv) RoutePostContactPoint(c *contextmodel.ReqContext, cp
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusInternalServerError, err, "")
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, "", err)
|
||||
}
|
||||
return response.JSON(http.StatusAccepted, contactPoint)
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ type RuleGroup struct {
|
||||
Name string `json:"name"`
|
||||
// required: true
|
||||
File string `json:"file"`
|
||||
// required: true
|
||||
FolderUID string `json:"folderUid"`
|
||||
// In order to preserve rule ordering, while exposing type (alerting or recording)
|
||||
// specific properties, both alerting and recording rules are exposed in the
|
||||
// same array.
|
||||
@@ -165,9 +167,13 @@ type AlertingRule struct {
|
||||
// adapted from cortex
|
||||
// swagger:model
|
||||
type Rule struct {
|
||||
// required: true
|
||||
UID string `json:"uid"`
|
||||
// required: true
|
||||
Name string `json:"name"`
|
||||
// required: true
|
||||
FolderUID string `json:"folderUid"`
|
||||
// required: true
|
||||
Query string `json:"query"`
|
||||
Labels promlabels.Labels `json:"labels,omitempty"`
|
||||
// required: true
|
||||
|
||||
@@ -198,10 +198,7 @@ func (ecp *ContactPointService) CreateContactPoint(
|
||||
// check if uid is already used in receiver
|
||||
for _, rec := range receiver.PostableGrafanaReceivers.GrafanaManagedReceivers {
|
||||
if grafanaReceiver.UID == rec.UID {
|
||||
return apimodels.EmbeddedContactPoint{}, fmt.Errorf(
|
||||
"receiver configuration with UID '%s' already exist in contact point '%s'. Please use unique identifiers for receivers across all contact points",
|
||||
rec.UID,
|
||||
rec.Name)
|
||||
return apimodels.EmbeddedContactPoint{}, MakeErrContactPointUidExists(rec.UID, rec.Name)
|
||||
}
|
||||
}
|
||||
if receiver.Name == contactPoint.Name {
|
||||
|
||||
@@ -28,6 +28,10 @@ var (
|
||||
|
||||
ErrContactPointReferenced = errutil.Conflict("alerting.notifications.contact-points.referenced", errutil.WithPublicMessage("Contact point is currently referenced by a notification policy."))
|
||||
ErrContactPointUsedInRule = errutil.Conflict("alerting.notifications.contact-points.used-by-rule", errutil.WithPublicMessage("Contact point is currently used in the notification settings of one or many alert rules."))
|
||||
contactPointUidExists = "Receiver configuration with UID '{{ .Public.UID }}' already exists in contact point '{{ .Public.Name }}'. Please use unique identifiers for receivers across all contact points."
|
||||
ErrContactPointUidExists = errutil.Conflict("alerting.notifications.contact-points.uidInUse").MustTemplate(
|
||||
contactPointUidExists, errutil.WithPublic(contactPointUidExists),
|
||||
)
|
||||
|
||||
ErrRouteInvalidFormat = errutil.BadRequest("alerting.notifications.routes.invalidFormat").MustTemplate(
|
||||
"Invalid format of the submitted route.",
|
||||
@@ -104,3 +108,12 @@ func MakeErrRouteInvalidFormat(err error) error {
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
|
||||
func MakeErrContactPointUidExists(uid, name string) error {
|
||||
return ErrContactPointUidExists.Build(errutil.TemplateData{
|
||||
Public: map[string]any{
|
||||
"UID": uid,
|
||||
"Name": name,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -196,8 +196,7 @@ func (a *alertRule) Status() ngmodels.RuleStatus {
|
||||
func (a *alertRule) Eval(eval *Evaluation) (bool, *Evaluation) {
|
||||
if a.key.AlertRuleKey != eval.rule.GetKey() {
|
||||
// Make sure that rule has the same key. This should not happen
|
||||
a.logger.Error("Invalid rule sent for evaluating. Skipping", "ruleKeyToEvaluate", eval.rule.GetKey().String())
|
||||
return false, eval
|
||||
panic(fmt.Sprintf("Invalid rule sent for evaluating. Expected rule key %s, got %s", a.key.AlertRuleKey, eval.rule.GetKey()))
|
||||
}
|
||||
// read the channel in unblocking manner to make sure that there is no concurrent send operation.
|
||||
var droppedMsg *Evaluation
|
||||
|
||||
@@ -220,6 +220,9 @@ func TestAlertRule(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
rule := gen.GenerateRef()
|
||||
rule.UID = r.key.UID
|
||||
rule.OrgID = r.key.OrgID
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
@@ -234,7 +237,7 @@ func TestAlertRule(t *testing.T) {
|
||||
case 2:
|
||||
r.Eval(&Evaluation{
|
||||
scheduledAt: time.Now(),
|
||||
rule: gen.GenerateRef(),
|
||||
rule: rule,
|
||||
folderTitle: util.GenerateShortUID(),
|
||||
})
|
||||
case 3:
|
||||
|
||||
@@ -114,7 +114,9 @@ func NewExternalAlertmanagerSender(l log.Logger, reg prometheus.Registerer, opts
|
||||
s.logger.Error("failed to register service discovery metrics", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
s.sdManager = discovery.NewManager(sdCtx, s.logger, prometheus.NewRegistry(), sdMetrics)
|
||||
// Convert s.Logger to slog.Logger using the adapter
|
||||
slogLogger := toSlogLogger(s.logger)
|
||||
s.sdManager = discovery.NewManager(sdCtx, slogLogger, prometheus.NewRegistry(), sdMetrics)
|
||||
|
||||
if s.sdManager == nil {
|
||||
return nil, errors.New("failed to create new discovery manager")
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
)
|
||||
|
||||
// InfraLoggerHandler adapts infra's log.Logger to an slog.Handler
|
||||
type InfraLoggerHandler struct {
|
||||
infraLogger log.Logger
|
||||
}
|
||||
|
||||
type SlogAdapter struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// Log implements the Log method to satisfy the log.Logger interface.
|
||||
func (s *SlogAdapter) Log(keyvals ...interface{}) error {
|
||||
msg := ""
|
||||
args := []interface{}{}
|
||||
|
||||
// Extract message and attributes (customize as needed).
|
||||
if len(keyvals) > 0 {
|
||||
msg, _ = keyvals[0].(string)
|
||||
if len(keyvals) > 1 {
|
||||
args = keyvals[1:]
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate to slog.Logger
|
||||
s.logger.Info(msg, args...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enabled determines if the log level is enabled
|
||||
func (h *InfraLoggerHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
// Assume all levels are enabled; customize this based on infra's log.Logger
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle processes a log record and routes it to infra's log.Logger
|
||||
func (h *InfraLoggerHandler) Handle(_ context.Context, record slog.Record) error {
|
||||
// Build the log message
|
||||
msg := record.Message
|
||||
args := []interface{}{}
|
||||
record.Attrs(func(a slog.Attr) bool {
|
||||
args = append(args, a.Key, a.Value.Any())
|
||||
return true
|
||||
})
|
||||
|
||||
// Route the log message to Infra's logger
|
||||
switch record.Level {
|
||||
case slog.LevelDebug:
|
||||
h.infraLogger.Debug(msg, args...)
|
||||
case slog.LevelInfo:
|
||||
h.infraLogger.Info(msg, args...)
|
||||
case slog.LevelWarn:
|
||||
h.infraLogger.Warn(msg, args...)
|
||||
case slog.LevelError:
|
||||
h.infraLogger.Error(msg, args...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithAttrs adds attributes to the logger context
|
||||
func (h *InfraLoggerHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return h // Context attributes are ignored; implement if needed
|
||||
}
|
||||
|
||||
// WithGroup groups log records; not used here
|
||||
func (h *InfraLoggerHandler) WithGroup(name string) slog.Handler {
|
||||
return h // Grouping is ignored; implement if needed
|
||||
}
|
||||
|
||||
// toSlogLogger is a function to create *slog.Logger from Infra's log.Logger
|
||||
func toSlogLogger(infraLogger log.Logger) *slog.Logger {
|
||||
handler := &InfraLoggerHandler{infraLogger: infraLogger}
|
||||
return slog.New(handler)
|
||||
}
|
||||
@@ -221,6 +221,8 @@ func (ss *sqlStore) Delete(ctx context.Context, cmd *org.DeleteOrgCommand) error
|
||||
|
||||
deletes := []string{
|
||||
"DELETE FROM star WHERE org_id = ?",
|
||||
"DELETE FROM playlist_item WHERE playlist_id IN (SELECT id FROM playlist WHERE org_id = ?)",
|
||||
"DELETE FROM playlist WHERE org_id = ?",
|
||||
"DELETE FROM dashboard_tag WHERE org_id = ?",
|
||||
"DELETE FROM api_key WHERE org_id = ?",
|
||||
"DELETE FROM data_source WHERE org_id = ?",
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/playlist"
|
||||
"github.com/grafana/grafana/pkg/services/playlist/playlistimpl"
|
||||
"github.com/grafana/grafana/pkg/services/quota/quotaimpl"
|
||||
"github.com/grafana/grafana/pkg/services/searchusers/sortopts"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
@@ -115,11 +117,32 @@ func TestIntegrationOrgDataAccess(t *testing.T) {
|
||||
t.Run("Removing org", func(t *testing.T) {
|
||||
// make sure ac2 has no org
|
||||
ac2 := &org.Org{ID: 22, Name: "ac2", Version: 1, Created: time.Now(), Updated: time.Now()}
|
||||
_, err := orgStore.Insert(context.Background(), ac2)
|
||||
orgId, err := orgStore.Insert(context.Background(), ac2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create some org-scoped items like playlists, so we can assert that they
|
||||
// are cleaned up on delete.
|
||||
plItems := []playlist.PlaylistItem{
|
||||
{
|
||||
Type: "foo",
|
||||
},
|
||||
}
|
||||
plStore := playlistimpl.ProvideService(ss, tracing.InitializeTracerForTest())
|
||||
plCreateCommand := playlist.CreatePlaylistCommand{
|
||||
OrgId: orgId,
|
||||
Name: "test",
|
||||
Items: plItems,
|
||||
}
|
||||
pl, err := plStore.Create(context.Background(), &plCreateCommand)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = orgStore.Delete(context.Background(), &org.DeleteOrgCommand{ID: ac2.ID})
|
||||
require.NoError(t, err)
|
||||
|
||||
plDTO, err := plStore.Get(context.Background(), &playlist.GetPlaylistByUidQuery{OrgId: pl.OrgId, UID: pl.UID})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, plDTO)
|
||||
|
||||
// TODO: this part of the test will be added when we move RemoveOrgUser to org store
|
||||
// "Removing user from org should delete user completely if in no other org"
|
||||
// // remove ac2 user from ac1 org
|
||||
|
||||
@@ -115,6 +115,7 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/dlmiddlecote/sqlstats v1.0.2 // indirect
|
||||
github.com/docker/docker v27.4.1+incompatible // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elazarl/goproxy v1.3.0 // indirect
|
||||
|
||||
@@ -300,8 +300,8 @@ github.com/dlmiddlecote/sqlstats v1.0.2/go.mod h1:0CWaIh/Th+z2aI6Q9Jpfg/o21zmGxW
|
||||
github.com/docker/distribution v2.7.0+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker v0.7.3-0.20190103212154-2b7e084dc98b/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v0.7.3-0.20190817195342-4760db040282/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4=
|
||||
github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
|
||||
@@ -12,12 +12,12 @@ import (
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
authnlib "github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/authlib/authz"
|
||||
|
||||
infraDB "github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/options"
|
||||
"github.com/grafana/grafana/pkg/services/authn/grpcutils"
|
||||
"github.com/grafana/grafana/pkg/services/authz"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
@@ -35,7 +35,7 @@ func ProvideUnifiedStorageClient(
|
||||
db infraDB.DB,
|
||||
tracer tracing.Tracer,
|
||||
reg prometheus.Registerer,
|
||||
authzc authz.Client,
|
||||
authzc authz.AccessClient,
|
||||
docs resource.DocumentBuilderSupplier,
|
||||
) (resource.ResourceClient, error) {
|
||||
// See: apiserver.ApplyGrafanaConfig(cfg, features, o)
|
||||
@@ -62,7 +62,7 @@ func newClient(opts options.StorageOptions,
|
||||
db infraDB.DB,
|
||||
tracer tracing.Tracer,
|
||||
reg prometheus.Registerer,
|
||||
authzc authz.Client,
|
||||
authzc authz.AccessClient,
|
||||
docs resource.DocumentBuilderSupplier,
|
||||
) (resource.ResourceClient, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -72,7 +72,7 @@ func NewIndexMetrics(indexDir string, searchBackend SearchBackend) *BleveIndexMe
|
||||
Namespace: "index_server",
|
||||
Name: "index_tenants",
|
||||
Help: "Number of tenants in the index",
|
||||
}, []string{"namespace", "index_storage"}), // index_storage is either "file" or "memory"
|
||||
}, []string{"index_storage"}), // index_storage is either "file" or "memory"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ func NewGRPCResourceClient(tracer tracing.Tracer, conn *grpc.ClientConn) (Resour
|
||||
return &resourceClient{
|
||||
ResourceStoreClient: NewResourceStoreClient(cc),
|
||||
ResourceIndexClient: NewResourceIndexClient(cc),
|
||||
BlobStoreClient: NewBlobStoreClient(cc),
|
||||
DiagnosticsClient: NewDiagnosticsClient(cc),
|
||||
}, nil
|
||||
}
|
||||
@@ -125,6 +126,7 @@ func NewCloudResourceClient(tracer tracing.Tracer, conn *grpc.ClientConn, cfg au
|
||||
return &resourceClient{
|
||||
ResourceStoreClient: NewResourceStoreClient(cc),
|
||||
ResourceIndexClient: NewResourceIndexClient(cc),
|
||||
BlobStoreClient: NewBlobStoreClient(cc),
|
||||
DiagnosticsClient: NewDiagnosticsClient(cc),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -237,6 +237,8 @@ func (x *searchableDocumentFields) Fields() []string {
|
||||
}
|
||||
|
||||
func (x *searchableDocumentFields) Field(name string) *ResourceTableColumnDefinition {
|
||||
name = strings.TrimPrefix(name, "fields.")
|
||||
|
||||
f, ok := x.fields[name]
|
||||
if ok {
|
||||
return f.def
|
||||
|
||||
@@ -519,8 +519,9 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 h1:SwcnSwBR7X/5EHJQlXBockkJVIMRVt5yKaesBPMtyZQ=
|
||||
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6/go.mod h1:WrYiIuiXUMIvTDAQw97C+9l0CnBmCcvosPjN3XDqS/o=
|
||||
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
|
||||
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
|
||||
|
||||
@@ -372,7 +372,7 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *Resour
|
||||
}
|
||||
|
||||
check := authz.CheckRequest{
|
||||
Verb: "create",
|
||||
Verb: utils.VerbCreate,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
Namespace: key.Namespace,
|
||||
@@ -386,7 +386,7 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *Resour
|
||||
if oldValue == nil {
|
||||
event.Type = WatchEvent_ADDED
|
||||
} else {
|
||||
check.Verb = "update"
|
||||
check.Verb = utils.VerbUpdate
|
||||
|
||||
temp := &unstructured.Unstructured{}
|
||||
err = temp.UnmarshalJSON(oldValue)
|
||||
@@ -437,8 +437,12 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *Resour
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// We only set name for update checks
|
||||
if check.Verb == utils.VerbUpdate {
|
||||
check.Name = key.Name
|
||||
}
|
||||
|
||||
check.Folder = obj.GetFolder()
|
||||
check.Name = key.Name
|
||||
a, err := s.access.Check(ctx, user, check)
|
||||
if err != nil {
|
||||
return nil, AsErrorResult(err)
|
||||
|
||||
@@ -182,6 +182,19 @@ type resourceTableColumn struct {
|
||||
OpenAPIFormat string
|
||||
}
|
||||
|
||||
// helper to decode a cell value
|
||||
func DecodeCell(columnDef *ResourceTableColumnDefinition, index int, cellVal []byte) (any, error) {
|
||||
col, err := newResourceTableColumn(columnDef, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := col.Decode(cellVal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// nolint:gocyclo
|
||||
func newResourceTableColumn(def *ResourceTableColumnDefinition, index int) (*resourceTableColumn, error) {
|
||||
col := &resourceTableColumn{def: def, index: index}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -312,3 +314,15 @@ func TestColumnEncoding(t *testing.T) {
|
||||
require.Empty(t, missingArrays, "missing array tests for types")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecodeCell(t *testing.T) {
|
||||
colDef := &ResourceTableColumnDefinition{Type: ResourceTableColumnDefinition_INT64}
|
||||
var buf bytes.Buffer
|
||||
err := binary.Write(&buf, binary.BigEndian, int64(123))
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := DecodeCell(colDef, 0, buf.Bytes())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(123), res)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
|
||||
"github.com/grafana/authlib/authz"
|
||||
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
)
|
||||
|
||||
@@ -138,10 +140,10 @@ func (b *bleveBackend) BuildIndex(ctx context.Context,
|
||||
index, err = bleve.New(dir, mapper)
|
||||
}
|
||||
|
||||
resource.IndexMetrics.IndexTenants.WithLabelValues(key.Namespace, "file").Inc()
|
||||
resource.IndexMetrics.IndexTenants.WithLabelValues("file").Inc()
|
||||
} else {
|
||||
index, err = bleve.NewMemOnly(mapper)
|
||||
resource.IndexMetrics.IndexTenants.WithLabelValues(key.Namespace, "memory").Inc()
|
||||
resource.IndexMetrics.IndexTenants.WithLabelValues("memory").Inc()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -488,8 +490,18 @@ func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.Acce
|
||||
for _, f := range req.Facet {
|
||||
facets[f.Field] = bleve.NewFacetRequest(f.Field, int(f.Limit))
|
||||
}
|
||||
|
||||
// Convert resource-specific fields to bleve fields (just considers dashboard fields for now)
|
||||
fields := make([]string, 0, len(req.Fields))
|
||||
for _, f := range req.Fields {
|
||||
if slices.Contains(DashboardFields(), f) {
|
||||
f = "fields." + f
|
||||
}
|
||||
fields = append(fields, f)
|
||||
}
|
||||
|
||||
searchrequest := &bleve.SearchRequest{
|
||||
Fields: req.Fields,
|
||||
Fields: fields,
|
||||
Size: int(req.Limit),
|
||||
From: int(req.Offset),
|
||||
Explain: req.Explain,
|
||||
@@ -719,7 +731,14 @@ func (b *bleveIndex) hitsToTable(selectFields []string, hits search.DocumentMatc
|
||||
row.Cells[i], err = json.Marshal(match.Expl)
|
||||
}
|
||||
default:
|
||||
v := match.Fields[f.Name]
|
||||
fieldName := f.Name
|
||||
// since the bleve index fields mix common and resource-specific fields, it is possible a conflict can happen
|
||||
// if a specific field is named the same as a common field
|
||||
v := match.Fields[fieldName]
|
||||
// fields that are specific to the resource get stored as fields.<fieldName>, so we need to check for that
|
||||
if v == nil {
|
||||
v = match.Fields["fields."+fieldName]
|
||||
}
|
||||
if v != nil {
|
||||
// Encode the value to protobuf
|
||||
row.Cells[i], err = encoders[i](v)
|
||||
|
||||
@@ -103,5 +103,8 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
|
||||
labelMapper := bleve.NewDocumentMapping()
|
||||
mapper.AddSubDocumentMapping(resource.SEARCH_FIELD_LABELS, labelMapper)
|
||||
|
||||
fieldMapper := bleve.NewDocumentMapping()
|
||||
mapper.AddSubDocumentMapping("fields", fieldMapper)
|
||||
|
||||
return mapper
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -76,9 +77,9 @@ func TestBleveBackend(t *testing.T) {
|
||||
TitleSort: "aaa (dash)",
|
||||
Folder: "xxx",
|
||||
Fields: map[string]any{
|
||||
DASHBOARD_LEGACY_ID: 12,
|
||||
DASHBOARD_PANEL_TYPES: []string{"timeseries", "table"},
|
||||
DASHBOARD_ERRORS_TODAY: 25,
|
||||
DASHBOARD_PANEL_TYPES: []string{"timeseries", "table"},
|
||||
DASHBOARD_ERRORS_TODAY: 25,
|
||||
DASHBOARD_VIEWS_LAST_1_DAYS: 50,
|
||||
},
|
||||
Labels: map[string]string{
|
||||
utils.LabelKeyDeprecatedInternalID: "10", // nolint:staticcheck
|
||||
@@ -104,9 +105,9 @@ func TestBleveBackend(t *testing.T) {
|
||||
TitleSort: "bbb (dash)",
|
||||
Folder: "xxx",
|
||||
Fields: map[string]any{
|
||||
DASHBOARD_LEGACY_ID: 12,
|
||||
DASHBOARD_PANEL_TYPES: []string{"timeseries"},
|
||||
DASHBOARD_ERRORS_TODAY: 40,
|
||||
DASHBOARD_PANEL_TYPES: []string{"timeseries"},
|
||||
DASHBOARD_ERRORS_TODAY: 40,
|
||||
DASHBOARD_VIEWS_LAST_1_DAYS: 100,
|
||||
},
|
||||
Tags: []string{"aa"},
|
||||
Labels: map[string]string{
|
||||
@@ -136,10 +137,8 @@ func TestBleveBackend(t *testing.T) {
|
||||
Name: "repo2",
|
||||
Path: "path/in/repo2.yaml",
|
||||
},
|
||||
Fields: map[string]any{
|
||||
DASHBOARD_LEGACY_ID: 12,
|
||||
},
|
||||
Tags: []string{"aa"},
|
||||
Fields: map[string]any{},
|
||||
Tags: []string{"aa"},
|
||||
Labels: map[string]string{
|
||||
"region": "west",
|
||||
},
|
||||
@@ -217,6 +216,27 @@ func TestBleveBackend(t *testing.T) {
|
||||
rsp.Results.Rows[1].Key.Name,
|
||||
})
|
||||
|
||||
// can get sprinkles fields
|
||||
rsp, err = index.Search(ctx, nil, &resource.ResourceSearchRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Key: key,
|
||||
},
|
||||
Limit: 100000,
|
||||
Fields: []string{DASHBOARD_ERRORS_TODAY, DASHBOARD_VIEWS_LAST_1_DAYS, "fieldThatDoesntExist"},
|
||||
SortBy: []*resource.ResourceSearchRequest_Sort{
|
||||
{Field: "fields." + DASHBOARD_VIEWS_LAST_1_DAYS, Desc: true},
|
||||
},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, len(rsp.Results.Columns))
|
||||
require.Equal(t, DASHBOARD_ERRORS_TODAY, rsp.Results.Columns[0].Name)
|
||||
require.Equal(t, DASHBOARD_VIEWS_LAST_1_DAYS, rsp.Results.Columns[1].Name)
|
||||
|
||||
// sorted descending so should start with highest dashboard_views_last_1_days (100)
|
||||
val, err := resource.DecodeCell(rsp.Results.Columns[1], 0, rsp.Results.Rows[0].Cells[1])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(100), val)
|
||||
|
||||
// Now look for repositories
|
||||
found, err := index.ListRepositoryObjects(ctx, &resource.ListRepositoryObjectsRequest{
|
||||
Name: "repo-1",
|
||||
@@ -408,6 +428,29 @@ func TestBleveBackend(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestToBleveSearchRequest(t *testing.T) {
|
||||
t.Run("will prepend 'fields.' to all dashboard fields", func(t *testing.T) {
|
||||
fields := []string{"title", "name", "folder"}
|
||||
fields = append(fields, DashboardFields()...)
|
||||
resReq := &resource.ResourceSearchRequest{
|
||||
Options: &resource.ListOptions{},
|
||||
Fields: fields,
|
||||
}
|
||||
bleveReq, err := toBleveSearchRequest(resReq, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("error creating bleve search request: %v", err)
|
||||
}
|
||||
|
||||
require.Equal(t, len(fields), len(bleveReq.Fields))
|
||||
for _, field := range DashboardFields() {
|
||||
require.True(t, slices.Contains(bleveReq.Fields, "fields."+field))
|
||||
}
|
||||
require.Contains(t, bleveReq.Fields, "title")
|
||||
require.Contains(t, bleveReq.Fields, "name")
|
||||
require.Contains(t, bleveReq.Fields, "folder")
|
||||
})
|
||||
}
|
||||
|
||||
func asTimePointer(milli int64) *time.Time {
|
||||
if milli > 0 {
|
||||
t := time.UnixMilli(milli)
|
||||
|
||||
@@ -69,6 +69,126 @@ func DashboardBuilder(namespaced resource.NamespacedDocumentSupplier) (resource.
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_ERRORS_TODAY,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Number of errors that occurred today",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_ERRORS_LAST_1_DAYS,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Number of errors that occurred in the last 1 days",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_ERRORS_LAST_7_DAYS,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Number of errors that occurred in the last 7 days",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_ERRORS_LAST_30_DAYS,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Number of errors that occurred in the last 30 days",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_ERRORS_TOTAL,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Total number of errors",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_QUERIES_TODAY,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Number of queries that occurred today",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_QUERIES_LAST_1_DAYS,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Number of queries that occurred in the last 1 days",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_QUERIES_LAST_7_DAYS,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Number of queries that occurred in the last 7 days",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_QUERIES_LAST_30_DAYS,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Number of queries that occurred in the last 30 days",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_QUERIES_TOTAL,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Total number of queries",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_VIEWS_TODAY,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Number of views that occurred today",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_VIEWS_LAST_1_DAYS,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Number of views that occurred in the last 1 days",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_VIEWS_LAST_7_DAYS,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Number of views that occurred in the last 7 days",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_VIEWS_LAST_30_DAYS,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Number of views that occurred in the last 30 days",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_VIEWS_TOTAL,
|
||||
Type: resource.ResourceTableColumnDefinition_INT64,
|
||||
Description: "Total number of views",
|
||||
Properties: &resource.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
if namespaced == nil {
|
||||
namespaced = func(ctx context.Context, namespace string, blob resource.BlobSupport) (resource.DocumentBuilder, error) {
|
||||
@@ -215,3 +335,36 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func DashboardFields() []string {
|
||||
baseFields := []string{
|
||||
DASHBOARD_LEGACY_ID,
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
DASHBOARD_LINK_COUNT,
|
||||
DASHBOARD_PANEL_TYPES,
|
||||
DASHBOARD_DS_TYPES,
|
||||
DASHBOARD_TRANSFORMATIONS,
|
||||
}
|
||||
|
||||
return append(baseFields, UsageInsightsFields()...)
|
||||
}
|
||||
|
||||
func UsageInsightsFields() []string {
|
||||
return []string{
|
||||
DASHBOARD_VIEWS_LAST_1_DAYS,
|
||||
DASHBOARD_VIEWS_LAST_7_DAYS,
|
||||
DASHBOARD_VIEWS_LAST_30_DAYS,
|
||||
DASHBOARD_VIEWS_TODAY,
|
||||
DASHBOARD_VIEWS_TOTAL,
|
||||
DASHBOARD_QUERIES_LAST_1_DAYS,
|
||||
DASHBOARD_QUERIES_LAST_7_DAYS,
|
||||
DASHBOARD_QUERIES_LAST_30_DAYS,
|
||||
DASHBOARD_QUERIES_TODAY,
|
||||
DASHBOARD_QUERIES_TOTAL,
|
||||
DASHBOARD_ERRORS_LAST_1_DAYS,
|
||||
DASHBOARD_ERRORS_LAST_7_DAYS,
|
||||
DASHBOARD_ERRORS_LAST_30_DAYS,
|
||||
DASHBOARD_ERRORS_TODAY,
|
||||
DASHBOARD_ERRORS_TOTAL,
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,111 @@
|
||||
"format": "",
|
||||
"description": "How many links appear on the page",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "errors_today",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Number of errors that occurred today",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "errors_last_1_days",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Number of errors that occurred in the last 1 days",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "errors_last_7_days",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Number of errors that occurred in the last 7 days",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "errors_last_30_days",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Number of errors that occurred in the last 30 days",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "errors_total",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Total number of errors",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "queries_today",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Number of queries that occurred today",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "queries_last_1_days",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Number of queries that occurred in the last 1 days",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "queries_last_7_days",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Number of queries that occurred in the last 7 days",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "queries_last_30_days",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Number of queries that occurred in the last 30 days",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "queries_total",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Total number of queries",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "views_today",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Number of views that occurred today",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "views_last_1_days",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Number of views that occurred in the last 1 days",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "views_last_7_days",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Number of views that occurred in the last 7 days",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "views_last_30_days",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Number of views that occurred in the last 30 days",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "views_total",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "Total number of views",
|
||||
"priority": 0
|
||||
}
|
||||
],
|
||||
"rows": [
|
||||
@@ -78,6 +183,21 @@
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"object": {
|
||||
@@ -102,6 +222,23 @@
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[
|
||||
"timeseries"
|
||||
],
|
||||
40,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
100,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"object": {
|
||||
@@ -127,6 +264,24 @@
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[
|
||||
"timeseries",
|
||||
"table"
|
||||
],
|
||||
25,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
50,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"object": {
|
||||
|
||||
@@ -7,13 +7,13 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/authlib/authz"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"github.com/grafana/grafana/pkg/storage/unified/search"
|
||||
|
||||
infraDB "github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/authz"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
// Creates a new ResourceServer
|
||||
func NewResourceServer(ctx context.Context, db infraDB.DB, cfg *setting.Cfg,
|
||||
features featuremgmt.FeatureToggles, docs resource.DocumentBuilderSupplier,
|
||||
tracer tracing.Tracer, reg prometheus.Registerer, ac authz.Client) (resource.ResourceServer, error) {
|
||||
tracer tracing.Tracer, reg prometheus.Registerer, ac authz.AccessClient) (resource.ResourceServer, error) {
|
||||
apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver")
|
||||
opts := resource.ResourceServerOptions{
|
||||
Tracer: tracer,
|
||||
|
||||
@@ -27,6 +27,9 @@ import (
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
)
|
||||
|
||||
// Declare respModel at the function level
|
||||
var respModel apimodels.UpdateRuleGroupResponse
|
||||
|
||||
func TestIntegrationPrometheusRules(t *testing.T) {
|
||||
testinfra.SQLiteIntegrationTest(t)
|
||||
|
||||
@@ -157,7 +160,6 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||
var respModel apimodels.UpdateRuleGroupResponse
|
||||
require.NoError(t, json.Unmarshal(b, &respModel))
|
||||
require.Len(t, respModel.Created, len(rules.Rules))
|
||||
}
|
||||
@@ -235,18 +237,21 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
require.JSONEq(t, `
|
||||
require.JSONEq(t, fmt.Sprintf(`
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"groups": [{
|
||||
"name": "arulegroup",
|
||||
"file": "default",
|
||||
"folderUid": "default",
|
||||
"rules": [{
|
||||
"state": "inactive",
|
||||
"name": "AlwaysFiring",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
||||
"duration": 10,
|
||||
"folderUid": "default",
|
||||
"uid": "%s",
|
||||
"annotations": {
|
||||
"annotation1": "val1"
|
||||
},
|
||||
@@ -261,6 +266,8 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
||||
"state": "inactive",
|
||||
"name": "AlwaysFiringButSilenced",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
||||
"folderUid": "default",
|
||||
"uid": "%s",
|
||||
"health": "ok",
|
||||
"type": "alerting",
|
||||
"lastEvaluation": "0001-01-01T00:00:00Z",
|
||||
@@ -277,7 +284,7 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
||||
"inactive": 2
|
||||
}
|
||||
}
|
||||
}`, string(b))
|
||||
}`, respModel.Created[0], respModel.Created[1]), string(b))
|
||||
}
|
||||
|
||||
{
|
||||
@@ -293,18 +300,21 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
require.JSONEq(t, `
|
||||
require.JSONEq(t, fmt.Sprintf(`
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"groups": [{
|
||||
"name": "arulegroup",
|
||||
"file": "default",
|
||||
"folderUid": "default",
|
||||
"rules": [{
|
||||
"state": "inactive",
|
||||
"name": "AlwaysFiring",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
||||
"duration": 10,
|
||||
"folderUid": "default",
|
||||
"uid": "%s",
|
||||
"annotations": {
|
||||
"annotation1": "val1"
|
||||
},
|
||||
@@ -319,6 +329,8 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
||||
"state": "inactive",
|
||||
"name": "AlwaysFiringButSilenced",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
||||
"folderUid": "default",
|
||||
"uid": "%s",
|
||||
"health": "ok",
|
||||
"type": "alerting",
|
||||
"lastEvaluation": "0001-01-01T00:00:00Z",
|
||||
@@ -335,7 +347,7 @@ func TestIntegrationPrometheusRules(t *testing.T) {
|
||||
"inactive": 2
|
||||
}
|
||||
}
|
||||
}`, string(b))
|
||||
}`, respModel.Created[0], respModel.Created[1]), string(b))
|
||||
return true
|
||||
}, 18*time.Second, 2*time.Second)
|
||||
}
|
||||
@@ -441,7 +453,6 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||
var respModel apimodels.UpdateRuleGroupResponse
|
||||
require.NoError(t, json.Unmarshal(b, &respModel))
|
||||
require.Len(t, respModel.Created, len(rules.Rules))
|
||||
}
|
||||
@@ -453,9 +464,12 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
|
||||
"groups": [{
|
||||
"name": "anotherrulegroup",
|
||||
"file": "default",
|
||||
"folderUid": "default",
|
||||
"rules": [{
|
||||
"state": "inactive",
|
||||
"name": "AlwaysFiring",
|
||||
"uid": "%s",
|
||||
"folderUid": "default",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
||||
"duration": 10,
|
||||
"annotations": {
|
||||
@@ -469,6 +483,8 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
|
||||
}, {
|
||||
"state": "inactive",
|
||||
"name": "AlwaysFiringButSilenced",
|
||||
"uid": "%s",
|
||||
"folderUid": "default",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
||||
"health": "ok",
|
||||
"type": "alerting",
|
||||
@@ -486,7 +502,7 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
|
||||
"inactive": 2
|
||||
}
|
||||
}
|
||||
}`, dashboardUID)
|
||||
}`, respModel.Created[0], dashboardUID, respModel.Created[1])
|
||||
expectedFilteredByJSON := fmt.Sprintf(`
|
||||
{
|
||||
"status": "success",
|
||||
@@ -494,9 +510,12 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
|
||||
"groups": [{
|
||||
"name": "anotherrulegroup",
|
||||
"file": "default",
|
||||
"folderUid": "default",
|
||||
"rules": [{
|
||||
"state": "inactive",
|
||||
"name": "AlwaysFiring",
|
||||
"uid": "%s",
|
||||
"folderUid": "default",
|
||||
"query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]",
|
||||
"duration": 10,
|
||||
"annotations": {
|
||||
@@ -519,7 +538,7 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
|
||||
"inactive": 1
|
||||
}
|
||||
}
|
||||
}`, dashboardUID)
|
||||
}`, respModel.Created[0], dashboardUID)
|
||||
expectedNoneJSON := `
|
||||
{
|
||||
"status": "success",
|
||||
|
||||
@@ -487,7 +487,14 @@ func (c *K8sTestHelper) LoadYAMLOrJSON(body string) *unstructured.Unstructured {
|
||||
func (c *K8sTestHelper) createTestUsers(orgName string) OrgUsers {
|
||||
c.t.Helper()
|
||||
users := OrgUsers{
|
||||
Admin: c.CreateUser("admin", orgName, org.RoleAdmin, nil),
|
||||
Admin: c.CreateUser("admin", orgName, org.RoleAdmin, []resourcepermissions.SetResourcePermissionCommand{
|
||||
{
|
||||
Actions: []string{"dashboards:read", "dashboards:write", "dashboards:create", "dashboards:delete"},
|
||||
Resource: "dashboards",
|
||||
ResourceAttribute: "uid",
|
||||
ResourceID: "*",
|
||||
},
|
||||
}),
|
||||
Editor: c.CreateUser("editor", orgName, org.RoleEditor, nil),
|
||||
Viewer: c.CreateUser("viewer", orgName, org.RoleViewer, nil),
|
||||
}
|
||||
|
||||
@@ -461,6 +461,13 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
|
||||
_, err = grafanaComSection.NewKey("api_url", opts.GrafanaComAPIURL)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
if opts.GrafanaComSSOAPIToken != "" {
|
||||
grafanaComSection, err := getOrCreateSection("grafana_com")
|
||||
require.NoError(t, err)
|
||||
_, err = grafanaComSection.NewKey("sso_api_token", opts.GrafanaComSSOAPIToken)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
if opts.UnifiedStorageConfig != nil {
|
||||
for k, v := range opts.UnifiedStorageConfig {
|
||||
section, err := getOrCreateSection(fmt.Sprintf("unified_storage.%s", k))
|
||||
@@ -526,6 +533,7 @@ type GrafanaOpts struct {
|
||||
GrafanaComAPIURL string
|
||||
UnifiedStorageConfig map[string]setting.UnifiedStorageConfig
|
||||
PermittedProvisioningPaths string
|
||||
GrafanaComSSOAPIToken string
|
||||
|
||||
// When "unified-grpc" is selected it will also start the grpc server
|
||||
APIServerStorageType options.StorageType
|
||||
|
||||
@@ -4,13 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource/kinds"
|
||||
)
|
||||
|
||||
@@ -78,34 +78,89 @@ func (s *Service) testStreamHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
ctxLogger := s.logger.FromContext(req.Context())
|
||||
ctxLogger.Debug("Received resource call", "url", req.URL.String(), "method", req.Method)
|
||||
|
||||
header := rw.Header()
|
||||
header.Set("Cache-Control", "no-store")
|
||||
header.Set("X-Content-Type-Options", "nosniff")
|
||||
header.Set("Content-Type", "text/plain")
|
||||
|
||||
writeError := func(code int, message string) {
|
||||
rw.WriteHeader(code)
|
||||
_, _ = rw.Write([]byte(message))
|
||||
}
|
||||
|
||||
if req.Method != http.MethodGet {
|
||||
writeError(http.StatusMethodNotAllowed, "only supports get")
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
query := req.URL.Query()
|
||||
count := 10
|
||||
countstr := req.URL.Query().Get("count")
|
||||
if countstr != "" {
|
||||
if i, err := strconv.Atoi(countstr); err == nil {
|
||||
count = i
|
||||
if query.Has("count") {
|
||||
count, err = strconv.Atoi(query.Get("count"))
|
||||
if err != nil {
|
||||
writeError(http.StatusBadRequest, "invalid count value")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sleep := req.URL.Query().Get("sleep")
|
||||
sleepDuration, err := time.ParseDuration(sleep)
|
||||
if err != nil {
|
||||
sleepDuration = time.Millisecond
|
||||
start := 1
|
||||
if query.Has("start") {
|
||||
start, err = strconv.Atoi(query.Get("start"))
|
||||
if err != nil {
|
||||
writeError(http.StatusBadRequest, "invalid start value")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", "text/plain")
|
||||
flush := 100 // flush 100% of the time
|
||||
if query.Has("flush") {
|
||||
flush, err = strconv.Atoi(query.Get("flush"))
|
||||
if err != nil {
|
||||
writeError(http.StatusBadRequest, "invalid flush value")
|
||||
return
|
||||
}
|
||||
if flush > 100 || flush < 0 {
|
||||
writeError(http.StatusBadRequest, "expecting flush between 0-100")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
speed := time.Millisecond * 10
|
||||
if query.Has("speed") {
|
||||
speed, err = time.ParseDuration(query.Get("speed"))
|
||||
if err != nil {
|
||||
writeError(http.StatusBadRequest, "invalid speed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
line := func(i int) string {
|
||||
return fmt.Sprintf("Message #%d", i)
|
||||
}
|
||||
switch query.Get("format") {
|
||||
case "json":
|
||||
line = func(i int) string {
|
||||
return fmt.Sprintf(`{"message": %d, "value": %.3f, "time": %d}`, i, rand.Float64(), time.Now().UnixMilli())
|
||||
}
|
||||
case "influx":
|
||||
line = func(i int) string {
|
||||
val := rand.Float64()
|
||||
return fmt.Sprintf("measurement,tag1=value1,tag2=value2 message=%d,value=%.3f %d", i, val, time.Now().UnixMilli())
|
||||
}
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
|
||||
for i := 1; i <= count; i++ {
|
||||
if _, err := io.WriteString(rw, fmt.Sprintf("Message #%d", i)); err != nil {
|
||||
for i := start; i <= count; i++ {
|
||||
if _, err := io.WriteString(rw, line(i)+"\n"); err != nil {
|
||||
ctxLogger.Error("Failed to write response", "error", err)
|
||||
return
|
||||
}
|
||||
rw.(http.Flusher).Flush()
|
||||
time.Sleep(sleepDuration)
|
||||
// This may send multiple lines in one chunk
|
||||
if flush > rand.Intn(100) {
|
||||
rw.(http.Flusher).Flush()
|
||||
}
|
||||
time.Sleep(speed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,14 @@ export const Trans = (props: TransProps): ReactElement => {
|
||||
return <I18NextTrans shouldUnescape ns={NAMESPACES} {...props} />;
|
||||
};
|
||||
|
||||
// Wrap t() to provide default namespaces and enforce a consistent API
|
||||
/**
|
||||
* This is a simple wrapper over i18n.t() to provide default namespaces and enforce a consistent API.
|
||||
* Note: Don't use this in the top level module scope. This wrapper needs initialization, which is done during Grafana
|
||||
* startup, and it will throw if used before.
|
||||
* @param id ID of the translation string
|
||||
* @param defaultMessage Default message to use if the translation is missing
|
||||
* @param values Values to be interpolated into the string
|
||||
*/
|
||||
export const t = (id: string, defaultMessage: string, values?: Record<string, unknown>) => {
|
||||
if (!tFunc) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
|
||||
@@ -142,6 +142,83 @@ export class BackendSrv implements BackendService {
|
||||
});
|
||||
}
|
||||
|
||||
chunkRequestId = 1;
|
||||
|
||||
chunked(options: BackendSrvRequest): Observable<FetchResponse<Uint8Array | undefined>> {
|
||||
const requestId = options.requestId ?? `chunked-${this.chunkRequestId++}`;
|
||||
const controller = new AbortController();
|
||||
const url = parseUrlFromOptions(options);
|
||||
const init = parseInitFromOptions({
|
||||
...options,
|
||||
requestId,
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
return new Observable((observer) => {
|
||||
let done = false;
|
||||
// Calling fromFetch explicitly avoids the request queue
|
||||
const sub = this.dependencies.fromFetch(url, init).subscribe({
|
||||
next: (response) => {
|
||||
const rsp = {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
ok: response.ok,
|
||||
headers: response.headers,
|
||||
url: response.url,
|
||||
type: response.type,
|
||||
redirected: response.redirected,
|
||||
config: options,
|
||||
traceId: response.headers.get(GRAFANA_TRACEID_HEADER) ?? undefined,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
if (!response.body) {
|
||||
observer.next(rsp);
|
||||
observer.complete();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
async function process() {
|
||||
while (reader && !done) {
|
||||
if (controller.signal.aborted) {
|
||||
reader.cancel(controller.signal.reason);
|
||||
console.log(requestId, 'signal.aborted');
|
||||
return;
|
||||
}
|
||||
const chunk = await reader.read();
|
||||
observer.next({
|
||||
...rsp,
|
||||
data: chunk.value,
|
||||
});
|
||||
if (chunk.done) {
|
||||
done = true;
|
||||
console.log(requestId, 'done');
|
||||
}
|
||||
}
|
||||
}
|
||||
process()
|
||||
.catch((e) => {
|
||||
console.log(requestId, 'catch', e);
|
||||
}) // from abort
|
||||
.then(() => {
|
||||
console.log(requestId, 'complete');
|
||||
observer.complete();
|
||||
}); // runs in background
|
||||
},
|
||||
error: (e) => {
|
||||
observer.error(e);
|
||||
},
|
||||
});
|
||||
|
||||
return function unsubscribe() {
|
||||
console.log(requestId, 'unsubscribe');
|
||||
controller.abort('unsubscribe');
|
||||
sub.unsubscribe();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private internalFetch<T>(options: BackendSrvRequest): Observable<FetchResponse<T>> {
|
||||
if (options.requestId) {
|
||||
this.inFlightRequests.next(options.requestId);
|
||||
|
||||
@@ -15,6 +15,7 @@ export const parseInitFromOptions = (options: BackendSrvRequest): RequestInit =>
|
||||
headers,
|
||||
body,
|
||||
credentials,
|
||||
signal: options.abortSignal,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { produce } from 'immer';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
@@ -89,10 +90,9 @@ export function paramsWithMatcherAndState(
|
||||
return paramsResult;
|
||||
}
|
||||
|
||||
export const groupRulesByFileName = (groups: PromRuleGroupDTO[], dataSourceName: string) => {
|
||||
const nsMap: { [key: string]: RuleNamespace } = {};
|
||||
groups.forEach((group) => {
|
||||
group.rules.forEach((rule) => {
|
||||
export function normalizeRuleGroup(group: PromRuleGroupDTO): PromRuleGroupDTO {
|
||||
return produce(group, (draft) => {
|
||||
draft.rules.forEach((rule) => {
|
||||
rule.query = rule.query || '';
|
||||
if (rule.type === PromRuleType.Alerting) {
|
||||
// There's a possibility that a custom/unexpected datasource might response with
|
||||
@@ -100,11 +100,19 @@ export const groupRulesByFileName = (groups: PromRuleGroupDTO[], dataSourceName:
|
||||
// In this case, we fall back to `Inactive` state so that elsewhere in the UI we don't fail/have to handle the edge case
|
||||
// and log a message so we can identify how frequently this might be happening
|
||||
if (!rule.state) {
|
||||
logInfo('prom rule with type=alerting is missing a state', { dataSourceName, ruleName: rule.name });
|
||||
logInfo('prom rule with type=alerting is missing a state', { ruleName: rule.name });
|
||||
rule.state = PromAlertingRuleState.Inactive;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const groupRulesByFileName = (groups: PromRuleGroupDTO[], dataSourceName: string) => {
|
||||
const normalizedGroups = groups.map(normalizeRuleGroup);
|
||||
|
||||
const nsMap: { [key: string]: RuleNamespace } = {};
|
||||
normalizedGroups.forEach((group) => {
|
||||
if (!nsMap[group.file]) {
|
||||
nsMap[group.file] = {
|
||||
dataSourceName,
|
||||
@@ -118,6 +126,7 @@ export const groupRulesByFileName = (groups: PromRuleGroupDTO[], dataSourceName:
|
||||
|
||||
return Object.values(nsMap);
|
||||
};
|
||||
|
||||
export const ungroupRulesByFileName = (namespaces: RuleNamespace[] = []): PromRuleGroupDTO[] => {
|
||||
return namespaces?.flatMap((namespace) =>
|
||||
namespace.groups.flatMap((group) => ruleGroupToPromRuleGroupDTO(group, namespace.name))
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
import { GrafanaPromRuleGroupDTO, PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
|
||||
import { alertingApi } from './alertingApi';
|
||||
import { normalizeRuleGroup } from './prometheus';
|
||||
|
||||
interface PromRulesResponse {
|
||||
export interface PromRulesResponse<TRuleGroup> {
|
||||
status: string;
|
||||
data: {
|
||||
groups: PromRuleGroupDTO[];
|
||||
groups: TRuleGroup[];
|
||||
groupNextToken?: string;
|
||||
};
|
||||
errorType?: string;
|
||||
@@ -22,11 +25,37 @@ interface PromRulesOptions {
|
||||
groupNextToken?: string;
|
||||
}
|
||||
|
||||
type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource'> & {
|
||||
dashboardUid?: string;
|
||||
panelId?: number;
|
||||
};
|
||||
|
||||
export const prometheusApi = alertingApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
groups: build.query<PromRulesResponse, PromRulesOptions>({
|
||||
query: ({ ruleSource, namespace, groupName, ruleName, groupLimit, excludeAlerts, groupNextToken }) => ({
|
||||
url: `api/prometheus/${ruleSource.uid}/api/v1/rules`,
|
||||
getGroups: build.query<PromRulesResponse<PromRuleGroupDTO<PromRuleDTO>>, PromRulesOptions>({
|
||||
query: ({ ruleSource, namespace, groupName, ruleName, groupLimit, excludeAlerts, groupNextToken }) => {
|
||||
if (ruleSource.uid === GRAFANA_RULES_SOURCE_NAME) {
|
||||
throw new Error('Please use getGrafanaGroups endpoint for grafana rules');
|
||||
}
|
||||
return {
|
||||
url: `api/prometheus/${ruleSource.uid}/api/v1/rules`,
|
||||
params: {
|
||||
'file[]': namespace,
|
||||
'group[]': groupName,
|
||||
'rule[]': ruleName,
|
||||
exclude_alerts: excludeAlerts?.toString(),
|
||||
group_limit: groupLimit?.toFixed(0),
|
||||
group_next_token: groupNextToken,
|
||||
},
|
||||
};
|
||||
},
|
||||
transformResponse: (response: PromRulesResponse<PromRuleGroupDTO<PromRuleDTO>>) => {
|
||||
return { ...response, data: { ...response.data, groups: response.data.groups.map(normalizeRuleGroup) } };
|
||||
},
|
||||
}),
|
||||
getGrafanaGroups: build.query<PromRulesResponse<GrafanaPromRuleGroupDTO>, GrafanaPromRulesOptions>({
|
||||
query: ({ namespace, groupName, ruleName, groupLimit, excludeAlerts, groupNextToken }) => ({
|
||||
url: `api/prometheus/grafana/api/v1/rules`,
|
||||
params: {
|
||||
'file[]': namespace,
|
||||
'group[]': groupName,
|
||||
|
||||
+5
-2
@@ -14,12 +14,15 @@ import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButto
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
const { useGetRuleGroupForNamespaceQuery } = alertRuleApi;
|
||||
|
||||
interface AlertRuleLoaderProps {
|
||||
interface DataSourceRuleLoaderProps {
|
||||
rule: Rule;
|
||||
groupIdentifier: DataSourceRuleGroupIdentifier;
|
||||
}
|
||||
|
||||
export const AlertRuleLoader = memo(function AlertRuleLoader({ rule, groupIdentifier }: AlertRuleLoaderProps) {
|
||||
export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({
|
||||
rule,
|
||||
groupIdentifier,
|
||||
}: DataSourceRuleLoaderProps) {
|
||||
const { rulesSource, namespace, groupName } = groupIdentifier;
|
||||
|
||||
const ruleIdentifier = fromRule(rulesSource.name, namespace.name, groupName, rule);
|
||||
@@ -9,12 +9,19 @@ import { isLoading, useAsync } from '../hooks/useAsync';
|
||||
import { RulesFilter } from '../search/rulesSearchParser';
|
||||
import { hashRule } from '../utils/rule-id';
|
||||
|
||||
import { AlertRuleLoader } from './AlertRuleLoader';
|
||||
import { DataSourceRuleLoader } from './DataSourceRuleLoader';
|
||||
import { GrafanaRuleLoader } from './GrafanaRuleLoader';
|
||||
import LoadMoreHelper from './LoadMoreHelper';
|
||||
import { UnknownRuleListItem } from './components/AlertRuleListItem';
|
||||
import { ListItem } from './components/ListItem';
|
||||
import { ActionsLoader } from './components/RuleActionsButtons.V2';
|
||||
import { RuleListIcon } from './components/RuleListIcon';
|
||||
import { RuleWithOrigin, useFilteredRulesIteratorProvider } from './hooks/useFilteredRulesIterator';
|
||||
import {
|
||||
GrafanaRuleWithOrigin,
|
||||
PromRuleWithOrigin,
|
||||
RuleWithOrigin,
|
||||
useFilteredRulesIteratorProvider,
|
||||
} from './hooks/useFilteredRulesIterator';
|
||||
|
||||
interface FilterViewProps {
|
||||
filterState: RulesFilter;
|
||||
@@ -30,13 +37,13 @@ export function FilterView({ filterState }: FilterViewProps) {
|
||||
return <FilterViewResults filterState={filterState} key={JSON.stringify(filterState)} />;
|
||||
}
|
||||
|
||||
interface KeyedRuleWithOrigin extends RuleWithOrigin {
|
||||
type KeyedRuleWithOrigin = RuleWithOrigin & {
|
||||
/**
|
||||
* Artificial frontend-only identifier for the rule.
|
||||
* It's used as a key for the rule in the rule list to prevent key duplication
|
||||
*/
|
||||
key: string;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the list of rules that match the filter.
|
||||
@@ -107,9 +114,25 @@ function FilterViewResults({ filterState }: FilterViewProps) {
|
||||
return (
|
||||
<Stack direction="column" gap={0}>
|
||||
<ul aria-label="filtered-rule-list">
|
||||
{rules.map(({ key, rule, groupIdentifier }) => (
|
||||
<AlertRuleLoader key={key} rule={rule} groupIdentifier={groupIdentifier} />
|
||||
))}
|
||||
{rules.map((ruleWithOrigin) => {
|
||||
const { key, rule, groupIdentifier, origin } = ruleWithOrigin;
|
||||
|
||||
switch (origin) {
|
||||
case 'grafana':
|
||||
return (
|
||||
<GrafanaRuleLoader
|
||||
key={key}
|
||||
rule={rule}
|
||||
groupName={groupIdentifier.groupName}
|
||||
namespaceName={ruleWithOrigin.namespaceName}
|
||||
/>
|
||||
);
|
||||
case 'datasource':
|
||||
return <DataSourceRuleLoader key={key} rule={rule} groupIdentifier={groupIdentifier} />;
|
||||
default:
|
||||
return <UnknownRuleListItem key={key} rule={rule} groupIdentifier={groupIdentifier} />;
|
||||
}
|
||||
})}
|
||||
{loading && (
|
||||
<>
|
||||
<AlertRuleListItemLoader />
|
||||
@@ -146,7 +169,22 @@ function onFinished<T>(fn: () => void) {
|
||||
return tap<T>(undefined, undefined, fn);
|
||||
}
|
||||
|
||||
function getRuleKey(ruleWithOrigin: RuleWithOrigin) {
|
||||
function getRuleKey(ruleWithOrigin: RuleWithOrigin): string {
|
||||
if (ruleWithOrigin.origin === 'grafana') {
|
||||
return getGrafanaRuleKey(ruleWithOrigin);
|
||||
}
|
||||
return getDataSourceRuleKey(ruleWithOrigin);
|
||||
}
|
||||
|
||||
function getGrafanaRuleKey(ruleWithOrigin: GrafanaRuleWithOrigin) {
|
||||
const {
|
||||
groupIdentifier: { namespace, groupName },
|
||||
rule,
|
||||
} = ruleWithOrigin;
|
||||
return `grafana-${namespace.uid}-${groupName}-${rule.uid}}`;
|
||||
}
|
||||
|
||||
function getDataSourceRuleKey(ruleWithOrigin: PromRuleWithOrigin) {
|
||||
const {
|
||||
rule,
|
||||
groupIdentifier: { rulesSource, namespace, groupName },
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { GrafanaPromRuleDTO, PromRuleType } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { GrafanaRulesSource } from '../utils/datasource';
|
||||
import { createRelativeUrl } from '../utils/url';
|
||||
|
||||
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem';
|
||||
|
||||
interface GrafanaRuleLoaderProps {
|
||||
rule: GrafanaPromRuleDTO;
|
||||
groupName: string;
|
||||
namespaceName: string;
|
||||
}
|
||||
|
||||
export function GrafanaRuleLoader({ rule, groupName, namespaceName }: GrafanaRuleLoaderProps) {
|
||||
const { folderUid } = rule;
|
||||
|
||||
const commonProps = {
|
||||
name: rule.name,
|
||||
rulesSource: GrafanaRulesSource,
|
||||
group: groupName,
|
||||
namespace: namespaceName,
|
||||
href: createRelativeUrl(`/alerting/grafana/${rule.uid}/view`),
|
||||
health: rule.health,
|
||||
error: rule.lastError,
|
||||
labels: rule.labels,
|
||||
};
|
||||
|
||||
if (rule.type === PromRuleType.Alerting) {
|
||||
return (
|
||||
<AlertRuleListItem
|
||||
{...commonProps}
|
||||
application="grafana"
|
||||
summary={rule.annotations?.summary}
|
||||
state={rule.state}
|
||||
isProvisioned={undefined}
|
||||
instancesCount={rule.alerts?.length}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (rule.type === PromRuleType.Recording) {
|
||||
return <RecordingRuleListItem {...commonProps} application="grafana" isProvisioned={undefined} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnknownRuleListItem
|
||||
rule={rule}
|
||||
groupIdentifier={{
|
||||
rulesSource: GrafanaRulesSource,
|
||||
groupName,
|
||||
namespace: { uid: folderUid },
|
||||
groupOrigin: 'grafana',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +1,46 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { PropsWithChildren, ReactNode, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, Dropdown, Icon, IconButton, LinkButton, Menu, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { DataSourceNamespaceIdentifier, DataSourceRuleGroupIdentifier, RuleGroup } from 'app/types/unified-alerting';
|
||||
import { RulesSourceApplication } from 'app/types/unified-alerting-dto';
|
||||
import { Stack } from '@grafana/ui';
|
||||
import { DataSourceRulesSourceIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { Spacer } from '../components/Spacer';
|
||||
import { WithReturnButton } from '../components/WithReturnButton';
|
||||
import { getDatasourceAPIUid, getExternalRulesSources } from '../utils/datasource';
|
||||
import { hashRule } from '../utils/rule-id';
|
||||
import { GrafanaRulesSource, getExternalRulesSources } from '../utils/datasource';
|
||||
|
||||
import { AlertRuleLoader } from './AlertRuleLoader';
|
||||
import { ListGroup } from './components/ListGroup';
|
||||
import { ListSection } from './components/ListSection';
|
||||
import { DataSourceIcon } from './components/Namespace';
|
||||
import { LoadingIndicator } from './components/RuleGroup';
|
||||
import { usePaginatedPrometheusRuleNamespaces } from './hooks/usePaginatedPrometheusRuleNamespaces';
|
||||
import { PaginatedDataSourceLoader } from './PaginatedDataSourceLoader';
|
||||
import { PaginatedGrafanaLoader } from './PaginatedGrafanaLoader';
|
||||
import { DataSourceErrorBoundary } from './components/DataSourceErrorBoundary';
|
||||
import { DataSourceSection } from './components/DataSourceSection';
|
||||
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
const GROUP_PAGE_SIZE = 40;
|
||||
|
||||
export function GroupedView() {
|
||||
const externalRuleSources = useMemo(() => getExternalRulesSources(), []);
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={1} role="list">
|
||||
<GrafanaDataSourceLoader />
|
||||
<DataSourceErrorBoundary rulesSourceIdentifier={GrafanaRulesSource}>
|
||||
<PaginatedGrafanaLoader />
|
||||
</DataSourceErrorBoundary>
|
||||
{externalRuleSources.map((ruleSource) => {
|
||||
return <DataSourceLoader key={ruleSource.uid} uid={ruleSource.uid} name={ruleSource.name} />;
|
||||
return <DataSourceLoader key={ruleSource.uid} rulesSourceIdentifier={ruleSource} />;
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataSourceLoaderProps {
|
||||
name: string;
|
||||
uid: string;
|
||||
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
|
||||
}
|
||||
|
||||
export function GrafanaDataSourceLoader() {
|
||||
return <DataSourceSection name="Grafana" application="grafana" uid="grafana" isLoading={true} />;
|
||||
}
|
||||
|
||||
export function DataSourceLoader({ uid, name }: DataSourceLoaderProps) {
|
||||
const { data: dataSourceInfo, isLoading } = useDiscoverDsFeaturesQuery({ uid });
|
||||
function DataSourceLoader({ rulesSourceIdentifier }: DataSourceLoaderProps) {
|
||||
const { data: dataSourceInfo, isLoading } = useDiscoverDsFeaturesQuery({ uid: rulesSourceIdentifier.uid });
|
||||
|
||||
const { uid, name } = rulesSourceIdentifier;
|
||||
|
||||
if (isLoading) {
|
||||
return <DataSourceSection loader={<Skeleton width={250} height={16} />} uid={uid} name={name} />;
|
||||
@@ -56,223 +49,15 @@ export function DataSourceLoader({ uid, name }: DataSourceLoaderProps) {
|
||||
// 2. grab prometheus rule groups with max_groups if supported
|
||||
if (dataSourceInfo) {
|
||||
return (
|
||||
<PaginatedDataSourceLoader
|
||||
ruleSourceName={dataSourceInfo.name}
|
||||
uid={uid}
|
||||
name={name}
|
||||
application={dataSourceInfo.application}
|
||||
/>
|
||||
<DataSourceErrorBoundary rulesSourceIdentifier={rulesSourceIdentifier}>
|
||||
<PaginatedDataSourceLoader
|
||||
key={rulesSourceIdentifier.uid}
|
||||
rulesSourceIdentifier={rulesSourceIdentifier}
|
||||
application={dataSourceInfo.application}
|
||||
/>
|
||||
</DataSourceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO Try to use a better rules source identifier
|
||||
interface PaginatedDataSourceLoaderProps
|
||||
extends Required<Pick<DataSourceSectionProps, 'application' | 'uid' | 'name'>> {
|
||||
ruleSourceName: string;
|
||||
}
|
||||
|
||||
function PaginatedDataSourceLoader({ ruleSourceName, name, uid, application }: PaginatedDataSourceLoaderProps) {
|
||||
const {
|
||||
page: ruleNamespaces,
|
||||
nextPage,
|
||||
previousPage,
|
||||
canMoveForward,
|
||||
canMoveBackward,
|
||||
isLoading,
|
||||
} = usePaginatedPrometheusRuleNamespaces(ruleSourceName, GROUP_PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<DataSourceSection name={name} application={application} uid={uid} isLoading={isLoading}>
|
||||
<Stack direction="column" gap={1}>
|
||||
{ruleNamespaces.map((namespace) => (
|
||||
<ListSection
|
||||
key={namespace.name}
|
||||
title={
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Icon name="folder" />{' '}
|
||||
<Text variant="body" element="h3">
|
||||
{namespace.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{namespace.groups.map((group) => (
|
||||
<RuleGroupListItem
|
||||
key={`${ruleSourceName}-${namespace.name}-${group.name}`}
|
||||
group={group}
|
||||
ruleSourceName={ruleSourceName}
|
||||
namespaceId={namespace}
|
||||
/>
|
||||
))}
|
||||
</ListSection>
|
||||
))}
|
||||
<LazyPagination
|
||||
nextPage={nextPage}
|
||||
previousPage={previousPage}
|
||||
canMoveForward={canMoveForward}
|
||||
canMoveBackward={canMoveBackward}
|
||||
/>
|
||||
</Stack>
|
||||
</DataSourceSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface RuleGroupListItemProps {
|
||||
group: RuleGroup;
|
||||
ruleSourceName: string;
|
||||
namespaceId: DataSourceNamespaceIdentifier;
|
||||
}
|
||||
|
||||
function RuleGroupListItem({ group, ruleSourceName, namespaceId }: RuleGroupListItemProps) {
|
||||
const rulesWithGroupId = useMemo(
|
||||
() =>
|
||||
group.rules.map((rule) => {
|
||||
const groupIdentifier: DataSourceRuleGroupIdentifier = {
|
||||
rulesSource: { uid: getDatasourceAPIUid(ruleSourceName), name: ruleSourceName },
|
||||
namespace: namespaceId,
|
||||
groupName: group.name,
|
||||
groupOrigin: 'datasource',
|
||||
};
|
||||
return { rule, groupIdentifier };
|
||||
}),
|
||||
[group, namespaceId, ruleSourceName]
|
||||
);
|
||||
|
||||
return (
|
||||
<ListGroup
|
||||
key={group.name}
|
||||
name={group.name}
|
||||
isOpen={false}
|
||||
actions={
|
||||
<>
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item label="Edit" icon="pen" data-testid="edit-group-action" />
|
||||
<Menu.Item label="Re-order rules" icon="flip" />
|
||||
<Menu.Divider />
|
||||
<Menu.Item label="Export" icon="download-alt" />
|
||||
<Menu.Item label="Delete" icon="trash-alt" destructive />
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<IconButton name="ellipsis-h" aria-label="rule group actions" />
|
||||
</Dropdown>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{rulesWithGroupId.map(({ rule, groupIdentifier }) => (
|
||||
<AlertRuleLoader key={hashRule(rule)} rule={rule} groupIdentifier={groupIdentifier} />
|
||||
))}
|
||||
</ListGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataSourceSectionProps extends PropsWithChildren {
|
||||
uid: string;
|
||||
name: string;
|
||||
loader?: ReactNode;
|
||||
application?: RulesSourceApplication;
|
||||
isLoading?: boolean;
|
||||
description?: ReactNode;
|
||||
}
|
||||
|
||||
const DataSourceSection = ({
|
||||
uid,
|
||||
name,
|
||||
application,
|
||||
children,
|
||||
loader,
|
||||
isLoading = false,
|
||||
description = null,
|
||||
}: DataSourceSectionProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`datasource-${uid}-heading`} role="listitem">
|
||||
<Stack direction="column" gap={1}>
|
||||
<Stack direction="column" gap={0}>
|
||||
{isLoading && <LoadingIndicator datasourceUid={uid} />}
|
||||
<div className={styles.dataSourceSectionTitle}>
|
||||
{loader ?? (
|
||||
<Stack alignItems="center">
|
||||
{application && <DataSourceIcon application={application} />}
|
||||
<Text variant="body" weight="bold" element="h2" id={`datasource-${uid}-heading`}>
|
||||
{name}
|
||||
</Text>
|
||||
{description && (
|
||||
<>
|
||||
{'·'}
|
||||
{description}
|
||||
</>
|
||||
)}
|
||||
<Spacer />
|
||||
<WithReturnButton
|
||||
title="alert rules"
|
||||
component={
|
||||
<LinkButton variant="secondary" size="sm" href={`/connections/datasources/edit/${uid}`}>
|
||||
<Trans i18nKey="alerting.rule-list.configure-datasource">Configure</Trans>
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
<div className={styles.itemsWrapper}>{children}</div>
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
itemsWrapper: css({
|
||||
position: 'relative',
|
||||
marginLeft: theme.spacing(1.5),
|
||||
|
||||
'&:before': {
|
||||
content: "''",
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
|
||||
marginLeft: `-${theme.spacing(1.5)}`,
|
||||
borderLeft: `solid 1px ${theme.colors.border.weak}`,
|
||||
},
|
||||
}),
|
||||
dataSourceSectionTitle: css({
|
||||
background: theme.colors.background.secondary,
|
||||
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
|
||||
|
||||
border: `solid 1px ${theme.colors.border.weak}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
}),
|
||||
});
|
||||
|
||||
interface LazyPaginationProps {
|
||||
canMoveForward: boolean;
|
||||
canMoveBackward: boolean;
|
||||
nextPage: () => void;
|
||||
previousPage: () => void;
|
||||
}
|
||||
|
||||
function LazyPagination({ canMoveForward, canMoveBackward, nextPage, previousPage }: LazyPaginationProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={1}>
|
||||
<Button
|
||||
aria-label={`previous page`}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={previousPage}
|
||||
disabled={!canMoveBackward}
|
||||
>
|
||||
<Icon name="angle-left" />
|
||||
</Button>
|
||||
<Button aria-label={`next page`} size="sm" variant="secondary" onClick={nextPage} disabled={!canMoveForward}>
|
||||
<Icon name="angle-right" />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { groupBy } from 'lodash';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { Icon, Stack, Text } from '@grafana/ui';
|
||||
import { DataSourceRuleGroupIdentifier, DataSourceRulesSourceIdentifier, RuleGroup } from 'app/types/unified-alerting';
|
||||
|
||||
import { hashRule } from '../utils/rule-id';
|
||||
|
||||
import { DataSourceRuleLoader } from './DataSourceRuleLoader';
|
||||
import { DataSourceSection, DataSourceSectionProps } from './components/DataSourceSection';
|
||||
import { LazyPagination } from './components/LazyPagination';
|
||||
import { ListGroup } from './components/ListGroup';
|
||||
import { ListSection } from './components/ListSection';
|
||||
import { RuleGroupActionsMenu } from './components/RuleGroupActionsMenu';
|
||||
import { usePrometheusGroupsGenerator } from './hooks/prometheusGroupsGenerator';
|
||||
import { usePaginatedPrometheusGroups } from './hooks/usePaginatedPrometheusGroups';
|
||||
|
||||
const DATA_SOURCE_GROUP_PAGE_SIZE = 40;
|
||||
|
||||
interface PaginatedDataSourceLoaderProps extends Required<Pick<DataSourceSectionProps, 'application'>> {
|
||||
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
|
||||
}
|
||||
export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }: PaginatedDataSourceLoaderProps) {
|
||||
const { uid, name } = rulesSourceIdentifier;
|
||||
const prometheusGroupsGenerator = usePrometheusGroupsGenerator();
|
||||
|
||||
const groupsGenerator = useRef(prometheusGroupsGenerator(rulesSourceIdentifier, DATA_SOURCE_GROUP_PAGE_SIZE));
|
||||
|
||||
useEffect(() => {
|
||||
const currentGenerator = groupsGenerator.current;
|
||||
return () => {
|
||||
currentGenerator.return();
|
||||
};
|
||||
}, [groupsGenerator]);
|
||||
|
||||
const {
|
||||
page: groupsPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
canMoveForward,
|
||||
canMoveBackward,
|
||||
isLoading,
|
||||
} = usePaginatedPrometheusGroups(groupsGenerator.current, DATA_SOURCE_GROUP_PAGE_SIZE);
|
||||
|
||||
const groupsByNamespace = useMemo(() => groupBy(groupsPage, 'file'), [groupsPage]);
|
||||
|
||||
return (
|
||||
<DataSourceSection name={name} application={application} uid={uid} isLoading={isLoading}>
|
||||
<Stack direction="column" gap={1}>
|
||||
{Object.entries(groupsByNamespace).map(([namespace, groups]) => (
|
||||
<ListSection
|
||||
key={namespace}
|
||||
title={
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Icon name="folder" />{' '}
|
||||
<Text variant="body" element="h3">
|
||||
{namespace}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<RuleGroupListItem
|
||||
key={`${rulesSourceIdentifier.uid}-${namespace}-${group.name}`}
|
||||
group={group}
|
||||
rulesSourceIdentifier={rulesSourceIdentifier}
|
||||
namespaceName={namespace}
|
||||
/>
|
||||
))}
|
||||
</ListSection>
|
||||
))}
|
||||
<LazyPagination
|
||||
nextPage={nextPage}
|
||||
previousPage={previousPage}
|
||||
canMoveForward={canMoveForward}
|
||||
canMoveBackward={canMoveBackward}
|
||||
/>
|
||||
</Stack>
|
||||
</DataSourceSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface RuleGroupListItemProps {
|
||||
group: RuleGroup;
|
||||
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
|
||||
namespaceName: string;
|
||||
}
|
||||
function RuleGroupListItem({ rulesSourceIdentifier, group, namespaceName }: RuleGroupListItemProps) {
|
||||
const rulesWithGroupId = useMemo(() => {
|
||||
return group.rules.map((rule) => {
|
||||
const groupIdentifier: DataSourceRuleGroupIdentifier = {
|
||||
rulesSource: rulesSourceIdentifier,
|
||||
namespace: { name: namespaceName },
|
||||
groupName: group.name,
|
||||
groupOrigin: 'datasource',
|
||||
};
|
||||
return { rule, groupIdentifier };
|
||||
});
|
||||
}, [group, namespaceName, rulesSourceIdentifier]);
|
||||
|
||||
return (
|
||||
<ListGroup key={group.name} name={group.name} isOpen={false} actions={<RuleGroupActionsMenu />}>
|
||||
{rulesWithGroupId.map(({ rule, groupIdentifier }) => (
|
||||
<DataSourceRuleLoader key={hashRule(rule)} rule={rule} groupIdentifier={groupIdentifier} />
|
||||
))}
|
||||
</ListGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { groupBy } from 'lodash';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { Icon, Stack, Text } from '@grafana/ui';
|
||||
import { GrafanaRulesSourceSymbol } from 'app/types/unified-alerting';
|
||||
import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { GrafanaRuleLoader } from './GrafanaRuleLoader';
|
||||
import { DataSourceSection } from './components/DataSourceSection';
|
||||
import { LazyPagination } from './components/LazyPagination';
|
||||
import { ListGroup } from './components/ListGroup';
|
||||
import { ListSection } from './components/ListSection';
|
||||
import { RuleGroupActionsMenu } from './components/RuleGroupActionsMenu';
|
||||
import { useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator';
|
||||
import { usePaginatedPrometheusGroups } from './hooks/usePaginatedPrometheusGroups';
|
||||
|
||||
const GRAFANA_GROUP_PAGE_SIZE = 40;
|
||||
|
||||
export function PaginatedGrafanaLoader() {
|
||||
const grafanaGroupsGenerator = useGrafanaGroupsGenerator();
|
||||
|
||||
const groupsGenerator = useRef(grafanaGroupsGenerator(GRAFANA_GROUP_PAGE_SIZE));
|
||||
|
||||
useEffect(() => {
|
||||
const currentGenerator = groupsGenerator.current;
|
||||
return () => {
|
||||
currentGenerator.return();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
page: groupsPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
canMoveForward,
|
||||
canMoveBackward,
|
||||
isLoading,
|
||||
} = usePaginatedPrometheusGroups(groupsGenerator.current, GRAFANA_GROUP_PAGE_SIZE);
|
||||
|
||||
const groupsByFolder = useMemo(() => groupBy(groupsPage, 'folderUid'), [groupsPage]);
|
||||
|
||||
return (
|
||||
<DataSourceSection name="Grafana" application="grafana" uid={GrafanaRulesSourceSymbol} isLoading={isLoading}>
|
||||
<Stack direction="column" gap={1}>
|
||||
{Object.entries(groupsByFolder).map(([folderUid, groups]) => {
|
||||
// Groups are grouped by folder, so we can use the first group to get the folder name
|
||||
const folderName = groups[0].file;
|
||||
return (
|
||||
<ListSection
|
||||
key={folderUid}
|
||||
title={
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Icon name="folder" />{' '}
|
||||
<Text variant="body" element="h3">
|
||||
{folderName}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<GrafanaRuleGroupListItem
|
||||
key={`grafana-ns-${folderUid}-${group.name}`}
|
||||
group={group}
|
||||
namespaceName={folderName}
|
||||
/>
|
||||
))}
|
||||
</ListSection>
|
||||
);
|
||||
})}
|
||||
<LazyPagination
|
||||
nextPage={nextPage}
|
||||
previousPage={previousPage}
|
||||
canMoveForward={canMoveForward}
|
||||
canMoveBackward={canMoveBackward}
|
||||
/>
|
||||
</Stack>
|
||||
</DataSourceSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface GrafanaRuleGroupListItemProps {
|
||||
group: GrafanaPromRuleGroupDTO;
|
||||
namespaceName: string;
|
||||
}
|
||||
export function GrafanaRuleGroupListItem({ group, namespaceName }: GrafanaRuleGroupListItemProps) {
|
||||
return (
|
||||
<ListGroup key={group.name} name={group.name} isOpen={false} actions={<RuleGroupActionsMenu />}>
|
||||
{group.rules.map((rule) => {
|
||||
return <GrafanaRuleLoader key={rule.uid} rule={rule} groupName={group.name} namespaceName={namespaceName} />;
|
||||
})}
|
||||
</ListGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
import pluralize from 'pluralize';
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Alert, Icon, Stack, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
@@ -266,15 +266,17 @@ interface UnknownRuleListItemProps {
|
||||
|
||||
export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListItemProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { rulesSource, namespace, groupName } = groupIdentifier;
|
||||
|
||||
const ruleContext = {
|
||||
name: rule.name,
|
||||
groupName,
|
||||
namespace: JSON.stringify(namespace),
|
||||
rulesSource: rulesSource.uid === GrafanaRulesSourceSymbol ? GRAFANA_RULES_SOURCE_NAME : rulesSource.uid,
|
||||
};
|
||||
logError(new Error('unknown rule type'), ruleContext);
|
||||
useEffect(() => {
|
||||
const { rulesSource, namespace, groupName } = groupIdentifier;
|
||||
const ruleContext = {
|
||||
name: rule.name,
|
||||
groupName,
|
||||
namespace: JSON.stringify(namespace),
|
||||
rulesSource: rulesSource.uid === GrafanaRulesSourceSymbol ? GRAFANA_RULES_SOURCE_NAME : rulesSource.uid,
|
||||
};
|
||||
logError(new Error('unknown rule type'), ruleContext);
|
||||
}, [rule, groupIdentifier]);
|
||||
|
||||
return (
|
||||
<Alert title={'Unknown rule type'} className={styles.resetMargin}>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Alert, ErrorBoundary, ErrorWithStack, Text } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { RulesSourceIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { DataSourceSection } from './DataSourceSection';
|
||||
|
||||
/**
|
||||
* Some more exotic Prometheus data sources might not be 100% compatible with Prometheus API
|
||||
* We don't want them to break the whole page, so we wrap them in an error boundary
|
||||
*/
|
||||
|
||||
export function DataSourceErrorBoundary({
|
||||
children,
|
||||
rulesSourceIdentifier,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
rulesSourceIdentifier: RulesSourceIdentifier;
|
||||
}) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{({ error, errorInfo }) => {
|
||||
if (error || errorInfo) {
|
||||
const { uid, name } = rulesSourceIdentifier;
|
||||
return (
|
||||
<DataSourceSection uid={uid} name={name}>
|
||||
<Alert
|
||||
title={t('alerting.rule-list.ds-error-boundary.title', 'Unable to load rules from this data source')}
|
||||
>
|
||||
<Text>
|
||||
<Trans i18nKey="alerting.rule-list.ds-error-boundary.description">
|
||||
Check the data source configuration. Does the data source support Prometheus API?
|
||||
</Trans>
|
||||
</Text>
|
||||
<ErrorWithStack
|
||||
error={error}
|
||||
errorInfo={errorInfo}
|
||||
title={t('alerting.rule-list.ds-error-boundary.title', 'Unable to load rules from this data source')}
|
||||
/>
|
||||
</Alert>
|
||||
</DataSourceSection>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { LinkButton, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { RulesSourceIdentifier } from 'app/types/unified-alerting';
|
||||
import { RulesSourceApplication } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { Spacer } from '../../components/Spacer';
|
||||
import { WithReturnButton } from '../../components/WithReturnButton';
|
||||
|
||||
import { DataSourceIcon } from './Namespace';
|
||||
import { LoadingIndicator } from './RuleGroup';
|
||||
|
||||
export interface DataSourceSectionProps extends PropsWithChildren {
|
||||
uid: RulesSourceIdentifier['uid'];
|
||||
name: string;
|
||||
loader?: ReactNode;
|
||||
application?: RulesSourceApplication;
|
||||
isLoading?: boolean;
|
||||
description?: ReactNode;
|
||||
}
|
||||
|
||||
export const DataSourceSection = ({
|
||||
uid,
|
||||
name,
|
||||
application,
|
||||
children,
|
||||
loader,
|
||||
isLoading = false,
|
||||
description = null,
|
||||
}: DataSourceSectionProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`datasource-${String(uid)}-heading`} role="listitem">
|
||||
<Stack direction="column" gap={1}>
|
||||
<Stack direction="column" gap={0}>
|
||||
{isLoading && <LoadingIndicator datasourceUid={String(uid)} />}
|
||||
<div className={styles.dataSourceSectionTitle}>
|
||||
{loader ?? (
|
||||
<Stack alignItems="center">
|
||||
{application && <DataSourceIcon application={application} />}
|
||||
<Text variant="body" weight="bold" element="h2" id={`datasource-${String(uid)}-heading`}>
|
||||
{name}
|
||||
</Text>
|
||||
{description && (
|
||||
<>
|
||||
{'·'}
|
||||
{description}
|
||||
</>
|
||||
)}
|
||||
<Spacer />
|
||||
<WithReturnButton
|
||||
title={t('alerting.rule-list.return-button.title', 'Alert rules')}
|
||||
component={
|
||||
<LinkButton variant="secondary" size="sm" href={`/connections/datasources/edit/${String(uid)}`}>
|
||||
<Trans i18nKey="alerting.rule-list.configure-datasource">Configure</Trans>
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
<div className={styles.itemsWrapper}>{children}</div>
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
itemsWrapper: css({
|
||||
position: 'relative',
|
||||
marginLeft: theme.spacing(1.5),
|
||||
|
||||
'&:before': {
|
||||
content: "''",
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
|
||||
marginLeft: `-${theme.spacing(1.5)}`,
|
||||
borderLeft: `solid 1px ${theme.colors.border.weak}`,
|
||||
},
|
||||
}),
|
||||
dataSourceSectionTitle: css({
|
||||
background: theme.colors.background.secondary,
|
||||
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
|
||||
|
||||
border: `solid 1px ${theme.colors.border.weak}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Button, Icon, Stack } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
interface LazyPaginationProps {
|
||||
canMoveForward: boolean;
|
||||
canMoveBackward: boolean;
|
||||
nextPage: () => void;
|
||||
previousPage: () => void;
|
||||
}
|
||||
|
||||
export function LazyPagination({ canMoveForward, canMoveBackward, nextPage, previousPage }: LazyPaginationProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={1}>
|
||||
<Button
|
||||
aria-label={t('alerting.rule-list.pagination.previous-page', 'previous page')}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={previousPage}
|
||||
disabled={!canMoveBackward}
|
||||
>
|
||||
<Icon name="angle-left" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t('alerting.rule-list.pagination.next-page', 'next page')}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={nextPage}
|
||||
disabled={!canMoveForward}
|
||||
>
|
||||
<Icon name="angle-right" />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Dropdown, IconButton, Menu } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
export function RuleGroupActionsMenu() {
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item label={t('alerting.group-actions.edit', 'Edit')} icon="pen" data-testid="edit-group-action" />
|
||||
<Menu.Item label={t('alerting.group-actions.reorder', 'Re-order rules')} icon="flip" />
|
||||
<Menu.Divider />
|
||||
<Menu.Item label={t('alerting.group-actions.export', 'Export')} icon="download-alt" />
|
||||
<Menu.Item label={t('alerting.group-actions.delete', 'Delete')} icon="trash-alt" destructive />
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<IconButton name="ellipsis-h" aria-label={t('alerting.group-actions.actions-trigger', 'Rule group actions')} />
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { BaseQueryFn } from '@reduxjs/toolkit/query';
|
||||
import { TypedLazyQueryTrigger } from '@reduxjs/toolkit/query/react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { DataSourceRulesSourceIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { BaseQueryFnArgs } from '../../api/alertingApi';
|
||||
import { PromRulesResponse, prometheusApi } from '../../api/prometheusApi';
|
||||
|
||||
const { useLazyGetGroupsQuery, useLazyGetGrafanaGroupsQuery } = prometheusApi;
|
||||
|
||||
interface FetchGroupsOptions {
|
||||
groupLimit?: number;
|
||||
groupNextToken?: string;
|
||||
}
|
||||
|
||||
export function usePrometheusGroupsGenerator() {
|
||||
const [getGroups] = useLazyGetGroupsQuery();
|
||||
|
||||
return useCallback(
|
||||
async function* (ruleSource: DataSourceRulesSourceIdentifier, groupLimit: number) {
|
||||
const getRuleSourceGroups = (options: FetchGroupsOptions) =>
|
||||
getGroups({ ruleSource: { uid: ruleSource.uid }, ...options });
|
||||
|
||||
yield* genericGroupsGenerator(getRuleSourceGroups, groupLimit);
|
||||
},
|
||||
[getGroups]
|
||||
);
|
||||
}
|
||||
|
||||
export function useGrafanaGroupsGenerator() {
|
||||
const [getGrafanaGroups] = useLazyGetGrafanaGroupsQuery();
|
||||
|
||||
return useCallback(
|
||||
async function* (groupLimit: number) {
|
||||
yield* genericGroupsGenerator(getGrafanaGroups, groupLimit);
|
||||
},
|
||||
[getGrafanaGroups]
|
||||
);
|
||||
}
|
||||
|
||||
// Generator lazily provides groups one by one only when needed
|
||||
// This might look a bit complex but it allows us to have one API for paginated and non-paginated Prometheus data sources
|
||||
// For unpaginated data sources we fetch everything in one go
|
||||
// For paginated we fetch the next page when needed
|
||||
async function* genericGroupsGenerator<TGroup>(
|
||||
fetchGroups: TypedLazyQueryTrigger<PromRulesResponse<TGroup>, FetchGroupsOptions, BaseQueryFn<BaseQueryFnArgs>>,
|
||||
groupLimit: number
|
||||
) {
|
||||
const response = await fetchGroups({ groupLimit });
|
||||
|
||||
if (!response.isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.data) {
|
||||
yield* response.data.data.groups;
|
||||
}
|
||||
|
||||
let lastToken: string | undefined = undefined;
|
||||
if (response.data?.data?.groupNextToken) {
|
||||
lastToken = response.data.data.groupNextToken;
|
||||
}
|
||||
|
||||
while (lastToken) {
|
||||
const response = await fetchGroups({
|
||||
groupNextToken: lastToken,
|
||||
groupLimit: groupLimit,
|
||||
});
|
||||
|
||||
if (!response.isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.data) {
|
||||
yield* response.data.data.groups;
|
||||
}
|
||||
|
||||
lastToken = response.data?.data?.groupNextToken;
|
||||
}
|
||||
}
|
||||
@@ -1,97 +1,95 @@
|
||||
import { AsyncIterableX, from } from 'ix/asynciterable/index';
|
||||
import { merge } from 'ix/asynciterable/merge';
|
||||
import { filter, flatMap, map } from 'ix/asynciterable/operators';
|
||||
import { compact } from 'lodash';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { DataSourceRuleGroupIdentifier, ExternalRulesSourceIdentifier } from 'app/types/unified-alerting';
|
||||
import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
import {
|
||||
DataSourceRuleGroupIdentifier,
|
||||
DataSourceRulesSourceIdentifier,
|
||||
GrafanaRuleGroupIdentifier,
|
||||
} from 'app/types/unified-alerting';
|
||||
import {
|
||||
GrafanaPromRuleDTO,
|
||||
GrafanaPromRuleGroupDTO,
|
||||
PromRuleDTO,
|
||||
PromRuleGroupDTO,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { prometheusApi } from '../../api/prometheusApi';
|
||||
import { RulesFilter } from '../../search/rulesSearchParser';
|
||||
import { labelsMatchMatchers } from '../../utils/alertmanager';
|
||||
import { Annotation } from '../../utils/constants';
|
||||
import { getDatasourceAPIUid, getExternalRulesSources } from '../../utils/datasource';
|
||||
import { GrafanaRulesSource, getDatasourceAPIUid, getExternalRulesSources } from '../../utils/datasource';
|
||||
import { parseMatcher } from '../../utils/matchers';
|
||||
import { isAlertingRule } from '../../utils/rules';
|
||||
|
||||
export interface RuleWithOrigin {
|
||||
rule: PromRuleDTO;
|
||||
groupIdentifier: DataSourceRuleGroupIdentifier;
|
||||
import { useGrafanaGroupsGenerator, usePrometheusGroupsGenerator } from './prometheusGroupsGenerator';
|
||||
|
||||
export type RuleWithOrigin = PromRuleWithOrigin | GrafanaRuleWithOrigin;
|
||||
|
||||
export interface GrafanaRuleWithOrigin {
|
||||
rule: GrafanaPromRuleDTO;
|
||||
groupIdentifier: GrafanaRuleGroupIdentifier;
|
||||
/**
|
||||
* The name of the namespace that contains the rule group
|
||||
* groupIdentifier contains the uid of the namespace, but not the user-friendly display name
|
||||
*/
|
||||
namespaceName: string;
|
||||
origin: 'grafana';
|
||||
}
|
||||
|
||||
const { useLazyGroupsQuery } = prometheusApi;
|
||||
export interface PromRuleWithOrigin {
|
||||
rule: PromRuleDTO;
|
||||
groupIdentifier: DataSourceRuleGroupIdentifier;
|
||||
origin: 'datasource';
|
||||
}
|
||||
|
||||
export function useFilteredRulesIteratorProvider() {
|
||||
const [fetchGroups] = useLazyGroupsQuery();
|
||||
const allExternalRulesSources = getExternalRulesSources();
|
||||
|
||||
/**
|
||||
* This async generator will continue to yield rule groups and will keep fetching backend pages as long as the consumer
|
||||
* is iterating.
|
||||
*/
|
||||
const fetchRuleSourceGroups = useCallback(
|
||||
async function* (ruleSource: ExternalRulesSourceIdentifier, maxGroups: number) {
|
||||
const response = await fetchGroups({ ruleSource: { uid: ruleSource.uid }, groupLimit: maxGroups });
|
||||
const prometheusGroupsGenerator = usePrometheusGroupsGenerator();
|
||||
const grafanaGroupsGenerator = useGrafanaGroupsGenerator();
|
||||
|
||||
if (!response.isSuccess) {
|
||||
return;
|
||||
}
|
||||
const getFilteredRulesIterator = (filterState: RulesFilter, groupLimit: number): AsyncIterableX<RuleWithOrigin> => {
|
||||
const normalizedFilterState = normalizeFilterState(filterState);
|
||||
|
||||
if (response.data?.data) {
|
||||
yield* response.data.data.groups.map((group) => [ruleSource, group] as const);
|
||||
}
|
||||
|
||||
let lastToken: string | undefined = undefined;
|
||||
if (response.data?.data?.groupNextToken) {
|
||||
lastToken = response.data.data.groupNextToken;
|
||||
}
|
||||
|
||||
while (lastToken) {
|
||||
const response = await fetchGroups({
|
||||
ruleSource: { uid: ruleSource.uid },
|
||||
groupNextToken: lastToken,
|
||||
groupLimit: maxGroups,
|
||||
});
|
||||
|
||||
if (!response.isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.data) {
|
||||
yield* response.data.data.groups.map((group) => [ruleSource, group] as const);
|
||||
}
|
||||
|
||||
lastToken = response.data?.data?.groupNextToken;
|
||||
}
|
||||
},
|
||||
[fetchGroups]
|
||||
);
|
||||
|
||||
const getFilteredRulesIterator = (filterState: RulesFilter, groupLimit: number) => {
|
||||
const ruleSourcesToFetchFrom = filterState.dataSourceNames.length
|
||||
? filterState.dataSourceNames.map((ds) => ({ name: ds, uid: getDatasourceAPIUid(ds) }))
|
||||
? filterState.dataSourceNames.map<DataSourceRulesSourceIdentifier>((ds) => ({
|
||||
name: ds,
|
||||
uid: getDatasourceAPIUid(ds),
|
||||
ruleSourceType: 'datasource',
|
||||
}))
|
||||
: allExternalRulesSources;
|
||||
|
||||
// This split into the first one and the rest is only for compatibility with the merge function from ix
|
||||
const [source, ...iterables] = ruleSourcesToFetchFrom.map((ds) => fetchRuleSourceGroups(ds, groupLimit));
|
||||
const grafanaIterator = from(grafanaGroupsGenerator(groupLimit)).pipe(
|
||||
filter((group) => groupFilter(group, normalizedFilterState)),
|
||||
flatMap((group) => group.rules.map((rule) => [group, rule] as const)),
|
||||
filter(([_, rule]) => ruleFilter(rule, normalizedFilterState)),
|
||||
map(([group, rule]) => mapGrafanaRuleToRuleWithOrigin(group, rule))
|
||||
);
|
||||
|
||||
return merge(source, ...iterables).pipe(
|
||||
filter(([_, group]) => groupFilter(group, filterState)),
|
||||
const [source, ...iterables] = ruleSourcesToFetchFrom.map((ds) => {
|
||||
return from(prometheusGroupsGenerator(ds, groupLimit)).pipe(map((group) => [ds, group] as const));
|
||||
});
|
||||
|
||||
const dataSourcesIterator = merge(source, ...iterables).pipe(
|
||||
filter(([_, group]) => groupFilter(group, normalizedFilterState)),
|
||||
flatMap(([rulesSource, group]) => group.rules.map((rule) => [rulesSource, group, rule] as const)),
|
||||
filter(([_, __, rule]) => ruleFilter(rule, filterState)),
|
||||
map(([rulesSource, group, rule]) => mapRuleToRuleWithOrigin(rulesSource, group, rule))
|
||||
);
|
||||
|
||||
return merge(grafanaIterator, dataSourcesIterator);
|
||||
};
|
||||
|
||||
return { getFilteredRulesIterator };
|
||||
}
|
||||
|
||||
function mapRuleToRuleWithOrigin(
|
||||
rulesSource: ExternalRulesSourceIdentifier,
|
||||
rulesSource: DataSourceRulesSourceIdentifier,
|
||||
group: PromRuleGroupDTO,
|
||||
rule: PromRuleDTO
|
||||
): RuleWithOrigin {
|
||||
): PromRuleWithOrigin {
|
||||
return {
|
||||
rule,
|
||||
groupIdentifier: {
|
||||
@@ -100,6 +98,24 @@ function mapRuleToRuleWithOrigin(
|
||||
groupName: group.name,
|
||||
groupOrigin: 'datasource',
|
||||
},
|
||||
origin: 'datasource',
|
||||
};
|
||||
}
|
||||
|
||||
function mapGrafanaRuleToRuleWithOrigin(
|
||||
group: GrafanaPromRuleGroupDTO,
|
||||
rule: GrafanaPromRuleDTO
|
||||
): GrafanaRuleWithOrigin {
|
||||
return {
|
||||
rule,
|
||||
groupIdentifier: {
|
||||
rulesSource: GrafanaRulesSource,
|
||||
namespace: { uid: group.folderUid },
|
||||
groupName: group.name,
|
||||
groupOrigin: 'grafana',
|
||||
},
|
||||
namespaceName: group.file,
|
||||
origin: 'grafana',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,11 +127,11 @@ function groupFilter(group: PromRuleGroupDTO, filterState: RulesFilter): boolean
|
||||
const { name, file } = group;
|
||||
|
||||
// TODO Add fuzzy filtering or not
|
||||
if (filterState.namespace && !file.includes(filterState.namespace)) {
|
||||
if (filterState.namespace && !file.toLowerCase().includes(filterState.namespace)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterState.groupName && !name.includes(filterState.groupName)) {
|
||||
if (filterState.groupName && !name.toLowerCase().includes(filterState.groupName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -125,11 +141,13 @@ function groupFilter(group: PromRuleGroupDTO, filterState: RulesFilter): boolean
|
||||
function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) {
|
||||
const { name, labels = {}, health, type } = rule;
|
||||
|
||||
if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => name.includes(word))) {
|
||||
const nameLower = name.toLowerCase();
|
||||
|
||||
if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => nameLower.includes(word))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterState.ruleName && !name.includes(filterState.ruleName)) {
|
||||
if (filterState.ruleName && !nameLower.includes(filterState.ruleName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -165,6 +183,19 @@ function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercase free form words, rule name, group name and namespace
|
||||
*/
|
||||
function normalizeFilterState(filterState: RulesFilter): RulesFilter {
|
||||
return {
|
||||
...filterState,
|
||||
freeFormWords: filterState.freeFormWords.map((word) => word.toLowerCase()),
|
||||
ruleName: filterState.ruleName?.toLowerCase(),
|
||||
groupName: filterState.groupName?.toLowerCase(),
|
||||
namespace: filterState.namespace?.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function looseParseMatcher(matcherQuery: string): Matcher | undefined {
|
||||
try {
|
||||
return parseMatcher(matcherQuery);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { isLoading, useAsync } from '../../hooks/useAsync';
|
||||
|
||||
/**
|
||||
* Provides pagination functionality for rule groups with lazy loading.
|
||||
* Instead of loading all groups at once, it uses a generator to fetch them in batches as needed,
|
||||
* which helps with performance when dealing with large numbers of rules.
|
||||
*
|
||||
* @param groupsGenerator - An async generator that yields rule groups in batches
|
||||
* @param pageSize - Number of groups to display per page
|
||||
* @returns Pagination state and controls for navigating through rule groups
|
||||
*/
|
||||
export function usePaginatedPrometheusGroups<TGroup extends PromRuleGroupDTO>(
|
||||
groupsGenerator: AsyncGenerator<TGroup, void, unknown>,
|
||||
pageSize: number
|
||||
) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [groups, setGroups] = useState<TGroup[]>([]);
|
||||
const [lastPage, setLastPage] = useState<number | undefined>(undefined);
|
||||
|
||||
const [{ execute: fetchMoreGroups }, groupsRequestState] = useAsync(async (groupsCount: number) => {
|
||||
let done = false;
|
||||
const currentGroups: TGroup[] = [];
|
||||
|
||||
while (currentGroups.length < groupsCount) {
|
||||
const generatorResult = await groupsGenerator.next();
|
||||
if (generatorResult.done) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
const group = generatorResult.value;
|
||||
currentGroups.push(group);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
const groupsTotal = groups.length + currentGroups.length;
|
||||
setLastPage(Math.ceil(groupsTotal / pageSize));
|
||||
}
|
||||
|
||||
setGroups((groups) => [...groups, ...currentGroups]);
|
||||
});
|
||||
|
||||
// lastPage could be computed from groups.length and pageSize
|
||||
const fetchInProgress = isLoading(groupsRequestState);
|
||||
const canMoveForward = !fetchInProgress && (!lastPage || currentPage < lastPage);
|
||||
// When going backward we already have the groups loaded, so no need to check if fetchInProgress
|
||||
const canMoveBackward = currentPage > 1;
|
||||
|
||||
const nextPage = useCallback(async () => {
|
||||
if (canMoveForward) {
|
||||
setCurrentPage((page) => page + 1);
|
||||
}
|
||||
}, [canMoveForward]);
|
||||
|
||||
const previousPage = useCallback(async () => {
|
||||
if (canMoveBackward) {
|
||||
setCurrentPage((page) => page - 1);
|
||||
}
|
||||
}, [canMoveBackward]);
|
||||
|
||||
// groups.length - pageSize to have one more page loaded to prevent flickering with loading state
|
||||
// lastPage === undefined because 0 is falsy but a value which should stop fetching (e.g for broken data sources)
|
||||
const shouldFetchNextPage = groups.length - pageSize < pageSize * currentPage && lastPage === undefined;
|
||||
|
||||
if (shouldFetchNextPage && !fetchInProgress) {
|
||||
fetchMoreGroups(pageSize);
|
||||
}
|
||||
|
||||
const groupsPage = useMemo(() => {
|
||||
return groups.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
}, [groups, currentPage, pageSize]);
|
||||
|
||||
return { isLoading: fetchInProgress, page: groupsPage, nextPage, previousPage, canMoveForward, canMoveBackward };
|
||||
}
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { usePrevious } from 'react-use';
|
||||
|
||||
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { groupRulesByFileName } from '../../api/prometheus';
|
||||
import { prometheusApi } from '../../api/prometheusApi';
|
||||
import { isLoading, useAsync } from '../../hooks/useAsync';
|
||||
import { getDatasourceAPIUid } from '../../utils/datasource';
|
||||
|
||||
const { useLazyGroupsQuery } = prometheusApi;
|
||||
|
||||
export function usePaginatedPrometheusRuleNamespaces(ruleSourceName: string, pageSize: number) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [groups, setGroups] = useState<PromRuleGroupDTO[]>([]);
|
||||
const [lastPage, setLastPage] = useState<number | undefined>(undefined);
|
||||
|
||||
const { groupsGenerator } = usePrometheusGroupsGenerator(ruleSourceName, pageSize);
|
||||
|
||||
const [{ execute: fetchMoreGroups }, groupsRequestState] = useAsync(async (groupsCount: number) => {
|
||||
let done = false;
|
||||
const currentGroups: PromRuleGroupDTO[] = [];
|
||||
|
||||
while (currentGroups.length < groupsCount) {
|
||||
const group = await groupsGenerator.next();
|
||||
if (group.done) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
|
||||
currentGroups.push(group.value);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
const groupsTotal = groups.length + currentGroups.length;
|
||||
setLastPage(Math.ceil(groupsTotal / pageSize));
|
||||
}
|
||||
|
||||
setGroups((groups) => [...groups, ...currentGroups]);
|
||||
});
|
||||
|
||||
const fetchInProgress = isLoading(groupsRequestState);
|
||||
const canMoveForward = !fetchInProgress && (!lastPage || currentPage < lastPage);
|
||||
const canMoveBackward = currentPage > 1 && !fetchInProgress;
|
||||
|
||||
const nextPage = useCallback(async () => {
|
||||
if (canMoveForward) {
|
||||
setCurrentPage((page) => page + 1);
|
||||
}
|
||||
}, [canMoveForward]);
|
||||
|
||||
const previousPage = useCallback(async () => {
|
||||
if (canMoveBackward) {
|
||||
setCurrentPage((page) => page - 1);
|
||||
}
|
||||
}, [canMoveBackward]);
|
||||
|
||||
// groups.length - pageSize to have one more page loaded to prevent flickering with loading state
|
||||
// lastPage === undefined because 0 is falsy but a value which should stop fetching (e.g for broken data sources)
|
||||
const shouldFetchNextPage = groups.length - pageSize < pageSize * currentPage && lastPage === undefined;
|
||||
|
||||
if (shouldFetchNextPage && !fetchInProgress) {
|
||||
fetchMoreGroups(pageSize);
|
||||
}
|
||||
|
||||
const pageNamespaces = useMemo(() => {
|
||||
const pageGroups = groups.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
// groupRulesByFileName mutates the array and RTKQ query freezes the response data
|
||||
return groupRulesByFileName(structuredClone(pageGroups), ruleSourceName);
|
||||
}, [groups, ruleSourceName, currentPage, pageSize]);
|
||||
|
||||
return { isLoading: fetchInProgress, page: pageNamespaces, nextPage, previousPage, canMoveForward, canMoveBackward };
|
||||
}
|
||||
|
||||
function usePrometheusGroupsGenerator(ruleSourceName: string, pageSize: number) {
|
||||
const [fetchGroups, { isLoading }] = useLazyGroupsQuery();
|
||||
|
||||
const prevRuleSourceName = usePrevious(ruleSourceName);
|
||||
// Generator lazily provides groups one by one only when needed
|
||||
// This might look a bit complex but it allows us to have one API for paginated and non-paginated Prometheus data sources
|
||||
// For unpaginated data sources we just fetch everything in one go
|
||||
// For paginated we fetch the next page when needed
|
||||
const getGroups = useCallback(
|
||||
async function* (ruleSourceName: string, maxGroups: number) {
|
||||
const ruleSourceUid = getDatasourceAPIUid(ruleSourceName);
|
||||
|
||||
const response = await fetchGroups({
|
||||
ruleSource: { uid: ruleSourceUid },
|
||||
groupLimit: maxGroups,
|
||||
});
|
||||
|
||||
if (!response.isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.data) {
|
||||
yield* response.data.data.groups;
|
||||
}
|
||||
|
||||
let lastToken: string | undefined = undefined;
|
||||
if (response.data?.data?.groupNextToken) {
|
||||
lastToken = response.data.data.groupNextToken;
|
||||
}
|
||||
|
||||
while (lastToken) {
|
||||
const response = await fetchGroups({
|
||||
ruleSource: { uid: ruleSourceUid },
|
||||
groupNextToken: lastToken,
|
||||
groupLimit: maxGroups,
|
||||
});
|
||||
|
||||
if (!response.isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.data) {
|
||||
yield* response.data.data.groups;
|
||||
}
|
||||
|
||||
lastToken = response.data?.data?.groupNextToken;
|
||||
}
|
||||
},
|
||||
[fetchGroups]
|
||||
);
|
||||
|
||||
const [groupsGenerator, setGroupsGenerator] = useState<AsyncGenerator<PromRuleGroupDTO, void, unknown>>(
|
||||
getGroups(ruleSourceName, pageSize)
|
||||
);
|
||||
|
||||
const resetGenerator = useCallback(() => {
|
||||
setGroupsGenerator(getGroups(ruleSourceName, pageSize));
|
||||
}, [ruleSourceName, getGroups, pageSize]);
|
||||
|
||||
if (prevRuleSourceName && prevRuleSourceName !== ruleSourceName) {
|
||||
resetGenerator();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentGenerator = groupsGenerator;
|
||||
return () => {
|
||||
currentGenerator.return();
|
||||
};
|
||||
}, [groupsGenerator]);
|
||||
|
||||
return { groupsGenerator, isLoading };
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import {
|
||||
ExternalRulesSourceIdentifier,
|
||||
DataSourceRulesSourceIdentifier as DataSourceRulesSourceIdentifier,
|
||||
GrafanaRulesSourceIdentifier,
|
||||
GrafanaRulesSourceSymbol,
|
||||
RulesSource,
|
||||
RulesSourceUid,
|
||||
@@ -28,6 +29,12 @@ import { getAllDataSources } from './config';
|
||||
export const GRAFANA_RULES_SOURCE_NAME = 'grafana';
|
||||
export const GRAFANA_DATASOURCE_NAME = '-- Grafana --';
|
||||
|
||||
export const GrafanaRulesSource: GrafanaRulesSourceIdentifier = {
|
||||
uid: GrafanaRulesSourceSymbol,
|
||||
name: GRAFANA_RULES_SOURCE_NAME,
|
||||
ruleSourceType: 'grafana',
|
||||
};
|
||||
|
||||
export enum DataSourceType {
|
||||
Alertmanager = 'alertmanager',
|
||||
Loki = 'loki',
|
||||
@@ -214,10 +221,11 @@ export function getAllRulesSourceNames(): string[] {
|
||||
return availableRulesSources;
|
||||
}
|
||||
|
||||
export function getExternalRulesSources(): ExternalRulesSourceIdentifier[] {
|
||||
export function getExternalRulesSources(): DataSourceRulesSourceIdentifier[] {
|
||||
return getRulesDataSources().map((ds) => ({
|
||||
name: ds.name,
|
||||
uid: ds.uid,
|
||||
ruleSourceType: 'datasource',
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isGrafanaRulerRule } from './rules';
|
||||
function fromCombinedRule(rule: CombinedRule): RuleGroupIdentifierV2 {
|
||||
if (isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulesSource(rule.namespace.rulesSource)) {
|
||||
return {
|
||||
rulesSource: { uid: GrafanaRulesSourceSymbol, name: GRAFANA_RULES_SOURCE_NAME },
|
||||
rulesSource: { uid: GrafanaRulesSourceSymbol, name: GRAFANA_RULES_SOURCE_NAME, ruleSourceType: 'grafana' },
|
||||
namespace: { uid: rule.rulerRule.grafana_alert.namespace_uid },
|
||||
groupName: rule.group.name,
|
||||
groupOrigin: 'grafana',
|
||||
@@ -16,7 +16,7 @@ function fromCombinedRule(rule: CombinedRule): RuleGroupIdentifierV2 {
|
||||
const rulesSourceName = getRulesSourceName(rule.namespace.rulesSource);
|
||||
const rulesSourceUid = getDatasourceAPIUid(rulesSourceName);
|
||||
return {
|
||||
rulesSource: { uid: rulesSourceUid, name: rulesSourceName },
|
||||
rulesSource: { uid: rulesSourceUid, name: rulesSourceName, ruleSourceType: 'datasource' },
|
||||
namespace: { name: rule.namespace.name },
|
||||
groupName: rule.group.name,
|
||||
groupOrigin: 'datasource',
|
||||
|
||||
@@ -258,7 +258,7 @@ export function fieldMap(provider: string): Record<string, FieldData> {
|
||||
description: 'The client authentication method used to authenticate to the token endpoint.',
|
||||
multi: false,
|
||||
options: clientAuthenticationOptions(provider),
|
||||
defaultValue: { value: 'client_secret_post', label: 'Client secret' },
|
||||
defaultValue: { value: 'none', label: 'None' },
|
||||
validation: {
|
||||
required: true,
|
||||
message: 'This field is required',
|
||||
@@ -683,11 +683,15 @@ function clientAuthenticationOptions(provider: string): Array<SelectableValue<st
|
||||
switch (provider) {
|
||||
case 'azuread':
|
||||
return [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'client_secret_post', label: 'Client secret' },
|
||||
{ value: 'managed_identity', label: 'Managed identity' },
|
||||
];
|
||||
// Other providers ...
|
||||
default:
|
||||
return [{ value: 'client_secret_post', label: 'Client secret' }];
|
||||
return [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'client_secret_post', label: 'Client secret' },
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,10 @@ export function createFetchCorrelationsResponse<T>(overrides?: DeepPartial<Fetch
|
||||
data: undefined,
|
||||
status: 200,
|
||||
url: '',
|
||||
config: { url: '' },
|
||||
config: {
|
||||
url: '',
|
||||
abortSignal: undefined,
|
||||
},
|
||||
type: 'basic',
|
||||
statusText: 'Ok',
|
||||
redirected: false,
|
||||
|
||||
@@ -76,6 +76,7 @@ export function setupExplore(options?: SetupOptions): {
|
||||
setBackendSrv({
|
||||
datasourceRequest: jest.fn().mockRejectedValue(undefined),
|
||||
delete: jest.fn().mockRejectedValue(undefined),
|
||||
chunked: jest.fn().mockRejectedValue(undefined),
|
||||
fetch: jest.fn().mockImplementation((req) => {
|
||||
let data: Record<string, string | object | number> = {};
|
||||
if (req.url.startsWith('/api/datasources/correlations') && req.method === 'GET') {
|
||||
|
||||
+2
-1
@@ -17,6 +17,7 @@ const types = [
|
||||
{ value: 'logs', label: 'Logs' },
|
||||
{ value: 'fetch', label: 'Fetch' },
|
||||
{ value: 'traces', label: 'Traces' },
|
||||
{ value: 'watch', label: 'Watch' },
|
||||
];
|
||||
|
||||
export const StreamingClientEditor = ({ onChange, query }: EditorProps) => {
|
||||
@@ -34,7 +35,7 @@ export const StreamingClientEditor = ({ onChange, query }: EditorProps) => {
|
||||
const fields =
|
||||
streamType === 'signal'
|
||||
? streamingClientFields
|
||||
: ['logs', 'traces'].includes(streamType)
|
||||
: ['logs', 'traces', 'watch'].includes(streamType)
|
||||
? [streamingClientFields[0]] // speed
|
||||
: [];
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface StreamingQuery {
|
||||
noise: number;
|
||||
speed: number;
|
||||
spread: number;
|
||||
type: 'signal' | 'logs' | 'fetch' | 'traces';
|
||||
type: 'signal' | 'logs' | 'fetch' | 'traces' | 'watch';
|
||||
url?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defaults } from 'lodash';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getDisplayProcessor,
|
||||
createTheme,
|
||||
} from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
|
||||
import { getRandomLine } from './LogIpsum';
|
||||
import { TestDataDataQuery, StreamingQuery } from './dataquery';
|
||||
@@ -44,6 +45,8 @@ export function runStream(
|
||||
return runFetchStream(target, query, req);
|
||||
case 'traces':
|
||||
return runTracesStream(target, query, req);
|
||||
case 'watch':
|
||||
return runWatchStream(target, query, req);
|
||||
}
|
||||
throw new Error(`Unknown Stream Type: ${query.type}`);
|
||||
}
|
||||
@@ -176,6 +179,89 @@ export function runLogsStream(
|
||||
});
|
||||
}
|
||||
|
||||
interface StreamMessage {
|
||||
message: number; // incrementing number
|
||||
time: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function runWatchStream(
|
||||
target: TestDataDataQuery,
|
||||
query: StreamingQuery,
|
||||
req: DataQueryRequest<TestDataDataQuery>
|
||||
): Observable<DataQueryResponse> {
|
||||
const uid = req.targets[0].datasource?.uid;
|
||||
if (!uid) {
|
||||
return throwError(() => new Error('expected datasource uid'));
|
||||
}
|
||||
|
||||
return new Observable<DataQueryResponse>((subscriber) => {
|
||||
const streamId = `watch-${req.panelId || 'explore'}-${target.refId}`;
|
||||
const data = new CircularDataFrame({
|
||||
append: 'tail',
|
||||
capacity: req.maxDataPoints || 1000,
|
||||
});
|
||||
data.refId = target.refId;
|
||||
data.name = target.alias || 'Logs ' + target.refId;
|
||||
data.addField({ name: 'time', type: FieldType.time });
|
||||
data.addField({ name: 'message', type: FieldType.number });
|
||||
data.addField({ name: 'value', type: FieldType.number });
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const sub = getBackendSrv()
|
||||
.chunked({
|
||||
url: `api/datasources/uid/${uid}/resources/stream`,
|
||||
params: {
|
||||
count: req.maxDataPoints || 1000, // connection will close when done
|
||||
format: 'json',
|
||||
speed: `${query.speed ?? 250}ms`,
|
||||
flush: 85, // 85% (eg, sometimes send a few at a time)
|
||||
},
|
||||
})
|
||||
.subscribe({
|
||||
next: (chunk) => {
|
||||
if (!chunk.data || !chunk.ok) {
|
||||
console.info('chunk missing data', chunk);
|
||||
return;
|
||||
}
|
||||
decoder
|
||||
.decode(chunk.data, { stream: true })
|
||||
.split('\n')
|
||||
.forEach((line) => {
|
||||
if (line?.length) {
|
||||
try {
|
||||
const msg: StreamMessage = JSON.parse(line);
|
||||
|
||||
data.fields[0].values.push(msg.time);
|
||||
data.fields[1].values.push(msg.message);
|
||||
data.fields[2].values.push(msg.value);
|
||||
|
||||
subscriber.next({
|
||||
data: [data],
|
||||
key: streamId,
|
||||
state: LoadingState.Streaming,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('error parsing line', line, err);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.warn('error in stream', streamId, err);
|
||||
},
|
||||
complete: () => {
|
||||
console.info('complete stream', streamId);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log('unsubscribing to stream', streamId);
|
||||
sub.unsubscribe();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function runFetchStream(
|
||||
target: TestDataDataQuery,
|
||||
query: StreamingQuery,
|
||||
|
||||
@@ -64,9 +64,20 @@ describe('runSplitQuery()', () => {
|
||||
});
|
||||
|
||||
test('Splits datasource queries', async () => {
|
||||
await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => {
|
||||
await expect(runSplitQuery(datasource, request)).toEmitValuesWith((emitted) => {
|
||||
// 3 days, 3 chunks, 3 requests.
|
||||
expect(datasource.runQuery).toHaveBeenCalledTimes(3);
|
||||
// 3 sub-requests + complete
|
||||
expect(emitted).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
test('Skips partial updates as an option', async () => {
|
||||
await expect(runSplitQuery(datasource, request, { skipPartialUpdates: true })).toEmitValuesWith((emitted) => {
|
||||
// 3 days, 3 chunks, 3 requests.
|
||||
expect(datasource.runQuery).toHaveBeenCalledTimes(3);
|
||||
// partial updates skipped
|
||||
expect(emitted).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +91,16 @@ describe('runSplitQuery()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('Does not retry failed queries as an option', async () => {
|
||||
jest
|
||||
.mocked(datasource.runQuery)
|
||||
.mockReturnValueOnce(of({ state: LoadingState.Error, errors: [{ refId: 'A', message: 'timeout' }], data: [] }));
|
||||
await expect(runSplitQuery(datasource, request, { disableRetry: true })).toEmitValuesWith(() => {
|
||||
// No retries
|
||||
expect(datasource.runQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('Does not retry on other errors', async () => {
|
||||
jest
|
||||
.mocked(datasource.runQuery)
|
||||
|
||||
@@ -48,6 +48,17 @@ export function partitionTimeRange(
|
||||
});
|
||||
}
|
||||
|
||||
interface QuerySplittingOptions {
|
||||
/**
|
||||
* Tells the query splitting code to not emit partial updates. Only emit on error or when it finishes querying.
|
||||
*/
|
||||
skipPartialUpdates?: boolean;
|
||||
/**
|
||||
* Do not retry failed queries.
|
||||
*/
|
||||
disableRetry?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Based in the state of the current response, if any, adjust target parameters such as `maxLines`.
|
||||
* For `maxLines`, we will update it as `maxLines - current amount of lines`.
|
||||
@@ -76,7 +87,11 @@ export function adjustTargetsFromResponseState(targets: LokiQuery[], response: D
|
||||
})
|
||||
.filter((target) => target.maxLines === undefined || target.maxLines > 0);
|
||||
}
|
||||
export function runSplitGroupedQueries(datasource: LokiDatasource, requests: LokiGroupedRequest[]) {
|
||||
export function runSplitGroupedQueries(
|
||||
datasource: LokiDatasource,
|
||||
requests: LokiGroupedRequest[],
|
||||
options: QuerySplittingOptions = {}
|
||||
) {
|
||||
const responseKey = requests.length ? requests[0].request.queryGroupId : uuidv4();
|
||||
let mergedResponse: DataQueryResponse = { data: [], state: LoadingState.Streaming, key: responseKey };
|
||||
const totalRequests = Math.max(...requests.map(({ partition }) => partition.length));
|
||||
@@ -116,6 +131,9 @@ export function runSplitGroupedQueries(datasource: LokiDatasource, requests: Lok
|
||||
};
|
||||
|
||||
const retry = (errorResponse?: DataQueryResponse) => {
|
||||
if (options.disableRetry) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (errorResponse && !isRetriableError(errorResponse)) {
|
||||
return false;
|
||||
@@ -170,13 +188,17 @@ export function runSplitGroupedQueries(datasource: LokiDatasource, requests: Lok
|
||||
shouldStop = true;
|
||||
}
|
||||
mergedResponse = combineResponses(mergedResponse, partialResponse);
|
||||
mergedResponse = updateLoadingFrame(mergedResponse, subRequest, longestPartition, requestN);
|
||||
if (!options.skipPartialUpdates) {
|
||||
mergedResponse = updateLoadingFrame(mergedResponse, subRequest, longestPartition, requestN);
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
if (retrying) {
|
||||
return;
|
||||
}
|
||||
subscriber.next(mergedResponse);
|
||||
if (!options.skipPartialUpdates) {
|
||||
subscriber.next(mergedResponse);
|
||||
}
|
||||
nextRequest();
|
||||
},
|
||||
error: (error) => {
|
||||
@@ -268,7 +290,11 @@ function querySupportsSplitting(query: LokiQuery) {
|
||||
);
|
||||
}
|
||||
|
||||
export function runSplitQuery(datasource: LokiDatasource, request: DataQueryRequest<LokiQuery>) {
|
||||
export function runSplitQuery(
|
||||
datasource: LokiDatasource,
|
||||
request: DataQueryRequest<LokiQuery>,
|
||||
options: QuerySplittingOptions = {}
|
||||
) {
|
||||
const queries = request.targets.filter((query) => !query.hide).filter((query) => query.expr);
|
||||
const [nonSplittingQueries, normalQueries] = partition(queries, (query) => !querySupportsSplitting(query));
|
||||
const [logQueries, metricQueries] = partition(normalQueries, (query) => isLogsQuery(query.expr));
|
||||
@@ -330,7 +356,7 @@ export function runSplitQuery(datasource: LokiDatasource, request: DataQueryRequ
|
||||
}
|
||||
|
||||
const startTime = new Date();
|
||||
return runSplitGroupedQueries(datasource, requests).pipe(
|
||||
return runSplitGroupedQueries(datasource, requests, options).pipe(
|
||||
tap((response) => {
|
||||
if (response.state === LoadingState.Done) {
|
||||
trackGroupedQueries(response, requests, request, startTime, {
|
||||
|
||||
@@ -66,6 +66,24 @@ describe('runShardSplitQuery()', () => {
|
||||
});
|
||||
|
||||
test('Splits datasource queries', async () => {
|
||||
const querySplittingRange = {
|
||||
from: dateTime('2023-02-08T05:00:00.000Z'),
|
||||
to: dateTime('2023-02-10T06:00:00.000Z'),
|
||||
raw: {
|
||||
from: dateTime('2023-02-08T05:00:00.000Z'),
|
||||
to: dateTime('2023-02-10T06:00:00.000Z'),
|
||||
},
|
||||
};
|
||||
request = createRequest([{ expr: '$SELECTOR', refId: 'A', direction: LokiQueryDirection.Scan }], {
|
||||
range: querySplittingRange,
|
||||
});
|
||||
await expect(runShardSplitQuery(datasource, request)).toEmitValuesWith(() => {
|
||||
// 5 shards, 3 groups + empty shard group, 4 requests * 3 days, 3 chunks, 3 requests = 12 requests
|
||||
expect(datasource.runQuery).toHaveBeenCalledTimes(12);
|
||||
});
|
||||
});
|
||||
|
||||
test('Users query splitting for querying over a day', async () => {
|
||||
await expect(runShardSplitQuery(datasource, request)).toEmitValuesWith(() => {
|
||||
// 5 shards, 3 groups + empty shard group, 4 requests
|
||||
expect(datasource.runQuery).toHaveBeenCalledTimes(4);
|
||||
@@ -79,7 +97,8 @@ describe('runShardSplitQuery()', () => {
|
||||
expect(datasource.runQuery).toHaveBeenCalledWith({
|
||||
intervalMs: expect.any(Number),
|
||||
range: expect.any(Object),
|
||||
requestId: 'TEST_shard_0_0_2',
|
||||
queryGroupId: expect.any(String),
|
||||
requestId: 'TEST_shard_0_0_2_1',
|
||||
targets: [
|
||||
{
|
||||
expr: '{a="b", __stream_shard__=~"20|10"}',
|
||||
@@ -92,7 +111,8 @@ describe('runShardSplitQuery()', () => {
|
||||
expect(datasource.runQuery).toHaveBeenCalledWith({
|
||||
intervalMs: expect.any(Number),
|
||||
range: expect.any(Object),
|
||||
requestId: 'TEST_shard_0_2_2',
|
||||
queryGroupId: expect.any(String),
|
||||
requestId: 'TEST_shard_0_2_2_1',
|
||||
targets: [
|
||||
{
|
||||
expr: '{a="b", __stream_shard__=~"3|2"}',
|
||||
@@ -105,7 +125,8 @@ describe('runShardSplitQuery()', () => {
|
||||
expect(datasource.runQuery).toHaveBeenCalledWith({
|
||||
intervalMs: expect.any(Number),
|
||||
range: expect.any(Object),
|
||||
requestId: 'TEST_shard_0_4_1',
|
||||
queryGroupId: expect.any(String),
|
||||
requestId: 'TEST_shard_0_4_1_1',
|
||||
targets: [
|
||||
{
|
||||
expr: '{a="b", __stream_shard__="1"}',
|
||||
@@ -118,7 +139,8 @@ describe('runShardSplitQuery()', () => {
|
||||
expect(datasource.runQuery).toHaveBeenCalledWith({
|
||||
intervalMs: expect.any(Number),
|
||||
range: expect.any(Object),
|
||||
requestId: 'TEST_shard_0_5_1',
|
||||
queryGroupId: expect.any(String),
|
||||
requestId: 'TEST_shard_0_5_1_1',
|
||||
targets: [
|
||||
{
|
||||
expr: '{a="b", __stream_shard__=""}',
|
||||
@@ -147,7 +169,8 @@ describe('runShardSplitQuery()', () => {
|
||||
expect(datasource.runQuery).toHaveBeenCalledWith({
|
||||
intervalMs: expect.any(Number),
|
||||
range: expect.any(Object),
|
||||
requestId: 'TEST_shard_0_0_2',
|
||||
queryGroupId: expect.any(String),
|
||||
requestId: 'TEST_shard_0_0_2_1',
|
||||
targets: [
|
||||
{
|
||||
expr: '{service_name="test", filter="true", __stream_shard__=~"20|10"}',
|
||||
@@ -208,17 +231,6 @@ describe('runShardSplitQuery()', () => {
|
||||
});
|
||||
|
||||
test('Adjusts the group size based on errors and execution time', async () => {
|
||||
const request = createRequest([{ expr: '$SELECTOR', refId: 'A', direction: LokiQueryDirection.Scan }], {
|
||||
range: {
|
||||
from: dateTime('2024-11-13T05:00:00.000Z'),
|
||||
to: dateTime('2024-11-14T06:00:00.000Z'),
|
||||
raw: {
|
||||
from: dateTime('2024-11-13T05:00:00.000Z'),
|
||||
to: dateTime('2024-11-14T06:00:00.000Z'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(datasource.languageProvider.fetchLabelValues)
|
||||
.mockResolvedValue(['1', '10', '2', '20', '3', '4', '5', '6', '7', '8', '9']);
|
||||
@@ -373,7 +385,8 @@ describe('runShardSplitQuery()', () => {
|
||||
expect(datasource.runQuery).toHaveBeenCalledWith({
|
||||
intervalMs: expect.any(Number),
|
||||
range: expect.any(Object),
|
||||
requestId: 'TEST_shard_0_0_3',
|
||||
queryGroupId: expect.any(String),
|
||||
requestId: 'TEST_shard_0_0_3_1',
|
||||
targets: [
|
||||
{
|
||||
expr: '{a="b", __stream_shard__=~"20|10|9"}',
|
||||
@@ -387,7 +400,8 @@ describe('runShardSplitQuery()', () => {
|
||||
expect(datasource.runQuery).toHaveBeenCalledWith({
|
||||
intervalMs: expect.any(Number),
|
||||
range: expect.any(Object),
|
||||
requestId: 'TEST_shard_0_3_4',
|
||||
queryGroupId: expect.any(String),
|
||||
requestId: 'TEST_shard_0_3_4_1',
|
||||
targets: [
|
||||
{
|
||||
expr: '{a="b", __stream_shard__=~"8|7|6|5"}',
|
||||
@@ -401,7 +415,8 @@ describe('runShardSplitQuery()', () => {
|
||||
expect(datasource.runQuery).toHaveBeenCalledWith({
|
||||
intervalMs: expect.any(Number),
|
||||
range: expect.any(Object),
|
||||
requestId: 'TEST_shard_0_3_2',
|
||||
queryGroupId: expect.any(String),
|
||||
requestId: 'TEST_shard_0_3_2_1',
|
||||
targets: [
|
||||
{
|
||||
expr: '{a="b", __stream_shard__=~"8|7"}',
|
||||
@@ -415,7 +430,8 @@ describe('runShardSplitQuery()', () => {
|
||||
expect(datasource.runQuery).toHaveBeenCalledWith({
|
||||
intervalMs: expect.any(Number),
|
||||
range: expect.any(Object),
|
||||
requestId: 'TEST_shard_0_5_3',
|
||||
queryGroupId: expect.any(String),
|
||||
requestId: 'TEST_shard_0_5_3_1',
|
||||
targets: [
|
||||
{
|
||||
expr: '{a="b", __stream_shard__=~"6|5|4"}',
|
||||
@@ -429,7 +445,8 @@ describe('runShardSplitQuery()', () => {
|
||||
expect(datasource.runQuery).toHaveBeenCalledWith({
|
||||
intervalMs: expect.any(Number),
|
||||
range: expect.any(Object),
|
||||
requestId: 'TEST_shard_0_8_2',
|
||||
queryGroupId: expect.any(String),
|
||||
requestId: 'TEST_shard_0_8_2_1',
|
||||
targets: [
|
||||
{
|
||||
expr: '{a="b", __stream_shard__=~"3|2"}',
|
||||
@@ -443,7 +460,8 @@ describe('runShardSplitQuery()', () => {
|
||||
expect(datasource.runQuery).toHaveBeenCalledWith({
|
||||
intervalMs: expect.any(Number),
|
||||
range: expect.any(Object),
|
||||
requestId: 'TEST_shard_0_10_1',
|
||||
queryGroupId: expect.any(String),
|
||||
requestId: 'TEST_shard_0_10_1_1',
|
||||
targets: [
|
||||
{
|
||||
expr: '{a="b", __stream_shard__="1"}',
|
||||
@@ -457,7 +475,8 @@ describe('runShardSplitQuery()', () => {
|
||||
expect(datasource.runQuery).toHaveBeenCalledWith({
|
||||
intervalMs: expect.any(Number),
|
||||
range: expect.any(Object),
|
||||
requestId: 'TEST_shard_0_11_1',
|
||||
queryGroupId: expect.any(String),
|
||||
requestId: 'TEST_shard_0_11_1_1',
|
||||
targets: [
|
||||
{
|
||||
expr: '{a="b", __stream_shard__=""}',
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user