Merge branch 'master' into update-cypress-6.3.0

This commit is contained in:
Vicky Lee 2021-01-29 09:49:30 +00:00
commit b040c364b4
140 changed files with 3660 additions and 3437 deletions

View File

@ -433,7 +433,7 @@ steps:
- package
- name: publish-storybook
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- printenv GCP_KEY | base64 -d > /tmp/gcpkey.json
- gcloud auth activate-service-account --key-file=/tmp/gcpkey.json
@ -527,7 +527,7 @@ steps:
- end-to-end-tests
- name: upload-packages
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- ./bin/grabpl upload-packages --edition oss
environment:
@ -638,7 +638,7 @@ steps:
DOCKERIZE_VERSION: 0.6.1
- name: publish-packages-oss
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- printenv GCP_KEY | base64 -d > /tmp/gcpkey.json
- ./bin/grabpl publish-packages --edition oss --gcp-key /tmp/gcpkey.json --build-id ${DRONE_BUILD_NUMBER}
@ -935,7 +935,7 @@ steps:
- test-frontend
- name: upload-packages
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- ./bin/grabpl upload-packages --edition oss
environment:
@ -948,7 +948,7 @@ steps:
- postgres-integration-tests
- name: publish-storybook
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- printenv GCP_KEY | base64 -d > /tmp/gcpkey.json
- gcloud auth activate-service-account --key-file=/tmp/gcpkey.json
@ -966,6 +966,8 @@ steps:
commands:
- ./scripts/build/release-packages.sh ${DRONE_TAG}
environment:
GITHUB_PACKAGE_TOKEN:
from_secret: github_package_token
NPM_TOKEN:
from_secret: npm_token
depends_on:
@ -1324,7 +1326,7 @@ steps:
- test-frontend
- name: upload-packages
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- ./bin/grabpl upload-packages --edition enterprise
environment:
@ -1377,7 +1379,7 @@ steps:
- end-to-end-tests-server-enterprise2
- name: upload-packages-enterprise2
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-enterprise2
environment:
@ -1506,7 +1508,7 @@ steps:
DOCKERIZE_VERSION: 0.6.1
- name: publish-packages-oss
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- printenv GCP_KEY | base64 -d > /tmp/gcpkey.json
- ./bin/grabpl publish-packages --edition oss --gcp-key /tmp/gcpkey.json ${DRONE_TAG}
@ -1525,7 +1527,7 @@ steps:
- initialize
- name: publish-packages-enterprise
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- printenv GCP_KEY | base64 -d > /tmp/gcpkey.json
- ./bin/grabpl publish-packages --edition enterprise --gcp-key /tmp/gcpkey.json ${DRONE_TAG}
@ -1816,7 +1818,7 @@ steps:
- test-frontend
- name: upload-packages
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads-test
environment:
@ -1829,7 +1831,7 @@ steps:
- postgres-integration-tests
- name: publish-storybook
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- echo Testing release
environment:
@ -1842,6 +1844,8 @@ steps:
- name: release-npm-packages
image: grafana/build-container:1.3.1
environment:
GITHUB_PACKAGE_TOKEN:
from_secret: github_package_token
NPM_TOKEN:
from_secret: npm_token
depends_on:
@ -2194,7 +2198,7 @@ steps:
- test-frontend
- name: upload-packages
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads-test
environment:
@ -2247,7 +2251,7 @@ steps:
- end-to-end-tests-server-enterprise2
- name: upload-packages-enterprise2
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-test
environment:
@ -2376,7 +2380,7 @@ steps:
DOCKERIZE_VERSION: 0.6.1
- name: publish-packages-oss
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- printenv GCP_KEY | base64 -d > /tmp/gcpkey.json
- ./bin/grabpl publish-packages --edition oss --gcp-key /tmp/gcpkey.json --deb-db-bucket grafana-testing-aptly-db --deb-repo-bucket grafana-testing-repo --packages-bucket grafana-downloads-test --rpm-repo-bucket grafana-testing-repo --simulate-release v7.3.0-test
@ -2395,7 +2399,7 @@ steps:
- initialize
- name: publish-packages-enterprise
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- printenv GCP_KEY | base64 -d > /tmp/gcpkey.json
- ./bin/grabpl publish-packages --edition enterprise --gcp-key /tmp/gcpkey.json --deb-db-bucket grafana-testing-aptly-db --deb-repo-bucket grafana-testing-repo --packages-bucket grafana-downloads-test --rpm-repo-bucket grafana-testing-repo --simulate-release v7.3.0-test
@ -2682,7 +2686,7 @@ steps:
- test-frontend
- name: upload-packages
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- ./bin/grabpl upload-packages --edition oss
environment:
@ -3040,7 +3044,7 @@ steps:
- test-frontend
- name: upload-packages
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- ./bin/grabpl upload-packages --edition enterprise
environment:
@ -3093,7 +3097,7 @@ steps:
- end-to-end-tests-server-enterprise2
- name: upload-packages-enterprise2
image: grafana/grafana-ci-deploy:1.2.7
image: grafana/grafana-ci-deploy:1.3.0
commands:
- ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-enterprise2
environment:

View File

@ -5,12 +5,11 @@
### Features and enhancements
* ** MSSQL**: Integrated security. [#30369](https://github.com/grafana/grafana/pull/30369), [@daniellee](https://github.com/daniellee)
* **API**: Add ID to snapshot API responses. [#29600](https://github.com/grafana/grafana/pull/29600), [@AgnesToulet](https://github.com/AgnesToulet)
* **AlertListPanel**: Add options to sort by Time(asc) and Time(desc). [#29764](https://github.com/grafana/grafana/pull/29764), [@dboslee](https://github.com/dboslee)
* **AlertListPanel**: Changed alert url to to go the panel view instead of panel edit. [#29060](https://github.com/grafana/grafana/pull/29060), [@zakiharis](https://github.com/zakiharis)
* **Alerting**: Add support for Sensu Go notification channel. [#28012](https://github.com/grafana/grafana/pull/28012), [@nixwiz](https://github.com/nixwiz)
* **Alerting**: Evaluate data templating in alert rule name and message. [#29908](https://github.com/grafana/grafana/pull/29908), [@wbrowne](https://github.com/wbrowne)
* **Alerting**: Add support for alert notification query label interpolation. [#29908](https://github.com/grafana/grafana/pull/29908), [@wbrowne](https://github.com/wbrowne)
* **Annotations**: Remove annotation_tag entries as part of annotations cleanup. [#29534](https://github.com/grafana/grafana/pull/29534), [@dafydd-t](https://github.com/dafydd-t)
* **Azure Monitor**: Add Microsoft.Network/natGateways. [#29479](https://github.com/grafana/grafana/pull/29479), [@JoeyLemur](https://github.com/JoeyLemur)
* **Backend plugins**: Support Forward OAuth Identity for backend data source plugins. [#27055](https://github.com/grafana/grafana/pull/27055), [@billoley](https://github.com/billoley)
@ -40,9 +39,11 @@
* **Loki**: Add query type and line limit to query editor in dashboard. [#29356](https://github.com/grafana/grafana/pull/29356), [@ivanahuckova](https://github.com/ivanahuckova)
* **Loki**: Add query type selector to query editor in Explore. [#28817](https://github.com/grafana/grafana/pull/28817), [@ivanahuckova](https://github.com/ivanahuckova)
* **Loki**: Retry web socket connection when connection is closed abnormally. [#29438](https://github.com/grafana/grafana/pull/29438), [@ivanahuckova](https://github.com/ivanahuckova)
* **MS SQL**: Integrated security. [#30369](https://github.com/grafana/grafana/pull/30369), [@daniellee](https://github.com/daniellee)
* **Middleware**: Add CSP support. [#29740](https://github.com/grafana/grafana/pull/29740), [@aknuds1](https://github.com/aknuds1)
* **OAuth**: Configurable user name attribute. [#28286](https://github.com/grafana/grafana/pull/28286), [@alexanderzobnin](https://github.com/alexanderzobnin)
* **PanelEditor**: Render panel field config categories as separate option group sections. [#30301](https://github.com/grafana/grafana/pull/30301), [@dprokop](https://github.com/dprokop)
* **Postgres**: SSL certification. [#30352](https://github.com/grafana/grafana/pull/30352), [@ying-jeanne](https://github.com/ying-jeanne)
* **Prometheus**: Add support for Exemplars. [#28057](https://github.com/grafana/grafana/pull/28057), [@zoltanbedi](https://github.com/zoltanbedi)
* **Prometheus**: Improve autocomplete performance and remove disabling of dynamic label lookup. [#30199](https://github.com/grafana/grafana/pull/30199), [@ivanahuckova](https://github.com/ivanahuckova)
* **Prometheus**: Update default query type option to "Both" in Explore query editor. [#28935](https://github.com/grafana/grafana/pull/28935), [@ivanahuckova](https://github.com/ivanahuckova)
@ -55,6 +56,7 @@
* **Templating**: Custom variable edit UI, change options input into textarea. [#28322](https://github.com/grafana/grafana/pull/28322), [@darrylsepeda](https://github.com/darrylsepeda)
* **TimeSeriesPanel**: The new graph panel now supports y-axis value mapping. [#30272](https://github.com/grafana/grafana/pull/30272), [@torkelo](https://github.com/torkelo)
* **Tracing**: Tag spans with user login and datasource name instead of id. [#29183](https://github.com/grafana/grafana/pull/29183), [@bergquist](https://github.com/bergquist)
* **Transformations**: Add "Rename By Regex" transformer. [#29281](https://github.com/grafana/grafana/pull/29281), [@simianhacker](https://github.com/simianhacker)
* **Transformations**: Added new transform for excluding and including rows based on their values. [#26884](https://github.com/grafana/grafana/pull/26884), [@Totalus](https://github.com/Totalus)
* **Transforms**: Add sort by transformer. [#30370](https://github.com/grafana/grafana/pull/30370), [@ryantxu](https://github.com/ryantxu)
* **Variables**: Add deprecation warning for value group tags. [#30160](https://github.com/grafana/grafana/pull/30160), [@torkelo](https://github.com/torkelo)
@ -63,7 +65,6 @@
* **Variables**: Adds variables inspection. [#25214](https://github.com/grafana/grafana/pull/25214), [@hugohaggmark](https://github.com/hugohaggmark)
* **Variables**: New Variables are stored immediately. [#29178](https://github.com/grafana/grafana/pull/29178), [@hugohaggmark](https://github.com/hugohaggmark)
* **Zipkin**: Remove browser access mode. [#30360](https://github.com/grafana/grafana/pull/30360), [@zoltanbedi](https://github.com/zoltanbedi)
* **postgres SSL certification**. [#30352](https://github.com/grafana/grafana/pull/30352), [@ying-jeanne](https://github.com/ying-jeanne)
### Bug fixes

View File

@ -895,5 +895,5 @@ use_browser_locale = false
default_timezone = browser
[expressions]
# Disable expressions & UI features
# Enable or disable the expressions functionality.
enabled = true

View File

@ -885,5 +885,5 @@
;default_timezone = browser
[expressions]
# Disable expressions & UI features
# Enable or disable the expressions functionality.
;enabled = true

View File

@ -0,0 +1,17 @@
# Grafana technical terminology
<!-- Keep terms in alphabetical order: -->
This document defines technical terms used in Grafana.
## TLS/SSL
The acronyms [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) (Transport Layer Security and
[SSL](https://en.wikipedia.org/wiki/SSL) (Secure Socket Layer) are both used to describe the HTTPS security layer,
and are in practice synonymous. However, TLS is considered the current name for the technology, and SSL is considered
[deprecated](https://tools.ietf.org/html/rfc7568).
As such, while both terms are in use (also in our codebase) and are indeed interchangeable, TLS is the preferred term.
That said however, we have at Grafana Labs decided to use both acronyms in combination when referring to this type of
technology, i.e. _TLS/SSL_. This is in order to not confuse those who may not be aware of them being synonymous,
and SSL still being so prevalent in common discourse.

View File

@ -147,6 +147,18 @@ The first letter of the name of an integration is always capitalized, even if th
- Etcd Integration
- I installed an integration on my local Grafana.
#### Kubernetes objects
Capitalize Kubernetes objects such as Job, Pod, and StatefulSet when it is clear you are specifically talking about them and not generic jobs, pods, or whatever.
Introduce the object as "Kubernetes XX" on the first usage, then just the object in subsequent uses.
**Example:**
Create the Kubernetes Job and check the logs to retrieve the generated token:
The Job requires the token be submitted as …
### Links and references
When referencing another document, use "Refer to" rather than alternatives such as "See" or "Check out."
@ -291,6 +303,13 @@ When referencing the Prometheus data source exporters, always use "node_exporter
**Correct:** node_exporter, windows_exporter
**Incorrect:** Node Exporter, node exporter, Windows Exporter, Windows exporter, windows exporter.
#### web server
Two words, not one.
**Correct:** webserver
**Incorrect:** web server
### MS SQL Server
Always use "MS SQL" when referring to MS SQL Server application.

View File

@ -142,7 +142,8 @@
},
"id": 3,
"libraryPanel": {
"uid": "MAnX2ifMk"
"uid": "MAnX2ifMk",
"name": "React Table"
}
},
{
@ -154,7 +155,8 @@
},
"id": 2,
"libraryPanel": {
"uid": "g1sNpCaMz"
"uid": "g1sNpCaMz",
"name": "React Gauge"
}
}
],

View File

@ -60,8 +60,8 @@ aliases = ["/docs/grafana/v1.1", "/docs/grafana/latest/guides/reference/admin",
<h4>Provisioning</h4>
<p>Learn how to automate your Grafana configuration.</p>
</a>
<a href="{{< relref "whatsnew/whats-new-in-v7-3.md" >}}" class="nav-cards__item nav-cards__item--guide">
<h4>What's new in v7.3</h4>
<a href="{{< relref "whatsnew/whats-new-in-v7-4.md" >}}" class="nav-cards__item nav-cards__item--guide">
<h4>What's new in v7.4</h4>
<p>Explore the features and enhancements in the latest release.</p>
</a>
@ -86,7 +86,7 @@ aliases = ["/docs/grafana/v1.1", "/docs/grafana/latest/guides/reference/admin",
<img src="/img/docs/logos/icon_prometheus.svg" >
<h5>Prometheus</h5>
</a>
<a href="{{< relref "datasources/cloudmonitoring.md" >}}" class="nav-cards__item nav-cards__item--ds">
<a href="{{< relref "datasources/google-cloud-monitoring/_index.md" >}}" class="nav-cards__item nav-cards__item--ds">
<img src="/img/docs/logos/icon_cloudmonitoring.svg">
<h5>Google Cloud Monitoring</h5>
</a>

View File

@ -631,6 +631,10 @@ The duration in time a user invitation remains valid before expiring.
This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week).
Default is `24h` (24 hours). The minimum supported duration is `15m` (15 minutes).
### hidden_users
This is a comma-separated list of usernames. Users specified here are hidden in the Grafana UI. They are still visible to Grafana administrators and to themselves.
<hr>
## [auth]
@ -1515,6 +1519,6 @@ Set this to `true` to have date formats automatically derived from your browser
Used as the default time zone for user preferences. Can be either `browser` for the browser local time zone or a time zone name from the IANA Time Zone database, such as `UTC` or `Europe/Amsterdam`.
## [expressions]
>Note: This is available in Grafana v7.4 and later versions.
> **Note:** This feature is available in Grafana v7.4 and later versions.
### enabled
Set this to `false` to disable expressions and hide them in the Grafana UI. Default is `true`.

View File

@ -19,7 +19,7 @@ The following data sources are officially supported:
- [AWS CloudWatch]({{< relref "cloudwatch.md" >}})
- [Azure Monitor]({{< relref "azuremonitor.md" >}})
- [Elasticsearch]({{< relref "elasticsearch.md" >}})
- [Google Cloud Monitoring]({{< relref "cloudmonitoring.md" >}})
- [Google Cloud Monitoring]({{< relref "google-cloud-monitoring/_index.md" >}})
- [Graphite]({{< relref "graphite.md" >}})
- [InfluxDB]({{< relref "influxdb.md" >}})
- [Loki]({{< relref "loki.md" >}})

View File

@ -49,7 +49,7 @@ http.cors.allow-origin: "*"
### Index settings
![Elasticsearch data source details](/img/docs/elasticsearch/elasticsearch_ds_details.png)
![Elasticsearch data source details](/img/docs/elasticsearch/elasticsearch-ds-details-7-4.png)
Here you can specify a default for the `time field` and specify the name of your Elasticsearch index. You can use
a time pattern for the index name or a wildcard.
@ -100,7 +100,7 @@ Each data link configuration consists of:
## Metric Query editor
![Elasticsearch Query Editor](/img/docs/elasticsearch/query_editor.png)
![Elasticsearch Query Editor](/img/docs/elasticsearch/query-editor-7-4.png)
The Elasticsearch query editor allows you to select multiple metrics and group by multiple terms or filters. Use the plus and minus icons to the right to add/remove
metrics or group by clauses. Some metrics and group by clauses haves options, click the option text to expand the row to view and edit metric or group by options.
@ -119,7 +119,7 @@ You can control the name for time series via the `Alias` input field.
Some metric aggregations are called Pipeline aggregations, for example, *Moving Average* and *Derivative*. Elasticsearch pipeline metrics require another metric to be based on. Use the eye icon next to the metric to hide metrics from appearing in the graph. This is useful for metrics you only have in the query for use in a pipeline metric.
![](/img/docs/elasticsearch/pipeline_metrics_editor.png)
![Pipeline aggregation editor](/img/docs/elasticsearch/pipeline-aggregation-editor-7-4.png)
## Templating
@ -169,7 +169,7 @@ There are two syntaxes:
Why two ways? The first syntax is easier to read and write but does not allow you to use a variable in the middle of a word. When the *Multi-value* or *Include all value*
options are enabled, Grafana converts the labels from plain text to a lucene compatible condition.
![](/img/docs/v43/elastic_templating_query.png)
![Query with template variables](/img/docs/elasticsearch/elastic-templating-query-7-4.png)
In the above example, we have a lucene query that filters documents based on the `@hostname` property using a variable named `$hostname`. It is also using
a variable in the *Terms* group by field input box. This allows you to use a variable to quickly change how the data is grouped.

View File

@ -1,34 +1,26 @@
+++
title = "Cloud Monitoring"
title = "Google Cloud Monitoring"
description = "Guide for using Google Cloud Monitoring in Grafana"
keywords = ["grafana", "stackdriver", "google", "guide", "cloud", "monitoring"]
aliases = ["/docs/grafana/latest/features/datasources/stackdriver", "/docs/grafana/latest/features/datasources/cloudmonitoring/"]
aliases = ["/docs/grafana/latest/features/datasources/stackdriver", "/docs/grafana/latest/datasources/cloudmonitoring/", "/docs/grafana/latest/features/datasources/cloudmonitoring/"]
weight = 200
+++
# Using Google Cloud Monitoring in Grafana
> Officially released in Grafana v6.0.0
Grafana ships with built-in support for Google Cloud Monitoring. Just add it as a data source and you are ready to build dashboards for your Google Cloud Monitoring metrics. Refer to [Add a data source]({{< relref "../add-a-data-source.md" >}}) for instructions on how to add a data source to Grafana. Only users with the organization admin role can add data sources.
> Before Grafana v7.1 this data source was named Google Stackdriver.
> **Note** Before Grafana v7.1, Google Cloud Monitoring was referred to as Google Stackdriver.
Grafana ships with built-in support for Google Cloud Monitoring. Just add it as a data source and you are ready to build dashboards for your Google Cloud Monitoring metrics.
## Google Cloud Monitoring settings
## Adding the data source
1. Open the side menu by clicking the Grafana icon in the top header.
1. In the side menu under the `Dashboards` link you should find a link named `Data Sources`.
1. Click the `+ Add data source` button in the top header.
1. Select `Google Cloud Monitoring` from the _Type_ dropdown.
1. Upload or paste in the Service Account Key file. See below for steps on how to create a Service Account Key file.
> **Note:** If you're not seeing the `Data Sources` link in your side menu, then your current user account does not have the `Admin` role for the current organization.
To access Google Cloud Monitoring settings, hover your mouse over the **Configuration** (gear) icon, then click **Data Sources**, and then click the Google Cloud Monitoring data source.
| Name | Description |
| --------------------- | ------------------------------------------------------------------------------------- |
| `Name` | The data source name. This is how you refer to the data source in panels and queries. |
| `Default` | Default data source means that it will be pre-selected for new panels. |
| `Service Account Key` | Service Account Key File for a GCP Project. Instructions below on how to create it. |
| `Service Account Key` | Upload or paste in the Service Account Key file for a GCP Project. Refer to [Using a Google Service Account Key File](#using-a-google-service-account-key-file) for details.|
## Authentication
@ -47,31 +39,31 @@ The following APIs need to be enabled first:
Click on the links above and click the `Enable` button:
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_enable_api.png" class="docs-image--no-shadow" caption="Enable GCP APIs" >}}
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_enable_api.png" max-width="450px" class="docs-image--no-shadow" caption="Enable GCP APIs" >}}
#### Create a GCP Service Account for a Project
1. Navigate to the [APIs and Services Credentials page](https://console.cloud.google.com/apis/credentials).
1. Click on the `Create credentials` dropdown/button and choose the `Service account key` option.
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_create_service_account_button.png" class="docs-image--no-shadow" caption="Create service account button" >}}
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_create_service_account_button.png" max-width="500px" class="docs-image--no-shadow" caption="Create service account button" >}}
1. On the `Create service account key` page, choose key type `JSON`. Then in the `Service Account` dropdown, choose the `New service account` option:
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_create_service_account_key.png" class="docs-image--no-shadow" caption="Create service account key" >}}
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_create_service_account_key.png" max-width="500px" class="docs-image--no-shadow" caption="Create service account key" >}}
1. Some new fields will appear. Fill in a name for the service account in the `Service account name` field and then choose the `Monitoring Viewer` role from the `Role` dropdown:
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_service_account_choose_role.png" class="docs-image--no-shadow" caption="Choose role" >}}
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_service_account_choose_role.png" max-width="600px" class="docs-image--no-shadow" caption="Choose role" >}}
1. Click the Create button. A JSON key file will be created and downloaded to your computer. Store this file in a secure place as it allows access to your Google Cloud Monitoring data.
1. Upload it to Grafana on the data source Configuration page. You can either upload the file or paste in the contents of the file.
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_grafana_upload_key.png" class="docs-image--no-shadow" caption="Upload service key file to Grafana" >}}
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_grafana_upload_key.png" max-width="550px" class="docs-image--no-shadow" caption="Upload service key file to Grafana" >}}
1. The file contents will be encrypted and saved in the Grafana database. Don't forget to save after uploading the file!
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_grafana_key_uploaded.png" class="docs-image--no-shadow" caption="Service key file is uploaded to Grafana" >}}
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_grafana_key_uploaded.png" max-width="600px" class="docs-image--no-shadow" caption="Service key file is uploaded to Grafana" >}}
### Using GCE Default Service Account
@ -181,9 +173,18 @@ Example Alias By: `{{resource.type}} - {{metric.type}}`
Example Result: `gce_instance - compute.googleapis.com/instance/cpu/usage_time`
#### Deep linking from Grafana panels to the Metrics Explorer in Google Cloud Console
> **Note:** Available in Grafana v7.1 and later versions.
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_deep_linking.png" max-width="500px" class="docs-image--right" caption="Google Cloud Monitoring deep linking" >}}
Click on a time series in the panel to see a context menu with a link to View in Metrics Explorer in Google Cloud Console. Clicking that link opens the Metrics Explorer in the Google Cloud Console and runs the query from the Grafana panel there.
The link navigates the user first to the Google Account Chooser and after successfully selecting an account, the user is redirected to the Metrics Explorer. The provided link is valid for any account, but it only displays the query if your account has access to the GCP project specified in the query.
### SLO (Service Level Objective) queries
> Only available in Grafana v7.0+
> **Note:** Available in Grafana v7.0 and later versions.
{{< docs-imagebox img="/img/docs/v70/slo-query-builder.png" max-width= "400px" class="docs-image--right" >}}
@ -224,7 +225,7 @@ SLO queries use the same [alignment period functionality as metric queries]({{<
### MQL (Monitoring Query Language) queries
> **Note:** Only available in Grafana v7.4+.
> **Note:** Available in Grafana v7.4 and later versions.
The MQL query builder in the Google Cloud Monitoring data source allows you to display MQL results in time series format. To get an understanding of the basic concepts in MQL, refer to [Introduction to Monitoring Query Language](https://cloud.google.com/monitoring/mql).
@ -249,7 +250,7 @@ Instead of hard-coding things like server, application and sensor name in your m
Variables are shown as dropdown select boxes at the top of the dashboard. These dropdowns make it easy to change the data
being displayed in your dashboard.
Check out the [Templating]({{< relref "../variables/_index.md" >}}) documentation for an introduction to the templating feature and the different
Check out the [Templating]({{< relref "../../variables/_index.md" >}}) documentation for an introduction to the templating feature and the different
types of template variables.
### Query Variable
@ -282,7 +283,7 @@ Why two ways? The first syntax is easier to read and write but does not allow yo
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_annotations_query_editor.png" max-width= "400px" class="docs-image--right" >}}
[Annotations]({{< relref "../dashboards/annotations.md" >}}) allow you to overlay rich event information on top of graphs. You add annotation
[Annotations]({{< relref "../../dashboards/annotations.md" >}}) allow you to overlay rich event information on top of graphs. You add annotation
queries via the Dashboard menu / Annotations view. Annotation rendering is expensive so it is important to limit the number of rows returned. There is no support for showing Google Cloud Monitoring annotations and events yet but it works well with [custom metrics](https://cloud.google.com/monitoring/custom-metrics/) in Google Cloud Monitoring.
With the query editor for annotations, you can select a metric and filters. The `Title` and `Text` fields support templating and can use data returned from the query. For example, the Title field could have the following text:
@ -304,7 +305,7 @@ Example Result: `monitoring.googleapis.com/uptime_check/http_status has this val
## Configure the data source with provisioning
It's now possible to configure data sources using config files with Grafana's provisioning system. You can read more about how it works and all the settings you can set for data sources on the [provisioning docs page]({{< relref "../administration/provisioning/#datasources" >}})
You can configure data sources using config files with Grafana's provisioning system. Read more about how it works and all the settings you can set for data sources on the [provisioning docs page]({{< relref "../../administration/provisioning/#datasources" >}})
Here is a provisioning example using the JWT (Service Account key file) authentication type.
@ -341,35 +342,3 @@ datasources:
jsonData:
authenticationType: gce
```
## Deep linking from Grafana panels to the Metrics Explorer in Google Cloud Console
Only available in Grafana v7.1+.
{{< docs-imagebox img="/img/docs/v71/cloudmonitoring_deep_linking.png" max-width="500px" class="docs-image--right" caption="Google Cloud Monitoring deep linking" >}}
> **Note:** This feature is only available for Metric queries.
Click on a time series in the panel to see a context menu with a link to View in Metrics Explorer in Google Cloud Console. Clicking that link opens the Metrics Explorer in the Google Cloud Console and runs the query from the Grafana panel there.
The link navigates the user first to the Google Account Chooser and after successfully selecting an account, the user is redirected to the Metrics Explorer. The provided link is valid for any account, but it only displays the query if your account has access to the GCP project specified in the query.
## Out-of-the-box dashboards
> Only available in Grafana v7.3+.
The updated Cloud Monitoring data source ships with pre-configured dashboards for five of the most popular GCP services:
1. BigQuery
1. Cloud Load Balancing
1. Cloud SQL
1. Google Compute Engine `GCE`
1. Google Kubernetes Engine `GKE`
To import the pre-configured dashboards, go to the configuration page of a Cloud monitoring data source and click on the `Dashboards` tab. Click `Import` for the dashboard you would like to use.
The datasource of the newly created dashboard panels will be the one selected above.
The dashboards have a template variable which is populated with the projects accessible by the configured service account every time the dashboard is loaded. After the dashboard is loaded, you can select the project you prefer from the drop-down list.
To customize the dashboard, we recommend saving the dashboard under a different name, because otherwise the dashboard will be overwritten when a new version of the dashboard is released.
{{< docs-imagebox img="/img/docs/v73/cloud-monitoring-dashboard-import.png" caption="Cloud Monitoring dashboard import" >}}

View File

@ -0,0 +1,24 @@
+++
title = "Preconfigured dashboards"
description = "Guide for using Google Cloud Monitoring in Grafana"
keywords = ["grafana", "stackdriver", "google", "guide", "cloud", "monitoring"]
aliases = ["/docs/grafana/latest/features/datasources/stackdriver", "/docs/grafana/latest/features/datasources/cloudmonitoring/"]
weight = 10
+++
# Preconfigured Cloud Monitoring dashboards
Google Cloud Monitoring data source ships with pre-configured dashboards for some of the most popular GCP services. These curated dashboards are based on similar dashboards in the GCP dashboard samples repository. See also, [Using Google Cloud Monitoring in Grafana]({{< relref "./_index.md" >}}) for detailed instructions on how to add and configure the Google Cloud Monitoring data source.
## Curated dashboards
To import the curated dashboards:
1. On the configuration page of your Cloud Monitoring data source, click the **Dashboards** tab.
1. Click **Import** for the dashboard you would like to use.
The data source of the newly created dashboard panels will be the one selected above. The dashboards have a template variable that is populated with the projects accessible by the configured service account every time the dashboard is loaded. After the dashboard is loaded, you can select the project you prefer from the drop-down list.
In case you want to customize a dashboard, we recommend that you save it under a different name. Otherwise the dashboard will be overwritten when a new version of the dashboard is released.
{{< docs-imagebox img="/img/docs/google-cloud-monitoring/curated-dashboards-7-4.png" max-width= "650px" >}}

View File

@ -75,6 +75,10 @@ Grafana uses [RxJS](https://rxjs.dev/) to continuously send data from a data sou
1. Use `subscriber.next()` to send the updated data frame whenever you receive new updates.
```ts
import { LoadingState } from '@grafana/data';
```
```ts
const intervalId = setInterval(() => {
frame.add({ time: Date.now(), value: Math.random() });
@ -82,6 +86,7 @@ Grafana uses [RxJS](https://rxjs.dev/) to continuously send data from a data sou
subscriber.next({
data: [frame],
key: query.refId,
state: LoadingState.Streaming,
});
}, 500);

View File

@ -2,7 +2,7 @@
title = "Activate an Enterprise license"
description = "Activate an Enterprise license"
keywords = ["grafana", "licensing", "enterprise"]
weight = 7
weight = 100
+++
# Activate an Enterprise license

View File

@ -22,34 +22,43 @@ Audit logs are JSON objects representing user actions like:
Audit logs contain the following fields. The fields followed by **\*** are always available, the others depends on the type of action logged.
| Field name | Type | Description |
| ----------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `timestamp`\* | string | The date and time the request was made, in coordinated universal time (UTC) using the [RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format. |
| `user`\* | object | Information about the user that made the request. At least one of the `UserID` / `ApiKeyID` fields will not be empty if `isAnonymous=false`. |
| `user.userId` | number | ID of the Grafana user that made the request. |
| `user.orgId`\* | number | Current organization of the user that made the request. |
| `user.orgRole` | string | Current role of the user that made the request. |
| `user.name` | string | Name of the Grafana user that made the request. |
| `user.apiKeyId` | number | ID of the Grafana API key used to make the request. |
| `user.isAnonymous`\* | boolean | `true` if an anonymous user made the request, `false` otherwise. |
| `action`\* | string | The request action (eg. `create`, `update`, `manage-permissions`). |
| `request`\* | object | Information about the HTTP request. |
| `request.params` | object | Request path parameters. |
| `request.query` | object | Request query parameters. |
| `request.body` | string | Request body. |
| `result`\* | object | Information about the HTTP response. |
| `result.statusType`\* | string | `success` if the request action was successful, `failure` otherwise. |
| `result.statusCode` | number | HTTP status of the request. |
| `result.failureMessage` | string | HTTP error message. |
| `result.body` | string | Response body. |
| `resources` | array | Information about the resources that the request action impacted. Can be null for non-resource actions like `login` and `logout`. |
| `resources[x].id`\* | number | ID of the resource. |
| `resources[x].type`\* | string | Type of the resource (logged resources are: `alert`, `alert-notification`, `annotation`, `api-key`, `auth-token`, `dashboard`, `datasource`, `folder`, `org`, `panel`, `playlist`, `report`, `team`, `user`, `version`). |
| `requestUri`\* | string | Request URI. |
| `ipAddress`\* | string | IP address that the request was made from. |
| `userAgent`\* | string | Agent through which the request was made. |
| `grafanaVersion`\* | string | Grafana current version when this log is created. |
| `additionalData` | object | Provide additional information on the request. For now, it's only used in `login` actions to log external user information if an external system was used to log in. |
| Field name | Type | Description |
| ---------- | ---- | ----------- |
| `timestamp`\* | string | The date and time the request was made, in coordinated universal time (UTC) using the [RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format. |
| `user`\* | object | Information about the user that made the request. Either one of the `UserID` or `ApiKeyID` fields will contain content if `isAnonymous=false`. |
| `user.userId` | number | ID of the Grafana user that made the request. |
| `user.orgId`\* | number | Current organization of the user that made the request. |
| `user.orgRole` | string | Current role of the user that made the request. |
| `user.name` | string | Name of the Grafana user that made the request. |
| `user.tokenId` | number | ID of the user authentication token. |
| `user.apiKeyId` | number | ID of the Grafana API key used to make the request. |
| `user.isAnonymous`\* | boolean | If an anonymous user made the request, `true`. Otherwise, `false`. |
| `action`\* | string | The request action. For example, `create`, `update`, or `manage-permissions`. |
| `request`\* | object | Information about the HTTP request. |
| `request.params` | object | Requests path parameters. |
| `request.query` | object | Requests query parameters. |
| `request.body` | string | Requests body. |
| `result`\* | object | Information about the HTTP response. |
| `result.statusType` | string | If the request action was successful, `success`. Otherwise, `failure`. |
| `result.statusCode` | number | HTTP status of the request. |
| `result.failureMessage` | string | HTTP error message. |
| `result.body` | string | Response body. |
| `resources` | array | Information about the resources that the request action affected. This field can be null for non-resource actions such as `login` or `logout`. |
| `resources[x].id`\* | number | ID of the resource. |
| `resources[x].type`\* | string | The type of the resource that was logged: `alert`, `alert-notification`, `annotation`, `api-key`, `auth-token`, `dashboard`, `datasource`, `folder`, `org`, `panel`, `playlist`, `report`, `team`, `user`, or `version`. |
| `requestUri`\* | string | Request URI. |
| `ipAddress`\* | string | IP address that the request was made from. |
| `userAgent`\* | string | Agent through which the request was made. |
| `grafanaVersion`\* | string | Current version of Grafana when this log is created. |
| `additionalData` | object | Additional information that can be provided about the request. |
The `additionalData` field can contain the following information:
| Field name | Action | Description |
| ---------- | ------ | ----------- |
| `loginUsername` | `login` | Login used in the Grafana authentication form. |
| `extUserInfo` | `login` | User information provided by the external system that was used to log in. |
| `authTokenCount` | `login` | Number of active authentication tokens for the user that logged in. |
| `terminationReason` | `logout` | The reason why the user logged out, such as a manual logout or a token expiring. |
### Recorded actions
@ -58,7 +67,7 @@ The audit logs include records about the following categories of actions:
**Sessions**
- Log in.
- Log out.
- Log out (manual log out, token expired/revoked, [SAML Single Logout]({{< relref "saml.md#single-logout" >}})).
- Revoke a user authentication token.
- Create or delete an API key.

View File

@ -2,7 +2,7 @@
title = "License Expiration"
description = ""
keywords = ["grafana", "licensing"]
weight = 8
weight = 120
+++
# License expiration

View File

@ -0,0 +1,49 @@
+++
title = "License restrictions"
description = "Grafana Enterprise license restrictions"
keywords = ["grafana", "licensing", "enterprise"]
weight = 110
+++
# License restrictions
Enterprise licenses are limited by the number of active users, a license expiration date, and the URL of the Grafana instance.
## User limits
Grafana licenses allow for a certain number of active users per instance. An active user is any user that has signed in to Grafana within the past 30 days.
In the context of licensing, each user is classified as either a viewer or an editor:
- An editor is a user who has permission to edit and save a dashboard. Examples of editors are as follows:
- Grafana server administrators.
- Users who are assigned an organizational role of Editor or Admin.
- Users that have been granted Admin or Edit permissions at the dashboard or folder level. Refer to [Dashboard and folder permissions](https://grafana.com/docs/grafana/latest/permissions/dashboard_folder_permissions/).
- A viewer is a user with the Viewer role, which does not permit the user to save a dashboard.
Restrictions are applied separately for viewers and editors.
When the number of maximum active viewers or editors is reached, Grafana displays a warning banner.
## Expiration date
The license expiration date is the date when a license is no longer active. As the license expiration date approaches, Grafana Enterprise displays a banner.
## License URL
License URL is the root URL of your Grafana instance. The license will not work on an instance of Grafana with a different root URL.
## Download a dashboard and folder permissions report
This CSV report helps to identify users, teams, and roles that have been granted Admin or Edit permissions at the dashboard or folder level.
To download the report:
1. Hover your cursor over the **Server Admin** (shield) icon in the side menu and then click **Licensing**.
2. At the bottom of the page, click **Download report**.
## Update license restrictions
To increase the number of licensed users within Grafana, extend a license, or change your licensed URL, contact [Grafana support](https://grafana.com/profile/org#support) or your Grafana Labs account team. They will update your license, which you can activate from within Grafana.
For instructions on how to activate your license after it is updated, refer to
[Activate an Enterprise license]({{< relref "./activate-license.md" >}})

View File

@ -1,565 +0,0 @@
- name: Getting started
link: /getting-started/
children:
- name: With Grafana
link: /getting-started/getting-started/
- name: With Grafana and Prometheus
link: /getting-started/getting-started-prometheus/
- name: Intro to time series
link: /getting-started/timeseries/
- name: Time series dimensions
link: /getting-started/timeseries-dimensions/
- name: Intro to histograms
link: /getting-started/intro-histograms/
- name: Observability strategies
link: /getting-started/strategies/
- name: Glossary
link: /getting-started/glossary/
- name: Best practices
link: /best-practices/
children:
- name: Best practices for creating dashboards
link: /best-practices/best-practices-for-creating-dashboards/
- name: Best practices for managing dashboards
link: /best-practices/best-practices-for-managing-dashboards/
- name: Observability strategies
link: /best-practices/common-observability-strategies/
- name: Dashboard management maturity model
link: /best-practices/dashboard-management-maturity-levels/
- name: Installation
link: /installation/
children:
- name: Installation
link: /installation/installation/
- name: Requirements
link: /installation/requirements/
- name: Install on Ubuntu/Debian
link: /installation/debian/
- name: Install on Centos/RedHat/SUSE
link: /installation/rpm/
- name: Install on Windows
link: /installation/windows/
- name: Install on macOS
link: /installation/mac/
- name: Run Docker image
link: /installation/docker/
- name: Upgrade Grafana
link: /installation/upgrading/
- name: Administration
link: /administration/
children:
- name: Administration tasks
link: /administration/
- name: Change password
link: /administration/change-your-password/
- name: Change preferences
link: /administration/preferences/
- name: Configuration
link: /administration/configuration/
- name: View server settings
link: /administration/view-server-settings/
- name: Configure Docker image
link: /administration/configure-docker/
- name: Security
link: /administration/security/
- name: Authentication
link: /auth/
children:
- link: /auth/overview/
name: Overview
- link: /auth/grafana/
name: Grafana Authentication
- link: /auth/auth-proxy/
name: Auth Proxy
- link: /auth/ldap/
name: LDAP
- link: /auth/enhanced_ldap/
name: Enhanced LDAP
- link: /auth/generic-oauth/
name: Generic OAuth
- link: /auth/google/
name: Google
- link: /auth/azuread/
name: Azure AD
- link: /auth/github/
name: GitHub
- link: /auth/gitlab/
name: GitLab
- link: /auth/okta/
name: Okta
- link: /auth/saml/
name: SAML
- link: /auth/team-sync/
name: Team Sync
- name: Permissions
link: /permissions/
children:
- link: /permissions/
name: Overview
- link: /permissions/organization_roles/
name: Organization Roles
- link: /permissions/dashboard_folder_permissions/
name: Dashboard and Folder
- link: /permissions/datasource_permissions/
name: Data source
- name: Provisioning
link: /administration/provisioning/
- name: Grafana CLI
link: /administration/cli/
- name: Internal metrics
link: /administration/metrics/
- name: View server stats
link: /administration/view-server-stats/
- name: Jaeger instrumentation
link: /administration/jaeger-instrumentation/
- name: Set up Grafana for high availability
link: /administration/set-up-for-high-availability/
- name: Change home dashboard
link: /administration/change-home-dashboard/
- name: Manage users
link: /manage-users
children:
- link: /manage-users/
name: Overview
- link: /manage-users/add-or-remove-user/
name: Add or remove a user
- link: /manage-users/enable-or-disable-user/
name: Enable or disable a user
- link: /manage-users/create-or-remove-team/
name: Create or remove a team
- link: /manage-users/add-or-remove-user-from-team/
name: Add or remove user from team
- name: Data sources
link: /datasources/
children:
- link: /datasources/add-a-data-source/
name: Add data source
- link: /datasources/cloudwatch/
name: AWS Cloudwatch
- link: /datasources/azuremonitor/
name: Azure Monitor
- link: /datasources/elasticsearch/
name: Elasticsearch
- link: /datasources/cloudmonitoring/
name: Google Cloud Monitoring
- link: /datasources/graphite/
name: Graphite
- link: /datasources/influxdb/
name: InfluxDB
- link: /datasources/jaeger/
name: Jaeger
- link: /datasources/loki/
name: Loki
- link: /datasources/mssql/
name: Microsoft SQL Server
- link: /datasources/mysql/
name: MySQL
- link: /datasources/opentsdb/
name: OpenTSDB
- link: /datasources/postgres/
name: PostgreSQL
- link: /datasources/prometheus/
name: Prometheus
- link: /datasources/tempo/
name: Tempo
- link: /datasources/testdata/
name: TestData DB
- link: /datasources/zipkin/
name: Zipkin
- name: Panels
link: /panels/
children:
- link: /panels/panels-overview/
name: Overview
- link: /panels/add-a-panel/
name: Add panel
- link: /panels/queries/
name: Queries
- link: /panels/share-query-results/
name: Share query results
- link: /panels/transformations/
name: Transformations
children:
- link: /panels/transformations/
name: Overview
- link: /panels/transformations/apply-transformations/
name: Apply transformations
- link: /panels/transformations/types-options/
name: Transformation types and options
- link: /panels/field-options/
name: Field options and overrides
children:
- link: /panels/field-options/
name: Overview
- link: /panels/field-options/configure-all-fields/
name: Configure all fields
- link: /panels/field-options/configure-specific-fields/
name: Configure specific fields
- link: /panels/field-options/standard-field-options/
name: Standard field options
- link: /panels/panel-editor/
name: Panel editor
- name: Visualizations
link: /panels/visualizations/
children:
- link: /panels/visualizations/alert-list-panel/
name: Alert list
- link: /panels/visualizations/bar-gauge-panel/
name: Bar gauge
- link: /panels/visualizations/dashboard-list-panel/
name: Dashboard list
- link: /panels/visualizations/gauge-panel/
name: Gauge
- link: /panels/visualizations/graph-panel/
name: Graph
- link: /panels/visualizations/heatmap/
name: Heatmap
- link: /panels/visualizations/logs-panel/
name: Logs
- link: /panels/visualizations/news-panel/
name: News
- link: /panels/visualizations/stat-panel/
name: Stat
- link: /panels/visualizations/table-panel/
name: Table
children:
- name: Overview
link: /panels/visualizations/table/
- link: /panels/visualizations/table/table-field-options/
name: Table field options
- link: /panels/visualizations/table/filter-table-columns/
name: Filter table columns
- link: /panels/visualizations/text-panel/
name: Text
- link: /panels/thresholds/
name: Thresholds
- link: /panels/inspect-panel/
name: Inspect panel
- link: /panels/calculations-list/
name: Calculations list
- name: Dashboards
link: /dashboards/
children:
- link: /dashboards/
name: Overview
- link: /dashboards/annotations/
name: Annotations
- link: /dashboards/dashboard_folders/
name: Folders
- link: /dashboards/playlist/
name: Playlist
- link: /dashboards/search/
name: Search
- link: /dashboards/share-dashboard/
name: Share dashboard
- link: /dashboards/share-panel/
name: Share a panel
- link: /dashboards/time-range-controls/
name: Time range controls
- link: /dashboards/export-import/
name: Export and import
- link: /dashboards/dashboard_history/
name: Dashboard version history
- name: Keyboard shortcuts
link: /dashboards/shortcuts/
- name: Reporting
link: /dashboards/reporting/
- link: /dashboards/json-model/
name: JSON model
- link: /dashboards/scripted-dashboards/
name: Scripted dashboards
- name: Explore
link: /explore/
- name: Alerting
link: /alerting/
children:
- link: /alerting/alerts-overview/
name: Overview
- link: /alerting/notifications/
name: Alert notifications
- link: /alerting/create-alerts/
name: Create alerts
- link: /alerting/view-alerts/
name: View alerts
- link: /alerting/pause-an-alert-rule/
name: Pause alert rule
- link: /alerting/troubleshoot-alerts/
name: Troubleshoot alerts
- name: Image rendering
link: /administration/image_rendering/
- name: Linking
link: /linking/
children:
- name: Overview
link: /linking/linking-overview/
- name: Dashboard links
link: /linking/dashboard-links/
- name: Panel links
link: /linking/panel-links/
- name: Data links
link: /linking/data-links/
- link: /linking/data-link-variables/
name: Data link variables
- name: Templates and variables
link: /variables/
children:
- link: /variables/syntax/
name: Variables syntax
- link: /variables/variable-examples/
name: Variable examples
- name: Variable types
link: /variables/variable-types/
children:
- link: /variables/variable-types/add-query-variable/
name: Add query variable
- link: /variables/variable-types/add-custom-variable/
name: Add custom variable
- link: /variables/variable-types/add-text-box-variable/
name: Add text box variable
- link: /variables/variable-types/add-constant-variable/
name: Add constant variable
- link: /variables/variable-types/add-data-source-variable/
name: Add data source variable
- link: /variables/variable-types/add-interval-variable/
name: Add interval variable
- link: /variables/variable-types/add-ad-hoc-filters/
name: Add ad hoc filters
- link: /variables/variable-types/chained-variables/
name: Chained variables
- link: /variables/variable-types/global-variables/
name: Global variables
- name: Selection options
link: /variables/variable-selection-options/
- name: Value groups/tags
link: /variables/variable-value-tags/
- link: /variables/advanced-variable-format-options/
name: Advanced variable formats
- link: /variables/formatting-multi-value-variables/
name: Formatting multi-value variables
- link: /variables/filter-variables-with-regex/
name: Filter variables with regex
- link: /variables/repeat-panels-or-rows/
name: Repeat panels or rows
- name: What's new in Grafana
link: /whatsnew/
children:
- name: Version 7.3
link: /whatsnew/whats-new-in-v7-3/
- name: Version 7.2
link: /whatsnew/whats-new-in-v7-2/
- name: Version 7.1
link: /whatsnew/whats-new-in-v7-1/
- name: Version 7.0
link: /whatsnew/whats-new-in-v7-0/
- name: Version 6.7
link: /whatsnew/whats-new-in-v6-7/
- name: Version 6.6
link: /whatsnew/whats-new-in-v6-6/
- name: Version 6.5
link: /whatsnew/whats-new-in-v6-5/
- name: Version 6.4
link: /whatsnew/whats-new-in-v6-4/
- name: Version 6.3
link: /whatsnew/whats-new-in-v6-3/
- name: Version 6.2
link: /whatsnew/whats-new-in-v6-2/
- name: Version 6.1
link: /whatsnew/whats-new-in-v6-1/
- name: Version 6.0
link: /whatsnew/whats-new-in-v6-0/
- name: Old versions
link: /whatsnew/
children:
- name: Version 5.4
link: /whatsnew/whats-new-in-v5-4/
- name: Version 5.3
link: /whatsnew/whats-new-in-v5-3/
- name: Version 5.2
link: /whatsnew/whats-new-in-v5-2/
- name: Version 5.1
link: /whatsnew/whats-new-in-v5-1/
- name: Version 5.0
link: /whatsnew/whats-new-in-v5/
- name: Version 4.6
link: /whatsnew/whats-new-in-v4-6/
- name: Version 4.5
link: /whatsnew/whats-new-in-v4-5/
- name: Version 4.4
link: /whatsnew/whats-new-in-v4-4/
- name: Version 4.3
link: /whatsnew/whats-new-in-v4-3/
- name: Version 4.2
link: /whatsnew/whats-new-in-v4-2/
- name: Version 4.1
link: /whatsnew/whats-new-in-v4-1/
- name: Version 4.0
link: /whatsnew/whats-new-in-v4/
- name: Version 3.1
link: /whatsnew/whats-new-in-v3-1/
- name: Version 3.0
link: /whatsnew/whats-new-in-v3/
- name: Grafana Enterprise
link: /enterprise/
children:
- name: Overview
link: /enterprise/
- name: Activate license
link: /enterprise/activate-license/
- name: Auditing
link: /enterprise/auditing/
- name: Configuration
link: /enterprise/enterprise-configuration/
- name: Data source permissions
link: /enterprise/datasource_permissions/
- name: Enhanced LDAP
link: /enterprise/enhanced_ldap/
- name: Reporting
link: /enterprise/reporting/
- name: Export dashboard as PDF
link: /enterprise/export-pdf/
- name: SAML authentication
link: /enterprise/saml/
- name: Team sync
link: /enterprise/team-sync/
- name: White labeling
link: /enterprise/white-labeling/
- name: Usage insights
link: /enterprise/usage-insights/
- name: Vault integration
link: /enterprise/vault/
- name: License expiration
link: /enterprise/license-expiration/
- name: Plugins
link: /plugins/
children:
- name: Overview
link: /plugins/
- name: Install plugins
link: /plugins/installation/
- name: Plugin signatures
link: /plugins/plugin-signatures/
- name: HTTP APIs
link: /http_api/
children:
- name: API Authentication
link: /http_api/auth/
- name: Create API Tokens and Dashboards for a Specific Organization
link: /http_api/create-api-tokens-for-org/
- name: cURL examples
link: /http_api/curl-examples/
- name: Admin API
link: /http_api/admin/
- name: Alerting API
link: /http_api/alerting/
- name: Alerting Notifications API
link: /http_api/alerting_notification_channels/
- name: Annotations API
link: /http_api/annotations/
- name: Dashboard API
link: /http_api/dashboard/
- name: Dashboard Permissions API
link: /http_api/dashboard_permissions/
- name: Dashboard Versions API
link: /http_api/dashboard_versions/
- name: Data Source API
link: /http_api/data_source/
- name: Data source Permissions API
link: /http_api/datasource_permissions/
- name: External Group Sync API
link: /http_api/external_group_sync/
- name: Folder API
link: /http_api/folder/
- name: Folder Permissions API
link: /http_api/folder_permissions/
- name: Folder/Dashboard Search API
link: /http_api/folder_dashboard_search/
- name: Organization API
link: /http_api/org/
- name: Other APIs
link: /http_api/other/
- name: Playlist API
link: /http_api/playlist/
- name: Preferences API
link: /http_api/preferences/
- name: Reporting API
link: /http_api/reporting/
- name: Snapshot API
link: /http_api/snapshot/
- name: Teams API
link: /http_api/team/
- name: Users API
link: /http_api/user/
- name: Troubleshooting
children:
- name: Overview
link: /troubleshooting/
- name: Enable diagnostics
link: /troubleshooting/diagnostics/
- name: Troubleshoot dashboards
link: /troubleshooting/troubleshoot-dashboards/
- name: Troubleshoot queries
link: /troubleshooting/troubleshoot-queries/
- name: Developers
children:
- name: Plugins
children:
- name: Overview
link: /developers/plugins/
- name: plugin.json
link: /developers/plugins/metadata/
- name: Data frames
link: /developers/plugins/data-frames/
- name: Working with data frames
link: /developers/plugins/working-with-data-frames/
- name: Add support for variables
link: /developers/plugins/add-support-for-variables/
- name: Add support for annotations
link: /developers/plugins/add-support-for-annotations/
- name: Add support for Explore queries
link: /developers/plugins/add-support-for-explore-queries/
- name: Build a logs data source plugin
link: /developers/plugins/build-a-logs-data-source-plugin/
- name: Build a streaming data source plugin
link: /developers/plugins/build-a-streaming-data-source-plugin/
- name: Authentication
link: /developers/plugins/authentication/
- name: Sign a plugin
link: /developers/plugins/sign-a-plugin/
- name: Add authentication for data source plugins
link: /developers/plugins/add-authentication-for-data-source-plugins/
- name: Backend plugins
children:
- link: /developers/plugins/backend/
name: Overview
- link: /developers/plugins/backend/plugin-protocol/
name: Plugin protocol
- link: /developers/plugins/backend/grafana-plugin-sdk-for-go/
name: Grafana plugin SDK for Go
- name: Package a plugin
link: /developers/plugins/package-a-plugin/
- name: Error handling
link: /developers/plugins/error-handling/
- name: Plugin migration guide
link: /developers/plugins/migration-guide/
- name: Legacy plugins
children:
- link: /developers/plugins/legacy/
name: Overview
- link: /developers/plugins/legacy/style-guide/
name: Code style guide
- link: /developers/plugins/legacy/review-guidelines/
name: Review guidelines
- link: /developers/plugins/legacy/defaults-and-editor-mode/
name: Defaults and editor mode
- link: /developers/plugins/legacy/apps/
name: App plugins
- link: /developers/plugins/legacy/data-sources/
name: Data source plugins
- link: /developers/plugins/legacy/snapshot-mode/
name: Snapshot mode
- name: API reference
link: /packages_api/
- name: Contribute
link: /developers/contribute/
- name: Contributor License Agreement (CLA)
link: /developers/cla/

View File

@ -0,0 +1,151 @@
+++
title = "Expressions"
weight = 800
+++
# Server-side expressions
> **Note:** This documentation is for a beta feature.
Server-side expressions allow you to manipulate data returned from queries with math and other operations. Expressions create new data and do not manipulate the data returned by data sources, aside from some minor data restructuring to make the data acceptable input for expressions.
## Using expressions
The primary use case for expressions is for the upcoming next version of Grafana alerting. Like alerting, processing is done server-side, so expressions can operate without a browser session. However, expressions can be used with backend data sources and visualization as well.
> **Note:** Expressions do not work with current Grafana alerting.
Expressions are meant to augment data sources by enabling queries from different data sources to be combined or by providing operations unavailable in a data source.
> **Note:** When possible, you should do data processing inside the data source. Copying data from storage to the Grafana server for processing is inefficient, so expressions are targeted at lightweight data processing.
Expressions work with data source queries that return time series or number data. They also operate on [multiple-dimensional data]({{< relref "../getting-started/timeseries-dimensions.md" >}}). For example, a query that returns multiple series, where each series is identified by labels or tags.
An individual expression takes one or more queries or other expressions as input and adds data to the result. Each individual expression or query is represented by a variable that is a named identifier known as its RefID (e.g., the default letter `A` or `B`).
To reference the output of an individual expression or a data source query in another expression, this identifier is used as a variable.
## Types of expression
Expressions work with two types of data.
- A collections of time series.
- A collection of numbers, where each collection could be a single series or single number.
Each collection is returned from a single data source query or expression and represented by the RefID. Each collection is a set, where each item in the set is uniquely identified by it dimensions which are stored as [labels]({{< relref "../getting-started/timeseries-dimensions.md#labels" >}}) or key-value pairs.
## Data source queries
Server-side expressions only support data source queries for backend data sources. The data is generally assumed to be labeled time series data. In the future we intended to add an assertion of the query return type (number or time series) data so expressions can handle errors better.
Data source queries, when used with expressions, are executed by the expression engine. When it does this, it restructures data to be either one time series or one number per data frame. So for example if using a data source that returns multiple series on one frame in the table view, you might notice it looks different when executed with expressions.
Currently, the only non-time series format (number) is supported when using data frames are you have a table response that returns a data frame with no time, string columns, and one number column:
Loc | Host | Avg_CPU |
----|------| ------- |
MIA | A | 1
NYC | B | 2
will produce a number that works with expressions. The string columns become labels and the number column the corresponding value. For example `{"Loc": "MIA", "Host": "A"}` with a value of 1.
## Operations
You can use the following operations in expressions: math, reduce, and resample.
### Math
Math is for free-form math formulas on time series or number data. Math operations take numbers and time series as input and changes them to different numbers and time series.
Data from other queries or expressions are referenced with the RefID prefixed with a dollar sign, for example `$A`. If the variable has spaces in the name, then you can use a brace syntax like `${my variable}`.
Numeric constants may be in decimal (`2.24`), octal (with a leading zero like `072`), or hex (with a leading 0x like `0x2A`). Exponentials and signs are also supported (e.g., `-0.8e-2`).
#### Operators
The arithmetic (`+`, binary and unary `-`, `*`, `/`, `%`, exponent `**`), relational (`<`, `>`, `==`, `!=`, `>=`, `<=`), and logical (`&&`, `||`, and unary `!`) operators are supported.
How the operation behaves with data depends on if it is a number or time series data.
With binary operations, such as `$A + $B` or `$A || $B`, the operator is applied in the following ways depending on the type of data:
- If both `$A` and `$B` are a number, then the operation is performed between the two numbers.
- If one variable is a number, and the other variable is a time series, then the operation between the value of each point in the time series and the number is performed.
- If both `$A` and `$B` are time series data, then the operation between each value in the two series is performed for each time stamp that exists in both `$A` and `$B`. The Resample operation can be used to line up time stamps. (**Note:** in the future, we plan to add options to the Math operation for different behaviors).
So in summary:
- Number OP number = number
- Number OP series = series
- Series OP series = series
Because expressions work with multiple series or numbers represented by a single variable, binary operations also perform a union (join) between the two variables. This is done based on the identifying labels associated with each individual series or number.
So if you have numbers with labels like `{host=web01}` in `$A` and another number in `$B` with the same labels then the operation is performed between those two items within each variable, and the result will share the same labels. The rules for the behavior of this union are as follows:
- An item with no labels will join to anything.
- If both `$A` and `$B` each contain only one item (one series, or one number), they will join.
- If labels are exact math they will join.
- If labels are a subset of the other, for example and item in `$A` is labeled `{host=A,dc=MIA}` and and item in `$B` is labeled `{host=A}` they will join.
- Currently, if within a variable such as `$A` there are different tag _keys_ for each item, the join behavior is undefined.
The relational and logical operators return 0 for false 1 for true.
#### Math Functions
While most functions exist in the own expression operations, the math operation does have some functions that similar to math operators or symbols. When functions can take either numbers or series, than the same type as the argument will be returned. When it is a series, the operation of performed for the value of each point in the series.
##### abs
abs returns the absolute value of its argument which can be a number or a series. For example `abs(-1)` or `abs($A)`.
##### log
Log returns the natural logarithm of of its argument which can be a number or a series. If the value is less than 0, NaN is returned. For example `log(-1)` or `log($A)`.
##### inf, nan, and null
The inf, nan, and null functions all return a single value of the name. They primarily exist for testing. Example: `null()`. (Note: inf always returns positive infinity, should probably change this to take an argument so it can return negative infinity).
### Reduce
Reduce takes one or more time series returned from a query or an expression and turns each series into a single number. The labels of the time series are kept as labels on each outputted reduced number.
**Fields:**
- **Function -** The reduction function to use
- **Input -** The variable (refID (such as `A`)) to resample
#### Reduction Functions
> **Note:** In the future we plan to add options to control empty, NaN, and null behavior for reduction functions.
##### Count
Count returns the number of points in each series.
##### Mean
Mean returns the total of all values in each series divided by the number of points in that series. If any values in the series are null or nan, or if the series is empty, NaN is returned.
##### Min and Max
Min and Max return the smallest or largest value in the series respectively. If any values in the series are null or nan, or if the series is empty, NaN is returned.
##### Sum
Sum returns the total of all values in the series. If series is of zero length, the sum will be 0. If there are any NaN or Null values in the series, NaN is returned.
### Resample
Resample changes the time stamps in each time series to have a consistent time interval. The main use case is so you can resample time series that do not share the same timestamps so math can be performed between them. This can be done by resample each of the two series, and then in a Math operation referencing the resampled variables.
**Fields:**
- **Input -** The variable of time series data (refID (such as `A`)) to resample
- **Resample to -** The duration of time to resample to, for example `10s`. Units may be `s` seconds, `m` for minutes, `h` for hours, `d` for days, `w` for weeks, and `y` of years.
- **Downsample -** The reduction function to use when there are more than one data point per window sample. See the reduction operation for behavior details.
- **Upsample -** The method to use to fill a window sample that has no data points.
- **pad** fills with the last know value
- **backfill** with next known value
- **fillna** to fill empty sample windows with NaNs

View File

@ -21,7 +21,7 @@ Because of the difference between query languages, data sources may have query e
**Prometheus (PromQL) query editor**
{{< docs-imagebox img="/img/docs/queries/prometheus-query-editor-7-2.png" class="docs-image--no-shadow" max-width="1000px" >}}
{{< docs-imagebox img="/img/docs/queries/prometheus-query-editor-7-4.png" class="docs-image--no-shadow" max-width="1000px" >}}
## Query syntax
@ -49,6 +49,7 @@ The Query tab consists of the following elements:
- Query options
- Query inspector button
- Query editor list
- Expressions
{{< docs-imagebox img="/img/docs/queries/query-editor-7-2.png" class="docs-image--no-shadow" max-width="1000px" >}}
@ -118,9 +119,14 @@ You can:
| Icon | Description |
|:--:|:---|
| {{< docs-imagebox img="/img/docs/queries/query-editor-help-7-4.png" class="docs-image--no-shadow" max-width="30px" max-height="30px" >}} | Toggle query editor help. If supported by the data source, this will toggle displaying information on how to use its query editor, or provide quick
access to commonly-used queries. |
| {{< docs-imagebox img="/img/docs/queries/query-editor-help-7-4.png" class="docs-image--no-shadow" max-width="30px" max-height="30px" >}} | Toggle query editor help. If supported by the data source, click this icon to display information on how to use the query editor or provide quick access to common queries. |
| {{< docs-imagebox img="/img/docs/queries/duplicate-query-icon-7-0.png" class="docs-image--no-shadow" max-width="30px" max-height="30px" >}} | Copy a query. Duplicating queries is useful when working with multiple complex queries that are similar and you want to either experiment with different variants or do minor alterations. |
| {{< docs-imagebox img="/img/docs/queries/hide-query-icon-7-0.png" class="docs-image--no-shadow" max-width="30px" max-height="30px" >}} | Hide a query. Grafana does not send hidden queries to the data source. |
| {{< docs-imagebox img="/img/docs/queries/remove-query-icon-7-0.png" class="docs-image--no-shadow" max-width="30px" max-height="30px" >}} | Remove a query. Removing a query permanently deletes it, but sometimes you can recover deleted queries by reverting to previously saved versions of the panel. |
| {{< docs-imagebox img="/img/docs/queries/query-drag-icon-7-2.png" class="docs-image--no-shadow" max-width="30px" max-height="30px" >}} | Reorder queries. Change the order of queries by clicking and holding the drag icon, then drag queries where desired. The order of results reflects the order of the queries, so you can often adjust your visual results based on query order. |
### Expressions
If your data source supports them, then Grafana displays the **Expression** button and shows any existing expressions in the query editor list.
For more information about expressions, refer to [Expressions]({{< relref "expressions.md" >}}).

View File

@ -10,12 +10,11 @@ list = false
### Features and enhancements
* ** MSSQL**: Integrated security. [#30369](https://github.com/grafana/grafana/pull/30369), [@daniellee](https://github.com/daniellee)
* **API**: Add ID to snapshot API responses. [#29600](https://github.com/grafana/grafana/pull/29600), [@AgnesToulet](https://github.com/AgnesToulet)
* **AlertListPanel**: Add options to sort by Time(asc) and Time(desc). [#29764](https://github.com/grafana/grafana/pull/29764), [@dboslee](https://github.com/dboslee)
* **AlertListPanel**: Changed alert url to to go the panel view instead of panel edit. [#29060](https://github.com/grafana/grafana/pull/29060), [@zakiharis](https://github.com/zakiharis)
* **Alerting**: Add support for Sensu Go notification channel. [#28012](https://github.com/grafana/grafana/pull/28012), [@nixwiz](https://github.com/nixwiz)
* **Alerting**: Evaluate data templating in alert rule name and message. [#29908](https://github.com/grafana/grafana/pull/29908), [@wbrowne](https://github.com/wbrowne)
* **Alerting**: Add support for alert notification query label interpolation. [#29908](https://github.com/grafana/grafana/pull/29908), [@wbrowne](https://github.com/wbrowne)
* **Annotations**: Remove annotation_tag entries as part of annotations cleanup. [#29534](https://github.com/grafana/grafana/pull/29534), [@dafydd-t](https://github.com/dafydd-t)
* **Azure Monitor**: Add Microsoft.Network/natGateways. [#29479](https://github.com/grafana/grafana/pull/29479), [@JoeyLemur](https://github.com/JoeyLemur)
* **Backend plugins**: Support Forward OAuth Identity for backend data source plugins. [#27055](https://github.com/grafana/grafana/pull/27055), [@billoley](https://github.com/billoley)
@ -45,9 +44,11 @@ list = false
* **Loki**: Add query type and line limit to query editor in dashboard. [#29356](https://github.com/grafana/grafana/pull/29356), [@ivanahuckova](https://github.com/ivanahuckova)
* **Loki**: Add query type selector to query editor in Explore. [#28817](https://github.com/grafana/grafana/pull/28817), [@ivanahuckova](https://github.com/ivanahuckova)
* **Loki**: Retry web socket connection when connection is closed abnormally. [#29438](https://github.com/grafana/grafana/pull/29438), [@ivanahuckova](https://github.com/ivanahuckova)
* **MS SQL**: Integrated security. [#30369](https://github.com/grafana/grafana/pull/30369), [@daniellee](https://github.com/daniellee)
* **Middleware**: Add CSP support. [#29740](https://github.com/grafana/grafana/pull/29740), [@aknuds1](https://github.com/aknuds1)
* **OAuth**: Configurable user name attribute. [#28286](https://github.com/grafana/grafana/pull/28286), [@alexanderzobnin](https://github.com/alexanderzobnin)
* **PanelEditor**: Render panel field config categories as separate option group sections. [#30301](https://github.com/grafana/grafana/pull/30301), [@dprokop](https://github.com/dprokop)
* **Postgres**: SSL certification. [#30352](https://github.com/grafana/grafana/pull/30352), [@ying-jeanne](https://github.com/ying-jeanne)
* **Prometheus**: Add support for Exemplars. [#28057](https://github.com/grafana/grafana/pull/28057), [@zoltanbedi](https://github.com/zoltanbedi)
* **Prometheus**: Improve autocomplete performance and remove disabling of dynamic label lookup. [#30199](https://github.com/grafana/grafana/pull/30199), [@ivanahuckova](https://github.com/ivanahuckova)
* **Prometheus**: Update default query type option to "Both" in Explore query editor. [#28935](https://github.com/grafana/grafana/pull/28935), [@ivanahuckova](https://github.com/ivanahuckova)
@ -60,6 +61,7 @@ list = false
* **Templating**: Custom variable edit UI, change options input into textarea. [#28322](https://github.com/grafana/grafana/pull/28322), [@darrylsepeda](https://github.com/darrylsepeda)
* **TimeSeriesPanel**: The new graph panel now supports y-axis value mapping. [#30272](https://github.com/grafana/grafana/pull/30272), [@torkelo](https://github.com/torkelo)
* **Tracing**: Tag spans with user login and datasource name instead of id. [#29183](https://github.com/grafana/grafana/pull/29183), [@bergquist](https://github.com/bergquist)
* **Transformations**: Add "Rename By Regex" transformer. [#29281](https://github.com/grafana/grafana/pull/29281), [@simianhacker](https://github.com/simianhacker)
* **Transformations**: Added new transform for excluding and including rows based on their values. [#26884](https://github.com/grafana/grafana/pull/26884), [@Totalus](https://github.com/Totalus)
* **Transforms**: Add sort by transformer. [#30370](https://github.com/grafana/grafana/pull/30370), [@ryantxu](https://github.com/ryantxu)
* **Variables**: Add deprecation warning for value group tags. [#30160](https://github.com/grafana/grafana/pull/30160), [@torkelo](https://github.com/torkelo)
@ -68,7 +70,6 @@ list = false
* **Variables**: Adds variables inspection. [#25214](https://github.com/grafana/grafana/pull/25214), [@hugohaggmark](https://github.com/hugohaggmark)
* **Variables**: New Variables are stored immediately. [#29178](https://github.com/grafana/grafana/pull/29178), [@hugohaggmark](https://github.com/hugohaggmark)
* **Zipkin**: Remove browser access mode. [#30360](https://github.com/grafana/grafana/pull/30360), [@zoltanbedi](https://github.com/zoltanbedi)
* **postgres SSL certification**. [#30352](https://github.com/grafana/grafana/pull/30352), [@ying-jeanne](https://github.com/ying-jeanne)
### Bug fixes
@ -144,4 +145,3 @@ This option to group query variable values into groups by tags has been an exper
* **Card**: Add new Card component. [#28216](https://github.com/grafana/grafana/pull/28216), [@Clarity-89](https://github.com/Clarity-89)
* **FieldConfig**: Implementation slider editor (#27592). [#28007](https://github.com/grafana/grafana/pull/28007), [@isaozlerfm](https://github.com/isaozlerfm)
* **MutableDataFrame**: Remove unique field name constraint and values field index and unused/seldom used stuff. [#27573](https://github.com/grafana/grafana/pull/27573), [@torkelo](https://github.com/torkelo)

View File

@ -37,7 +37,7 @@ The Grafana Stackdriver plugin comes with support for automatic unit detection.
The data source is still in the `beta` phase, meaning it's currently in active development and is still missing one important feature - templating queries.
Please try it out, but be aware of that it might be subject to changes and possible bugs. We would love to hear your feedback.
Please read [Using Google Stackdriver in Grafana]({{< relref "../datasources/cloudmonitoring/" >}}) for more detailed information on how to get started and use it.
Refer to [Using Google Stackdriver in Grafana]({{< relref "../datasources/google-cloud-monitoring/_index.md" >}}) for more detailed information on how to get started and use it.
## TV and Kiosk Mode

View File

@ -45,9 +45,9 @@ Stackdriver is the first data source which has support for a custom templating q
create their very own templating query editor.
Additionally, if Grafana is running on a Google Compute Engine (GCE) virtual machine, it is now possible for Grafana to automatically retrieve default credentials from the metadata server.
This has the advantage of not needing to generate a private key file for the service account and also not having to upload the file to Grafana. [Learn more]({{< relref "../datasources/cloudmonitoring/#using-gce-default-service-account" >}}).
This has the advantage of not needing to generate a private key file for the service account and also not having to upload the file to Grafana. [Learn more]({{< relref "../datasources/google-cloud-monitoring/_index.md/#using-gce-default-service-account" >}}).
Please read [Using Google Stackdriver in Grafana]({{< relref "../datasources/cloudmonitoring/" >}}) for more detailed information on how to get started and use it.
Please read [Using Google Stackdriver in Grafana]({{< relref "../datasources/google-cloud-monitoring/_index.md/" >}}) for more detailed information on how to get started and use it.
<div class="clearfix"></div>

View File

@ -114,7 +114,7 @@ will be shared soon.
Built-in support for [Google Stackdriver](https://cloud.google.com/stackdriver/) is officially released in Grafana 6.0. Beta support was added in Grafana 5.3 and we have added lots of improvements since then.
To get started read the guide: [Using Google Stackdriver in Grafana]({{< relref "../datasources/cloudmonitoring/" >}}).
To get started read the guide: [Using Google Stackdriver in Grafana]({{< relref "../datasources/google-cloud-monitoring/_index.md/" >}}).
## Azure Monitor data source

View File

@ -179,7 +179,7 @@ It was released as a beta feature in Grafana 6.7. The feedback has been really p
## Stackdriver data source supports Service Monitoring
[Service monitoring](https://cloud.google.com/service-monitoring) in Google Cloud Platform (GCP) enables you to monitor based on Service Level Objectives (SLOs) for your GCP services. The new SLO query builder in the Stackdriver data source allows you to display SLO data in Grafana. Read more about it in the [Stackdriver data source documentation]({{< relref "../datasources/cloudmonitoring/#slo-service-level-objective-queries" >}}).
[Service monitoring](https://cloud.google.com/service-monitoring) in Google Cloud Platform (GCP) enables you to monitor based on Service Level Objectives (SLOs) for your GCP services. The new SLO query builder in the Stackdriver data source allows you to display SLO data in Grafana. Read more about it in the [Stackdriver data source documentation]({{< relref "../datasources/google-cloud-monitoring/_index.md/#slo-service-level-objective-queries" >}}).
## Time zone support

View File

@ -83,7 +83,7 @@ Additionally, the Raw Edit mode for Application Insights Analytics has been repl
## Deep linking for Google Cloud Monitoring (formerly named Google Stackdriver) data source
A new feature in Grafana 7.1 is [deep linking from Grafana panels to the Metrics Explorer in Google Cloud Console]({{<relref "../datasources/cloudmonitoring.md#deep-linking-from-grafana-panels-to-the-metrics-explorer-in-google-cloud-console">}}). Click on a time series in the panel to see a context menu with a link to View in Metrics explorer in Google Cloud Console. Clicking that link opens the Metrics explorer in the Monitoring Google Cloud Console and runs the query from the Grafana panel there.
A new feature in Grafana 7.1 is [deep linking from Grafana panels to the Metrics Explorer in Google Cloud Console]({{<relref "../datasources/google-cloud-monitoring/_index.md#deep-linking-from-grafana-panels-to-the-metrics-explorer-in-google-cloud-console">}}). Click on a time series in the panel to see a context menu with a link to View in Metrics explorer in Google Cloud Console. Clicking that link opens the Metrics explorer in the Monitoring Google Cloud Console and runs the query from the Grafana panel there.
## Time range picker update

View File

@ -75,7 +75,7 @@ The updated Google Cloud monitoring data source is shipped with pre-configured d
To import the pre-configured dashboards, go to the configuration page of your Google Cloud Monitoring data source and click on the `Dashboards` tab. Click `Import` for the dashboard you would like to use. To customize the dashboard, we recommend to save the dashboard under a different name, because otherwise the dashboard will be overwritten when a new version of the dashboard is released.
For more details, see the [Google Cloud Monitoring docs]({{<relref "../datasources/cloudmonitoring/#out-of-the-box-dashboards">}})
For more details, see the [Google Cloud Monitoring docs]({{<relref "../datasources/google-cloud-monitoring/_index.md/#out-of-the-box-dashboards">}})
## Shorten URL for dashboards and Explore

View File

@ -85,16 +85,20 @@ The following topics were updated as a result of this feature:
_Server-side expressions_ is an experimental feature that allows you to manipulate data returned from backend data source queries. Expressions allow you to manipulate data with math and other operations when the data source is a backend data source or a **--Mixed--** data source.
The main use case is for [multi-dimensional](https://grafana.com/docs/grafana/latest/getting-started/timeseries-dimensions/#time-series-dimensions) data sources used with the upcoming next generation alerting, but expressions can be used with backend data sources and visualization as well.
The main use case is for [multi-dimensional]({{< relref "../getting-started/timeseries-dimensions.md" >}}) data sources used with the upcoming next generation alerting, but expressions can be used with backend data sources and visualization as well.
> **Note:** Queries built with this feature might break with minor version upgrades until Grafana 8 is released. This feature does not work with the current Grafana alerting.
For more information, refer to [Expressions]({{< relref "../panels/expressions.md" >}}). [Queries]({{< relref "../panels/queries.md" >}}) was also updated as a result of this feature.
### Alert notification query label interpolation
You can now provide detailed information to alert notification recipients by injecting alert label data as template variables into an alert notification. Labels that exist from the evaluation of the alert query can be used in the alert rule name and in the alert notification message fields using the `${Label}` syntax. The alert label data is automatically injected into the notification fields when the alert is in the alerting state. When there are multiple unique values for the same label, the values are comma-separated.
{{< figure src="/img/docs/alerting/alert-notification-template-7-4.png" max-width="700px" caption="Variable support in alert notifications" >}}
For more information, refer to the [alert notification docs]({{< relref "../alerting/notifications.md#notification-templating" >}}).
### Content security policy support
We have added support for [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP), a layer of security that helps detect and mitigate certain types of attacks, including Cross Site Scripting (XSS) and data injection attacks.
@ -105,6 +109,11 @@ You can lock down what can be done in the frontend code. Lock down what can be l
[content_security_policy]({{< relref "../administration/configuration.md#content_security_policy" >}}) and [content_security_policy_template]({{< relref "../administration/configuration.md#content_security_policy_template" >}}) were added to [Configuration]({{< relref "../administration/configuration.md" >}}) as a result of this change.
### Hide users in UI
You can now use the `hidden_users` configuration setting to hide specific users in the UI. For example, this feature can be used to hide users that are used for automation purposes.
[Configuration]({{< relref "../administration/configuration.md#hidden_users" >}}) has been updated for this feature.
### Elasticsearch data source updates
Grafana 7.4 includes the following enhancements
@ -130,10 +139,18 @@ Unlike the visual query builder, MQL allows you to control the time range and pe
MQL uses a set of operations and functions. Operations are linked together using the common pipe mechanism, where the output of one operation becomes the input to the next. Linking operations makes it possible to build up complex queries incrementally.
Once query type Metrics is selected in the Cloud Monitoring query editor, you can toggle between the editor modes for visual query builder and MQL.
Once query type Metrics is selected in the Cloud Monitoring query editor, you can toggle between the editor modes for visual query builder and MQL. For more information, refer to the [Google Cloud Monitoring docs]({{<relref "../datasources/google-cloud-monitoring/_index.md/#out-of-the-box-dashboards">}}).
Many thanks to [mtanda](https://github.com/mtanda) this contribution!
## Curated dashboards for Google Cloud Monitoring
Google Cloud Monitoring data source ships with pre-configured dashboards for some of the most popular GCP services. These curated dashboards are based on similar dashboards in the GCP dashboard samples repository. In this release, we have expanded the set of pre-configured dashboards.
{{< docs-imagebox img="/img/docs/google-cloud-monitoring/curated-dashboards-7-4.png" max-width= "650px" >}}
If you want to customize a dashboard, we recommend that you save it under a different name. Otherwise the dashboard will be overwritten when a new version of the dashboard is released. For more information, refer to the [Google Cloud Monitoring docs]({{<relref "../datasources/google-cloud-monitoring/_index.md/#out-of-the-box-dashboards">}}).
### Query Editor Help
The feature previously referred to as DataSource Start Pages or Cheat Sheets has been renamed to Query Editor Help, and is now supported in panel query editors (depending on the data source), as well as in Explore.

View File

@ -8,7 +8,7 @@ export const smokeTestScenario = {
skipScenario: false,
scenario: () => {
e2e.flows.openDashboard();
e2e.pages.Dashboard.Toolbar.toolbarItems('Add panel').click();
e2e.components.PageToolbar.item('Add panel').click();
e2e.pages.AddDashboard.addNewPanel().click();
e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer()

View File

@ -38,7 +38,7 @@ e2e.scenario({
);
}
e2e.pages.Dashboard.Toolbar.toolbarItems('Dashboard settings').click();
e2e.components.PageToolbar.item('Dashboard settings').click();
e2e.components.TimeZonePicker.container()
.should('be.visible')

View File

@ -8,7 +8,7 @@ e2e.scenario({
skipScenario: false,
scenario: () => {
e2e.flows.openDashboard({ uid: '5SdHCadmz' });
e2e.pages.Dashboard.Toolbar.toolbarItems('Dashboard settings').click();
e2e.components.PageToolbar.item('Dashboard settings').click();
e2e.components.FolderPicker.container()
.should('be.visible')

View File

@ -50,7 +50,7 @@ e2e.scenario({
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('p2').should('be.visible').click();
e2e.pages.Dashboard.Toolbar.navBar().click();
e2e.components.PageToolbar.container().click();
e2e.components.DashboardLinks.dropDown().should('be.visible').click().wait('@tagsTemplatingSearch');

View File

@ -20,7 +20,7 @@ describe('Variables - Set options from ui', () => {
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible').click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible').click();
e2e.pages.Dashboard.Toolbar.navBar().click();
e2e.components.PageToolbar.container().click();
e2e().wait('@query');
@ -77,7 +77,7 @@ describe('Variables - Set options from ui', () => {
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A').should('be.visible').click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible').click();
e2e.pages.Dashboard.Toolbar.navBar().click();
e2e.components.PageToolbar.container().click();
e2e().wait('@query');
e2e().wait(500);
@ -132,7 +132,7 @@ describe('Variables - Set options from ui', () => {
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A + B').should('be.visible').click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible').click();
e2e.pages.Dashboard.Toolbar.navBar().click();
e2e.components.PageToolbar.container().click();
e2e().wait('@query');
e2e().wait(500);

View File

@ -185,7 +185,7 @@ function copyExistingDashboard() {
}
function saveDashboard(saveVariables: boolean) {
e2e.pages.Dashboard.Toolbar.toolbarItems('Save dashboard').should('be.visible').click();
e2e.components.PageToolbar.item('Save dashboard').should('be.visible').click();
if (saveVariables) {
e2e.pages.SaveDashboardModal.saveVariables().should('exist').click({ force: true });
@ -212,7 +212,7 @@ function validateTextboxAndMarkup(value: string) {
}
function validateVariable(value: string) {
e2e.pages.Dashboard.Toolbar.toolbarItems('Dashboard settings').should('be.visible').click();
e2e.components.PageToolbar.item('Dashboard settings').should('be.visible').click();
e2e.pages.Dashboard.Settings.General.sectionItems('Variables').should('be.visible').click();
@ -245,7 +245,7 @@ function changeTextBoxInput() {
}
function changeQueryInput() {
e2e.pages.Dashboard.Toolbar.toolbarItems('Dashboard settings').should('be.visible').click();
e2e.components.PageToolbar.item('Dashboard settings').should('be.visible').click();
e2e.pages.Dashboard.Settings.General.sectionItems('Variables').should('be.visible').click();

View File

@ -89,6 +89,7 @@
"@types/d3": "5.7.2",
"@types/d3-force": "^2.1.0",
"@types/d3-scale-chromatic": "1.3.1",
"@types/debounce-promise": "3.1.3",
"@types/enzyme": "3.10.5",
"@types/enzyme-adapter-react-16": "1.0.6",
"@types/file-saver": "2.0.1",
@ -234,6 +235,7 @@
"d3-force": "^2.1.1",
"d3-scale-chromatic": "1.5.0",
"dangerously-set-html-content": "1.0.6",
"debounce-promise": "3.1.2",
"emotion": "10.0.27",
"eventemitter3": "4.0.0",
"fast-text-encoding": "^1.0.0",

View File

@ -11,4 +11,4 @@ export {
} from './standardTransformersRegistry';
export { RegexpOrNamesMatcherOptions, ByNamesMatcherOptions, ByNamesMatcherMode } from './matchers/nameMatcher';
export { RenameByRegexTransformerOptions } from './transformers/renameByRegex';
export { outerJoinDataFrames } from './transformers/seriesToColumns';
export { outerJoinDataFrames } from './transformers/joinDataFrames';

View File

@ -49,52 +49,82 @@ describe('ensureColumns transformer', () => {
const frame = filtered[0];
expect(frame.fields.length).toEqual(5);
expect(filtered[0]).toEqual(
toDataFrame({
fields: [
{
name: 'TheTime',
type: 'time',
config: {},
values: [1000, 2000],
labels: undefined,
expect(filtered[0]).toMatchInlineSnapshot(`
Object {
"fields": Array [
Object {
"config": Object {},
"name": "TheTime",
"state": Object {
"displayName": "TheTime",
},
"type": "time",
"values": Array [
1000,
2000,
],
},
{
name: 'A',
type: 'number',
config: {},
values: [1, 100],
labels: {},
Object {
"config": Object {},
"labels": Object {},
"name": "A",
"state": Object {
"displayName": "A",
},
"type": "number",
"values": Array [
1,
100,
],
},
{
name: 'B',
type: 'number',
config: {},
values: [2, 200],
labels: {},
Object {
"config": Object {},
"labels": Object {},
"name": "B",
"state": Object {
"displayName": "B",
},
"type": "number",
"values": Array [
2,
200,
],
},
{
name: 'C',
type: 'number',
config: {},
values: [3, 300],
labels: {},
Object {
"config": Object {},
"labels": Object {},
"name": "C",
"state": Object {
"displayName": "C",
},
"type": "number",
"values": Array [
3,
300,
],
},
{
name: 'D',
type: 'string',
config: {},
values: ['first', 'second'],
labels: {},
Object {
"config": Object {},
"labels": Object {},
"name": "D",
"state": Object {
"displayName": "D",
},
"type": "string",
"values": Array [
"first",
"second",
],
},
],
meta: {
transformations: ['ensureColumns'],
"length": 2,
"meta": Object {
"transformations": Array [
"ensureColumns",
],
},
name: undefined,
refId: undefined,
})
);
}
`);
});
});

View File

@ -0,0 +1,302 @@
import { toDataFrame } from '../../dataframe/processDataFrame';
import { FieldType } from '../../types/dataFrame';
import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry';
import { ArrayVector } from '../../vector';
import { calculateFieldTransformer } from './calculateField';
import { isLikelyAscendingVector, outerJoinDataFrames } from './joinDataFrames';
describe('align frames', () => {
beforeAll(() => {
mockTransformationsRegistry([calculateFieldTransformer]);
});
it('by first time field', () => {
const series1 = toDataFrame({
fields: [
{ name: 'TheTime', type: FieldType.time, values: [1000, 2000] },
{ name: 'A', type: FieldType.number, values: [1, 100] },
],
});
const series2 = toDataFrame({
fields: [
{ name: '_time', type: FieldType.time, values: [1000, 1500, 2000] },
{ name: 'A', type: FieldType.number, values: [2, 20, 200] },
{ name: 'B', type: FieldType.number, values: [3, 30, 300] },
{ name: 'C', type: FieldType.string, values: ['first', 'second', 'third'] },
],
});
const out = outerJoinDataFrames({ frames: [series1, series2] })!;
expect(
out.fields.map((f) => ({
name: f.name,
values: f.values.toArray(),
}))
).toMatchInlineSnapshot(`
Array [
Object {
"name": "TheTime",
"values": Array [
1000,
1500,
2000,
],
},
Object {
"name": "A",
"values": Array [
1,
undefined,
100,
],
},
Object {
"name": "A",
"values": Array [
2,
20,
200,
],
},
Object {
"name": "B",
"values": Array [
3,
30,
300,
],
},
Object {
"name": "C",
"values": Array [
"first",
"second",
"third",
],
},
]
`);
});
it('unsorted input keep indexes', () => {
//----------
const series1 = toDataFrame({
fields: [
{ name: 'TheTime', type: FieldType.time, values: [1000, 2000, 1500] },
{ name: 'A1', type: FieldType.number, values: [1, 2, 15] },
],
});
const series3 = toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [2000, 1000] },
{ name: 'A2', type: FieldType.number, values: [2, 1] },
],
});
let out = outerJoinDataFrames({ frames: [series1, series3], keepOriginIndices: true })!;
expect(
out.fields.map((f) => ({
name: f.name,
values: f.values.toArray(),
state: f.state,
}))
).toMatchInlineSnapshot(`
Array [
Object {
"name": "TheTime",
"state": Object {
"displayName": "TheTime",
"origin": Object {
"fieldIndex": 0,
"frameIndex": 0,
},
},
"values": Array [
1000,
1500,
2000,
],
},
Object {
"name": "A1",
"state": Object {
"displayName": "A1",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
},
"values": Array [
1,
15,
2,
],
},
Object {
"name": "A2",
"state": Object {
"displayName": "A2",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 1,
},
},
"values": Array [
1,
undefined,
2,
],
},
]
`);
// Fast path still adds origin indecies
out = outerJoinDataFrames({ frames: [series1], keepOriginIndices: true })!;
expect(
out.fields.map((f) => ({
name: f.name,
state: f.state,
}))
).toMatchInlineSnapshot(`
Array [
Object {
"name": "TheTime",
"state": Object {
"displayName": "TheTime",
"origin": Object {
"fieldIndex": 0,
"frameIndex": 0,
},
},
},
Object {
"name": "A1",
"state": Object {
"displayName": "A1",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
},
},
]
`);
});
it('sort single frame', () => {
const series1 = toDataFrame({
fields: [
{ name: 'TheTime', type: FieldType.time, values: [6000, 2000, 1500] },
{ name: 'A1', type: FieldType.number, values: [1, 22, 15] },
],
});
const out = outerJoinDataFrames({ frames: [series1], enforceSort: true, keepOriginIndices: true })!;
expect(
out.fields.map((f) => ({
name: f.name,
values: f.values.toArray(),
}))
).toMatchInlineSnapshot(`
Array [
Object {
"name": "TheTime",
"values": Array [
1500,
2000,
6000,
],
},
Object {
"name": "A1",
"values": Array [
15,
22,
1,
],
},
]
`);
});
it('supports duplicate times', () => {
//----------
// NOTE!!!
// * ideally we would *keep* dupicate fields
//----------
const series1 = toDataFrame({
fields: [
{ name: 'TheTime', type: FieldType.time, values: [1000, 2000] },
{ name: 'A', type: FieldType.number, values: [1, 100] },
],
});
const series3 = toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [1000, 1000, 1000] },
{ name: 'A', type: FieldType.number, values: [2, 20, 200] },
],
});
const out = outerJoinDataFrames({ frames: [series1, series3] })!;
expect(
out.fields.map((f) => ({
name: f.name,
values: f.values.toArray(),
}))
).toMatchInlineSnapshot(`
Array [
Object {
"name": "TheTime",
"values": Array [
1000,
2000,
],
},
Object {
"name": "A",
"values": Array [
1,
100,
],
},
Object {
"name": "A",
"values": Array [
200,
undefined,
],
},
]
`);
});
describe('check ascending data', () => {
it('simple ascending', () => {
const v = new ArrayVector([1, 2, 3, 4, 5]);
expect(isLikelyAscendingVector(v)).toBeTruthy();
});
it('simple ascending with null', () => {
const v = new ArrayVector([null, 2, 3, 4, null]);
expect(isLikelyAscendingVector(v)).toBeTruthy();
});
it('single value', () => {
const v = new ArrayVector([null, null, null, 4, null]);
expect(isLikelyAscendingVector(v)).toBeTruthy();
expect(isLikelyAscendingVector(new ArrayVector([4]))).toBeTruthy();
expect(isLikelyAscendingVector(new ArrayVector([]))).toBeTruthy();
});
it('middle values', () => {
const v = new ArrayVector([null, null, 5, 4, null]);
expect(isLikelyAscendingVector(v)).toBeFalsy();
});
it('decending', () => {
expect(isLikelyAscendingVector(new ArrayVector([7, 6, null]))).toBeFalsy();
expect(isLikelyAscendingVector(new ArrayVector([7, 8, 6]))).toBeFalsy();
});
});
});

View File

@ -0,0 +1,313 @@
import { DataFrame, Field, FieldMatcher, FieldType, Vector } from '../../types';
import { ArrayVector } from '../../vector';
import { fieldMatchers } from '../matchers';
import { FieldMatcherID } from '../matchers/ids';
import { getTimeField, sortDataFrame } from '../../dataframe';
import { getFieldDisplayName } from '../../field';
export function pickBestJoinField(data: DataFrame[]): FieldMatcher {
const { timeField } = getTimeField(data[0]);
if (timeField) {
return fieldMatchers.get(FieldMatcherID.firstTimeField).get({});
}
let common: string[] = [];
for (const f of data[0].fields) {
if (f.type === FieldType.number) {
common.push(f.name);
}
}
for (let i = 1; i < data.length; i++) {
const names: string[] = [];
for (const f of data[0].fields) {
if (f.type === FieldType.number) {
names.push(f.name);
}
}
common = common.filter((v) => !names.includes(v));
}
return fieldMatchers.get(FieldMatcherID.byName).get(common[0]);
}
/**
* @alpha
*/
export interface JoinOptions {
/**
* The input fields
*/
frames: DataFrame[];
/**
* The field to join -- frames that do not have this field will be droppped
*/
joinBy?: FieldMatcher;
/**
* Optionally filter the non-join fields
*/
keep?: FieldMatcher;
/**
* When the result is a single frame, this will to a quick check to see if the values are sorted,
* and sort if necessary. If the first/last values are in order the whole vector is assumed to be
* sorted
*/
enforceSort?: boolean;
/**
* @internal -- used when we need to keep a reference to the original frame/field index
*/
keepOriginIndices?: boolean;
}
function getJoinMatcher(options: JoinOptions): FieldMatcher {
return options.joinBy ?? pickBestJoinField(options.frames);
}
/**
* This will return a single frame joined by the first matching field. When a join field is not specified,
* the default will use the first time field
*/
export function outerJoinDataFrames(options: JoinOptions): DataFrame | undefined {
if (!options.frames?.length) {
return undefined;
}
if (options.frames.length === 1) {
let frame = options.frames[0];
if (options.keepOriginIndices) {
frame = {
...frame,
fields: frame.fields.map((f, fieldIndex) => {
const copy = { ...f };
const origin = {
frameIndex: 0,
fieldIndex,
};
if (copy.state) {
copy.state.origin = origin;
} else {
copy.state = { origin };
}
return copy;
}),
};
}
if (options.enforceSort) {
const joinFieldMatcher = getJoinMatcher(options);
const joinIndex = frame.fields.findIndex((f) => joinFieldMatcher(f, frame, options.frames));
if (joinIndex >= 0) {
if (!isLikelyAscendingVector(frame.fields[joinIndex].values)) {
return sortDataFrame(frame, joinIndex);
}
}
}
return frame;
}
const nullModes: JoinNullMode[][] = [];
const allData: AlignedData[] = [];
const originalFields: Field[] = [];
const joinFieldMatcher = getJoinMatcher(options);
for (let frameIndex = 0; frameIndex < options.frames.length; frameIndex++) {
const frame = options.frames[frameIndex];
if (!frame || !frame.fields?.length) {
continue; // skip the frame
}
const nullModesFrame: JoinNullMode[] = [NULL_REMOVE];
let join: Field | undefined = undefined;
let fields: Field[] = [];
for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) {
const field = frame.fields[fieldIndex];
getFieldDisplayName(field, frame, options.frames); // cache displayName in state
if (!join && joinFieldMatcher(field, frame, options.frames)) {
join = field;
} else {
if (options.keep && !options.keep(field, frame, options.frames)) {
continue; // skip field
}
// Support the standard graph span nulls field config
nullModesFrame.push(field.config.custom?.spanNulls ? NULL_REMOVE : NULL_EXPAND);
let labels = field.labels ?? {};
if (frame.name) {
labels = { ...labels, name: frame.name };
}
fields.push({
...field,
labels, // add the name label from frame
});
}
if (options.keepOriginIndices) {
field.state!.origin = {
frameIndex,
fieldIndex,
};
}
}
if (!join) {
continue; // skip the frame
}
if (originalFields.length === 0) {
originalFields.push(join); // first join field
}
nullModes.push(nullModesFrame);
const a: AlignedData = [join.values.toArray()]; //
for (const field of fields) {
a.push(field.values.toArray());
originalFields.push(field);
}
allData.push(a);
}
const joined = join(allData, nullModes);
return {
// ...options.data[0], // keep name, meta?
length: joined[0].length,
fields: originalFields.map((f, index) => ({
...f,
values: new ArrayVector(joined[index]),
})),
};
}
//--------------------------------------------------------------------------------
// Below here is copied from uplot (MIT License)
// https://github.com/leeoniya/uPlot/blob/master/src/utils.js#L325
// This avoids needing to import uplot into the data package
//--------------------------------------------------------------------------------
// Copied from uplot
type AlignedData = [number[], ...Array<Array<number | null>>];
// nullModes
const NULL_REMOVE = 0; // nulls are converted to undefined (e.g. for spanGaps: true)
const NULL_RETAIN = 1; // nulls are retained, with alignment artifacts set to undefined (default)
const NULL_EXPAND = 2; // nulls are expanded to include any adjacent alignment artifacts
type JoinNullMode = number; // NULL_IGNORE | NULL_RETAIN | NULL_EXPAND;
// sets undefined values to nulls when adjacent to existing nulls (minesweeper)
function nullExpand(yVals: Array<number | null>, nullIdxs: number[], alignedLen: number) {
for (let i = 0, xi, lastNullIdx = -1; i < nullIdxs.length; i++) {
let nullIdx = nullIdxs[i];
if (nullIdx > lastNullIdx) {
xi = nullIdx - 1;
while (xi >= 0 && yVals[xi] == null) {
yVals[xi--] = null;
}
xi = nullIdx + 1;
while (xi < alignedLen && yVals[xi] == null) {
yVals[(lastNullIdx = xi++)] = null;
}
}
}
}
// nullModes is a tables-matched array indicating how to treat nulls in each series
function join(tables: AlignedData[], nullModes: number[][]) {
const xVals = new Set<number>();
for (let ti = 0; ti < tables.length; ti++) {
let t = tables[ti];
let xs = t[0];
let len = xs.length;
for (let i = 0; i < len; i++) {
xVals.add(xs[i]);
}
}
let data = [Array.from(xVals).sort((a, b) => a - b)];
let alignedLen = data[0].length;
let xIdxs = new Map();
for (let i = 0; i < alignedLen; i++) {
xIdxs.set(data[0][i], i);
}
for (let ti = 0; ti < tables.length; ti++) {
let t = tables[ti];
let xs = t[0];
for (let si = 1; si < t.length; si++) {
let ys = t[si];
let yVals = Array(alignedLen).fill(undefined);
let nullMode = nullModes ? nullModes[ti][si] : NULL_RETAIN;
let nullIdxs = [];
for (let i = 0; i < ys.length; i++) {
let yVal = ys[i];
let alignedIdx = xIdxs.get(xs[i]);
if (yVal == null) {
if (nullMode !== NULL_REMOVE) {
yVals[alignedIdx] = yVal;
if (nullMode === NULL_EXPAND) {
nullIdxs.push(alignedIdx);
}
}
} else {
yVals[alignedIdx] = yVal;
}
}
nullExpand(yVals, nullIdxs, alignedLen);
data.push(yVals);
}
}
return data;
}
// Quick test if the first and last points look to be ascending
// Only exported for tests
export function isLikelyAscendingVector(data: Vector): boolean {
let first: any = undefined;
for (let idx = 0; idx < data.length; idx++) {
const v = data.get(idx);
if (v != null) {
if (first != null) {
if (first > v) {
return false; // descending
}
break;
}
first = v;
}
}
let idx = data.length - 1;
while (idx >= 0) {
const v = data.get(idx--);
if (v != null) {
if (first > v) {
return false;
}
return true;
}
}
return true; // only one non-null point
}

View File

@ -2,7 +2,6 @@ import {
ArrayVector,
DataTransformerConfig,
DataTransformerID,
Field,
FieldType,
toDataFrame,
transformDataFrame,
@ -45,58 +44,102 @@ describe('SeriesToColumns Transformer', () => {
(received) => {
const data = received[0];
const filtered = data[0];
expect(filtered.fields).toEqual([
{
name: 'time',
state: {
displayName: 'time',
expect(filtered.fields).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "time",
"state": Object {
"displayName": "time",
},
"type": "time",
"values": Array [
1000,
3000,
4000,
5000,
6000,
7000,
],
},
type: FieldType.time,
values: new ArrayVector([1000, 3000, 4000, 5000, 6000, 7000]),
config: {},
labels: undefined,
},
{
name: 'temperature',
state: {
displayName: 'temperature even',
Object {
"config": Object {},
"labels": Object {
"name": "even",
},
"name": "temperature",
"state": Object {
"displayName": "even temperature",
},
"type": "number",
"values": Array [
undefined,
10.3,
10.4,
10.5,
10.6,
undefined,
],
},
type: FieldType.number,
values: new ArrayVector([null, 10.3, 10.4, 10.5, 10.6, null]),
config: {},
labels: { name: 'even' },
},
{
name: 'humidity',
state: {
displayName: 'humidity even',
Object {
"config": Object {},
"labels": Object {
"name": "even",
},
"name": "humidity",
"state": Object {
"displayName": "even humidity",
},
"type": "number",
"values": Array [
undefined,
10000.3,
10000.4,
10000.5,
10000.6,
undefined,
],
},
type: FieldType.number,
values: new ArrayVector([null, 10000.3, 10000.4, 10000.5, 10000.6, null]),
config: {},
labels: { name: 'even' },
},
{
name: 'temperature',
state: {
displayName: 'temperature odd',
Object {
"config": Object {},
"labels": Object {
"name": "odd",
},
"name": "temperature",
"state": Object {
"displayName": "odd temperature",
},
"type": "number",
"values": Array [
11.1,
11.3,
undefined,
11.5,
undefined,
11.7,
],
},
type: FieldType.number,
values: new ArrayVector([11.1, 11.3, null, 11.5, null, 11.7]),
config: {},
labels: { name: 'odd' },
},
{
name: 'humidity',
state: {
displayName: 'humidity odd',
Object {
"config": Object {},
"labels": Object {
"name": "odd",
},
"name": "humidity",
"state": Object {
"displayName": "odd humidity",
},
"type": "number",
"values": Array [
11000.1,
11000.3,
undefined,
11000.5,
undefined,
11000.7,
],
},
type: FieldType.number,
values: new ArrayVector([11000.1, 11000.3, null, 11000.5, null, 11000.7]),
config: {},
labels: { name: 'odd' },
},
]);
]
`);
}
);
});
@ -113,58 +156,7 @@ describe('SeriesToColumns Transformer', () => {
(received) => {
const data = received[0];
const filtered = data[0];
expect(filtered.fields).toEqual([
{
name: 'temperature',
state: {
displayName: 'temperature',
},
type: FieldType.number,
values: new ArrayVector([10.3, 10.4, 10.5, 10.6, 11.1, 11.3, 11.5, 11.7]),
config: {},
labels: undefined,
},
{
name: 'time',
state: {
displayName: 'time even',
},
type: FieldType.time,
values: new ArrayVector([3000, 4000, 5000, 6000, null, null, null, null]),
config: {},
labels: { name: 'even' },
},
{
name: 'humidity',
state: {
displayName: 'humidity even',
},
type: FieldType.number,
values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6, null, null, null, null]),
config: {},
labels: { name: 'even' },
},
{
name: 'time',
state: {
displayName: 'time odd',
},
type: FieldType.time,
values: new ArrayVector([null, null, null, null, 1000, 3000, 5000, 7000]),
config: {},
labels: { name: 'odd' },
},
{
name: 'humidity',
state: {
displayName: 'humidity odd',
},
type: FieldType.number,
values: new ArrayVector([null, null, null, null, 11000.1, 11000.3, 11000.5, 11000.7]),
config: {},
labels: { name: 'odd' },
},
]);
expect(filtered.fields).toMatchInlineSnapshot(`Array []`);
}
);
});
@ -185,58 +177,102 @@ describe('SeriesToColumns Transformer', () => {
(received) => {
const data = received[0];
const filtered = data[0];
expect(filtered.fields).toEqual([
{
name: 'time',
state: {
displayName: 'time',
expect(filtered.fields).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "time",
"state": Object {
"displayName": "time",
},
"type": "time",
"values": Array [
1000,
3000,
4000,
5000,
6000,
7000,
],
},
type: FieldType.time,
values: new ArrayVector([1000, 3000, 4000, 5000, 6000, 7000]),
config: {},
labels: undefined,
},
{
name: 'temperature',
state: {
displayName: 'temperature even',
Object {
"config": Object {},
"labels": Object {
"name": "even",
},
"name": "temperature",
"state": Object {
"displayName": "even temperature",
},
"type": "number",
"values": Array [
undefined,
10.3,
10.4,
10.5,
10.6,
undefined,
],
},
type: FieldType.number,
values: new ArrayVector([null, 10.3, 10.4, 10.5, 10.6, null]),
config: {},
labels: { name: 'even' },
},
{
name: 'humidity',
state: {
displayName: 'humidity even',
Object {
"config": Object {},
"labels": Object {
"name": "even",
},
"name": "humidity",
"state": Object {
"displayName": "even humidity",
},
"type": "number",
"values": Array [
undefined,
10000.3,
10000.4,
10000.5,
10000.6,
undefined,
],
},
type: FieldType.number,
values: new ArrayVector([null, 10000.3, 10000.4, 10000.5, 10000.6, null]),
config: {},
labels: { name: 'even' },
},
{
name: 'temperature',
state: {
displayName: 'temperature odd',
Object {
"config": Object {},
"labels": Object {
"name": "odd",
},
"name": "temperature",
"state": Object {
"displayName": "odd temperature",
},
"type": "number",
"values": Array [
11.1,
11.3,
undefined,
11.5,
undefined,
11.7,
],
},
type: FieldType.number,
values: new ArrayVector([11.1, 11.3, null, 11.5, null, 11.7]),
config: {},
labels: { name: 'odd' },
},
{
name: 'humidity',
state: {
displayName: 'humidity odd',
Object {
"config": Object {},
"labels": Object {
"name": "odd",
},
"name": "humidity",
"state": Object {
"displayName": "odd humidity",
},
"type": "number",
"values": Array [
11000.1,
11000.3,
undefined,
11000.5,
undefined,
11000.7,
],
},
type: FieldType.number,
values: new ArrayVector([11000.1, 11000.3, null, 11000.5, null, 11000.7]),
config: {},
labels: { name: 'odd' },
},
]);
]
`);
}
);
});
@ -270,40 +306,58 @@ describe('SeriesToColumns Transformer', () => {
(received) => {
const data = received[0];
const filtered = data[0];
const expected: Field[] = [
{
name: 'time',
state: {
displayName: 'time',
expect(filtered.fields).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "time",
"state": Object {
"displayName": "time",
},
"type": "time",
"values": Array [
1000,
2000,
3000,
4000,
],
},
type: FieldType.time,
values: new ArrayVector([1000, 2000, 3000, 4000]),
config: {},
labels: undefined,
},
{
name: 'temperature',
type: FieldType.number,
values: new ArrayVector([1, 3, 5, 7]),
config: {},
state: {
displayName: 'temperature temperature',
Object {
"config": Object {},
"labels": Object {
"name": "temperature",
},
"name": "temperature",
"state": Object {
"displayName": "temperature temperature",
},
"type": "number",
"values": Array [
1,
3,
5,
7,
],
},
labels: { name: 'temperature' },
},
{
name: 'temperature',
state: {
displayName: 'temperature B',
Object {
"config": Object {},
"labels": Object {
"name": "B",
},
"name": "temperature",
"state": Object {
"displayName": "B temperature",
},
"type": "number",
"values": Array [
2,
4,
6,
8,
],
},
type: FieldType.number,
values: new ArrayVector([2, 4, 6, 8]),
config: {},
labels: { name: 'B' },
},
];
expect(filtered.fields).toEqual(expected);
]
`);
}
);
});
@ -341,31 +395,55 @@ describe('SeriesToColumns Transformer', () => {
await expect(transformDataFrame([cfg], [frame1, frame2, frame3])).toEmitValuesWith((received) => {
const data = received[0];
const filtered = data[0];
expect(filtered.fields).toEqual([
{
name: 'time',
state: { displayName: 'time' },
type: FieldType.time,
values: new ArrayVector([1, 2, 3]),
config: {},
},
{
name: 'temperature',
state: { displayName: 'temperature A' },
type: FieldType.number,
values: new ArrayVector([10, 11, 12]),
config: {},
labels: { name: 'A' },
},
{
name: 'temperature',
state: { displayName: 'temperature C' },
type: FieldType.number,
values: new ArrayVector([20, 22, 24]),
config: {},
labels: { name: 'C' },
},
]);
expect(filtered.fields).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "time",
"state": Object {
"displayName": "time",
},
"type": "time",
"values": Array [
1,
2,
3,
],
},
Object {
"config": Object {},
"labels": Object {
"name": "A",
},
"name": "temperature",
"state": Object {
"displayName": "A temperature",
},
"type": "number",
"values": Array [
10,
11,
12,
],
},
Object {
"config": Object {},
"labels": Object {
"name": "C",
},
"name": "temperature",
"state": Object {
"displayName": "C temperature",
},
"type": "number",
"values": Array [
20,
22,
24,
],
},
]
`);
});
});
@ -394,31 +472,45 @@ describe('SeriesToColumns Transformer', () => {
await expect(transformDataFrame([cfg], [frame1, frame2])).toEmitValuesWith((received) => {
const data = received[0];
const filtered = data[0];
expect(filtered.fields).toEqual([
{
name: 'time',
state: { displayName: 'time' },
type: FieldType.time,
values: new ArrayVector([1]),
config: {},
},
{
name: 'temperature',
state: { displayName: 'temperature 1' },
type: FieldType.number,
values: new ArrayVector([10]),
config: {},
labels: {},
},
{
name: 'temperature',
state: { displayName: 'temperature 2' },
type: FieldType.number,
values: new ArrayVector([20]),
config: {},
labels: {},
},
]);
expect(filtered.fields).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "time",
"state": Object {
"displayName": "time",
},
"type": "time",
"values": Array [
1,
],
},
Object {
"config": Object {},
"labels": Object {},
"name": "temperature",
"state": Object {
"displayName": "temperature",
},
"type": "number",
"values": Array [
10,
],
},
Object {
"config": Object {},
"labels": Object {},
"name": "temperature",
"state": Object {
"displayName": "temperature",
},
"type": "number",
"values": Array [
20,
],
},
]
`);
});
});
});

View File

@ -1,153 +1,36 @@
import { map } from 'rxjs/operators';
import { DataFrame, DataTransformerInfo, Field } from '../../types';
import { DataTransformerInfo, FieldMatcher } from '../../types';
import { DataTransformerID } from './ids';
import { MutableDataFrame } from '../../dataframe';
import { ArrayVector } from '../../vector';
import { getFieldDisplayName } from '../../field/fieldState';
import { outerJoinDataFrames } from './joinDataFrames';
import { fieldMatchers } from '../matchers';
import { FieldMatcherID } from '../matchers/ids';
export interface SeriesToColumnsOptions {
byField?: string;
byField?: string; // empty will pick the field automatically
}
const DEFAULT_KEY_FIELD = 'Time';
export const seriesToColumnsTransformer: DataTransformerInfo<SeriesToColumnsOptions> = {
id: DataTransformerID.seriesToColumns,
name: 'Series as columns',
name: 'Series as columns', // Called 'Outer join' in the UI!
description: 'Groups series by field and returns values as columns',
defaultOptions: {
byField: DEFAULT_KEY_FIELD,
byField: undefined, // DEFAULT_KEY_FIELD,
},
operator: (options) => (source) =>
source.pipe(
map((data) => {
return outerJoinDataFrames(data, options);
if (data.length > 1) {
let joinBy: FieldMatcher | undefined = undefined;
if (options.byField) {
joinBy = fieldMatchers.get(FieldMatcherID.byName).get(options.byField);
}
const joined = outerJoinDataFrames({ frames: data, joinBy });
if (joined) {
return [joined];
}
}
return data;
})
),
};
/**
* @internal
*/
export function outerJoinDataFrames(data: DataFrame[], options: SeriesToColumnsOptions) {
const keyFieldMatch = options.byField || DEFAULT_KEY_FIELD;
const allFields: FieldsToProcess[] = [];
for (let frameIndex = 0; frameIndex < data.length; frameIndex++) {
const frame = data[frameIndex];
const keyField = findKeyField(frame, keyFieldMatch);
if (!keyField) {
continue;
}
for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) {
const sourceField = frame.fields[fieldIndex];
if (sourceField === keyField) {
continue;
}
let labels = sourceField.labels ?? {};
if (frame.name) {
labels = { ...labels, name: frame.name };
}
allFields.push({
keyField,
sourceField,
newField: {
...sourceField,
state: null,
values: new ArrayVector([]),
labels,
},
});
}
}
// if no key fields or more than one value field
if (allFields.length <= 1) {
return data;
}
const resultFrame = new MutableDataFrame();
resultFrame.addField({
...allFields[0].keyField,
values: new ArrayVector([]),
});
for (const item of allFields) {
item.newField = resultFrame.addField(item.newField);
}
const keyFieldTitle = getFieldDisplayName(resultFrame.fields[0], resultFrame);
const byKeyField: { [key: string]: { [key: string]: any } } = {};
/*
this loop creates a dictionary object that groups the key fields values
{
"key field first value as string" : {
"key field name": key field first value,
"other series name": other series value
"other series n name": other series n value
},
"key field n value as string" : {
"key field name": key field n value,
"other series name": other series value
"other series n name": other series n value
}
}
*/
for (let fieldIndex = 0; fieldIndex < allFields.length; fieldIndex++) {
const { sourceField, keyField, newField } = allFields[fieldIndex];
const newFieldTitle = getFieldDisplayName(newField, resultFrame);
for (let valueIndex = 0; valueIndex < sourceField.values.length; valueIndex++) {
const value = sourceField.values.get(valueIndex);
const keyValue = keyField.values.get(valueIndex);
if (!byKeyField[keyValue]) {
byKeyField[keyValue] = { [newFieldTitle]: value, [keyFieldTitle]: keyValue };
} else {
byKeyField[keyValue][newFieldTitle] = value;
}
}
}
const keyValueStrings = Object.keys(byKeyField);
for (let rowIndex = 0; rowIndex < keyValueStrings.length; rowIndex++) {
const keyValueAsString = keyValueStrings[rowIndex];
for (let fieldIndex = 0; fieldIndex < resultFrame.fields.length; fieldIndex++) {
const field = resultFrame.fields[fieldIndex];
const otherColumnName = getFieldDisplayName(field, resultFrame);
const value = byKeyField[keyValueAsString][otherColumnName] ?? null;
field.values.add(value);
}
}
return [resultFrame];
}
function findKeyField(frame: DataFrame, matchTitle: string): Field | null {
for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) {
const field = frame.fields[fieldIndex];
if (matchTitle === getFieldDisplayName(field)) {
return field;
}
}
return null;
}
interface FieldsToProcess {
newField: Field;
sourceField: Field;
keyField: Field;
}

View File

@ -56,8 +56,8 @@ export const Components = {
},
OptionsPane: {
content: 'Panel editor option pane content',
close: 'Dashboard navigation bar button Close options pane',
open: 'Dashboard navigation bar button Open options pane',
close: 'Page toolbar button Close options pane',
open: 'Page toolbar button Open options pane',
select: 'Panel editor option pane select',
tab: (title: string) => `Panel editor option pane tab ${title}`,
},
@ -123,6 +123,10 @@ export const Components = {
calculationsLabel: 'Transform calculations label',
},
},
PageToolbar: {
container: () => '.page-toolbar',
item: (tooltip: string) => `Page toolbar button ${tooltip}`,
},
QueryEditorToolbarItem: {
button: (title: string) => `QueryEditor toolbar item button ${title}`,
},

View File

@ -34,10 +34,6 @@ export const Pages = {
},
Dashboard: {
url: (uid: string) => `/d/${uid}`,
Toolbar: {
toolbarItems: (button: string) => `Dashboard navigation bar button ${button}`,
navBar: () => '.navbar',
},
SubMenu: {
submenuItem: 'Dashboard template variables submenu item',
submenuItemLabels: (item: string) => `Dashboard template variables submenu Label ${item}`,

View File

@ -59,7 +59,7 @@ export const addDashboard = (config?: Partial<AddDashboardConfig>) => {
e2e.pages.AddDashboard.visit();
if (annotations.length > 0 || variables.length > 0) {
e2e.pages.Dashboard.Toolbar.toolbarItems('Dashboard settings').click();
e2e.components.PageToolbar.item('Dashboard settings').click();
addAnnotations(annotations);
fullConfig.variables = addVariables(variables);
@ -69,7 +69,7 @@ export const addDashboard = (config?: Partial<AddDashboardConfig>) => {
setDashboardTimeRange(timeRange);
e2e.pages.Dashboard.Toolbar.toolbarItems('Save dashboard').click();
e2e.components.PageToolbar.item('Save dashboard').click();
e2e.pages.SaveDashboardAsModal.newName().clear().type(title);
e2e.pages.SaveDashboardAsModal.save().click();
e2e.flows.assertSuccessNotification();

View File

@ -99,7 +99,7 @@ export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelC
e2e.components.Panels.Panel.title(panelTitle).click();
e2e.components.Panels.Panel.headerItems('Edit').click();
} else {
e2e.pages.Dashboard.Toolbar.toolbarItems('Add panel').click();
e2e.components.PageToolbar.item('Add panel').click();
e2e.pages.AddDashboard.addNewPanel().click();
}
}

View File

@ -33,7 +33,7 @@ const quickDelete = (uid: string) => {
const uiDelete = (uid: string, title: string) => {
e2e.pages.Dashboard.visit(uid);
e2e.pages.Dashboard.Toolbar.toolbarItems('Dashboard settings').click();
e2e.components.PageToolbar.item('Dashboard settings').click();
e2e.pages.Dashboard.Settings.General.deleteDashBoard().click();
e2e.pages.ConfirmModal.delete().click();
e2e.flows.assertSuccessNotification();

View File

@ -1,7 +1,7 @@
import { e2e } from '../index';
export const saveDashboard = () => {
e2e.pages.Dashboard.Toolbar.toolbarItems('Save dashboard').click();
e2e.components.PageToolbar.item('Save dashboard').click();
e2e.pages.SaveDashboardModal.save().click();

View File

@ -4,4 +4,4 @@ import { setTimeRange, TimeRangeConfig } from './setTimeRange';
export { TimeRangeConfig };
export const setDashboardTimeRange = (config: TimeRangeConfig) =>
e2e.pages.Dashboard.Toolbar.navBar().within(() => setTimeRange(config));
e2e.components.PageToolbar.container().within(() => setTimeRange(config));

View File

@ -1,6 +1,5 @@
FROM debian:buster-slim
FROM debian:testing-20210111-slim
USER root
ENV DEBIAN_FRONTEND=noninteractive
COPY scripts scripts

View File

@ -5,5 +5,5 @@
##
DOCKER_IMAGE_BASE_NAME="grafana/grafana-plugin-ci-e2e"
DOCKER_IMAGE_VERSION="1.0.2"
DOCKER_IMAGE_VERSION="1.1.0"
DOCKER_IMAGE_NAME="${DOCKER_IMAGE_BASE_NAME}:${DOCKER_IMAGE_VERSION}"

View File

@ -22,16 +22,16 @@ source "/etc/profile"
npm i -g yarn
# Install Go
filename="go1.15.1.linux-amd64.tar.gz"
get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "70ac0dbf60a8ee9236f337ed0daa7a4c3b98f6186d4497826f68e97c0c0413f6"
filename="go1.15.7.linux-amd64.tar.gz"
get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "0d142143794721bb63ce6c8a6180c4062bcf8ef4715e7d6d6609f3a8282629b3"
untar_file "/tmp/$filename"
# Install golangci-lint
GOLANGCILINT_VERSION=1.31.0
GOLANGCILINT_VERSION=1.36.0
filename="golangci-lint-${GOLANGCILINT_VERSION}-linux-amd64"
get_file "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCILINT_VERSION}/$filename.tar.gz" \
"/tmp/$filename.tar.gz" \
"9a5d47b51442d68b718af4c7350f4406cdc087e2236a5b9ae52f37aebede6cb3"
"9b8856b3a1c9bfbcf3a06b78e94611763b79abd9751c245246787cd3bf0e78a5"
untar_file "/tmp/$filename.tar.gz"
ln -s /usr/local/${filename}/golangci-lint /usr/local/bin/golangci-lint
ln -s /usr/local/go/bin/go /usr/local/bin/go

View File

@ -9,7 +9,7 @@ services:
- ${HOME}/.ssh:/root/.ssh
- ../../..:/root/grafana-toolkit
cibuilt:
image: "srclosson/grafana-plugin-ci-e2e"
image: "grafana/grafana-plugin-ci-e2e"
user: root
volumes:
- ../scripts:/root/scripts

View File

@ -78,12 +78,12 @@
"@rollup/plugin-commonjs": "16.0.0",
"@rollup/plugin-image": "2.0.5",
"@rollup/plugin-node-resolve": "10.0.0",
"@storybook/addon-controls": "6.1.9",
"@storybook/addon-essentials": "6.1.9",
"@storybook/addon-knobs": "6.1.9",
"@storybook/addon-storysource": "6.1.9",
"@storybook/react": "6.1.9",
"@storybook/theming": "6.1.9",
"@storybook/addon-controls": "6.1.15",
"@storybook/addon-essentials": "6.1.15",
"@storybook/addon-knobs": "6.1.15",
"@storybook/addon-storysource": "6.1.15",
"@storybook/react": "6.1.15",
"@storybook/theming": "6.1.15",
"@types/classnames": "2.2.7",
"@types/common-tags": "^1.8.0",
"@types/d3": "5.7.2",
@ -110,7 +110,7 @@
"rollup-plugin-terser": "7.0.2",
"rollup-plugin-typescript2": "0.29.0",
"rollup-plugin-visualizer": "4.2.0",
"storybook-dark-mode": "1.0.3",
"storybook-dark-mode": "1.0.4",
"ts-loader": "8.0.11",
"typescript": "4.1.2",
"webpack-filter-warnings-plugin": "1.2.1"

View File

@ -1,8 +1,9 @@
import React from 'react';
import { ToolbarButton, ButtonGroup, useTheme, VerticalGroup, HorizontalGroup } from '@grafana/ui';
import { ToolbarButton, ButtonGroup, VerticalGroup, HorizontalGroup } from '@grafana/ui';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { ToolbarButtonRow } from './ToolbarButtonRow';
import { ToolbarButtonVariant } from './ToolbarButton';
import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas';
export default {
title: 'Buttons/ToolbarButton',
@ -12,11 +13,10 @@ export default {
};
export const List = () => {
const theme = useTheme();
const variants: ToolbarButtonVariant[] = ['default', 'active', 'primary', 'destructive'];
return (
<div style={{ background: theme.colors.dashboardBg, padding: '32px' }}>
<DashboardStoryCanvas>
<VerticalGroup>
Button states
<ToolbarButtonRow>
@ -47,6 +47,22 @@ export const List = () => {
))}
</ToolbarButtonRow>
<br />
disabled
<ToolbarButtonRow>
<ToolbarButton icon="sync" disabled>
Disabled
</ToolbarButton>
</ToolbarButtonRow>
<br />
Variants
<ToolbarButtonRow>
{variants.map((variant) => (
<ToolbarButton icon="sync" tooltip="Sync" variant={variant} key={variant}>
{variant}
</ToolbarButton>
))}
</ToolbarButtonRow>
<br />
Wrapped in noSpacing ButtonGroup
<ButtonGroup>
<ToolbarButton icon="clock-nine" tooltip="Time picker">
@ -76,6 +92,6 @@ export const List = () => {
</ButtonGroup>
</HorizontalGroup>
</VerticalGroup>
</div>
</DashboardStoryCanvas>
);
};

View File

@ -7,6 +7,7 @@ import { Tooltip } from '../Tooltip/Tooltip';
import { Icon } from '../Icon/Icon';
import { getPropertiesForVariant } from './Button';
import { isString } from 'lodash';
import { selectors } from '@grafana/e2e-selectors';
export interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Icon name */
@ -31,7 +32,20 @@ export type ToolbarButtonVariant = 'default' | 'primary' | 'destructive' | 'acti
export const ToolbarButton = forwardRef<HTMLButtonElement, Props>(
(
{ tooltip, icon, className, children, imgSrc, fullWidth, isOpen, narrow, variant = 'default', iconOnly, ...rest },
{
tooltip,
icon,
className,
children,
imgSrc,
fullWidth,
isOpen,
narrow,
variant = 'default',
iconOnly,
'aria-label': ariaLabel,
...rest
},
ref
) => {
const styles = useStyles(getStyles);
@ -54,10 +68,10 @@ export const ToolbarButton = forwardRef<HTMLButtonElement, Props>(
});
const body = (
<button ref={ref} className={buttonStyles} {...rest}>
<button ref={ref} className={buttonStyles} aria-label={getButttonAriaLabel(ariaLabel, tooltip)} {...rest}>
{renderIcon(icon)}
{imgSrc && <img className={styles.img} src={imgSrc} />}
{children && !iconOnly && <span className={contentStyles}>{children}</span>}
{children && !iconOnly && <div className={contentStyles}>{children}</div>}
{isOpen === false && <Icon name="angle-down" />}
{isOpen === true && <Icon name="angle-up" />}
</button>
@ -73,6 +87,10 @@ export const ToolbarButton = forwardRef<HTMLButtonElement, Props>(
}
);
function getButttonAriaLabel(ariaLabel: string | undefined, tooltip: string | undefined) {
return ariaLabel ? ariaLabel : tooltip ? selectors.components.PageToolbar.item(tooltip) : undefined;
}
function renderIcon(icon: IconName | React.ReactNode) {
if (!icon) {
return null;
@ -100,6 +118,7 @@ const getStyles = (theme: GrafanaTheme) => {
line-height: ${theme.height.md - 2}px;
font-weight: ${theme.typography.weight.semibold};
border: 1px solid ${theme.colors.border2};
white-space: nowrap;
&:focus {
outline: none;
@ -109,7 +128,6 @@ const getStyles = (theme: GrafanaTheme) => {
&:disabled {
cursor: not-allowed;
opacity: 0.5;
&:hover {
color: ${theme.colors.textWeak};
background: ${theme.colors.bg1};
@ -119,7 +137,6 @@ const getStyles = (theme: GrafanaTheme) => {
default: css`
color: ${theme.colors.textWeak};
background-color: ${theme.colors.bg1};
&:hover {
color: ${theme.colors.text};
background: ${styleMixins.hoverColor(theme.colors.bg1, theme)};
@ -129,7 +146,6 @@ const getStyles = (theme: GrafanaTheme) => {
color: ${theme.palette.orangeDark};
border-color: ${theme.palette.orangeDark};
background-color: transparent;
&:hover {
color: ${theme.colors.text};
background: ${styleMixins.hoverColor(theme.colors.bg1, theme)};
@ -156,14 +172,14 @@ const getStyles = (theme: GrafanaTheme) => {
`,
content: css`
flex-grow: 1;
display: none;
@media only screen and (min-width: ${theme.breakpoints.md}) {
display: block;
}
`,
contentWithIcon: css`
display: none;
padding-left: ${theme.spacing.sm};
@media ${styleMixins.mediaUp(theme.breakpoints.md)} {
display: block;
}
`,
contentWithRightIcon: css`
padding-right: ${theme.spacing.xs};

View File

@ -1,6 +1,6 @@
import React from 'react';
import renderer from 'react-test-renderer';
import CustomScrollbar from './CustomScrollbar';
import { CustomScrollbar } from './CustomScrollbar';
describe('CustomScrollbar', () => {
it('renders correctly', () => {

View File

@ -1,19 +1,21 @@
import React, { Component } from 'react';
import React, { FC, useCallback, useEffect, useRef } from 'react';
import isNil from 'lodash/isNil';
import classNames from 'classnames';
import { css } from 'emotion';
import Scrollbars from 'react-custom-scrollbars';
import { useStyles } from '../../themes';
import { GrafanaTheme } from '@grafana/data';
interface Props {
className?: string;
autoHide?: boolean;
autoHideTimeout?: number;
autoHideDuration?: number;
autoHeightMax?: string;
hideTracksWhenNotNeeded?: boolean;
hideHorizontalTrack?: boolean;
hideVerticalTrack?: boolean;
scrollTop?: number;
setScrollTop: (event: any) => void;
setScrollTop?: (event: any) => void;
autoHeightMin?: number | string;
updateAfterMountMs?: number;
}
@ -21,122 +23,152 @@ interface Props {
/**
* Wraps component into <Scrollbars> component from `react-custom-scrollbars`
*/
export class CustomScrollbar extends Component<Props> {
static defaultProps: Partial<Props> = {
autoHide: false,
autoHideTimeout: 200,
autoHideDuration: 200,
setScrollTop: () => {},
hideTracksWhenNotNeeded: false,
autoHeightMin: '0',
autoHeightMax: '100%',
export const CustomScrollbar: FC<Props> = ({
autoHide = false,
autoHideTimeout = 200,
setScrollTop,
className,
autoHeightMin = '0',
autoHeightMax = '100%',
hideTracksWhenNotNeeded = false,
hideHorizontalTrack,
hideVerticalTrack,
updateAfterMountMs,
scrollTop,
children,
}) => {
const ref = useRef<Scrollbars>(null);
const styles = useStyles(getStyles);
const updateScroll = () => {
if (ref.current && !isNil(scrollTop)) {
ref.current.scrollTop(scrollTop);
}
};
private ref: React.RefObject<Scrollbars>;
useEffect(() => {
updateScroll();
});
constructor(props: Props) {
super(props);
this.ref = React.createRef<Scrollbars>();
/**
* Special logic for doing a update a few milliseconds after mount to check for
* updated height due to dynamic content
*/
if (updateAfterMountMs) {
useEffect(() => {
setTimeout(() => {
const scrollbar = ref.current as any;
if (scrollbar?.update) {
scrollbar.update();
}
}, updateAfterMountMs);
}, []);
}
updateScroll() {
const ref = this.ref.current;
const { scrollTop } = this.props;
if (ref && !isNil(scrollTop)) {
ref.scrollTop(scrollTop);
}
}
componentDidMount() {
this.updateScroll();
// this logic is to make scrollbar visible when content is added body after mount
if (this.props.updateAfterMountMs) {
setTimeout(() => this.updateAfterMount(), this.props.updateAfterMountMs);
}
}
updateAfterMount() {
if (this.ref && this.ref.current) {
const scrollbar = this.ref.current as any;
if (scrollbar.update) {
scrollbar.update();
}
}
}
componentDidUpdate() {
this.updateScroll();
}
renderTrack = (track: 'track-vertical' | 'track-horizontal', hideTrack: boolean | undefined, passedProps: any) => {
function renderTrack(className: string, hideTrack: boolean | undefined, passedProps: any) {
if (passedProps.style && hideTrack) {
passedProps.style.display = 'none';
}
return <div {...passedProps} className={track} />;
};
renderThumb = (thumb: 'thumb-horizontal' | 'thumb-vertical', passedProps: any) => {
return <div {...passedProps} className={thumb} />;
};
renderTrackHorizontal = (passedProps: any) => {
return this.renderTrack('track-horizontal', this.props.hideHorizontalTrack, passedProps);
};
renderTrackVertical = (passedProps: any) => {
return this.renderTrack('track-vertical', this.props.hideVerticalTrack, passedProps);
};
renderThumbHorizontal = (passedProps: any) => {
return this.renderThumb('thumb-horizontal', passedProps);
};
renderThumbVertical = (passedProps: any) => {
return this.renderThumb('thumb-vertical', passedProps);
};
renderView = (passedProps: any) => {
return <div {...passedProps} className="view" />;
};
render() {
const {
className,
children,
autoHeightMax,
autoHeightMin,
setScrollTop,
autoHide,
autoHideTimeout,
hideTracksWhenNotNeeded,
} = this.props;
return (
<Scrollbars
ref={this.ref}
className={classNames('custom-scrollbar', className)}
onScroll={setScrollTop}
autoHeight={true}
autoHide={autoHide}
autoHideTimeout={autoHideTimeout}
hideTracksWhenNotNeeded={hideTracksWhenNotNeeded}
// These autoHeightMin & autoHeightMax options affect firefox and chrome differently.
// Before these where set to inherit but that caused problems with cut of legends in firefox
autoHeightMax={autoHeightMax}
autoHeightMin={autoHeightMin}
renderTrackHorizontal={this.renderTrackHorizontal}
renderTrackVertical={this.renderTrackVertical}
renderThumbHorizontal={this.renderThumbHorizontal}
renderThumbVertical={this.renderThumbVertical}
renderView={this.renderView}
>
{children}
</Scrollbars>
);
return <div {...passedProps} className={className} />;
}
}
const renderTrackHorizontal = useCallback(
(passedProps: any) => {
return renderTrack('track-horizontal', hideHorizontalTrack, passedProps);
},
[hideHorizontalTrack]
);
const renderTrackVertical = useCallback(
(passedProps: any) => {
return renderTrack('track-vertical', hideVerticalTrack, passedProps);
},
[hideVerticalTrack]
);
const renderThumbHorizontal = useCallback((passedProps: any) => {
return <div {...passedProps} className="thumb-horizontal" />;
}, []);
const renderThumbVertical = useCallback((passedProps: any) => {
return <div {...passedProps} className="thumb-vertical" />;
}, []);
const renderView = useCallback((passedProps: any) => {
return <div {...passedProps} className="scrollbar-view" />;
}, []);
return (
<Scrollbars
ref={ref}
className={classNames(styles.customScrollbar, className)}
onScroll={setScrollTop}
autoHeight={true}
autoHide={autoHide}
autoHideTimeout={autoHideTimeout}
hideTracksWhenNotNeeded={hideTracksWhenNotNeeded}
// These autoHeightMin & autoHeightMax options affect firefox and chrome differently.
// Before these where set to inherit but that caused problems with cut of legends in firefox
autoHeightMax={autoHeightMax}
autoHeightMin={autoHeightMin}
renderTrackHorizontal={renderTrackHorizontal}
renderTrackVertical={renderTrackVertical}
renderThumbHorizontal={renderThumbHorizontal}
renderThumbVertical={renderThumbVertical}
renderView={renderView}
>
{children}
</Scrollbars>
);
};
export default CustomScrollbar;
const getStyles = (theme: GrafanaTheme) => {
return {
customScrollbar: css`
// Fix for Firefox. For some reason sometimes .view container gets a height of its content, but in order to
// make scroll working it should fit outer container size (scroll appears only when inner container size is
// greater than outer one).
display: flex;
flex-grow: 1;
.scrollbar-view {
display: flex;
flex-grow: 1;
flex-direction: column;
}
.track-vertical {
border-radius: ${theme.border.radius.md};
width: ${theme.spacing.sm} !important;
right: 0px;
bottom: ${theme.spacing.xxs};
top: ${theme.spacing.xxs};
}
.track-horizontal {
border-radius: ${theme.border.radius.md};
height: ${theme.spacing.sm} !important;
right: ${theme.spacing.xxs};
bottom: ${theme.spacing.xxs};
left: ${theme.spacing.xxs};
}
.thumb-vertical {
background: ${theme.colors.bg3};
border-radius: ${theme.border.radius.md};
opacity: 0;
}
.thumb-horizontal {
background: ${theme.colors.bg3};
border-radius: ${theme.border.radius.md};
opacity: 0;
}
&:hover {
.thumb-vertical,
.thumb-horizontal {
opacity: 1;
transition: opacity 0.3s ease-in-out;
}
}
`,
};
};

View File

@ -1,57 +0,0 @@
.custom-scrollbar {
// Fix for Firefox. For some reason sometimes .view container gets a height of its content, but in order to
// make scroll working it should fit outer container size (scroll appears only when inner container size is
// greater than outer one).
display: flex;
flex-grow: 1;
.view {
display: flex;
flex-grow: 1;
flex-direction: column;
}
.track-vertical {
border-radius: 3px;
width: 8px !important;
right: 2px;
bottom: 2px;
top: 2px;
}
.track-horizontal {
border-radius: 3px;
height: 8px !important;
right: 2px;
bottom: 2px;
left: 2px;
}
.thumb-vertical {
@include gradient-vertical($scrollbarBackground, $scrollbarBackground2);
border-radius: 6px;
opacity: 0;
}
.thumb-horizontal {
@include gradient-horizontal($scrollbarBackground, $scrollbarBackground2);
border-radius: 6px;
opacity: 0;
}
&:hover {
.thumb-vertical,
.thumb-horizontal {
opacity: 0.8;
transition: opacity 0.3s ease-in-out;
}
}
// page scrollbar should stick to left side to aid hitting it
&--page {
.track-vertical {
right: 0;
}
}
}

View File

@ -2,7 +2,7 @@
exports[`CustomScrollbar renders correctly 1`] = `
<div
className="custom-scrollbar"
className="css-1fb8j9z"
style={
Object {
"height": "auto",
@ -15,7 +15,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
}
>
<div
className="view"
className="scrollbar-view"
style={
Object {
"WebkitOverflowScrolling": "touch",

View File

@ -4,7 +4,7 @@ import RcDrawer from 'rc-drawer';
import { css } from 'emotion';
import { selectors } from '@grafana/e2e-selectors';
import CustomScrollbar from '../CustomScrollbar/CustomScrollbar';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { IconButton } from '../IconButton/IconButton';
import { stylesFactory, useTheme } from '../../themes';

View File

@ -11,7 +11,6 @@ export interface Props<T> extends HTMLAttributes<HTMLButtonElement> {
className?: string;
options: Array<SelectableValue<T>>;
value?: SelectableValue<T>;
maxMenuHeight?: number;
onChange: (item: SelectableValue<T>) => void;
tooltipContent?: PopoverContent;
narrow?: boolean;

View File

@ -5,14 +5,16 @@ import {
DisplayValue,
FieldConfig,
FieldMatcher,
FieldMatcherID,
fieldMatchers,
fieldReducers,
FieldType,
formattedValueToString,
getFieldDisplayName,
outerJoinDataFrames,
reduceField,
TimeRange,
} from '@grafana/data';
import { joinDataFrames } from './utils';
import { useTheme } from '../../themes';
import { UPlotChart } from '../uPlot/Plot';
import { PlotProps } from '../uPlot/types';
@ -64,7 +66,16 @@ export const GraphNG: React.FC<GraphNGProps> = ({
const theme = useTheme();
const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden);
const frame = useMemo(() => joinDataFrames(data, fields), [data, fields]);
const frame = useMemo(() => {
// Default to timeseries config
if (!fields) {
fields = {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
};
}
return outerJoinDataFrames({ frames: data, joinBy: fields.x, keep: fields.y, keepOriginIndices: true });
}, [data, fields]);
const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => {
if (a && b) {
@ -107,6 +118,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
// X is the first field in the aligned frame
const xField = frame.fields[0];
let seriesIndex = 0;
if (xField.type === FieldType.time) {
builder.addScale({
@ -150,6 +162,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
if (field === xField || field.type !== FieldType.number) {
continue;
}
field.state!.seriesIndex = seriesIndex++;
const fmt = field.display ?? defaultFormatter;
const scaleKey = config.unit || FIXED_UNIT;

View File

@ -1,334 +0,0 @@
import { ArrayVector, DataFrame, FieldType, toDataFrame } from '@grafana/data';
import { joinDataFrames, isLikelyAscendingVector } from './utils';
describe('joinDataFrames', () => {
describe('joined frame', () => {
it('should align multiple data frames into one data frame', () => {
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature A', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature B', type: FieldType.number, values: [0, 2, 6, 7] },
],
}),
];
const joined = joinDataFrames(data);
expect(joined?.fields).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "time",
"state": Object {
"origin": undefined,
},
"type": "time",
"values": Array [
1000,
2000,
3000,
4000,
],
},
Object {
"config": Object {},
"name": "temperature A",
"state": Object {
"displayName": "temperature A",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"seriesIndex": 0,
},
"type": "number",
"values": Array [
1,
3,
5,
7,
],
},
Object {
"config": Object {},
"name": "temperature B",
"state": Object {
"displayName": "temperature B",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 1,
},
"seriesIndex": 1,
},
"type": "number",
"values": Array [
0,
2,
6,
7,
],
},
]
`);
});
it('should align multiple data frames into one data frame but only keep first time field', () => {
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time2', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature B', type: FieldType.number, values: [0, 2, 6, 7] },
],
}),
];
const aligned = joinDataFrames(data);
expect(aligned?.fields).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "time",
"state": Object {
"origin": undefined,
},
"type": "time",
"values": Array [
1000,
2000,
3000,
4000,
],
},
Object {
"config": Object {},
"name": "temperature",
"state": Object {
"displayName": "temperature",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"seriesIndex": 0,
},
"type": "number",
"values": Array [
1,
3,
5,
7,
],
},
Object {
"config": Object {},
"name": "temperature B",
"state": Object {
"displayName": "temperature B",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 1,
},
"seriesIndex": 1,
},
"type": "number",
"values": Array [
0,
2,
6,
7,
],
},
]
`);
});
it('should align multiple data frames into one data frame and skip non-numeric fields', () => {
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
{ name: 'state', type: FieldType.string, values: ['on', 'off', 'off', 'on'] },
],
}),
];
const aligned = joinDataFrames(data);
expect(aligned?.fields).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "time",
"state": Object {
"origin": undefined,
},
"type": "time",
"values": Array [
1000,
2000,
3000,
4000,
],
},
Object {
"config": Object {},
"name": "temperature",
"state": Object {
"displayName": "temperature",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"seriesIndex": 0,
},
"type": "number",
"values": Array [
1,
3,
5,
7,
],
},
]
`);
});
it('should align multiple data frames into one data frame and skip non-numeric fields', () => {
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
{ name: 'state', type: FieldType.string, values: ['on', 'off', 'off', 'on'] },
],
}),
];
const aligned = joinDataFrames(data);
expect(aligned?.fields).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "time",
"state": Object {
"origin": undefined,
},
"type": "time",
"values": Array [
1000,
2000,
3000,
4000,
],
},
Object {
"config": Object {},
"name": "temperature",
"state": Object {
"displayName": "temperature",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"seriesIndex": 0,
},
"type": "number",
"values": Array [
1,
3,
5,
7,
],
},
]
`);
});
});
describe('getDataFrameFieldIndex', () => {
let aligned: DataFrame | null;
beforeAll(() => {
const data: DataFrame[] = [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature A', type: FieldType.number, values: [1, 3, 5, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature B', type: FieldType.number, values: [0, 2, 6, 7] },
{ name: 'humidity', type: FieldType.number, values: [0, 2, 6, 7] },
],
}),
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] },
{ name: 'temperature C', type: FieldType.number, values: [0, 2, 6, 7] },
],
}),
];
aligned = joinDataFrames(data);
});
it.each`
yDim | index
${1} | ${[0, 1]}
${2} | ${[1, 1]}
${3} | ${[1, 2]}
${4} | ${[2, 1]}
`('should return correct index for yDim', ({ yDim, index }) => {
const [frameIndex, fieldIndex] = index;
expect(aligned?.fields[yDim].state?.origin).toEqual({
frameIndex,
fieldIndex,
});
});
});
describe('check ascending data', () => {
it('simple ascending', () => {
const v = new ArrayVector([1, 2, 3, 4, 5]);
expect(isLikelyAscendingVector(v)).toBeTruthy();
});
it('simple ascending with null', () => {
const v = new ArrayVector([null, 2, 3, 4, null]);
expect(isLikelyAscendingVector(v)).toBeTruthy();
});
it('single value', () => {
const v = new ArrayVector([null, null, null, 4, null]);
expect(isLikelyAscendingVector(v)).toBeTruthy();
expect(isLikelyAscendingVector(new ArrayVector([4]))).toBeTruthy();
expect(isLikelyAscendingVector(new ArrayVector([]))).toBeTruthy();
});
it('middle values', () => {
const v = new ArrayVector([null, null, 5, 4, null]);
expect(isLikelyAscendingVector(v)).toBeFalsy();
});
it('decending', () => {
expect(isLikelyAscendingVector(new ArrayVector([7, 6, null]))).toBeFalsy();
expect(isLikelyAscendingVector(new ArrayVector([7, 8, 6]))).toBeFalsy();
});
});
});

View File

@ -1,180 +0,0 @@
import {
DataFrame,
ArrayVector,
NullValueMode,
getFieldDisplayName,
Field,
fieldMatchers,
FieldMatcherID,
FieldType,
FieldState,
DataFrameFieldIndex,
sortDataFrame,
Vector,
} from '@grafana/data';
import uPlot, { AlignedData, JoinNullMode } from 'uplot';
import { XYFieldMatchers } from './GraphNG';
// the results ofter passing though data
export interface XYDimensionFields {
x: Field; // independent axis (cause)
y: Field[]; // dependent axis (effect)
}
export function mapDimesions(match: XYFieldMatchers, frame: DataFrame, frames?: DataFrame[]): XYDimensionFields {
let x: Field | undefined;
const y: Field[] = [];
for (const field of frame.fields) {
if (!x && match.x(field, frame, frames ?? [])) {
x = field;
}
if (match.y(field, frame, frames ?? [])) {
y.push(field);
}
}
return { x: x as Field, y };
}
/**
* Returns a single DataFrame with:
* - A shared time column
* - only numeric fields
*
* @alpha
*/
export function joinDataFrames(frames: DataFrame[], fields?: XYFieldMatchers): DataFrame | null {
const valuesFromFrames: AlignedData[] = [];
const sourceFields: Field[] = [];
const sourceFieldsRefs: Record<number, DataFrameFieldIndex> = {};
const nullModes: JoinNullMode[][] = [];
// Default to timeseries config
if (!fields) {
fields = {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
};
}
for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) {
let frame = frames[frameIndex];
let dims = mapDimesions(fields, frame, frames);
if (!(dims.x && dims.y.length)) {
continue; // no numeric and no time fields
}
// Quick check that x is ascending order
if (!isLikelyAscendingVector(dims.x.values)) {
const xIndex = frame.fields.indexOf(dims.x);
frame = sortDataFrame(frame, xIndex);
dims = mapDimesions(fields, frame, frames);
}
let nullModesFrame: JoinNullMode[] = [0];
// Add the first X axis
if (!sourceFields.length) {
sourceFields.push(dims.x);
}
const alignedData: AlignedData = [
dims.x.values.toArray(), // The x axis (time)
];
for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) {
const field = frame.fields[fieldIndex];
if (!fields.y(field, frame, frames)) {
continue;
}
let values = field.values.toArray();
let joinNullMode = field.config.custom?.spanNulls ? 0 : 2;
if (field.config.nullValueMode === NullValueMode.AsZero) {
values = values.map((v) => (v === null ? 0 : v));
joinNullMode = 0;
}
sourceFieldsRefs[sourceFields.length] = { frameIndex, fieldIndex };
alignedData.push(values);
nullModesFrame.push(joinNullMode);
// This will cache an appropriate field name in the field state
getFieldDisplayName(field, frame, frames);
sourceFields.push(field);
}
valuesFromFrames.push(alignedData);
nullModes.push(nullModesFrame);
}
if (valuesFromFrames.length === 0) {
return null;
}
// do the actual alignment (outerJoin on the first arrays)
let joinedData = uPlot.join(valuesFromFrames, nullModes);
if (joinedData!.length !== sourceFields.length) {
throw new Error('outerJoinValues lost a field?');
}
let seriesIdx = 0;
// Replace the values from the outer-join field
return {
...frames[0],
length: joinedData![0].length,
fields: joinedData!.map((vals, idx) => {
let state: FieldState = {
...sourceFields[idx].state,
origin: sourceFieldsRefs[idx],
};
if (sourceFields[idx].type !== FieldType.time) {
state.seriesIndex = seriesIdx;
seriesIdx++;
}
return {
...sourceFields[idx],
state,
values: new ArrayVector(vals),
};
}),
};
}
// Quick test if the first and last points look to be ascending
export function isLikelyAscendingVector(data: Vector): boolean {
let first: any = undefined;
for (let idx = 0; idx < data.length; idx++) {
const v = data.get(idx);
if (v != null) {
if (first != null) {
if (first > v) {
return false; // descending
}
break;
}
first = v;
}
}
let idx = data.length - 1;
while (idx >= 0) {
const v = data.get(idx--);
if (v != null) {
if (first > v) {
return false;
}
return true;
}
}
return true; // only one non-null point
}

View File

@ -10,7 +10,7 @@ import * as MonoIcon from './assets';
import { customIcons } from './custom';
import { SvgProps } from './assets/types';
const alwaysMonoIcons = ['grafana', 'favorite', 'heart-break', 'heart'];
const alwaysMonoIcons = ['grafana', 'favorite', 'heart-break', 'heart', 'panel-add'];
export interface IconProps extends React.HTMLAttributes<HTMLDivElement> {
name: IconName;

View File

@ -1,15 +1,15 @@
import React, { FunctionComponent } from 'react';
import { SvgProps } from './types';
export const PanelAdd: FunctionComponent<SvgProps> = ({ size, ...rest }) => {
export const PanelAdd: FunctionComponent<SvgProps> = ({ ...rest }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
enableBackground="new 0 0 117.8 64"
viewBox="0 0 117.8 64"
xmlSpace="preserve"
width={size}
height={size}
width={24}
height={24}
{...rest}
>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="96.4427" y1="83.7013" x2="96.4427" y2="-9.4831">

View File

@ -65,6 +65,18 @@ const InterpolationStepAfter: FC<SvgProps> = ({ size, ...rest }) => {
);
};
export const Logs: FC<SvgProps> = ({ size, ...rest }) => {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width={30} height={size} {...rest}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.36906 14.2144H8.68657C8.89601 14.2144 9.09687 14.2976 9.24496 14.4457C9.39306 14.5938 9.47626 14.7946 9.47626 15.0041C9.47626 15.2135 9.39306 15.4144 9.24496 15.5625C9.09687 15.7106 8.89601 15.7938 8.68657 15.7938H2.36906C1.74075 15.7938 1.13817 15.5442 0.693883 15.0999C0.249597 14.6556 0 14.053 0 13.4247V2.36906C0 1.74075 0.249597 1.13817 0.693883 0.693883C1.13817 0.249597 1.74075 0 2.36906 0H7.15457L7.37569 0.0789687H7.44676C7.52795 0.116938 7.60259 0.167585 7.66787 0.229009L12.406 4.96714C12.5156 5.07819 12.5898 5.2192 12.6193 5.37239C12.6488 5.52559 12.6323 5.68409 12.5718 5.8279C12.5126 5.97211 12.412 6.09556 12.2827 6.18269C12.1534 6.26982 12.0012 6.31673 11.8453 6.3175H8.68657C8.05825 6.3175 7.45567 6.06791 7.01139 5.62362C6.5671 5.17934 6.3175 4.57675 6.3175 3.94844V1.57938H2.36906C2.15963 1.57938 1.95877 1.66258 1.81067 1.81067C1.66258 1.95877 1.57938 2.15963 1.57938 2.36906V13.4247C1.57938 13.6341 1.66258 13.835 1.81067 13.9831C1.95877 14.1312 2.15963 14.2144 2.36906 14.2144ZM9.94217 4.73813L7.89688 2.69284V3.94844C7.89688 4.15788 7.98008 4.35874 8.12817 4.50683C8.27627 4.65493 8.47713 4.73813 8.68657 4.73813H9.94217ZM3.34761 12.2739C3.31241 12.1914 3.29137 12.1041 3.2854 12.0158L3.2854 8.36528C3.28184 8.18607 3.34961 8.01561 3.47382 7.89141C3.59802 7.76721 3.76848 7.69943 3.94769 7.70299C4.1269 7.70655 4.30019 7.78116 4.42943 7.9104C4.55867 8.03964 4.63327 8.21293 4.63683 8.39214L4.63684 11.2969L5.92266 11.2969C6.01149 11.2983 6.12471 11.3282 6.20753 11.3636C6.27155 11.391 6.31643 11.4391 6.3631 11.4892L6.36311 11.4892C6.37681 11.5039 6.39066 11.5187 6.4052 11.5332C6.46926 11.5973 6.52051 11.6729 6.55597 11.7557C6.59143 11.8386 6.61041 11.9269 6.61181 12.0158C6.61394 12.1046 6.59846 12.1923 6.56626 12.2738C6.53407 12.3553 6.48579 12.4289 6.42422 12.4905C6.36265 12.552 6.289 12.6003 6.20753 12.6325C6.12605 12.6647 6.01151 12.707 5.92266 12.7049H3.97454C3.8858 12.7025 3.79782 12.6813 3.71644 12.6427C3.55164 12.5712 3.41911 12.4387 3.34761 12.2739ZM8.70634 12.793C9.98203 12.793 11.0162 11.6439 11.0162 10.2265C11.0162 8.80904 9.98203 7.65998 8.70634 7.65998C7.43065 7.65998 6.3965 8.80904 6.3965 10.2265C6.3965 11.6439 7.43065 12.793 8.70634 12.793ZM8.73597 11.5453C9.37381 11.5453 9.89089 10.9407 9.89089 10.1949C9.89089 9.44911 9.37381 8.84453 8.73597 8.84453C8.09813 8.84453 7.58105 9.44911 7.58105 10.1949C7.58105 10.9407 8.09813 11.5453 8.73597 11.5453ZM11.3715 10.2265C11.3715 8.80904 12.4056 7.65998 13.6813 7.65998C14.1833 7.65998 14.7779 7.9067 15.0267 8.14005C15.1561 8.2614 15.3112 8.44705 15.3397 8.63063C15.366 8.80006 15.2757 8.96772 15.162 9.08144C15.0708 9.17262 14.938 9.2579 14.8066 9.25912C14.5968 9.26106 14.3907 9.13001 14.3646 9.08144C14.1787 8.93199 13.9535 8.84451 13.7109 8.84451C13.0731 8.84451 12.556 9.44909 12.556 10.1949C12.556 10.9407 13.0731 11.5452 13.7109 11.5452C14.0485 11.5452 14.3522 11.3759 14.5634 11.106L14.6882 10.8582H14.2144C14.1414 10.8571 14.0483 10.8326 13.9802 10.8034C13.9276 10.7809 13.8907 10.7413 13.8523 10.7002C13.8411 10.6882 13.8297 10.676 13.8177 10.664C13.7651 10.6113 13.723 10.5492 13.6938 10.4811C13.6647 10.413 13.6491 10.3404 13.6479 10.2674C13.6462 10.1943 13.6589 10.1222 13.6853 10.0553C13.7118 9.9883 13.7515 9.92777 13.8021 9.87716C13.8527 9.82654 13.9132 9.78686 13.9802 9.7604C14.0472 9.73393 14.1414 9.69913 14.2144 9.70088H15.4247C15.4977 9.70283 15.57 9.72026 15.6369 9.75202C15.7723 9.8108 15.8813 9.91973 15.94 10.0552C15.94 10.0552 15.9629 10.1198 15.9912 10.266C16.0154 10.3912 15.9852 10.5411 15.9549 10.6909C15.9499 10.7159 15.9448 10.741 15.94 10.7659C15.717 11.9243 14.7905 12.793 13.6813 12.793C12.4056 12.793 11.3715 11.6439 11.3715 10.2265Z"
/>
</svg>
);
};
const IconNotFound: FC<SvgProps> = ({ size, ...rest }) => {
return <svg width={size} height={size} {...rest} />;
};
@ -74,5 +86,6 @@ export const customIcons: Record<string, ComponentType<SvgProps>> = {
'gf-interpolation-smooth': InterpolationSmooth,
'gf-interpolation-step-before': InterpolationStepBefore,
'gf-interpolation-step-after': InterpolationStepAfter,
'gf-logs': Logs,
notFoundDummy: IconNotFound,
};

View File

@ -1,12 +1,13 @@
import { Field, LinkModel } from '@grafana/data';
import React from 'react';
import { Button } from '..';
import { ButtonProps, Button } from '../Button';
type FieldLinkProps = {
link: LinkModel<Field>;
buttonProps?: ButtonProps;
};
export function FieldLink({ link }: FieldLinkProps) {
export function FieldLink({ link, buttonProps }: FieldLinkProps) {
return (
<a
href={link.href}
@ -23,7 +24,9 @@ export function FieldLink({ link }: FieldLinkProps) {
: undefined
}
>
<Button icon="external-link-alt">{link.title}</Button>
<Button icon="external-link-alt" {...buttonProps}>
{link.title}
</Button>
</a>
);
}

View File

@ -179,6 +179,8 @@ const getMenuStyles = (theme: GrafanaTheme) => {
color: ${linkColor};
display: flex;
cursor: pointer;
padding: 5px 12px 5px 10px;
&:hover {
color: ${linkColorHover};
text-decoration: none;
@ -186,7 +188,6 @@ const getMenuStyles = (theme: GrafanaTheme) => {
`,
item: css`
background: none;
padding: 5px 12px 5px 10px;
border-left: 2px solid transparent;
cursor: pointer;
white-space: nowrap;

View File

@ -0,0 +1,53 @@
import React from 'react';
import { ToolbarButton, VerticalGroup } from '@grafana/ui';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { PageToolbar } from './PageToolbar';
import { StoryExample } from '../../utils/storybook/StoryExample';
import { action } from '@storybook/addon-actions';
import { IconButton } from '../IconButton/IconButton';
export default {
title: 'Layout/PageToolbar',
component: PageToolbar,
decorators: [withCenteredStory],
parameters: {},
};
export const Examples = () => {
return (
<VerticalGroup>
<StoryExample name="With non clickable title">
<PageToolbar pageIcon="bell" title="Dashboard">
<ToolbarButton icon="panel-add" />
<ToolbarButton icon="sync">Sync</ToolbarButton>
</PageToolbar>
</StoryExample>
<StoryExample name="With clickable title and parent">
<PageToolbar
pageIcon="apps"
title="A very long dashboard name"
parent="A long folder name"
onClickTitle={() => action('Title clicked')}
onClickParent={() => action('Parent clicked')}
leftItems={[
<IconButton name="share-alt" size="lg" key="share" />,
<IconButton name="favorite" iconType="mono" size="lg" key="favorite" />,
]}
>
<ToolbarButton icon="panel-add" />
<ToolbarButton icon="share-alt" />
<ToolbarButton icon="sync">Sync</ToolbarButton>
<ToolbarButton icon="cog">Settings </ToolbarButton>
</PageToolbar>
</StoryExample>
<StoryExample name="Go back version">
<PageToolbar title="Service overview / Edit panel" onGoBack={() => action('Go back')}>
<ToolbarButton icon="cog" />
<ToolbarButton icon="save" />
<ToolbarButton>Discard</ToolbarButton>
<ToolbarButton>Apply</ToolbarButton>
</PageToolbar>
</StoryExample>
</VerticalGroup>
);
};

View File

@ -0,0 +1,202 @@
import React, { FC, ReactNode } from 'react';
import { css, cx } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { useStyles } from '../../themes/ThemeContext';
import { IconName } from '../../types';
import { Icon } from '../Icon/Icon';
import { styleMixins } from '../../themes';
import { IconButton } from '../IconButton/IconButton';
import { selectors } from '@grafana/e2e-selectors';
export interface Props {
pageIcon?: IconName;
title: string;
parent?: string;
onGoBack?: () => void;
onClickTitle?: () => void;
onClickParent?: () => void;
leftItems?: ReactNode[];
children?: ReactNode;
className?: string;
isFullscreen?: boolean;
}
/** @alpha */
export const PageToolbar: FC<Props> = React.memo(
({
title,
parent,
pageIcon,
onGoBack,
children,
onClickTitle,
onClickParent,
leftItems,
isFullscreen,
className,
}) => {
const styles = useStyles(getStyles);
/**
* .page-toolbar css class is used for some legacy css view modes (TV/Kiosk) and
* media queries for mobile view when toolbar needs left padding to make room
* for mobile menu icon. This logic hopefylly can be changed when we move to a full react
* app and change how the app side menu & mobile menu is rendered.
*/
const mainStyle = cx(
'page-toolbar',
styles.toolbar,
{
['page-toolbar--fullscreen']: isFullscreen,
},
className
);
return (
<div className={mainStyle}>
<div className={styles.toolbarLeft}>
{pageIcon && !onGoBack && (
<div className={styles.pageIcon}>
<Icon name={pageIcon} size="lg" />
</div>
)}
{onGoBack && (
<div className={styles.goBackButton}>
<IconButton
name="arrow-left"
tooltip="Go back (Esc)"
tooltipPlacement="bottom"
size="xxl"
surface="dashboard"
aria-label={selectors.components.BackButton.backArrow}
onClick={onGoBack}
/>
</div>
)}
<div className={styles.titleWrapper}>
{parent && onClickParent && (
<button onClick={onClickParent} className={cx(styles.titleLink, styles.parentLink)}>
{parent} <span className={styles.parentIcon}>/</span>
</button>
)}
{onClickTitle && (
<button onClick={onClickTitle} className={styles.titleLink}>
{title}
</button>
)}
{!onClickTitle && <div className={styles.titleText}>{title}</div>}
</div>
{leftItems?.map((child, index) => (
<div className={styles.leftActionItem} key={index}>
{child}
</div>
))}
</div>
<div className={styles.spacer}></div>
{React.Children.toArray(children)
.filter(Boolean)
.map((child, index) => {
return (
<div className={styles.actionWrapper} key={index}>
{child}
</div>
);
})}
</div>
);
}
);
PageToolbar.displayName = 'PageToolbar';
const getStyles = (theme: GrafanaTheme) => {
const { spacing, typography } = theme;
const titleStyles = `
font-size: ${typography.size.lg};
padding-left: ${spacing.sm};
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 240px;
// clear default button styles
background: none;
border: none;
@media ${styleMixins.mediaUp(theme.breakpoints.xl)} {
max-width: unset;
}
`;
return {
toolbar: css`
display: flex;
background: ${theme.colors.dashboardBg};
justify-content: flex-end;
flex-wrap: wrap;
padding: 0 ${spacing.md} ${spacing.sm} ${spacing.md};
`,
toolbarLeft: css`
display: flex;
flex-grow: 1;
min-width: 0;
`,
spacer: css`
flex-grow: 1;
`,
pageIcon: css`
padding-top: ${spacing.sm};
align-items: center;
display: none;
@media ${styleMixins.mediaUp(theme.breakpoints.md)} {
display: flex;
}
`,
titleWrapper: css`
display: flex;
align-items: center;
padding-top: ${spacing.sm};
padding-right: ${spacing.sm};
min-width: 0;
overflow: hidden;
`,
goBackButton: css`
position: relative;
top: 8px;
`,
parentIcon: css`
margin-left: 4px;
`,
titleText: css`
${titleStyles};
`,
titleLink: css`
${titleStyles};
`,
parentLink: css`
display: none;
@media ${styleMixins.mediaUp(theme.breakpoints.md)} {
display: inline-block;
}
`,
actionWrapper: css`
padding-left: ${spacing.sm};
padding-top: ${spacing.sm};
`,
leftActionItem: css`
display: none;
height: 40px;
position: relative;
top: 5px;
align-items: center;
padding-left: ${spacing.xs};
@media ${styleMixins.mediaUp(theme.breakpoints.md)} {
display: flex;
}
`,
};
};

View File

@ -83,7 +83,6 @@ export class RefreshPicker extends PureComponent<Props> {
value={selectedValue}
options={options}
onChange={this.onChangeSelect as any}
maxMenuHeight={380}
variant={variant}
/>
)}

View File

@ -150,7 +150,7 @@ export function SelectBase<T>({
let ReactSelectComponent: ReactSelect | Creatable = ReactSelect;
const creatableProps: any = {};
let asyncSelectProps: any = {};
let selectedValue = [];
let selectedValue;
if (isMulti && loadOptions) {
selectedValue = value as any;
} else {
@ -207,7 +207,7 @@ export function SelectBase<T>({
renderControl,
showAllSelectedWhenOpen,
tabSelectsValue,
value: isMulti ? selectedValue : selectedValue[0],
value: isMulti ? selectedValue : selectedValue?.[0],
};
if (allowCustomValue) {

View File

@ -74,11 +74,11 @@ describe('Select utils', () => {
expect(cleanValue('test1', optGroup)).toEqual([{ label: 'Group 4 - Option 1', value: 'test1' }]);
expect(cleanValue(3, options)).toEqual([{ label: 'Option 3', value: 3 }]);
});
it('should return empty array for null/undefined/empty values', () => {
expect(cleanValue([undefined], options)).toEqual([]);
expect(cleanValue(undefined, options)).toEqual([]);
expect(cleanValue(null, options)).toEqual([]);
expect(cleanValue('', options)).toEqual([]);
it('should return undefined for null/undefined/empty values', () => {
expect(cleanValue([undefined], options)).toEqual(undefined);
expect(cleanValue(undefined, options)).toEqual(undefined);
expect(cleanValue(null, options)).toEqual(undefined);
expect(cleanValue('', options)).toEqual(undefined);
});
});
});

View File

@ -4,12 +4,10 @@ import { SelectableOptGroup } from './types';
/**
* Normalize the value format to SelectableValue[] | []. Only used for single select
*/
export const cleanValue = (
value: any,
options: Array<SelectableValue | SelectableOptGroup | SelectableOptGroup[]>
): SelectableValue[] | [] => {
export const cleanValue = (value: any, options: Array<SelectableValue | SelectableOptGroup | SelectableOptGroup[]>) => {
if (Array.isArray(value)) {
return value.filter(Boolean);
const filtered = value.filter(Boolean);
return filtered?.length ? filtered : undefined;
}
if (typeof value === 'object' && value !== null) {
return [value];
@ -20,7 +18,7 @@ export const cleanValue = (
return [selectedValue];
}
}
return [];
return undefined;
};
/**

View File

@ -186,7 +186,9 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
const getLabelStyles = stylesFactory((theme: GrafanaTheme) => {
return {
container: css`
display: inline-block;
display: flex;
align-items: center;
white-space: nowrap;
`,
utc: css`
color: ${theme.palette.orange};

View File

@ -1,6 +1,6 @@
import React, { FC, CSSProperties, ComponentType } from 'react';
import { useMeasure } from 'react-use';
import CustomScrollbar from '../CustomScrollbar/CustomScrollbar';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
/**
* @beta

View File

@ -1,6 +1,5 @@
@import 'ButtonCascader/ButtonCascader';
@import 'ColorPicker/ColorPicker';
@import 'CustomScrollbar/CustomScrollbar';
@import 'Drawer/Drawer';
@import 'FormField/FormField';
@import 'RefreshPicker/RefreshPicker';

View File

@ -45,6 +45,7 @@ export { ModalHeader } from './Modal/ModalHeader';
export { ModalTabsHeader } from './Modal/ModalTabsHeader';
export { ModalTabContent } from './Modal/ModalTabContent';
export { ModalsProvider, ModalRoot, ModalsController } from './Modal/ModalsContext';
export { PageToolbar } from './PageLayout/PageToolbar';
// Renderless
export { SetInterval } from './SetInterval/SetInterval';

View File

@ -240,9 +240,6 @@ $horizontalComponentOffset: 180px;
$navbarHeight: 55px;
$navbarBorder: 1px solid $dark-6;
$navbarButtonBackground: $panel-bg;
$navbar-button-border: #2f2f32;
// Sidemenu
// -------------------------
$side-menu-bg: $panel-bg;

View File

@ -234,9 +234,6 @@ $horizontalComponentOffset: 180px;
$navbarHeight: 52px;
$navbarBorder: 1px solid $gray-5;
$navbarButtonBackground: $panel-bg;
$navbar-button-border: $gray-4;
// Sidemenu
// -------------------------
$side-menu-bg: ${theme.palette.gray15};

View File

@ -34,6 +34,10 @@ export function listItemSelected(theme: GrafanaTheme): string {
`;
}
export function mediaUp(breakpoint: string) {
return `only screen and (min-width: ${breakpoint})`;
}
export const focusCss = (theme: GrafanaTheme) => `
outline: 2px dotted transparent;
outline-offset: 2px;

View File

@ -125,7 +125,8 @@ export type IconName =
| 'gf-interpolation-linear'
| 'gf-interpolation-smooth'
| 'gf-interpolation-step-before'
| 'gf-interpolation-step-after';
| 'gf-interpolation-step-after'
| 'gf-logs';
export const getAvailableIcons = (): IconName[] => [
'fa fa-spinner',
@ -249,4 +250,5 @@ export const getAvailableIcons = (): IconName[] => [
'gf-interpolation-smooth',
'gf-interpolation-step-before',
'gf-interpolation-step-after',
'gf-logs',
];

View File

@ -30,7 +30,8 @@ import AccordianReferences from './AccordianReferences';
import { autoColor, createStyle, Theme, useTheme } from '../../Theme';
import { UIDivider } from '../../uiElementsContext';
import { ubFlex, ubFlexAuto, ubItemsCenter, ubM0, ubMb1, ubMy1, ubTxRightAlign } from '../../uberUtilityStyles';
import { TextArea } from '@grafana/ui';
import { FieldLink, TextArea } from '@grafana/ui';
import { CreateSpanLink } from '../types';
const getStyles = createStyle((theme: Theme) => {
return {
@ -115,6 +116,7 @@ type SpanDetailProps = {
stackTracesToggle: (spanID: string) => void;
referencesToggle: (spanID: string) => void;
focusSpan: (uiFind: string) => void;
createSpanLink?: CreateSpanLink;
};
export default function SpanDetail(props: SpanDetailProps) {
@ -131,6 +133,7 @@ export default function SpanDetail(props: SpanDetailProps) {
stackTracesToggle,
referencesToggle,
focusSpan,
createSpanLink,
} = props;
const {
isTagsOpen,
@ -171,13 +174,17 @@ export default function SpanDetail(props: SpanDetailProps) {
];
const deepLinkCopyText = `${window.location.origin}${window.location.pathname}?uiFind=${spanID}`;
const styles = getStyles(useTheme());
const link = createSpanLink?.(span);
return (
<div>
<div className={cx(ubFlex, ubItemsCenter)}>
<div className={cx(ubFlex, ubItemsCenter, ubMb1)}>
<h2 className={cx(ubFlexAuto, ubM0)}>{operationName}</h2>
<LabeledList className={ubTxRightAlign} dividerClassName={styles.divider} items={overviewItems} />
</div>
{link ? (
<FieldLink link={{ ...link, title: 'Logs for this span' } as any} buttonProps={{ icon: 'gf-logs' }} />
) : null}
<UIDivider className={cx(styles.divider, styles.dividerVertical, ubMy1)} />
<div>
<div>

View File

@ -22,6 +22,7 @@ import TimelineRow from './TimelineRow';
import { autoColor, createStyle, Theme, withTheme } from '../Theme';
import { TraceLog, TraceSpan, TraceKeyValuePair, TraceLink } from '@grafana/data';
import { CreateSpanLink } from './types';
const getStyles = createStyle((theme: Theme) => {
return {
@ -85,6 +86,7 @@ type SpanDetailRowProps = {
addHoverIndentGuideId: (spanID: string) => void;
removeHoverIndentGuideId: (spanID: string) => void;
theme: Theme;
createSpanLink?: CreateSpanLink;
};
export class UnthemedSpanDetailRow extends React.PureComponent<SpanDetailRowProps> {
@ -116,6 +118,7 @@ export class UnthemedSpanDetailRow extends React.PureComponent<SpanDetailRowProp
addHoverIndentGuideId,
removeHoverIndentGuideId,
theme,
createSpanLink,
} = this.props;
const styles = getStyles(theme);
return (
@ -154,6 +157,7 @@ export class UnthemedSpanDetailRow extends React.PureComponent<SpanDetailRowProp
tagsToggle={tagsToggle}
traceStartTime={traceStartTime}
focusSpan={focusSpan}
createSpanLink={createSpanLink}
/>
</div>
</TimelineRow.Cell>

View File

@ -33,6 +33,7 @@ import { TraceLog, TraceSpan, Trace, TraceKeyValuePair, TraceLink } from '@grafa
import TTraceTimeline from '../types/TTraceTimeline';
import { createStyle, Theme, withTheme } from '../Theme';
import { CreateSpanLink } from './types';
type TExtractUiFindFromStateReturn = {
uiFind: string | undefined;
@ -79,9 +80,7 @@ type TVirtualizedTraceViewOwnProps = {
addHoverIndentGuideId: (spanID: string) => void;
removeHoverIndentGuideId: (spanID: string) => void;
theme: Theme;
createSpanLink?: (
span: TraceSpan
) => { href: string; onClick?: (e: React.MouseEvent) => void; content: React.ReactNode };
createSpanLink?: CreateSpanLink;
scrollElement?: Element;
};
@ -411,6 +410,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
removeHoverIndentGuideId,
linksGetter,
theme,
createSpanLink,
} = this.props;
const detailState = detailStates.get(spanID);
if (!trace || !detailState) {
@ -439,6 +439,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
hoverIndentGuideIds={hoverIndentGuideIds}
addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId}
createSpanLink={createSpanLink}
/>
</div>
);

View File

@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { TraceSpan } from '@grafana/data';
import { TNil } from '../types';
interface TimeCursorUpdate {
@ -51,3 +52,11 @@ export interface ViewRangeTime {
export interface ViewRange {
time: ViewRangeTime;
}
export type CreateSpanLink = (
span: TraceSpan
) => {
href: string;
onClick?: (e: React.MouseEvent) => void;
content: React.ReactNode;
};

View File

@ -18,7 +18,6 @@ import (
func AddCSPHeader(cfg *setting.Cfg, logger log.Logger) macaron.Handler {
return func(w http.ResponseWriter, req *http.Request, c *macaron.Context) {
if !cfg.CSPEnabled {
logger.Debug("Not adding CSP header to response since it's disabled")
return
}

View File

@ -5,17 +5,15 @@ import (
"fmt"
"time"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/util"
)
// createLibraryPanel adds a Library Panel.
func (lps *LibraryPanelService) createLibraryPanel(c *models.ReqContext, cmd createLibraryPanelCommand) (LibraryPanel, error) {
func (lps *LibraryPanelService) createLibraryPanel(c *models.ReqContext, cmd createLibraryPanelCommand) (LibraryPanelDTO, error) {
libraryPanel := LibraryPanel{
OrgID: c.SignedInUser.OrgId,
FolderID: cmd.FolderID,
@ -39,7 +37,31 @@ func (lps *LibraryPanelService) createLibraryPanel(c *models.ReqContext, cmd cre
return nil
})
return libraryPanel, err
dto := LibraryPanelDTO{
ID: libraryPanel.ID,
OrgID: libraryPanel.OrgID,
FolderID: libraryPanel.FolderID,
UID: libraryPanel.UID,
Name: libraryPanel.Name,
Model: libraryPanel.Model,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
Created: libraryPanel.Created,
Updated: libraryPanel.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: libraryPanel.CreatedBy,
Name: c.SignedInUser.Login,
AvatarUrl: dtos.GetGravatarUrl(c.SignedInUser.Email),
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: libraryPanel.UpdatedBy,
Name: c.SignedInUser.Login,
AvatarUrl: dtos.GetGravatarUrl(c.SignedInUser.Email),
},
},
}
return dto, err
}
func connectDashboard(session *sqlstore.DBSession, dialect migrator.Dialect, user *models.SignedInUser, uid string, dashboardID int64) error {
@ -91,13 +113,20 @@ func (lps *LibraryPanelService) connectLibraryPanelsForDashboard(c *models.ReqCo
// deleteLibraryPanel deletes a Library Panel.
func (lps *LibraryPanelService) deleteLibraryPanel(c *models.ReqContext, uid string) error {
orgID := c.SignedInUser.OrgId
return lps.SQLStore.WithTransactionalDbSession(context.Background(), func(session *sqlstore.DBSession) error {
result, err := session.Exec("DELETE FROM library_panel WHERE uid=? and org_id=?", uid, orgID)
panel, err := getLibraryPanel(session, uid, c.SignedInUser.OrgId)
if err != nil {
return err
}
if _, err := session.Exec("DELETE FROM library_panel_dashboard WHERE librarypanel_id=?", panel.ID); err != nil {
return err
}
result, err := session.Exec("DELETE FROM library_panel WHERE id=?", panel.ID)
if err != nil {
return err
}
if rowsAffected, err := result.RowsAffected(); err != nil {
return err
} else if rowsAffected != 1 {
@ -148,42 +177,90 @@ func (lps *LibraryPanelService) disconnectLibraryPanelsForDashboard(dashboardID
})
}
func getLibraryPanel(session *sqlstore.DBSession, uid string, orgID int64) (LibraryPanel, error) {
libraryPanels := make([]LibraryPanel, 0)
session.Table("library_panel")
session.Where("uid=? AND org_id=?", uid, orgID)
err := session.Find(&libraryPanels)
func getLibraryPanel(session *sqlstore.DBSession, uid string, orgID int64) (LibraryPanelWithMeta, error) {
libraryPanels := make([]LibraryPanelWithMeta, 0)
sql := `SELECT
lp.id, lp.org_id, lp.folder_id, lp.uid, lp.name, lp.model, lp.created, lp.created_by, lp.updated, lp.updated_by
, 0 AS can_edit
, u1.login AS created_by_name
, u1.email AS created_by_email
, u2.login AS updated_by_name
, u2.email AS updated_by_email
FROM library_panel AS lp
LEFT JOIN user AS u1 ON lp.created_by = u1.id
LEFT JOIN user AS u2 ON lp.updated_by = u2.id
WHERE lp.uid=? AND lp.org_id=?`
sess := session.SQL(sql, uid, orgID)
err := sess.Find(&libraryPanels)
if err != nil {
return LibraryPanel{}, err
return LibraryPanelWithMeta{}, err
}
if len(libraryPanels) == 0 {
return LibraryPanel{}, errLibraryPanelNotFound
return LibraryPanelWithMeta{}, errLibraryPanelNotFound
}
if len(libraryPanels) > 1 {
return LibraryPanel{}, fmt.Errorf("found %d panels, while expecting at most one", len(libraryPanels))
return LibraryPanelWithMeta{}, fmt.Errorf("found %d panels, while expecting at most one", len(libraryPanels))
}
return libraryPanels[0], nil
}
// getLibraryPanel gets a Library Panel.
func (lps *LibraryPanelService) getLibraryPanel(c *models.ReqContext, uid string) (LibraryPanel, error) {
var libraryPanel LibraryPanel
func (lps *LibraryPanelService) getLibraryPanel(c *models.ReqContext, uid string) (LibraryPanelDTO, error) {
var libraryPanel LibraryPanelWithMeta
err := lps.SQLStore.WithDbSession(context.Background(), func(session *sqlstore.DBSession) error {
var err error
libraryPanel, err = getLibraryPanel(session, uid, c.SignedInUser.OrgId)
return err
})
return libraryPanel, err
dto := LibraryPanelDTO{
ID: libraryPanel.ID,
OrgID: libraryPanel.OrgID,
FolderID: libraryPanel.FolderID,
UID: libraryPanel.UID,
Name: libraryPanel.Name,
Model: libraryPanel.Model,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
Created: libraryPanel.Created,
Updated: libraryPanel.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: libraryPanel.CreatedBy,
Name: libraryPanel.CreatedByName,
AvatarUrl: dtos.GetGravatarUrl(libraryPanel.CreatedByEmail),
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: libraryPanel.UpdatedBy,
Name: libraryPanel.UpdatedByName,
AvatarUrl: dtos.GetGravatarUrl(libraryPanel.UpdatedByEmail),
},
},
}
return dto, err
}
// getAllLibraryPanels gets all library panels.
func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext) ([]LibraryPanel, error) {
func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext) ([]LibraryPanelDTO, error) {
orgID := c.SignedInUser.OrgId
libraryPanels := make([]LibraryPanel, 0)
libraryPanels := make([]LibraryPanelWithMeta, 0)
err := lps.SQLStore.WithDbSession(context.Background(), func(session *sqlstore.DBSession) error {
err := session.SQL("SELECT * FROM library_panel WHERE org_id=?", orgID).Find(&libraryPanels)
sql := `SELECT
lp.id, lp.org_id, lp.folder_id, lp.uid, lp.name, lp.model, lp.created, lp.created_by, lp.updated, lp.updated_by
, 0 AS can_edit
, u1.login AS created_by_name
, u1.email AS created_by_email
, u2.login AS updated_by_name
, u2.email AS updated_by_email
FROM library_panel AS lp
LEFT JOIN user AS u1 ON lp.created_by = u1.id
LEFT JOIN user AS u2 ON lp.updated_by = u2.id
WHERE lp.org_id=?`
sess := session.SQL(sql, orgID)
err := sess.Find(&libraryPanels)
if err != nil {
return err
}
@ -191,7 +268,34 @@ func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext) ([]Lib
return nil
})
return libraryPanels, err
retDTOs := make([]LibraryPanelDTO, 0)
for _, panel := range libraryPanels {
retDTOs = append(retDTOs, LibraryPanelDTO{
ID: panel.ID,
OrgID: panel.OrgID,
FolderID: panel.FolderID,
UID: panel.UID,
Name: panel.Name,
Model: panel.Model,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
Created: panel.Created,
Updated: panel.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: panel.CreatedBy,
Name: panel.CreatedByName,
AvatarUrl: dtos.GetGravatarUrl(panel.CreatedByEmail),
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: panel.UpdatedBy,
Name: panel.UpdatedByName,
AvatarUrl: dtos.GetGravatarUrl(panel.UpdatedByEmail),
},
},
})
}
return retDTOs, err
}
// getConnectedDashboards gets all dashboards connected to a Library Panel.
@ -225,7 +329,7 @@ func (lps *LibraryPanelService) getLibraryPanelsForDashboardID(dashboardID int64
libraryPanelMap := make(map[string]LibraryPanel)
err := lps.SQLStore.WithDbSession(context.Background(), func(session *sqlstore.DBSession) error {
sql := `SELECT
lp.id, lp.org_id, lp.folder_id, lp.uid, lp.name, lp.model, lp.created, lp.created_by, lp.updated, updated_by
lp.id, lp.org_id, lp.folder_id, lp.uid, lp.name, lp.model, lp.created, lp.created_by, lp.updated, lp.updated_by
FROM
library_panel_dashboard AS lpd
INNER JOIN
@ -249,15 +353,15 @@ func (lps *LibraryPanelService) getLibraryPanelsForDashboardID(dashboardID int64
}
// patchLibraryPanel updates a Library Panel.
func (lps *LibraryPanelService) patchLibraryPanel(c *models.ReqContext, cmd patchLibraryPanelCommand, uid string) (LibraryPanel, error) {
var libraryPanel LibraryPanel
func (lps *LibraryPanelService) patchLibraryPanel(c *models.ReqContext, cmd patchLibraryPanelCommand, uid string) (LibraryPanelDTO, error) {
var dto LibraryPanelDTO
err := lps.SQLStore.WithTransactionalDbSession(context.Background(), func(session *sqlstore.DBSession) error {
panelInDB, err := getLibraryPanel(session, uid, c.SignedInUser.OrgId)
if err != nil {
return err
}
libraryPanel = LibraryPanel{
var libraryPanel = LibraryPanel{
ID: panelInDB.ID,
OrgID: c.SignedInUser.OrgId,
FolderID: cmd.FolderID,
@ -289,8 +393,32 @@ func (lps *LibraryPanelService) patchLibraryPanel(c *models.ReqContext, cmd patc
return errLibraryPanelNotFound
}
dto = LibraryPanelDTO{
ID: libraryPanel.ID,
OrgID: libraryPanel.OrgID,
FolderID: libraryPanel.FolderID,
UID: libraryPanel.UID,
Name: libraryPanel.Name,
Model: libraryPanel.Model,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
Created: libraryPanel.Created,
Updated: libraryPanel.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: libraryPanel.CreatedBy,
Name: panelInDB.CreatedByName,
AvatarUrl: dtos.GetGravatarUrl(panelInDB.CreatedByEmail),
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: libraryPanel.UpdatedBy,
Name: c.SignedInUser.Login,
AvatarUrl: dtos.GetGravatarUrl(c.SignedInUser.Email),
},
},
}
return nil
})
return libraryPanel, err
return dto, err
}

View File

@ -71,7 +71,16 @@ func (lps *LibraryPanelService) LoadLibraryPanelsForDashboard(dash *models.Dashb
libraryPanelInDB, ok := libraryPanels[uid]
if !ok {
return fmt.Errorf("found connection to library panel %q that isn't in database", uid)
name := libraryPanel.Get("name").MustString()
elem := dash.Data.Get("panels").GetIndex(i)
elem.Set("gridPos", panelAsJSON.Get("gridPos").MustMap())
elem.Set("id", panelAsJSON.Get("id").MustInt64())
elem.Set("type", fmt.Sprintf("Name: \"%s\", UID: \"%s\"", name, uid))
elem.Set("libraryPanel", map[string]interface{}{
"uid": uid,
"name": name,
})
continue
}
// we have a match between what is stored in db and in dashboard json

View File

@ -449,7 +449,7 @@ func TestPatchLibraryPanel(t *testing.T) {
var result libraryPanelResult
err = json.Unmarshal(response.Body(), &result)
require.NoError(t, err)
existing.Result.UpdatedBy = int64(2)
existing.Result.Meta.UpdatedBy.ID = int64(2)
if diff := cmp.Diff(existing.Result, result.Result, getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
@ -650,7 +650,7 @@ func TestLoadLibraryPanelsForDashboard(t *testing.T) {
require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error())
})
testScenario(t, "When an admin tries to load a dashboard with a library panel that is not connected, it should fail",
testScenario(t, "When an admin tries to load a dashboard with a library panel that is not connected, it should set correct JSON and continue",
func(t *testing.T, sc scenarioContext) {
command := getCreateCommand(1, "Text - Library Panel1")
response := sc.service.createHandler(sc.reqContext, command)
@ -692,7 +692,38 @@ func TestLoadLibraryPanelsForDashboard(t *testing.T) {
}
err = sc.service.LoadLibraryPanelsForDashboard(&dash)
require.EqualError(t, err, fmt.Errorf("found connection to library panel %q that isn't in database", existing.Result.UID).Error())
require.NoError(t, err)
expectedJSON := map[string]interface{}{
"panels": []interface{}{
map[string]interface{}{
"id": int64(1),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]interface{}{
"id": int64(2),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 6,
"y": 0,
},
"libraryPanel": map[string]interface{}{
"uid": existing.Result.UID,
"name": existing.Result.Name,
},
"type": fmt.Sprintf("Name: \"%s\", UID: \"%s\"", existing.Result.Name, existing.Result.UID),
},
},
}
expected := simplejson.NewFromAny(expectedJSON)
if diff := cmp.Diff(expected.Interface(), dash.Data.Interface(), getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
}
@ -1092,16 +1123,13 @@ func TestDisconnectLibraryPanelsForDashboard(t *testing.T) {
}
type libraryPanel struct {
ID int64 `json:"id"`
OrgID int64 `json:"orgId"`
FolderID int64 `json:"folderId"`
UID string `json:"uid"`
Name string `json:"name"`
Model map[string]interface{} `json:"model"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
CreatedBy int64 `json:"createdBy"`
UpdatedBy int64 `json:"updatedBy"`
ID int64 `json:"id"`
OrgID int64 `json:"orgId"`
FolderID int64 `json:"folderId"`
UID string `json:"uid"`
Name string `json:"name"`
Model map[string]interface{} `json:"model"`
Meta LibraryPanelDTOMeta `json:"meta"`
}
type libraryPanelResult struct {

View File

@ -22,6 +22,56 @@ type LibraryPanel struct {
UpdatedBy int64
}
// LibraryPanelWithMeta is the model used to retrieve library panels with additional meta information.
type LibraryPanelWithMeta struct {
ID int64 `xorm:"pk autoincr 'id'"`
OrgID int64 `xorm:"org_id"`
FolderID int64 `xorm:"folder_id"`
UID string `xorm:"uid"`
Name string
Model json.RawMessage
Created time.Time
Updated time.Time
CanEdit bool
CreatedBy int64
UpdatedBy int64
CreatedByName string
CreatedByEmail string
UpdatedByName string
UpdatedByEmail string
}
// LibraryPanelDTO is the frontend DTO for library panels.
type LibraryPanelDTO struct {
ID int64 `json:"id"`
OrgID int64 `json:"orgId"`
FolderID int64 `json:"folderId"`
UID string `json:"uid"`
Name string `json:"name"`
Model json.RawMessage `json:"model"`
Meta LibraryPanelDTOMeta `json:"meta"`
}
// LibraryPanelDTOMeta is the meta information for LibraryPanelDTO.
type LibraryPanelDTOMeta struct {
CanEdit bool `json:"canEdit"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
CreatedBy LibraryPanelDTOMetaUser `json:"createdBy"`
UpdatedBy LibraryPanelDTOMetaUser `json:"updatedBy"`
}
// LibraryPanelDTOMetaUser is the meta information for user that creates/changes the library panel.
type LibraryPanelDTOMetaUser struct {
ID int64 `json:"id"`
Name string `json:"name"`
AvatarUrl string `json:"avatarUrl"`
}
// libraryPanelDashboard is the model for library panel connections.
type libraryPanelDashboard struct {
ID int64 `xorm:"pk autoincr 'id'"`

View File

@ -1,6 +1,8 @@
package ngalert
import (
"fmt"
"github.com/go-macaron/binding"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/api/response"
@ -21,6 +23,8 @@ func (ng *AlertNG) registerAPIEndpoints() {
alertDefinitions.Delete("/:alertDefinitionUID", ng.validateOrgAlertDefinition, routing.Wrap(ng.deleteAlertDefinitionEndpoint))
alertDefinitions.Post("/", middleware.ReqSignedIn, binding.Bind(saveAlertDefinitionCommand{}), routing.Wrap(ng.createAlertDefinitionEndpoint))
alertDefinitions.Put("/:alertDefinitionUID", ng.validateOrgAlertDefinition, binding.Bind(updateAlertDefinitionCommand{}), routing.Wrap(ng.updateAlertDefinitionEndpoint))
alertDefinitions.Post("/pause", ng.validateOrgAlertDefinition, binding.Bind(updateAlertDefinitionPausedCommand{}), routing.Wrap(ng.alertDefinitionPauseEndpoint))
alertDefinitions.Post("/unpause", ng.validateOrgAlertDefinition, binding.Bind(updateAlertDefinitionPausedCommand{}), routing.Wrap(ng.alertDefinitionUnpauseEndpoint))
})
ng.RouteRegister.Group("/api/ngalert/", func(schedulerRouter routing.RouteRegister) {
@ -180,3 +184,27 @@ func (ng *AlertNG) unpauseScheduler() response.Response {
}
return response.JSON(200, util.DynMap{"message": "alert definition scheduler unpaused"})
}
// alertDefinitionPauseEndpoint handles POST /api/alert-definitions/pause.
func (ng *AlertNG) alertDefinitionPauseEndpoint(c *models.ReqContext, cmd updateAlertDefinitionPausedCommand) response.Response {
cmd.OrgID = c.SignedInUser.OrgId
cmd.Paused = true
err := ng.updateAlertDefinitionPaused(&cmd)
if err != nil {
return response.Error(500, "Failed to pause alert definition", err)
}
return response.JSON(200, util.DynMap{"message": fmt.Sprintf("%d alert definitions paused", cmd.ResultCount)})
}
// alertDefinitionUnpauseEndpoint handles POST /api/alert-definitions/unpause.
func (ng *AlertNG) alertDefinitionUnpauseEndpoint(c *models.ReqContext, cmd updateAlertDefinitionPausedCommand) response.Response {
cmd.OrgID = c.SignedInUser.OrgId
cmd.Paused = false
err := ng.updateAlertDefinitionPaused(&cmd)
if err != nil {
return response.Error(500, "Failed to unpause alert definition", err)
}
return response.JSON(200, util.DynMap{"message": fmt.Sprintf("%d alert definitions unpaused", cmd.ResultCount)})
}

View File

@ -212,7 +212,7 @@ func (ng *AlertNG) getOrgAlertDefinitions(query *listAlertDefinitionsQuery) erro
func (ng *AlertNG) getAlertDefinitions(query *listAlertDefinitionsQuery) error {
return ng.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
alerts := make([]*AlertDefinition, 0)
q := "SELECT uid, org_id, interval_seconds, version FROM alert_definition"
q := "SELECT uid, org_id, interval_seconds, version, paused FROM alert_definition"
if err := sess.SQL(q).Find(&alerts); err != nil {
return err
}
@ -221,6 +221,39 @@ func (ng *AlertNG) getAlertDefinitions(query *listAlertDefinitionsQuery) error {
return nil
})
}
func (ng *AlertNG) updateAlertDefinitionPaused(cmd *updateAlertDefinitionPausedCommand) error {
return ng.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
placeHolders := strings.Builder{}
const separator = ", "
separatorVar := separator
params := []interface{}{cmd.Paused, cmd.OrgID}
for i, UID := range cmd.UIDs {
if i == len(cmd.UIDs)-1 {
separatorVar = ""
}
placeHolders.WriteString(fmt.Sprintf("?%s", separatorVar))
params = append(params, UID)
}
sql := fmt.Sprintf("UPDATE alert_definition SET paused = ? WHERE org_id = ? AND uid IN (%s)", placeHolders.String())
// prepend sql statement to params
var i interface{}
params = append(params, i)
copy(params[1:], params[0:])
params[0] = sql
res, err := sess.Exec(params...)
if err != nil {
return err
}
if cmd.ResultCount, err = res.RowsAffected(); err != nil {
ng.log.Debug("failed to get rows affected: %w", err)
}
return nil
})
}
func generateNewAlertDefinitionUID(sess *sqlstore.DBSession, orgID int64) (string, error) {
for i := 0; i < 3; i++ {
uid := util.GenerateShortUID()

View File

@ -46,6 +46,10 @@ func addAlertDefinitionMigrations(mg *migrator.Migrator) {
}
mg.AddMigration("add unique index in alert_definition on org_id and title columns", migrator.NewAddIndexMigration(alertDefinition, uniqueIndices[0]))
mg.AddMigration("add unique index in alert_definition on org_id and uid columns", migrator.NewAddIndexMigration(alertDefinition, uniqueIndices[1]))
mg.AddMigration("Add column paused in alert_definition", migrator.NewAddColumnMigration(alertDefinition, &migrator.Column{
Name: "paused", Type: migrator.DB_Bool, Nullable: false, Default: "0",
}))
}
func addAlertDefinitionVersionMigrations(mg *migrator.Migrator) {

View File

@ -21,6 +21,7 @@ type AlertDefinition struct {
IntervalSeconds int64 `json:"intervalSeconds"`
Version int64 `json:"version"`
UID string `xorm:"uid" json:"uid"`
Paused bool `json:"paused"`
}
type alertDefinitionKey struct {
@ -101,3 +102,11 @@ type listAlertDefinitionsQuery struct {
Result []*AlertDefinition
}
type updateAlertDefinitionPausedCommand struct {
OrgID int64 `json:"-"`
UIDs []string `json:"uids"`
Paused bool `json:"-"`
ResultCount int64
}

View File

@ -185,6 +185,10 @@ func (ng *AlertNG) alertingTicker(grafanaCtx context.Context) error {
}
readyToRun := make([]readyToRunItem, 0)
for _, item := range alertDefinitions {
if item.Paused {
continue
}
key := item.getKey()
itemVersion := item.Version
newRoutine := !ng.schedule.registry.exists(key)

View File

@ -123,6 +123,33 @@ func TestAlertingTicker(t *testing.T) {
tick := advanceClock(t, mockedClock)
assertEvalRun(t, evalAppliedCh, tick, expectedAlertDefinitionsEvaluated...)
})
// pause alert definition
err = ng.updateAlertDefinitionPaused(&updateAlertDefinitionPausedCommand{UIDs: []string{alerts[2].UID}, OrgID: alerts[2].OrgID, Paused: true})
require.NoError(t, err)
t.Logf("alert definition: %v paused", alerts[2].getKey())
expectedAlertDefinitionsEvaluated = []alertDefinitionKey{}
t.Run(fmt.Sprintf("on 8th tick alert definitions: %s should be evaluated", concatenate(expectedAlertDefinitionsEvaluated)), func(t *testing.T) {
tick := advanceClock(t, mockedClock)
assertEvalRun(t, evalAppliedCh, tick, expectedAlertDefinitionsEvaluated...)
})
expectedAlertDefinitionsStopped = []alertDefinitionKey{alerts[2].getKey()}
t.Run(fmt.Sprintf("on 8th tick alert definitions: %s should be stopped", concatenate(expectedAlertDefinitionsStopped)), func(t *testing.T) {
assertStopRun(t, stopAppliedCh, expectedAlertDefinitionsStopped...)
})
// unpause alert definition
err = ng.updateAlertDefinitionPaused(&updateAlertDefinitionPausedCommand{UIDs: []string{alerts[2].UID}, OrgID: alerts[2].OrgID, Paused: false})
require.NoError(t, err)
t.Logf("alert definition: %v unpaused", alerts[2].getKey())
expectedAlertDefinitionsEvaluated = []alertDefinitionKey{alerts[0].getKey(), alerts[2].getKey()}
t.Run(fmt.Sprintf("on 9th tick alert definitions: %s should be evaluated", concatenate(expectedAlertDefinitionsEvaluated)), func(t *testing.T) {
tick := advanceClock(t, mockedClock)
assertEvalRun(t, evalAppliedCh, tick, expectedAlertDefinitionsEvaluated...)
})
}
func assertEvalRun(t *testing.T, ch <-chan evalAppliedInfo, tick time.Time, keys ...alertDefinitionKey) {

View File

@ -27,7 +27,7 @@ func executeQuery(ctx context.Context, query queryModel, runner queryRunner, max
glog.Warn("Flux query failed", "err", err, "query", flux)
dr.Error = err
} else {
dr = readDataFrames(tables, int(float64(query.MaxDataPoints)*1.5), maxSeries)
dr = readDataFrames(tables, int(float64(query.MaxDataPoints)*2), maxSeries)
}
// Make sure there is at least one frame

Some files were not shown because too many files have changed in this diff Show More