diff --git a/.gitattributes b/.gitattributes index 6dd73a786fb..506da7cc196 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,6 +2,8 @@ *.gen.ts linguist-generated *_gen.ts linguist-generated *_gen.go linguist-generated +*_gen.csv linguist-generated +*_gen.json linguist-generated **/openapi_snapshots/*.json linguist-generated apps/**/pkg/apis/*_manifest.go linguist-generated public/openapi3.json linguist-generated diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 234789dfa4c..6adf2386d10 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -41,12 +41,14 @@ /docs/sources/ @irenerl24 /docs/sources/alerting/ @JohnnyK-Grafana +/docs/sources/as-code/ @urbiz-grafana +/docs/sources/developer-resources/ @urbiz-grafana /docs/sources/datasources/ @lwandz13 /docs/sources/upgrade-guide/ @jtvdez /docs/sources/whatsnew/ @jtvdez -/docs/sources/developers/plugins/ @grafana/plugins-platform-frontend @grafana/plugins-platform-backend +/docs/sources/developer-resources/plugins/ @grafana/plugins-platform-frontend @grafana/plugins-platform-backend /docs/sources/visualizations/dashboards/ @imatwawana /docs/sources/visualizations/panels-visualizations/ @imatwawana @@ -558,6 +560,7 @@ i18next.config.ts @grafana/grafana-frontend-platform /packages/grafana-data/src/transformations/ @grafana/datapro /packages/grafana-data/src/types/ @grafana/grafana-frontend-platform /packages/grafana-data/src/types/scopes.ts @grafana/grafana-operator-experience-squad +/packages/grafana-data/src/types/suggestions.ts @grafana/dataviz-squad /packages/grafana-data/src/utils/__snapshots__/ @grafanabot /packages/grafana-data/src/utils/anyToNumber.ts @grafana/grafana-frontend-platform /packages/grafana-data/src/utils/arrayUtils* @grafana/grafana-frontend-platform @@ -942,6 +945,7 @@ playwright.storybook.config.ts @grafana/grafana-frontend-platform /public/app/features/notifications/ @grafana/grafana-search-navigate-organise /public/app/features/org/ @grafana/grafana-search-navigate-organise /public/app/features/panel/ @grafana/dashboards-squad +/public/app/features/panel/suggestions/ @grafana/dataviz-squad /public/app/features/playlist/ @grafana/dashboards-squad /public/app/features/plugins/ @grafana/plugins-platform-frontend /public/app/features/profile/ @grafana/grafana-frontend-platform diff --git a/conf/defaults.ini b/conf/defaults.ini index e25fb0c1715..033a382d507 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -113,7 +113,7 @@ network = "tcp" address = "127.0.0.1:10000" use_tls = false cert_file = -key_file = +cert_key = # this will log the request and response for each unary gRPC call enable_logging = false @@ -2211,8 +2211,6 @@ encryption_provider = secret_key.v1 # These flags are required in on-prem installations for GitSync to work # -# Whether to register the MT CRUD API -register_api_server = true # Whether to create the MT secrets management database run_secrets_db_migrations = true # Whether to run the data key id migration. Requires that RunSecretsDBMigrations is also true. diff --git a/conf/sample.ini b/conf/sample.ini index ed0233c1a29..4b8ab4fe362 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -114,7 +114,7 @@ ;address = "127.0.0.1:10000" ;use_tls = false ;cert_file = -;key_file = +;cert_key = ;max_recv_msg_size = ;max_send_msg_size = # this will log the request and response for each unary gRPC call @@ -2109,8 +2109,6 @@ default_datasource_uid = # These flags are required in on-prem installations for GitSync to work # -# Whether to register the MT CRUD API -;register_api_server = true # Whether to create the MT secrets management database ;run_secrets_db_migrations = true # Whether to run the data key id migration. Requires that RunSecretsDBMigrations is also true. diff --git a/docs/sources/alerting/best-practices/_index.md b/docs/sources/alerting/best-practices/_index.md index 41251a25994..4e08dd8ef37 100644 --- a/docs/sources/alerting/best-practices/_index.md +++ b/docs/sources/alerting/best-practices/_index.md @@ -47,4 +47,11 @@ Designing and configuring an alert management set up that works takes time. Here - Continually tune your alert rules to review effectiveness. Remove alert rules to avoid duplication or ineffective alerts. - Continually review your thresholds and evaluation rules. +**How should you configure recording rules?** + +- Use frequent evaluation intervals. It is recommended to set a frequent evaluation interval for recording rules. Long intervals, such as an hour, can cause the recorded metric to be stale and lead to misaligned alert rule evaluations, especially when combined with a long pending period. +- Understand query types. Grafana Alerting uses both **Instant** and **Range** queries. Instant queries fetch a single data point, while Range queries fetch a series of data points over time. When using a Range query in an alert condition, you must use a Reduce expression to aggregate the series into a single value. +- Align alert evaluation with recording frequency. The evaluation interval of an alert rule that depends on a recorded metric should be aligned with the recording rule's interval. If a recording rule runs every 3 minutes, the alert rule should also be evaluated at a similar frequency to ensure it acts on fresh data. +- Use `_over_time` functions for instant queries. Since all alert rules are ultimately executed as an instant query, you can use functions like `max_over_time(my_metric[1h])` as an instant query. This allows you to get an aggregated value over a period without using a range query and a reduce expression. + {{< /shared >}} diff --git a/docs/sources/as-code/_index.md b/docs/sources/as-code/_index.md new file mode 100644 index 00000000000..3a252127402 --- /dev/null +++ b/docs/sources/as-code/_index.md @@ -0,0 +1,43 @@ +--- +aliases: +description: Deploy, configure and provision Grafana with as-code workflows. +menuTitle: As code +title: Deploy, configure and provision Grafana with as-code workflows +hero: + title: Configure and provision Grafana with as-code workflows + level: 1 + width: 100 + height: 100 + description: Manage resources, including folders and dashboards, and configurations with as-code workflows. +cards: + items: + - description: Using Observability as code, you can version, automate, and scale Grafana configurations, including dashboards and observability workflows. + height: 24 + href: ./observability-as-code/ + title: Observability as code + - description: Using Infrastructure as code, you can declaratively manage what Grafana resources to use. + height: 24 + href: ./infrastructure-as-code/ + title: Infrastructure as code +weight: 850 +canonical: https://grafana.com/docs/grafana/latest/as-code/ +--- + +{{< docs/hero-simple key="hero" >}} + +--- + +## Overview + +**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. Instead of manually configuring dashboards or settings through the Grafana UI, you can: + +- 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. + +In Grafana Cloud, you can use **Infrastructure as code** to declaratively create and manage dashboards via configuration files in source code, and incorporate them efficiently into your own use cases. This enables you to review code, reuse it, and create better workflows. Infrastructure as code tools include Terraform, Ansible, the Grafana Operator, and Grizzly. + +## Explore + +{{< card-grid key="cards" type="simple" >}} diff --git a/docs/sources/as-code/infrastructure-as-code/_index.md b/docs/sources/as-code/infrastructure-as-code/_index.md new file mode 100644 index 00000000000..51d46f4517d --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/_index.md @@ -0,0 +1,200 @@ +--- +keywords: + - Infrastructure as code + - Quickstart + - Grafana Cloud +menuTitle: Infrastructure as code +title: Provision Grafana Cloud with infrastructure as code +weight: 800 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/ +--- + +# Provision Grafana Cloud with infrastructure as code + +With Grafana Cloud, you can create dashboards via configuration files in source code. This enables you to review code, reuse it, and create better workflows. + +Via code, you can _declaratively_ manage _what_ Grafana resources to use. +The as-code tools and tutorials that follow show you what do to, to declaratively manage Grafana resources, and incorporate them efficiently into your own use cases. + +## Grafana Terraform provider + +Grafana administrators can manage dashboards, alerts and collectors, add synthetic monitoring probes and checks, manage identity and access, and more using the [Terraform provider for Grafana](https://registry.terraform.io/providers/grafana/grafana/latest). + +The following example shows a Terraform configuration for creating a dashboard: + +```terraform +resource "grafana_dashboard" "metrics" { + config_json = jsonencode({ + title = "as-code dashboard" + uid = "ascode" + }) +} +``` + +This example dashboard only creates the dashboard and does not add any panels or rows. +To get started, see the [Grafana Terraform provider guides](/docs/grafana-cloud/as-code/infrastructure-as-code/terraform/) or refer to the [Terraform Grafana Provider documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs). + +### Who is this recommended for? + +Grafana Terraform provider is best suited for users who are already using Terraform for non-Grafana use cases. + +To manage the entire Grafana ecosystem of resources on either Grafana Cloud or OSS deployments of Grafana, it’s best to use the Terraform Grafana provider because it supports the most Grafana resources compared to Grafana’s other as-code solutions. + +For Grafana Fleet Management users, the Grafana Terraform provider is best used to preregister new collectors before they are operational or add remote attributes to collectors already registered with the service. + +### Known limitations + +Managing dashboards isn’t the simplest process—you have to work with long JSON files, which can become difficult to review and update, as well. Grafonnet can help with generating dashboard JSONs that can be used in Terraform, but Grafonnet requires knowing Jsonnet. + +## Grafana Ansible collection + +Resources for configuration management are available for Grafana through the [Ansible collection for Grafana](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/index.html#plugins-in-grafana-grafana). The Grafana Ansible collection can be used to manage a variety of resources, including folders, cloud stacks, and dashboards. You can programmatically manage resources on Grafana that aren’t currently part of the Grafana Ansible collection by writing Ansible playbooks that use the HTTP APIs to manage resources for Grafana. + +The following example shows an Ansible configuration for creating a dashboard: + +```yaml +- name: dashboard as code + grafana.grafana.dashboard: + dashboard: { 'title': 'as-code dashboard', 'uid': 'ascode' } + stack_slug: '{{ stack_slug }}' + grafana_api_key: '{{ grafana_api_key }}' + state: present +``` + +This example dashboard creates only the dashboard and does not add any panels or rows. + +To get started, see the [quickstart guides for the Grafana Ansible Collection](/docs/grafana-cloud/as-code/infrastructure-as-code/ansible/) or check out the [collections's documentation](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/index.html#plugins-in-grafana-grafana). + +### Who is this recommended for? + +Like Terraform, the Grafana Ansible collection is best suited for people already using Ansible for non-Grafana use cases. The collection only works for Grafana Cloud right now, so it makes the most sense for Grafana Cloud customers who want to manage resources declaratively using Ansible. + +### Known limitations + +The Grafana Ansible collection only works for Grafana Cloud and only supports eight resources: API keys, cloud stacks, plugins, dashboards, folders, data sources, alert contact points, and notification policies. This can be a drawback if you want to manage the entire Grafana ecosystem as code with Ansible. As with Terraform, building dashboards is a challenging process. + +## Grafana Operator + +The Grafana Operator is a Kubernetes operator that can provision, manage, and operate Grafana instances and their associated resources within Kubernetes through Custom Resources. This Kubernetes-native tool eases the administration of Grafana, offering a declarative approach to managing dashboards, data sources, and folders. It also automatically syncs the Kubernetes Custom resources and the actual resources in the Grafana Instance. It supports leveraging Grafonnet for generating Grafana dashboard definitions for seamless dashboard configuration as code. + +To get started, see the [quickstart guides for the Grafana Operator](/docs/grafana-cloud/as-code/infrastructure-as-code/grafana-operator/) or check out the [Grafana Operator's documentation](https://grafana.github.io/grafana-operator/). + +A sample Kubernetes configuration for creating a dashboard using the Grafana operator looks like this: + +```yaml +apiVersion: integreatly.org/v1alpha1 +kind: GrafanaDashboard +metadata: + name: simple-dashboard + labels: + app: grafana +spec: + instanceSelector: + matchLabels: + dashboards: + json: > + { + "title": "as-code dashboard", + “uid” : “ascode” + } +``` + +### Who is this recommended for? + +The Grafana Operator is particularly fitting for: + +- Teams seeking integrated solutions to manage Grafana resources within the Kubernetes cluster ecosystem. +- Teams employing a GitOps approach, allowing them to treat Grafana configurations as code, stored alongside application manifests for versioned and automated deployments. + +### Known limitations + +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 [Grizzly’s 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 doesn’t 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. + +To get started with the Grafana Crossplane provider, install Crossplane in the Kubernetes cluster and use this command to install the provider: + +```shell +kubectl crossplane install provider grafana/crossplane-provider-grafana:v0.1.0 +``` + +During installation of the provider, CRDs for all the resources supported by the Terraform provider are added to the cluster so users can begin defining their Grafana resources as Kubernetes custom resources. The Crossplane provider ensures that whatever is defined in the custom resource definitions is what is visible in Grafana UI. If any changes are made directly in the UI, the changes will be discarded when the provider resyncs. This helps ensure that whatever is defined declaratively in the cluster will be the source of truth for Grafana resources. + +To get started, refer to the examples folder in the Grafana Crossplane repository. + +The following example shows a Kubernetes custom resource definition for creating a dashboard: + +```yaml +apiVersion: grafana.jet.crossplane.io/v1alpha1 +kind: Dashboard +metadata: + name: as-code-dashboard +spec: + forProvider: + configJson: | + { + "title": "as-code dashboard", + "uid": "ascode" + } + providerConfigRef: + name: grafana-crossplane-provider +``` + +### Who is this recommended for? + +The Grafana Crossplane provider is intended for existing Crossplane users looking to manage Grafana resources from within Kubernetes and as Kubernetes manifests for the GitOps pipelines. + +### Known limitations + +To use the Crossplane provider, you must have the Crossplane CLI and Crossplane installed in the Kubernetes cluster. Note that the Crossplane provider is in an alpha stage, so it has not reached a stable state yet. + +## Grafana as code comparison + +Most of the tools defined here can be used with one another. +The following chart compares the properties and tools mentioned above. + +| Property/Tool | Grafana Terraform Provider | Grafana Ansible Collection | Grafana Operator | Grizzly | Grafana Crossplane Provider | +| -------------------------------------- | --------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| Grafana resources supported | All major Grafana resources | Grafana Cloud stack, plugins, API keys, dashboards, data sources, and folders | Dashboards, Datasources, Folders | Synthetic Monitoring checks, dashboards, data sources, folders, and Prometheus rules | All major Grafana resources | +| Tool format | HCL/JSON | YAML | YAML | Jsonnet/YAML/JSON | YAML/JSON | +| Follows Kubernetes-style manifests | | | ✓ | ✓ | ✓ | +| Easy dashboard building process | | | ✓ | ✓ | | +| Manage resources using Kubernetes | | | ✓ | | ✓ | +| Retrieves Grafana resource information | ✓ | | | | | +| Built-in resource sync process | | | ✓ | ✓ | ✓ | +| Recommended for | Existing Terraform users | Existing Ansible users | Users looking to manage Grafana resources from within Kubernetes | Users looking to define Grafana resources in a Kubernetes-style YAML and users looking to get built-in workflow support and sync process | Users looking to manage Grafana resources from within Kubernetes | diff --git a/docs/sources/as-code/infrastructure-as-code/ansible/_index.md b/docs/sources/as-code/infrastructure-as-code/ansible/_index.md new file mode 100644 index 00000000000..46d44ff1f90 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/ansible/_index.md @@ -0,0 +1,29 @@ +--- +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Ansible +menuTitle: Ansible +title: Grafana Ansible collection +weight: 110 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/ansible/ +--- + +# Grafana Ansible collection + +The [Grafana Ansible collection](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/) provides configuration management resources for Grafana. You can use it to manage resources such as dashboards, Cloud stacks, folders, and more. + +The collection also houses the [Grafana Agent role](https://github.com/grafana/grafana-ansible-collection/tree/main/roles/grafana_agent) which can be used to deploy and manage Grafana Agent across various Linux machines. + +{{< docs/shared lookup="agent-deprecation.md" source="alloy" version="next" >}} + +For resources currently not available in the Grafana Ansible collection, you can manage those resources on Grafana Cloud programmatically by writing Ansible playbooks that use the [Ansible's builtin uri module](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/uri_module.html) to call the [HTTP APIs](/docs/grafana/latest/developers/http_api/) to manage resources for the Grafana Cloud portal, as well as those within a stack. + +Use the following guides to get started using Ansible to manage your Grafana Cloud stack: + +| Topic | Description | +| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| [Create and manage a Grafana Cloud stack using Ansible](ansible-cloud-stack/) | Describes how to create a Grafana Cloud stack and add a data source and dashboard using [Ansible](https://www.ansible.com/). | +| [Install Grafana Agent on a Linux host using Ansible](ansible-grafana-agent-linux/) | Describes how to install the Grafana Agent on a Linux node using Ansible and use it to push logs to Grafana Cloud. | +| [Monitor multiple Linux hosts with Grafana Agent Role](ansible-multiple-agents/) | Describes how to use the Grafana Ansible collection to manage agents across multiple Linux hosts. | diff --git a/docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md b/docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md new file mode 100644 index 00000000000..5dcab277f04 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md @@ -0,0 +1,259 @@ +--- +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Ansible +title: Create and manage a Grafana Cloud stack using Ansible +weight: 100 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/ +--- + +# Create and manage a Grafana Cloud stack using Ansible + +Learn how to add a data source, a dashboard, and a folder to a Grafana Cloud stack using Ansible collection for Grafana. + +## Before you begin + +Before you begin, you should have the following available: + +- A Grafana Cloud account. +- [Ansible](https://docs.ansible.com/ansible/latest/installation_guide/index.html) installed on your machine + +## Create a Cloud stack + +1. Create a Grafana Cloud Access Policy and get a token. + You'll need this for the Ansible playbook to be able to create a Grafana Cloud stack. + Refer to [Create a Grafana Cloud Access Policy](/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-policies/create-access-policies/). + +1. Create an Ansible playbook file. + + This Ansible playbook will create a Grafana Cloud stack by using the [Cloud stack module](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/cloud_stack_module.html#ansible-collections-grafana-grafana-cloud-stack-module). + + Create a file named `cloud-stack.yml` and add the following: + + ```yaml + - name: Create Grafana Cloud stack + connection: local + hosts: localhost + + vars: + grafana_cloud_api_key: '' + stack_name: '' + org_name: '' + + tasks: + - name: Create a Grafana Cloud stack + grafana.grafana.cloud_stack: + name: '{{ stack_name }}' + stack_slug: '{{ stack_name }}' + cloud_api_key: '{{ grafana_cloud_api_key }}' + org_slug: '{{ org_name }}' + delete_protection: true + state: present + ``` + +1. Replace the following field values: + - `` with a token from the Cloud Access Policy you created in the Grafana Cloud portal. + - `` with the name of your stack. + - `` with the name of the organization in Grafana Cloud. + +## Create an API key in the Grafana stack + +Create an API key in the Grafana stack. +You'll need this key to configure Ansible to be able to create data source, folders, and dashboards. + +1. Log into your Grafana Cloud instance. +2. Click **Administration** and select **API keys**. +3. Click **Add API key**. +4. In **Key name**, enter a name for your API key. +5. In **Role**, select **Admin** or **Editor** to associate the role with this API key. +6. Click **Copy** to save it for later use. + +## Add a data source + +This guide uses the InfluxDB data source. +The required arguments vary depending on the type of data source you select. + +1. Create a file named `data-source.yml` and add the following: + + ```yaml + - name: Add/Update data source + connection: local + hosts: localhost + + vars: + data_sources: + [ + { + name: '', + type: 'influxdb', + url: '', + user: '', + secureJsonData: { password: '' }, + database: '', + id: , + uid: '', + access: 'proxy', + }, + ] + + grafana_api_key: '' + stack_name: '' + + tasks: + - name: Create/Update Data sources + grafana.grafana.datasource: + datasource: '{{ item }}' + stack_slug: '{{ stack_name }}' + grafana_api_key: '{{ grafana_api_key }}' + state: present + loop: '{{ data_sources }}' + ``` + +1. Replace the following field values: + - `` with the name of the data source to be added in Grafana. + - `` with URL of your data source. + - `` with the username for authenticating with your data source. + - `` with the password for authenticating with your data source. + - `` with name of your database. + - `` with the ID for your data source in Grafana. + - `` wth the UID for your data source in Grafana. + - `` with the name of your stack. + - `` with the [API key created in the Grafana instance](#create-an-api-key-in-the-grafana-stack). + +## Add a folder + +This Ansible playbook creates a folder in your Grafana instance by using the [Folder module](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/folder_module.html#ansible-collections-grafana-grafana-folder-module). + +1. Create a file named `folder.yml` and add the following: + + ```yaml + - name: Add/Update Folders + connection: local + hosts: localhost + + vars: + folders: [{ title: '', uid: '' }] + + stack_name: '' + grafana_api_key: + + tasks: + - name: Create/Update a Folder in Grafana + grafana.grafana.folder: + title: '{{ item.title }}' + uid: '{{ item.uid }}' + stack_slug: '{{ stack_name }}' + grafana_api_key: '{{ grafana_api_key }}' + state: present + loop: '{{ folders }}' + ``` + +1. Replace the following field values: + - `` with the name of the folder to be added in Grafana. + - `` with the UID for your folder in Grafana. + - `` with the name of your stack. + - `` with the [API key created in the Grafana instance](#create-an-api-key-in-the-grafana-stack). + +## Add a dashboard to the folder + +This Ansible playbook iterates through the dashboard JSON source code files in the folder referenced in `dashboards_path` and adds them in the Grafana instance by using the [Dashboard module](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/dashboard_module.html#ansible-collections-grafana-grafana-dashboard-module). + +1. Create a file named `dashboard.yml` and add the following: + + ```yaml + - name: Add/Update Dashboards + connection: local + hosts: localhost + + vars: + dashboards_path: # Example "./dashboards" + stack_name: "" + grafana_api_key: + + tasks: + - name: Find dashboard files + find: + paths: "{{ dashboards_path }}" + file_type: file + recurse: Yes + patterns: "*.json" + register: files_matched + no_log: True + + - name: Create list of dashboard file names + set_fact: + dashboard_file_names: "{{ dashboard_file_names | default ([]) + [item.path] }}" + loop: "{{ files_matched.files }}" + no_log: True + + - name: Create/Update a dashboard + grafana.grafana.dashboard: + dashboard: "{{ lookup('ansible.builtin.file','{{ item }}' ) }}" + stack_slug: "{{ stack_name }}" + grafana_api_key: "{{ grafana_api_key }}" + state: present + loop: "{{ dashboard_file_names }}" + ``` + +1. Replace the following field values: + - `` with the path to the folder containing dashboard JSON source code files. + - `` with the name of your stack. + - `` with the [API key created in the Grafana instance](#create-an-api-key-in-the-grafana-stack). + +## Run the Ansible playbooks + +In a terminal, run the following commands from the directory where all of the Ansible playbooks are located. + +1. To create the Grafana Cloud stack. + + ```shell + ansible-playbook cloud-stack.yml + ``` + +1. To add a data source to the Grafana stack. + + ```shell + ansible-playbook data-source.yml + ``` + +1. To add a folder to the Grafana stack + + ```shell + ansible-playbook folder.yml + ``` + +1. To add a dashboard to the folder in your Grafana stack. + + ```shell + ansible-playbook dashboard.yml + ``` + +## Validation + +Once you run the Ansible playbooks, you should be able to verify the following: + +- The new Grafana stack is created and visible in the Cloud Portal. + + ![Cloud Portal](/static/img/docs/grafana-cloud/terraform/cloud_portal_tf.png) + +- A new data source (InfluxDB in this example) is visible in the Grafana stack. + + ![InfluxDB datasource](/media/docs/grafana-cloud/screenshot-influxdb_datasource_tf.png) + +- A new folder in Grafana. + In the following image, a folder named `Demos` was added. + + ![Folder](/media/docs/grafana-cloud/screenshot-folder_tf.png) + +- A new dashboard in the Grafana stack. + In the following image a dashboard named `InfluxDB Cloud Demos` was created inside the "Demos" folder. + + ![InfluxDB dashboard](/static/img/docs/grafana-cloud/terraform/influxdb_dashboard_tf.png) + +## Summary + +In this guide, you created a Grafana Cloud stack along with a data source, folder, and dashboard imported from a JSON file using Ansible. + +To learn more about managing Grafana using Ansible, refer to the [Grafana Ansible collection](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/). diff --git a/docs/sources/as-code/infrastructure-as-code/ansible/ansible-grafana-agent-linux/index.md b/docs/sources/as-code/infrastructure-as-code/ansible/ansible-grafana-agent-linux/index.md new file mode 100644 index 00000000000..8fa3a6f8357 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/ansible/ansible-grafana-agent-linux/index.md @@ -0,0 +1,156 @@ +--- +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Ansible +title: Install Grafana Agent on a Linux host using Ansible +weight: 200 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/ansible/ansible-grafana-agent-linux/ +--- + +# Install Grafana Agent on a Linux host using Ansible + +{{< docs/shared lookup="agent-deprecation.md" source="alloy" version="next" >}} + +This guide shows how to install Grafana Agent on a Linux host using [Ansible](https://www.ansible.com/) and to use it to push logs to Grafana Cloud. + +## Before you begin + +Before you begin, you should have the following available: + +- A Grafana Cloud account. +- A Linux machine +- Command line (terminal) access to that Linux machine with `unzip` binary installed +- Account permissions sufficient to install and use Grafana Agent on the Linux machine +- [Ansible](https://docs.ansible.com/ansible/latest/installation_guide/index.html) installed on the Linux machine + +## Choose your Grafana Agent installation method + +This guide covers two methods for installing and configuring Grafana Agent using Ansible: + +- Installing Grafana Agent in Flow mode +- Installing Grafana Agent in static mode + +Depending on your specific needs and the configuration of your environment, you may choose one method over the other for better compatibility or ease of setup. + + + +### Install Grafana Agent in flow mode using Ansible + +This Ansible playbook installs Grafana Agent in Flow mode and also creates a systemd service to manage it. + +It creates a user named `grafana-agent` on the Linux machine for running Grafana Agent. + +1. Create a file named `grafana-agent.yml` and add the following: + +```yaml +- name: Install Grafana Agent Flow + hosts: all + become: true + tasks: + - name: Install Grafana Agent Flow + ansible.builtin.include_role: + name: grafana.grafana.grafana_agent + vars: + grafana_agent_mode: flow + # Change config file on the host to .river + grafana_agent_config_filename: config.river + # Change config file to be copied + grafana_agent_provisioned_config_file: '' + # Remove default flags + grafana_agent_flags_extra: + server.http.listen-addr: '0.0.0.0:12345' +``` + +1. Replace the following field values: + - `` with the path to river configuration file on the Ansible Controller (Localhost). + +### Install Grafana Agent in static mode using Ansible + +This Ansible playbook installs Grafana Agent in static mode and also creates a systemd service to manage it. +It creates a user named `grafana-agent` on the Linux machine for running Grafana Agent. + +1. Create a file named `grafana-agent.yml` and add the following: + +```yaml +- name: Install Grafana Agent in static mode + hosts: all + become: true + + vars: + grafana_cloud_api_key: + logs_username: # Example - 411478 + loki_url: # Example - https://logs-prod-017.grafana.net/loki/api/v1/push + tasks: + - name: Install Grafana Agent in static mode + ansible.builtin.include_role: + name: grafana_agent + vars: + grafana_agent_logs_config: + configs: + - clients: + - basic_auth: + password: '{{ grafana_cloud_api_key }}' + username: '{{ logs_username }}' + url: '{{ loki_url }}' + name: default + positions: + filename: /tmp/positions.yaml + scrape_configs: + - job_name: integrations/node_exporter_direct_scrape + static_configs: + - targets: + - localhost + labels: + instance: hostname + __path__: /var/log/*.log + job: integrations/node_exporter + target_config: + sync_period: 10s +``` + +1. Replace the following field values: + - `` with a token from the Cloud Access Policy you created in the Grafana Cloud portal. + - `` with the Loki Username + - `` with the push endpoint URL of Loki Instance + +## Run the Ansible playbook on the Linux machine + +In the Linux machine's terminal, run the following command from the directory where the Ansible playbook is located. + +```shell +ansible-playbook grafana-agent.yml +``` + +## Validate + + + +1. Grafana Agent service on the Linux machine should be `active` and `running`. You should see a similar output: + + +```shell +$ sudo systemctl status grafana-agent.service + grafana-agent.service - Grafana Agent + Loaded: loaded (/etc/systemd/system/grafana-agent.service; enabled; vendor preset: enabled) + Active: active (running) since Wed 2022-07-20 09:56:15 UTC; 36s ago + Main PID: 3176 (agent-linux-amd) + Tasks: 8 (limit: 515) + Memory: 92.5M + CPU: 380ms + CGroup: /system.slice/grafana-agent.service + └─3176 /usr/local/bin/agent-linux-amd64 --config.file=/etc/grafana-cloud/agent-config.yaml +``` + +1. In a Grafana Cloud stack, click **Explore** in the left-side menu. + +1. At the top of the page, use the dropdown menu to select your Loki logs data source. In the Log Browser, run the query `{job="integrations/node_exporter"}` + + ![Loki Logs](/static/img/docs/grafana-cloud/ansible/ansible-agent-logs.png) + +## Summary + +In this guide, you installed Grafana Agent on a Linux node using Ansible and used it to pushed logs to Grafana Cloud. + +To learn more about the Grafana Ansible collection, refer to the [GitHub repository](https://github.com/grafana/grafana-ansible-collection) or its [documentation](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/index.html). diff --git a/docs/sources/as-code/infrastructure-as-code/ansible/ansible-multiple-agents/index.md b/docs/sources/as-code/infrastructure-as-code/ansible/ansible-multiple-agents/index.md new file mode 100644 index 00000000000..c6769e5ebde --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/ansible/ansible-multiple-agents/index.md @@ -0,0 +1,207 @@ +--- +menuTitle: Monitor multiple Linux hosts with the grafana_agent role +title: Monitor multiple Linux hosts with grafana_agent role +weight: 300 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/ansible/ansible-multiple-agents/ +--- + +# Monitor multiple Linux hosts with the `grafana_agent` role + +{{< docs/shared lookup="agent-deprecation.md" source="alloy" version="next" >}} + +Monitoring multiple Linux hosts can be difficult. +To make it easier, you can use the `grafana_agent` role with the [Grafana Ansible collection](../). +This guide shows how to use the `grafana_agent` Ansible role to deploy and manage Grafana Agent across multiple Linux hosts so you can monitor them using Grafana Cloud. + +## Before you begin + +Before you begin, you should have: + +- Linux hosts +- SSH access to the Linux hosts +- Account permissions sufficient to install and use Grafana Agent on the Linux hosts + +## Install the Grafana Ansible collection + +The [`grafana_agent` role](https://github.com/grafana/grafana-ansible-collection/tree/main/roles/grafana_agent) is available in the Grafana Ansible collection as of the 1.1.0 release. + +To install the Grafana Ansible collection, run this command: + +``` +ansible-galaxy collection install grafana.grafana +``` + +## Create an Ansible inventory file + +Next, you will set up your hosts and create an inventory file. + +1. Create your hosts and add public SSH keys to them. + + This example uses eight Linux hosts: two Ubuntu hosts, two CentOS hosts, two Fedora hosts, and two Debian hosts. + +1. Create an Ansible inventory file. + + The Ansible inventory, which resides in a file named `inventory`, looks similar to this: + + ``` + 146.190.208.216 # hostname = ubuntu-01 + 146.190.208.190 # hostname = ubuntu-02 + 137.184.155.128 # hostname = centos-01 + 146.190.216.129 # hostname = centos-02 + 198.199.82.174 # hostname = debian-01 + 198.199.77.93 # hostname = debian-02 + 143.198.182.156 # hostname = fedora-01 + 143.244.174.246 # hostname = fedora-02 + ``` + +1. Create an `ansible.cfg` file within the same directory as `inventory`, with the following values: + ``` + [defaults] + inventory = inventory # Path to the inventory file + private_key_file = ~/.ssh/id_rsa # Path to my private SSH Key + remote_user=root # username + ``` + {{< admonition type="note" >}} + If you are copying the previously listed files, remove the comments (#). + {{< /admonition >}} + +## Use the `grafana_agent` Ansible role + +Next you will create an Ansible playbook that calls the `grafana_agent` role from the `grafana.grafana` Ansible collection. + +To use the `grafana_agent` Ansible role: + +1. Create a file named `deploy-agent.yml` in the same directory as `ansible.cfg` and `inventory` and add the configuration below. + + ```yaml + - name: Install Grafana Agent + hosts: all + become: true + + vars: + grafana_cloud_api_key: + metrics_username: # Example - 825019 + logs_username: # Example - 411478 + prometheus_url: # Example - https://prometheus-us-central1.grafana.net/api/prom/push + loki_url: # Example - https://logs-prod-017.grafana.net/loki/api/v1/push + tasks: + - name: Install Grafana Agent + ansible.builtin.include_role: + name: grafana.grafana.grafana_agent + vars: + grafana_agent_metrics_config: + configs: + - name: integrations + remote_write: + - basic_auth: + password: '{{ grafana_cloud_api_key }}' + username: '{{ metrics_username }}' + url: '{{ prometheus_url }}' + + global: + scrape_interval: 60s + wal_directory: /tmp/grafana-agent-wal + grafana_agent_logs_config: + configs: + - name: default + clients: + - basic_auth: + password: '{{ grafana_cloud_api_key }}' + username: '{{ logs_username }}' + url: '{{ loki_url }}' + positions: + filename: /tmp/positions.yaml + target_config: + sync_period: 10s + scrape_configs: + - job_name: varlogs + static_configs: + - targets: [localhost] + labels: + instance: ${HOSTNAME:-default} + job: varlogs + __path__: /var/log/*log + grafana_agent_integrations_config: + node_exporter: + enabled: true + instance: ${HOSTNAME:-default} + prometheus_remote_write: + - basic_auth: + password: '{{ grafana_cloud_api_key }}' + username: '{{ metrics_username }}' + url: '{{ prometheus_url }}' + grafana_agent_env_vars: + HOSTNAME: '%H' + ``` + + The playbook calls the `grafana_agent` role from the `grafana.grafana` Ansible collection. + + The Agent configuration in this playbook send metrics and logs from the Linux hosts to Grafana Cloud along with the hostname of each instance + + Refer to the [Grafana Ansible documentation](https://github.com/grafana/grafana-ansible-collection/tree/main/roles/grafana_agent#role-variables) to understand the other variables you can pass to the `grafana_agent` role. + + When deploying the Agent across multiple instances for monitoring them, It is essential that the Agent is able to auto-detect the hostname for ease in monitoring. + Notice that the label `instance` has been set to the value `${HOSTNAME:-default}`, which is substituted by the value of the HOSTNAME environment variable in the Linux host. + + To read more about the variable substitution, refer to the Grafana Agent [node_exporter_config](/docs/grafana-cloud/send-data/agent/static/configuration/integrations/node-exporter-config/) documentation. + +1. To run the playbook, run this command: + + ``` + ansible-playbook deploy-agent.yml + ``` + +{{< admonition type="note" >}} +You can place the `deploy-agent.yml`, `ansible.cfg` and `inventory` files in different directories based on your needs. +{{< /admonition >}} + +## Check that logs and metrics are being ingested into Grafana Cloud + +Logs and metrics will soon be available in Grafana Cloud. +To test this, use the Explore feature. +Click the **Explore** icon (compass icon) in the vertical navigation bar. + +### Check logs + +To check logs: + +1. Use the drop-down menu at the top of the page to select your Loki logs data source. + +1. In the log browser, run the query `{instance="centos-01"}` where `centos-01` is the hostname of one of the Linux hosts. + + If you see log lines (shown in the example below), logs are being received. + + {{< figure alt="Grafana Explore showing a graph and log output from the preceding query" src="/static/assets/img/blog/ansible-to-manage-agent1.png" >}} + + If no log lines appear, logs aren't being collected. + +### Check metrics + +To check metrics: + +1. Use the drop-down menu at the top of the page to select your Prometheus data source. + +1. Run the query `{instance="centos-01"}` where `centos-01` is the hostname of one of the Linux hosts. + + If you see a metrics graph and table (shown in the example below), metrics are being received. + + {{< figure alt="Grafana Explore showing a graph and metrics table output from the preceding query" src="/static/assets/img/blog/ansible-to-manage-agent2.png" >}} + + If no metrics appear, metrics aren't being collected. + +### View dashboards + +Now that you have logs and metrics in Grafana, you can use dashboards to view them. +Here's an example of one of the prebuilt dashboards included with the Linux integration in Grafana Cloud: + +{{< figure alt="The Grafana Node Exporter integration dashboard showing panels of visualizations" src="/static/assets/img/blog/ansible-to-manage-agent3.png" >}} + +Using the **Instance** drop-down in the dashboard, you can select from the hostnames where you deployed Grafana Agent and start monitoring them. + +## Summary + +The `grafana_agent` Ansible role makes it easy to deploy and manage Grafana Agent across multiple machines. +This example showed Grafana Agent deployments across eight Linux hosts, but it's possible to monitor more hosts using the`grafana_agent` role. +To add monitor more Linux hosts, update the `inventory` file and re-run the Ansible playbook. + +To learn more about the Grafana Ansible collection, see its [GitHub repository](https://github.com/grafana/grafana-ansible-collection) or its [documentation](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/index.html). diff --git a/docs/sources/as-code/infrastructure-as-code/grafana-operator/_index.md b/docs/sources/as-code/infrastructure-as-code/grafana-operator/_index.md new file mode 100644 index 00000000000..42e8f18d79a --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/grafana-operator/_index.md @@ -0,0 +1,33 @@ +--- +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Grafana Operator +menuTitle: Grafana Operator +title: Grafana Operator +weight: 120 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/grafana-operator/ +--- + +# Grafana Operator + +[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. + +## Installing the Grafana Operator + +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 [Grafana Operator Installation Documentation](https://grafana.github.io/grafana-operator/docs/installation/). + +## Getting Started + +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/). diff --git a/docs/sources/as-code/infrastructure-as-code/grafana-operator/manage-dashboards-argocd.md b/docs/sources/as-code/infrastructure-as-code/grafana-operator/manage-dashboards-argocd.md new file mode 100644 index 00000000000..3c92270e860 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/grafana-operator/manage-dashboards-argocd.md @@ -0,0 +1,299 @@ +--- +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Grafana Operator + - ArgoCD +title: Manage Dashboards with GitOps Using ArgoCD +weight: 110 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/grafana-operator/manage-dashboards-argocd/ +--- + +# Managing Grafana Dashboards with GitOps Using ArgoCD + +This guide will walk you through setting up a continuous deployment pipeline using ArgoCD to synchronize your Grafana dashboards with a Git repository. We'll use the Grafana Dashboard Custom Resource provided by the Grafana Operator to manage dashboard configurations declaratively. + +## Prerequisites + +- An existing Grafana Cloud stack +- A Kubernetes cluster with Grafana Operator installed, as shown in [Grafana Operator Installation](/docs/grafana-cloud/as-code/infrastructure-as-code/grafana-operator/#installing-the-grafana-operator). +- ArgoCD installed on your Kubernetes cluster. Refer the [Installation Guide](https://argo-cd.readthedocs.io/en/stable/getting_started/). +- Git repository to store your dashboard configurations. + +## Set Up Your Git Repository + +Within the repository, create a directory structure to organize your grafana and dashboard configurations. For this tutorial, lets create a folder named `grafana`. + +## Grafana Operator Setup + +The Grafana Operator allows us to authenticate with the Grafana instance using the Grafana Custom Resource (CR). + +1. **Create the Grafana API Token Secret:** + +Store the Grafana API Token in a secret with the following content in a file named `grafana-token.yml` in the `grafana` folder in your Git repo: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: grafana-cloud-credentials + namespace: +stringData: + GRAFANA_CLOUD_INSTANCE_TOKEN: +type: Opaque +``` + +Replace the following field values: + +- `` with API key from the Grafana instance. To create an API key, refer [Grafana API Key Documentation](/docs/grafana/latest/administration/api-keys/). +- `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. + +2. **Configure the Grafana Custom Resource:** + +Set up connection to your Grafana Cloud instance by creating a file named `grafana-cloud.yml` in the `grafana` folder in your Git repo with the following contents: + +```yaml +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: + namespace: + labels: + dashboards: +spec: + external: + url: https://.grafana.net/ + apiKey: + name: grafana-cloud-credentials + key: GRAFANA_CLOUD_INSTANCE_TOKEN +``` + +Replace the following field values: + +- `` with API key from the Grafana instance. +- `` with the name of your Grafana Cloud Stack. +- `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. + +## Add Dashboards to a Git repository + +In your `grafana` directory, Create a sub-folder called `dashboards`. For this tutorial, we will create 3 seperate dashboards. + +1. Under `dashboards` folder, Create a file named `simple-dashboard.yaml` with the following content for the first dashboard: + + ```yaml + apiVersion: grafana.integreatly.org/v1beta1 + kind: GrafanaDashboard + metadata: + name: grafanadashboard-sample + namespace: + spec: + resyncPeriod: 30s + instanceSelector: + matchLabels: + dashboards: + json: > + { + "id": null, + "title": "Simple Dashboard", + "tags": [], + "style": "dark", + "timezone": "browser", + "editable": true, + "hideControls": false, + "graphTooltip": 1, + "panels": [], + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "time_options": [], + "refresh_intervals": [] + }, + "templating": { + "list": [] + }, + "annotations": { + "list": [] + }, + "refresh": "5s", + "schemaVersion": 17, + "version": 0, + "links": [] + } + ``` + + Replace the following field values: + - `` with the name of your Grafana Cloud Stack. + - `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. + +1. Under `dashboards` folder, Create a file named `dashboard-from-cm.yaml` with the following content for the second dashboard: + + ```yaml + apiVersion: v1 + kind: ConfigMap + metadata: + name: dashboard-definition + namespace: + spec: + resyncPeriod: 30s + instanceSelector: + matchLabels: + dashboards: + json: > + { + "id": null, + "title": "Simple Dashboard from ConfigMap", + "tags": [], + "style": "dark", + "timezone": "browser", + "editable": true, + "hideControls": false, + "graphTooltip": 1, + "panels": [], + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "time_options": [], + "refresh_intervals": [] + }, + "templating": { + "list": [] + }, + "annotations": { + "list": [] + }, + "refresh": "5s", + "schemaVersion": 17, + "version": 0, + "links": [] + } + --- + apiVersion: grafana.integreatly.org/v1beta1 + kind: GrafanaDashboard + metadata: + name: grafanadashboard-from-configmap + namespace: + spec: + instanceSelector: + matchLabels: + dashboards: + configMapRef: + name: dashboard-definition + key: json + ``` + + Replace the following field values: + - `` with the name of your Grafana Cloud Stack. + - `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. + +1. Under `dashboards` folder, Create a file named `dashboard-from-id.yaml` with the following content for the third dashboard: + + ```yaml + apiVersion: grafana.integreatly.org/v1beta1 + kind: GrafanaDashboard + metadata: + name: node-exporter-latest + namespace: + spec: + instanceSelector: + matchLabels: + dashboards: + grafanaCom: + id: 1860 + ``` + + Replace the following field values: + - `` with the name of your Grafana Cloud Stack. + - `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. + +## Configure Argo CD to Sync the Git Repository + +Once all changes are committed to Git, Log in to the Argo CD user interface or use the CLI. + +2. Create an Argo CD application to manage the synchronization: + + **Using UI**: + - Navigate to 'New App' and fill out the form with your Git repository details and the path to your `grafana` folder. + - Make sure to tick mark directory Recurse. + - Set the sync policy to `Automatic`. + + **Using CLI**: + - Prepare an application manifest named `argo-application.yaml` with the configuration pointing to your Git repository: + + ```yaml + apiVersion: argoproj.io/v1alpha1 + kind: Application + metadata: + name: Grafana + namespace: + spec: + destination: + name: '' + namespace: '' + server: 'https://kubernetes.default.svc' + source: + path: + repoURL: '' + targetRevision: HEAD + directory: + recurse: true + sources: [] + project: default + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + retry: + limit: 2 + backoff: + duration: 5s + maxDuration: 3m0s + factor: 2 + ``` + + Replace the following field values: + - `` with the URL of your GIT Repository. + - `` with the path to the `grafana` folder. + - `` with the namespace where ArgoCD is deployed in Kubernetes Cluster. + + - Create the application in Argo CD: + + ```shell + kubectl apply -f argo-application.yaml + ``` + +## Verify Sync Status in Argo CD + +1. Monitor the newly created Argo CD application, ensuring that it successfully syncs your dashboard configuration. + +2. Visit the Argo CD dashboard and check the sync status. If it's successful, your Grafana dashboard should be up to date with the configuration from your Git repository. + +## Updating the Dashboards + +To update an existing dashboard: + +1. Make changes to the dashboard JSON configuration in your Git repository. +2. Commit and push the changes. +3. Argo CD will detect the update and synchronize the changes to your Cutom Resource. +4. Grafana Operator will then sync changes to the Grafana Instance. + +## Validating the Grafana Dashboard Update + +Log in to your Grafana dashboard and confirm that the changes have been applied. You should see the dashboard update reflected in the Grafana UI. + +## Additional Tips + +- You can also install the Grafana Operator's Helm Chart using ArgoCD to manage your setup with GitOps. +- You can follow a similar setup for Grafana Dashboards and Folders. + +## Conclusion + +You've set up a GitOps workflow to manage Grafana dashboards using Argo CD and the Grafana Operator. Your dashboards are now version-controlled and can be consistently deployed across environments. This approach provides a reliable and auditable way to manage observability dashboards and scale your operations. + +To learn more about managing Grafana using Grafana Operator, see the [Grafana Operator documentation](https://grafana.github.io/grafana-operator/docs/). diff --git a/docs/sources/as-code/infrastructure-as-code/grafana-operator/operator-dashboards-folders-datasources.md b/docs/sources/as-code/infrastructure-as-code/grafana-operator/operator-dashboards-folders-datasources.md new file mode 100644 index 00000000000..9d8617e12c6 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/grafana-operator/operator-dashboards-folders-datasources.md @@ -0,0 +1,168 @@ +--- +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Grafana Operator +title: Manage folders, data sources, and dashboards using Grafana Operator +weight: 100 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/grafana-operator/operator-dashboards-folders-datasources/ +--- + +# Creating and managing folders, data sources, and dashboards using the Grafana Operator + +Learn how to manage data sources, folders and dashboard, using Grafana Operator. + +## Prerequisites + +Before you begin, you should have the following available: + +- An existing Grafana Cloud stack. +- Grafana Operator Installed in your Cluster, as shown in [Grafana Operator Installation](/docs/grafana-cloud/as-code/infrastructure-as-code/grafana-operator/#installing-the-grafana-operator). + +## Grafana Operator Setup + +The Grafana Operator allows us to authenticate with the Grafana instance using the Grafana Custom Resource (CR). + +1. **Create the Grafana API Token Secret:** + +Store the Grafana API Token in a secret with the following content in a file named `grafana-token.yml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: grafana-cloud-credentials + namespace: +stringData: + GRAFANA_CLOUD_INSTANCE_TOKEN: +type: Opaque +``` + +Replace the following field values: + +- `` with API key from the Grafana instance. To create an API key, refer [Grafana API Key Documentation](/docs/grafana/latest/administration/api-keys/). +- `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. + +2. **Configure the Grafana Custom Resource:** + +Set up connection to your Grafana Cloud instance by creating a file named `grafana-cloud.yml` with the following contents: + +```yaml +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: + namespace: + labels: + dashboards: +spec: + external: + url: https://.grafana.net/ + apiKey: + name: grafana-cloud-credentials + key: GRAFANA_CLOUD_INSTANCE_TOKEN +``` + +Replace the following field values: + +- `` with API key from the Grafana instance. +- `` with the name of your Grafana Cloud Stack. +- `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. + +## Add a data source + +The following steps use the Prometheus data source. The required arguments vary depending on the data source you select. + +1. **Create the Data Source Configuration:** + +Save a new YAML file `datasource.yml` with the following content: + +```yaml +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaDatasource +metadata: + name: + namespace: +spec: + instanceSelector: + matchLabels: + dashboards: + allowCrossNamespaceImport: true + datasource: + access: proxy + database: prometheus + jsonData: + timeInterval: 5s + tlsSkipVerify: true + name: + type: prometheus + url: +``` + +Replace the following field values: + +- `` with the name of the data source to be added in Grafana. +- `` with URL of your data source. +- `` with the name of your Grafana Cloud Stack. +- `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. + +## Add a dashboard to a folder + +Use the following YAML definition to create a simple dashboard in the Grafana instance under a custom folder. If the folder defined under spec.folder fields doesnt not exist, The operator will create it before placing the dashboard inside the folder. + +1. **Prepare the Dashboard Configuration File:** + +In `dashboard.yml`, define the dashboard and assign it to a folder: + +```yaml +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaDashboard +metadata: + name: + namespace: +spec: + instanceSelector: + matchLabels: + dashboards: + folder: "" + json: > + { + "title": "as-code dashboard", + “uid” : “ascode” + } +``` + +Replace the following field values: + +- `` with the name of the folder in which you want the Dashboard to be created. +- `` with the name of your Grafana Cloud Stack. +- `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. + +## Apply Kubernetes Manifests + +In a terminal, run the following commands from the directory where all of the above Kubernetes YAML definitions are located. + +1. Create Kubernetes Custom resources for all of the above configurations. + + ```shell + kubectl apply -f grafana-token.yml grafana-cloud.yml datasource.yml dashboard.yml + ``` + +## Validation + +Once you apply the configurations, you should be able to verify the following: + +- A new data source is visible in Grafana. In the following image a datasource named `InfluxDB` was created. + + ![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 the Grafana Operator. + +To learn more about managing Grafana using Grafana Operator, see the [Grafana Operator documentation](https://grafana.github.io/grafana-operator/docs/). diff --git a/docs/sources/as-code/infrastructure-as-code/grizzly/_index.md b/docs/sources/as-code/infrastructure-as-code/grizzly/_index.md new file mode 100644 index 00000000000..17650914d49 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/grizzly/_index.md @@ -0,0 +1,22 @@ +--- +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Grizzly + - CLI +menuTitle: Grizzly +title: Grizzly +weight: 130 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/grizzly/ +--- + +# Grizzly (deprecated) + +{{< admonition type="warning" >}} +Grizzly has been removed. It is no longer deployed, enhanced, or supported. + +Use the [Grafana CLI](/docs/grafana//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. diff --git a/docs/sources/as-code/infrastructure-as-code/grizzly/dashboards-folders-datasources.md b/docs/sources/as-code/infrastructure-as-code/grizzly/dashboards-folders-datasources.md new file mode 100644 index 00000000000..4290df5e0d5 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/grizzly/dashboards-folders-datasources.md @@ -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= +export GRAFANA_TOKEN= +``` + +Replace the following field values: + +- `` with the URL of your Grafana instance. +- `` 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: + spec: + name: + type: influxdb + url: + database: + user: + secureJsonData: + password: '' + uid: + id: + access: proxy + ``` + +1. Replace the following field values: + - `` with the name of the data source to be added in Grafana. + - `` with URL of your data source. + - `` with the username for authenticating with your data source. + - `` with the password for authenticating with your data source. + - `` with name of your database. + - `` with the ID for your data source in Grafana. + - `` 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: + spec: + title: + uid: + ``` + +1. Replace the following field values: + - `` with the name of the folder to be added in Grafana. + - `` 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: + name: influxdb-cloud-demos + spec: + title: InfluxDB Cloud Demos + uid: influxdb-cloud-demos + ``` + +1. Replace the following field values: + - `` 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/). diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/_index.md b/docs/sources/as-code/infrastructure-as-code/terraform/_index.md new file mode 100644 index 00000000000..2fce9ae2650 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/_index.md @@ -0,0 +1,26 @@ +--- +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Terraform +menuTitle: Terraform +title: Grafana Terraform provider +weight: 100 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/terraform/ +--- + +# Grafana Terraform provider + +The [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest) provisions configuration management resources for Grafana. You can use it to manage resources such as dashboards, data sources, plugins, folders, organizations or alert notification channels. + +Use the following guides to get started using Terraform to manage your Grafana Cloud stack: + +- [Creating and managing a Grafana Cloud stack using Terraform](terraform-cloud-stack/) describes how to create a Grafana Cloud stack and add a data source and dashboard using [Terraform](https://www.terraform.io/). +- [Creating and managing dashboards using Terraform and GitHub Actions](dashboards-github-action/) describes how to create and manage multiple dashboards represented as JSON source code for Grafana using [Terraform](https://www.terraform.io/) and [GitHub Actions](https://github.com/features/actions). +- [Managing IRM on Grafana Cloud using Terraform](terraform-oncall/) describes how to connect an integration to Grafana IRM, configure escalation policies, and add your on-call schedule using [Terraform](https://www.terraform.io/). +- [Managing Fleet Management in Grafana Cloud using Terraform](https://grafana.com/docs/grafana-cloud/as-code/infrastructure-as-code/terraform/terraform-fleet-management/) describes how to create collectors and pipelines in Grafana Fleet Management using [Terraform](https://www.terraform.io/). +- [Managing Frontend Observability in Grafana Cloud using Terraform](https://grafana.com/docs/grafana-cloud/as-code/infrastructure-as-code/terraform/terraform-frontend-observability/) describes how to manage resources in Frontend Observability using [Terraform](https://www.terraform.io/). +- [Manage Cloud Provider Observability in Grafana Cloud using Terraform](terraform-cloud-provider-o11y/) describes how to manage Amazon CloudWatch and Microsoft Azure resources in Cloud Provider Observability using Terraform. +- [Manage Knowledge Graph in Grafana Cloud using Terraform](terraform-knowledge-graph/) describes how to create and manage notification alerts, suppressed assertions, custom model rules, log configurations, and threshold configurations in Grafana Cloud Knowledge Graph using [Terraform](https://www.terraform.io/). +- [Install plugins in Grafana Cloud using Terraform](terraform-plugins) describes how to install plugins in Grafana Cloud using [Terraform](https://www.terraform.io/). diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/dashboards-github-action.md b/docs/sources/as-code/infrastructure-as-code/terraform/dashboards-github-action.md new file mode 100644 index 00000000000..82f8d3db2ef --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/dashboards-github-action.md @@ -0,0 +1,250 @@ +--- +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Terraform + - GitHub Actions +title: Creating and managing dashboards using Terraform and GitHub Actions +weight: 110 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/terraform/dashboards-github-action/ +--- + +# Creating and managing dashboards using Terraform and GitHub Actions + +Learn how to create and manage multiple dashboards represented as JSON source code for Grafana using [Terraform](https://www.terraform.io/) and [GitHub Actions](https://github.com/features/actions). + +## Prerequisites + +Before you begin, you should have the following available: + +- A Grafana Cloud account, as shown in [Get started](/docs/grafana-cloud/get-started/) +- A [GitHub](https://github.com/) repository + +## Add Dashboards to a GitHub repository + +For this guide, we are adding dashboards for ElasticSearch, InfluxDB, and AWS EC2. You can use different dashboards according to your configured data sources. + +1. In your GitHub repository, create a folder named `dashboards` in the root directory. + +1. In the `dashboards` folder create three sub-folders. For this guide, we will create three sub-folders named `elasticsearch`, `influxdb`, and `aws`. + +1. Add dashboard JSON source code to each of the three sub-folders. + +## Terraform configuration for Grafana provider + +This Terraform configuration configures the [Grafana provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) to provide necessary authentication when creating folders and dashboards in the Grafana instance. + +1. Create a service account and token in the Grafana instance by following these steps: + 1. [Create a service account in Grafana](/docs/grafana-cloud/account-management/authentication-and-permissions/service-accounts/#create-a-service-account-in-grafana) + 1. [Add a token to a service account](/docs/grafana-cloud/account-management/authentication-and-permissions/service-accounts/#add-a-token-to-a-service-account-in-grafana) + +1. Create a file named `main.tf` in the Git root directory and add the following code block: + + ```terraform + terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = ">= 2.9.0" + } + } + } + + provider "grafana" { + alias = "cloud" + + url = "" + auth = "" + } + ``` + +1. Replace the following field values: + - `` with the URL of your Grafana instance, for example `"https://my-stack.grafana.net/"`. + - `` with a Service Account token from the Grafana instance. + +## Terraform configuration for folders + +This Terraform configuration creates three folders named `ElasticSearch`, `InfluxDB` and `AWS` in the Grafana instance using [grafana_folder (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/folder). + +Create a file named `folders.tf` in the Git root directory and add the following code block: + +```terraform +resource "grafana_folder" "ElasticSearch" { + provider = grafana.cloud + + title = "ElasticSearch" +} + +resource "grafana_folder" "InfluxDB" { + provider = grafana.cloud + + title = "InfluxDB" +} + +resource "grafana_folder" "AWS" { + provider = grafana.cloud + + title = "AWS" +} +``` + +## Terraform configuration for dashboards + +This Terraform configuration iterates through the Json files in the three folders (`elasticsearch`, `influxdb` and `aws`) you created in the GitHub repository and adds them to the respective folders in the Grafana instance using [grafana_dashboard (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/dashboard). + +For example, the dashboard represented as JSON source code in the `elasticsearch` folder in the GitHub repository will be created in the `ElasticSearch` folder in the Grafana instance. + +Create a file named `dashboards.tf` in the Git root directory and add the following code block: + +```terraform +resource "grafana_dashboard" "elasticsearch" { + provider = grafana.cloud + + for_each = fileset("${path.module}/dashboards/elasticsearch", "*.json") + config_json = file("${path.module}/dashboards/elasticsearch/${each.key}") + folder = grafana_folder.ElasticSearch.id +} + +resource "grafana_dashboard" "influxdb" { + provider = grafana.cloud + + for_each = fileset("${path.module}/dashboards/influxdb", "*.json") + config_json = file("${path.module}/dashboards/influxdb/${each.key}") + folder = grafana_folder.InfluxDB.id +} + +resource "grafana_dashboard" "aws" { + provider = grafana.cloud + + for_each = fileset("${path.module}/dashboards/aws", "*.json") + config_json = file("${path.module}/dashboards/aws/${each.key}") + folder = grafana_folder.AWS.id +} +``` + +## GitHub workflow for managing dashboards using Terraform + +This GitHub workflow consists of the following steps: + +- Using the [actions/checkout@v3](https://github.com/actions/checkout) action, The GitHub repository is checked out so that the GitHub workflow can access it. +- The Terraform CLI is installed on the GitHub runner using the [hashicorp/setup-terraform@v1](https://github.com/hashicorp/setup-terraform) action. +- `terraform init` is run as a bash command in the GitHub runner to initialize a working directory containing Terraform configuration files. +- `terraform fmt -check` is run as a bash command in the GitHub runner to check if the Terraform configuration files are properly formatted. If the Terraform configuration files are not properly formatted, the workflow will fail at this step. +- `terraform plan` is run as a bash command in the GitHub runner to preview the changes that Terraform will make. +- Using [mshick/add-pr-comment@v1](https://github.com/mshick/add-pr-comment) action, the preview from Terraform plan is posted as a comment on the pull request. This helps in reviewing the changes that Terraform will make before the pull request is merged. +- `terraform appy -auto-approve` is run as a bash command in the GitHub runner to apply the Terraform configuration files. `-auto-approve` flag is added to the command to skip interactive approval of plan before applying and make the workflow automated. + This step is run only when changes are committed to `main` branch. When a pull request is merged, the merge action creates a commit to the `main` branch which triggers the `terraform apply -auto-approve` step to execute. + +1. In your GitHub repository, create a folder named `.github` in the root directory . + +1. In the `.github` folder create a sub-folder named `workflows`. + +1. To add the GitHub workflow to your GitHub repository, create a file named `terraform.yml` in the `workflows` directory and add the following code block: + + ````yaml + name: Terraform + + on: + push: + branches: + - 'main' + pull_request: + + jobs: + terraform: + runs-on: ubuntu-latest + + steps: + # Checkout the repository to the GitHub Actions runner + - name: Checkout + uses: actions/checkout@v3 + + # Install the latest version of Terraform CLI + - name: Setup Terraform + uses: hashicorp/setup-terraform@v1 + + # Initialize a new or existing Terraform working directory by creating initial files, loading any remote state, downloading modules, etc. + - name: Terraform Init + run: terraform init + + # Checks that all Terraform configuration files adhere to a canonical format + - name: Terraform Format + run: terraform fmt -check + + # Previews the changes that Terraform will make + - name: Plan Terraform + id: plan + continue-on-error: true + run: terraform plan -input=false -no-color + + # Post the preview (terraform plan) from the previous step as a GitHub pull request comment + - name: Post Plan to GitHub PR + if: github.ref != 'refs/heads/main' + uses: mshick/add-pr-comment@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + repo-token-user-login: 'github-actions[bot]' + message: | + Applying: + + ``` + ${{ steps.plan.outputs.stdout }} + ``` + + # Applies the terraform configuration files when the branch is `main` + - name: Apply Terraform + if: github.ref == 'refs/heads/main' + id: apply + continue-on-error: true + run: | + terraform apply -auto-approve + ```` + +1. Commit the changes made to the `terraform.yml` in the previous step to the `main` branch in your GitHub repository. Once the changes are committed, The GitHub workflow you created should start to run automatically as the workflow we defined in the previous step runs when a pull request is created or when changes are committed to `main` branch. + +## Managing the Terraform state + +If you are not using a [Terraform backend](https://www.terraform.io/language/settings/backends/configuration) to store the `.tfstate` file, add the following code block to the end of the GitHub workflow file to make sure the Terraform state file is stored in Git. + +```yaml +- name: commit the terraform state + if: github.ref == 'refs/heads/main' + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Updating Terraform state + file_pattern: terraform.tfstate +``` + +When you run `terraform apply`,Terraform automatically manages and updates the `terraform.tfstate` file to store state about your infrastructure and configuration. +This step uses the [stefanzweifel/git-auto-commit-action@v4](https://github.com/stefanzweifel/git-auto-commit-action) action to auto-commit the `terraform.tfstate` file for changes made by the running the `terraform apply` step. + +{{< admonition type="note" >}} +The Terraform state file (terraform.tfstate) should not be stored in Git to avoid leakage of sensitive data. Instead, store Terraform state file using a remote backend like AWS S3 with proper RBAC. For more information, see [Terraform state](https://www.terraform.io/language/state). +{{< /admonition >}} + +## Validation + +Once the GitHub workflow run is successful, you should be able to verify the following: + +- `ElasticSearch`, `InfluxDB` and `AWS` folders are created in the Grafana instance. + + ![Folders in Dashboards](/media/docs/grafana-cloud/screenshot-folders-github-action-tf.png) + +- Dashboard represented as JSON source code from `elasticsearch` folder in GitHub are added under the `ElasticSearch` folder in the Grafana instance. + + ![ElasticSearch Folder](/media/docs/grafana-cloud/screenshot-elastic-folder-github-action-tf.png) + +- Dashboard source code from the `influxdb` folder in GitHub is added under the `InfluxDB` folder in the Grafana instance. + + ![InfluxDB Folder](/media/docs/grafana-cloud/screenshot-influxdb-folder-github-action-tf.png) + +- Dashboards from `aws` folder in GitHub are added under the `AWS` folder in the Grafana instance. + + ![AWS EC2 Folder](/media/docs/grafana-cloud/screenshots-aws-folder-github-action-tf.png) + +## Conclusion + +In this guide, you created a GitHub workflow using Terraform to manage dashboard source code. Using this workflow, the dashboards in the Grafana instance will always be synchronized with the JSON source code files for dashboards in GitHub. + +To learn more about managing Grafana Cloud using Terraform, see [Grafana provider's documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs). diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-cloud-provider-o11y.md b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-cloud-provider-o11y.md new file mode 100644 index 00000000000..dcf0f559f61 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-cloud-provider-o11y.md @@ -0,0 +1,115 @@ +--- +description: Learn how to manage Grafana Cloud Provider Observability resources in Grafana Cloud using Terraform +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Terraform + - Cloud Provider Observability +title: Manage Cloud Provider Observability in Grafana Cloud using Terraform +weight: 210 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/terraform/terraform-cloud-provider-o11y/ +--- + +# Manage Cloud Provider Observability in Grafana Cloud using Terraform + +Manage Cloud Provider Observability, including Amazon CloudWatch and Microsoft Azure resources, in Grafana Cloud using Terraform. +For more information on Cloud Provider Observability, refer to the [Cloud Provider Observability](/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/) documentation. + +## Before you begin + +Before you begin, you should have the following available: + +- A Grafana Cloud account + - For more information on setting up a Grafana Cloud account, refer to [Get started](/docs/grafana-cloud/get-started/). +- Terraform installed on your machine + - For more information on how to install Terraform, refer to the [Terraform install documentation](https://developer.hashicorp.com/terraform/install). +- Administrator permissions in your Grafana instance + - For more information on assigning Grafana RBAC roles, refer to [Assign RBAC roles](/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-control/assign-rbac-roles/). + +{{< admonition type="note" >}} +Save all of the following Terraform configuration files in the same directory. +{{< /admonition >}} + +## Configure authentication for the Grafana Provider + +The Grafana Provider is a logical abstraction of an upstream API that you can use to interact with Grafana Cloud resources. +You must configure it with the following information: + +- A Grafana Cloud access policy token that includes the permissions the provider needs to access the Grafana Cloud Provider API. +- A regional cloud provider API endpoint to establish which Grafana Cloud stack you are accessing. + +To configure authentication for the Grafana Provider: + +1. Create a Grafana Cloud access policy and token. + - To create an access policy for your organization, refer to the [Create an access policy for a stack steps](/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-policies/create-access-policies/#create-an-access-policy-for-a-stack) and use the following scopes listed for the supported Amazon CloudWatch or Microsoft Azure resources: + - Amazon CloudWatch + - Metrics scrape or resource metadata scrape + - `integration-management:read` + - `integration-management:write` + - `stacks:read` + - Metric streams + - `metrics:write` + - ALB access logs, logs with Lambda, or logs with Amazon Data Firehose + - `logs:write` + - Microsoft Azure + - Serverless metrics + - `integration-management:read` + - `integration-management:write` + - `stacks:read` + - Logs with Azure functions + - `logs:write` + +1. Obtain the regional Cloud Provider API endpoint. + - To obtain the regional Cloud provider API endpoint, use your access policy token and the following command to return a list of all of the Grafana stacks you own, along with their respective Cloud Provider API hostnames: + ```bash + curl -sH "Authorization: Bearer " "https://grafana.com/api/instances" | \ + jq '[.items[]|{stackName: .slug, clusterName:.clusterSlug, cloudProviderAPIURL: "https://cloud-provider-api-\(.clusterSlug).grafana.net"}]' + ``` +1. Create a file named `cloud-provider.tf` and add the following code block: + + ```tf + terraform { + required_providers { + grafana = { + source = "grafana/grafana" + } + } + } + + provider "grafana" { + cloud_api_url = "" + cloud_access_policy_token = "" + } + ``` + +1. Create a `variables.tf` file and paste the `` and `}} +All of the following Terraform configuration files should be saved in the same directory. +{{< /admonition >}} + +## Create a Cloud stack + +1. Create a Terraform configuration file. + + This Terraform configuration will create a Grafana Cloud stack and a second token needed for your Grafana instance. + + Create a file named `cloud-stack.tf` and add the following: + + ```terraform + terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = ">= 2.9.0" + } + } + } + // Step 1: Create a stack + provider "grafana" { + alias = "cloud" + cloud_access_policy_token = "" + } + + + resource "grafana_cloud_stack" "my_stack" { + provider = grafana.cloud + + name = "" + slug = "" + region_slug = "" # Example "us","eu" etc + delete_protection = true + } + + // Step 2: Create a service account and key for the stack + resource "grafana_cloud_stack_service_account" "cloud_sa" { + provider = grafana.cloud + stack_slug = grafana_cloud_stack.my_stack.slug + + name = "" + role = "Admin" + is_disabled = false + } + + resource "grafana_cloud_stack_service_account_token" "cloud_sa" { + provider = grafana.cloud + stack_slug = grafana_cloud_stack.my_stack.slug + + name = "terraform serviceaccount key" + service_account_id = grafana_cloud_stack_service_account.cloud_sa.id + } + + // Step 3: Create resources within the stack + provider "grafana" { + alias = "my_stack" + + url = grafana_cloud_stack.my_stack.url + auth = grafana_cloud_stack_service_account_token.cloud_sa.key + } + resource "grafana_folder" "my_folder" { + provider = grafana.my_stack + + title = "Test Folder" + } + ``` + +1. Replace the following field values: + - `` with your Grafana Cloud Access Policy Token. + To create a new one, refer [Grafana Cloud Access Policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) + Add all stacks to the realms list. + The scopes needed for the example are: + - dashboards:read + - orgs:read + - stack-dashboards:read + - stacks:read + - dashboards:write + - orgs:write + - stack-dashboards:write + - stacks:write + - stack-service-accounts:write + - dashboards:delete + - stack-dashboards:delete + - stacks:delete + - `` with the name of your stack. + - `` with the region in which you want to create the stack. For example `us`, `eu`. + - `` with a name for the serviceaccount that will be created to use for operations within the stack/instance. + +The first provider block, `grafana.cloud`, uses the Cloud Access Policy Token from the Cloud Portal and is referenced as a parameter when creating the Cloud stack and the token in the Grafana instance to provide the necessary authentication. + +The second provider block, `grafana.my_stack`, is referenced as a parameter when creating resources inside the Grafana instance. + +## Add a data source + +This guide uses the InfluxDB data source. The required arguments for [grafana_data_source (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/data_source) vary depending on the type of data source you select. + +1. Create a file named `datasource.tf` and add the following: + + ```terraform + resource "grafana_data_source" "" { + provider = grafana.my_stack + + type = "influxdb" + name = "" + url = "" + username = "" + password = "" + database_name = "" + } + ``` + +1. Replace the following field values: + - `` with the name of the data source to be added in Grafana. + - `` with URL of your data source. + - `` with the username for authenticating with your data source. + - `` with password for authenticating with your data source. + - `` with name of your database. + +## Add a folder + +This Terraform configuration creates a folder in your Grafana instance using [grafana_folder (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/folder). + +1. Create a file named `folder.tf` and add the following: + + ```terraform + resource "grafana_folder" "" { + provider = grafana.my_stack + + title = "" + } + ``` + +1. Replace the following field value: + - `` with a name for the folder. + +## Add a dashboard to the folder + +This Terraform configuration creates a dashboard inside the folder created above in your Grafana instance using [grafana_dashboard (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/dashboard). + +1. Create a file named `dashboard.tf` and add the following: + + ```terraform + # Using a JSON file + resource "grafana_dashboard" "dashboard" { + provider = grafana.my_stack + + config_json = file(".json") + folder = grafana_folder..id + } + ``` + +1. Replace the following field value: + - `` with the name of the JSON file that has the source code for the dashboard. + + The dashboard is represented by its JSON source code and referenced in the `config_json` parameter. + +## Apply the Terraform configuration + +In a terminal, run the following commands from the directory where all of the configuration files are located. + +1. Initialize a working directory containing Terraform configuration files. + + ```shell + terraform init + ``` + +1. Preview the changes that Terraform will make. + + ```shell + terraform plan + ``` + +1. Apply the configuration files. + + ```shell + terraform apply + ``` + +## Validation + +Once you apply the changes in the Terraform configurations, you should be able to verify the following: + +- The new Grafana stack is created and visible in the Cloud Portal + + ![Cloud Portal](/static/img/docs/grafana-cloud/terraform/cloud_portal_tf.png) + +- A service account key token is added in your Grafana instance. In the following image, the service account key token named "terraform serviceaccount key" was added by the [grafana_cloud_stack_service_account_token (Resource)](#create-a-cloud-stack). + + ![API Key](/media/docs/grafana-cloud/screenshot-api_key_tf.png) + +- A new data source (InfluxDB in this example) is visible in the grafana instance. + + ![InfluxDB data source](/media/docs/grafana-cloud/screenshot-influxdb_datasource_tf.png) + +- A new folder in Grafana. In the following image, a folder named "Demos" was added by the [grafana_folder (Resource)](./#add-a-folder). + + ![Folder](/media/docs/grafana-cloud/screenshot-folder_tf.png) + +- A new dashboard in the Grafana instance. In the following image a dashboard named "InfluxDB Cloud Demos" was created inside the "Demos" folder. + + ![InfluxDB dashboard](/static/img/docs/grafana-cloud/terraform/influxdb_dashboard_tf.png) + +## Conclusion + +In this guide, you created a Grafana Cloud stack along with a data source, folder, and dashboard imported from a JSON file using Terraform. + +To learn more about managing Grafana Cloud using Terraform, see [Grafana provider's documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs). diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-fleet-management.md b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-fleet-management.md new file mode 100644 index 00000000000..d8799db6093 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-fleet-management.md @@ -0,0 +1,384 @@ +--- +description: Learn how to create Grafana Fleet Management collectors and pipelines in Grafana Cloud using Terraform +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Terraform + - Fleet Management + - Alloy +labels: + products: + - cloud +title: Manage Fleet Management in Grafana Cloud using Terraform +weight: 200 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/terraform/terraform-fleet-management/ +--- + +# Manage Fleet Management in Grafana Cloud using Terraform + +Learn how to create [Grafana Fleet Management](https://grafana.com/docs/grafana-cloud/send-data/fleet-management/) collectors and pipelines in Grafana Cloud using Terraform. +This guide shows you how to create an access policy and a token for Fleet Management and [Grafana Alloy](https://grafana.com/docs/alloy/latest/), a collector with remote attributes, and a pipeline for profiling Alloy. + +## Before you begin + +Before you begin, you should have the following available: + +- A Grafana Cloud account, as shown in [Get started](https://grafana.com/docs/grafana-cloud/get-started/) +- [Terraform](https://www.terraform.io/downloads) installed on your machine +- [Alloy](https://grafana.com/docs/alloy/latest/set-up/install/) installed on your machine +- Administrator permissions in your Grafana instance + +{{< admonition type="note" >}} +All of the following Terraform configuration files should be saved in the same directory. +{{< /admonition >}} + +## Configure a provider for Grafana Cloud + +This Terraform configuration configures the [Grafana provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) to provide necessary authentication when interacting with the Cloud API. +The [`grafana_cloud_stack` (Data Source)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/data-sources/cloud_stack) is used to retrieve the user ID and URL details of your instance. + +1. Create a Grafana Cloud access policy and token. + To create a new one, refer to [Grafana Cloud Access Policies](https://grafana.com/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-policies/). + Add your stack to the realms list. + The scopes needed for the examples in this guide are: + - `accesspolicies:read` + - `accesspolicies:write` + - `accesspolicies:delete` + - `stacks:read` + +1. Create a file named `cloud-provider.tf` and add the following code block: + + ```terraform + terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = ">= 3.19.0" + } + } + } + + provider "grafana" { + alias = "cloud" + + cloud_access_policy_token = "" + } + + data "grafana_cloud_stack" "stack" { + provider = grafana.cloud + + slug = "" + } + ``` + +1. Replace the following field values: + - `` with the access policy token you created in the first step + - `` with your stack slug, which is the subdomain where your Grafana Cloud instance is available: `https://.grafana.net` + +## Create an access policy and token for Fleet Management + +This Terraform configuration creates the following: + +- An access policy named `fleet-management-policy` with `fleet-management:read` and `fleet-management:write` scopes, using [`grafana_cloud_access_policy` (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/cloud_access_policy) +- A token named `fleet-management-token`, using [`grafana_cloud_access_policy_token` (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/cloud_access_policy_token) + +1. Create a file named `fm-access-policy.tf` and add the following code block: + + ```terraform + resource "grafana_cloud_access_policy" "fm_policy" { + provider = grafana.cloud + + name = "fleet-management-policy" + region = data.grafana_cloud_stack.stack.region_slug + + scopes = [ + "fleet-management:read", + "fleet-management:write" + ] + + realm { + type = "stack" + identifier = data.grafana_cloud_stack.stack.id + } + } + + resource "grafana_cloud_access_policy_token" "fm_token" { + provider = grafana.cloud + + name = "fleet-management-token" + region = grafana_cloud_access_policy.fm_policy.region + access_policy_id = grafana_cloud_access_policy.fm_policy.policy_id + } + ``` + +## Configure a provider for Fleet Management + +This Terraform configuration configures the [Grafana provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) to provide necessary authentication when interacting with the Fleet Management API. + +1. Create a file named `fm-provider.tf` and add the following code block: + + ```terraform + locals { + fm_id = data.grafana_cloud_stack.stack.fleet_management_user_id + fm_token = grafana_cloud_access_policy_token.fm_token.token + fm_url = data.grafana_cloud_stack.stack.fleet_management_url + } + + provider "grafana" { + alias = "fm" + + fleet_management_auth = "${local.fm_id}:${local.fm_token}" + fleet_management_url = local.fm_url + } + ``` + +## Create a Fleet Management collector + +This Terraform configuration creates a collector with a remote attribute, using [`grafana_fleet_management_collector` (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/fleet_management_collector). + +This configuration only preregisters the collector. +You must complete the [Run Alloy](#run-alloy) step for the collector to register with Fleet Management and be assigned remote attributes. + +1. Create a file named `fm-collector.tf` and add the following code block: + + ```terraform + resource "grafana_fleet_management_collector" "fm_collector" { + provider = grafana.fm + + id = "prod_collector" + remote_attributes = { + "env" = "PROD" + } + enabled = true + } + ``` + +## Create a Fleet Management pipeline + +This Terraform configuration creates a pipeline for Alloy profiling with a matcher for the collector declared in the previous step, using [`grafana_fleet_management_pipeline` (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/fleet_management_pipeline). +The pipeline writes the profiles to [Grafana Cloud Profiles](https://grafana.com/docs/grafana-cloud/monitor-applications/profiles/). + +1. Create a file named `profiling.alloy.tftpl` and add the following content: + + ```alloy + // This pipeline scrapes pprof Go profiles from Alloy and sends them to Pyroscope. + // + // It requires the following environment variables to be set where Alloy is running: + // Required: + // * GCLOUD_RW_API_KEY: The Grafana Cloud API key with write access to Pyroscope. + // Optional: + // * ALLOY_ADDRESS: The address Alloy listens on. Defaults to 127.0.0.1:12345. + pyroscope.scrape "alloy" { + targets = [ + { + "__address__" = coalesce( + sys.env("ALLOY_ADDRESS"), + "127.0.0.1:12345", + ), + "service_name" = "alloy", + }, + ] + forward_to = [pyroscope.write.grafana_cloud.receiver] + + profiling_config { + profile.process_cpu { + enabled = true + } + + profile.memory { + enabled = true + } + + profile.mutex { + enabled = true + } + + profile.block { + enabled = true + } + + profile.goroutine { + enabled = true + } + } + } + + pyroscope.write "grafana_cloud" { + endpoint { + url = "${profiles_url}" + + basic_auth { + username = "${profiles_id}" + password = sys.env("GCLOUD_RW_API_KEY") + } + } + } + ``` + +1. Create a file named `fm-pipeline.tf` and add the following code block: + + ```terraform + locals { + profiles_id = data.grafana_cloud_stack.stack.profiles_user_id + profiles_url = data.grafana_cloud_stack.stack.profiles_url + } + + resource "grafana_fleet_management_pipeline" "pipeline" { + provider = grafana.fm + + name = "profiling" + contents = templatefile( + "profiling.alloy.tftpl", + { + profiles_id = local.profiles_id, + profiles_url = local.profiles_url, + }, + ) + matchers = [ + "env=\"PROD\"" + ] + enabled = true + } + ``` + +## Create an access policy and token for Alloy + +This Terraform configuration creates the following: + +- An access policy named `alloy-policy` with `set:alloy-data-write` scope, using [`grafana_cloud_access_policy` (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/cloud_access_policy) +- A token named `alloy-token`, using [`grafana_cloud_access_policy_token` (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/cloud_access_policy_token) + +1. Create a file named `alloy-access-policy.tf` and add the following code block: + + ```terraform + resource "grafana_cloud_access_policy" "alloy_policy" { + provider = grafana.cloud + + name = "alloy-policy" + region = data.grafana_cloud_stack.stack.region_slug + + scopes = [ + "set:alloy-data-write" + ] + + realm { + type = "stack" + identifier = data.grafana_cloud_stack.stack.id + } + } + + resource "grafana_cloud_access_policy_token" "alloy_token" { + provider = grafana.cloud + + name = "alloy-token" + region = grafana_cloud_access_policy.alloy_policy.region + access_policy_id = grafana_cloud_access_policy.alloy_policy.policy_id + } + + output "alloy_token" { + value = grafana_cloud_access_policy_token.alloy_token.token + sensitive = true + } + ``` + +## Create a configuration file for Alloy + +This Terraform configuration creates an Alloy configuration file with the [`remotecfg` block](https://grafana.com/docs/grafana-cloud/send-data/alloy/reference/config-blocks/remotecfg/) for Fleet Management, using [`local_file` (Resource)](https://registry.terraform.io/providers/hashicorp/local/latest/docs/resources/file). + +1. Create a file named `config.alloy.tftpl` and add the following content: + + ```alloy + remotecfg { + id = "${collector_id}" + url = "${fm_url}" + poll_frequency = "60s" + + basic_auth { + username = "${fm_id}" + password = sys.env("GCLOUD_RW_API_KEY") + } + } + ``` + +1. Create a file named `alloy-config.tf` and add the following code block: + + ```terraform + resource "local_file" "alloy_config" { + filename = "" + content = templatefile( + "config.alloy.tftpl", + { + collector_id = "prod_collector", + fm_id = local.fm_id, + fm_url = local.fm_url, + }, + ) + directory_permission = "0644" + file_permission = "0644" + } + ``` + +1. Replace the following field values: + - `` with the path the Alloy configuration file should be written to, for example `config.alloy` + +## Apply the Terraform configuration + +In a terminal, run the following commands from the directory where all of the configuration files are located. + +1. Initialize a working directory containing Terraform configuration files: + + ```shell + terraform init + ``` + +1. Preview the Terraform changes: + + ```shell + terraform plan + ``` + +1. Apply the configuration: + + ```shell + terraform apply + ``` + +## Run Alloy + +To learn how to start or restart Alloy, refer to [Run Grafana Alloy](https://grafana.com/docs/alloy/latest/set-up/run/). + +{{< admonition type="note" >}} +The variable `GCLOUD_RW_API_KEY` must be set in the environment where Alloy is running for the remote configuration in this example to work. +{{< /admonition >}} + +1. Run the following command to view the Alloy token: + + ```shell + terraform output -raw alloy_token + ``` + +1. Set the environment variable `GCLOUD_RW_API_KEY` to the value from the first step. +1. Run Alloy. + +## Validation + +After you apply the changes in the Terraform configurations and run Alloy, you should be able to verify the following: + +- A collector is added to the Fleet Management **Inventory tab**: + + {{< figure alt="The Inventory screen in the Fleet Management interface in Grafana Cloud which shows that a new collector called `prod_collector` is registered with attribute `env=PROD`, has a healthy status, and was last modified a few seconds ago." src="/media/docs/fleet-management/screenshot-fleet-management-terraform-validate-collector.png" >}} + +- A pipeline is added to the Fleet Management **Remote configuration tab**: + + {{< figure alt="The Remote configuration screen in the Fleet Management interface in Grafana Cloud which shows that a new configuration pipeline called `profiling` is active and was last modified a few seconds ago." src="/media/docs/fleet-management/screenshot-fleet-management-terraform-validate-pipeline.png" >}} + +- Alloy profiles are being exported to Grafana Cloud Profiles: + + {{< figure alt="A dashboard in Grafana Cloud which shows Alloy profiling data, including graphs for CPU and memory." src="/media/docs/fleet-management/screenshot-fleet-management-terraform-validate-profiles.png" >}} + +## Conclusion + +In this guide, you created an access policy and a token for Fleet Management and Alloy, a collector with remote attributes, and a pipeline for profiling Alloy, all using Terraform. + +To learn more about managing Grafana Cloud using Terraform, refer to [Grafana provider's documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs). diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-frontend-observability.md b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-frontend-observability.md new file mode 100644 index 00000000000..b88557c56f6 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-frontend-observability.md @@ -0,0 +1,105 @@ +--- +description: Learn how to manage Grafana Frontend Observability resources in Grafana Cloud using Terraform +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Terraform + - Frontend Observability +title: Manage Frontend Observability in Grafana Cloud with Terraform +weight: 200 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/terraform/terraform-frontend-observability/ +--- + +# Manage Frontend Observability in Grafana Cloud with Terraform + +Learn how to use Terraform to manage [Grafana Frontend Observability](https://grafana.com/docs/grafana-cloud/frontend-observability/) resources, such as your applications. +This guide shows you how to create an access policy and a token for Frontend Observability so that you can connect to the Frontend Observability API. + +## Before you begin + +Before you begin, you should have the following available: + +- A Grafana Cloud account, as shown in [Get started](https://grafana.com/docs/grafana-cloud/get-started/) +- [Terraform](https://www.terraform.io/downloads) installed on your machine +- Administrator permissions in your Grafana instance + +{{< admonition type="note" >}} +All of the following Terraform configuration files should be saved in the same directory. +{{< /admonition >}} + +## Configure a provider for Grafana Cloud + +This Terraform configuration configures the [Grafana provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) to provide necessary authentication when interacting with the Cloud API. +The [`grafana_cloud_stack` (Data Source)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/data-sources/cloud_stack) is used to retrieve the details of your instance. + +1. Create a Grafana Cloud access policy and token. + To create a new one, refer to [Grafana Cloud Access Policies](https://grafana.com/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-policies/). + Add your stack to the realms list. + The scopes needed for the examples in this guide are: + - `accesspolicies:read` + - `accesspolicies:write` + - `accesspolicies:delete` + - `dashboards:read` + - `dashboards:write` + - `dashboards:delete` + - `orgs:read` + - `orgs:write` + - `stacks:read` + - `stacks:write` + - `stacks:delete` + - `stack-dashboards:read` + - `stack-dashboards:write` + - `stack-dashboards:delete` + - `stack-service-accounts:write` + +1. Create a file named `cloud-provider.tf` and add the following code block: + + ```terraform + terraform { + required_providers { + grafana = { + source = "grafana/grafana" + } + } + } + + provider "grafana" { + alias = "cloud" + + cloud_access_policy_token = "" + } + + data "grafana_cloud_stack" "stack" { + provider = grafana.cloud + + slug = "" + } + ``` + +1. Replace the following field values: + - `` with the access policy token you created in the first step + - `` with your stack slug, which is the subdomain where your Grafana Cloud instance is available: `https://.grafana.net` + +## Create an access policy and token for Frontend Observability + +You must create a Terraform configuration with the following: + +- An access policy with `frontend-observability:read`, `frontend-observability:write`, and `frontend-observability:delete` scopes, using [`grafana_cloud_access_policy` (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/cloud_access_policy) +- A token named `frontend_o11y_api_access_token`, using [`grafana_cloud_access_policy_token` (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/cloud_access_policy_token) + +## Configure the provider to use the Frontend Observability API + +After you have created the token, you can configure the provider as follows: + +```terraform +provider "grafana" { + frontend_o11y_api_access_token = "" +} +``` + +## Conclusion + +In this guide, you created an access policy and a token for Frontend Observability using Terraform. + +To learn more about managing Grafana Cloud using Terraform, refer to [Grafana provider's documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs). diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/_index.md b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/_index.md new file mode 100644 index 00000000000..d635bf41a78 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/_index.md @@ -0,0 +1,73 @@ +--- +cards: + items: + - description: Learn how to set up Terraform provider and configure your environment for managing Knowledge Graph resources. + height: 24 + href: ./getting-started/ + title: Get started with Terraform + - description: Configure notification alerts to manage how alerts are processed and routed in your Knowledge Graph. + height: 24 + href: ./notification-alerts/ + title: Notification alerts + - description: Define suppression rules to temporarily disable specific alerts during maintenance windows or testing. + height: 24 + href: ./suppressed-assertions/ + title: Suppressed assertions + - description: Create custom entity models and define how entities are discovered based on Prometheus queries. + height: 24 + href: ./custom-model-rules/ + title: Custom model rules + - description: Configure log data correlation with entities using data source mappings and filtering options. + height: 24 + href: ./log-configurations/ + title: Log configurations + - description: Set custom thresholds for request, resource, and health assertions to monitor your services. + height: 24 + href: ./thresholds/ + title: Thresholds + - description: Configure knowledge graph SLOs with entity-centric monitoring and RCA workbench integration for root cause analysis. + height: 24 + href: ./knowledge-graph-slo/ + title: Knowledge graph SLOs + title_class: pt-0 lh-1 +description: Manage Grafana Cloud Knowledge Graph using Terraform +hero: + description: Use Terraform to manage Grafana Cloud Knowledge Graph resources as code. Configure notification alerts, suppressed assertions, custom model rules, log configurations, and threshold configurations using infrastructure as code best practices. + level: 1 + title: Manage Knowledge Graph using Terraform +menuTitle: Manage Knowledge Graph in Grafana Cloud using Terraform +title: Manage Knowledge Graph in Grafana Cloud using Terraform +weight: 130 +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Terraform + - Knowledge Graph + - Alert Configuration + - Suppressed Assertions + - Custom Model Rules + - Log Configuration + - Threshold Configuration +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/ +--- + +{{< docs/hero-simple key="hero" >}} + +--- + +## Overview + +Terraform enables you to manage [Grafana Cloud Knowledge Graph](/docs/grafana-cloud/knowledge-graph/) resources using infrastructure as code. With Terraform, you can define, version control, and deploy Knowledge Graph configurations including alert rules, suppression policies, entity models, log correlations, and thresholds. + +## Explore + +{{< card-grid key="cards" type="simple" >}} + +--- + +## Related resources + +- [Grafana Terraform Provider Documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs) +- [Knowledge Graph Documentation](/docs/grafana-cloud/knowledge-graph/) +- [Terraform Best Practices](https://www.terraform.io/docs/cloud/guides/recommended-practices/index.html) diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/custom-model-rules.md b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/custom-model-rules.md new file mode 100644 index 00000000000..f7020ad8128 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/custom-model-rules.md @@ -0,0 +1,431 @@ +--- +description: Define custom entity models for Knowledge Graph using Terraform +menuTitle: Custom model rules +title: Create custom model rules using Terraform +weight: 400 +keywords: + - Terraform + - Knowledge Graph + - Custom Model Rules + - Entity Models + - Prometheus +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/custom-model-rules/ +--- + +# Create custom model rules using Terraform + +Custom model rules in [Knowledge Graph](/docs/grafana-cloud/knowledge-graph/) allow you to define how entities are discovered and modeled based on Prometheus queries. These rules enable you to create custom entity types, define their relationships, and specify how they should be enriched with additional data. + +For information about managing entities and relations in the Knowledge Graph UI, refer to [Manage entities and relations](/docs/grafana-cloud/knowledge-graph/configure/manage-entities-relations/). + +## Basic custom model rules + +Create a file named `custom-model-rules.tf` and add the following: + +```terraform +# Basic custom model rule for services +resource "grafana_asserts_custom_model_rules" "basic_service" { + provider = grafana.asserts + + name = "basic-service-model" + + rules { + entity { + type = "Service" + name = "service" + + defined_by { + query = "up{job!=''}" + label_values = { + service = "job" + } + literals = { + _source = "up_query" + } + } + } + } +} +``` + +## Advanced service model with scope and lookup + +Define service entities with environment scoping and relationship mappings: + +```terraform +# Advanced service model with environment scoping +resource "grafana_asserts_custom_model_rules" "advanced_service" { + provider = grafana.asserts + + name = "advanced-service-model" + + rules { + entity { + type = "Service" + name = "workload | service | job" + + scope = { + namespace = "namespace" + env = "asserts_env" + site = "asserts_site" + } + + lookup = { + workload = "workload | deployment | statefulset | daemonset | replicaset" + service = "service" + job = "job" + proxy_job = "job" + } + + defined_by { + query = "up{job!='', asserts_env!=''}" + label_values = { + service = "service" + job = "job" + workload = "workload" + namespace = "namespace" + } + literals = { + _source = "up_with_workload" + } + } + + defined_by { + query = "up{job='maintenance'}" + disabled = true + } + } + } +} +``` + +## Multi-entity model configuration + +Define multiple entity types in a single configuration: + +```terraform +# Multiple entity types in a single model +resource "grafana_asserts_custom_model_rules" "multi_entity" { + provider = grafana.asserts + + name = "kubernetes-entities" + + rules { + # Service entity + entity { + type = "Service" + name = "service" + + scope = { + namespace = "namespace" + cluster = "cluster" + } + + defined_by { + query = "up{service!=''}" + label_values = { + service = "service" + namespace = "namespace" + cluster = "cluster" + } + } + } + + # Pod entity + entity { + type = "Pod" + name = "Pod" + + scope = { + namespace = "namespace" + cluster = "cluster" + } + + lookup = { + service = "service" + workload = "workload" + } + + defined_by { + query = "kube_pod_info{pod!=''}" + label_values = { + Pod = "pod" + namespace = "namespace" + cluster = "cluster" + service = "service" + } + literals = { + _entity_type = "Pod" + } + } + } + + # Namespace entity + entity { + type = "Namespace" + name = "namespace" + + scope = { + cluster = "cluster" + } + + defined_by { + query = "kube_namespace_status_phase{namespace!=''}" + label_values = { + namespace = "namespace" + cluster = "cluster" + } + } + } + } +} +``` + +## Complex entity with enrichment + +Create service entities with multiple data sources and enrichment: + +```terraform +# Service entity with enrichment from multiple sources +resource "grafana_asserts_custom_model_rules" "enriched_service" { + provider = grafana.asserts + + name = "enriched-service-model" + + rules { + entity { + type = "Service" + name = "service" + + enriched_by = [ + "prometheus_metrics", + "kubernetes_metadata", + "application_logs" + ] + + scope = { + environment = "asserts_env" + region = "asserts_site" + team = "team" + } + + lookup = { + deployment = "workload" + Pod = "pod" + container = "container" + } + + # Primary definition from service up metrics + defined_by { + query = "up{service!='', asserts_env!=''}" + label_values = { + service = "service" + environment = "asserts_env" + region = "asserts_site" + team = "team" + } + literals = { + _primary_source = "service_up" + } + } + + # Secondary definition from application metrics + defined_by { + query = "http_requests_total{service!=''}" + label_values = { + service = "service" + environment = "environment" + version = "version" + } + literals = { + _secondary_source = "http_metrics" + } + } + + # Disabled definition for testing + defined_by { + query = "test_metric{service!=''}" + disabled = true + } + } + } +} +``` + +## Database and infrastructure entities + +Define database and infrastructure entity models: + +```terraform +# Database and infrastructure entity models +resource "grafana_asserts_custom_model_rules" "infrastructure" { + provider = grafana.asserts + + name = "infrastructure-entities" + + rules { + # Database entity + entity { + type = "Database" + name = "database_instance" + + scope = { + environment = "env" + region = "region" + } + + lookup = { + host = "instance" + port = "port" + db_name = "database" + } + + defined_by { + query = "mysql_up{instance!=''}" + label_values = { + database_instance = "instance" + database = "database" + env = "environment" + region = "region" + } + literals = { + _db_type = "mysql" + } + metric_value = "1" + } + + defined_by { + query = "postgres_up{instance!=''}" + label_values = { + database_instance = "instance" + database = "datname" + env = "environment" + } + literals = { + _db_type = "postgresql" + } + } + } + + # Load balancer entity + entity { + type = "LoadBalancer" + name = "lb_instance" + + scope = { + environment = "env" + } + + defined_by { + query = "haproxy_up{proxy!=''}" + label_values = { + lb_instance = "instance" + proxy = "proxy" + env = "environment" + } + literals = { + _lb_type = "haproxy" + } + } + } + } +} +``` + +## Resource reference + +### `grafana_asserts_custom_model_rules` + +Manage Knowledge Graph custom model rules through the Grafana API. This resource allows you to define custom entity models based on Prometheus queries with advanced mapping and enrichment capabilities. + +#### Arguments + +| Name | Type | Required | Description | +| ------- | -------------- | -------- | -------------------------------------------------------------------------------------------------------- | +| `name` | `string` | Yes | The name of the custom model rules. This field is immutable and forces recreation if changed. | +| `rules` | `list(object)` | Yes | The rules configuration containing entity definitions. Refer to [rules block](#rules-block) for details. | + +#### Rules block + +Each `rules` block supports the following: + +| Name | Type | Required | Description | +| -------- | -------------- | -------- | ------------------------------------------------------------------------------- | +| `entity` | `list(object)` | Yes | List of entity definitions. Refer to [entity block](#entity-block) for details. | + +#### Entity block + +Each `entity` block supports the following: + +| Name | Type | Required | Description | +| ------------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------ | +| `type` | `string` | Yes | The type of the entity (for example, Service, Pod, Namespace). | +| `name` | `string` | Yes | The name pattern for the entity. Can include pipe-separated alternatives. | +| `defined_by` | `list(object)` | Yes | List of queries that define this entity. Refer to [`defined_by` block](#defined_by-block) for details. | +| `disabled` | `bool` | No | Whether this entity is disabled. Defaults to `false`. | +| `enriched_by` | `list(string)` | No | List of enrichment sources for the entity. | +| `lookup` | `map(string)` | No | Lookup mappings for the entity to relate different label names. | +| `scope` | `map(string)` | No | Scope labels that define the boundaries of this entity type. | + +#### `defined_by` block + +Each `defined_by` block supports the following: + +| Name | Type | Required | Description | +| -------------- | ------------- | -------- | ------------------------------------------------------------------------- | +| `query` | `string` | Yes | The Prometheus query that defines this entity. | +| `disabled` | `bool` | No | Whether this query is disabled. Defaults to `false`. | +| `label_values` | `map(string)` | No | Label value mappings for extracting entity attributes from query results. | +| `literals` | `map(string)` | No | Literal value mappings for adding static attributes to entities. | +| `metric_value` | `string` | No | Metric value to use from the query result. | + +{{< admonition type="note" >}} +When `disabled = true` is set for a `defined_by` query, only the `query` field is used for matching. All other fields in the block are ignored. +{{< /admonition >}} + +## Best practices + +### Entity models + +- Design your entity models to reflect your actual infrastructure and application architecture +- Use descriptive names for custom model rules that indicate their purpose and scope +- Start with basic entity definitions and gradually add complexity as needed +- Define clear entity scopes using the `scope` parameter to organize entities by environment, region, or team + +### Query design and performance + +- Write efficient Prometheus queries that don't overload your monitoring system +- Test your Prometheus queries independently before using them in model rules +- Use specific label filters to reduce the scope of your queries where possible +- Consider the cardinality implications of your entity definitions +- Use the `disabled` flag to temporarily disable problematic queries during debugging + +### Relationships and enrichment + +- Use `lookup` mappings to establish relationships between different entity types +- Leverage `enriched_by` to specify additional data sources for entity enrichment +- Map Prometheus labels to entity attributes using clear and descriptive names +- Use meaningful `literals` to add static metadata that helps with entity identification + +### Label and attribute management + +- Establish consistent labeling conventions across your infrastructure +- Use `label_values` to extract dynamic attributes from your metrics +- Document the meaning and expected values of custom literals +- Ensure label names match across different entity definitions for proper relationship discovery + +## Validation + +After applying the Terraform configuration, verify that: + +- Custom model rules are applied in your Knowledge Graph instance +- Entities are being discovered according to your defined queries +- Entity relationships and enrichment are working as expected +- Entity graphs display the correct entity types and connections +- Queries perform well without causing excessive load + +## Related documentation + +- [Manage entities and relations in Knowledge Graph](/docs/grafana-cloud/knowledge-graph/configure/manage-entities-relations/) +- [Get started with Terraform for Knowledge Graph](../getting-started/) +- [Knowledge graph basics](/docs/grafana-cloud/knowledge-graph/knowledge-graph-basics/) diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/getting-started.md b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/getting-started.md new file mode 100644 index 00000000000..e91c45a777b --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/getting-started.md @@ -0,0 +1,140 @@ +--- +description: Learn how to configure Terraform to manage Knowledge Graph resources +menuTitle: Get started +title: Get started with Terraform for Knowledge Graph +weight: 100 +keywords: + - Terraform + - Knowledge Graph + - Provider Setup + - Getting Started +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/getting-started/ +--- + +# Get started with Terraform for Knowledge Graph + +Learn how to configure Terraform to manage [Grafana Cloud Knowledge Graph](/docs/grafana-cloud/knowledge-graph/) resources. This guide walks you through setting up the Grafana Terraform provider and preparing your environment. + +## Before you begin + +Before you begin, ensure you have the following: + +- A Grafana Cloud account, as shown in [Get started](/docs/grafana-cloud/get-started/) +- [Terraform](https://www.terraform.io/downloads) installed on your machine +- Administrator permissions in your Grafana instance +- [Knowledge Graph enabled](/docs/grafana-cloud/knowledge-graph/get-started/) in your Grafana Cloud stack + +{{< admonition type="note" >}} +All Terraform configuration files should be saved in the same directory. +{{< /admonition >}} + +## Configure the Grafana provider + +This Terraform configuration sets up the [Grafana provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) to provide necessary authentication when managing knowledge graph resources. + +You can reuse a similar setup to the one described in [Creating and managing a Grafana Cloud stack using Terraform](/docs/grafana-cloud/as-code/infrastructure-as-code/terraform/terraform-cloud-stack/) to set up a service account and a token. + +### Steps + +1. Create a Service account and token in Grafana. + + To create a new one, refer to [Service account tokens](/docs/grafana/latest/administration/service-accounts/#service-account-tokens). + +1. Create a file named `main.tf` and add the following: + + ```terraform + terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = ">= 2.9.0" + } + } + } + + provider "grafana" { + alias = "asserts" + + url = "" + auth = "" + stack_id = "" + } + ``` + +1. Replace the following field values: + - `` with the URL of your Grafana stack (for example, `https://my-stack.grafana.net/`) + - `` with the service account token that you created + - `` with your Grafana Cloud stack ID + +{{< admonition type="note" >}} +The `stack_id` parameter is required for Knowledge Graph resources to identify the stack where the resources belong. +{{< /admonition >}} + +## Apply Terraform configurations + +After creating your Terraform configuration files, apply them using the following commands: + +1. Initialize a working directory containing Terraform configuration files: + + ```shell + terraform init + ``` + +1. Preview the changes that Terraform makes: + + ```shell + terraform plan + ``` + +1. Apply the configuration files: + + ```shell + terraform apply + ``` + +## Verify your setup + +After applying the configuration, verify your setup by checking that: + +- Terraform can authenticate with your Grafana Cloud stack +- The provider is properly configured with the correct stack ID +- No errors appear in the Terraform output + +## Best practices + +When managing Knowledge Graph resources with Terraform, consider the following best practices: + +### Name conventions + +- Use descriptive names that clearly indicate the purpose of each resource +- Follow a consistent naming pattern across your organization +- Include environment or team identifiers in names when appropriate + +### Version control + +- Store your Terraform configurations in version control (Git) +- Use separate directories or workspaces for different environments +- Document changes in commit messages + +### State management + +- Use remote state backends for team collaboration +- Enable state locking to prevent concurrent modifications +- Regularly back up your Terraform state files + +### Security + +- Never commit service account tokens or sensitive data to version control +- Use environment variables or secret management tools for credentials +- Rotate service account tokens regularly + +## Next steps + +Now that you have configured the Terraform provider, you can start managing knowledge graph resources: + +- [Configure notification alerts](../notification-alerts/) +- [Define suppressed assertions](../suppressed-assertions/) +- [Create custom model rules](../custom-model-rules/) +- [Set up log configurations](../log-configurations/) +- [Configure thresholds](../thresholds/) +- [Configure knowledge graph SLOs](../knowledge-graph-slo/) diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/knowledge-graph-slo.md b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/knowledge-graph-slo.md new file mode 100644 index 00000000000..e422bde6877 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/knowledge-graph-slo.md @@ -0,0 +1,696 @@ +--- +description: Learn how to configure knowledge graph SLOs in Grafana using Terraform for entity-centric monitoring and root cause analysis +menuTitle: Knowledge graph SLOs +title: Configure knowledge graph SLOs using Terraform +weight: 650 +keywords: + - Terraform + - Knowledge graph + - SLO + - Service Level Objectives + - RCA workbench +--- + +# Configure knowledge graph SLOs using Terraform + +Service level objectives (SLOs) in the [knowledge graph](/docs/grafana-cloud/knowledge-graph/) provide entity-centric service level monitoring with integrated root cause analysis capabilities. By using the `grafana_slo_provenance` label with the value `asserts`, you can create SLOs that display the "asserts" badge in the UI and enable the **Open RCA workbench** button for seamless troubleshooting. + +For details about creating and managing SLOs in the knowledge graph UI, refer to [Create and manage the knowledge graph SLOs](/docs/grafana-cloud/knowledge-graph/configure/manage-slos/). + +## Overview + +Knowledge graph SLOs extend standard Grafana SLOs with entity-centric monitoring and root cause analysis features: + +- **Entity-centric monitoring:** SLOs are tied to specific services, applications, or infrastructure entities tracked by the knowledge graph +- **RCA workbench integration:** The **Open RCA workbench** button enables deep-linking to pre-filtered troubleshooting views +- **Knowledge graph provenance badge:** SLOs display an "asserts" badge instead of "provisioned" in the UI +- **Search expressions:** Define custom search expressions to filter entities in RCA workbench when troubleshooting an SLO breach + +## Before you begin + +To create a knowledge graph SLO using Terraform, you need to: + +- Configure the knowledge graph and have metrics flowing into Grafana Cloud +- [Set up Terraform for the knowledge Graph](../getting-started/) +- Possess knowledge of and have experience with defining SLOs, SLIs, SLAs, and error budgets +- Have an understanding of PromQL + +## Create a basic knowledge graph SLO + +Create a file named `kg-slo.tf` and add the following: + +```terraform +# Basic knowledge graph SLO with entity-centric monitoring +resource "grafana_slo" "kg_example" { + name = "API Service Availability" + description = "SLO managed by knowledge graph for entity-centric monitoring and RCA" + + query { + freeform { + query = "sum(rate(http_requests_total{code!~\"5..\"}[$__rate_interval])) / sum(rate(http_requests_total[$__rate_interval]))" + } + type = "freeform" + } + + objectives { + value = 0.995 + window = "30d" + } + + destination_datasource { + uid = "grafanacloud-prom" + } + + # Knowledge graph integration labels + # The grafana_slo_provenance label triggers knowledge graph-specific behavior: + # - Displays "asserts" badge instead of "provisioned" + # - Shows "Open RCA workbench" button in the SLO UI + # - Enables correlation with knowledge graph entity-centric monitoring + label { + key = "grafana_slo_provenance" + value = "asserts" + } + + label { + key = "service_name" + value = "api-service" + } + + # Search expression for RCA workbench + # This enables the "Open RCA workbench" button to deep-link with pre-filtered context + search_expression = "service=api-service" + + alerting { + fastburn { + annotation { + key = "name" + value = "SLO Burn Rate Very High" + } + annotation { + key = "description" + value = "Error budget is burning too fast" + } + } + slowburn { + annotation { + key = "name" + value = "SLO Burn Rate High" + } + annotation { + key = "description" + value = "Error budget is burning too fast" + } + } + } +} +``` + +## Configure an SLO with multiple entity labels + +Configure SLOs with multiple entity labels for fine-grained filtering in RCA workbench: + +```terraform +# Knowledge graph SLO with comprehensive entity labels +resource "grafana_slo" "payment_service" { + name = "Payment Service Latency SLO" + description = "Latency SLO for payment processing with team and environment context" + + query { + freeform { + query = "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{service=\"payment\"}[$__rate_interval])) by (le)) < 0.5" + } + type = "freeform" + } + + objectives { + value = 0.99 + window = "7d" + } + + destination_datasource { + uid = "grafanacloud-prom" + } + + # Knowledge graph provenance - required for RCA workbench integration + label { + key = "grafana_slo_provenance" + value = "asserts" + } + + # Service identification + label { + key = "service_name" + value = "payment-service" + } + + # Team ownership + label { + key = "team_name" + value = "payments-team" + } + + # Environment + label { + key = "environment" + value = "production" + } + + # Business unit + label { + key = "business_unit" + value = "fintech" + } + + # Search expression with multiple filters + search_expression = "service=payment-service AND environment=production" + + alerting { + fastburn { + annotation { + key = "name" + value = "Payment Latency Critical" + } + annotation { + key = "description" + value = "Payment service P99 latency exceeding SLO - immediate attention required" + } + annotation { + key = "runbook_url" + value = "https://docs.example.com/runbooks/payment-latency" + } + } + slowburn { + annotation { + key = "name" + value = "Payment Latency Warning" + } + annotation { + key = "description" + value = "Payment service experiencing elevated latency" + } + } + } +} +``` + +## Configure a Kubernetes service SLO + +Configure knowledge graph SLOs for Kubernetes services with Pod and namespace context: + +```terraform +# Knowledge graph SLO for Kubernetes service +resource "grafana_slo" "k8s_frontend" { + name = "Frontend Service Availability" + description = "Availability SLO for frontend service in Kubernetes" + + query { + freeform { + query = "sum(rate(http_requests_total{namespace=\"frontend\",code!~\"5..\"}[$__rate_interval])) / sum(rate(http_requests_total{namespace=\"frontend\"}[$__rate_interval]))" + } + type = "freeform" + } + + objectives { + value = 0.999 + window = "30d" + } + + destination_datasource { + uid = "grafanacloud-prom" + } + + label { + key = "grafana_slo_provenance" + value = "asserts" + } + + label { + key = "service_name" + value = "frontend" + } + + label { + key = "namespace" + value = "frontend" + } + + label { + key = "cluster" + value = "prod-us-west-2" + } + + # Search expression targeting Kubernetes entities + search_expression = "namespace=frontend AND cluster=prod-us-west-2" + + alerting { + fastburn { + annotation { + key = "name" + value = "Frontend Service Critical" + } + annotation { + key = "description" + value = "Frontend service availability below SLO" + } + annotation { + key = "severity" + value = "critical" + } + } + slowburn { + annotation { + key = "name" + value = "Frontend Service Degraded" + } + annotation { + key = "description" + value = "Frontend service showing signs of degradation" + } + annotation { + key = "severity" + value = "warning" + } + } + } +} +``` + +## Configure an API endpoint-specific SLO + +Configure knowledge graph SLOs for specific API endpoints with request context: + +```terraform +# Knowledge graph SLO for critical API endpoint +resource "grafana_slo" "checkout_api" { + name = "Checkout API Availability" + description = "Availability SLO for /api/checkout endpoint" + + query { + freeform { + query = "sum(rate(http_requests_total{path=\"/api/checkout\",code!~\"5..\"}[$__rate_interval])) / sum(rate(http_requests_total{path=\"/api/checkout\"}[$__rate_interval]))" + } + type = "freeform" + } + + objectives { + value = 0.9999 + window = "30d" + } + + destination_datasource { + uid = "grafanacloud-prom" + } + + label { + key = "grafana_slo_provenance" + value = "asserts" + } + + label { + key = "service_name" + value = "checkout-service" + } + + label { + key = "endpoint" + value = "/api/checkout" + } + + label { + key = "criticality" + value = "high" + } + + # Search expression with endpoint context + search_expression = "service=checkout-service AND path=/api/checkout" + + alerting { + fastburn { + annotation { + key = "name" + value = "Checkout API Critical Failure" + } + annotation { + key = "description" + value = "Checkout API experiencing high error rates - revenue impact" + } + annotation { + key = "severity" + value = "critical" + } + annotation { + key = "alert_priority" + value = "P0" + } + } + slowburn { + annotation { + key = "name" + value = "Checkout API Degradation" + } + annotation { + key = "description" + value = "Checkout API showing elevated error rates" + } + annotation { + key = "severity" + value = "warning" + } + } + } +} +``` + +## Configure a multi-environment SLO + +Manage knowledge graph SLOs across multiple environments using Terraform workspaces or modules: + +```terraform +# Variable for environment-specific configuration +variable "environment" { + description = "Environment name" + type = string +} + +variable "slo_target" { + description = "SLO target percentage" + type = number +} + +# Environment-aware knowledge graph SLO +resource "grafana_slo" "api_service" { + name = "${var.environment} - API Service Availability" + description = "API service availability SLO for ${var.environment} environment" + + query { + freeform { + query = "sum(rate(http_requests_total{environment=\"${var.environment}\",code!~\"5..\"}[$__rate_interval])) / sum(rate(http_requests_total{environment=\"${var.environment}\"}[$__rate_interval]))" + } + type = "freeform" + } + + objectives { + value = var.slo_target + window = "30d" + } + + destination_datasource { + uid = "grafanacloud-prom" + } + + label { + key = "grafana_slo_provenance" + value = "asserts" + } + + label { + key = "service_name" + value = "api-service" + } + + label { + key = "environment" + value = var.environment + } + + search_expression = "service=api-service AND environment=${var.environment}" + + alerting { + fastburn { + annotation { + key = "name" + value = "${var.environment} API Critical" + } + annotation { + key = "description" + value = "API service in ${var.environment} experiencing critical errors" + } + } + slowburn { + annotation { + key = "name" + value = "${var.environment} API Warning" + } + annotation { + key = "description" + value = "API service in ${var.environment} showing elevated errors" + } + } + } +} +``` + +## Resource reference + +### `grafana_slo` with knowledge graph provenance + +When creating knowledge graph-managed SLOs, the `grafana_slo` resource requires the `grafana_slo_provenance` label set to `asserts` to enable RCA workbench integration. + +#### Required knowledge graph configuration + +| Name | Type | Required | Description | +| ------------------------------ | -------- | ----------- | -------------------------------------------------------------------------------------------------- | +| `grafana_slo_provenance` label | `string` | Yes | Must be set to `asserts` to enable knowledge graph-specific features and RCA workbench integration | +| `search_expression` | `string` | Recommended | Search expression for filtering entities in RCA workbench | + +#### Key arguments for knowledge graph SLOs + +| Name | Type | Required | Description | +| ------------------------ | -------------- | -------- | ----------------------------------------------------------------- | +| `name` | `string` | Yes | The name of the SLO | +| `description` | `string` | No | Description of the SLO purpose and scope | +| `query` | `object` | Yes | Query configuration defining how SLO is calculated | +| `objectives` | `object` | Yes | Target objectives including value and time window | +| `destination_datasource` | `object` | Yes | Destination data source for SLO metrics | +| `label` | `list(object)` | Yes | Labels for the SLO, must include `grafana_slo_provenance=asserts` | +| `search_expression` | `string` | No | Search expression for RCA workbench filtering | +| `alerting` | `object` | No | Alerting configuration for fast burn and slow burn alerts | + +#### Query block + +The `query` block supports the following: + +| Name | Type | Required | Description | +| ---------- | -------- | -------- | --------------------------------------------------------- | +| `type` | `string` | Yes | Query type, typically `freeform` for knowledge graph SLOs | +| `freeform` | `object` | Yes | Freeform query configuration | + +The `freeform` block supports: + +| Name | Type | Required | Description | +| ------- | -------- | -------- | -------------------------------- | +| `query` | `string` | Yes | PromQL query for SLO calculation | + +#### Objectives block + +The `objectives` block supports the following: + +| Name | Type | Required | Description | +| -------- | -------- | -------- | --------------------------------------------------- | +| `value` | `number` | Yes | Target SLO value (for example, 0.995 for 99.5%) | +| `window` | `string` | Yes | Time window for SLO evaluation (for example, "30d") | + +#### Label block + +Each `label` block supports the following: + +| Name | Type | Required | Description | +| ------- | -------- | -------- | ----------- | +| `key` | `string` | Yes | Label key | +| `value` | `string` | Yes | Label value | + +**Required label for knowledge graph SLOs:** + +- `grafana_slo_provenance` = `asserts` (enables knowledge graph features) + +**Recommended labels for entity tracking:** + +- `service_name` - Name of the service +- `team_name` - Team responsible for the service +- `environment` - Environment (prod, staging, development) +- `namespace` - Kubernetes namespace +- `cluster` - Kubernetes cluster name + + + +#### Alerting block + +The `alerting` block supports the following: + +| Name | Type | Required | Description | +| ---------- | -------- | -------- | ---------------------------------- | +| `fastburn` | `object` | No | Fast burn rate alert configuration | +| `slowburn` | `object` | No | Slow burn rate alert configuration | + +Each alert block (`fastburn`, `slowburn`) supports: + +| Name | Type | Required | Description | +| ------------ | -------------- | -------- | ------------------------------- | +| `annotation` | `list(object)` | No | Annotations to add to the alert | + +Each `annotation` block supports: + +| Name | Type | Required | Description | +| ------- | -------- | -------- | ---------------- | +| `key` | `string` | Yes | Annotation key | +| `value` | `string` | Yes | Annotation value | + +Common annotation keys: + +- `name` - Alert name +- `description` - Alert description +- `severity` - Alert severity level +- `runbook_url` - Link to runbook documentation + + +#### Example + +```terraform +resource "grafana_slo" "kg_example" { + name = "My Service SLO" + description = "SLO with knowledge graph RCA integration" + + query { + freeform { + query = "sum(rate(http_requests_total{code!~\"5..\"}[$__rate_interval])) / sum(rate(http_requests_total[$__rate_interval]))" + } + type = "freeform" + } + + objectives { + value = 0.995 + window = "30d" + } + + destination_datasource { + uid = "grafanacloud-prom" + } + + label { + key = "grafana_slo_provenance" + value = "asserts" + } + + label { + key = "service_name" + value = "my-service" + } + + search_expression = "service=my-service" + + alerting { + fastburn { + annotation { + key = "name" + value = "SLO Fast Burn" + } + } + slowburn { + annotation { + key = "name" + value = "SLO Slow Burn" + } + } + } +} +``` + +## Best practices + +Follow these best practices when setting knowledge graph SLOs. + +### Use the knowledge graph provenance label + +- Always include the `grafana_slo_provenance` label with value `asserts` for knowledge graph-managed SLOs +- This label enables the "asserts" badge in the UI instead of "provisioned" +- It also enables the **Open RCA workbench** button for troubleshooting SLO breaches + +### Define search expressions + +- Define meaningful search expressions that filter relevant entities in RCA workbench +- The search expression defines which entities populate RCA workbench when you troubleshoot an SLO breach +- Use entity attributes like service name, environment, namespace, and cluster +- Combine multiple filters with `AND` operators for precise filtering +- Test search expressions in RCA workbench before codifying them in Terraform + +### Add entity labels + +- Add descriptive labels to track service ownership, environment, and criticality +- Use consistent label naming conventions across all SLOs +- Include team names to enable quick identification of ownership +- Tag critical business services with appropriate labels + +### Set SLO targets + +- Set realistic SLO targets based on service requirements and capabilities +- Use higher targets (0.999+) for critical user-facing services +- Consider different targets for different environments (production vs staging) +- Review and adjust targets based on actual service performance + +### Add alert annotations + +- Add comprehensive descriptions to help on-call engineers understand the alert +- Include runbook URLs in annotations for quick access to troubleshooting guides +- Set appropriate severity levels (critical, warning) based on business impact +- Customize alert names to clearly identify the affected service and issue + +### Configure queries + +- Use PromQL queries that accurately represent service health +- Exclude expected error codes, such as 404, from error calculations when appropriate +- Leverage rate intervals with `$__rate_interval` for dynamic time range support +- Test queries in Grafana before adding them to Terraform configurations + +### Set compliance windows + +- Use 30-day windows for production SLOs to align with monthly reporting +- Consider shorter windows (7d) for development or testing environments +- Ensure compliance windows align with business requirements and error budget policies + +## Verify the configuration + +After applying the Terraform configuration, verify that: + +- SLOs are created in your Grafana Cloud stack +- SLOs appear in **Observability > SLO** with the "asserts" badge +- The **Open RCA workbench** button is visible when you expand **Objective** for an SLO +- You can select a time range in the **Error Budget Burndown** panel and click **Open in RCA workbench** +- Search expressions correctly filter entities in RCA workbench +- Fast burn and slow burn alerts are configured with appropriate thresholds +- Labels are correctly applied and visible in the SLO details + +## Troubleshooting + +Follow these troubleshooting steps if you experience issues setting knowledge graph SLOs. + +### SLO shows "provisioned" instead of "asserts" badge + +Ensure the `grafana_slo_provenance` label is set to `asserts`: + +```terraform +label { + key = "grafana_slo_provenance" + value = "asserts" +} +``` + +### Open RCA workbench button not appearing + +- Verify the `search_expression` field is populated +- The **Open RCA workbench** button appears after you have added a search expression in the **RCA workbench Context** section +- Ensure the search expression uses valid entity attributes +- Check that the knowledge graph is properly configured and receiving data + +### Alerts not triggering + +- Verify the PromQL query returns valid results in Grafana +- Check that the destination data source is correctly configured +- Ensure alerting blocks are properly defined with annotations + +## Related documentation + +- [Create and manage knowledge graph SLOs](/docs/grafana-cloud/knowledge-graph/configure/manage-slos/) +- [Troubleshoot an SLO breach with the knowledge graph](/docs/grafana-cloud/knowledge-graph/troubleshoot-infra-apps/slos/) +- [Get started with Terraform for the knowledge graph](../getting-started/) +- [Introduction to Grafana SLO](/docs/grafana-cloud/alerting-and-irm/slo/introduction/) +- [Configure notifications in the knowledge graph](/docs/grafana-cloud/knowledge-graph/configure/notifications/) diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/log-configurations.md b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/log-configurations.md new file mode 100644 index 00000000000..00b6bdede35 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/log-configurations.md @@ -0,0 +1,290 @@ +--- +description: Configure log correlation for Knowledge Graph using Terraform +menuTitle: Log configurations +title: Configure log correlation using Terraform +weight: 500 +keywords: + - Terraform + - Knowledge Graph + - Log Configuration + - Log Correlation + - Loki +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/log-configurations/ +--- + +# Configure log correlation using Terraform + +Log configurations in [Knowledge Graph](/docs/grafana-cloud/knowledge-graph/) allow you to define how log data is queried and correlated with entities. You can specify data sources, entity matching rules, label mappings, and filtering options for spans and traces. + +For information about configuring log correlation in the Knowledge Graph UI, refer to [Configure logs correlation](/docs/grafana-cloud/knowledge-graph/configure/logs-correlation/). + +## Basic log configuration + +Create a file named `log-configs.tf` and add the following: + +```terraform +# Basic log configuration for services +resource "grafana_asserts_log_config" "production" { + provider = grafana.asserts + + name = "production" + priority = 1000 + default_config = false + data_source_uid = "grafanacloud-logs" + error_label = "error" + + match { + property = "asserts_entity_type" + op = "EQUALS" + values = ["Service"] + } + + match { + property = "environment" + op = "EQUALS" + values = ["production", "staging"] + } + + entity_property_to_log_label_mapping = { + "otel_namespace" = "service_namespace" + "otel_service" = "service_name" + "environment" = "env" + "site" = "region" + } + + filter_by_span_id = true + filter_by_trace_id = true +} +``` + +## Log configuration with multiple match rules + +Configure log correlation with multiple entity matching criteria: + +```terraform +# Development environment log configuration +resource "grafana_asserts_log_config" "development" { + provider = grafana.asserts + + name = "development" + priority = 2000 + default_config = true + data_source_uid = "elasticsearch-dev" + error_label = "error" + + match { + property = "asserts_entity_type" + op = "EQUALS" + values = ["Service"] + } + + match { + property = "environment" + op = "EQUALS" + values = ["development", "testing"] + } + + match { + property = "site" + op = "EQUALS" + values = ["us-east-1"] + } + + match { + property = "service" + op = "EQUALS" + values = ["api"] + } + + entity_property_to_log_label_mapping = { + "otel_namespace" = "service_namespace" + "otel_service" = "service_name" + "environment" = "env" + "site" = "region" + "service" = "app" + } + + filter_by_span_id = true + filter_by_trace_id = true +} +``` + +## Minimal log configuration + +Create a minimal configuration for all entities: + +```terraform +# Minimal configuration for all entities +resource "grafana_asserts_log_config" "minimal" { + provider = grafana.asserts + + name = "minimal" + priority = 3000 + default_config = false + data_source_uid = "loki-minimal" + + match { + property = "asserts_entity_type" + op = "IS_NOT_NULL" + values = [] + } +} +``` + +## Advanced log configuration with complex match rules + +Configure logs with multiple operations and advanced match rules: + +```terraform +# Advanced configuration with multiple operations +resource "grafana_asserts_log_config" "advanced" { + provider = grafana.asserts + + name = "advanced" + priority = 1500 + default_config = false + data_source_uid = "loki-advanced" + error_label = "level" + + match { + property = "service_type" + op = "CONTAINS" + values = ["web", "api"] + } + + match { + property = "environment" + op = "NOT_EQUALS" + values = ["test"] + } + + match { + property = "team" + op = "IS_NOT_NULL" + values = [] + } + + entity_property_to_log_label_mapping = { + "service_type" = "type" + "team" = "owner" + "environment" = "env" + "version" = "app_version" + } + + filter_by_span_id = true + filter_by_trace_id = false +} +``` + +## Resource reference + +### `grafana_asserts_log_config` + +Manage Knowledge Graph log configurations through the Grafana API. + +#### Arguments + +| Name | Type | Required | Description | +| -------------------------------------- | -------------- | -------- | -------------------------------------------------------------------------------------------- | +| `name` | `string` | Yes | The name of the log configuration. This field is immutable and forces recreation if changed. | +| `priority` | `number` | Yes | Priority of the log configuration. Higher priority configurations are evaluated first. | +| `default_config` | `bool` | Yes | Whether this is the default configuration. Default configurations cannot be deleted. | +| `data_source_uid` | `string` | Yes | DataSource UID to be queried (for example, a Loki instance). | +| `match` | `list(object)` | No | List of match rules for entity properties. Refer to [match block](#match-block) for details. | +| `error_label` | `string` | No | Label name used to identify error logs. | +| `entity_property_to_log_label_mapping` | `map(string)` | No | Mapping of entity properties to log labels for correlation. | +| `filter_by_span_id` | `bool` | No | Whether to filter logs by span ID for distributed tracing correlation. | +| `filter_by_trace_id` | `bool` | No | Whether to filter logs by trace ID for distributed tracing correlation. | + +#### Match block + +Each `match` block supports the following: + +| Name | Type | Required | Description | +| ---------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | +| `property` | `string` | Yes | Entity property to match against. | +| `op` | `string` | Yes | Operation to use for matching. One of: `EQUALS`, `NOT_EQUALS`, `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_NULL`, `IS_NOT_NULL`. | +| `values` | `list(string)` | Yes | Values to match against. Can be empty for `IS_NULL` and `IS_NOT_NULL` operations. | + +#### Example + +```terraform +resource "grafana_asserts_log_config" "example" { + provider = grafana.asserts + + name = "example-logs" + priority = 1000 + default_config = false + data_source_uid = "loki-prod" + error_label = "level" + + match { + property = "asserts_entity_type" + op = "EQUALS" + values = ["Service", "Pod"] + } + + entity_property_to_log_label_mapping = { + "service" = "app" + "namespace" = "k8s_namespace" + "environment" = "env" + } + + filter_by_span_id = true + filter_by_trace_id = true +} +``` + +## Best practices + +### Priority management + +- Assign lower priority numbers to more specific configurations +- Higher priority configurations are evaluated first +- Use consistent priority ranges for different configuration types +- Document the reasoning behind priority assignments + +### Data source configuration + +- Ensure the data source UID matches your actual Loki or log aggregation system +- Test data source connectivity before applying configurations +- Use descriptive names for log configurations to indicate their purpose +- Consider using separate data sources for different environments + +### Label map strategy + +- Map entity properties consistently across all log configurations +- Use meaningful log label names that match your logging standards +- Document the mapping relationships in configuration comments +- Verify that mapped labels exist in your log data + +### Match rules design + +- Start with broad match rules and refine based on needs +- Use specific property names that exist in your entity model +- Test match rules with sample data before deploying +- Combine multiple match rules for precise entity targeting + +### Distributed trace integration + +- Enable `filter_by_span_id` and `filter_by_trace_id` when using OpenTelemetry +- Ensure your logs contain the appropriate trace and span ID labels +- Use consistent label names for trace IDs across your logging infrastructure +- Test trace correlation to verify it works as expected + +## Validation + +After applying the Terraform configuration, verify that: + +- Log configurations are created in your Knowledge Graph instance +- Configurations appear in the Knowledge Graph UI under **Observability > Configuration > Logs** +- Log correlation works when drilling down from entities +- Label mappings correctly translate entity properties to log labels +- Match rules properly filter entities +- Trace and span ID filtering works for distributed tracing + +## Related documentation + +- [Configure logs correlation in Knowledge Graph](/docs/grafana-cloud/knowledge-graph/configure/logs-correlation/) +- [Get started with Terraform for Knowledge Graph](../getting-started/) +- [Loki documentation](/docs/loki/latest/) diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/notification-alerts.md b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/notification-alerts.md new file mode 100644 index 00000000000..ac2fb1f4a77 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/notification-alerts.md @@ -0,0 +1,224 @@ +--- +description: Configure notification alerts for Knowledge Graph using Terraform +menuTitle: Notification alerts +title: Configure notification alerts using Terraform +weight: 200 +keywords: + - Terraform + - Knowledge Graph + - Notification Alerts + - Alert Configuration +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/notification-alerts/ +--- + +# Configure notification alerts using Terraform + +Notification alerts configurations in [Knowledge Graph](/docs/grafana-cloud/knowledge-graph/) allow you to manage how alerts are processed and routed. You can specify match labels to filter alerts, add custom labels, set duration requirements, and control silencing. + +For information about configuring notification alerts in the Knowledge Graph UI, refer to [Configure notifications](/docs/grafana-cloud/knowledge-graph/configure/notifications/). + +## Basic notification alerts configuration + +Create a file named `alert-configs.tf` and add the following: + +```terraform +# Basic alert configuration with silencing +resource "grafana_asserts_notification_alerts_config" "prometheus_remote_storage_failures" { + provider = grafana.asserts + + name = "PrometheusRemoteStorageFailures" + + match_labels = { + alertname = "PrometheusRemoteStorageFailures" + alertgroup = "prometheus.alerts" + asserts_env = "prod" + } + + silenced = true +} + +# High severity alert with specific job and context matching +resource "grafana_asserts_notification_alerts_config" "error_buildup_notify" { + provider = grafana.asserts + + name = "ErrorBuildupNotify" + + match_labels = { + alertname = "ErrorBuildup" + job = "acai" + asserts_request_type = "inbound" + asserts_request_context = "/auth" + } + + silenced = false +} +``` + +## Notification alerts with additional labels and duration + +Configure alerts with custom labels and timing requirements: + +```terraform +# Alert with additional labels and custom duration +resource "grafana_asserts_notification_alerts_config" "payment_test_alert" { + provider = grafana.asserts + + name = "PaymentTestAlert" + + match_labels = { + alertname = "PaymentTestAlert" + additional_labels = "asserts_severity=~\"critical\"" + alertgroup = "alex-k8s-integration-test.alerts" + } + + alert_labels = { + testing = "onetwothree" + } + + duration = "5m" + silenced = false +} +``` + +## Latency and performance notification alerts + +Monitor and alert on latency and performance issues: + +```terraform +# Latency alert for shipping service +resource "grafana_asserts_notification_alerts_config" "high_shipping_latency" { + provider = grafana.asserts + + name = "high shipping latency" + + match_labels = { + alertname = "LatencyP99ErrorBuildup" + job = "shipping" + asserts_request_type = "inbound" + } + + silenced = false +} + +# CPU throttling alert with warning severity +resource "grafana_asserts_notification_alerts_config" "cpu_throttling_sustained" { + provider = grafana.asserts + + name = "CPUThrottlingSustained" + + match_labels = { + alertname = "CPUThrottlingSustained" + additional_labels = "asserts_severity=~\"warning\"" + } + + silenced = true +} +``` + +## Infrastructure and service notification alerts + +Configure alerts for infrastructure components and services: + +```terraform +# Ingress error rate alert +resource "grafana_asserts_notification_alerts_config" "ingress_error" { + provider = grafana.asserts + + name = "ingress error" + + match_labels = { + alertname = "ErrorRatioBreach" + job = "ingress-nginx-controller-metrics" + asserts_request_type = "inbound" + } + + silenced = false +} + +# MySQL Galera cluster alert +resource "grafana_asserts_notification_alerts_config" "mysql_galera_not_ready" { + provider = grafana.asserts + + name = "MySQLGaleraNotReady" + + match_labels = { + alertname = "MySQLGaleraNotReady" + } + + silenced = false +} +``` + +## Resource reference + +### `grafana_asserts_notification_alerts_config` + +Manage Knowledge Graph notification alerts configurations through the Grafana API. + +#### Arguments + +| Name | Type | Required | Description | +| -------------- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `name` | `string` | Yes | The name of the notification alerts configuration. This field is immutable and forces recreation if changed. | +| `match_labels` | `map(string)` | No | Labels to match for this notification alerts configuration. Used to filter which alerts this configuration applies to. | +| `alert_labels` | `map(string)` | No | Labels to add to alerts generated by this notification alerts configuration. | +| `duration` | `string` | No | Duration for which the condition must be true before firing (for example, '5m', '30s'). Maps to 'for' in Knowledge Graph API. | +| `silenced` | `bool` | No | Whether this notification alerts configuration is silenced. Defaults to `false`. | + +#### Example + +```terraform +resource "grafana_asserts_notification_alerts_config" "example" { + provider = grafana.asserts + + name = "ExampleAlert" + + match_labels = { + alertname = "HighCPUUsage" + job = "monitoring" + } + + alert_labels = { + severity = "warning" + team = "platform" + } + + duration = "5m" + silenced = false +} +``` + +## Best practices + +### Label management + +- Use specific and meaningful labels in `match_labels` to ensure precise alert filtering +- Leverage existing label conventions from your monitoring setup +- Consider using `asserts_env` and `asserts_site` labels for multi-environment setups + +### Silence strategy + +- Use the `silenced` parameter for temporary suppression rather than deleting notification alerts configurations +- Document the reason for silencing in your Terraform configuration comments +- Regularly review silenced configurations to ensure they're still needed + +### Duration configuration + +- Set appropriate duration values based on your alerting requirements +- Consider the nature of the monitored condition when choosing duration +- Use consistent duration formats across similar alert types + +## Validation + +After applying the Terraform configuration, verify that: + +- Notification alerts configurations are created in your Knowledge Graph instance +- Configurations appear in the Knowledge Graph UI under **Observability > Rules > Notify** +- Match labels correctly filter the intended alerts +- Custom labels are properly applied to generated alerts + +## Related documentation + +- [Configure notifications in Knowledge Graph](/docs/grafana-cloud/knowledge-graph/configure/notifications/) +- [Get started with Terraform for Knowledge Graph](../getting-started/) +- [Configure alerts in Knowledge Graph](/docs/grafana-cloud/knowledge-graph/configure/alerts/) diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/suppressed-assertions.md b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/suppressed-assertions.md new file mode 100644 index 00000000000..94002078b6b --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/suppressed-assertions.md @@ -0,0 +1,308 @@ +--- +description: Configure suppressed assertions for Knowledge Graph using Terraform +menuTitle: Suppressed assertions +title: Configure suppressed assertions using Terraform +weight: 300 +keywords: + - Terraform + - Knowledge Graph + - Suppressed Assertions + - Alert Suppression +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/suppressed-assertions/ +--- + +# Configure suppressed assertions using Terraform + +Suppressed assertions configurations allow you to disable specific alerts or assertions based on label matching in [Knowledge Graph](/docs/grafana-cloud/knowledge-graph/). This is useful for maintenance windows, test environments, or when you want to temporarily suppress certain types of alerts. + +For information about suppressing insights in the Knowledge Graph UI, refer to [Suppress insights](/docs/grafana-cloud/knowledge-graph/troubleshoot-infra-apps/suppress-insights/). + +## Basic suppressed assertions configuration + +Create a file named `suppressed-assertions.tf` and add the following: + +```terraform +# Basic suppressed alert configuration for maintenance +resource "grafana_asserts_suppressed_assertions_config" "maintenance_window" { + provider = grafana.asserts + + name = "MaintenanceWindow" + + match_labels = { + service = "api-service" + maintenance = "true" + } +} + +# Suppress specific alertname during deployment +resource "grafana_asserts_suppressed_assertions_config" "deployment_suppression" { + provider = grafana.asserts + + name = "DeploymentSuppression" + + match_labels = { + alertname = "HighLatency" + job = "web-service" + env = "staging" + } +} + +# Suppress alerts for specific test environment +resource "grafana_asserts_suppressed_assertions_config" "test_environment_suppression" { + provider = grafana.asserts + + name = "TestEnvironmentSuppression" + + match_labels = { + alertgroup = "test.alerts" + environment = "test" + } +} +``` + +## Service-specific suppression configurations + +Suppress alerts for specific services during maintenance or operational activities: + +```terraform +# Suppress alerts for specific services during maintenance +resource "grafana_asserts_suppressed_assertions_config" "api_service_maintenance" { + provider = grafana.asserts + + name = "APIServiceMaintenance" + + match_labels = { + service = "api-gateway" + job = "api-gateway" + maintenance = "scheduled" + } +} + +# Suppress database alerts during backup operations +resource "grafana_asserts_suppressed_assertions_config" "database_backup" { + provider = grafana.asserts + + name = "DatabaseBackupSuppression" + + match_labels = { + service = "postgresql" + job = "postgres-exporter" + backup_mode = "active" + } +} + +# Suppress monitoring system alerts during updates +resource "grafana_asserts_suppressed_assertions_config" "monitoring_update" { + provider = grafana.asserts + + name = "MonitoringSystemUpdate" + + match_labels = { + service = "prometheus" + job = "prometheus" + update = "in_progress" + } +} +``` + +## Environment and team-based suppression + +Create suppression rules based on environment or team: + +```terraform +# Suppress all alerts for development environment +resource "grafana_asserts_suppressed_assertions_config" "dev_environment" { + provider = grafana.asserts + + name = "DevelopmentEnvironmentSuppression" + + match_labels = { + environment = "development" + team = "platform" + } +} + +# Suppress alerts for specific team during their maintenance window +resource "grafana_asserts_suppressed_assertions_config" "team_maintenance" { + provider = grafana.asserts + + name = "TeamMaintenanceWindow" + + match_labels = { + team = "backend" + maintenance = "team_scheduled" + timezone = "UTC" + } +} + +# Suppress alerts for staging environment during testing +resource "grafana_asserts_suppressed_assertions_config" "staging_testing" { + provider = grafana.asserts + + name = "StagingTestingSuppression" + + match_labels = { + environment = "staging" + testing = "automated" + job = "integration-tests" + } +} +``` + +## Alert type and severity-based suppression + +Suppress alerts based on their type or severity: + +```terraform +# Suppress low severity alerts during business hours +resource "grafana_asserts_suppressed_assertions_config" "low_severity_business_hours" { + provider = grafana.asserts + + name = "LowSeverityBusinessHours" + + match_labels = { + severity = "warning" + timezone = "business_hours" + } +} + +# Suppress specific alert types during known issues +resource "grafana_asserts_suppressed_assertions_config" "known_issue_suppression" { + provider = grafana.asserts + + name = "KnownIssueSuppression" + + match_labels = { + alertname = "HighMemoryUsage" + service = "legacy-service" + issue_id = "LEG-123" + } +} + +# Suppress infrastructure alerts during planned maintenance +resource "grafana_asserts_suppressed_assertions_config" "infrastructure_maintenance" { + provider = grafana.asserts + + name = "InfrastructureMaintenance" + + match_labels = { + alertgroup = "infrastructure.alerts" + maintenance_type = "planned" + affected_services = "all" + } +} +``` + +## Complex multi-label suppression + +Define complex suppression rules with multiple labels: + +```terraform +# Complex suppression for multi-service deployments +resource "grafana_asserts_suppressed_assertions_config" "multi_service_deployment" { + provider = grafana.asserts + + name = "MultiServiceDeploymentSuppression" + + match_labels = { + deployment_id = "deploy-2024-01-15" + services = "api,worker,frontend" + environment = "production" + deployment_type = "blue_green" + } +} + +# Suppress alerts for specific cluster during maintenance +resource "grafana_asserts_suppressed_assertions_config" "cluster_maintenance" { + provider = grafana.asserts + + name = "ClusterMaintenanceSuppression" + + match_labels = { + cluster = "production-cluster-1" + maintenance = "cluster_upgrade" + affected_nodes = "all" + estimated_duration = "2h" + } +} + +# Suppress alerts for specific region during network issues +resource "grafana_asserts_suppressed_assertions_config" "regional_network_issue" { + provider = grafana.asserts + + name = "RegionalNetworkIssueSuppression" + + match_labels = { + region = "us-west-2" + issue_type = "network" + affected_services = "external_dependencies" + incident_id = "NET-456" + } +} +``` + +## Resource reference + +### `grafana_asserts_suppressed_assertions_config` + +Manage Knowledge Graph suppressed assertions configurations through the Grafana API. + +#### Arguments + +| Name | Type | Required | Description | +| -------------- | ------------- | -------- | ------------------------------------------------------------------------------------------------------------------ | +| `name` | `string` | Yes | The name of the suppressed assertions configuration. This field is immutable and forces recreation if changed. | +| `match_labels` | `map(string)` | No | Labels to match for this suppressed assertions configuration. Used to determine which alerts should be suppressed. | + +#### Example + +```terraform +resource "grafana_asserts_suppressed_assertions_config" "example" { + provider = grafana.asserts + + name = "ExampleSuppression" + + match_labels = { + alertname = "TestAlert" + env = "development" + } +} +``` + +## Best practices + +### Suppression strategy + +- Use suppression rules for temporary situations rather than permanent solutions +- Document the reason for suppression in your Terraform configuration comments +- Set expiration dates or reminders to review suppression rules +- Prefer fixing alert thresholds over suppressing recurring false positives + +### Label match rules + +- Be specific with match labels to avoid suppressing unintended alerts +- Test suppression rules in non-production environments first +- Use descriptive names that indicate the purpose and scope of the suppression +- Include relevant context in labels (for example, incident IDs, maintenance windows) + +### Lifecycle management + +- Regularly review active suppression rules to ensure they're still needed +- Remove or update suppression rules after maintenance windows or deployments +- Use version control to track when suppression rules were added and why +- Consider using time-based automation to enable or disable suppression rules + +## Validation + +After applying the Terraform configuration, verify that: + +- Suppressed assertions configurations are active in your Knowledge Graph instance +- Configurations appear in the Knowledge Graph UI under **Observability > Rules > Suppress** +- Matching alerts are properly suppressed +- Suppression rules don't affect unintended alerts + +## Related documentation + +- [Suppress insights in Knowledge Graph](/docs/grafana-cloud/knowledge-graph/troubleshoot-infra-apps/suppress-insights/) +- [Get started with Terraform for Knowledge Graph](../getting-started/) +- [Configure notifications](/docs/grafana-cloud/knowledge-graph/configure/notifications/) diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/thresholds.md b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/thresholds.md new file mode 100644 index 00000000000..fd2ed8ed073 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/thresholds.md @@ -0,0 +1,355 @@ +--- +description: Configure thresholds for Knowledge Graph using Terraform +menuTitle: Thresholds +title: Configure thresholds using Terraform +weight: 600 +keywords: + - Terraform + - Knowledge Graph + - Thresholds + - Request Thresholds + - Resource Thresholds + - Health Thresholds +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/terraform/terraform-knowledge-graph/thresholds/ +--- + +# Configure thresholds using Terraform + +Threshold configurations in [Knowledge Graph](/docs/grafana-cloud/knowledge-graph/) allow you to define custom thresholds for request, resource, and health assertions. These configurations help you set specific limits and conditions for monitoring your services and infrastructure. + +For information about managing thresholds in the Knowledge Graph UI, refer to [Manage thresholds](/docs/grafana-cloud/knowledge-graph/configure/manage-thresholds/). + +## Basic threshold configuration + +Create a file named `thresholds.tf` and add the following: + +```terraform +# Basic threshold configuration with all three types +resource "grafana_asserts_thresholds" "basic" { + provider = grafana.asserts + + request_thresholds = [{ + entity_name = "payment-service" + assertion_name = "ErrorRatioBreach" + request_type = "inbound" + request_context = "/charge" + value = 0.01 + }] + + resource_thresholds = [{ + assertion_name = "Saturation" + resource_type = "container" + container_name = "worker" + source = "metrics" + severity = "warning" + value = 75 + }] + + health_thresholds = [{ + assertion_name = "ServiceDown" + expression = "up < 1" + entity_type = "Service" + }] +} +``` + +## Request threshold configurations + +Configure thresholds for different service request types and contexts: + +```terraform +# Multiple request thresholds for different services +resource "grafana_asserts_thresholds" "request_thresholds" { + provider = grafana.asserts + + request_thresholds = [ + { + entity_name = "api-service" + assertion_name = "ErrorRatioBreach" + request_type = "inbound" + request_context = "/api/v1/users" + value = 0.02 + }, + { + entity_name = "api-service" + assertion_name = "LatencyP99ErrorBuildup" + request_type = "inbound" + request_context = "/api/v1/orders" + value = 500 + }, + { + entity_name = "payment-gateway" + assertion_name = "RequestRateAnomaly" + request_type = "outbound" + request_context = "/payment/process" + value = 1000 + } + ] +} +``` + +## Resource threshold configurations + +Define resource thresholds for different severity levels: + +```terraform +# Resource thresholds for different severity levels +resource "grafana_asserts_thresholds" "resource_thresholds" { + provider = grafana.asserts + + resource_thresholds = [ + { + assertion_name = "Saturation" + resource_type = "container" + container_name = "web-server" + source = "metrics" + severity = "warning" + value = 75 + }, + { + assertion_name = "Saturation" + resource_type = "container" + container_name = "web-server" + source = "metrics" + severity = "critical" + value = 90 + }, + { + assertion_name = "ResourceRateBreach" + resource_type = "Pod" + container_name = "database" + source = "logs" + severity = "warning" + value = 80 + } + ] +} +``` + +## Health threshold configurations + +Configure health checks with Prometheus expressions: + +```terraform +# Health thresholds with Prometheus expressions +resource "grafana_asserts_thresholds" "health_thresholds" { + provider = grafana.asserts + + health_thresholds = [ + { + assertion_name = "ServiceDown" + expression = "up{job=\"api-service\"} < 1" + entity_type = "Service" + }, + { + assertion_name = "HighMemoryUsage" + expression = "memory_usage_percent > 85" + entity_type = "Service" + }, + { + assertion_name = "DatabaseConnectivity" + expression = "db_connection_pool_active / db_connection_pool_max > 0.9" + entity_type = "Service" + } + ] +} +``` + +## Comprehensive threshold configuration + +Define comprehensive thresholds for production environments: + +```terraform +# Production environment with comprehensive thresholds +resource "grafana_asserts_thresholds" "production" { + provider = grafana.asserts + + request_thresholds = [ + { + entity_name = "frontend" + assertion_name = "ErrorRatioBreach" + request_type = "inbound" + request_context = "/" + value = 0.005 + }, + { + entity_name = "backend-api" + assertion_name = "LatencyP99ErrorBuildup" + request_type = "inbound" + request_context = "/api" + value = 200 + } + ] + + resource_thresholds = [ + { + assertion_name = "Saturation" + resource_type = "container" + container_name = "frontend" + source = "metrics" + severity = "warning" + value = 70 + }, + { + assertion_name = "Saturation" + resource_type = "container" + container_name = "backend-api" + source = "metrics" + severity = "critical" + value = 85 + } + ] + + health_thresholds = [ + { + assertion_name = "ServiceDown" + expression = "up < 1" + entity_type = "Service" + }, + { + assertion_name = "NodeDown" + expression = "up{job=\"node-exporter\"} < 1" + entity_type = "Service" + } + ] +} +``` + +## Resource reference + +### `grafana_asserts_thresholds` + +Manage Knowledge Graph threshold configurations through the Grafana API. This resource allows you to define custom thresholds for request, resource, and health assertions. + +#### Arguments + +| Name | Type | Required | Description | +| --------------------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | +| `request_thresholds` | `list(object)` | No | List of request threshold configurations. Refer to [request thresholds block](#request-thresholds-block) for details. | +| `resource_thresholds` | `list(object)` | No | List of resource threshold configurations. Refer to [resource thresholds block](#resource-thresholds-block) for details. | +| `health_thresholds` | `list(object)` | No | List of health threshold configurations. Refer to [health thresholds block](#health-thresholds-block) for details. | + +#### Request thresholds block + +Each `request_thresholds` block supports the following: + +| Name | Type | Required | Description | +| ----------------- | -------- | -------- | ------------------------------------------------------ | +| `entity_name` | `string` | Yes | The name of the entity to apply the threshold to. | +| `assertion_name` | `string` | Yes | The name of the assertion to configure. | +| `request_type` | `string` | Yes | The type of request (inbound, outbound). | +| `request_context` | `string` | Yes | The request context or path to apply the threshold to. | +| `value` | `number` | Yes | The threshold value. | + +#### Resource thresholds block + +Each `resource_thresholds` block supports the following: + +| Name | Type | Required | Description | +| ---------------- | -------- | -------- | ---------------------------------------------------- | +| `assertion_name` | `string` | Yes | The name of the assertion to configure. | +| `resource_type` | `string` | Yes | The type of resource (container, Pod, node). | +| `container_name` | `string` | Yes | The name of the container to apply the threshold to. | +| `source` | `string` | Yes | The source of the metrics (metrics, logs). | +| `severity` | `string` | Yes | The severity level (warning, critical). | +| `value` | `number` | Yes | The threshold value. | + +#### Health thresholds block + +Each `health_thresholds` block supports the following: + +| Name | Type | Required | Description | +| ---------------- | -------- | -------- | ------------------------------------------------------------------------------------ | +| `assertion_name` | `string` | Yes | The name of the assertion to configure. | +| `expression` | `string` | Yes | The Prometheus expression for the health check. | +| `entity_type` | `string` | Yes | Entity type for the health threshold (for example, Service, Pod, Namespace, Volume). | +| `alert_category` | `string` | No | Optional alert category label for the health threshold. | + +#### Example + +```terraform +resource "grafana_asserts_thresholds" "example" { + provider = grafana.asserts + + request_thresholds = [{ + entity_name = "api-service" + assertion_name = "ErrorRatioBreach" + request_type = "inbound" + request_context = "/api/v1/users" + value = 0.02 + }] + + resource_thresholds = [{ + assertion_name = "Saturation" + resource_type = "container" + container_name = "web-server" + source = "metrics" + severity = "warning" + value = 75 + }] + + health_thresholds = [{ + assertion_name = "ServiceDown" + expression = "up{job=\"api-service\"} < 1" + entity_type = "Service" + }] +} +``` + +## Best practices + +### Threshold configuration management + +- Set appropriate threshold values based on your service level objectives (SLOs) +- Use different severity levels (warning, critical) to create escalation paths +- Test threshold configurations in non-production environments first +- Monitor threshold effectiveness and adjust values based on actual performance data + +### Request threshold best practices + +- Configure request thresholds for critical user-facing endpoints +- Set different thresholds for different request types (inbound vs outbound) +- Consider request context when setting thresholds for specific API paths +- Use error ratio thresholds to catch service degradation early +- Review historical performance data to set realistic threshold values + +### Resource threshold best practices + +- Set resource thresholds based on your infrastructure capacity +- Use container-specific thresholds for microservices architectures +- Configure both warning and critical thresholds for gradual escalation +- Monitor resource utilization patterns to set realistic threshold values +- Consider seasonal or periodic patterns in resource usage + +### Health threshold best practices + +- Use Prometheus expressions that accurately reflect service health +- Test health check expressions independently before applying them +- Set up health thresholds for critical dependencies and external services +- Use composite expressions for complex health checks +- Ensure expressions perform efficiently without causing excessive load + +### Value selection guidelines + +- Start conservative and adjust based on real-world performance +- Use percentages (0-1 range) for ratio-based metrics +- Use milliseconds for latency thresholds +- Document the reasoning behind specific threshold values +- Review and update thresholds regularly based on system evolution + +## Validation + +After applying the Terraform configuration, verify that: + +- Threshold configurations are applied in your Knowledge Graph instance +- Configurations appear in the Knowledge Graph UI under **Observability > Rules > Threshold** +- Request thresholds correctly identify breaches for specified services +- Resource thresholds trigger at appropriate severity levels +- Health thresholds accurately reflect service status +- Threshold values align with your SLO commitments + +## Related documentation + +- [Manage thresholds in Knowledge Graph](/docs/grafana-cloud/knowledge-graph/configure/manage-thresholds/) +- [Get started with Terraform for Knowledge Graph](../getting-started/) +- [Configure alerts in Knowledge Graph](/docs/grafana-cloud/knowledge-graph/configure/alerts/) diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-oncall.md b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-oncall.md new file mode 100644 index 00000000000..9a29fa15ff4 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-oncall.md @@ -0,0 +1,357 @@ +--- +description: Learn how to create Grafana IRM integrations, escalation policies, and on-call schedules in Grafana Cloud using Terraform +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Terraform + - Grafana Cloud IRM + - OnCall +title: Manage Grafana IRM in Grafana Cloud using Terraform +weight: 120 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/terraform/terraform-oncall/ +--- + +# Manage Grafana IRM in Grafana Cloud using Terraform + +Learn how to use Terraform to manage [Grafana IRM](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/) resources. +This guide shows you how to connect an integration, configure escalation policies, and add on-call schedules using Terraform. + +To illustrate the use of IRM across multiple teams, this guide features examples with two teams: `Devs` and `SREs`. +Additionally, it includes the necessary steps to configure Slack for IRM. + +{{< admonition type="note" >}} +Grafana IRM supports Terraform-based configuration for a limited set of resources, primarily those related to OnCall functionality. +These resources use the `grafana_oncall_` naming convention in Terraform. Additional IRM components are not yet configurable via Terraform. +{{< /admonition >}} + +## Before you begin + +Before you begin, you should have the following: + +- A Grafana Cloud account, as shown in [Get started](https://grafana.com/docs/grafana-cloud/get-started/) +- [Terraform](https://www.terraform.io/downloads) installed on your machine +- Administrator permissions in your Grafana instance +- (Optional) Administrator permissions in your Slack workspace, if you plan to integrate Slack with Grafana IRM + +{{< admonition type="note" >}} +All of the following Terraform configuration files should be saved in the same directory. +{{< /admonition >}} + +## Connect Slack to Grafana IRM + +Before including Slack settings in your Terraform setup, you must first configure the Slack integration with Grafana IRM. + +To connect your Slack workspace to Grafana IRM, refer to the [Slack integration for Grafana IRM](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/configure/integrations/irm-slack/) documentation. + +## Configure the Grafana provider + +This Terraform configuration sets up the [Grafana provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) to provide necessary authentication when managing resources for Grafana IRM. + +You can reuse a similar setup to the one described in [Creating and managing a Grafana Cloud stack using Terraform](../terraform-cloud-stack/) to set up a service account and a token. + +1. Create a Service account and token in Grafana. To create a new one, refer to [Service account tokens](https://grafana.com/docs/grafana/latest/administration/service-accounts/#service-account-tokens). + +1. Create a file named `main.tf` and add the following: + + ```terraform + terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = ">= 3.15.3" + } + } + } + + provider "grafana" { + alias = "oncall" + + url = "" + auth = "" + oncall_url = "" + } + ``` + +1. Replace the following field values: + - `` with the URL of your Grafana stack + - `` with the service account token that you created + - `` with the API URL found on the **Admin & API** tab of the IRM **Settings** page + +{{< admonition type="note" >}} +If the service account has the right permissions, this provider setup also allows you to manage other Grafana resources. +{{< /admonition >}} + +### Authentication via OnCall API tokens (deprecated) + +OnCall API tokens are being deprecated. +While existing tokens will continue to work, we recommend using +[Grafana Cloud service account tokens](https://grafana.com/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/service-accounts/) for all new API authentication. + +{{< collapse title="Authentication via OnCall API tokens" >}} +To use an existing OnCall API token: + +1. Log into your Grafana Cloud instance +1. Select **Alerts & IRM** > **IRM** +1. Click **Settings**, and then select **Admin & API** +1. Locate the **Grafana IRM API** section +1. View, copy or revoke existing **OnCall API tokens** + +1. Create a file named `main.tf` and add the following: + + ```terraform + terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = ">= 2.9.0" + } + } + } + + provider "grafana" { + alias = "oncall" + + oncall_access_token = "" + oncall_url = "" + } + ``` + +1. Replace the following field values: + - `` with your existing OnCall API Token + - `` with the API URL found on the **Admin & API** tab of the IRM **Settings** page + {{< /collapse >}} + +## Add on-call schedules + +This Terraform configuration sets up two on-call schedules, `SREs` and `Devs`, using the [`grafana_oncall_schedule` resource](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/oncall_schedule) to define the schedules within Grafana IRM. +Additionally, this configuration includes Slack channels to receive notifications for the on-call schedules of each team. + +To learn more about managing on-call schedules, refer to the [On-call schedules documentation](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/manage/on-call-schedules/). + +1. Create two new calendars in your calendar service, one for `Devs` and one for `SREs` + +1. Locate and save the secret iCal URLs. + For example, in a Google calendar, these URLs can be found in **Settings > Settings for my calendars > Integrate calendar** + +1. Create a file named `schedule.tf` and add the following: + + ```terraform + # Name of the Slack channel to notify about on-call schedules for Devs + data "grafana_oncall_slack_channel" "Devs" { + provider = grafana.oncall + + name = "" + } + + # Name of the Slack channel to notify about on-call schedules for SREs + data "grafana_oncall_slack_channel" "SREs" { + provider = grafana.oncall + + name = "" + } + + resource "grafana_oncall_schedule" "schedule_Devs" { + provider = grafana.oncall + + name = "Devs" + type = "ical" + ical_url_primary = "" + slack { + channel_id = data.grafana_oncall_slack_channel.Devs.slack_id + } + } + + resource "grafana_oncall_schedule" "schedule_SREs" { + provider = grafana.oncall + + name = "SREs" + type = "ical" + ical_url_primary = "" + slack { + channel_id = data.grafana_oncall_slack_channel.SREs.slack_id + } + } + ``` + +1. Replace the following field values: + - `` with name of the Slack channel to notify about on-call schedules for `Devs` + - `` with name of the Slack channel to notify about on-call schedules for `SREs` + - `` with the secret iCal URL created in the first step for `Devs` Calendar + - `` with the secret iCal URL created in the first step for `SREs` Calendar + +## Add escalation chains + +This Terraform configuration creates two escalation chains named `SREs` and `Devs` in Grafana IRM using the [`grafana_oncall_escalation_chain` (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/oncall_escalation_chain). +The configuration also adds the following three steps to each escalation chain using the [`grafana_oncall_escalation` (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/oncall_escalation): + +- Notify users from on-call schedule +- Wait for 5 minutes +- Notify default Slack channel + +1. Create a file named `escalation-devs.tf` and add the following: + + ```terraform + resource "grafana_oncall_escalation_chain" "Devs" { + provider = grafana.oncall + + name = "Devs" + } + + // Notify users from on-call schedule + resource "grafana_oncall_escalation" "notify_schedule_step_Devs" { + provider = grafana.oncall + + escalation_chain_id = grafana_oncall_escalation_chain.Devs.id + type = "notify_on_call_from_schedule" + notify_on_call_from_schedule = grafana_oncall_schedule.schedule_Devs.id + position = 0 + } + + // Wait step for 5 Minutes + resource "grafana_oncall_escalation" "wait_step_Devs" { + provider = grafana.oncall + + escalation_chain_id = grafana_oncall_escalation_chain.Devs.id + type = "wait" + duration = 300 + position = 1 + } + + // Notify default Slack channel step + resource "grafana_oncall_escalation" "notify_step_Devs" { + provider = grafana.oncall + + escalation_chain_id = grafana_oncall_escalation_chain.Devs.id + type = "notify_whole_channel" + important = true + position = 2 + } + ``` + +2. Create a file named `escalation-sre.tf` and add the following: + + ```terraform + resource "grafana_oncall_escalation_chain" "SREs" { + provider = grafana.oncall + + name = "SREs" + } + + // Notify users from on-call schedule + resource "grafana_oncall_escalation" "notify_schedule_step_SREs" { + provider = grafana.oncall + + escalation_chain_id = grafana_oncall_escalation_chain.SREs.id + type = "notify_on_call_from_schedule" + notify_on_call_from_schedule = grafana_oncall_schedule.schedule_SREs.id + position = 0 + } + + // Wait step for 5 Minutes + resource "grafana_oncall_escalation" "wait_step_SREs" { + provider = grafana.oncall + + escalation_chain_id = grafana_oncall_escalation_chain.SREs.id + type = "wait" + duration = 300 + position = 1 + } + + // Notify default Slack channel step + resource "grafana_oncall_escalation" "notify_step_SREs" { + provider = grafana.oncall + + escalation_chain_id = grafana_oncall_escalation_chain.SREs.id + type = "notify_whole_channel" + important = true + position = 2 + } + ``` + +## Connect an integration to Grafana IRM + +This Terraform configuration connects Alertmanager to Grafana IRM using the [`grafana_oncall_integration` (Resource)](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/oncall_integration). +It also adds the `Devs` escalation chain as the default route for alerts. + +1. Create a file named `integrations.tf` and add the following: + + ```terraform + resource "grafana_oncall_integration" "AlertManager" { + provider = grafana.oncall + + name = "AlertManager" + type = "alertmanager" + default_route { + escalation_chain_id = grafana_oncall_escalation_chain.Devs.id + } + } + ``` + +1. To configure Alertmanager, refer to [Alertmanager integration for Grafana OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/oncall/integrations/alertmanager/) + +## Set up a route to configure escalation behavior for alert group notifications + +This Terraform configuration sets up a route to the Alertmanager integration using the `grafana_oncall_route` (Resource). +This route ensures that notifications for alerts with `\"namespace\" *: *\"ops-.*\"` in the payload are escalated to the `SREs` escalation chain. + +Create a file named `routes.tf` and add the following: + +```terraform +resource "grafana_oncall_route" "route_SREs" { + provider = grafana.oncall + + integration_id = grafana_oncall_integration.AlertManager.id + escalation_chain_id = grafana_oncall_escalation_chain.SREs.id + routing_regex = "\"namespace\" *: *\"ops-.*\"" + position = 0 +} +``` + +## Apply the Terraform configuration + +In a terminal, run the following commands from the directory where all of the configuration files are located. + +1. Initialize a working directory containing Terraform configuration files. + + ```shell + terraform init + ``` + +1. Preview the changes that Terraform will make. + + ```shell + terraform plan + ``` + +1. Apply the configuration files. + + ```shell + terraform apply + ``` + +## Validation + +After you apply the changes in the Terraform configurations, you can verify the following: + +- Two new Schedules named `Devs` and `SREs` are created in Grafana IRM: + + ![`Devs` and `SREs` OnCall schedules](/media/docs/grafana-cloud/alerting-and-irm/screenshot-oncall-schedules-tf.png) + +- New Escalation chain named `SREs` is created in Grafana IRM: + + ![`SREs` escalation chain](/media/docs/grafana-cloud/alerting-and-irm/screenshot-oncall-escalation-sre-tf.png) + +- New Escalation chain named `Devs` is created in Grafana IRM: + + ![`Devs` escalation chain](/media/docs/grafana-cloud/alerting-and-irm/screenshot-oncall-escalation-devs-tf.png) + +- The Alertmanager integration is added and configured with escalation policies: + + ![Alertmanager integration for SREs escalation](/media/docs/grafana-cloud/alerting-and-irm/screenshot-oncall-alertmanager-tf.png) + +## Conclusion + +In this guide, you learned how to use Terraform to manage Grafana IRM by connecting an integration, configuring escalation policies, and setting up on-call schedules. + +To learn more about managing Grafana Cloud using Terraform, refer to [Grafana provider's documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs). diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-plugins.md b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-plugins.md new file mode 100644 index 00000000000..f0626540ca3 --- /dev/null +++ b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-plugins.md @@ -0,0 +1,81 @@ +--- +description: Learn how to install plugins in Grafana Cloud using Terraform +keywords: + - Infrastructure as Code + - Quickstart + - Grafana Cloud + - Terraform + - Plugins +title: Install plugins in Grafana Cloud using Terraform +weight: 300 +canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/terraform/terraform-plugins/ +--- + +# Install plugins in Grafana Cloud using Terraform + +This guide shows you how to install plugins in Grafana Cloud using Terraform. For more information about Grafana plugins see [Find and use Grafana plugins](/docs/grafana-cloud/introduction/find-and-use-plugins/). + +## Before you begin + +Before you begin, you should have the following available: + +- A Grafana Cloud account; for more information on setting up a Grafana Cloud account, refer to [Get started](https://grafana.com/docs/grafana-cloud/get-started/). +- Terraform installed on your machine; for more information on how to install Terraform, refer to the [Terraform install documentation](https://developer.hashicorp.com/terraform/install). +- Administrator permissions in your Grafana instance; for more information on assigning Grafana RBAC roles, refer to [Assign RBAC roles](/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-control/assign-rbac-roles/). + +{{< admonition type="note" >}} +All of the following Terraform configuration files should be saved in the same directory. +{{< /admonition >}} + +## Configure the Grafana provider + +Use this Terraform configuration to set up the [Grafana provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) to provide the authentication required to manage plugin resources. + +1. Create a service account and token in Grafana. For more information on creating a service account and token, refer to [Service account tokens](https://grafana.com/docs/grafana/latest/administration/service-accounts/#service-account-tokens). You can also refer to [Creating and managing a Grafana Cloud stack using Terraform](../terraform-cloud-stack/) to set up a service account and a token. + +1. Make sure that the token has the following permissions: + +- `stack-plugins:read` +- `stack-plugins:write` +- `stack-plugins:delete` + +Next, create a file named `main.tf` and add the following: + +```terraform + terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = ">= 4.5.3" + } + } + } + + provider "grafana" { + cloud_api_url = "" + cloud_access_policy_token = "" + } +``` + +Replace the following field values: + +- `Stack-URL` with the URL of your Grafana stack, for example `https://my-stack.grafana.net/` +- `Service-account-token` with the service account token that you created + +## Create new plugin resource + +Create a file named `plugins.tf` and add the following: + +```terraform +resource "grafana_cloud_plugin_installation" "grafana-clock-panel" { + stack_slug = "" + slug = "grafana-clock-panel" + version = "latest" +} +``` + +## Conclusion + +In this guide, you learned how to install a plugin in Grafana Cloud using Terraform. + +To learn more about plugin installation, refer to [Grafana provider's documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/cloud_plugin_installation). diff --git a/docs/sources/observability-as-code/_index.md b/docs/sources/as-code/observability-as-code/_index.md similarity index 97% rename from docs/sources/observability-as-code/_index.md rename to docs/sources/as-code/observability-as-code/_index.md index 33a2c6aeb4d..96c5275781a 100644 --- a/docs/sources/observability-as-code/_index.md +++ b/docs/sources/as-code/observability-as-code/_index.md @@ -48,6 +48,9 @@ hero: height: 110 level: 1 width: 110 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/ +aliases: + - ../observability-as-code/ # /docs/grafana/next/observability-as-code/ --- {{< docs/hero-simple key="hero" >}} diff --git a/docs/sources/observability-as-code/foundation-sdk/_index.md b/docs/sources/as-code/observability-as-code/foundation-sdk/_index.md similarity index 96% rename from docs/sources/observability-as-code/foundation-sdk/_index.md rename to docs/sources/as-code/observability-as-code/foundation-sdk/_index.md index a332cc10bb8..86bb5bd5f52 100644 --- a/docs/sources/observability-as-code/foundation-sdk/_index.md +++ b/docs/sources/as-code/observability-as-code/foundation-sdk/_index.md @@ -10,6 +10,9 @@ labels: - oss title: Foundation SDK weight: 250 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/foundation-sdk/ +aliases: + - ../../observability-as-code/foundation-sdk/ # /docs/grafana/next/observability-as-code/foundation-sdk/ --- # Get started with the Grafana Foundation SDK @@ -181,7 +184,7 @@ After you've defined your dashboard as code, build the final dashboard represent With the JSON payload, you can: - **Manually import:** Paste into Grafana’s dashboard import feature. -- **Automate:** Use [Grafana’s API](../../developers/http_api/) or the [Grafana CLI](../grafana-cli/) to programmatically upload the dashboard JSON. +- **Automate:** Use [Grafana's API](/docs/grafana//developer-resources/api-reference/http-api/) or the [Grafana CLI](../grafana-cli/) to programmatically upload the dashboard JSON. ## Concepts diff --git a/docs/sources/observability-as-code/foundation-sdk/dashboard-automation.md b/docs/sources/as-code/observability-as-code/foundation-sdk/dashboard-automation.md similarity index 97% rename from docs/sources/observability-as-code/foundation-sdk/dashboard-automation.md rename to docs/sources/as-code/observability-as-code/foundation-sdk/dashboard-automation.md index 16149c71d2c..e257936328a 100644 --- a/docs/sources/observability-as-code/foundation-sdk/dashboard-automation.md +++ b/docs/sources/as-code/observability-as-code/foundation-sdk/dashboard-automation.md @@ -12,6 +12,9 @@ labels: - oss title: Automate dashboard provisioning with CI/CD weight: 200 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/foundation-sdk/dashboard-automation/ +aliases: + - ../../../observability-as-code/foundation-sdk/dashboard-automation/ # /docs/grafana/next/observability-as-code/foundation-sdk/dashboard-automation/ --- # Automate dashboard provisioning with CI/CD diff --git a/docs/sources/observability-as-code/get-started.md b/docs/sources/as-code/observability-as-code/get-started.md similarity index 86% rename from docs/sources/observability-as-code/get-started.md rename to docs/sources/as-code/observability-as-code/get-started.md index 52aa16e4f66..97271dbf430 100644 --- a/docs/sources/observability-as-code/get-started.md +++ b/docs/sources/as-code/observability-as-code/get-started.md @@ -13,6 +13,9 @@ labels: - 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 @@ -67,19 +70,19 @@ Refer to the [Foundation SDK](../foundation-sdk) documentation for more informat 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/developer-resources/infrastructure-as-code/terraform/) +- [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/developer-resources/infrastructure-as-code/ansible/) +- [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/developer-resources/infrastructure-as-code/grafana-operator/) +- [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/developer-resources/infrastructure-as-code/grizzly/dashboards-folders-datasources/) is a deprecated command-line tool that simplifies managing Grafana resources using Kubernetes-inspired YAML syntax. +- [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. diff --git a/docs/sources/observability-as-code/grafana-cli/_index.md b/docs/sources/as-code/observability-as-code/grafana-cli/_index.md similarity index 87% rename from docs/sources/observability-as-code/grafana-cli/_index.md rename to docs/sources/as-code/observability-as-code/grafana-cli/_index.md index 07ce089d81f..4fef9d2c699 100644 --- a/docs/sources/observability-as-code/grafana-cli/_index.md +++ b/docs/sources/as-code/observability-as-code/grafana-cli/_index.md @@ -38,6 +38,9 @@ hero: title: Introduction to Grafana CLI menuTitle: Grafana CLI weight: 130 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/grafana-cli/ +aliases: + - ../../observability-as-code/grafana-cli/ # /docs/grafana/next/observability-as-code/grafana-cli/ --- {{< docs/hero-simple key="hero" >}} diff --git a/docs/sources/observability-as-code/grafana-cli/grafanacli-workflows.md b/docs/sources/as-code/observability-as-code/grafana-cli/grafanacli-workflows.md similarity index 96% rename from docs/sources/observability-as-code/grafana-cli/grafanacli-workflows.md rename to docs/sources/as-code/observability-as-code/grafana-cli/grafanacli-workflows.md index e0c0d0fe51a..9b1c483cb90 100644 --- a/docs/sources/observability-as-code/grafana-cli/grafanacli-workflows.md +++ b/docs/sources/as-code/observability-as-code/grafana-cli/grafanacli-workflows.md @@ -13,6 +13,9 @@ labels: - oss title: Manage resources with Grafana CLI weight: 300 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/grafana-cli/grafanacli-workflows/ +aliases: + - ../../../observability-as-code/grafana-cli/grafanacli-workflows/ # /docs/grafana/next/observability-as-code/grafana-cli/grafanacli-workflows/ --- # Manage resources with Grafana CLI diff --git a/docs/sources/observability-as-code/grafana-cli/install-grafana-cli.md b/docs/sources/as-code/observability-as-code/grafana-cli/install-grafana-cli.md similarity index 82% rename from docs/sources/observability-as-code/grafana-cli/install-grafana-cli.md rename to docs/sources/as-code/observability-as-code/grafana-cli/install-grafana-cli.md index 01c0059c0a5..d99c91114fc 100644 --- a/docs/sources/observability-as-code/grafana-cli/install-grafana-cli.md +++ b/docs/sources/as-code/observability-as-code/grafana-cli/install-grafana-cli.md @@ -14,6 +14,9 @@ labels: - oss title: Install Grafana CLI weight: 100 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/grafana-cli/install-grafana-cli/ +aliases: + - ../../../observability-as-code/grafana-cli/install-grafana-cli/ # /docs/grafana/next/observability-as-code/grafana-cli/install-grafana-cli/ --- # Install Grafana CLI diff --git a/docs/sources/observability-as-code/grafana-cli/set-up-grafana-cli.md b/docs/sources/as-code/observability-as-code/grafana-cli/set-up-grafana-cli.md similarity index 93% rename from docs/sources/observability-as-code/grafana-cli/set-up-grafana-cli.md rename to docs/sources/as-code/observability-as-code/grafana-cli/set-up-grafana-cli.md index d2498b15ee9..a5076c39c75 100644 --- a/docs/sources/observability-as-code/grafana-cli/set-up-grafana-cli.md +++ b/docs/sources/as-code/observability-as-code/grafana-cli/set-up-grafana-cli.md @@ -13,6 +13,9 @@ labels: - oss title: Set up Grafana CLI weight: 200 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/grafana-cli/set-up-grafana-cli/ +aliases: + - ../../../observability-as-code/grafana-cli/set-up-grafana-cli/ # /docs/grafana/next/observability-as-code/grafana-cli/set-up-grafana-cli/ --- # Set up Grafana CLI diff --git a/docs/sources/observability-as-code/provision-resources/_index.md b/docs/sources/as-code/observability-as-code/provision-resources/_index.md similarity index 94% rename from docs/sources/observability-as-code/provision-resources/_index.md rename to docs/sources/as-code/observability-as-code/provision-resources/_index.md index 6e0f1665d67..c057c71e755 100644 --- a/docs/sources/observability-as-code/provision-resources/_index.md +++ b/docs/sources/as-code/observability-as-code/provision-resources/_index.md @@ -14,6 +14,9 @@ labels: title: Provision resources and sync dashboards menuTitle: Provision resources weight: 300 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/provision-resources/ +aliases: + - ../../observability-as-code/provision-resources/ # /docs/grafana/next/observability-as-code/provision-resources/ --- # Provision resources and sync dashboards diff --git a/docs/sources/observability-as-code/provision-resources/file-path-setup.md b/docs/sources/as-code/observability-as-code/provision-resources/file-path-setup.md similarity index 96% rename from docs/sources/observability-as-code/provision-resources/file-path-setup.md rename to docs/sources/as-code/observability-as-code/provision-resources/file-path-setup.md index 6b16926d969..f2cd24a46b1 100644 --- a/docs/sources/observability-as-code/provision-resources/file-path-setup.md +++ b/docs/sources/as-code/observability-as-code/provision-resources/file-path-setup.md @@ -11,6 +11,9 @@ labels: - oss title: Set up file provisioning weight: 200 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/provision-resources/file-path-setup/ +aliases: + - ../../../observability-as-code/provision-resources/file-path-setup/ # /docs/grafana/next/observability-as-code/provision-resources/file-path-setup/ --- # Set up file provisioning diff --git a/docs/sources/observability-as-code/provision-resources/git-sync-setup.md b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-setup.md similarity index 97% rename from docs/sources/observability-as-code/provision-resources/git-sync-setup.md rename to docs/sources/as-code/observability-as-code/provision-resources/git-sync-setup.md index dd94b7e507b..0a7bd7be28b 100644 --- a/docs/sources/observability-as-code/provision-resources/git-sync-setup.md +++ b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-setup.md @@ -11,6 +11,9 @@ labels: - oss title: Set up Git Sync weight: 100 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/provision-resources/git-sync-setup/ +aliases: + - ../../../observability-as-code/provision-resources/git-sync-setup/ # /docs/grafana/next/observability-as-code/provision-resources/git-sync-setup/ --- # Set up Git Sync diff --git a/docs/sources/observability-as-code/provision-resources/intro-git-sync.md b/docs/sources/as-code/observability-as-code/provision-resources/intro-git-sync.md similarity index 96% rename from docs/sources/observability-as-code/provision-resources/intro-git-sync.md rename to docs/sources/as-code/observability-as-code/provision-resources/intro-git-sync.md index 5b1b1809463..4d9bda529a3 100644 --- a/docs/sources/observability-as-code/provision-resources/intro-git-sync.md +++ b/docs/sources/as-code/observability-as-code/provision-resources/intro-git-sync.md @@ -18,6 +18,9 @@ refs: destination: /docs/grafana-cloud/account-management/authentication-and-permissions/cloud-roles/ title: Git Sync weight: 100 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/provision-resources/intro-git-sync/ +aliases: + - ../../../observability-as-code/provision-resources/intro-git-sync/ # /docs/grafana/next/observability-as-code/provision-resources/intro-git-sync/ --- # Introduction to Git Sync @@ -81,7 +84,7 @@ Refer to [Requirements](https://grafana.com/docs/grafana//obser - You can only sync dashboards and folders. Refer to [Supported resources](#supported-resources) for more information. - If you're using Git Sync in Grafana OSS and Grafana Enterprise, some resources might be in an incompatible data format and won't be synced. -- Full-instance sync is not available in Grafana Cloud and has limitations in Grafana OSS and Grafana Enterprise. Refer to [Choose what to synchronize](../git-sync-setup.md#choose-what-to-synchronize) for more details. +- Full-instance sync is not available in Grafana Cloud and has limitations in Grafana OSS and Grafana Enterprise. Refer to [Choose what to synchronize](../git-sync-setup/#choose-what-to-synchronize) for more details. - When migrating to full instance sync, during the synchronization process your resources will be temporarily unavailable. No one will be able to create, edit, or delete resources during this process. - If you want to manage existing resources with Git Sync, you need to save them as JSON files and commit them to the synced repository. Open a PR to import, copy, move, or save a dashboard. - Restoring resources from the UI is currently not possible. As an alternative, you can restore dashboards directly in your GitHub repository by raising a PR, and they will be updated in Grafana. diff --git a/docs/sources/observability-as-code/provision-resources/provisioned-dashboards.md b/docs/sources/as-code/observability-as-code/provision-resources/provisioned-dashboards.md similarity index 96% rename from docs/sources/observability-as-code/provision-resources/provisioned-dashboards.md rename to docs/sources/as-code/observability-as-code/provision-resources/provisioned-dashboards.md index fbe08fd3389..cc4964ff9e1 100644 --- a/docs/sources/observability-as-code/provision-resources/provisioned-dashboards.md +++ b/docs/sources/as-code/observability-as-code/provision-resources/provisioned-dashboards.md @@ -11,6 +11,9 @@ labels: - oss title: Work with provisioned dashboards weight: 300 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/provision-resources/provisioned-dashboards/ +aliases: + - ../../../observability-as-code/provision-resources/provisioned-dashboards/ # /docs/grafana/next/observability-as-code/provision-resources/provisioned-dashboards/ --- # Work with provisioned dashboards diff --git a/docs/sources/observability-as-code/provision-resources/use-git-sync.md b/docs/sources/as-code/observability-as-code/provision-resources/use-git-sync.md similarity index 93% rename from docs/sources/observability-as-code/provision-resources/use-git-sync.md rename to docs/sources/as-code/observability-as-code/provision-resources/use-git-sync.md index 11751314865..257e83c41ed 100644 --- a/docs/sources/observability-as-code/provision-resources/use-git-sync.md +++ b/docs/sources/as-code/observability-as-code/provision-resources/use-git-sync.md @@ -14,6 +14,9 @@ labels: title: Manage provisioned repositories with Git Sync menuTitle: Manage repositories with Git Sync weight: 400 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/provision-resources/use-git-sync/ +aliases: + - ../../../observability-as-code/provision-resources/use-git-sync/ # /docs/grafana/next/observability-as-code/provision-resources/use-git-sync/ --- # Manage provisioned repositories with Git Sync diff --git a/docs/sources/observability-as-code/schema-v2/_index.md b/docs/sources/as-code/observability-as-code/schema-v2/_index.md similarity index 97% rename from docs/sources/observability-as-code/schema-v2/_index.md rename to docs/sources/as-code/observability-as-code/schema-v2/_index.md index 9dafaf936bf..5eb693f0f1d 100644 --- a/docs/sources/observability-as-code/schema-v2/_index.md +++ b/docs/sources/as-code/observability-as-code/schema-v2/_index.md @@ -14,6 +14,9 @@ labels: - oss title: JSON schema v2 weight: 200 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/ +aliases: + - ../../observability-as-code/schema-v2/ # /docs/grafana/next/observability-as-code/schema-v2/ --- # Dashboard JSON schema v2 diff --git a/docs/sources/observability-as-code/schema-v2/annotations-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/annotations-schema.md similarity index 90% rename from docs/sources/observability-as-code/schema-v2/annotations-schema.md rename to docs/sources/as-code/observability-as-code/schema-v2/annotations-schema.md index 7d5e057d6e8..e99e7c2cce6 100644 --- a/docs/sources/observability-as-code/schema-v2/annotations-schema.md +++ b/docs/sources/as-code/observability-as-code/schema-v2/annotations-schema.md @@ -17,6 +17,9 @@ labels: menuTitle: annotations schema title: annotations weight: 100 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/annotations-schema/ +aliases: + - ../../../observability-as-code/schema-v2/annotations-schema/ # /docs/grafana/next/observability-as-code/schema-v2/annotations-schema/ --- # `annotations` diff --git a/docs/sources/observability-as-code/schema-v2/layout-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/layout-schema.md similarity index 98% rename from docs/sources/observability-as-code/schema-v2/layout-schema.md rename to docs/sources/as-code/observability-as-code/schema-v2/layout-schema.md index 71bd7e30399..ca31417bbcf 100644 --- a/docs/sources/observability-as-code/schema-v2/layout-schema.md +++ b/docs/sources/as-code/observability-as-code/schema-v2/layout-schema.md @@ -17,6 +17,9 @@ labels: menuTitle: layout schema title: layout weight: 400 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/layout-schema/ +aliases: + - ../../../observability-as-code/schema-v2/layout-schema/ # /docs/grafana/next/observability-as-code/schema-v2/layout-schema/ --- # `layout` diff --git a/docs/sources/observability-as-code/schema-v2/librarypanel-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/librarypanel-schema.md similarity index 86% rename from docs/sources/observability-as-code/schema-v2/librarypanel-schema.md rename to docs/sources/as-code/observability-as-code/schema-v2/librarypanel-schema.md index 323fa388d8c..45715e15b15 100644 --- a/docs/sources/observability-as-code/schema-v2/librarypanel-schema.md +++ b/docs/sources/as-code/observability-as-code/schema-v2/librarypanel-schema.md @@ -17,6 +17,9 @@ labels: menuTitle: LibraryPanelKind schema title: LibraryPanelKind weight: 300 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/librarypanel-schema/ +aliases: + - ../../../observability-as-code/schema-v2/librarypanel-schema/ # /docs/grafana/next/observability-as-code/schema-v2/librarypanel-schema/ --- # `LibraryPanelKind` diff --git a/docs/sources/observability-as-code/schema-v2/links-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/links-schema.md similarity index 90% rename from docs/sources/observability-as-code/schema-v2/links-schema.md rename to docs/sources/as-code/observability-as-code/schema-v2/links-schema.md index 3439e57d16b..9598477354f 100644 --- a/docs/sources/observability-as-code/schema-v2/links-schema.md +++ b/docs/sources/as-code/observability-as-code/schema-v2/links-schema.md @@ -17,6 +17,9 @@ labels: menuTitle: links schema title: links weight: 500 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/links-schema/ +aliases: + - ../../../observability-as-code/schema-v2/links-schema/ # /docs/grafana/next/observability-as-code/schema-v2/links-schema/ --- # `links` diff --git a/docs/sources/observability-as-code/schema-v2/panel-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/panel-schema.md similarity index 98% rename from docs/sources/observability-as-code/schema-v2/panel-schema.md rename to docs/sources/as-code/observability-as-code/schema-v2/panel-schema.md index 629d8d45fdf..088ab8eebf4 100644 --- a/docs/sources/observability-as-code/schema-v2/panel-schema.md +++ b/docs/sources/as-code/observability-as-code/schema-v2/panel-schema.md @@ -17,6 +17,9 @@ labels: menuTitle: PanelKind schema title: PanelKind weight: 200 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/panel-schema/ +aliases: + - ../../../observability-as-code/schema-v2/panel-schema/ # /docs/grafana/next/observability-as-code/schema-v2/panel-schema/ --- # `PanelKind` diff --git a/docs/sources/observability-as-code/schema-v2/timesettings-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/timesettings-schema.md similarity index 92% rename from docs/sources/observability-as-code/schema-v2/timesettings-schema.md rename to docs/sources/as-code/observability-as-code/schema-v2/timesettings-schema.md index 7cf3483d5fb..8db14212740 100644 --- a/docs/sources/observability-as-code/schema-v2/timesettings-schema.md +++ b/docs/sources/as-code/observability-as-code/schema-v2/timesettings-schema.md @@ -17,6 +17,9 @@ labels: menuTitle: timesettings schema title: timesettings weight: 600 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/timesettings-schema/ +aliases: + - ../../../observability-as-code/schema-v2/timesettings-schema/ # /docs/grafana/next/observability-as-code/schema-v2/timesettings-schema/ --- # `timeSettings` diff --git a/docs/sources/observability-as-code/schema-v2/variables-schema.md b/docs/sources/as-code/observability-as-code/schema-v2/variables-schema.md similarity index 98% rename from docs/sources/observability-as-code/schema-v2/variables-schema.md rename to docs/sources/as-code/observability-as-code/schema-v2/variables-schema.md index 8706858ea1f..549478692f1 100644 --- a/docs/sources/observability-as-code/schema-v2/variables-schema.md +++ b/docs/sources/as-code/observability-as-code/schema-v2/variables-schema.md @@ -17,6 +17,9 @@ labels: menuTitle: variables schema title: variables weight: 700 +canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/variables-schema/ +aliases: + - ../../../observability-as-code/schema-v2/variables-schema/ # /docs/grafana/next/observability-as-code/schema-v2/variables-schema/ --- # `variables` diff --git a/docs/sources/developer-resources/_index.md b/docs/sources/developer-resources/_index.md new file mode 100644 index 00000000000..83144f6f632 --- /dev/null +++ b/docs/sources/developer-resources/_index.md @@ -0,0 +1,44 @@ +--- +description: Provides developer-oriented content. +menuTitle: Developer resources +title: Developer resources +labels: + products: + - enterprise + - oss + - cloud +weight: 900 +canonical: https://grafana.com/docs/grafana/latest/developer-resources/ +--- + +# Developer resources + +This section of the documentation contains additional resources for developers and contributors. + +## Contribute to Grafana + +Refer to the [Contribute to Grafana](https://grafana.com/docs/grafana-cloud/developer-resources/contribute) guide to learn the various ways you can contribute to Grafana. Read the [Grafana Labs Software Grant and Contributor License Agreement](https://grafana.com/docs/grafana-cloud/developer-resources/cla) before making any contribution. + +## Grafana developer portal + +Go to the [Grafana developer portal](https://grafana.com/developers) to access the following documentation: + +- [Grafana plugin development tools](https://grafana.com/developers/plugin-tools) +- [Grafana data model](https://grafana.com/developers/dataplane) +- [Grafana React components library](https://developers.grafana.com) +- [Grafana Scenes library](https://grafana.com/developers/scenes) + +## API reference guides + +Learn about the following Grafana APIs: + +- [HTTP API](./api-reference/http-api/) +- [Cloud API](./api-reference/cloud-api/) +- [Tracing API](./api-reference/tracing-api/) + +## Other resources + +You might also find the following resources to be helpful: + +- [Grafana Tutorials:](https://grafana.com/tutorials/) Step-by-step guides that help you make the most of Grafana. +- [Grafana Community Forums:](https://community.grafana.com) Get technical support for open source Grafana, Loki, and Tempo. diff --git a/docs/sources/developer-resources/api-reference/cloud-api.md b/docs/sources/developer-resources/api-reference/cloud-api.md new file mode 100644 index 00000000000..bade8ada7e0 --- /dev/null +++ b/docs/sources/developer-resources/api-reference/cloud-api.md @@ -0,0 +1,2101 @@ +--- +title: Grafana Cloud API +labels: + products: + - cloud +weight: 125 +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/cloud-api/ +--- + +# Grafana Cloud API + +The Grafana Cloud API, sometimes referred to as the Grafana.com API or GCOM API, allows you to interact with resources from your [Grafana Cloud Stack](/docs/grafana-cloud/account-management/cloud-stacks/) programmatically. + +Below is the list of approved, static endpoints and calls for general use. Any other paths are subject to change and are not maintained for general user consumption. + +{{< admonition type="note" >}} + +If you need to manage or access resources from your Grafana instance—such as dashboards, alerts, data sources, users, and more—refer to the [HTTP API](../http-api/) instead. + +{{< /admonition >}} + +## Authentication + +You must create a Cloud Access Policy and token to use the Cloud API. +To create a Grafana Cloud Access Policy, refer to [Create an access policy](https://grafana.com/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-policies/create-access-policies/). + +Requests to the API are authenticated using the `Authorization` header: + +```http +Authorization: Bearer +``` + +## Access policies and tokens + +{{< admonition type="note" >}} +The rate limit on the Grafana Cloud API endpoint for access policies is 600 per hour. +{{< /admonition >}} + +Access policies and tokens use `access policy`, `token`, `scope`, `realm`, `labelselector` and `conditions` resources. +For more information about these resources, refer to [Grafana Cloud Access Policies](https://grafana.com/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-policies/). + +{{< admonition type="note" >}} +Access policies and tokens must have a unique combination of name, org ID, and region. +{{< /admonition >}} + +All API requests must specify a token in the request's `Authorization` header. + +This API relies on stack and org IDs, and region: + +- Stack ID: get from the `https://grafana.com/api/orgs/{org}/instances` endpoint +- Org ID: get from the `https://grafana.com/api/orgs/{org}` endpoint +- Region: get the region of your stack from the `https://grafana.com/api/orgs/{org}/instances` endpoint or get a list of all the available regions from the `https://grafana.com/api/stack-regions` endpoint + +Paginated endpoints optionally accept both `pageSize` and `pageCursor` query parameters. +If you omit the `pageCursor` parameter, or provide an empty `pageCursor` value, you receive the first page. + +You can get the next page's URL the current page's `metadata.pagination.nextPage` property. +If that field is `null`, you've reached the last page and there are no records left. + +### Create an access policy + +Create an access policy with the `POST` method. + +```http +POST https://www.grafana.com/api/v1/accesspolicies +``` + +#### Parameters + +| Name | Type | Description | Required | +| -------- | ------ | -------------------------------------------------------------------- | -------- | +| `region` | String | Region for the Access Policy. Generally where the stack is deployed. | Yes | + +#### Request body + +| Name | Type | Description | Required | +| ------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --- | --- | +| `name` | String | Name of the access policy. It must be 1-255 characters long. Characters can only include lowercase letters from the English alphabet (a-z), numbers (0-9), hyphens (-) and underscores (\_). | Yes | +| `displayName` | String | Display name of the access policy, visible in the UI. Set to `name` if not provided. It must be 1-255 characters long. | No | | No | +| `scopes` | List[String] | List of [**scopes**](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/#scopes). | Yes | +| `realms` | List[Realm] | List of [**realms**](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/#realms). | Yes | +| `conditions` | Conditions | A set of criteria that is used to restrict access of the access policy and tokens. | No | + +**Realm** + +| Name | Type | Description | Required | +| --------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | +| `type` | String | Type of realm. Can be `org` or `stack`. | Yes | +| `identifier` | String | The unique identifier of a realm (ID of the org or stack depending on the realm type). | Yes | +| `labelPolicies` | List[LabelPolicy] | List of [label policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/#labelpolicy-or-label-selectors). Available only with read permissions for metrics and logs. | No | + +**LabelPolicy** + +| Name | Type | Description | Required | +| ---------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `selector` | String | [Label selector](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/#labelpolicy-or-label-selectors). | Yes | + +**Conditions** + +| Name | Type | Description | Required | +| ---------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `allowedSubnets` | List[String] | An array of IP addresses with subnet masks in CIDR notation (both IPv4 or IPv6 are supported) for [IP range based access control](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/#ip-range-based-access-control). | Yes | + +Example request: + +```json +{ + "name": "stack-readers", + "displayName": "Stack Readers", + "scopes": ["metrics:read", "logs:read", "traces:read", "alerts:read"], + "realms": [ + { + "type": "stack", + "identifier": "123", + "labelPolicies": [ + { + "selector": "{env != \"dev\"}" + } + ] + } + ], + "conditions": { + "allowedSubnets": ["192.168.0.0/24", "10.1.2.99/32"] + } +} +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------- | +| `200` | Successful operation | + +Example response: + +```json +{ + "id": "c45485b6-8321-4cf2-bcec-12006df755ff", + "orgId": "1", + "name": "stack-readers", + "displayName": "Stack Readers", + "scopes": ["metrics:read", "logs:read", "traces:read", "alerts:read"], + "realms": [ + { + "type": "stack", + "identifier": "123", + "labelPolicies": [ + { + "selector": "{env != \"dev\"}" + } + ] + } + ], + "conditions": { + "allowedSubnets": ["192.168.0.0/24", "10.1.2.99/32"] + }, + "createdAt": "2022-06-08T20:07:21.223Z", + "updatedAt": "2022-06-08T20:07:21.223Z" +} +``` + +| Code | Description | +| ----- | -------------------------------- | +| `400` | Bad request | +| `401` | API token is missing or invalid. | +| `409` | Conflict | + +### List access policies + +List specified access policies with the `GET` method. + +```http +GET https://www.grafana.com/api/v1/accesspolicies +``` + +#### Parameters + +| Name | Type | Description | Required | +| ----------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `name` | Query | Name of the access policy to filter by. | No | +| `realmType` | String | Query. Available values are `org` and `stack`. | No | +| `realmIdentifier` | String | Requires `realmType`. Identifier of the realm. | No | +| `pageSize` | String | The number of records to be returned per page. The default value is 500; the maximum value is 500. | No | +| `pageCursor` | String | Query. A cursor used for paging through the results. If you omit the `pageCursor` parameter, or provide an empty `pageCursor` value, you receive the first page. | No | +| `region` | String | Query. Region of the Access Policy, as defined in the Access Policy. Some example values are `us`, `eu`, `au`, and `prod-eu-west-3`. For more information, refer to [List regions](#list-regions). | Yes | +| `status` | String | Query. A status that can be used to filter the final list of Access Policies. Available values are `active` and `inactive`. | No | + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------- | +| `200` | Successful operation | + +Example response: + +```json +{ + "items": [ + { + "id": "c45485b6-8321-4cf2-bcec-12006df755ff", + "orgId": "1", + "name": "stack-readers", + "displayName": "Stack Readers", + "scopes": ["metrics:read", "logs:read", "traces:read", "alerts:read"], + "realms": [ + { + "type": "stack", + "identifier": "123", + "labelPolicies": [ + { + "selector": "{env != \"dev\"}" + } + ] + } + ], + "conditions": { + "allowedSubnets": ["192.168.0.0/24", "10.1.2.99/32"] + }, + "createdAt": "2022-06-08T16:47:46.151Z", + "updatedAt": "2022-06-08T16:47:46.151Z", + "status": "active" + } + ], + "metadata": { + "pagination": { + "pageSize": 500, + "pageCursor": "ZDMyYzZhODktZjU1ZC00NGViLWJmYWEtMTEyYmE2NTFlNDJifDIwMjItMDQtMTFUMTI6NTQ6MDBa", + "nextPage": "/v1/accesspolicies?pageCursor=ZDMyYzZhODktZjU1ZC00NGViLWJmYWEtMTEyYmE2NTFlNDJifDIwMjItMDQtMTFUMTI6NTQ6MDBa" + } + } +} +``` + +| Code | Description | +| ----- | -------------------------------- | +| `400` | Bad request | +| `401` | API token is missing or invalid. | + +### List one access policy + +List one access policy with the `GET` method. + +```http +GET https://www.grafana.com/api/v1/accesspolicies/{accessPolicyID} +``` + +#### Parameters + +| Name | Type | Description | Required | +| ---------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `region` | String | Query. Region of the Access Policy, as defined in the Access Policy. Some example values are `us`, `eu`, `au`, and `prod-eu-west-3`. For more information, refer to [List regions](#list-regions). | Yes | +| `accessPolicyId` | String | UUID. Path. ID of the access policy. | Yes | + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------- | +| `200` | Successful operation | + +Example response: + +```json +{ + "id": "c45485b6-8321-4cf2-bcec-12006df755ff", + "orgId": "1", + "name": "stack-readers", + "displayName": "Stack Readers", + "scopes": ["metrics:read", "logs:read", "traces:read", "alerts:read"], + "realms": [ + { + "type": "stack", + "identifier": "123", + "labelPolicies": [ + { + "selector": "{env != \"dev\"}" + } + ] + } + ], + "conditions": { + "allowedSubnets": ["192.168.0.0/24", "10.1.2.99/32"] + }, + "createdAt": "2022-06-08T21:06:27.853Z", + "updatedAt": "2022-06-08T21:06:27.853Z", + "status": "active" +} +``` + +| Code | Description | +| ----- | -------------------------------- | +| `400` | Bad request | +| `401` | API token is missing or invalid. | + +### Update an access policy + +Update an existing access policy with the `POST` method. + +{{< admonition type="note" >}} +To remove [IP ranges](/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/ip-ranges-access-policies/) do one of the following: + +- set `allowedSubnets` to an empty array (`[]`) +- set `conditions` to `null` or to an empty object (`{}`). + +{{< /admonition >}} + +```http +POST https://www.grafana.com/api/v1/accesspolicies/{accessPolicyId} +``` + +#### Parameters + +| Name | Type | Description | Required | +| ---------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `region` | String | Query. Region of the Access Policy, as defined in the Access Policy. Some example values are `us`, `eu`, `au`, and `prod-eu-west-3`. For more information, refer to [List regions](#list-regions). | Yes | +| `accessPolicyId` | String | (`UUID`). Path. ID of the access policy. | Yes | + +#### Request body + +The request body specifies the revised access policy. + +| Name | Type | Description | Required | +| ------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | --- | --- | +| `displayName` | String | Display name of the access policy, visible in the UI. It must be 1-255 characters long. | No | | No | +| `scopes` | List[String] | List of [**scopes**](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/#scopes). | Yes | +| `realms` | List[Realm] | List of [**realms**](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/#realms). | Yes | +| `conditions` | Conditions | A set of criteria that is used to restrict access of the access policy and tokens. Providing an empty object `{}` results in the complete removal of `conditions`. | No | +| `status` | String | The status of the access policy. Must be `active` or `inactive`. | No | + +**Realm** + +| Name | Type | Description | Required | +| --------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | +| `type` | String | Type of realm. Can be `org` or `stack`. | Yes | +| `identifier` | String | The unique identifier of a realm (ID of the org or stack depending on the realm type). | Yes | +| `labelPolicies` | List[LabelPolicy] | List of [label policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/#labelpolicy-or-label-selectors). Available only with read permissions for metrics and logs. | No | + +**LabelPolicy** + +| Name | Type | Description | Required | +| ---------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `selector` | String | [Label selector](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/#labelpolicy-or-label-selectors). | Yes | + +**Conditions** + +| Name | Type | Description | Required | +| ---------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `allowedSubnets` | List[String] | An array of IP addresses with subnet masks in CIDR notation (both IPv4 or IPv6 are supported) for [IP range based access control](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/#ip-range-based-access-control). | Yes | + +Example request: + +```json +{ + "displayName": "Stack Readers", + "scopes": ["metrics:read", "logs:read", "traces:read", "alerts:read"], + "realms": [ + { + "type": "stack", + "identifier": "123", + "labelPolicies": [ + { + "selector": "{env != \"dev\"}" + } + ] + } + ], + "conditions": { + "allowedSubnets": ["192.168.99.100/32"] + }, + "status": "active" +} +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------- | +| `200` | Successful operation | + +Example response: + +```json +{ + "id": "c45485b6-8321-4cf2-bcec-12006df755ff", + "orgId": "1", + "name": "stack-readers", + "displayName": "Stack Readers", + "scopes": ["metrics:read", "logs:read", "traces:read", "alerts:read"], + "realms": [ + { + "type": "stack", + "identifier": "123", + "labelPolicies": [ + { + "selector": "{env != \"dev\"}" + } + ] + } + ], + "conditions": { + "allowedSubnets": ["192.168.99.100/32"] + }, + "createdAt": "2022-06-08T21:10:37.011Z", + "updatedAt": "2022-06-08T21:10:37.011Z", + "status": "active" +} +``` + +| Code | Description | +| ----- | -------------------------------- | +| `400` | Bad request | +| `401` | API token is missing or invalid. | + +### Delete an access policy + +Remove an access policy with the `DELETE` method. + +```http +DELETE https://www.grafana.com/api/v1/accesspolicies/{accessPolicyId} +``` + +#### Parameters + +| Name | Type | Description | Required | +| ---------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `region` | String | Query. Region of the Access Policy, as defined in the Access Policy. Some example values are `us`, `eu`, `au`, and `prod-eu-west-3`. For more information, refer to [List regions](#list-regions). | Yes | +| `accessPolicyId` | String | (`UUID`). Path. ID of the access policy. | Yes | + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | ----------------------------------- | +| `204` | Access policy deleted successfully. | +| `400` | Bad request | +| `401` | API token is missing or invalid | + +### Create a token + +Create a token with the `POST` method. + +```http +POST https://www.grafana.com/api/v1/tokens +``` + +#### Parameters + +| Name | Type | Description | Required | +| -------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `region` | String | Query. Region of the Access Policy, as defined in the Access Policy. Some example values are `us`, `eu`, `au`, and `prod-eu-west-3`. For more information, refer to [List regions](#list-regions). | Yes | + +#### Request body + +The request body contains details about the token being created. + +| Name | Type | Description | Required | +| ---------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `name` | String | Name of the access policy. It must be 1-255 characters long. Characters can only include lowercase letters from the English alphabet (a-z), numbers (0-9), hyphens (-) and underscores (\_). | Yes | +| `displayName` | String | Display name of the token, visible in the UI. Set to `name` if not provided. It must be 1-255 characters long. | No | +| `accessPolicyId` | String | ID of the access policy to create the token for. | Yes | +| `expiresAt` | String | Token expiration date. The token does not expire if this is not provided. | No | + +Example request: + +```json +{ + "accessPolicyId": "c45485b6-8321-4cf2-bcec-12006df755ff", + "name": "mytoken", + "displayName": "My Token", + "expiresAt": "2022-06-08T22:05:46.958Z" +} +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------- | +| `200` | Successful operation | + +Example response: + +```json +{ + "id": "c45485b6-8321-4cf2-bcec-12006df755ff", + "accessPolicyId": "c45485b6-8321-4cf2-bcec-12006df755ff", + "name": "mytoken", + "displayName": "My Token", + "expiresAt": "2022-06-08T22:05:46.959Z", + "firstUsedAt": "2022-06-08T22:05:46.959Z", + "lastUsedAt": "2022-06-08T22:05:46.959Z", + "createdAt": "2022-06-08T22:05:46.959Z", + "updatedAt": "2022-06-08T22:05:46.959Z", + "token": "glc_eyJrIjoiZjI0YzZkNGEwZDBmZmZjMmUzNTU2ODcxMmY0ZWZlNTQ1NTljMDFjOCIsIm4iOiJteXRva2VuIiwiaWQiOjF9" +} +``` + +| Code | Description | +| ----- | ------------------------------- | +| `400` | Bad request | +| `401` | API token is missing or invalid | +| `409` | Conflict | + +### List a set of tokens + +List a set of tokens with the `GET` method. + +```http +GET https://www.grafana.com/api/v1/tokens +``` + +#### Parameters + +| Name | Type | Description | Required | +| ----------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `accessPolicyId ` | String | Query. ID of the access policy to filter by. | No | +| `accessPolicyName` | String | Query. Name of the access policy to filter by. | No | +| `accessPolicyRealmType` | String | Query. Type of the access policy realm. Available values are `org` and `stack`. | No | +| `accessPolicyRealmIdentifier` | String | Query. Identifier of the access policy realm. Requires `accessPolicyRealmType.` | No | +| `name` | String | Query. Name of the Token to filter by. | No | +| `expiresBefore` | String | Query. Time (in ISO8601 UTC format) to filter tokens that have `expiresAt` set before the given time. | No | +| `expiresAfter` | String | Query. Time (in ISO8601 UTC format) to filter tokens that have `expiresAt` set after the given time. | No | +| `pageSize` | String | Query. The number of records to be returned per page. Default value is `500`; the maximum value is `500`. | No | +| `pageCursor` | String | Query. A cursor used for paging through the results. If you omit the `pageCursor` parameter, or provide an empty `pageCursor` value, you receive the first page. | No | +| `region` | String | Query. Region of the Access Policy, as defined in the Access Policy. Some example values are `us`, `eu`, `au`, and `prod-eu-west-3`. For more information, refer to [List regions](#list-regions). | Yes | +| `accessPolicyStatus` | String | Query. A filter to only list tokens which Access Policies are in the given status. Available values are `active` and `inactive`. | No | + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------- | +| `200` | Successful operation | + +Example response: + +```json +{ + "items": [ + { + "id": "c45485b6-8321-4cf2-bcec-12006df755ff", + "accessPolicyId": "c45485b6-8321-4cf2-bcec-12006df755ff", + "name": "mytoken", + "displayName": "My Token", + "expiresAt": "2022-06-08T22:11:05.614Z", + "firstUsedAt": "2022-06-08T22:11:05.614Z", + "lastUsedAt": "2022-06-08T22:11:05.614Z", + "createdAt": "2022-06-08T22:11:05.614Z", + "updatedAt": "2022-06-08T22:11:05.614Z" + } + ], + "metadata": { + "pagination": { + "pageSize": 500, + "pageCursor": "ZDMyYzZhODktZjU1ZC00NGViLWJmYWEtMTEyYmE2NTFlNDJifDIwMjItMDQtMTFUMTI6NTQ6MDBa", + "nextPage": "/v1/accesspolicies?pageCursor=ZDMyYzZhODktZjU1ZC00NGViLWJmYWEtMTEyYmE2NTFlNDJifDIwMjItMDQtMTFUMTI6NTQ6MDBa" + } + } +} +``` + +| Code | Description | +| ----- | ------------------------------- | +| `400` | Bad request | +| `401` | API token is missing or invalid | + +### List a single token + +List a specified token with the `GET` method. + +```http +GET https://www.grafana.com/api/v1/tokens/{tokenId} +``` + +#### Parameters + +| Name | Type | Description | Required | +| --------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `region` | String | Query. Region of the Access Policy, as defined in the Access Policy. Some example values are `us`, `eu`, `au`, and `prod-eu-west-3`. For more information, refer to [List regions](#list-regions). | Yes | +| `tokenId` | String | (`UUID`). Path. ID of the Token. | Yes | + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------- | +| `200` | Successful operation | + +Example response: + +```json +{ + "id": "c45485b6-8321-4cf2-bcec-12006df755ff", + "accessPolicyId": "c45485b6-8321-4cf2-bcec-12006df755ff", + "name": "mytoken", + "displayName": "My Token", + "expiresAt": "2022-06-09T04:31:23.559Z", + "firstUsedAt": "2022-06-09T04:31:23.559Z", + "lastUsedAt": "2022-06-09T04:31:23.559Z", + "createdAt": "2022-06-09T04:31:23.559Z", + "updatedAt": "2022-06-09T04:31:23.559Z" +} +``` + +| Code | Description | +| ----- | ------------------------------- | +| `400` | Bad request | +| `401` | API token is missing or invalid | + +### Update a token + +Update a specified token with the `POST` method. + +```http +POST https://www.grafana.com/api/v1/tokens/{tokenId} +``` + +#### Parameters + +| Name | Type | Description | Required | +| --------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `region` | String | Query. Region of the Access Policy, as defined in the Access Policy. Some example values are `us`, `eu`, `au`, and `prod-eu-west-3`. For more information, refer to [List regions](#list-regions). | Yes | +| `tokenId` | String | (`UUID`). Path. ID of the Token. | Yes | + +#### Request body + +The request body contains the updated values being applied to the token. + +| Name | Type | Description | Required | +| ------------- | ------ | -------------------------------------------------------------------------------------------------------- | -------- | +| `displayName` | String | Display name of the token, visible in the UI. It must be 1-255 characters long. | No | +| `expiresAt` | String | Token expiration date (in ISO8601 UTC format). The token does not expire if this field is set to `null`. | No | + +Example request: + +```json +{ + "displayName": "My token", + "expiresAt": "2022-06-09T04:43:16.296Z" +} +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------- | +| `200` | Successful operation | + +Example response: + +```json +{ + "id": "c45485b6-8321-4cf2-bcec-12006df755ff", + "accessPolicyId": "c45485b6-8321-4cf2-bcec-12006df755ff", + "name": "mytoken", + "displayName": "My token", + "expiresAt": "2022-06-09T04:43:16.296Z", + "firstUsedAt": "2022-06-09T04:43:16.296Z", + "lastUsedAt": "2022-06-09T04:43:16.296Z", + "createdAt": "2022-06-09T04:43:16.296Z", + "updatedAt": "2022-06-09T04:43:16.296Z" +} +``` + +| Code | Description | +| ----- | ------------------------------- | +| `400` | Bad request | +| `401` | API token is missing or invalid | +| `409` | Conflict. | + +### Delete a token + +Remove a specified token with the `DELETE` method. + +```http +DELETE https://www.grafana.com/api/v1/tokens/{tokenId} +``` + +#### Parameters + +| Name | Type | Description | Required | +| --------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `region` | String | Query. Region of the Access Policy, as defined in the Access Policy. Some example values are `us`, `eu`, `au`, and `prod-eu-west-3`. For more information, refer to [List regions](#list-regions). | Yes | +| `tokenId` | String | (`UUID`). Path. ID of the Token. | Yes | + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | ------------------------------- | +| `204` | Token deleted successfully | +| `400` | Bad request | +| `401` | API token is missing or invalid | + +## Stacks + +{{< admonition type="note" >}} +Grafana Cloud Free includes 1 stack and Grafana Cloud Pro includes up to 3 stacks. +[Reach out to support](/contact/) about a Grafana Cloud contracted plan if you would like to add additional stacks to your account. +{{< /admonition >}} + +### List stacks + +```http +GET https://grafana.com/api/orgs//instances +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `403` | Forbidden. | + +Example response: + +```json +{ + "items": [ + { + "id": 007303, + "orgId": 052992, + "orgSlug": "grafanacom", + "orgName": "grafanacom", + "type": "grafana", + "name": "cloudapistack.grafana.net", + "url": "https://cloudapistack.grafana.net", + "slug": "cloudapistack", + "version": "stable", + "description": "", + "status": "active", + "gateway": "istio", + "createdAt": "2023-01-04T06:43:24.000Z", + "createdBy": "foobar", + "updatedAt": null, + "updatedBy": "", + "trial": 0, + "trialExpiresAt": null, + "clusterId": 69, + "clusterSlug": "prod-us-central-0", + "clusterName": "prod-us-central-0", + "plan": "gcloud", + "planName": "Grafana Cloud", + "billingStartDate": "2023-01-04T06:43:23.000Z", + "billingEndDate": null, + "billingActiveUsers": 0, + "billingGrafanaActiveUsers": 0, + "billingOnCallActiveUsers": 0, + "currentActiveUsers": 0, + "currentActiveAdminUsers": 0, + "currentActiveEditorUsers": 0, + "currentActiveViewerUsers": 0, + "dailyUserCnt": 0, + "dailyAdminCnt": 0, + "dailyEditorCnt": 0, + "dailyViewerCnt": 0, + "dashboardCnt": 8, + "datasourceCnts": {}, + "userQuota": 10, + "dashboardQuota": -1, + "alertQuota": -1, + "alertCnt": 0, + "ssl": true, + "customAuth": true, + "customDomain": true, + "support": true, + "runningVersion": "9.3.2-45365 (commit: ef5286dd77, branch: v9.3.x)", + "machineLearning": 0, + "incident": 0, + "hmInstancePromId": 715391, + "hmInstancePromUrl": "https://prometheus-us-central1.grafana.net", + "hmInstancePromName": "cloudapistack-prom", + "hmInstancePromStatus": "active", + "hmInstancePromCurrentUsage": 0, + "hmInstancePromCurrentActiveSeries": 0, + "hmInstanceGraphiteId": 715392, + "hmInstanceGraphiteUrl": "https://graphite-prod-10-prod-us-central-0.grafana.net", + "hmInstanceGraphiteName": "cloudapistack-graphite", + "hmInstanceGraphiteType": "graphite-v5", + "hmInstanceGraphiteStatus": "active", + "hmInstanceGraphiteCurrentUsage": 0, + "hlInstanceId": 356665, + "hlInstanceUrl": "https://logs-prod-017.grafana.net", + "hlInstanceName": "cloudapistack-logs", + "hlInstanceStatus": "active", + "hlInstanceCurrentUsage": 0, + "amInstanceId": 355647, + "amInstanceName": "cloudapistack-alerts", + "amInstanceUrl": "https://alertmanager-us-central1.grafana.net", + "amInstanceStatus": "active", + "amInstanceGeneratorUrl": "https://cloudapistack.grafana.net", + "amInstanceGeneratorUrlDatasource": "", + "htInstanceId": 353178, + "htInstanceUrl": "https://tempo-us-central1.grafana.net", + "htInstanceName": "cloudapistack-traces", + "htInstanceStatus": "active", + "regionId": 1, + "regionSlug": "us", + "links": [ + { + "rel": "self", + "href": "/instances/cloudapistack" + }, + { + "rel": "org", + "href": "/orgs/grafanacom" + }, + { + "rel": "plugins", + "href": "/instances/cloudapistack/plugins" + } + ] + } + ], + "orderBy": "name", + "direction": "asc", + "total": 1, + "pages": 1, + "pageSize": 1000000, + "page": 1, + "links": [ + { + "rel": "self", + "href": "/instances" + } + ] +} +``` + +### Get a stack's connectivity info + +```http +GET https://grafana.com/api/instances//connections +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `403` | Forbidden. | + +Example response: + +```json +{ + "privateConnectivityInfo": { + "tenants": [ + { + "type": "prometheus", + "id": 1899232, + "info": { + "privateDNS": "cortex-prod-13-cortex-gw.us-east-2.vpce.grafana.net", + "serviceName": "com.amazonaws.vpce.us-east-2.vpce-svc-0d13a270cd91a0a3a" + }, + "ipAllowListCNAME": "src-ips.prometheus-prod-13-prod-us-east-0.grafana.net" + }, + { + "type": "graphite", + "id": 1899233, + "info": { + "privateDNS": "cortex-prod-13-cortex-gw.us-east-2.vpce.grafana.net", + "serviceName": "com.amazonaws.vpce.us-east-2.vpce-svc-0d13a270cd91a0a3a" + }, + "ipAllowListCNAME": "src-ips.prometheus-prod-13-prod-us-east-0.grafana.net" + }, + { + "type": "logs", + "id": 1048899, + "info": { + "privateDNS": "loki-prod-006-cortex-gw.us-east-2.vpce.grafana.net", + "serviceName": "com.amazonaws.vpce.us-east-2.vpce-svc-071e7d98821c1698b" + }, + "ipAllowListCNAME": "src-ips.logs-prod-006.grafana.net" + }, + { + "type": "traces", + "id": 1043214, + "info": { + "privateDNS": "tempo-prod-04-cortex-gw.us-east-2.vpce.grafana.net", + "serviceName": "com.amazonaws.vpce.us-east-2.vpce-svc-0a830aaea99ecfc91" + }, + "ipAllowListCNAME": "src-ips.tempo-prod-04-prod-us-east-0.grafana.net" + }, + { + "type": "profiles", + "id": 1091120, + "info": { + "privateDNS": "profiles-prod-001-cortex-gw.us-east-2.vpce.grafana.net", + "serviceName": "com.amazonaws.vpce.us-east-2.vpce-svc-079d447d0143b24e7" + }, + "ipAllowListCNAME": "src-ips.profiles-prod-001.grafana.net" + }, + { + "type": "alerts", + "id": 947554, + "ipAllowListCNAME": "src-ips.alertmanager-prod-us-east-0.grafana.net" + }, + { + "type": "grafana", + "id": 1091120, + "ipAllowListCNAME": null + } + ], + "otlp": { + "privateDNS": "prod-us-east-0-otlp-gateway.us-east-2.vpce.grafana.net", + "serviceName": "com.amazonaws.vpce.us-east-2.vpce-svc-0d36af67949f874c4" + }, + "pdc": { + "api": { + "privateDNS": "private-datasource-connect-api.us-east-2.vpce.grafana.net", + "serviceName": "com.amazonaws.vpce.us-east-2.vpce-svc-0078cfdaab047fc37" + }, + "gateway": { + "privateDNS": "private-datasource-connect.us-east-2.vpce.grafana.net", + "serviceName": "com.amazonaws.vpce.us-east-2.vpce-svc-032570426402bc97e" + } + } + }, + "influxUrl": "https://influx-prod-13-prod-us-east-0.grafana.net", + "otlpHttpUrl": "https://otlp-gateway-prod-us-east-0.grafana.net", + "oncallApiUrl": "https://oncall-prod-us-east-0.grafana.net/oncall", + "appPlatform": { + "url": "https://app-platform-apiserver-prod-us-east-0.grafana.net", + "caData": "" + } +} +``` + +That endpoint contains the way to connect to the various tenants the stack has, +including [AWS PrivateLink](/docs/grafana-cloud/send-data/aws-privatelink/) if the region is AWS. + +### Create stack + +{{< admonition type="note" >}} +This `POST` request accepts lowercase characters only. +{{< /admonition >}} + +```http +POST https://grafana.com/api/instances +``` + +#### Request Body + +| Name | Type | Description | Required | +| ------------------ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `name` | String | Name of stack. Conventionally matches the URL of the instance. For example, `.grafana.net`. | Yes | +| `slug` | String | Subdomain that the Grafana instance to make the instance available at. For example, if you set the slug to ``, the full URL for the instance is `https://.grafana.net`. | Yes | +| `url` | String | If you use a custom domain for the instance, you must provide it here. For example, `"https://grafana.yourdoman.io"`. | No | +| `region` | String | Choose a region for your stack. For example, you can specify the United States (`us`) or Europe (`eu`). Use the `GET /api/stack-regions` endpoint to see a list of regions to choose from. For more information, refer to [List regions](#list-regions). If you don't specify a region, the default is `us`. | No | +| `description` | String | A short text that describes the purpose of the stack. | No | +| `labels` | map[String]String | Add labels to a stack if you want to add a visual way to distinguish them [in the UI](/docs/grafana-cloud/account-management/cloud-portal/#create-stack-labels). Labels are `key:value` pairs where the both the key and value can alphanumeric, `.`, `-` or `/`. Up to 10 labels are allowed. Example: `{"team":"platform", "environment":"dev"}` | No | +| `deleteProtection` | Boolean | Prevents the stack from being accidentally deleted. When set to true, delete operations on the stack are blocked until this protection is disabled. Recommended for production or critical environments. | No | + +{{< admonition type="note" >}} +For the custom domain, you must set up a `CNAME` record that points to `.grafana.net` before you can specify the domain. +{{< /admonition >}} + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `403` | Forbidden. | +| `409` | Conflict. | + +Example response: + +```json +{ + "id": 507363, + "orgId": 652992, + "orgSlug": "grafanacom", + "orgName": "grafanacom", + "type": "grafana", + "name": "createcloudstack", + "url": "https://createcloudstack.grafana.net", + "slug": "createcloudstack", + "version": "stable", + "labels": { + "key": "value" + }, + "description": "", + "status": "active", + "gateway": "istio", + "createdAt": "2023-01-04T08:20:07.000Z", + "createdBy": "testengineer", + "updatedAt": null, + "updatedBy": "", + "trial": 0, + "trialExpiresAt": null, + "clusterId": 69, + "clusterSlug": "prod-us-central-0", + "clusterName": "prod-us-central-0", + "plan": "gcloud", + "planName": "Grafana Cloud", + "billingStartDate": "2023-01-04T08:20:06.000Z", + "billingEndDate": null, + "billingActiveUsers": 0, + "billingGrafanaActiveUsers": 0, + "billingOnCallActiveUsers": 0, + "currentActiveUsers": 0, + "currentActiveAdminUsers": 0, + "currentActiveEditorUsers": 0, + "currentActiveViewerUsers": 0, + "dailyUserCnt": 0, + "dailyAdminCnt": 0, + "dailyEditorCnt": 0, + "dailyViewerCnt": 0, + "dashboardCnt": 0, + "datasourceCnts": {}, + "userQuota": 10, + "dashboardQuota": -1, + "alertQuota": -1, + "alertCnt": 0, + "ssl": true, + "customAuth": true, + "customDomain": true, + "support": true, + "runningVersion": "", + "machineLearning": 0, + "incident": 0, + "deleteProtection": true, + "hmInstancePromId": 715511, + "hmInstancePromUrl": "https://prometheus-us-central1.grafana.net", + "hmInstancePromName": "createcloudstack-prom", + "hmInstancePromStatus": "active", + "hmInstancePromCurrentUsage": 0, + "hmInstancePromCurrentActiveSeries": 0, + "hmInstanceGraphiteId": 715512, + "hmInstanceGraphiteUrl": "https://graphite-prod-10-prod-us-central-0.grafana.net", + "hmInstanceGraphiteName": "createcloudstack-graphite", + "hmInstanceGraphiteType": "graphite-v5", + "hmInstanceGraphiteStatus": "active", + "hmInstanceGraphiteCurrentUsage": 0, + "hlInstanceId": 356725, + "hlInstanceUrl": "https://logs-prod-017.grafana.net", + "hlInstanceName": "createcloudstack-logs", + "hlInstanceStatus": "active", + "hlInstanceCurrentUsage": 0, + "amInstanceId": 355707, + "amInstanceName": "createcloudstack-alerts", + "amInstanceUrl": "https://alertmanager-us-central1.grafana.net", + "amInstanceStatus": "active", + "amInstanceGeneratorUrl": "https://createcloudstack.grafana.net", + "amInstanceGeneratorUrlDatasource": "", + "htInstanceId": 353238, + "htInstanceUrl": "https://tempo-us-central1.grafana.net", + "htInstanceName": "createcloudstack-traces", + "htInstanceStatus": "active", + "regionId": 1, + "regionSlug": "us", + "links": [ + { + "rel": "self", + "href": "/instances/createcloudstack" + }, + { + "rel": "org", + "href": "/orgs/grafanacom" + }, + { + "rel": "plugins", + "href": "/instances/createcloudstack/plugins" + } + ] +} +``` + +### Update stack + +{{< admonition type="note" >}} +This `POST` request accepts lowercase characters only. +{{< /admonition >}} + +```http +POST https://grafana.com/api/instances/ +``` + +#### Request Body + +| Name | Type | Description | Required | +| ------------------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `description` | String | A short text that describes the purpose of the stack. | No | +| `labels` | map[String]String | Updates labels for a stack. Labels are `key:value` pairs where the both the key and value can alphanumeric, `.`, `-` or `/`. Up to 10 labels are allowed. To remove a label, just omit it in this request. To remove all labels send an empty object. Example: `{"team":"platform", "environment":"dev"}` | No | +| `name` | String | Name of stack. Conventionally matches the URL of the instance. For example, `.grafana.net`. | No | +| `deleteProtection` | Boolean | Enables or disables delete protection for the stack. When set to true, it prevents the stack from being accidentally deleted. To allow deletion, set this to false. | No | + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `403` | Forbidden. | +| `409` | Conflict. | + +Example response: + +```json +{ + "id": 507363, + "orgId": 652992, + "orgSlug": "grafanacom", + "orgName": "grafanacom", + "type": "grafana", + "name": "createcloudstack", + "url": "https://createcloudstack.grafana.net", + "slug": "createcloudstack", + "labels": { + "newkey": "newvalue" + }, + "version": "stable", + "description": "", + "status": "active", + "gateway": "istio", + "createdAt": "2023-01-04T08:20:07.000Z", + "createdBy": "testengineer", + "updatedAt": null, + "updatedBy": "", + "trial": 0, + "trialExpiresAt": null, + "clusterId": 69, + "clusterSlug": "prod-us-central-0", + "clusterName": "prod-us-central-0", + "plan": "gcloud", + "planName": "Grafana Cloud", + "billingStartDate": "2023-01-04T08:20:06.000Z", + "billingEndDate": null, + "billingActiveUsers": 0, + "billingGrafanaActiveUsers": 0, + "billingOnCallActiveUsers": 0, + "currentActiveUsers": 0, + "currentActiveAdminUsers": 0, + "currentActiveEditorUsers": 0, + "currentActiveViewerUsers": 0, + "dailyUserCnt": 0, + "dailyAdminCnt": 0, + "dailyEditorCnt": 0, + "dailyViewerCnt": 0, + "dashboardCnt": 0, + "datasourceCnts": {}, + "userQuota": 10, + "dashboardQuota": -1, + "alertQuota": -1, + "alertCnt": 0, + "ssl": true, + "customAuth": true, + "customDomain": true, + "support": true, + "runningVersion": "", + "machineLearning": 0, + "incident": 0, + "deleteProtection": true, + "hmInstancePromId": 715511, + "hmInstancePromUrl": "https://prometheus-us-central1.grafana.net", + "hmInstancePromName": "createcloudstack-prom", + "hmInstancePromStatus": "active", + "hmInstancePromCurrentUsage": 0, + "hmInstancePromCurrentActiveSeries": 0, + "hmInstanceGraphiteId": 715512, + "hmInstanceGraphiteUrl": "https://graphite-prod-10-prod-us-central-0.grafana.net", + "hmInstanceGraphiteName": "createcloudstack-graphite", + "hmInstanceGraphiteType": "graphite-v5", + "hmInstanceGraphiteStatus": "active", + "hmInstanceGraphiteCurrentUsage": 0, + "hlInstanceId": 356725, + "hlInstanceUrl": "https://logs-prod-017.grafana.net", + "hlInstanceName": "createcloudstack-logs", + "hlInstanceStatus": "active", + "hlInstanceCurrentUsage": 0, + "amInstanceId": 355707, + "amInstanceName": "createcloudstack-alerts", + "amInstanceUrl": "https://alertmanager-us-central1.grafana.net", + "amInstanceStatus": "active", + "amInstanceGeneratorUrl": "https://createcloudstack.grafana.net", + "amInstanceGeneratorUrlDatasource": "", + "htInstanceId": 353238, + "htInstanceUrl": "https://tempo-us-central1.grafana.net", + "htInstanceName": "createcloudstack-traces", + "htInstanceStatus": "active", + "regionId": 1, + "regionSlug": "us", + "links": [ + { + "rel": "self", + "href": "/instances/createcloudstack" + }, + { + "rel": "org", + "href": "/orgs/grafanacom" + }, + { + "rel": "plugins", + "href": "/instances/createcloudstack/plugins" + } + ] +} +``` + +### Delete stack + +```http +DELETE https://grafana.com/api/instances/ +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `404` | Cloud Stack not found. | +| `409` | Delete protection is enabled. | + +{{< admonition type="caution" >}} +If you receive a `409` response code, it means the stack has delete protection enabled. You must first disable the `deleteProtection` flag for the stack before you can successfully delete it. +{{< /admonition >}} + +Example response: + +```json +{ + "id": 507366, + "orgId": 652992, + "orgSlug": "grafanacom", + "orgName": "grafanacom", + "type": "grafana", + "name": "createcloudstack", + "url": "https://createcloudstack.grafana.net", + "slug": "createcloudstack", + "version": "stable", + "description": "", + "status": "deleted", + "gateway": "istio", + "createdAt": "2023-01-04T08:22:00.000Z", + "createdBy": "ishanjain", + "updatedAt": "2023-01-04T08:30:36.066Z", + "updatedBy": "ishanjain", + "trial": 0, + "trialExpiresAt": null, + "clusterId": 69, + "clusterSlug": "prod-us-central-0", + "clusterName": "prod-us-central-0", + "plan": "gcloud", + "planName": "Grafana Cloud", + "billingStartDate": "2023-01-04T08:21:59.000Z", + "billingEndDate": "2023-01-04T08:30:36.066Z", + "billingActiveUsers": 0, + "billingGrafanaActiveUsers": 0, + "billingOnCallActiveUsers": 0, + "currentActiveUsers": 0, + "currentActiveAdminUsers": 0, + "currentActiveEditorUsers": 0, + "currentActiveViewerUsers": 0, + "dailyUserCnt": 0, + "dailyAdminCnt": 0, + "dailyEditorCnt": 0, + "dailyViewerCnt": 0, + "dashboardCnt": 0, + "datasourceCnts": {}, + "userQuota": 10, + "dashboardQuota": -1, + "alertQuota": -1, + "alertCnt": 0, + "ssl": true, + "customAuth": true, + "customDomain": true, + "support": true, + "runningVersion": "9.3.2-45365 (commit: ef5286dd77, branch: v9.3.x)", + "machineLearning": 0, + "incident": 0, + "deleteProtection": false, + "hmInstancePromId": 715517, + "hmInstancePromUrl": "https://prometheus-us-central1.grafana.net", + "hmInstancePromName": "createcloudstack-prom", + "hmInstancePromStatus": "active", + "hmInstancePromCurrentUsage": 0, + "hmInstancePromCurrentActiveSeries": 0, + "hmInstanceGraphiteId": 715518, + "hmInstanceGraphiteUrl": "https://graphite-prod-10-prod-us-central-0.grafana.net", + "hmInstanceGraphiteName": "createcloudstack-graphite", + "hmInstanceGraphiteType": "graphite-v5", + "hmInstanceGraphiteStatus": "active", + "hmInstanceGraphiteCurrentUsage": 0, + "hlInstanceId": 356728, + "hlInstanceUrl": "https://logs-prod-017.grafana.net", + "hlInstanceName": "createcloudstack-logs", + "hlInstanceStatus": "active", + "hlInstanceCurrentUsage": 0, + "amInstanceId": 355710, + "amInstanceName": "createcloudstack1-alerts", + "amInstanceUrl": "https://alertmanager-us-central1.grafana.net", + "amInstanceStatus": "active", + "amInstanceGeneratorUrl": "https://createcloudstack.grafana.net", + "amInstanceGeneratorUrlDatasource": "", + "htInstanceId": 353241, + "htInstanceUrl": "https://tempo-us-central1.grafana.net", + "htInstanceName": "createcloudstack-traces", + "htInstanceStatus": "active", + "regionId": 1, + "regionSlug": "us", + "links": [ + { + "rel": "self", + "href": "/instances/507366" + }, + { + "rel": "org", + "href": "/orgs/grafanacom" + }, + { + "rel": "plugins", + "href": "/instances/507366/plugins" + } + ] +} +``` + +### Restart Grafana + +```http +POST https://grafana.com/api/instances//restart +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------------------- | +| `200` | Successful operation | +| `401` | API token is missing or invalid. | +| `404` | Cloud Stack not found | + +Example response: + +```json +true +``` + +### Create Hosted Grafana instance API keys + +```http +POST https://grafana.com/api/instances//api/auth/keys +``` + +This creates API keys specific to use for managing your hosted Grafana instance. +This is different from [Grafana Cloud API keys](#api-keys) created for Grafana Cloud operations. + +This endpoint requires the Admin role. + +#### Request Body + +| Name | Type | Description | Required | +| --------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `name` | String | Name of the API key. | Yes | +| `role` | String | Access level/Grafana role for the key. Can be one of the following values: `Viewer`, `Editor`, or `Admin`. | Yes | +| `secondsToLive` | Number | Key expiration in seconds. If it's a positive number, an expiration date for the key is set. The key doesn't expire if it's `null`, `0`, or is omitted completely (unless `api_key_max_seconds_to_live` configuration option is set). | No | + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `404` | Cloud Stack not found. | +| `409` | Conflict. | + +Example response: + +```json +{ + "id": 1, + "name": "testkey", + "key": "eyJrIjoiMWpSRVhRUVJHZlc3NW1laklzV3htQUt0cUxtS3RuWFUiLCJuIjoidGVzdGtleSIsImlkIjoxf" +} +``` + +### List data sources + +```http +GET https://grafana.com/api/instances//datasources +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | ---------------------------------------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `404` | Cloud Stack not found. | +| `409` | Conflicting operation, another operation is already in progress. | + +Example response: + +```json +{ + "items": [ + { + "id": 25744816, + "instanceId": 2860016, + "name": "grafanacloud-usage", + "type": "prometheus", + "access": "proxy", + "grafanaOrgId": 1, + "url": "https://billing.grafana.net/api/prom", + "password": "", + "user": "", + "database": "", + "basicAuth": 1, + "basicAuthUser": "65299211", + "withCredentials": 0, + "isDefault": 0, + "jsonData": { + "timeInterval": "60s", + "timeout": "150", + "prometheusVersion": "2.3.0", + "prometheusType": "Mimir" + }, + "version": 1, + "editable": 1, + "delete": 0, + "createdAt": "2023-01-04T08:20:13.484927Z", + "updatedAt": null + }, + { + "id": 25744915, + "instanceId": 2860016, + "name": "grafanacloud-createcloudstack-logs", + "type": "loki", + "access": "proxy", + "grafanaOrgId": 1, + "url": "https://logs-prod-017.grafana.net", + "password": "", + "user": "", + "database": "", + "basicAuth": 1, + "basicAuthUser": "3567215", + "withCredentials": 0, + "isDefault": 0, + "jsonData": { + "timeout": "300" + }, + "version": 1, + "editable": 1, + "delete": 0, + "createdAt": "2023-01-04T08:20:13.625323Z", + "updatedAt": null + } + ] +} +``` + +## Grafana plugins + +The API allows managing plugins installed on your hosted Grafana instances. + +You can discover plugins in the [Grafana Plugins Directory](/grafana/plugins/). + +### List plugins installed on an instance + +```http +GET https://grafana.com/api/instances//plugins +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | ---------------------------------------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `404` | Cloud Stack not found. | +| `409` | Conflicting operation, another operation is already in progress. | + +Example response: + +```json +{ + "items": [ + { + "id": 256529, + "instanceId": 507363, + "instanceUrl": "https://createcloudstack.grafana.net", + "pluginId": 663, + "pluginSlug": "grafana-github-datasource", + "pluginName": "GitHub", + "version": "1.3.1", + "latestVersion": "1.3.1", + "createdAt": "2023-01-04T09:33:55.000Z", + "updatedAt": null, + "links": [ + { + "rel": "self", + "href": "/instances/507363/plugins/grafana-github-datasource" + }, + { + "rel": "instance", + "href": "/instances/507363" + } + ] + } + ], + "orderBy": "pluginName", + "direction": "asc", + "links": [ + { + "rel": "self", + "href": "/instances/createcloudstack/plugins" + } + ] +} +``` + +### Add a plugin to instance + +```http +POST https://grafana.com/api/instances//plugins +``` + +#### Request Body + +| Name | Type | Description | Required | +| --------- | ------ | ------------------------------------------------------------- | -------- | +| `plugin` | String | Name of the plugin, for example, `grafana-github-datasource`. | Yes | +| `version` | String | Version of the plugin to install. Defaults to `latest`. | No | + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | ---------------------------------------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `404` | Plugin or Cloud Stack not found. | +| `409` | Conflicting operation, another operation is already in progress. | + +Example response: + +```json +{ + "id": 256519, + "instanceId": 507363, + "instanceUrl": "https://createcloudstack.grafana.net", + "instanceSlug": "createcloudstack", + "pluginId": 663, + "pluginSlug": "grafana-github-datasource", + "pluginName": "GitHub", + "version": "1.3.1", + "latestVersion": "1.3.1", + "createdAt": "2023-01-04T08:50:42.000Z", + "updatedAt": null, + "links": [ + { + "rel": "self", + "href": "/instances/createcloudstack/plugins/grafana-github-datasource" + }, + { + "rel": "instance", + "href": "/instances/createcloudstack" + } + ] +} +``` + +### Get installed plugin info + +```http +GET https://grafana.com/api/instances//plugins/ +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | ---------------------------------------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `404` | Plugin or Cloud Stack not found. | +| `409` | Conflicting operation, another operation is already in progress. | + +Example response: + +```json +{ + "id": 256519, + "instanceId": 507363, + "instanceUrl": "https://createcloudstack.grafana.net", + "instanceSlug": "createcloudstack", + "pluginId": 663, + "pluginSlug": "grafana-github-datasource", + "pluginName": "GitHub", + "version": "1.3.1", + "latestVersion": "1.3.1", + "createdAt": "2023-01-04T08:50:42.000Z", + "updatedAt": null, + "links": [ + { + "rel": "self", + "href": "/instances/createcloudstack/plugins/grafana-github-datasource" + }, + { + "rel": "instance", + "href": "/instances/createcloudstack" + } + ] +} +``` + +### Update installed plugin version + +```http +POST https://grafana.com/api/instances//plugins/ +``` + +#### Request Body + +| Name | Type | Description | Required | +| --------- | ------ | ------------------------------ | -------- | +| `version` | String | Updated version of the plugin. | Yes | + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | ---------------------------------------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `404` | Plugin or Cloud Stack not found. | +| `409` | Conflicting operation, another operation is already in progress. | + +Example response: + +```json +{ + "id": 256519, + "instanceId": 507363, + "instanceUrl": "https://createcloudstack.grafana.net", + "instanceSlug": "createcloudstack", + "pluginId": 663, + "pluginSlug": "grafana-github-datasource", + "pluginName": "GitHub", + "version": "1.3.0", + "latestVersion": "1.3.1", + "createdAt": "2023-01-04T08:50:42.000Z", + "updatedAt": "2023-01-04T08:55:00.088Z", + "links": [ + { + "rel": "self", + "href": "/instances/createcloudstack/plugins/grafana-github-datasource" + }, + { + "rel": "instance", + "href": "/instances/createcloudstack" + } + ] +} +``` + +### Delete an installed plugin + +```http +DELETE https://grafana.com/api/instances//plugins/ +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | ---------------------------------------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `404` | Plugin or Cloud Stack not found. | +| `409` | Conflicting operation, another operation is already in progress. | + +Example response: + +```json +{ + "id": 256519, + "instanceId": 507363, + "instanceUrl": "https://createcloudstack.grafana.net", + "instanceSlug": "createcloudstack", + "pluginId": 663, + "pluginSlug": "grafana-github-datasource", + "pluginName": "GitHub", + "version": "1.3.1", + "latestVersion": "1.3.1", + "createdAt": "2023-01-04T08:50:42.000Z", + "updatedAt": "2023-01-04T08:59:20.794Z", + "links": [ + { + "rel": "self", + "href": "/instances/createcloudstack/plugins/grafana-github-datasource" + }, + { + "rel": "instance", + "href": "/instances/createcloudstack" + } + ] +} +``` + +## Regions + +### List regions + +Use the following call to retrieve a list of regions to specify when you create a stack. + +```http +GET https://grafana.com/api/stack-regions +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | ---------------------------------------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `409` | Conflicting operation, another operation is already in progress. | + +Example response: + +```json +{ + "items": [ + { + "id": 1, + "status": "active", + "slug": "us", + "name": "GCP US Central", + "description": "United States", + "provider": "gcp", + "createdAt": "2021-08-20T20:00:27.000Z", + "updatedAt": "2022-12-12T12:29:37.000Z" + }, + { + "id": 2, + "status": "active", + "slug": "us-azure", + "name": "Azure US Central", + "description": "United States (Azure)", + "provider": "azure", + "createdAt": "2021-08-20T20:08:03.000Z", + "updatedAt": "2022-11-29T12:04:00.000Z" + }, + { + "id": 3, + "status": "active", + "slug": "eu", + "name": "GCP Belgium", + "description": "Europe", + "provider": "gcp", + "createdAt": "2021-08-20T20:28:52.000Z", + "updatedAt": "2022-12-05T18:05:33.000Z" + }, + { + "id": 4, + "status": "active", + "slug": "au", + "name": "GCP Australia", + "description": "Australia", + "provider": "gcp", + "createdAt": "2021-11-16T22:03:18.000Z", + "updatedAt": "2022-09-22T09:27:47.000Z" + } + ], + "orderBy": "id", + "direction": "asc", + "total": 9, + "pages": 1, + "pageSize": 1000000, + "page": 1, + "links": [ + { + "rel": "self", + "href": "/stack-regions" + } + ] +} +``` + +## API keys + +{{< admonition type="caution" >}} +Cloud API keys are now deprecated. Use [Cloud Access Policies](#access-policies-and-tokens) instead. +{{< /admonition >}} + +### List API keys + +```http +GET https://grafana.com/api/orgs//api-keys +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `403` | Forbidden. | + +Example response: + +```json +{ + "items": [ + { + "id": 5045812, + "orgId": 652945, + "orgSlug": "grafanacom", + "orgName": "grafanacom", + "instanceId": null, + "name": "SRE", + "role": "Admin", + "createdAt": "2023-01-04T06:43:51.000Z", + "updatedAt": null, + "firstUsed": "2023-01-04T06:44:26.000Z", + "links": [ + { + "rel": "self", + "href": "/orgs/grafanacom/api-keys/SRE" + }, + { + "rel": "org", + "href": "/orgs/grafanacom" + } + ] + } + ], + "orderBy": "name", + "direction": "asc", + "links": [ + { + "rel": "self", + "href": "/orgs/grafanacom/api-keys" + } + ] +} +``` + +### Create API key + +```http +POST https://grafana.com/api/orgs//api-keys +``` + +#### Request Body + +| Name | Type | Description | Required | +| ------ | ------ | --------------------------------------------------------------------------------------- | -------- | +| `name` | String | API key name | Yes | +| `role` | String | Permission level of API key. One of `Viewer`, `Editor`, `Admin`, or `MetricsPublisher`. | Yes | + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `409` | Conflict. | + +Example response: + +```json +{ + "id": 5046212, + "orgId": 652945, + "orgSlug": "grafanacom", + "orgName": "grafanacom", + "instanceId": null, + "name": "createapikey", + "role": "Admin", + "createdAt": "2023-01-04T07:50:54.000Z", + "updatedAt": null, + "firstUsed": null, + "token": "eyJrIjoiZmU5ZDlmY2JkODkzNTg4ZGUyYTJhNmJiZGJiMWYwNjQyMGM0MzBkNiIsIm4iOiJjcmVhdGVhcGlrZXkiLCJpZCI6NjUyOTkyf", + "links": [ + { + "rel": "self", + "href": "/orgs/grafanacom/api-keys/createapikey" + }, + { + "rel": "org", + "href": "/orgs/grafanacom" + } + ] +} +``` + +### Delete API key + +```http +DELETE https://grafana.com/api/orgs//api-keys/ +``` + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `404` | API Key not found. | + +Example response: + +```json +true +``` + +## Billed usage + +This API makes it possible to fetch billed usage broken down by stack for a specified year and month. + +### Get billed usage + +```http +GET https://grafana.com/api/orgs//billed-usage?month=&year= +``` + +#### Parameters + +| Name | Type | Description | Required | +| ------- | ----- | -------------------------------------------------------- | -------- | +| `month` | Query | numeric value for the month to retrieve billed usage for | yes | +| `year` | Query | numeric value for the year to retrieve billed usage for | yes | + +#### Responses + +The following responses may be returned. + +| Code | Description | +| ----- | -------------------------------- | +| `200` | Successful operation. | +| `401` | API token is missing or invalid. | +| `403` | Forbidden. | +| `404` | Not found. | +| `409` | Wrong or missing parameters. | + +Example response: + +```json +{ + "items": [ + { + "id": 1111198068, + "dimensionId": "hl", + "dimensionName": "Logs", + "unit": "GB", + "includedUsage": 50, + "totalUsage": 251.02109133593612, + "overage": 201, + "orgRates": { + "tiers": [ + { + "min": 50, + "rate": 0.5 + } + ] + }, + "amountDue": 100.5, + "periodStart": "2024-09-01T00:00:00Z", + "periodEnd": "2024-09-30T23:59:59Z", + "description": "Hosted Logs Usage - September 2024", + "notes": "Per-instance Usage\n - example-logs - Usage: 251.021GB\n\nIncluded Usage: 50GB\nTotal Usage: 251.021GB\nUsage in excess of 50GB: 201GB @ $0.5/GB = $100.50\nTotal Usage Amount: $100.50", + "usages": [ + { + "id": 1111163428, + "stackId": 111118, + "periodStart": "2024-09-01T00:00:00Z", + "periodEnd": "2024-09-30T23:59:59Z", + "totalUsage": 251.02109133593612, + "isProrated": false, + "ingestUsage": 251.02109133593612, + "queryUsage": 1255.8636820528654, + "stackName": "example.grafana.net", + "stackLabels": {} + } + ] + }, + { + "id": 2222297587, + "dimensionId": "hm", + "dimensionName": "Metrics", + "unit": "series", + "includedUsage": 10000, + "totalUsage": 129755.72, + "overage": 119756, + "orgRates": { + "tiers": [ + { + "min": 10000, + "rate": 6.5 + } + ], + "includedDPM": 1 + }, + "amountDue": 778.41, + "periodStart": "2024-09-01T00:00:00Z", + "periodEnd": "2024-09-30T23:59:59Z", + "description": "Hosted Metrics Usage - September 2024", + "notes": "Per-instance Usage\n - example-prom - Series: 55937, DPM: 132364, Usage: 132364\n Usage pro-rated 2024-09-01 - 2024-09-30: 129756\n\nIncluded Usage: 10000\nTotal Usage: 129756\nUsage in excess of 10000: 119756 @ $6.50/1000 = $778.41\nTotal Usage Amount: $778.41", + "usages": [ + { + "id": 1111162008, + "stackId": 111118, + "periodStart": "2024-09-01T00:00:00Z", + "periodEnd": "2024-09-30T23:59:59Z", + "totalUsage": 129755.72, + "isProrated": false, + "activeSeries": 55937, + "dpm": 132364, + "stackName": "example.grafana.net", + "stackLabels": {} + } + ] + }, + { + "id": 1111195084, + "dimensionId": "hg", + "dimensionName": "Grafana Users", + "unit": "user", + "includedUsage": 3, + "totalUsage": 14, + "overage": 11, + "orgRates": { + "tiers": [ + { + "min": 3, + "rate": 8 + } + ] + }, + "amountDue": 88, + "periodStart": "2024-09-01T00:00:00Z", + "periodEnd": "2024-09-30T23:59:59Z", + "description": "Hosted Grafana Usage - September 2024", + "notes": "Per-instance Usage\n - example.grafana.net - Total Unique Users: 14\n\nIncluded Users: 3\nTotal Unique Users: 14\nUsers in excess of 3: 11 @ $8/User = $88.00\nTotal Usage Amount: $88.00", + "usages": [ + { + "id": 1111157272, + "stackId": 111118, + "periodStart": "2024-09-01T00:00:00Z", + "periodEnd": "2024-09-30T23:59:59Z", + "totalUsage": 14, + "isProrated": false, + "grafanaUsage": 14, + "onCallUsage": 0, + "stackName": "example.grafana.net", + "stackLabels": {} + } + ] + } + ] +} +``` diff --git a/docs/sources/developers/http_api/_index.md b/docs/sources/developer-resources/api-reference/http-api/_index.md similarity index 68% rename from docs/sources/developers/http_api/_index.md rename to docs/sources/developer-resources/api-reference/http-api/_index.md index 86d9ab8d185..7d45c435832 100644 --- a/docs/sources/developers/http_api/_index.md +++ b/docs/sources/developer-resources/api-reference/http-api/_index.md @@ -1,8 +1,10 @@ --- aliases: - - ../http_api/ - - ../overview/ -canonical: /docs/grafana/latest/developers/http_api/ + - ../../http_api/ # /docs/grafana/next/http_api/ + - ../../overview/ # /docs/grafana/next/overview/ + - ../../../developers/http_api/ # /docs/grafana/developers/http_api/ + - ../../developers/http_api/ # /docs/grafana/next/developers/http_api/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/ description: Grafana HTTP API keywords: - grafana @@ -14,14 +16,14 @@ labels: products: - enterprise - oss + - cloud title: HTTP API weight: 100 --- -# HTTP API reference +# Grafana HTTP API reference -The Grafana backend exposes an HTTP API, which is the same API that's used by the frontend to do everything from saving -dashboards, creating users, and updating data sources. +Every Grafana instance exposes an HTTP API, which is the same API used by the Grafana frontend to manage resources like saving dashboards, creating users, updating data sources, deleting alerts, and more. You can use the HTTP API to programmatically access or manage resources from your Grafana instance running in Grafana Cloud. Below is the list of existing [HTTP APIs](#http-apis). HTTP API details are [specified](https://editor.swagger.io/?url=https://raw.githubusercontent.com/grafana/grafana/main/public/api-merged.json) using OpenAPI v2. @@ -29,10 +31,22 @@ There is also an [OpenAPI v3 specification](https://editor.swagger.io/?url=https Users can browser and try out both via the Swagger UI editor (served by the Grafana server) by navigating to `/swagger-ui`. -## Authenticate API requests +{{< admonition type="note" >}} + +If you need to manage or access other resources from your [Grafana Cloud Stack](/docs/grafana-cloud/account-management/cloud-stacks/), refer to the [Grafana Cloud API](../cloud-api/) instead. + +{{< /admonition >}} + +## Authenticate HTTP API requests + +### Grafana OSS {{< docs/shared lookup="developers/authentication.md" source="grafana" version="" >}} +### Grafana Cloud + +{{< docs/shared source="grafana-cloud" lookup="/developer-resources/authentication.md" version="" >}} + ## X-Grafana-Org-Id Header **X-Grafana-Org-Id** is an optional property that specifies the organization to which the action is applied. If it is not set, the created key belongs to the current context org. Use this header in all requests except those regarding admin. @@ -75,10 +89,6 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - [Team API](team/) - [User API](user/) -## Deprecated HTTP APIs - -- [Authentication API](auth/) - ## Grafana Enterprise HTTP APIs Grafana Enterprise includes all of the Grafana OSS APIs as well as the following APIs: @@ -89,3 +99,7 @@ Grafana Enterprise includes all of the Grafana OSS APIs as well as the following - [Reporting API](reporting/) - [Role-based access control API](access_control/) - [Team sync API](team_sync/) + +## Deprecated HTTP APIs + +- [Authentication API](authentication/) diff --git a/docs/sources/developers/http_api/access_control.md b/docs/sources/developer-resources/api-reference/http-api/access_control.md similarity index 99% rename from docs/sources/developers/http_api/access_control.md rename to docs/sources/developer-resources/api-reference/http-api/access_control.md index e4d0dc2d5a3..0c65bfe5445 100644 --- a/docs/sources/developers/http_api/access_control.md +++ b/docs/sources/developer-resources/api-reference/http-api/access_control.md @@ -1,8 +1,9 @@ --- aliases: - - ../../http_api/access_control/ - - ../../http_api/accesscontrol/ -canonical: /docs/grafana/latest/developers/http_api/access_control/ + - ../../../http_api/access_control/ # /docs/grafana/next/http_api/access_control/ + - ../../../http_api/accesscontrol/ # /docs/grafana/next/http_api/accesscontrol/ + - ../../../developers/http_api/access_control/ # /docs/grafana/next/developers/http_api/access_control/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/access_control/ description: '' keywords: - grafana @@ -16,6 +17,7 @@ labels: products: - enterprise - oss + - cloud title: RBAC HTTP API --- diff --git a/docs/sources/developers/http_api/admin.md b/docs/sources/developer-resources/api-reference/http-api/admin.md similarity index 94% rename from docs/sources/developers/http_api/admin.md rename to docs/sources/developer-resources/api-reference/http-api/admin.md index 952dbce1e0a..85cefdae97e 100644 --- a/docs/sources/developers/http_api/admin.md +++ b/docs/sources/developer-resources/api-reference/http-api/admin.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/admin/ -canonical: /docs/grafana/latest/developers/http_api/admin/ + - ../../../http_api/admin/ # /docs/grafana/next/http_api/admin/ + - ../../../developers/http_api/admin/ # /docs/grafana/next/developers/http_api/admin/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/admin/ description: Grafana Admin HTTP API keywords: - grafana @@ -28,7 +29,7 @@ To use these API endpoints you have to use Basic authentication and the Grafana The `admin` user that Grafana is provisioned with by default has permissions to use these API endpoints. {{< /admonition >}} -> If you are running Grafana Enterprise, for some endpoints you'll need to have specific permissions. Refer to [Role-based access control permissions](../../../administration/roles-and-permissions/access-control/custom-role-actions-scopes/) for more information. +> If you are running Grafana Enterprise, for some endpoints you'll need to have specific permissions. Refer to [Role-based access control permissions](/docs/grafana//administration/roles-and-permissions/access-control/custom-role-actions-scopes/) for more information. ## Fetch settings @@ -371,7 +372,7 @@ Content-Type: application/json } ``` -Note that `OrgId` is an optional parameter that can be used to assign a new user to a different organization when [auto_assign_org](../../../setup-grafana/configure-grafana/#auto-assign-org) is set to `true`. +Note that `OrgId` is an optional parameter that can be used to assign a new user to a different organization when [auto_assign_org](/docs/grafana//setup-grafana/configure-grafana/#auto-assign-org) is set to `true`. **Example Response**: @@ -694,7 +695,7 @@ Content-Type: application/json `POST /api/admin/encryption/rotate-data-keys` -[Rotates](../../../setup-grafana/configure-security/configure-database-encryption/#rotate-data-keys) data encryption keys. +[Rotates](/docs/grafana//setup-grafana/configure-security/configure-database-encryption/#rotate-data-keys) data encryption keys. **Example Request**: @@ -715,7 +716,7 @@ Content-Type: application/json `POST /api/admin/encryption/reencrypt-data-keys` -[Re-encrypts](../../../setup-grafana/configure-security/configure-database-encryption/#re-encrypt-data-keys) data encryption keys. +[Re-encrypts](/docs/grafana//setup-grafana/configure-security/configure-database-encryption/#re-encrypt-data-keys) data encryption keys. **Example Request**: @@ -736,7 +737,7 @@ Content-Type: application/json `POST /api/admin/encryption/reencrypt-secrets` -[Re-encrypts](../../../setup-grafana/configure-security/configure-database-encryption/#re-encrypt-secrets) secrets. +[Re-encrypts](/docs/grafana//setup-grafana/configure-security/configure-database-encryption/#re-encrypt-secrets) secrets. **Example Request**: @@ -757,7 +758,7 @@ Content-Type: application/json `POST /api/admin/encryption/rollback-secrets` -[Rolls back](../../../setup-grafana/configure-security/configure-database-encryption/#roll-back-secrets) secrets. +[Rolls back](/docs/grafana//setup-grafana/configure-security/configure-database-encryption/#roll-back-secrets) secrets. **Example Request**: diff --git a/docs/sources/developers/http_api/alerting_provisioning.md b/docs/sources/developer-resources/api-reference/http-api/alerting_provisioning.md similarity index 52% rename from docs/sources/developers/http_api/alerting_provisioning.md rename to docs/sources/developer-resources/api-reference/http-api/alerting_provisioning.md index 4d991d54c8c..abbbf3c6785 100644 --- a/docs/sources/developers/http_api/alerting_provisioning.md +++ b/docs/sources/developer-resources/api-reference/http-api/alerting_provisioning.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/alerting_provisioning/ -canonical: /docs/grafana/latest/developers/http_api/alerting_provisioning/ + - ../../../http_api/alerting_provisioning/ # /docs/grafana/next/http_api/alerting_provisioning/ + - ../../../developers/http_api/alerting_provisioning/ # /docs/grafana/next/developers/http_api/alerting_provisioning/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/alerting_provisioning/ description: Grafana Alerts HTTP API keywords: - grafana diff --git a/docs/sources/developers/http_api/annotations.md b/docs/sources/developer-resources/api-reference/http-api/annotations.md similarity index 97% rename from docs/sources/developers/http_api/annotations.md rename to docs/sources/developer-resources/api-reference/http-api/annotations.md index 42dd486d991..19c514ad73f 100644 --- a/docs/sources/developers/http_api/annotations.md +++ b/docs/sources/developer-resources/api-reference/http-api/annotations.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/annotations/ -canonical: /docs/grafana/latest/developers/http_api/annotations/ + - ../../../http_api/annotations/ # /docs/grafana/next/http_api/annotations/ + - ../../../developers/http_api/annotations/ # /docs/grafana/next/developers/http_api/annotations/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/annotations/ description: Grafana Annotations HTTP API keywords: - grafana @@ -15,6 +16,7 @@ labels: products: - enterprise - oss + - cloud title: Annotations HTTP API --- diff --git a/docs/sources/developers/http_api/apis.md b/docs/sources/developer-resources/api-reference/http-api/apis.md similarity index 93% rename from docs/sources/developers/http_api/apis.md rename to docs/sources/developer-resources/api-reference/http-api/apis.md index 5dc90cba139..3bc092ad536 100644 --- a/docs/sources/developers/http_api/apis.md +++ b/docs/sources/developer-resources/api-reference/http-api/apis.md @@ -1,8 +1,8 @@ --- aliases: - - ../../http_api/new_api_structure/ - - ../../http_api/new_api_structure/ -canonical: /docs/grafana/latest/developers/http_api/new_api_structure/ + - ../../../http_api/new_api_structure/ # /docs/grafana/next/http_api/new_api_structure/ + - ../../../developers/http_api/apis/ # /docs/grafana/next/developers/http_api/apis/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/apis/ description: '' keywords: - grafana @@ -13,6 +13,7 @@ labels: products: - enterprise - oss + - cloud title: New API Structure --- diff --git a/docs/sources/developer-resources/api-reference/http-api/authentication.md b/docs/sources/developer-resources/api-reference/http-api/authentication.md new file mode 100644 index 00000000000..eda70363086 --- /dev/null +++ b/docs/sources/developer-resources/api-reference/http-api/authentication.md @@ -0,0 +1,31 @@ +--- +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/authentication/ +description: 'You can authenticate HTTP API requests using basic authentication or a service account token.' +keywords: + - grafana + - http + - documentation + - api + - role-based-access-control + - acl + - enterprise +labels: + products: + - enterprise + - oss +title: Authentication options for the HTTP API +menuTitle: Authentication +weight: 01 +aliases: + - ../../../developers/http_api/authentication/ # /docs/grafana/next/developers/http_api/authentication/ +--- + +# Authentication + +## Authentication options for the HTTP API for Grafana OSS + +{{< docs/shared lookup="developers/authentication.md" source="grafana" version="" >}} + +## Authentication options for the HTTP API in Grafana Cloud + +{{< docs/shared source="grafana-cloud" lookup="/developer-resources/authentication.md" version="" >}} diff --git a/docs/sources/developers/http_api/correlations.md b/docs/sources/developer-resources/api-reference/http-api/correlations.md similarity index 95% rename from docs/sources/developers/http_api/correlations.md rename to docs/sources/developer-resources/api-reference/http-api/correlations.md index 88c9359728a..20051795c8e 100644 --- a/docs/sources/developers/http_api/correlations.md +++ b/docs/sources/developer-resources/api-reference/http-api/correlations.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/correlations/ -canonical: /docs/grafana/latest/developers/http_api/correlations/ + - ../../../http_api/correlations/ # /docs/grafana/next/http_api/correlations/ + - ../../../developers/http_api/correlations/ # /docs/grafana/next/developers/http_api/correlations/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/correlations/ description: Grafana Correlations HTTP API keywords: - grafana @@ -14,6 +15,7 @@ labels: products: - enterprise - oss + - cloud title: 'Correlations HTTP API ' --- diff --git a/docs/sources/developers/http_api/dashboard.md b/docs/sources/developer-resources/api-reference/http-api/dashboard.md similarity index 99% rename from docs/sources/developers/http_api/dashboard.md rename to docs/sources/developer-resources/api-reference/http-api/dashboard.md index 0c886a83b48..5f28d4c54b1 100644 --- a/docs/sources/developers/http_api/dashboard.md +++ b/docs/sources/developer-resources/api-reference/http-api/dashboard.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/dashboard/ -canonical: /docs/grafana/latest/developers/http_api/dashboard/ + - ../../../http_api/dashboard/ # /docs/grafana/next/http_api/dashboard/ + - ../../../developers/http_api/dashboard/ # /docs/grafana/next/developers/http_api/dashboard/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/dashboard/ description: Grafana Dashboard HTTP API keywords: - grafana @@ -13,6 +14,7 @@ labels: products: - enterprise - oss + - cloud title: Dashboard HTTP API --- diff --git a/docs/sources/developers/http_api/dashboard_permissions.md b/docs/sources/developer-resources/api-reference/http-api/dashboard_permissions.md similarity index 91% rename from docs/sources/developers/http_api/dashboard_permissions.md rename to docs/sources/developer-resources/api-reference/http-api/dashboard_permissions.md index 3a12656faf5..f35b0fd5324 100644 --- a/docs/sources/developers/http_api/dashboard_permissions.md +++ b/docs/sources/developer-resources/api-reference/http-api/dashboard_permissions.md @@ -1,8 +1,9 @@ --- aliases: - - ../../http_api/dashboard_permissions/ - - ../../http_api/dashboardpermissions/ -canonical: /docs/grafana/latest/developers/http_api/dashboard_permissions/ + - ../../../http_api/dashboard_permissions/ # /docs/grafana/next/http_api/dashboard_permissions/ + - ../../../http_api/dashboardpermissions/ # /docs/grafana/next/http_api/dashboardpermissions/ + - ../../../developers/http_api/dashboard_permissions/ # /docs/grafana/next/developers/http_api/dashboard_permissions/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/dashboard_permissions/ description: Grafana Dashboard Permissions HTTP API keywords: - grafana @@ -17,6 +18,7 @@ labels: products: - enterprise - oss + - cloud title: Dashboard Permissions HTTP API --- diff --git a/docs/sources/developers/http_api/dashboard_public.md b/docs/sources/developer-resources/api-reference/http-api/dashboard_public.md similarity index 96% rename from docs/sources/developers/http_api/dashboard_public.md rename to docs/sources/developer-resources/api-reference/http-api/dashboard_public.md index 181a6b99b14..b5a68207fa3 100644 --- a/docs/sources/developers/http_api/dashboard_public.md +++ b/docs/sources/developer-resources/api-reference/http-api/dashboard_public.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/dashboard_public/ -canonical: /docs/grafana/latest/developers/http_api/dashboard_public/ + - ../../../http_api/dashboard_public/ # /docs/grafana/next/http_api/dashboard_public/ + - ../../../developers/http_api/dashboard_public/ # /docs/grafana/next/developers/http_api/dashboard_public/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/dashboard_public/ description: Grafana Shared Dashboards HTTP API keywords: - grafana @@ -13,6 +14,7 @@ labels: products: - enterprise - oss + - cloud title: Shared Dashboards HTTP API refs: role-based-access-control-permissions: diff --git a/docs/sources/developers/http_api/dashboard_versions.md b/docs/sources/developer-resources/api-reference/http-api/dashboard_versions.md similarity index 93% rename from docs/sources/developers/http_api/dashboard_versions.md rename to docs/sources/developer-resources/api-reference/http-api/dashboard_versions.md index fd9d3b85293..f4b46adad3f 100644 --- a/docs/sources/developers/http_api/dashboard_versions.md +++ b/docs/sources/developer-resources/api-reference/http-api/dashboard_versions.md @@ -1,8 +1,9 @@ --- aliases: - - ../../http_api/dashboard_versions/ - - ../../http_api/dashboardversions/ -canonical: /docs/grafana/latest/developers/http_api/dashboard_versions/ + - ../../../http_api/dashboard_versions/ # /docs/grafana/next/http_api/dashboard_versions/ + - ../../../http_api/dashboardversions/ # /docs/grafana/next/http_api/dashboardversions/ + - ../../../developers/http_api/dashboard_versions/ # /docs/grafana/next/developers/http_api/dashboard_versions/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/dashboard_versions/ description: Grafana Dashboard Versions HTTP API keywords: - grafana @@ -15,6 +16,7 @@ labels: products: - enterprise - oss + - cloud title: 'Dashboard Versions HTTP API ' --- diff --git a/docs/sources/developers/http_api/data_source.md b/docs/sources/developer-resources/api-reference/http-api/data_source.md similarity index 98% rename from docs/sources/developers/http_api/data_source.md rename to docs/sources/developer-resources/api-reference/http-api/data_source.md index b7eddb23310..171fb4ecae8 100644 --- a/docs/sources/developers/http_api/data_source.md +++ b/docs/sources/developer-resources/api-reference/http-api/data_source.md @@ -1,8 +1,9 @@ --- aliases: - - ../../http_api/data_source/ - - ../../http_api/datasource/ -canonical: /docs/grafana/latest/developers/http_api/data_source/ + - ../../../http_api/data_source/ # /docs/grafana/next/http_api/data_source/ + - ../../../http_api/datasource/ # /docs/grafana/next/http_api/datasource/ + - ../../../developers/http_api/data_source/ # /docs/grafana/next/developers/http_api/data_source/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/data_source/ description: Grafana Data source HTTP API keywords: - grafana @@ -14,6 +15,7 @@ labels: products: - enterprise - oss + - cloud title: Data source HTTP API --- diff --git a/docs/sources/developers/http_api/datasource_lbac_rules.md b/docs/sources/developer-resources/api-reference/http-api/datasource_lbac_rules.md similarity index 90% rename from docs/sources/developers/http_api/datasource_lbac_rules.md rename to docs/sources/developer-resources/api-reference/http-api/datasource_lbac_rules.md index cd13af3e4ec..5f91db7c8e6 100644 --- a/docs/sources/developers/http_api/datasource_lbac_rules.md +++ b/docs/sources/developer-resources/api-reference/http-api/datasource_lbac_rules.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/datasource_lbac_rules/ -canonical: /docs/grafana/latest/developers/http_api/datasource_lbac_rules/ + - ../../../http_api/datasource_lbac_rules/ # /docs/grafana/next/http_api/datasource_lbac_rules/ + - ../../../developers/http_api/datasource_lbac_rules/ # /docs/grafana/next/developers/http_api/datasource_lbac_rules/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/datasource_lbac_rules/ description: Data Source LBAC rules API keywords: - grafana diff --git a/docs/sources/developers/http_api/datasource_permissions.md b/docs/sources/developer-resources/api-reference/http-api/datasource_permissions.md similarity index 95% rename from docs/sources/developers/http_api/datasource_permissions.md rename to docs/sources/developer-resources/api-reference/http-api/datasource_permissions.md index b65464d0dba..772c3c16289 100644 --- a/docs/sources/developers/http_api/datasource_permissions.md +++ b/docs/sources/developer-resources/api-reference/http-api/datasource_permissions.md @@ -1,8 +1,9 @@ --- aliases: - - ../../http_api/datasource_permissions/ - - ../../http_api/datasourcepermissions/ -canonical: /docs/grafana/latest/developers/http_api/datasource_permissions/ + - ../../../http_api/datasource_permissions/ # /docs/grafana/next/http_api/datasource_permissions/ + - ../../../http_api/datasourcepermissions/ # /docs/grafana/next/http_api/datasourcepermissions/ + - ../../../developers/http_api/datasource_permissions/ # /docs/grafana/next/developers/http_api/datasource_permissions/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/datasource_permissions/ description: Data Source Permissions API keywords: - grafana @@ -18,6 +19,7 @@ labels: products: - enterprise - oss + - cloud title: Datasource Permissions HTTP API --- diff --git a/docs/sources/developer-resources/api-reference/http-api/examples/_index.md b/docs/sources/developer-resources/api-reference/http-api/examples/_index.md new file mode 100644 index 00000000000..e3b72a83195 --- /dev/null +++ b/docs/sources/developer-resources/api-reference/http-api/examples/_index.md @@ -0,0 +1,20 @@ +--- +aliases: + - ../../../developers/http_api/examples/ # /docs/grafana/next/developers/http_api/examples/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/examples/ +keywords: + - grafana + - tutorials + - API +labels: + products: + - enterprise + - oss +title: 'HTTP API examples' +menuTitle: 'Examples' +weight: 02 +--- + +# HTTP API examples + +{{< section >}} diff --git a/docs/sources/developers/http_api/examples/create-api-tokens-for-org.md b/docs/sources/developer-resources/api-reference/http-api/examples/create-api-tokens-for-org.md similarity index 89% rename from docs/sources/developers/http_api/examples/create-api-tokens-for-org.md rename to docs/sources/developer-resources/api-reference/http-api/examples/create-api-tokens-for-org.md index caa6bc90243..8752470afb5 100644 --- a/docs/sources/developers/http_api/examples/create-api-tokens-for-org.md +++ b/docs/sources/developer-resources/api-reference/http-api/examples/create-api-tokens-for-org.md @@ -1,8 +1,9 @@ --- aliases: - - ../../../http_api/create-api-tokens-for-org/ # /docs/grafana//http_api/create-api-tokens-for-org/ - - ../../../tutorials/api_org_token_howto/ # /docs/grafana//tutorials/api_org_token_howto/ -canonical: /docs/grafana/latest/developers/http_api/examples/create-api-tokens-for-org/ + - ../../../../http_api/create-api-tokens-for-org/ # /docs/grafana/next/http_api/create-api-tokens-for-org/ + - ../../../../tutorials/api_org_token_howto/ # /docs/grafana/next/tutorials/api_org_token_howto/ + - ../../../../developers/http_api/examples/create-api-tokens-for-org/ # /docs/grafana/next/developers/http_api/examples/create-api-tokens-for-org/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/examples/create-api-tokens-for-org/ keywords: - grafana - tutorials diff --git a/docs/sources/developers/http_api/examples/curl-examples.md b/docs/sources/developer-resources/api-reference/http-api/examples/curl-examples.md similarity index 76% rename from docs/sources/developers/http_api/examples/curl-examples.md rename to docs/sources/developer-resources/api-reference/http-api/examples/curl-examples.md index 80e48db81a5..0bd757e6a5a 100644 --- a/docs/sources/developers/http_api/examples/curl-examples.md +++ b/docs/sources/developer-resources/api-reference/http-api/examples/curl-examples.md @@ -1,8 +1,9 @@ --- aliases: - - ../../../http_api/curl-examples/ # /docs/grafana//http_api/curl-examples/ - - ../../http_api/curl-examples/ # /docs/grafana//developers/http_api/curl-examples/ -canonical: /docs/grafana/latest/developers/http_api/examples/curl-examples/ + - ../../../../http_api/curl-examples/ # /docs/grafana/next/http_api/curl-examples/ + - ../../../../developers/http_api/curl-examples/ # /docs/grafana/next/developers/http_api/curl-examples/ + - ../../../../developers/http_api/examples/curl-examples/ # /docs/grafana/next/developers/http_api/examples/curl-examples/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/examples/curl-examples/ description: cURL examples keywords: - grafana diff --git a/docs/sources/developers/http_api/folder.md b/docs/sources/developer-resources/api-reference/http-api/folder.md similarity index 98% rename from docs/sources/developers/http_api/folder.md rename to docs/sources/developer-resources/api-reference/http-api/folder.md index bc3b6605db0..f042a3a5a3e 100644 --- a/docs/sources/developers/http_api/folder.md +++ b/docs/sources/developer-resources/api-reference/http-api/folder.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/folder/ -canonical: /docs/grafana/latest/developers/http_api/folder/ + - ../../../http_api/folder/ # /docs/grafana/next/http_api/folder/ + - ../../../developers/http_api/folder/ # /docs/grafana/next/developers/http_api/folder/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/folder/ description: Grafana Folder HTTP API keywords: - grafana @@ -13,13 +14,14 @@ labels: products: - enterprise - oss + - cloud title: Folder HTTP API refs: apis: - pattern: /docs/grafana/ - destination: /docs/grafana//developers/http_api/apis/ + destination: /docs/grafana//developer-resources/api-reference/http-api/apis/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/developer-resources/api-reference/http-api/apis/ + destination: ./apis/ alerting: - pattern: /docs/grafana/ destination: /docs/grafana//alerting diff --git a/docs/sources/developers/http_api/folder_dashboard_search.md b/docs/sources/developer-resources/api-reference/http-api/folder_dashboard_search.md similarity index 89% rename from docs/sources/developers/http_api/folder_dashboard_search.md rename to docs/sources/developer-resources/api-reference/http-api/folder_dashboard_search.md index 819a1fcac6c..11de2e1a9b5 100644 --- a/docs/sources/developers/http_api/folder_dashboard_search.md +++ b/docs/sources/developer-resources/api-reference/http-api/folder_dashboard_search.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/folder_dashboard_search/ -canonical: /docs/grafana/latest/developers/http_api/folder_dashboard_search/ + - ../../../http_api/folder_dashboard_search/ # /docs/grafana/next/http_api/folder_dashboard_search/ + - ../../../developers/http_api/folder_dashboard_search/ # /docs/grafana/next/developers/http_api/folder_dashboard_search/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/folder_dashboard_search/ description: Grafana Folder/Dashboard Search HTTP API keywords: - grafana @@ -15,6 +16,7 @@ labels: products: - enterprise - oss + - cloud title: Folder/Dashboard Search HTTP API --- diff --git a/docs/sources/developers/http_api/folder_permissions.md b/docs/sources/developer-resources/api-reference/http-api/folder_permissions.md similarity index 90% rename from docs/sources/developers/http_api/folder_permissions.md rename to docs/sources/developer-resources/api-reference/http-api/folder_permissions.md index 74eeffff35a..93618409972 100644 --- a/docs/sources/developers/http_api/folder_permissions.md +++ b/docs/sources/developer-resources/api-reference/http-api/folder_permissions.md @@ -1,8 +1,9 @@ --- aliases: - - ../../http_api/dashboardpermissions/ - - ../../http_api/folder_permissions/ -canonical: /docs/grafana/latest/developers/http_api/folder_permissions/ + - ../../../http_api/dashboardpermissions/ # /docs/grafana/next/http_api/dashboardpermissions/ + - ../../../http_api/folder_permissions/ # /docs/grafana/next/http_api/folder_permissions/ + - ../../../developers/http_api/folder_permissions/ # /docs/grafana/next/developers/http_api/folder_permissions/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/folder_permissions/ description: Grafana Folder Permissions HTTP API keywords: - grafana @@ -17,6 +18,7 @@ labels: products: - enterprise - oss + - cloud title: Folder Permissions HTTP API --- diff --git a/docs/sources/developers/http_api/library_element.md b/docs/sources/developer-resources/api-reference/http-api/library_element.md similarity index 97% rename from docs/sources/developers/http_api/library_element.md rename to docs/sources/developer-resources/api-reference/http-api/library_element.md index 58f8f84fbe1..ea241c220e1 100644 --- a/docs/sources/developers/http_api/library_element.md +++ b/docs/sources/developer-resources/api-reference/http-api/library_element.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/library_element/ -canonical: /docs/grafana/latest/developers/http_api/library_element/ + - ../../../http_api/library_element/ # /docs/grafana/next/http_api/library_element/ + - ../../../developers/http_api/library_element/ # /docs/grafana/next/developers/http_api/library_element/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/library_element/ description: Grafana Library Element HTTP API keywords: - grafana @@ -13,6 +14,7 @@ labels: products: - enterprise - oss + - cloud title: 'Library Element HTTP API ' --- diff --git a/docs/sources/developers/http_api/licensing.md b/docs/sources/developer-resources/api-reference/http-api/licensing.md similarity index 95% rename from docs/sources/developers/http_api/licensing.md rename to docs/sources/developer-resources/api-reference/http-api/licensing.md index a7a8edba2eb..79cbb2135c0 100644 --- a/docs/sources/developers/http_api/licensing.md +++ b/docs/sources/developer-resources/api-reference/http-api/licensing.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/licensing/ -canonical: /docs/grafana/latest/developers/http_api/licensing/ + - ../../../http_api/licensing/ # /docs/grafana/next/http_api/licensing/ + - ../../../developers/http_api/licensing/ # /docs/grafana/next/developers/http_api/licensing/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/licensing/ description: Enterprise Licensing HTTP API keywords: - grafana diff --git a/docs/sources/developers/http_api/org.md b/docs/sources/developer-resources/api-reference/http-api/org.md similarity index 97% rename from docs/sources/developers/http_api/org.md rename to docs/sources/developer-resources/api-reference/http-api/org.md index d5fb8ab9cb5..5ad3389479f 100644 --- a/docs/sources/developers/http_api/org.md +++ b/docs/sources/developer-resources/api-reference/http-api/org.md @@ -1,8 +1,9 @@ --- aliases: - - ../../http_api/org/ - - ../../http_api/organization/ -canonical: /docs/grafana/latest/developers/http_api/org/ + - ../../../http_api/org/ # /docs/grafana/next/http_api/org/ + - ../../../http_api/organization/ # /docs/grafana/next/http_api/organization/ + - ../../../developers/http_api/org/ # /docs/grafana/next/developers/http_api/org/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/org/ description: Grafana Organization HTTP API keywords: - grafana diff --git a/docs/sources/developers/http_api/other.md b/docs/sources/developer-resources/api-reference/http-api/other.md similarity index 88% rename from docs/sources/developers/http_api/other.md rename to docs/sources/developer-resources/api-reference/http-api/other.md index 2cb0e3b342e..b0dbcd3a741 100644 --- a/docs/sources/developers/http_api/other.md +++ b/docs/sources/developer-resources/api-reference/http-api/other.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/other/ -canonical: /docs/grafana/latest/developers/http_api/other/ + - ../../../http_api/other/ # /docs/grafana/next/http_api/other/ + - ../../../developers/http_api/other/ # /docs/grafana/next/developers/http_api/other/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/other/ description: Grafana Other HTTP API keywords: - grafana diff --git a/docs/sources/developers/http_api/playlist.md b/docs/sources/developer-resources/api-reference/http-api/playlist.md similarity index 93% rename from docs/sources/developers/http_api/playlist.md rename to docs/sources/developer-resources/api-reference/http-api/playlist.md index 96a7c2f014a..a2c143e5f5b 100644 --- a/docs/sources/developers/http_api/playlist.md +++ b/docs/sources/developer-resources/api-reference/http-api/playlist.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/playlist/ -canonical: /docs/grafana/latest/developers/http_api/playlist/ + - ../../../http_api/playlist/ # /docs/grafana/next/http_api/playlist/ + - ../../../developers/http_api/playlist/ # /docs/grafana/next/developers/http_api/playlist/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/playlist/ description: Playlist Admin HTTP API keywords: - grafana @@ -13,6 +14,7 @@ labels: products: - enterprise - oss + - cloud title: 'Playlist HTTP API ' --- diff --git a/docs/sources/developers/http_api/preferences.md b/docs/sources/developer-resources/api-reference/http-api/preferences.md similarity index 92% rename from docs/sources/developers/http_api/preferences.md rename to docs/sources/developer-resources/api-reference/http-api/preferences.md index 048f9c38c6f..1cd350ee059 100644 --- a/docs/sources/developers/http_api/preferences.md +++ b/docs/sources/developer-resources/api-reference/http-api/preferences.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/preferences/ -canonical: /docs/grafana/latest/developers/http_api/preferences/ + - ../../../http_api/preferences/ # /docs/grafana/next/http_api/preferences/ + - ../../../developers/http_api/preferences/ # /docs/grafana/next/developers/http_api/preferences/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/preferences/ description: Grafana HTTP API keywords: - grafana @@ -13,6 +14,7 @@ labels: products: - enterprise - oss + - cloud title: 'Preferences API' --- diff --git a/docs/sources/developers/http_api/query_and_resource_caching.md b/docs/sources/developer-resources/api-reference/http-api/query_and_resource_caching.md similarity index 94% rename from docs/sources/developers/http_api/query_and_resource_caching.md rename to docs/sources/developer-resources/api-reference/http-api/query_and_resource_caching.md index 1102f593a72..32e74319ea3 100644 --- a/docs/sources/developers/http_api/query_and_resource_caching.md +++ b/docs/sources/developer-resources/api-reference/http-api/query_and_resource_caching.md @@ -1,9 +1,10 @@ --- aliases: - - ../../http_api/query_caching/ - - ../../http_api/resource_caching/ - - ../../http_api/caching/ -canonical: /docs/grafana/latest/developers/http_api/query_and_resource_caching/ + - ../../../http_api/query_caching/ # /docs/grafana/next/http_api/query_caching/ + - ../../../http_api/resource_caching/ # /docs/grafana/next/http_api/resource_caching/ + - ../../../http_api/caching/ # /docs/grafana/next/http_api/caching/ + - ../../../developers/http_api/query_and_resource_caching/ # /docs/grafana/next/developers/http_api/query_and_resource_caching/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/query_and_resource_caching/ description: Grafana Enterprise Query and Resource Caching HTTP API keywords: - grafana @@ -17,6 +18,8 @@ keywords: labels: products: - enterprise + - oss + - cloud title: Query and Resource Caching HTTP API --- diff --git a/docs/sources/developers/http_api/query_history.md b/docs/sources/developer-resources/api-reference/http-api/query_history.md similarity index 96% rename from docs/sources/developers/http_api/query_history.md rename to docs/sources/developer-resources/api-reference/http-api/query_history.md index d380ed1f722..d9a978e08f6 100644 --- a/docs/sources/developers/http_api/query_history.md +++ b/docs/sources/developer-resources/api-reference/http-api/query_history.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/query_history/ -canonical: /docs/grafana/latest/developers/http_api/query_history/ + - ../../../http_api/query_history/ # /docs/grafana/next/http_api/query_history/ + - ../../../developers/http_api/query_history/ # /docs/grafana/next/developers/http_api/query_history/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/query_history/ description: Grafana Query History HTTP API keywords: - grafana @@ -13,6 +14,7 @@ labels: products: - enterprise - oss + - cloud title: 'Query History HTTP API ' --- diff --git a/docs/sources/developers/http_api/reporting.md b/docs/sources/developer-resources/api-reference/http-api/reporting.md similarity index 98% rename from docs/sources/developers/http_api/reporting.md rename to docs/sources/developer-resources/api-reference/http-api/reporting.md index 4b02276975b..8e18f01d510 100644 --- a/docs/sources/developers/http_api/reporting.md +++ b/docs/sources/developer-resources/api-reference/http-api/reporting.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/reporting/ -canonical: /docs/grafana/latest/developers/http_api/reporting/ + - ../../../http_api/reporting/ # /docs/grafana/next/http_api/reporting/ + - ../../../developers/http_api/reporting/ # /docs/grafana/next/developers/http_api/reporting/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/reporting/ description: Grafana Enterprise APIs keywords: - grafana @@ -12,6 +13,7 @@ labels: products: - enterprise - oss + - cloud title: Reporting API --- diff --git a/docs/sources/developers/http_api/serviceaccount.md b/docs/sources/developer-resources/api-reference/http-api/serviceaccount.md similarity index 96% rename from docs/sources/developers/http_api/serviceaccount.md rename to docs/sources/developer-resources/api-reference/http-api/serviceaccount.md index 928fe7bf84d..a8f73db7dad 100644 --- a/docs/sources/developers/http_api/serviceaccount.md +++ b/docs/sources/developer-resources/api-reference/http-api/serviceaccount.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/serviceaccount/ -canonical: /docs/grafana/latest/developers/http_api/serviceaccount/ + - ../../../http_api/serviceaccount/ # /docs/grafana/next/http_api/serviceaccount/ + - ../../../developers/http_api/serviceaccount/ # /docs/grafana/next/developers/http_api/serviceaccount/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/serviceaccount/ description: Grafana service account HTTP API keywords: - grafana @@ -13,6 +14,7 @@ labels: products: - enterprise - oss + - cloud title: Service account HTTP API --- diff --git a/docs/sources/developers/http_api/short_url.md b/docs/sources/developer-resources/api-reference/http-api/short_url.md similarity index 79% rename from docs/sources/developers/http_api/short_url.md rename to docs/sources/developer-resources/api-reference/http-api/short_url.md index 84c754a42a5..b26e7a68a69 100644 --- a/docs/sources/developers/http_api/short_url.md +++ b/docs/sources/developer-resources/api-reference/http-api/short_url.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/short_url/ -canonical: /docs/grafana/latest/developers/http_api/short_url/ + - ../../../http_api/short_url/ # /docs/grafana/next/http_api/short_url/ + - ../../../developers/http_api/short_url/ # /docs/grafana/next/developers/http_api/short_url/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/short_url/ description: Grafana Short URL HTTP API keywords: - grafana @@ -13,6 +14,7 @@ labels: products: - enterprise - oss + - cloud title: 'Short URL HTTP API ' --- diff --git a/docs/sources/developers/http_api/snapshot.md b/docs/sources/developer-resources/api-reference/http-api/snapshot.md similarity index 94% rename from docs/sources/developers/http_api/snapshot.md rename to docs/sources/developer-resources/api-reference/http-api/snapshot.md index cae3828f2c6..7242f36dbf5 100644 --- a/docs/sources/developers/http_api/snapshot.md +++ b/docs/sources/developer-resources/api-reference/http-api/snapshot.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/snapshot/ -canonical: /docs/grafana/latest/developers/http_api/snapshot/ + - ../../../http_api/snapshot/ # /docs/grafana/next/http_api/snapshot/ + - ../../../developers/http_api/snapshot/ # /docs/grafana/next/developers/http_api/snapshot/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/snapshot/ description: Grafana HTTP API keywords: - grafana @@ -13,6 +14,7 @@ labels: products: - enterprise - oss + - cloud title: 'Snapshot API' --- diff --git a/docs/sources/developers/http_api/sso-settings.md b/docs/sources/developer-resources/api-reference/http-api/sso-settings.md similarity index 92% rename from docs/sources/developers/http_api/sso-settings.md rename to docs/sources/developer-resources/api-reference/http-api/sso-settings.md index 6aba3759f9c..e8a30c667c9 100644 --- a/docs/sources/developers/http_api/sso-settings.md +++ b/docs/sources/developer-resources/api-reference/http-api/sso-settings.md @@ -1,8 +1,9 @@ --- aliases: - - ../../http_api/sso-settings/ - - ../../http_api/ssosettings/ -canonical: /docs/grafana/latest/developers/http_api/sso-settings/ + - ../../../http_api/sso-settings/ # /docs/grafana/next/http_api/sso-settings/ + - ../../../http_api/ssosettings/ # /docs/grafana/next/http_api/ssosettings/ + - ../../../developers/http_api/sso-settings/ # /docs/grafana/next/developers/http_api/sso-settings/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/sso-settings/ description: Grafana SSO Settings API keywords: - grafana @@ -15,6 +16,7 @@ labels: products: - enterprise - oss + - cloud title: SSO Settings API --- @@ -25,7 +27,7 @@ title: SSO Settings API The API can be used to create, update, delete, get, and list SSO Settings for OAuth2 and SAML. The settings managed by this API are stored in the database and override -[settings from other sources](../../../setup-grafana/configure-access/configure-authentication/) +[settings from other sources](/docs/grafana//setup-grafana/configure-access/configure-authentication/) (arguments, environment variables, settings file, etc). Therefore, every time settings for a specific provider are removed or reset to the default settings at runtime, the settings are inherited from the other sources in the reverse order of precedence diff --git a/docs/sources/developers/http_api/team.md b/docs/sources/developer-resources/api-reference/http-api/team.md similarity index 97% rename from docs/sources/developers/http_api/team.md rename to docs/sources/developer-resources/api-reference/http-api/team.md index f7a501e4df6..25578435ba6 100644 --- a/docs/sources/developers/http_api/team.md +++ b/docs/sources/developer-resources/api-reference/http-api/team.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/team/ -canonical: /docs/grafana/latest/developers/http_api/team/ + - ../../../http_api/team/ # /docs/grafana/next/http_api/team/ + - ../../../developers/http_api/team/ # /docs/grafana/next/developers/http_api/team/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/team/ description: Grafana Team HTTP API keywords: - grafana @@ -15,6 +16,7 @@ labels: products: - enterprise - oss + - cloud title: Team HTTP API --- diff --git a/docs/sources/developers/http_api/team_sync.md b/docs/sources/developer-resources/api-reference/http-api/team_sync.md similarity index 90% rename from docs/sources/developers/http_api/team_sync.md rename to docs/sources/developer-resources/api-reference/http-api/team_sync.md index e3061524684..fc0a41769cc 100644 --- a/docs/sources/developers/http_api/team_sync.md +++ b/docs/sources/developer-resources/api-reference/http-api/team_sync.md @@ -1,8 +1,9 @@ --- aliases: - - ../../http_api/external_group_sync/ - - ./external_group_sync/ -canonical: /docs/grafana/latest/developers/http_api/team_sync/ + - ../../../http_api/external_group_sync/ # /docs/grafana/next/http_api/external_group_sync/ + - ../../../developers/http_api/external_group_sync/ # /docs/grafana/next/developers/http_api/external_group_sync/ + - ../../../developers/http_api/team_sync/ # /docs/grafana/next/developers/http_api/team_sync/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/team_sync/ description: Grafana Team Sync HTTP API keywords: - grafana @@ -18,6 +19,7 @@ labels: products: - enterprise - oss + - cloud title: Team Sync HTTP API --- diff --git a/docs/sources/developers/http_api/user.md b/docs/sources/developer-resources/api-reference/http-api/user.md similarity index 98% rename from docs/sources/developers/http_api/user.md rename to docs/sources/developer-resources/api-reference/http-api/user.md index c7d3d3a9294..d033b29e541 100644 --- a/docs/sources/developers/http_api/user.md +++ b/docs/sources/developer-resources/api-reference/http-api/user.md @@ -1,7 +1,8 @@ --- aliases: - - ../../http_api/user/ -canonical: /docs/grafana/latest/developers/http_api/user/ + - ../../../http_api/user/ # /docs/grafana/next/http_api/user/ + - ../../../developers/http_api/user/ # /docs/grafana/next/developers/http_api/user/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/user/ description: Grafana User HTTP API keywords: - grafana @@ -13,6 +14,7 @@ labels: products: - enterprise - oss + - cloud title: User HTTP API refs: role-based-access-control-permissions: diff --git a/docs/sources/developer-resources/api-reference/tracing-api.md b/docs/sources/developer-resources/api-reference/tracing-api.md new file mode 100644 index 00000000000..9fa850c13ac --- /dev/null +++ b/docs/sources/developer-resources/api-reference/tracing-api.md @@ -0,0 +1,22 @@ +--- +description: This topic includes links to Grafana Cloud tracing API documentation. +keywords: + - Grafana + - Cloud + - trace + - tracing + - API +menuTitle: Tracing API reference +title: Grafana Cloud tracing API reference +weight: 200 +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/tracing-api/ +--- + +# Grafana Cloud tracing API reference + +Consult the following tracing API reference resources as necessary: + +- [Tempo HTTP API](/docs/tempo/latest/api_docs/) +- [Push spans with HTTP: Example using HTTP API](/docs/tempo/latest/api_docs/pushing-spans-with-http/) +- [Metrics summary API (experimental)](/docs/grafana-cloud/send-data/traces/metrics-summary-api/) +- [Grafana Enterprise Traces Admin API](/docs/enterprise-traces/latest/admin-api/) diff --git a/docs/sources/developers/cla.md b/docs/sources/developer-resources/cla.md similarity index 97% rename from docs/sources/developers/cla.md rename to docs/sources/developer-resources/cla.md index 741a958f1ba..c8088917b94 100644 --- a/docs/sources/developers/cla.md +++ b/docs/sources/developer-resources/cla.md @@ -1,7 +1,8 @@ --- aliases: - - ../project/cla/ + - ../project/cla/ # /docs/grafana/next/project/cla/ - /docs/contributing/cla.html/ + - ../developers/cla/ # /docs/grafana/next/developers/cla/ description: Contributor License Agreement (CLA) labels: products: @@ -9,6 +10,7 @@ labels: - oss title: Contributor License Agreement (CLA) weight: 400 +canonical: https://grafana.com/docs/grafana/latest/developer-resources/cla/ --- # Grafana Labs Software Grant and Contributor License Agreement ("Agreement") diff --git a/docs/sources/developers/contribute.md b/docs/sources/developer-resources/contribute.md similarity index 95% rename from docs/sources/developers/contribute.md rename to docs/sources/developer-resources/contribute.md index 6f828a049ca..10d9095bcd9 100644 --- a/docs/sources/developers/contribute.md +++ b/docs/sources/developer-resources/contribute.md @@ -10,6 +10,9 @@ labels: - oss title: Contribute to Grafana weight: 300 +canonical: https://grafana.com/docs/grafana/latest/developer-resources/contribute/ +aliases: + - ../developers/contribute/ # /docs/grafana/next/developers/contribute/ --- # Contribute to Grafana @@ -20,7 +23,7 @@ This page lists resources for developers who want to contribute to the Grafana s Choose how you want to contribute to Grafana, from code to plugins to community support. -### Contribute Code to Grafana +### Contribute code to Grafana - [Contributing to Grafana](https://github.com/grafana/grafana/blob/main/CONTRIBUTING.md) introduces the contribution process and community resources. - [Developer guide](https://github.com/grafana/grafana/blob/main/contribute/developer-guide.md) shows how to set up your environment and run Grafana in development. @@ -41,7 +44,7 @@ Choose how you want to contribute to Grafana, from code to plugins to community - Help with [issue triage](https://github.com/grafana/grafana/blob/main/contribute/triage-issues.md) by reviewing, labeling, and clarifying open issues. - Report security vulnerabilities following our [security policy](https://github.com/grafana/grafana/security/policy). -## Choose the right channel +## Communicate with Grafana Use the right place to ask questions, report problems, and propose changes. diff --git a/docs/sources/developers/plugins/plugin.schema.json b/docs/sources/developer-resources/plugins/plugin.schema.json similarity index 100% rename from docs/sources/developers/plugins/plugin.schema.json rename to docs/sources/developer-resources/plugins/plugin.schema.json diff --git a/docs/sources/developers/_index.md b/docs/sources/developers/_index.md deleted file mode 100644 index 44c6ca4a2e7..00000000000 --- a/docs/sources/developers/_index.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -aliases: - - plugins/developing/ -labels: - products: - - enterprise - - oss -title: Developers -weight: 190 ---- - -# Developers - -Go to the [Grafana developer portal](/developers) to access the following documentation: - -- [Grafana plugin development](/developers/plugin-tools) -- [Grafana design system](https://developers.grafana.com) -- [Grafana Scenes](/developers/scenes) -- [Grafana data plane](/developers/dataplane) - -This section of our documentation contains additional resources: - -{{< section >}} - -You might also find the following resources to be helpful: - -- [Grafana Tutorials:](https://grafana.com/tutorials/) Step-by-step guides that help you make the most of Grafana. -- [Grafana Community Forums:](https://community.grafana.com) Get technical support for open source Grafana, Loki, and Tempo. diff --git a/docs/sources/developers/http_api/authentication.md b/docs/sources/developers/http_api/authentication.md deleted file mode 100644 index 40ef698feb1..00000000000 --- a/docs/sources/developers/http_api/authentication.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -canonical: https://grafana.com/docs/grafana/latest/developers/http_api/authentication/ -description: 'You can authenticate HTTP API requests using basic authentication or a service account token.' -keywords: - - grafana - - http - - documentation - - api - - role-based-access-control - - acl - - enterprise -labels: - products: - - enterprise - - oss -title: Authentication options for the HTTP API -menuTitle: Authentication -weight: 01 ---- - -# Authentication options for the HTTP API - -{{< docs/shared lookup="developers/authentication.md" source="grafana" version="" >}} diff --git a/docs/sources/developers/http_api/examples/_index.md b/docs/sources/developers/http_api/examples/_index.md deleted file mode 100644 index 6fd3810eaed..00000000000 --- a/docs/sources/developers/http_api/examples/_index.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -canonical: https://grafana.com/docs/grafana/latest/developers/http_api/examples/ -keywords: - - grafana - - tutorials - - API -labels: - products: - - enterprise - - oss -title: 'HTTP API examples' -menuTitle: 'Examples' -weight: 02 ---- - -# HTTP API examples - -{{< section >}} diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index a060cc4f750..0b802145d87 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -27,7 +27,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | | | `publicDashboardsScene` | Enables public dashboard rendering using scenes | Yes | | `featureHighlights` | Highlight Grafana Enterprise features | | -| `correlations` | Correlations page | Yes | | `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes | | `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes | | `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | Yes | @@ -107,6 +106,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `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 diff --git a/docs/sources/setup-grafana/configure-grafana/proxy/index.md b/docs/sources/setup-grafana/configure-grafana/proxy/index.md index 7f17909ad5d..c448b252cce 100644 --- a/docs/sources/setup-grafana/configure-grafana/proxy/index.md +++ b/docs/sources/setup-grafana/configure-grafana/proxy/index.md @@ -15,35 +15,35 @@ weight: 1110 # Configure a data source SOCKS5 connection proxy -Grafana provides support for proxying data source connections through a Secure Socks5 Tunnel. This enables you to securely connect to data sources hosted in a different network than Grafana. +Grafana provides support for proxying data source connections through a secure SOCKS5 proxy. This enables you to securely connect to data sources hosted in a different network than Grafana. -To make use of this functionality, you need to deploy a socks5 proxy server that supports TLS on a machine exposed to the public internet within the same network as your data source. From there, Grafana establishes a mutually trusted connection from Grafana to the Proxy. Then the Proxy can proxy the Grafana connection to your private server without exposing your data sources to the public internet. +To make use of this functionality, you need to deploy a SOCKS5 proxy server that supports TLS on a machine accessible from where your Grafana instance is running, and within the same network as your data source. From there, Grafana establishes a mutually trusted connection from Grafana to the proxy. Then the SOCKS5 proxy can proxy the Grafana data source connection to your private data source instance without exposing your data source to the public internet. ## Known limitations -- You can configure only one socks5 proxy per Grafana instance +- You can configure only one SOCKS5 proxy per Grafana instance. This is because the SOCKS5 proxy address and TLS configuration is set at the Grafana instance level. - All built-in core data sources are compatible, but not all external data sources are. For a list of supported data sources, refer to [private data source connect](/docs/grafana-cloud/data-configuration/configure-private-datasource-connect/#known-limitations). ## Before you begin -To complete this task, you must first deploy a socks proxy server that supports TLS, is publicly accessible, and is hosted within the same network as the data source. +To complete this task, you must first have a SOCKS5 proxy server running, using host certificates signed by a Certificate Authority (CA) that you can get the public certificate for. You must also have a TLS client key pair for Grafana, with the client certificate signed by the same CA used to sign the proxy server certificate. ## Steps 1. For Grafana to send data source connections to the socks5 server, use the following table to configure the `secure_socks_datasource_proxy` section of the `config.ini`: - | Key | Description | Example | - | ---------------- | ------------------------------------------ | ------------------------------- | - | `enabled` | Enable this feature in Grafana | true | - | `root_ca_cert` | The file path of the root ca cert | /etc/ca.crt | - | `client_key` | The file path of the client private key | /etc/client.key | - | `client_cert` | The file path of the client public key | /etc/client.crt | - | `server_name` | The domain name of the proxy, used for SNI | proxy.grafana.svc.cluster.local | - | `proxy_address` | The address of the proxy | localhost:9090 | - | `allow_insecure` | Disable TLS in the socks proxy | false | + | Key | Description | Example | + | ---------------- | --------------------------------------------------------------------------- | ------------------------------- | + | `enabled` | Enable this feature in Grafana | true | + | `root_ca_cert` | The file path of the root ca cert used to sign the proxy server certificate | /etc/ca.crt | + | `client_key` | The file path of the client private key | /etc/client.key | + | `client_cert` | The file path of the client public key | /etc/client.crt | + | `server_name` | The domain name of the proxy, used for SNI | proxy.grafana.svc.cluster.local | + | `proxy_address` | The address of the proxy | localhost:9090 | + | `allow_insecure` | Disable TLS in the socks proxy | false | 1. Set up a data source and configure it to send data source connections through the proxy. To configure your data sources to send connections through the proxy, `enableSecureSocksProxy=true` must be specified in the data source json. You can do this in the [API](../../../developers/http_api/data_source/) or use [file based provisioning](../../../administration/provisioning/#data-sources). - Additionally, you can set the socks5 username and password by adding `secureSocksProxyUsername` in the data source json and `secureSocksProxyPassword` in the secure data source json. + Additionally, if using SOCKS5 authentication, you can set the SOCKS5 username and password by adding `secureSocksProxyUsername` in the data source's `jsonData` field and `secureSocksProxyPassword` in the data source's `secureJsonData` field. diff --git a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts index 6d4b589b79c..2d56cd14512 100644 --- a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts @@ -1,4 +1,4 @@ -import { addDays, addHours, differenceInCalendarDays, differenceInMinutes, isBefore, parseISO, toDate } from 'date-fns'; +import { differenceInMinutes, parseISO, toDate } from 'date-fns'; import { Page } from 'playwright-core'; import { test, expect, DashboardPage, E2ESelectorGroups } from '@grafana/plugin-e2e'; @@ -213,10 +213,7 @@ const isTimeCorrect = (inUtc: string, inTz: string, offset: number): boolean => } const utcDate = toDate(parseISO(inUtc)); - const utcDateWithOffset = addHours(toDate(parseISO(inUtc)), offset); - const dayDifference = differenceInCalendarDays(utcDate, utcDateWithOffset); // if the utcDate +/- offset is the day before/after then we need to adjust reference - const dayOffset = isBefore(utcDateWithOffset, utcDate) ? dayDifference * -1 : dayDifference; - const tzDate = addDays(toDate(parseISO(inTz)), dayOffset); // adjust tzDate with any dayOffset + const tzDate = toDate(parseISO(inTz)); const diff = Math.abs(differenceInMinutes(utcDate, tzDate)); // use Math.abs if tzDate is in future return diff <= Math.abs(offset * 60); diff --git a/e2e-playwright/panels-suite/timeseries.spec.ts b/e2e-playwright/panels-suite/timeseries.spec.ts new file mode 100644 index 00000000000..fb2124f7dda --- /dev/null +++ b/e2e-playwright/panels-suite/timeseries.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +const DASHBOARD_UID = '1KxMUdE7k'; + +test.use({ + featureToggles: { + timeRangePan: true, + }, +}); + +test.describe('Panels test: TimeSeries X-axis panning', { tag: ['@panels', '@timeseries'] }, () => { + 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(); + await expect(timeseriesPanel, 'panel rendered').toBeVisible(); + + const xAxis = timeseriesPanel.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'); + }); + }); + + 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 }); + + 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(); + + 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; + + return dashboardPage; + }); + + 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); + }); + }); +}); diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 57b56b49744..bcb6d105d35 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -237,7 +237,7 @@ }, "packages/grafana-data/src/types/panel.ts": { "@typescript-eslint/no-explicit-any": { - "count": 13 + "count": 11 } }, "packages/grafana-data/src/types/plugin.ts": { diff --git a/package.json b/package.json index f626ebc1486..76e6b804c52 100644 --- a/package.json +++ b/package.json @@ -278,8 +278,8 @@ "@glideapps/glide-data-grid": "^6.0.0", "@grafana/alerting": "workspace:*", "@grafana/api-clients": "workspace:*", - "@grafana/assistant": "0.1.1", - "@grafana/aws-sdk": "0.7.1", + "@grafana/assistant": "0.1.4", + "@grafana/aws-sdk": "0.8.3", "@grafana/azure-sdk": "0.0.8", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", @@ -293,11 +293,11 @@ "@grafana/llm": "0.22.1", "@grafana/monaco-logql": "^0.0.8", "@grafana/o11y-ds-frontend": "workspace:*", - "@grafana/plugin-ui": "^0.10.10", + "@grafana/plugin-ui": "^0.11.1", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "^6.42.1", - "@grafana/scenes-react": "^6.42.1", + "@grafana/scenes": "^6.46.0", + "@grafana/scenes-react": "^6.46.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", @@ -306,7 +306,7 @@ "@leeoniya/ufuzzy": "1.0.19", "@lezer/common": "1.2.3", "@lezer/highlight": "1.2.1", - "@lezer/lr": "1.4.2", + "@lezer/lr": "1.4.3", "@locker/near-membrane-dom": "0.14.0", "@locker/near-membrane-shared": "0.14.0", "@locker/near-membrane-shared-dom": "0.14.0", @@ -382,7 +382,7 @@ "nanoid": "^5.0.9", "node-forge": "^1.3.1", "ol": "10.6.1", - "ol-ext": "4.0.35", + "ol-ext": "4.0.36", "ol-mapbox-style": "^13.0.1", "pluralize": "^8.0.0", "prismjs": "1.30.0", diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index a3ef43c91e8..06b7f19444e 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -651,12 +651,7 @@ export { type PanelMenuItem, type AngularPanelMenuItem, type PanelPluginDataSupport, - type VisualizationSuggestion, - type VisualizationSuggestionsSupplier, VizOrientation, - VisualizationSuggestionScore, - VisualizationSuggestionsBuilder, - VisualizationSuggestionsListAppender, } from './types/panel'; export { type DataSourcePluginOptionsEditorProps, @@ -717,6 +712,13 @@ export { type ApplyFieldOverrideOptions, FieldConfigProperty, } from './types/fieldOverrides'; +export { + type VisualizationSuggestion, + type VisualizationSuggestionsSupplier, + VisualizationSuggestionScore, + VisualizationSuggestionsBuilder, + VisualizationSuggestionsListAppender, +} from './types/suggestions'; export { type MatcherConfig, type DataTransformContext, diff --git a/packages/grafana-data/src/panel/PanelPlugin.ts b/packages/grafana-data/src/panel/PanelPlugin.ts index 4822d4c6acb..e9119a37b52 100644 --- a/packages/grafana-data/src/panel/PanelPlugin.ts +++ b/packages/grafana-data/src/panel/PanelPlugin.ts @@ -7,7 +7,6 @@ import { PanelModel } from '../types/dashboard'; import { FieldConfigProperty, FieldConfigSource } from '../types/fieldOverrides'; import { PanelPluginMeta, - VisualizationSuggestionsSupplier, PanelProps, PanelEditorProps, PanelMigrationHandler, @@ -15,6 +14,7 @@ import { PanelPluginDataSupport, } from '../types/panel'; import { GrafanaPlugin } from '../types/plugin'; +import { VisualizationSuggestionsSupplier } from '../types/suggestions'; import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders'; import { deprecationWarning } from '../utils/deprecationWarning'; diff --git a/packages/grafana-data/src/themes/breakpoints.ts b/packages/grafana-data/src/themes/breakpoints.ts index 1ad0d713781..3d9f01111db 100644 --- a/packages/grafana-data/src/themes/breakpoints.ts +++ b/packages/grafana-data/src/themes/breakpoints.ts @@ -18,6 +18,10 @@ export interface ThemeBreakpoints { unit: string; up: (key: ThemeBreakpointsKey | number) => string; down: (key: ThemeBreakpointsKey | number) => string; + container: { + up: (key: ThemeBreakpointsKey | number, name?: string) => string; + down: (key: ThemeBreakpointsKey | number, name?: string) => string; + }; } /** @internal */ @@ -44,6 +48,18 @@ export function createBreakpoints(): ThemeBreakpoints { return `@media (max-width:${value - step / 100}${unit})`; } + function containerUp(key: ThemeBreakpointsKey | number, name?: string) { + const value = typeof key === 'number' ? key : values[key]; + const query = typeof name === 'string' ? `@container ${name}` : '@container'; + return `${query} (width >= ${value}${unit})`; + } + + function containerDown(key: ThemeBreakpointsKey | number, name?: string) { + const value = typeof key === 'number' ? key : values[key]; + const query = typeof name === 'string' ? `@container ${name}` : '@container'; + return `${query} (width < ${value}${unit})`; + } + // TODO add functions for between and only return { @@ -52,5 +68,9 @@ export function createBreakpoints(): ThemeBreakpoints { down, keys, unit, + container: { + up: containerUp, + down: containerDown, + }, }; } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index fa9205b7bc4..91e27172c1f 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -50,11 +50,6 @@ export interface FeatureToggles { */ storage?: boolean; /** - * Correlations page - * @default true - */ - correlations?: boolean; - /** * Allow elements nesting */ canvasPanelNesting?: boolean; @@ -350,6 +345,10 @@ export interface FeatureToggles { */ alertingProvenanceLockWrites?: boolean; /** + * Enables the UI to use certain backend-side filters + */ + alertingUIUseBackendFilters?: boolean; + /** * Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. */ alertmanagerRemotePrimary?: boolean; @@ -412,10 +411,6 @@ export interface FeatureToggles { */ logRowsPopoverMenu?: boolean; /** - * Disables passing host environment variable to plugin processes - */ - pluginsSkipHostEnvVars?: boolean; - /** * Enables shared crosshair in table panel */ tableSharedCrosshair?: boolean; @@ -839,10 +834,6 @@ export interface FeatureToggles { */ teamHttpHeadersTempo?: boolean; /** - * Use new **Combobox** component for template variables - */ - templateVariablesUsesCombobox?: boolean; - /** * Enables Advisor app */ grafanaAdvisor?: boolean; @@ -1186,6 +1177,11 @@ export interface FeatureToggles { */ newGauge?: boolean; /** + * Enable new visualization suggestions + * @default false + */ + newVizSuggestions?: boolean; + /** * Restrict PanelChrome contents with overflow: hidden; * @default true */ diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index 426c52abadf..2664d0bec72 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -1,8 +1,5 @@ -import { defaultsDeep } from 'lodash'; - import { EventBus } from '../events/types'; import { StandardEditorProps } from '../field/standardFieldConfigEditorRegistry'; -import { PanelDataSummary, getPanelDataSummary } from '../panel/suggestions/getPanelDataSummary'; import { Registry } from '../utils/Registry'; import { OptionsEditorItem } from './OptionsUIRegistryBuilder'; @@ -17,7 +14,6 @@ import { IconName } from './icon'; import { OptionEditorConfig } from './options'; import { PluginMeta } from './plugin'; import { AbsoluteTimeRange, TimeRange, TimeZone } from './time'; -import { DataTransformerConfig } from './transformations'; export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string; @@ -219,95 +215,3 @@ export interface PanelPluginDataSupport { annotations: boolean; alertStates: boolean; } - -/** - * @alpha - */ -export interface VisualizationSuggestion { - /** Name of suggestion */ - name: string; - /** Description */ - description?: string; - /** Panel plugin id */ - pluginId: string; - /** Panel plugin options */ - options?: Partial; - /** Panel plugin field options */ - fieldConfig?: FieldConfigSource>; - /** Data transformations */ - transformations?: DataTransformerConfig[]; - /** Options for how to render suggestion card */ - cardOptions?: { - /** Tweak for small preview */ - previewModifier?: (suggestion: VisualizationSuggestion) => void; - icon?: string; - imgSrc?: string; - }; - /** A value between 0-100 how suitable suggestion is */ - score?: VisualizationSuggestionScore; -} - -/** - * @alpha - */ -export enum VisualizationSuggestionScore { - /** We are pretty sure this is the best possible option */ - Best = 100, - /** Should be a really good option */ - Good = 70, - /** Can be visualized but there are likely better options. If no score is set this score is assumed */ - OK = 50, -} - -/** - * @alpha - */ -export class VisualizationSuggestionsBuilder { - /** Current data */ - data?: PanelData; - /** Current panel & options */ - panel?: PanelModel; - /** Summary stats for current data */ - dataSummary: PanelDataSummary; - - private list: VisualizationSuggestion[] = []; - - constructor(data?: PanelData, panel?: PanelModel) { - this.data = data; - this.panel = panel; - this.dataSummary = getPanelDataSummary(this.data?.series); - } - - getListAppender(defaults: VisualizationSuggestion) { - return new VisualizationSuggestionsListAppender(this.list, defaults); - } - - getList() { - return this.list; - } -} - -/** - * @alpha - */ -export type VisualizationSuggestionsSupplier = { - /** - * Adds good suitable suggestions for the current data - */ - getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void; -}; - -/** - * Helps with typings and defaults - * @alpha - */ -export class VisualizationSuggestionsListAppender { - constructor( - private list: VisualizationSuggestion[], - private defaults: VisualizationSuggestion - ) {} - - append(overrides: Partial>) { - this.list.push(defaultsDeep(overrides, this.defaults)); - } -} diff --git a/packages/grafana-data/src/types/suggestions.ts b/packages/grafana-data/src/types/suggestions.ts new file mode 100644 index 00000000000..a7b2611a990 --- /dev/null +++ b/packages/grafana-data/src/types/suggestions.ts @@ -0,0 +1,100 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { defaultsDeep } from 'lodash'; + +import { DataTransformerConfig } from '@grafana/schema'; + +import { PanelDataSummary, getPanelDataSummary } from '../panel/suggestions/getPanelDataSummary'; + +import { PanelModel } from './dashboard'; +import { FieldConfigSource } from './fieldOverrides'; +import { PanelData } from './panel'; + +/** + * @alpha + */ +export interface VisualizationSuggestion { + /** Name of suggestion */ + name: string; + /** Description */ + description?: string; + /** Panel plugin id */ + pluginId: string; + /** Panel plugin options */ + options?: Partial; + /** Panel plugin field options */ + fieldConfig?: FieldConfigSource>; + /** Data transformations */ + transformations?: DataTransformerConfig[]; + /** Options for how to render suggestion card */ + cardOptions?: { + /** Tweak for small preview */ + previewModifier?: (suggestion: VisualizationSuggestion) => void; + icon?: string; + imgSrc?: string; + }; + /** A value between 0-100 how suitable suggestion is */ + score?: VisualizationSuggestionScore; +} + +/** + * @alpha + */ +export enum VisualizationSuggestionScore { + /** We are pretty sure this is the best possible option */ + Best = 100, + /** Should be a really good option */ + Good = 70, + /** Can be visualized but there are likely better options. If no score is set this score is assumed */ + OK = 50, +} + +/** + * @alpha + */ +export class VisualizationSuggestionsBuilder { + /** Summary stats for current data */ + dataSummary: PanelDataSummary; + private list: VisualizationSuggestion[] = []; + + constructor( + /** Current data */ + public data?: PanelData, + /** Current panel & options */ + public panel?: PanelModel + ) { + this.dataSummary = getPanelDataSummary(data?.series); + } + + getListAppender(defaults: VisualizationSuggestion) { + return new VisualizationSuggestionsListAppender(this.list, defaults); + } + + getList() { + return this.list; + } +} + +/** + * @alpha + */ +export type VisualizationSuggestionsSupplier = { + /** + * Adds good suitable suggestions for the current data + */ + getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void; +}; + +/** + * Helps with typings and defaults + * @alpha + */ +export class VisualizationSuggestionsListAppender { + constructor( + private list: VisualizationSuggestion[], + private defaults: VisualizationSuggestion + ) {} + + append(overrides: Partial>) { + this.list.push(defaultsDeep(overrides, this.defaults)); + } +} diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index a4d36839bf6..a0dba4898e9 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -52,7 +52,7 @@ "@leeoniya/ufuzzy": "1.0.19", "@lezer/common": "1.2.3", "@lezer/highlight": "1.2.1", - "@lezer/lr": "1.4.2", + "@lezer/lr": "1.4.3", "@prometheus-io/lezer-promql": "0.305.0", "@reduxjs/toolkit": "2.9.0", "@types/debounce-promise": "3.1.9", diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx index 17347c7a20b..3468be71316 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx @@ -6,8 +6,8 @@ import * as React from 'react'; import { CoreApp, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; -import { EditorField, EditorRow, EditorSwitch } from '@grafana/plugin-ui'; -import { AutoSizeInput, RadioButtonGroup, Select } from '@grafana/ui'; +import { EditorField, EditorSwitch } from '@grafana/plugin-ui'; +import { AutoSizeInput, Box, RadioButtonGroup, Select } from '@grafana/ui'; import { getQueryTypeChangeHandler, getQueryTypeOptions } from '../../components/PromExploreExtraField'; import { PromQueryFormat } from '../../dataquery'; @@ -80,7 +80,7 @@ export const PromQueryBuilderOptions = React.memo( const queryTypeLabel = queryTypeOptions.find((x) => x.value === queryTypeValue)!.label; return ( - +
( )}
-
+ ); } ); diff --git a/packages/grafana-ui/src/components/Table/TableCellInspector.test.tsx b/packages/grafana-ui/src/components/Table/TableCellInspector.test.tsx new file mode 100644 index 00000000000..c4a6e823add --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableCellInspector.test.tsx @@ -0,0 +1,17 @@ +import { screen, render } from '@testing-library/react'; + +import { TableCellInspector, TableCellInspectorMode } from './TableCellInspector'; + +describe('TableCellInspector', () => { + it.each([ + { type: 'string', value: 'simple string' }, + { type: 'number', value: 12345 }, + { type: 'object', value: { key: 'value', anotherKey: 42 } }, + { type: 'array', value: [1, 2, 3, 4, 5] }, + { type: 'null', value: null }, + { type: 'undefined', value: undefined }, + ])('should successfully render for input of type $type', ({ value }) => { + render( {}} mode={TableCellInspectorMode.text} />); + expect(screen.getByText('Copy to Clipboard')).toBeInTheDocument(); + }); +}); diff --git a/packages/grafana-ui/src/components/Table/TableCellInspector.tsx b/packages/grafana-ui/src/components/Table/TableCellInspector.tsx index 10169328ba4..e1ecae5b355 100644 --- a/packages/grafana-ui/src/components/Table/TableCellInspector.tsx +++ b/packages/grafana-ui/src/components/Table/TableCellInspector.tsx @@ -18,15 +18,21 @@ export enum TableCellInspectorMode { } interface TableCellInspectorProps { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: string; + value: unknown; onDismiss: () => void; mode: TableCellInspectorMode; } +const toString = (value: unknown): string => { + if (typeof value === 'string') { + return value; + } + return value?.toString?.() ?? ''; +}; + export function TableCellInspector({ value, onDismiss, mode }: TableCellInspectorProps) { const [currentMode, setMode] = useState(mode); - const text = value.trim(); + const text = toString(value).trim(); const styles = useStyles2(getStyles); const tabs = [ diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index 2431d9ecff5..27322714477 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -29,6 +29,8 @@ const cursorDefaults: Cursor = { type PrepData = (frames: DataFrame[]) => AlignedData | FacetedData; type PreDataStacked = (frames: DataFrame[], stackingGroups: StackingGroup[]) => AlignedData | FacetedData; +type PlotState = { isPanning: false } | { isPanning: true; min: number; max: number }; + export class UPlotConfigBuilder { readonly uid = Math.random().toString(36).slice(2); @@ -47,6 +49,7 @@ export class UPlotConfigBuilder { // to prevent more than one threshold per scale private thresholds: Record = {}; private padding?: Padding = undefined; + private state: PlotState = { isPanning: false }; private cachedConfig?: PlotConfig; @@ -59,6 +62,14 @@ export class UPlotConfigBuilder { // Exposed to let the container know the primary scale keys scaleKeys: [string, string] = ['', '']; + setState(state: PlotState) { + this.state = merge({}, this.state, state); + } + + getState() { + return this.state; + } + addHook(type: T, hook: Hooks.Defs[T]) { pluginLog('UPlotConfigBuilder', false, 'addHook', type); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.test.tsx b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.test.tsx new file mode 100644 index 00000000000..d9a6e787251 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.test.tsx @@ -0,0 +1,159 @@ +import uPlot from 'uplot'; + +import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; + +import { calculatePanRange, setupXAxisPan } from './XAxisInteractionAreaPlugin'; + +const asUPlot = (partial: Partial) => partial as uPlot; +const asConfigBuilder = (partial: Partial) => partial as UPlotConfigBuilder; + +const createMockXAxis = () => { + const element = document.createElement('div'); + element.classList.add('u-axis'); + return element; +}; + +const createMockConfigBuilder = () => { + return { + setState: jest.fn(), + getState: jest.fn(() => ({ isPanning: false })), + } satisfies Partial; +}; + +const createMockUPlot = (xAxisElement: HTMLElement) => { + const root = document.createElement('div'); + root.appendChild(xAxisElement); + + const over = document.createElement('div'); + Object.defineProperty(over, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width: 800, height: 400 }), + }); + + return { + root, + over, + bbox: { width: 800, height: 400, left: 0, top: 0 }, + scales: { + x: { + min: 1000, + max: 2000, + range: () => [1000, 2000], + }, + }, + setScale: jest.fn(), + } satisfies Partial; +}; + +describe('XAxisInteractionAreaPlugin', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('calculatePanRange', () => { + it('should calculate pan range correctly for positive and negative drag', () => { + const timeFrom = 1000; + const timeTo = 2000; + const plotWidth = 800; + + const dragRight100px = calculatePanRange(timeFrom, timeTo, 100, plotWidth); + expect(dragRight100px.from).toBeCloseTo(875, 1); + expect(dragRight100px.to).toBeCloseTo(1875, 1); + + const dragLeft100px = calculatePanRange(timeFrom, timeTo, -100, plotWidth); + expect(dragLeft100px.from).toBeCloseTo(1125, 1); + expect(dragLeft100px.to).toBeCloseTo(2125, 1); + }); + + it('should return original range when not dragged', () => { + const noDrag = calculatePanRange(1000, 2000, 0, 800); + expect(noDrag.from).toBe(1000); + expect(noDrag.to).toBe(2000); + }); + }); + + describe('setupXAxisPan', () => { + let mockQueryZoom: jest.Mock; + let mockConfigBuilder: ReturnType; + let xAxisElement: HTMLElement; + let mockUPlot: ReturnType; + + beforeEach(() => { + mockQueryZoom = jest.fn(); + mockConfigBuilder = createMockConfigBuilder(); + xAxisElement = createMockXAxis(); + mockUPlot = createMockUPlot(xAxisElement); + document.body.appendChild(mockUPlot.root!); + }); + + afterEach(() => { + document.body.innerHTML = ''; + jest.clearAllMocks(); + }); + + it('should handle missing x-axis element gracefully', () => { + const emptyRoot = document.createElement('div'); + const emptyUPlot = { ...mockUPlot, root: emptyRoot }; + + expect(() => setupXAxisPan(asUPlot(emptyUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom)).not.toThrow(); + }); + + it('should show grab cursor on hover and grabbing during drag', () => { + setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom); + + xAxisElement.dispatchEvent(new MouseEvent('mouseenter')); + expect(xAxisElement).toHaveStyle({ cursor: 'grab' }); + + xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true })); + expect(xAxisElement).toHaveStyle({ cursor: 'grabbing' }); + + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 350, bubbles: true })); + expect(xAxisElement).toHaveStyle({ cursor: 'grab' }); + }); + + it('should update scale during drag and call queryZoom on completion', () => { + setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom); + + xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true })); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 350, bubbles: true })); + + const expectedRange = calculatePanRange(1000, 2000, -50, 800); + + expect(mockUPlot.setScale).toHaveBeenCalledWith('x', { + min: expectedRange.from, + max: expectedRange.to, + }); + + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 350, bubbles: true })); + + expect(mockQueryZoom).toHaveBeenCalledWith(expectedRange); + }); + + it('should not call queryZoom when drag distance is below threshold', () => { + setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom); + + xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true })); + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 402, bubbles: true })); + + expect(mockQueryZoom).not.toHaveBeenCalled(); + }); + + it('should set isPanning state during drag and clear on mouseup', () => { + setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom); + + xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true })); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 350, bubbles: true })); + + const expectedRange = calculatePanRange(1000, 2000, -50, 800); + + expect(mockConfigBuilder.setState).toHaveBeenCalledWith({ + isPanning: true, + min: expectedRange.from, + max: expectedRange.to, + }); + + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 350, bubbles: true })); + + expect(mockConfigBuilder.setState).toHaveBeenCalledWith({ isPanning: false }); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.tsx new file mode 100644 index 00000000000..694f372307d --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.tsx @@ -0,0 +1,164 @@ +import { useLayoutEffect } from 'react'; +import uPlot from 'uplot'; + +import { getFeatureToggle } from '../../../utils/featureToggle'; +import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; + +const MIN_PAN_DIST = 5; + +/** + * Calculates the new time range after a pan operation. + * + * @returns Object containing the new from and to time values + * @internal - exported for testing only + */ +export const calculatePanRange = ( + timeFrom: number, + timeTo: number, + dragPixels: number, + plotWidth: number +): { from: number; to: number } => { + const unitsPerPx = (timeTo - timeFrom) / (plotWidth / uPlot.pxRatio); + const timeShift = dragPixels * unitsPerPx; + + return { + from: timeFrom - timeShift, + to: timeTo - timeShift, + }; +}; + +/** + * Enables panning the time range by click and dragging x-axis labels with the mouse. + * Provides visual feedback (grab/grabbing cursor) and real-time grid updates during drag. + * + * @returns Cleanup function to remove event listeners + * @internal - exported for testing only + */ +export const setupXAxisPan = ( + u: uPlot, + config: UPlotConfigBuilder, + queryZoom: (range: { from: number; to: number }) => void +): (() => void) => { + let xAxes = u.root.querySelectorAll('.u-axis'); + let xAxis = xAxes[0]; + + if (!xAxis || !(xAxis instanceof HTMLElement)) { + return () => {}; + } + + const xAxisEl = xAxis; + + let activeMoveListener: ((e: MouseEvent) => void) | null = null; + let activeUpListener: ((e: MouseEvent) => void) | null = null; + + const handleMouseEnter = () => { + xAxisEl.style.cursor = 'grab'; + }; + + const handleMouseLeave = () => { + xAxisEl.style.cursor = ''; + }; + + const handleMouseDown = (e: Event) => { + if (!(e instanceof MouseEvent)) { + return; + } + e.preventDefault(); + + xAxisEl.style.cursor = 'grabbing'; + + let xScale = u.scales.x; + + let rect = u.over.getBoundingClientRect(); + let startX = e.clientX - rect.left; + let startMin = xScale.min!; + let startMax = xScale.max!; + + const onMove = (e: MouseEvent) => { + e.preventDefault(); + + let currentX = e.clientX - rect.left; + let dragPixels = currentX - startX; + + const { from, to } = calculatePanRange(startMin, startMax, dragPixels, u.bbox.width); + + config.setState({ isPanning: true, min: from, max: to }); + + u.setScale('x', { + min: from, + max: to, + }); + }; + + const onUp = (e: MouseEvent) => { + let endX = e.clientX - rect.left; + let dragPixels = endX - startX; + + xAxisEl.style.cursor = 'grab'; + + config.setState({ isPanning: false }); + + if (Math.abs(dragPixels) >= MIN_PAN_DIST) { + const newRange = calculatePanRange(startMin, startMax, dragPixels, u.bbox.width); + queryZoom(newRange); + } + + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + activeMoveListener = null; + activeUpListener = null; + }; + + activeMoveListener = onMove; + activeUpListener = onUp; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + + xAxisEl.addEventListener('mouseenter', handleMouseEnter); + xAxisEl.addEventListener('mouseleave', handleMouseLeave); + xAxisEl.addEventListener('mousedown', handleMouseDown); + + return () => { + xAxisEl.removeEventListener('mouseenter', handleMouseEnter); + xAxisEl.removeEventListener('mouseleave', handleMouseLeave); + xAxisEl.removeEventListener('mousedown', handleMouseDown); + + if (activeMoveListener) { + document.removeEventListener('mousemove', activeMoveListener); + } + if (activeUpListener) { + document.removeEventListener('mouseup', activeUpListener); + } + }; +}; + +export interface XAxisInteractionAreaPluginProps { + config: UPlotConfigBuilder; + queryZoom?: (range: { from: number; to: number }) => void; +} + +/** + * Plugin for handling x-axis area interactions, such as time range panning. + * Properly manages event listener lifecycle to prevent memory leaks. + */ +export const XAxisInteractionAreaPlugin = ({ config, queryZoom }: XAxisInteractionAreaPluginProps) => { + useLayoutEffect(() => { + let cleanup: (() => void) | undefined; + + config.addHook('init', (u) => { + if (queryZoom != null && getFeatureToggle('timeRangePan')) { + cleanup = setupXAxisPan(u, config, queryZoom); + } + }); + + return () => { + if (cleanup) { + cleanup(); + } + }; + }, [config, queryZoom]); + + return null; +}; diff --git a/packages/grafana-ui/src/index.ts b/packages/grafana-ui/src/index.ts index b9082ae9901..b46ce1d33bb 100644 --- a/packages/grafana-ui/src/index.ts +++ b/packages/grafana-ui/src/index.ts @@ -353,6 +353,7 @@ export { EventsCanvas } from './components/uPlot/geometries/EventsCanvas'; export { TooltipPlugin2 } from './components/uPlot/plugins/TooltipPlugin2'; export { EventBusPlugin } from './components/uPlot/plugins/EventBusPlugin'; export { KeyboardPlugin } from './components/uPlot/plugins/KeyboardPlugin'; +export { XAxisInteractionAreaPlugin } from './components/uPlot/plugins/XAxisInteractionAreaPlugin'; export { type PlotTooltipInterpolator, type PlotSelection, FIXED_UNIT } from './components/uPlot/types'; export { type UPlotConfigPrepFn } from './components/uPlot/config/UPlotConfigBuilder'; diff --git a/pkg/api/login.go b/pkg/api/login.go index 7d41aa12cce..60b894d0c49 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "net/url" - "path" "regexp" "strings" @@ -41,8 +40,10 @@ var getViewIndex = func() string { return viewIndex } -// Only allow redirects that start with a slash followed by an alphanumerical character, a dash or an underscore. -var redirectRe = regexp.MustCompile(`^/[a-zA-Z0-9-_].*`) +var redirectAllowRe = regexp.MustCompile(`^/[a-zA-Z0-9-_./]*$`) + +// Do not allow redirect URLs that contain "//" or ".." +var redirectDenyRe = regexp.MustCompile(`(//|\.\.)`) var ( errAbsoluteRedirectTo = errors.New("absolute URLs are not allowed for redirect_to cookie value") @@ -64,26 +65,11 @@ func (hs *HTTPServer) ValidateRedirectTo(redirectTo string) error { return errForbiddenRedirectTo } - // path should have exactly one leading slash - if !strings.HasPrefix(to.Path, "/") { + if redirectDenyRe.MatchString(to.Path) { return errForbiddenRedirectTo } - if strings.HasPrefix(to.Path, "//") { - return errForbiddenRedirectTo - } - - if to.Path != "/" && !redirectRe.MatchString(to.Path) { - return errForbiddenRedirectTo - } - - cleanPath := path.Clean(to.Path) - // "." is what path.Clean returns for empty paths - if cleanPath == "." { - return errForbiddenRedirectTo - } - - if cleanPath != "/" && !redirectRe.MatchString(cleanPath) { + if to.Path != "/" && !redirectAllowRe.MatchString(to.Path) { return errForbiddenRedirectTo } diff --git a/pkg/middleware/org_redirect.go b/pkg/middleware/org_redirect.go index e507b9bbfe7..ab2734f0f0a 100644 --- a/pkg/middleware/org_redirect.go +++ b/pkg/middleware/org_redirect.go @@ -3,7 +3,6 @@ package middleware import ( "fmt" "net/http" - "path" "regexp" "strconv" @@ -13,8 +12,10 @@ import ( "github.com/grafana/grafana/pkg/web" ) -// Only allow redirects that start with a slash followed by an alphanumerical character, a dash or an underscore. -var redirectRe = regexp.MustCompile(`^/?[a-zA-Z0-9-_].*`) +var redirectAllowRe = regexp.MustCompile(`^/?[a-zA-Z0-9-_./]*$`) + +// Do not allow redirect URLs that contain "//" or ".." +var redirectDenyRe = regexp.MustCompile(`(//|\.\.)`) // OrgRedirect changes org and redirects users if the // querystring `orgId` doesn't match the active org. @@ -66,9 +67,9 @@ func OrgRedirect(cfg *setting.Cfg, userSvc user.Service) web.Handler { } func validRedirectPath(p string) bool { - if p != "" && p != "/" && !redirectRe.MatchString(p) { + if redirectDenyRe.MatchString(p) { return false } - cleanPath := path.Clean(p) - return cleanPath == "." || cleanPath == "/" || redirectRe.MatchString(cleanPath) + + return p == "" || p == "/" || redirectAllowRe.MatchString(p) } diff --git a/pkg/middleware/validate_host.go b/pkg/middleware/validate_host.go index eb077b8e06c..ebdf96b4274 100644 --- a/pkg/middleware/validate_host.go +++ b/pkg/middleware/validate_host.go @@ -32,6 +32,16 @@ func ValidateHostHeader(cfg *setting.Cfg) web.Handler { } if !strings.EqualFold(h, cfg.Domain) { + // the normal redirecting logic doesn't work when running as frontend service, since it has no knowledge of the custom domain. + // instead, we modify the single tenant `/bootdata` call with a 204 response and a header indicating the domain to redirect to + // this is safe because only the frontend service calls `/bootdata` + // the redirect is then handled client side. + // see pkg/services/frontend/index.html + if c.Req.URL.Path == "/bootdata" { + c.Resp.Header().Set("Redirect-Domain", cfg.Domain) + c.Resp.WriteHeader(204) + return + } hostRedirectCounter.Inc() c.Logger.Info("Enforcing Host header", "hosted", c.Req.Host, "expected", cfg.Domain) c.Redirect(strings.TrimSuffix(cfg.AppURL, "/")+c.Req.RequestURI, 301) diff --git a/pkg/plugins/config/config.go b/pkg/plugins/config/config.go index e4edf65dd3d..40e744147bb 100644 --- a/pkg/plugins/config/config.go +++ b/pkg/plugins/config/config.go @@ -28,7 +28,6 @@ type PluginManagementCfg struct { // Features contains the feature toggles used for the plugin management system. type Features struct { - SkipHostEnvVarsEnabled bool SriChecksEnabled bool LocalizationForPlugins bool // Needed only until Tempo Alerting / metrics TraceQL is stable diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index 248bc9d32ce..6e6021bc021 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -122,12 +122,13 @@ func TestLoader_Load(t *testing.T) { Backend: true, QueryOptions: map[string]bool{"minInterval": true}, }, - Module: "core:plugin/cloudwatch", - BaseURL: "public/plugins/cloudwatch", - FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch")), - Signature: plugins.SignatureStatusInternal, - Class: plugins.ClassCore, - Translations: map[string]string{}, + Module: "core:plugin/cloudwatch", + BaseURL: "public/plugins/cloudwatch", + FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch")), + Signature: plugins.SignatureStatusInternal, + Class: plugins.ClassCore, + SkipHostEnvVars: true, + Translations: map[string]string{}, }, }, }, @@ -215,14 +216,15 @@ func TestLoader_Load(t *testing.T) { ExtensionPoints: []plugins.ExtensionPoint{}, }, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-app/module.js", - BaseURL: "public/plugins/test-app", - FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")), - Signature: "valid", - SignatureType: plugins.SignatureTypeGrafana, - SignatureOrg: "Grafana Labs", - Translations: map[string]string{}, + Class: plugins.ClassExternal, + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", + FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")), + Signature: "valid", + SignatureType: plugins.SignatureTypeGrafana, + SignatureOrg: "Grafana Labs", + SkipHostEnvVars: true, + Translations: map[string]string{}, }, }, }, @@ -268,12 +270,13 @@ func TestLoader_Load(t *testing.T) { Backend: true, State: plugins.ReleaseStateAlpha, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")), - Signature: "unsigned", - Translations: map[string]string{}, + Class: plugins.ClassExternal, + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")), + Signature: "unsigned", + SkipHostEnvVars: true, + Translations: map[string]string{}, }, }, }, @@ -326,12 +329,13 @@ func TestLoader_Load(t *testing.T) { Backend: true, State: plugins.ReleaseStateAlpha, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")), - Signature: plugins.SignatureStatusUnsigned, - Translations: map[string]string{}, + Class: plugins.ClassExternal, + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")), + Signature: plugins.SignatureStatusUnsigned, + SkipHostEnvVars: true, + Translations: map[string]string{}, }, }, }, @@ -422,13 +426,14 @@ func TestLoader_Load(t *testing.T) { }, Backend: false, }, - DefaultNavURL: "/plugins/test-app/page/root-page-react", - FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/test-app-with-includes")), - Class: plugins.ClassExternal, - Signature: plugins.SignatureStatusUnsigned, - Module: "public/plugins/test-app/module.js", - BaseURL: "public/plugins/test-app", - Translations: map[string]string{}, + DefaultNavURL: "/plugins/test-app/page/root-page-react", + FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/test-app-with-includes")), + Class: plugins.ClassExternal, + Signature: plugins.SignatureStatusUnsigned, + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", + SkipHostEnvVars: true, + Translations: map[string]string{}, }, }, }, diff --git a/pkg/plugins/manager/pipeline/bootstrap/steps.go b/pkg/plugins/manager/pipeline/bootstrap/steps.go index 7dcc452807b..7608ba2c4fa 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/steps.go +++ b/pkg/plugins/manager/pipeline/bootstrap/steps.go @@ -137,11 +137,10 @@ func configureAppChildPlugin(parent *plugins.Plugin, child *plugins.Plugin) { } // SkipHostEnvVarsDecorateFunc returns a DecorateFunc that configures the SkipHostEnvVars field of the plugin. -// It will be set to true if the FlagPluginsSkipHostEnvVars feature flag is set, and the plugin is not present in the -// ForwardHostEnvVars plugin ids list. +// It will be set to true if the plugin is not present in the ForwardHostEnvVars plugin ids list. func SkipHostEnvVarsDecorateFunc(cfg *config.PluginManagementCfg) DecorateFunc { return func(_ context.Context, p *plugins.Plugin) (*plugins.Plugin, error) { - p.SkipHostEnvVars = cfg.Features.SkipHostEnvVarsEnabled && !slices.Contains(cfg.ForwardHostEnvVars, p.ID) + p.SkipHostEnvVars = !slices.Contains(cfg.ForwardHostEnvVars, p.ID) return p, nil } } diff --git a/pkg/plugins/manager/pipeline/bootstrap/steps_test.go b/pkg/plugins/manager/pipeline/bootstrap/steps_test.go index 0f2bf7008e7..cda6d64bf82 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/steps_test.go +++ b/pkg/plugins/manager/pipeline/bootstrap/steps_test.go @@ -144,65 +144,39 @@ func Test_configureAppChildPlugin(t *testing.T) { func TestSkipEnvVarsDecorateFunc(t *testing.T) { const pluginID = "plugin-id" - - t.Run("config field is false", func(t *testing.T) { - f := SkipHostEnvVarsDecorateFunc(&config.PluginManagementCfg{ - Features: config.Features{SkipHostEnvVarsEnabled: false}, - }) - p, err := f(context.Background(), &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID}}) - require.NoError(t, err) - require.False(t, p.SkipHostEnvVars) - }) - - t.Run("config field is true", func(t *testing.T) { - t.Run("no plugin settings should set SkipHostEnvVars to true", func(t *testing.T) { + for _, tc := range []struct { + name string + forwardHostEnvVars []string + expSkipHostEnvVars bool + }{ + { + name: "plugin id not present in forwardHostEnvVars should set SkipHostEnvVars to true (empty)", + forwardHostEnvVars: []string{}, + expSkipHostEnvVars: true, + }, + { + name: "plugin id not present in forwardHostEnvVars should set SkipHostEnvVars to true (other id)", + forwardHostEnvVars: []string{"other-id", "yet-another-id"}, + expSkipHostEnvVars: true, + }, + { + name: "plugin id in forwardHostEnvVars should set SkipHostEnvVars to false (only)", + forwardHostEnvVars: []string{pluginID}, + expSkipHostEnvVars: false, + }, + { + name: "plugin id in forwardHostEnvVars should set SkipHostEnvVars to false (with other)", + forwardHostEnvVars: []string{"a-plugin", pluginID, "other-id"}, + expSkipHostEnvVars: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { f := SkipHostEnvVarsDecorateFunc(&config.PluginManagementCfg{ - Features: config.Features{SkipHostEnvVarsEnabled: true}, + ForwardHostEnvVars: tc.forwardHostEnvVars, }) p, err := f(context.Background(), &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID}}) require.NoError(t, err) - require.True(t, p.SkipHostEnvVars) + require.Equal(t, tc.expSkipHostEnvVars, p.SkipHostEnvVars) }) - - t.Run("plugin setting", func(t *testing.T) { - for _, tc := range []struct { - name string - forwardHostEnvVars []string - expSkipHostEnvVars bool - }{ - { - name: "plugin id not present in forwardHostEnvVars should set SkipHostEnvVars to true (empty)", - forwardHostEnvVars: []string{}, - expSkipHostEnvVars: true, - }, - { - name: "plugin id not present in forwardHostEnvVars should set SkipHostEnvVars to true (other id)", - forwardHostEnvVars: []string{"other-id", "yet-another-id"}, - expSkipHostEnvVars: true, - }, - { - name: "plugin id in forwardHostEnvVars should set SkipHostEnvVars to false (only)", - forwardHostEnvVars: []string{pluginID}, - expSkipHostEnvVars: false, - }, - { - name: "plugin id in forwardHostEnvVars should set SkipHostEnvVars to false (with other)", - forwardHostEnvVars: []string{"a-plugin", pluginID, "other-id"}, - expSkipHostEnvVars: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - f := SkipHostEnvVarsDecorateFunc(&config.PluginManagementCfg{ - Features: config.Features{ - SkipHostEnvVarsEnabled: true, - }, - ForwardHostEnvVars: tc.forwardHostEnvVars, - }) - p, err := f(context.Background(), &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID}}) - require.NoError(t, err) - require.Equal(t, tc.expSkipHostEnvVars, p.SkipHostEnvVars) - }) - } - }) - }) + } } diff --git a/pkg/registry/apis/ofrep/register.go b/pkg/registry/apis/ofrep/register.go index cd6f8f0eb29..d6a443cfee7 100644 --- a/pkg/registry/apis/ofrep/register.go +++ b/pkg/registry/apis/ofrep/register.go @@ -64,10 +64,6 @@ func NewAPIBuilder(providerType string, url *url.URL, insecure bool, caFile stri } func RegisterAPIService(apiregistration builder.APIRegistrar, cfg *setting.Cfg) (*APIBuilder, error) { - if !cfg.OpenFeature.APIEnabled { - return nil, nil - } - var staticEvaluator featuremgmt.StaticFlagEvaluator // No static evaluator needed for non-static provider var err error if cfg.OpenFeature.ProviderType == setting.StaticProviderType { diff --git a/pkg/registry/apis/provisioning/controller/repository.go b/pkg/registry/apis/provisioning/controller/repository.go index ffeb3f1fb9f..d624879b984 100644 --- a/pkg/registry/apis/provisioning/controller/repository.go +++ b/pkg/registry/apis/provisioning/controller/repository.go @@ -55,7 +55,10 @@ type RepositoryController struct { logger logging.Logger dualwrite dualwrite.Service - jobs jobs.Queue + jobs interface { + jobs.Queue + jobs.Store + } finalizer finalizerProcessor statusPatcher StatusPatcher @@ -79,7 +82,10 @@ func NewRepositoryController( repoFactory repository.Factory, resourceLister resources.ResourceLister, clients resources.ClientFactory, - jobs jobs.Queue, + jobs interface { + jobs.Queue + jobs.Store + }, dualwrite dualwrite.Service, healthChecker *HealthChecker, statusPatcher StatusPatcher, @@ -273,7 +279,7 @@ func (rc *RepositoryController) updateDeleteStatus(ctx context.Context, obj *pro }) } -func (rc *RepositoryController) shouldResync(obj *provisioning.Repository) bool { +func (rc *RepositoryController) shouldResync(ctx context.Context, obj *provisioning.Repository) bool { // don't trigger resync if a sync was never started if obj.Status.Sync.Finished == 0 && obj.Status.Sync.State == "" { return false @@ -283,6 +289,30 @@ func (rc *RepositoryController) shouldResync(obj *provisioning.Repository) bool syncInterval := time.Duration(obj.Spec.Sync.IntervalSeconds) * time.Second tolerance := time.Second + // Check for stale sync status - if sync status indicates a job is running but the job no longer exists + // Only check if Finished is set (meaning a sync has completed before) to avoid interfering with initial syncs + // Only trigger resync if sync is enabled and sync interval has elapsed (to avoid unnecessary operations) + if obj.Status.Sync.Finished > 0 && + obj.Spec.Sync.Enabled && + (obj.Status.Sync.State == provisioning.JobStatePending || obj.Status.Sync.State == provisioning.JobStateWorking) && + obj.Status.Sync.JobID != "" { + _, err := rc.jobs.Get(ctx, obj.Namespace, obj.Status.Sync.JobID) + if apierrors.IsNotFound(err) { + // Job was cleaned up but sync status wasn't updated - trigger resync to reconcile + // Only trigger if sync interval has elapsed to avoid unnecessary operations + if syncAge >= (syncInterval - tolerance) { + logger := logging.FromContext(ctx) + logger.Info("detected stale sync status", "job_id", obj.Status.Sync.JobID) + return true + } + } + // For other errors, log but continue with normal logic + if err != nil { + logger := logging.FromContext(ctx) + logger.Warn("failed to check job existence for stale sync status", "error", err, "job_id", obj.Status.Sync.JobID) + } + } + // HACK: how would this work in a multi-tenant world or under heavy load? // It will start queueing up jobs and we will have to deal with that pendingForTooLong := syncAge >= syncInterval/2 && obj.Status.Sync.State == provisioning.JobStatePending @@ -490,7 +520,7 @@ func (rc *RepositoryController) process(item *queueItem) error { return rc.handleDelete(ctx, obj) } - shouldResync := rc.shouldResync(obj) + shouldResync := rc.shouldResync(ctx, obj) shouldCheckHealth := rc.healthChecker.ShouldCheckHealth(obj) hasSpecChanged := obj.Generation != obj.Status.ObservedGeneration patchOperations := []map[string]interface{}{} diff --git a/pkg/registry/apis/provisioning/controller/repository_test.go b/pkg/registry/apis/provisioning/controller/repository_test.go index e5789672e9d..8eab43d847f 100644 --- a/pkg/registry/apis/provisioning/controller/repository_test.go +++ b/pkg/registry/apis/provisioning/controller/repository_test.go @@ -3,15 +3,20 @@ package controller import ( "context" "testing" + "time" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/controller/mocks" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/rest" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/controller/mocks" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1" client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" @@ -338,3 +343,204 @@ func TestShouldUseIncrementalSync(t *testing.T) { assert.False(t, got) }) } + +// mockJobsQueueStore implements both jobs.Queue and jobs.Store for testing +type mockJobsQueueStore struct { + *jobs.MockQueue + *jobs.MockStore +} + +func TestRepositoryController_shouldResync_StaleSyncStatus(t *testing.T) { + testCases := []struct { + name string + repo *provisioning.Repository + jobGetError error + expectedResync bool + description string + }{ + { + name: "stale sync status with Pending state - job not found", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Enabled: true, + IntervalSeconds: 300, + }, + }, + Status: provisioning.RepositoryStatus{ + Sync: provisioning.SyncStatus{ + State: provisioning.JobStatePending, + JobID: "test-job-123", + Started: time.Now().Add(-10 * time.Minute).UnixMilli(), + Finished: time.Now().Add(-10 * time.Minute).UnixMilli(), + }, + }, + }, + jobGetError: apierrors.NewNotFound(schema.GroupResource{Resource: "jobs"}, "test-job-123"), + expectedResync: true, + description: "should return true to trigger resync when job is not found", + }, + { + name: "stale sync status with Working state - job not found", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Enabled: true, + IntervalSeconds: 300, + }, + }, + Status: provisioning.RepositoryStatus{ + Sync: provisioning.SyncStatus{ + State: provisioning.JobStateWorking, + JobID: "test-job-456", + Started: time.Now().Add(-5 * time.Minute).UnixMilli(), + Finished: time.Now().Add(-5 * time.Minute).UnixMilli(), + }, + }, + }, + jobGetError: apierrors.NewNotFound(schema.GroupResource{Resource: "jobs"}, "test-job-456"), + expectedResync: true, + description: "should return true to trigger resync when working job is not found", + }, + { + name: "non-stale sync status - job exists", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Enabled: true, + IntervalSeconds: 300, + }, + }, + Status: provisioning.RepositoryStatus{ + Sync: provisioning.SyncStatus{ + State: provisioning.JobStatePending, + JobID: "test-job-789", + Started: time.Now().Add(-2 * time.Minute).UnixMilli(), + Finished: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + }, + }, + jobGetError: nil, // Job exists + expectedResync: false, // Should continue with normal logic + description: "should continue with normal logic when job exists", + }, + { + name: "non-stale sync status - no JobID", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Enabled: true, + IntervalSeconds: 300, + }, + }, + Status: provisioning.RepositoryStatus{ + Sync: provisioning.SyncStatus{ + State: provisioning.JobStatePending, + JobID: "", + Started: time.Now().Add(-2 * time.Minute).UnixMilli(), + Finished: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + }, + }, + jobGetError: nil, + expectedResync: false, + description: "should not check when JobID is empty", + }, + { + name: "non-stale sync status - already finished", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Enabled: true, + IntervalSeconds: 300, + }, + }, + Status: provisioning.RepositoryStatus{ + Sync: provisioning.SyncStatus{ + State: provisioning.JobStateSuccess, + JobID: "test-job-999", + Finished: time.Now().Add(-1 * time.Minute).UnixMilli(), + }, + }, + }, + jobGetError: nil, + expectedResync: false, + description: "should not check when sync status is already finished", + }, + { + name: "stale sync status - job lookup error (non-NotFound)", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Enabled: true, + IntervalSeconds: 300, + }, + }, + Status: provisioning.RepositoryStatus{ + Sync: provisioning.SyncStatus{ + State: provisioning.JobStatePending, + JobID: "test-job-error", + Started: time.Now().Add(-2 * time.Minute).UnixMilli(), + Finished: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + }, + }, + jobGetError: assert.AnError, // Non-NotFound error + expectedResync: false, // Should continue with normal logic + description: "should handle non-NotFound errors gracefully and continue with normal logic", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create mocks + mockQueue := jobs.NewMockQueue(t) + mockStore := jobs.NewMockStore(t) + mockJobs := &mockJobsQueueStore{ + MockQueue: mockQueue, + MockStore: mockStore, + } + + // Set up job Get mock + if tc.repo.Status.Sync.JobID != "" && (tc.repo.Status.Sync.State == provisioning.JobStatePending || tc.repo.Status.Sync.State == provisioning.JobStateWorking) { + mockStore.On("Get", mock.Anything, tc.repo.Namespace, tc.repo.Status.Sync.JobID).Return(nil, tc.jobGetError).Once() + } + + // Create controller + rc := &RepositoryController{ + jobs: mockJobs, + } + + // Test shouldResync + ctx := context.Background() + result := rc.shouldResync(ctx, tc.repo) + + // Verify + assert.Equal(t, tc.expectedResync, result, tc.description) + }) + } +} diff --git a/pkg/registry/apis/provisioning/jobs/concurrent_driver.go b/pkg/registry/apis/provisioning/jobs/concurrent_driver.go index dce8d65a97e..39f705aca31 100644 --- a/pkg/registry/apis/provisioning/jobs/concurrent_driver.go +++ b/pkg/registry/apis/provisioning/jobs/concurrent_driver.go @@ -14,7 +14,6 @@ import ( type ConcurrentJobDriver struct { numDrivers int jobTimeout time.Duration - cleanupInterval time.Duration jobInterval time.Duration leaseRenewalInterval time.Duration store Store @@ -27,7 +26,7 @@ type ConcurrentJobDriver struct { // NewConcurrentJobDriver creates a new concurrent job driver that spawns multiple job drivers. func NewConcurrentJobDriver( numDrivers int, - jobTimeout, cleanupInterval, jobInterval, leaseRenewalInterval time.Duration, + jobTimeout, jobInterval, leaseRenewalInterval time.Duration, store Store, repoGetter RepoGetter, historicJobs HistoryWriter, @@ -45,24 +44,12 @@ func NewConcurrentJobDriver( if leaseRenewalInterval < 5*time.Second { leaseRenewalInterval = 5 * time.Second } - // For lease-based cleanup, run at most every 3-4 lease renewal intervals - // to detect expired leases promptly but not too aggressively - if cleanupInterval <= 0 { - cleanupInterval = leaseRenewalInterval * 3 - } - if cleanupInterval < 30*time.Second { - cleanupInterval = 30 * time.Second // Minimum cleanup interval - } - if cleanupInterval > 5*time.Minute { - cleanupInterval = 5 * time.Minute // Maximum cleanup interval - } recordConcurrentDriverMetric(registry, numDrivers) return &ConcurrentJobDriver{ numDrivers: numDrivers, jobTimeout: jobTimeout, - cleanupInterval: cleanupInterval, jobInterval: jobInterval, leaseRenewalInterval: leaseRenewalInterval, store: store, @@ -73,43 +60,17 @@ func NewConcurrentJobDriver( }, nil } -// Run starts multiple job drivers concurrently and handles cleanup coordination. +// Run starts multiple job drivers concurrently. // This is a blocking function that will run until the context is canceled or an error occurs. // // Note: This function intentionally does NOT create a tracing span because it runs indefinitely -// until shutdown. Individual job processing and cleanup operations already have their own spans. +// until shutdown. Individual job processing operations already have their own spans. func (c *ConcurrentJobDriver) Run(ctx context.Context) error { logger := logging.FromContext(ctx).With("logger", "concurrent-job-driver", "num_drivers", c.numDrivers) - logger.Info("start concurrent job driver", "num_drivers", c.numDrivers, "cleanup_interval", c.cleanupInterval) - - // Set up cleanup ticker - runs more frequently with lease-based approach - cleanupTicker := time.NewTicker(c.cleanupInterval) - defer cleanupTicker.Stop() - - // Initial cleanup - if err := c.store.Cleanup(ctx); err != nil { - logger.Error("failed initial cleanup", "error", err) - } + logger.Info("start concurrent job driver", "num_drivers", c.numDrivers) var wg sync.WaitGroup - errChan := make(chan error, c.numDrivers+1) // +1 for cleanup goroutine - - // Start cleanup goroutine - wg.Add(1) - go func() { - defer wg.Done() - for { - select { - case <-cleanupTicker.C: - if err := c.store.Cleanup(ctx); err != nil { - logger.Error("failed cleanup", "error", err) - } - case <-ctx.Done(): - logger.Debug("cleanup routine stopped") - return - } - } - }() + errChan := make(chan error, c.numDrivers) // Start driver goroutines for i := 0; i < c.numDrivers; i++ { diff --git a/pkg/registry/apis/provisioning/jobs/driver.go b/pkg/registry/apis/provisioning/jobs/driver.go index b1cb0ee6da1..5241c211517 100644 --- a/pkg/registry/apis/provisioning/jobs/driver.go +++ b/pkg/registry/apis/provisioning/jobs/driver.go @@ -31,14 +31,10 @@ type Store interface { // The err may be ErrNoJobs if there are no jobs to claim. Claim(ctx context.Context) (job *provisioning.Job, rollback func(), err error) - // Complete marks a job as completed and moves it to the historic job store. - // When in the historic store, there is no more claim on the job. + // Complete marks a job as completed and removes it from the active job store. + // Callers are responsible for writing the job to history after calling this. Complete(ctx context.Context, job *provisioning.Job) error - // Cleanup should be called periodically to clean up abandoned jobs. - // An abandoned job is one that has been claimed by a worker, but the worker has not updated the job in a while. - Cleanup(ctx context.Context) error - // Update saves the job back to the store. Update(ctx context.Context, job *provisioning.Job) (*provisioning.Job, error) @@ -48,6 +44,10 @@ type Store interface { // Get retrieves a job by name for conflict resolution. Get(ctx context.Context, namespace, name string) (*provisioning.Job, error) + + // ListExpiredJobs lists jobs with expired leases (claim timestamp older than the given time). + // Returns jobs in batches up to the specified limit. + ListExpiredJobs(ctx context.Context, expiredBefore time.Time, limit int) ([]*provisioning.Job, error) } // jobDriver drives jobs to completion and manages the job queue. diff --git a/pkg/registry/apis/provisioning/jobs/expired_job_cleanup.go b/pkg/registry/apis/provisioning/jobs/expired_job_cleanup.go new file mode 100644 index 00000000000..d6cc1dc2e25 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/expired_job_cleanup.go @@ -0,0 +1,177 @@ +package jobs + +import ( + "context" + "time" + + "go.opentelemetry.io/otel/attribute" + + "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana/apps/provisioning/pkg/apifmt" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/infra/tracing" +) + +// JobCleanupController handles cleanup of expired/abandoned jobs. +type JobCleanupController struct { + store Store + historicJobs HistoryWriter + clock func() time.Time + expiry time.Duration + cleanupInterval time.Duration +} + +// NewJobCleanupController creates a new job cleanup controller. +func NewJobCleanupController( + store Store, + historicJobs HistoryWriter, + expiry time.Duration, +) *JobCleanupController { + // Calculate cleanup interval based on expiry duration + // Run cleanup every 3-4 expiry intervals to detect expired leases promptly but not too aggressively + cleanupInterval := expiry * 3 + + // Enforce minimum and maximum bounds + if cleanupInterval < 30*time.Second { + cleanupInterval = 30 * time.Second + } + if cleanupInterval > 5*time.Minute { + cleanupInterval = 5 * time.Minute + } + + return &JobCleanupController{ + store: store, + historicJobs: historicJobs, + clock: time.Now, + expiry: expiry, + cleanupInterval: cleanupInterval, + } +} + +// Run starts the cleanup loop that runs at an appropriate interval. +// This is a blocking function that runs until the context is canceled. +func (c *JobCleanupController) Run(ctx context.Context) error { + logger := logging.FromContext(ctx).With("logger", "job-cleanup-controller") + ctx = logging.Context(ctx, logger) + + // Set up provisioning identity to access jobs across all namespaces + ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") + if err != nil { + return apifmt.Errorf("failed to grant provisioning identity for cleanup: %w", err) + } + + logger.Info("starting job cleanup controller", "cleanup_interval", c.cleanupInterval, "expiry", c.expiry) + + // Initial cleanup + if err := c.Cleanup(ctx); err != nil { + logger.Error("failed to clean up jobs at start", "error", err) + } + + ticker := time.NewTicker(c.cleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := c.Cleanup(ctx); err != nil { + logger.Error("failed to cleanup jobs", "error", err) + } + case <-ctx.Done(): + logger.Info("job cleanup controller stopping") + return ctx.Err() + } + } +} + +// Cleanup finds jobs with expired leases and marks them as failed. +// This should be called periodically to clean up jobs from crashed workers. +func (c *JobCleanupController) Cleanup(ctx context.Context) error { + ctx, span := tracing.Start(ctx, "provisioning.jobs.cleanup") + defer span.End() + + startTime := c.clock() + logger := logging.FromContext(ctx) + + // Find jobs with expired leases + expiredBefore := c.clock().Add(-c.expiry) + + // Process in batches of 100 to avoid overwhelming the system + const batchSize = 100 + jobs, err := c.store.ListExpiredJobs(ctx, expiredBefore, batchSize) + if err != nil { + span.RecordError(err) + return apifmt.Errorf("failed to list jobs with expired leases: %w", err) + } + + // If no jobs found, cleanup is complete + if len(jobs) == 0 { + duration := c.clock().Sub(startTime) + span.SetAttributes( + attribute.Int("count", 0), + attribute.Int64("duration_ms", duration.Milliseconds()), + ) + return nil + } + + logger.Info("cleaning up expired jobs", "count", len(jobs)) + + for _, job := range jobs { + if err := c.cleanUpExpiredJob(ctx, job); err != nil { + // Log error but continue processing other jobs + logger.Error("failed to clean up expired job", "error", err, "job", job.GetName(), "namespace", job.GetNamespace()) + } + } + + duration := c.clock().Sub(startTime) + logger.Info("cleanup complete", "duration", duration, "count", len(jobs)) + + span.SetAttributes( + attribute.Int("count", len(jobs)), + attribute.Int64("duration_ms", duration.Milliseconds()), + ) + + return nil +} + +// cleanUpExpiredJob marks a single expired job as failed and archives it. +func (c *JobCleanupController) cleanUpExpiredJob(ctx context.Context, job *provisioning.Job) error { + ctx, span := tracing.Start(ctx, "provisioning.jobs.cleanup.complete_expired_job") + defer span.End() + + // Mark job as failed due to lease expiry + jobCopy := job.DeepCopy() + jobCopy.Status.State = provisioning.JobStateError + jobCopy.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection" + jobCopy.Status.Finished = c.clock().UnixMilli() + + span.SetAttributes( + attribute.String("job.name", jobCopy.GetName()), + attribute.String("job.namespace", jobCopy.GetNamespace()), + attribute.String("job.repository", jobCopy.Spec.Repository), + attribute.String("job.action", string(jobCopy.Spec.Action)), + ) + + jobLogger := logging.FromContext(ctx).With("namespace", jobCopy.GetNamespace(), "job", jobCopy.GetName(), "action", jobCopy.Spec.Action) + + // Delete from active job store first + if err := c.store.Complete(ctx, jobCopy); err != nil { + span.RecordError(err) + return apifmt.Errorf("failed to complete expired job: %w", err) + } + + // Remove the claim label before archiving + if jobCopy.Labels != nil { + delete(jobCopy.Labels, LabelJobClaim) + } + + // Write to history after deleting from active store (matching driver.go pattern) + if err := c.historicJobs.WriteJob(ctx, jobCopy); err != nil { + span.RecordError(err) + jobLogger.Warn("failed to write expired job to history", "error", err) + // Job was already deleted, so we can't recover from this + } + + jobLogger.Debug("cleaned up expired job") + return nil +} diff --git a/pkg/registry/apis/provisioning/jobs/expired_job_cleanup_test.go b/pkg/registry/apis/provisioning/jobs/expired_job_cleanup_test.go new file mode 100644 index 00000000000..6018e5d91e0 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/expired_job_cleanup_test.go @@ -0,0 +1,465 @@ +package jobs + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewJobCleanupController(t *testing.T) { + store := &MockStore{} + historyWriter := &MockHistoryWriter{} + + t.Run("creates controller with default cleanup interval", func(t *testing.T) { + expiry := 30 * time.Second + controller := NewJobCleanupController(store, historyWriter, expiry) + + assert.NotNil(t, controller) + assert.Equal(t, expiry, controller.expiry) + // Cleanup interval should be 3x expiry = 90 seconds + assert.Equal(t, 90*time.Second, controller.cleanupInterval) + }) + + t.Run("enforces minimum cleanup interval", func(t *testing.T) { + // With expiry of 5 seconds, 3x = 15 seconds, but minimum is 30 seconds + expiry := 5 * time.Second + controller := NewJobCleanupController(store, historyWriter, expiry) + + assert.Equal(t, 30*time.Second, controller.cleanupInterval) + }) + + t.Run("enforces maximum cleanup interval", func(t *testing.T) { + // With expiry of 5 minutes, 3x = 15 minutes, but maximum is 5 minutes + expiry := 5 * time.Minute + controller := NewJobCleanupController(store, historyWriter, expiry) + + assert.Equal(t, 5*time.Minute, controller.cleanupInterval) + }) +} + +func TestJobCleanupController_Cleanup(t *testing.T) { + t.Run("no expired jobs returns nil", func(t *testing.T) { + store := &MockStore{} + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + ctx := context.Background() + + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{}, nil) + + err := controller.Cleanup(ctx) + + assert.NoError(t, err) + store.AssertExpectations(t) + // store.AssertNotCalled(t, "Complete") - not needed with combined Store mock + historyWriter.AssertNotCalled(t, "WriteJob") + }) + + t.Run("error listing expired jobs returns error", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + ctx := context.Background() + + expectedErr := errors.New("list failed") + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return(nil, expectedErr) + + err := controller.Cleanup(ctx) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to list jobs with expired leases") + store.AssertExpectations(t) + }) + + t.Run("successfully cleans up expired job", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + ctx := context.Background() + + job := &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + Labels: map[string]string{ + LabelJobClaim: "123456789", + }, + }, + Spec: provisioning.JobSpec{ + Repository: "test-repo", + Action: provisioning.JobActionPull, + }, + } + + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job}, nil) + store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + return j.Status.State == provisioning.JobStateError && + j.Status.Message == "Job failed due to lease expiry - worker may have crashed or lost connection" + })).Return(nil) + historyWriter.On("WriteJob", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + // Verify claim label was removed before writing to history + _, hasLabel := j.Labels[LabelJobClaim] + return !hasLabel && j.Status.State == provisioning.JobStateError + })).Return(nil) + + err := controller.Cleanup(ctx) + + assert.NoError(t, err) + store.AssertExpectations(t) + historyWriter.AssertExpectations(t) + }) + + t.Run("continues on complete error", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + ctx := context.Background() + + job1 := &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "job-1", + Namespace: "test-ns", + Labels: map[string]string{LabelJobClaim: "123"}, + }, + Spec: provisioning.JobSpec{ + Repository: "repo-1", + Action: provisioning.JobActionPull, + }, + } + job2 := &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "job-2", + Namespace: "test-ns", + Labels: map[string]string{LabelJobClaim: "456"}, + }, + Spec: provisioning.JobSpec{ + Repository: "repo-2", + Action: provisioning.JobActionPull, + }, + } + + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job1, job2}, nil) + + // First job fails to complete + store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + return j.Name == "job-1" + })).Return(errors.New("complete failed")) + + // Second job succeeds + store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + return j.Name == "job-2" + })).Return(nil) + historyWriter.On("WriteJob", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + return j.Name == "job-2" + })).Return(nil) + + err := controller.Cleanup(ctx) + + // Should not return error, continues processing + assert.NoError(t, err) + store.AssertExpectations(t) + historyWriter.AssertExpectations(t) + }) + + t.Run("continues on history write error", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + ctx := context.Background() + + job := &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + Labels: map[string]string{LabelJobClaim: "123"}, + }, + Spec: provisioning.JobSpec{ + Repository: "test-repo", + Action: provisioning.JobActionPull, + }, + } + + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job}, nil) + store.On("Complete", mock.Anything, mock.Anything).Return(nil) + historyWriter.On("WriteJob", mock.Anything, mock.Anything).Return(errors.New("write failed")) + + err := controller.Cleanup(ctx) + + // Should not return error, just log warning + assert.NoError(t, err) + store.AssertExpectations(t) + historyWriter.AssertExpectations(t) + }) + + t.Run("sets job status correctly", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + controller.clock = func() time.Time { return fixedTime } + ctx := context.Background() + + job := &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + Labels: map[string]string{LabelJobClaim: "123"}, + }, + Spec: provisioning.JobSpec{ + Repository: "test-repo", + Action: provisioning.JobActionPull, + }, + } + + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job}, nil) + store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + assert.Equal(t, provisioning.JobStateError, j.Status.State) + assert.Equal(t, "Job failed due to lease expiry - worker may have crashed or lost connection", j.Status.Message) + assert.Equal(t, fixedTime.UnixMilli(), j.Status.Finished) + return true + })).Return(nil) + historyWriter.On("WriteJob", mock.Anything, mock.Anything).Return(nil) + + err := controller.Cleanup(ctx) + + assert.NoError(t, err) + store.AssertExpectations(t) + }) + + t.Run("removes claim label before writing to history", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + ctx := context.Background() + + job := &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "test-ns", + Labels: map[string]string{ + LabelJobClaim: "123456789", + "other-label": "value", + }, + }, + Spec: provisioning.JobSpec{ + Repository: "test-repo", + Action: provisioning.JobActionPull, + }, + } + + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job}, nil) + store.On("Complete", mock.Anything, mock.Anything).Return(nil) + historyWriter.On("WriteJob", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + _, hasClaim := j.Labels[LabelJobClaim] + _, hasOther := j.Labels["other-label"] + assert.False(t, hasClaim, "claim label should be removed") + assert.True(t, hasOther, "other labels should be preserved") + return !hasClaim && hasOther + })).Return(nil) + + err := controller.Cleanup(ctx) + + assert.NoError(t, err) + historyWriter.AssertExpectations(t) + }) + + t.Run("processes multiple expired jobs", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + ctx := context.Background() + + jobs := []*provisioning.Job{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "job-1", + Namespace: "ns-1", + Labels: map[string]string{LabelJobClaim: "111"}, + }, + Spec: provisioning.JobSpec{Repository: "repo-1", Action: provisioning.JobActionPull}, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "job-2", + Namespace: "ns-2", + Labels: map[string]string{LabelJobClaim: "222"}, + }, + Spec: provisioning.JobSpec{Repository: "repo-2", Action: provisioning.JobActionPush}, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "job-3", + Namespace: "ns-3", + Labels: map[string]string{LabelJobClaim: "333"}, + }, + Spec: provisioning.JobSpec{Repository: "repo-3", Action: provisioning.JobActionMigrate}, + }, + } + + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return(jobs, nil) + for _, job := range jobs { + store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + return j.Name == job.Name + })).Return(nil) + historyWriter.On("WriteJob", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool { + return j.Name == job.Name + })).Return(nil) + } + + err := controller.Cleanup(ctx) + + assert.NoError(t, err) + store.AssertExpectations(t) + historyWriter.AssertExpectations(t) + // Verify all 3 jobs were processed + store.AssertNumberOfCalls(t, "Complete", 3) + historyWriter.AssertNumberOfCalls(t, "WriteJob", 3) + }) +} + +func TestJobCleanupController_Run(t *testing.T) { + t.Run("runs cleanup on start and periodically", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + // Use short expiry to get short cleanup interval for testing + controller := NewJobCleanupController(store, historyWriter, 10*time.Second) + // Override to even shorter for test + controller.cleanupInterval = 50 * time.Millisecond + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + // Expect initial cleanup + periodic cleanups (at least 2, maybe more depending on timing) + callCount := 0 + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100). + Return([]*provisioning.Job{}, nil). + Run(func(args mock.Arguments) { + callCount++ + }). + Maybe() // Allow variable number of calls due to timing + + err := controller.Run(ctx) + + // Should return context.DeadlineExceeded when context times out + require.Error(t, err) + assert.Equal(t, context.DeadlineExceeded, err) + + // Verify cleanup was called at least 3 times (initial + 2 periodic) + assert.GreaterOrEqual(t, callCount, 3, "should have run cleanup at least 3 times") + }) + + t.Run("stops when context is cancelled", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 30*time.Second) + controller.cleanupInterval = 1 * time.Second + + ctx, cancel := context.WithCancel(context.Background()) + + // Expect initial cleanup + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{}, nil).Once() + + // Cancel after initial cleanup + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + err := controller.Run(ctx) + + assert.Error(t, err) + assert.Equal(t, context.Canceled, err) + store.AssertExpectations(t) + }) + + t.Run("continues running after cleanup error", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 10*time.Second) + controller.cleanupInterval = 50 * time.Millisecond + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + + // Track successful calls after initial failure + successCount := 0 + // First cleanup fails + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100). + Return(nil, errors.New("first failure")).Once() + // Subsequent cleanups succeed + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100). + Run(func(args mock.Arguments) { + successCount++ + }). + Return([]*provisioning.Job{}, nil). + Maybe() + + err := controller.Run(ctx) + + // Should still run and return context error + require.Error(t, err) + assert.Equal(t, context.DeadlineExceeded, err) + // Verify it was called successfully at least once after first failure + assert.GreaterOrEqual(t, successCount, 1, "should have retried after first failure") + }) + + t.Run("logs error when periodic cleanup fails", func(t *testing.T) { + store := &MockStore{} + + historyWriter := &MockHistoryWriter{} + + controller := NewJobCleanupController(store, historyWriter, 10*time.Second) + controller.cleanupInterval = 50 * time.Millisecond + + ctx, cancel := context.WithTimeout(context.Background(), 125*time.Millisecond) + defer cancel() + + // Initial cleanup succeeds + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100). + Return([]*provisioning.Job{}, nil).Once() + + // First periodic cleanup fails (this tests the error logging in ticker case) + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100). + Return(nil, errors.New("periodic failure")).Once() + + // Subsequent cleanups succeed + store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100). + Return([]*provisioning.Job{}, nil). + Maybe() + + err := controller.Run(ctx) + + // Should still run and return context error, not the cleanup error + require.Error(t, err) + assert.Equal(t, context.DeadlineExceeded, err) + store.AssertExpectations(t) + }) +} diff --git a/pkg/registry/apis/provisioning/jobs/history_reader_mock.go b/pkg/registry/apis/provisioning/jobs/history_reader_mock.go new file mode 100644 index 00000000000..284c446192e --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/history_reader_mock.go @@ -0,0 +1,158 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package jobs + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockHistoryReader is an autogenerated mock type for the HistoryReader type +type MockHistoryReader struct { + mock.Mock +} + +type MockHistoryReader_Expecter struct { + mock *mock.Mock +} + +func (_m *MockHistoryReader) EXPECT() *MockHistoryReader_Expecter { + return &MockHistoryReader_Expecter{mock: &_m.Mock} +} + +// GetJob provides a mock function with given fields: ctx, namespace, repo, uid +func (_m *MockHistoryReader) GetJob(ctx context.Context, namespace string, repo string, uid string) (*v0alpha1.Job, error) { + ret := _m.Called(ctx, namespace, repo, uid) + + if len(ret) == 0 { + panic("no return value specified for GetJob") + } + + var r0 *v0alpha1.Job + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (*v0alpha1.Job, error)); ok { + return rf(ctx, namespace, repo, uid) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, string) *v0alpha1.Job); ok { + r0 = rf(ctx, namespace, repo, uid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0alpha1.Job) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { + r1 = rf(ctx, namespace, repo, uid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockHistoryReader_GetJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJob' +type MockHistoryReader_GetJob_Call struct { + *mock.Call +} + +// GetJob is a helper method to define mock.On call +// - ctx context.Context +// - namespace string +// - repo string +// - uid string +func (_e *MockHistoryReader_Expecter) GetJob(ctx interface{}, namespace interface{}, repo interface{}, uid interface{}) *MockHistoryReader_GetJob_Call { + return &MockHistoryReader_GetJob_Call{Call: _e.mock.On("GetJob", ctx, namespace, repo, uid)} +} + +func (_c *MockHistoryReader_GetJob_Call) Run(run func(ctx context.Context, namespace string, repo string, uid string)) *MockHistoryReader_GetJob_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string)) + }) + return _c +} + +func (_c *MockHistoryReader_GetJob_Call) Return(_a0 *v0alpha1.Job, _a1 error) *MockHistoryReader_GetJob_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockHistoryReader_GetJob_Call) RunAndReturn(run func(context.Context, string, string, string) (*v0alpha1.Job, error)) *MockHistoryReader_GetJob_Call { + _c.Call.Return(run) + return _c +} + +// RecentJobs provides a mock function with given fields: ctx, namespace, repo +func (_m *MockHistoryReader) RecentJobs(ctx context.Context, namespace string, repo string) (*v0alpha1.JobList, error) { + ret := _m.Called(ctx, namespace, repo) + + if len(ret) == 0 { + panic("no return value specified for RecentJobs") + } + + var r0 *v0alpha1.JobList + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*v0alpha1.JobList, error)); ok { + return rf(ctx, namespace, repo) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *v0alpha1.JobList); ok { + r0 = rf(ctx, namespace, repo) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0alpha1.JobList) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, namespace, repo) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockHistoryReader_RecentJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecentJobs' +type MockHistoryReader_RecentJobs_Call struct { + *mock.Call +} + +// RecentJobs is a helper method to define mock.On call +// - ctx context.Context +// - namespace string +// - repo string +func (_e *MockHistoryReader_Expecter) RecentJobs(ctx interface{}, namespace interface{}, repo interface{}) *MockHistoryReader_RecentJobs_Call { + return &MockHistoryReader_RecentJobs_Call{Call: _e.mock.On("RecentJobs", ctx, namespace, repo)} +} + +func (_c *MockHistoryReader_RecentJobs_Call) Run(run func(ctx context.Context, namespace string, repo string)) *MockHistoryReader_RecentJobs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockHistoryReader_RecentJobs_Call) Return(_a0 *v0alpha1.JobList, _a1 error) *MockHistoryReader_RecentJobs_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockHistoryReader_RecentJobs_Call) RunAndReturn(run func(context.Context, string, string) (*v0alpha1.JobList, error)) *MockHistoryReader_RecentJobs_Call { + _c.Call.Return(run) + return _c +} + +// NewMockHistoryReader creates a new instance of MockHistoryReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockHistoryReader(t interface { + mock.TestingT + Cleanup(func()) +}) *MockHistoryReader { + mock := &MockHistoryReader{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/jobs/history_writer_mock.go b/pkg/registry/apis/provisioning/jobs/history_writer_mock.go new file mode 100644 index 00000000000..8ec0e63018e --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/history_writer_mock.go @@ -0,0 +1,84 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package jobs + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockHistoryWriter is an autogenerated mock type for the HistoryWriter type +type MockHistoryWriter struct { + mock.Mock +} + +type MockHistoryWriter_Expecter struct { + mock *mock.Mock +} + +func (_m *MockHistoryWriter) EXPECT() *MockHistoryWriter_Expecter { + return &MockHistoryWriter_Expecter{mock: &_m.Mock} +} + +// WriteJob provides a mock function with given fields: ctx, job +func (_m *MockHistoryWriter) WriteJob(ctx context.Context, job *v0alpha1.Job) error { + ret := _m.Called(ctx, job) + + if len(ret) == 0 { + panic("no return value specified for WriteJob") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Job) error); ok { + r0 = rf(ctx, job) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockHistoryWriter_WriteJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteJob' +type MockHistoryWriter_WriteJob_Call struct { + *mock.Call +} + +// WriteJob is a helper method to define mock.On call +// - ctx context.Context +// - job *v0alpha1.Job +func (_e *MockHistoryWriter_Expecter) WriteJob(ctx interface{}, job interface{}) *MockHistoryWriter_WriteJob_Call { + return &MockHistoryWriter_WriteJob_Call{Call: _e.mock.On("WriteJob", ctx, job)} +} + +func (_c *MockHistoryWriter_WriteJob_Call) Run(run func(ctx context.Context, job *v0alpha1.Job)) *MockHistoryWriter_WriteJob_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*v0alpha1.Job)) + }) + return _c +} + +func (_c *MockHistoryWriter_WriteJob_Call) Return(_a0 error) *MockHistoryWriter_WriteJob_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockHistoryWriter_WriteJob_Call) RunAndReturn(run func(context.Context, *v0alpha1.Job) error) *MockHistoryWriter_WriteJob_Call { + _c.Call.Return(run) + return _c +} + +// NewMockHistoryWriter creates a new instance of MockHistoryWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockHistoryWriter(t interface { + mock.TestingT + Cleanup(func()) +}) *MockHistoryWriter { + mock := &MockHistoryWriter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/jobs/loki_client_mock.go b/pkg/registry/apis/provisioning/jobs/loki_client_mock.go index 9043c329875..557350d2661 100644 --- a/pkg/registry/apis/provisioning/jobs/loki_client_mock.go +++ b/pkg/registry/apis/provisioning/jobs/loki_client_mock.go @@ -134,8 +134,7 @@ func (_c *MockLokiClient_RangeQuery_Call) RunAndReturn(run func(context.Context, func NewMockLokiClient(t interface { mock.TestingT Cleanup(func()) -}, -) *MockLokiClient { +}) *MockLokiClient { mock := &MockLokiClient{} mock.Mock.Test(t) diff --git a/pkg/registry/apis/provisioning/jobs/persistentstore.go b/pkg/registry/apis/provisioning/jobs/persistentstore.go index b7c7e47bd35..8b164320bd3 100644 --- a/pkg/registry/apis/provisioning/jobs/persistentstore.go +++ b/pkg/registry/apis/provisioning/jobs/persistentstore.go @@ -306,11 +306,11 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err) } - // We need to delete the job from the job store and create it in the historic job store. - // We are fine with the job being lost if the historic job store fails to create it. + // Delete the job from the active job store. + // Callers are responsible for writing the job to history after calling this. // // We will assume that the caller is the claimant. If this is not true, an error is returned. - // This is a best-effort operation; if the job is not in the claimed state, we will still attempt to move it to the historic job store. + // This is a best-effort operation; if the job is not in the claimed state, we will still attempt to delete it. err = s.client.Jobs(job.GetNamespace()).Delete(ctx, job.GetName(), metav1.DeleteOptions{}) if err != nil { span.RecordError(err) @@ -329,6 +329,56 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e return nil } +// ListExpiredJobs lists jobs with expired leases (claim timestamp older than the given time). +// Returns jobs in batches up to the specified limit. +func (s *persistentStore) ListExpiredJobs(ctx context.Context, expiredBefore time.Time, limit int) ([]*provisioning.Job, error) { + ctx, span := tracing.Start(ctx, "provisioning.jobs.list_expired_jobs") + defer span.End() + + logger := logging.FromContext(ctx).With("operation", "list_expired_jobs") + + // Set up provisioning identity to access jobs across all namespaces + ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") + if err != nil { + span.RecordError(err) + return nil, apifmt.Errorf("failed to grant provisioning identity for listing expired jobs: %w", err) + } + + // Find jobs with expired leases (older than expiredBefore) + expiry := expiredBefore.UnixMilli() + logger.Debug("searching for expired jobs", "expiry_threshold", expiredBefore.Format(time.RFC3339)) + + requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)}) + if err != nil { + span.RecordError(err) + return nil, apifmt.Errorf("could not create requirement: %w", err) + } + + span.SetAttributes( + attribute.String("expiry_threshold", expiredBefore.Format(time.RFC3339)), + attribute.Int("limit", limit), + ) + + jobList, err := s.client.Jobs("").List(ctx, metav1.ListOptions{ + LabelSelector: labels.NewSelector().Add(*requirement).String(), + Limit: int64(limit), + }) + if err != nil { + span.RecordError(err) + return nil, apifmt.Errorf("failed to list jobs with expired leases: %w", err) + } + + result := make([]*provisioning.Job, len(jobList.Items)) + for i := range jobList.Items { + result[i] = &jobList.Items[i] + } + + span.SetAttributes(attribute.Int("jobs_found", len(result))) + logger.Debug("found expired jobs", "count", len(result)) + + return result, nil +} + // RenewLease renews the lease for a claimed job, extending its expiry time. // Returns an error if the lease cannot be renewed (e.g., job was completed or lease expired). func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) error { @@ -405,164 +455,6 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) return nil } -// Cleanup finds jobs with expired leases and marks them as failed. -// This replaces the old cleanup mechanism and should be called more frequently. -func (s *persistentStore) Cleanup(ctx context.Context) error { - ctx, span := tracing.Start(ctx, "provisioning.jobs.cleanup") - defer span.End() - - startTime := s.clock() - logger := logging.FromContext(ctx).With("operation", "cleanup") - - // List expired jobs - jobs, err := s.listExpiredJobs(ctx) - if err != nil { - span.RecordError(err) - return err - } - - // If no jobs found, cleanup is complete - if len(jobs) == 0 { - duration := s.clock().Sub(startTime) - logger.Info("cleanup complete - no expired jobs found", "duration", duration) - span.SetAttributes( - attribute.Int("count", 0), - attribute.Int64("duration_ms", duration.Milliseconds()), - ) - return nil - } - - logger.Info("found expired jobs", "count", len(jobs)) - - // Clean up each expired job - for _, job := range jobs { - if err := s.cleanUpExpiredJob(ctx, job); err != nil { - span.RecordError(err) - return err - } - } - - duration := s.clock().Sub(startTime) - logger.Info("cleanup complete", - "duration", duration, - "count", len(jobs), - ) - - span.SetAttributes( - attribute.Int("count", len(jobs)), - attribute.Int64("duration_ms", duration.Milliseconds()), - ) - - return nil -} - -// listExpiredJobs returns jobs with expired leases. -func (s *persistentStore) listExpiredJobs(ctx context.Context) ([]provisioning.Job, error) { - logger := logging.FromContext(ctx) - - // Set up provisioning identity to access jobs across all namespaces - ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") // "*" grants access to all namespaces - if err != nil { - return nil, apifmt.Errorf("failed to grant provisioning identity for cleanup: %w", err) - } - - // Find jobs with expired leases (older than expiry time) - expiry := s.clock().Add(-s.expiry).UnixMilli() - expiryTime := time.UnixMilli(expiry) - logger.Debug("search for expired jobs", "expiry_threshold", expiryTime.Format(time.RFC3339)) - - requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)}) - if err != nil { - return nil, apifmt.Errorf("could not create requirement: %w", err) - } - - listCtx, listSpan := tracing.Start(ctx, "provisioning.jobs.cleanup.list_expired_jobs") - defer listSpan.End() - - listSpan.SetAttributes( - attribute.String("expiry_threshold", expiryTime.Format(time.RFC3339)), - attribute.Int64("expiry_duration_seconds", int64(s.expiry.Seconds())), - ) - - timeoutCtx, cancel := context.WithTimeout(listCtx, 5*time.Second) - defer cancel() - - jobList, err := s.client.Jobs("").List(timeoutCtx, metav1.ListOptions{ - LabelSelector: labels.NewSelector().Add(*requirement).String(), - Limit: 100, // Process in batches - }) - if err != nil { - listSpan.RecordError(err) - return nil, apifmt.Errorf("failed to list jobs with expired leases: %w", err) - } - - listSpan.SetAttributes(attribute.Int("jobs_found", len(jobList.Items))) - return jobList.Items, nil -} - -// cleanUpExpiredJob marks a single expired job as failed and archives it. -func (s *persistentStore) cleanUpExpiredJob(ctx context.Context, job provisioning.Job) error { - // Calculate how long the job has been expired - var expiredFor time.Duration - var claimTimestamp time.Time - if claimTime, exists := job.Labels[LabelJobClaim]; exists { - claimMillis, parseErr := strconv.ParseInt(claimTime, 10, 64) - if parseErr == nil { - claimTimestamp = time.UnixMilli(claimMillis) - expiredFor = s.clock().Sub(claimTimestamp) - } - } - - logger := logging.FromContext(ctx).With( - "job", job.GetName(), - "namespace", job.GetNamespace(), - "repository", job.Spec.Repository, - "action", job.Spec.Action, - "expired_for", expiredFor, - ) - - if !claimTimestamp.IsZero() { - logger = logger.With("claim_time", claimTimestamp.Format(time.RFC3339)) - } - - jobCtx, jobSpan := tracing.Start(ctx, "provisioning.jobs.cleanup.complete_expired_job") - defer jobSpan.End() - - jobSpan.SetAttributes( - attribute.String("job.name", job.GetName()), - attribute.String("job.namespace", job.GetNamespace()), - attribute.String("job.repository", job.Spec.Repository), - attribute.String("job.action", string(job.Spec.Action)), - attribute.String("job.expired_for", expiredFor.String()), - ) - - // Mark job as failed due to lease expiry and archive it - jobCopy := job.DeepCopy() - jobCopy.Status.State = provisioning.JobStateError - jobCopy.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection" - - // Set namespace context for the completion - jobCtx, _, err := identity.WithProvisioningIdentity(jobCtx, job.GetNamespace()) - if err != nil { - jobSpan.RecordError(err) - return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err) - } - - // Use Complete to properly archive the failed job - if err := s.Complete(jobCtx, jobCopy); err != nil { - if apierrors.IsNotFound(err) { - // Job was already completed/deleted by another process - this is expected - logger.Warn("job already completed or deleted by another process") - return nil - } - jobSpan.RecordError(err) - return apifmt.Errorf("failed to complete expired job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) - } - - logger.Info("clean up expired job complete") - return nil -} - func (s *persistentStore) Insert(ctx context.Context, namespace string, spec provisioning.JobSpec) (*provisioning.Job, error) { ctx, span := tracing.Start(ctx, "provisioning.jobs.insert") defer span.End() diff --git a/pkg/registry/apis/provisioning/jobs/store_mock.go b/pkg/registry/apis/provisioning/jobs/store_mock.go index aebcac9bbbe..99a1945c6a8 100644 --- a/pkg/registry/apis/provisioning/jobs/store_mock.go +++ b/pkg/registry/apis/provisioning/jobs/store_mock.go @@ -1,12 +1,14 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. +// Code generated by mockery v2.53.4. DO NOT EDIT. package jobs import ( context "context" + time "time" + + mock "github.com/stretchr/testify/mock" v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - mock "github.com/stretchr/testify/mock" ) // MockStore is an autogenerated mock type for the Store type @@ -89,52 +91,6 @@ func (_c *MockStore_Claim_Call) RunAndReturn(run func(context.Context) (*v0alpha return _c } -// Cleanup provides a mock function with given fields: ctx -func (_m *MockStore) Cleanup(ctx context.Context) error { - ret := _m.Called(ctx) - - if len(ret) == 0 { - panic("no return value specified for Cleanup") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context) error); ok { - r0 = rf(ctx) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// MockStore_Cleanup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Cleanup' -type MockStore_Cleanup_Call struct { - *mock.Call -} - -// Cleanup is a helper method to define mock.On call -// - ctx context.Context -func (_e *MockStore_Expecter) Cleanup(ctx interface{}) *MockStore_Cleanup_Call { - return &MockStore_Cleanup_Call{Call: _e.mock.On("Cleanup", ctx)} -} - -func (_c *MockStore_Cleanup_Call) Run(run func(ctx context.Context)) *MockStore_Cleanup_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context)) - }) - return _c -} - -func (_c *MockStore_Cleanup_Call) Return(_a0 error) *MockStore_Cleanup_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MockStore_Cleanup_Call) RunAndReturn(run func(context.Context) error) *MockStore_Cleanup_Call { - _c.Call.Return(run) - return _c -} - // Complete provides a mock function with given fields: ctx, job func (_m *MockStore) Complete(ctx context.Context, job *v0alpha1.Job) error { ret := _m.Called(ctx, job) @@ -182,9 +138,9 @@ func (_c *MockStore_Complete_Call) RunAndReturn(run func(context.Context, *v0alp return _c } -// Get provides a mock function with given fields: ctx, name -func (_m *MockStore) Get(ctx context.Context, name string) (*v0alpha1.Job, error) { - ret := _m.Called(ctx, name) +// Get provides a mock function with given fields: ctx, namespace, name +func (_m *MockStore) Get(ctx context.Context, namespace string, name string) (*v0alpha1.Job, error) { + ret := _m.Called(ctx, namespace, name) if len(ret) == 0 { panic("no return value specified for Get") @@ -192,19 +148,19 @@ func (_m *MockStore) Get(ctx context.Context, name string) (*v0alpha1.Job, error var r0 *v0alpha1.Job var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string) (*v0alpha1.Job, error)); ok { - return rf(ctx, name) + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*v0alpha1.Job, error)); ok { + return rf(ctx, namespace, name) } - if rf, ok := ret.Get(0).(func(context.Context, string) *v0alpha1.Job); ok { - r0 = rf(ctx, name) + if rf, ok := ret.Get(0).(func(context.Context, string, string) *v0alpha1.Job); ok { + r0 = rf(ctx, namespace, name) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*v0alpha1.Job) } } - if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = rf(ctx, name) + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, namespace, name) } else { r1 = ret.Error(1) } @@ -219,14 +175,15 @@ type MockStore_Get_Call struct { // Get is a helper method to define mock.On call // - ctx context.Context +// - namespace string // - name string -func (_e *MockStore_Expecter) Get(ctx interface{}, name interface{}) *MockStore_Get_Call { - return &MockStore_Get_Call{Call: _e.mock.On("Get", ctx, name)} +func (_e *MockStore_Expecter) Get(ctx interface{}, namespace interface{}, name interface{}) *MockStore_Get_Call { + return &MockStore_Get_Call{Call: _e.mock.On("Get", ctx, namespace, name)} } -func (_c *MockStore_Get_Call) Run(run func(ctx context.Context, name string)) *MockStore_Get_Call { +func (_c *MockStore_Get_Call) Run(run func(ctx context.Context, namespace string, name string)) *MockStore_Get_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string)) + run(args[0].(context.Context), args[1].(string), args[2].(string)) }) return _c } @@ -236,7 +193,67 @@ func (_c *MockStore_Get_Call) Return(_a0 *v0alpha1.Job, _a1 error) *MockStore_Ge return _c } -func (_c *MockStore_Get_Call) RunAndReturn(run func(context.Context, string) (*v0alpha1.Job, error)) *MockStore_Get_Call { +func (_c *MockStore_Get_Call) RunAndReturn(run func(context.Context, string, string) (*v0alpha1.Job, error)) *MockStore_Get_Call { + _c.Call.Return(run) + return _c +} + +// ListExpiredJobs provides a mock function with given fields: ctx, expiredBefore, limit +func (_m *MockStore) ListExpiredJobs(ctx context.Context, expiredBefore time.Time, limit int) ([]*v0alpha1.Job, error) { + ret := _m.Called(ctx, expiredBefore, limit) + + if len(ret) == 0 { + panic("no return value specified for ListExpiredJobs") + } + + var r0 []*v0alpha1.Job + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, time.Time, int) ([]*v0alpha1.Job, error)); ok { + return rf(ctx, expiredBefore, limit) + } + if rf, ok := ret.Get(0).(func(context.Context, time.Time, int) []*v0alpha1.Job); ok { + r0 = rf(ctx, expiredBefore, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*v0alpha1.Job) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, time.Time, int) error); ok { + r1 = rf(ctx, expiredBefore, limit) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockStore_ListExpiredJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListExpiredJobs' +type MockStore_ListExpiredJobs_Call struct { + *mock.Call +} + +// ListExpiredJobs is a helper method to define mock.On call +// - ctx context.Context +// - expiredBefore time.Time +// - limit int +func (_e *MockStore_Expecter) ListExpiredJobs(ctx interface{}, expiredBefore interface{}, limit interface{}) *MockStore_ListExpiredJobs_Call { + return &MockStore_ListExpiredJobs_Call{Call: _e.mock.On("ListExpiredJobs", ctx, expiredBefore, limit)} +} + +func (_c *MockStore_ListExpiredJobs_Call) Run(run func(ctx context.Context, expiredBefore time.Time, limit int)) *MockStore_ListExpiredJobs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(time.Time), args[2].(int)) + }) + return _c +} + +func (_c *MockStore_ListExpiredJobs_Call) Return(_a0 []*v0alpha1.Job, _a1 error) *MockStore_ListExpiredJobs_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockStore_ListExpiredJobs_Call) RunAndReturn(run func(context.Context, time.Time, int) ([]*v0alpha1.Job, error)) *MockStore_ListExpiredJobs_Call { _c.Call.Return(run) return _c } diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 13104e3e2f5..c30b43eeb60 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -287,6 +287,16 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { return authorizer.DecisionAllow, "", nil } + // Check if any extra authorizer has a decision. + // Since the move to access checker when useExclusivelyAccessCheckerForAuthz=true, extra authorizers + // need to run first because access checker is not aware of the extras logic + for _, extra := range b.extras { + decision, reason, err := extra.Authorize(ctx, a) + if decision != authorizer.DecisionNoOpinion { + return decision, reason, err + } + } + info, ok := authlib.AuthInfoFrom(ctx) // when running as standalone API server, the identity type may not always match TypeAccessPolicy // so we allow it to use the access checker if there is any auth info available @@ -310,6 +320,12 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { return authorizer.DecisionAllow, "", nil } + + id, err := identity.GetRequester(ctx) + if err != nil { + return authorizer.DecisionDeny, "failed to find requester", err + } + // Different routes may need different permissions. // * Reading and modifying a repository's configuration requires administrator privileges. // * Reading a repository's limited configuration (/stats & /settings) requires viewer privileges. @@ -322,19 +338,6 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { // * Testing a repository configuration requires administrator privileges. // * Viewing a repository's history requires editor privileges. - id, err := identity.GetRequester(ctx) - if err != nil { - return authorizer.DecisionDeny, "failed to find requester", err - } - - // Check if any extra authorizer has a decision. - for _, extra := range b.extras { - decision, reason, err := extra.Authorize(ctx, a) - if decision != authorizer.DecisionNoOpinion { - return decision, reason, err - } - } - switch a.GetResource() { case provisioning.RepositoryResourceInfo.GetName(): // TODO: Support more fine-grained permissions than the basic roles. Especially on Enterprise. @@ -772,13 +775,21 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH } repoGetter := resources.NewRepositoryGetter(b.repoFactory, b.client) + + // Create job cleanup controller + jobExpiry := 30 * time.Second + jobCleanupController := jobs.NewJobCleanupController( + b.jobs, + jobHistoryWriter, + jobExpiry, + ) + // This is basically our own JobQueue system driver, err := jobs.NewConcurrentJobDriver( 3, // 3 drivers for now 20*time.Minute, // Max time for each job - time.Minute, // Cleanup jobs 30*time.Second, // Periodically look for new jobs - 30*time.Second, // Lease renewal interval + jobExpiry, // Lease renewal interval b.jobs, repoGetter, jobHistoryWriter, jobController.InsertNotifications(), b.registry, @@ -794,6 +805,12 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH } }() + go func() { + if err := jobCleanupController.Run(postStartHookCtx.Context); err != nil { + logging.FromContext(postStartHookCtx.Context).Error("job cleanup controller failed", "error", err) + } + }() + repoController, err := controller.NewRepositoryController( b.GetClient(), repoInformer, @@ -818,7 +835,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH if b.jobHistoryLoki == nil { // Create HistoryJobController for cleanup of old job history entries // Separate informer factory for HistoryJob cleanup with resync interval - historyJobExpiration := 30 * time.Second + historyJobExpiration := 10 * time.Minute historyJobInformerFactory := informers.NewSharedInformerFactory(c, historyJobExpiration) historyJobInformer := historyJobInformerFactory.Provisioning().V0alpha1().HistoricJobs() go historyJobInformer.Informer().Run(postStartHookCtx.Done()) diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index cfcf3540b43..98a9c08ed25 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -308,6 +308,7 @@ func InstallAPIs( var mode = grafanarest.DualWriterMode(0) var ( + err error dualWriterPeriodicDataSyncJobEnabled bool dualWriterMigrationDataSyncDisabled bool dataSyncerInterval = time.Hour @@ -329,29 +330,45 @@ func InstallAPIs( return storage, nil } - // TODO: inherited context from main Grafana process - ctx := context.Background() + currentMode := mode + if !dualWriterMigrationDataSyncDisabled || dualWriterPeriodicDataSyncJobEnabled { + // TODO: inherited context from main Grafana process + ctx := context.Background() - // Moving from one version to the next can only happen after the previous step has - // successfully synchronized. - requestInfo := getRequestInfo(gr, namespaceMapper) + // Moving from one version to the next can only happen after the previous step has + // successfully synchronized. + requestInfo := getRequestInfo(gr, namespaceMapper) - syncerCfg := &grafanarest.SyncerConfig{ - Kind: key, - RequestInfo: requestInfo, - Mode: mode, - SkipDataSync: dualWriterMigrationDataSyncDisabled, - LegacyStorage: legacy, - Storage: storage, - ServerLockService: serverLock, - DataSyncerInterval: dataSyncerInterval, - DataSyncerRecordsLimit: dataSyncerRecordsLimit, - } + syncerCfg := &grafanarest.SyncerConfig{ + Kind: key, + RequestInfo: requestInfo, + Mode: mode, + SkipDataSync: dualWriterMigrationDataSyncDisabled, + LegacyStorage: legacy, + Storage: storage, + ServerLockService: serverLock, + DataSyncerInterval: dataSyncerInterval, + DataSyncerRecordsLimit: dataSyncerRecordsLimit, + } - // This also sets the currentMode on the syncer config. - currentMode, err := grafanarest.SetDualWritingMode(ctx, kvStore, syncerCfg, dualWriterMetrics) - if err != nil { - return nil, err + // This also sets the currentMode on the syncer config. + currentMode, err = grafanarest.SetDualWritingMode(ctx, kvStore, syncerCfg, dualWriterMetrics) + if err != nil { + return nil, err + } + + // when unable to use + if currentMode != mode { + klog.Warningf("Requested DualWrite mode: %d, but using %d for %+v", mode, currentMode, gr) + } + + if dualWriterPeriodicDataSyncJobEnabled && (currentMode >= grafanarest.Mode1 && currentMode <= grafanarest.Mode3) { + // The mode might have changed in SetDualWritingMode, so apply current mode first. + syncerCfg.Mode = currentMode + if err := grafanarest.StartPeriodicDataSyncer(ctx, syncerCfg, dualWriterMetrics); err != nil { + return nil, err + } + } } builderMetrics.RecordDualWriterModes(gr.Resource, gr.Group, mode, currentMode) @@ -362,21 +379,8 @@ func InstallAPIs( case grafanarest.Mode4, grafanarest.Mode5: return storage, nil default: + return dualwrite.NewDualWriter(gr, currentMode, legacy, storage) } - - if dualWriterPeriodicDataSyncJobEnabled { - // The mode might have changed in SetDualWritingMode, so apply current mode first. - syncerCfg.Mode = currentMode - if err := grafanarest.StartPeriodicDataSyncer(ctx, syncerCfg, dualWriterMetrics); err != nil { - return nil, err - } - } - - // when unable to use - if currentMode != mode { - klog.Warningf("Requested DualWrite mode: %d, but using %d for %+v", mode, currentMode, gr) - } - return dualwrite.NewDualWriter(gr, currentMode, legacy, storage) } } diff --git a/pkg/services/apiserver/options/storage.go b/pkg/services/apiserver/options/storage.go index ee91ff8eb89..28f6e1046ab 100644 --- a/pkg/services/apiserver/options/storage.go +++ b/pkg/services/apiserver/options/storage.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "net" + "strconv" + "strings" "time" "github.com/spf13/pflag" @@ -14,6 +16,7 @@ import ( "k8s.io/apiserver/pkg/server/options" "k8s.io/client-go/rest" + apiserverrest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/tracing" secret "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" inlinesecurevalue "github.com/grafana/grafana/pkg/registry/apis/secret/inline" @@ -87,6 +90,58 @@ type StorageOptions struct { ConfigProvider RestConfigProvider } +// unifiedStorageConfigValue implements pflag.Value for parsing unified storage config +type unifiedStorageConfigValue struct { + config *map[string]setting.UnifiedStorageConfig +} + +func (v *unifiedStorageConfigValue) String() string { + if v.config == nil || len(*v.config) == 0 { + return "" + } + parts := make([]string, 0, len(*v.config)) + for key, cfg := range *v.config { + parts = append(parts, fmt.Sprintf("%s=%d", key, cfg.DualWriterMode)) + } + return strings.Join(parts, ",") +} + +func (v *unifiedStorageConfigValue) Set(val string) error { + if val == "" { + return nil + } + + // Parse comma-separated key=value pairs + pairs := strings.Split(val, ",") + for _, pair := range pairs { + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + return fmt.Errorf("invalid format: %s (expected key=value)", pair) + } + + key := strings.TrimSpace(kv[0]) + mode, err := strconv.Atoi(strings.TrimSpace(kv[1])) + if err != nil { + return fmt.Errorf("invalid mode value for %s: %w", key, err) + } + + if mode < 0 || mode > 5 { + return fmt.Errorf("mode must be between 0 and 5, got %d for %s", mode, key) + } + + (*v.config)[key] = setting.UnifiedStorageConfig{ + DualWriterMode: apiserverrest.DualWriterMode(mode), + DualWriterMigrationDataSyncDisabled: true, + } + } + + return nil +} + +func (v *unifiedStorageConfigValue) Type() string { + return "stringToUnifiedStorageConfig" +} + func NewStorageOptions() *StorageOptions { return &StorageOptions{ StorageType: StorageTypeUnified, @@ -95,6 +150,7 @@ func NewStorageOptions() *StorageOptions { GrpcClientAuthenticationAllowInsecure: false, GrpcClientKeepaliveTime: 0, BlobThresholdBytes: BlobThresholdDefault, + UnifiedStorageConfig: make(map[string]setting.UnifiedStorageConfig), } } @@ -109,6 +165,11 @@ func (o *StorageOptions) AddFlags(fs *pflag.FlagSet) { fs.BoolVar(&o.GrpcClientAuthenticationAllowInsecure, "grpc-client-authentication-allow-insecure", o.GrpcClientAuthenticationAllowInsecure, "Allow insecure grpc client authentication") fs.DurationVar(&o.GrpcClientKeepaliveTime, "grpc-client-keepalive-time", o.GrpcClientKeepaliveTime, "gRPC client keep-alive ping interval (e.g., 6m).") + // Use custom flag value for unified storage config + fs.Var(&unifiedStorageConfigValue{config: &o.UnifiedStorageConfig}, + "grafana-apiserver-unified-storage-config", + "Unified storage configuration per resource.group in the format resource.group=mode,... where mode is 0-5") + // Secrets Manager Configuration flags fs.BoolVar(&o.SecretsManagerGrpcClientEnable, "grafana.secrets-manager.grpc-client-enable", false, "Enable gRPC client for secrets manager") fs.StringVar(&o.SecretsManagerGrpcServerAddress, "grafana.secrets-manager.grpc-server-address", "", "gRPC server address for secrets manager") diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go index e45346740cc..5ef2e0c5ad6 100644 --- a/pkg/services/authz/rbac/service.go +++ b/pkg/services/authz/rbac/service.go @@ -618,6 +618,7 @@ func (s *Service) checkPermission(ctx context.Context, scopeMap map[string]bool, } if t.SkipScope(req.Verb) { + // Resource doesn't require scope on this verb, so allow if the user has the action return scopeMap[""], nil } @@ -627,18 +628,6 @@ func (s *Service) checkPermission(ctx context.Context, scopeMap map[string]bool, req.ParentFolder = accesscontrol.GeneralFolderUID } - //if req.Verb == utils.VerbCreate { - // // Resource doesn't require scope on create, so allow if the user has the action - // if t.SkipScopeOnCreate() { - // return scopeMap[""], nil - // } - // // If creating a resource that goes in a folder, but no folder is specified, - // // assume parent folder is the general folder - // if t.HasFolderSupport() && req.ParentFolder == "" { - // req.ParentFolder = accesscontrol.GeneralFolderUID - // } - //} - // Wildcard grant, no further checks needed if scopeMap["*"] { return true, nil diff --git a/pkg/services/authz/zanzana/server/metrics.go b/pkg/services/authz/zanzana/server/metrics.go index 7df91aed169..182cedf41be 100644 --- a/pkg/services/authz/zanzana/server/metrics.go +++ b/pkg/services/authz/zanzana/server/metrics.go @@ -25,7 +25,7 @@ func newZanzanaServerMetrics(reg prometheus.Registerer) *metrics { Subsystem: metricsSubSystem, Buckets: prometheus.ExponentialBuckets(0.00001, 4, 10), }, - []string{"method", "namespace"}, + []string{"method", "request_namespace"}, ), } } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 68409eab390..e2d5e31861d 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -69,14 +69,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaSearchAndStorageSquad, }, - { - Name: "correlations", - Description: "Correlations page", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaDataProSquad, - Expression: "true", // enabled by default - AllowSelfServe: true, - }, { Name: "canvasPanelNesting", Description: "Allow elements nesting", @@ -169,7 +161,7 @@ var ( Description: "populate star status from apiserver", Stage: FeatureStageExperimental, FrontendOnly: true, - Owner: grafanaFrontendPlatformSquad, + Owner: grafanaFrontendSearchNavOrganise, AllowSelfServe: false, HideFromDocs: true, }, @@ -583,6 +575,14 @@ var ( HideFromAdminPage: true, HideFromDocs: true, }, + { + Name: "alertingUIUseBackendFilters", + Description: "Enables the UI to use certain backend-side filters", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromAdminPage: true, + HideFromDocs: true, + }, { Name: "alertmanagerRemotePrimary", Description: "Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.", @@ -687,13 +687,6 @@ var ( Expression: "true", Owner: grafanaObservabilityLogsSquad, }, - { - Name: "pluginsSkipHostEnvVars", - Description: "Disables passing host environment variable to plugin processes", - Stage: FeatureStageExperimental, - FrontendOnly: false, - Owner: grafanaPluginsPlatformSquad, - }, { Name: "tableSharedCrosshair", Description: "Enables shared crosshair in table panel", @@ -1001,7 +994,7 @@ var ( Name: "pinNavItems", Description: "Enables pinning of nav items", Stage: FeatureStageGeneralAvailability, - Owner: grafanaFrontendPlatformSquad, + Owner: grafanaFrontendSearchNavOrganise, Expression: "true", // enabled by default }, { @@ -1394,7 +1387,7 @@ var ( Name: "unifiedHistory", Description: "Displays the navigation history so the user can navigate back to previous pages", Stage: FeatureStageExperimental, - Owner: grafanaFrontendPlatformSquad, + Owner: grafanaFrontendSearchNavOrganise, FrontendOnly: true, }, { @@ -1442,13 +1435,6 @@ var ( FrontendOnly: false, Owner: identityAccessTeam, }, - { - Name: "templateVariablesUsesCombobox", - Description: "Use new **Combobox** component for template variables", - Stage: FeatureStageExperimental, - Owner: grafanaFrontendPlatformSquad, - FrontendOnly: true, - }, { Name: "grafanaAdvisor", Description: "Enables Advisor app", @@ -1517,7 +1503,7 @@ var ( Name: "useScopesNavigationEndpoint", Description: "Use the scopes navigation endpoint instead of the dashboardbindings endpoint", Stage: FeatureStageExperimental, - Owner: grafanaFrontendPlatformSquad, + Owner: grafanaOperatorExperienceSquad, FrontendOnly: true, HideFromDocs: true, HideFromAdminPage: true, @@ -1526,7 +1512,7 @@ var ( Name: "scopeSearchAllLevels", Description: "Enable scope search to include all levels of the scope node tree", Stage: FeatureStageExperimental, - Owner: grafanaFrontendPlatformSquad, + Owner: grafanaOperatorExperienceSquad, HideFromDocs: true, HideFromAdminPage: true, }, @@ -1779,7 +1765,7 @@ var ( Name: "restoreDashboards", Description: "Enables restore deleted dashboards feature", Stage: FeatureStageExperimental, - Owner: grafanaFrontendPlatformSquad, + Owner: grafanaFrontendSearchNavOrganise, HideFromAdminPage: true, Expression: "false", }, @@ -2055,6 +2041,14 @@ var ( Owner: grafanaDatavizSquad, Expression: "false", }, + { + Name: "newVizSuggestions", + Description: "Enable new visualization suggestions", + Stage: FeatureStagePublicPreview, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + Expression: "false", + }, { Name: "preventPanelChromeOverflow", Description: "Restrict PanelChrome contents with overflow: hidden;", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index c88b2ae7ec6..f18ad23ecc5 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -6,7 +6,6 @@ publicDashboardsScene,GA,@grafana/grafana-operator-experience-squad,false,false, lokiExperimentalStreaming,experimental,@grafana/observability-logs,false,false,false featureHighlights,GA,@grafana/grafana-operator-experience-squad,false,false,false storage,experimental,@grafana/search-and-storage,false,false,false -correlations,GA,@grafana/datapro,false,false,false canvasPanelNesting,experimental,@grafana/dataviz-squad,false,false,true logRequestsInstrumentedAsUnknown,experimental,@grafana/grafana-backend-group,false,false,false grpcServer,preview,@grafana/search-and-storage,false,false,false @@ -19,7 +18,7 @@ lokiShardSplitting,experimental,@grafana/observability-logs,false,false,true lokiQuerySplitting,GA,@grafana/observability-logs,false,false,true individualCookiePreferences,experimental,@grafana/grafana-backend-group,false,false,false influxdbBackendMigration,GA,@grafana/partner-datasources,false,false,true -starsFromAPIServer,experimental,@grafana/grafana-frontend-platform,false,false,true +starsFromAPIServer,experimental,@grafana/grafana-search-navigate-organise,false,false,true kubernetesStars,experimental,@grafana/grafana-app-platform-squad,false,true,false influxqlStreamingParser,experimental,@grafana/partner-datasources,false,false,false influxdbRunQueriesInParallel,privatePreview,@grafana/partner-datasources,false,false,false @@ -77,6 +76,7 @@ cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-e addFieldFromCalculationStatFunctions,GA,@grafana/datapro,false,false,true alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false alertingProvenanceLockWrites,experimental,@grafana/alerting-squad,false,false,false +alertingUIUseBackendFilters,experimental,@grafana/alerting-squad,false,false,false alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true @@ -91,7 +91,6 @@ canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true timeComparison,experimental,@grafana/dataviz-squad,false,false,true logsInfiniteScrolling,GA,@grafana/observability-logs,false,false,true logRowsPopoverMenu,GA,@grafana/observability-logs,false,false,true -pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,false,false,false tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true regressionTransformation,preview,@grafana/datapro,false,false,true kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true @@ -130,7 +129,7 @@ preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,fals alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,true pluginProxyPreserveTrailingSlash,GA,@grafana/plugins-platform-backend,false,false,false azureMonitorPrometheusExemplars,GA,@grafana/partner-datasources,false,false,false -pinNavItems,GA,@grafana/grafana-frontend-platform,false,false,false +pinNavItems,GA,@grafana/grafana-search-navigate-organise,false,false,false authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false ssoSettingsLDAP,GA,@grafana/identity-access-team,false,true,false zanzana,experimental,@grafana/identity-access-team,false,false,false @@ -181,14 +180,13 @@ alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false elasticsearchCrossClusterSearch,GA,@grafana/partner-datasources,false,false,false -unifiedHistory,experimental,@grafana/grafana-frontend-platform,false,false,true +unifiedHistory,experimental,@grafana/grafana-search-navigate-organise,false,false,true lokiLabelNamesQueryApi,GA,@grafana/observability-logs,false,false,false investigationsBackend,experimental,@grafana/grafana-app-platform-squad,false,false,false k8SFolderCounts,experimental,@grafana/search-and-storage,false,false,false k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false teamHttpHeadersTempo,experimental,@grafana/identity-access-team,false,false,false -templateVariablesUsesCombobox,experimental,@grafana/grafana-frontend-platform,false,false,true grafanaAdvisor,privatePreview,@grafana/plugins-platform-backend,false,false,false elasticsearchImprovedParsing,experimental,@grafana/aws-datasources,false,false,false datasourceConnectionsTab,privatePreview,@grafana/plugins-platform-backend,false,false,true @@ -197,8 +195,8 @@ newLogsPanel,GA,@grafana/observability-logs,false,false,true grafanaconThemes,GA,@grafana/grafana-frontend-platform,false,true,false alertingJiraIntegration,experimental,@grafana/alerting-squad,false,false,true alertingUseNewSimplifiedRoutingHashAlgorithm,preview,@grafana/alerting-squad,false,true,false -useScopesNavigationEndpoint,experimental,@grafana/grafana-frontend-platform,false,false,true -scopeSearchAllLevels,experimental,@grafana/grafana-frontend-platform,false,false,false +useScopesNavigationEndpoint,experimental,@grafana/grafana-operator-experience-squad,false,false,true +scopeSearchAllLevels,experimental,@grafana/grafana-operator-experience-squad,false,false,false alertingRuleVersionHistoryRestore,GA,@grafana/alerting-squad,false,false,true newShareReportDrawer,preview,@grafana/grafana-operator-experience-squad,false,false,false rendererDisableAppPluginsPreload,experimental,@grafana/grafana-operator-experience-squad,false,false,true @@ -230,7 +228,7 @@ kubernetesAuthZHandlerRedirect,experimental,@grafana/identity-access-team,false, kubernetesAuthzResourcePermissionApis,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthzZanzanaSync,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,false -restoreDashboards,experimental,@grafana/grafana-frontend-platform,false,false,false +restoreDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,false alertEnrichment,experimental,@grafana/alerting-squad,false,false,false alertEnrichmentMultiStep,experimental,@grafana/alerting-squad,false,false,false alertEnrichmentConditional,experimental,@grafana/alerting-squad,false,false,false @@ -264,6 +262,7 @@ cdnPluginsLoadFirst,experimental,@grafana/plugins-platform-backend,false,false,f cdnPluginsUrls,experimental,@grafana/plugins-platform-backend,false,false,false pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,false newGauge,experimental,@grafana/dataviz-squad,false,false,true +newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index bdab6514db0..c6cb22cbf57 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -35,10 +35,6 @@ const ( // Configurable storage for dashboards, datasources, and resources FlagStorage = "storage" - // FlagCorrelations - // Correlations page - FlagCorrelations = "correlations" - // FlagCanvasPanelNesting // Allow elements nesting FlagCanvasPanelNesting = "canvasPanelNesting" @@ -319,6 +315,10 @@ const ( // Enables a feature to avoid issues with concurrent writes to the alerting provenance table in MySQL FlagAlertingProvenanceLockWrites = "alertingProvenanceLockWrites" + // FlagAlertingUIUseBackendFilters + // Enables the UI to use certain backend-side filters + FlagAlertingUIUseBackendFilters = "alertingUIUseBackendFilters" + // FlagAlertmanagerRemotePrimary // Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. FlagAlertmanagerRemotePrimary = "alertmanagerRemotePrimary" @@ -375,10 +375,6 @@ const ( // Enable filtering menu displayed when text of a log line is selected FlagLogRowsPopoverMenu = "logRowsPopoverMenu" - // FlagPluginsSkipHostEnvVars - // Disables passing host environment variable to plugin processes - FlagPluginsSkipHostEnvVars = "pluginsSkipHostEnvVars" - // FlagTableSharedCrosshair // Enables shared crosshair in table panel FlagTableSharedCrosshair = "tableSharedCrosshair" @@ -763,10 +759,6 @@ const ( // Enables LBAC for datasources for Tempo to apply LBAC filtering of traces to the client requests for users in teams FlagTeamHttpHeadersTempo = "teamHttpHeadersTempo" - // FlagTemplateVariablesUsesCombobox - // Use new **Combobox** component for template variables - FlagTemplateVariablesUsesCombobox = "templateVariablesUsesCombobox" - // FlagGrafanaAdvisor // Enables Advisor app FlagGrafanaAdvisor = "grafanaAdvisor" @@ -1066,6 +1058,10 @@ const ( // Enable new gauge visualization FlagNewGauge = "newGauge" + // FlagNewVizSuggestions + // Enable new visualization suggestions + FlagNewVizSuggestions = "newVizSuggestions" + // FlagPreventPanelChromeOverflow // Restrict PanelChrome contents with overflow: hidden; FlagPreventPanelChromeOverflow = "preventPanelChromeOverflow" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 23d85c2484b..dd79670cfb7 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -614,6 +614,20 @@ "expression": "true" } }, + { + "metadata": { + "name": "alertingUIUseBackendFilters", + "resourceVersion": "1762966218072", + "creationTimestamp": "2025-11-12T16:50:18Z" + }, + "spec": { + "description": "Enables the UI to use certain backend-side filters", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "alertingUseNewSimplifiedRoutingHashAlgorithm", @@ -1024,6 +1038,7 @@ "name": "correlations", "resourceVersion": "1762442825881", "creationTimestamp": "2022-09-16T13:14:27Z", + "deletionTimestamp": "2025-11-12T13:11:31Z", "annotations": { "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" } @@ -2896,6 +2911,20 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "newVizSuggestions", + "resourceVersion": "1762456851857", + "creationTimestamp": "2025-11-06T19:20:51Z" + }, + "spec": { + "description": "Enable new visualization suggestions", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true, + "expression": "false" + } + }, { "metadata": { "name": "oauthRequireSubClaim", @@ -3063,13 +3092,16 @@ { "metadata": { "name": "pinNavItems", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-06-10T11:40:03Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2024-06-10T11:40:03Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "Enables pinning of nav items", "stage": "GA", - "codeowner": "@grafana/grafana-frontend-platform", + "codeowner": "@grafana/grafana-search-navigate-organise", "expression": "true" } }, @@ -3188,7 +3220,8 @@ "metadata": { "name": "pluginsSkipHostEnvVars", "resourceVersion": "1753448760331", - "creationTimestamp": "2023-11-15T17:09:14Z" + "creationTimestamp": "2023-11-15T17:09:14Z", + "deletionTimestamp": "2025-11-04T16:51:13Z" }, "spec": { "description": "Disables passing host environment variable to plugin processes", @@ -3589,13 +3622,16 @@ { "metadata": { "name": "restoreDashboards", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-05-23T14:35:54Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2025-05-23T14:35:54Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "Enables restore deleted dashboards feature", "stage": "experimental", - "codeowner": "@grafana/grafana-frontend-platform", + "codeowner": "@grafana/grafana-search-navigate-organise", "hideFromAdminPage": true, "expression": "false" } @@ -3690,13 +3726,16 @@ { "metadata": { "name": "scopeSearchAllLevels", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-04-14T07:42:16Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2025-04-14T07:42:16Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "Enable scope search to include all levels of the scope node tree", "stage": "experimental", - "codeowner": "@grafana/grafana-frontend-platform", + "codeowner": "@grafana/grafana-operator-experience-squad", "hideFromAdminPage": true, "hideFromDocs": true } @@ -3846,13 +3885,16 @@ { "metadata": { "name": "starsFromAPIServer", - "resourceVersion": "1758276055065", - "creationTimestamp": "2025-09-19T10:00:55Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2025-09-19T10:00:55Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "populate star status from apiserver", "stage": "experimental", - "codeowner": "@grafana/grafana-frontend-platform", + "codeowner": "@grafana/grafana-search-navigate-organise", "frontend": true, "hideFromDocs": true } @@ -3979,7 +4021,8 @@ "metadata": { "name": "templateVariablesUsesCombobox", "resourceVersion": "1753448760331", - "creationTimestamp": "2025-01-31T09:53:13Z" + "creationTimestamp": "2025-01-31T09:53:13Z", + "deletionTimestamp": "2025-11-12T14:40:39Z" }, "spec": { "description": "Use new **Combobox** component for template variables", @@ -4091,13 +4134,16 @@ { "metadata": { "name": "unifiedHistory", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-12-13T10:41:18Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2024-12-13T10:41:18Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "Displays the navigation history so the user can navigate back to previous pages", "stage": "experimental", - "codeowner": "@grafana/grafana-frontend-platform", + "codeowner": "@grafana/grafana-search-navigate-organise", "frontend": true } }, @@ -4325,13 +4371,16 @@ { "metadata": { "name": "useScopesNavigationEndpoint", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-03-31T15:20:00Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2025-03-31T15:20:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "Use the scopes navigation endpoint instead of the dashboardbindings endpoint", "stage": "experimental", - "codeowner": "@grafana/grafana-frontend-platform", + "codeowner": "@grafana/grafana-operator-experience-squad", "frontend": true, "hideFromAdminPage": true, "hideFromDocs": true diff --git a/pkg/services/frontend/index.html b/pkg/services/frontend/index.html index f937a987ca4..c8ba00870b9 100644 --- a/pkg/services/frontend/index.html +++ b/pkg/services/frontend/index.html @@ -220,6 +220,16 @@ } const resp = await fetch(bootDataUrl); + + // manual redirect for custom domains + // see pkg/middleware/validate_host.go + if (resp.status === 204) { + const redirectDomain = resp.headers.get('Redirect-Domain'); + if (redirectDomain) { + window.location.hostname = redirectDomain; + return; + } + } const textResponse = await resp.text(); let rawBootData; diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index 02e9f44bdbd..7e2af182a87 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -89,7 +89,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink Url: s.cfg.AppSubURL + "/plugins", }) } - if s.features.IsEnabled(ctx, featuremgmt.FlagCorrelations) && hasAccess(correlations.ConfigurationPageAccess) { + if hasAccess(correlations.ConfigurationPageAccess) { pluginsNodeLinks = append(pluginsNodeLinks, &navtree.NavLink{ Text: "Correlations", Icon: "gf-glue", diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index db380834c4a..830c2f83434 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -2215,6 +2215,7 @@ func createProvisioningSrvSutFromEnv(t *testing.T, env *testEnvironment) Provisi env.log, ngalertfakes.NewFakeReceiverPermissionsService(), tracer, + false, ) return ProvisioningSrv{ log: env.log, diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index b44f0f97651..a1884b56e98 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -479,6 +479,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ruleGroups := opts.Query["rule_group"] receiverName := opts.Query.Get("receiver_name") + title := opts.Query.Get("search.rule_name") maxGroups := getInt64WithDefault(opts.Query, "group_limit", -1) nextToken := opts.Query.Get("group_next_token") @@ -495,6 +496,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt PanelID: panelID, RuleGroups: ruleGroups, ReceiverName: receiverName, + SearchTitle: title, }, Limit: maxGroups, ContinueToken: nextToken, @@ -624,6 +626,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru ruleGroups := opts.Query["rule_group"] receiverName := opts.Query.Get("receiver_name") + title := opts.Query.Get("search.rule_name") alertRuleQuery := ngmodels.ListAlertRulesQuery{ OrgID: opts.OrgID, @@ -632,6 +635,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru PanelID: panelID, RuleGroups: ruleGroups, ReceiverName: receiverName, + SearchTitle: title, } ruleList, err := store.ListAlertRules(opts.Ctx, &alertRuleQuery) if err != nil { diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index c7350a91620..fc6b5fe104d 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -978,6 +978,9 @@ type ListAlertRulesQuery struct { ReceiverName string TimeIntervalName string + // SearchTitle allows searching for alert rules that contain + // the given string in their title (case insensitive) + SearchTitle string HasPrometheusRuleDefinition *bool } diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go index 321f13199d7..6fb624f52a5 100644 --- a/pkg/services/ngalert/models/testing.go +++ b/pkg/services/ngalert/models/testing.go @@ -1256,6 +1256,18 @@ func (n ReceiverMutators) WithOrigin(origin ResourceOrigin) Mutator[Receiver] { } } +func (n ReceiverMutators) WithEmptyIntegrations() Mutator[Receiver] { + return func(r *Receiver) { + r.Integrations = []*Integration{} + } +} + +func (n ReceiverMutators) WithUID(uid string) Mutator[Receiver] { + return func(r *Receiver) { + r.UID = uid + } +} + // Integrations // CopyIntegrationWith creates a deep copy of Integration and then applies mutators to it. diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 6de3f7b1ba0..299a511c35e 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -408,6 +408,8 @@ func (ng *AlertNG) init() error { ng.Log, ng.ResourcePermissions, ng.tracer, + //nolint:staticcheck // not yet migrated to OpenFeature + ng.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingImportAlertmanagerAPI), ) provisioningReceiverService := notifier.NewReceiverService( ac.NewReceiverAccess[*models.Receiver](ng.accesscontrol, true), @@ -419,6 +421,7 @@ func (ng *AlertNG) init() error { ng.Log, ng.ResourcePermissions, ng.tracer, + false, // imported resources are not exposed via provisioning APIs ) // Provisioning diff --git a/pkg/services/ngalert/notifier/legacy_storage/compat.go b/pkg/services/ngalert/notifier/legacy_storage/compat.go index ea93830d5fb..bd1946ba109 100644 --- a/pkg/services/ngalert/notifier/legacy_storage/compat.go +++ b/pkg/services/ngalert/notifier/legacy_storage/compat.go @@ -71,6 +71,13 @@ func PostableApiReceiverToReceiver(postable *apimodels.PostableApiReceiver, prov if err != nil { return nil, err } + if postable.HasMimirIntegrations() { + mimir, err := PostableMimirReceiverToIntegrations(postable.Receiver) + if err != nil { + return nil, err + } + integrations = append(integrations, mimir...) + } r := &models.Receiver{ UID: NameToUid(postable.GetName()), // TODO replace with stable UID. Name: postable.GetName(), @@ -117,6 +124,26 @@ func PostableGrafanaReceiversToIntegrations(postables []*apimodels.PostableGrafa return integrations, nil } +func PostableMimirReceiverToIntegrations(r alertingNotify.ConfigReceiver) ([]*models.Integration, error) { + v0, err := alertingNotify.ConfigReceiverToMimirIntegrations(r) + if err != nil { + return nil, fmt.Errorf("failed to convert v0 receiver to integrations: %w", err) + } + result := make([]*models.Integration, 0, len(v0)) + for _, config := range v0 { + s, err := config.ConfigMap() + if err != nil { + return nil, fmt.Errorf("failed to get settings of v0 receiver %s (version %s): %w", config.Schema.Type(), config.Schema.Version, err) + } + result = append(result, &models.Integration{ + Config: config.Schema, + Settings: s, + SecureSettings: map[string]string{}, + }) + } + return result, nil +} + func PostableGrafanaReceiverToIntegration(p *apimodels.PostableGrafanaReceiver) (*models.Integration, error) { integrationType, err := alertingNotify.IntegrationTypeFromString(p.Type) if err != nil { diff --git a/pkg/services/ngalert/notifier/legacy_storage/compat_test.go b/pkg/services/ngalert/notifier/legacy_storage/compat_test.go new file mode 100644 index 00000000000..c239b69b9b2 --- /dev/null +++ b/pkg/services/ngalert/notifier/legacy_storage/compat_test.go @@ -0,0 +1,59 @@ +package legacy_storage + +import ( + "fmt" + "reflect" + "testing" + + "github.com/grafana/alerting/definition" + "github.com/grafana/alerting/notify" + "github.com/grafana/alerting/notify/notifytest" + "github.com/grafana/alerting/receivers/schema" + "github.com/grafana/alerting/receivers/teams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostableMimirReceiverToIntegrations(t *testing.T) { + t.Run("can convert all known types", func(t *testing.T) { + notifytest.ForEachIntegrationTypeReceiver(t, func(configType reflect.Type, receiver notify.ConfigReceiver, rawConfig string) { + expectedType, err := notify.IntegrationTypeFromMimirTypeReflect(configType) + assert.NoError(t, err) + expectedVersion := schema.V0mimir1 + if configType.Name() == "MSTeamsConfig" { + expectedType = teams.Type + } + if configType.Name() == "MSTeamsV2Config" { + expectedType = teams.Type + expectedVersion = schema.V0mimir2 + } + t.Run(fmt.Sprintf("%s as %s %s", configType.Name(), expectedType, expectedVersion), func(t *testing.T) { + integrations, err := PostableMimirReceiverToIntegrations(receiver) + require.NoError(t, err) + require.Len(t, integrations, 1) + integration := integrations[0] + rawSettings, err := definition.MarshalJSONWithSecrets(integration.Settings) + require.NoError(t, err) + + assert.EqualValues(t, expectedVersion, integration.Config.Version) + assert.EqualValues(t, expectedType, integration.Config.Type()) + assert.JSONEq(t, rawConfig, string(rawSettings)) + assert.Empty(t, integration.SecureSettings) + }) + }) + }) + + t.Run("can convert receiver with all integrations", func(t *testing.T) { + recv, err := notifytest.GetMimirReceiverWithAllIntegrations() + require.NoError(t, err) + integrations, err := PostableMimirReceiverToIntegrations(recv) + require.NoError(t, err) + require.Len(t, integrations, len(notifytest.AllValidMimirConfigs)) + }) + + t.Run("returns empty if receiver has no integrations", func(t *testing.T) { + integrations, err := PostableMimirReceiverToIntegrations(notify.ConfigReceiver{Name: "test"}) + require.NoError(t, err) + require.Empty(t, integrations) + }) +} diff --git a/pkg/services/ngalert/notifier/legacy_storage/imported.go b/pkg/services/ngalert/notifier/legacy_storage/imported.go new file mode 100644 index 00000000000..9657e93daa6 --- /dev/null +++ b/pkg/services/ngalert/notifier/legacy_storage/imported.go @@ -0,0 +1,87 @@ +package legacy_storage + +import ( + "fmt" + "slices" + + "github.com/grafana/alerting/definition" + + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +type ImportedConfigRevision struct { + rev *ConfigRevision + opts definition.MergeOpts + importedConfig *definition.PostableApiAlertingConfig +} + +func (rev *ConfigRevision) Imported() (ImportedConfigRevision, error) { + result := ImportedConfigRevision{ + rev: rev, + } + if len(rev.Config.ExtraConfigs) == 0 { + return result, nil + } + // support only one config for now + mimirCfg := rev.Config.ExtraConfigs[0] + opts := definition.MergeOpts{ + DedupSuffix: mimirCfg.Identifier, + SubtreeMatchers: mimirCfg.MergeMatchers, + } + if err := opts.Validate(); err != nil { + return result, fmt.Errorf("invalid merge options: %w", err) + } + + mcfg, err := mimirCfg.GetAlertmanagerConfig() + if err != nil { + return result, fmt.Errorf("failed to get mimir alertmanager config: %w", err) + } + result.importedConfig = &mcfg + result.opts = opts + return result, nil +} + +func (e ImportedConfigRevision) GetReceivers(uids []string) ([]*models.Receiver, error) { + if e.importedConfig == nil { + return nil, nil + } + original := e.rev.Config.AlertmanagerConfig.GetReceivers() + merged, _ := definition.MergeReceivers(original, e.importedConfig.GetReceivers(), e.opts.DedupSuffix) + + capacity := len(uids) + if capacity == 0 { + capacity = len(e.importedConfig.Receivers) + } + result := make([]*models.Receiver, 0, capacity) + // merged config contains all receivers from both. We only want the ones from the staged config. However, we need to rename them if necessary. + for _, r := range merged[len(original):] { + uid := NameToUid(r.Name) + if len(uids) > 0 && !slices.Contains(uids, uid) { + continue + } + recv, err := PostableApiReceiverToReceiver(r, models.ProvenanceConvertedPrometheus, models.ResourceOriginImported) + if err != nil { + return nil, fmt.Errorf("failed to convert receiver %q: %w", r.Name, err) + } + result = append(result, recv) + } + return result, nil +} + +// ReceiverUseByName returns a map of receiver names to the number of times they are used in routes. +func (e ImportedConfigRevision) ReceiverUseByName() map[string]int { + if e.importedConfig == nil { + return nil + } + m := make(map[string]int) + receiverUseCounts([]*definitions.Route{e.importedConfig.Route}, m) + _, renames := definition.MergeReceivers(e.rev.Config.AlertmanagerConfig.GetReceivers(), e.importedConfig.GetReceivers(), e.opts.DedupSuffix) + for original, renamed := range renames { + if cnt, ok := m[original]; ok { + delete(m, original) + m[renamed] = cnt + } + } + return m +} diff --git a/pkg/services/ngalert/notifier/legacy_storage/imported_test.go b/pkg/services/ngalert/notifier/legacy_storage/imported_test.go new file mode 100644 index 00000000000..8a8ff95476b --- /dev/null +++ b/pkg/services/ngalert/notifier/legacy_storage/imported_test.go @@ -0,0 +1,223 @@ +package legacy_storage + +import ( + "testing" + + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/pkg/labels" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +func TestConfigRevisionImported(t *testing.T) { + t.Run("should return error if extra config is invalid", func(t *testing.T) { + rev := getConfigRevisionForTest(withExtraConfig(extraConfig("invalid"))) + _, err := rev.Imported() + require.Error(t, err) + }) + + t.Run("should return imported config if extra config is valid even if it cannot be fully merged", func(t *testing.T) { + rev := getConfigRevisionForTest(withExtraConfigAndConflictingMatchers()) + imported, err := rev.Imported() + require.NoError(t, err) + require.NotNil(t, imported) + }) + t.Run("should return imported config if extra config is valid", func(t *testing.T) { + rev := getConfigRevisionForTest(withExtraConfig(extraConfig(extraConfigurationYaml))) + + imported, err := rev.Imported() + require.NoError(t, err) + require.NotNil(t, imported) + }) +} + +func TestConfigRevisionImported_GetReceivers(t *testing.T) { + rev := getConfigRevisionForTest(withExtraConfig(extraConfig(extraConfigurationYaml))) + imported, err := rev.Imported() + require.NoError(t, err) + + t.Run("should return all receivers available in imported config", func(t *testing.T) { + receivers, err := imported.GetReceivers(nil) + require.NoError(t, err) + require.Len(t, receivers, 2) + assert.Equal(t, "imported-receiver-1", receivers[0].Name) + assert.Equal(t, NameToUid("imported-receiver-1"), receivers[0].UID) + assert.Equal(t, models.ProvenanceConvertedPrometheus, receivers[0].Provenance) + assert.Equal(t, models.ResourceOriginImported, receivers[0].Origin) + assert.Len(t, receivers[0].Integrations, 1) + + assert.Equal(t, "imported-receiver-2", receivers[1].Name) + assert.Equal(t, NameToUid("imported-receiver-2"), receivers[1].UID) + assert.Equal(t, models.ProvenanceConvertedPrometheus, receivers[1].Provenance) + assert.Equal(t, models.ResourceOriginImported, receivers[1].Origin) + assert.Len(t, receivers[1].Integrations, 1) + }) + t.Run("should filter receivers in imported config", func(t *testing.T) { + receivers, err := imported.GetReceivers([]string{models.NameToUid("imported-receiver-2")}) + require.NoError(t, err) + require.Len(t, receivers, 1) + assert.Equal(t, "imported-receiver-2", receivers[0].Name) + assert.Equal(t, NameToUid("imported-receiver-2"), receivers[0].UID) + + receivers, err = imported.GetReceivers([]string{models.NameToUid("not-found")}) + require.NoError(t, err) + assert.Empty(t, receivers) + }) + + t.Run("should be correctly named in case of renames", func(t *testing.T) { + const dupeConfig = ` +route: + receiver: receiver1 +receivers: + - name: receiver1 + - name: dupe-receiver +` + extra := extraConfig(dupeConfig) + expectedDedupSuffix := extra.Identifier + rev := getConfigRevisionForTest(withExtraConfig(extra)) + imported, err = rev.Imported() + require.NoError(t, err) + + result, err := imported.GetReceivers(nil) + require.NoError(t, err) + require.Len(t, result, 2) + assert.Equal(t, "receiver1"+expectedDedupSuffix, result[0].Name) + assert.Equal(t, models.NameToUid("receiver1"+expectedDedupSuffix), result[0].UID) + assert.Equal(t, "dupe-receiver"+expectedDedupSuffix, result[1].Name) + assert.Equal(t, models.NameToUid("dupe-receiver"+expectedDedupSuffix), result[1].UID) + + t.Run("should search by renamed uid", func(t *testing.T) { + result, err = imported.GetReceivers([]string{NameToUid("receiver1")}) + require.NoError(t, err) + require.Empty(t, result) + + result, err = imported.GetReceivers([]string{NameToUid("receiver1" + expectedDedupSuffix)}) + require.NoError(t, err) + require.Len(t, result, 1) + }) + }) + + t.Run("should return empty list if no imported configuration", func(t *testing.T) { + rev := getConfigRevisionForTest() + imported, err = rev.Imported() + require.NoError(t, err) + assert.Nil(t, imported.importedConfig) + + result, err := imported.GetReceivers(nil) + require.NoError(t, err) + require.Empty(t, result) + }) +} + +func TestConfigRevisionImported_ReceiverUseByName(t *testing.T) { + t.Run("should be empty if no configuration", func(t *testing.T) { + rev := getConfigRevisionForTest() + imported, err := rev.Imported() + require.NoError(t, err) + require.Empty(t, imported.ReceiverUseByName()) + }) + t.Run("should count usages of the receivers", func(t *testing.T) { + const cfg = ` +route: + receiver: r1 + routes: + - receiver: r2 + routes: + - receiver: r1 + - routes: + - receiver: r2 +receivers: +- name: r1 +- name: r2 +` + extra := extraConfig(cfg) + rev := getConfigRevisionForTest(withExtraConfig(extra)) + + imported, err := rev.Imported() + require.NoError(t, err) + actual := imported.ReceiverUseByName() + require.EqualValues(t, map[string]int{ + "": 1, + "r1": 2, + "r2": 2, + }, actual) + }) + + t.Run("should correctly handle deduplicated names", func(t *testing.T) { + const dupeConfig = ` +route: + receiver: receiver1 + routes: + - receiver: dupe-receiver + - receiver: r1 +receivers: + - name: receiver1 + - name: dupe-receiver + - name: r1 +` + extra := extraConfig(dupeConfig) + expectedDedupSuffix := extra.Identifier + rev := getConfigRevisionForTest(withExtraConfig(extra)) + + imported, err := rev.Imported() + require.NoError(t, err) + actual := imported.ReceiverUseByName() + require.EqualValues(t, map[string]int{ + "receiver1" + expectedDedupSuffix: 1, + "dupe-receiver" + expectedDedupSuffix: 1, + "r1": 1, + }, actual) + }) +} + +func extraConfig(yamlString string) definitions.ExtraConfiguration { + return definitions.ExtraConfiguration{ + Identifier: "test", + MergeMatchers: config.Matchers{ + &labels.Matcher{Type: labels.MatchEqual, Name: "__imported", Value: "test"}, + }, + AlertmanagerConfig: yamlString, + } +} + +func withExtraConfig(extra definitions.ExtraConfiguration) opt { + return func(rev *ConfigRevision) { + rev.Config.ExtraConfigs = []definitions.ExtraConfiguration{ + extra, + } + } +} + +func withExtraConfigAndConflictingMatchers() opt { + return func(rev *ConfigRevision) { + matcher := &labels.Matcher{Type: labels.MatchEqual, Name: "__imported", Value: "test"} + rev.Config.AlertmanagerConfig.Route.Routes = append(rev.Config.AlertmanagerConfig.Route.Routes, &definitions.Route{ + Matchers: []*labels.Matcher{matcher}, + }) + rev.Config.ExtraConfigs = []definitions.ExtraConfiguration{ + { + Identifier: "test", + MergeMatchers: config.Matchers{ + matcher, + }, + TemplateFiles: nil, + AlertmanagerConfig: extraConfigurationYaml, + }, + } + } +} + +const extraConfigurationYaml = ` +route: + receiver: imported-receiver-1 +receivers: + - name: imported-receiver-1 + webhook_configs: + - url: "http://localhost/" + - name: imported-receiver-2 + webhook_configs: + - url: "http://localhost/" +` diff --git a/pkg/services/ngalert/notifier/legacy_storage/receivers_test.go b/pkg/services/ngalert/notifier/legacy_storage/receivers_test.go index 528f5c6d518..bf362ec0eac 100644 --- a/pkg/services/ngalert/notifier/legacy_storage/receivers_test.go +++ b/pkg/services/ngalert/notifier/legacy_storage/receivers_test.go @@ -469,8 +469,10 @@ func TestRenameReceiverInRoutes(t *testing.T) { }) } -func getConfigRevisionForTest() *ConfigRevision { - return &ConfigRevision{ +type opt func(*ConfigRevision) + +func getConfigRevisionForTest(opts ...opt) *ConfigRevision { + r := &ConfigRevision{ Config: &definitions.PostableUserConfig{ AlertmanagerConfig: definitions.PostableApiAlertingConfig{ Config: definitions.Config{ @@ -519,4 +521,8 @@ func getConfigRevisionForTest() *ConfigRevision { }, }, } + for _, o := range opts { + o(r) + } + return r } diff --git a/pkg/services/ngalert/notifier/receiver_svc.go b/pkg/services/ngalert/notifier/receiver_svc.go index d25f735fcd7..56a68fb3e55 100644 --- a/pkg/services/ngalert/notifier/receiver_svc.go +++ b/pkg/services/ngalert/notifier/receiver_svc.go @@ -55,6 +55,7 @@ type ReceiverService struct { provenanceValidator validation.ProvenanceStatusTransitionValidator resourcePermissions ac.ReceiverPermissionsService tracer tracing.Tracer + includeImported bool } type alertRuleNotificationSettingsStore interface { @@ -108,6 +109,7 @@ func NewReceiverService( log log.Logger, resourcePermissions ac.ReceiverPermissionsService, tracer tracing.Tracer, + includeStaged bool, ) *ReceiverService { return &ReceiverService{ authz: authz, @@ -120,6 +122,7 @@ func NewReceiverService( provenanceValidator: validation.ValidateProvenanceRelaxed, resourcePermissions: resourcePermissions, tracer: tracer, + includeImported: includeStaged, } } @@ -149,7 +152,15 @@ func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiver rcv, err := revision.GetReceiver(legacy_storage.NameToUid(q.Name), prov) if err != nil { - return nil, err + if errors.Is(err, legacy_storage.ErrReceiverNotFound) && rs.includeImported { + imported := rs.getImportedReceivers(ctx, span, []string{legacy_storage.NameToUid(q.Name)}, revision) + if len(imported) > 0 { + rcv = imported[0] + } + } + if rcv == nil { + return nil, err + } } span.AddEvent("Loaded receiver", trace.WithAttributes( @@ -216,6 +227,11 @@ func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceive attribute.Int("count", len(receivers)), )) + if rs.includeImported { + imported := rs.getImportedReceivers(ctx, span, uids, revision) + receivers = append(receivers, imported...) + } + filterFn := rs.authz.FilterReadDecrypted if !q.Decrypt { filterFn = rs.authz.FilterRead @@ -270,14 +286,17 @@ func (rs *ReceiverService) DeleteReceiver(ctx context.Context, uid string, calle existing, err := revision.GetReceiver(uid, prov) if err != nil { - if errors.Is(err, legacy_storage.ErrReceiverNotFound) { - return nil + if !errors.Is(err, legacy_storage.ErrReceiverNotFound) { + return err } - return err - } - - if existing.Origin != models.ResourceOriginGrafana { - return makeErrReceiverOrigin(existing, "delete") + if rs.includeImported { + // try to get the imported receiver and return a specific error if it exists + result := rs.getImportedReceivers(ctx, span, []string{uid}, revision) + if len(result) > 0 { + return makeErrReceiverOrigin(result[0], "delete") + } + } + return nil } logger := rs.log.FromContext(ctx).New("receiver", existing.Name, "uid", uid, "version", version, "integrations", existing.GetIntegrationTypes()) @@ -395,6 +414,10 @@ func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receive attribute.StringSlice("integrations", r.GetIntegrationTypes()), )) defer span.End() + // check origin of the provided receiver + if r.Origin != models.ResourceOriginGrafana { + return nil, makeErrReceiverOrigin(r, "update") + } if err := rs.authz.AuthorizeUpdate(ctx, user, r); err != nil { return nil, err @@ -415,6 +438,13 @@ func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receive existing, err := revision.GetReceiver(r.GetUID(), prov) if err != nil { + if errors.Is(err, legacy_storage.ErrReceiverNotFound) && rs.includeImported { + // try to get the imported receiver and return a specific error if it exists + result := rs.getImportedReceivers(ctx, span, []string{r.GetUID()}, revision) + if len(result) > 0 { + return nil, makeErrReceiverOrigin(result[0], "update") + } + } return nil, err } @@ -440,10 +470,6 @@ func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receive return nil, err } - if existing.Origin != models.ResourceOriginGrafana { - return nil, makeErrReceiverOrigin(existing, "update") - } - if err := rs.provenanceValidator(existing.Provenance, r.Provenance); err != nil { return nil, err } @@ -519,7 +545,21 @@ func (rs *ReceiverService) UsedByRules(ctx context.Context, orgID int64, name st // AccessControlMetadata returns access control metadata for the given Receivers. func (rs *ReceiverService) AccessControlMetadata(ctx context.Context, user identity.Requester, receivers ...*models.Receiver) (map[string]models.ReceiverPermissionSet, error) { - return rs.authz.Access(ctx, user, receivers...) + permissions, err := rs.authz.Access(ctx, user, receivers...) + if err != nil { + return nil, err + } + for _, m := range receivers { + if m.Origin == models.ResourceOriginGrafana { + continue + } + perms := permissions[m.GetUID()] + perms.Set(models.ReceiverPermissionAdmin, false) + perms.Set(models.ReceiverPermissionWrite, false) + perms.Set(models.ReceiverPermissionDelete, false) + permissions[m.GetUID()] = perms + } + return permissions, nil } // InUseMetadata returns metadata for the given Receivers about their usage in routes and rules. @@ -528,31 +568,64 @@ func (rs *ReceiverService) InUseMetadata(ctx context.Context, orgID int64, recei if err != nil { return nil, err } - receiverUses := revision.ReceiverUseByName() - q := models.ListNotificationSettingsQuery{OrgID: orgID} - if len(receivers) == 1 { - q.ReceiverName = receivers[0].Name - } - keys, err := rs.ruleNotificationsStore.ListNotificationSettings(ctx, q) - if err != nil { - return nil, err + var hasGrafanaOrigin, hasImportedOrigin bool + for i := range receivers { + switch receivers[i].Origin { + case models.ResourceOriginGrafana: + hasGrafanaOrigin = true + case models.ResourceOriginImported: + hasImportedOrigin = true + } + if hasGrafanaOrigin && hasImportedOrigin { + break + } } - byReceiver := map[string][]models.AlertRuleKey{} - for key, settings := range keys { - for _, s := range settings { - if s.Receiver != "" { - byReceiver[s.Receiver] = append(byReceiver[s.Receiver], key) + var receiverUsesInRoutes map[string]int + var importedUsesInRoutes map[string]int + receiverUsesInRules := map[string][]models.AlertRuleKey{} + if hasGrafanaOrigin { + receiverUsesInRoutes = revision.ReceiverUseByName() + q := models.ListNotificationSettingsQuery{OrgID: orgID} + if len(receivers) == 1 { + q.ReceiverName = receivers[0].Name + } + keys, err := rs.ruleNotificationsStore.ListNotificationSettings(ctx, q) + if err != nil { + return nil, err + } + + for key, settings := range keys { + for _, s := range settings { + if s.Receiver != "" { + receiverUsesInRules[s.Receiver] = append(receiverUsesInRules[s.Receiver], key) + } } } } + if hasImportedOrigin { + s, err := revision.Imported() + if err == nil { + importedUsesInRoutes = s.ReceiverUseByName() + } else { + rs.log.FromContext(ctx).Warn("Unable to include imported receivers. Skipping", "err", err) + } + } results := make(map[string]models.ReceiverMetadata, len(receivers)) for _, rcv := range receivers { + if rcv.Origin == models.ResourceOriginImported { + results[rcv.GetUID()] = models.ReceiverMetadata{ + InUseByRoutes: importedUsesInRoutes[rcv.Name], + InUseByRules: nil, + CanUse: false, + } + continue + } results[rcv.GetUID()] = models.ReceiverMetadata{ - InUseByRoutes: receiverUses[rcv.Name], - InUseByRules: byReceiver[rcv.Name], + InUseByRoutes: receiverUsesInRoutes[rcv.Name], + InUseByRules: receiverUsesInRules[rcv.Name], CanUse: rcv.Origin == models.ResourceOriginGrafana, // Only receivers from the Grafana configuration can be used. } } @@ -741,3 +814,23 @@ func (rs *ReceiverService) RenameReceiverInDependentResources(ctx context.Contex } return nil } + +func (rs *ReceiverService) getImportedReceivers(ctx context.Context, span trace.Span, uids []string, revision *legacy_storage.ConfigRevision) []*models.Receiver { + var result []*models.Receiver + imported, err := revision.Imported() + if err == nil { + result, err = imported.GetReceivers(uids) + } + if err != nil { + rs.log.FromContext(ctx).Warn("Unable to include imported receivers. Skipping", "err", err) + span.RecordError(err, trace.WithAttributes( + attribute.String("concurrency_token", revision.ConcurrencyToken), + )) + } else if len(result) > 0 { // if the list is empty, then we do not have any imported configuration + span.AddEvent("Loaded importedReceivers receivers", trace.WithAttributes( + attribute.String("concurrency_token", revision.ConcurrencyToken), + attribute.Int("count", len(result)), + )) + } + return result +} diff --git a/pkg/services/ngalert/notifier/receiver_svc_test.go b/pkg/services/ngalert/notifier/receiver_svc_test.go index 221f3e805d0..8d4b7630436 100644 --- a/pkg/services/ngalert/notifier/receiver_svc_test.go +++ b/pkg/services/ngalert/notifier/receiver_svc_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/alerting/receivers/line" "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/pkg/labels" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -49,20 +50,43 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("service gets receiver from AM config", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService) - - Receiver, err := sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser) + recv, err := sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser) require.NoError(t, err) - require.Equal(t, "slack receiver", Receiver.Name) - require.Len(t, Receiver.Integrations, 1) - require.Equal(t, "UID2", Receiver.Integrations[0].UID) + require.Equal(t, "slack receiver", recv.Name) + require.Len(t, recv.Integrations, 1) + require.Equal(t, "UID2", recv.Integrations[0].UID) }) t.Run("service returns error when receiver does not exist", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService) - _, err := sut.GetReceiver(context.Background(), singleQ(1, "nonexistent"), redactedUser) + _, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) }) + + t.Run("when includeImported is true", func(t *testing.T) { + t.Run("gets imported receivers", func(t *testing.T) { + sut := createReceiverServiceSut(t, secretsService, withImportedIncluded) + + recv, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) + require.NoError(t, err) + assert.Equal(t, models.ResourceOriginImported, recv.Origin) + assert.Equal(t, "receiver1", recv.Name) + assert.Equal(t, models.ProvenanceConvertedPrometheus, recv.Provenance) + + require.Len(t, recv.Integrations, 2) + integration := recv.Integrations[0] + assert.Equal(t, "", integration.UID) + }) + + t.Run("falls to only Grafana if cannot read imported receivers", func(t *testing.T) { + sut := createReceiverServiceSut(t, secretsService, withImportedIncluded, withInvalidExtraConfig) + _, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) + require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) + _, err = sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser) + require.NoError(t, err) + }) + }) } func TestIntegrationReceiverService_GetReceivers(t *testing.T) { @@ -95,6 +119,29 @@ func TestIntegrationReceiverService_GetReceivers(t *testing.T) { require.Len(t, Receivers, 1) require.Equal(t, "slack receiver", Receivers[0].Name) }) + + t.Run("when includeImported is true", func(t *testing.T) { + t.Run("returns imported receivers in the list", func(t *testing.T) { + sut := createReceiverServiceSut(t, secretsService, withImportedIncluded) + + recvs, err := sut.GetReceivers(context.Background(), multiQ(1), redactedUser) + require.NoError(t, err) + require.Len(t, recvs, 5) + names := make([]string, 0, len(recvs)) + for _, recv := range recvs { + names = append(names, recv.Name) + } + expectedNames := []string{"grafana-default-email", "slack receiver", "receiver1", "empty receiver", "email-receiver"} + assert.ElementsMatch(t, expectedNames, names) + }) + + t.Run("falls to only Grafana if cannot read imported receivers", func(t *testing.T) { + sut := createReceiverServiceSut(t, secretsService, withImportedIncluded, withInvalidExtraConfig) + recvs, err := sut.GetReceivers(context.Background(), multiQ(1), redactedUser) + require.NoError(t, err) + require.Len(t, recvs, 2) + }) + }) } func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { @@ -153,51 +200,75 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { err: "", }, } { - for _, method := range getMethods { - t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) { - sut := createReceiverServiceSut(t, secretsService) + origin := []struct { + origin models.ResourceOrigin + receiver string + opts []createReceiverServiceSutOpt + secureSettingKey string + decryptedSettingValue string + }{ + { + origin: models.ResourceOriginImported, + secureSettingKey: "auth_password", + decryptedSettingValue: "another-secret-password", + receiver: "email-receiver", + opts: []createReceiverServiceSutOpt{ + withImportedIncluded, + }, + }, + { + origin: models.ResourceOriginGrafana, + secureSettingKey: "url", + decryptedSettingValue: "secure url", + receiver: "slack receiver", + }, + } - var res *models.Receiver - var err error - if method == "single" { - q := singleQ(1, "slack receiver") - q.Decrypt = tc.decrypt - res, err = sut.GetReceiver(context.Background(), q, tc.user) - } else { - q := multiQ(1, "slack receiver") - q.Decrypt = tc.decrypt - var multiRes []*models.Receiver - multiRes, err = sut.GetReceivers(context.Background(), q, tc.user) - if tc.err == "" { - require.Len(t, multiRes, 1) - res = multiRes[0] - } - } - if tc.err == "" { - require.NoError(t, err) - } else { - require.ErrorContains(t, err, tc.err) - } + for _, o := range origin { + for _, method := range getMethods { + t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) { + t.Run(fmt.Sprintf("%s %s (%s)", tc.name, method, o.origin), func(t *testing.T) { + sut := createReceiverServiceSut(t, secretsService, o.opts...) - if tc.err == "" { - require.Equal(t, "slack receiver", res.Name) - require.Len(t, res.Integrations, 1) - require.Equal(t, "UID2", res.Integrations[0].UID) + var res *models.Receiver + var err error + if method == "single" { + q := singleQ(1, o.receiver) + q.Decrypt = tc.decrypt + res, err = sut.GetReceiver(context.Background(), q, tc.user) + } else { + q := multiQ(1, o.receiver) + q.Decrypt = tc.decrypt + var multiRes []*models.Receiver + multiRes, err = sut.GetReceivers(context.Background(), q, tc.user) + if tc.err == "" { + require.Len(t, multiRes, 1) + res = multiRes[0] + } + } + if tc.err != "" { + require.ErrorContains(t, err, tc.err) + return + } - require.NoError(t, err) - if tc.decrypt { - require.Equal(t, "secure url", res.Integrations[0].Settings["url"]) - require.NotContains(t, res.Integrations[0].SecureSettings, "url") - } else { - require.NotContains(t, res.Integrations[0].Settings, "url") + require.Equal(t, o.receiver, res.Name) + require.Len(t, res.Integrations, 1) + require.NoError(t, err) - // Ensure the encrypted value exists and is not redacted or decrypted. - require.NotEmpty(t, res.Integrations[0].SecureSettings["url"]) - require.NotEqual(t, definitions.RedactedValue, res.Integrations[0].SecureSettings["url"]) - require.NotEqual(t, "secure url", res.Integrations[0].SecureSettings["url"]) - } - } - }) + if tc.decrypt { + require.Equal(t, o.decryptedSettingValue, res.Integrations[0].Settings[o.secureSettingKey]) + require.NotContains(t, res.Integrations[0].SecureSettings, o.secureSettingKey) + } else { + require.NotContains(t, res.Integrations[0].Settings, o.secureSettingKey) + + // Ensure the encrypted value exists and is not redacted or decrypted. + require.NotEmpty(t, res.Integrations[0].SecureSettings[o.secureSettingKey]) + require.NotEqual(t, definitions.RedactedValue, res.Integrations[0].SecureSettings[o.secureSettingKey]) + require.NotEqual(t, o.decryptedSettingValue, res.Integrations[0].SecureSettings[o.secureSettingKey]) + } + }) + }) + } } } } @@ -225,6 +296,7 @@ func TestReceiverService_Delete(t *testing.T) { storeSettings map[models.AlertRuleKey][]models.NotificationSettings existing *models.Receiver expectedErr error + opts []createReceiverServiceSutOpt }{ { name: "service deletes receiver", @@ -293,13 +365,32 @@ func TestReceiverService_Delete(t *testing.T) { version: "wrong version", expectedErr: ErrReceiverVersionConflict, }, + { + name: "delete of receiver with non-Grafana origin fails", + user: writer, + deleteUID: legacy_storage.NameToUid("empty receiver"), + expectedErr: ErrReceiverOrigin, + opts: []createReceiverServiceSutOpt{ + withImportedIncluded, + }, + }, + { + name: "delete of receiver succeeds even with invalid imported config", + user: writer, + deleteUID: baseReceiver.UID, + existing: util.Pointer(baseReceiver.Clone()), + opts: []createReceiverServiceSutOpt{ + withInvalidExtraConfig, + withImportedIncluded, + }, + }, } { t.Run(tc.name, func(t *testing.T) { store := &fakeAlertRuleNotificationStore{} store.ListNotificationSettingsFn = func(ctx context.Context, q models.ListNotificationSettingsQuery) (map[models.AlertRuleKey][]models.NotificationSettings, error) { return tc.storeSettings, nil } - sut := createReceiverServiceSut(t, &secretsService) + sut := createReceiverServiceSut(t, &secretsService, tc.opts...) sut.ruleNotificationsStore = store if tc.existing != nil { @@ -364,6 +455,7 @@ func TestReceiverService_Create(t *testing.T) { expectedStored *definitions.PostableApiReceiver expectedErr error expectedProvenances map[string]models.Provenance + opts []createReceiverServiceSutOpt }{ { name: "service creates receiver", @@ -482,9 +574,20 @@ func TestReceiverService_Create(t *testing.T) { receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithName("")), expectedErr: legacy_storage.ErrReceiverInvalid, }, + { + name: "should be able to create receiver with the same name as imported ones", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithName("receiver1")), + expectedCreate: models.CopyReceiverWith(baseReceiver, + models.ReceiverMuts.Encrypted(models.Base64Enrypt), + models.ReceiverMuts.WithName("receiver1"), + ), + expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceNone}, + opts: []createReceiverServiceSutOpt{withImportedIncluded}, + }, } { t.Run(tc.name, func(t *testing.T) { - sut := createReceiverServiceSut(t, &secretsService) + sut := createReceiverServiceSut(t, &secretsService, tc.opts...) created, err := sut.CreateReceiver(context.Background(), &tc.receiver, tc.user.GetOrgID(), tc.user) if tc.expectedErr == nil { @@ -586,6 +689,7 @@ func TestReceiverService_Update(t *testing.T) { expectedUpdate models.Receiver expectedProvenances map[string]models.Provenance expectedErr error + opts []createReceiverServiceSutOpt }{ { name: "copies existing secure fields", @@ -741,9 +845,31 @@ func TestReceiverService_Update(t *testing.T) { existing: util.Pointer(baseReceiver.Clone()), expectedErr: legacy_storage.ErrReceiverInvalid, }, + { + name: "receivers with non-Grafana origin are not accepted", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, rm.WithOrigin(models.ResourceOriginImported)), + existing: util.Pointer(baseReceiver.Clone()), + expectedErr: ErrReceiverOrigin, + }, + { + name: "receivers of non-Grafana origin cannot be updated", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, rm.WithName("receiver1")), + expectedErr: ErrReceiverOrigin, + opts: []createReceiverServiceSutOpt{withImportedIncluded}, + }, + { + name: "update should not fail if imported cannot be included", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, rm.WithEmptyIntegrations()), + existing: util.Pointer(models.CopyReceiverWith(baseReceiver)), + expectedUpdate: models.CopyReceiverWith(baseReceiver, rm.WithEmptyIntegrations()), + opts: []createReceiverServiceSutOpt{withImportedIncluded, withInvalidExtraConfig}, + }, } { t.Run(tc.name, func(t *testing.T) { - sut := createReceiverServiceSut(t, &secretsService) + sut := createReceiverServiceSut(t, &secretsService, tc.opts...) if tc.existing != nil { // Create route after receivers as they will be referenced. @@ -813,6 +939,9 @@ func TestReceiverService_Update(t *testing.T) { provenances, err := sut.provisioningStore.GetProvenances(context.Background(), tc.user.GetOrgID(), (&definitions.EmbeddedContactPoint{}).ResourceType()) require.NoError(t, err) + if tc.expectedProvenances == nil { + tc.expectedProvenances = make(map[string]models.Provenance) + } assert.Equal(t, tc.expectedProvenances, provenances) }) } @@ -917,6 +1046,30 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) { _, err := sut.UpdateReceiver(context.Background(), &baseReceiver, nil, writer.GetOrgID(), writer) require.ErrorIs(t, err, legacy_storage.ErrReceiverInvalid) }) + + t.Run("can rename receiver to name that is already used by another receiver of different origin", func(t *testing.T) { + ruleStore := &fakeAlertRuleNotificationStore{} + sut := createReceiverServiceSut(t, &secretsService, withImportedIncluded) + sut.ruleNotificationsStore = ruleStore + + newReceiverName = "receiver1" + actual, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer) + require.NoError(t, err) + require.Equal(t, models.ResourceOriginImported, actual.Origin) + require.Equal(t, newReceiverName, actual.Name) + require.NotEmpty(t, actual.Integrations) + + baseReceiver.Name = newReceiverName + + recv, err := sut.UpdateReceiver(context.Background(), &baseReceiver, nil, writer.GetOrgID(), writer) + require.NoError(t, err) + require.NotEqual(t, actual, recv) + require.Equal(t, models.ResourceOriginGrafana, recv.Origin) + + actual, err = sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer) + require.NoError(t, err) + require.Equal(t, recv.Name, actual.Name) + }) } func TestReceiverServiceAC_Read(t *testing.T) { @@ -1557,13 +1710,61 @@ func TestReceiverService_InUseMetadata(t *testing.T) { } } -func createReceiverServiceSut(t *testing.T, encryptSvc secretService) *ReceiverService { - cfg := createEncryptedConfig(t, encryptSvc) +func TestReceiverService_AccessControlMetadata(t *testing.T) { + secretsService := fake_secrets.NewFakeSecretsService() + sut := createReceiverServiceSut(t, &secretsService, withImportedIncluded) + + admin := &user.SignedInUser{OrgID: 1, OrgRole: org.RoleAdmin, Permissions: map[int64]map[string][]string{ + 1: { + accesscontrol.ActionAlertingNotificationsWrite: nil, + accesscontrol.ActionAlertingNotificationsRead: nil, + accesscontrol.ActionAlertingReceiversReadSecrets: []string{ + models.ScopeReceiversProvider.GetResourceAllScope(), + }, + }, + }} + + r, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: 1, Name: "receiver1"}, admin) + require.NoError(t, err) + + t.Run("should override metadata for imported receivers", func(t *testing.T) { + meta, err := sut.AccessControlMetadata(context.Background(), admin, r) + require.NoError(t, err) + expectedPermissions := models.NewReceiverPermissionSet() + expectedPermissions.Set(models.ReceiverPermissionAdmin, false) + expectedPermissions.Set(models.ReceiverPermissionWrite, false) + expectedPermissions.Set(models.ReceiverPermissionDelete, false) + expectedPermissions.Set(models.ReceiverPermissionReadSecret, true) + + expected := map[string]models.ReceiverPermissionSet{ + r.GetUID(): expectedPermissions, + } + assert.Equal(t, expected, meta) + }) +} + +type createReceiverServiceSutOpt func(*testing.T, *ReceiverService) + +func withInvalidExtraConfig(t *testing.T, sut *ReceiverService) { + t.Helper() + extra := getExtraConfig() + extra.AlertmanagerConfig = "yaml:invalid" + cfg := createEncryptedConfig(t, sut.encryptionService, extra) + store := fakes.NewFakeAlertmanagerConfigStore(cfg) + sut.cfgStore = legacy_storage.NewAlertmanagerConfigStore(store, NewExtraConfigsCrypto(sut.encryptionService)) +} + +func withImportedIncluded(_ *testing.T, sut *ReceiverService) { + sut.includeImported = true +} + +func createReceiverServiceSut(t *testing.T, encryptSvc secretService, opts ...createReceiverServiceSutOpt) *ReceiverService { + cfg := createEncryptedConfig(t, encryptSvc, getExtraConfig()) store := fakes.NewFakeAlertmanagerConfigStore(cfg) xact := newNopTransactionManager() provisioningStore := fakes.NewFakeProvisioningStore() - return NewReceiverService( + sut := NewReceiverService( ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), false), legacy_storage.NewAlertmanagerConfigStore(store, NewExtraConfigsCrypto(encryptSvc)), provisioningStore, @@ -1573,10 +1774,15 @@ func createReceiverServiceSut(t *testing.T, encryptSvc secretService) *ReceiverS log.NewNopLogger(), fakes.NewFakeReceiverPermissionsService(), tracing.InitializeTracerForTest(), + false, ) + for _, opt := range opts { + opt(t, sut) + } + return sut } -func createEncryptedConfig(t *testing.T, secretService secretService) string { +func createEncryptedConfig(t *testing.T, secretService secretService, extraConfig *definitions.ExtraConfiguration) string { c := &definitions.PostableUserConfig{} err := json.Unmarshal([]byte(defaultAlertmanagerConfigJSON), c) require.NoError(t, err) @@ -1584,6 +1790,10 @@ func createEncryptedConfig(t *testing.T, secretService secretService) string { return secretService.Encrypt(ctx, payload, secrets.WithoutScope()) }) require.NoError(t, err) + if extraConfig != nil { + c.ExtraConfigs = append(c.ExtraConfigs, *extraConfig) + require.NoError(t, NewExtraConfigsCrypto(secretService).EncryptExtraConfigs(context.Background(), c)) + } bytes, err := json.Marshal(c) require.NoError(t, err) return string(bytes) @@ -1645,6 +1855,40 @@ const defaultAlertmanagerConfigJSON = ` } ` +func getExtraConfig() *definitions.ExtraConfiguration { + return &definitions.ExtraConfiguration{ + Identifier: "import", + MergeMatchers: []*labels.Matcher{{Type: labels.MatchEqual, Name: "__imported", Value: "true"}}, + TemplateFiles: nil, + AlertmanagerConfig: defaultExtraConfig, + } +} + +const defaultExtraConfig = ` +route: + receiver: receiver1 + routes: + - receiver: email-receiver +receivers: + - name: empty receiver + - name: receiver1 + webhook_configs: + - url: 'https://webhook.example.com/alerts' + http_config: + basic_auth: + username: 'admin' + password: 'super-secret-password' + - url: 'https://slack.com/webhook/ABC123' + send_resolved: true + - name: email-receiver + email_configs: + - to: 'alerts@example.com' + from: 'grafana@example.com' + smarthost: 'smtp.gmail.com:587' + auth_username: 'grafana@example.com' + auth_password: 'another-secret-password' +` + type NopTransactionManager struct{} func newNopTransactionManager() *NopTransactionManager { diff --git a/pkg/services/ngalert/provisioning/contactpoints_test.go b/pkg/services/ngalert/provisioning/contactpoints_test.go index ddddadb1104..3d54f640c56 100644 --- a/pkg/services/ngalert/provisioning/contactpoints_test.go +++ b/pkg/services/ngalert/provisioning/contactpoints_test.go @@ -570,6 +570,7 @@ func createContactPointServiceSutWithConfigStore(t *testing.T, secretService sec log.NewNopLogger(), fakes.NewFakeReceiverPermissionsService(), tracing.InitializeTracerForTest(), + false, ) return NewContactPointService( diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 4b3b9dbbfde..0b0f51f4dcc 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -855,6 +855,16 @@ func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.Lis } } + if query.SearchTitle != "" { + words := strings.Fields(query.SearchTitle) + if len(words) > 0 { + // Build sequential pattern: %word1%word2%word3% + pattern := strings.Join(words, "%") + sql, param := st.SQLStore.GetDialect().LikeOperator("title", true, pattern, true) + q = q.And(sql, param) + } + } + if query.HasPrometheusRuleDefinition != nil { q, err = st.filterWithPrometheusRuleDefinition(*query.HasPrometheusRuleDefinition, q) if err != nil { diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index d1ac3f59cd4..6fcc1375abe 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -2020,12 +2020,10 @@ func Benchmark_ListAlertRules(b *testing.B) { func TestIntegration_ListAlertRules(t *testing.T) { tutil.SkipIntegrationTestInShortMode(t) - sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{ BaseInterval: time.Duration(rand.Int64N(100)) * time.Second, } - folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) b := &fakeBus{} orgID := int64(1) ruleGen := models.RuleGen @@ -2034,6 +2032,8 @@ func TestIntegration_ListAlertRules(t *testing.T) { ruleGen.WithOrgID(orgID), ) t.Run("filter by HasPrometheusRuleDefinition", func(t *testing.T) { + sqlStore := db.InitTestDB(t) + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b) regularRule := createRule(t, store, ruleGen) importedRule := createRule(t, store, ruleGen.With( @@ -2072,6 +2072,75 @@ func TestIntegration_ListAlertRules(t *testing.T) { }) } }) + + t.Run("filter by SearchTitle", func(t *testing.T) { + sqlStore := db.InitTestDB(t) + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b) + rule1 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("CPU Usage Alert"))) + rule2 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("Memory Usage Alert"))) + rule3 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("Disk Space Alert"))) + rule4 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("Application Error Rate"))) + + tc := []struct { + name string + titleSearch string + expectedRules []*models.AlertRule + }{ + { + name: "should find rules", + titleSearch: "alert", + expectedRules: []*models.AlertRule{rule1, rule2, rule3}, + }, + { + name: "should find rule with partial match", + titleSearch: "aPpl", + expectedRules: []*models.AlertRule{rule4}, + }, + { + name: "should return no rules when no match", + titleSearch: "nonexistent", + expectedRules: []*models.AlertRule{}, + }, + { + name: "should return all rules when empty", + titleSearch: "", + expectedRules: []*models.AlertRule{rule1, rule2, rule3, rule4}, + }, + { + name: "should not find rules when word order is reversed", + titleSearch: "usage cpu", + expectedRules: []*models.AlertRule{}, + }, + { + name: "should find multiple rules matching sequential words", + titleSearch: "usage alert", + expectedRules: []*models.AlertRule{rule1, rule2}, + }, + { + name: "should handle extra whitespace between words", + titleSearch: " cpu usage ", + expectedRules: []*models.AlertRule{rule1}, + }, + { + name: "should handle multiple words with partial matches", + titleSearch: "aPp erR", + expectedRules: []*models.AlertRule{rule4}, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + query := &models.ListAlertRulesQuery{ + OrgID: orgID, + SearchTitle: tt.titleSearch, + } + result, err := store.ListAlertRules(context.Background(), query) + require.NoError(t, err) + require.ElementsMatch(t, tt.expectedRules, result) + }) + } + }) } func TestIntegration_ListAlertRulesPaginated(t *testing.T) { diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index 110f5de490d..2ac557edaaa 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -119,12 +119,13 @@ func TestLoader_Load(t *testing.T) { Backend: true, QueryOptions: map[string]bool{"minInterval": true}, }, - Module: "core:plugin/cloudwatch", - BaseURL: "public/plugins/cloudwatch", - FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir(t), "app/plugins/datasource/cloudwatch")), - Signature: plugins.SignatureStatusInternal, - Class: plugins.ClassCore, - Translations: map[string]string{}, + Class: plugins.ClassCore, + BaseURL: "public/plugins/cloudwatch", + FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir(t), "app/plugins/datasource/cloudwatch")), + Module: "core:plugin/cloudwatch", + Signature: plugins.SignatureStatusInternal, + SkipHostEnvVars: true, + Translations: map[string]string{}, }, }, }, @@ -212,14 +213,15 @@ func TestLoader_Load(t *testing.T) { ExtensionPoints: []plugins.ExtensionPoint{}, }, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-app/module.js", - BaseURL: "public/plugins/test-app", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")), - Signature: "valid", - SignatureType: plugins.SignatureTypeGrafana, - SignatureOrg: "Grafana Labs", - Translations: map[string]string{}, + Class: plugins.ClassExternal, + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")), + Signature: "valid", + SignatureType: plugins.SignatureTypeGrafana, + SignatureOrg: "Grafana Labs", + SkipHostEnvVars: true, + Translations: map[string]string{}, }, }, }, @@ -265,12 +267,13 @@ func TestLoader_Load(t *testing.T) { Backend: true, State: plugins.ReleaseStateAlpha, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")), - Signature: "unsigned", - Translations: map[string]string{}, + Class: plugins.ClassExternal, + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")), + Signature: "unsigned", + SkipHostEnvVars: true, + Translations: map[string]string{}, }, }, }, @@ -329,12 +332,13 @@ func TestLoader_Load(t *testing.T) { Backend: true, State: plugins.ReleaseStateAlpha, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")), - Signature: plugins.SignatureStatusUnsigned, - Translations: map[string]string{}, + Class: plugins.ClassExternal, + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")), + Signature: plugins.SignatureStatusUnsigned, + SkipHostEnvVars: true, + Translations: map[string]string{}, }, }, }, @@ -449,13 +453,14 @@ func TestLoader_Load(t *testing.T) { }, Backend: false, }, - DefaultNavURL: "/plugins/test-app/page/root-page-react", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-includes")), - Class: plugins.ClassExternal, - Signature: plugins.SignatureStatusUnsigned, - Module: "public/plugins/test-app/module.js", - BaseURL: "public/plugins/test-app", - Translations: map[string]string{}, + DefaultNavURL: "/plugins/test-app/page/root-page-react", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-includes")), + Class: plugins.ClassExternal, + Signature: plugins.SignatureStatusUnsigned, + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", + SkipHostEnvVars: true, + Translations: map[string]string{}, }, }, }, @@ -546,7 +551,8 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) { ClientID: "client-id", ClientSecret: "secretz", }, - Translations: map[string]string{}, + SkipHostEnvVars: true, + Translations: map[string]string{}, }, } @@ -641,14 +647,15 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) { Executable: "test", State: plugins.ReleaseStateAlpha, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")), - Signature: "valid", - SignatureType: plugins.SignatureTypePrivate, - SignatureOrg: "Will Browne", - Translations: map[string]string{}, + Class: plugins.ClassExternal, + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")), + Signature: "valid", + SignatureType: plugins.SignatureTypePrivate, + SignatureOrg: "Will Browne", + SkipHostEnvVars: true, + Translations: map[string]string{}, }, }, pluginErrors: map[string]*plugins.Error{ @@ -760,14 +767,15 @@ func TestLoader_Load_RBACReady(t *testing.T) { }, Backend: false, }, - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")), - Class: plugins.ClassExternal, - Signature: plugins.SignatureStatusValid, - SignatureType: plugins.SignatureTypePrivate, - SignatureOrg: "gabrielmabille", - Module: "public/plugins/test-app/module.js", - BaseURL: "public/plugins/test-app", - Translations: map[string]string{}, + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")), + Class: plugins.ClassExternal, + Signature: plugins.SignatureStatusValid, + SignatureType: plugins.SignatureTypePrivate, + SignatureOrg: "gabrielmabille", + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", + SkipHostEnvVars: true, + Translations: map[string]string{}, }, }, }, @@ -829,14 +837,15 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) { Backend: true, Executable: "test", }, - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")), - Class: plugins.ClassExternal, - Signature: plugins.SignatureStatusValid, - SignatureType: plugins.SignatureTypePrivate, - SignatureOrg: "Will Browne", - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - Translations: map[string]string{}, + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")), + Class: plugins.ClassExternal, + Signature: plugins.SignatureStatusValid, + SignatureType: plugins.SignatureTypePrivate, + SignatureOrg: "Will Browne", + SkipHostEnvVars: true, + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + Translations: map[string]string{}, }, } @@ -916,14 +925,15 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) { }, Backend: false, }, - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")), - Class: plugins.ClassExternal, - Signature: plugins.SignatureStatusValid, - SignatureType: plugins.SignatureTypeGrafana, - SignatureOrg: "Grafana Labs", - Module: "public/plugins/test-app/module.js", - BaseURL: "public/plugins/test-app", - Translations: map[string]string{}, + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")), + Class: plugins.ClassExternal, + Signature: plugins.SignatureStatusValid, + SignatureType: plugins.SignatureTypeGrafana, + SignatureOrg: "Grafana Labs", + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", + SkipHostEnvVars: true, + Translations: map[string]string{}, }, } @@ -1007,14 +1017,15 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) { }, Backend: false, }, - FS: mustNewStaticFSForTests(t, pluginDir1), - Class: plugins.ClassExternal, - Signature: plugins.SignatureStatusValid, - SignatureType: plugins.SignatureTypeGrafana, - SignatureOrg: "Grafana Labs", - Module: "public/plugins/test-app/module.js", - BaseURL: "public/plugins/test-app", - Translations: map[string]string{}, + FS: mustNewStaticFSForTests(t, pluginDir1), + Class: plugins.ClassExternal, + Signature: plugins.SignatureStatusValid, + SignatureType: plugins.SignatureTypeGrafana, + SignatureOrg: "Grafana Labs", + Module: "public/plugins/test-app/module.js", + BaseURL: "public/plugins/test-app", + SkipHostEnvVars: true, + Translations: map[string]string{}, }, } @@ -1169,14 +1180,15 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, Backend: true, }, - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")), - Signature: plugins.SignatureStatusValid, - SignatureType: plugins.SignatureTypeGrafana, - SignatureOrg: "Grafana Labs", - Class: plugins.ClassExternal, - Translations: map[string]string{}, + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")), + Signature: plugins.SignatureStatusValid, + SignatureType: plugins.SignatureTypeGrafana, + SignatureOrg: "Grafana Labs", + Class: plugins.ClassExternal, + SkipHostEnvVars: true, + Translations: map[string]string{}, } child := &plugins.Plugin{ @@ -1213,14 +1225,15 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { ExtensionPoints: []plugins.ExtensionPoint{}, }, }, - Module: "public/plugins/test-panel/module.js", - BaseURL: "public/plugins/test-panel", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")), - Signature: plugins.SignatureStatusValid, - SignatureType: plugins.SignatureTypeGrafana, - SignatureOrg: "Grafana Labs", - Class: plugins.ClassExternal, - Translations: map[string]string{}, + Module: "public/plugins/test-panel/module.js", + BaseURL: "public/plugins/test-panel", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")), + Signature: plugins.SignatureStatusValid, + SignatureType: plugins.SignatureTypeGrafana, + SignatureOrg: "Grafana Labs", + Class: plugins.ClassExternal, + SkipHostEnvVars: true, + Translations: map[string]string{}, } parent.Children = []*plugins.Plugin{child} @@ -1362,15 +1375,16 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, Backend: false, }, - Module: "public/plugins/myorgid-simple-app/module.js", - BaseURL: "public/plugins/myorgid-simple-app", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")), - DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react", - Signature: plugins.SignatureStatusValid, - SignatureType: plugins.SignatureTypeGrafana, - SignatureOrg: "Grafana Labs", - Class: plugins.ClassExternal, - Translations: map[string]string{}, + Module: "public/plugins/myorgid-simple-app/module.js", + BaseURL: "public/plugins/myorgid-simple-app", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")), + DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react", + Signature: plugins.SignatureStatusValid, + SignatureType: plugins.SignatureTypeGrafana, + SignatureOrg: "Grafana Labs", + Class: plugins.ClassExternal, + SkipHostEnvVars: true, + Translations: map[string]string{}, } child := &plugins.Plugin{ @@ -1421,6 +1435,7 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", Class: plugins.ClassExternal, + SkipHostEnvVars: true, Translations: map[string]string{}, } diff --git a/pkg/services/pluginsintegration/pluginconfig/config.go b/pkg/services/pluginsintegration/pluginconfig/config.go index 9b73cec6500..3207c2b3bd4 100644 --- a/pkg/services/pluginsintegration/pluginconfig/config.go +++ b/pkg/services/pluginsintegration/pluginconfig/config.go @@ -30,9 +30,8 @@ func ProvidePluginManagementConfig(cfg *setting.Cfg, settingProvider setting.Pro cfg.AppURL, //nolint:staticcheck // not yet migrated to OpenFeature config.Features{ - SkipHostEnvVarsEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSkipHostEnvVars), - SriChecksEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSriChecks), - TempoAlertingEnabled: features.IsEnabledGlobally(featuremgmt.FlagTempoAlerting), + SriChecksEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSriChecks), + TempoAlertingEnabled: features.IsEnabledGlobally(featuremgmt.FlagTempoAlerting), }, cfg.GrafanaComAPIURL, cfg.DisablePlugins, diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index e7e8c3c5fa7..c79a9625d50 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -332,6 +332,7 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error ps.log, ps.resourcePermissions, ps.tracer, + false, ) contactPointService := provisioning.NewContactPointService(configStore, ps.secretService, ps.alertingStore, ps.SQLStore, receiverSvc, ps.log, ps.alertingStore, ps.resourcePermissions) diff --git a/pkg/setting/setting_secrets_manager.go b/pkg/setting/setting_secrets_manager.go index d05e6f40f85..260b98264d8 100644 --- a/pkg/setting/setting_secrets_manager.go +++ b/pkg/setting/setting_secrets_manager.go @@ -36,8 +36,6 @@ type SecretsManagerSettings struct { // How long to wait for the process to clean up a secure value to complete. GCWorkerPerSecureValueCleanupTimeout time.Duration - // Whether to register the MT CRUD API - RegisterAPIServer bool // Whether to create the MT secrets management database RunSecretsDBMigrations bool // Whether to run the data key id migration. Requires that RunSecretsDBMigrations is also true. @@ -62,7 +60,6 @@ func (cfg *Cfg) readSecretsManagerSettings() { cfg.SecretsManagement.GCWorkerPollInterval = secretsMgmt.Key("gc_worker_poll_interval").MustDuration(1 * time.Minute) cfg.SecretsManagement.GCWorkerPerSecureValueCleanupTimeout = secretsMgmt.Key("gc_worker_per_request_timeout").MustDuration(5 * time.Second) - cfg.SecretsManagement.RegisterAPIServer = secretsMgmt.Key("register_api_server").MustBool(true) cfg.SecretsManagement.RunSecretsDBMigrations = secretsMgmt.Key("run_secrets_db_migrations").MustBool(true) cfg.SecretsManagement.RunDataKeyMigration = secretsMgmt.Key("run_data_key_migration").MustBool(true) diff --git a/pkg/setting/setting_secrets_manager_test.go b/pkg/setting/setting_secrets_manager_test.go index c326c250821..34f88a481b5 100644 --- a/pkg/setting/setting_secrets_manager_test.go +++ b/pkg/setting/setting_secrets_manager_test.go @@ -171,28 +171,6 @@ domain = example.com assert.Empty(t, cfg.SecretsManagement.ConfiguredKMSProviders) }) - t.Run("should handle configuration with register_api_server disabled", func(t *testing.T) { - iniContent := ` -[secrets_manager] -register_api_server = false -` - cfg, err := NewCfgFromBytes([]byte(iniContent)) - require.NoError(t, err) - - assert.False(t, cfg.SecretsManagement.RegisterAPIServer) - }) - - t.Run("should handle configuration without register_api_server set", func(t *testing.T) { - iniContent := ` -[secrets_manager] -encryption_provider = aws_kms -` - cfg, err := NewCfgFromBytes([]byte(iniContent)) - require.NoError(t, err) - - assert.True(t, cfg.SecretsManagement.RegisterAPIServer) - }) - t.Run("should handle configuration with run_secrets_db_migrations disabled", func(t *testing.T) { iniContent := ` [secrets_manager] diff --git a/pkg/storage/unified/apistore/store.go b/pkg/storage/unified/apistore/store.go index 2ad474dc90f..5adf5e8b958 100644 --- a/pkg/storage/unified/apistore/store.go +++ b/pkg/storage/unified/apistore/store.go @@ -33,6 +33,7 @@ import ( "k8s.io/client-go/tools/cache" authtypes "github.com/grafana/authlib/types" + "github.com/grafana/dskit/concurrency" "github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana/pkg/apimachinery/utils" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" @@ -472,16 +473,36 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti } if v.IsNil() { - v.Set(reflect.MakeSlice(v.Type(), 0, 0)) + v.Set(reflect.MakeSlice(v.Type(), 0, len(rsp.Items))) } - for _, item := range rsp.Items { + // Pre-allocate results slice to preserve order and avoid race conditions. + // Each goroutine writes to its own index, no mutex needed. + type resultSlot struct { + obj runtime.Object + shouldAppend bool + } + results := make([]resultSlot, len(rsp.Items)) + + // Concurrently process items as some may be large and take a while to process. + err = concurrency.ForEachJob(ctx, len(rsp.Items), 10, func(ctx context.Context, idx int) error { + item := rsp.Items[idx] obj, shouldAppend, err := s.processItem(ctx, item, opts, predicate) if err != nil { return err } if shouldAppend { - v.Set(reflect.Append(v, reflect.ValueOf(obj).Elem())) + results[idx] = resultSlot{obj: obj, shouldAppend: true} + } + return nil + }) + if err != nil { + return err + } + + for _, r := range results { + if r.shouldAppend { + v.Set(reflect.Append(v, reflect.ValueOf(r.obj).Elem())) } } diff --git a/pkg/storage/unified/search/options.go b/pkg/storage/unified/search/options.go index 0773b00bc4a..ca61b684061 100644 --- a/pkg/storage/unified/search/options.go +++ b/pkg/storage/unified/search/options.go @@ -66,5 +66,9 @@ func NewSearchOptions( IndexMinUpdateInterval: cfg.IndexMinUpdateInterval, }, nil } - return resource.SearchOptions{}, nil + return resource.SearchOptions{ + // it is used for search after write and throttles index updates + IndexMinUpdateInterval: cfg.IndexMinUpdateInterval, + MaxIndexAge: cfg.MaxFileIndexAge, + }, nil } diff --git a/pkg/tests/apis/alerting/notifications/receivers/imported_test.go b/pkg/tests/apis/alerting/notifications/receivers/imported_test.go new file mode 100644 index 00000000000..f265834b229 --- /dev/null +++ b/pkg/tests/apis/alerting/notifications/receivers/imported_test.go @@ -0,0 +1,148 @@ +package receivers + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path" + "testing" + + "github.com/grafana/alerting/notify" + "github.com/grafana/alerting/receivers/schema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/alertingnotifications/v0alpha1" + "github.com/grafana/grafana/pkg/services/featuremgmt" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/tests/api/alerting" + "github.com/grafana/grafana/pkg/tests/apis" + test_common "github.com/grafana/grafana/pkg/tests/apis/alerting/notifications/common" + "github.com/grafana/grafana/pkg/tests/testinfra" +) + +func TestIntegrationReadImported_Snapshot(t *testing.T) { + ctx := context.Background() + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{ + featuremgmt.FlagAlertingImportAlertmanagerAPI, + }, + }) + + receiverClient := test_common.NewReceiverClient(t, helper.Org1.Admin) + + cliCfg := helper.Org1.Admin.NewRestConfig() + alertingApi := alerting.NewAlertingLegacyAPIClient(helper.GetEnv().Server.HTTPServer.Listener.Addr().String(), cliCfg.Username, cliCfg.Password) + + configYaml, err := testData.ReadFile(path.Join("test-data", "imported.yaml")) + require.NoError(t, err) + + identifier := "test-create-get-config" + mergeMatchers := "_imported=true" + + headers := map[string]string{ + "Content-Type": "application/yaml", + "X-Grafana-Alerting-Config-Identifier": identifier, + "X-Grafana-Alerting-Merge-Matchers": mergeMatchers, + } + + amConfig := apimodels.AlertmanagerUserConfig{ + AlertmanagerConfig: string(configYaml), + } + + response := alertingApi.ConvertPrometheusPostAlertmanagerConfig(t, amConfig, headers) + require.Equal(t, "success", response.Status) + + receiversRaw, err := receiverClient.Client.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + raw, err := receiversRaw.MarshalJSON() + require.NoError(t, err) + + expectedBytes, err := os.ReadFile(path.Join("test-data", "imported-expected-snapshot.json")) + require.NoError(t, err) + + if !assert.JSONEq(t, string(expectedBytes), string(raw)) { + var prettyJSON bytes.Buffer + err := json.Indent(&prettyJSON, raw, "", " ") + require.NoError(t, err) + err = os.WriteFile(path.Join("test-data", "imported-expected-snapshot.json"), prettyJSON.Bytes(), 0o644) + require.NoError(t, err) + } + + receivers, err := receiverClient.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + t.Run("secure fields should be properly masked", func(t *testing.T) { + for _, receiver := range receivers.Items { + if receiver.Spec.Title == "grafana-default-email" { + continue + } + for _, integration := range receiver.Spec.Integrations { + sch, ok := notify.GetSchemaVersionForIntegration(schema.IntegrationType(integration.Type), schema.Version(integration.Version)) + require.Truef(t, ok, "unknown integration type %s and version %s", integration.Type, integration.Version) + keys := flattenKeys(integration.Settings) + secrets := make(map[string]struct{}) + for _, fieldPath := range sch.GetSecretFieldsPaths() { + assert.NotContainsf(t, keys, fieldPath.String(), "receiver %s integration %s has secret field %s", receiver.Name, integration.Type, fieldPath.String()) + secrets[fieldPath.String()] = struct{}{} + } + for key := range integration.SecureFields { + assert.Containsf(t, secrets, key, "receiver %s integration %s has secure field %s that is not in the schema", receiver.Name, integration.Type, key) + } + } + } + }) + t.Run("should set the correct annotations", func(t *testing.T) { + for _, receiver := range receivers.Items { + if receiver.Spec.Title == "grafana-default-email" { + continue + } + assert.EqualValuesf(t, models.ProvenanceConvertedPrometheus, receiver.GetProvenanceStatus(), "receiver %s has unexpected provenance", receiver.Name) + assert.Equalf(t, "false", receiver.Annotations[v0alpha1.CanUseAnnotationKey], "receiver %s has unexpected can use annotation", receiver.Name) + assert.Equalf(t, "", receiver.Annotations[v0alpha1.AccessControlAnnotation("canAdmin")], "receiver %s has unexpected can admin annotation", receiver.Name) + assert.Equalf(t, "", receiver.Annotations[v0alpha1.AccessControlAnnotation("canDelete")], "receiver %s has unexpected can delete annotation", receiver.Name) + assert.Equalf(t, "true", receiver.Annotations[v0alpha1.AccessControlAnnotation("canReadSecrets")], "receiver %s has unexpected can read secrets annotation", receiver.Name) + assert.Equalf(t, "", receiver.Annotations[v0alpha1.AccessControlAnnotation("canWrite")], "receiver %s has unexpected can write annotation", receiver.Name) + } + }) + + t.Run("should not be able to update", func(t *testing.T) { + toUpdate := receivers.Items[1] + toUpdate.Spec.Title = "another title" + + _, err = receiverClient.Update(ctx, &toUpdate, v1.UpdateOptions{}) + require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest but got %s", err) + }) + + t.Run("should not be able to delete", func(t *testing.T) { + toDelete := receivers.Items[1] + + err = receiverClient.Delete(ctx, toDelete.Name, v1.DeleteOptions{}) + require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest but got %s", err) + }) +} + +func flattenKeys(m map[string]any) map[string]struct{} { + result := map[string]struct{}{} + flattenHelper("", m, result) + return result +} + +func flattenHelper(prefix string, m map[string]any, result map[string]struct{}) { + for key, value := range m { + newKey := key + if prefix != "" { + newKey = prefix + "." + key + } + + if valMap, ok := value.(map[string]any); ok { + flattenHelper(newKey, valMap, result) + } else { + result[newKey] = struct{}{} + } + } +} diff --git a/pkg/tests/apis/alerting/notifications/receivers/test-data/imported-expected-snapshot.json b/pkg/tests/apis/alerting/notifications/receivers/test-data/imported-expected-snapshot.json new file mode 100644 index 00000000000..621f9c3af16 --- /dev/null +++ b/pkg/tests/apis/alerting/notifications/receivers/test-data/imported-expected-snapshot.json @@ -0,0 +1,818 @@ +{ + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "items": [ + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canAdmin": "true", + "grafana.com/access/canDelete": "true", + "grafana.com/access/canReadSecrets": "true", + "grafana.com/access/canWrite": "true", + "grafana.com/canUse": "true", + "grafana.com/inUse/routes": "1", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "none" + }, + "name": "Z3JhZmFuYS1kZWZhdWx0LWVtYWls", + "namespace": "default", + "resourceVersion": "a82b34036bdabbc4", + "uid": "zyXFk301pvwNz4HRPrTMKPMFO2934cPB7H1ZXmyM1TUX" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "settings": { + "addresses": "\u003cexample@email.com\u003e" + }, + "type": "email", + "uid": "", + "version": "v1" + } + ], + "title": "grafana-default-email" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "0", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "Z3JhZmFuYS1kZWZhdWx0LWVtYWlsdGVzdC1jcmVhdGUtZ2V0LWNvbmZpZw", + "namespace": "default", + "resourceVersion": "b2823b50ffa1eff6", + "uid": "JzW6DIlcxj4sRN8A2ULcwTXAmm0Vs0Z68aEBqXSvxK0X" + }, + "spec": { + "integrations": [], + "title": "grafana-default-emailtest-create-get-config" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "0", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "ZGlzY29yZA", + "namespace": "default", + "resourceVersion": "06e437697f62ac59", + "uid": "8cH8Ql2S6VhPEVUhwlQEKYWyPbRJS7YKj2lEXdrehH8X" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "secureFields": { + "webhook_url": true + }, + "settings": { + "http_config": { + "enable_http2": true, + "follow_redirects": false, + "proxy_url": null, + "tls_config": { + "insecure_skip_verify": false + } + }, + "message": "{{ template \"discord.default.message\" . }}", + "send_resolved": true, + "title": "{{ template \"discord.default.title\" . }}" + }, + "type": "discord", + "uid": "", + "version": "v0mimir1" + } + ], + "title": "discord" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "0", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "ZW1haWw", + "namespace": "default", + "resourceVersion": "9b3ffed277cee189", + "uid": "bhlvlN758xmnwVrHVPX0c5XvFHepenUbOXP0fuE6eUMX" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "secureFields": { + "auth_password": true + }, + "settings": { + "auth_username": "alertmanager", + "from": "alertmanager@example.com", + "headers": { + "Subject": "test subject" + }, + "hello": "localhost", + "html": "{{ template \"email.default.html\" . }}", + "require_tls": true, + "send_resolved": true, + "smarthost": "smtp.example.com:587", + "text": "test email", + "tls_config": { + "insecure_skip_verify": false + }, + "to": "team@example.com" + }, + "type": "email", + "uid": "", + "version": "v0mimir1" + } + ], + "title": "email" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "0", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "amlyYQ", + "namespace": "default", + "resourceVersion": "deae9d34f8554205", + "uid": "7Pu4xcRXbvw4XEX279SoqyO8Ibo8cMl0vAJyYTsJ0NEX" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "secureFields": { + "http_config.basic_auth.password": true + }, + "settings": { + "api_url": "http://localhost/jira", + "custom_fields": { + "customfield_10000": "test customfield_10000" + }, + "description": "{{ template \"jira.default.description\" . }}", + "http_config": { + "basic_auth": { + "username": "alertmanager@example.com" + }, + "enable_http2": true, + "follow_redirects": true, + "proxy_url": null, + "tls_config": { + "insecure_skip_verify": false + } + }, + "issue_type": "Bug", + "labels": [ + "alertmanager", + "{{ .CommonLabels.severity }}" + ], + "priority": "{{ template \"jira.default.priority\" . }}", + "project": "PROJ", + "send_resolved": true, + "summary": "{{ template \"jira.default.summary\" . }}" + }, + "type": "jira", + "uid": "", + "version": "v0mimir1" + } + ], + "title": "jira" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "0", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "bXN0ZWFtcw", + "namespace": "default", + "resourceVersion": "95c8d082d65466a3", + "uid": "z7xTMDjrk1HAHXPEx78tQb63LXYA6ivXLOtz2Z09ucIX" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "secureFields": { + "webhook_url": true + }, + "settings": { + "http_config": { + "enable_http2": true, + "follow_redirects": true, + "proxy_url": "http://proxy.example.com:8080", + "tls_config": { + "insecure_skip_verify": false + } + }, + "send_resolved": true, + "summary": "{{ template \"msteams.default.summary\" . }}", + "text": "{{ template \"msteams.default.text\" . }}", + "title": "{{ template \"msteams.default.title\" . }}" + }, + "type": "teams", + "uid": "", + "version": "v0mimir1" + } + ], + "title": "msteams" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "0", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "b3BzZ2VuaWU", + "namespace": "default", + "resourceVersion": "8ee2957ba150ba16", + "uid": "XmkZ214Dj030hvynYiwNLq8i6uRCjUYXMXjE5m19OKAX" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "secureFields": { + "api_key": true + }, + "settings": { + "actions": "test actions", + "api_url": "http://localhost/opsgenie/", + "description": "test description", + "details": { + "firing": "test firing" + }, + "entity": "test entity", + "http_config": { + "enable_http2": true, + "follow_redirects": true, + "proxy_url": null, + "tls_config": { + "insecure_skip_verify": false + } + }, + "message": "test message", + "note": "Triggered by Alertmanager", + "priority": "P3", + "responders": [ + { + "name": "ops-team", + "type": "team" + } + ], + "send_resolved": true, + "source": "Alertmanager", + "tags": "test-tags", + "update_alerts": true + }, + "type": "opsgenie", + "uid": "", + "version": "v0mimir1" + } + ], + "title": "opsgenie" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "1", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "cGFnZXJkdXR5", + "namespace": "default", + "resourceVersion": "fe673d5dcd67ccf0", + "uid": "QNitkUCkwzrIc7WVCCJGGDyvXLyo9csSUVqfyStyctQX" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "secureFields": { + "routing_key": true, + "service_key": true + }, + "settings": { + "class": "test class", + "client": "Alertmanager", + "client_url": "https://monitoring.example.com", + "component": "test component", + "description": "test description", + "details": { + "firing": "test firing", + "num_firing": "{{ .Alerts.Firing | len }}", + "num_resolved": "{{ .Alerts.Resolved | len }}", + "resolved": "{{ template \"pagerduty.default.instances\" .Alerts.Resolved }}" + }, + "group": "test group", + "http_config": { + "enable_http2": true, + "follow_redirects": true, + "proxy_url": null, + "tls_config": { + "insecure_skip_verify": false + } + }, + "images": [ + { + "alt": "test alt", + "href": "http://localhost", + "src": "test src" + } + ], + "links": [ + { + "href": "http://localhost", + "text": "test text" + } + ], + "send_resolved": true, + "severity": "test severity", + "source": "test source", + "url": "http://localhost/pagerduty" + }, + "type": "pagerduty", + "uid": "", + "version": "v0mimir1" + } + ], + "title": "pagerduty" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "0", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "cHVzaG92ZXI", + "namespace": "default", + "resourceVersion": "6ae076725ab463e0", + "uid": "t2TJSktI6vyGfdbLOKmxH4eBqgcIGsAuW8Qm9m0HRycX" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "secureFields": { + "http_config.authorization.credentials": true, + "token": true, + "user_key": true + }, + "settings": { + "expire": "1h0m0s", + "http_config": { + "authorization": { + "type": "Bearer" + }, + "enable_http2": true, + "follow_redirects": true, + "proxy_url": null, + "tls_config": { + "insecure_skip_verify": false + } + }, + "message": "{{ template \"pushover.default.message\" . }}", + "priority": "{{ if eq .Status \"firing\" }}2{{ else }}0{{ end }}", + "retry": "30s", + "send_resolved": true, + "title": "{{ template \"pushover.default.title\" . }}", + "url": "http://localhost/pushover" + }, + "type": "pushover", + "uid": "", + "version": "v0mimir1" + } + ], + "title": "pushover" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "0", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "c2xhY2s", + "namespace": "default", + "resourceVersion": "ec0e343029ff5d8b", + "uid": "xSB0hnoc9j1CnLCHR3VgeVGXdVXILM0p2dM64bbHN9oX" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "secureFields": { + "api_url": true + }, + "settings": { + "actions": [ + { + "confirm": { + "dismiss_text": "test-dismiss-text", + "ok_text": "test-ok-text", + "text": "test-text", + "title": "test-title" + }, + "name": "test-name", + "style": "test-style", + "text": "test-text", + "type": "test-type", + "value": "test-value" + } + ], + "callback_id": "test callback id", + "channel": "#alerts", + "color": "danger", + "fallback": "test fallback", + "fields": [ + { + "short": true, + "title": "test title", + "value": "test value" + } + ], + "footer": "test footer", + "http_config": { + "enable_http2": true, + "follow_redirects": true, + "proxy_url": null, + "tls_config": { + "insecure_skip_verify": false + } + }, + "icon_emoji": ":warning:", + "icon_url": "https://example.com/icon.png", + "image_url": "https://example.com/image.png", + "link_names": true, + "mrkdwn_in": [ + "fallback", + "pretext", + "text" + ], + "pretext": "test pretext", + "send_resolved": true, + "text": "test text", + "thumb_url": "https://example.com/thumb.png", + "title": "test title", + "title_link": "http://localhost", + "username": "Alerting Team" + }, + "type": "slack", + "uid": "", + "version": "v0mimir1" + } + ], + "title": "slack" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "0", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "c25z", + "namespace": "default", + "resourceVersion": "77d734ad4c196d36", + "uid": "vSP8NtFr23hnqZqLxRgzUKfr1wOemOvZm1S6MYkfRI4X" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "secureFields": { + "sigv4.SecretKey": true + }, + "settings": { + "attributes": { + "key1": "value1" + }, + "http_config": { + "enable_http2": false, + "follow_redirects": true, + "proxy_url": null, + "tls_config": { + "insecure_skip_verify": false + } + }, + "message": "{{ template \"sns.default.message\" . }}", + "send_resolved": true, + "sigv4": { + "AccessKey": "secret-access-key", + "Profile": "default,", + "Region": "us-east-1", + "RoleARN": "arn:aws:iam::123456789012:role/role-name" + }, + "subject": "{{ template \"sns.default.subject\" . }}", + "topic_arn": "arn:aws:sns:us-east-1:123456789012:alerts" + }, + "type": "sns", + "uid": "", + "version": "v0mimir1" + } + ], + "title": "sns" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "0", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "dGVsZWdyYW0", + "namespace": "default", + "resourceVersion": "d9850878a33e302e", + "uid": "XLWjtmYcjP5PiqBCwZXX3YKHV1G8niRtpCakIpcHqoYX" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "secureFields": { + "token": true + }, + "settings": { + "api_url": "http://localhost/telegram-default", + "chat": -1001234567890, + "http_config": { + "enable_http2": true, + "follow_redirects": true, + "proxy_url": null, + "tls_config": { + "insecure_skip_verify": false + } + }, + "message": "{{ template \"telegram.default.message\" . }}", + "parse_mode": "MarkdownV2", + "send_resolved": true + }, + "type": "telegram", + "uid": "", + "version": "v0mimir1" + } + ], + "title": "telegram" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "0", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "dmljdG9yb3Bz", + "namespace": "default", + "resourceVersion": "1e6886531440afc2", + "uid": "EWiwQ6TIW0GpEo46WusW7Nvg0HuD4QAbHf0JZ2OSOhEX" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "secureFields": { + "api_key": true + }, + "settings": { + "api_url": "http://localhost/victorops-default/", + "entity_display_name": "{{ template \"victorops.default.entity_display_name\" . }}", + "http_config": { + "enable_http2": true, + "follow_redirects": true, + "proxy_url": null, + "tls_config": { + "insecure_skip_verify": false, + "server_name": "victorops.com" + } + }, + "message_type": "CRITICAL", + "monitoring_tool": "{{ template \"victorops.default.monitoring_tool\" . }}", + "routing_key": "team1", + "send_resolved": true, + "state_message": "{{ template \"victorops.default.state_message\" . }}" + }, + "type": "victorops", + "uid": "", + "version": "v0mimir1" + } + ], + "title": "victorops" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "0", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "d2ViZXg", + "namespace": "default", + "resourceVersion": "08fc955a08dfe9c0", + "uid": "wDNufI44UXHWq4ERRYenZ7XgXVV3Tjxaokz9IjMRZ54X" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "secureFields": { + "http_config.authorization.credentials": true + }, + "settings": { + "api_url": "http://localhost/webes-default", + "http_config": { + "authorization": { + "type": "Bearer" + }, + "enable_http2": true, + "follow_redirects": true, + "proxy_url": null, + "tls_config": { + "insecure_skip_verify": false + } + }, + "message": "{{ template \"webex.default.message\" . }}", + "room_id": "Y2lzY29zcGFyazovL3VzL1JPT00v12345678", + "send_resolved": true + }, + "type": "webex", + "uid": "", + "version": "v0mimir1" + } + ], + "title": "webex" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "1", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "d2ViaG9vaw", + "namespace": "default", + "resourceVersion": "494392f899a7b410", + "uid": "aKzigXATPp6HOh20yTrlTcuF2Y9IrPHridGIcWrJygsX" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "secureFields": { + "url": true + }, + "settings": { + "http_config": { + "enable_http2": true, + "follow_redirects": true, + "proxy_url": null, + "tls_config": { + "insecure_skip_verify": false + } + }, + "max_alerts": 10, + "send_resolved": true, + "timeout": "0s", + "url_file": "" + }, + "type": "webhook", + "uid": "", + "version": "v0mimir1" + } + ], + "title": "webhook" + } + }, + { + "apiVersion": "notifications.alerting.grafana.app/v0alpha1", + "kind": "Receiver", + "metadata": { + "annotations": { + "grafana.com/access/canReadSecrets": "true", + "grafana.com/canUse": "false", + "grafana.com/inUse/routes": "0", + "grafana.com/inUse/rules": "0", + "grafana.com/provenance": "converted_prometheus" + }, + "name": "d2VjaGF0", + "namespace": "default", + "resourceVersion": "135913515cbc156b", + "uid": "jkXCvNrNVw7XX5nmYFyrGiA4ckAvJ282u2scW8KZq7IX" + }, + "spec": { + "integrations": [ + { + "disableResolveMessage": false, + "secureFields": { + "api_secret": true + }, + "settings": { + "agent_id": "1000002", + "api_url": "http://localhost/wechat/", + "corp_id": "12345", + "http_config": { + "enable_http2": true, + "follow_redirects": true, + "proxy_url": null, + "tls_config": { + "insecure_skip_verify": false + } + }, + "message": "test message", + "message_type": "text", + "send_resolved": true, + "to_party": "party1", + "to_tag": "tag1", + "to_user": "user1" + }, + "type": "wechat", + "uid": "", + "version": "v0mimir1" + } + ], + "title": "wechat" + } + } + ], + "kind": "ReceiverList", + "metadata": {} +} diff --git a/pkg/tests/apis/alerting/notifications/receivers/test-data/imported.yaml b/pkg/tests/apis/alerting/notifications/receivers/test-data/imported.yaml new file mode 100644 index 00000000000..5d4e8fd927f --- /dev/null +++ b/pkg/tests/apis/alerting/notifications/receivers/test-data/imported.yaml @@ -0,0 +1,247 @@ +global: + resolve_timeout: 5m + smtp_smarthost: localhost:1025 + smtp_from: alertmanager@example.com + smtp_auth_username: alertmanager + smtp_auth_password: password123 + smtp_require_tls: false + smtp_hello: localhost + http_config: + follow_redirects: true + enable_http2: true + pagerduty_url: http://localhost/pagerduty-default + opsgenie_api_key: opsgenie-api-key-secret + opsgenie_api_url: http://localhost/opsgenie-default + victorops_api_key: victorops-api-key-secret + victorops_api_url: http://localhost/victorops-default + slack_api_url: http://localhost/slack-default + wechat_api_url: http://localhost/wechat-default + wechat_api_secret: wechat-api-secret + wechat_api_corp_id: corp123 + telegram_api_url: http://localhost/telegram-default + webex_api_url: http://localhost/webes-default + jira_api_url: http://localhost/jira-default +route: + receiver: webhook + group_by: + - alertname + - cluster + group_wait: 1s + group_interval: 5s + repeat_interval: 60s + routes: + - matchers: + - severity="critical" + receiver: pagerduty +receivers: + - name: grafana-default-email + - name: discord + discord_configs: + - webhook_url: http://localhost/discord + send_resolved: true + http_config: + follow_redirects: false + enable_http2: true + title: '{{ template "discord.default.title" . }}' + message: '{{ template "discord.default.message" . }}' + - name: email + email_configs: + - to: team@example.com + from: alertmanager@example.com + smarthost: smtp.example.com:587 + auth_username: alertmanager + auth_password: password123 + require_tls: true + send_resolved: true + text: test email + hello: localhost + headers: + Subject: test subject + - name: jira + jira_configs: + - api_url: http://localhost/jira + send_resolved: true + http_config: + basic_auth: + username: alertmanager@example.com + password: pat123 + project: PROJ + issue_type: Bug + summary: '{{ template "jira.default.summary" . }}' + description: '{{ template "jira.default.description" . }}' + priority: '{{ template "jira.default.priority" . }}' + labels: + - alertmanager + - '{{ .CommonLabels.severity }}' + fields: + customfield_10000: test customfield_10000 + - name: msteams + msteams_configs: + - webhook_url: http://localhost/msteams + send_resolved: true + http_config: + proxy_url: http://proxy.example.com:8080 + title: '{{ template "msteams.default.title" . }}' + summary: '{{ template "msteams.default.summary" . }}' + text: '{{ template "msteams.default.text" . }}' + - name: opsgenie + opsgenie_configs: + - api_key: api-secret-key + api_url: http://localhost/opsgenie + message: test message + description: test description + source: Alertmanager + details: + firing: test firing + entity: test entity + responders: + - type: team + name: ops-team + actions: test actions + tags: test-tags + note: Triggered by Alertmanager + priority: P3 + update_alerts: true + send_resolved: true + - name: pagerduty + pagerduty_configs: + - url: http://localhost/pagerduty + routing_key: test-routing-secret-key + service_key: test-service-secret-key + client: Alertmanager + client_url: https://monitoring.example.com + description: test description + severity: test severity + details: + firing: test firing + images: + - alt: test alt + src: test src + href: http://localhost + links: + - href: http://localhost + text: test text + source: test source + class: test class + component: test component + group: test group + send_resolved: true + - name: pushover + pushover_configs: + - user_key: secret-user-key + token: secret-token + send_resolved: true + http_config: + bearer_token: token123 + title: '{{ template "pushover.default.title" . }}' + message: '{{ template "pushover.default.message" . }}' + url: http://localhost/pushover + priority: '{{ if eq .Status "firing" }}2{{ else }}0{{ end }}' + retry: 30s + expire: 1h + - name: slack + slack_configs: + - api_url: http://localhost/slack + channel: '#alerts' + username: Alerting Team + color: danger + title: test title + title_link: http://localhost + pretext: test pretext + text: test text + fields: + - title: test title + value: test value + short: true + short_fields: false + footer: test footer + fallback: test fallback + callback_id: test callback id + icon_emoji: ':warning:' + icon_url: https://example.com/icon.png + image_url: https://example.com/image.png + thumb_url: https://example.com/thumb.png + link_names: true + mrkdwn_in: + - fallback + - pretext + - text + actions: + - type: test-type + text: test-text + style: test-style + name: test-name + value: test-value + confirm: + title: test-title + text: test-text + ok_text: test-ok-text + dismiss_text: test-dismiss-text + send_resolved: true + - name: sns + sns_configs: + - topic_arn: arn:aws:sns:us-east-1:123456789012:alerts + send_resolved: true + sigv4: + region: us-east-1 + access_key: secret-access-key + secret_key: secret-secret-key + profile: default, + role_arn: "arn:aws:iam::123456789012:role/role-name" + http_config: + enable_http2: false + subject: '{{ template "sns.default.subject" . }}' + message: '{{ template "sns.default.message" . }}' + attributes: + key1: value1 + - name: telegram + telegram_configs: + - bot_token: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 + chat_id: -1001234567890 + send_resolved: true + http_config: + follow_redirects: true + enable_http2: true + message: '{{ template "telegram.default.message" . }}' + parse_mode: MarkdownV2 + - name: victorops + victorops_configs: + - api_key: victorops-api-key-secret + send_resolved: true + http_config: + tls_config: + server_name: victorops.com + routing_key: team1 + message_type: CRITICAL + entity_display_name: '{{ template "victorops.default.entity_display_name" . }}' + state_message: '{{ template "victorops.default.state_message" . }}' + monitoring_tool: '{{ template "victorops.default.monitoring_tool" . }}' + - name: webex + webex_configs: + - send_resolved: true + http_config: + authorization: + type: Bearer + credentials: webex-secret-token + room_id: Y2lzY29zcGFyazovL3VzL1JPT00v12345678 + message: '{{ template "webex.default.message" . }}' + - name: webhook + webhook_configs: + - url: http://localhost/webhook + send_resolved: true + http_config: + follow_redirects: true + enable_http2: true + max_alerts: 10 + - name: wechat + wechat_configs: + - send_resolved: true + api_url: http://localhost/wechat + api_secret: wechat-api-secret + corp_id: 12345 + to_user: user1 + to_party: party1 + to_tag: tag1 + agent_id: 1000002 + message: test message + message_type: text \ No newline at end of file diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx index 1b632bdfcbf..3ced1f997e2 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx @@ -61,10 +61,13 @@ const getStyles = (theme: GrafanaTheme2) => { display: 'flex', flexDirection: 'column', gap: theme.spacing(1), - padding: theme.spacing(1), width: '100%', height: '100%', overflow: 'auto', + // Temp fix for AI assistant, remove in a 1-2 months + ' > div > div': { + margin: 0, + }, }), content: css({ flex: 1, diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx index df93d6848b6..3145909e7f8 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx @@ -1,9 +1,7 @@ -import { css, cx } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { ToolbarButton, useStyles2 } from '@grafana/ui'; +import { ToolbarButton } from '@grafana/ui'; interface ToolbarItemButtonProps { isOpen: boolean; @@ -30,35 +28,24 @@ function ExtensionToolbarItemButtonComponent( { isOpen, title, onClick, pluginId }: ToolbarItemButtonProps, ref: React.ForwardedRef ) { - const styles = useStyles2(getStyles); const icon = getPluginIcon(pluginId); + const tooltip = (() => { + if (isOpen) { + return t('navigation.extension-sidebar.button-tooltip.close', 'Close {{title}}', { title }); + } + if (title) { + return t('navigation.extension-sidebar.button-tooltip.open', 'Open {{title}}', { title }); + } + return t('navigation.extension-sidebar.button-tooltip.open-all', 'Open AI assistants and sidebar apps'); + })(); - if (isOpen) { - // render button to close the sidebar - return ( - - ); - } - // if a title is provided, use it in the tooltip - let tooltip = t('navigation.extension-sidebar.button-tooltip.open-all', 'Open AI assistants and sidebar apps'); - if (title) { - tooltip = t('navigation.extension-sidebar.button-tooltip.open', 'Open {{title}}', { title }); - } return ( @@ -70,25 +57,3 @@ function ExtensionToolbarItemButtonComponent( export const ExtensionToolbarItemButton = React.forwardRef( ExtensionToolbarItemButtonComponent ); - -function getStyles(theme: GrafanaTheme2) { - return { - button: css({ - // this is needed because with certain breakpoints the button will get `width: auto` - // and the icon will stretch - aspectRatio: '1 / 1 !important', - width: '28px', - height: '28px', - padding: 0, - justifyContent: 'center', - borderRadius: theme.shape.radius.circle, - margin: theme.spacing(0, 0.25), - }), - buttonActive: css({ - borderRadius: theme.shape.radius.circle, - backgroundColor: theme.colors.primary.transparent, - border: `1px solid ${theme.colors.primary.borderTransparent}`, - color: theme.colors.text.primary, - }), - }; -} diff --git a/public/app/core/components/AppChrome/TopBar/HelpTopBarButton.tsx b/public/app/core/components/AppChrome/TopBar/HelpTopBarButton.tsx index 02215cda4fd..27198a86050 100644 --- a/public/app/core/components/AppChrome/TopBar/HelpTopBarButton.tsx +++ b/public/app/core/components/AppChrome/TopBar/HelpTopBarButton.tsx @@ -1,10 +1,8 @@ -import { css } from '@emotion/css'; import { memo } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; import { getAppEvents } from '@grafana/runtime'; -import { Dropdown, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { Dropdown, ToolbarButton } from '@grafana/ui'; import { OpenExtensionSidebarEvent } from 'app/types/events'; import { @@ -23,7 +21,6 @@ interface Props { export const HelpTopBarButton = memo(function HelpTopBarButton({ isSmallScreen }: Props) { const enrichedHelpNode = useHelpNode(); const { setDockedComponentId, dockedComponentId, availableComponents } = useExtensionSidebarContext(); - const styles = useStyles2(getStyles); if (!enrichedHelpNode) { return null; @@ -52,7 +49,7 @@ export const HelpTopBarButton = memo(function HelpTopBarButton({ isSmallScreen } iconOnly icon="question-circle" aria-label={t('navigation.help.aria-label', 'Help')} - className={isOpen ? styles.helpButtonActive : undefined} + variant={isOpen ? 'active' : 'default'} tooltip={ isOpen ? t( @@ -77,12 +74,3 @@ export const HelpTopBarButton = memo(function HelpTopBarButton({ isSmallScreen } /> ); }); - -const getStyles = (theme: GrafanaTheme2) => ({ - helpButtonActive: css({ - borderRadius: theme.shape.radius.circle, - backgroundColor: theme.colors.primary.transparent, - border: `1px solid ${theme.colors.primary.borderTransparent}`, - color: theme.colors.text.primary, - }), -}); diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index cd29bbfb556..f1d8102dae1 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -99,6 +99,7 @@ const getStyles = (theme: GrafanaTheme2) => { flex: '1 1 0', flexDirection: 'column', position: 'relative', + container: 'page / inline-size', }), pageContent: css({ label: 'page-content', diff --git a/public/app/core/components/RolePicker/TeamRolePicker.tsx b/public/app/core/components/RolePicker/TeamRolePicker.tsx index 78e3b0f2c82..d7b35e4ee51 100644 --- a/public/app/core/components/RolePicker/TeamRolePicker.tsx +++ b/public/app/core/components/RolePicker/TeamRolePicker.tsx @@ -44,23 +44,25 @@ export const TeamRolePicker = ({ width, isLoading, }: Props) => { - const [{ loading, value: appliedRoles = roles || [] }, getTeamRoles] = useAsyncFn(async () => { - try { - if (roles) { - return roles; + const [{ loading, value: appliedRoles = roles || [] }, getTeamRoles] = useAsyncFn( + async (force = false) => { + try { + if (!force && roles) { + return roles; + } + if (!force && apply && Boolean(pendingRoles?.length)) { + return pendingRoles; + } + if (contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesList) && teamId > 0) { + return await fetchTeamRoles(teamId); + } + } catch (e) { + console.error('Error fetching roles', e); } - if (apply && Boolean(pendingRoles?.length)) { - return pendingRoles; - } - - if (contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesList) && teamId > 0) { - return await fetchTeamRoles(teamId); - } - } catch (e) { - console.error('Error loading options', e); - } - return []; - }, [teamId, pendingRoles, roles]); + return []; + }, + [teamId, pendingRoles, roles] + ); useEffect(() => { getTeamRoles(); @@ -69,7 +71,7 @@ export const TeamRolePicker = ({ const onRolesChange = async (roles: Role[]) => { if (!apply) { await updateTeamRoles(roles, teamId); - await getTeamRoles(); + await getTeamRoles(true); // Force fetch from backend after update } else if (onApplyRoles) { onApplyRoles(roles); } diff --git a/public/app/core/components/RolePicker/UserRolePicker.tsx b/public/app/core/components/RolePicker/UserRolePicker.tsx index a4232f4b61a..1855e3e53ba 100644 --- a/public/app/core/components/RolePicker/UserRolePicker.tsx +++ b/public/app/core/components/RolePicker/UserRolePicker.tsx @@ -54,24 +54,25 @@ export const UserRolePicker = ({ width, isLoading, }: Props) => { - const [{ loading, value: appliedRoles = roles || [] }, getUserRoles] = useAsyncFn(async () => { - try { - if (roles) { - return roles; + const [{ loading, value: appliedRoles = roles || [] }, getUserRoles] = useAsyncFn( + async (force = false) => { + try { + if (!force && roles) { + return roles; + } + if (!force && apply && Boolean(pendingRoles?.length)) { + return pendingRoles; + } + if (contextSrv.hasPermission(AccessControlAction.ActionUserRolesList) && userId > 0) { + return await fetchUserRoles(userId, orgId); + } + } catch (e) { + console.error('Error fetching user roles'); } - if (apply && Boolean(pendingRoles?.length)) { - return pendingRoles; - } - - if (contextSrv.hasPermission(AccessControlAction.ActionUserRolesList) && userId > 0) { - return await fetchUserRoles(userId, orgId); - } - } catch (e) { - // TODO handle error - console.error('Error loading options'); - } - return []; - }, [orgId, userId, pendingRoles, roles]); + return []; + }, + [orgId, userId, pendingRoles, roles] + ); useEffect(() => { // only load roles when there is an Org selected @@ -83,7 +84,7 @@ export const UserRolePicker = ({ const onRolesChange = async (roles: Role[]) => { if (!apply) { await updateUserRoles(roles, userId, orgId); - await getUserRoles(); + await getUserRoles(true); // Force fetch from backend after update } else if (onApplyRoles) { onApplyRoles(roles, userId, orgId); } diff --git a/public/app/core/components/TimeSeries/utils.ts b/public/app/core/components/TimeSeries/utils.ts index 82c18cdd359..f81de163b7f 100644 --- a/public/app/core/components/TimeSeries/utils.ts +++ b/public/app/core/components/TimeSeries/utils.ts @@ -136,6 +136,10 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ direction: isHorizontal ? ScaleDirection.Right : ScaleDirection.Up, isTime: true, range: () => { + const state = builder.getState(); + if (state.isPanning) { + return [state.min, state.max]; + } const r = getTimeRange(); return [r.from.valueOf(), r.to.valueOf()]; }, diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index 01e5fdaa1b9..8aafa13a8b3 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -42,6 +42,7 @@ type GrafanaPromRulesOptions = Omit ({ url: `api/prometheus/grafana/api/v1/rules`, params: { @@ -103,6 +105,7 @@ export const prometheusApi = alertingApi.injectEndpoints({ limit_alerts: limitAlerts, group_limit: groupLimit?.toFixed(0), group_next_token: groupNextToken, + 'search.rule_name': title, }, }), providesTags: (_result, _error, { folderUid, groupName, ruleName }) => { diff --git a/public/app/features/alerting/unified/featureToggles.ts b/public/app/features/alerting/unified/featureToggles.ts index d3b3d755249..46b202ac7f8 100644 --- a/public/app/features/alerting/unified/featureToggles.ts +++ b/public/app/features/alerting/unified/featureToggles.ts @@ -21,3 +21,5 @@ export const shouldAllowRecoveringDeletedRules = () => export const shouldAllowPermanentlyDeletingRules = () => (shouldAllowRecoveringDeletedRules() && config.featureToggles.alertingRulePermanentlyDelete) ?? false; + +export const shouldUseBackendFilters = () => config.featureToggles.alertingUIUseBackendFilters ?? false; diff --git a/public/app/features/alerting/unified/rule-list/FilterView.tsx b/public/app/features/alerting/unified/rule-list/FilterView.tsx index 5e1a651cdaf..8ac1e1a8c58 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.tsx @@ -20,9 +20,10 @@ import { GrafanaRuleWithOrigin, PromRuleWithOrigin, RuleWithOrigin, + hasClientSideFilters, useFilteredRulesIteratorProvider, } from './hooks/useFilteredRulesIterator'; -import { FRONTEND_LIST_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits'; +import { FRONTEND_LIST_PAGE_SIZE, getSearchApiGroupPageSize } from './paginationLimits'; interface FilterViewProps { filterState: RulesFilter; @@ -77,7 +78,10 @@ function FilterViewResults({ filterState }: FilterViewProps) { * ⚠️ Make sure we are returning / using a "iterator" and not an "iterable" since the iterable is only a blueprint * and the iterator will allow us to exhaust the iterable in a stateful way */ - const { iterable, abortController } = getFilteredRulesIterator(filterState, getApiGroupPageSize(true)); + const { iterable, abortController } = getFilteredRulesIterator( + filterState, + getSearchApiGroupPageSize(hasClientSideFilters(filterState)) + ); const rulesBatchIterator = iterable .pipe( bufferCountOrTime(FRONTEND_LIST_PAGE_SIZE, 1000), diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts index 729576da9f2..1dfdf735bf6 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts @@ -60,6 +60,34 @@ describe('ruleFilter', () => { 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' }); + + // 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 perform title filtering when backendFiltered is false', () => { + const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + + // 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 perform title filtering when backendFiltered is not specified (backward compatibility)', () => { + const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + + // 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); + }); + }); + it('should filter by labels', () => { const rule = mockPromAlertingRule({ labels: { severity: 'critical', team: 'ops' }, diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.ts index 96db43ad35a..8db19b5a8dd 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.ts @@ -36,18 +36,20 @@ export function groupFilter( /** * @returns True if the rule matches the filter, false otherwise + * @param backendFiltered - If true, title search is skipped (already filtered by backend) */ -export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) { +export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter, backendFiltered?: boolean) { const { name, labels = {}, health, type } = rule; - if (filterState.freeFormWords.length > 0) { + if (filterState.freeFormWords.length > 0 && !backendFiltered) { const nameMatches = fuzzyMatches(name, filterState.freeFormWords.join(' ')); if (!nameMatches) { return false; } } - if (filterState.ruleName && !fuzzyMatches(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; } diff --git a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts index 71ee5537465..47e6d534e47 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts @@ -46,6 +46,7 @@ interface GrafanaPromApiFilter { state?: PromAlertingRuleState[]; health?: RuleHealth[]; contactPoint?: string; + title?: string; } interface GrafanaFetchGroupsOptions extends FetchGroupsOptions { diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts new file mode 100644 index 00000000000..1f37be2be09 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts @@ -0,0 +1,96 @@ +import { config } from '@grafana/runtime'; + +import { RuleSource } from '../../search/rulesSearchParser'; +import { getFilter } from '../../utils/search'; + +import { buildTitleSearch, hasClientSideFilters } from './useFilteredRulesIterator'; + +describe('hasClientSideFilters', () => { + const originalFeatureToggles = config.featureToggles; + + beforeEach(() => { + config.featureToggles = { ...originalFeatureToggles }; + }); + + afterEach(() => { + config.featureToggles = originalFeatureToggles; + }); + + describe('when alertingUIUseBackendFilters is enabled', () => { + beforeEach(() => { + config.featureToggles.alertingUIUseBackendFilters = true; + }); + + it('should return false for title search filters (backend-supported)', () => { + expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(false); + }); + + it('should return true for client-side only filters', () => { + expect(hasClientSideFilters(getFilter({ namespace: 'test' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ ruleSource: RuleSource.DataSource }))).toBe(true); + }); + + it('should return false when no filters are applied', () => { + expect(hasClientSideFilters(getFilter({}))).toBe(false); + }); + }); + + describe('when alertingUIUseBackendFilters is disabled', () => { + beforeEach(() => { + config.featureToggles.alertingUIUseBackendFilters = false; + }); + + it('should return true for title search filters (client-side fallback)', () => { + expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(true); + }); + + it('should return true for client-side only filters', () => { + expect(hasClientSideFilters(getFilter({ namespace: 'test' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ ruleSource: RuleSource.DataSource }))).toBe(true); + }); + + it('should return false when no filters are applied', () => { + expect(hasClientSideFilters(getFilter({}))).toBe(false); + }); + }); + + describe('when alertingUIUseBackendFilters is undefined (default)', () => { + beforeEach(() => { + config.featureToggles.alertingUIUseBackendFilters = undefined; + }); + + it('should return true for title search filters (backward compatibility)', () => { + // Default behavior should be client-side filtering + expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(true); + }); + }); +}); + +describe('buildTitleSearch', () => { + it('returns undefined when no search terms are provided', () => { + expect(buildTitleSearch(getFilter({}))).toBeUndefined(); + }); + + it('returns the rule name when only a rule search is provided', () => { + expect(buildTitleSearch(getFilter({ ruleName: 'cpu' }))).toBe('cpu'); + }); + + it('returns joined free-form words when only text search is provided', () => { + expect(buildTitleSearch(getFilter({ freeFormWords: ['cpu', 'memory'] }))).toBe('cpu memory'); + }); + + it('concatenates rule search and free-form text when both are provided', () => { + expect(buildTitleSearch(getFilter({ ruleName: 'cpu', freeFormWords: ['memory', 'latency'] }))).toBe( + 'cpu memory latency' + ); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts index b1611558604..12cbcfb7a73 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts @@ -15,6 +15,7 @@ import { PromRuleGroupDTO, } from 'app/types/unified-alerting-dto'; +import { shouldUseBackendFilters } from '../../featureToggles'; import { RuleSource, RulesFilter } from '../../search/rulesSearchParser'; import { getDataSourceByUid, @@ -79,12 +80,16 @@ export function useFilteredRulesIteratorProvider() { const normalizedFilterState = normalizeFilterState(filterState); const hasDataSourceFilterActive = Boolean(filterState.dataSourceNames.length); + const useBackendFilters = shouldUseBackendFilters(); + + const titleSearch = useBackendFilters ? buildTitleSearch(filterState) : undefined; const grafanaRulesGenerator: AsyncIterableX = from( grafanaGroupsGenerator(groupLimit, { contactPoint: filterState.contactPoint ?? undefined, health: filterState.ruleHealth ? [filterState.ruleHealth] : [], state: filterState.ruleState ? [filterState.ruleState] : [], + title: titleSearch, }) ).pipe( withAbort(abortController.signal), @@ -92,7 +97,7 @@ export function useFilteredRulesIteratorProvider() { groups .filter((group) => groupFilter(group, normalizedFilterState)) .flatMap((group) => group.rules.map((rule) => ({ group, rule }))) - .filter(({ rule }) => ruleFilter(rule, normalizedFilterState)) + .filter(({ rule }) => ruleFilter(rule, normalizedFilterState, useBackendFilters)) .map(({ group, rule }) => mapGrafanaRuleToRuleWithOrigin(group, rule)) ), catchError(() => empty()) @@ -117,7 +122,7 @@ export function useFilteredRulesIteratorProvider() { groups .filter((group) => groupFilter(group, normalizedFilterState)) .flatMap((group) => group.rules.map((rule, index) => ({ group, rule, index }))) - .filter(({ rule }) => ruleFilter(rule, normalizedFilterState)) + .filter(({ rule }) => ruleFilter(rule, normalizedFilterState, false)) .map(({ group, rule, index }) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule, index)) ), catchError(() => empty()) @@ -146,6 +151,48 @@ export function useFilteredRulesIteratorProvider() { return getFilteredRulesIterable; } +/** + * Determines if client-side filtering is needed for Grafana-managed rules. + */ +export function hasClientSideFilters(filterState: RulesFilter): boolean { + const useBackendFilters = shouldUseBackendFilters(); + + return ( + // When backend filters are disabled, title search needs client-side filtering + (!useBackendFilters && (filterState.freeFormWords.length > 0 || Boolean(filterState.ruleName))) || + // Client-side only filters: + Boolean(filterState.namespace) || + filterState.dataSourceNames.length > 0 || + filterState.labels.length > 0 || + Boolean(filterState.dashboardUid) || + filterState.ruleSource === RuleSource.DataSource + ); +} + +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 { if (iterables.length === 0) { return empty(); diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.ts index 41c5293cbde..a8e03ac5a48 100644 --- a/public/app/features/alerting/unified/rule-list/paginationLimits.ts +++ b/public/app/features/alerting/unified/rule-list/paginationLimits.ts @@ -1,9 +1,15 @@ export const FRONTEND_LIST_PAGE_SIZE = 100; -export const FILTERED_GROUPS_API_PAGE_SIZE = 2000; +export const FILTERED_GROUPS_LARGE_API_PAGE_SIZE = 2000; +export const FILTERED_GROUPS_SMALL_API_PAGE_SIZE = 100; + export const DEFAULT_GROUPS_API_PAGE_SIZE = 40; export const FRONTED_GROUPED_PAGE_SIZE = DEFAULT_GROUPS_API_PAGE_SIZE; export function getApiGroupPageSize(hasFilters: boolean) { - return hasFilters ? FILTERED_GROUPS_API_PAGE_SIZE : DEFAULT_GROUPS_API_PAGE_SIZE; + return hasFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : DEFAULT_GROUPS_API_PAGE_SIZE; +} + +export function getSearchApiGroupPageSize(hasFrontendFilters: boolean) { + return hasFrontendFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : FILTERED_GROUPS_SMALL_API_PAGE_SIZE; } diff --git a/public/app/features/correlations/CorrelationsFeatureToggle.tsx b/public/app/features/correlations/CorrelationsFeatureToggle.tsx deleted file mode 100644 index ccbe05cb0e9..00000000000 --- a/public/app/features/correlations/CorrelationsFeatureToggle.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Trans } from '@grafana/i18n'; -import { Page } from 'app/core/components/Page/Page'; - -export default function FeatureTogglePage() { - return ( - - -

- Correlations are disabled -

- To enable Correlations, add it in the Grafana config: -
-
-            {`[feature_toggles]
-correlations = true
-`}
-          
-
-
-
- ); -} diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx index 0e23d53e6fa..714c1a81bb3 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx @@ -163,23 +163,20 @@ function DashboardControlsRenderer({ model }: SceneComponentProps - - {!hideVariableControls && ( - <> - - - - )} - - {!hideLinksControls && !editPanel && } - {editPanel && } - {!hideTimeControls && (
)} + {!hideVariableControls && ( + <> + + + + )} + {!hideLinksControls && !editPanel && } + {editPanel && } {!hideDashboardControls && model.hasDashboardControls() && ( @@ -208,11 +205,8 @@ function renderHiddenVariables(dashboard: DashboardScene) { function getStyles(theme: GrafanaTheme2) { return { controls: css({ - display: 'flex', - alignItems: 'flex-start', - flex: '100%', gap: theme.spacing(1), - padding: theme.spacing(2), + padding: theme.spacing(2, 2, 1, 2), flexDirection: 'row', flexWrap: 'nowrap', position: 'relative', @@ -240,6 +234,8 @@ function getStyles(theme: GrafanaTheme2) { display: 'flex', justifyContent: 'flex-end', gap: theme.spacing(1), + marginBottom: theme.spacing(1), + float: 'right', }), timeControlsWrap: css({ flexWrap: 'wrap', diff --git a/public/app/features/dashboard-scene/scene/VariableControls.tsx b/public/app/features/dashboard-scene/scene/VariableControls.tsx index eba51280522..3509848c442 100644 --- a/public/app/features/dashboard-scene/scene/VariableControls.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControls.tsx @@ -19,6 +19,7 @@ import { AddVariableButton } from './VariableControlsAddButton'; export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { const { variables } = sceneGraph.getVariables(dashboard)!.useState(); + const styles = useStyles2(getStyles); return ( <> @@ -27,7 +28,11 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { .map((variable) => ( ))} - {config.featureToggles.dashboardNewLayouts ? : null} + {config.featureToggles.dashboardNewLayouts ? ( +
+ +
+ ) : null} ); } @@ -105,7 +110,7 @@ export function VariableValueSelectWrapper({ variable, inMenu }: VariableSelectP onPointerDown={onPointerDown} data-testid={selectors.pages.Dashboard.SubMenu.submenuItem} > - + ); @@ -145,12 +150,16 @@ function VariableLabel({ const getStyles = (theme: GrafanaTheme2) => ({ container: css({ - display: 'flex', + display: 'inline-flex', + alignItems: 'center', + verticalAlign: 'middle', // No border for second element (inputs) as label and input border is shared '> :nth-child(2)': css({ borderTopLeftRadius: 'unset', borderBottomLeftRadius: 'unset', }), + marginBottom: theme.spacing(1), + marginRight: theme.spacing(1), }), verticalContainer: css({ display: 'flex', @@ -176,4 +185,15 @@ const getStyles = (theme: GrafanaTheme2) => ({ labelSelectable: css({ cursor: 'pointer', }), + label: css({ + display: 'flex', + alignItems: 'center', + }), + addButton: css({ + display: 'inline-flex', + alignItems: 'center', + verticalAlign: 'middle', + marginBottom: theme.spacing(1), + marginRight: theme.spacing(1), + }), }); diff --git a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx index 7e39e1e696e..8bce6364a34 100644 --- a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx +++ b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx @@ -152,6 +152,95 @@ describe('PanelTimeRange', () => { expect(panelTime.state.from).toBe('now-2h'); expect(panelTime.state.to).toBe('now'); }); + + describe('onTimeRangeChange', () => { + it('should reverse timeShift when updating time range', () => { + const oneHourShift = '1h'; + const panelTime = new PanelTimeRange({ timeShift: oneHourShift }); + const sceneTimeRange = new SceneTimeRange({ from: 'now-6h', to: 'now' }); + const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime }); + const scene = new SceneFlexLayout({ + $timeRange: sceneTimeRange, + children: [new SceneFlexItem({ body: panel })], + }); + + activateFullSceneTree(scene); + + const panelTimeFrom = dateTime('2019-02-11T12:00:00.000Z'); + const panelTimeTo = dateTime('2019-02-11T18:00:00.000Z'); + + panelTime.onTimeRangeChange({ + from: panelTimeFrom, + to: panelTimeTo, + raw: { from: panelTimeFrom, to: panelTimeTo }, + }); + + const expectedDashboardTimeFrom = dateTime('2019-02-11T13:00:00.000Z'); + const expectedDashboardTimeTo = dateTime('2019-02-11T19:00:00.000Z'); + + expect(sceneTimeRange.state.value.from.toISOString()).toBe(expectedDashboardTimeFrom.toISOString()); + expect(sceneTimeRange.state.value.to.toISOString()).toBe(expectedDashboardTimeTo.toISOString()); + }); + + it('should pass through time range when no timeShift is configured', () => { + const panelTime = new PanelTimeRange({}); + const sceneTimeRange = new SceneTimeRange({ from: 'now-6h', to: 'now' }); + const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime }); + const scene = new SceneFlexLayout({ + $timeRange: sceneTimeRange, + children: [new SceneFlexItem({ body: panel })], + }); + + activateFullSceneTree(scene); + + const userSelectedFrom = dateTime('2019-02-11T12:00:00.000Z'); + const userSelectedTo = dateTime('2019-02-11T18:00:00.000Z'); + + panelTime.onTimeRangeChange({ + from: userSelectedFrom, + to: userSelectedTo, + raw: { from: userSelectedFrom, to: userSelectedTo }, + }); + + expect(sceneTimeRange.state.value.from.toISOString()).toBe(userSelectedFrom.toISOString()); + expect(sceneTimeRange.state.value.to.toISOString()).toBe(userSelectedTo.toISOString()); + }); + + it('should handle variable expressions in timeShift', () => { + const twoHourShiftValue = '2h'; + const customTimeShift = new TestVariable({ + name: 'testShift', + value: twoHourShiftValue, + }); + const panelTime = new PanelTimeRange({ timeShift: '$testShift' }); + const sceneTimeRange = new SceneTimeRange({ from: 'now-6h', to: 'now' }); + const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime }); + const scene = new SceneFlexLayout({ + $variables: new SceneVariableSet({ + variables: [customTimeShift], + }), + $timeRange: sceneTimeRange, + children: [new SceneFlexItem({ body: panel })], + }); + + activateFullSceneTree(scene); + + const panelTimeFrom = dateTime('2019-02-11T11:00:00.000Z'); + const panelTimeTo = dateTime('2019-02-11T17:00:00.000Z'); + + panelTime.onTimeRangeChange({ + from: panelTimeFrom, + to: panelTimeTo, + raw: { from: panelTimeFrom, to: panelTimeTo }, + }); + + const expectedDashboardTimeFrom = dateTime('2019-02-11T13:00:00.000Z'); + const expectedDashboardTimeTo = dateTime('2019-02-11T19:00:00.000Z'); + + expect(sceneTimeRange.state.value.from.toISOString()).toBe(expectedDashboardTimeFrom.toISOString()); + expect(sceneTimeRange.state.value.to.toISOString()).toBe(expectedDashboardTimeTo.toISOString()); + }); + }); }); function buildAndActivateSceneFor(panelTime: PanelTimeRange) { diff --git a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx index 4449cff2ea6..5f8e2e3350e 100644 --- a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx +++ b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx @@ -113,6 +113,30 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase { expect(staticOptionsToggle).toBeInTheDocument(); }); + it('should update the variable with default datasource when opening editor', async () => { + const onRunQueryMock = jest.fn(); + const variable = new QueryVariable({ datasource: undefined, query: '' }); + + await setup({ + variable, + onRunQuery: onRunQueryMock, + }); + + await waitFor(async () => { + expect(variable.state.datasource).not.toBe(undefined); + }); + }); + it('should update the variable with default query for the selected DS', async () => { const onRunQueryMock = jest.fn(); const variable = new QueryVariable({ datasource: { uid: 'mock-ds-2', type: 'test' }, query: '' }); diff --git a/public/app/features/explore/ExploreActions.tsx b/public/app/features/explore/ExploreActions.tsx index 376cc25d715..56d8fa06776 100644 --- a/public/app/features/explore/ExploreActions.tsx +++ b/public/app/features/explore/ExploreActions.tsx @@ -1,7 +1,6 @@ import { useRegisterActions, useKBar, Action, Priority } from 'kbar'; import { useEffect, useState } from 'react'; -import { config } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { AccessControlAction } from 'app/types/accessControl'; @@ -76,7 +75,7 @@ export const ExploreActions = () => { return pane?.datasourceInstance?.uid === MIXED_DATASOURCE_NAME; }); - if (config.featureToggles.correlations && canWriteCorrelations && !hasMixed) { + if (canWriteCorrelations && !hasMixed) { actionsArr.push({ id: 'explore/correlations-editor', name: 'Correlations editor', diff --git a/public/app/features/explore/ExplorePage.tsx b/public/app/features/explore/ExplorePage.tsx index ed3c0de6d3b..a5527be8b6c 100644 --- a/public/app/features/explore/ExplorePage.tsx +++ b/public/app/features/explore/ExplorePage.tsx @@ -3,7 +3,6 @@ import { useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { config } from '@grafana/runtime'; import { ErrorBoundaryAlert, LoadingPlaceholder, useStyles2, useTheme2 } from '@grafana/ui'; import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper'; import { useGrafana } from 'app/core/context/GrafanaContext'; @@ -50,7 +49,7 @@ function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryPa const hasSplit = useSelector(isSplit); const correlationDetails = useSelector(selectCorrelationDetails); const { drawerOpened, setDrawerOpened } = useQueriesDrawerContext(); - const showCorrelationEditorBar = config.featureToggles.correlations && (correlationDetails?.editorMode || false); + const showCorrelationEditorBar = correlationDetails?.editorMode || false; useEffect(() => { //This is needed for breadcrumbs and topnav. diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx index 93822458ac7..971c55e4101 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx @@ -39,9 +39,6 @@ export const getStyles = (theme: GrafanaTheme2) => { padding: '0.25em 0.1em', textOverflow: 'ellipsis', whiteSpace: 'nowrap', - '&:hover': { - background: autoColor(theme, '#e8e8e8'), - }, }), headerLabel: css({ width: '120px', diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx index efb36f26948..4e40044bab6 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx @@ -38,9 +38,6 @@ const getStyles = (theme: GrafanaTheme2) => { color: 'inherit', display: 'flex', alignItems: 'center', - '&:hover': { - background: autoColor(theme, '#e8e8e8'), - }, }), AccordianLogsContent: css({ label: 'AccordianLogsContent', diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx index 10c31897ec6..9c80e9855b6 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx @@ -37,20 +37,15 @@ const getStyles = (theme: GrafanaTheme2) => ({ AccordianReferences: css({ label: 'AccordianReferences', position: 'relative', - marginBottom: '0.25rem', }), AccordianReferencesHeader: css({ label: 'AccordianReferencesHeader', color: 'inherit', display: 'block', padding: '0.25rem 0', - '&:hover': { - background: autoColor(theme, '#dadada'), - }, }), AccordianReferencesContent: css({ label: 'AccordianReferencesContent', - background: autoColor(theme, '#f0f0f0'), borderTop: `1px solid ${autoColor(theme, '#d8d8d8')}`, padding: '0.5rem 0.5rem 0.25rem 0.5rem', }), @@ -96,7 +91,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ debugLabel: css({ margin: '0 5px 0 5px', '&::before': { - color: '#bbb', + color: autoColor(theme, '#666'), content: 'attr(data-label)', }, }), diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx index a6c79b2d2cf..58dec09f302 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx @@ -32,8 +32,6 @@ export const getStyles = (theme: GrafanaTheme2) => { KeyValueTable: css({ label: 'KeyValueTable', background: autoColor(theme, '#fff'), - border: `1px solid ${autoColor(theme, '#ddd')}`, - marginBottom: '0.5rem', maxHeight: '450px', overflow: 'auto', }), diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/ShareSpanButton.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/ShareSpanButton.tsx index cca6e4f3562..f33691e0cd8 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/ShareSpanButton.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/ShareSpanButton.tsx @@ -1,14 +1,29 @@ -import { LinkModel } from '@grafana/data'; +import { css } from '@emotion/css'; + +import { GrafanaTheme2, LinkModel } from '@grafana/data'; import { Trans } from '@grafana/i18n'; -import { Button } from '@grafana/ui'; +import { Button, useStyles2 } from '@grafana/ui'; type Props = { focusSpanLink: LinkModel; }; +function getStyles(theme: GrafanaTheme2) { + return { + shareButton: css({ + [theme.breakpoints.down('sm')]: { + span: { + display: 'none', + }, + }, + }), + }; +} + export function ShareSpanButton(props: Props) { const { focusSpanLink } = props; const { interpolatedParams, ...linkProps } = focusSpanLink ?? {}; + const styles = useStyles2(getStyles); return ( {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} @@ -29,7 +44,7 @@ export function ShareSpanButton(props: Props) { } }} > - diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx index 8c0f82337f4..528c4ea7944 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx @@ -1,3 +1,5 @@ +import React from 'react'; + import { CoreApp, TimeRange } from '@grafana/data'; import { usePluginLinks } from '@grafana/runtime'; import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer'; @@ -25,6 +27,10 @@ const timeRange = { to: new Date(1000), } as unknown as TimeRange; +function getContent(result: React.ReactElement) { + return result.props.children.props.children[0]; +} + describe('getSpanDetailLinkButtons', () => { beforeEach(() => { jest.clearAllMocks(); @@ -55,8 +61,9 @@ describe('getSpanDetailLinkButtons', () => { app: CoreApp.Explore, }); - expect(result.props.children).toHaveLength(1); - expect(result.props.children[0].props.link.title).toBe('Logs for this span'); + const content = getContent(result); + expect(content).toHaveLength(1); + expect(content[0].props.spanLinkModel.linkModel.title).toBe('Logs for this span'); }); it('should create profile link button when profiles link exists', () => { @@ -75,8 +82,9 @@ describe('getSpanDetailLinkButtons', () => { app: CoreApp.Dashboard, }); - expect(result.props.children).toHaveLength(1); - expect(result.props.children[0].props.link.title).toBe('Profiles for this span'); + const content = getContent(result); + expect(content).toHaveLength(1); + expect(content[0].props.spanLinkModel.linkModel.title).toBe('Profiles for this span'); }); it('should create session link button when session link exists', () => { @@ -91,8 +99,9 @@ describe('getSpanDetailLinkButtons', () => { app: CoreApp.Explore, }); - expect(result.props.children).toHaveLength(1); - expect(result.props.children[0].props.link.title).toBe('Session for this span'); + const content = getContent(result); + expect(content).toHaveLength(1); + expect(content[0].props.spanLinkModel.linkModel.title).toBe('Session for this span'); }); it('should create profile drilldown button when plugin link exists', () => { @@ -121,9 +130,10 @@ describe('getSpanDetailLinkButtons', () => { app: CoreApp.Explore, }); - expect(result.props.children).toHaveLength(2); - expect(result.props.children[0].props.link.title).toBe('Profiles for this span'); - expect(result.props.children[1].props.link.title).toBe('Open in Profiles Drilldown'); + const content = getContent(result); + expect(content).toHaveLength(2); + expect(content[0].props.spanLinkModel.linkModel.title).toBe('Profiles for this span'); + expect(content[1].props.spanLinkModel.linkModel.title).toBe('Open in Profiles Drilldown'); }); it('should not create profile drilldown button when not in Explore', () => { @@ -152,8 +162,9 @@ describe('getSpanDetailLinkButtons', () => { app: CoreApp.Dashboard, }); - expect(result.props.children).toHaveLength(1); - expect(result.props.children[0].props.link.title).toBe('Profiles for this span'); + const content = getContent(result); + expect(content).toHaveLength(1); + expect(content[0].props.spanLinkModel.linkModel.title).toBe('Profiles for this span'); }); }); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx index 605280c292a..45828f7c247 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx @@ -1,11 +1,20 @@ +import { css } from '@emotion/css'; import * as React from 'react'; -import { CoreApp, IconName, LinkModel, PluginExtensionPoints, RawTimeRange, TimeRange } from '@grafana/data'; +import { + CoreApp, + GrafanaTheme2, + IconName, + LinkModel, + PluginExtensionPoints, + RawTimeRange, + TimeRange, +} from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend'; import { config, locationService, reportInteraction, usePluginLinks } from '@grafana/runtime'; import { DataSourceRef } from '@grafana/schema'; -import { DataLinkButton, Dropdown, Menu, ToolbarButton } from '@grafana/ui'; +import { Button, DataLinkButton, Dropdown, Menu, useStyles2 } from '@grafana/ui'; import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer'; import { pyroscopeProfileIdTagKey } from '../../../createSpanLink'; @@ -28,6 +37,7 @@ export type Props = { timeRange: TimeRange; createSpanLink?: SpanLinkFunc; app: CoreApp; + shareButton?: React.ReactNode; }; /** @@ -51,9 +61,10 @@ const MAX_LINKS = 3; const ABSOLUTE_LINK_PATTERN = /^https?:\/\//i; export const getSpanDetailLinkButtons = (props: Props) => { - const { span, createSpanLink, traceToProfilesOptions, timeRange, datasourceType, app } = props; + const { span, createSpanLink, traceToProfilesOptions, timeRange, datasourceType, app, shareButton } = props; let linkToProfiles: SpanLinkDef | undefined; + let content = shareButton ? <>{shareButton} : undefined; if (createSpanLink) { const links = (createSpanLink(span) || []) @@ -112,23 +123,65 @@ export const getSpanDetailLinkButtons = (props: Props) => { }); if (links.length > MAX_LINKS) { - return ; - } else { - return ( + content = ( <> - {links.map(({ linkModel, icon, className }, index) => ( - + + {shareButton} + + ); + } else if (links.length > 0) { + content = ( + <> + {links.map((spanLinkModel, index) => ( + ))} + {shareButton} ); } } - return <>; + if (!content) { + return <>; + } + + return ( + + {content} + + ); +}; + +function getResponsibleButtonStyles(theme: GrafanaTheme2) { + return css({ + [theme.breakpoints.down('sm')]: { + span: { display: 'none' }, + }, + }); +} + +const SingleLinkButton: React.FC<{ spanLinkModel: SpanLinkModel }> = ({ spanLinkModel }) => { + const styles = useStyles2(getResponsibleButtonStyles); + const { linkModel, icon, className } = spanLinkModel; + return ( + + + + ); }; const DropDownMenu = ({ links }: { links: SpanLinkModel[] }) => { - const [isOpen, setIsOpen] = React.useState(false); + const [_, setIsOpen] = React.useState(false); + const styles = useStyles2(getResponsibleButtonStyles); const menu = ( @@ -144,14 +197,15 @@ const DropDownMenu = ({ links }: { links: SpanLinkModel[] }) => { return ( - Links - + ); }; diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx index 85a15233995..238002e7975 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx @@ -14,7 +14,7 @@ import { css, cx } from '@emotion/css'; import { SpanStatusCode } from '@opentelemetry/api'; -import { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { CoreApp, @@ -114,17 +114,32 @@ const useResourceAttributesExtensionLinks = ({ const getStyles = (theme: GrafanaTheme2) => { return { + card: css({ + ':not(:empty)': { + border: '1px solid ' + theme.colors.border.weak, + '&:hover': { + border: '1px solid ' + theme.colors.border.strong, + }, + }, + borderRadius: theme.shape.radius.md, + margin: '6px', + padding: '5px', + }), header: css({ + label: 'SpanDetailHeader', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '0 1rem', marginBottom: '0.25rem', + flexDirection: 'column', }), content: css({ + label: 'SpanDetailContent', fontSize: theme.typography.bodySmall.fontSize, }), listWrapper: css({ + label: 'SpanDetailListWrapper', overflow: 'hidden', flexGrow: 1, display: 'flex', @@ -133,13 +148,25 @@ const getStyles = (theme: GrafanaTheme2) => { list: css({ textAlign: 'left', }), + spanDetailComponent: css({ + label: 'SpanDetailComponent', + display: 'flex', + flexDirection: 'column', // On bigger screens display attributes below service name + }), + serviceNameAndLinks: css({ + label: 'ServiceNameAndLinks', + display: 'flex', + width: '100%', + marginBottom: '16px', + }), operationName: css({ + label: 'SpanDetailOperationName', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '50%', - flexGrow: 0, + flexGrow: 1, flexShrink: 0, }), AccordianWarnings: css({ @@ -152,9 +179,6 @@ const getStyles = (theme: GrafanaTheme2) => { label: 'AccordianWarningsHeader', background: autoColor(theme, '#fff7e6'), padding: '0.25rem 0.5rem', - '&:hover': { - background: autoColor(theme, '#ffe7ba'), - }, }), AccordianWarningsHeaderOpen: css({ label: 'AccordianWarningsHeaderOpen', @@ -180,6 +204,7 @@ const getStyles = (theme: GrafanaTheme2) => { letterSpacing: '0.25px', margin: '0.5em 0 -0.75em', textAlign: 'right', + clear: 'both', }), debugLabel: css({ label: 'debugLabel', @@ -314,6 +339,8 @@ export default function SpanDetail(props: SpanDetailProps) { : []), ]; + const mainContainerRef = useRef(null); + const styles = useStyles2(getStyles); if (span.kind) { overviewItems.push({ @@ -358,15 +385,6 @@ export default function SpanDetail(props: SpanDetailProps) { }); } - const linksComponent = getSpanDetailLinkButtons({ - span, - createSpanLink, - datasourceType, - traceToProfilesOptions, - timeRange, - app, - }); - const { interpolatedParams, ...focusSpanLink } = createFocusSpanLink(traceID, spanID); const resourceLinksGetter = useResourceAttributesExtensionLinks({ process, @@ -376,102 +394,132 @@ export default function SpanDetail(props: SpanDetailProps) { timeRange, }); + const linksComponent = getSpanDetailLinkButtons({ + span, + createSpanLink, + datasourceType, + traceToProfilesOptions, + timeRange, + app, + shareButton: , + }); + + const listOfContentCards = []; + + listOfContentCards.push( + tagsToggle(spanID)} + /> + ); + + if (process.tags) { + listOfContentCards.push( + processToggle(spanID)} + /> + ); + } + + if (logs && logs.length > 0) { + listOfContentCards.push( + logsToggle(spanID)} + onItemToggle={(logItem) => logItemToggle(spanID, logItem)} + timestamp={traceStartTime} + /> + ); + } + + if (warnings && warnings.length > 0) { + listOfContentCards.push( + ({ + key: '', + value: warning, + type: 'warning', + }))} + onlyValues={true} + showSummary={false} + showCountBadge={true} + isOpen={isWarningsOpen} + onToggle={() => warningsToggle(spanID)} + label={t('explore.span-detail.label-warnings', 'Warnings')} + /> + ); + } + + if (stackTraces?.length) { + listOfContentCards.push( + ({ + key: '', + value: stackTrace, + type: 'code', + }))} + onlyValues={true} + showSummary={false} + showCountBadge={true} + isOpen={isStackTracesOpen} + onToggle={() => stackTracesToggle(spanID)} + label={t('explore.span-detail.label-stack-trace', 'Stack trace')} + /> + ); + } + + if (references && references.length > 0 && (references.length > 1 || references[0].refType !== 'CHILD_OF')) { + listOfContentCards.push( + referencesToggle(spanID)} + onItemToggle={(reference) => referenceItemToggle(spanID, reference)} + createFocusSpanLink={createFocusSpanLink} + /> + ); + } + + if (span.tags.some((tag) => tag.key === pyroscopeProfileIdTagKey)) { + listOfContentCards.push( + + ); + } + return ( -
+
-
- {operationName} -
+
+
+ {operationName} +
+ {linksComponent} +
-
-
{linksComponent}
-
- tagsToggle(spanID)} - /> - {process.tags && ( - processToggle(spanID)} - /> - )} -
- {logs && logs.length > 0 && ( - logsToggle(spanID)} - onItemToggle={(logItem) => logItemToggle(spanID, logItem)} - timestamp={traceStartTime} - /> - )} - - {warnings && warnings.length > 0 && ( - ({ - key: '', - value: warning, - type: 'text', - }))} - showSummary={false} - showCountBadge={true} - isOpen={isWarningsOpen} - onlyValues={true} - onToggle={() => warningsToggle(spanID)} - label={t('explore.span-detail.warnings', 'Warnings')} - /> - )} - - {stackTraces?.length ? ( - ({ - key: '', - value: stackTrace, - type: 'code', - }))} - onlyValues={true} - showSummary={false} - showCountBadge={true} - isOpen={isStackTracesOpen} - onToggle={() => stackTracesToggle(spanID)} - label={t('explore.span-detail.label-stack-trace', 'Stack trace')} - /> - ) : null} - - {references && references.length > 0 && (references.length > 1 || references[0].refType !== 'CHILD_OF') && ( - referencesToggle(spanID)} - onItemToggle={(reference) => referenceItemToggle(spanID, reference)} - createFocusSpanLink={createFocusSpanLink} - /> - )} - {span.tags.some((tag) => tag.key === pyroscopeProfileIdTagKey) && ( - - )} + {/* TODO: fix keyboard a11y */} @@ -507,3 +555,52 @@ export const getAbsoluteTime = (startTime: number, timeZone: TimeZone) => { const absoluteTime = match[1] ? match[1] : dateStr; return ` (${absoluteTime})`; }; + +const CardsContainer = ({ + listOfContentCards, + mainContainerRef, +}: { + listOfContentCards: React.ReactNode[]; + mainContainerRef?: React.RefObject; +}) => { + const styles = useStyles2(getStyles); + + const useTwoColumns = + mainContainerRef && mainContainerRef.current && mainContainerRef.current.getBoundingClientRect().width > 1000; + + if (useTwoColumns) { + return ( + <> +
+ {listOfContentCards.map((card, index) => + index % 2 === 0 ? ( +
+ {card} +
+ ) : null + )} +
+ +
+ {listOfContentCards.map((card, index) => + index % 2 === 1 ? ( +
+ {card} +
+ ) : null + )} +
+ + ); + } + + return ( +
+ {listOfContentCards.map((card, index) => ( +
+ {card} +
+ ))} +
+ ); +}; diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx index 0a72d621269..d3988d68e95 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx @@ -66,6 +66,7 @@ function setupToolbarExtensionPoint( describe('ToolbarExtensionPoint', () => { describe('with extension points', () => { beforeAll(() => { + contextSrvMock.hasPermission.mockReturnValue(false); usePluginLinksMock.mockReturnValue({ links: [ { @@ -183,6 +184,7 @@ describe('ToolbarExtensionPoint', () => { describe('with extension points without categories', () => { beforeAll(() => { + contextSrvMock.hasPermission.mockReturnValue(false); usePluginLinksMock.mockReturnValue({ links: [ { @@ -257,6 +259,7 @@ describe('ToolbarExtensionPoint', () => { describe('with multiple queryless apps links', () => { beforeAll(() => { + contextSrvMock.hasPermission.mockReturnValue(false); usePluginLinksMock.mockReturnValue({ links: [ { @@ -323,6 +326,7 @@ describe('ToolbarExtensionPoint', () => { describe('with single queryless apps link', () => { beforeAll(() => { + contextSrvMock.hasPermission.mockReturnValue(false); usePluginLinksMock.mockReturnValue({ links: [ { diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx index 3c3a5f2286a..070e48caa47 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx @@ -1,7 +1,7 @@ import { ReactElement, useMemo, useState } from 'react'; import { type PluginExtensionLink, PluginExtensionPoints, RawTimeRange, getTimeZone } from '@grafana/data'; -import { config, reportInteraction, usePluginLinks } from '@grafana/runtime'; +import { reportInteraction, usePluginLinks } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types/accessControl'; @@ -109,12 +109,7 @@ function useExtensionPointContext(props: Props): PluginExtensionExploreContext { data: queryResponse, timeRange: range.raw, timeZone: getTimeZone({ timeZone }), - shouldShowAddCorrelation: - config.featureToggles.correlations === true && - canWriteCorrelations && - !isCorrelationsEditorMode && - isLeftPane && - numUniqueIds === 1, + shouldShowAddCorrelation: canWriteCorrelations && !isCorrelationsEditorMode && isLeftPane && numUniqueIds === 1, }; }, [ exploreId, diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index 98e6fd7f980..d78b56f89a5 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -7,7 +7,7 @@ import { GrafanaTheme2, PanelData, PanelModel, VisualizationSuggestion } from '@ import { Trans } from '@grafana/i18n'; import { useStyles2 } from '@grafana/ui'; -import { getAllSuggestions } from '../../state/getAllSuggestions'; +import { getAllSuggestions } from '../../suggestions/getAllSuggestions'; import { VisualizationSuggestionCard } from './VisualizationSuggestionCard'; import { VizTypeChangeDetails } from './types'; diff --git a/public/app/features/panel/state/getAllSuggestions.test.ts b/public/app/features/panel/suggestions/getAllSuggestions.test.ts similarity index 98% rename from public/app/features/panel/state/getAllSuggestions.test.ts rename to public/app/features/panel/suggestions/getAllSuggestions.test.ts index 0ce616f9309..e0b194e1901 100644 --- a/public/app/features/panel/state/getAllSuggestions.test.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.test.ts @@ -8,6 +8,7 @@ import { toDataFrame, VisualizationSuggestion, } from '@grafana/data'; +import { GraphFieldConfig, ReduceDataOptions } from '@grafana/schema'; import { config } from 'app/core/config'; import { SuggestionName } from 'app/types/suggestions'; @@ -32,7 +33,7 @@ config.panels['text'] = { class ScenarioContext { data: DataFrame[] = []; - suggestions: VisualizationSuggestion[] = []; + suggestions: Array> = []; setData(scenarioData: DataFrame[]) { this.data = scenarioData; diff --git a/public/app/features/panel/state/getAllSuggestions.ts b/public/app/features/panel/suggestions/getAllSuggestions.ts similarity index 100% rename from public/app/features/panel/state/getAllSuggestions.ts rename to public/app/features/panel/suggestions/getAllSuggestions.ts diff --git a/public/app/features/variables/query/QueryVariableEditor.test.tsx b/public/app/features/variables/query/QueryVariableEditor.test.tsx index 0322a73b048..44b2d98a0f9 100644 --- a/public/app/features/variables/query/QueryVariableEditor.test.tsx +++ b/public/app/features/variables/query/QueryVariableEditor.test.tsx @@ -29,7 +29,12 @@ ds.variables = { }; const setupTestContext = async (options: Partial) => { - const variableDefaults: Partial = { rootStateKey: 'key' }; + const variableDefaults: Partial = { + rootStateKey: 'key', + // adds a default datasource in old arch tests so they continue passing + // in new scenes arch the datasource will be calculated if not provided + datasource: { uid: 'uid', type: 'type' }, + }; const extended = { VariableQueryEditor: LegacyVariableQueryEditor, dataSource: ds, diff --git a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.tsx index 757385c374c..88be530bdf6 100644 --- a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/ConfigEditor.tsx @@ -12,7 +12,7 @@ import { DataSourceTestFailed, GrafanaTheme2, } from '@grafana/data'; -import { ConfigSection } from '@grafana/plugin-ui'; +import { ConfigSection, DataSourceDescription } from '@grafana/plugin-ui'; import { getAppEvents, usePluginInteractionReporter, getDataSourceSrv, config } from '@grafana/runtime'; import { Alert, Input, FieldProps, Field, Divider, useStyles2 } from '@grafana/ui'; @@ -94,6 +94,11 @@ export const ConfigEditor = (props: Props) => { {warning} )} + + ( }, [query.step, datasource]); return ( - + ( )} - + ); } ); diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index 2ded44441f6..fc130e6089a 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -390,7 +390,8 @@ export class TempoDatasource extends DataSourceWithBackend({ + const list = builder.getListAppender({ name: SuggestionName.Gauge, pluginId: 'gauge', options: {}, @@ -36,8 +37,8 @@ export class GaugeSuggestionsSupplier { }, cardOptions: { previewModifier: (s) => { - if (s.options!.reduceOptions.values) { - s.options!.reduceOptions.limit = 2; + if (s.options?.reduceOptions?.values) { + s.options.reduceOptions.limit = 2; } }, }, diff --git a/public/app/plugins/panel/radialbar/suggestions.ts b/public/app/plugins/panel/radialbar/suggestions.ts index 468451be9f6..8471dc27cbe 100644 --- a/public/app/plugins/panel/radialbar/suggestions.ts +++ b/public/app/plugins/panel/radialbar/suggestions.ts @@ -1,5 +1,6 @@ import { VisualizationSuggestionsBuilder } from '@grafana/data'; -import { FieldColorModeId } from '@grafana/schema/dist/esm/index.gen'; +import { FieldColorModeId } from '@grafana/schema'; +import { GraphFieldConfig } from '@grafana/ui'; import { SuggestionName } from 'app/types/suggestions'; import { Options } from './panelcfg.gen'; @@ -17,7 +18,7 @@ export class GaugeSuggestionsSupplier { return; } - const list = builder.getListAppender({ + const list = builder.getListAppender({ name: SuggestionName.Gauge, pluginId: 'gauge', options: {}, @@ -27,8 +28,8 @@ export class GaugeSuggestionsSupplier { }, cardOptions: { previewModifier: (s) => { - if (s.options!.reduceOptions.values) { - s.options!.reduceOptions.limit = 2; + if (s.options?.reduceOptions?.values) { + s.options.reduceOptions.limit = 2; } }, }, diff --git a/public/app/plugins/panel/stat/suggestions.ts b/public/app/plugins/panel/stat/suggestions.ts index c07fa1d1b07..00af3220135 100644 --- a/public/app/plugins/panel/stat/suggestions.ts +++ b/public/app/plugins/panel/stat/suggestions.ts @@ -1,5 +1,5 @@ import { VisualizationSuggestionsBuilder } from '@grafana/data'; -import { BigValueColorMode, BigValueGraphMode } from '@grafana/schema'; +import { BigValueColorMode, BigValueGraphMode, GraphFieldConfig } from '@grafana/schema'; import { SuggestionName } from 'app/types/suggestions'; import { Options } from './panelcfg.gen'; @@ -12,7 +12,7 @@ export class StatSuggestionsSupplier { return; } - const list = builder.getListAppender({ + const list = builder.getListAppender({ name: SuggestionName.Stat, pluginId: 'stat', options: {}, @@ -25,8 +25,8 @@ export class StatSuggestionsSupplier { }, cardOptions: { previewModifier: (s) => { - if (s.options!.reduceOptions.values) { - s.options!.reduceOptions.limit = 1; + if (s.options?.reduceOptions?.values) { + s.options.reduceOptions.limit = 1; } }, }, diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index bb0a93310a8..24f6be1672e 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -12,7 +12,13 @@ import { } from '@grafana/data'; import { PanelDataErrorView } from '@grafana/runtime'; import { TooltipDisplayMode, VizOrientation } from '@grafana/schema'; -import { EventBusPlugin, KeyboardPlugin, TooltipPlugin2, usePanelContext } from '@grafana/ui'; +import { + EventBusPlugin, + KeyboardPlugin, + TooltipPlugin2, + XAxisInteractionAreaPlugin, + usePanelContext, +} from '@grafana/ui'; import { TimeRange2, TooltipHoverMode } from '@grafana/ui/internal'; import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; import { config } from 'app/core/config'; @@ -141,6 +147,7 @@ export const TimeSeriesPanel = ({ {cursorSync !== DashboardCursorSync.Off && ( )} + {options.tooltip.mode !== TooltipDisplayMode.None && ( - config.featureToggles.correlations - ? import(/* webpackChunkName: "CorrelationsPage" */ 'app/features/correlations/CorrelationsPage') - : import( - /* webpackChunkName: "CorrelationsFeatureToggle" */ 'app/features/correlations/CorrelationsFeatureToggle' - ) + component: SafeDynamicImport( + () => import(/* webpackChunkName: "CorrelationsPage" */ 'app/features/correlations/CorrelationsPage') ), }, { diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index bb5e3e4dedb..9c098a1df95 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -6798,9 +6798,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Další podrobnosti o chybě" }, - "footer": { - "add-csv-or-spreadsheet": "Přidat soubor CSV nebo tabulku" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Zdroj dat Adobe Analytics", @@ -11869,6 +11866,11 @@ "path-label": "Cesta k úložišti", "path-required": "Cesta k úložišti je povinná" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Po nastavení bude vytvořena nová složka Grafany a synchronizována s externím úložištěm. Pokud jsou v externím úložišti přítomny nějaké zdroje, budou zajištěny v této nové složce. Všechny nové zdroje vytvořené v této složce budou uloženy a upraveny v externím úložišti.", diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index e3fae8d9105..1b9cccb64ba 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Weitere Details zum Fehler" }, - "footer": { - "add-csv-or-spreadsheet": "CSV oder Tabelle hinzufügen" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics-Datenquelle", @@ -11763,6 +11760,11 @@ "path-label": "Repository-Pfad", "path-required": "Repository-Pfad erforderlich" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Nach der Einrichtung wird ein neuer Grafana-Ordner erstellt und mit dem externen Speicher synchronisiert. Wenn im externen Speicher Ressourcen vorhanden sind, werden sie in diesem neuen Ordner bereitgestellt. Alle in diesem Ordner erstellten neuen Ressourcen werden im externen Speicher gespeichert und versioniert.", diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index b59d15da0bd..2707b681ae4 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -4378,8 +4378,6 @@ "next-button": "Next", "save-button": "Save" }, - "page-content": "To enable Correlations, add it in the Grafana config:", - "page-heading": "Correlations are disabled", "query-editor": { "control-rules": "The selected target data source must export a query editor.", "data-source-text": "Please select a target data source first.", @@ -7467,6 +7465,7 @@ "label-resource-attributes": "Resource attributes", "label-span-attributes": "Span attributes", "label-stack-trace": "Stack trace", + "label-warnings": "Warnings", "overview-items": { "label": { "child-count": "Child Count:", @@ -7475,8 +7474,7 @@ "start-time": "Start Time:" } }, - "share-span": "Share", - "warnings": "Warnings" + "share-span": "Share" }, "span-filters": { "aria-label-select-max-span-operator": "Select max span operator", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index a3be9fc5549..9b4353f5c12 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Más detalles sobre el error" }, - "footer": { - "add-csv-or-spreadsheet": "Añadir CSV u hoja de cálculo" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Fuente de datos de Adobe Analytics", @@ -11763,6 +11760,11 @@ "path-label": "Ruta del repositorio", "path-required": "La ruta del repositorio es obligatoria" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Después de la configuración, se creará una nueva carpeta de Grafana y se sincronizará con el almacenamiento externo. Si hay recursos presentes en el almacenamiento externo, se aprovisionarán en esta nueva carpeta. Todos los recursos nuevos creados en esta carpeta se almacenarán y versionarán en un almacenamiento externo.", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 7f571ea83cd..79a47ec9cd3 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Plus de détails sur l’erreur" }, - "footer": { - "add-csv-or-spreadsheet": "Ajouter un fichier CSV ou une feuille de calcul" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Source de données Adobe Analytics", @@ -11763,6 +11760,11 @@ "path-label": "Chemin du dépôt", "path-required": "Le chemin du dépôt est requis" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Après la configuration, un nouveau dossier Grafana sera créé et synchronisé avec un stockage externe. Si des ressources sont présentes dans le stockage externe, elles seront mises en service dans ce nouveau dossier. Toutes les nouvelles ressources créées dans ce dossier seront stockées et versionnées dans un stockage externe.", diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 4867f45641f..f1696f4a38d 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "További részletek a hibáról" }, - "footer": { - "add-csv-or-spreadsheet": "CSV vagy táblázat hozzáadása" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics-adatforrás", @@ -11763,6 +11760,11 @@ "path-label": "Adattár elérési útja", "path-required": "Az adattár elérési útvonalának megadása kötelező" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "A beállítás után egy új Grafana-mappa jön létre, és szinkronizálódik a külső tárolóval. Ha vannak erőforrások a külső tárolóban, azok ebbe az új mappába kerülnek. A mappában létrehozott összes új erőforrás külső tárolóban lesz tárolva és verziószámozva.", diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 64918e98bed..3d1eb7ba91f 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -6726,9 +6726,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Detail lebih lanjut tentang kesalahan" }, - "footer": { - "add-csv-or-spreadsheet": "Tambahkan csv atau spreadsheet" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Sumber data Adobe Analytics", @@ -11710,6 +11707,11 @@ "path-label": "Jalur Repositori", "path-required": "Jalur repositori wajib diisi" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Setelah penyiapan, folder Grafana baru akan dibuat dan disinkronkan dengan penyimpanan eksternal. Jika ada sumber daya yang ada di penyimpanan eksternal, sumber daya akan disediakan ke folder baru ini. Semua sumber daya baru yang dibuat dalam folder ini akan disimpan dan diberi versi dalam penyimpanan eksternal.", diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 637ec3ef191..a969743972d 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Maggiori dettagli sull'errore" }, - "footer": { - "add-csv-or-spreadsheet": "Aggiungi csv o foglio di calcolo" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Origine dati Adobe Analytics", @@ -11763,6 +11760,11 @@ "path-label": "Percorso repository", "path-required": "Il percorso del repository è obbligatorio" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Dopo la configurazione, verrà creata una nuova cartella Grafana, che sarà sincronizzata con la memoria esterna. Se sono presenti risorse nella memoria esterna, verranno fornite a questa nuova cartella. Tutte le nuove risorse create in questa cartella saranno salvate e riceveranno un numero di versione in una memoria esterna.", diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 9d065377154..c781e2ec4a1 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -6726,9 +6726,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "エラーの詳細" }, - "footer": { - "add-csv-or-spreadsheet": "CSVまたはスプレッドシートを追加" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analyticsデータソース", @@ -11710,6 +11707,11 @@ "path-label": "リポジトリパス", "path-required": "リポジトリパスが必要です" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "セットアップ後、新しいGrafanaフォルダが作成され、外部ストレージと同期されます。外部ストレージにリソースがある場合、それらはこの新しいフォルダにプロビジョニングされます。このフォルダで作成された新しいリソースはすべて、外部ストレージに保存されバージョン管理されます。", diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index b1daa0f19a2..40b1b458cb9 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -6726,9 +6726,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "오류에 대한 자세한 정보" }, - "footer": { - "add-csv-or-spreadsheet": "CSV 또는 스프레드시트 추가" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics 데이터 소스", @@ -11710,6 +11707,11 @@ "path-label": "리포지토리 경로", "path-required": "리포지토리 경로는 필수 항목입니다" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "설정 후 새 Grafana 폴더가 생성되고 외부 스토리지와 동기화됩니다. 외부 스토리지에 리소스가 있는 경우 해당 리소스가 이 새 폴더에 프로비저닝됩니다. 이 폴더에 생성된 모든 새 리소스는 외부 스토리지에 저장되고 버전이 관리됩니다.", diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index c399b5f60d2..80dda357e6c 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Meer informatie over de fout" }, - "footer": { - "add-csv-or-spreadsheet": "Csv of spreadsheet toevoegen" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics-gegevensbron", @@ -11763,6 +11760,11 @@ "path-label": "Repository-pad", "path-required": "Repository-pad is vereist" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Na het instellen wordt een nieuwe Grafana-map gemaakt en gesynchroniseerd met externe opslag. Als er bronnen aanwezig zijn in externe opslag, worden deze in deze nieuwe map ingericht. Alle nieuwe bronnen die in deze map worden gemaakt, worden opgeslagen en in versies beheerd in externe opslag.", diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index a4a11d378cf..ab9d515dadf 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -6798,9 +6798,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Więcej informacji o błędzie" }, - "footer": { - "add-csv-or-spreadsheet": "Dodaj plik CSV lub arkusz kalkulacyjny" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Źródło danych Adobe Analytics", @@ -11869,6 +11866,11 @@ "path-label": "Ścieżka do repozytorium", "path-required": "Ścieżka do repozytorium jest wymagana" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Po konfiguracji utworzony zostanie nowy folder Grafany, który zostanie zsynchronizowany z zewnętrzną pamięcią masową. Jeśli w zewnętrznej pamięci masowej są dostępne zasoby, zostaną one przydzielone do tego nowego folderu. Wszystkie nowe zasoby utworzone w tym folderze będą przechowywane i wersjonowane w zewnętrznej pamięci masowej.", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index b878fecb6aa..4d6c3429b55 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Mais detalhes sobre o erro" }, - "footer": { - "add-csv-or-spreadsheet": "Adicionar csv ou planilha" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Fonte de dados do Adobe Analytics", @@ -11763,6 +11760,11 @@ "path-label": "Diretório do repositório", "path-required": "O diretório do repositório é obrigatório" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Após a configuração, uma nova pasta Grafana será criada e sincronizada com o armazenamento externo. Se algum recurso estiver presente no armazenamento externo, ele será provisionado para esta nova pasta. Todos os novos recursos criados nesta pasta serão armazenados e versionados no armazenamento externo.", diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 81a7c3b856e..df86672dd25 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Mais detalhes sobre o erro" }, - "footer": { - "add-csv-or-spreadsheet": "Adicionar csv ou folha de cálculo" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Origem de dados do Adobe Analytics", @@ -11763,6 +11760,11 @@ "path-label": "Caminho do repositório", "path-required": "O caminho do repositório é obrigatório" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Após a configuração, uma nova pasta Grafana será criada e sincronizada com o armazenamento externo. Se houver recursos presentes no armazenamento externo, estes serão aprovisionados para esta nova pasta. Todos os novos recursos criados nesta pasta serão armazenados e versionados no armazenamento externo.", diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 528cfeaba88..5864941ec5c 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -6798,9 +6798,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Подробнее об ошибке" }, - "footer": { - "add-csv-or-spreadsheet": "Добавить CSV-файл или электронную таблицу" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Источник данных Adobe Analytics", @@ -11869,6 +11866,11 @@ "path-label": "Путь к репозиторию", "path-required": "Требуется указать путь к репозиторию" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "После настройки будет создана новая папка Grafana, которая синхронизируется с внешним хранилищем. Если во внешнем хранилище есть ресурсы, они будут перемещены в эту новую папку. Все новые ресурсы, созданные в этой папке, будут храниться с присвоением версии во внешнем хранилище.", diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 19e5aa10a9b..9a9260ef5a2 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Mer information om felet" }, - "footer": { - "add-csv-or-spreadsheet": "Lägg till csv eller kalkylblad" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics-datakälla", @@ -11763,6 +11760,11 @@ "path-label": "Sökväg till lagringsplats", "path-required": "Sökväg till lagringsplats krävs" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Efter installationen kommer en ny Grafana-mapp att skapas och synkroniseras med extern lagring. Om några resurser finns i extern lagring kommer de att provisioneras till den här nya mappen. Alla nya resurser som skapas i den här mappen kommer att sparas och versionshanteras i extern lagring.", diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 8e8b0e9b36b..f027d72a81e 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -6750,9 +6750,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "Hata hakkında daha fazla bilgi" }, - "footer": { - "add-csv-or-spreadsheet": "CSV veya elektronik tablo ekle" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics veri kaynağı", @@ -11763,6 +11760,11 @@ "path-label": "Depo Yolu", "path-required": "Depo yolu gereklidir" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "Kurulumdan sonra yeni bir Grafana klasörü oluşturulacak ve harici depolama ile senkronize edilecek. Harici depolamada bulunan kaynaklar bu yeni klasöre sağlanacak. Bu klasörde oluşturulan tüm yeni kaynaklar haricî depolamada saklanacak ve sürümlendirilecektir.", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index b1dcd4ccf2f..0e66d9ee83b 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -6726,9 +6726,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "有关错误的更多详情" }, - "footer": { - "add-csv-or-spreadsheet": "添加 csv 或电子表格" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics 数据源", @@ -11710,6 +11707,11 @@ "path-label": "存储库路径", "path-required": "存储库路径为必填项" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "设置后,将创建一个新的 Grafana 文件夹,并与外部存储同步。如果外部存储中存在任何资源,它们将被预配到此新文件夹中。在此文件夹中创建的所有新资源都将存储在外部存储中并进行版本控制。", diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index a0bbdcef7de..8eecca2042f 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -6726,9 +6726,6 @@ "error-details-link": { "aria-label-more-details-about-the-error": "有關錯誤的更多詳細資料" }, - "footer": { - "add-csv-or-spreadsheet": "新增 CSV 或試算表" - }, "get-enterprise-phantom-plugins": { "description": { "adobe-analytics-datasource": "Adobe Analytics 資料來源", @@ -11710,6 +11707,11 @@ "path-label": "儲存庫路徑", "path-required": "儲存庫路徑為必填" }, + "message": { + "show-less": "", + "show-more": "", + "truncated": "" + }, "mode-options": { "folder": { "description": "設定後,將建立新的 Grafana 資料夾,並與外部儲存空間同步。如果外部儲存空間中存在任何資源,則會將其佈建到此新資料夾。在此資料夾中建立的所有新資源都將儲存在外部儲存空間中,並進行版本控制。", diff --git a/scripts/grafana-server/custom.ini b/scripts/grafana-server/custom.ini index c2e0f355df6..3e972f08fcd 100644 --- a/scripts/grafana-server/custom.ini +++ b/scripts/grafana-server/custom.ini @@ -40,6 +40,5 @@ host = localhost:7777 developer_mode = true ; Enable developer mode to use in-memory implementations of 3rdparty services needed. [secrets_manager] -register_api_server = true run_secrets_db_migrations = true run_data_key_migration = true diff --git a/yarn.lock b/yarn.lock index 2055cfc9cdc..2543e2bf755 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1473,7 +1473,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.25.6, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.7, @babel/runtime@npm:^7.27.0, @babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.7": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.25.6, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.26.7, @babel/runtime@npm:^7.27.0, @babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.7": version: 7.28.4 resolution: "@babel/runtime@npm:7.28.4" checksum: 10/6c9a70452322ea80b3c9b2a412bcf60771819213a67576c8cec41e88a95bb7bf01fc983754cda35dc19603eef52df22203ccbf7777b9d6316932f9fb77c25163 @@ -2920,7 +2920,7 @@ __metadata: "@grafana/schema": "workspace:*" "@grafana/ui": "workspace:*" "@lezer/common": "npm:1.2.3" - "@lezer/lr": "npm:1.4.2" + "@lezer/lr": "npm:1.4.3" "@opentelemetry/api": "npm:1.9.0" "@opentelemetry/exporter-collector": "npm:0.25.0" "@opentelemetry/semantic-conventions": "npm:1.37.0" @@ -3057,9 +3057,9 @@ __metadata: languageName: unknown linkType: soft -"@grafana/assistant@npm:0.1.1": - version: 0.1.1 - resolution: "@grafana/assistant@npm:0.1.1" +"@grafana/assistant@npm:0.1.4": + version: 0.1.4 + resolution: "@grafana/assistant@npm:0.1.4" peerDependencies: "@grafana/data": ">=12.1.0" "@grafana/runtime": ">=12.1.0" @@ -3067,27 +3067,27 @@ __metadata: "@grafana/ui": ">=12.1.0" react: ">=18.0.0" rxjs: ">=7.0.0" - checksum: 10/33e5b3f59b3b7a747a736f36183c77014214fa6e8048faac7a680dbba64833b1827dd7ef2e8c1ddf566a31829347449579ef6255811586b39914ca0510bb34a5 + checksum: 10/1435e4552763e88a2d4715f2e529d1a247d949563567bfb46405a2a3e348b98dc08b32c96214c2d00c46e185f92a76da8d29422b1b70edbd8447e02b3b8cf3d0 languageName: node linkType: hard -"@grafana/async-query-data@npm:0.4.1": - version: 0.4.1 - resolution: "@grafana/async-query-data@npm:0.4.1" +"@grafana/async-query-data@npm:0.4.2": + version: 0.4.2 + resolution: "@grafana/async-query-data@npm:0.4.2" dependencies: semver: "npm:^7.6.3" tslib: "npm:^2.8.0" - checksum: 10/f75d3114f15b91227e23fa8214ca55ec00f5b6bce0ebb977ab14ad197ae1ab46126e9fdcce9af5816774b13e2797e5e26283f6a14d3f57420a08b2ded64a6515 + checksum: 10/e54477b1ae15a7f5944474eb50851f73ca4ee6ece10795469a82200f77feebd74596b76e47e643525e1172675cfe374705161931909c86e72ba271a6876b84e0 languageName: node linkType: hard -"@grafana/aws-sdk@npm:0.7.1": - version: 0.7.1 - resolution: "@grafana/aws-sdk@npm:0.7.1" +"@grafana/aws-sdk@npm:0.8.3": + version: 0.8.3 + resolution: "@grafana/aws-sdk@npm:0.8.3" dependencies: - "@grafana/async-query-data": "npm:0.4.1" - "@grafana/plugin-ui": "npm:^0.10.1" - checksum: 10/3b03b2bc6a7174d7b7e8aae77d62d70e87fb694781e69c16bb75312071c2adac9c6cfa2a51c0881cc302c6630a7b94adbb2696e0dc674e43cc9de8954ac761bf + "@grafana/async-query-data": "npm:0.4.2" + "@grafana/plugin-ui": "npm:^0.11.0" + checksum: 10/e2b7ab725f1602831857ad63adb314ec62b9b4d08404f71ffa2ce278df0c364f5ad7a9a83f5e8d7e646922124f812cdc2a7035bff4a952a9f455eaf2c9a5992a languageName: node linkType: hard @@ -3452,7 +3452,7 @@ __metadata: languageName: node linkType: hard -"@grafana/plugin-ui@npm:^0.10.1, @grafana/plugin-ui@npm:^0.10.10": +"@grafana/plugin-ui@npm:^0.10.10": version: 0.10.10 resolution: "@grafana/plugin-ui@npm:0.10.10" dependencies: @@ -3480,6 +3480,34 @@ __metadata: languageName: node linkType: hard +"@grafana/plugin-ui@npm:^0.11.0, @grafana/plugin-ui@npm:^0.11.1": + version: 0.11.1 + resolution: "@grafana/plugin-ui@npm:0.11.1" + dependencies: + "@emotion/css": "npm:^11.11.2" + "@hello-pangea/dnd": "npm:^17.0.0" + "@react-awesome-query-builder/ui": "npm:^6.6.4" + "@types/prismjs": "npm:^1.26.4" + lodash: "npm:^4.17.21" + prismjs: "npm:^1.30.0" + react-calendar: "npm:^4.8.0" + react-popper-tooltip: "npm:^4.4.2" + react-use: "npm:^17.3.1" + react-virtualized-auto-sizer: "npm:^1.0.6" + sql-formatter-plus: "npm:^1.3.6" + uuid: "npm:^11.0.0" + peerDependencies: + "@grafana/data": ">=10.4.0" + "@grafana/e2e-selectors": ">=10.4.0" + "@grafana/runtime": ">=10.4.0" + "@grafana/ui": ">=10.4.0" + react: ^18.2.0 + react-dom: ^18.2.0 + rxjs: ^7.8.1 + checksum: 10/e22f79ca453338b37ce3778ee71b253aaa6dfb8e3c9e77a4f4d26e5ed6f5e618c453ebffef713e12a75d5aa63dce3209389fb38a27727882007d8ba4b6bd63c6 + languageName: node + linkType: hard + "@grafana/prometheus@workspace:*, @grafana/prometheus@workspace:packages/grafana-prometheus": version: 0.0.0-use.local resolution: "@grafana/prometheus@workspace:packages/grafana-prometheus" @@ -3497,7 +3525,7 @@ __metadata: "@leeoniya/ufuzzy": "npm:1.0.19" "@lezer/common": "npm:1.2.3" "@lezer/highlight": "npm:1.2.1" - "@lezer/lr": "npm:1.4.2" + "@lezer/lr": "npm:1.4.3" "@prometheus-io/lezer-promql": "npm:0.305.0" "@reduxjs/toolkit": "npm:2.9.0" "@rollup/plugin-dynamic-import-vars": "npm:2.1.5" @@ -3596,11 +3624,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:^6.42.1": - version: 6.42.1 - resolution: "@grafana/scenes-react@npm:6.42.1" +"@grafana/scenes-react@npm:^6.46.0": + version: 6.46.0 + resolution: "@grafana/scenes-react@npm:6.46.0" dependencies: - "@grafana/scenes": "npm:6.42.1" + "@grafana/scenes": "npm:6.46.0" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3612,7 +3640,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/190289561d343a7a2d9d036f9b23abb063cee20f3a3e9e9275d8f11ef2e0df9fbd256e4687bf5a7b8f59aa79ae2bbc05bbb009876f582ebaeed97a0f52492907 + checksum: 10/be082f31c14e636efe6c60f98771a03b94606c6210a1f90d64a1c4ee8429ccb5aaa9e401092f4f6d728befeec13051d9811ec894bdee9b3c39865a5ebc2bba41 languageName: node linkType: hard @@ -3642,9 +3670,9 @@ __metadata: languageName: node linkType: hard -"@grafana/scenes@npm:6.42.1, @grafana/scenes@npm:^6.42.1": - version: 6.42.1 - resolution: "@grafana/scenes@npm:6.42.1" +"@grafana/scenes@npm:6.46.0, @grafana/scenes@npm:^6.46.0": + version: 6.46.0 + resolution: "@grafana/scenes@npm:6.46.0" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3664,7 +3692,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/f1f455f823c01324942fef9feb256b04cb209d486a43e6dd683e984abb88f078d8e2a9d85e31619e37c9c3ee4356c97707d258a608f9b64ae8df097813bf178f + checksum: 10/c4b2b3113da0ea9b5745b4d560e73ad8877934138cb2862ed0fecf0954a36dc7aee2b15e12b802e0884ff8f56756935acc545019753f99a2900ba19a620a4e96 languageName: node linkType: hard @@ -5193,12 +5221,12 @@ __metadata: languageName: node linkType: hard -"@lezer/lr@npm:1.4.2": - version: 1.4.2 - resolution: "@lezer/lr@npm:1.4.2" +"@lezer/lr@npm:1.4.3": + version: 1.4.3 + resolution: "@lezer/lr@npm:1.4.3" dependencies: "@lezer/common": "npm:^1.0.0" - checksum: 10/f7b505906c8d8df14c07866553cf3dae1e065b1da8b28fbb4193fd67ab8d187eb45f92759e29a2cfe4283296f0aa864b38a0a91708ecfc3e24b8f662d626e0c6 + checksum: 10/d6dfecedcd51027b38bac756026bcbdffa2e086d9a48a4ef0f84176ca28b300d9da0e508fc77d8d64181540b6027571a7fe1dc3705abdcc5d6db45386d6fb482 languageName: node linkType: hard @@ -6123,11 +6151,11 @@ __metadata: linkType: hard "@openfeature/web-sdk@npm:^1.6.1": - version: 1.6.1 - resolution: "@openfeature/web-sdk@npm:1.6.1" + version: 1.7.1 + resolution: "@openfeature/web-sdk@npm:1.7.1" peerDependencies: "@openfeature/core": ^1.9.0 - checksum: 10/8bd7d1ea386e21cdd7492cab2fd1d2b138b4e6a376a4c0a40244633e5955f6452039bc2633fc5230bd7b494506a4137ba7210d40850634f9618f77a0ee435f9d + checksum: 10/358cabfddda8bf67a9bd96ce28f0f84a3218416907f8df4f2a58750ee840d164d86a1c859e8c9aa92d132fa28fcf72a0f17ceece798ff8af297d3e1c1e30cdab languageName: node linkType: hard @@ -7130,12 +7158,12 @@ __metadata: linkType: hard "@reduxjs/toolkit@npm:^2.9.0": - version: 2.9.2 - resolution: "@reduxjs/toolkit@npm:2.9.2" + version: 2.10.1 + resolution: "@reduxjs/toolkit@npm:2.10.1" dependencies: "@standard-schema/spec": "npm:^1.0.0" "@standard-schema/utils": "npm:^0.3.0" - immer: "npm:^10.0.3" + immer: "npm:^10.2.0" redux: "npm:^5.0.1" redux-thunk: "npm:^3.1.0" reselect: "npm:^5.1.0" @@ -7147,7 +7175,7 @@ __metadata: optional: true react-redux: optional: true - checksum: 10/7a8fa57086bc44dafe2f04428d6557edad46ee6eb46b2fe80468ebf934a441a7d4d750b50a6d41bc360d9ab61baadb1dddb5a8f556cfac68977f56e832f509a2 + checksum: 10/0d444d749d2261103bc1ad8a3eb43700aef5fd0417c21e758005d5c2e67f567248cc18708a336a8d407245642b863cb14a8db90aaa5bc18fd13ca53fe7fb0ab7 languageName: node linkType: hard @@ -18864,8 +18892,8 @@ __metadata: "@glideapps/glide-data-grid": "npm:^6.0.0" "@grafana/alerting": "workspace:*" "@grafana/api-clients": "workspace:*" - "@grafana/assistant": "npm:0.1.1" - "@grafana/aws-sdk": "npm:0.7.1" + "@grafana/assistant": "npm:0.1.4" + "@grafana/aws-sdk": "npm:0.8.3" "@grafana/azure-sdk": "npm:0.0.8" "@grafana/data": "workspace:*" "@grafana/e2e-selectors": "workspace:*" @@ -18882,11 +18910,11 @@ __metadata: "@grafana/monaco-logql": "npm:^0.0.8" "@grafana/o11y-ds-frontend": "workspace:*" "@grafana/plugin-e2e": "npm:^3.0.1" - "@grafana/plugin-ui": "npm:^0.10.10" + "@grafana/plugin-ui": "npm:^0.11.1" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" - "@grafana/scenes": "npm:^6.42.1" - "@grafana/scenes-react": "npm:^6.42.1" + "@grafana/scenes": "npm:^6.46.0" + "@grafana/scenes-react": "npm:^6.46.0" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/test-utils": "workspace:*" @@ -18896,7 +18924,7 @@ __metadata: "@leeoniya/ufuzzy": "npm:1.0.19" "@lezer/common": "npm:1.2.3" "@lezer/highlight": "npm:1.2.1" - "@lezer/lr": "npm:1.4.2" + "@lezer/lr": "npm:1.4.3" "@locker/near-membrane-dom": "npm:0.14.0" "@locker/near-membrane-shared": "npm:0.14.0" "@locker/near-membrane-shared-dom": "npm:0.14.0" @@ -19107,7 +19135,7 @@ __metadata: node-notifier: "npm:10.0.1" nx: "npm:21.3.11" ol: "npm:10.6.1" - ol-ext: "npm:4.0.35" + ol-ext: "npm:4.0.36" ol-mapbox-style: "npm:^13.0.1" open: "npm:^10.2.0" ora: "npm:^9.0.0" @@ -20008,22 +20036,22 @@ __metadata: linkType: hard "i18next@npm:^23.5.1 || ^24.2.0": - version: 24.2.2 - resolution: "i18next@npm:24.2.2" + version: 24.2.3 + resolution: "i18next@npm:24.2.3" dependencies: - "@babel/runtime": "npm:^7.23.2" + "@babel/runtime": "npm:^7.26.10" peerDependencies: typescript: ^5 peerDependenciesMeta: typescript: optional: true - checksum: 10/f66ed9e56d9412e59502f5df39163631daf9f1264774732fb21edbd66a528ca7a6b67dc2e2aec95683c6c7956e42c651587a54bd8ee082bd12008880ce6cd326 + checksum: 10/6c73d964f2a98b1aa2c2717fe6da66fc265bcbbc5fcd52b2bfff51ff013d30d4f7d7449c4eb7f464d27af43e2e73f2e7f1d46a144d731bd3bdb1385d4c199e4c languageName: node linkType: hard "i18next@npm:^25.0.0, i18next@npm:^25.5.2": - version: 25.5.2 - resolution: "i18next@npm:25.5.2" + version: 25.6.2 + resolution: "i18next@npm:25.6.2" dependencies: "@babel/runtime": "npm:^7.27.6" peerDependencies: @@ -20031,7 +20059,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/8d52e82386722a228f4465aa5cf39d82bd9861ea9cc8b31d7cc4d22d5e70b2740ee006068b09f486d5273cabd227a2ac14f37d68fab26344524200083f679bcd + checksum: 10/bb7f3e200caac72bacaead766287dbad5ed5945430bd9f2be1c14fbccd49cf78ab751f78cf53cd0be3ec063bc4c40837d7d771f7b14d50fcb9022e1ed5d6e72f languageName: node linkType: hard @@ -20126,13 +20154,20 @@ __metadata: languageName: node linkType: hard -"immer@npm:10.1.3, immer@npm:^10.0.3": +"immer@npm:10.1.3": version: 10.1.3 resolution: "immer@npm:10.1.3" checksum: 10/672faf5fc84177eecbfb49143ea8f6b5c1f981bd78c57e1562469354f6df658921ed6e0372b9ceed1e07c031e0c6d66863c4df80605928c6c774840b945aef83 languageName: node linkType: hard +"immer@npm:^10.0.3, immer@npm:^10.2.0": + version: 10.2.0 + resolution: "immer@npm:10.2.0" + checksum: 10/d73e218c8f8ffbb39f9290dfafa478b94af73403dcf26b5672eef35233bb30f09ffe231f8a78a6c9cb442968510edd89e851776ec90a5ddfa82cee6db6b35137 + languageName: node + linkType: hard + "immutable@npm:5.1.4, immutable@npm:^5.0.2": version: 5.1.4 resolution: "immutable@npm:5.1.4" @@ -23121,9 +23156,9 @@ __metadata: linkType: hard "lossless-json@npm:^4.1.1": - version: 4.2.0 - resolution: "lossless-json@npm:4.2.0" - checksum: 10/b29cbf9a90b8f3108219ff410223a90a3a6ad14cf902eed4038571d9421c69ade7077fb59737e1ca20f3f404ddd68fad69be11365b0d79b4b61421b054759551 + version: 4.3.0 + resolution: "lossless-json@npm:4.3.0" + checksum: 10/a984a882c79b6e62a917d0202518472c17587bdee002f1427d7ec61115f9fbdeb5f0baa9f0e9fff8d9dbacd17da6b6c910012b2dcab69dd33d151cb7c13d5a37 languageName: node linkType: hard @@ -23598,9 +23633,9 @@ __metadata: linkType: hard "micro-memoize@npm:^4.1.2": - version: 4.1.3 - resolution: "micro-memoize@npm:4.1.3" - checksum: 10/4e9c7767911cc76ae9c9779584ec87844437af9446b295a01774640a732c2c7f91944794027f44625031f7330ab7f9147740d0a9fb612680d1d2d858dad43402 + version: 4.2.0 + resolution: "micro-memoize@npm:4.2.0" + checksum: 10/260e27a5c15809f7dc435a89a424d93e49ccca62b743fa96ee72f254264df58c95edacbd847a2899a6d7f8ad0daef188548a8b12add5ccff83e88869a50a762c languageName: node linkType: hard @@ -25281,12 +25316,12 @@ __metadata: languageName: node linkType: hard -"ol-ext@npm:4.0.35": - version: 4.0.35 - resolution: "ol-ext@npm:4.0.35" +"ol-ext@npm:4.0.36": + version: 4.0.36 + resolution: "ol-ext@npm:4.0.36" peerDependencies: ol: ">= 5.3.0" - checksum: 10/d6adcbbb4b21a785ee1113e50c6c6e5d8da8f9209019de420ff2634ca7fd5c0028aac2628ea6ce4056a9d9ef54edace0a5d3ea478fed87661a044fe32d8fa0be + checksum: 10/071ce9be427dce8be6fb8e4fbdc972038ace08090c21f6dce7ae0929fe5263518a1a80d7b205e0fd208ac337d49e3782ead805be19f74f0474c291dea8c8d01e languageName: node linkType: hard