mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into update-cypress-6.3.0
This commit is contained in:
commit
b040c364b4
40
.drone.yml
40
.drone.yml
@ -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:
|
||||
|
@ -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
|
||||
|
||||
|
@ -895,5 +895,5 @@ use_browser_locale = false
|
||||
default_timezone = browser
|
||||
|
||||
[expressions]
|
||||
# Disable expressions & UI features
|
||||
# Enable or disable the expressions functionality.
|
||||
enabled = true
|
||||
|
@ -885,5 +885,5 @@
|
||||
;default_timezone = browser
|
||||
|
||||
[expressions]
|
||||
# Disable expressions & UI features
|
||||
# Enable or disable the expressions functionality.
|
||||
;enabled = true
|
||||
|
17
contribute/engineering/terminology.md
Normal file
17
contribute/engineering/terminology.md
Normal 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.
|
@ -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.
|
||||
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
@ -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>
|
||||
|
@ -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`.
|
||||
|
@ -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" >}})
|
||||
|
@ -49,7 +49,7 @@ http.cors.allow-origin: "*"
|
||||
|
||||
### Index settings
|
||||
|
||||

|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
@ -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" >}}
|
@ -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" >}}
|
@ -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);
|
||||
|
||||
|
@ -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
|
||||
|
@ -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 | Request’s path parameters. |
|
||||
| `request.query` | object | Request’s query parameters. |
|
||||
| `request.body` | string | Request’s 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.
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
title = "License Expiration"
|
||||
description = ""
|
||||
keywords = ["grafana", "licensing"]
|
||||
weight = 8
|
||||
weight = 120
|
||||
+++
|
||||
|
||||
# License expiration
|
||||
|
49
docs/sources/enterprise/license-restrictions.md
Normal file
49
docs/sources/enterprise/license-restrictions.md
Normal 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" >}})
|
@ -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/
|
151
docs/sources/panels/expressions.md
Normal file
151
docs/sources/panels/expressions.md
Normal 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
|
@ -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" >}}).
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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()
|
||||
|
@ -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')
|
||||
|
@ -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')
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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
|
||||
}
|
@ -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,
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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}`,
|
||||
},
|
||||
|
@ -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}`,
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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));
|
||||
|
@ -1,6 +1,5 @@
|
||||
FROM debian:buster-slim
|
||||
FROM debian:testing-20210111-slim
|
||||
|
||||
USER root
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
COPY scripts scripts
|
||||
|
@ -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}"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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};
|
||||
|
@ -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', () => {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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",
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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
|
||||
}
|
@ -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;
|
||||
|
@ -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">
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
202
packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx
Normal file
202
packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx
Normal 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;
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
@ -83,7 +83,6 @@ export class RefreshPicker extends PureComponent<Props> {
|
||||
value={selectedValue}
|
||||
options={options}
|
||||
onChange={this.onChangeSelect as any}
|
||||
maxMenuHeight={380}
|
||||
variant={variant}
|
||||
/>
|
||||
)}
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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};
|
||||
|
@ -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
|
||||
|
@ -1,6 +1,5 @@
|
||||
@import 'ButtonCascader/ButtonCascader';
|
||||
@import 'ColorPicker/ColorPicker';
|
||||
@import 'CustomScrollbar/CustomScrollbar';
|
||||
@import 'Drawer/Drawer';
|
||||
@import 'FormField/FormField';
|
||||
@import 'RefreshPicker/RefreshPicker';
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
|
@ -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};
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
];
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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'"`
|
||||
|
@ -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)})
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user