Compare commits

..
Author SHA1 Message Date
Georges Chaudy f634f37b5f Enhance SQL KV store with snowflake ID support and resource version handling
- Updated the parsedKey struct to include ResourceVersionSnowflake for storing the original snowflake ID.
- Implemented snowflakeToMicroseconds function to convert snowflake IDs to microsecond timestamps for database storage.
- Modified parseDataKey function to utilize the new microsecond timestamp for ResourceVersion.
- Enhanced SQL queries in upsertResourceVersion to conditionally update resource versions based on the new timestamp logic.

These changes improve the accuracy and precision of resource versioning in the SQL KV store, enhancing data integrity and management.
2025-11-18 12:42:56 +01:00
Georges Chaudy f5ef25233d Enhance SQL KV store and backend diagnostics
- Updated sqlKV struct to maintain a reference to the dbProvider, preventing garbage collection of the database connection.
- Implemented Ping and checkDB methods in sqlKV for verifying database connection health.
- Enhanced kvStorageBackend to implement the DiagnosticsServer interface, adding IsHealthy method for health checks.
- Updated resource server to utilize the new diagnostics capabilities of kvStorageBackend.

These changes improve the reliability and maintainability of the SQL-based KV store and its integration with the resource server.
2025-11-18 12:12:05 +01:00
Georges Chaudy e156dfd92a Implement lifecycle management for kvStorageBackend
- Added context and cancel function to kvStorageBackend for lifecycle management.
- Implemented Init and Stop methods to manage background tasks and context cancellation.
- Updated resource server to utilize the kvStorageBackend's lifecycle hooks.

This change enhances the management of background processes within the kvStorageBackend, improving resource handling and cleanup.
2025-11-18 11:40:48 +01:00
Georges Chaudy fa3d906d41 Add unifiedStorageKVBackend feature toggle
- Introduced the `unifiedStorageKVBackend` feature toggle to enable the use of a KV-backed SQL storage backend instead of direct SQL queries.
- Updated relevant files to include the new feature toggle in the registry, CSV, JSON, and Go definitions.
- Enhanced the resource server logic to conditionally use the KV backend based on the feature flag.

This change expands the storage options available for the application, improving flexibility in data management.
2025-11-18 11:35:17 +01:00
Georges Chaudy a8c86de2d6 Add SQL-based KV store implementation
- Introduced a new `sqlkv.go` file implementing the KV interface using SQL storage.
- Added methods for creating, retrieving, updating, and deleting key-value pairs in a SQL database.
- Implemented support for multiple SQL dialects (MySQL, PostgreSQL, SQLite).
- Included functionality for batch operations and key parsing.

This change lays the foundation for a robust SQL-based key-value storage solution, enhancing data management capabilities.
2025-11-18 11:27:10 +01:00
Georges Chaudy 761a8f6c31 Enhance resource_history SQL update to include key_path column
- Updated SQL to set key_path based on resource attributes and action type.
- Removed the migration to make key_path NOT NULL, keeping it nullable to accommodate specific write patterns.
- Adjusted comments to clarify the rationale behind key_path's nullable status.

This change improves the handling of resource history updates and maintains flexibility in data management.
2025-11-17 17:39:50 +01:00
Georges Chaudy 3c0fc606ae Add key_path column and backfill for resource_history table
- Introduced key_path column to resource_history for KV interface.
- Implemented backfill logic for key_path using a new migrator.
- Made key_path column NOT NULL after backfill completion.
- Added index on key_path column for improved query performance.
- Created resource_events table to support KV interface.

This change enhances the resource history management and optimizes data retrieval.
2025-11-17 14:57:18 +01:00
72 changed files with 2495 additions and 2622 deletions
+1 -4
View File
@@ -8,9 +8,7 @@ name: "CodeQL checks"
on:
workflow_dispatch:
push:
branches:
- main
- release-*.*.*
branches: ['**'] # run on all branches
paths-ignore:
- '**/*.cue'
- '**/*.json'
@@ -76,7 +74,6 @@ jobs:
name: Set go version
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00
with:
cache: false
go-version-file: go.mod
# Initializes the CodeQL tools for scanning.
+1 -1
View File
@@ -77,7 +77,7 @@ services:
GF_TRACING_OPENTELEMETRY_OTLP_PROPAGATION: jaeger,w3c
postgres:
image: postgres:17.2-alpine3.19@sha256:d98de4f4959fb6b4531f3fb0cfe92aa49f799e5ebc7a086ae7775b59e7d534e7
image: postgres:16.1-alpine3.19@sha256:17eb369d9330fe7fbdb2f705418c18823d66322584c77c2b43cc0e1851d01de7
environment:
POSTGRES_USER: grafana
POSTGRES_PASSWORD: grafana
@@ -110,6 +110,39 @@ The Grafana Operator is particularly fitting for:
While the Grafana Operator simplifies many aspects of operating Grafana and its resources on Kubernetes, its current support is mainly focused on managing dashboards, folders, and data sources. Advanced features like alerting and plugins (only works for OSS) are not supported yet.
## Grizzly
[Grizzly](https://grafana.github.io/grizzly/) is a command line tool that allows you to manage your observability resources with code. Grizzly supports Kubernetes-inspired YAML representation for the Grafana resource, which makes it easier to learn. With Grizzly, you can move dashboards within Grafana instances and also retrieve information about already provisioned Grafana resources. Grizzly currently supports:
- Grafana dashboards and dashboard folders
- Grafana data sources
- Prometheus recording rules and alerts in Grafana Cloud
- Grafana Cloud Synthetic Monitoring checks
Grizzly can also deploy dashboards built in Jsonnet using Grafonnet. (Learn more in the [Grafonnet documentation](https://grafana.github.io/grafonnet-lib/api-docs/).)
The following example shows a Kubernetes-style Grizzly configuration for creating a dashboard:
```yaml
apiVersion: grizzly.grafana.com/v1alpha1
kind: Dashboard
metadata:
name: as-code-dashboard
spec:
title: as-code dashboard
uid: ascode
```
To get started, see the [Grizzly guides](grizzly/dashboards-folders-datasources/) or refer to the [Grizzlys documentation](https://grafana.github.io/grizzly/).
### Who is this recommended for?
Grizzly is best suited for users who are either using Jsonnet to manage Grafana resources or those who prefer a Kubernetes-style YAML definition of their Grafana resources.
### Known limitations
Grizzly currently doesnt support Grafana OnCall and Grafana Alerting resources.
## Grafana Crossplane provider
[Grafana Crossplane provider](https://github.com/grafana/crossplane-provider-grafana) is built using Terrajet and provides support for all resources supported by the Grafana Terraform provider. It enables users to define Grafana resources as Kubernetes manifests and it also help users who build their GitOps pipelines around Kubernetes manifests using tools like ArgoCD.
@@ -12,22 +12,22 @@ canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-cod
# Grafana Operator
The [Grafana Operator](https://grafana.github.io/grafana-operator/) is a Kubernetes operator built to help you manage your Grafana instances and its resources in a Kubernetes environment. The Grafana Operator automatically syncs Kubernetes custom resources and actual resources in your Grafana instance, and allows you to install and manage local Grafana instances, dashboards and data sources in Kubernetes or OpenShift.
[Grafana Operator](https://grafana.github.io/grafana-operator/) is a Kubernetes operator built to help you manage your Grafana instances and its resources from within Kubernetes. The Operator can install and manage local Grafana instances, Dashboards and Datasources through Kubernetes/OpenShift Custom resources. The Grafana Operator Automatically syncs the Kubernetes Custom resources and the actual resources in the Grafana Instance.
## Install the Grafana Operator
## Installing the Grafana Operator
To install the Grafana Operator in your Kubernetes cluster, run the following command in your terminal:
To install the Grafana Operator in your Kubernetes cluster, Run the following command in your terminal:
```
helm repo add grafana https://grafana.github.io/helm-charts
helm upgrade -i grafana-operator grafana/grafana-operator
```
For other installation methods, refer to the [Grafana Operator Installation](https://grafana.github.io/grafana-operator/docs/installation/) documentation.
For other installation methods, Refer [Grafana Operator Installation Documentation](https://grafana.github.io/grafana-operator/docs/installation/).
## Use the Grafana Operator
## Getting Started
Use the following guides to use the Grafana Operator to manage your Grafana instance:
Use the following guide to get started with using Grafana Operator to manage your Grafana instance:
- [Manage data sources, and dashboards with folders using the Grafana Operator](operator-dashboards-folders-datasources/) describes how to add a folders, data sources, and dashboards, using the [Grafana Operator](https://grafana.github.io/grafana-operator/).
- [Manage Dashboards with GitOps Using ArgoCD](manage-dashboards-argocd/) describes how to create and manage dashboards using ArgoCD and [Grafana Operator](https://grafana.github.io/grafana-operator/).
@@ -5,12 +5,10 @@ keywords:
- Grafana Cloud
- Grizzly
- CLI
menuTitle: Grizzly (deprecated)
menuTitle: Grizzly
title: Grizzly
weight: 200
weight: 130
canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/grizzly/
aliases:
- ../infrastructure-as-code/grizzly/dashboards-folders-datasources
---
# Grizzly (deprecated)
@@ -20,3 +18,5 @@ Grizzly has been removed. It is no longer deployed, enhanced, or supported.
Use the [Grafana CLI](/docs/grafana/<GRAFANA_VERSION>/observability-as-code/grafana-cli/) instead.
{{< /admonition >}}
[Grizzly](https://grafana.github.io/grizzly/) is a command line tool that allows you to manage your observability resources with code. You can use it to manage dashboards, data sources, Prometheus rules, and Synthetic monitoring.
@@ -0,0 +1,149 @@
---
keywords:
- Infrastructure as Code
- Quickstart
- Grafana Cloud
- Grizzly
title: Creating and managing folders, data sources, and dashboards using Grizzly
weight: 100
canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/grizzly/dashboards-folders-datasources/
---
# Creating and managing folders, data sources, and dashboards using Grizzly
Learn how to add a data sources, folders and dashboard, using Grizzly.
## Prerequisites
Before you begin, you should have the following available:
- A Grafana Cloud account
- An existing Grafana Cloud stack with a Grafana API Key
- [Grizzly](https://grafana.github.io/grizzly/installation/) installed on your machine
## Authentication Setup
To authenticate with the Grizzly API, you must create environment variables. Run the following commands to create environment variables named `GRAFANA_URL` and `GRAFANA_TOKEN`:
```shell
export GRAFANA_URL=<Grafana-instance-url>
export GRAFANA_TOKEN=<Grafana-API-Key>
```
Replace the following field values:
- `<Grafana-instance-url>` with the URL of your Grafana instance.
- `<Grafana-API-Key>` with API key from the Grafana instance.
## Add a data source
The following steps use the InfluxDB data source. The required arguments vary depending on the data source you select.
1. Create a file named `data-source.yml` and add the following:
```yaml
apiVersion: grizzly.grafana.com/v1alpha1
kind: Datasource
metadata:
name: <data-source-name>
spec:
name: <data-source-name>
type: influxdb
url: <data-source-url>
database: <db-name>
user: <username>
secureJsonData:
password: '<password>'
uid: <uid>
id: <id>
access: proxy
```
1. Replace the following field values:
- `<data-source-name>` with the name of the data source to be added in Grafana.
- `<data-source-url>` with URL of your data source.
- `<username>` with the username for authenticating with your data source.
- `<password>` with the password for authenticating with your data source.
- `<db-name>` with name of your database.
- `<id>` with the ID for your data source in Grafana.
- `<uid>` wth the UID for your data source in Grafana.
## Add a folder
The following YAML definition creates a folder in your Grafana instance.
1. Create a file named `folder.yml` and add the following:
```yaml
apiVersion: grizzly.grafana.com/v1alpha1
kind: DashboardFolder
metadata:
name: <folder-name>
spec:
title: <folder-name>
uid: <uid>
```
1. Replace the following field values:
- `<folder-name>` with the name of the folder to be added in Grafana.
- `<uid>` with the UID for your folder in Grafana.
## Add a dashboard to the folder
Use the following YAML definition to create a simple dashboard in the Grafana instance folder from the previous step. To add more than a title and UID to the dashboard, you can convert your dashboard JSON config to YAML and paste it under `spec`.
1. Create a file named `dashboard.yml` and add the following:
```yaml
apiVersion: grizzly.grafana.com/v1alpha1
kind: Dashboard
metadata:
folder: <folder-name>
name: influxdb-cloud-demos
spec:
title: InfluxDB Cloud Demos
uid: influxdb-cloud-demos
```
1. Replace the following field values:
- `<folder-name>` with the name of the folder created in the previous step.
## Using Grizzly CLI
In a terminal, run the following commands from the directory where all of the YAML definitions are located.
1. Add the data source.
```shell
grr apply data-source.yml
```
1. Add a folder.
```shell
grr apply folder.yml
```
1. Add a dashboard to the folder.
```shell
grr apply dashboard.yml
```
## Validation
Once you apply the configurations using the Grizzly CLI, you should be able to verify the following:
- A new data source (InfluxDB in this example) is visible in Grafana.
![InfluxDB datasource](/static/img/docs/grafana-cloud/terraform/influxdb_datasource_tf.png)
- A new dashboard and folder in Grafana. In the following image a dashboard named `InfluxDB Cloud Demos` was created inside the `Demos` folder.
![InfluxDB dashboard](/static/img/docs/grafana-cloud/grizzly/grizzly-folder-dashboard-datasource.png)
## Conclusion
In this guide, you created a data source, folder, and dashboard using Grizzly.
To learn more about managing Grafana using Grizzly, see the [Grizzly documentation](https://grafana.github.io/grizzly/).
@@ -1,5 +1,5 @@
---
description: Overview of Observability as code including description, key features, and explanation of benefits.
description: Overview of Observability as Code including description, key features, and explanation of benefits.
keywords:
- observability
- configuration
@@ -13,26 +13,30 @@ labels:
- enterprise
- oss
- cloud
title: Observability as code
title: Observability as Code
weight: 100
cards:
items:
- title: Get started
height: 24
href: ./get-started/
description: Learn about how you can use Observability as Code.
- title: Grafana CLI
height: 24
href: ./grafana-cli/
description: Grafana CLI (`grafanactl`) is a command-line tool designed to simplify interaction with Grafana instances using the new REST APIs. You can authenticate, manage multiple environments, and perform administrative tasks from the terminal. It's suitable for CI/CD pipelines, local development, or free-form tasks.
- title: Foundation SDK
height: 24
href: ./foundation-sdk/
description: The Grafana Foundation SDK is a set of tools, types, and libraries that let you define Grafana dashboards and resources using familiar programming languages like Go, TypeScript, Python, Java, and PHP. Use it in conjunction with `grafanactl` to push your programmatically generated resources.
description: Grafana CLI (`grafanactl`) is a command-line tool designed to simplify interaction with Grafana instances. You can authenticate, manage multiple environments, and perform administrative tasks through Grafanas REST API, all from the terminal.
- title: JSON schema v2
height: 24
href: ./schema-v2/
description: Grafana dashboards are represented as JSON objects that store metadata, panels, variables, and settings. Observability as Code works with all versions of the JSON model, and it's fully compatible with version 2.
- title: Foundation SDK
height: 24
href: ./foundation-sdk/
description: The Grafana Foundation SDK is a set of tools, types, and libraries that let you define Grafana dashboards and resources using strongly typed code.
- title: Git Sync (private preview)
height: 24
href: ./provision-resources/intro-git-sync/
description: Git Sync lets you store your dashboard files in a GitHub repository and synchronize those changes with your Grafana instance, enabling version control, branching, and pull requests directly from Grafana.
description: Git Sync is an experimental feature that lets you store your dashboard files in a GitHub repository and synchronize those changes with your Grafana instance.
- title: File provisioning (private preview)
height: 24
href: ./provision-resources/
@@ -47,13 +51,6 @@ hero:
canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/
aliases:
- ../observability-as-code/ # /docs/grafana/next/observability-as-code/
- ../observability-as-code/get-started/
refs:
infra-as-code:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/as-code/infrastructure-as-code/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/as-code/infrastructure-as-code/
---
{{< docs/hero-simple key="hero" >}}
@@ -62,31 +59,58 @@ refs:
## Overview
Grafana provides a suite of tools for **Observability as code** to help you manage your Grafana resources programmatically and at scale. This approach lets you define dashboards, data sources, and other configurations in code, enabling version control, automated testing, and reliable deployments through CI/CD pipelines. You can apply code management best practices to your observability resources, and integrate them into existing infrastructure-as-code workflows.
Observability as Code lets you apply code management best practices to your observability resources.
By representing Grafana resources as code, you can integrate them into existing infrastructure-as-code workflows and apply standard development practices.
Historically, managing Grafana as code involved various community and Grafana Labs tools, but lacked a single, cohesive story. Grafana 12 introduces foundational improvements, including new versioned APIs and official tooling, to provide a clearer path forward:
Observability as Code provides more control over configuration. Instead of manually configuring dashboards or settings through the Grafana UI, you can:
- This approach requires handling HTTP requests and responses but provides complete control over resource management.
- `grafanactl`, Git Sync, and the Foundation SDK are all built on top of these APIs.
- To understand Dashboard Schemas accepted by the APIs, refer to the [JSON models documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/observability-as-code/schema-v2/).
- Write configurations in code: Define dashboards in JSON or other supported formats.
- Sync your Grafana setup to GitHub: Track changes, collaborate, and roll back updates using Git and GitHub, or other remote sources.
- Automate with CI/CD: Integrate Grafana directly into your development and deployment pipelines.
- Standardize workflows: Ensure consistency across your teams by using repeatable, codified processes for managing Grafana resources.
## Explore
{{< card-grid key="cards" type="simple" >}}
## Additional Observability as code tools
<!-- Hiding this part of the doc because the rest of the docs aren't released yet
If you're already using established [Infrastructure as code](ref:infra-as-code) or other configuration management tools, Grafana offers integrations to manage resources within your existing workflows.
## Key features
- [Terraform](https://grafana.com/docs/grafana-cloud/as-code/infrastructure-as-code/terraform/)
- Use the Grafana Terraform provider to manage dashboards, alerts, and more.
- Understand how to define and deploy resources using HCL/JSON configurations.
- [Ansible](https://grafana.com/docs/grafana-cloud/as-code/infrastructure-as-code/ansible/)
- Learn to use the Grafana Ansible collection to manage Grafana Cloud resources, including folders and cloud stacks.
- Write playbooks to automate resource provisioning through the Grafana API.
- [Grafana Operator](https://grafana.com/docs/grafana-cloud/as-code/infrastructure-as-code/grafana-operator/)
- Utilize Kubernetes-native management with the Grafana Operator.
- Manage dashboards, folders, and data sources via Kubernetes Custom Resources.
- Integrate with GitOps workflows for seamless version control and deployment.
- [Crossplane](https://github.com/grafana/crossplane-provider-grafana) lets you manage Grafana resources using Kubernetes manifests with the Grafana Crossplane provider.
- [Grafonnet](https://github.com/grafana/grafonnet) is a Jsonnet library for generating Grafana dashboard JSON definitions programmatically.
At this time, Observability as Code lets you configure dashboards in static files rather than using the UI.
The number of resources covered by this approach will expand over time.
### App Platform: A unified foundation
The [App Platform](https://github.com/grafana/grafana-app-sdk) is the backbone of Observability as Code. It provides consistent APIs for managing Grafana resources like dashboards, data sources, and service-level objectives (SLOs). With the App Platform, you gain:
- A stable and predictable API for integrating Grafana into your systems.
- Support for cloud-native workflows, making it easier to build and scale observability solutions.
- The ability to manage Grafana resources programmatically.
- Backwards compatibility with earlier versions of Grafana APIs, so older applications still work.
### Git integration
Version control is at the heart of Observability as Code. By integrating Grafana with Git, you can:
- Store your dashboards in a Git repository.
- Automatically deploy changes through CI/CD pipelines.
- Track who made changes, when they were made, and why.
### Enhanced dashboard management
Dashboards are central to Grafanas value, and Observability as Code introduces improvements to make them easier to work with:
- **Ready for Schema v2:** An experimental dashboard schema that simplifies dashboards definition, separating properties for better clarity and making configurations more intuitive.
- **New layout options:** Flexible layouts, including a new responsive grid layout that allow for more dynamic and responsive panel layouts.
- **Improved metadata management:** Add descriptions, tags, and other metadata to better organize and understand your dashboards.
### Tooling and integrations
Observability as Code comes with tools to make your workflows seamless:
- Examples and best practices for integrating Grafana with tools like Terraform, Kubernetes, and GitHub Actions.
- The Foundation SDK provides a set of libraries for getting started quickly configuring and manipulating Grafana resources.
- A command line tool for configuring your dashboards programmatically.
- Documentation, videos, and SDKs to help you get started quickly.
-->
@@ -0,0 +1,88 @@
---
description: Get started with Observability as Code by exploring the documentation, libraries, and tools available for as-code practices.
keywords:
- configuration
- as code
- as-code
- dashboards
- Git Sync
- Git
labels:
products:
- enterprise
- oss
title: Get started with Observability as Code
weight: 100
canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/get-started/
aliases:
- ../../observability-as-code/get-started/ # /docs/grafana/next/observability-as-code/get-started/
---
# Get started with Observability as Code
Grafana provides a suite of tools for **Observability as Code** to help you manage your Grafana resources programmatically and at scale. This approach lets you define dashboards, data sources, and other configurations in code, enabling version control, automated testing, and reliable deployments through CI/CD pipelines.
Historically, managing Grafana as code involved various community and Grafana Labs tools, but lacked a single, cohesive story. Grafana 12 introduces foundational improvements, including new versioned APIs and official tooling, to provide a clearer path forward.
## Grafana CLI (`grafanactl`)
Use the official command-line tool, `grafanactl`, to interact with your Grafana instances and manage resources via the new APIs.
- It's the recommended tool for automation and direct API interaction, suitable for CI/CD pipelines and local development or free-form tasks. It supports pulling/pushing configurations from remote instances, validating configurations, and more.
- `grafanactl` works across all environments for Grafana OSS, Enterprise, and Cloud.
Refer to the [Grafana CLI (`grafanactl`)](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/observability-as-code/grafana-cli) documentation for more information.
## Git Sync
For an integrated, UI-driven Git workflow focused on dashboards, explore Git Sync.
- Connect folders or entire Grafana instances directly to a GitHub repository to synchronize dashboard definitions, enabling version control, branching, and pull requests directly from Grafana.
- Git Sync offers a simple, out-of-the-box approach for managing dashboards as code.
{{< admonition type="note" >}}
Git Sync is available in **private preview** for Grafana Cloud, and it's an **experimental feature** in Grafana 12, available in Grafana OSS and Enterprise [nightly releases](https://grafana.com/grafana/download/nightly).
{{< /admonition >}}
Refer to the [Git Sync documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/observability-as-code/provision-resources/intro-git-sync/) to learn more.
## Direct API usage
For maximum flexibility, advanced use cases, or building custom tooling, you can interact directly with the underlying versioned APIs.
- This approach requires handling HTTP requests and responses but provides complete control over resource management.
- `grafanactl`, Git Sync, and the Foundation SDK are all built on top of these APIs.
- To understand Dashboard Schemas accepted by the APIs, refer to the [JSON models documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/observability-as-code/schema-v2/).
Refer to the [Grafana APIs](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/developers/http_api/apis/) documentation for more information.
## Foundation SDK
To programmatically define your Grafana resources (like dashboards or alerts) using familiar programming languages, use Foundation SDK.
- Define resources using strongly typed builders in languages like Go, TypeScript, Python, Java, and PHP.
- Avoid crafting complex JSON manually and integrate resource generation into your existing development workflows.
- Catch errors at compile time and easily integrate resource generation into your CI/CD pipelines.
- Use in conjunction with `grafanactl` to push your programmatically generated resources.
Refer to the [Foundation SDK](../foundation-sdk) documentation for more information.
## Additional Observability as Code tools
If you're already using established Infrastructure as Code or other configuration management tools, Grafana offers integrations to manage resources within your existing workflows.
- [Terraform](https://grafana.com/docs/grafana-cloud/as-code/infrastructure-as-code/terraform/)
- Use the Grafana Terraform provider to manage dashboards, alerts, and more.
- Understand how to define and deploy resources using HCL/JSON configurations.
- [Ansible](https://grafana.com/docs/grafana-cloud/as-code/infrastructure-as-code/ansible/)
- Learn to use the Grafana Ansible collection to manage Grafana Cloud resources, including folders and cloud stacks.
- Write playbooks to automate resource provisioning through the Grafana API.
- [Grafana Operator](https://grafana.com/docs/grafana-cloud/as-code/infrastructure-as-code/grafana-operator/)
- Utilize Kubernetes-native management with the Grafana Operator.
- Manage dashboards, folders, and data sources via Kubernetes Custom Resources.
- Integrate with GitOps workflows for seamless version control and deployment.
- [Crossplane](https://github.com/grafana/crossplane-provider-grafana) lets you manage Grafana resources using Kubernetes manifests with the Grafana Crossplane provider.
- [Grafonnet](https://github.com/grafana/grafonnet) is a Jsonnet library for generating Grafana dashboard JSON definitions programmatically.
- [Grizzly](https://grafana.com/docs/grafana-cloud/as-code/infrastructure-as-code/grizzly/dashboards-folders-datasources/) is a deprecated command-line tool that simplifies managing Grafana resources using Kubernetes-inspired YAML syntax.
@@ -30,7 +30,7 @@ cards:
title: Manage resources with Grafana CLI
title_class: pt-0 lh-1
hero:
description: Grafana CLI (`grafanactl`) is a command-line tool designed to simplify interaction with Grafana instances. It enables users to authenticate, manage multiple environments, and perform administrative tasks through Grafanas REST API, all from the terminal. Whether you're automating workflows in CI/CD pipelines or switching between staging and production environments, Grafana CLI provides a flexible and scriptable way to manage your Grafana setup efficiently. `grafanactl` works across all environments for Grafana OSS, Enterprise, and Cloud.
description: Grafana CLI (`grafanactl`) is a command-line tool designed to simplify interaction with Grafana instances. It enables users to authenticate, manage multiple environments, and perform administrative tasks through Grafanas REST API, all from the terminal. Whether you're automating workflows in CI/CD pipelines or switching between staging and production environments, Grafana CLI provides a flexible and scriptable way to manage your Grafana setup efficiently.
height: 110
level: 1
title: Grafana CLI
@@ -25,7 +25,7 @@ aliases:
Git Sync is available in [private preview](https://grafana.com/docs/release-life-cycle/) for Grafana Cloud. Support and documentation is available but might be limited to enablement, configuration, and some troubleshooting. No SLAs are provided. You can sign up to the private preview using the [Git Sync early access form](https://forms.gle/WKkR3EVMcbqsNnkD9).
Git Sync and local file provisioning are [experimental features](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions available in [nightly releases](https://grafana.com/grafana/download/nightly). Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided.
Git Sync and local file provisioning are [experimental features](https://grafana.com/docs/release-life-cycle/) introduced in Grafana v12 for open source and Enterprise editions. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided.
{{< /admonition >}}
@@ -79,32 +79,31 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
[Public preview](https://grafana.com/docs/release-life-cycle/#public-preview) features are supported by our Support teams, but might be limited to enablement, configuration, and some troubleshooting.
| Feature toggle name | Description |
| --------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `panelTitleSearch` | Search for dashboards using panel title |
| `grpcServer` | Run the GRPC server |
| `renderAuthJWT` | Uses JWT-based auth for rendering instead of relying on remote cache |
| `refactorVariablesTimeRange` | Refactor time range variables flow to reduce number of API calls made when query variables are chained |
| `faroDatasourceSelector` | Enable the data source selector within the Frontend Apps section of the Frontend Observability |
| `enableDatagridEditing` | Enables the edit functionality in the datagrid panel |
| `reportingRetries` | Enables rendering retries for the reporting feature |
| `externalServiceAccounts` | Automatic service account and token setup for plugins |
| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches |
| `pdfTables` | Enables generating table data as PDF in reporting |
| `canvasPanelPanZoom` | Allow pan and zoom in canvas panel |
| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage. Default is enabled. |
| `sqlExpressions` | Enables SQL Expressions, which can execute SQL queries against data source results. |
| `queryLibrary` | Enables Saved queries (query library) feature |
| `enableSCIM` | Enables SCIM support for user and group management |
| `alertRuleRestore` | Enables the alert rule restore feature |
| `azureMonitorLogsBuilderEditor` | Enables the logs builder mode for the Azure Monitor data source |
| `localeFormatPreference` | Specifies the locale so the correct format for numbers and dates can be shown |
| `logsPanelControls` | Enables a control component for the logs panel in Explore |
| `interactiveLearning` | Enables the interactive learning app |
| `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker |
| `newVizSuggestions` | Enable new visualization suggestions |
| `preventPanelChromeOverflow` | Restrict PanelChrome contents with overflow: hidden; |
| `transformationsEmptyPlaceholder` | Show transformation quick-start cards in empty transformations state |
| Feature toggle name | Description |
| ------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `panelTitleSearch` | Search for dashboards using panel title |
| `grpcServer` | Run the GRPC server |
| `renderAuthJWT` | Uses JWT-based auth for rendering instead of relying on remote cache |
| `refactorVariablesTimeRange` | Refactor time range variables flow to reduce number of API calls made when query variables are chained |
| `faroDatasourceSelector` | Enable the data source selector within the Frontend Apps section of the Frontend Observability |
| `enableDatagridEditing` | Enables the edit functionality in the datagrid panel |
| `reportingRetries` | Enables rendering retries for the reporting feature |
| `externalServiceAccounts` | Automatic service account and token setup for plugins |
| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches |
| `pdfTables` | Enables generating table data as PDF in reporting |
| `canvasPanelPanZoom` | Allow pan and zoom in canvas panel |
| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage. Default is enabled. |
| `sqlExpressions` | Enables SQL Expressions, which can execute SQL queries against data source results. |
| `queryLibrary` | Enables Saved queries (query library) feature |
| `enableSCIM` | Enables SCIM support for user and group management |
| `alertRuleRestore` | Enables the alert rule restore feature |
| `azureMonitorLogsBuilderEditor` | Enables the logs builder mode for the Azure Monitor data source |
| `localeFormatPreference` | Specifies the locale so the correct format for numbers and dates can be shown |
| `logsPanelControls` | Enables a control component for the logs panel in Explore |
| `interactiveLearning` | Enables the interactive learning app |
| `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker |
| `newVizSuggestions` | Enable new visualization suggestions |
| `preventPanelChromeOverflow` | Restrict PanelChrome contents with overflow: hidden; |
## Development feature toggles
@@ -1,112 +0,0 @@
import { test, expect } from '@grafana/plugin-e2e';
const DASHBOARD_UID = 'MP-Di9F7k';
test.use({
featureToggles: {
timeRangePan: true,
},
});
test.describe('Panels test: Candlestick X-axis panning', { tag: ['@panels', '@candlestick'] }, () => {
test('x-axis panning functionality', async ({ gotoDashboardPage, page, selectors }) => {
let centerX: number;
let centerY: number;
let initialFromTime: number;
let initialToTime: number;
const dashboardPage = await test.step('Load dashboard and verify cursor changes to grab', async () => {
const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID });
const candlestickPanel = page.locator('.uplot').first();
await expect(candlestickPanel, 'panel rendered').toBeVisible();
const xAxis = candlestickPanel.locator('.u-axis').first();
await expect(xAxis, 'x-axis rendered').toBeVisible();
await xAxis.hover();
const cursorStyle = await xAxis.evaluate((el: HTMLElement) => window.getComputedStyle(el).cursor);
expect(cursorStyle, 'cursor is grab').toBe('grab');
return dashboardPage;
});
await test.step('Capture initial time range', async () => {
const candlestickPanel = page.locator('.uplot').first();
const xAxis = candlestickPanel.locator('.u-axis').first();
const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton);
await timePickerButton.click();
const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField);
const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField);
const initialFrom = await fromField.inputValue();
const initialTo = await toField.inputValue();
initialFromTime = new Date(initialFrom).getTime();
initialToTime = new Date(initialTo).getTime();
await page.keyboard.press('Escape');
const axisBox = await xAxis.boundingBox();
if (!axisBox) {
throw new Error('X-axis bounding box not found');
}
centerX = axisBox.x + axisBox.width / 2;
centerY = axisBox.y + axisBox.height / 2;
});
await test.step('Drag right pans backward in time', async () => {
await page.mouse.move(centerX, centerY);
await page.mouse.down();
await page.mouse.move(centerX + 100, centerY);
await page.mouse.up();
await page.waitForTimeout(1000);
const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton);
await timePickerButton.click();
const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField);
const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField);
const afterRightFrom = await fromField.inputValue();
const afterRightTo = await toField.inputValue();
const afterRightFromTime = new Date(afterRightFrom).getTime();
const afterRightToTime = new Date(afterRightTo).getTime();
expect(afterRightFromTime, 'panned backward').toBeLessThan(initialFromTime);
expect(afterRightToTime, 'panned backward').toBeLessThan(initialToTime);
await page.keyboard.press('Escape');
initialFromTime = afterRightFromTime;
initialToTime = afterRightToTime;
});
await test.step('Drag left pans forward in time', async () => {
await page.mouse.move(centerX, centerY);
await page.mouse.down();
await page.mouse.move(centerX - 100, centerY);
await page.mouse.up();
await page.waitForTimeout(1000);
const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton);
await timePickerButton.click();
const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField);
const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField);
const afterLeftFrom = await fromField.inputValue();
const afterLeftTo = await toField.inputValue();
const afterLeftFromTime = new Date(afterLeftFrom).getTime();
const afterLeftToTime = new Date(afterLeftTo).getTime();
expect(afterLeftFromTime, 'panned forward').toBeGreaterThan(initialFromTime);
expect(afterLeftToTime, 'panned forward').toBeGreaterThan(initialToTime);
});
});
});
+17 -10
View File
@@ -9,13 +9,8 @@ test.use({
});
test.describe('Panels test: TimeSeries X-axis panning', { tag: ['@panels', '@timeseries'] }, () => {
test('x-axis panning functionality', async ({ gotoDashboardPage, page, selectors }) => {
let centerX: number;
let centerY: number;
let initialFromTime: number;
let initialToTime: number;
const dashboardPage = await test.step('Load dashboard and verify cursor changes to grab', async () => {
test('cursor changes to grab hand over x-axis', async ({ gotoDashboardPage, page }) => {
await test.step('Load dashboard and verify cursor changes to grab', async () => {
const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID });
const timeseriesPanel = page.locator('.uplot').first();
@@ -28,13 +23,23 @@ test.describe('Panels test: TimeSeries X-axis panning', { tag: ['@panels', '@tim
const cursorStyle = await xAxis.evaluate((el: HTMLElement) => window.getComputedStyle(el).cursor);
expect(cursorStyle, 'cursor is grab').toBe('grab');
return dashboardPage;
});
});
test('drag right pans backward in time, drag left pans forward', async ({ gotoDashboardPage, page, selectors }) => {
let centerX: number;
let centerY: number;
let initialFromTime: number;
let initialToTime: number;
const dashboardPage = await test.step('Load dashboard and capture initial time range', async () => {
const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID });
await test.step('Capture initial time range', async () => {
const timeseriesPanel = page.locator('.uplot').first();
await expect(timeseriesPanel, 'panel rendered').toBeVisible();
const xAxis = timeseriesPanel.locator('.u-axis').first();
await expect(xAxis, 'x-axis rendered').toBeVisible();
const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton);
await timePickerButton.click();
@@ -56,6 +61,8 @@ test.describe('Panels test: TimeSeries X-axis panning', { tag: ['@panels', '@tim
centerX = axisBox.x + axisBox.width / 2;
centerY = axisBox.y + axisBox.height / 2;
return dashboardPage;
});
await test.step('Drag right pans backward in time', async () => {
+5 -5
View File
@@ -136,7 +136,7 @@
"@types/lodash": "4.17.20",
"@types/logfmt": "^1.2.3",
"@types/lucene": "^2",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/node-forge": "^1",
"@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.3.0",
"@types/pluralize": "^0.0.33",
@@ -323,7 +323,7 @@
"@react-aria/overlays": "3.30.0",
"@react-aria/utils": "3.31.0",
"@react-awesome-query-builder/ui": "6.6.15",
"@reduxjs/toolkit": "2.10.1",
"@reduxjs/toolkit": "2.9.0",
"@visx/event": "3.12.0",
"@visx/gradient": "3.12.0",
"@visx/group": "3.12.0",
@@ -381,7 +381,7 @@
"moveable": "0.53.0",
"nanoid": "^5.0.9",
"node-forge": "^1.3.1",
"ol": "10.7.0",
"ol": "10.6.1",
"ol-ext": "4.0.36",
"ol-mapbox-style": "^13.0.1",
"pluralize": "^8.0.0",
@@ -427,7 +427,7 @@
"slate": "0.47.9",
"slate-plain-serializer": "0.7.13",
"slate-react": "0.22.10",
"swagger-ui-react": "5.30.2",
"swagger-ui-react": "5.28.1",
"symbol-observable": "4.0.0",
"systemjs": "6.15.1",
"tslib": "2.8.1",
@@ -468,7 +468,7 @@
]
},
"engines": {
"node": ">= 22 <25"
"node": ">= 22 <23"
},
"packageManager": "yarn@4.11.0",
"dependenciesMeta": {
+2 -2
View File
@@ -73,7 +73,7 @@
"marked-mangle": "1.1.12",
"moment": "2.30.1",
"moment-timezone": "0.5.47",
"ol": "10.7.0",
"ol": "10.6.1",
"papaparse": "5.5.3",
"react-use": "17.6.0",
"rxjs": "7.8.2",
@@ -89,7 +89,7 @@
"@testing-library/react": "16.3.0",
"@types/history": "4.7.11",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/papaparse": "5.3.16",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
+4 -4
View File
@@ -912,6 +912,10 @@ export interface FeatureToggles {
*/
unifiedStorageGrpcConnectionPool?: boolean;
/**
* Use KV-backed SQL storage backend instead of direct SQL queries
*/
unifiedStorageKVBackend?: boolean;
/**
* Enables UI functionality to permanently delete alert rules
* @default true
*/
@@ -1200,8 +1204,4 @@ export interface FeatureToggles {
* @default false
*/
awsDatasourcesHttpProxy?: boolean;
/**
* Show transformation quick-start cards in empty transformations state
*/
transformationsEmptyPlaceholder?: boolean;
}
+1 -1
View File
@@ -40,7 +40,7 @@
},
"devDependencies": {
"@rollup/plugin-node-resolve": "16.0.1",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/semver": "7.7.1",
"esbuild": "0.25.8",
"rimraf": "6.0.1",
@@ -915,10 +915,6 @@ export const versionedComponents = {
'10.1.0': 'data-testid add transformation button',
[MIN_GRAFANA_VERSION]: 'add transformation button',
},
goToQueriesButton: {
'10.4.0': 'data-testid go to queries button',
[MIN_GRAFANA_VERSION]: 'go to queries button',
},
removeAllTransformationsButton: {
'10.4.0': 'data-testid remove all transformations button',
},
+1 -1
View File
@@ -67,7 +67,7 @@
"@types/d3": "^7",
"@types/jest": "^29.5.4",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/react": "18.3.18",
"@types/react-virtualized-auto-sizer": "1.0.8",
"@types/tinycolor2": "1.4.6",
@@ -35,7 +35,7 @@
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "^29.5.4",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/react": "18.3.18",
"@types/systemjs": "6.15.3",
"jest": "^29.6.4",
+2 -2
View File
@@ -54,7 +54,7 @@
"@lezer/highlight": "1.2.3",
"@lezer/lr": "1.4.3",
"@prometheus-io/lezer-promql": "0.307.3",
"@reduxjs/toolkit": "2.10.1",
"@reduxjs/toolkit": "2.9.0",
"@types/debounce-promise": "3.1.9",
"@types/lodash": "4.17.20",
"@types/react": "18.3.18",
@@ -87,7 +87,7 @@
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/pluralize": "^0.0.33",
"@types/prismjs": "1.26.5",
"esbuild": "0.25.8",
+1 -1
View File
@@ -42,7 +42,7 @@
"@testing-library/user-event": "14.6.1",
"@types/jest": "^29.5.4",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/react-virtualized-auto-sizer": "1.0.8",
+1 -1
View File
@@ -65,7 +65,7 @@
"@types/chance": "^1.1.7",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"jest": "29.7.0",
"typescript": "5.9.2"
}
+2 -2
View File
@@ -99,7 +99,7 @@
"micro-memoize": "^4.1.2",
"moment": "2.30.1",
"monaco-editor": "0.34.1",
"ol": "10.7.0",
"ol": "10.6.1",
"prismjs": "1.30.0",
"rc-cascader": "3.34.0",
"rc-drawer": "7.3.0",
@@ -165,7 +165,7 @@
"@types/is-hotkey": "0.1.10",
"@types/jest": "29.5.14",
"@types/mock-raf": "1.0.6",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/prismjs": "1.26.5",
"@types/react": "18.3.18",
"@types/react-color": "3.0.13",
+8 -7
View File
@@ -1580,6 +1580,14 @@ var (
HideFromAdminPage: true,
HideFromDocs: true,
},
{
Name: "unifiedStorageKVBackend",
Description: "Use KV-backed SQL storage backend instead of direct SQL queries",
Stage: FeatureStageExperimental,
Owner: grafanaSearchAndStorageSquad,
HideFromAdminPage: true,
HideFromDocs: true,
},
{
Name: "alertingRulePermanentlyDelete",
Description: "Enables UI functionality to permanently delete alert rules",
@@ -2082,13 +2090,6 @@ var (
Owner: awsDatasourcesSquad,
Expression: "false",
},
{
Name: "transformationsEmptyPlaceholder",
Description: "Show transformation quick-start cards in empty transformations state",
Stage: FeatureStagePublicPreview,
FrontendOnly: true,
Owner: grafanaDataProSquad,
},
}
)
+1 -1
View File
@@ -205,6 +205,7 @@ unifiedStorageHistoryPruner,GA,@grafana/search-and-storage,false,false,false
azureMonitorLogsBuilderEditor,preview,@grafana/partner-datasources,false,false,false
localeFormatPreference,preview,@grafana/grafana-frontend-platform,false,false,false
unifiedStorageGrpcConnectionPool,experimental,@grafana/search-and-storage,false,false,false
unifiedStorageKVBackend,experimental,@grafana/search-and-storage,false,false,false
alertingRulePermanentlyDelete,GA,@grafana/alerting-squad,false,false,true
alertingRuleRecoverDeleted,GA,@grafana/alerting-squad,false,false,true
multiTenantTempCredentials,experimental,@grafana/aws-datasources,false,false,false
@@ -267,4 +268,3 @@ panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false
dashboardTemplates,experimental,@grafana/sharing-squad,false,false,false
kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false
awsDatasourcesHttpProxy,experimental,@grafana/aws-datasources,false,false,false
transformationsEmptyPlaceholder,preview,@grafana/datapro,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
205 azureMonitorLogsBuilderEditor preview @grafana/partner-datasources false false false
206 localeFormatPreference preview @grafana/grafana-frontend-platform false false false
207 unifiedStorageGrpcConnectionPool experimental @grafana/search-and-storage false false false
208 unifiedStorageKVBackend experimental @grafana/search-and-storage false false false
209 alertingRulePermanentlyDelete GA @grafana/alerting-squad false false true
210 alertingRuleRecoverDeleted GA @grafana/alerting-squad false false true
211 multiTenantTempCredentials experimental @grafana/aws-datasources false false false
268 dashboardTemplates experimental @grafana/sharing-squad false false false
269 kubernetesAnnotations experimental @grafana/grafana-backend-services-squad false false false
270 awsDatasourcesHttpProxy experimental @grafana/aws-datasources false false false
transformationsEmptyPlaceholder preview @grafana/datapro false false true
+4 -4
View File
@@ -830,6 +830,10 @@ const (
// Enables the unified storage grpc connection pool
FlagUnifiedStorageGrpcConnectionPool = "unifiedStorageGrpcConnectionPool"
// FlagUnifiedStorageKVBackend
// Use KV-backed SQL storage backend instead of direct SQL queries
FlagUnifiedStorageKVBackend = "unifiedStorageKVBackend"
// FlagAlertingRulePermanentlyDelete
// Enables UI functionality to permanently delete alert rules
FlagAlertingRulePermanentlyDelete = "alertingRulePermanentlyDelete"
@@ -1077,8 +1081,4 @@ const (
// FlagAwsDatasourcesHttpProxy
// Enables http proxy settings for aws datasources
FlagAwsDatasourcesHttpProxy = "awsDatasourcesHttpProxy"
// FlagTransformationsEmptyPlaceholder
// Show transformation quick-start cards in empty transformations state
FlagTransformationsEmptyPlaceholder = "transformationsEmptyPlaceholder"
)
+14 -16
View File
@@ -4108,22 +4108,6 @@
"expression": "true"
}
},
{
"metadata": {
"name": "transformationsEmptyPlaceholder",
"resourceVersion": "1763373021129",
"creationTimestamp": "2025-11-11T13:14:25Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-11-17 09:50:21.129721 +0000 UTC"
}
},
"spec": {
"description": "Show transformation quick-start cards in empty transformations state",
"stage": "preview",
"codeowner": "@grafana/datapro",
"frontend": true
}
},
{
"metadata": {
"name": "transformationsRedesign",
@@ -4224,6 +4208,20 @@
"expression": "true"
}
},
{
"metadata": {
"name": "unifiedStorageKVBackend",
"resourceVersion": "1763461706359",
"creationTimestamp": "2025-11-18T10:28:26Z"
},
"spec": {
"description": "Use KV-backed SQL storage backend instead of direct SQL queries",
"stage": "experimental",
"codeowner": "@grafana/search-and-storage",
"hideFromAdminPage": true,
"hideFromDocs": true
}
},
{
"metadata": {
"name": "unifiedStorageSearch",
@@ -168,14 +168,7 @@ func readDashboardIter(iter *jsoniter.Iterator, lookup DatasourceLookup) (*Dashb
dash.TimeZone = iter.ReadString()
case "editable":
switch iter.WhatIsNext() {
case jsoniter.BoolValue:
dash.ReadOnly = !iter.ReadBool()
case jsoniter.StringValue:
dash.ReadOnly = iter.ReadString() != "true"
default:
iter.Skip()
}
dash.ReadOnly = !iter.ReadBool()
case "refresh":
nxt := iter.WhatIsNext()
@@ -72,7 +72,6 @@ func TestReadDashboard(t *testing.T) {
"panels-without-datasources",
"panel-with-library-panel-field",
"k8s-wrapper",
"k8s-wrapper-editable-string",
}
devdash := "../../../../../devenv/dev-dashboards/"
@@ -1,75 +0,0 @@
{
"id": 141,
"title": "pppp",
"tags": null,
"datasource": [
{
"uid": "default.uid",
"type": "default.type"
}
],
"panels": [
{
"id": 1,
"title": "green pie",
"libraryPanel": "a7975b7a-fb53-4ab7-951d-15810953b54f",
"datasource": [
{
"uid": "default.uid",
"type": "default.type"
}
]
},
{
"id": 2,
"title": "green pie",
"libraryPanel": "e1d5f519-dabd-47c6-9ad7-83d181ce1cee",
"datasource": [
{
"uid": "default.uid",
"type": "default.type"
}
]
},
{
"id": 7,
"title": "",
"type": "barchart",
"datasource": [
{
"uid": "default.uid",
"type": "default.type"
}
]
},
{
"id": 8,
"title": "",
"type": "graph",
"datasource": [
{
"uid": "default.uid",
"type": "default.type"
}
]
},
{
"id": 3,
"title": "collapsed row",
"type": "row",
"collapsed": [
{
"id": 42,
"title": "blue pie",
"libraryPanel": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur"
}
]
}
],
"schemaVersion": 38,
"linkCount": 0,
"timeFrom": "now-6h",
"timeTo": "now",
"timezone": "",
"readOnly": true
}
@@ -1,122 +0,0 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v0alpha1",
"metadata": {
"name": "adfbg6f",
"namespace": "default",
"uid": "b396894e-56bf-4a01-837b-64157912ca00",
"creationTimestamp": "2024-10-30T18:30:54Z",
"annotations": {
"grafana.app/createdBy": "user:be2g71ke8yoe8b",
"grafana.app/originHash": "Grafana v9.2.0 (NA)",
"grafana.app/originName": "UI",
"grafana.app/originPath": "/dashboard/new"
}
},
"spec": {
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": "false",
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 141,
"links": [],
"liveNow": false,
"panels": [
{
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"libraryPanel": {
"name": "green pie",
"uid": "a7975b7a-fb53-4ab7-951d-15810953b54f"
},
"title": "green pie"
},
{
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"libraryPanel": {
"name": "red pie",
"uid": "e1d5f519-dabd-47c6-9ad7-83d181ce1cee"
},
"title": "green pie"
},
{
"id": 7,
"type": "barchart"
},
{
"id": 8,
"type": "graph"
},
{
"collapsed": true,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 9
},
"id": 3,
"panels": [
{
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 42,
"libraryPanel": {
"name": "blue pie",
"uid": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur"
},
"title": "blue pie"
}
],
"title": "collapsed row",
"type": "row"
}
],
"refresh": "",
"schemaVersion": 38,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "pppp",
"uid": "adfbg6f",
"version": 3,
"weekStart": ""
}
}
+835
View File
@@ -0,0 +1,835 @@
package resource
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"io"
"iter"
"strconv"
"strings"
"time"
"github.com/bwmarrin/snowflake"
"github.com/google/uuid"
"github.com/grafana/grafana/pkg/storage/unified/sql/db"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
)
const (
sectionData = "unified/data"
sectionEvents = "unified/events"
)
// sqlKV implements the KV interface using SQL storage
type sqlKV struct {
dbProvider db.DBProvider // Keep reference to prevent GC
db db.DB
dialect sqltemplate.Dialect
}
// NewSQLKV creates a new SQL-based KV store
func NewSQLKV(dbProvider db.DBProvider) (KV, error) {
if dbProvider == nil {
return nil, errors.New("dbProvider is required")
}
// Initialize the database connection
ctx := context.Background()
dbConn, err := dbProvider.Init(ctx)
if err != nil {
return nil, fmt.Errorf("initialize DB: %w", err)
}
// Determine the SQL dialect
var dialect sqltemplate.Dialect
switch dbConn.DriverName() {
case "mysql":
dialect = sqltemplate.MySQL
case "postgres":
dialect = sqltemplate.PostgreSQL
case "sqlite3", "sqlite":
dialect = sqltemplate.SQLite
default:
return nil, fmt.Errorf("unsupported database driver: %s", dbConn.DriverName())
}
return &sqlKV{
dbProvider: dbProvider, // Keep reference to prevent GC from closing the database
db: dbConn,
dialect: dialect,
}, nil
}
// Verify that sqlKV implements KV interface
var _ KV = &sqlKV{}
// Helper function to build identifiers safely
func (k *sqlKV) ident(name string) (string, error) {
return k.dialect.Ident(name)
}
// Helper function to get table name for a section
func (k *sqlKV) getTableName(section string) (string, error) {
switch section {
case sectionData:
return k.ident("resource_history")
case sectionEvents:
return k.ident("resource_events")
default:
return "", fmt.Errorf("unsupported section: %s", section)
}
}
// parsedKey represents the components of a key_path for the data section
// Format: {Group}/{Resource}/{Namespace}/{Name}/{ResourceVersion}~{Action}~{Folder}
type parsedKey struct {
Group string
Resource string
Namespace string
Name string
ResourceVersion int64 // Microsecond timestamp
ResourceVersionSnowflake int64 // Original snowflake ID from key
Action int // 1: create, 2: update, 3: delete
Folder string
}
// snowflakeToMicroseconds converts a snowflake ID to a unix microsecond timestamp
// Uses the snowflake library's Time() method which handles the Grafana epoch internally
func snowflakeToMicroseconds(snowflakeID int64) int64 {
// Extract unix milliseconds from snowflake (handles Grafana epoch internally)
unixMilliseconds := snowflake.ID(snowflakeID).Time()
// Extract sequence number (low 12 bits) for sub-millisecond precision
sequence := snowflakeID & 0xFFF // 0xFFF = 4095 = 2^12 - 1
// Convert to unix microseconds: (unix_ms * 1000) + sequence
return (unixMilliseconds * 1000) + sequence
}
// parseDataKey parses a data section key_path
func parseDataKey(keyPath string) (*parsedKey, error) {
// Split by ~ to separate main key from action and folder
parts := strings.Split(keyPath, "~")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid key format: expected 3 parts separated by '~', got %d", len(parts))
}
// Split main key by /
mainParts := strings.Split(parts[0], "/")
if len(mainParts) != 5 {
return nil, fmt.Errorf("invalid key format: expected 5 parts separated by '/', got %d", len(mainParts))
}
// Parse resource version (stored as snowflake ID in key)
snowflakeID, err := strconv.ParseInt(mainParts[4], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid resource_version: %w", err)
}
// Convert snowflake ID to microsecond timestamp for database storage
microseconds := snowflakeToMicroseconds(snowflakeID)
// Convert action string to int
var action int
switch parts[1] {
case "created":
action = 1
case "updated":
action = 2
case "deleted":
action = 3
default:
return nil, fmt.Errorf("invalid action: %s", parts[1])
}
return &parsedKey{
Group: mainParts[0],
Resource: mainParts[1],
Namespace: mainParts[2],
Name: mainParts[3],
ResourceVersion: microseconds, // Microsecond timestamp for DB
ResourceVersionSnowflake: snowflakeID, // Original snowflake ID
Action: action,
Folder: parts[2], // May be empty string
}, nil
}
// Get retrieves the value for a key from the store
func (k *sqlKV) Get(ctx context.Context, section string, key string) (io.ReadCloser, error) {
if section == "" {
return nil, fmt.Errorf("section is required")
}
if key == "" {
return nil, fmt.Errorf("key is required")
}
tableName, err := k.getTableName(section)
if err != nil {
return nil, err
}
valueIdent, err := k.ident("value")
if err != nil {
return nil, fmt.Errorf("invalid column identifier: %w", err)
}
keyPathIdent, err := k.ident("key_path")
if err != nil {
return nil, fmt.Errorf("invalid column identifier: %w", err)
}
query := fmt.Sprintf(
"SELECT %s FROM %s WHERE %s = %s",
valueIdent,
tableName,
keyPathIdent,
k.dialect.ArgPlaceholder(1),
)
// Execute the query
var value []byte
err = k.db.QueryRowContext(ctx, query, key).Scan(&value)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("query failed: %w", err)
}
return io.NopCloser(bytes.NewReader(value)), nil
}
// BatchGet retrieves multiple values for the given keys from the store
// Uses a UNION ALL subquery with LEFT JOIN to preserve order and enable streaming
func (k *sqlKV) BatchGet(ctx context.Context, section string, keys []string) iter.Seq2[KeyValue, error] {
if section == "" {
return func(yield func(KeyValue, error) bool) {
yield(KeyValue{}, fmt.Errorf("section is required"))
}
}
if len(keys) == 0 {
return func(yield func(KeyValue, error) bool) {
// Empty result set - nothing to yield
}
}
return func(yield func(KeyValue, error) bool) {
tableName, err := k.getTableName(section)
if err != nil {
yield(KeyValue{}, err)
return
}
keyPathIdent, err := k.ident("key_path")
if err != nil {
yield(KeyValue{}, fmt.Errorf("invalid column identifier: %w", err))
return
}
valueIdent, err := k.ident("value")
if err != nil {
yield(KeyValue{}, fmt.Errorf("invalid column identifier: %w", err))
return
}
// Build UNION ALL subquery to preserve key order
// SELECT 0 AS idx, ? AS kp UNION ALL SELECT 1, ? UNION ALL ...
var unionParts []string
var args []interface{}
argNum := 1
for i, key := range keys {
if i == 0 {
unionParts = append(unionParts, fmt.Sprintf(
"SELECT %s AS idx, %s AS kp",
k.dialect.ArgPlaceholder(argNum),
k.dialect.ArgPlaceholder(argNum+1),
))
} else {
unionParts = append(unionParts, fmt.Sprintf(
"UNION ALL SELECT %s, %s",
k.dialect.ArgPlaceholder(argNum),
k.dialect.ArgPlaceholder(argNum+1),
))
}
args = append(args, i, key)
argNum += 2
}
// Build the full query with LEFT JOIN to preserve order
// This allows streaming results directly without buffering
query := fmt.Sprintf(
"SELECT v.idx, t.%s, t.%s FROM (%s) AS v LEFT JOIN %s t ON t.%s = v.kp ORDER BY v.idx",
keyPathIdent,
valueIdent,
strings.Join(unionParts, " "),
tableName,
keyPathIdent,
)
// Execute the query
rows, err := k.db.QueryContext(ctx, query, args...)
if err != nil {
yield(KeyValue{}, fmt.Errorf("query failed: %w", err))
return
}
defer rows.Close()
// Stream results directly - no buffering needed!
// Results come back in the order specified by idx
for rows.Next() {
var idx int
var keyPath sql.NullString
var value []byte
if err := rows.Scan(&idx, &keyPath, &value); err != nil {
yield(KeyValue{}, fmt.Errorf("scan failed: %w", err))
return
}
// Skip keys that don't exist (LEFT JOIN returns NULL)
if !keyPath.Valid {
continue
}
kv := KeyValue{
Key: keyPath.String,
Value: io.NopCloser(bytes.NewReader(value)),
}
if !yield(kv, nil) {
return
}
}
if err := rows.Err(); err != nil {
yield(KeyValue{}, fmt.Errorf("rows error: %w", err))
return
}
}
}
// Keys returns all the keys in the store
func (k *sqlKV) Keys(ctx context.Context, section string, opt ListOptions) iter.Seq2[string, error] {
if section == "" {
return func(yield func(string, error) bool) {
yield("", fmt.Errorf("section is required"))
}
}
return func(yield func(string, error) bool) {
tableName, err := k.getTableName(section)
if err != nil {
yield("", err)
return
}
keyPathIdent, err := k.ident("key_path")
if err != nil {
yield("", fmt.Errorf("invalid column identifier: %w", err))
return
}
// Build WHERE clauses
var whereClauses []string
var args []interface{}
argNum := 1
// Start key (inclusive)
if opt.StartKey != "" {
whereClauses = append(whereClauses, fmt.Sprintf("%s >= %s", keyPathIdent, k.dialect.ArgPlaceholder(argNum)))
args = append(args, opt.StartKey)
argNum++
}
// End key (exclusive)
if opt.EndKey != "" {
whereClauses = append(whereClauses, fmt.Sprintf("%s < %s", keyPathIdent, k.dialect.ArgPlaceholder(argNum)))
args = append(args, opt.EndKey)
argNum++
}
// Build ORDER BY clause
orderBy := "ASC"
if opt.Sort == SortOrderDesc {
orderBy = "DESC"
}
// Build the query
query := fmt.Sprintf(
"SELECT %s FROM %s",
keyPathIdent,
tableName,
)
if len(whereClauses) > 0 {
query += " WHERE " + strings.Join(whereClauses, " AND ")
}
query += fmt.Sprintf(" ORDER BY %s %s", keyPathIdent, orderBy)
if opt.Limit > 0 {
query += fmt.Sprintf(" LIMIT %d", opt.Limit)
}
// Execute the query
rows, err := k.db.QueryContext(ctx, query, args...)
if err != nil {
yield("", fmt.Errorf("query failed: %w", err))
return
}
defer rows.Close()
// Yield each key
for rows.Next() {
var keyPath string
if err := rows.Scan(&keyPath); err != nil {
yield("", fmt.Errorf("scan failed: %w", err))
return
}
if !yield(keyPath, nil) {
return
}
}
if err := rows.Err(); err != nil {
yield("", fmt.Errorf("rows error: %w", err))
return
}
}
}
// Save a new value - returns a WriteCloser to write the value to
func (k *sqlKV) Save(ctx context.Context, section string, key string) (io.WriteCloser, error) {
if section == "" {
return nil, fmt.Errorf("section is required")
}
if key == "" {
return nil, fmt.Errorf("key is required")
}
return &sqlWriteCloser{
kv: k,
ctx: ctx,
section: section,
key: key,
buf: &bytes.Buffer{},
closed: false,
}, nil
}
// sqlWriteCloser implements io.WriteCloser for SQL KV Save operations
type sqlWriteCloser struct {
kv *sqlKV
ctx context.Context
section string
key string
buf *bytes.Buffer
closed bool
}
// Write implements io.Writer
func (w *sqlWriteCloser) Write(p []byte) (int, error) {
if w.closed {
return 0, fmt.Errorf("write to closed writer")
}
return w.buf.Write(p)
}
// Close implements io.Closer - stores the buffered data in SQL
func (w *sqlWriteCloser) Close() error {
if w.closed {
return nil
}
w.closed = true
value := w.buf.Bytes()
switch w.section {
case sectionEvents:
// Simple upsert for events section
return w.closeEvents(value)
case sectionData:
// Complex multi-table transaction for data section
return w.closeData(value)
default:
return fmt.Errorf("unsupported section: %s", w.section)
}
}
// closeEvents handles the simple upsert for the events section
func (w *sqlWriteCloser) closeEvents(value []byte) error {
tableName, err := w.kv.getTableName(w.section)
if err != nil {
return err
}
keyPathIdent, err := w.kv.ident("key_path")
if err != nil {
return fmt.Errorf("invalid column identifier: %w", err)
}
valueIdent, err := w.kv.ident("value")
if err != nil {
return fmt.Errorf("invalid column identifier: %w", err)
}
ph1 := w.kv.dialect.ArgPlaceholder(1)
ph2 := w.kv.dialect.ArgPlaceholder(2)
var query string
switch w.kv.dialect.DialectName() {
case "postgres":
query = fmt.Sprintf(
"INSERT INTO %s (%s, %s) VALUES (%s, %s) ON CONFLICT (%s) DO UPDATE SET %s = EXCLUDED.%s",
tableName, keyPathIdent, valueIdent, ph1, ph2, keyPathIdent, valueIdent, valueIdent,
)
case "mysql":
query = fmt.Sprintf(
"INSERT INTO %s (%s, %s) VALUES (%s, %s) ON DUPLICATE KEY UPDATE %s = VALUES(%s)",
tableName, keyPathIdent, valueIdent, ph1, ph2, valueIdent, valueIdent,
)
case "sqlite":
query = fmt.Sprintf(
"INSERT INTO %s (%s, %s) VALUES (%s, %s) ON CONFLICT (%s) DO UPDATE SET %s = excluded.%s",
tableName, keyPathIdent, valueIdent, ph1, ph2, keyPathIdent, valueIdent, valueIdent,
)
default:
return fmt.Errorf("unsupported dialect: %s", w.kv.dialect.DialectName())
}
_, err = w.kv.db.ExecContext(w.ctx, query, w.key, value)
if err != nil {
return fmt.Errorf("insert/update failed: %w", err)
}
return nil
}
// closeData handles the complex multi-table transaction for the data section
func (w *sqlWriteCloser) closeData(value []byte) error {
// Parse the key to extract all fields
parsed, err := parseDataKey(w.key)
if err != nil {
return fmt.Errorf("parse key: %w", err)
}
// Generate a GUID for this write
guid := uuid.New().String()
// Execute all operations in a transaction
return w.kv.db.WithTx(w.ctx, nil, func(ctx context.Context, tx db.Tx) error {
// 1. Insert/update resource_history
if err := w.upsertResourceHistory(ctx, tx, parsed, guid, value); err != nil {
return fmt.Errorf("upsert resource_history: %w", err)
}
// 2. Handle resource table based on action
if parsed.Action == 3 { // deleted
if err := w.deleteResource(ctx, tx, parsed); err != nil {
return fmt.Errorf("delete resource: %w", err)
}
} else { // created or updated
if err := w.upsertResource(ctx, tx, parsed, guid, value); err != nil {
return fmt.Errorf("upsert resource: %w", err)
}
}
// 3. Upsert resource_version table
if err := w.upsertResourceVersion(ctx, tx, parsed); err != nil {
return fmt.Errorf("upsert resource_version: %w", err)
}
return nil
})
}
// upsertResourceHistory inserts/updates a row in the resource_history table
func (w *sqlWriteCloser) upsertResourceHistory(ctx context.Context, tx db.Tx, parsed *parsedKey, guid string, value []byte) error {
// Build identifiers
tableIdent, _ := w.kv.ident("resource_history")
guidIdent, _ := w.kv.ident("guid")
groupIdent, _ := w.kv.ident("group")
resourceIdent, _ := w.kv.ident("resource")
namespaceIdent, _ := w.kv.ident("namespace")
nameIdent, _ := w.kv.ident("name")
rvIdent, _ := w.kv.ident("resource_version")
prevRVIdent, _ := w.kv.ident("previous_resource_version")
valueIdent, _ := w.kv.ident("value")
actionIdent, _ := w.kv.ident("action")
folderIdent, _ := w.kv.ident("folder")
keyPathIdent, _ := w.kv.ident("key_path")
// Build placeholders
var query string
ph := func(n int) string { return w.kv.dialect.ArgPlaceholder(n) }
switch w.kv.dialect.DialectName() {
case "postgres":
query = fmt.Sprintf(`
INSERT INTO %s (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, 0, %s, %s, %s)
ON CONFLICT (%s) DO UPDATE SET %s = EXCLUDED.%s, %s = EXCLUDED.%s`,
tableIdent, guidIdent, keyPathIdent, groupIdent, resourceIdent, namespaceIdent, nameIdent,
rvIdent, prevRVIdent, valueIdent, actionIdent, folderIdent,
ph(1), ph(2), ph(3), ph(4), ph(5), ph(6), ph(7), ph(8), ph(9), ph(10),
guidIdent, valueIdent, valueIdent, keyPathIdent, keyPathIdent,
)
case "mysql", "sqlite":
// For MySQL and SQLite, use INSERT OR REPLACE (requires all columns)
query = fmt.Sprintf(`
REPLACE INTO %s (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, 0, %s, %s, %s)`,
tableIdent, guidIdent, keyPathIdent, groupIdent, resourceIdent, namespaceIdent, nameIdent,
rvIdent, prevRVIdent, valueIdent, actionIdent, folderIdent,
ph(1), ph(2), ph(3), ph(4), ph(5), ph(6), ph(7), ph(8), ph(9), ph(10),
)
default:
return fmt.Errorf("unsupported dialect: %s", w.kv.dialect.DialectName())
}
_, err := tx.ExecContext(ctx, query,
guid, w.key, parsed.Group, parsed.Resource, parsed.Namespace, parsed.Name,
parsed.ResourceVersion, value, parsed.Action, parsed.Folder,
)
return err
}
// upsertResource inserts/updates a row in the resource table
func (w *sqlWriteCloser) upsertResource(ctx context.Context, tx db.Tx, parsed *parsedKey, guid string, value []byte) error {
// Build identifiers
tableIdent, _ := w.kv.ident("resource")
guidIdent, _ := w.kv.ident("guid")
groupIdent, _ := w.kv.ident("group")
resourceIdent, _ := w.kv.ident("resource")
namespaceIdent, _ := w.kv.ident("namespace")
nameIdent, _ := w.kv.ident("name")
rvIdent, _ := w.kv.ident("resource_version")
valueIdent, _ := w.kv.ident("value")
actionIdent, _ := w.kv.ident("action")
var query string
ph := func(n int) string { return w.kv.dialect.ArgPlaceholder(n) }
switch w.kv.dialect.DialectName() {
case "postgres":
query = fmt.Sprintf(`
INSERT INTO %s (%s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (%s, %s, %s, %s) DO UPDATE SET
%s = EXCLUDED.%s, %s = EXCLUDED.%s, %s = EXCLUDED.%s, %s = EXCLUDED.%s`,
tableIdent, guidIdent, groupIdent, resourceIdent, namespaceIdent, nameIdent, rvIdent, valueIdent, actionIdent,
ph(1), ph(2), ph(3), ph(4), ph(5), ph(6), ph(7), ph(8),
namespaceIdent, groupIdent, resourceIdent, nameIdent,
guidIdent, guidIdent, rvIdent, rvIdent, valueIdent, valueIdent, actionIdent, actionIdent,
)
case "mysql", "sqlite":
query = fmt.Sprintf(`
REPLACE INTO %s (%s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)`,
tableIdent, guidIdent, groupIdent, resourceIdent, namespaceIdent, nameIdent, rvIdent, valueIdent, actionIdent,
ph(1), ph(2), ph(3), ph(4), ph(5), ph(6), ph(7), ph(8),
)
default:
return fmt.Errorf("unsupported dialect: %s", w.kv.dialect.DialectName())
}
_, err := tx.ExecContext(ctx, query,
guid, parsed.Group, parsed.Resource, parsed.Namespace, parsed.Name,
parsed.ResourceVersion, value, parsed.Action,
)
return err
}
// deleteResource deletes a row from the resource table
func (w *sqlWriteCloser) deleteResource(ctx context.Context, tx db.Tx, parsed *parsedKey) error {
tableIdent, _ := w.kv.ident("resource")
groupIdent, _ := w.kv.ident("group")
resourceIdent, _ := w.kv.ident("resource")
namespaceIdent, _ := w.kv.ident("namespace")
nameIdent, _ := w.kv.ident("name")
ph := func(n int) string { return w.kv.dialect.ArgPlaceholder(n) }
query := fmt.Sprintf(`
DELETE FROM %s WHERE %s = %s AND %s = %s AND %s = %s AND %s = %s`,
tableIdent, groupIdent, ph(1), resourceIdent, ph(2), namespaceIdent, ph(3), nameIdent, ph(4),
)
_, err := tx.ExecContext(ctx, query, parsed.Group, parsed.Resource, parsed.Namespace, parsed.Name)
return err
}
// upsertResourceVersion inserts/updates the resource_version table
func (w *sqlWriteCloser) upsertResourceVersion(ctx context.Context, tx db.Tx, parsed *parsedKey) error {
tableIdent, _ := w.kv.ident("resource_version")
groupIdent, _ := w.kv.ident("group")
resourceIdent, _ := w.kv.ident("resource")
rvIdent, _ := w.kv.ident("resource_version")
ph := func(n int) string { return w.kv.dialect.ArgPlaceholder(n) }
var query string
switch w.kv.dialect.DialectName() {
case "postgres":
// Only update if new resource_version is greater than existing
query = fmt.Sprintf(`
INSERT INTO %s (%s, %s, %s) VALUES (%s, %s, %s)
ON CONFLICT (%s, %s) DO UPDATE SET %s = EXCLUDED.%s
WHERE EXCLUDED.%s > %s.%s`,
tableIdent, groupIdent, resourceIdent, rvIdent, ph(1), ph(2), ph(3),
groupIdent, resourceIdent, rvIdent, rvIdent,
rvIdent, tableIdent, rvIdent,
)
case "sqlite":
// SQLite supports WHERE clause in ON CONFLICT DO UPDATE
query = fmt.Sprintf(`
INSERT INTO %s (%s, %s, %s) VALUES (%s, %s, %s)
ON CONFLICT (%s, %s) DO UPDATE SET %s = EXCLUDED.%s
WHERE EXCLUDED.%s > %s.%s`,
tableIdent, groupIdent, resourceIdent, rvIdent, ph(1), ph(2), ph(3),
groupIdent, resourceIdent, rvIdent, rvIdent,
rvIdent, tableIdent, rvIdent,
)
case "mysql":
// MySQL uses ON DUPLICATE KEY UPDATE with conditional
query = fmt.Sprintf(`
INSERT INTO %s (%s, %s, %s) VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE %s = IF(VALUES(%s) > %s, VALUES(%s), %s)`,
tableIdent, groupIdent, resourceIdent, rvIdent, ph(1), ph(2), ph(3),
rvIdent, rvIdent, rvIdent, rvIdent, rvIdent,
)
default:
return fmt.Errorf("unsupported dialect: %s", w.kv.dialect.DialectName())
}
_, err := tx.ExecContext(ctx, query, parsed.Group, parsed.Resource, parsed.ResourceVersion)
return err
}
// Delete a value
func (k *sqlKV) Delete(ctx context.Context, section string, key string) error {
if section == "" {
return fmt.Errorf("section is required")
}
if key == "" {
return fmt.Errorf("key is required")
}
tableName, err := k.getTableName(section)
if err != nil {
return err
}
keyPathIdent, err := k.ident("key_path")
if err != nil {
return fmt.Errorf("invalid column identifier: %w", err)
}
// First check if key exists (to return ErrNotFound if missing)
checkQuery := fmt.Sprintf(
"SELECT 1 FROM %s WHERE %s = %s",
tableName,
keyPathIdent,
k.dialect.ArgPlaceholder(1),
)
var exists int
err = k.db.QueryRowContext(ctx, checkQuery, key).Scan(&exists)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("check existence failed: %w", err)
}
// Delete the key
deleteQuery := fmt.Sprintf(
"DELETE FROM %s WHERE %s = %s",
tableName,
keyPathIdent,
k.dialect.ArgPlaceholder(1),
)
_, err = k.db.ExecContext(ctx, deleteQuery, key)
if err != nil {
return fmt.Errorf("delete failed: %w", err)
}
return nil
}
// BatchDelete removes multiple keys from the store
func (k *sqlKV) BatchDelete(ctx context.Context, section string, keys []string) error {
if section == "" {
return fmt.Errorf("section is required")
}
if len(keys) == 0 {
return nil // Nothing to delete
}
tableName, err := k.getTableName(section)
if err != nil {
return err
}
keyPathIdent, err := k.ident("key_path")
if err != nil {
return fmt.Errorf("invalid column identifier: %w", err)
}
// Build IN clause placeholders
placeholders := make([]string, len(keys))
args := make([]interface{}, len(keys))
for i, key := range keys {
placeholders[i] = k.dialect.ArgPlaceholder(i + 1)
args[i] = key
}
// Build the query
query := fmt.Sprintf(
"DELETE FROM %s WHERE %s IN (%s)",
tableName,
keyPathIdent,
strings.Join(placeholders, ", "),
)
// Execute the query (idempotent - non-existent keys are silently ignored)
_, err = k.db.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("batch delete failed: %w", err)
}
return nil
}
// UnixTimestamp returns the current time in seconds since Epoch
func (k *sqlKV) UnixTimestamp(ctx context.Context) (int64, error) {
return time.Now().Unix(), nil
}
// Ping checks if the database connection is alive
func (k *sqlKV) Ping(ctx context.Context) error {
if k.db == nil {
return fmt.Errorf("database connection is nil")
}
return k.db.PingContext(ctx)
}
// checkDB verifies the database connection is still valid before operations
func (k *sqlKV) checkDB() error {
if k.db == nil {
return fmt.Errorf("database connection is nil")
}
// Quick ping to verify connection is alive
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := k.db.PingContext(ctx); err != nil {
return fmt.Errorf("database connection is not healthy: %w", err)
}
return nil
}
@@ -66,9 +66,15 @@ type kvStorageBackend struct {
withExperimentalClusterScope bool
//tracer trace.Tracer
//reg prometheus.Registerer
// lifecycle management
ctx context.Context
cancel context.CancelFunc
}
var _ StorageBackend = &kvStorageBackend{}
var _ LifecycleHooks = &kvStorageBackend{}
var _ resourcepb.DiagnosticsServer = &kvStorageBackend{}
type KVBackendOptions struct {
KvStore KV
@@ -81,7 +87,6 @@ type KVBackendOptions struct {
}
func NewKVStorageBackend(opts KVBackendOptions) (StorageBackend, error) {
ctx := context.Background()
kv := opts.KvStore
s, err := snowflake.NewNode(rand.Int64N(1024))
@@ -100,6 +105,9 @@ func NewKVStorageBackend(opts KVBackendOptions) (StorageBackend, error) {
eventPruningInterval = defaultEventPruningInterval
}
// Create a cancellable context for lifecycle management
ctx, cancel := context.WithCancel(context.Background())
backend := &kvStorageBackend{
kv: kv,
dataStore: newDataStore(kv),
@@ -111,18 +119,58 @@ func NewKVStorageBackend(opts KVBackendOptions) (StorageBackend, error) {
eventRetentionPeriod: eventRetentionPeriod,
eventPruningInterval: eventPruningInterval,
withExperimentalClusterScope: opts.WithExperimentalClusterScope,
ctx: ctx,
cancel: cancel,
}
err = backend.initPruner(ctx)
if err != nil {
return nil, fmt.Errorf("failed to initialize pruner: %w", err)
}
// Start the event cleanup background job
go backend.runCleanupOldEvents(ctx)
return backend, nil
}
// Init implements LifecycleHooks
func (k *kvStorageBackend) Init(ctx context.Context) error {
// Initialize the pruner
if err := k.initPruner(ctx); err != nil {
return fmt.Errorf("failed to initialize pruner: %w", err)
}
// Start the event cleanup background job using the backend's lifecycle context
go k.runCleanupOldEvents(k.ctx)
// Start the pruner if it was configured
if k.historyPruner != nil {
k.historyPruner.Start(k.ctx)
}
return nil
}
// Stop implements LifecycleHooks
func (k *kvStorageBackend) Stop(ctx context.Context) error {
// Cancel the context to stop background goroutines
// This will stop both runCleanupOldEvents and the pruner (via debouncer)
k.cancel()
return nil
}
// IsHealthy implements DiagnosticsServer
func (k *kvStorageBackend) IsHealthy(ctx context.Context, _ *resourcepb.HealthCheckRequest) (*resourcepb.HealthCheckResponse, error) {
// Check if the underlying KV store supports Ping (e.g., sqlKV)
type pinger interface {
Ping(context.Context) error
}
if p, ok := k.kv.(pinger); ok {
if err := p.Ping(ctx); err != nil {
return nil, fmt.Errorf("KV store health check failed: %w", err)
}
}
return &resourcepb.HealthCheckResponse{Status: resourcepb.HealthCheckResponse_SERVING}, nil
}
// Read implements DiagnosticsServer
func (k *kvStorageBackend) Read(ctx context.Context, req *resourcepb.ReadRequest) (*resourcepb.ReadResponse, error) {
return nil, ErrNotImplementedYet
}
// runCleanupOldEvents starts a background goroutine that periodically cleans up old events
func (k *kvStorageBackend) runCleanupOldEvents(ctx context.Context) {
// Run cleanup every hour
@@ -217,7 +265,6 @@ func (k *kvStorageBackend) initPruner(ctx context.Context) error {
}
k.historyPruner = pruner
k.historyPruner.Start(ctx)
return nil
}
@@ -1,11 +1,45 @@
UPDATE {{ .Ident "resource_history" }}
SET {{ .Ident "resource_version" }} = (
SET
{{ .Ident "resource_version" }} = (
CASE
{{ range $guid, $rv := .GUIDToRV }}
WHEN {{ $.Ident "guid" }} = {{ $.Arg $guid }} THEN CAST({{ $.Arg $rv }} AS {{ if eq $.DialectName "postgres" }}BIGINT{{ else }}SIGNED{{ end }})
{{ end }}
END
)
),
{{ .Ident "key_path" }} = {{ if eq .DialectName "sqlite" -}}
{{ .Ident "group" }} || CHAR(47) || {{ .Ident "resource" }} || CHAR(47) || {{ .Ident "namespace" }} || CHAR(47) || {{ .Ident "name" }} || CHAR(47) ||
CAST((CASE
{{- range $guid, $rv := .GUIDToRV }}
WHEN {{ $.Ident "guid" }} = {{ $.Arg $guid }} THEN ((({{ $.Arg $rv }} / 1000) - 1288834974657) * 4194304) + ({{ $.Arg $rv }} % 1000)
{{- end }}
END) AS TEXT) || CHAR(126) ||
CASE {{ .Ident "action" }}
WHEN 1 THEN 'created'
WHEN 2 THEN 'updated'
WHEN 3 THEN 'deleted'
ELSE 'unknown'
END || CHAR(126) || COALESCE({{ .Ident "folder" }}, '')
{{- else -}}
CONCAT(
{{ .Ident "group" }}, CHAR(47),
{{ .Ident "resource" }}, CHAR(47),
{{ .Ident "namespace" }}, CHAR(47),
{{ .Ident "name" }}, CHAR(47),
CAST((CASE
{{- range $guid, $rv := .GUIDToRV }}
WHEN {{ $.Ident "guid" }} = {{ $.Arg $guid }} THEN ((({{ $.Arg $rv }} DIV 1000) - 1288834974657) * 4194304) + ({{ $.Arg $rv }} MOD 1000)
{{- end }}
END) AS {{ if eq .DialectName "postgres" }}TEXT{{ else }}CHAR{{ end }}), CHAR(126),
CASE {{ .Ident "action" }}
WHEN 1 THEN 'created'
WHEN 2 THEN 'updated'
WHEN 3 THEN 'deleted'
ELSE 'unknown'
END, CHAR(126),
COALESCE({{ .Ident "folder" }}, '')
)
{{- end }}
WHERE {{ .Ident "guid" }} IN (
{{$first := true}}
{{ range $guid, $rv := .GUIDToRV }}{{if $first}}{{$first = false}}{{else}}, {{end}}{{ $.Arg $guid }}{{ end }}
@@ -3,7 +3,9 @@ package migrations
import (
"fmt"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/util/xorm"
)
func initResourceTables(mg *migrator.Migrator) string {
@@ -185,5 +187,170 @@ func initResourceTables(mg *migrator.Migrator) string {
Name: "UQE_resource_last_import_time_last_import_time",
}))
// TODO: Do we want the value to be MEDIUMTEXT ?
// TODO: What's the best name for the key_path column?
// Add key_path column to resource_history for KV interface
mg.AddMigration("Add key_path column to resource_history", migrator.NewAddColumnMigration(resource_history_table, &migrator.Column{
Name: "key_path", Type: migrator.DB_NVarchar, Length: 2048, Nullable: true,
}))
// Backfill key_path column in resource_history
mg.AddMigration("Backfill key_path column in resource_history", &resourceHistoryKeyBackfillMigrator{})
// Note: key_path remains nullable because the write pattern is:
// 1. INSERT (key_path = NULL)
// 2. UPDATE (key_path = actual value after RV allocation)
// Add index on key_path column
mg.AddMigration("Add index on key_path column in resource_history", migrator.NewAddIndexMigration(resource_history_table, &migrator.Index{
Name: "IDX_resource_history_key_path",
Cols: []string{"key_path"},
Type: migrator.IndexType,
}))
// Create resource_events table for KV interface
resource_events_table := migrator.Table{
Name: "resource_events",
Columns: []*migrator.Column{
{Name: "key_path", Type: migrator.DB_NVarchar, Length: 2048, Nullable: false, IsPrimaryKey: true},
{Name: "value", Type: migrator.DB_MediumText, Nullable: false},
},
}
mg.AddMigration("create table "+resource_events_table.Name, migrator.NewAddTableMigration(resource_events_table))
return marker
}
// resourceHistoryKeyBackfillMigrator backfills the key_path column in resource_history table
// It processes rows in batches to reduce lock duration and avoid timeouts on large tables
type resourceHistoryKeyBackfillMigrator struct {
migrator.MigrationBase
}
func (m *resourceHistoryKeyBackfillMigrator) SQL(dialect migrator.Dialect) string {
return "Backfill key_path column in resource_history using pattern: {Group}/{Resource}/{Namespace}/{Name}/{ResourceVersion}~{Action}~{Folder}"
}
func (m *resourceHistoryKeyBackfillMigrator) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
dialect := mg.Dialect.DriverName()
logger := log.New("resource-history-key-backfill")
// TODO: Verify the RV to Snowflake ID conversion is correct.
// Snowflake ID epoch in milliseconds (2010-11-04T01:42:54.657Z)
const epochMs = 1288834974657
const batchSize = 1000 // Process 1000 rows at a time
// Count total rows to backfill
totalCount, err := sess.Table("resource_history").Where("key_path IS NULL").Count()
if err != nil {
return fmt.Errorf("failed to count rows: %w", err)
}
if totalCount == 0 {
logger.Info("No rows to backfill")
return nil
}
logger.Info("Starting key_path backfill", "total_rows", totalCount)
// Build the SQL query based on the database dialect
var updateSQL string
switch dialect {
case "mysql":
updateSQL = `
UPDATE resource_history
SET key_path = CONCAT(
` + "`group`" + `, '/',
` + "`resource`" + `, '/',
` + "`namespace`" + `, '/',
` + "`name`" + `, '/',
CAST((((` + "`resource_version`" + ` DIV 1000) - ?) * 4194304) + (` + "`resource_version`" + ` MOD 1000) AS CHAR), '~',
CASE ` + "`action`" + `
WHEN 1 THEN 'created'
WHEN 2 THEN 'updated'
WHEN 3 THEN 'deleted'
ELSE 'unknown'
END, '~',
COALESCE(` + "`folder`" + `, '')
)
WHERE key_path IS NULL
LIMIT ?
`
case "postgres":
updateSQL = `
UPDATE resource_history
SET key_path = CONCAT(
"group", '/',
"resource", '/',
"namespace", '/',
"name", '/',
CAST((((resource_version / 1000) - $1) * 4194304) + (resource_version % 1000) AS BIGINT), '~',
CASE "action"
WHEN 1 THEN 'created'
WHEN 2 THEN 'updated'
WHEN 3 THEN 'deleted'
ELSE 'unknown'
END, '~',
COALESCE("folder", '')
)
WHERE guid IN (
SELECT guid FROM resource_history
WHERE key_path IS NULL
LIMIT $2
)
`
case "sqlite3":
updateSQL = `
UPDATE resource_history
SET key_path =
"group" || '/' ||
resource || '/' ||
namespace || '/' ||
name || '/' ||
CAST((((resource_version / 1000) - ?) * 4194304) + (resource_version % 1000) AS TEXT) || '~' ||
CASE action
WHEN 1 THEN 'created'
WHEN 2 THEN 'updated'
WHEN 3 THEN 'deleted'
ELSE 'unknown'
END || '~' ||
COALESCE(folder, '')
WHERE guid IN (
SELECT guid FROM resource_history
WHERE key_path IS NULL
LIMIT ?
)
`
default:
return fmt.Errorf("unsupported database dialect: %s", dialect)
}
// Process in batches
processed := int64(0)
for {
result, err := sess.Exec(updateSQL, epochMs, batchSize)
if err != nil {
return fmt.Errorf("failed to update batch: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
processed += rowsAffected
logger.Info("Backfill progress", "processed", processed, "total", totalCount,
"percent", fmt.Sprintf("%.1f%%", float64(processed)/float64(totalCount)*100))
// If we updated fewer rows than batch size, we're done
if rowsAffected < int64(batchSize) {
break
}
}
logger.Info("Backfill completed", "total_processed", processed)
return nil
}
+40 -14
View File
@@ -19,6 +19,7 @@ import (
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
)
@@ -101,21 +102,46 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
//nolint:staticcheck // not yet migrated to OpenFeature
withPruner := opts.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageHistoryPruner)
backend, err := NewBackend(BackendOptions{
DBProvider: eDB,
Tracer: opts.Tracer,
Reg: opts.Reg,
IsHA: isHA,
withPruner: withPruner,
storageMetrics: opts.StorageMetrics,
LastImportTimeMaxAge: opts.SearchOptions.MaxIndexAge, // No need to keep last_import_times older than max index age.
})
if err != nil {
return nil, err
// Check if KV backend is enabled via feature flag
//nolint:staticcheck // not yet migrated to OpenFeature
if opts.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageKVBackend) {
// Create SQL KV instance
sqlKV, err := resource.NewSQLKV(eDB)
if err != nil {
return nil, fmt.Errorf("create SQL KV: %w", err)
}
// Use existing KV storage backend (already implements StorageBackend interface)
kvBackend, err := resource.NewKVStorageBackend(resource.KVBackendOptions{
KvStore: sqlKV,
WithPruner: withPruner,
Tracer: opts.Tracer,
Reg: opts.Reg,
})
if err != nil {
return nil, fmt.Errorf("create KV backend: %w", err)
}
serverOptions.Backend = kvBackend
serverOptions.Lifecycle = kvBackend.(resource.LifecycleHooks)
serverOptions.Diagnostics = kvBackend.(resourcepb.DiagnosticsServer)
} else {
// Use existing SQL backend
backend, err := NewBackend(BackendOptions{
DBProvider: eDB,
Tracer: opts.Tracer,
Reg: opts.Reg,
IsHA: isHA,
withPruner: withPruner,
storageMetrics: opts.StorageMetrics,
LastImportTimeMaxAge: opts.SearchOptions.MaxIndexAge, // No need to keep last_import_times older than max index age.
})
if err != nil {
return nil, err
}
serverOptions.Backend = backend
serverOptions.Diagnostics = backend
serverOptions.Lifecycle = backend
}
serverOptions.Backend = backend
serverOptions.Diagnostics = backend
serverOptions.Lifecycle = backend
}
serverOptions.Search = opts.SearchOptions
@@ -34,7 +34,7 @@ type PromRulesOptions = WithNotificationOptions<{
groupNextToken?: string;
}>;
export type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'namespace' | 'excludeAlerts'> & {
type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'namespace' | 'excludeAlerts'> & {
folderUid?: string;
dashboardUid?: string;
panelId?: number;
@@ -14,7 +14,7 @@ import { ListGroup } from './components/ListGroup';
import { ListSection } from './components/ListSection';
import { LoadMoreButton } from './components/LoadMoreButton';
import { NoRulesFound } from './components/NoRulesFound';
import { getDatasourceFilter } from './hooks/filters';
import { groupFilter as groupFilterFn } from './hooks/filters';
import { toIndividualRuleGroups, usePrometheusGroupsGenerator } from './hooks/prometheusGroupsGenerator';
import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups';
import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits';
@@ -68,24 +68,14 @@ function PaginatedGroupsLoader({ rulesSourceIdentifier, application, groupFilter
};
}, []);
const filterFn = useMemo(() => {
const { groupMatches } = getDatasourceFilter({
namespace: namespaceFilter,
groupName: groupFilter,
freeFormWords: [],
ruleName: '',
labels: [],
ruleType: undefined,
ruleState: undefined,
ruleHealth: undefined,
dashboardUid: undefined,
dataSourceNames: [],
plugins: undefined,
contactPoint: undefined,
ruleSource: undefined,
});
return (group: PromRuleGroupDTO) => groupMatches(group);
}, [namespaceFilter, groupFilter]);
const filterFn = useMemo(
() => (group: PromRuleGroupDTO) =>
groupFilterFn(group, {
namespace: namespaceFilter,
groupName: groupFilter,
}),
[namespaceFilter, groupFilter]
);
const { isLoading, groups, hasMoreGroups, fetchMoreGroups, error } = useLazyLoadPrometheusGroups(
groupsGenerator.current,
@@ -17,7 +17,7 @@ import { ListGroup } from './components/ListGroup';
import { ListSection } from './components/ListSection';
import { LoadMoreButton } from './components/LoadMoreButton';
import { NoRulesFound } from './components/NoRulesFound';
import { getGrafanaFilter } from './hooks/filters';
import { groupFilter as groupFilterFn } from './hooks/filters';
import { toIndividualRuleGroups, useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator';
import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups';
import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits';
@@ -57,24 +57,14 @@ function PaginatedGroupsLoader({ groupFilter, namespaceFilter }: LoaderProps) {
};
}, []);
const filterFn = useMemo(() => {
const { frontendFilter } = getGrafanaFilter({
namespace: namespaceFilter,
groupName: groupFilter,
freeFormWords: [],
ruleName: '',
labels: [],
ruleType: undefined,
ruleState: undefined,
ruleHealth: undefined,
dashboardUid: undefined,
dataSourceNames: [],
plugins: undefined,
contactPoint: undefined,
ruleSource: undefined,
});
return (group: PromRuleGroupDTO) => frontendFilter.groupMatches(group);
}, [namespaceFilter, groupFilter]);
const filterFn = useMemo(
() => (group: PromRuleGroupDTO) =>
groupFilterFn(group, {
namespace: namespaceFilter,
groupName: groupFilter,
}),
[namespaceFilter, groupFilter]
);
const { isLoading, groups, hasMoreGroups, fetchMoreGroups, error } = useLazyLoadPrometheusGroups(
groupsGenerator.current,
@@ -1,744 +1,302 @@
import { testWithFeatureToggles } from 'test/test-utils';
import { PromAlertingRuleState, PromRuleGroupDTO, PromRuleType } from 'app/types/unified-alerting-dto';
import { mockGrafanaPromAlertingRule, mockPromAlertingRule, mockPromRecordingRule } from '../../mocks';
import { RuleHealth } from '../../search/rulesSearchParser';
import { Annotation } from '../../utils/constants';
import { getDatasourceAPIUid } from '../../utils/datasource';
import * as datasourceUtils from '../../utils/datasource';
import { getFilter } from '../../utils/search';
import { getDatasourceFilter, getGrafanaFilter } from './filters';
import { groupFilter, ruleFilter } from './filters';
jest.mock('../../utils/datasource');
describe('groupFilter', () => {
it('should filter by namespace (file path)', () => {
const group: PromRuleGroupDTO = {
name: 'Test Group',
file: 'production/alerts',
rules: [],
interval: 60,
};
const getDatasourceAPIUidMock = jest.mocked(getDatasourceAPIUid);
getDatasourceAPIUidMock.mockImplementation((ruleSourceName) => {
if (ruleSourceName === 'prometheus') {
return 'datasource-uid-1';
}
if (ruleSourceName === 'loki') {
return 'datasource-uid-3';
}
throw new Error(`Unknown datasource name: ${ruleSourceName}`);
});
describe('datasource-managed rules', () => {
describe('groupFilter', () => {
it('should filter by namespace (file path)', () => {
const group: PromRuleGroupDTO = {
name: 'Test Group',
file: 'production/alerts',
rules: [],
interval: 60,
};
const { groupMatches } = getDatasourceFilter(getFilter({ namespace: 'production' }));
expect(groupMatches(group)).toBe(true);
const { groupMatches: groupMatches2 } = getDatasourceFilter(getFilter({ namespace: 'staging' }));
expect(groupMatches2(group)).toBe(false);
});
it('should filter by group name', () => {
const group: PromRuleGroupDTO = {
name: 'CPU Usage Alerts',
file: 'production/alerts',
rules: [],
interval: 60,
};
const { groupMatches } = getDatasourceFilter(getFilter({ groupName: 'cpu' }));
expect(groupMatches(group)).toBe(true);
const { groupMatches: groupMatches2 } = getDatasourceFilter(getFilter({ groupName: 'memory' }));
expect(groupMatches2(group)).toBe(false);
});
it('should return true when no filters are applied', () => {
const group: PromRuleGroupDTO = {
name: 'Test Group',
file: 'production/alerts',
rules: [],
interval: 60,
};
const { groupMatches } = getDatasourceFilter(getFilter({}));
expect(groupMatches(group)).toBe(true);
});
expect(groupFilter(group, getFilter({ namespace: 'production' }))).toBe(true);
expect(groupFilter(group, getFilter({ namespace: 'staging' }))).toBe(false);
});
describe('ruleFilter', () => {
it('should filter by free form words in rule name', () => {
it('should filter by group name', () => {
const group: PromRuleGroupDTO = {
name: 'CPU Usage Alerts',
file: 'production/alerts',
rules: [],
interval: 60,
};
expect(groupFilter(group, getFilter({ groupName: 'cpu' }))).toBe(true);
expect(groupFilter(group, getFilter({ groupName: 'memory' }))).toBe(false);
});
it('should return true when no filters are applied', () => {
const group: PromRuleGroupDTO = {
name: 'Test Group',
file: 'production/alerts',
rules: [],
interval: 60,
};
expect(groupFilter(group, getFilter({}))).toBe(true);
});
});
describe('ruleFilter', () => {
it('should filter by free form words in rule name', () => {
const rule = mockPromAlertingRule({ name: 'High CPU Usage' });
expect(ruleFilter(rule, getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
expect(ruleFilter(rule, getFilter({ freeFormWords: ['memory'] }))).toBe(false);
});
it('should filter by rule name', () => {
const rule = mockPromAlertingRule({ name: 'High CPU Usage' });
expect(ruleFilter(rule, getFilter({ ruleName: 'cpu' }))).toBe(true);
expect(ruleFilter(rule, getFilter({ ruleName: 'memory' }))).toBe(false);
});
describe('backendFiltered parameter for backend filtering', () => {
it('should skip title filtering when backendFiltered is true', () => {
const rule = mockPromAlertingRule({ name: 'High CPU Usage' });
const { ruleMatches } = getDatasourceFilter(getFilter({ freeFormWords: ['cpu'] }));
expect(ruleMatches(rule)).toBe(true);
const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ freeFormWords: ['memory'] }));
expect(ruleMatches2(rule)).toBe(false);
// When backendFiltered is true, title search should be skipped (already filtered by backend)
expect(ruleFilter(rule, getFilter({ freeFormWords: ['memory'] }), true)).toBe(true);
expect(ruleFilter(rule, getFilter({ ruleName: 'memory' }), true)).toBe(true);
});
it('should filter by rule name', () => {
it('should perform title filtering when backendFiltered is false', () => {
const rule = mockPromAlertingRule({ name: 'High CPU Usage' });
const { ruleMatches } = getDatasourceFilter(getFilter({ ruleName: 'cpu' }));
expect(ruleMatches(rule)).toBe(true);
const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ ruleName: 'memory' }));
expect(ruleMatches2(rule)).toBe(false);
// When backendFiltered is false, title search should be performed client-side
expect(ruleFilter(rule, getFilter({ freeFormWords: ['cpu'] }), false)).toBe(true);
expect(ruleFilter(rule, getFilter({ freeFormWords: ['memory'] }), false)).toBe(false);
expect(ruleFilter(rule, getFilter({ ruleName: 'cpu' }), false)).toBe(true);
expect(ruleFilter(rule, getFilter({ ruleName: 'memory' }), false)).toBe(false);
});
it('should filter by labels', () => {
const rule = mockPromAlertingRule({
labels: { severity: 'critical', team: 'ops' },
alerts: [],
});
it('should perform title filtering when backendFiltered is not specified (backward compatibility)', () => {
const rule = mockPromAlertingRule({ name: 'High CPU Usage' });
const { ruleMatches } = getDatasourceFilter(getFilter({ labels: ['severity=critical'] }));
expect(ruleMatches(rule)).toBe(true);
// When backendFiltered is not provided, should perform client-side filtering (default behavior)
expect(ruleFilter(rule, getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
expect(ruleFilter(rule, getFilter({ freeFormWords: ['memory'] }))).toBe(false);
});
});
const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ labels: ['severity=warning'] }));
expect(ruleMatches2(rule)).toBe(false);
const { ruleMatches: ruleMatches3 } = getDatasourceFilter(getFilter({ labels: ['team=ops'] }));
expect(ruleMatches3(rule)).toBe(true);
it('should filter by labels', () => {
const rule = mockPromAlertingRule({
labels: { severity: 'critical', team: 'ops' },
alerts: [],
});
it('should filter by alert instance labels', () => {
const rule = mockPromAlertingRule({
labels: { severity: 'critical' },
alerts: [
{
labels: { instance: 'server-1', env: 'production' },
state: PromAlertingRuleState.Firing,
value: '100',
activeAt: '',
annotations: {},
},
],
});
expect(ruleFilter(rule, getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(ruleFilter(rule, getFilter({ labels: ['severity=warning'] }))).toBe(false);
expect(ruleFilter(rule, getFilter({ labels: ['team=ops'] }))).toBe(true);
});
const { ruleMatches } = getDatasourceFilter(getFilter({ labels: ['instance=server-1'] }));
expect(ruleMatches(rule)).toBe(true);
const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ labels: ['env=production'] }));
expect(ruleMatches2(rule)).toBe(true);
const { ruleMatches: ruleMatches3 } = getDatasourceFilter(getFilter({ labels: ['instance=server-2'] }));
expect(ruleMatches3(rule)).toBe(false);
it('should filter by alert instance labels', () => {
const rule = mockPromAlertingRule({
labels: { severity: 'critical' },
alerts: [
{
labels: { instance: 'server-1', env: 'production' },
state: PromAlertingRuleState.Firing,
value: '100',
activeAt: '',
annotations: {},
},
],
});
it('should filter by rule type', () => {
const alertingRule = mockPromAlertingRule({ name: 'Test Alert' });
const recordingRule = mockPromRecordingRule({ name: 'Test Recording' });
expect(ruleFilter(rule, getFilter({ labels: ['instance=server-1'] }))).toBe(true);
expect(ruleFilter(rule, getFilter({ labels: ['env=production'] }))).toBe(true);
expect(ruleFilter(rule, getFilter({ labels: ['instance=server-2'] }))).toBe(false);
});
const { ruleMatches } = getDatasourceFilter(getFilter({ ruleType: PromRuleType.Alerting }));
expect(ruleMatches(alertingRule)).toBe(true);
expect(ruleMatches(recordingRule)).toBe(false);
it('should filter by rule type', () => {
const alertingRule = mockPromAlertingRule({ name: 'Test Alert' });
const recordingRule = mockPromRecordingRule({ name: 'Test Recording' });
const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ ruleType: PromRuleType.Recording }));
expect(ruleMatches2(alertingRule)).toBe(false);
expect(ruleMatches2(recordingRule)).toBe(true);
expect(ruleFilter(alertingRule, getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true);
expect(ruleFilter(alertingRule, getFilter({ ruleType: PromRuleType.Recording }))).toBe(false);
expect(ruleFilter(recordingRule, getFilter({ ruleType: PromRuleType.Recording }))).toBe(true);
expect(ruleFilter(recordingRule, getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
});
it('should filter by rule state', () => {
const firingRule = mockPromAlertingRule({
name: 'Firing Alert',
state: PromAlertingRuleState.Firing,
});
it('should filter by rule state', () => {
const firingRule = mockPromAlertingRule({
name: 'Firing Alert',
state: PromAlertingRuleState.Firing,
});
const pendingRule = mockPromAlertingRule({
name: 'Pending Alert',
state: PromAlertingRuleState.Pending,
});
const { ruleMatches } = getDatasourceFilter(getFilter({ ruleState: PromAlertingRuleState.Firing }));
expect(ruleMatches(firingRule)).toBe(true);
expect(ruleMatches(pendingRule)).toBe(false);
const { ruleMatches: ruleMatches2 } = getDatasourceFilter(
getFilter({ ruleState: PromAlertingRuleState.Pending })
);
expect(ruleMatches2(firingRule)).toBe(false);
expect(ruleMatches2(pendingRule)).toBe(true);
const pendingRule = mockPromAlertingRule({
name: 'Pending Alert',
state: PromAlertingRuleState.Pending,
});
it('should filter out recording rules when filtering by rule state', () => {
const recordingRule = mockPromRecordingRule({
name: 'Recording Rule',
});
expect(ruleFilter(firingRule, getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(true);
expect(ruleFilter(firingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(false);
expect(ruleFilter(pendingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(true);
});
// Recording rules should always be filtered out when any rule state filter is applied as they don't have a state
const { ruleMatches } = getDatasourceFilter(getFilter({ ruleState: PromAlertingRuleState.Firing }));
expect(ruleMatches(recordingRule)).toBe(false);
const { ruleMatches: ruleMatches2 } = getDatasourceFilter(
getFilter({ ruleState: PromAlertingRuleState.Pending })
);
expect(ruleMatches2(recordingRule)).toBe(false);
const { ruleMatches: ruleMatches3 } = getDatasourceFilter(
getFilter({ ruleState: PromAlertingRuleState.Inactive })
);
expect(ruleMatches3(recordingRule)).toBe(false);
it('should filter out recording rules when filtering by rule state', () => {
const recordingRule = mockPromRecordingRule({
name: 'Recording Rule',
});
it('should filter by rule health', () => {
const healthyRule = mockPromAlertingRule({
name: 'Healthy Rule',
health: RuleHealth.Ok,
});
// Recording rules should always be filtered out when any rule state filter is applied as they don't have a state
expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false);
expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(false);
expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Inactive }))).toBe(false);
});
const errorRule = mockPromAlertingRule({
name: 'Error Rule',
health: RuleHealth.Error,
});
const prometheusErrorRule = mockPromAlertingRule({
name: 'Error Rule',
health: 'err',
});
const { ruleMatches } = getDatasourceFilter(getFilter({ ruleHealth: RuleHealth.Ok }));
expect(ruleMatches(healthyRule)).toBe(true);
expect(ruleMatches(errorRule)).toBe(false);
const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ ruleHealth: RuleHealth.Error }));
expect(ruleMatches2(healthyRule)).toBe(false);
expect(ruleMatches2(errorRule)).toBe(true);
expect(ruleMatches2(prometheusErrorRule)).toBe(true);
it('should filter by rule health', () => {
const healthyRule = mockPromAlertingRule({
name: 'Healthy Rule',
health: RuleHealth.Ok,
});
it('should normalize health values when filtering', () => {
// Legacy Prometheus health value 'err' should be normalized to 'error'
const legacyErrorRule = mockPromAlertingRule({
name: 'Legacy Error Rule',
health: 'err',
});
// When filtering for 'error', it should match rules with health 'err' (legacy) or 'error'
const { ruleMatches } = getDatasourceFilter(getFilter({ ruleHealth: RuleHealth.Error }));
expect(ruleMatches(legacyErrorRule)).toBe(true);
const errorRule = mockPromAlertingRule({
name: 'Error Rule',
health: RuleHealth.Error,
});
it('should filter by dashboard UID', () => {
const ruleDashboardA = mockPromAlertingRule({
name: 'Dashboard A Rule',
annotations: { [Annotation.dashboardUID]: 'dashboard-a' },
});
const ruleDashboardB = mockPromAlertingRule({
name: 'Dashboard B Rule',
annotations: { [Annotation.dashboardUID]: 'dashboard-b' },
});
const { ruleMatches } = getDatasourceFilter(getFilter({ dashboardUid: 'dashboard-a' }));
expect(ruleMatches(ruleDashboardA)).toBe(true);
expect(ruleMatches(ruleDashboardB)).toBe(false);
const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ dashboardUid: 'dashboard-b' }));
expect(ruleMatches2(ruleDashboardA)).toBe(false);
expect(ruleMatches2(ruleDashboardB)).toBe(true);
const prometheusErrorRule = mockPromAlertingRule({
name: 'Error Rule',
health: 'err',
});
it('should filter out recording rules when filtering by dashboard UID', () => {
const recordingRule = mockPromRecordingRule({
name: 'Recording Rule',
// Recording rules cannot have dashboard UIDs because they don't have annotations
});
expect(ruleFilter(healthyRule, getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(true);
expect(ruleFilter(healthyRule, getFilter({ ruleHealth: RuleHealth.Error }))).toBe(false);
expect(ruleFilter(errorRule, getFilter({ ruleHealth: RuleHealth.Error }))).toBe(true);
expect(ruleFilter(prometheusErrorRule, getFilter({ ruleHealth: RuleHealth.Error }))).toBe(true);
});
// Dashboard UID filter should filter out recording rules
const { ruleMatches } = getDatasourceFilter(getFilter({ dashboardUid: 'any-dashboard' }));
expect(ruleMatches(recordingRule)).toBe(false);
it('should normalize health values when filtering', () => {
// Legacy Prometheus health value 'err' should be normalized to 'error'
const legacyErrorRule = mockPromAlertingRule({
name: 'Legacy Error Rule',
health: 'err',
});
describe('dataSourceNames filter', () => {
it('should match rules that use the filtered datasource', () => {
// Create a Grafana rule with matching datasource
const ruleWithMatchingDatasource = mockGrafanaPromAlertingRule({
queriedDatasourceUIDs: ['datasource-uid-1'],
});
// When filtering for 'error', it should match rules with health 'err' (legacy) or 'error'
expect(ruleFilter(legacyErrorRule, getFilter({ ruleHealth: RuleHealth.Error }))).toBe(true);
});
// 'prometheus' resolves to 'datasource-uid-1' which is in the rule
const { ruleMatches } = getDatasourceFilter(getFilter({ dataSourceNames: ['prometheus'] }));
expect(ruleMatches(ruleWithMatchingDatasource)).toBe(true);
});
it('should filter by dashboard UID', () => {
const ruleDashboardA = mockPromAlertingRule({
name: 'Dashboard A Rule',
annotations: { [Annotation.dashboardUID]: 'dashboard-a' },
});
it("should filter out rules that don't use the filtered datasource", () => {
// Create a Grafana rule without the target datasource
const ruleWithoutMatchingDatasource = mockGrafanaPromAlertingRule({
queriedDatasourceUIDs: ['datasource-uid-1', 'datasource-uid-2'],
});
const ruleDashboardB = mockPromAlertingRule({
name: 'Dashboard B Rule',
annotations: { [Annotation.dashboardUID]: 'dashboard-b' },
});
// 'loki' resolves to 'datasource-uid-3' which is not in the rule
const { ruleMatches } = getDatasourceFilter(getFilter({ dataSourceNames: ['loki'] }));
expect(ruleMatches(ruleWithoutMatchingDatasource)).toBe(false);
});
expect(ruleFilter(ruleDashboardA, getFilter({ dashboardUid: 'dashboard-a' }))).toBe(true);
expect(ruleFilter(ruleDashboardA, getFilter({ dashboardUid: 'dashboard-b' }))).toBe(false);
expect(ruleFilter(ruleDashboardB, getFilter({ dashboardUid: 'dashboard-b' }))).toBe(true);
});
it('should return false when there is an error parsing the query', () => {
const ruleWithInvalidQuery = mockGrafanaPromAlertingRule({
query: 'not-valid-json',
});
it('should filter out recording rules when filtering by dashboard UID', () => {
const recordingRule = mockPromRecordingRule({
name: 'Recording Rule',
// Recording rules cannot have dashboard UIDs because they don't have annotations
});
const { ruleMatches } = getDatasourceFilter(getFilter({ dataSourceNames: ['prometheus'] }));
expect(ruleMatches(ruleWithInvalidQuery)).toBe(false);
// Dashboard UID filter should filter out recording rules
expect(ruleFilter(recordingRule, getFilter({ dashboardUid: 'any-dashboard' }))).toBe(false);
});
describe('dataSourceNames filter', () => {
let getDataSourceUIDSpy: jest.SpyInstance;
beforeEach(() => {
getDataSourceUIDSpy = jest.spyOn(datasourceUtils, 'getDatasourceAPIUid').mockImplementation((ruleSourceName) => {
if (ruleSourceName === 'prometheus') {
return 'datasource-uid-1';
}
if (ruleSourceName === 'loki') {
return 'datasource-uid-3';
}
throw new Error(`Unknown datasource name: ${ruleSourceName}`);
});
});
it('should combine multiple filters with AND logic', () => {
const rule = mockPromAlertingRule({
name: 'High CPU Usage Production',
labels: { severity: 'critical', environment: 'production' },
state: PromAlertingRuleState.Firing,
health: RuleHealth.Ok,
});
const filter = getFilter({
ruleName: 'cpu',
labels: ['severity=critical', 'environment=production'],
ruleState: PromAlertingRuleState.Firing,
ruleHealth: RuleHealth.Ok,
});
const { ruleMatches } = getDatasourceFilter(filter);
expect(ruleMatches(rule)).toBe(true);
afterEach(() => {
// Clean up
getDataSourceUIDSpy.mockRestore();
});
it('should return false if any filter does not match', () => {
const rule = mockPromAlertingRule({
name: 'High CPU Usage Production',
labels: { severity: 'critical', environment: 'production' },
state: PromAlertingRuleState.Firing,
health: RuleHealth.Ok,
alerts: [],
it('should match rules that use the filtered datasource', () => {
// Create a Grafana rule with matching datasource
const ruleWithMatchingDatasource = mockGrafanaPromAlertingRule({
queriedDatasourceUIDs: ['datasource-uid-1'],
});
const filter = getFilter({
ruleName: 'cpu',
labels: ['severity=warning'],
ruleState: PromAlertingRuleState.Firing,
ruleHealth: RuleHealth.Ok,
});
const { ruleMatches } = getDatasourceFilter(filter);
expect(ruleMatches(rule)).toBe(false);
});
});
});
describe('grafana-managed rules', () => {
describe('groupFilter', () => {
it('should filter by namespace (file path)', () => {
const group: PromRuleGroupDTO = {
name: 'Test Group',
file: 'production/alerts',
rules: [],
interval: 60,
};
const { frontendFilter } = getGrafanaFilter(getFilter({ namespace: 'production' }));
expect(frontendFilter.groupMatches(group)).toBe(true);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ namespace: 'staging' }));
expect(frontendFilter2.groupMatches(group)).toBe(false);
});
it('should filter by group name', () => {
const group: PromRuleGroupDTO = {
name: 'CPU Usage Alerts',
file: 'production/alerts',
rules: [],
interval: 60,
};
const { frontendFilter } = getGrafanaFilter(getFilter({ groupName: 'cpu' }));
expect(frontendFilter.groupMatches(group)).toBe(true);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ groupName: 'memory' }));
expect(frontendFilter2.groupMatches(group)).toBe(false);
});
it('should return true when no filters are applied', () => {
const group: PromRuleGroupDTO = {
name: 'Test Group',
file: 'production/alerts',
rules: [],
interval: 60,
};
const { frontendFilter } = getGrafanaFilter(getFilter({}));
expect(frontendFilter.groupMatches(group)).toBe(true);
});
});
describe('ruleFilter - frontend filters', () => {
it('should filter by free form words in rule name', () => {
const rule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage' });
const { frontendFilter } = getGrafanaFilter(getFilter({ freeFormWords: ['cpu'] }));
expect(frontendFilter.ruleMatches(rule)).toBe(true);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ freeFormWords: ['memory'] }));
expect(frontendFilter2.ruleMatches(rule)).toBe(false);
});
it('should filter by rule name', () => {
const rule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage' });
const { frontendFilter } = getGrafanaFilter(getFilter({ ruleName: 'cpu' }));
expect(frontendFilter.ruleMatches(rule)).toBe(true);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ ruleName: 'memory' }));
expect(frontendFilter2.ruleMatches(rule)).toBe(false);
});
it('should filter by labels', () => {
const rule = mockGrafanaPromAlertingRule({
labels: { severity: 'critical', team: 'ops' },
alerts: [],
});
const { frontendFilter } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] }));
expect(frontendFilter.ruleMatches(rule)).toBe(true);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] }));
expect(frontendFilter2.ruleMatches(rule)).toBe(false);
const { frontendFilter: frontendFilter3 } = getGrafanaFilter(getFilter({ labels: ['team=ops'] }));
expect(frontendFilter3.ruleMatches(rule)).toBe(true);
});
it('should filter by rule type', () => {
const alertingRule = mockGrafanaPromAlertingRule({ name: 'Test Alert' });
const recordingRule = mockPromRecordingRule({ name: 'Test Recording' });
const { frontendFilter } = getGrafanaFilter(getFilter({ ruleType: PromRuleType.Alerting }));
expect(frontendFilter.ruleMatches(alertingRule)).toBe(true);
expect(frontendFilter.ruleMatches(recordingRule)).toBe(false);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ ruleType: PromRuleType.Recording }));
expect(frontendFilter2.ruleMatches(alertingRule)).toBe(false);
expect(frontendFilter2.ruleMatches(recordingRule)).toBe(true);
});
it('should filter by dashboard UID', () => {
const ruleDashboardA = mockGrafanaPromAlertingRule({
name: 'Dashboard A Rule',
annotations: { [Annotation.dashboardUID]: 'dashboard-a' },
});
const ruleDashboardB = mockGrafanaPromAlertingRule({
name: 'Dashboard B Rule',
annotations: { [Annotation.dashboardUID]: 'dashboard-b' },
});
const { frontendFilter } = getGrafanaFilter(getFilter({ dashboardUid: 'dashboard-a' }));
expect(frontendFilter.ruleMatches(ruleDashboardA)).toBe(true);
expect(frontendFilter.ruleMatches(ruleDashboardB)).toBe(false);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ dashboardUid: 'dashboard-b' }));
expect(frontendFilter2.ruleMatches(ruleDashboardA)).toBe(false);
expect(frontendFilter2.ruleMatches(ruleDashboardB)).toBe(true);
});
describe('dataSourceNames filter', () => {
it('should match rules that use the filtered datasource', () => {
const ruleWithMatchingDatasource = mockGrafanaPromAlertingRule({
queriedDatasourceUIDs: ['datasource-uid-1'],
});
const { frontendFilter } = getGrafanaFilter(getFilter({ dataSourceNames: ['prometheus'] }));
expect(frontendFilter.ruleMatches(ruleWithMatchingDatasource)).toBe(true);
});
it("should filter out rules that don't use the filtered datasource", () => {
const ruleWithoutMatchingDatasource = mockGrafanaPromAlertingRule({
queriedDatasourceUIDs: ['datasource-uid-1', 'datasource-uid-2'],
});
const { frontendFilter } = getGrafanaFilter(getFilter({ dataSourceNames: ['loki'] }));
expect(frontendFilter.ruleMatches(ruleWithoutMatchingDatasource)).toBe(false);
});
});
});
describe('ruleFilter - backend filters (should NOT be applied in frontend)', () => {
it('should NOT filter by rule state in frontend (returns true regardless)', () => {
const firingRule = mockGrafanaPromAlertingRule({
name: 'Firing Alert',
state: PromAlertingRuleState.Firing,
});
const pendingRule = mockGrafanaPromAlertingRule({
name: 'Pending Alert',
state: PromAlertingRuleState.Pending,
});
// Frontend filter should return true for all states since backend handles this
const { frontendFilter } = getGrafanaFilter(getFilter({ ruleState: PromAlertingRuleState.Firing }));
expect(frontendFilter.ruleMatches(firingRule)).toBe(true);
expect(frontendFilter.ruleMatches(pendingRule)).toBe(true);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(
getFilter({ ruleState: PromAlertingRuleState.Pending })
);
expect(frontendFilter2.ruleMatches(firingRule)).toBe(true);
expect(frontendFilter2.ruleMatches(pendingRule)).toBe(true);
});
it('should NOT filter by rule health in frontend (returns true regardless)', () => {
const healthyRule = mockGrafanaPromAlertingRule({
name: 'Healthy Rule',
health: RuleHealth.Ok,
});
const errorRule = mockGrafanaPromAlertingRule({
name: 'Error Rule',
health: RuleHealth.Error,
});
// Frontend filter should return true for all health states since backend handles this
const { frontendFilter } = getGrafanaFilter(getFilter({ ruleHealth: RuleHealth.Ok }));
expect(frontendFilter.ruleMatches(healthyRule)).toBe(true);
expect(frontendFilter.ruleMatches(errorRule)).toBe(true);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ ruleHealth: RuleHealth.Error }));
expect(frontendFilter2.ruleMatches(healthyRule)).toBe(true);
expect(frontendFilter2.ruleMatches(errorRule)).toBe(true);
});
it('should NOT filter by contact point in frontend (returns true regardless)', () => {
const ruleWithContactPoint = mockGrafanaPromAlertingRule({
name: 'Rule with Contact Point',
notificationSettings: {
receiver: 'contact-point-1',
},
});
const ruleWithDifferentContactPoint = mockGrafanaPromAlertingRule({
name: 'Rule with Different Contact Point',
notificationSettings: {
receiver: 'contact-point-2',
},
});
// Frontend filter should return true for all contact points since backend handles this
const { frontendFilter } = getGrafanaFilter(getFilter({ contactPoint: 'contact-point-1' }));
expect(frontendFilter.ruleMatches(ruleWithContactPoint)).toBe(true);
expect(frontendFilter.ruleMatches(ruleWithDifferentContactPoint)).toBe(true);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ contactPoint: 'contact-point-2' }));
expect(frontendFilter2.ruleMatches(ruleWithContactPoint)).toBe(true);
expect(frontendFilter2.ruleMatches(ruleWithDifferentContactPoint)).toBe(true);
});
});
describe('backendFilter', () => {
it('should include ruleState in backend filter', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ ruleState: PromAlertingRuleState.Firing }));
expect(backendFilter.state).toEqual([PromAlertingRuleState.Firing]);
});
it('should include ruleHealth in backend filter', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ ruleHealth: RuleHealth.Error }));
expect(backendFilter.health).toEqual([RuleHealth.Error]);
});
it('should include contactPoint in backend filter', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ contactPoint: 'my-contact-point' }));
expect(backendFilter.contactPoint).toBe('my-contact-point');
});
it('should handle empty backend filters', () => {
const { backendFilter } = getGrafanaFilter(getFilter({}));
expect(backendFilter.state).toEqual([]);
expect(backendFilter.health).toEqual([]);
expect(backendFilter.contactPoint).toBeUndefined();
});
});
describe('backend filtering with alertingUIUseBackendFilters feature toggle', () => {
describe('when alertingUIUseBackendFilters is enabled', () => {
testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] });
it('should include title in backend filter when freeFormWords are provided', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ freeFormWords: ['cpu', 'usage'] }));
expect(backendFilter.title).toBe('cpu usage');
});
it('should include title in backend filter when ruleName is provided', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ ruleName: 'high cpu' }));
expect(backendFilter.title).toBe('high cpu');
});
it('should combine ruleName and freeFormWords in title', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ ruleName: 'alert', freeFormWords: ['cpu'] }));
expect(backendFilter.title).toBe('alert cpu');
});
it('should not include title when no title filters are provided', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ ruleState: PromAlertingRuleState.Firing }));
expect(backendFilter.title).toBeUndefined();
});
it('should skip freeFormWords filtering on frontend when backend filtering is enabled', () => {
const rule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage' });
const { frontendFilter } = getGrafanaFilter(getFilter({ freeFormWords: ['memory'] }));
// Should return true because freeFormWords filter is null (handled by backend)
expect(frontendFilter.ruleMatches(rule)).toBe(true);
});
it('should skip ruleName filtering on frontend when backend filtering is enabled', () => {
const rule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage' });
const { frontendFilter } = getGrafanaFilter(getFilter({ ruleName: 'memory' }));
// Should return true because ruleName filter is null (handled by backend)
expect(frontendFilter.ruleMatches(rule)).toBe(true);
});
it('should include ruleType in backend filter when provided', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ ruleType: PromRuleType.Alerting }));
expect(backendFilter.type).toBe(PromRuleType.Alerting);
});
it('should not include ruleType in backend filter when not provided', () => {
const { backendFilter } = getGrafanaFilter(getFilter({}));
expect(backendFilter.type).toBeUndefined();
});
it('should skip ruleType filtering on frontend when backend filtering is enabled', () => {
const alertingRule = mockGrafanaPromAlertingRule({ name: 'Test Alert' });
const recordingRule = mockPromRecordingRule({ name: 'Test Recording' });
const { frontendFilter } = getGrafanaFilter(getFilter({ ruleType: PromRuleType.Alerting }));
// Should return true for both because ruleType filter is null (handled by backend)
expect(frontendFilter.ruleMatches(alertingRule)).toBe(true);
expect(frontendFilter.ruleMatches(recordingRule)).toBe(true);
});
it('should include dashboardUid in backend filter when provided', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ dashboardUid: 'dashboard-123' }));
expect(backendFilter.dashboardUid).toBe('dashboard-123');
});
it('should not include dashboardUid in backend filter when not provided', () => {
const { backendFilter } = getGrafanaFilter(getFilter({}));
expect(backendFilter.dashboardUid).toBeUndefined();
});
it('should skip dashboardUid filtering on frontend when backend filtering is enabled', () => {
const ruleWithDashboard = mockGrafanaPromAlertingRule({
name: 'Dashboard Rule',
annotations: { [Annotation.dashboardUID]: 'dashboard-a' },
});
const { frontendFilter } = getGrafanaFilter(getFilter({ dashboardUid: 'dashboard-b' }));
// Should return true because dashboardUid filter is null (handled by backend)
expect(frontendFilter.ruleMatches(ruleWithDashboard)).toBe(true);
});
it('should still apply other frontend filters', () => {
const rule = mockGrafanaPromAlertingRule({
name: 'High CPU Usage',
labels: { severity: 'critical', team: 'ops' },
alerts: [],
});
// Label filter should still work on frontend
const { frontendFilter } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] }));
expect(frontendFilter.ruleMatches(rule)).toBe(false);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] }));
expect(frontendFilter2.ruleMatches(rule)).toBe(true);
});
});
describe('when alertingUIUseBackendFilters is disabled', () => {
testWithFeatureToggles({ disable: ['alertingUIUseBackendFilters'] });
it('should not include title in backend filter', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ freeFormWords: ['cpu'] }));
expect(backendFilter.title).toBeUndefined();
});
it('should perform freeFormWords filtering on frontend', () => {
const rule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage' });
const { frontendFilter } = getGrafanaFilter(getFilter({ freeFormWords: ['cpu'] }));
expect(frontendFilter.ruleMatches(rule)).toBe(true);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ freeFormWords: ['memory'] }));
expect(frontendFilter2.ruleMatches(rule)).toBe(false);
});
it('should perform ruleName filtering on frontend', () => {
const rule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage' });
const { frontendFilter } = getGrafanaFilter(getFilter({ ruleName: 'cpu' }));
expect(frontendFilter.ruleMatches(rule)).toBe(true);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ ruleName: 'memory' }));
expect(frontendFilter2.ruleMatches(rule)).toBe(false);
});
it('should not include ruleType in backend filter', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ ruleType: PromRuleType.Alerting }));
expect(backendFilter.type).toBeUndefined();
});
it('should perform ruleType filtering on frontend', () => {
const alertingRule = mockGrafanaPromAlertingRule({ name: 'Test Alert' });
const recordingRule = mockPromRecordingRule({ name: 'Test Recording' });
const { frontendFilter } = getGrafanaFilter(getFilter({ ruleType: PromRuleType.Alerting }));
expect(frontendFilter.ruleMatches(alertingRule)).toBe(true);
expect(frontendFilter.ruleMatches(recordingRule)).toBe(false);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ ruleType: PromRuleType.Recording }));
expect(frontendFilter2.ruleMatches(alertingRule)).toBe(false);
expect(frontendFilter2.ruleMatches(recordingRule)).toBe(true);
});
it('should not include dashboardUid in backend filter', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ dashboardUid: 'dashboard-123' }));
expect(backendFilter.dashboardUid).toBeUndefined();
});
it('should perform dashboardUid filtering on frontend', () => {
const ruleDashboardA = mockGrafanaPromAlertingRule({
name: 'Dashboard A Rule',
annotations: { [Annotation.dashboardUID]: 'dashboard-a' },
});
const ruleDashboardB = mockGrafanaPromAlertingRule({
name: 'Dashboard B Rule',
annotations: { [Annotation.dashboardUID]: 'dashboard-b' },
});
const { frontendFilter } = getGrafanaFilter(getFilter({ dashboardUid: 'dashboard-a' }));
expect(frontendFilter.ruleMatches(ruleDashboardA)).toBe(true);
expect(frontendFilter.ruleMatches(ruleDashboardB)).toBe(false);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ dashboardUid: 'dashboard-b' }));
expect(frontendFilter2.ruleMatches(ruleDashboardA)).toBe(false);
expect(frontendFilter2.ruleMatches(ruleDashboardB)).toBe(true);
});
// 'prometheus' resolves to 'datasource-uid-1' which is in the rule
expect(ruleFilter(ruleWithMatchingDatasource, getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
});
it("should filter out rules that don't use the filtered datasource", () => {
// Create a Grafana rule without the target datasource
const ruleWithoutMatchingDatasource = mockGrafanaPromAlertingRule({
queriedDatasourceUIDs: ['datasource-uid-1', 'datasource-uid-2'],
});
// 'loki' resolves to 'datasource-uid-3' which is not in the rule
expect(ruleFilter(ruleWithoutMatchingDatasource, getFilter({ dataSourceNames: ['loki'] }))).toBe(false);
});
it('should return false when there is an error parsing the query', () => {
const ruleWithInvalidQuery = mockGrafanaPromAlertingRule({
query: 'not-valid-json',
});
expect(ruleFilter(ruleWithInvalidQuery, getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false);
});
});
it('should combine multiple filters with AND logic', () => {
const rule = mockPromAlertingRule({
name: 'High CPU Usage Production',
labels: { severity: 'critical', environment: 'production' },
state: PromAlertingRuleState.Firing,
health: RuleHealth.Ok,
});
const filter = getFilter({
ruleName: 'cpu',
labels: ['severity=critical', 'environment=production'],
ruleState: PromAlertingRuleState.Firing,
ruleHealth: RuleHealth.Ok,
});
expect(ruleFilter(rule, filter)).toBe(true);
});
it('should return false if any filter does not match', () => {
const rule = mockPromAlertingRule({
name: 'High CPU Usage Production',
labels: { severity: 'critical', environment: 'production' },
state: PromAlertingRuleState.Firing,
health: RuleHealth.Ok,
alerts: [],
});
const filter = getFilter({
ruleName: 'cpu',
labels: ['severity=warning'],
ruleState: PromAlertingRuleState.Firing,
ruleHealth: RuleHealth.Ok,
});
expect(ruleFilter(rule, filter)).toBe(false);
});
});
@@ -4,8 +4,6 @@ import memoize from 'micro-memoize';
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { GrafanaPromRulesOptions } from '../../api/prometheusApi';
import { shouldUseBackendFilters } from '../../featureToggles';
import { RulesFilter } from '../../search/rulesSearchParser';
import { labelsMatchMatchers } from '../../utils/alertmanager';
import { Annotation } from '../../utils/constants';
@@ -15,119 +13,21 @@ import { parseMatcher } from '../../utils/matchers';
import { isPluginProvidedRule, prometheusRuleType } from '../../utils/rules';
import { normalizeHealth } from '../components/util';
type RuleFilterHandler = (rule: PromRuleDTO, filterState: RulesFilter) => boolean;
type GroupFilterHandler = (
group: PromRuleGroupDTO,
filterState: Pick<RulesFilter, 'namespace' | 'groupName'>
) => boolean;
type RuleFilterConfig = Record<
Exclude<keyof RulesFilter, 'namespace' | 'groupName' | 'ruleSource'>,
RuleFilterHandler | null
>;
type GroupFilterConfig = Record<keyof Pick<RulesFilter, 'namespace' | 'groupName'>, GroupFilterHandler | null>;
export function getGrafanaFilter(filterState: RulesFilter) {
const normalizedFilterState = normalizeFilterState(filterState);
const useBackendFilters = shouldUseBackendFilters();
// Build title search for backend filtering
const titleSearch = buildTitleSearch(normalizedFilterState);
const backendFilter: GrafanaPromRulesOptions = {
state: normalizedFilterState.ruleState ? [normalizedFilterState.ruleState] : [],
health: normalizedFilterState.ruleHealth ? [normalizedFilterState.ruleHealth] : [],
contactPoint: normalizedFilterState.contactPoint ?? undefined,
title: useBackendFilters ? titleSearch : undefined,
type: useBackendFilters ? normalizedFilterState.ruleType : undefined,
dashboardUid: useBackendFilters ? normalizedFilterState.dashboardUid : undefined,
};
const grafanaFilterProcessingConfig: RuleFilterConfig = {
// When backend filtering is enabled, these filters are handled by the backend
freeFormWords: useBackendFilters ? null : freeFormFilter,
ruleName: useBackendFilters ? null : ruleNameFilter,
ruleState: null,
ruleType: useBackendFilters ? null : ruleTypeFilter,
dataSourceNames: dataSourceNamesFilter,
labels: labelsFilter,
ruleHealth: null,
dashboardUid: useBackendFilters ? null : dashboardUidFilter,
plugins: pluginsFilter,
contactPoint: null,
};
const grafanaGroupFilterConfig: GroupFilterConfig = {
namespace: namespaceFilter,
groupName: groupNameFilter,
};
return {
backendFilter,
frontendFilter: {
groupMatches: (group: PromRuleGroupDTO) => groupMatches(group, normalizedFilterState, grafanaGroupFilterConfig),
ruleMatches: (rule: PromRuleDTO) => ruleMatches(rule, normalizedFilterState, grafanaFilterProcessingConfig),
},
};
}
export function getDatasourceFilter(filterState: RulesFilter) {
const normalizedFilterState = normalizeFilterState(filterState);
const dsRuleFilterConfig: RuleFilterConfig = {
freeFormWords: freeFormFilter,
ruleName: ruleNameFilter,
ruleState: ruleStateFilter,
ruleType: ruleTypeFilter,
dataSourceNames: dataSourceNamesFilter,
labels: labelsFilter,
ruleHealth: ruleHealthFilter,
dashboardUid: dashboardUidFilter,
plugins: pluginsFilter,
contactPoint: contactPointFilter,
};
const dsGroupFilterConfig: GroupFilterConfig = {
namespace: namespaceFilter,
groupName: groupNameFilter,
};
return {
groupMatches: (group: PromRuleGroupDTO) => groupMatches(group, normalizedFilterState, dsGroupFilterConfig),
ruleMatches: (rule: PromRuleDTO) => ruleMatches(rule, normalizedFilterState, dsRuleFilterConfig),
};
}
/**
* @returns True if the group matches the filter, false otherwise. Keeps rules intact
*/
function groupMatches(
export function groupFilter(
group: PromRuleGroupDTO,
filterState: Pick<RulesFilter, 'namespace' | 'groupName'>,
filterConfig: GroupFilterConfig
filterState: Pick<RulesFilter, 'namespace' | 'groupName'>
): boolean {
if (filterConfig.namespace && filterConfig.namespace(group, filterState) === false) {
const { name, file } = group;
const { namespace, groupName } = filterState;
if (namespace && !fuzzyMatches(file, namespace)) {
return false;
}
if (filterConfig.groupName && filterConfig.groupName(group, filterState) === false) {
return false;
}
return true;
}
function namespaceFilter(group: PromRuleGroupDTO, filterState: Pick<RulesFilter, 'namespace' | 'groupName'>): boolean {
if (filterState.namespace && !fuzzyMatches(group.file, filterState.namespace)) {
return false;
}
return true;
}
function groupNameFilter(group: PromRuleGroupDTO, filterState: Pick<RulesFilter, 'namespace' | 'groupName'>): boolean {
if (filterState.groupName && !fuzzyMatches(group.name, filterState.groupName)) {
if (groupName && !fuzzyMatches(name, groupName)) {
return false;
}
@@ -136,74 +36,26 @@ function groupNameFilter(group: PromRuleGroupDTO, filterState: Pick<RulesFilter,
/**
* @returns True if the rule matches the filter, false otherwise
* @param backendFiltered - If true, title search is skipped (already filtered by backend)
*/
function ruleMatches(rule: PromRuleDTO, filterState: RulesFilter, filterConfig: RuleFilterConfig) {
if (filterConfig.freeFormWords && filterConfig.freeFormWords(rule, filterState) === false) {
return false;
}
export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter, backendFiltered?: boolean) {
const { name, labels = {}, health, type } = rule;
if (filterConfig.ruleName && filterConfig.ruleName(rule, filterState) === false) {
return false;
}
if (filterConfig.labels && filterConfig.labels(rule, filterState) === false) {
return false;
}
if (filterConfig.ruleType && filterConfig.ruleType(rule, filterState) === false) {
return false;
}
if (filterConfig.ruleState && filterConfig.ruleState(rule, filterState) === false) {
return false;
}
if (filterConfig.ruleHealth && filterConfig.ruleHealth(rule, filterState) === false) {
return false;
}
if (filterConfig.contactPoint && filterConfig.contactPoint(rule, filterState) === false) {
return false;
}
if (filterConfig.dashboardUid && filterConfig.dashboardUid(rule, filterState) === false) {
return false;
}
if (filterConfig.plugins && filterConfig.plugins(rule, filterState) === false) {
return false;
}
if (filterConfig.dataSourceNames && filterConfig.dataSourceNames(rule, filterState) === false) {
return false;
}
return true;
}
function freeFormFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean {
if (filterState.freeFormWords.length > 0) {
const nameMatches = fuzzyMatches(rule.name, filterState.freeFormWords.join(' '));
if (filterState.freeFormWords.length > 0 && !backendFiltered) {
const nameMatches = fuzzyMatches(name, filterState.freeFormWords.join(' '));
if (!nameMatches) {
return false;
}
}
return true;
}
function ruleNameFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean {
if (filterState.ruleName && !fuzzyMatches(rule.name, filterState.ruleName)) {
// Rule name search: Backend-supported for backend-filtered rules, client-side otherwise
if (filterState.ruleName && !backendFiltered && !fuzzyMatches(name, filterState.ruleName)) {
return false;
}
return true;
}
function labelsFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean {
if (filterState.labels.length > 0) {
const matchers = compact(filterState.labels.map(looseParseMatcher));
const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(rule.labels || {}, matchers);
const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(labels, matchers);
// Also check alerts if they exist
const doAlertsContainMatchingLabels =
@@ -217,18 +69,10 @@ function labelsFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean {
}
}
return true;
}
function ruleTypeFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean {
if (filterState.ruleType && rule.type !== filterState.ruleType) {
if (filterState.ruleType && type !== filterState.ruleType) {
return false;
}
return true;
}
function ruleStateFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean {
if (filterState.ruleState) {
if (!prometheusRuleType.alertingRule(rule)) {
return false;
@@ -238,18 +82,10 @@ function ruleStateFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean {
}
}
return true;
}
function ruleHealthFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean {
if (filterState.ruleHealth && normalizeHealth(rule.health) !== filterState.ruleHealth) {
if (filterState.ruleHealth && normalizeHealth(health) !== filterState.ruleHealth) {
return false;
}
return true;
}
function contactPointFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean {
if (filterState.contactPoint) {
if (!prometheusRuleType.grafana.alertingRule(rule)) {
return false;
@@ -264,10 +100,6 @@ function contactPointFilter(rule: PromRuleDTO, filterState: RulesFilter): boolea
}
}
return true;
}
function dashboardUidFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean {
if (filterState.dashboardUid) {
if (!prometheusRuleType.alertingRule(rule)) {
return false;
@@ -279,19 +111,11 @@ function dashboardUidFilter(rule: PromRuleDTO, filterState: RulesFilter): boolea
}
}
return true;
}
function pluginsFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean {
// Plugins filter - hide plugin-provided rules when set to 'hide'
if (filterState.plugins === 'hide' && isPluginProvidedRule(rule)) {
return false;
}
return true;
}
function dataSourceNamesFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean {
// Note: We can't implement these filters from reduceGroups because they rely on rulerRule property
// which is not available in PromRuleDTO:
// - contactPoint filter
@@ -332,45 +156,3 @@ const mapDataSourceNamesToUids = memoize(
},
{ maxSize: 1 }
);
/**
* Build title search parameter for backend filtering
* Combines ruleName and freeFormWords into a single search string
*/
export function buildTitleSearch(filterState: RulesFilter): string | undefined {
const titleParts: string[] = [];
const ruleName = filterState.ruleName?.trim();
if (ruleName) {
titleParts.push(ruleName);
}
const freeFormSegment = filterState.freeFormWords
.map((word) => word.trim())
.filter(Boolean)
.join(' ');
if (freeFormSegment) {
titleParts.push(freeFormSegment);
}
if (titleParts.length === 0) {
return undefined;
}
return titleParts.join(' ');
}
/**
* Normalize filter state for case-insensitive matching
* 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(),
};
}
@@ -4,8 +4,7 @@ import { PromRuleType } from 'app/types/unified-alerting-dto';
import { RuleSource } from '../../search/rulesSearchParser';
import { getFilter } from '../../utils/search';
import { buildTitleSearch } from './filters';
import { hasClientSideFilters } from './useFilteredRulesIterator';
import { buildTitleSearch, hasClientSideFilters } from './useFilteredRulesIterator';
describe('hasClientSideFilters', () => {
const originalFeatureToggles = config.featureToggles;
@@ -25,7 +25,7 @@ import {
} from '../../utils/datasource';
import { RulePositionHash, createRulePositionHash } from '../rulePositionHash';
import { getDatasourceFilter, getGrafanaFilter } from './filters';
import { groupFilter, ruleFilter } from './filters';
import { useGrafanaGroupsGenerator, usePrometheusGroupsGenerator } from './prometheusGroupsGenerator';
export type RuleWithOrigin = PromRuleWithOrigin | GrafanaRuleWithOrigin;
@@ -78,19 +78,30 @@ export function useFilteredRulesIteratorProvider() {
/* this is the abort controller that allows us to stop an AsyncIterable */
const abortController = new AbortController();
const normalizedFilterState = normalizeFilterState(filterState);
const hasDataSourceFilterActive = Boolean(filterState.dataSourceNames.length);
const useBackendFilters = shouldUseBackendFilters();
const { backendFilter, frontendFilter } = getGrafanaFilter(filterState);
const titleSearch = useBackendFilters ? buildTitleSearch(filterState) : undefined;
const ruleType = useBackendFilters ? filterState.ruleType : undefined;
const dashboardUid = useBackendFilters ? filterState.dashboardUid : undefined;
const grafanaRulesGenerator: AsyncIterableX<RuleWithOrigin> = from(
grafanaGroupsGenerator(groupLimit, backendFilter)
grafanaGroupsGenerator(groupLimit, {
contactPoint: filterState.contactPoint ?? undefined,
health: filterState.ruleHealth ? [filterState.ruleHealth] : [],
state: filterState.ruleState ? [filterState.ruleState] : [],
title: titleSearch,
type: ruleType,
dashboardUid,
})
).pipe(
withAbort(abortController.signal),
concatMap((groups) =>
groups
.filter((group) => frontendFilter.groupMatches(group))
.filter((group) => groupFilter(group, normalizedFilterState))
.flatMap((group) => group.rules.map((rule) => ({ group, rule })))
.filter(({ rule }) => frontendFilter.ruleMatches(rule))
.filter(({ rule }) => ruleFilter(rule, normalizedFilterState, useBackendFilters))
.map(({ group, rule }) => mapGrafanaRuleToRuleWithOrigin(group, rule))
),
catchError(() => empty())
@@ -105,8 +116,6 @@ export function useFilteredRulesIteratorProvider() {
return { iterable: grafanaRulesGenerator, abortController };
}
const { groupMatches, ruleMatches } = getDatasourceFilter(filterState);
const dataSourceGenerators: Array<AsyncIterableX<RuleWithOrigin>> = externalRulesSourcesToFetchFrom.map(
(dataSourceIdentifier) => {
const promGroupsGenerator: AsyncIterableX<RuleWithOrigin> = from(
@@ -115,9 +124,9 @@ export function useFilteredRulesIteratorProvider() {
withAbort(abortController.signal),
concatMap((groups) =>
groups
.filter((group) => groupMatches(group))
.filter((group) => groupFilter(group, normalizedFilterState))
.flatMap((group) => group.rules.map((rule, index) => ({ group, rule, index })))
.filter(({ rule }) => ruleMatches(rule))
.filter(({ rule }) => ruleFilter(rule, normalizedFilterState, false))
.map(({ group, rule, index }) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule, index))
),
catchError(() => empty())
@@ -167,6 +176,30 @@ export function hasClientSideFilters(filterState: RulesFilter): boolean {
);
}
export function buildTitleSearch(filterState: RulesFilter): string | undefined {
const titleParts: string[] = [];
const ruleName = filterState.ruleName?.trim();
if (ruleName) {
titleParts.push(ruleName);
}
const freeFormSegment = filterState.freeFormWords
.map((word) => word.trim())
.filter(Boolean)
.join(' ');
if (freeFormSegment) {
titleParts.push(freeFormSegment);
}
if (titleParts.length === 0) {
return undefined;
}
return titleParts.join(' ');
}
function mergeIterables(iterables: Array<AsyncIterableX<RuleWithOrigin>>): AsyncIterableX<RuleWithOrigin> {
if (iterables.length === 0) {
return empty();
@@ -235,3 +268,16 @@ function mapGrafanaRuleToRuleWithOrigin(
origin: 'grafana',
};
}
/**
* 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(),
};
}
@@ -1,93 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { standardTransformersRegistry } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { getStandardTransformers } from 'app/features/transformers/standardTransformers';
import { LegacyEmptyTransformationsMessage, NewEmptyTransformationsMessage } from './EmptyTransformationsMessage';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
}));
describe('EmptyTransformationsMessage', () => {
standardTransformersRegistry.setInit(getStandardTransformers);
const onShowPicker = jest.fn();
const onGoToQueries = jest.fn();
const onAddTransformation = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
describe('LegacyEmptyTransformationsMessage', () => {
it('should render the legacy empty state message', () => {
render(<LegacyEmptyTransformationsMessage onShowPicker={onShowPicker} />);
expect(screen.getByText('Start transforming data')).toBeInTheDocument();
expect(screen.getByText(/Transformations allow data to be changed in various ways/)).toBeInTheDocument();
});
it('should call onShowPicker when "Add transformation" button is clicked', async () => {
const user = userEvent.setup();
render(<LegacyEmptyTransformationsMessage onShowPicker={onShowPicker} />);
const button = screen.getByTestId(selectors.components.Transforms.addTransformationButton);
await user.click(button);
expect(onShowPicker).toHaveBeenCalledTimes(1);
});
});
describe('NewEmptyTransformationsMessage', () => {
it('should render transformation cards when both onGoToQueries and onAddTransformation are provided', () => {
render(
<NewEmptyTransformationsMessage
onShowPicker={onShowPicker}
onGoToQueries={onGoToQueries}
onAddTransformation={onAddTransformation}
/>
);
// Should show SQL transformation card
expect(screen.getByText('SQL Expressions')).toBeInTheDocument();
expect(screen.getByText('Organize fields by name')).toBeInTheDocument();
expect(screen.getByText('Group by')).toBeInTheDocument();
expect(screen.getByText('Extract fields')).toBeInTheDocument();
expect(screen.getByText('Filter data by values')).toBeInTheDocument();
});
it('should call onShowPicker when "Show more" button is clicked', async () => {
const user = userEvent.setup();
render(
<NewEmptyTransformationsMessage
onShowPicker={onShowPicker}
onGoToQueries={onGoToQueries}
onAddTransformation={onAddTransformation}
/>
);
const button = screen.getByTestId(selectors.components.Transforms.addTransformationButton);
await user.click(button);
expect(onShowPicker).toHaveBeenCalledTimes(1);
});
it('should not show SQL transformation card when onGoToQueries is not provided', () => {
render(<NewEmptyTransformationsMessage onShowPicker={onShowPicker} onAddTransformation={onAddTransformation} />);
expect(screen.queryByText('SQL Expressions')).not.toBeInTheDocument();
});
it('should not show transformation cards grid when neither onGoToQueries nor onAddTransformation are provided', () => {
render(<NewEmptyTransformationsMessage onShowPicker={onShowPicker} />);
expect(screen.queryByText('SQL Expressions')).not.toBeInTheDocument();
// But should still show the "Show more" button
expect(screen.getByTestId(selectors.components.Transforms.addTransformationButton)).toBeInTheDocument();
});
});
});
@@ -1,31 +1,11 @@
import { useMemo } from 'react';
import { DataTransformerID, standardTransformersRegistry, TransformerRegistryItem } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Box, Button, Grid, Stack, Text } from '@grafana/ui';
import config from 'app/core/config';
import { SqlExpressionCard } from '../../../dashboard/components/TransformationsEditor/SqlExpressionCard';
import { TransformationCard } from '../../../dashboard/components/TransformationsEditor/TransformationCard';
import sqlDarkImage from '../../../transformers/images/dark/sqlExpression.svg';
import sqlLightImage from '../../../transformers/images/light/sqlExpression.svg';
import { Trans } from '@grafana/i18n';
import { Box, Button, Stack, Text } from '@grafana/ui';
interface EmptyTransformationsProps {
onShowPicker: () => void;
onGoToQueries?: () => void;
onAddTransformation?: (transformationId: string) => void;
}
const TRANSFORMATION_IDS = [
DataTransformerID.organize,
DataTransformerID.groupBy,
DataTransformerID.extractFields,
DataTransformerID.filterByValue,
];
export function LegacyEmptyTransformationsMessage({ onShowPicker }: { onShowPicker: () => void }) {
export function EmptyTransformationsMessage(props: EmptyTransformationsProps) {
return (
<Box alignItems="center" padding={4}>
<Stack direction="column" alignItems="center" gap={2}>
@@ -44,7 +24,7 @@ export function LegacyEmptyTransformationsMessage({ onShowPicker }: { onShowPick
icon="plus"
variant="primary"
size="md"
onClick={onShowPicker}
onClick={props.onShowPicker}
data-testid={selectors.components.Transforms.addTransformationButton}
>
<Trans i18nKey="dashboard-scene.empty-transformations-message.add-transformation">Add transformation</Trans>
@@ -53,92 +33,3 @@ export function LegacyEmptyTransformationsMessage({ onShowPicker }: { onShowPick
</Box>
);
}
export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps) {
const hasGoToQueries = props.onGoToQueries != null;
const hasAddTransformation = props.onAddTransformation != null;
// Get transformations from registry
const transformations = useMemo(() => {
return standardTransformersRegistry.list().filter((t): t is TransformerRegistryItem => {
return TRANSFORMATION_IDS.some((id) => t.id === id);
});
}, []);
const handleSqlTransformationClick = () => {
reportInteraction('dashboards_expression_interaction', {
action: 'add_expression',
expression_type: 'sql',
context: 'empty_transformations_placeholder',
});
props.onGoToQueries?.();
};
const handleTransformationClick = (transformationId: string) => {
reportInteraction('grafana_panel_transformations_clicked', {
type: transformationId,
context: 'empty_transformations_placeholder',
});
props.onAddTransformation?.(transformationId);
};
const handleShowMoreClick = () => {
reportInteraction('grafana_panel_transformations_show_more_clicked', {
context: 'empty_transformations_placeholder',
});
props.onShowPicker();
};
return (
<Box alignItems="center" padding={4}>
<Stack direction="column" alignItems="center" gap={4}>
{(hasAddTransformation || hasGoToQueries) && (
<Grid columns={5} gap={1}>
{hasGoToQueries && (
<SqlExpressionCard
name={t('dashboard-scene.empty-transformations-message.sql-name', 'SQL Expressions')}
description={t(
'dashboard-scene.empty-transformations-message.sql-transformation-description',
'Manipulate your data using MySQL-like syntax'
)}
imageUrl={config.theme2.isDark ? sqlDarkImage : sqlLightImage}
onClick={handleSqlTransformationClick}
testId="go-to-queries-button"
/>
)}
{hasAddTransformation &&
transformations.map((transform) => (
<TransformationCard
key={transform.id}
transform={transform}
onClick={handleTransformationClick}
showIllustrations={true}
showPluginState={false}
showTags={false}
/>
))}
</Grid>
)}
<Stack direction="row" gap={2}>
<Button
icon="plus"
variant="primary"
size="md"
onClick={handleShowMoreClick}
data-testid={selectors.components.Transforms.addTransformationButton}
>
<Trans i18nKey="dashboard-scene.empty-transformations-message.show-more">Show more</Trans>
</Button>
</Stack>
</Stack>
</Box>
);
}
export function EmptyTransformationsMessage(props: EmptyTransformationsProps) {
if (config.featureToggles.transformationsEmptyPlaceholder) {
return <NewEmptyTransformationsMessage {...props} />;
}
return <LegacyEmptyTransformationsMessage onShowPicker={props.onShowPicker} />;
}
@@ -12,7 +12,6 @@ import {
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { SceneDataTransformer, SceneQueryRunner } from '@grafana/scenes';
import config from 'app/core/config';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { getStandardTransformers } from 'app/features/transformers/standardTransformers';
import { DashboardDataDTO } from 'app/types/dashboard';
@@ -166,22 +165,6 @@ describe('PanelDataTransformationsTab', () => {
const reduce = screen.queryByTestId(selectors.components.TransformTab.newTransform('Reduce'));
expect(reduce).toBeNull();
});
it('renders SQL transformation card in empty state when feature toggle is enabled', async () => {
const originalFeatureToggle = config.featureToggles.transformationsEmptyPlaceholder;
config.featureToggles.transformationsEmptyPlaceholder = true;
try {
const modelMock = createModelMock(mockData);
render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>);
// Should show SQL transformation card in empty state
expect(screen.getByText('SQL Expressions')).toBeInTheDocument();
expect(screen.getByTestId('go-to-queries-button')).toBeInTheDocument();
} finally {
config.featureToggles.transformationsEmptyPlaceholder = originalFeatureToggle;
}
});
});
function setupTabScene(panelId: string) {
@@ -195,5 +178,5 @@ function setupTabScene(panelId: string) {
// @ts-expect-error
getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene));
return { transformsTab, panel };
return { transformsTab };
}
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { DragDropContext, DropResult, Droppable } from '@hello-pangea/dnd';
import { useCallback, useMemo, useState } from 'react';
import { useState } from 'react';
import { DataTransformerConfig, GrafanaTheme2, PanelData } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -16,18 +16,12 @@ import {
} from '@grafana/scenes';
import { Button, ButtonGroup, ConfirmModal, Tab, useStyles2 } from '@grafana/ui';
import { TransformationOperationRows } from 'app/features/dashboard/components/TransformationsEditor/TransformationOperationRows';
import { ExpressionQueryType } from 'app/features/expressions/types';
import { getQueryRunnerFor } from '../../utils/utils';
import { EmptyTransformationsMessage } from './EmptyTransformationsMessage';
import { PanelDataPane } from './PanelDataPane';
import { PanelDataQueriesTab } from './PanelDataQueriesTab';
import { TransformationsDrawer } from './TransformationsDrawer';
import { PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types';
import { findSqlExpression, scrollToQueryRow } from './utils';
const SET_TIMEOUT = 750;
interface PanelDataTransformationsTabState extends SceneObjectState {
panelRef: SceneObjectRef<VizPanel>;
@@ -72,16 +66,8 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro
const styles = useStyles2(getStyles);
const sourceData = model.getQueryRunner().useState();
const { data, transformations: transformsWrongType } = model.getDataTransformer().useState();
// Type guard to ensure transformations are DataTransformerConfig[]
const transformations = useMemo<DataTransformerConfig[]>(() => {
return Array.isArray(transformsWrongType)
? transformsWrongType.filter(
(t): t is DataTransformerConfig =>
t !== null && typeof t === 'object' && 'id' in t && typeof t.id === 'string'
)
: [];
}, [transformsWrongType]);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const transformations: DataTransformerConfig[] = transformsWrongType as unknown as DataTransformerConfig[];
const [drawerOpen, setDrawerOpen] = useState<boolean>(false);
const [confirmModalOpen, setConfirmModalOpen] = useState<boolean>(false);
@@ -89,45 +75,6 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro
const openDrawer = () => setDrawerOpen(true);
const closeDrawer = () => setDrawerOpen(false);
const onGoToQueries = useCallback(() => {
const parent = model.parent;
if (!(parent instanceof PanelDataPane)) {
return;
}
const queriesTab = parent.state.tabs.find((tab) => tab.tabId === TabId.Queries);
if (!(queriesTab instanceof PanelDataQueriesTab)) {
return;
}
const queries = queriesTab.getQueries();
const existingSqlQuery = findSqlExpression(queries);
if (!existingSqlQuery) {
// Create new SQL expression
queriesTab.onAddExpressionOfType(ExpressionQueryType.sql);
}
// Navigate to the Queries tab
parent.onChangeTab(queriesTab);
// Scroll to SQL query after tab renders
setTimeout(() => {
// If SQL already existed, use it; otherwise find the newly created one
const targetRefId = existingSqlQuery?.refId || findSqlExpression(queriesTab.getQueries())?.refId;
if (targetRefId) {
scrollToQueryRow(targetRefId);
}
}, SET_TIMEOUT);
}, [model]);
const onAddTransformation = useCallback(
(transformationId: string) => {
model.onChangeTransformations([...transformations, { id: transformationId, options: {} }]);
},
[model, transformations]
);
if (!data || !sourceData.data) {
return;
}
@@ -144,17 +91,13 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro
}}
isOpen={drawerOpen}
series={data.series}
/>
></TransformationsDrawer>
);
if (transformations.length < 1) {
return (
<>
<EmptyTransformationsMessage
onShowPicker={openDrawer}
onGoToQueries={onGoToQueries}
onAddTransformation={onAddTransformation}
/>
<EmptyTransformationsMessage onShowPicker={openDrawer}></EmptyTransformationsMessage>
{transformationsDrawer}
</>
);
@@ -1,24 +0,0 @@
import { DataQuery } from '@grafana/schema';
import { ExpressionQueryType } from 'app/features/expressions/types';
export function findSqlExpression(queries: DataQuery[]) {
return queries.find((query) => {
return typeof query === 'object' && query !== null && 'type' in query && query.type === ExpressionQueryType.sql;
});
}
export function scrollToQueryRow(refId: string) {
// Query rows use uniqueId(refId + '_') for their internal id
// The aria-controls attribute will be like "A_1" for refId "A"
// So we need to search for aria-controls starting with "refId_"
const queryRowHeader = document.querySelector(`[aria-controls^="${refId}_"]`);
if (queryRowHeader) {
// Find the parent query row wrapper
const queryRow = queryRowHeader.closest('[data-testid="query-editor-row"]');
if (queryRow instanceof HTMLElement) {
queryRow.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
}
@@ -1,71 +0,0 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Card, useStyles2 } from '@grafana/ui';
export interface SqlExpressionCardProps {
name: string;
description: string;
imageUrl?: string;
onClick: () => void;
testId?: string;
}
export function SqlExpressionCard({ name, description, imageUrl, onClick, testId }: SqlExpressionCardProps) {
const styles = useStyles2(getSqlExpressionCardStyles);
return (
<Card className={styles.card} data-testid={testId} onClick={onClick} noMargin>
<Card.Heading className={styles.heading}>
<div className={styles.titleRow}>
<span>{name}</span>
</div>
</Card.Heading>
<Card.Description className={styles.description}>
<span>{description}</span>
{imageUrl && (
<span>
<img className={styles.image} src={imageUrl} alt={name} />
</span>
)}
</Card.Description>
</Card>
);
}
function getSqlExpressionCardStyles(theme: GrafanaTheme2) {
return {
card: css({
gridTemplateRows: 'min-content 0 1fr 0',
marginBottom: 0,
}),
heading: css({
fontWeight: 400,
'> button': {
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: theme.spacing(1),
},
}),
titleRow: css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'nowrap',
width: '100%',
}),
description: css({
fontSize: theme.typography.bodySmall.fontSize,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}),
image: css({
display: 'block',
maxWidth: '100%',
marginTop: theme.spacing(2),
}),
};
}
@@ -1,148 +0,0 @@
import { cx, css } from '@emotion/css';
import {
DataFrame,
GrafanaTheme2,
TransformerRegistryItem,
TransformationApplicabilityLevels,
standardTransformersRegistry,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Badge, Card, IconButton, useStyles2, useTheme2 } from '@grafana/ui';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
export interface TransformationCardProps {
transform: TransformerRegistryItem;
onClick: (id: string) => void;
showIllustrations?: boolean;
data?: DataFrame[];
showPluginState?: boolean;
showTags?: boolean;
}
export function TransformationCard({
transform,
showIllustrations,
onClick,
data = [],
showPluginState = true,
showTags = true,
}: TransformationCardProps) {
const theme = useTheme2();
const styles = useStyles2(getTransformationCardStyles);
// Check to see if the transform is applicable to the given data
let applicabilityScore = TransformationApplicabilityLevels.Applicable;
if (data.length > 0 && transform.transformation.isApplicable !== undefined) {
applicabilityScore = transform.transformation.isApplicable(data);
}
const isApplicable = applicabilityScore > 0;
let applicabilityDescription = null;
if (data.length > 0 && transform.transformation.isApplicableDescription !== undefined) {
if (typeof transform.transformation.isApplicableDescription === 'function') {
applicabilityDescription = transform.transformation.isApplicableDescription(data);
} else {
applicabilityDescription = transform.transformation.isApplicableDescription;
}
}
const cardClasses = !isApplicable && data.length > 0 ? cx(styles.newCard, styles.cardDisabled) : styles.newCard;
const imageUrl = theme.isDark ? transform.imageDark : transform.imageLight;
const description = standardTransformersRegistry.getIfExists(transform.id)?.description;
return (
<Card
className={cardClasses}
data-testid={selectors.components.TransformTab.newTransform(transform.name)}
onClick={() => onClick(transform.id)}
noMargin
>
<Card.Heading className={styles.heading}>
<div className={styles.titleRow}>
<span>{transform.name}</span>
{showPluginState && (
<span className={styles.pluginStateInfoWrapper}>
<PluginStateInfo state={transform.state} />
</span>
)}
</div>
{showTags && transform.tags && transform.tags.size > 0 && (
<div className={styles.tagsWrapper}>
{Array.from(transform.tags).map((tag) => (
<Badge color="darkgrey" icon="tag-alt" key={tag} text={tag} />
))}
</div>
)}
</Card.Heading>
<Card.Description className={styles.description}>
<span>{description}</span>
{showIllustrations && imageUrl && (
<span>
<img className={styles.image} src={imageUrl} alt={transform.name} />
</span>
)}
{!isApplicable && applicabilityDescription !== null && (
<IconButton className={styles.cardApplicableInfo} name="info-circle" tooltip={applicabilityDescription} />
)}
</Card.Description>
</Card>
);
}
function getTransformationCardStyles(theme: GrafanaTheme2) {
return {
heading: css({
fontWeight: 400,
'> button': {
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: theme.spacing(1),
},
}),
titleRow: css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'nowrap',
width: '100%',
}),
description: css({
fontSize: theme.typography.bodySmall.fontSize,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}),
image: css({
display: 'block',
maxWidth: '100%',
marginTop: theme.spacing(2),
}),
cardDisabled: css({
backgroundColor: theme.colors.action.disabledBackground,
img: {
filter: 'grayscale(100%)',
opacity: 0.33,
},
}),
cardApplicableInfo: css({
position: 'absolute',
bottom: theme.spacing(1),
right: theme.spacing(1),
}),
newCard: css({
gridTemplateRows: 'min-content 0 1fr 0',
marginBottom: 0,
}),
pluginStateInfoWrapper: css({
marginLeft: theme.spacing(0.5),
}),
tagsWrapper: css({
display: 'flex',
flexWrap: 'wrap',
gap: theme.spacing(0.5),
}),
};
}
@@ -1,106 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { standardTransformersRegistry } from '@grafana/data';
import { getStandardTransformers } from 'app/features/transformers/standardTransformers';
import { SqlExpressionCard } from './SqlExpressionCard';
import { TransformationCard } from './TransformationCard';
describe('TransformationCard', () => {
standardTransformersRegistry.setInit(getStandardTransformers);
const onClick = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders transformation name and description', () => {
const transform = standardTransformersRegistry.get('organize');
render(<TransformationCard transform={transform} onClick={onClick} />);
expect(screen.getByText('Organize fields by name')).toBeInTheDocument();
// Description is rendered but we won't assert on exact text since it may change
});
it('calls onClick with transformation id when clicked', async () => {
const user = userEvent.setup();
const transform = standardTransformersRegistry.get('organize');
render(<TransformationCard transform={transform} onClick={onClick} />);
const card = screen.getByText('Organize fields by name').closest('button');
await user.click(card!);
expect(onClick).toHaveBeenCalledWith('organize');
});
it('shows illustration when showIllustrations is true', () => {
const transform = standardTransformersRegistry.get('organize');
const { container } = render(<TransformationCard transform={transform} onClick={onClick} showIllustrations />);
const img = container.querySelector('img');
expect(img).toBeInTheDocument();
expect(img?.alt).toBe('Organize fields by name');
});
it('hides illustration when showIllustrations is false', () => {
const transform = standardTransformersRegistry.get('organize');
const { container } = render(
<TransformationCard transform={transform} onClick={onClick} showIllustrations={false} />
);
expect(container.querySelector('img')).not.toBeInTheDocument();
});
it('hides plugin state when showPluginState is false', () => {
const transform = standardTransformersRegistry.get('organize');
const { container } = render(
<TransformationCard transform={transform} onClick={onClick} showPluginState={false} />
);
expect(container.querySelector('[class*="pluginStateInfoWrapper"]')).not.toBeInTheDocument();
});
it('hides tags when showTags is false', () => {
const transform = standardTransformersRegistry.get('organize');
const { container } = render(<TransformationCard transform={transform} onClick={onClick} showTags={false} />);
expect(container.querySelector('[class*="tagsWrapper"]')).not.toBeInTheDocument();
});
});
describe('SqlExpressionCard', () => {
const onClick = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders SQL expression name and description', () => {
render(<SqlExpressionCard name="SQL Expressions" description="Manipulate data with SQL" onClick={onClick} />);
expect(screen.getByText('SQL Expressions')).toBeInTheDocument();
expect(screen.getByText('Manipulate data with SQL')).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const user = userEvent.setup();
render(<SqlExpressionCard name="SQL Expressions" description="Test" onClick={onClick} />);
const card = screen.getByText('SQL Expressions').closest('button');
await user.click(card!);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('renders image when imageUrl is provided', () => {
const { container } = render(
<SqlExpressionCard name="SQL" description="Test" imageUrl="/test.svg" onClick={onClick} />
);
const img = container.querySelector('img');
expect(img).toBeInTheDocument();
expect(img?.src).toContain('/test.svg');
});
});
@@ -1,16 +1,34 @@
import { css } from '@emotion/css';
import { cx, css } from '@emotion/css';
import { FormEventHandler, KeyboardEventHandler, ReactNode, useCallback } from 'react';
import { DataFrame, GrafanaTheme2, TransformerRegistryItem, SelectableValue } from '@grafana/data';
import {
DataFrame,
TransformerRegistryItem,
TransformationApplicabilityLevels,
GrafanaTheme2,
standardTransformersRegistry,
SelectableValue,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Drawer, FilterPill, Grid, Input, Stack, Switch, useStyles2 } from '@grafana/ui';
import {
Badge,
Card,
Drawer,
FilterPill,
Grid,
IconButton,
Input,
Stack,
Switch,
useStyles2,
useTheme2,
} from '@grafana/ui';
import config from 'app/core/config';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
import { getCategoriesLabels } from 'app/features/transformers/utils';
import { SqlExpressionsBanner } from './SqlExpressions/SqlExpressionsBanner';
import { TransformationCard } from './TransformationCard';
import { FilterCategory } from './TransformationsEditor';
const VIEW_ALL_VALUE = 'viewAll';
@@ -114,10 +132,6 @@ export function TransformationPickerNg(props: TransformationPickerNgProps) {
transformations={xforms}
data={data}
onClick={(id) => {
reportInteraction('grafana_panel_transformations_clicked', {
type: id,
context: 'transformations_drawer',
});
onTransformationAdd({ value: id });
}}
/>
@@ -161,17 +175,143 @@ interface TransformationsGridProps {
}
function TransformationsGrid({ showIllustrations, transformations, onClick, data }: TransformationsGridProps) {
const theme = useTheme2();
const styles = useStyles2(getTransformationGridStyles);
return (
<Grid columns={3} gap={1}>
{transformations.map((transform) => (
<TransformationCard
key={transform.id}
transform={transform}
showIllustrations={showIllustrations}
onClick={onClick}
data={data}
/>
))}
{transformations.map((transform) => {
// Check to see if the transform
// is applicable to the given data
let applicabilityScore = TransformationApplicabilityLevels.Applicable;
if (transform.transformation.isApplicable !== undefined) {
applicabilityScore = transform.transformation.isApplicable(data);
}
const isApplicable = applicabilityScore > 0;
let applicabilityDescription = null;
if (transform.transformation.isApplicableDescription !== undefined) {
if (typeof transform.transformation.isApplicableDescription === 'function') {
applicabilityDescription = transform.transformation.isApplicableDescription(data);
} else {
applicabilityDescription = transform.transformation.isApplicableDescription;
}
}
// Add disabled styles to disabled
let cardClasses = styles.newCard;
if (!isApplicable) {
cardClasses = cx(styles.newCard, styles.cardDisabled);
}
const imageUrl = theme.isDark ? transform.imageDark : transform.imageLight;
return (
<Card
className={cardClasses}
data-testid={selectors.components.TransformTab.newTransform(transform.name)}
onClick={() => onClick(transform.id)}
key={transform.id}
noMargin
>
<Card.Heading className={styles.heading}>
<div className={styles.titleRow}>
<span>{transform.name}</span>
<span className={styles.pluginStateInfoWrapper}>
<PluginStateInfo state={transform.state} />
</span>
</div>
{transform.tags && transform.tags.size > 0 && (
<div className={styles.tagsWrapper}>
{Array.from(transform.tags).map((tag) => (
<Badge color="darkgrey" icon="tag-alt" key={tag} text={tag} />
))}
</div>
)}
</Card.Heading>
<Card.Description className={styles.description}>
<span>{standardTransformersRegistry.getIfExists(transform.id)?.description}</span>
{showIllustrations && (
<span>
<img className={styles.image} src={imageUrl} alt={transform.name} />
</span>
)}
{!isApplicable && applicabilityDescription !== null && (
<IconButton
className={styles.cardApplicableInfo}
name="info-circle"
tooltip={applicabilityDescription}
/>
)}
</Card.Description>
</Card>
);
})}
</Grid>
);
}
function getTransformationGridStyles(theme: GrafanaTheme2) {
return {
heading: css({
fontWeight: 400,
'> button': {
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: theme.spacing(1),
},
}),
titleRow: css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'nowrap',
width: '100%',
}),
description: css({
fontSize: theme.typography.bodySmall.fontSize,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}),
image: css({
display: 'block',
maxWidth: '100%',
marginTop: theme.spacing(2),
}),
grid: css({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gridAutoRows: '1fr',
gap: theme.spacing(1),
width: '100%',
padding: `${theme.spacing(1)} 0`,
}),
cardDisabled: css({
backgroundColor: theme.colors.action.disabledBackground,
img: {
filter: 'grayscale(100%)',
opacity: 0.33,
},
}),
cardApplicableInfo: css({
position: 'absolute',
bottom: theme.spacing(1),
right: theme.spacing(1),
}),
newCard: css({
gridTemplateRows: 'min-content 0 1fr 0',
marginBottom: 0,
}),
pluginStateInfoWrapper: css({
marginLeft: theme.spacing(0.5),
}),
tagsWrapper: css({
display: 'flex',
flexWrap: 'wrap',
gap: theme.spacing(0.5),
}),
};
}
@@ -1,49 +0,0 @@
<svg width="120" height="48" viewBox="0 0 120 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Input data table -->
<path d="M0 1.04083V13H28V0H1.16951C0.859756 0 0.56261 0.109659 0.337073 0.304853C0.111537 0.500047 0 0.764787 0 1.04083Z" fill="url(#paint0_linear_sql_dark)"/>
<path d="M28 17H0V30H28V17Z" fill="#84AFF1"/>
<path d="M28 34H0V48H28V34Z" fill="#84AFF1"/>
<!-- a1 text -->
<path d="M13.7744 9.5C14.5264 9.5 15.0464 9.23767 15.3415 8.74147H15.3837V9.41783H17V6.1182C17 5.09102 15.9845 4.5 14.6107 4.5C13.1595 4.5 12.3373 5.1574 12.2003 6.04235L13.785 6.09292C13.8587 5.78319 14.1434 5.59355 14.5966 5.59355C15.0183 5.59355 15.2853 5.77686 15.2853 6.1024V6.1182C15.2853 6.4153 14.9269 6.47851 14.0063 6.5512C12.9136 6.63338 12 6.99684 12 8.07143C12 9.03224 12.7414 9.5 13.7744 9.5ZM14.305 8.48862C13.9079 8.48862 13.6268 8.31795 13.6268 7.99558C13.6268 7.68268 13.9009 7.49305 14.3893 7.42668C14.7091 7.38559 15.1026 7.32238 15.2959 7.23072V7.69216C15.2959 8.16625 14.8531 8.48862 14.305 8.48862Z" fill="#24292E"/>
<!-- 1 text -->
<path d="M14.5 21H13.0716L11.5 21.9199V23.1738L12.9253 22.3535H12.9627V27H14.5V21Z" fill="#24292E"/>
<!-- 2 text -->
<path d="M10.3373 44.5H14.75V43.3468H12.3341V43.3092L13.0472 42.6561C14.3396 41.5376 14.6772 40.9682 14.6772 40.289C14.6772 39.2225 13.8011 38.5 12.4476 38.5C11.129 38.5 10.2471 39.2543 10.25 40.4595H11.6151C11.6151 39.9249 11.947 39.6156 12.4418 39.6156C12.9279 39.6156 13.2801 39.9133 13.2801 40.4017C13.2801 40.8439 13.0007 41.1445 12.5116 41.5809L10.3373 43.4711V44.5Z" fill="#24292E"/>
<!-- SQL symbol in middle -->
<g transform="translate(32, 16)">
<!-- Database cylinder icon -->
<ellipse cx="14" cy="5" rx="10" ry="3.5" fill="#F2CC0C" fill-opacity="0.3" stroke="#FF9830" stroke-width="1.5"/>
<path d="M4 5 V10 C4 11.933 8.477 13.5 14 13.5 C19.523 13.5 24 11.933 24 10 V5" fill="#F2CC0C" fill-opacity="0.2" stroke="#FF9830" stroke-width="1.5"/>
<line x1="4" y1="8" x2="4" y2="10" stroke="#FF9830" stroke-width="1.5"/>
<line x1="24" y1="8" x2="24" y2="10" stroke="#FF9830" stroke-width="1.5"/>
<!-- SQL text -->
<text x="14" y="23" font-family="Arial, sans-serif" font-size="7" font-weight="600" fill="#CCCCDC" text-anchor="middle">SQL</text>
</g>
<!-- Output data table -->
<path d="M92 1.04083V13H120V0H93.1695C92.8598 0 92.5626 0.109659 92.3371 0.304853C92.1115 0.500047 92 0.764787 92 1.04083Z" fill="url(#paint1_linear_sql_dark)"/>
<path d="M120 17H92V30H120V17Z" fill="#84AFF1"/>
<path d="M120 34H92V48H120V34Z" fill="#84AFF1"/>
<!-- b1 text -->
<path d="M103 9.68623H104.566V8.96449H104.615C104.816 9.37609 105.262 9.75 106.013 9.75C107.113 9.75 108 8.97898 108 7.46304C108 5.89203 107.061 5.17609 106.023 5.17609C105.236 5.17609 104.806 5.58768 104.615 5.99638H104.583V3.75H103V9.68623ZM104.55 7.46015C104.55 6.73261 104.887 6.28333 105.466 6.28333C106.052 6.28333 106.375 6.7442 106.375 7.46015C106.375 8.17899 106.052 8.64565 105.466 8.64565C104.887 8.64565 104.55 8.18188 104.55 7.46015Z" fill="#24292E"/>
<!-- 1 text -->
<path d="M106.5 21H105.072L103.5 21.9199V23.1738L104.925 22.3535H104.963V27H106.5V21Z" fill="#24292E"/>
<!-- 2 text -->
<path d="M102.337 44.5H106.75V43.3468H104.334V43.3092L105.047 42.6561C106.34 41.5376 106.677 40.9682 106.677 40.289C106.677 39.2225 105.801 38.5 104.448 38.5C103.129 38.5 102.247 39.2543 102.25 40.4595H103.615C103.615 39.9249 103.947 39.6156 104.442 39.6156C104.928 39.6156 105.28 39.9133 105.28 40.4017C105.28 40.8439 105.001 41.1445 104.512 41.5809L102.337 43.4711V44.5Z" fill="#24292E"/>
<!-- Arrow indicating transformation -->
<path d="M71.9067 30C72.6011 30 77.9327 26 77.9327 24C77.9327 22 72.7357 18 71.9067 18C71.0778 18 70.4023 18.5 70.4023 19.4756C70.4023 20.4512 73.9067 22.9206 73.9067 22.9206C73.9067 22.9206 66.2539 22.25 66 22.9206C65.7461 23.5911 65.7461 24.4089 66 25.0794C66.2539 25.75 73.9067 25.0794 73.9067 25.0794C73.9067 25.0794 70.4023 27.75 70.4023 28.5301C70.4023 29.3103 71.2124 30 71.9067 30Z" fill="#CCCCDC"/>
<defs>
<linearGradient id="paint0_linear_sql_dark" x1="0" y1="6.5" x2="28" y2="6.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2CC0C"/>
<stop offset="1" stop-color="#FF9830"/>
</linearGradient>
<linearGradient id="paint1_linear_sql_dark" x1="92" y1="6.5" x2="120" y2="6.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2CC0C"/>
<stop offset="1" stop-color="#FF9830"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

@@ -1,49 +0,0 @@
<svg width="120" height="48" viewBox="0 0 120 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Input data table -->
<path d="M0 1.04083V13H28V0H1.16951C0.859756 0 0.56261 0.109659 0.337073 0.304853C0.111537 0.500047 0 0.764787 0 1.04083Z" fill="url(#paint0_linear_sql)"/>
<path d="M28 17H0V30H28V17Z" fill="#84AFF1"/>
<path d="M28 34H0V48H28V34Z" fill="#84AFF1"/>
<!-- a1 text -->
<path d="M13.7744 9.5C14.5264 9.5 15.0464 9.23767 15.3415 8.74147H15.3837V9.41783H17V6.1182C17 5.09102 15.9845 4.5 14.6107 4.5C13.1595 4.5 12.3373 5.1574 12.2003 6.04235L13.785 6.09292C13.8587 5.78319 14.1434 5.59355 14.5966 5.59355C15.0183 5.59355 15.2853 5.77686 15.2853 6.1024V6.1182C15.2853 6.4153 14.9269 6.47851 14.0063 6.5512C12.9136 6.63338 12 6.99684 12 8.07143C12 9.03224 12.7414 9.5 13.7744 9.5ZM14.305 8.48862C13.9079 8.48862 13.6268 8.31795 13.6268 7.99558C13.6268 7.68268 13.9009 7.49305 14.3893 7.42668C14.7091 7.38559 15.1026 7.32238 15.2959 7.23072V7.69216C15.2959 8.16625 14.8531 8.48862 14.305 8.48862Z" fill="#24292E"/>
<!-- 1 text -->
<path d="M14.5 21H13.0716L11.5 21.9199V23.1738L12.9253 22.3535H12.9627V27H14.5V21Z" fill="#24292E"/>
<!-- 2 text -->
<path d="M10.3373 44.5H14.75V43.3468H12.3341V43.3092L13.0472 42.6561C14.3396 41.5376 14.6772 40.9682 14.6772 40.289C14.6772 39.2225 13.8011 38.5 12.4476 38.5C11.129 38.5 10.2471 39.2543 10.25 40.4595H11.6151C11.6151 39.9249 11.947 39.6156 12.4418 39.6156C12.9279 39.6156 13.2801 39.9133 13.2801 40.4017C13.2801 40.8439 13.0007 41.1445 12.5116 41.5809L10.3373 43.4711V44.5Z" fill="#24292E"/>
<!-- SQL symbol in middle -->
<g transform="translate(32, 16)">
<!-- Database cylinder icon -->
<ellipse cx="14" cy="5" rx="10" ry="3.5" fill="#F2CC0C" fill-opacity="0.3" stroke="#FF9830" stroke-width="1.5"/>
<path d="M4 5 V10 C4 11.933 8.477 13.5 14 13.5 C19.523 13.5 24 11.933 24 10 V5" fill="#F2CC0C" fill-opacity="0.2" stroke="#FF9830" stroke-width="1.5"/>
<line x1="4" y1="8" x2="4" y2="10" stroke="#FF9830" stroke-width="1.5"/>
<line x1="24" y1="8" x2="24" y2="10" stroke="#FF9830" stroke-width="1.5"/>
<!-- SQL text -->
<text x="14" y="23" font-family="Arial, sans-serif" font-size="7" font-weight="600" fill="#24292E" text-anchor="middle">SQL</text>
</g>
<!-- Output data table -->
<path d="M92 1.04083V13H120V0H93.1695C92.8598 0 92.5626 0.109659 92.3371 0.304853C92.1115 0.500047 92 0.764787 92 1.04083Z" fill="url(#paint1_linear_sql)"/>
<path d="M120 17H92V30H120V17Z" fill="#84AFF1"/>
<path d="M120 34H92V48H120V34Z" fill="#84AFF1"/>
<!-- b1 text -->
<path d="M103 9.68623H104.566V8.96449H104.615C104.816 9.37609 105.262 9.75 106.013 9.75C107.113 9.75 108 8.97898 108 7.46304C108 5.89203 107.061 5.17609 106.023 5.17609C105.236 5.17609 104.806 5.58768 104.615 5.99638H104.583V3.75H103V9.68623ZM104.55 7.46015C104.55 6.73261 104.887 6.28333 105.466 6.28333C106.052 6.28333 106.375 6.7442 106.375 7.46015C106.375 8.17899 106.052 8.64565 105.466 8.64565C104.887 8.64565 104.55 8.18188 104.55 7.46015Z" fill="#24292E"/>
<!-- 1 text -->
<path d="M106.5 21H105.072L103.5 21.9199V23.1738L104.925 22.3535H104.963V27H106.5V21Z" fill="#24292E"/>
<!-- 2 text -->
<path d="M102.337 44.5H106.75V43.3468H104.334V43.3092L105.047 42.6561C106.34 41.5376 106.677 40.9682 106.677 40.289C106.677 39.2225 105.801 38.5 104.448 38.5C103.129 38.5 102.247 39.2543 102.25 40.4595H103.615C103.615 39.9249 103.947 39.6156 104.442 39.6156C104.928 39.6156 105.28 39.9133 105.28 40.4017C105.28 40.8439 105.001 41.1445 104.512 41.5809L102.337 43.4711V44.5Z" fill="#24292E"/>
<!-- Arrow indicating transformation -->
<path d="M71.9067 30C72.6011 30 77.9327 26 77.9327 24C77.9327 22 72.7357 18 71.9067 18C71.0778 18 70.4023 18.5 70.4023 19.4756C70.4023 20.4512 73.9067 22.9206 73.9067 22.9206C73.9067 22.9206 66.2539 22.25 66 22.9206C65.7461 23.5911 65.7461 24.4089 66 25.0794C66.2539 25.75 73.9067 25.0794 73.9067 25.0794C73.9067 25.0794 70.4023 27.75 70.4023 28.5301C70.4023 29.3103 71.2124 30 71.9067 30Z" fill="#24292E"/>
<defs>
<linearGradient id="paint0_linear_sql" x1="0" y1="6.5" x2="28" y2="6.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2CC0C"/>
<stop offset="1" stop-color="#FF9830"/>
</linearGradient>
<linearGradient id="paint1_linear_sql" x1="92" y1="6.5" x2="120" y2="6.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2CC0C"/>
<stop offset="1" stop-color="#FF9830"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

@@ -34,7 +34,7 @@
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/prismjs": "1.26.5",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
@@ -35,7 +35,7 @@
"@types/debounce-promise": "3.1.9",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/prismjs": "1.26.5",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
@@ -23,7 +23,7 @@
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/react": "18.3.18",
"jest": "29.7.0",
"ts-node": "10.9.2",
@@ -27,7 +27,7 @@
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/prismjs": "1.26.5",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
@@ -30,7 +30,7 @@
"@types/d3-random": "^3.0.2",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/uuid": "10.0.0",
@@ -10,7 +10,7 @@
"@grafana/runtime": "12.4.0-pre",
"@grafana/schema": "12.4.0-pre",
"@grafana/ui": "12.4.0-pre",
"@reduxjs/toolkit": "2.10.1",
"@reduxjs/toolkit": "2.9.0",
"lodash": "4.17.21",
"moment": "2.30.1",
"react": "18.3.1",
@@ -31,7 +31,7 @@
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/semver": "7.7.1",
@@ -31,7 +31,7 @@
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/logfmt": "^1.2.3",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/react-window": "1.8.8",
@@ -33,7 +33,7 @@
"@types/d3-random": "^3.0.2",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/uuid": "10.0.0",
@@ -24,7 +24,7 @@
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/react": "18.3.18",
"i18next-cli": "1.11.12",
"ts-node": "10.9.2",
@@ -23,7 +23,7 @@
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/react": "18.3.18",
"jest": "29.7.0",
"ts-node": "10.9.2",
@@ -29,7 +29,7 @@
"@types/debounce-promise": "3.1.9",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "22.17.0",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/uuid": "10.0.0",
@@ -23,7 +23,7 @@
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"jest": "29.7.0",
@@ -45,7 +45,7 @@
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/prismjs": "1.26.5",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
@@ -26,7 +26,7 @@
"@testing-library/react": "16.3.0",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/node": "24.9.2",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"jest": "29.7.0",
@@ -12,7 +12,6 @@ import {
KeyboardPlugin,
TooltipPlugin2,
UPlotConfigBuilder,
XAxisInteractionAreaPlugin,
usePanelContext,
useTheme2,
} from '@grafana/ui';
@@ -277,7 +276,6 @@ export const CandlestickPanel = ({
{cursorSync !== DashboardCursorSync.Off && (
<EventBusPlugin config={uplotConfig} eventBus={eventBus} frame={alignedFrame} />
)}
<XAxisInteractionAreaPlugin config={uplotConfig} queryZoom={onChangeTimeRange} />
{options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={uplotConfig}
+1 -4
View File
@@ -5930,10 +5930,7 @@
}
},
"empty-transformations-message": {
"add-transformation": "Add transformation",
"show-more": "Show more",
"sql-name": "SQL Expressions",
"sql-transformation-description": "Manipulate your data using MySQL-like syntax"
"add-transformation": "Add transformation"
},
"general-settings-edit-view": {
"editable_options": {
+411 -399
View File
File diff suppressed because it is too large Load Diff