Merge remote-tracking branch 'origin/main' into feat/nanogit-retries
This commit is contained in:
@@ -1959,6 +1959,10 @@ max_connections = 100
|
||||
# The limit can be disabled by setting it to -1.
|
||||
message_size_limit = 8388608
|
||||
|
||||
# client_queue_max_size is the maximum size in bytes of the client queue
|
||||
# for Live connections. Defaults to 4MB.
|
||||
client_queue_max_size = 4194304
|
||||
|
||||
# allowed_origins is a comma-separated list of origins that can establish connection with Grafana Live.
|
||||
# If not set then origin will be matched over root_url. Supports wildcard symbol "*".
|
||||
allowed_origins =
|
||||
|
||||
@@ -1903,6 +1903,10 @@ default_datasource_uid =
|
||||
# ha_prefix is a prefix for keys in the HA engine. It's used to separate keys for different Grafana instances.
|
||||
;ha_prefix =
|
||||
|
||||
# client_queue_max_size is the maximum size in bytes of the client queue
|
||||
# for Live connections. Defaults to 4MB.
|
||||
;client_queue_max_size =
|
||||
|
||||
#################################### Grafana Image Renderer Plugin ##########################
|
||||
[plugin.grafana-image-renderer]
|
||||
# Instruct headless browser instance to use a default timezone when not provided by Grafana, e.g. when rendering panel image of alert.
|
||||
|
||||
@@ -4,17 +4,23 @@ keywords:
|
||||
- Quickstart
|
||||
- Grafana Cloud
|
||||
menuTitle: Infrastructure as code
|
||||
title: Provision Grafana Cloud with infrastructure as code
|
||||
title: Provision Grafana Cloud with Infrastructure as code
|
||||
weight: 800
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/
|
||||
---
|
||||
|
||||
# Provision Grafana Cloud with 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.
|
||||
With Grafana Cloud, you can use as-code tools to create and manage resources via code, and incorporate them efficiently into your own use cases. 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.
|
||||
{{< admonition type="note" >}}
|
||||
|
||||
Most of the tools defined here can be used with one another.
|
||||
|
||||
{{< /admonition >}}
|
||||
|
||||
## Grafana Terraform provider
|
||||
|
||||
@@ -67,7 +73,7 @@ To get started, see the [quickstart guides for the Grafana Ansible Collection](/
|
||||
|
||||
### 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.
|
||||
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 using Ansible.
|
||||
|
||||
### Known limitations
|
||||
|
||||
@@ -75,7 +81,7 @@ The Grafana Ansible collection only works for Grafana Cloud and only supports ei
|
||||
|
||||
## 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.
|
||||
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, including 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/).
|
||||
|
||||
@@ -120,7 +126,7 @@ To get started with the Grafana Crossplane provider, install Crossplane in the K
|
||||
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.
|
||||
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 via code 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.
|
||||
|
||||
@@ -152,16 +158,15 @@ To use the Crossplane provider, you must have the Crossplane CLI and Crossplane
|
||||
|
||||
## 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 |
|
||||
| Property/Tool | Grafana Terraform Provider | Grafana Ansible Collection | Grafana Operator | Grafana Crossplane Provider |
|
||||
| -------------------------------------- | --------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| Grafana resources supported | All major Grafana resources | Grafana Cloud stack, plugins, API keys, dashboards, data sources, and folders | Dashboards, data sources, Folders | All major Grafana resources |
|
||||
| Tool format | HCL/JSON | YAML | YAML | 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 manage Grafana resources from within Kubernetes |
|
||||
|
||||
@@ -8,22 +8,42 @@ menuTitle: Ansible
|
||||
title: Grafana Ansible collection
|
||||
weight: 110
|
||||
canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/ansible/
|
||||
aliases:
|
||||
- ../../infrastructure-as-code/ansible/ansible-grafana-agent-linux
|
||||
- ../../infrastructure-as-code/ansible/ansible-multiple-agents
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
---
|
||||
|
||||
# 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 [Grafana Ansible collection](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/) provides configuration management resources for Grafana. You can use it to manage:
|
||||
|
||||
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.
|
||||
- Grafana Cloud stacks
|
||||
- Dashboards
|
||||
- Data sources
|
||||
- Folders
|
||||
- Alerting contact points
|
||||
- Notification policies
|
||||
- API keys
|
||||
|
||||
If your resources aren't currently available in the Grafana Ansible collection, you can manage them on Grafana Cloud programmatically by writing Ansible playbooks that use the [Ansible's built-in 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.
|
||||
|
||||
## Learn more
|
||||
|
||||
Refer to [Create and manage a Grafana Cloud stack using Ansible](ansible-cloud-stack/) to learn how to create a Grafana Cloud stack and add a data source and dashboard using [Ansible](https://www.ansible.com/).
|
||||
|
||||
To learn more about managing Grafana with Infrastructure as code:
|
||||
|
||||
- [Grafana Ansible collection documentation](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/)
|
||||
- [Ansible playbook best practices](https://docs.ansible.com/ansible/latest/user_guide/playbooks_best_practices.html)
|
||||
- [Grafana API documentation](/docs/grafana/latest/developers/http_api/)
|
||||
- [Grafana Cloud API documentation](https://grafana.com/docs/grafana-cloud/developer-resources/api-reference/)
|
||||
- [Infrastructure as Code with Terraform](/docs/grafana/latest/as-code/infrastructure-as-code/terraform/)
|
||||
|
||||
## Grafana Agent (deprecated)
|
||||
|
||||
{{< 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. |
|
||||
The Ansible collection also houses [Grafana Agent role](https://github.com/grafana/grafana-ansible-collection/tree/main/roles/grafana_agent), which is now deprecated.
|
||||
|
||||
+185
-169
@@ -4,64 +4,78 @@ keywords:
|
||||
- Quickstart
|
||||
- Grafana Cloud
|
||||
- Ansible
|
||||
title: Create and manage a Grafana Cloud stack using Ansible
|
||||
title: Create and manage your Grafana Cloud stack using Ansible
|
||||
menuTitle: Manage 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
|
||||
# Create and manage your 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.
|
||||
This guide shows you how to create a Grafana Cloud stack and add a data source, dashboard, and folder using the Ansible Collection for Grafana. You'll manage your Grafana infrastructure through Ansible playbooks.
|
||||
|
||||
## Before you begin
|
||||
|
||||
Before you begin, you should have the following available:
|
||||
Before you begin, make sure you have the following available:
|
||||
|
||||
- A Grafana Cloud account.
|
||||
- A Grafana Cloud account
|
||||
- [Ansible](https://docs.ansible.com/ansible/latest/installation_guide/index.html) installed on your machine
|
||||
|
||||
## Install the Grafana Ansible collection
|
||||
|
||||
Install the Grafana Ansible collection:
|
||||
|
||||
```sh
|
||||
ansible-galaxy collection install grafana.grafana
|
||||
```
|
||||
|
||||
This collection provides all the modules needed to manage Grafana Cloud stacks and resources.
|
||||
|
||||
## 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/).
|
||||
First, 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.
|
||||
Next, create an Ansible playbook file. This Ansible playbook creates a Grafana Cloud stack 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).
|
||||
|
||||
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).
|
||||
To do so, create a file named `cloud-stack.yml` and add the following:
|
||||
|
||||
Create a file named `cloud-stack.yml` and add the following:
|
||||
```yaml
|
||||
- name: Create Grafana Cloud stack
|
||||
connection: local
|
||||
hosts: localhost
|
||||
|
||||
```yaml
|
||||
- name: Create Grafana Cloud stack
|
||||
connection: local
|
||||
hosts: localhost
|
||||
vars:
|
||||
grafana_cloud_api_key: '<CLOUD_ACCESS_POLICY_TOKEN>'
|
||||
stack_name: '<STACK_NAME>'
|
||||
org_name: '<ORG_NAME>'
|
||||
|
||||
vars:
|
||||
grafana_cloud_api_key: '<Your Cloud Access Policy token>'
|
||||
stack_name: '<stack-name>'
|
||||
org_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
|
||||
register: stack_result
|
||||
|
||||
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
|
||||
```
|
||||
- name: Display stack URL
|
||||
debug:
|
||||
msg: 'Stack created at: {{ stack_result.url }}'
|
||||
```
|
||||
|
||||
1. Replace the following field values:
|
||||
- `<token>` with a token from the Cloud Access Policy you created in the Grafana Cloud portal.
|
||||
- `<stack-name>` with the name of your stack.
|
||||
- `<org-name>` with the name of the organization in Grafana Cloud.
|
||||
Replace the placeholders with your values:
|
||||
|
||||
## Create an API key in the Grafana stack
|
||||
- _`<CLOUD_ACCESS_POLICY_TOKEN>`_: Token from the Cloud Access Policy you created in the Grafana Cloud portal
|
||||
- _`<STACK_NAME>`_: Name of your stack
|
||||
- _`<ORG_NAME>`_: Name of the organization in Grafana Cloud
|
||||
|
||||
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.
|
||||
The playbook registers the stack creation result and displays the stack URL, which you'll need for subsequent resource management.
|
||||
|
||||
## Create an API key in your Grafana stack
|
||||
|
||||
Create an API key in the Grafana stack. You'll need this key to configure Ansible to create data sources, folders, and dashboards.
|
||||
|
||||
1. Log into your Grafana Cloud instance.
|
||||
2. Click **Administration** and select **API keys**.
|
||||
@@ -70,171 +84,169 @@ You'll need this key to configure Ansible to be able to create data source, fold
|
||||
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
|
||||
## Add resources using playbooks
|
||||
|
||||
This guide uses the InfluxDB data source.
|
||||
The required arguments vary depending on the type of data source you select.
|
||||
### Add a data source
|
||||
|
||||
1. Create a file named `data-source.yml` and add the following:
|
||||
The following steps use the InfluxDB data source. The required arguments vary depending on the type of data source you select.
|
||||
|
||||
```yaml
|
||||
- name: Add/Update data source
|
||||
connection: local
|
||||
hosts: localhost
|
||||
Create a file named `data-source.yml`:
|
||||
|
||||
vars:
|
||||
data_sources:
|
||||
[
|
||||
{
|
||||
name: '<data-source-name>',
|
||||
type: 'influxdb',
|
||||
url: '<data-source-url>',
|
||||
user: '<username>',
|
||||
secureJsonData: { password: '<password>' },
|
||||
database: '<db-name>',
|
||||
id: <id>,
|
||||
uid: '<uid>',
|
||||
access: 'proxy',
|
||||
},
|
||||
]
|
||||
```yaml
|
||||
- name: Add/Update data source
|
||||
connection: local
|
||||
hosts: localhost
|
||||
|
||||
grafana_api_key: '<API-Key>'
|
||||
stack_name: '<stack-name>'
|
||||
vars:
|
||||
grafana_url: 'https://<STACK_NAME>.grafana.net'
|
||||
grafana_api_key: '<GRAFANA_API_KEY>'
|
||||
data_source_config:
|
||||
name: '<DATA_SOURCE_NAME>'
|
||||
type: 'influxdb'
|
||||
url: '<DATA_SOURCE_URL>'
|
||||
user: '<USERNAME>'
|
||||
secureJsonData:
|
||||
password: '<PASSWORD>'
|
||||
database: '<DATABASE_NAME>'
|
||||
uid: '<UID>'
|
||||
access: 'proxy'
|
||||
|
||||
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 }}'
|
||||
```
|
||||
tasks:
|
||||
- name: Create/Update Data source
|
||||
grafana.grafana.datasource:
|
||||
dataSource: '{{ data_source_config }}'
|
||||
grafana_url: '{{ grafana_url }}'
|
||||
grafana_api_key: '{{ grafana_api_key }}'
|
||||
state: present
|
||||
```
|
||||
|
||||
1. Replace the following field values:
|
||||
- `<data-source-name>` with the name of the data source to be added in Grafana.
|
||||
- `<data-source-url>` with URL of your data source.
|
||||
- `<username>` with the username for authenticating with your data source.
|
||||
- `<password>` with the password for authenticating with your data source.
|
||||
- `<db-name>` with name of your database.
|
||||
- `<id>` with the ID for your data source in Grafana.
|
||||
- `<uid>` wth the UID for your data source in Grafana.
|
||||
- `<stack-name>` with the name of your stack.
|
||||
- `<API-key>` with the [API key created in the Grafana instance](#create-an-api-key-in-the-grafana-stack).
|
||||
Replace the placeholders with your values:
|
||||
|
||||
## Add a folder
|
||||
- _`<DATA_SOURCE_NAME>`_: Name of the data source to be added in Grafana
|
||||
- _`<DATA_SOURCE_URL>`_: URL of your data source
|
||||
- _`<USERNAME>`_: Username for authenticating with your data source
|
||||
- _`<PASSWORD>`_: Password for authenticating with your data source
|
||||
- _`<DATABASE_NAME>`_: Name of your database
|
||||
- _`<UID>`_: UID for your data source in Grafana
|
||||
- _`<STACK_NAME>`_: Name of your stack
|
||||
- _`<GRAFANA_API_KEY>`_: API key created in the Grafana instance
|
||||
|
||||
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).
|
||||
### Add a folder
|
||||
|
||||
1. Create a file named `folder.yml` and add the following:
|
||||
This playbook creates a folder in your Grafana instance using the [Folder module](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/folder_module.html#ansible-collections-grafana-grafana-folder-module).
|
||||
|
||||
```yaml
|
||||
- name: Add/Update Folders
|
||||
connection: local
|
||||
hosts: localhost
|
||||
Create a file named `folder.yml`:
|
||||
|
||||
vars:
|
||||
folders: [{ title: '<folder-name>', uid: '<uid>' }]
|
||||
```yaml
|
||||
- name: Add/Update Folders
|
||||
connection: local
|
||||
hosts: localhost
|
||||
|
||||
stack_name: '<stack-name>'
|
||||
grafana_api_key: <API-key>
|
||||
vars:
|
||||
grafana_url: 'https://<STACK_NAME>.grafana.net'
|
||||
grafana_api_key: '<GRAFANA_API_KEY>'
|
||||
folders:
|
||||
- title: '<FOLDER_NAME>'
|
||||
uid: '<UID>'
|
||||
|
||||
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 }}'
|
||||
```
|
||||
tasks:
|
||||
- name: Create/Update a Folder in Grafana
|
||||
grafana.grafana.folder:
|
||||
title: '{{ item.title }}'
|
||||
uid: '{{ item.uid }}'
|
||||
grafana_url: '{{ grafana_url }}'
|
||||
grafana_api_key: '{{ grafana_api_key }}'
|
||||
state: present
|
||||
loop: '{{ folders }}'
|
||||
```
|
||||
|
||||
1. Replace the following field values:
|
||||
- `<folder-name>` with the name of the folder to be added in Grafana.
|
||||
- `<uid>` with the UID for your folder in Grafana.
|
||||
- `<stack-name>` with the name of your stack.
|
||||
- `<API-key>` with the [API key created in the Grafana instance](#create-an-api-key-in-the-grafana-stack).
|
||||
Replace the placeholders with your values:
|
||||
|
||||
## Add a dashboard to the folder
|
||||
- _`<FOLDER_NAME>`_: Name of the folder to be added in Grafana
|
||||
- _`<UID>`_: UID for your folder in Grafana
|
||||
- _`<STACK_NAME>`_: Name of your stack
|
||||
- _`<GRAFANA_API_KEY>`_: API key created in the Grafana instance
|
||||
|
||||
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).
|
||||
### Add a dashboard to the folder
|
||||
|
||||
1. Create a file named `dashboard.yml` and add the following:
|
||||
This playbook iterates through the dashboard JSON source code files in the folder referenced in `dashboards_path` and adds them to the Grafana instance using the [Dashboard module](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/dashboard_module.html#ansible-collections-grafana-grafana-dashboard-module).
|
||||
|
||||
```yaml
|
||||
- name: Add/Update Dashboards
|
||||
connection: local
|
||||
hosts: localhost
|
||||
Create a file named `dashboard.yml`:
|
||||
|
||||
vars:
|
||||
dashboards_path: <path-to-dashboard-files> # Example "./dashboards"
|
||||
stack_name: "<stack-name>"
|
||||
grafana_api_key: <API-key>
|
||||
```yaml
|
||||
- name: Add/Update Dashboards
|
||||
connection: local
|
||||
hosts: localhost
|
||||
|
||||
tasks:
|
||||
- name: Find dashboard files
|
||||
find:
|
||||
paths: "{{ dashboards_path }}"
|
||||
file_type: file
|
||||
recurse: Yes
|
||||
patterns: "*.json"
|
||||
register: files_matched
|
||||
no_log: True
|
||||
vars:
|
||||
grafana_url: 'https://<STACK_NAME>.grafana.net'
|
||||
grafana_api_key: '<GRAFANA_API_KEY>'
|
||||
dashboards_path: '<PATH_TO_DASHBOARD_FILES>' # Example "./dashboards"
|
||||
|
||||
- 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
|
||||
tasks:
|
||||
- name: Find dashboard files
|
||||
find:
|
||||
paths: '{{ dashboards_path }}'
|
||||
file_type: file
|
||||
recurse: true
|
||||
patterns: '*.json'
|
||||
register: files_matched
|
||||
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 }}"
|
||||
```
|
||||
- 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
|
||||
|
||||
1. Replace the following field values:
|
||||
- `<path-to-dashboard-files>` with the path to the folder containing dashboard JSON source code files.
|
||||
- `<stack-name>` with the name of your stack.
|
||||
- `<API-key>` with the [API key created in the Grafana instance](#create-an-api-key-in-the-grafana-stack).
|
||||
- name: Create/Update a dashboard
|
||||
grafana.grafana.dashboard:
|
||||
dashboard: "{{ lookup('ansible.builtin.file', item) }}"
|
||||
grafana_url: '{{ grafana_url }}'
|
||||
grafana_api_key: '{{ grafana_api_key }}'
|
||||
state: present
|
||||
loop: '{{ dashboard_file_names }}'
|
||||
```
|
||||
|
||||
Replace the placeholders with your values:
|
||||
|
||||
- _`<PATH_TO_DASHBOARD_FILES>`_: Path to the folder containing dashboard JSON source code files
|
||||
- _`<STACK_NAME>`_: Name of your stack
|
||||
- _`<GRAFANA_API_KEY>`_: API key created in the Grafana instance
|
||||
|
||||
## 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.
|
||||
Create the Grafana Cloud stack:
|
||||
|
||||
```shell
|
||||
ansible-playbook cloud-stack.yml
|
||||
```
|
||||
```sh
|
||||
ansible-playbook cloud-stack.yml
|
||||
```
|
||||
|
||||
1. To add a data source to the Grafana stack.
|
||||
Add a data source to the Grafana stack:
|
||||
|
||||
```shell
|
||||
ansible-playbook data-source.yml
|
||||
```
|
||||
```sh
|
||||
ansible-playbook data-source.yml
|
||||
```
|
||||
|
||||
1. To add a folder to the Grafana stack
|
||||
Add a folder to the Grafana stack:
|
||||
|
||||
```shell
|
||||
ansible-playbook folder.yml
|
||||
```
|
||||
```sh
|
||||
ansible-playbook folder.yml
|
||||
```
|
||||
|
||||
1. To add a dashboard to the folder in your Grafana stack.
|
||||
Add a dashboard to the folder in your Grafana stack:
|
||||
|
||||
```shell
|
||||
ansible-playbook dashboard.yml
|
||||
```
|
||||
```sh
|
||||
ansible-playbook dashboard.yml
|
||||
```
|
||||
|
||||
## Validation
|
||||
## Validate your configuration
|
||||
|
||||
Once you run the Ansible playbooks, you should be able to verify the following:
|
||||
After you've run the Ansible playbooks, you can verify the following:
|
||||
|
||||
- The new Grafana stack is created and visible in the Cloud Portal.
|
||||
- The new Grafana Cloud stack is created and visible in the Cloud Portal.
|
||||
|
||||

|
||||
|
||||
@@ -242,18 +254,22 @@ Once you run the Ansible playbooks, you should be able to verify the following:
|
||||
|
||||

|
||||
|
||||
- A new folder in Grafana.
|
||||
In the following image, a folder named `Demos` was added.
|
||||
- A new folder is available in your Grafana stack. In the following image, a folder named `Demos` was added.
|
||||
|
||||

|
||||
|
||||
- A new dashboard in the Grafana stack.
|
||||
In the following image a dashboard named `InfluxDB Cloud Demos` was created inside the "Demos" folder.
|
||||
- A new dashboard is visible in the Grafana stack. In the following image, a dashboard named `InfluxDB Cloud Demos` was created inside the "Demos" folder.
|
||||
|
||||

|
||||
|
||||
## Summary
|
||||
## Next steps
|
||||
|
||||
In this guide, you created a Grafana Cloud stack along with a data source, folder, and dashboard imported from a JSON file using Ansible.
|
||||
You've successfully created a Grafana Cloud stack along with a data source, a folder, and a dashboard using Ansible. Your Grafana infrastructure is now managed through code.
|
||||
|
||||
To learn more about managing Grafana using Ansible, refer to the [Grafana Ansible collection](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/).
|
||||
To learn more about managing Grafana with Infrastructure as code:
|
||||
|
||||
- [Grafana Ansible collection documentation](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/)
|
||||
- [Ansible playbook best practices](https://docs.ansible.com/ansible/latest/user_guide/playbooks_best_practices.html)
|
||||
- [Grafana API documentation](/docs/grafana/latest/developers/http_api/)
|
||||
- [Grafana Cloud API documentation](https://grafana.com/docs/grafana-cloud/developer-resources/api-reference/)
|
||||
- [Infrastructure as Code with Terraform](/docs/grafana/latest/as-code/infrastructure-as-code/terraform/)
|
||||
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
---
|
||||
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.
|
||||
|
||||
<!-- vale Grafana.Spelling = NO -->
|
||||
|
||||
### 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: '<path-to-config-file>'
|
||||
# Remove default flags
|
||||
grafana_agent_flags_extra:
|
||||
server.http.listen-addr: '0.0.0.0:12345'
|
||||
```
|
||||
|
||||
1. Replace the following field values:
|
||||
- `<path-to-config-file-on-localhost>` 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: <Your Cloud Access Policy token>
|
||||
logs_username: <loki-username> # Example - 411478
|
||||
loki_url: <loki-push-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:
|
||||
- `<Your Cloud Access Policy token>` with a token from the Cloud Access Policy you created in the Grafana Cloud portal.
|
||||
- `<loki-username>` with the Loki Username
|
||||
- `<loki-push-url>` 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
|
||||
|
||||
<!-- vale Grafana.ReferTo = NO -->
|
||||
|
||||
1. Grafana Agent service on the Linux machine should be `active` and `running`. You should see a similar output:
|
||||
<!-- vale Grafana.ReferTo = NO -->
|
||||
|
||||
```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"}`
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
@@ -1,207 +0,0 @@
|
||||
---
|
||||
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: <Your Cloud Access Policy token>
|
||||
metrics_username: <prometheus-username> # Example - 825019
|
||||
logs_username: <loki-username> # Example - 411478
|
||||
prometheus_url: <prometheus-push-url> # Example - https://prometheus-us-central1.grafana.net/api/prom/push
|
||||
loki_url: <loki-push-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).
|
||||
+219
-216
@@ -5,295 +5,298 @@ keywords:
|
||||
- Grafana Cloud
|
||||
- Grafana Operator
|
||||
- ArgoCD
|
||||
title: Manage Dashboards with GitOps Using ArgoCD
|
||||
title: Manage dashboards with GitOps using ArgoCD
|
||||
menuTitle: Manage dashboards with 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
|
||||
# Manage 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.
|
||||
This guide shows you how to set up a continuous deployment pipeline using ArgoCD to synchronize your Grafana dashboards with a Git repository. You'll use the Grafana Dashboard Custom Resource provided by the Grafana Operator to manage dashboard configurations declaratively.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, make sure you have the following:
|
||||
|
||||
- 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.
|
||||
- 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 to the [ArgoCD Installation Guide](https://argo-cd.readthedocs.io/en/stable/getting_started/)
|
||||
- A Git repository to store your dashboard configurations
|
||||
|
||||
## Set Up Your Git Repository
|
||||
## 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`.
|
||||
Create a directory structure in your repository to organize your Grafana and dashboard configurations. For this tutorial, create a folder named `grafana`.
|
||||
|
||||
## Grafana Operator Setup
|
||||
## Set up the Grafana Operator
|
||||
|
||||
The Grafana Operator allows us to authenticate with the Grafana instance using the Grafana Custom Resource (CR).
|
||||
The Grafana Operator allows you to authenticate with the Grafana instance using the Grafana Custom Resource (CR).
|
||||
|
||||
1. **Create the Grafana API Token Secret:**
|
||||
### 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:
|
||||
Store the Grafana API Token in a secret. Create a file named `grafana-token.yml` in the `grafana` folder in your Git repository:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: grafana-cloud-credentials
|
||||
namespace: <grafana-operator-namespace>
|
||||
namespace: '<GRAFANA_OPERATOR_NAMESPACE>'
|
||||
stringData:
|
||||
GRAFANA_CLOUD_INSTANCE_TOKEN: <Grafana-API-Key>
|
||||
GRAFANA_CLOUD_INSTANCE_TOKEN: '<GRAFANA_API_KEY>'
|
||||
type: Opaque
|
||||
```
|
||||
|
||||
Replace the following field values:
|
||||
Replace the placeholders with your values:
|
||||
|
||||
- `<Grafana-API-Key>` with API key from the Grafana instance. To create an API key, refer [Grafana API Key Documentation](/docs/grafana/latest/administration/api-keys/).
|
||||
- `<grafana-operator-namespace>` with the namespace where the grafana-operator is deployed in Kubernetes Cluster.
|
||||
- _`<GRAFANA_API_KEY>`_: API key from your Grafana instance. To create an API key, refer to [Grafana API Key Documentation](/docs/grafana/latest/administration/api-keys/)
|
||||
- _`<GRAFANA_OPERATOR_NAMESPACE>`_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster
|
||||
|
||||
2. **Configure the Grafana Custom Resource:**
|
||||
### 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:
|
||||
Set up the connection to your Grafana Cloud instance. Create a file named `grafana-cloud.yml` in the `grafana` folder in your Git repository:
|
||||
|
||||
```yaml
|
||||
apiVersion: grafana.integreatly.org/v1beta1
|
||||
kind: Grafana
|
||||
metadata:
|
||||
name: <Grafana-cloud-stack-name>
|
||||
namespace: <grafana-operator-namespace>
|
||||
name: '<GRAFANA_CLOUD_STACK_NAME>'
|
||||
namespace: '<GRAFANA_OPERATOR_NAMESPACE>'
|
||||
labels:
|
||||
dashboards: <Grafana-cloud-stack-name>
|
||||
dashboards: '<GRAFANA_CLOUD_STACK_NAME>'
|
||||
spec:
|
||||
external:
|
||||
url: https://<Grafana-cloud-stack-name>.grafana.net/
|
||||
url: https://<GRAFANA_CLOUD_STACK_NAME>.grafana.net/
|
||||
apiKey:
|
||||
name: grafana-cloud-credentials
|
||||
key: GRAFANA_CLOUD_INSTANCE_TOKEN
|
||||
```
|
||||
|
||||
Replace the following field values:
|
||||
Replace the placeholders with your values:
|
||||
|
||||
- `<Grafana-API-Key>` with API key from the Grafana instance.
|
||||
- `<Grafana-cloud-stack-name>` with the name of your Grafana Cloud Stack.
|
||||
- `<grafana-operator-namespace>` with the namespace where the grafana-operator is deployed in Kubernetes Cluster.
|
||||
- _`<GRAFANA_CLOUD_STACK_NAME>`_: Name of your Grafana Cloud Stack
|
||||
- _`<GRAFANA_OPERATOR_NAMESPACE>`_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster
|
||||
|
||||
## Add Dashboards to a Git repository
|
||||
## Add dashboards to your Git repository
|
||||
|
||||
In your `grafana` directory, Create a sub-folder called `dashboards`. For this tutorial, we will create 3 seperate dashboards.
|
||||
In your `grafana` directory, create a sub-folder called `dashboards`.
|
||||
|
||||
1. Under `dashboards` folder, Create a file named `simple-dashboard.yaml` with the following content for the first dashboard:
|
||||
This guide shows you how to creates three separate dashboards. For all dashboard configurations, replace the placeholders with your values:
|
||||
|
||||
```yaml
|
||||
apiVersion: grafana.integreatly.org/v1beta1
|
||||
kind: GrafanaDashboard
|
||||
metadata:
|
||||
name: grafanadashboard-sample
|
||||
namespace: <grafana-operator-namespace>
|
||||
spec:
|
||||
resyncPeriod: 30s
|
||||
instanceSelector:
|
||||
matchLabels:
|
||||
dashboards: <Grafana-cloud-stack-name>
|
||||
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": []
|
||||
}
|
||||
```
|
||||
- _`<GRAFANA_CLOUD_STACK_NAME>`_: Name of your Grafana Cloud Stack
|
||||
- _`<GRAFANA_OPERATOR_NAMESPACE>`_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster
|
||||
|
||||
Replace the following field values:
|
||||
- `<Grafana-cloud-stack-name>` with the name of your Grafana Cloud Stack.
|
||||
- `<grafana-operator-namespace>` with the namespace where the grafana-operator is deployed in Kubernetes Cluster.
|
||||
### Create a simple dashboard
|
||||
|
||||
1. Under `dashboards` folder, Create a file named `dashboard-from-cm.yaml` with the following content for the second dashboard:
|
||||
Under the `dashboards` folder, create a file named `simple-dashboard.yaml`:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: dashboard-definition
|
||||
namespace: <grafana-operator-namespace>
|
||||
spec:
|
||||
resyncPeriod: 30s
|
||||
instanceSelector:
|
||||
matchLabels:
|
||||
dashboards: <Grafana-cloud-stack-name>
|
||||
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: <grafana-operator-namespace>
|
||||
spec:
|
||||
instanceSelector:
|
||||
matchLabels:
|
||||
dashboards: <Grafana-cloud-stack-name>
|
||||
configMapRef:
|
||||
name: dashboard-definition
|
||||
key: json
|
||||
```
|
||||
```yaml
|
||||
apiVersion: grafana.integreatly.org/v1beta1
|
||||
kind: GrafanaDashboard
|
||||
metadata:
|
||||
name: grafanadashboard-sample
|
||||
namespace: '<GRAFANA_OPERATOR_NAMESPACE>'
|
||||
spec:
|
||||
resyncPeriod: 30s
|
||||
instanceSelector:
|
||||
matchLabels:
|
||||
dashboards: '<GRAFANA_CLOUD_STACK_NAME>'
|
||||
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:
|
||||
- `<Grafana-cloud-stack-name>` with the name of your Grafana Cloud Stack.
|
||||
- `<grafana-operator-namespace>` with the namespace where the grafana-operator is deployed in Kubernetes Cluster.
|
||||
### Create a dashboard from ConfigMap
|
||||
|
||||
1. Under `dashboards` folder, Create a file named `dashboard-from-id.yaml` with the following content for the third dashboard:
|
||||
Under the `dashboards` folder, create a file named `dashboard-from-cm.yaml`:
|
||||
|
||||
```yaml
|
||||
apiVersion: grafana.integreatly.org/v1beta1
|
||||
kind: GrafanaDashboard
|
||||
metadata:
|
||||
name: node-exporter-latest
|
||||
namespace: <grafana-operator-namespace>
|
||||
spec:
|
||||
instanceSelector:
|
||||
matchLabels:
|
||||
dashboards: <Grafana-cloud-stack-name>
|
||||
grafanaCom:
|
||||
id: 1860
|
||||
```
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: dashboard-definition
|
||||
namespace: <GRAFANA_OPERATOR_NAMESPACE>
|
||||
data:
|
||||
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: '<GRAFANA_OPERATOR_NAMESPACE>'
|
||||
spec:
|
||||
instanceSelector:
|
||||
matchLabels:
|
||||
dashboards: '<GRAFANA_CLOUD_STACK_NAME>'
|
||||
configMapRef:
|
||||
name: dashboard-definition
|
||||
key: json
|
||||
```
|
||||
|
||||
Replace the following field values:
|
||||
- `<Grafana-cloud-stack-name>` with the name of your Grafana Cloud Stack.
|
||||
- `<grafana-operator-namespace>` with the namespace where the grafana-operator is deployed in Kubernetes Cluster.
|
||||
### Create a dashboard from Grafana.com
|
||||
|
||||
## Configure Argo CD to Sync the Git Repository
|
||||
Under the `dashboards` folder, create a file named `dashboard-from-id.yaml`:
|
||||
|
||||
Once all changes are committed to Git, Log in to the Argo CD user interface or use the CLI.
|
||||
```yaml
|
||||
apiVersion: grafana.integreatly.org/v1beta1
|
||||
kind: GrafanaDashboard
|
||||
metadata:
|
||||
name: node-exporter-latest
|
||||
namespace: '<GRAFANA_OPERATOR_NAMESPACE>'
|
||||
spec:
|
||||
instanceSelector:
|
||||
matchLabels:
|
||||
dashboards: '<GRAFANA_CLOUD_STACK_NAME>'
|
||||
grafanaCom:
|
||||
id: 1860
|
||||
```
|
||||
|
||||
2. Create an Argo CD application to manage the synchronization:
|
||||
## Configure ArgoCD to sync the Git repository
|
||||
|
||||
**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`.
|
||||
After you commit all changes to Git, log in to the ArgoCD user interface or use the CLI.
|
||||
|
||||
**Using CLI**:
|
||||
- Prepare an application manifest named `argo-application.yaml` with the configuration pointing to your Git repository:
|
||||
### Create an ArgoCD application
|
||||
|
||||
```yaml
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: Grafana
|
||||
namespace: <argocd-namespace>
|
||||
spec:
|
||||
destination:
|
||||
name: ''
|
||||
namespace: ''
|
||||
server: 'https://kubernetes.default.svc'
|
||||
source:
|
||||
path: <Path-to-grafana-folder>
|
||||
repoURL: '<Git-repo-url>'
|
||||
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
|
||||
```
|
||||
**Using the UI:**
|
||||
|
||||
Replace the following field values:
|
||||
- `<Git-repo-url>` with the URL of your GIT Repository.
|
||||
- `<Path-to-grafana-folder>` with the path to the `grafana` folder.
|
||||
- `<argocd-namespace>` with the namespace where ArgoCD is deployed in Kubernetes Cluster.
|
||||
1. Navigate to **New App** and complete the form with your Git repository details and the path to your `grafana` folder
|
||||
2. Enable **Directory Recurse**
|
||||
3. Set the sync policy to **Automatic**
|
||||
|
||||
- Create the application in Argo CD:
|
||||
**Using the CLI:**
|
||||
|
||||
```shell
|
||||
kubectl apply -f argo-application.yaml
|
||||
```
|
||||
Prepare an application manifest named `argo-application.yaml`:
|
||||
|
||||
## Verify Sync Status in Argo CD
|
||||
```yaml
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: Grafana
|
||||
namespace: '<ARGOCD_NAMESPACE>'
|
||||
spec:
|
||||
destination:
|
||||
name: ''
|
||||
namespace: ''
|
||||
server: 'https://kubernetes.default.svc'
|
||||
source:
|
||||
path: '<PATH_TO_GRAFANA_FOLDER>'
|
||||
repoURL: '<GIT_REPO_URL>'
|
||||
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
|
||||
```
|
||||
|
||||
1. Monitor the newly created Argo CD application, ensuring that it successfully syncs your dashboard configuration.
|
||||
Replace the placeholders with your values:
|
||||
|
||||
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.
|
||||
- _`<GIT_REPO_URL>`_: URL of your Git repository
|
||||
- _`<PATH_TO_GRAFANA_FOLDER>`_: Path to the `grafana` folder in your repository
|
||||
- _`<ARGOCD_NAMESPACE>`_: Namespace where ArgoCD is deployed in your Kubernetes cluster
|
||||
|
||||
## Updating the Dashboards
|
||||
Create the application in ArgoCD:
|
||||
|
||||
```sh
|
||||
kubectl apply -f argo-application.yaml
|
||||
```
|
||||
|
||||
## Verify sync status in ArgoCD
|
||||
|
||||
1. Monitor the newly created ArgoCD application to ensure it successfully syncs your dashboard configuration
|
||||
2. Visit the ArgoCD dashboard and check the sync status. If it's successful, your Grafana dashboards should be up to date with the configuration from your Git repository
|
||||
|
||||
## Update your 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.
|
||||
1. Make changes to the dashboard JSON configuration in your Git repository
|
||||
2. Commit and push the changes
|
||||
3. ArgoCD detects the update and synchronizes the changes to your Custom Resource
|
||||
4. Grafana Operator then syncs changes to the Grafana instance
|
||||
|
||||
## Validating the Grafana Dashboard Update
|
||||
### Validate your dashboard updates
|
||||
|
||||
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.
|
||||
Log in to your Grafana dashboard and confirm that the changes are applied. You should see the dashboard updates reflected in the Grafana UI.
|
||||
|
||||
## Additional Tips
|
||||
## Next steps
|
||||
|
||||
- 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.
|
||||
You've successfully set up a GitOps workflow to manage Grafana dashboards using ArgoCD 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.
|
||||
|
||||
## Conclusion
|
||||
To learn more about managing Grafana using Grafana Operator:
|
||||
|
||||
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.
|
||||
- [Grafana Operator documentation](https://grafana.github.io/grafana-operator/docs/)
|
||||
- [Grafana dashboard provisioning](/docs/grafana/latest/administration/provisioning/#dashboards)
|
||||
- [ArgoCD best practices](https://argo-cd.readthedocs.io/en/stable/user-guide/best_practices/)
|
||||
|
||||
To learn more about managing Grafana using Grafana Operator, see the [Grafana Operator documentation](https://grafana.github.io/grafana-operator/docs/).
|
||||
### Additional considerations
|
||||
|
||||
- You can install the Grafana Operator's Helm Chart using ArgoCD to manage your setup with GitOps
|
||||
- You can follow a similar setup for Grafana Folders and other resources
|
||||
|
||||
+71
-65
@@ -5,26 +5,27 @@ keywords:
|
||||
- Grafana Cloud
|
||||
- Grafana Operator
|
||||
title: Manage folders, data sources, and dashboards using Grafana Operator
|
||||
menuTitle: Manage resources with the 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
|
||||
# Manage folders, data sources, and dashboards using the Grafana Operator
|
||||
|
||||
Learn how to manage data sources, folders and dashboard, using Grafana Operator.
|
||||
This guide shows you how to manage data sources, folders, and dashboards using the Grafana Operator. You'll create these resources declaratively using Kubernetes custom resources.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, you should have the following available:
|
||||
Before you begin, make sure you have the following:
|
||||
|
||||
- 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).
|
||||
- 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
|
||||
## Set up the Grafana Operator
|
||||
|
||||
The Grafana Operator allows us to authenticate with the Grafana instance using the Grafana Custom Resource (CR).
|
||||
The Grafana Operator allows you to authenticate with your Grafana instance using the Grafana Custom Resource (CR).
|
||||
|
||||
1. **Create the Grafana API Token Secret:**
|
||||
### 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`:
|
||||
|
||||
@@ -33,61 +34,64 @@ apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: grafana-cloud-credentials
|
||||
namespace: <grafana-operator-namespace>
|
||||
namespace: '<GRAFANA_OPERATOR_NAMESPACE>'
|
||||
stringData:
|
||||
GRAFANA_CLOUD_INSTANCE_TOKEN: <Grafana-API-Key>
|
||||
GRAFANA_CLOUD_INSTANCE_TOKEN: '<GRAFANA_API_KEY>'
|
||||
type: Opaque
|
||||
```
|
||||
|
||||
Replace the following field values:
|
||||
Replace the placeholders with your values:
|
||||
|
||||
- `<Grafana-API-Key>` with API key from the Grafana instance. To create an API key, refer [Grafana API Key Documentation](/docs/grafana/latest/administration/api-keys/).
|
||||
- `<grafana-operator-namespace>` with the namespace where the grafana-operator is deployed in Kubernetes Cluster.
|
||||
- _`<GRAFANA_API_KEY>`_: API key from your Grafana instance. To create an API key, refer to [Grafana API Key Documentation](/docs/grafana/latest/administration/api-keys/)
|
||||
- _`<GRAFANA_OPERATOR_NAMESPACE>`_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster
|
||||
|
||||
2. **Configure the Grafana Custom Resource:**
|
||||
### 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:
|
||||
Set up connection to your Grafana Cloud instance. Create a file named `grafana-cloud.yml`:
|
||||
|
||||
```yaml
|
||||
apiVersion: grafana.integreatly.org/v1beta1
|
||||
kind: Grafana
|
||||
metadata:
|
||||
name: <Grafana-cloud-stack-name>
|
||||
namespace: <grafana-operator-namespace>
|
||||
name: '<GRAFANA_CLOUD_STACK_NAME>'
|
||||
namespace: '<GRAFANA_OPERATOR_NAMESPACE>'
|
||||
labels:
|
||||
dashboards: <Grafana-cloud-stack-name>
|
||||
dashboards: '<GRAFANA_CLOUD_STACK_NAME>'
|
||||
spec:
|
||||
external:
|
||||
url: https://<Grafana-cloud-stack-name>.grafana.net/
|
||||
url: https://<GRAFANA_CLOUD_STACK_NAME>.grafana.net/
|
||||
apiKey:
|
||||
name: grafana-cloud-credentials
|
||||
key: GRAFANA_CLOUD_INSTANCE_TOKEN
|
||||
```
|
||||
|
||||
Replace the following field values:
|
||||
Replace the placeholders with your values:
|
||||
|
||||
- `<Grafana-API-Key>` with API key from the Grafana instance.
|
||||
- `<Grafana-cloud-stack-name>` with the name of your Grafana Cloud Stack.
|
||||
- `<grafana-operator-namespace>` with the namespace where the grafana-operator is deployed in Kubernetes Cluster.
|
||||
- _`<GRAFANA_CLOUD_STACK_NAME>`_: Name of your Grafana Cloud stack
|
||||
- _`<GRAFANA_OPERATOR_NAMESPACE>`_: Namespace where the `grafana-operator` is deployed in your 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.
|
||||
{{< admonition type="note" >}}
|
||||
|
||||
1. **Create the Data Source Configuration:**
|
||||
This example uses the Prometheus data source. Note that the required arguments vary depending on the data source you select.
|
||||
|
||||
Save a new YAML file `datasource.yml` with the following content:
|
||||
{{< /admonition >}}
|
||||
|
||||
### Create a data source configuration
|
||||
|
||||
Create and save a new YAML file `datasource.yml` with your data source's configuration:
|
||||
|
||||
```yaml
|
||||
apiVersion: grafana.integreatly.org/v1beta1
|
||||
kind: GrafanaDatasource
|
||||
metadata:
|
||||
name: <data-source-name>
|
||||
namespace: <grafana-operator-namespace>
|
||||
name: '<DATA_SOURCE_NAME>'
|
||||
namespace: '<GRAFANA_OPERATOR_NAMESPACE>'
|
||||
spec:
|
||||
instanceSelector:
|
||||
matchLabels:
|
||||
dashboards: <Grafana-cloud-stack-name>
|
||||
dashboards: '<GRAFANA_CLOUD_STACK_NAME>'
|
||||
allowCrossNamespaceImport: true
|
||||
datasource:
|
||||
access: proxy
|
||||
@@ -95,74 +99,76 @@ spec:
|
||||
jsonData:
|
||||
timeInterval: 5s
|
||||
tlsSkipVerify: true
|
||||
name: <data-source-name>
|
||||
name: '<DATA_SOURCE_NAME>'
|
||||
type: prometheus
|
||||
url: <data-source-url>
|
||||
url: '<DATA_SOURCE_URL>'
|
||||
```
|
||||
|
||||
Replace the following field values:
|
||||
Replace the placeholders with your values:
|
||||
|
||||
- `<data-source-name>` with the name of the data source to be added in Grafana.
|
||||
- `<data-source-url>` with URL of your data source.
|
||||
- `<Grafana-cloud-stack-name>` with the name of your Grafana Cloud Stack.
|
||||
- `<grafana-operator-namespace>` with the namespace where the grafana-operator is deployed in Kubernetes Cluster.
|
||||
- _`<DATA_SOURCE_NAME>`_: Name of the data source to be added in Grafana
|
||||
- _`<DATA_SOURCE_URL>`_: URL of your data source
|
||||
- _`<GRAFANA_CLOUD_STACK_NAME>`_: Name of your Grafana Cloud stack
|
||||
- _`<GRAFANA_OPERATOR_NAMESPACE>`_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster
|
||||
|
||||
## Add a dashboard to a folder
|
||||
### 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.
|
||||
Use the following YAML definition to create a simple dashboard in the Grafana instance under a custom folder. If the folder defined under the `spec.folder` field doesn't exist, the operator creates 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:
|
||||
Prepare the dashboard configuration. In `dashboard.yml`, define the dashboard and assign it to a folder:
|
||||
|
||||
```yaml
|
||||
apiVersion: grafana.integreatly.org/v1beta1
|
||||
kind: GrafanaDashboard
|
||||
metadata:
|
||||
name: <folder-name>
|
||||
namespace: <grafana-operator-namespace>
|
||||
name: '<FOLDER_NAME>'
|
||||
namespace: '<GRAFANA_OPERATOR_NAMESPACE>'
|
||||
spec:
|
||||
instanceSelector:
|
||||
matchLabels:
|
||||
dashboards: <Grafana-cloud-stack-name>
|
||||
folder: "<folder-name>"
|
||||
dashboards: '<GRAFANA_CLOUD_STACK_NAME>'
|
||||
folder: '<FOLDER_NAME>'
|
||||
json: >
|
||||
{
|
||||
"title": "as-code dashboard",
|
||||
“uid” : “ascode”
|
||||
}
|
||||
{
|
||||
"title": "as-code dashboard",
|
||||
"uid" : "ascode"
|
||||
}
|
||||
```
|
||||
|
||||
Replace the following field values:
|
||||
Replace the placeholders with your values:
|
||||
|
||||
- `<folder-name>` with the name of the folder in which you want the Dashboard to be created.
|
||||
- `<Grafana-cloud-stack-name>` with the name of your Grafana Cloud Stack.
|
||||
- `<grafana-operator-namespace>` with the namespace where the grafana-operator is deployed in Kubernetes Cluster.
|
||||
- _`<FOLDER_NAME>`_: Name of the folder in which you want the dashboard to be created
|
||||
- _`<GRAFANA_CLOUD_STACK_NAME>`_: Name of your Grafana Cloud stack
|
||||
- _`<GRAFANA_OPERATOR_NAMESPACE>`_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster
|
||||
|
||||
## Apply Kubernetes Manifests
|
||||
## Apply the 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.
|
||||
Create Kubernetes Custom resources for all of the configurations:
|
||||
|
||||
```shell
|
||||
kubectl apply -f grafana-token.yml grafana-cloud.yml datasource.yml dashboard.yml
|
||||
```
|
||||
```sh
|
||||
kubectl apply -f grafana-token.yml grafana-cloud.yml datasource.yml dashboard.yml
|
||||
```
|
||||
|
||||
## Validation
|
||||
## Validate your configuration
|
||||
|
||||
Once you apply the configurations, you should be able to verify the following:
|
||||
After you apply the configurations, verify that:
|
||||
|
||||
- A new data source is visible in Grafana. In the following image a datasource named `InfluxDB` was created.
|
||||
- A new data source is visible in Grafana. In the following image, a data source named `InfluxDB` was created.
|
||||
|
||||

|
||||
|
||||
- A new dashboard and folder in Grafana. In the following image a dashboard named `InfluxDB Cloud Demos` was created inside the `Demos` folder.
|
||||
- A new dashboard and folder have been created in Grafana. In the following image, a dashboard named `InfluxDB Cloud Demos` was created inside the `Demos` folder.
|
||||
|
||||

|
||||
|
||||
## Conclusion
|
||||
## Next steps
|
||||
|
||||
In this guide, you created a data source, folder, and dashboard using the Grafana Operator.
|
||||
You've successfully created a data source, folder, and dashboard using the Grafana Operator. Your Grafana resources are now managed declaratively through Kubernetes custom resources.
|
||||
|
||||
To learn more about managing Grafana using Grafana Operator, see the [Grafana Operator documentation](https://grafana.github.io/grafana-operator/docs/).
|
||||
To learn more about managing Grafana:
|
||||
|
||||
- [Grafana Operator documentation](https://grafana.github.io/grafana-operator/docs/)
|
||||
- [Grafana dashboard provisioning](/docs/grafana/latest/administration/provisioning/#dashboards)
|
||||
- [Grafana data source provisioning](/docs/grafana/latest/administration/provisioning/#data-sources)
|
||||
|
||||
@@ -13,7 +13,7 @@ labels:
|
||||
- enterprise
|
||||
- oss
|
||||
title: JSON schema v2
|
||||
weight: 200
|
||||
weight: 500
|
||||
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/
|
||||
|
||||
+1
-1
@@ -213,7 +213,7 @@ role_attribute_path = [login=='octocat'][0] && 'GrafanaAdmin' || 'Viewer'
|
||||
## Configure team synchronization
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team.
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and [Grafana Cloud](https://grafana.com/products/cloud/)
|
||||
{{< /admonition >}}
|
||||
|
||||
By using Team Sync, you can map teams from your GitHub organization to teams within Grafana. This will automatically assign users to the appropriate teams.
|
||||
|
||||
+1
-1
@@ -238,7 +238,7 @@ use_refresh_token = true
|
||||
## Configure team synchronization
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team.
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and [Grafana Cloud](https://grafana.com/products/cloud/)
|
||||
{{< /admonition >}}
|
||||
|
||||
By using Team Sync, you can map GitLab groups to teams within Grafana. This will automatically assign users to the appropriate teams.
|
||||
|
||||
+1
-1
@@ -162,7 +162,7 @@ auto_login = true
|
||||
### Configure team synchronization
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team.
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and [Grafana Cloud](https://grafana.com/products/cloud/).
|
||||
{{< /admonition >}}
|
||||
|
||||
With team sync, you can easily add users to teams by utilizing their Google groups. To set up team sync for Google OAuth, refer to the following example.
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ viewer
|
||||
## Team sync
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team.
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and [Grafana Cloud](https://grafana.com/products/cloud/).
|
||||
{{< /admonition >}}
|
||||
|
||||
[Team Sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-access/configure-team-sync/) is a feature that allows you to map groups from your identity provider to Grafana teams. This is useful if you want to give your users access to specific dashboards or folders based on their group membership.
|
||||
|
||||
@@ -238,7 +238,7 @@ org_mapping = ["Group 1:org_foo:Viewer", "Group 2:org_bar:Editor", "*:3:Editor"]
|
||||
### Configure team synchronization
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team.
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and [Grafana Cloud](https://grafana.com/products/cloud/).
|
||||
{{< /admonition >}}
|
||||
|
||||
By using Team Sync, you can link your Okta groups to teams within Grafana. This will automatically assign users to the appropriate teams.
|
||||
|
||||
@@ -18,7 +18,7 @@ weight: 600
|
||||
Team sync lets you set up synchronization between your auth providers teams and teams in Grafana. This enables LDAP, OAuth, or SAML users who are members of certain teams or groups to automatically be added or removed as members of certain teams in Grafana.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and [Grafana Cloud Advanced](https://grafana.com/docs/grafana-cloud/).
|
||||
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and [Grafana Cloud](https://grafana.com/docs/grafana-cloud/).
|
||||
{{< /admonition >}}
|
||||
|
||||
Grafana keeps track of all synchronized users in teams, and you can see which users have been synchronized in the team members list, see `LDAP` label in screenshot.
|
||||
|
||||
@@ -190,6 +190,10 @@ Name of the TrueType font file with bold style.
|
||||
|
||||
Name of the TrueType font file with italic style. Default is `DejaVuSansCondensed-Oblique.ttf`.
|
||||
|
||||
### font_min_text_size
|
||||
|
||||
The minimum pixel size that Grafana uses when rendering fonts. Default is `4`.
|
||||
|
||||
### max_retries_per_panel
|
||||
|
||||
Maximum number of times the following reporting rendering requests are retried before returning an error: generating PDFs, generating embedded dashboard images for report emails, and generating attached CSV files. To disable the retry feature, enter `0`. This is available in public preview and requires the `reportingRetries` feature toggle. Default is `3`.
|
||||
|
||||
@@ -22,8 +22,6 @@ test.describe(
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.section).click();
|
||||
|
||||
// Should be able to click Variables item in outline to see add variable button
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('Variables')).click();
|
||||
await expect(
|
||||
|
||||
@@ -199,7 +199,6 @@ test.describe(
|
||||
.click();
|
||||
|
||||
// Open the modal editor in the side pane
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.section).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.node('Variables')).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('foo')).click();
|
||||
await openModal(dashboardPage, selectors);
|
||||
|
||||
@@ -48,7 +48,6 @@ export const flows = {
|
||||
},
|
||||
async newEditPaneVariableClick(dashboardPage: DashboardPage, selectors: E2ESelectorGroups) {
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.section).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('Variables')).click();
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.addVariableButton)
|
||||
|
||||
@@ -748,10 +748,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
alertingNotificationsStepMode?: boolean;
|
||||
/**
|
||||
* Enables a button to send feedback from the Grafana UI
|
||||
*/
|
||||
feedbackButton?: boolean;
|
||||
/**
|
||||
* Enable unified storage search UI
|
||||
*/
|
||||
unifiedStorageSearchUI?: boolean;
|
||||
|
||||
@@ -85,6 +85,9 @@ export const versionedPages = {
|
||||
dataSources: {
|
||||
[MIN_GRAFANA_VERSION]: (dataSourceName: string) => `Data source list item ${dataSourceName}`,
|
||||
},
|
||||
dataSourceAddButton: {
|
||||
'12.4.0': 'data-testid data-source-add-button',
|
||||
},
|
||||
},
|
||||
EditDataSource: {
|
||||
url: {
|
||||
|
||||
+12
-10
@@ -118,16 +118,18 @@ export const PromQueryEditorSelector = memo<Props>((props) => {
|
||||
onAddQuery={onAddQuery}
|
||||
/>
|
||||
<EditorHeader>
|
||||
<Button
|
||||
data-testid={selectors.components.QueryBuilder.queryPatterns}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleOpenQueryPatternsModal}
|
||||
>
|
||||
<Trans i18nKey="grafana-prometheus.querybuilder.prom-query-editor-selector.kick-start-your-query">
|
||||
Kick start your query
|
||||
</Trans>
|
||||
</Button>
|
||||
{!query.expr && (
|
||||
<Button
|
||||
data-testid={selectors.components.QueryBuilder.queryPatterns}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleOpenQueryPatternsModal}
|
||||
>
|
||||
<Trans i18nKey="grafana-prometheus.querybuilder.prom-query-editor-selector.kick-start-your-query">
|
||||
Kick start your query
|
||||
</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<div data-testid={selectors.components.DataSource.Prometheus.queryEditor.explain}>
|
||||
<QueryHeaderSwitch
|
||||
label={t('grafana-prometheus.querybuilder.prom-query-editor-selector.label-explain', 'Explain')}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Meta, ArgTypes } from '@storybook/blocks';
|
||||
import { Sidebar } from './Sidebar';
|
||||
|
||||
Sidebar
|
||||
When to use
|
||||
@@ -0,0 +1,211 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '../Button/Button';
|
||||
import { Box } from '../Layout/Box/Box';
|
||||
|
||||
import { Sidebar, SidebarPosition, useSidebar } from './Sidebar';
|
||||
import mdx from './Sidebar.mdx';
|
||||
|
||||
interface StoryProps {
|
||||
position: SidebarPosition;
|
||||
}
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: 'Overlays/Sidebar',
|
||||
parameters: {
|
||||
docs: {
|
||||
page: mdx,
|
||||
},
|
||||
controls: {},
|
||||
},
|
||||
args: {
|
||||
position: 'right',
|
||||
},
|
||||
argTypes: {
|
||||
position: { control: { type: 'radio' }, options: ['right', 'left'] },
|
||||
},
|
||||
};
|
||||
|
||||
export const Example: StoryFn<StoryProps> = (args) => {
|
||||
const [openPane, setOpenPane] = useState('');
|
||||
|
||||
const containerStyle = css({
|
||||
flexGrow: 1,
|
||||
height: '600px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const gridStyle = css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gridAutoRows: '300px',
|
||||
gap: '8px',
|
||||
flexGrow: 1,
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
const togglePane = (pane: string) => {
|
||||
if (openPane === pane) {
|
||||
setOpenPane('');
|
||||
} else {
|
||||
setOpenPane(pane);
|
||||
}
|
||||
};
|
||||
|
||||
const contextValue = useSidebar({
|
||||
hasOpenPane: !!openPane,
|
||||
position: args.position,
|
||||
bottomMargin: 0,
|
||||
edgeMargin: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box padding={2} backgroundColor={'canvas'} maxWidth={100} borderStyle={'solid'} borderColor={'weak'}>
|
||||
<div className={containerStyle} {...contextValue.outerWrapperProps}>
|
||||
<div className={gridStyle}>
|
||||
{renderBox('A')}
|
||||
{renderBox('B')}
|
||||
{renderBox('C')}
|
||||
{renderBox('D')}
|
||||
{renderBox('E')}
|
||||
{renderBox('F')}
|
||||
{renderBox('G')}
|
||||
</div>
|
||||
<Sidebar contextValue={contextValue}>
|
||||
{openPane === 'settings' && (
|
||||
<Sidebar.OpenPane>
|
||||
<Sidebar.PaneHeader title="Settings" onClose={() => togglePane('')}>
|
||||
<Button variant="secondary" size="sm">
|
||||
Action
|
||||
</Button>
|
||||
</Sidebar.PaneHeader>
|
||||
</Sidebar.OpenPane>
|
||||
)}
|
||||
{openPane === 'outline' && (
|
||||
<Sidebar.OpenPane>
|
||||
<Sidebar.PaneHeader title="Outline" onClose={() => togglePane('')} />
|
||||
</Sidebar.OpenPane>
|
||||
)}
|
||||
{openPane === 'add' && (
|
||||
<Sidebar.OpenPane>
|
||||
<Sidebar.PaneHeader title="Add element" onClose={() => togglePane('')} />
|
||||
</Sidebar.OpenPane>
|
||||
)}
|
||||
<Sidebar.Toolbar>
|
||||
<Sidebar.Button
|
||||
icon="plus"
|
||||
title="Add"
|
||||
tooltip="Add element"
|
||||
active={openPane === 'add'}
|
||||
onClick={() => togglePane('add')}
|
||||
/>
|
||||
|
||||
<Sidebar.Button
|
||||
icon="cog"
|
||||
title="Settings"
|
||||
active={openPane === 'settings'}
|
||||
onClick={() => togglePane('settings')}
|
||||
/>
|
||||
<Sidebar.Button
|
||||
icon="list-ui-alt"
|
||||
title="Outline"
|
||||
active={openPane === 'outline'}
|
||||
onClick={() => togglePane('outline')}
|
||||
/>
|
||||
<Sidebar.Divider />
|
||||
<Sidebar.Button icon="info-circle" title="Insights" />
|
||||
<Sidebar.Button icon="code-branch" title="Integrations" />
|
||||
</Sidebar.Toolbar>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const VerticalTabs: StoryFn = (args) => {
|
||||
const [openPane, setOpenPane] = useState('queries');
|
||||
|
||||
const togglePane = (pane: string) => {
|
||||
setOpenPane(pane);
|
||||
};
|
||||
|
||||
const containerStyle = css({
|
||||
flexGrow: 1,
|
||||
height: '600px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
gap: '16px',
|
||||
});
|
||||
|
||||
const vizWrapper = css({
|
||||
height: '30%',
|
||||
display: 'flex',
|
||||
});
|
||||
|
||||
const contextValue = useSidebar({
|
||||
position: args.position,
|
||||
tabsMode: true,
|
||||
edgeMargin: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box padding={2} backgroundColor={'canvas'} maxWidth={100} borderStyle={'solid'} borderColor={'weak'}>
|
||||
<div className={containerStyle}>
|
||||
<div className={vizWrapper}>{renderBox('Visualization')}</div>
|
||||
<Sidebar contextValue={contextValue}>
|
||||
{openPane === 'queries' && (
|
||||
<Sidebar.OpenPane>
|
||||
<Sidebar.PaneHeader title="Queries" />
|
||||
</Sidebar.OpenPane>
|
||||
)}
|
||||
{openPane === 'transformations' && (
|
||||
<Sidebar.OpenPane>
|
||||
<Sidebar.PaneHeader title="Transformations" />
|
||||
</Sidebar.OpenPane>
|
||||
)}
|
||||
<Sidebar.Toolbar>
|
||||
<Sidebar.Button
|
||||
icon="database"
|
||||
title="Queries"
|
||||
active={openPane === 'queries'}
|
||||
onClick={() => togglePane('queries')}
|
||||
/>
|
||||
<Sidebar.Button
|
||||
icon="process"
|
||||
title="Data"
|
||||
tooltip="Data transformations"
|
||||
active={openPane === 'transformations'}
|
||||
onClick={() => togglePane('transformations')}
|
||||
/>
|
||||
<Sidebar.Button icon="bell" title="Alerts" />
|
||||
</Sidebar.Toolbar>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function renderBox(label: string) {
|
||||
return (
|
||||
<Box
|
||||
backgroundColor={'primary'}
|
||||
borderColor={'weak'}
|
||||
borderStyle={'solid'}
|
||||
justifyContent={'center'}
|
||||
alignItems={'center'}
|
||||
display={'flex'}
|
||||
flex={1}
|
||||
>
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React, { act } from 'react';
|
||||
|
||||
import { Sidebar, useSidebar } from './Sidebar';
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('should render sidebar', async () => {
|
||||
render(<TestSetup />);
|
||||
|
||||
act(() => screen.getByLabelText('Settings').click());
|
||||
|
||||
// Verify pane is open
|
||||
expect(screen.getByTestId('sidebar-pane-header-title')).toBeInTheDocument();
|
||||
|
||||
act(() => screen.getByLabelText('Dock').click());
|
||||
|
||||
// Verify wrapper pushes content when docked
|
||||
const wrapper = screen.getByTestId('sidebar-test-wrapper');
|
||||
expect(wrapper).toHaveStyle('padding-right: 352px');
|
||||
|
||||
// Close pane
|
||||
act(() => screen.getByLabelText('Close').click());
|
||||
// Verify pane is closed
|
||||
expect(screen.queryByTestId('sidebar-pane-header-title')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function TestSetup() {
|
||||
const [openPane, setOpenPane] = React.useState('');
|
||||
const contextValue = useSidebar({
|
||||
position: 'right',
|
||||
hasOpenPane: openPane !== '',
|
||||
});
|
||||
|
||||
return (
|
||||
<div {...contextValue.outerWrapperProps} data-testid="sidebar-test-wrapper">
|
||||
<Sidebar contextValue={contextValue}>
|
||||
{openPane === 'settings' && (
|
||||
<Sidebar.OpenPane>
|
||||
<Sidebar.PaneHeader title="Settings" onClose={() => setOpenPane('')} />
|
||||
</Sidebar.OpenPane>
|
||||
)}
|
||||
<Sidebar.Toolbar>
|
||||
<Sidebar.Button icon="cog" title="Settings" onClick={() => setOpenPane('settings')} />
|
||||
<Sidebar.Button icon="process" title="Data" tooltip="Data transformations" />
|
||||
<Sidebar.Button icon="bell" title="Alerts" />
|
||||
</Sidebar.Toolbar>
|
||||
</Sidebar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { ReactNode, useContext } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
|
||||
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
|
||||
|
||||
import { SidebarButton } from './SidebarButton';
|
||||
import { SidebarPaneHeader } from './SidebarPaneHeader';
|
||||
import { SidebarResizer } from './SidebarResizer';
|
||||
import { SIDE_BAR_WIDTH_ICON_ONLY, SIDE_BAR_WIDTH_WITH_TEXT, SidebarContext, SidebarContextValue } from './useSidebar';
|
||||
|
||||
export interface Props {
|
||||
children?: ReactNode;
|
||||
contextValue: SidebarContextValue;
|
||||
}
|
||||
|
||||
export function SidebarComp({ children, contextValue }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const theme = useTheme2();
|
||||
const { isDocked, position, tabsMode, hasOpenPane, edgeMargin, bottomMargin } = contextValue;
|
||||
|
||||
const className = cx({
|
||||
[styles.container]: true,
|
||||
[styles.undockedPaneOpen]: hasOpenPane && !isDocked,
|
||||
[styles.containerLeft]: position === 'left',
|
||||
[styles.containerTabsMode]: tabsMode,
|
||||
});
|
||||
|
||||
const style = { [position]: theme.spacing(edgeMargin), bottom: theme.spacing(bottomMargin) };
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div className={className} style={style}>
|
||||
{!tabsMode && <SidebarResizer />}
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SiderbarToolbarProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function SiderbarToolbar({ children }: SiderbarToolbarProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const context = useContext(SidebarContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Sidebar.Toolbar must be used within a Sidebar component');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.toolbar, context.compact && styles.toolbarIconsOnly)}>
|
||||
{children}
|
||||
<div className={styles.flexGrow} />
|
||||
{context.hasOpenPane && (
|
||||
<SidebarButton
|
||||
icon={'web-section-alt'}
|
||||
onClick={context.onDockChange}
|
||||
title={context.isDocked ? t('grafana-ui.sidebar.undock', 'Undock') : t('grafana-ui.sidebar.dock', 'Dock')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarDivider() {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return <div className={styles.divider} />;
|
||||
}
|
||||
|
||||
export interface SidebarOpenPaneProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function SidebarOpenPane({ children }: SidebarOpenPaneProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const context = useContext(SidebarContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Sidebar.OpenPane must be used within a Sidebar component');
|
||||
}
|
||||
|
||||
const className = cx(styles.openPane, context.position === 'right' ? styles.openPaneRight : styles.openPaneLeft);
|
||||
|
||||
return (
|
||||
<div className={className} style={{ width: context.paneWidth }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
container: css({
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
flexDirection: 'row',
|
||||
flex: '1 1 0',
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
background: theme.colors.background.primary,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
zIndex: theme.zIndex.navbarFixed,
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
}),
|
||||
containerTabsMode: css({
|
||||
position: 'relative',
|
||||
}),
|
||||
containerLeft: css({
|
||||
right: 'unset',
|
||||
flexDirection: 'row-reverse',
|
||||
left: 0,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
}),
|
||||
undockedPaneOpen: css({
|
||||
boxShadow: theme.shadows.z3,
|
||||
}),
|
||||
toolbar: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing(1, 0),
|
||||
flexGrow: 0,
|
||||
gap: theme.spacing(1),
|
||||
overflow: 'hidden',
|
||||
width: theme.spacing(SIDE_BAR_WIDTH_WITH_TEXT),
|
||||
}),
|
||||
toolbarIconsOnly: css({
|
||||
width: theme.spacing(SIDE_BAR_WIDTH_ICON_ONLY),
|
||||
}),
|
||||
divider: css({
|
||||
height: '1px',
|
||||
background: theme.colors.border.weak,
|
||||
width: '100%',
|
||||
}),
|
||||
flexGrow: css({
|
||||
flexGrow: 1,
|
||||
}),
|
||||
openPane: css({
|
||||
width: '280px',
|
||||
flexGrow: 1,
|
||||
paddingBottom: theme.spacing(2),
|
||||
}),
|
||||
openPaneRight: css({
|
||||
borderRight: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
openPaneLeft: css({
|
||||
borderLeft: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const Sidebar = Object.assign(SidebarComp, {
|
||||
Toolbar: SiderbarToolbar,
|
||||
Button: SidebarButton,
|
||||
OpenPane: SidebarOpenPane,
|
||||
Divider: SidebarDivider,
|
||||
PaneHeader: SidebarPaneHeader,
|
||||
});
|
||||
|
||||
export { type SidebarPosition, type SidebarContextValue, useSidebar } from './useSidebar';
|
||||
@@ -0,0 +1,159 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { GrafanaTheme2, IconName, isIconName } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../themes/ThemeContext';
|
||||
import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins';
|
||||
import { getActiveButtonStyles } from '../Button/Button';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { Tooltip } from '../Tooltip/Tooltip';
|
||||
|
||||
import { SidebarContext } from './useSidebar';
|
||||
|
||||
export interface Props {
|
||||
icon: IconName;
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
title: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export function SidebarButton({ icon, active, onClick, title, tooltip }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const context = useContext(SidebarContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Sidebar.Button must be used within a Sidebar component');
|
||||
}
|
||||
|
||||
const buttonClass = cx(
|
||||
styles.button,
|
||||
context.compact && styles.compact,
|
||||
active && styles.active,
|
||||
context.position === 'left' && styles.leftButton
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip ?? title} placement={context.position === 'left' ? 'right' : 'left'}>
|
||||
<button className={buttonClass} aria-label={title} aria-expanded={active} type="button" onClick={onClick}>
|
||||
<div className={styles.iconWrapper}>{renderIcon(icon, context.compact)}</div>
|
||||
{!context.compact && <div className={cx(styles.title, active && styles.titleActive)}>{title}</div>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function renderIcon(icon: IconName | React.ReactNode, compact?: boolean) {
|
||||
if (!icon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isIconName(icon)) {
|
||||
return <Icon name={icon} size={compact ? `lg` : `lg`} />;
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
button: css({
|
||||
label: 'toolbar-button',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: theme.spacing(theme.components.height.md),
|
||||
padding: theme.spacing(0, 1),
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
// borderRadius: theme.shape.radius.sm,
|
||||
lineHeight: `${theme.components.height.md * theme.spacing.gridSize - 2}px`,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
color: theme.colors.text.secondary,
|
||||
background: 'transparent',
|
||||
border: `none`,
|
||||
|
||||
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
|
||||
transition: theme.transitions.create(['background-color', 'border-color', 'color'], {
|
||||
duration: theme.transitions.duration.short,
|
||||
}),
|
||||
},
|
||||
|
||||
'&:focus, &:focus-visible': {
|
||||
...getFocusStyles(theme),
|
||||
zIndex: 1,
|
||||
},
|
||||
|
||||
'&:focus:not(:focus-visible)': getMouseFocusStyles(theme),
|
||||
|
||||
'&[disabled], &:disabled': {
|
||||
cursor: 'not-allowed',
|
||||
opacity: theme.colors.action.disabledOpacity,
|
||||
background: theme.colors.action.disabledBackground,
|
||||
boxShadow: 'none',
|
||||
|
||||
'&:hover': {
|
||||
color: theme.colors.text.disabled,
|
||||
background: theme.colors.action.disabledBackground,
|
||||
boxShadow: 'none',
|
||||
},
|
||||
},
|
||||
|
||||
'&:hover, &:focus-visible': {
|
||||
color: theme.colors.text.primary,
|
||||
background: theme.colors.action.hover,
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
...getActiveButtonStyles(theme.colors.secondary, 'solid'),
|
||||
},
|
||||
}),
|
||||
compact: css({
|
||||
height: theme.spacing(theme.components.height.md),
|
||||
padding: theme.spacing(0, 1),
|
||||
width: theme.spacing(5),
|
||||
}),
|
||||
active: css({
|
||||
color: theme.colors.text.primary,
|
||||
background: theme.colors.action.selected,
|
||||
'&::before': {
|
||||
display: 'block',
|
||||
content: '" "',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: '2px',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
backgroundImage: theme.colors.gradients.brandVertical,
|
||||
},
|
||||
}),
|
||||
buttonWrapper: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
whiteSpace: 'nowrap',
|
||||
}),
|
||||
iconWrapper: css({}),
|
||||
title: css({
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
color: theme.colors.text.secondary,
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
}),
|
||||
titleActive: css({
|
||||
color: theme.colors.text.primary,
|
||||
}),
|
||||
leftButton: css({
|
||||
'&::before': {
|
||||
right: 'unset',
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
|
||||
import { useStyles2 } from '../../themes/ThemeContext';
|
||||
import { IconButton } from '../IconButton/IconButton';
|
||||
import { Text } from '../Text/Text';
|
||||
|
||||
export interface Props {
|
||||
children?: ReactNode;
|
||||
title: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function SidebarPaneHeader({ children, onClose, title }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{onClose && (
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
name="times"
|
||||
onClick={onClose}
|
||||
aria-label={t('grafana-ui.sidebar.close', 'Close')}
|
||||
tooltip={t('grafana-ui.sidebar.close', 'Close')}
|
||||
/>
|
||||
)}
|
||||
<Text weight="medium" variant="h6" truncate data-testid="sidebar-pane-header-title">
|
||||
{title}
|
||||
</Text>
|
||||
<div className={styles.flexGrow} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
wrapper: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing(1.5),
|
||||
height: theme.spacing(6),
|
||||
gap: theme.spacing(1),
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
flexGrow: css({
|
||||
flexGrow: 1,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useCallback, useContext, useRef } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../themes/ThemeContext';
|
||||
|
||||
import { SidebarContext } from './useSidebar';
|
||||
|
||||
export function SidebarResizer() {
|
||||
const styles = useStyles2(getStyles);
|
||||
const context = useContext(SidebarContext);
|
||||
const resizerRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragStart = useRef<number | null>(null);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Sidebar.Resizer must be used within a Sidebar component');
|
||||
}
|
||||
|
||||
const { onResize, position } = context;
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (resizerRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
resizerRef.current.setPointerCapture(e.pointerId);
|
||||
dragStart.current = e.clientX;
|
||||
},
|
||||
[resizerRef]
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (dragStart.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = e.clientX - dragStart.current;
|
||||
dragStart.current = e.clientX;
|
||||
|
||||
onResize(position === 'right' ? -diff : diff);
|
||||
},
|
||||
[dragStart, onResize, position]
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
dragStart.current = null;
|
||||
},
|
||||
[dragStart]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={resizerRef}
|
||||
className={styles[context.position]}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
right: css({
|
||||
position: 'absolute',
|
||||
width: theme.spacing.gridSize,
|
||||
left: -theme.spacing.gridSize,
|
||||
top: theme.shape.radius.default,
|
||||
bottom: theme.shape.radius.default,
|
||||
cursor: 'col-resize',
|
||||
zIndex: 1,
|
||||
'&:hover': {
|
||||
borderRight: `1px solid ${theme.colors.primary.border}`,
|
||||
},
|
||||
}),
|
||||
left: css({
|
||||
position: 'absolute',
|
||||
width: theme.spacing.gridSize,
|
||||
right: -theme.spacing.gridSize,
|
||||
top: theme.shape.radius.default,
|
||||
bottom: theme.shape.radius.default,
|
||||
cursor: 'col-resize',
|
||||
zIndex: 1,
|
||||
'&:hover': {
|
||||
borderLeft: `1px solid ${theme.colors.primary.border}`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { clamp } from 'lodash';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { useTheme2 } from '../../themes/ThemeContext';
|
||||
|
||||
export type SidebarPosition = 'left' | 'right';
|
||||
|
||||
export interface SidebarContextValue {
|
||||
isDocked: boolean;
|
||||
position: SidebarPosition;
|
||||
compact: boolean;
|
||||
hasOpenPane?: boolean;
|
||||
tabsMode?: boolean;
|
||||
outerWrapperProps: React.HTMLAttributes<HTMLDivElement>;
|
||||
paneWidth: number;
|
||||
bottomMargin: number;
|
||||
edgeMargin: number;
|
||||
contentMargin: number;
|
||||
onDockChange: () => void;
|
||||
onResize: (diff: number) => void;
|
||||
}
|
||||
|
||||
export const SidebarContext: React.Context<SidebarContextValue | undefined> = React.createContext<
|
||||
SidebarContextValue | undefined
|
||||
>(undefined);
|
||||
|
||||
export interface UseSideBarOptions {
|
||||
hasOpenPane?: boolean;
|
||||
position?: SidebarPosition;
|
||||
tabsMode?: boolean;
|
||||
compactDefault?: boolean;
|
||||
/** defaults to 2 grid units (16px) */
|
||||
bottomMargin?: number;
|
||||
/** defaults to 2 grid units (16px) */
|
||||
edgeMargin?: number;
|
||||
/** defaults to 2 grid units (16px) */
|
||||
contentMargin?: number;
|
||||
}
|
||||
|
||||
export const SIDE_BAR_WIDTH_ICON_ONLY = 5;
|
||||
export const SIDE_BAR_WIDTH_WITH_TEXT = 8;
|
||||
|
||||
export function useSidebar({
|
||||
hasOpenPane,
|
||||
position = 'right',
|
||||
tabsMode,
|
||||
compactDefault = true,
|
||||
bottomMargin = 2,
|
||||
edgeMargin = 2,
|
||||
contentMargin = 2,
|
||||
}: UseSideBarOptions): SidebarContextValue {
|
||||
const theme = useTheme2();
|
||||
const [isDocked, setIsDocked] = React.useState(false);
|
||||
const [paneWidth, setPaneWidth] = React.useState(280);
|
||||
const [compact, setCompact] = React.useState(compactDefault);
|
||||
// Used to accumulate drag distance to know when to change compact mode
|
||||
const [_, setCompactDrag] = React.useState(0);
|
||||
|
||||
const onDockChange = useCallback(() => setIsDocked((prev) => !prev), []);
|
||||
|
||||
const prop = position === 'right' ? 'paddingRight' : 'paddingLeft';
|
||||
const toolbarWidth =
|
||||
((compact ? SIDE_BAR_WIDTH_ICON_ONLY : SIDE_BAR_WIDTH_WITH_TEXT) + edgeMargin + contentMargin) *
|
||||
theme.spacing.gridSize;
|
||||
|
||||
const outerWrapperProps = {
|
||||
style: {
|
||||
[prop]: isDocked && hasOpenPane ? paneWidth + toolbarWidth : toolbarWidth,
|
||||
},
|
||||
};
|
||||
|
||||
const onResize = useCallback(
|
||||
(diff: number) => {
|
||||
setPaneWidth((prevWidth) => {
|
||||
// If no pane is open we use the resize action to toggle compact mode (button text visibility)
|
||||
if (!hasOpenPane) {
|
||||
setCompactDrag((prevDrag) => {
|
||||
const newDrag = prevDrag + diff;
|
||||
if (newDrag < -20 && !compact) {
|
||||
setCompact(true);
|
||||
return 0;
|
||||
} else if (newDrag > 20 && compact) {
|
||||
setCompact(false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return newDrag;
|
||||
});
|
||||
|
||||
return prevWidth;
|
||||
}
|
||||
|
||||
return clamp(prevWidth + diff, 100, 500);
|
||||
});
|
||||
},
|
||||
[hasOpenPane, compact]
|
||||
);
|
||||
|
||||
return {
|
||||
isDocked,
|
||||
onDockChange,
|
||||
onResize,
|
||||
outerWrapperProps,
|
||||
position,
|
||||
compact,
|
||||
hasOpenPane,
|
||||
tabsMode,
|
||||
paneWidth,
|
||||
edgeMargin,
|
||||
bottomMargin,
|
||||
contentMargin,
|
||||
};
|
||||
}
|
||||
@@ -466,6 +466,7 @@ export { RunnerPlugin } from './slate-plugins/runner';
|
||||
export { SelectionShortcutsPlugin } from './slate-plugins/selection_shortcuts';
|
||||
export { SlatePrism, type Token } from './slate-plugins/slate-prism';
|
||||
export { SuggestionsPlugin } from './slate-plugins/suggestions';
|
||||
export { Sidebar, useSidebar, type SidebarPosition, type SidebarContextValue } from './components/Sidebar/Sidebar';
|
||||
|
||||
// @deprecated import from @grafana/schema
|
||||
export {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql"
|
||||
)
|
||||
|
||||
var logger = log.New("datasource")
|
||||
@@ -71,7 +70,7 @@ func ValidateURL(typeName, urlStr string) (*url.URL, error) {
|
||||
var err error
|
||||
switch strings.ToLower(typeName) {
|
||||
case "mssql":
|
||||
u, err = mssql.ParseURL(urlStr, logger)
|
||||
u, err = parseURL(urlStr, logger)
|
||||
default:
|
||||
logger.Debug("Applying default URL parsing for this data source type", "type", typeName, "url", urlStr)
|
||||
|
||||
@@ -90,3 +89,28 @@ func ValidateURL(typeName, urlStr string) (*url.URL, error) {
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
type DebugOnlyLogger interface {
|
||||
Debug(msg string, args ...interface{})
|
||||
}
|
||||
|
||||
// ParseURL tries to parse an URL string into a URL object.
|
||||
func parseURL(u string, logger DebugOnlyLogger) (*url.URL, error) {
|
||||
logger.Debug("Parsing URL", "url", u)
|
||||
|
||||
// Recognize ODBC connection strings like host\instance:1234
|
||||
reODBC := regexp.MustCompile(`^[^\\:]+(?:\\[^:]+)?(?::\d+)?(?:;.+)?$`)
|
||||
var host string
|
||||
switch {
|
||||
case reODBC.MatchString(u):
|
||||
logger.Debug("Recognized as ODBC URL format", "url", u)
|
||||
host = u
|
||||
default:
|
||||
logger.Debug("Couldn't recognize as valid MSSQL URL", "url", u)
|
||||
return nil, fmt.Errorf("unrecognized URL format: %q", u)
|
||||
}
|
||||
return &url.URL{
|
||||
Scheme: "sqlserver",
|
||||
Host: host,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -886,7 +886,7 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) {
|
||||
description: "Invalid ODBC URL",
|
||||
url: `localhost\instance::1433`,
|
||||
err: datasource.URLValidationError{
|
||||
Err: errors.New(`unrecognized MSSQL URL format: "localhost\\instance::1433"`),
|
||||
Err: errors.New(`unrecognized URL format: "localhost\\instance::1433"`),
|
||||
URL: `localhost\instance::1433`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -28,20 +28,20 @@ func Test_WithPathRewriters(t *testing.T) {
|
||||
handler := WithPathRewriters(mockHandler, rewriters)
|
||||
|
||||
t.Run("should rewrite path", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", "/apis/scope.grafana.app/namespaces/stack-1234/query/blah", nil)
|
||||
req, err := http.NewRequest("GET", "/apis/scope.grafana.app/namespaces/stacks-1234/query/blah", nil)
|
||||
assert.NoError(t, err)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Equal(t, "/apis/scope.grafana.app/namespaces/stack-1234/query", rr.Body.String())
|
||||
assert.Equal(t, "/apis/scope.grafana.app/namespaces/stacks-1234/query", rr.Body.String())
|
||||
})
|
||||
|
||||
t.Run("should ignore requests that don't match", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", "/apis/scope.grafana.app/namespaces/stack-1234/scopes/1", nil)
|
||||
req, err := http.NewRequest("GET", "/apis/scope.grafana.app/namespaces/stacks-1234/scopes/1", nil)
|
||||
assert.NoError(t, err)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Equal(t, "/apis/scope.grafana.app/namespaces/stack-1234/scopes/1", rr.Body.String())
|
||||
assert.Equal(t, "/apis/scope.grafana.app/namespaces/stacks-1234/scopes/1", rr.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -536,7 +536,7 @@ func TestCheckResourceOwnership(t *testing.T) {
|
||||
"name": "test-resource",
|
||||
"annotations": map[string]interface{}{
|
||||
utils.AnnoKeyManagerKind: "terraform",
|
||||
utils.AnnoKeyManagerIdentity: "tf-stack-1",
|
||||
utils.AnnoKeyManagerIdentity: "tf-stacks-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -546,7 +546,7 @@ func TestCheckResourceOwnership(t *testing.T) {
|
||||
Identity: "repo-1",
|
||||
},
|
||||
expectError: true,
|
||||
expectedMessage: "resource 'test-resource' is managed by terraform 'tf-stack-1' and cannot be modified by repo 'repo-1'",
|
||||
expectedMessage: "resource 'test-resource' is managed by terraform 'tf-stacks-1' and cannot be modified by repo 'repo-1'",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestNamespaceMapper(t *testing.T) {
|
||||
orgId: 123,
|
||||
expected: "org-123",
|
||||
},
|
||||
// an invalid use-case, but just documenting that it's handled as stack-0
|
||||
// an invalid use-case, but just documenting that it's handled as stacks-0
|
||||
// this currently prevents the need to have the Mapper return (mapped, err) instead of just mapped.
|
||||
// err checking is avoided for now to keep the usage fluent
|
||||
{
|
||||
|
||||
@@ -1298,13 +1298,6 @@ var (
|
||||
FrontendOnly: true,
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "feedbackButton",
|
||||
Description: "Enables a button to send feedback from the Grafana UI",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaOperatorExperienceSquad,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "unifiedStorageSearchUI",
|
||||
Description: "Enable unified storage search UI",
|
||||
|
||||
Generated
-1
@@ -169,7 +169,6 @@ alertingEnrichmentPerRule,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertingEnrichmentAssistantInvestigations,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertingAIAnalyzeCentralStateHistory,experimental,@grafana/alerting-squad,false,false,false
|
||||
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-search-navigate-organise,false,false,true
|
||||
|
||||
|
Generated
-4
@@ -687,10 +687,6 @@ const (
|
||||
// Enables simplified step mode in the notifications section
|
||||
FlagAlertingNotificationsStepMode = "alertingNotificationsStepMode"
|
||||
|
||||
// FlagFeedbackButton
|
||||
// Enables a button to send feedback from the Grafana UI
|
||||
FlagFeedbackButton = "feedbackButton"
|
||||
|
||||
// FlagUnifiedStorageSearchUI
|
||||
// Enable unified storage search UI
|
||||
FlagUnifiedStorageSearchUI = "unifiedStorageSearchUI"
|
||||
|
||||
+2
-1
@@ -1731,7 +1731,8 @@
|
||||
"metadata": {
|
||||
"name": "feedbackButton",
|
||||
"resourceVersion": "1753448760331",
|
||||
"creationTimestamp": "2024-12-02T17:08:15Z"
|
||||
"creationTimestamp": "2024-12-02T17:08:15Z",
|
||||
"deletionTimestamp": "2025-11-20T09:53:52Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables a button to send feedback from the Grafana UI",
|
||||
|
||||
@@ -120,7 +120,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r
|
||||
Metrics: centrifuge.MetricsConfig{
|
||||
MetricsNamespace: "grafana_live",
|
||||
},
|
||||
ClientQueueMaxSize: 4194304, // 4MB
|
||||
ClientQueueMaxSize: cfg.LiveClientQueueMaxSize,
|
||||
// Use reasonably large expiration interval for stream meta key,
|
||||
// much bigger than maximum HistoryLifetime value in Node config.
|
||||
// This way stream meta data will expire, in some cases you may want
|
||||
|
||||
@@ -385,14 +385,24 @@ func (srv RulerSrv) RouteGetRuleVersionsByUID(c *contextmodel.ReqContext, ruleUI
|
||||
}
|
||||
sort.Slice(rules, func(i, j int) bool { return rules[i].ID > rules[j].ID })
|
||||
result := make(apimodels.GettableRuleVersions, 0, len(rules))
|
||||
userUIDmapping := srv.getUserUIDmapping(ctx, rules)
|
||||
userUIDmapping := srv.getUserUIDmapping(ctx, alertRuleVersionsToAlertRules(rules))
|
||||
for _, rule := range rules {
|
||||
// do not provide provenance status because we do not have historical changes for it
|
||||
result = append(result, toGettableExtendedRuleNode(*rule, map[string]ngmodels.Provenance{}, userUIDmapping))
|
||||
ruleNode := toGettableExtendedRuleNode(rule.AlertRule, map[string]ngmodels.Provenance{}, userUIDmapping)
|
||||
ruleNode.GrafanaManagedAlert.Message = rule.Message
|
||||
result = append(result, ruleNode)
|
||||
}
|
||||
return response.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func alertRuleVersionsToAlertRules(vs []*ngmodels.AlertRuleVersion) []*ngmodels.AlertRule {
|
||||
result := make([]*ngmodels.AlertRule, len(vs))
|
||||
for i := range vs {
|
||||
result[i] = &vs[i].AlertRule
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceUID string) response.Response {
|
||||
var deletePermanently bool
|
||||
if c.QueryBool("deletePermanently") {
|
||||
|
||||
@@ -560,11 +560,17 @@ func TestRouteGetRuleVersionsByUID(t *testing.T) {
|
||||
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], f)
|
||||
|
||||
rule := gen.GenerateRef()
|
||||
history := gen.With(gen.WithUID(rule.UID)).GenerateManyRef(3)
|
||||
historyRules := gen.With(gen.WithUID(rule.UID)).GenerateManyRef(3)
|
||||
history := make([]*models.AlertRuleVersion, len(historyRules))
|
||||
// simulate order of the history
|
||||
rule.ID = 100
|
||||
for i, alertRule := range history {
|
||||
for i, alertRule := range historyRules {
|
||||
alertRule.ID = rule.ID - int64(i) - 1
|
||||
|
||||
history[i] = &models.AlertRuleVersion{
|
||||
AlertRule: *alertRule,
|
||||
Message: fmt.Sprintf("revision %d", i),
|
||||
}
|
||||
}
|
||||
|
||||
ruleStore.PutRule(context.Background(), rule)
|
||||
@@ -584,11 +590,22 @@ func TestRouteGetRuleVersionsByUID(t *testing.T) {
|
||||
require.Len(t, result, len(history)+1) // history + current version
|
||||
|
||||
t.Run("should be in correct order", func(t *testing.T) {
|
||||
expectedHistory := append([]*models.AlertRule{rule}, history...)
|
||||
expectedHistory := append([]*models.AlertRuleVersion{{AlertRule: *rule}}, history...)
|
||||
for i, rul := range expectedHistory {
|
||||
assert.Equal(t, rul.UID, result[i].GrafanaManagedAlert.UID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should have correct messages", func(t *testing.T) {
|
||||
expectedMessages := make([]string, 0, len(history)+1)
|
||||
expectedMessages = append(expectedMessages, "")
|
||||
for i := range history {
|
||||
expectedMessages = append(expectedMessages, history[i].Message)
|
||||
}
|
||||
for i := range expectedMessages {
|
||||
assert.Equal(t, expectedMessages[i], result[i].GrafanaManagedAlert.Message)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NotFound when rule does not exist", func(t *testing.T) {
|
||||
@@ -599,10 +616,17 @@ func TestRouteGetRuleVersionsByUID(t *testing.T) {
|
||||
UID: "test",
|
||||
}
|
||||
guid := uuid.NewString()
|
||||
history := gen.With(gen.WithGUID(guid), gen.WithKey(ruleKey)).GenerateManyRef(3)
|
||||
historyRules := gen.With(gen.WithGUID(guid), gen.WithKey(ruleKey)).GenerateManyRef(3)
|
||||
history := make([]*models.AlertRuleVersion, len(historyRules))
|
||||
for i, alertRule := range historyRules {
|
||||
history[i] = &models.AlertRuleVersion{
|
||||
AlertRule: *alertRule,
|
||||
Message: fmt.Sprintf("revision %d", i),
|
||||
}
|
||||
}
|
||||
ruleStore.History[guid] = append(ruleStore.History[guid], history...) // even if history is full of records
|
||||
|
||||
perms := createPermissionsForRules(history, orgID)
|
||||
perms := createPermissionsForRules(historyRules, orgID)
|
||||
req := createRequestContextWithPerms(orgID, perms, nil)
|
||||
response := createService(ruleStore, nil).RouteGetRuleVersionsByUID(req, ruleKey.UID)
|
||||
|
||||
@@ -643,10 +667,17 @@ func TestRouteGetRuleVersionsByUID(t *testing.T) {
|
||||
guid := uuid.NewString()
|
||||
rule := gen.With(gen.WithGUID(guid), gen.WithKey(ruleKey), gen.WithNamespaceUID(anotherFolder.UID)).GenerateRef()
|
||||
ruleStore.PutRule(context.Background(), rule)
|
||||
history := gen.With(gen.WithGUID(guid), gen.WithKey(ruleKey)).GenerateManyRef(3)
|
||||
historyRules := gen.With(gen.WithGUID(guid), gen.WithKey(ruleKey)).GenerateManyRef(3)
|
||||
history := make([]*models.AlertRuleVersion, len(historyRules))
|
||||
for i, alertRule := range historyRules {
|
||||
history[i] = &models.AlertRuleVersion{
|
||||
AlertRule: *alertRule,
|
||||
Message: fmt.Sprintf("revision %d", i),
|
||||
}
|
||||
}
|
||||
ruleStore.History[guid] = history
|
||||
|
||||
perms := createPermissionsForRules(history, orgID) // grant permissions to all records in history but not the rule itself
|
||||
perms := createPermissionsForRules(historyRules, orgID) // grant permissions to all records in history but not the rule itself
|
||||
req := createRequestContextWithPerms(orgID, perms, nil)
|
||||
response := createService(ruleStore, nil).RouteGetRuleVersionsByUID(req, ruleKey.UID)
|
||||
|
||||
|
||||
@@ -35,6 +35,6 @@ type RuleStore interface {
|
||||
|
||||
// IncreaseVersionForAllRulesInNamespaces Increases version for all rules that have specified namespace uids
|
||||
IncreaseVersionForAllRulesInNamespaces(ctx context.Context, orgID int64, namespaceUIDs []string) ([]ngmodels.AlertRuleKeyWithVersion, error)
|
||||
GetAlertRuleVersions(ctx context.Context, orgID int64, guid string) ([]*ngmodels.AlertRule, error)
|
||||
GetAlertRuleVersions(ctx context.Context, orgID int64, guid string) ([]*ngmodels.AlertRuleVersion, error)
|
||||
accesscontrol.RuleUIDToNamespaceStore
|
||||
}
|
||||
|
||||
@@ -617,6 +617,9 @@ type GettableGrafanaRule struct {
|
||||
Metadata *AlertRuleMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
GUID string `json:"guid" yaml:"guid"`
|
||||
MissingSeriesEvalsToResolve *int64 `json:"missing_series_evals_to_resolve,omitempty" yaml:"missing_series_evals_to_resolve,omitempty"`
|
||||
|
||||
// Field is only populated when listing alert rule versions.
|
||||
Message string `yaml:"message,omitempty" json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// UserInfo represents user-related information, including a unique identifier and a name.
|
||||
|
||||
@@ -369,6 +369,13 @@ type AlertRule struct {
|
||||
MissingSeriesEvalsToResolve *int64
|
||||
}
|
||||
|
||||
type AlertRuleVersion struct {
|
||||
AlertRule
|
||||
|
||||
// Message is only stored in the alert_rule_version table.
|
||||
Message string
|
||||
}
|
||||
|
||||
type AlertRuleMetadata struct {
|
||||
EditorSettings EditorSettings `json:"editor_settings"`
|
||||
PrometheusStyleRule *PrometheusStyleRule `json:"prometheus_style_rule,omitempty"`
|
||||
|
||||
@@ -192,8 +192,8 @@ func (st DBstore) GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAler
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (st DBstore) GetAlertRuleVersions(ctx context.Context, orgID int64, guid string) ([]*ngmodels.AlertRule, error) {
|
||||
alertRules := make([]*ngmodels.AlertRule, 0)
|
||||
func (st DBstore) GetAlertRuleVersions(ctx context.Context, orgID int64, guid string) ([]*ngmodels.AlertRuleVersion, error) {
|
||||
alertRules := make([]*ngmodels.AlertRuleVersion, 0)
|
||||
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
rows, err := sess.Table(new(alertRuleVersion)).Where("rule_org_id = ? AND rule_guid = ?", orgID, guid).Asc("id").Rows(new(alertRuleVersion))
|
||||
if err != nil {
|
||||
@@ -213,7 +213,7 @@ func (st DBstore) GetAlertRuleVersions(ctx context.Context, orgID int64, guid st
|
||||
if previousVersion != nil && previousVersion.EqualSpec(*rule) {
|
||||
continue
|
||||
}
|
||||
converted, err := alertRuleToModelsAlertRule(alertRuleVersionToAlertRule(*rule), st.Logger)
|
||||
converted, err := alertRuleVersionToModelsAlertRuleVersion(*rule, st.Logger)
|
||||
if err != nil {
|
||||
st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "GetAlertRuleVersions", "error", err, "version_id", rule.ID)
|
||||
continue
|
||||
@@ -226,7 +226,7 @@ func (st DBstore) GetAlertRuleVersions(ctx context.Context, orgID int64, guid st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slices.SortFunc(alertRules, func(a, b *ngmodels.AlertRule) int {
|
||||
slices.SortFunc(alertRules, func(a, b *ngmodels.AlertRuleVersion) int {
|
||||
if a.ID > b.ID {
|
||||
return -1
|
||||
}
|
||||
@@ -257,6 +257,7 @@ func (st DBstore) ListDeletedRules(ctx context.Context, orgID int64) ([]*ngmodel
|
||||
st.Logger.Error("Invalid rule version found in DB store, ignoring it", "func", "GetAlertRuleVersions", "error", err)
|
||||
continue
|
||||
}
|
||||
// Note: Message is not returned as a message cannot be set when deleting rules.
|
||||
converted, err := alertRuleToModelsAlertRule(alertRuleVersionToAlertRule(*rule), st.Logger)
|
||||
if err != nil {
|
||||
st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "GetAlertRuleVersions", "error", err, "version_id", rule.ID)
|
||||
|
||||
@@ -1682,7 +1682,7 @@ func TestIntegrationGetRuleVersions(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, versions, 2)
|
||||
assert.IsDecreasing(t, versions[0].ID, versions[1].ID)
|
||||
diff := versions[1].Diff(versions[0], AlertRuleFieldsToIgnoreInDiff[:]...)
|
||||
diff := versions[1].Diff(&versions[0].AlertRule, AlertRuleFieldsToIgnoreInDiff[:]...)
|
||||
assert.ElementsMatch(t, []string{"Title", "RuleGroupIndex"}, diff.Paths())
|
||||
})
|
||||
|
||||
@@ -1712,7 +1712,7 @@ func TestIntegrationGetRuleVersions(t *testing.T) {
|
||||
versions, err := store.GetAlertRuleVersions(context.Background(), ruleV3.OrgID, ruleV3.GUID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, versions, 3)
|
||||
diff := versions[0].Diff(versions[1], AlertRuleFieldsToIgnoreInDiff[:]...)
|
||||
diff := versions[0].Diff(&versions[1].AlertRule, AlertRuleFieldsToIgnoreInDiff[:]...)
|
||||
assert.ElementsMatch(t, []string{"RuleGroup", "NamespaceUID"}, diff.Paths())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ func alertRuleToAlertRuleVersion(rule alertRule) alertRuleVersion {
|
||||
Version: rule.Version,
|
||||
Created: rule.Updated, // assuming the Updated time as the creation time
|
||||
CreatedBy: rule.UpdatedBy,
|
||||
Message: "", // Message is set by caller when creating versions
|
||||
Title: rule.Title,
|
||||
Condition: rule.Condition,
|
||||
Data: rule.Data,
|
||||
@@ -261,3 +262,15 @@ func alertRuleVersionToAlertRule(version alertRuleVersion) alertRule {
|
||||
MissingSeriesEvalsToResolve: version.MissingSeriesEvalsToResolve,
|
||||
}
|
||||
}
|
||||
|
||||
func alertRuleVersionToModelsAlertRuleVersion(version alertRuleVersion, l log.Logger) (models.AlertRuleVersion, error) {
|
||||
result, err := alertRuleToModelsAlertRule(alertRuleVersionToAlertRule(version), l)
|
||||
if err != nil {
|
||||
return models.AlertRuleVersion{}, err
|
||||
}
|
||||
|
||||
return models.AlertRuleVersion{
|
||||
AlertRule: result,
|
||||
Message: version.Message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -69,10 +69,11 @@ type alertRuleVersion struct {
|
||||
NotificationSettings string `xorm:"notification_settings"`
|
||||
Metadata string `xorm:"metadata"`
|
||||
MissingSeriesEvalsToResolve *int64 `xorm:"missing_series_evals_to_resolve"`
|
||||
Message string
|
||||
}
|
||||
|
||||
// EqualSpec compares two alertRuleVersion objects for equality based on their specifications and returns true if they match.
|
||||
// The comparison is very basic and can produce false-negative. Fields excluded: ID, ParentVersion, RestoredFrom, Version, Created, RuleGroupIndex and CreatedBy
|
||||
// The comparison is very basic and can produce false-negative. Fields excluded: ID, ParentVersion, RestoredFrom, Version, Created, RuleGroupIndex, CreatedBy and Message
|
||||
func (a alertRuleVersion) EqualSpec(b alertRuleVersion) bool {
|
||||
return a.RuleOrgID == b.RuleOrgID &&
|
||||
a.RuleGUID == b.RuleGUID &&
|
||||
|
||||
@@ -23,7 +23,7 @@ type RuleStore struct {
|
||||
mtx sync.Mutex
|
||||
// OrgID -> RuleGroup -> Namespace -> Rules
|
||||
Rules map[int64][]*models.AlertRule
|
||||
History map[string][]*models.AlertRule
|
||||
History map[string][]*models.AlertRuleVersion
|
||||
Deleted map[int64][]*models.AlertRule
|
||||
Hook func(cmd any) error // use Hook if you need to intercept some query and return an error
|
||||
RecordedOps []any
|
||||
@@ -43,7 +43,7 @@ func NewRuleStore(t *testing.T) *RuleStore {
|
||||
return nil
|
||||
},
|
||||
Folders: map[int64][]*folder.Folder{},
|
||||
History: map[string][]*models.AlertRule{},
|
||||
History: map[string][]*models.AlertRuleVersion{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,10 @@ mainloop:
|
||||
for _, r := range rules {
|
||||
rgs := f.Rules[r.OrgID]
|
||||
cp := models.CopyRule(r)
|
||||
f.History[r.GUID] = append(f.History[r.GUID], cp)
|
||||
f.History[r.GUID] = append(f.History[r.GUID], &models.AlertRuleVersion{
|
||||
AlertRule: *cp,
|
||||
Message: "",
|
||||
})
|
||||
for idx, rulePtr := range rgs {
|
||||
if rulePtr.UID == r.UID {
|
||||
rgs[idx] = r
|
||||
@@ -87,6 +90,18 @@ mainloop:
|
||||
}
|
||||
}
|
||||
|
||||
// AppendHistory appends to rules to the version history with the given change message.
|
||||
func (f *RuleStore) AppendHistory(guid string, rules []*models.AlertRule, message string) {
|
||||
versions := make([]*models.AlertRuleVersion, len(rules))
|
||||
for i := range rules {
|
||||
versions[i] = &models.AlertRuleVersion{
|
||||
AlertRule: *rules[i],
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
f.History[guid] = append(f.History[guid], versions...)
|
||||
}
|
||||
|
||||
// GetRecordedCommands filters recorded commands using predicate function. Returns the subset of the recorded commands that meet the predicate
|
||||
func (f *RuleStore) GetRecordedCommands(predicate func(cmd any) (any, bool)) []any {
|
||||
f.mtx.Lock()
|
||||
@@ -563,7 +578,7 @@ func (f *RuleStore) GetNamespacesByRuleUID(ctx context.Context, orgID int64, uid
|
||||
return namespacesMap, nil
|
||||
}
|
||||
|
||||
func (f *RuleStore) GetAlertRuleVersions(_ context.Context, orgID int64, guid string) ([]*models.AlertRule, error) {
|
||||
func (f *RuleStore) GetAlertRuleVersions(_ context.Context, orgID int64, guid string) ([]*models.AlertRuleVersion, error) {
|
||||
f.mtx.Lock()
|
||||
defer f.mtx.Unlock()
|
||||
|
||||
|
||||
@@ -157,6 +157,10 @@ func (api *ServiceAccountsAPI) CreateToken(c *contextmodel.ReqContext) response.
|
||||
}
|
||||
|
||||
if api.cfg.SATokenExpirationDayLimit > 0 {
|
||||
if cmd.SecondsToLive == 0 {
|
||||
return response.Error(http.StatusBadRequest, "Cannot create token with no expiration date when service_accounts.token_expiration_day_limit is set", nil)
|
||||
}
|
||||
|
||||
dayExpireLimit := time.Now().Add(time.Duration(api.cfg.SATokenExpirationDayLimit) * time.Hour * 24).Truncate(24 * time.Hour)
|
||||
expirationDate := time.Now().Add(time.Duration(cmd.SecondsToLive) * time.Second).Truncate(24 * time.Hour)
|
||||
if expirationDate.After(dayExpireLimit) {
|
||||
|
||||
@@ -60,14 +60,15 @@ func TestServiceAccountsAPI_ListTokens(t *testing.T) {
|
||||
|
||||
func TestServiceAccountsAPI_CreateToken(t *testing.T) {
|
||||
type TestCase struct {
|
||||
desc string
|
||||
id int64
|
||||
body string
|
||||
permissions []accesscontrol.Permission
|
||||
tokenTTL int64
|
||||
expectedErr error
|
||||
expectedAPIKey *apikey.APIKey
|
||||
expectedCode int
|
||||
desc string
|
||||
id int64
|
||||
body string
|
||||
permissions []accesscontrol.Permission
|
||||
tokenTTL int64
|
||||
tokenExpirationDayLimit int
|
||||
expectedErr error
|
||||
expectedAPIKey *apikey.APIKey
|
||||
expectedCode int
|
||||
}
|
||||
|
||||
tests := []TestCase{
|
||||
@@ -105,12 +106,58 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) {
|
||||
permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}},
|
||||
expectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
desc: "should not be able to create token for service account if max ttl is configured and exceeds the limit",
|
||||
id: 1,
|
||||
body: `{"name": "test", "secondsToLive": 11000}`,
|
||||
tokenTTL: 10000,
|
||||
permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}},
|
||||
expectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
desc: "should be able to create token for service account if max ttl is configured and within the limit",
|
||||
id: 1,
|
||||
body: `{"name": "test", "secondsToLive": 5000}`,
|
||||
tokenTTL: 10000,
|
||||
permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}},
|
||||
expectedAPIKey: &apikey.APIKey{},
|
||||
expectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
desc: "should not be able to create token for service account if token expiration day limit is configured but not set in body",
|
||||
id: 1,
|
||||
body: `{"name": "test"}`,
|
||||
tokenTTL: -1,
|
||||
tokenExpirationDayLimit: 30,
|
||||
permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}},
|
||||
expectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
desc: "should not be able to create token for service account if token expiration day limit is configured and exceeds the limit",
|
||||
id: 1,
|
||||
body: `{"name": "test", "secondsToLive": 340000}`, // 340000 seconds is > 3 days
|
||||
tokenTTL: -1,
|
||||
tokenExpirationDayLimit: 3,
|
||||
permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}},
|
||||
expectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
desc: "should be able to create token for service account if token expiration day limit is configured and within the limit",
|
||||
id: 1,
|
||||
body: `{"name": "test", "secondsToLive": 250000}`, // 250000 seconds is almost 3 days
|
||||
tokenTTL: -1,
|
||||
tokenExpirationDayLimit: 3,
|
||||
permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}},
|
||||
expectedAPIKey: &apikey.APIKey{},
|
||||
expectedCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
server := setupTests(t, func(a *ServiceAccountsAPI) {
|
||||
a.cfg.ApiKeyMaxSecondsToLive = tt.tokenTTL
|
||||
a.cfg.SATokenExpirationDayLimit = tt.tokenExpirationDayLimit
|
||||
a.service = &satests.FakeServiceAccountService{
|
||||
ExpectedErr: tt.expectedErr,
|
||||
ExpectedAPIKey: tt.expectedAPIKey,
|
||||
|
||||
@@ -24,6 +24,9 @@ func AddTablesMigrations(mg *migrator.Migrator) {
|
||||
mg.AddMigration("add last_applied column to alert_configuration_history", migrator.NewAddColumnMigration(migrator.Table{Name: "alert_configuration_history"}, &migrator.Column{
|
||||
Name: "last_applied", Type: migrator.DB_Int, Nullable: false, Default: "0",
|
||||
}))
|
||||
mg.AddMigration("add message column to alert_rule_version", migrator.NewAddColumnMigration(migrator.Table{Name: "alert_rule_version"}, &migrator.Column{
|
||||
Name: "message", Type: migrator.DB_Text, Nullable: true,
|
||||
}))
|
||||
// End of migration log, add new migrations above this line.
|
||||
}
|
||||
|
||||
|
||||
@@ -480,6 +480,9 @@ type Cfg struct {
|
||||
// LiveMessageSizeLimit is the maximum size in bytes of Websocket messages
|
||||
// from clients. Defaults to 64KB.
|
||||
LiveMessageSizeLimit int
|
||||
// LiveClientQueueMaxSize is the maximum size in bytes of the client queue
|
||||
// for Live connections. Defaults to 4MB.
|
||||
LiveClientQueueMaxSize int
|
||||
|
||||
// Grafana.com URL, used for OAuth redirect.
|
||||
GrafanaComURL string
|
||||
@@ -2066,6 +2069,11 @@ func (cfg *Cfg) readLiveSettings(iniFile *ini.File) error {
|
||||
if cfg.LiveMessageSizeLimit < -1 {
|
||||
return fmt.Errorf("unexpected value %d for [live] message_size_limit", cfg.LiveMaxConnections)
|
||||
}
|
||||
cfg.LiveClientQueueMaxSize = section.Key("client_queue_max_size").MustInt(4194304)
|
||||
if cfg.LiveClientQueueMaxSize <= 0 {
|
||||
return fmt.Errorf("unexpected value %d for [live] client_queue_max_size", cfg.LiveMaxConnections)
|
||||
}
|
||||
|
||||
cfg.LiveHAEngine = section.Key("ha_engine").MustString("")
|
||||
switch cfg.LiveHAEngine {
|
||||
case "", "redis":
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
|
||||
)
|
||||
|
||||
func GetAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentials, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool) (string, error) {
|
||||
connStr := ""
|
||||
switch c := azureCredentials.(type) {
|
||||
case *azcredentials.AzureManagedIdentityCredentials:
|
||||
if azureManagedIdentityClientId != "" {
|
||||
connStr += fmt.Sprintf("user id=%s;", azureManagedIdentityClientId)
|
||||
}
|
||||
connStr += fmt.Sprintf("fedauth=%s;",
|
||||
"ActiveDirectoryManagedIdentity")
|
||||
case *azcredentials.AzureClientSecretCredentials:
|
||||
connStr += fmt.Sprintf("user id=%s@%s;password=%s;fedauth=%s;",
|
||||
c.ClientId,
|
||||
c.TenantId,
|
||||
c.ClientSecret,
|
||||
"ActiveDirectoryApplication",
|
||||
)
|
||||
case *azcredentials.AzureEntraPasswordCredentials:
|
||||
if azureEntraPasswordCredentialsEnabled {
|
||||
connStr += fmt.Sprintf("user id=%s;password=%s;applicationclientid=%s;fedauth=%s;",
|
||||
c.UserId,
|
||||
c.Password,
|
||||
c.ClientId,
|
||||
"ActiveDirectoryPassword",
|
||||
)
|
||||
} else {
|
||||
return "", fmt.Errorf("azure entra password authentication is not enabled")
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported azure authentication type")
|
||||
}
|
||||
return connStr, nil
|
||||
}
|
||||
+10
-358
@@ -2,32 +2,18 @@ package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azsettings"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
|
||||
mssql "github.com/microsoft/go-mssqldb"
|
||||
"github.com/microsoft/go-mssqldb/azuread"
|
||||
_ "github.com/microsoft/go-mssqldb/integratedauth/krb5"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/sqleng"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/utils"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -35,16 +21,6 @@ type Service struct {
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
const (
|
||||
azureAuthentication = "Azure AD Authentication"
|
||||
windowsAuthentication = "Windows Authentication"
|
||||
sqlServerAuthentication = "SQL Server Authentication"
|
||||
kerberosRaw = "Windows AD: Username + password"
|
||||
kerberosKeytab = "Windows AD: Keytab"
|
||||
kerberosCredentialCache = "Windows AD: Credential cache" // #nosec G101
|
||||
kerberosCredentialCacheFile = "Windows AD: Credential cache file" // #nosec G101
|
||||
)
|
||||
|
||||
func ProvideService(cfg *setting.Cfg) *Service {
|
||||
logger := backend.NewLoggerWith("logger", "tsdb.mssql")
|
||||
return &Service{
|
||||
@@ -70,72 +46,6 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
|
||||
return dsHandler.QueryData(ctx, req)
|
||||
}
|
||||
|
||||
func newMSSQL(ctx context.Context, driverName string, userFacingDefaultError string, rowLimit int64, dsInfo sqleng.DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*sql.DB, *sqleng.DataSourceHandler, error) {
|
||||
var connector *mssql.Connector
|
||||
var err error
|
||||
if driverName == "azuresql" {
|
||||
connector, err = azuread.NewConnector(cnnstr)
|
||||
} else {
|
||||
connector, err = mssql.NewConnector(cnnstr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Error("mssql connector creation failed", "error", err)
|
||||
return nil, nil, fmt.Errorf("mssql connector creation failed")
|
||||
}
|
||||
|
||||
proxyClient, err := settings.ProxyClient(ctx)
|
||||
if err != nil {
|
||||
logger.Error("mssql proxy creation failed", "error", err)
|
||||
return nil, nil, fmt.Errorf("mssql proxy creation failed")
|
||||
}
|
||||
|
||||
if proxyClient.SecureSocksProxyEnabled() {
|
||||
dialer, err := proxyClient.NewSecureSocksProxyContextDialer()
|
||||
if err != nil {
|
||||
logger.Error("mssql proxy creation failed", "error", err)
|
||||
return nil, nil, fmt.Errorf("mssql proxy creation failed")
|
||||
}
|
||||
URL, err := ParseURL(dsInfo.URL, logger)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
mssqlDialer, err := newMSSQLProxyDialer(URL.Hostname(), dialer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// update the mssql dialer with the proxy dialer
|
||||
connector.Dialer = (mssqlDialer)
|
||||
}
|
||||
|
||||
config := sqleng.DataPluginConfiguration{
|
||||
DSInfo: dsInfo,
|
||||
MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"},
|
||||
RowLimit: rowLimit,
|
||||
}
|
||||
|
||||
queryResultTransformer := mssqlQueryResultTransformer{
|
||||
userError: userFacingDefaultError,
|
||||
}
|
||||
|
||||
db := sql.OpenDB(connector)
|
||||
|
||||
db.SetMaxOpenConns(config.DSInfo.JsonData.MaxOpenConns)
|
||||
db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns)
|
||||
db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second)
|
||||
|
||||
handler, err := sqleng.NewQueryDataHandler(userFacingDefaultError, db, config, &queryResultTransformer, newMssqlMacroEngine(),
|
||||
logger)
|
||||
if err != nil {
|
||||
logger.Error("Failed connecting to Postgres", "err", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
logger.Debug("Successfully connected to Postgres")
|
||||
return db, handler, nil
|
||||
}
|
||||
|
||||
func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.InstanceFactoryFunc {
|
||||
return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
|
||||
grafCfg := backend.GrafanaConfigFromContext(ctx)
|
||||
@@ -151,14 +61,11 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
|
||||
ConnectionTimeout: 0,
|
||||
SecureDSProxy: false,
|
||||
}
|
||||
azureCredentials, err := utils.GetAzureCredentials(settings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading azure credentials")
|
||||
}
|
||||
|
||||
kerberosAuth, err := kerberos.GetKerberosSettings(settings)
|
||||
azureSettings, err := azsettings.ReadSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting kerberos settings: %w", err)
|
||||
logger.Error("failed to read Azure settings from Grafana", "error", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(settings.JSONData, &jsonData)
|
||||
@@ -181,23 +88,18 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
|
||||
UID: settings.UID,
|
||||
DecryptedSecureJSONData: settings.DecryptedSecureJSONData,
|
||||
}
|
||||
cnnstr, err := generateConnectionString(dsInfo, cfg.Azure.ManagedIdentityClientId, cfg.Azure.AzureEntraPasswordCredentialsEnabled, azureCredentials, kerberosAuth, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
driverName := "mssql"
|
||||
if jsonData.AuthenticationType == azureAuthentication {
|
||||
driverName = "azuresql"
|
||||
}
|
||||
|
||||
userFacingDefaultError, err := grafCfg.UserFacingDefaultError()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, handler, err := newMSSQL(ctx, driverName, userFacingDefaultError, sqlCfg.RowLimit, dsInfo, cnnstr, logger, settings)
|
||||
|
||||
config := sqleng.DataPluginConfiguration{
|
||||
DSInfo: dsInfo,
|
||||
MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"},
|
||||
RowLimit: sqlCfg.RowLimit,
|
||||
}
|
||||
handler, err := sqleng.NewQueryDataHandler(ctx, settings, userFacingDefaultError, config, logger, azureSettings)
|
||||
if err != nil {
|
||||
logger.Error("Failed connecting to MSSQL", "err", err)
|
||||
return nil, err
|
||||
@@ -208,159 +110,6 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
|
||||
}
|
||||
}
|
||||
|
||||
// ParseURL is called also from pkg/api/datasource/validation.go,
|
||||
// which uses a different logging interface,
|
||||
// so we have a special minimal interface that is fulfilled by
|
||||
// both places.
|
||||
type DebugOnlyLogger interface {
|
||||
Debug(msg string, args ...interface{})
|
||||
}
|
||||
|
||||
// ParseURL tries to parse an MSSQL URL string into a URL object.
|
||||
func ParseURL(u string, logger DebugOnlyLogger) (*url.URL, error) {
|
||||
logger.Debug("Parsing MSSQL URL", "url", u)
|
||||
|
||||
// Recognize ODBC connection strings like host\instance:1234
|
||||
reODBC := regexp.MustCompile(`^[^\\:]+(?:\\[^:]+)?(?::\d+)?(?:;.+)?$`)
|
||||
var host string
|
||||
switch {
|
||||
case reODBC.MatchString(u):
|
||||
logger.Debug("Recognized as ODBC URL format", "url", u)
|
||||
host = u
|
||||
default:
|
||||
logger.Debug("Couldn't recognize as valid MSSQL URL", "url", u)
|
||||
return nil, fmt.Errorf("unrecognized MSSQL URL format: %q", u)
|
||||
}
|
||||
return &url.URL{
|
||||
Scheme: "sqlserver",
|
||||
Host: host,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateConnectionString(dsInfo sqleng.DataSourceInfo, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool, azureCredentials azcredentials.AzureCredentials, kerberosAuth kerberos.KerberosAuth, logger log.Logger) (string, error) {
|
||||
const dfltPort = "0"
|
||||
var addr util.NetworkAddress
|
||||
if dsInfo.URL != "" {
|
||||
u, err := ParseURL(dsInfo.URL, logger)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
addr, err = util.SplitHostPortDefault(u.Host, "localhost", dfltPort)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
addr = util.NetworkAddress{
|
||||
Host: "localhost",
|
||||
Port: dfltPort,
|
||||
}
|
||||
}
|
||||
|
||||
args := []any{
|
||||
"url", dsInfo.URL, "host", addr.Host,
|
||||
}
|
||||
if addr.Port != "0" {
|
||||
args = append(args, "port", addr.Port)
|
||||
}
|
||||
logger.Debug("Generating connection string", args...)
|
||||
|
||||
encrypt := dsInfo.JsonData.Encrypt
|
||||
tlsSkipVerify := dsInfo.JsonData.TlsSkipVerify
|
||||
hostNameInCertificate := dsInfo.JsonData.Servername
|
||||
certificate := dsInfo.JsonData.RootCertFile
|
||||
connStr := fmt.Sprintf("server=%s;database=%s;",
|
||||
addr.Host,
|
||||
dsInfo.Database,
|
||||
)
|
||||
|
||||
switch dsInfo.JsonData.AuthenticationType {
|
||||
case azureAuthentication:
|
||||
azureCredentialDSNFragment, err := getAzureCredentialDSNFragment(azureCredentials, azureManagedIdentityClientId, azureEntraPasswordCredentialsEnabled)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
connStr += azureCredentialDSNFragment
|
||||
case windowsAuthentication:
|
||||
// No user id or password. We're using windows single sign on.
|
||||
case kerberosRaw, kerberosKeytab, kerberosCredentialCacheFile, kerberosCredentialCache:
|
||||
connStr = kerberos.Krb5ParseAuthCredentials(addr.Host, addr.Port, dsInfo.Database, dsInfo.User, dsInfo.DecryptedSecureJSONData["password"], kerberosAuth)
|
||||
default:
|
||||
connStr += fmt.Sprintf("user id=%s;password=%s;", dsInfo.User, dsInfo.DecryptedSecureJSONData["password"])
|
||||
}
|
||||
|
||||
// Port number 0 means to determine the port automatically, so we can let the driver choose
|
||||
if addr.Port != "0" {
|
||||
connStr += fmt.Sprintf("port=%s;", addr.Port)
|
||||
}
|
||||
switch encrypt {
|
||||
case "true":
|
||||
connStr += fmt.Sprintf("encrypt=%s;TrustServerCertificate=%t;", encrypt, tlsSkipVerify)
|
||||
if hostNameInCertificate != "" {
|
||||
connStr += fmt.Sprintf("hostNameInCertificate=%s;", hostNameInCertificate)
|
||||
}
|
||||
|
||||
if certificate != "" {
|
||||
connStr += fmt.Sprintf("certificate=%s;", certificate)
|
||||
}
|
||||
case "disable":
|
||||
connStr += fmt.Sprintf("encrypt=%s;", dsInfo.JsonData.Encrypt)
|
||||
}
|
||||
|
||||
if dsInfo.JsonData.ConnectionTimeout != 0 {
|
||||
connStr += fmt.Sprintf("connection timeout=%d;", dsInfo.JsonData.ConnectionTimeout)
|
||||
}
|
||||
|
||||
return connStr, nil
|
||||
}
|
||||
|
||||
func getAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentials, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool) (string, error) {
|
||||
connStr := ""
|
||||
switch c := azureCredentials.(type) {
|
||||
case *azcredentials.AzureManagedIdentityCredentials:
|
||||
if azureManagedIdentityClientId != "" {
|
||||
connStr += fmt.Sprintf("user id=%s;", azureManagedIdentityClientId)
|
||||
}
|
||||
connStr += fmt.Sprintf("fedauth=%s;",
|
||||
"ActiveDirectoryManagedIdentity")
|
||||
case *azcredentials.AzureClientSecretCredentials:
|
||||
connStr += fmt.Sprintf("user id=%s@%s;password=%s;fedauth=%s;",
|
||||
c.ClientId,
|
||||
c.TenantId,
|
||||
c.ClientSecret,
|
||||
"ActiveDirectoryApplication",
|
||||
)
|
||||
case *azcredentials.AzureEntraPasswordCredentials:
|
||||
if azureEntraPasswordCredentialsEnabled {
|
||||
connStr += fmt.Sprintf("user id=%s;password=%s;applicationclientid=%s;fedauth=%s;",
|
||||
c.UserId,
|
||||
c.Password,
|
||||
c.ClientId,
|
||||
"ActiveDirectoryPassword",
|
||||
)
|
||||
} else {
|
||||
return "", fmt.Errorf("azure entra password authentication is not enabled")
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported azure authentication type")
|
||||
}
|
||||
return connStr, nil
|
||||
}
|
||||
|
||||
type mssqlQueryResultTransformer struct {
|
||||
userError string
|
||||
}
|
||||
|
||||
func (t *mssqlQueryResultTransformer) TransformQueryError(logger log.Logger, err error) error {
|
||||
// go-mssql overrides source error, so we currently match on string
|
||||
// ref https://github.com/denisenkom/go-mssqldb/blob/045585d74f9069afe2e115b6235eb043c8047043/tds.go#L904
|
||||
if strings.HasPrefix(strings.ToLower(err.Error()), "unable to open tcp connection with host") {
|
||||
logger.Error("Query error", "error", err)
|
||||
return fmt.Errorf("failed to connect to server - %s", t.userError)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckHealth pings the connected SQL database
|
||||
func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
|
||||
dsHandler, err := s.getDataSourceHandler(ctx, req.PluginContext)
|
||||
@@ -370,100 +119,3 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
|
||||
|
||||
return dsHandler.CheckHealth(ctx, req)
|
||||
}
|
||||
|
||||
func (t *mssqlQueryResultTransformer) GetConverterList() []sqlutil.StringConverter {
|
||||
return []sqlutil.StringConverter{
|
||||
{
|
||||
Name: "handle MONEY",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "MONEY",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableFloat64,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(*in, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle SMALLMONEY",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "SMALLMONEY",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableFloat64,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(*in, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle DECIMAL",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "DECIMAL",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableFloat64,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(*in, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle UNIQUEIDENTIFIER",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "UNIQUEIDENTIFIER",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableString,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
uuid := &mssql.UniqueIdentifier{}
|
||||
if err := uuid.Scan([]byte(*in)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := uuid.String()
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle SQL_VARIANT",
|
||||
InputScanKind: reflect.Pointer,
|
||||
InputTypeName: "SQL_VARIANT",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableString,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return in, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+14
-273
@@ -3,21 +3,21 @@ package mssql
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azsettings"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/sqleng"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/utils"
|
||||
)
|
||||
|
||||
// To run this test, set runMssqlTests=true
|
||||
@@ -39,7 +39,6 @@ func TestMSSQL(t *testing.T) {
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
queryResultTransformer := mssqlQueryResultTransformer{}
|
||||
dsInfo := sqleng.DataSourceInfo{}
|
||||
config := sqleng.DataPluginConfiguration{
|
||||
DSInfo: dsInfo,
|
||||
@@ -50,8 +49,10 @@ func TestMSSQL(t *testing.T) {
|
||||
logger := backend.NewLoggerWith("logger", "mssql.test")
|
||||
|
||||
db := initMSSQLTestDB(t, config.DSInfo.JsonData)
|
||||
ctx := context.Background()
|
||||
settings := backend.DataSourceInstanceSettings{}
|
||||
|
||||
endpoint, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger)
|
||||
endpoint, err := sqleng.NewQueryDataHandler(ctx, settings, "", config, logger, &azsettings.AzureSettings{})
|
||||
require.NoError(t, err)
|
||||
|
||||
fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local)
|
||||
@@ -799,14 +800,16 @@ func TestMSSQL(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("When doing a metric query using stored procedure should return correct result", func(t *testing.T) {
|
||||
queryResultTransformer := mssqlQueryResultTransformer{}
|
||||
dsInfo := sqleng.DataSourceInfo{}
|
||||
config := sqleng.DataPluginConfiguration{
|
||||
DSInfo: dsInfo,
|
||||
MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"},
|
||||
RowLimit: 1000000,
|
||||
}
|
||||
endpoint, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger)
|
||||
ctx := context.Background()
|
||||
settings := backend.DataSourceInstanceSettings{}
|
||||
|
||||
endpoint, err := sqleng.NewQueryDataHandler(ctx, settings, "", config, logger, &azsettings.AzureSettings{})
|
||||
require.NoError(t, err)
|
||||
query := &backend.QueryDataRequest{
|
||||
Queries: []backend.DataQuery{
|
||||
@@ -1202,7 +1205,6 @@ func TestMSSQL(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("When row limit set to 1", func(t *testing.T) {
|
||||
queryResultTransformer := mssqlQueryResultTransformer{}
|
||||
dsInfo := sqleng.DataSourceInfo{}
|
||||
config := sqleng.DataPluginConfiguration{
|
||||
DSInfo: dsInfo,
|
||||
@@ -1210,7 +1212,10 @@ func TestMSSQL(t *testing.T) {
|
||||
RowLimit: 1,
|
||||
}
|
||||
|
||||
handler, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger)
|
||||
ctx := context.Background()
|
||||
settings := backend.DataSourceInstanceSettings{}
|
||||
|
||||
handler, err := sqleng.NewQueryDataHandler(ctx, settings, "", config, logger, &azsettings.AzureSettings{})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("When doing a table query that returns 2 rows should limit the result to 1 row", func(t *testing.T) {
|
||||
@@ -1313,7 +1318,7 @@ func TestMSSQL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTransformQueryError(t *testing.T) {
|
||||
transformer := &mssqlQueryResultTransformer{}
|
||||
transformer := &utils.MSSQLQueryResultTransformer{}
|
||||
|
||||
logger := backend.NewLoggerWith("logger", "mssql.test")
|
||||
|
||||
@@ -1334,252 +1339,6 @@ func TestTransformQueryError(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateConnectionString(t *testing.T) {
|
||||
kerberosLookup := []kerberos.KerberosLookup{
|
||||
{
|
||||
Address: "example.host",
|
||||
DBName: "testDB",
|
||||
User: "testUser",
|
||||
CredentialCacheFilename: "/tmp/cache",
|
||||
},
|
||||
}
|
||||
tmpFile := genTempCacheFile(t, kerberosLookup)
|
||||
defer func() {
|
||||
err := os.Remove(tmpFile)
|
||||
if err != nil {
|
||||
t.Log(err)
|
||||
}
|
||||
}()
|
||||
|
||||
testCases := []struct {
|
||||
desc string
|
||||
kerberosCfg kerberos.KerberosAuth
|
||||
dataSource sqleng.DataSourceInfo
|
||||
expConnStr string
|
||||
}{
|
||||
{
|
||||
desc: "Use Kerberos Credential Cache",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
CredentialCache: "/tmp/krb5cc_1000",
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 1,
|
||||
},
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
JsonData: sqleng.JsonData{
|
||||
AuthenticationType: "Windows AD: Credential cache",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;krb5-credcachefile=/tmp/krb5cc_1000;",
|
||||
},
|
||||
{
|
||||
desc: "Use Kerberos Credential Cache File path",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
CredentialCacheLookupFile: tmpFile,
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 1,
|
||||
},
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "example.host",
|
||||
Database: "testDB",
|
||||
User: "testUser",
|
||||
JsonData: sqleng.JsonData{
|
||||
AuthenticationType: "Windows AD: Credential cache file",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=example.host;database=testDB;krb5-credcachefile=/tmp/cache;",
|
||||
},
|
||||
{
|
||||
desc: "Use Kerberos Keytab",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
KeytabFilePath: "/foo/bar.keytab",
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 1,
|
||||
},
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "foo@test.lab",
|
||||
JsonData: sqleng.JsonData{
|
||||
AuthenticationType: "Windows AD: Keytab",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;krb5-keytabfile=/foo/bar.keytab;",
|
||||
},
|
||||
{
|
||||
desc: "Use Kerberos Username and Password",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 1,
|
||||
},
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "foo@test.lab",
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"password": "foo",
|
||||
},
|
||||
JsonData: sqleng.JsonData{
|
||||
AuthenticationType: "Windows AD: Username + password",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;",
|
||||
},
|
||||
{
|
||||
desc: "Use non-default UDP connection limit",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 0,
|
||||
},
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "foo@test.lab",
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"password": "foo",
|
||||
},
|
||||
JsonData: sqleng.JsonData{
|
||||
AuthenticationType: "Windows AD: Username + password",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;krb5-udppreferencelimit=0;",
|
||||
},
|
||||
|
||||
{
|
||||
desc: "From URL w/ port",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost:1001",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost;database=database;user id=user;password=;port=1001;",
|
||||
},
|
||||
// When no port is specified, the driver should be allowed to choose
|
||||
{
|
||||
desc: "From URL w/o port",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost;database=database;user id=user;password=;",
|
||||
},
|
||||
// Port 0 should be equivalent to not specifying a port, i.e. let the driver choose
|
||||
{
|
||||
desc: "From URL w port 0",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost:0",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and port",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance:333",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and ApplicationIntent",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With ApplicationIntent instance name and port",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance:333;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and port",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance:333",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and ApplicationIntent",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With ApplicationIntent instance name and port",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
URL: "localhost\\instance:333;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;",
|
||||
},
|
||||
{
|
||||
desc: "Defaults",
|
||||
dataSource: sqleng.DataSourceInfo{
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: sqleng.JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost;database=database;user id=user;password=;",
|
||||
},
|
||||
}
|
||||
|
||||
logger := backend.NewLoggerWith("logger", "mssql.test")
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
connStr, err := generateConnectionString(tc.dataSource, "", false, nil, tc.kerberosCfg, logger)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expConnStr, connStr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func initMSSQLTestDB(t *testing.T, jsonData sqleng.JsonData) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
@@ -1614,21 +1373,3 @@ func genTimeRangeByInterval(from time.Time, duration time.Duration, interval tim
|
||||
|
||||
return timeRange
|
||||
}
|
||||
|
||||
func genTempCacheFile(t *testing.T, lookups []kerberos.KerberosLookup) string {
|
||||
content, err := json.Marshal(lookups)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to marshall json for temp lookup: %v", err)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "lookup*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create temporary file for temp lookup: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tmpFile.Write(content); err != nil {
|
||||
t.Fatalf("Unable to write to temporary file for temp lookup: %v", err)
|
||||
}
|
||||
|
||||
return tmpFile.Name()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/azure"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/utils"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
mssql "github.com/microsoft/go-mssqldb"
|
||||
"github.com/microsoft/go-mssqldb/azuread"
|
||||
)
|
||||
|
||||
func newMSSQL(ctx context.Context, driverName string, rowLimit int64, dsInfo DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*sql.DB, error) {
|
||||
var connector *mssql.Connector
|
||||
var err error
|
||||
if driverName == "azuresql" {
|
||||
connector, err = azuread.NewConnector(cnnstr)
|
||||
} else {
|
||||
connector, err = mssql.NewConnector(cnnstr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Error("mssql connector creation failed", "error", err)
|
||||
return nil, fmt.Errorf("mssql connector creation failed")
|
||||
}
|
||||
|
||||
proxyClient, err := settings.ProxyClient(ctx)
|
||||
if err != nil {
|
||||
logger.Error("mssql proxy creation failed", "error", err)
|
||||
return nil, fmt.Errorf("mssql proxy creation failed")
|
||||
}
|
||||
|
||||
if proxyClient.SecureSocksProxyEnabled() {
|
||||
dialer, err := proxyClient.NewSecureSocksProxyContextDialer()
|
||||
if err != nil {
|
||||
logger.Error("mssql proxy creation failed", "error", err)
|
||||
return nil, fmt.Errorf("mssql proxy creation failed")
|
||||
}
|
||||
URL, err := utils.ParseURL(dsInfo.URL, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mssqlDialer, err := newMSSQLProxyDialer(URL.Hostname(), dialer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// update the mssql dialer with the proxy dialer
|
||||
connector.Dialer = (mssqlDialer)
|
||||
}
|
||||
|
||||
config := DataPluginConfiguration{
|
||||
DSInfo: dsInfo,
|
||||
MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"},
|
||||
RowLimit: rowLimit,
|
||||
}
|
||||
|
||||
db := sql.OpenDB(connector)
|
||||
|
||||
db.SetMaxOpenConns(config.DSInfo.JsonData.MaxOpenConns)
|
||||
db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns)
|
||||
db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
const (
|
||||
azureAuthentication = "Azure AD Authentication"
|
||||
windowsAuthentication = "Windows Authentication"
|
||||
sqlServerAuthentication = "SQL Server Authentication"
|
||||
kerberosRaw = "Windows AD: Username + password"
|
||||
kerberosKeytab = "Windows AD: Keytab"
|
||||
kerberosCredentialCache = "Windows AD: Credential cache" // #nosec G101
|
||||
kerberosCredentialCacheFile = "Windows AD: Credential cache file" // #nosec G101
|
||||
)
|
||||
|
||||
func generateConnectionString(dsInfo DataSourceInfo, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool, azureCredentials azcredentials.AzureCredentials, kerberosAuth kerberos.KerberosAuth, logger log.Logger) (string, error) {
|
||||
const dfltPort = "0"
|
||||
var addr util.NetworkAddress
|
||||
if dsInfo.URL != "" {
|
||||
u, err := utils.ParseURL(dsInfo.URL, logger)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
addr, err = util.SplitHostPortDefault(u.Host, "localhost", dfltPort)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
addr = util.NetworkAddress{
|
||||
Host: "localhost",
|
||||
Port: dfltPort,
|
||||
}
|
||||
}
|
||||
|
||||
args := []any{
|
||||
"url", dsInfo.URL, "host", addr.Host,
|
||||
}
|
||||
if addr.Port != "0" {
|
||||
args = append(args, "port", addr.Port)
|
||||
}
|
||||
logger.Debug("Generating connection string", args...)
|
||||
|
||||
encrypt := dsInfo.JsonData.Encrypt
|
||||
tlsSkipVerify := dsInfo.JsonData.TlsSkipVerify
|
||||
hostNameInCertificate := dsInfo.JsonData.Servername
|
||||
certificate := dsInfo.JsonData.RootCertFile
|
||||
connStr := fmt.Sprintf("server=%s;database=%s;",
|
||||
addr.Host,
|
||||
dsInfo.Database,
|
||||
)
|
||||
|
||||
switch dsInfo.JsonData.AuthenticationType {
|
||||
case azureAuthentication:
|
||||
azureCredentialDSNFragment, err := azure.GetAzureCredentialDSNFragment(azureCredentials, azureManagedIdentityClientId, azureEntraPasswordCredentialsEnabled)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
connStr += azureCredentialDSNFragment
|
||||
case windowsAuthentication:
|
||||
// No user id or password. We're using windows single sign on.
|
||||
case kerberosRaw, kerberosKeytab, kerberosCredentialCacheFile, kerberosCredentialCache:
|
||||
connStr = kerberos.Krb5ParseAuthCredentials(addr.Host, addr.Port, dsInfo.Database, dsInfo.User, dsInfo.DecryptedSecureJSONData["password"], kerberosAuth)
|
||||
default:
|
||||
connStr += fmt.Sprintf("user id=%s;password=%s;", dsInfo.User, dsInfo.DecryptedSecureJSONData["password"])
|
||||
}
|
||||
|
||||
// Port number 0 means to determine the port automatically, so we can let the driver choose
|
||||
if addr.Port != "0" {
|
||||
connStr += fmt.Sprintf("port=%s;", addr.Port)
|
||||
}
|
||||
switch encrypt {
|
||||
case "true":
|
||||
connStr += fmt.Sprintf("encrypt=%s;TrustServerCertificate=%t;", encrypt, tlsSkipVerify)
|
||||
if hostNameInCertificate != "" {
|
||||
connStr += fmt.Sprintf("hostNameInCertificate=%s;", hostNameInCertificate)
|
||||
}
|
||||
|
||||
if certificate != "" {
|
||||
connStr += fmt.Sprintf("certificate=%s;", certificate)
|
||||
}
|
||||
case "disable":
|
||||
connStr += fmt.Sprintf("encrypt=%s;", dsInfo.JsonData.Encrypt)
|
||||
}
|
||||
|
||||
if dsInfo.JsonData.ConnectionTimeout != 0 {
|
||||
connStr += fmt.Sprintf("connection timeout=%d;", dsInfo.JsonData.ConnectionTimeout)
|
||||
}
|
||||
|
||||
return connStr, nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mssql
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,18 +8,17 @@ import (
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/sqleng"
|
||||
)
|
||||
|
||||
const rsIdentifier = `([_a-zA-Z0-9]+)`
|
||||
const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)`
|
||||
|
||||
type msSQLMacroEngine struct {
|
||||
*sqleng.SQLMacroEngineBase
|
||||
*SQLMacroEngineBase
|
||||
}
|
||||
|
||||
func newMssqlMacroEngine() sqleng.SQLMacroEngine {
|
||||
return &msSQLMacroEngine{SQLMacroEngineBase: sqleng.NewSQLMacroEngineBase()}
|
||||
func newMssqlMacroEngine() SQLMacroEngine {
|
||||
return &msSQLMacroEngine{SQLMacroEngineBase: NewSQLMacroEngineBase()}
|
||||
}
|
||||
|
||||
func (m *msSQLMacroEngine) Interpolate(query *backend.DataQuery, timeRange backend.TimeRange,
|
||||
@@ -79,7 +78,7 @@ func (m *msSQLMacroEngine) evaluateMacro(timeRange backend.TimeRange, query *bac
|
||||
return "", fmt.Errorf("error parsing interval %v", args[1])
|
||||
}
|
||||
if len(args) == 3 {
|
||||
err := sqleng.SetupFillmode(query, interval, args[2])
|
||||
err := SetupFillmode(query, interval, args[2])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -114,7 +113,7 @@ func (m *msSQLMacroEngine) evaluateMacro(timeRange backend.TimeRange, query *bac
|
||||
return "", fmt.Errorf("error parsing interval %v", args[1])
|
||||
}
|
||||
if len(args) == 3 {
|
||||
err := sqleng.SetupFillmode(query, interval, args[2])
|
||||
err := SetupFillmode(query, interval, args[2])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mssql
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package mssql
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package mssql
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -14,11 +14,16 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azsettings"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/utils"
|
||||
)
|
||||
|
||||
// MetaKeyExecutedQueryString is the key where the executed query should get stored
|
||||
@@ -88,6 +93,10 @@ type DataSourceHandler struct {
|
||||
dsInfo DataSourceInfo
|
||||
rowLimit int64
|
||||
userError string
|
||||
azureSettings *azsettings.AzureSettings
|
||||
azureCredentials azcredentials.AzureCredentials
|
||||
kerberosAuth kerberos.KerberosAuth
|
||||
driverName string
|
||||
}
|
||||
|
||||
type QueryJson struct {
|
||||
@@ -113,16 +122,38 @@ func (e *DataSourceHandler) TransformQueryError(logger log.Logger, err error) er
|
||||
return e.queryResultTransformer.TransformQueryError(logger, err)
|
||||
}
|
||||
|
||||
func NewQueryDataHandler(userFacingDefaultError string, db *sql.DB, config DataPluginConfiguration, queryResultTransformer SqlQueryResultTransformer,
|
||||
macroEngine SQLMacroEngine, log log.Logger) (*DataSourceHandler, error) {
|
||||
func NewQueryDataHandler(ctx context.Context, settings backend.DataSourceInstanceSettings, userFacingDefaultError string, config DataPluginConfiguration,
|
||||
log log.Logger, azureSettings *azsettings.AzureSettings) (*DataSourceHandler, error) {
|
||||
queryResultTransformer := utils.MSSQLQueryResultTransformer{
|
||||
UserError: userFacingDefaultError,
|
||||
}
|
||||
driverName := "mssql"
|
||||
if config.DSInfo.JsonData.AuthenticationType == azureAuthentication {
|
||||
driverName = "azuresql"
|
||||
}
|
||||
|
||||
azureCredentials, err := utils.GetAzureCredentials(settings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading azure credentials")
|
||||
}
|
||||
|
||||
kerberosAuth, err := kerberos.GetKerberosSettings(settings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting kerberos settings: %w", err)
|
||||
}
|
||||
|
||||
queryDataHandler := DataSourceHandler{
|
||||
queryResultTransformer: queryResultTransformer,
|
||||
macroEngine: macroEngine,
|
||||
queryResultTransformer: &queryResultTransformer,
|
||||
macroEngine: newMssqlMacroEngine(),
|
||||
timeColumnNames: []string{"time"},
|
||||
log: log,
|
||||
dsInfo: config.DSInfo,
|
||||
rowLimit: config.RowLimit,
|
||||
userError: userFacingDefaultError,
|
||||
azureSettings: azureSettings,
|
||||
azureCredentials: azureCredentials,
|
||||
kerberosAuth: kerberosAuth,
|
||||
driverName: driverName,
|
||||
}
|
||||
|
||||
if len(config.TimeColumnNames) > 0 {
|
||||
@@ -133,7 +164,19 @@ func NewQueryDataHandler(userFacingDefaultError string, db *sql.DB, config DataP
|
||||
queryDataHandler.metricColumnTypes = config.MetricColumnTypes
|
||||
}
|
||||
|
||||
cnnstr, err := generateConnectionString(config.DSInfo, azureSettings.ManagedIdentityClientId, azureSettings.AzureEntraPasswordCredentialsEnabled, azureCredentials, kerberosAuth, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := newMSSQL(ctx, driverName, config.RowLimit, config.DSInfo, cnnstr, log, settings)
|
||||
if err != nil {
|
||||
logger.Error("Failed connecting to MSSQL", "err", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
queryDataHandler.db = db
|
||||
|
||||
return &queryDataHandler, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package sqleng
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
|
||||
"github.com/grafana/grafana/pkg/tsdb/mssql/sqleng/util"
|
||||
)
|
||||
|
||||
@@ -436,3 +439,267 @@ func (t *testQueryResultTransformer) TransformQueryError(_ log.Logger, err error
|
||||
func (t *testQueryResultTransformer) GetConverterList() []sqlutil.StringConverter {
|
||||
return nil
|
||||
}
|
||||
|
||||
func genTempCacheFile(t *testing.T, lookups []kerberos.KerberosLookup) string {
|
||||
content, err := json.Marshal(lookups)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to marshall json for temp lookup: %v", err)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "lookup*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create temporary file for temp lookup: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tmpFile.Write(content); err != nil {
|
||||
t.Fatalf("Unable to write to temporary file for temp lookup: %v", err)
|
||||
}
|
||||
|
||||
return tmpFile.Name()
|
||||
}
|
||||
|
||||
func TestGenerateConnectionString(t *testing.T) {
|
||||
kerberosLookup := []kerberos.KerberosLookup{
|
||||
{
|
||||
Address: "example.host",
|
||||
DBName: "testDB",
|
||||
User: "testUser",
|
||||
CredentialCacheFilename: "/tmp/cache",
|
||||
},
|
||||
}
|
||||
tmpFile := genTempCacheFile(t, kerberosLookup)
|
||||
defer func() {
|
||||
err := os.Remove(tmpFile)
|
||||
if err != nil {
|
||||
t.Log(err)
|
||||
}
|
||||
}()
|
||||
|
||||
testCases := []struct {
|
||||
desc string
|
||||
kerberosCfg kerberos.KerberosAuth
|
||||
dataSource DataSourceInfo
|
||||
expConnStr string
|
||||
}{
|
||||
{
|
||||
desc: "Use Kerberos Credential Cache",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
CredentialCache: "/tmp/krb5cc_1000",
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 1,
|
||||
},
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
JsonData: JsonData{
|
||||
AuthenticationType: "Windows AD: Credential cache",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;krb5-credcachefile=/tmp/krb5cc_1000;",
|
||||
},
|
||||
{
|
||||
desc: "Use Kerberos Credential Cache File path",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
CredentialCacheLookupFile: tmpFile,
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 1,
|
||||
},
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "example.host",
|
||||
Database: "testDB",
|
||||
User: "testUser",
|
||||
JsonData: JsonData{
|
||||
AuthenticationType: "Windows AD: Credential cache file",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=example.host;database=testDB;krb5-credcachefile=/tmp/cache;",
|
||||
},
|
||||
{
|
||||
desc: "Use Kerberos Keytab",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
KeytabFilePath: "/foo/bar.keytab",
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 1,
|
||||
},
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "foo@test.lab",
|
||||
JsonData: JsonData{
|
||||
AuthenticationType: "Windows AD: Keytab",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;krb5-keytabfile=/foo/bar.keytab;",
|
||||
},
|
||||
{
|
||||
desc: "Use Kerberos Username and Password",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 1,
|
||||
},
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "foo@test.lab",
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"password": "foo",
|
||||
},
|
||||
JsonData: JsonData{
|
||||
AuthenticationType: "Windows AD: Username + password",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;",
|
||||
},
|
||||
{
|
||||
desc: "Use non-default UDP connection limit",
|
||||
kerberosCfg: kerberos.KerberosAuth{
|
||||
ConfigFilePath: "/etc/krb5.conf",
|
||||
UDPConnectionLimit: 0,
|
||||
},
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "foo@test.lab",
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"password": "foo",
|
||||
},
|
||||
JsonData: JsonData{
|
||||
AuthenticationType: "Windows AD: Username + password",
|
||||
},
|
||||
},
|
||||
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;krb5-udppreferencelimit=0;",
|
||||
},
|
||||
|
||||
{
|
||||
desc: "From URL w/ port",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost:1001",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost;database=database;user id=user;password=;port=1001;",
|
||||
},
|
||||
// When no port is specified, the driver should be allowed to choose
|
||||
{
|
||||
desc: "From URL w/o port",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost;database=database;user id=user;password=;",
|
||||
},
|
||||
// Port 0 should be equivalent to not specifying a port, i.e. let the driver choose
|
||||
{
|
||||
desc: "From URL w port 0",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost:0",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and port",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance:333",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and ApplicationIntent",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With ApplicationIntent instance name and port",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance:333;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and port",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance:333",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;",
|
||||
},
|
||||
{
|
||||
desc: "With instance name and ApplicationIntent",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;",
|
||||
},
|
||||
{
|
||||
desc: "With ApplicationIntent instance name and port",
|
||||
dataSource: DataSourceInfo{
|
||||
URL: "localhost\\instance:333;ApplicationIntent=ReadOnly",
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;",
|
||||
},
|
||||
{
|
||||
desc: "Defaults",
|
||||
dataSource: DataSourceInfo{
|
||||
Database: "database",
|
||||
User: "user",
|
||||
JsonData: JsonData{},
|
||||
},
|
||||
expConnStr: "server=localhost;database=database;user id=user;password=;",
|
||||
},
|
||||
}
|
||||
|
||||
logger := backend.NewLoggerWith("logger", "mssql.test")
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
connStr, err := generateConnectionString(tc.dataSource, "", false, nil, tc.kerberosCfg, logger)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expConnStr, connStr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,18 @@ package utils
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
|
||||
mssql "github.com/microsoft/go-mssqldb"
|
||||
)
|
||||
|
||||
// GetJsonData just gets the json in easier to work with type. It's used on multiple places which isn't super effective
|
||||
@@ -26,3 +35,140 @@ func GetAzureCredentials(settings backend.DataSourceInstanceSettings) (azcredent
|
||||
}
|
||||
return azcredentials.FromDatasourceData(jsonData, settings.DecryptedSecureJSONData)
|
||||
}
|
||||
|
||||
type DebugOnlyLogger interface {
|
||||
Debug(msg string, args ...interface{})
|
||||
}
|
||||
|
||||
// ParseURL tries to parse an MSSQL URL string into a URL object.
|
||||
func ParseURL(u string, logger DebugOnlyLogger) (*url.URL, error) {
|
||||
logger.Debug("Parsing MSSQL URL", "url", u)
|
||||
|
||||
// Recognize ODBC connection strings like host\instance:1234
|
||||
reODBC := regexp.MustCompile(`^[^\\:]+(?:\\[^:]+)?(?::\d+)?(?:;.+)?$`)
|
||||
var host string
|
||||
switch {
|
||||
case reODBC.MatchString(u):
|
||||
logger.Debug("Recognized as ODBC URL format", "url", u)
|
||||
host = u
|
||||
default:
|
||||
logger.Debug("Couldn't recognize as valid MSSQL URL", "url", u)
|
||||
return nil, fmt.Errorf("unrecognized MSSQL URL format: %q", u)
|
||||
}
|
||||
return &url.URL{
|
||||
Scheme: "sqlserver",
|
||||
Host: host,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type MSSQLQueryResultTransformer struct {
|
||||
UserError string
|
||||
}
|
||||
|
||||
func (t *MSSQLQueryResultTransformer) TransformQueryError(logger log.Logger, err error) error {
|
||||
// go-mssql overrides source error, so we currently match on string
|
||||
// ref https://github.com/denisenkom/go-mssqldb/blob/045585d74f9069afe2e115b6235eb043c8047043/tds.go#L904
|
||||
if strings.HasPrefix(strings.ToLower(err.Error()), "unable to open tcp connection with host") {
|
||||
logger.Error("Query error", "error", err)
|
||||
return fmt.Errorf("failed to connect to server - %s", t.UserError)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *MSSQLQueryResultTransformer) GetConverterList() []sqlutil.StringConverter {
|
||||
return []sqlutil.StringConverter{
|
||||
{
|
||||
Name: "handle MONEY",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "MONEY",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableFloat64,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(*in, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle SMALLMONEY",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "SMALLMONEY",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableFloat64,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(*in, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle DECIMAL",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "DECIMAL",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableFloat64,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(*in, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle UNIQUEIDENTIFIER",
|
||||
InputScanKind: reflect.Slice,
|
||||
InputTypeName: "UNIQUEIDENTIFIER",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableString,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
uuid := &mssql.UniqueIdentifier{}
|
||||
if err := uuid.Scan([]byte(*in)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := uuid.String()
|
||||
return &v, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "handle SQL_VARIANT",
|
||||
InputScanKind: reflect.Pointer,
|
||||
InputTypeName: "SQL_VARIANT",
|
||||
ConversionFunc: func(in *string) (*string, error) { return in, nil },
|
||||
Replacer: &sqlutil.StringFieldReplacer{
|
||||
OutputFieldType: data.FieldTypeNullableString,
|
||||
ReplaceFunc: func(in *string) (any, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return in, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function DashboardEditPaneRenderer({ editPane, isEditPaneCollapsed, onTog
|
||||
const isNewElement = selection?.isNewElement() ?? false;
|
||||
const [outlineCollapsed, setOutlineCollapsed] = useLocalStorage(
|
||||
'grafana.dashboard.edit-pane.outline.collapsed',
|
||||
true
|
||||
false
|
||||
);
|
||||
const [outlinePaneSize = 0.4, setOutlinePaneSize] = useLocalStorage('grafana.dashboard.edit-pane.outline.size', 0.4);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { BulkActionElement } from '../scene/types/BulkActionElement';
|
||||
import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
|
||||
import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
|
||||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
import { DashboardInteractions } from '../utils/interactions';
|
||||
import { getDashboardSceneFor, getPanelIdForVizPanel } from '../utils/utils';
|
||||
|
||||
import { MultiSelectedVizPanelsEditableElement } from './MultiSelectedVizPanelsEditableElement';
|
||||
@@ -95,6 +96,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
|
||||
public useEditPaneOptions = useEditPaneOptions.bind(this);
|
||||
|
||||
public onDelete() {
|
||||
DashboardInteractions.panelActionClicked('duplicate', getPanelIdForVizPanel(this.panel), 'edit_pane');
|
||||
const layout = dashboardSceneGraph.getLayoutManagerFor(this.panel);
|
||||
layout.removePanel?.(this.panel);
|
||||
}
|
||||
@@ -116,11 +118,13 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
|
||||
}
|
||||
|
||||
public onDuplicate() {
|
||||
DashboardInteractions.panelActionClicked('duplicate', getPanelIdForVizPanel(this.panel), 'edit_pane');
|
||||
const layout = dashboardSceneGraph.getLayoutManagerFor(this.panel);
|
||||
layout.duplicatePanel?.(this.panel);
|
||||
}
|
||||
|
||||
public onCopy() {
|
||||
DashboardInteractions.panelActionClicked('copy', getPanelIdForVizPanel(this.panel), 'edit_pane');
|
||||
const dashboard = getDashboardSceneFor(this.panel);
|
||||
dashboard.copyPanel(this.panel);
|
||||
}
|
||||
@@ -147,7 +151,9 @@ const OpenPanelEditViz = ({ panel }: OpenPanelEditVizProps) => {
|
||||
<Stack alignItems="center" width="100%">
|
||||
<Button
|
||||
onClick={() => {
|
||||
locationService.partial({ editPanel: getPanelIdForVizPanel(panel) });
|
||||
const panelId = getPanelIdForVizPanel(panel);
|
||||
locationService.partial({ editPanel: panelId });
|
||||
DashboardInteractions.panelActionClicked('configure', panelId, 'edit_pane');
|
||||
}}
|
||||
icon="sliders-v-alt"
|
||||
fullWidth
|
||||
|
||||
@@ -162,7 +162,7 @@ describe('NavToolbarActions', () => {
|
||||
await userEvent.click(await screen.findByTestId(selectors.components.NavToolbar.editDashboard.editButton));
|
||||
expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({
|
||||
dashboardUid: 'dash-1',
|
||||
outlineExpanded: false,
|
||||
outlineExpanded: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -95,6 +95,9 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
|
||||
viewPanel: panel.getPathId(),
|
||||
editPanel: undefined,
|
||||
}),
|
||||
onClick: () => {
|
||||
DashboardInteractions.panelActionClicked('view', getPanelIdForVizPanel(panel), 'panel');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,6 +109,9 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
|
||||
iconClassName: 'edit',
|
||||
shortcut: 'e',
|
||||
href: getEditPanelUrl(getPanelIdForVizPanel(panel)),
|
||||
onClick: () => {
|
||||
DashboardInteractions.panelActionClicked('edit', getPanelIdForVizPanel(panel), 'panel');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -187,6 +193,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
|
||||
text: t('panel.header-menu.duplicate', `Duplicate`),
|
||||
iconClassName: 'file-copy-alt',
|
||||
onClick: () => {
|
||||
DashboardInteractions.panelActionClicked('duplicate', getPanelIdForVizPanel(panel), 'panel');
|
||||
dashboard.duplicatePanel(panel);
|
||||
},
|
||||
shortcut: 'p d',
|
||||
@@ -198,6 +205,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
|
||||
text: t('panel.header-menu.copy', `Copy`),
|
||||
iconClassName: 'copy',
|
||||
onClick: () => {
|
||||
DashboardInteractions.panelActionClicked('copy', getPanelIdForVizPanel(panel), 'panel');
|
||||
dashboard.copyPanel(panel);
|
||||
},
|
||||
});
|
||||
@@ -359,6 +367,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
|
||||
text: t('panel.header-menu.remove', `Remove`),
|
||||
iconClassName: 'trash-alt',
|
||||
onClick: () => {
|
||||
DashboardInteractions.panelActionClicked('delete', getPanelIdForVizPanel(panel), 'panel');
|
||||
onRemovePanel(dashboard, panel);
|
||||
},
|
||||
shortcut: 'p r',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { locationService } from '@grafana/runtime';
|
||||
import { sceneUtils } from '@grafana/scenes';
|
||||
import { Box, Button, ButtonGroup, Dropdown, Menu, Stack } from '@grafana/ui';
|
||||
|
||||
import { DashboardInteractions } from '../utils/interactions';
|
||||
import { findVizPanelByKey, getVizPanelKeyForPanelId } from '../utils/utils';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
@@ -22,6 +23,7 @@ function UnconfiguredPanelComp(props: PanelProps) {
|
||||
|
||||
const onConfigure = () => {
|
||||
locationService.partial({ editPanel: props.id });
|
||||
DashboardInteractions.panelActionClicked('configure', props.id, 'panel');
|
||||
};
|
||||
|
||||
const onUseLibraryPanel = () => {
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ describe('EditDashboardSwitch', () => {
|
||||
it('should call DashboardInteractions.editButtonClicked with outlineExpanded:true if grafana.dashboard.edit-pane.outline.collapsed is undefined', async () => {
|
||||
render(<EditDashboardSwitch dashboard={buildTestScene()} />);
|
||||
await userEvent.click(await screen.findByTestId(selectors.components.NavToolbar.editDashboard.editButton));
|
||||
expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({ outlineExpanded: false });
|
||||
expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({ outlineExpanded: true });
|
||||
});
|
||||
|
||||
it('should call DashboardInteractions.editButtonClicked with outlineExpanded:true if grafana.dashboard.edit-pane.outline.collapsed is false', async () => {
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ describe('MakeDashboardEditableButton', () => {
|
||||
it('should call DashboardInteractions.editButtonClicked with outlineExpanded:true if grafana.dashboard.edit-pane.outline.collapsed is undefined', async () => {
|
||||
render(<MakeDashboardEditableButton dashboard={buildTestScene()} />);
|
||||
await userEvent.click(await screen.findByTestId(selectors.components.NavToolbar.editDashboard.editButton));
|
||||
expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({ outlineExpanded: false });
|
||||
expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({ outlineExpanded: true });
|
||||
});
|
||||
|
||||
it('should call DashboardInteractions.editButtonClicked with outlineExpanded:true if grafana.dashboard.edit-pane.outline.collapsed is false', async () => {
|
||||
|
||||
@@ -314,6 +314,9 @@ describe('DashboardSceneSerializer', () => {
|
||||
{
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
},
|
||||
@@ -344,9 +347,10 @@ describe('DashboardSceneSerializer', () => {
|
||||
uid: 'my-uid',
|
||||
title: 'hello',
|
||||
schemaVersion: DASHBOARD_SCHEMA_VERSION,
|
||||
panels_count: 3,
|
||||
panels_count: 4,
|
||||
panel_type_text_count: 2,
|
||||
panel_type_timeseries_count: 1,
|
||||
panel_type_row_count: 1,
|
||||
variable_type_query_count: 2,
|
||||
variable_type_textbox_count: 1,
|
||||
settings_nowdelay: undefined,
|
||||
@@ -680,6 +684,7 @@ describe('DashboardSceneSerializer', () => {
|
||||
uid: 'dashboard-test',
|
||||
title: 'Cloudwatch ec2 new layout',
|
||||
panels_count: 6,
|
||||
rowCount: 2,
|
||||
schemaVersion: DASHBOARD_SCHEMA_VERSION,
|
||||
settings_nowdelay: undefined,
|
||||
settings_livenow: true,
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface DashboardTrackingInfo {
|
||||
title?: string;
|
||||
schemaVersion: number;
|
||||
panels_count: number;
|
||||
rowCount?: number;
|
||||
settings_nowdelay?: number;
|
||||
settings_livenow?: boolean;
|
||||
}
|
||||
@@ -455,11 +456,18 @@ export class V2DashboardSerializer
|
||||
const variables =
|
||||
'variables' in this.initialSaveModel! ? getV2SchemaVariables(this.initialSaveModel.variables) : [];
|
||||
|
||||
const rowCount =
|
||||
'layout' in this.initialSaveModel
|
||||
? this.initialSaveModel.layout.kind === 'RowsLayout'
|
||||
? this.initialSaveModel.layout.spec.rows.length
|
||||
: 0
|
||||
: 0;
|
||||
return {
|
||||
schemaVersion: DASHBOARD_SCHEMA_VERSION,
|
||||
uid: s.state.uid,
|
||||
title: this.initialSaveModel.title,
|
||||
panels_count: panelPluginIds.length || 0,
|
||||
rowCount,
|
||||
settings_nowdelay: undefined,
|
||||
settings_livenow: !!this.initialSaveModel.liveNow,
|
||||
...panels,
|
||||
|
||||
@@ -14,7 +14,13 @@ type DashboardLibraryTrackingInfo = {
|
||||
export const DashboardInteractions = {
|
||||
// Dashboard interactions:
|
||||
dashboardInitialized: (
|
||||
properties: { theme: undefined; duration: number | undefined; isScene: boolean } & Partial<DashboardTrackingInfo> &
|
||||
properties: {
|
||||
theme: undefined;
|
||||
duration: number | undefined;
|
||||
isScene: boolean;
|
||||
hasEditPermissions?: boolean;
|
||||
hasSavePermissions?: boolean;
|
||||
} & Partial<DashboardTrackingInfo> &
|
||||
Partial<DynamicDashboardsTrackingInformation> &
|
||||
Partial<{ version_before_migration: number | undefined }>
|
||||
) => {
|
||||
@@ -28,11 +34,19 @@ export const DashboardInteractions = {
|
||||
dashboardCreatedOrSaved: (
|
||||
isNew: boolean | undefined,
|
||||
properties:
|
||||
| ({ name: string; url: string } & DashboardLibraryTrackingInfo)
|
||||
| ({
|
||||
name: string;
|
||||
url: string;
|
||||
uid: string;
|
||||
numPanels: number;
|
||||
numRows: number;
|
||||
} & DashboardLibraryTrackingInfo)
|
||||
| ({
|
||||
name: string;
|
||||
url: string;
|
||||
numPanels: number;
|
||||
numTabs: number;
|
||||
numRows: number;
|
||||
uid: string;
|
||||
conditionalRenderRules: number;
|
||||
autoLayoutCount: number;
|
||||
@@ -73,6 +87,14 @@ export const DashboardInteractions = {
|
||||
reportDashboardInteraction('add_variable_button_clicked', properties);
|
||||
},
|
||||
|
||||
panelActionClicked(
|
||||
item: 'configure' | 'edit' | 'copy' | 'duplicate' | 'delete' | 'view',
|
||||
id: number,
|
||||
source: 'panel' | 'edit_pane'
|
||||
) {
|
||||
reportDashboardInteraction('panel_action_clicked', { item, id, source });
|
||||
},
|
||||
|
||||
// Dashboard edit item actions
|
||||
// dashboards_edit_action_clicked: when user adds or removes an item in edit mode
|
||||
// props: { item: string } - item is one of: add_panel, group_row, group_tab, ungroup, paste_panel, remove_row, remove_tab
|
||||
|
||||
@@ -49,6 +49,8 @@ describe('dashboard tracking', () => {
|
||||
name: 'new dashboard',
|
||||
url: 'new-url',
|
||||
numPanels: 6,
|
||||
numRows: 6,
|
||||
numTabs: 4,
|
||||
conditionalRenderRules: 3,
|
||||
autoLayoutCount: 3,
|
||||
customGridLayoutCount: 2,
|
||||
@@ -69,6 +71,7 @@ describe('dashboard tracking', () => {
|
||||
duration: 42,
|
||||
isScene: true,
|
||||
tabCount: 4,
|
||||
rowCount: 2,
|
||||
templateVariableCount: 2,
|
||||
maxNestingLevel: 3,
|
||||
panel_type_timeseries_count: 6,
|
||||
@@ -86,6 +89,8 @@ describe('dashboard tracking', () => {
|
||||
uid: 'dashboard-test',
|
||||
variable_type_custom_count: 1,
|
||||
variable_type_query_count: 1,
|
||||
hasEditPermissions: true,
|
||||
hasSavePermissions: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ export function trackDashboardSceneLoaded(dashboard: DashboardScene, duration?:
|
||||
theme: undefined,
|
||||
duration,
|
||||
isScene: true,
|
||||
hasEditPermissions: dashboard.canEditDashboard(),
|
||||
hasSavePermissions: Boolean(dashboard.state.meta.canSave),
|
||||
...(dashboard.getTrackingInformation() ?? {}),
|
||||
...(dynamicDashboardsTrackingInformation
|
||||
? {
|
||||
@@ -44,7 +46,7 @@ export const trackDeleteDashboardElement = (element: EditableDashboardElementInf
|
||||
|
||||
export const trackDashboardSceneEditButtonClicked = (dashboardUid?: string) => {
|
||||
DashboardInteractions.editButtonClicked({
|
||||
outlineExpanded: !store.getBool('grafana.dashboard.edit-pane.outline.collapsed', true),
|
||||
outlineExpanded: !store.getBool('grafana.dashboard.edit-pane.outline.collapsed', false),
|
||||
dashboardUid,
|
||||
});
|
||||
};
|
||||
@@ -63,6 +65,8 @@ export function trackDashboardSceneCreatedOrSaved(
|
||||
|
||||
// Extract datasourceTypes from URL params (supports both community and provisioned dashboards) or dashboard panels
|
||||
const datasourceTypes = getDatasourceTypes(dashboard);
|
||||
|
||||
const sceneDashboardTrackingInfo = dashboard.getTrackingInformation();
|
||||
const dynamicDashboardsTrackingInformation = dashboard.getDynamicDashboardsTrackingInformation();
|
||||
|
||||
const dashboardLibraryProperties =
|
||||
@@ -81,8 +85,10 @@ export function trackDashboardSceneCreatedOrSaved(
|
||||
...initialProperties,
|
||||
...(dynamicDashboardsTrackingInformation
|
||||
? {
|
||||
uid: dashboard.state.uid,
|
||||
uid: dashboard.state.uid || '',
|
||||
numPanels: dynamicDashboardsTrackingInformation.panelCount,
|
||||
numTabs: dynamicDashboardsTrackingInformation.tabCount,
|
||||
numRows: dynamicDashboardsTrackingInformation.rowCount,
|
||||
conditionalRenderRules: dynamicDashboardsTrackingInformation.conditionalRenderRulesCount,
|
||||
autoLayoutCount: dynamicDashboardsTrackingInformation.autoLayoutCount,
|
||||
customGridLayoutCount: dynamicDashboardsTrackingInformation.customGridLayoutCount,
|
||||
@@ -90,6 +96,9 @@ export function trackDashboardSceneCreatedOrSaved(
|
||||
...dashboardLibraryProperties,
|
||||
}
|
||||
: {
|
||||
uid: dashboard.state.uid || '',
|
||||
numPanels: sceneDashboardTrackingInfo?.panels_count || 0,
|
||||
numRows: sceneDashboardTrackingInfo?.rowCount || 0,
|
||||
...dashboardLibraryProperties,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -65,7 +65,13 @@ export const useDashboardSave = (isCopy = false) => {
|
||||
if (isCopy) {
|
||||
DashboardInteractions.dashboardCopied({ name: dashboard.title || '', url: result.url });
|
||||
} else {
|
||||
trackDashboardCreatedOrSaved(!!dashboard.id, { name: dashboard.title, url: result.url });
|
||||
trackDashboardCreatedOrSaved(!!dashboard.id, {
|
||||
name: dashboard.title,
|
||||
url: result.url,
|
||||
uid: result.uid,
|
||||
numPanels: dashboard.panels.filter((p) => p.type !== 'row').length,
|
||||
numRows: dashboard.panels.filter((p) => p.type === 'row').length,
|
||||
});
|
||||
}
|
||||
|
||||
const currentPath = locationService.getLocation().pathname;
|
||||
|
||||
@@ -26,7 +26,10 @@ export function trackDashboardLoaded(dashboard: DashboardModel, duration?: numbe
|
||||
});
|
||||
}
|
||||
|
||||
export function trackDashboardCreatedOrSaved(isNew: boolean | undefined, trackingProps: { name: string; url: string }) {
|
||||
export function trackDashboardCreatedOrSaved(
|
||||
isNew: boolean | undefined,
|
||||
trackingProps: { name: string; url: string; uid: string; numPanels: number; numRows: number }
|
||||
) {
|
||||
DashboardInteractions.dashboardCreatedOrSaved(isNew, trackingProps);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { Pages } from '@grafana/e2e-selectors';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { LinkButton } from '@grafana/ui';
|
||||
@@ -16,7 +17,12 @@ export function DataSourceAddButton(): JSX.Element | null {
|
||||
}, []);
|
||||
|
||||
return canCreateDataSource ? (
|
||||
<LinkButton icon="plus" href={config.appSubUrl + ROUTES.DataSourcesNew} onClick={handleClick}>
|
||||
<LinkButton
|
||||
icon="plus"
|
||||
href={config.appSubUrl + ROUTES.DataSourcesNew}
|
||||
onClick={handleClick}
|
||||
data-testid={Pages.DataSources.dataSourceAddButton}
|
||||
>
|
||||
<Trans i18nKey="data-sources.datasource-add-button.label">Add new data source</Trans>
|
||||
</LinkButton>
|
||||
) : null;
|
||||
|
||||
@@ -16,10 +16,8 @@ import {
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
|
||||
const EXPIRATION_OPTIONS = [
|
||||
{ label: 'No expiration', value: false },
|
||||
{ label: 'Set expiration date', value: true },
|
||||
];
|
||||
const NO_EXPIRATION_OPTION = 'no-expiration';
|
||||
const CUSTOM_EXPIRATION_OPTION = 'custom-expiration';
|
||||
|
||||
export type ServiceAccountToken = {
|
||||
name: string;
|
||||
@@ -44,11 +42,15 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT
|
||||
} else {
|
||||
maxExpirationDate.setDate(8640000000000000);
|
||||
}
|
||||
const defaultExpirationDate = config.tokenExpirationDayLimit !== undefined && config.tokenExpirationDayLimit > 0;
|
||||
|
||||
const isTokenExpirationDayLimitConfigured =
|
||||
config.tokenExpirationDayLimit !== undefined && config.tokenExpirationDayLimit > 0;
|
||||
|
||||
const defaultExpirationOption = isTokenExpirationDayLimitConfigured ? CUSTOM_EXPIRATION_OPTION : NO_EXPIRATION_OPTION;
|
||||
|
||||
const [defaultTokenName, setDefaultTokenName] = useState('');
|
||||
const [newTokenName, setNewTokenName] = useState('');
|
||||
const [isWithExpirationDate, setIsWithExpirationDate] = useState(defaultExpirationDate);
|
||||
const [expirationOption, setExpirationOption] = useState(defaultExpirationOption);
|
||||
const [newTokenExpirationDate, setNewTokenExpirationDate] = useState<Date | string>(tomorrow);
|
||||
const [isExpirationDateValid, setIsExpirationDateValid] = useState(newTokenExpirationDate !== '');
|
||||
const styles = useStyles2(getStyles);
|
||||
@@ -69,14 +71,15 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT
|
||||
const onGenerateToken = () => {
|
||||
onCreateToken({
|
||||
name: newTokenName || defaultTokenName,
|
||||
secondsToLive: isWithExpirationDate ? getSecondsToLive(newTokenExpirationDate) : undefined,
|
||||
secondsToLive:
|
||||
expirationOption === CUSTOM_EXPIRATION_OPTION ? getSecondsToLive(newTokenExpirationDate) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const onCloseInternal = () => {
|
||||
setNewTokenName('');
|
||||
setDefaultTokenName('');
|
||||
setIsWithExpirationDate(defaultExpirationDate);
|
||||
setExpirationOption(defaultExpirationOption);
|
||||
setNewTokenExpirationDate(tomorrow);
|
||||
setIsExpirationDateValid(newTokenExpirationDate !== '');
|
||||
onClose();
|
||||
@@ -84,6 +87,24 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT
|
||||
|
||||
const modalTitle = !token ? 'Add service account token' : 'Service account token created';
|
||||
|
||||
const getExpirationOptions = () => {
|
||||
const noExpirationDescription = t(
|
||||
'serviceaccounts.create-token-modal.description-no-expiration-disabled',
|
||||
'Cannot create a token with no expiration date when token expiration day limit is configured'
|
||||
);
|
||||
return [
|
||||
{
|
||||
label: t('serviceaccounts.create-token-modal.label-no-expiration', 'No expiration'),
|
||||
value: NO_EXPIRATION_OPTION,
|
||||
description: isTokenExpirationDayLimitConfigured ? noExpirationDescription : undefined,
|
||||
},
|
||||
{
|
||||
label: t('serviceaccounts.create-token-modal.label-set-expiration-date', 'Set expiration date'),
|
||||
value: CUSTOM_EXPIRATION_OPTION,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={modalTitle} onDismiss={onCloseInternal} className={styles.modal}>
|
||||
{!token ? (
|
||||
@@ -109,13 +130,14 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT
|
||||
</Field>
|
||||
<Field label={t('serviceaccounts.create-token-modal.label-expiration', 'Expiration')}>
|
||||
<RadioButtonGroup
|
||||
options={EXPIRATION_OPTIONS}
|
||||
value={isWithExpirationDate}
|
||||
onChange={setIsWithExpirationDate}
|
||||
options={getExpirationOptions()}
|
||||
disabledOptions={isTokenExpirationDayLimitConfigured ? [NO_EXPIRATION_OPTION] : []}
|
||||
value={expirationOption}
|
||||
onChange={setExpirationOption}
|
||||
size="md"
|
||||
/>
|
||||
</Field>
|
||||
{isWithExpirationDate && (
|
||||
{expirationOption === CUSTOM_EXPIRATION_OPTION && (
|
||||
<Field label={t('serviceaccounts.create-token-modal.label-expiration-date', 'Expiration date')}>
|
||||
<DatePickerWithInput
|
||||
onChange={onExpirationDateChange}
|
||||
@@ -127,7 +149,10 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT
|
||||
</Field>
|
||||
)}
|
||||
<Modal.ButtonRow>
|
||||
<Button onClick={onGenerateToken} disabled={isWithExpirationDate && !isExpirationDateValid}>
|
||||
<Button
|
||||
onClick={onGenerateToken}
|
||||
disabled={expirationOption === CUSTOM_EXPIRATION_OPTION && !isExpirationDateValid}
|
||||
>
|
||||
<Trans i18nKey="serviceaccounts.create-token-modal.generate-token">Generate token</Trans>
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen, testWithFeatureToggles } from 'test/test-utils';
|
||||
import { render, screen, testWithFeatureToggles, waitFor } from 'test/test-utils';
|
||||
|
||||
import { GrafanaConfig, locationUtil } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config, setBackendSrv } from '@grafana/runtime';
|
||||
import { setupMockServer } from '@grafana/test-utils/server';
|
||||
import { getFolderFixtures } from '@grafana/test-utils/unstable';
|
||||
@@ -110,5 +111,43 @@ describe('StarToolbarButton', () => {
|
||||
|
||||
expect(screen.queryByTestId(expectedTestId)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows spinner initially and transitions to empty star when loading completes with no starred items', async () => {
|
||||
setup(itemToStar);
|
||||
const button = screen.getByTestId(selectors.components.NavToolbar.markAsFavorite);
|
||||
|
||||
// Initially, button should be disabled (loading state)
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
// Wait for loading to complete
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(button).not.toBeDisabled();
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
|
||||
// After loading, should show "Mark as favorite" (empty star state)
|
||||
expect(await findStarButton(itemToStar.title, false)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows spinner initially and transitions to filled star when loading completes with starred item', async () => {
|
||||
setup(existingStarredItem);
|
||||
const button = screen.getByTestId(selectors.components.NavToolbar.markAsFavorite);
|
||||
|
||||
// Initially, button should be disabled (loading state)
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
// Wait for loading to complete
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(button).not.toBeDisabled();
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
|
||||
// After loading, should show "Unmark as favorite" (filled star state)
|
||||
expect(await findStarButton(existingStarredItem.title, true)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ export function StarToolbarButton({ title, group, kind, id, onStarChange }: Prop
|
||||
onStarChange?.(id, !isStarred);
|
||||
};
|
||||
|
||||
const iconProps = (() => {
|
||||
const iconProps = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return { name: 'spinner', type: 'default' } as const;
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function StarToolbarButton({ title, group, kind, id, onStarChange }: Prop
|
||||
return { name: 'favorite', type: 'mono' } as const;
|
||||
}
|
||||
return { name: 'star', type: 'default' } as const;
|
||||
})();
|
||||
}, [isLoading, isStarred]);
|
||||
|
||||
const tooltipAndLabel = (() => {
|
||||
return isStarred
|
||||
@@ -55,7 +55,7 @@ export function StarToolbarButton({ title, group, kind, id, onStarChange }: Prop
|
||||
: { tooltip: tooltips.star, label: isLoading ? undefined : tooltips.starWithTitle };
|
||||
})();
|
||||
|
||||
const icon = <Icon {...iconProps} size="lg" />;
|
||||
const icon = <Icon {...iconProps} size="lg" key={`${isLoading}-${isStarred}`} />;
|
||||
return (
|
||||
<ToolbarButton
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -61,26 +61,45 @@ export const useStarredItems = (group: string, kind: string) => {
|
||||
const name = `user-${contextSrv.user.uid}`;
|
||||
const appPlatform = config.featureToggles.starsFromAPIServer;
|
||||
const legacyResponse = useLegacyGetStarsQuery(appPlatform ? skipToken : undefined);
|
||||
const appPlatformResponse = useListStarsQuery(!appPlatform ? skipToken : { fieldSelector: `metadata.name=${name}` });
|
||||
const queryArgs = !appPlatform ? skipToken : { fieldSelector: `metadata.name=${name}` };
|
||||
const appPlatformResponse = useListStarsQuery(queryArgs);
|
||||
|
||||
const appPlatformStarredItems = useMemo(() => {
|
||||
const { data } = appPlatformResponse;
|
||||
if (data) {
|
||||
const starredItems = appPlatformResponse.data?.items || [];
|
||||
if (!starredItems.length) {
|
||||
return [];
|
||||
}
|
||||
return starredItems[0]?.spec.resource.find((info) => info.group === group && info.kind === kind)?.names || [];
|
||||
const { data, isLoading, isUninitialized } = appPlatformResponse;
|
||||
|
||||
// If query hasn't been initiated yet or is still loading, return undefined to show loading state
|
||||
if (isUninitialized || isLoading) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
// If query completed but no data, return empty array
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const starredItems = appPlatformResponse.data?.items || [];
|
||||
if (!starredItems.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return starredItems[0]?.spec.resource.find((info) => info.group === group && info.kind === kind)?.names || [];
|
||||
}, [appPlatformResponse, group, kind]);
|
||||
|
||||
return appPlatform
|
||||
? {
|
||||
...appPlatformResponse,
|
||||
data: appPlatformStarredItems,
|
||||
}
|
||||
: legacyResponse;
|
||||
if (appPlatform) {
|
||||
return {
|
||||
...appPlatformResponse,
|
||||
data: appPlatformStarredItems,
|
||||
// Ensure isLoading is true when data is undefined (still loading or uninitialized)
|
||||
isLoading: appPlatformStarredItems === undefined ? true : appPlatformResponse.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
// For legacy response, ensure isLoading is true when query is uninitialized
|
||||
// RTK Query sets isLoading: false when uninitialized, but we need it to be true
|
||||
return {
|
||||
...legacyResponse,
|
||||
isLoading: legacyResponse.isUninitialized || legacyResponse.isLoading,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -8995,6 +8995,11 @@
|
||||
"series-color-picker-popover": {
|
||||
"y-axis-usage": "Use right y-axis"
|
||||
},
|
||||
"sidebar": {
|
||||
"close": "Close",
|
||||
"dock": "Dock",
|
||||
"undock": "Undock"
|
||||
},
|
||||
"slider": {
|
||||
"drag-handle-aria-label": "Use arrow keys to change the value"
|
||||
},
|
||||
@@ -12538,11 +12543,14 @@
|
||||
"copy-clipboard": "Copy to clipboard",
|
||||
"copy-to-clipboard-and-close": "Copy to clipboard and close",
|
||||
"description-name-to-easily-identify-the-token": "Name to easily identify the token",
|
||||
"description-no-expiration-disabled": "Cannot create a token with no expiration date when token expiration day limit is configured",
|
||||
"description-token": "Copy the token now as you will not be able to see it again. Losing a token requires creating a new one.",
|
||||
"generate-token": "Generate token",
|
||||
"label-display-name": "Display name",
|
||||
"label-expiration": "Expiration",
|
||||
"label-expiration-date": "Expiration date",
|
||||
"label-no-expiration": "No expiration",
|
||||
"label-set-expiration-date": "Set expiration date",
|
||||
"label-token": "Token"
|
||||
},
|
||||
"get-actions-cell": {
|
||||
|
||||
Reference in New Issue
Block a user