merge main

This commit is contained in:
Ryan McKinley committed 2025-01-15 19:38:09 +03:00
commit f7639f4520
107 files changed
+3786 -1047

No files matched your search

+4
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -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
+2 -3
View File
@@ -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 }}
+1
View File
@@ -2,3 +2,4 @@ MinAlertLevel = warning
[*]
BasedOnStyles = Grafana
TokenIgnores = (<http[^\n]+>+?), \*\*[^\n]+\*\*
+12 -6
View File
@@ -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.
+1
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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;
-5
View File
@@ -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) {
+21 -6
View File
@@ -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()
+4 -1
View File
@@ -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.")
}
+1
View File
@@ -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...
+5 -2
View File
@@ -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)
}
+23 -3
View File
@@ -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)
})
}
+5 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+15 -5
View File
@@ -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
+100
View File
@@ -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 {
+11 -26
View File
@@ -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)
}
+17 -12
View File
@@ -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,
+4 -1
View File
@@ -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"
+1 -1
View File
@@ -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,
},
})
}
+1 -2
View File
@@ -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:
+3 -1
View File
@@ -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)
}
+2
View File
@@ -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 = ?",
+24 -1
View File
@@ -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
+1
View File
@@ -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
+2 -2
View File
@@ -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=
+3 -3
View File
@@ -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"
}
})
+2
View File
@@ -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
}
+2
View File
@@ -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
+2 -1
View File
@@ -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=
+7 -3
View File
@@ -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)
+13
View File
@@ -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)
}
+23 -4
View File
@@ -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
}
+53 -10
View File
@@ -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)
+153
View File
@@ -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": {
+2 -2
View File
@@ -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 -8
View File
@@ -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",
+8 -1
View File
@@ -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),
}
+8
View File
@@ -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') {
+77
View File
@@ -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);
+1
View File
@@ -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,
@@ -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 };
}
@@ -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',
+6 -2
View File
@@ -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') {
@@ -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